From 08a4077d4311ff89f26834bb24cd1905de7ae998 Mon Sep 17 00:00:00 2001 From: gagan Date: Thu, 2 Jul 2026 00:35:35 +0530 Subject: [PATCH] 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/-.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 --- PLAN.md | 111 +++++++ apps/x/apps/main/src/ipc.ts | 26 ++ apps/x/apps/main/src/main.ts | 4 + apps/x/apps/renderer/src/App.tsx | 14 + .../renderer/src/components/meetings-view.tsx | 271 +++++++++++++++- .../x/packages/core/src/analytics/use_case.ts | 2 +- .../core/src/knowledge/knowledge_index.ts | 34 +- .../core/src/knowledge/meeting_prep.ts | 230 +++++++++++++ .../core/src/knowledge/meeting_prep_brief.ts | 307 ++++++++++++++++++ .../src/knowledge/meeting_prep_scheduler.ts | 158 +++++++++ apps/x/packages/shared/src/ipc.ts | 41 +++ 11 files changed, 1187 insertions(+), 11 deletions(-) create mode 100644 PLAN.md create mode 100644 apps/x/packages/core/src/knowledge/meeting_prep.ts create mode 100644 apps/x/packages/core/src/knowledge/meeting_prep_brief.ts create mode 100644 apps/x/packages/core/src/knowledge/meeting_prep_scheduler.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..05d6cd1b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,111 @@ +# Meeting Prep — "Next up" prep card + +## Goal + +When a meeting is imminent, surface who will be there by pulling up each +attendee's existing `person.md` from the knowledge base and showing it — no +click, no LLM in the hot path. An ambient card that's just *ready* when you +look. + +This is a deliberately light, deterministic surface. It does **not** replace +the two existing meeting-prep paths: +- the chat skill (`assistant/skills/meeting-prep/`) — LLM brief on demand +- the pre-built automation (`pre_built/meeting-prep.md`) — LLM brief files + +It complements them with an instant, no-LLM "who's in this meeting" view. + +## Decisions (settled) + +| Axis | Choice | +|------|--------| +| Surface | **Ambient card**, no notification. Pinned at top of Meetings view. | +| Activation | Next eligible meeting is **≤ 30 min away**. Outside that → no card. | +| Depth | **Just show `person.md`** — render the note, no LLM synthesis. | +| Who | **Everyone incl. teammates.** Skip self / all-day / declined / cancelled. | +| Sparse meetings | **Notes-first**: attendees with notes shown prominently; the rest collapse into "N others — no notes yet" → expand to create. | +| No-note action | **Offer to create** a note for that attendee. | + +## Resolution rules + +For each attendee (excluding `self`): +1. **Email exact match** (case-insensitive) against `KnowledgeIndex.people[].email` — reliable. +2. Else **name/alias match** on `displayName` → `people[].name` / `people[].aliases`, + only when it's a **single unambiguous hit**. +3. Matched → read `person.md`, return `{ name, role, org, path, markdown }`. +4. Unmatched → returned as a "no note" attendee (name + email). + +Org notes (`Organizations/{Org}.md`) are **deferred** — `person.md` already +carries Org/Role inline, enough for v1. + +## Architecture + +| Piece | Location | Notes | +|-------|----------|-------| +| Attendee → note resolver | **new IPC** `meeting-prep:resolve` in `apps/main/src/ipc.ts` | Input: attendee list (`{email?, displayName?, self?}[]`). Builds/uses `buildKnowledgeIndex()`, resolves per rules above, reads matched notes, returns structured payload. Deterministic + fast. | +| Shared types | `packages/shared/src/` | `MeetingPrepAttendee`, `MeetingPrepResult` request/response shapes + IPC channel typing. | +| "Next up" card UI | **new component** rendered above `UpcomingEvents` in `meetings-view.tsx` | Picks next event ≤30 min out, calls IPC, renders notes-first + collapsed others. Reuses existing markdown rendering. | +| Create-note action | reuse the note-creation agent (only LLM touch, user-initiated) | v1: open a prefilled Copilot chat to create the note (visible/steerable) rather than firing silently. | + +No new background loop — the renderer already polls `calendar_sync/` every +minute, so the card stays current for free. + +## Build slices + +1. **Resolver IPC + shared types** — `meeting-prep:resolve`, wired through + preload. Unit-testable in isolation. ← first slice +2. **"Next up" card UI** — component + 30-min gating + notes-first / collapsed + rendering, reading from the IPC. +3. **Create-note action** — prefilled Copilot chat for an unmatched attendee. + +## Out of scope (fast-follow candidates) + +- Notification variant (extend `notify_calendar_meetings.ts` tick). +- Org-note surfacing. +- Configurable lead time (hardcode 30 min for v1). +- LLM-synthesized brief button on the card. + +## Phase 2 — Proactive, meeting-aware prep (6h ahead) + +Shift from a static contact viewer to meeting-aware prep, generated ahead of time. + +### Decisions +- **Trigger:** a calendar-driven tick (clone of `notify_calendar_meetings.ts`), NOT the + cron/bg-task engine — fire time is `start − 6h`, which varies per event, so a + calendar scan + state file is the right fit (self-healing on reschedule/cancel). +- **Storage:** write a real note → `knowledge/Meetings/prep/-.md` (openable/editable). +- **Notify:** none — silent background generation; appears in the Meetings view. +- **Adaptive by meeting type:** + - Recurring (`recurringEventId` + prior notes found) → lead with **"Last time" recap** + (prior instance's `## Action items` / summary), then roster. + - One-off / new → lead with **agenda** (event `description`), then roster + company. +- **Roster:** always list every attendee; ones with a note link to it, ones without + get **Create note**. +- **AI brief:** the only LLM step. Reuses the `summarizeMeeting` path exactly — + `generateText` against the user's configured model (`getMeetingNotesModel` / + `getDefaultModelAndProvider`), tagged `useCase: 'meeting_prep'`. Deterministic + parts (roster, recap, agenda) are assembled in code; the model only writes the + "what matters" summary. + +### Build slices +- **A. Core generator** (`meeting_prep_brief.ts`): assemble context (roster + recap + + agenda, meeting-type aware) → `generatePrepBrief()` (one model call) → render + + `writePrepNote()` to `knowledge/Meetings/prep/`. Frontmatter stamps `eventId` / + `recurringEventId` / `meetingDate` / `generatedAt`. +- **B. Scheduler** (`meeting_prep_scheduler.ts`): tick (~few min), scan `calendar_sync/`, + for events starting within 6h, not ended, ≥1 non-self attendee, not already prepped + (state file keyed by eventId) → generate + write. `init()` wired in `main.ts` beside + `initCalendarNotifications()`. +- **C. Card integration:** the inline prep card prefers the generated prep note when it + exists (instant), else falls back to the live deterministic resolve. + +### Open detail / enabler +- Notes don't store an event id today, so recap linking is a title/date heuristic. + Stamp `eventId`/`recurringEventId` into captured notes' frontmatter going forward so + future links are exact. + +## Verification + +- Resolver: feed known attendee emails → correct note match; unknown → no-note. +- Card: appears only inside the 30-min window; notes-first ordering; collapse + toggles; create action opens prefilled chat. +- `npm run deps && npm run lint` clean. diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a1f6c4a6..9665172a 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -62,6 +62,9 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { search } from '@x/core/dist/search/search.js'; +import { resolveMeetingPrep } from '@x/core/dist/knowledge/meeting_prep.js'; +import { readPrepNoteForEvent } from '@x/core/dist/knowledge/meeting_prep_brief.js'; +import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index.js'; import { versionHistory, voice } from '@x/core'; import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; @@ -507,7 +510,25 @@ function queueChange(relPath: string): void { /** * Handle workspace change event from core watcher */ +function touchesKnowledge(event: z.infer): boolean { + const hit = (p: string | undefined) => typeof p === 'string' && p.startsWith('knowledge/'); + switch (event.type) { + case 'created': + case 'changed': + case 'deleted': + return hit(event.path); + case 'moved': + return hit(event.from) || hit(event.to); + case 'bulkChanged': + return !event.paths || event.paths.some(hit); + default: + return false; + } +} + function handleWorkspaceChange(event: z.infer): void { + // Any knowledge-base change drops the cached index so the next read rebuilds. + if (touchesKnowledge(event)) invalidateKnowledgeIndex(); // Debounce 'changed' events, emit others immediately if (event.type === 'changed' && event.path) { queueChange(event.path); @@ -1544,6 +1565,11 @@ export function setupIpcHandlers() { const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson); return { notes }; }, + 'meeting-prep:resolve': async (_event, args) => { + const result = await resolveMeetingPrep(args.attendees); + const prepNote = args.eventId ? await readPrepNoteForEvent(args.eventId) : null; + return { ...result, prepNote }; + }, 'inline-task:classifySchedule': async (_event, args) => { const schedule = await classifySchedule(args.instruction); return { schedule }; diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 450d0f9e..e93d8ac5 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -27,6 +27,7 @@ import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js" import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js"; +import { init as initMeetingPrep } from "@x/core/dist/knowledge/meeting_prep_scheduler.js"; import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js"; import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js"; import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js"; @@ -450,6 +451,9 @@ app.whenReady().then(async () => { // start calendar meeting notification service (fires 1-minute warnings) initCalendarNotifications(); + // start meeting prep scheduler (generates prep notes ~6h before a meeting) + void initMeetingPrep(); + // start chrome extension sync server initChromeSync(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index cf1d3d8f..9f635bde 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5238,6 +5238,20 @@ function App() { return () => window.removeEventListener('email-block:draft-with-assistant', handler) }, []) + // Meeting prep: create a person note for an unmatched attendee via Copilot. + useEffect(() => { + const handler = () => { + const pending = window.__pendingMeetingPrepCreate + if (pending) { + setPresetMessage(pending.prompt) + setIsChatSidebarOpen(true) + window.__pendingMeetingPrepCreate = undefined + } + } + window.addEventListener('meeting-prep:create-note', handler) + return () => window.removeEventListener('meeting-prep:create-note', handler) + }, []) + const resolveWikiFilePath = useCallback((wikiPath: string) => { const normalized = normalizeWikiPath(wikiPath) const { path: basePath } = splitWikiFragment(normalized) diff --git a/apps/x/apps/renderer/src/components/meetings-view.tsx b/apps/x/apps/renderer/src/components/meetings-view.tsx index 2ad04082..49090ce9 100644 --- a/apps/x/apps/renderer/src/components/meetings-view.tsx +++ b/apps/x/apps/renderer/src/components/meetings-view.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react' +import { Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, FileText, Loader2, MapPin, Mic, Sparkles, Square, UserPlus, UserRound, UsersRound, Video, X } from 'lucide-react' +import { Streamdown } from 'streamdown' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -14,6 +15,39 @@ const MEETINGS_ROOT = 'knowledge/Meetings' const CALENDAR_DIR = 'calendar_sync' const UPCOMING_MAX_DAYS = 4 // today + next 3 +declare global { + interface Window { + __pendingMeetingPrepCreate?: { prompt: string } + } +} + +// Mirrors the `meeting-prep:resolve` IPC response shape. +type PrepNote = { + path: string + name: string + role?: string + organization?: string + markdown: string +} +type PrepAttendee = { + label: string + email?: string + displayName?: string + note: PrepNote | null +} +type PrepOrg = { + path: string + name: string + markdown: string +} +type PrepResult = { + attendees: PrepAttendee[] + organizations: PrepOrg[] + prepNote: { path: string; brief: string } | null + matchedCount: number + unmatchedCount: number +} + type MeetingNoteRow = { path: string name: string @@ -358,7 +392,178 @@ function parseDescriptionParts(value: string): DescriptionPart[] { }).filter((part) => part.text.length > 0) } -function UpcomingEvents() { +// Hand the unmatched attendee off to the Copilot to research + create a note. +function requestCreateNote(attendee: PrepAttendee, meetingSummary: string) { + const who = attendee.displayName || attendee.label + const email = attendee.email ? ` <${attendee.email}>` : '' + window.__pendingMeetingPrepCreate = { + prompt: `Create a person note in my knowledge base for ${who}${email}. They're attending my "${meetingSummary}" meeting. Pull together what you know about them from my emails, past meetings, and calendar.`, + } + window.dispatchEvent(new Event('meeting-prep:create-note')) +} + +// One note row (used for both people and organizations): a clickable row that +// navigates to the note. The markdown is NOT rendered inline in the card. +function PrepNoteRow({ title, subtitle, path, onOpenNote }: { + title: string + subtitle?: string + path: string + onOpenNote: (path: string) => void +}) { + return ( + + ) +} + +function PrepAttendeeNote({ attendee, onOpenNote }: { attendee: PrepAttendee; onOpenNote: (path: string) => void }) { + const note = attendee.note + if (!note) return null + const subtitle = [note.role, note.organization].filter(Boolean).join(' · ') + return +} + +function PrepUnmatchedSection({ attendees, meetingSummary }: { attendees: PrepAttendee[]; meetingSummary: string }) { + const [open, setOpen] = useState(false) + if (attendees.length === 0) return null + + return ( +
+ + {open ? ( +
+ {attendees.map((att, idx) => ( +
+ {att.label} + +
+ ))} +
+ ) : null} +
+ ) +} + +// Inline prep for a single event: resolves the attendees against the knowledge +// base and renders their notes directly beneath the event row. Re-resolves when +// a person note changes (e.g. after "Create note") so it stays fresh. +function InlineMeetingPrep({ event, onOpenNote }: { event: UpcomingEvent; onOpenNote: (path: string) => void }) { + const [prep, setPrep] = useState(null) + const [refreshTick, setRefreshTick] = useState(0) + + useEffect(() => { + let cancelled = false + const run = async () => { + try { + const attendees = event.attendees.map((a) => ({ email: a.email, displayName: a.displayName, self: a.self })) + const result = await window.ipc.invoke('meeting-prep:resolve', { attendees, eventId: event.id }) + if (!cancelled) setPrep(result) + } catch (err) { + console.error('Meeting prep failed:', err) + if (!cancelled) setPrep(null) + } + } + void run() + return () => { + cancelled = true + } + }, [event.id, refreshTick]) + + // Refresh when a People note is created/changed so newly-created notes appear. + useEffect(() => { + const isPeoplePath = (p: string | undefined) => + typeof p === 'string' && p.startsWith('knowledge/People/') + const cleanup = window.ipc.on('workspace:didChange', (e) => { + switch (e.type) { + case 'created': + case 'changed': + case 'deleted': + if (isPeoplePath(e.path)) setRefreshTick((t) => t + 1) + break + case 'moved': + if (isPeoplePath(e.from) || isPeoplePath(e.to)) setRefreshTick((t) => t + 1) + break + case 'bulkChanged': + if (!e.paths || e.paths.some(isPeoplePath)) setRefreshTick((t) => t + 1) + break + } + }) + return cleanup + }, []) + + if (!prep || prep.attendees.length === 0) return null + + const matched = prep.attendees.filter((a) => a.note) + const unmatched = prep.attendees.filter((a) => !a.note) + + return ( +
+ {prep.prepNote && prep.prepNote.brief ? ( +
+ + {prep.prepNote.brief} + + +
+ ) : null} +
+ + People +
+ {matched.map((att, idx) => ( + + ))} + + {prep.organizations.length > 0 ? ( + <> +
+ + {prep.organizations.length === 1 ? 'Company' : 'Companies'} + +
+ {prep.organizations.map((org) => ( + + ))} + + ) : null} +
+ ) +} + +function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void }) { const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -487,6 +692,20 @@ function UpcomingEvents() { return selectVisibleDays(window) }, [events]) + // The next meeting that's worth prepping for — soonest timed event with at + // least one other attendee that hasn't ended. Its row gets inline prep. + // `events` is sorted (all-day first, then by start), so `find` returns it. + const prepEventId = useMemo(() => { + const nowMs = Date.now() + const candidate = events.find((ev) => { + if (ev.isAllDay) return false + if (ev.attendees.every((a) => a.self)) return false + const endMs = ev.end ? ev.end.getTime() : ev.start.getTime() + 30 * 60 * 1000 + return endMs > nowMs + }) + return candidate?.id ?? null + }, [events]) + const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0) const now = new Date() const todayKey = localDateKey(now) @@ -532,6 +751,8 @@ function UpcomingEvents() { key={day.dateKey} day={day} isToday={day.dateKey === todayKey} + prepEventId={prepEventId} + onOpenNote={onOpenNote} /> ))} @@ -542,7 +763,7 @@ function UpcomingEvents() { ) } -function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) { +function UpcomingDayCard({ day, isToday, prepEventId, onOpenNote }: { day: DayGroup; isToday: boolean; prepEventId: string | null; onOpenNote: (path: string) => void }) { const dayNum = day.date.getDate() const month = day.date.toLocaleDateString([], { month: 'short' }) const weekday = day.date.toLocaleDateString([], { weekday: 'short' }) @@ -573,7 +794,13 @@ function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) ) : ( day.events.map((ev, idx) => ( - + )) )} @@ -588,14 +815,20 @@ function NowBadge() { ) } -function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) { +function UpcomingEventItem({ event, isLast, isPrepTarget, onOpenNote }: { event: UpcomingEvent; isLast: boolean; isPrepTarget: boolean; onOpenNote: (path: string) => void }) { const [open, setOpen] = useState(false) + // The next meeting auto-expands its prep; any other meeting with attendees + // can be expanded on demand via the Prep toggle (resolves lazily on open). + const prepEligible = !event.isAllDay && event.attendees.some((a) => !a.self) + const [prepOpen, setPrepOpen] = useState(isPrepTarget) + const showPrep = prepEligible && prepOpen const isNow = isEventNow(event) const platform = meetingPlatformLabel(event.conferenceLink) const subtitle = platform ?? event.location const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary return ( +
@@ -625,7 +858,24 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo ) : null} -
+
+ {prepEligible ? ( + + ) : null} {event.conferenceLink ? ( triggerMeetingCapture(event, true)} @@ -647,6 +897,8 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo setOpen(false)} /> + {showPrep ? : null} +
) } @@ -917,6 +1169,9 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee const rows = entries .filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md')) + // Generated prep notes live under Meetings/prep/ — they're upcoming + // prep, not past meeting notes, so keep them out of this table. + .filter((entry) => !entry.path.startsWith(`${MEETINGS_ROOT}/prep/`)) .map((entry) => { const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length) const parts = relative.split('/') @@ -1017,7 +1272,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee

- +
{loading ? (
diff --git a/apps/x/packages/core/src/analytics/use_case.ts b/apps/x/packages/core/src/analytics/use_case.ts index a865a7cf..031a53fe 100644 --- a/apps/x/packages/core/src/analytics/use_case.ts +++ b/apps/x/packages/core/src/analytics/use_case.ts @@ -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; diff --git a/apps/x/packages/core/src/knowledge/knowledge_index.ts b/apps/x/packages/core/src/knowledge/knowledge_index.ts index 2df46ca3..0d2ef435 100644 --- a/apps/x/packages/core/src/knowledge/knowledge_index.ts +++ b/apps/x/packages/core/src/knowledge/knowledge_index.ts @@ -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 */ diff --git a/apps/x/packages/core/src/knowledge/meeting_prep.ts b/apps/x/packages/core/src/knowledge/meeting_prep.ts new file mode 100644 index 00000000..34e0afb6 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/meeting_prep.ts @@ -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 { + const index = getKnowledgeIndex(); + + // email -> person (first wins; emails are effectively unique). + const byEmail = new Map(); + // normalized name/alias -> people that carry it (for ambiguity checks). + const byName = new Map(); + + 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(); + const orgByName = new Map(); + 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(); + const readNote = async (person: (typeof index.people)[number]): Promise => { + 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(); + // Distinct external orgs to surface, keyed by note file. + const orgEntries = new Map(); + + 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, + }; +} diff --git a/apps/x/packages/core/src/knowledge/meeting_prep_brief.ts b/apps/x/packages/core/src/knowledge/meeting_prep_brief.ts new file mode 100644 index 00000000..45cb26e0 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/meeting_prep_brief.ts @@ -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 { + 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; + 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>): Promise { + 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>, 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 { + 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; + if (String(fm.eventId ?? '') !== eventId) continue; + return { + path: path.posix.join('knowledge', 'Meetings', 'prep', f), + brief: extractSection(content, 'What matters'), + }; + } + return null; +} diff --git a/apps/x/packages/core/src/knowledge/meeting_prep_scheduler.ts b/apps/x/packages/core/src/knowledge/meeting_prep_scheduler.ts new file mode 100644 index 00000000..b9b31ba3 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/meeting_prep_scheduler.ts @@ -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; +} + +interface CalendarEvent { + id?: string; + summary?: string; + status?: string; + start?: { dateTime?: string; date?: string }; + attendees?: Array<{ self?: boolean; responseStatus?: string }>; +} + +async function loadState(): Promise { + 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 { + // 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 { + 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)); + } +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 25ad6a74..067adb4f 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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({