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
111
PLAN.md
Normal file
111
PLAN.md
Normal file
|
|
@ -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/<slug>-<date>.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.
|
||||||
|
|
@ -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 { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||||
import { search } from '@x/core/dist/search/search.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 { versionHistory, voice } from '@x/core';
|
||||||
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||||
import { getBillingInfo } from '@x/core/dist/billing/billing.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
|
* Handle workspace change event from core watcher
|
||||||
*/
|
*/
|
||||||
|
function touchesKnowledge(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): 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<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
||||||
|
// Any knowledge-base change drops the cached index so the next read rebuilds.
|
||||||
|
if (touchesKnowledge(event)) invalidateKnowledgeIndex();
|
||||||
// Debounce 'changed' events, emit others immediately
|
// Debounce 'changed' events, emit others immediately
|
||||||
if (event.type === 'changed' && event.path) {
|
if (event.type === 'changed' && event.path) {
|
||||||
queueChange(event.path);
|
queueChange(event.path);
|
||||||
|
|
@ -1544,6 +1565,11 @@ export function setupIpcHandlers() {
|
||||||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||||
return { notes };
|
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) => {
|
'inline-task:classifySchedule': async (_event, args) => {
|
||||||
const schedule = await classifySchedule(args.instruction);
|
const schedule = await classifySchedule(args.instruction);
|
||||||
return { schedule };
|
return { schedule };
|
||||||
|
|
|
||||||
|
|
@ -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 initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.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 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 initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
|
||||||
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.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";
|
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)
|
// start calendar meeting notification service (fires 1-minute warnings)
|
||||||
initCalendarNotifications();
|
initCalendarNotifications();
|
||||||
|
|
||||||
|
// start meeting prep scheduler (generates prep notes ~6h before a meeting)
|
||||||
|
void initMeetingPrep();
|
||||||
|
|
||||||
// start chrome extension sync server
|
// start chrome extension sync server
|
||||||
initChromeSync();
|
initChromeSync();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5238,6 +5238,20 @@ function App() {
|
||||||
return () => window.removeEventListener('email-block:draft-with-assistant', handler)
|
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 resolveWikiFilePath = useCallback((wikiPath: string) => {
|
||||||
const normalized = normalizeWikiPath(wikiPath)
|
const normalized = normalizeWikiPath(wikiPath)
|
||||||
const { path: basePath } = splitWikiFragment(normalized)
|
const { path: basePath } = splitWikiFragment(normalized)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
|
@ -14,6 +15,39 @@ const MEETINGS_ROOT = 'knowledge/Meetings'
|
||||||
const CALENDAR_DIR = 'calendar_sync'
|
const CALENDAR_DIR = 'calendar_sync'
|
||||||
const UPCOMING_MAX_DAYS = 4 // today + next 3
|
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 = {
|
type MeetingNoteRow = {
|
||||||
path: string
|
path: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -358,7 +392,178 @@ function parseDescriptionParts(value: string): DescriptionPart[] {
|
||||||
}).filter((part) => part.text.length > 0)
|
}).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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenNote(path)}
|
||||||
|
title={`Open ${title}`}
|
||||||
|
className="flex w-full items-center gap-3 border-b px-5 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-[12.5px] font-semibold text-foreground">{title}</span>
|
||||||
|
{subtitle ? <span className="truncate text-[11px] text-muted-foreground">{subtitle}</span> : null}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <PrepNoteRow title={note.name} subtitle={subtitle || undefined} path={note.path} onOpenNote={onOpenNote} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrepUnmatchedSection({ attendees, meetingSummary }: { attendees: PrepAttendee[]; meetingSummary: string }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
if (attendees.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t bg-muted/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="size-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="size-4 shrink-0 text-muted-foreground" />}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{attendees.length} {attendees.length === 1 ? 'other' : 'others'} — no notes yet
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="flex flex-col gap-1 px-5 pb-3 pl-12">
|
||||||
|
{attendees.map((att, idx) => (
|
||||||
|
<div key={`${att.email ?? att.label}-${idx}`} className="flex items-center justify-between gap-3 py-1">
|
||||||
|
<span className="min-w-0 truncate text-sm text-foreground">{att.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => requestCreateNote(att, meetingSummary)}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<UserPlus className="size-3.5" />
|
||||||
|
Create note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<PrepResult | null>(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 (
|
||||||
|
<div className="bg-muted/10">
|
||||||
|
{prep.prepNote && prep.prepNote.brief ? (
|
||||||
|
<div className="border-b px-5 pb-3 pt-3">
|
||||||
|
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5 [&_p]:text-[12.5px] [&_li]:text-[12.5px]">
|
||||||
|
{prep.prepNote.brief}
|
||||||
|
</Streamdown>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenNote(prep.prepNote!.path)}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FileText className="size-3.5" />
|
||||||
|
Open full prep
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-1.5 px-5 pb-1 pt-2.5">
|
||||||
|
<UsersRound className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">People</span>
|
||||||
|
</div>
|
||||||
|
{matched.map((att, idx) => (
|
||||||
|
<PrepAttendeeNote key={att.note!.path + idx} attendee={att} onOpenNote={onOpenNote} />
|
||||||
|
))}
|
||||||
|
<PrepUnmatchedSection attendees={unmatched} meetingSummary={event.summary} />
|
||||||
|
{prep.organizations.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="px-5 pb-1 pt-2.5">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{prep.organizations.length === 1 ? 'Company' : 'Companies'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{prep.organizations.map((org) => (
|
||||||
|
<PrepNoteRow key={org.path} title={org.name} subtitle="Organization" path={org.path} onOpenNote={onOpenNote} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void }) {
|
||||||
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -487,6 +692,20 @@ function UpcomingEvents() {
|
||||||
return selectVisibleDays(window)
|
return selectVisibleDays(window)
|
||||||
}, [events])
|
}, [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 totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todayKey = localDateKey(now)
|
const todayKey = localDateKey(now)
|
||||||
|
|
@ -532,6 +751,8 @@ function UpcomingEvents() {
|
||||||
key={day.dateKey}
|
key={day.dateKey}
|
||||||
day={day}
|
day={day}
|
||||||
isToday={day.dateKey === todayKey}
|
isToday={day.dateKey === todayKey}
|
||||||
|
prepEventId={prepEventId}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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 dayNum = day.date.getDate()
|
||||||
const month = day.date.toLocaleDateString([], { month: 'short' })
|
const month = day.date.toLocaleDateString([], { month: 'short' })
|
||||||
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
|
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
|
||||||
|
|
@ -573,7 +794,13 @@ function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean })
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
day.events.map((ev, idx) => (
|
day.events.map((ev, idx) => (
|
||||||
<UpcomingEventItem key={ev.id} event={ev} isLast={idx === count - 1} />
|
<UpcomingEventItem
|
||||||
|
key={ev.id}
|
||||||
|
event={ev}
|
||||||
|
isLast={idx === count - 1}
|
||||||
|
isPrepTarget={ev.id === prepEventId}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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)
|
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 isNow = isEventNow(event)
|
||||||
const platform = meetingPlatformLabel(event.conferenceLink)
|
const platform = meetingPlatformLabel(event.conferenceLink)
|
||||||
const subtitle = platform ?? event.location
|
const subtitle = platform ?? event.location
|
||||||
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={cn(!isLast && 'border-b')}>
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<div
|
<div
|
||||||
|
|
@ -604,7 +837,7 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
||||||
title={titleAndLocation}
|
title={titleAndLocation}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
|
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
|
||||||
!isLast && 'border-b',
|
showPrep && 'border-b',
|
||||||
isNow ? 'bg-muted' : 'hover:bg-muted/50',
|
isNow ? 'bg-muted' : 'hover:bg-muted/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -625,7 +858,24 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<div className="shrink-0">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{prepEligible ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPrepOpen((v) => !v) }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
aria-expanded={prepOpen}
|
||||||
|
title={prepOpen ? 'Hide prep' : 'Show meeting prep'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||||
|
prepOpen ? 'bg-accent text-foreground' : 'bg-background text-foreground hover:bg-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-3.5" />
|
||||||
|
Prep
|
||||||
|
<ChevronDown className={cn('size-3 transition-transform', prepOpen && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{event.conferenceLink ? (
|
{event.conferenceLink ? (
|
||||||
<SplitJoinButton
|
<SplitJoinButton
|
||||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||||
|
|
@ -647,6 +897,8 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
|
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{showPrep ? <InlineMeetingPrep event={event} onOpenNote={onOpenNote} /> : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -917,6 +1169,9 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
||||||
|
|
||||||
const rows = entries
|
const rows = entries
|
||||||
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
|
.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) => {
|
.map((entry) => {
|
||||||
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
|
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
|
||||||
const parts = relative.split('/')
|
const parts = relative.split('/')
|
||||||
|
|
@ -1017,7 +1272,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<UpcomingEvents />
|
<UpcomingEvents onOpenNote={onOpenNote} />
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-10">
|
<div className="flex items-center justify-center py-10">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
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 {
|
export interface UseCaseContext {
|
||||||
useCase: UseCase;
|
useCase: UseCase;
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,10 @@ export interface KnowledgeIndex {
|
||||||
* Looks for patterns like **Field:** value or **Field:** [[Link]]
|
* Looks for patterns like **Field:** value or **Field:** [[Link]]
|
||||||
*/
|
*/
|
||||||
function extractField(content: string, fieldName: string): string | undefined {
|
function extractField(content: string, fieldName: string): string | undefined {
|
||||||
// Match **Field:** value (handles [[links]] and plain text)
|
// Match **Field:** value (handles [[links]] and plain text). Only consume
|
||||||
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+?)(?:\\n|$)`, 'i');
|
// 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);
|
const match = content.match(pattern);
|
||||||
if (match) {
|
if (match) {
|
||||||
let value = match[1].trim();
|
let value = match[1].trim();
|
||||||
|
|
@ -275,6 +277,34 @@ export function buildKnowledgeIndex(): KnowledgeIndex {
|
||||||
return index;
|
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
|
* 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(),
|
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
|
// Inline task schedule classification
|
||||||
'export:note': {
|
'export:note': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue