mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Meeting prep: attendee notes + proactive 6h-ahead prep briefs (#636)
* feat(meetings): inline meeting prep under next meeting Resolve a meeting's attendees against the knowledge base and render each attendee's existing person.md inline beneath the next upcoming meeting in the Meetings view. Deterministic (email-exact match, then unambiguous name/alias), no LLM in the hot path. - core: resolveMeetingPrep() + meeting-prep:resolve IPC - renderer: notes-first rows with expandable person.md; unmatched attendees collapse into a 'no notes yet' row with a Create note action that hands off to the Copilot - prep re-resolves when a People note changes * feat(meetings): per-meeting prep toggle Add a Prep toggle to every eligible meeting row, not just the next one. The next meeting still auto-expands; other meetings resolve their attendee notes lazily when their toggle is opened. All-day and solo events get no toggle. * feat(meetings): org matching, cached index, and field-bleed fix #2 Matching: resolve the meeting's external companies from attendee email domains (and matched people's Organization field), excluding the user's own domain, and surface those org notes in a Companies section. Normalize display names (strip '(via ...)' suffixes) before name/alias matching. #3 Perf: meeting prep reads a cached knowledge index instead of rescanning the whole knowledge dir on every resolve; invalidated whenever a file under knowledge/ changes (wired to the workspace watcher). Fix: extractField in the knowledge index consumed newlines after the label, so an empty field (e.g. **Role:**) bled the next line's value. Restrict it to spaces/tabs so empty fields resolve to undefined. Also strip the folder prefix from link targets for display (Organizations/Rowboat Labs -> Rowboat Labs). * feat(meetings): proactive prep notes generated 6h ahead Generate a meeting prep note ~6h before each meeting and surface its brief in the prep card. - Generator (meeting_prep_brief.ts): assembles roster + 'last time' recap (extracts the prior instance's Action items) + agenda, ordered by meeting type (recurring -> recap first, one-off -> agenda first), then one model call for a 'what matters' brief. Writes knowledge/Meetings/prep/<slug>-<date>.md with eventId/recurringEventId frontmatter. Reuses the summarizeMeeting LLM path (configured model, useCase: meeting_prep). - Scheduler (meeting_prep_scheduler.ts): calendar-aware tick (5m), generates prep within the 6h lead window, state file dedupes, self-heals on changes. - Card: shows the brief (bulleted, compact) above a People list whose rows link out to each person's note instead of rendering it inline. Generated prep notes are kept out of the past-notes table. * chore(meetings): poll prep scheduler every 15m instead of 5m
This commit is contained in:
parent
932a10c2f9
commit
08a4077d43
11 changed files with 1187 additions and 11 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync' | 'code_session';
|
||||
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'meeting_prep' | 'knowledge_sync' | 'code_session';
|
||||
|
||||
export interface UseCaseContext {
|
||||
useCase: UseCase;
|
||||
|
|
|
|||
|
|
@ -73,8 +73,10 @@ export interface KnowledgeIndex {
|
|||
* Looks for patterns like **Field:** value or **Field:** [[Link]]
|
||||
*/
|
||||
function extractField(content: string, fieldName: string): string | undefined {
|
||||
// Match **Field:** value (handles [[links]] and plain text)
|
||||
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+?)(?:\\n|$)`, 'i');
|
||||
// Match **Field:** value (handles [[links]] and plain text). Only consume
|
||||
// spaces/tabs after the label — NOT newlines — so an empty field returns
|
||||
// undefined instead of bleeding the next line's value into it.
|
||||
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*[ \\t]*(.+?)(?:\\r?\\n|$)`, 'i');
|
||||
const match = content.match(pattern);
|
||||
if (match) {
|
||||
let value = match[1].trim();
|
||||
|
|
@ -275,6 +277,34 @@ export function buildKnowledgeIndex(): KnowledgeIndex {
|
|||
return index;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cached access
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildKnowledgeIndex() does a synchronous recursive scan + read of the whole
|
||||
// knowledge dir, so callers that run often (e.g. meeting prep) should use the
|
||||
// cached accessor and rely on invalidateKnowledgeIndex() being called whenever
|
||||
// a knowledge file changes (wired to the workspace watcher in the main process).
|
||||
|
||||
let cachedIndex: KnowledgeIndex | null = null;
|
||||
|
||||
/**
|
||||
* Return the knowledge index, building it once and reusing it until invalidated.
|
||||
*/
|
||||
export function getKnowledgeIndex(): KnowledgeIndex {
|
||||
if (!cachedIndex) {
|
||||
cachedIndex = buildKnowledgeIndex();
|
||||
}
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached index so the next getKnowledgeIndex() rebuilds it. Call this
|
||||
* whenever a file under knowledge/ is created, changed, moved, or deleted.
|
||||
*/
|
||||
export function invalidateKnowledgeIndex(): void {
|
||||
cachedIndex = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the index as a string for inclusion in agent prompts
|
||||
*/
|
||||
|
|
|
|||
230
apps/x/packages/core/src/knowledge/meeting_prep.ts
Normal file
230
apps/x/packages/core/src/knowledge/meeting_prep.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { getKnowledgeIndex } from './knowledge_index.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
/**
|
||||
* A calendar attendee as it arrives from the renderer (sourced from the
|
||||
* Google Calendar event's `attendees[]`).
|
||||
*/
|
||||
export interface MeetingPrepAttendee {
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
self?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The note we resolved for a matched attendee. `markdown` is the full note
|
||||
* body so the renderer can render it as-is — no LLM, no summarisation.
|
||||
*/
|
||||
export interface MeetingPrepNote {
|
||||
/** Workspace-relative path, e.g. "knowledge/People/Sarah Chen.md". */
|
||||
path: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
organization?: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One attendee after resolution. `note` is set when we found a person note,
|
||||
* `null` otherwise (the "no note yet" case the UI offers to create).
|
||||
*/
|
||||
export interface MeetingPrepResolved {
|
||||
/** Best display label — the note name, else displayName, else email. */
|
||||
label: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
note: MeetingPrepNote | null;
|
||||
}
|
||||
|
||||
/** An organization note relevant to the meeting (resolved from attendee domains). */
|
||||
export interface MeetingPrepOrg {
|
||||
path: string;
|
||||
name: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export interface MeetingPrepResult {
|
||||
/** Resolved attendees, matched ones first (notes-first ordering). */
|
||||
attendees: MeetingPrepResolved[];
|
||||
/** Distinct external organizations in the meeting that have a note. */
|
||||
organizations: MeetingPrepOrg[];
|
||||
/** How many have a note vs. not — convenience for the UI header. */
|
||||
matchedCount: number;
|
||||
unmatchedCount: number;
|
||||
}
|
||||
|
||||
function norm(value: string | undefined): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Normalize a display name for matching: drop parenthetical suffixes like
|
||||
* "(via Google Calendar)" / "(Guest)" that calendars sometimes append. */
|
||||
function normName(value: string | undefined): string {
|
||||
return norm((value ?? '').replace(/\s*\([^)]*\)\s*$/g, ''));
|
||||
}
|
||||
|
||||
/** Lowercased domain part of an email, or '' if there isn't one. */
|
||||
function domainOf(email: string | undefined): string {
|
||||
const e = norm(email);
|
||||
const at = e.lastIndexOf('@');
|
||||
return at >= 0 ? e.slice(at + 1) : '';
|
||||
}
|
||||
|
||||
/** Tidy a field value for display: strip a leading knowledge folder segment so
|
||||
* a link target like "Organizations/Rowboat Labs" reads as "Rowboat Labs". */
|
||||
function displayRef(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const m = value.match(/^(?:People|Organizations|Projects|Topics)\/(.+)$/i);
|
||||
return (m ? m[1] : value).trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a meeting's attendees against the knowledge base, returning each
|
||||
* attendee's existing person note (or null). Deterministic: email-exact match
|
||||
* first, then an unambiguous name/alias match on the display name.
|
||||
*/
|
||||
export async function resolveMeetingPrep(
|
||||
attendees: MeetingPrepAttendee[],
|
||||
): Promise<MeetingPrepResult> {
|
||||
const index = getKnowledgeIndex();
|
||||
|
||||
// email -> person (first wins; emails are effectively unique).
|
||||
const byEmail = new Map<string, (typeof index.people)[number]>();
|
||||
// normalized name/alias -> people that carry it (for ambiguity checks).
|
||||
const byName = new Map<string, (typeof index.people)[number][]>();
|
||||
|
||||
for (const person of index.people) {
|
||||
const email = norm(person.email);
|
||||
if (email && !byEmail.has(email)) byEmail.set(email, person);
|
||||
|
||||
for (const key of [person.name, ...person.aliases]) {
|
||||
const nk = normName(key);
|
||||
if (!nk) continue;
|
||||
const list = byName.get(nk) ?? [];
|
||||
list.push(person);
|
||||
byName.set(nk, list);
|
||||
}
|
||||
}
|
||||
|
||||
// domain -> organization, and name/alias -> organization, for resolving the
|
||||
// companies in the meeting from attendee email domains.
|
||||
const orgByDomain = new Map<string, (typeof index.organizations)[number]>();
|
||||
const orgByName = new Map<string, (typeof index.organizations)[number]>();
|
||||
for (const org of index.organizations) {
|
||||
const d = norm(org.domain);
|
||||
if (d && !orgByDomain.has(d)) orgByDomain.set(d, org);
|
||||
for (const key of [org.name, ...org.aliases]) {
|
||||
const nk = norm(key);
|
||||
if (nk && !orgByName.has(nk)) orgByName.set(nk, org);
|
||||
}
|
||||
}
|
||||
|
||||
// The user's own domain (from the self attendee) is "internal" — we never
|
||||
// surface the user's own company as meeting context.
|
||||
const selfDomain = domainOf(attendees.find((a) => a.self)?.email);
|
||||
|
||||
// Cache note reads so a person listed under multiple keys is read once.
|
||||
const noteCache = new Map<string, MeetingPrepNote | null>();
|
||||
const readNote = async (person: (typeof index.people)[number]): Promise<MeetingPrepNote | null> => {
|
||||
if (noteCache.has(person.file)) return noteCache.get(person.file)!;
|
||||
let note: MeetingPrepNote | null = null;
|
||||
try {
|
||||
const markdown = await fs.readFile(path.join(KNOWLEDGE_DIR, person.file), 'utf-8');
|
||||
note = {
|
||||
path: path.posix.join('knowledge', person.file.split(path.sep).join('/')),
|
||||
name: person.name,
|
||||
role: displayRef(person.role),
|
||||
organization: displayRef(person.organization),
|
||||
markdown,
|
||||
};
|
||||
} catch {
|
||||
// Indexed file vanished between index build and read — treat as no note.
|
||||
note = null;
|
||||
}
|
||||
noteCache.set(person.file, note);
|
||||
return note;
|
||||
};
|
||||
|
||||
const resolved: MeetingPrepResolved[] = [];
|
||||
const seenFiles = new Set<string>();
|
||||
// Distinct external orgs to surface, keyed by note file.
|
||||
const orgEntries = new Map<string, (typeof index.organizations)[number]>();
|
||||
|
||||
for (const attendee of attendees) {
|
||||
if (attendee.self) continue;
|
||||
|
||||
const email = norm(attendee.email);
|
||||
const displayName = normName(attendee.displayName);
|
||||
const domain = domainOf(attendee.email);
|
||||
|
||||
let person = email ? byEmail.get(email) : undefined;
|
||||
if (!person && displayName) {
|
||||
const candidates = byName.get(displayName);
|
||||
// Only a single, unambiguous hit counts — never guess between two
|
||||
// people who happen to share a name.
|
||||
if (candidates && candidates.length === 1) person = candidates[0];
|
||||
}
|
||||
|
||||
// Resolve the attendee's company — but only for external domains, so an
|
||||
// internal standup doesn't surface the user's own org note. Prefer a
|
||||
// domain match; fall back to the matched person's Organization field.
|
||||
if (domain && domain !== selfDomain) {
|
||||
const org =
|
||||
orgByDomain.get(domain) ??
|
||||
(person?.organization ? orgByName.get(norm(person.organization)) : undefined);
|
||||
if (org) orgEntries.set(org.file, org);
|
||||
}
|
||||
|
||||
const label =
|
||||
person?.name ||
|
||||
attendee.displayName?.trim() ||
|
||||
attendee.email?.trim() ||
|
||||
'Unknown';
|
||||
|
||||
const note = person ? await readNote(person) : null;
|
||||
// Dedupe: the same person can appear once even if the calendar lists
|
||||
// them twice (e.g. organizer + attendee).
|
||||
if (note && seenFiles.has(note.path)) continue;
|
||||
if (note) seenFiles.add(note.path);
|
||||
|
||||
resolved.push({
|
||||
label,
|
||||
email: attendee.email?.trim() || undefined,
|
||||
displayName: attendee.displayName?.trim() || undefined,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
// Notes-first ordering; stable within each group.
|
||||
resolved.sort((a, b) => {
|
||||
if (Boolean(a.note) === Boolean(b.note)) return 0;
|
||||
return a.note ? -1 : 1;
|
||||
});
|
||||
|
||||
// Read the resolved org notes (skip any whose file vanished).
|
||||
const organizations: MeetingPrepOrg[] = [];
|
||||
for (const org of orgEntries.values()) {
|
||||
try {
|
||||
const markdown = await fs.readFile(path.join(KNOWLEDGE_DIR, org.file), 'utf-8');
|
||||
organizations.push({
|
||||
path: path.posix.join('knowledge', org.file.split(path.sep).join('/')),
|
||||
name: org.name,
|
||||
markdown,
|
||||
});
|
||||
} catch {
|
||||
// Indexed file vanished between index build and read — skip it.
|
||||
}
|
||||
}
|
||||
|
||||
const matchedCount = resolved.filter((a) => a.note).length;
|
||||
return {
|
||||
attendees: resolved,
|
||||
organizations,
|
||||
matchedCount,
|
||||
unmatchedCount: resolved.length - matchedCount,
|
||||
};
|
||||
}
|
||||
307
apps/x/packages/core/src/knowledge/meeting_prep_brief.ts
Normal file
307
apps/x/packages/core/src/knowledge/meeting_prep_brief.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
import { withUseCase } from '../analytics/use_case.js';
|
||||
import { parseFrontmatter } from '../application/lib/parse-frontmatter.js';
|
||||
import { resolveMeetingPrep, type MeetingPrepResult } from './meeting_prep.js';
|
||||
|
||||
const MEETINGS_DIR = path.join(WorkDir, 'knowledge', 'Meetings');
|
||||
const PREP_DIR = path.join(MEETINGS_DIR, 'prep');
|
||||
|
||||
/** The bits of a Google Calendar event we use for prep. */
|
||||
interface CalendarEvent {
|
||||
id?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
recurringEventId?: string;
|
||||
start?: { dateTime?: string; date?: string };
|
||||
attendees?: Array<{ email?: string; displayName?: string; self?: boolean; responseStatus?: string }>;
|
||||
}
|
||||
|
||||
export interface PrepNoteResult {
|
||||
/** Workspace-relative path of the written note. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
function norm(s: string | undefined): string {
|
||||
return (s ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
return (s || 'meeting')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60) || 'meeting';
|
||||
}
|
||||
|
||||
/** Local YYYY-MM-DD for the event's start. */
|
||||
function eventDateKey(event: CalendarEvent): string {
|
||||
const iso = event.start?.dateTime ?? event.start?.date ?? '';
|
||||
const d = iso ? new Date(iso) : null;
|
||||
if (!d || Number.isNaN(d.getTime())) return 'undated';
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Pull a "## Heading" section's body (until the next "## " or end). */
|
||||
function extractSection(markdown: string, heading: string): string {
|
||||
const headRe = new RegExp(`^##\\s+${heading}\\s*$`, 'i');
|
||||
const out: string[] = [];
|
||||
let capturing = false;
|
||||
for (const line of markdown.split('\n')) {
|
||||
if (capturing) {
|
||||
if (/^##\s/.test(line)) break;
|
||||
out.push(line);
|
||||
} else if (headRe.test(line)) {
|
||||
capturing = true;
|
||||
}
|
||||
}
|
||||
return out.join('\n').trim();
|
||||
}
|
||||
|
||||
interface PriorNote {
|
||||
file: string; // workspace-relative
|
||||
title: string;
|
||||
date: string;
|
||||
actionItems: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent prior meeting note for this series. We match by title
|
||||
* resemblance to the event summary (notes don't yet store an event id), and
|
||||
* only consider notes dated before the meeting.
|
||||
*/
|
||||
async function findLastMeetingNote(event: CalendarEvent): Promise<PriorNote | null> {
|
||||
const summaryNorm = norm(event.summary);
|
||||
if (!summaryNorm) return null;
|
||||
const meetingDate = eventDateKey(event);
|
||||
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = (await fs.readdir(MEETINGS_DIR, { recursive: true }))
|
||||
.filter((p) => p.endsWith('.md'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: PriorNote[] = [];
|
||||
for (const rel of entries) {
|
||||
// Skip our own generated prep notes.
|
||||
if (rel.startsWith('prep/') || rel.startsWith(`prep${path.sep}`)) continue;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path.join(MEETINGS_DIR, rel), 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const { frontmatter, content } = parseFrontmatter(raw);
|
||||
const fm = (frontmatter ?? {}) as Record<string, unknown>;
|
||||
const title = String(fm.title ?? (content.match(/^#\s+(.+)$/m)?.[1] ?? '')).trim();
|
||||
const date = String(fm.date ?? '').trim();
|
||||
const titleNorm = norm(title);
|
||||
// Series match: the event summary appears in the note title (e.g.
|
||||
// "standup" within "Eng Standup — 2026-06-18").
|
||||
if (!titleNorm || !titleNorm.includes(summaryNorm)) continue;
|
||||
// Only prior instances.
|
||||
if (date && meetingDate !== 'undated' && date >= meetingDate) continue;
|
||||
candidates.push({
|
||||
file: path.posix.join('knowledge', 'Meetings', rel.split(path.sep).join('/')),
|
||||
title,
|
||||
date,
|
||||
actionItems: extractSection(content, 'Action items'),
|
||||
body: content,
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
// Most recent by date (notes without a date sort last).
|
||||
candidates.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/** True when this looks like a recurring meeting we have history for. */
|
||||
function isRecurring(event: CalendarEvent, prior: PriorNote | null): boolean {
|
||||
return Boolean(event.recurringEventId) && prior !== null;
|
||||
}
|
||||
|
||||
/** Assemble the deterministic prep context for an event. */
|
||||
async function assembleContext(event: CalendarEvent): Promise<{
|
||||
roster: MeetingPrepResult;
|
||||
prior: PriorNote | null;
|
||||
recurring: boolean;
|
||||
agenda: string;
|
||||
}> {
|
||||
const attendees = (event.attendees ?? []).map((a) => ({
|
||||
email: a.email,
|
||||
displayName: a.displayName,
|
||||
self: a.self,
|
||||
}));
|
||||
const roster = await resolveMeetingPrep(attendees);
|
||||
const prior = await findLastMeetingNote(event);
|
||||
return {
|
||||
roster,
|
||||
prior,
|
||||
recurring: isRecurring(event, prior),
|
||||
agenda: (event.description ?? '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const BRIEF_SYSTEM = `You write a short, concrete "what matters for this meeting" brief.
|
||||
Rules:
|
||||
- Use ONLY the context provided. Never invent facts, names, or commitments.
|
||||
- 3-5 bullet points, one line each. No preamble, no headings, no sign-off.
|
||||
- Lead with what the user should focus on or decide. Reference open items and
|
||||
prior commitments by name where the context supplies them.
|
||||
- If the context is thin, say so in one line rather than padding.`;
|
||||
|
||||
/** Generate the "what matters" brief via the user's configured model. */
|
||||
async function generateBrief(event: CalendarEvent, ctx: Awaited<ReturnType<typeof assembleContext>>): Promise<string> {
|
||||
const parts: string[] = [`Meeting: ${event.summary || '(untitled)'}`];
|
||||
if (ctx.agenda) parts.push(`Agenda:\n${ctx.agenda}`);
|
||||
if (ctx.prior?.actionItems) parts.push(`Action items from last time (${ctx.prior.date || 'prior'}):\n${ctx.prior.actionItems}`);
|
||||
const attendeeLines = ctx.roster.attendees.map((a) => {
|
||||
if (!a.note) return `- ${a.label} (no note)`;
|
||||
const sub = [a.note.role, a.note.organization].filter(Boolean).join(', ');
|
||||
return `- ${a.note.name}${sub ? ` — ${sub}` : ''}`;
|
||||
});
|
||||
if (attendeeLines.length) parts.push(`Attendees:\n${attendeeLines.join('\n')}`);
|
||||
|
||||
const modelId = await getMeetingNotesModel();
|
||||
const { provider: providerName } = await getDefaultModelAndProvider();
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const model = createProvider(providerConfig).languageModel(modelId);
|
||||
|
||||
const result = await withUseCase({ useCase: 'meeting_prep' }, () => generateText({
|
||||
model,
|
||||
system: BRIEF_SYSTEM,
|
||||
prompt: parts.join('\n\n'),
|
||||
}));
|
||||
captureLlmUsage({ useCase: 'meeting_prep', model: modelId, provider: providerName, usage: result.usage });
|
||||
return result.text.trim();
|
||||
}
|
||||
|
||||
/** Render the prep note's markdown body (brief is optional). */
|
||||
function renderPrepNote(event: CalendarEvent, ctx: Awaited<ReturnType<typeof assembleContext>>, brief: string, generatedAt: string): string {
|
||||
const fm = [
|
||||
'---',
|
||||
'source: meeting-prep',
|
||||
`title: "Prep: ${(event.summary || 'Meeting').replace(/"/g, "'")}"`,
|
||||
`meetingDate: "${eventDateKey(event)}"`,
|
||||
event.id ? `eventId: "${event.id}"` : null,
|
||||
event.recurringEventId ? `recurringEventId: "${event.recurringEventId}"` : null,
|
||||
`generatedAt: "${generatedAt}"`,
|
||||
'---',
|
||||
'',
|
||||
].filter((l) => l !== null).join('\n');
|
||||
|
||||
const lines: string[] = [`# Prep: ${event.summary || 'Meeting'}`, ''];
|
||||
|
||||
if (brief) {
|
||||
lines.push('## What matters', '', brief, '');
|
||||
}
|
||||
|
||||
// Adaptive ordering: recurring → recap first; new → agenda first.
|
||||
const recapBlock = ctx.prior
|
||||
? ['## Last time', '', ctx.prior.actionItems
|
||||
? ctx.prior.actionItems
|
||||
: `See [[${ctx.prior.title}]].`, '']
|
||||
: [];
|
||||
const agendaBlock = ctx.agenda ? ['## Agenda', '', ctx.agenda, ''] : [];
|
||||
if (ctx.recurring) {
|
||||
lines.push(...recapBlock, ...agendaBlock);
|
||||
} else {
|
||||
lines.push(...agendaBlock, ...recapBlock);
|
||||
}
|
||||
|
||||
// Roster — every attendee, linking to their note when we have one.
|
||||
lines.push('## Who’s coming', '');
|
||||
for (const a of ctx.roster.attendees) {
|
||||
if (a.note) {
|
||||
const sub = [a.note.role, a.note.organization].filter(Boolean).join(', ');
|
||||
lines.push(`- [[${a.note.name}]]${sub ? ` — ${sub}` : ''}`);
|
||||
} else {
|
||||
lines.push(`- ${a.label} _(no note yet)_`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
if (ctx.roster.organizations.length > 0) {
|
||||
lines.push('## Companies', '');
|
||||
for (const org of ctx.roster.organizations) lines.push(`- [[${org.name}]]`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return fm + lines.join('\n').trimEnd() + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and write the prep note for a calendar event. Returns the note path,
|
||||
* or null when there's nothing to prep (no other attendees). The AI brief is
|
||||
* best-effort — if no model is configured the note is still written with the
|
||||
* deterministic sections.
|
||||
*/
|
||||
export async function generateAndWritePrep(eventJson: string): Promise<PrepNoteResult | null> {
|
||||
const event = JSON.parse(eventJson) as CalendarEvent;
|
||||
if (event.status === 'cancelled') return null;
|
||||
if (!(event.attendees ?? []).some((a) => !a.self)) return null; // nobody else
|
||||
|
||||
const ctx = await assembleContext(event);
|
||||
if (ctx.roster.attendees.length === 0) return null;
|
||||
|
||||
let brief = '';
|
||||
try {
|
||||
brief = await generateBrief(event, ctx);
|
||||
} catch (err) {
|
||||
console.error('[MeetingPrep] brief generation failed:', err);
|
||||
}
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
const body = renderPrepNote(event, ctx, brief, generatedAt);
|
||||
|
||||
await fs.mkdir(PREP_DIR, { recursive: true });
|
||||
const fileName = `${slugify(event.summary || 'meeting')}-${eventDateKey(event)}.md`;
|
||||
const absPath = path.join(PREP_DIR, fileName);
|
||||
await fs.writeFile(absPath, body, 'utf-8');
|
||||
|
||||
return { path: path.posix.join('knowledge', 'Meetings', 'prep', fileName) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the pre-generated prep note for a calendar event (matched by the
|
||||
* `eventId` stamped in frontmatter) and return its path + "What matters" brief.
|
||||
* Returns null when no prep has been generated yet.
|
||||
*/
|
||||
export async function readPrepNoteForEvent(eventId: string): Promise<{ path: string; brief: string } | null> {
|
||||
if (!eventId) return null;
|
||||
let files: string[];
|
||||
try {
|
||||
files = (await fs.readdir(PREP_DIR)).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const f of files) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path.join(PREP_DIR, f), 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const { frontmatter, content } = parseFrontmatter(raw);
|
||||
const fm = (frontmatter ?? {}) as Record<string, unknown>;
|
||||
if (String(fm.eventId ?? '') !== eventId) continue;
|
||||
return {
|
||||
path: path.posix.join('knowledge', 'Meetings', 'prep', f),
|
||||
brief: extractSection(content, 'What matters'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
158
apps/x/packages/core/src/knowledge/meeting_prep_scheduler.ts
Normal file
158
apps/x/packages/core/src/knowledge/meeting_prep_scheduler.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { generateAndWritePrep } from "./meeting_prep_brief.js";
|
||||
|
||||
// Generate prep up to 6h before a meeting. We tick every 5 minutes and scan the
|
||||
// synced calendar — a calendar-aware loop fits "N hours before each meeting"
|
||||
// better than a fixed cron, and re-reading the calendar each tick means moves
|
||||
// and cancellations are picked up automatically.
|
||||
const TICK_INTERVAL_MS = 15 * 60_000;
|
||||
const PREP_LEAD_MS = 6 * 60 * 60_000;
|
||||
// Drop state entries older than 24h so the file doesn't grow forever.
|
||||
const STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
|
||||
const STATE_FILE = path.join(WorkDir, "meeting_prep_state.json");
|
||||
|
||||
interface PrepState {
|
||||
preppedEventIds: Record<string, { preppedAt: string; startTime: string }>;
|
||||
}
|
||||
|
||||
interface CalendarEvent {
|
||||
id?: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
start?: { dateTime?: string; date?: string };
|
||||
attendees?: Array<{ self?: boolean; responseStatus?: string }>;
|
||||
}
|
||||
|
||||
async function loadState(): Promise<PrepState> {
|
||||
try {
|
||||
const raw = await fs.readFile(STATE_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && parsed.preppedEventIds) {
|
||||
return parsed as PrepState;
|
||||
}
|
||||
} catch {
|
||||
// No state file yet, or corrupt — start fresh.
|
||||
}
|
||||
return { preppedEventIds: {} };
|
||||
}
|
||||
|
||||
async function saveState(state: PrepState): Promise<void> {
|
||||
// Write to a sibling tmp file then rename so a mid-write crash can't leave
|
||||
// the JSON corrupt.
|
||||
const tmp = `${STATE_FILE}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
|
||||
await fs.rename(tmp, STATE_FILE);
|
||||
}
|
||||
|
||||
function gcState(state: PrepState): PrepState {
|
||||
const cutoff = Date.now() - STATE_TTL_MS;
|
||||
const fresh: PrepState["preppedEventIds"] = {};
|
||||
for (const [id, entry] of Object.entries(state.preppedEventIds)) {
|
||||
const ts = Date.parse(entry.preppedAt);
|
||||
if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry;
|
||||
}
|
||||
return { preppedEventIds: fresh };
|
||||
}
|
||||
|
||||
function isAllDay(event: CalendarEvent): boolean {
|
||||
return !event.start?.dateTime;
|
||||
}
|
||||
|
||||
function isDeclinedBySelf(event: CalendarEvent): boolean {
|
||||
return event.attendees?.find((a) => a.self)?.responseStatus === "declined";
|
||||
}
|
||||
|
||||
async function tick(state: PrepState): Promise<{ state: PrepState; dirty: boolean }> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true });
|
||||
} catch {
|
||||
return { state, dirty: false };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let dirty = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
if (entry.name.startsWith("sync_state")) continue;
|
||||
|
||||
const eventId = entry.name.replace(/\.json$/, "");
|
||||
if (state.preppedEventIds[eventId]) continue;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path.join(CALENDAR_SYNC_DIR, entry.name), "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let event: CalendarEvent;
|
||||
try {
|
||||
event = JSON.parse(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.status === "cancelled") continue;
|
||||
if (isAllDay(event)) continue;
|
||||
if (isDeclinedBySelf(event)) continue;
|
||||
if (!(event.attendees ?? []).some((a) => !a.self)) continue; // nobody else
|
||||
|
||||
const startStr = event.start?.dateTime;
|
||||
if (!startStr) continue;
|
||||
const startMs = Date.parse(startStr);
|
||||
if (!Number.isFinite(startMs)) continue;
|
||||
|
||||
const msUntilStart = startMs - now;
|
||||
if (msUntilStart > PREP_LEAD_MS) continue; // too far out
|
||||
if (msUntilStart <= 0) continue; // already started — too late to pre-generate
|
||||
|
||||
try {
|
||||
const result = await generateAndWritePrep(raw);
|
||||
if (result) {
|
||||
console.log(`[MeetingPrep] generated prep for "${event.summary ?? eventId}" → ${result.path}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[MeetingPrep] prep generation failed for ${eventId}:`, err);
|
||||
continue; // leave unmarked so we retry next tick
|
||||
}
|
||||
|
||||
state.preppedEventIds[eventId] = {
|
||||
preppedAt: new Date().toISOString(),
|
||||
startTime: startStr,
|
||||
};
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
return { state, dirty };
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
console.log("[MeetingPrep] starting meeting prep scheduler");
|
||||
console.log(`[MeetingPrep] tick every ${TICK_INTERVAL_MS / 60_000}m, lead ${PREP_LEAD_MS / 3_600_000}h`);
|
||||
|
||||
let state = gcState(await loadState());
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await tick(state);
|
||||
state = result.state;
|
||||
if (result.dirty) {
|
||||
state = gcState(state);
|
||||
try {
|
||||
await saveState(state);
|
||||
} catch (err) {
|
||||
console.error("[MeetingPrep] failed to save state:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[MeetingPrep] tick failed:", err);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
|
@ -1248,6 +1248,47 @@ const ipcSchemas = {
|
|||
notes: z.string(),
|
||||
}),
|
||||
},
|
||||
// Resolve a meeting's attendees against the knowledge base — returns each
|
||||
// attendee's existing person note (or null). Deterministic, no LLM; powers
|
||||
// the ambient "Next up" prep card.
|
||||
'meeting-prep:resolve': {
|
||||
req: z.object({
|
||||
attendees: z.array(z.object({
|
||||
email: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
self: z.boolean().optional(),
|
||||
})),
|
||||
// When provided, the response includes any pre-generated prep note for
|
||||
// this calendar event (matched by the eventId stamped in frontmatter).
|
||||
eventId: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
attendees: z.array(z.object({
|
||||
label: z.string(),
|
||||
email: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
note: z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string().optional(),
|
||||
organization: z.string().optional(),
|
||||
markdown: z.string(),
|
||||
}).nullable(),
|
||||
})),
|
||||
organizations: z.array(z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
markdown: z.string(),
|
||||
})),
|
||||
// The pre-generated prep note (brief + path), if one exists for eventId.
|
||||
prepNote: z.object({
|
||||
path: z.string(),
|
||||
brief: z.string(),
|
||||
}).nullable(),
|
||||
matchedCount: z.number().int().nonnegative(),
|
||||
unmatchedCount: z.number().int().nonnegative(),
|
||||
}),
|
||||
},
|
||||
// Inline task schedule classification
|
||||
'export:note': {
|
||||
req: z.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue