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:
gagan 2026-07-02 00:35:35 +05:30 committed by GitHub
parent 932a10c2f9
commit 08a4077d43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1187 additions and 11 deletions

View file

@ -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<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 {
// 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 };

View file

@ -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();

View file

@ -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)

View file

@ -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 (
<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 [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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}
/>
))}
</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 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 })
</div>
) : (
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>
@ -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 (
<div className={cn(!isLast && 'border-b')}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
@ -604,7 +837,7 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
title={titleAndLocation}
className={cn(
'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',
)}
>
@ -625,7 +858,24 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
</span>
) : null}
</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 ? (
<SplitJoinButton
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
@ -647,6 +897,8 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
</PopoverTrigger>
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
</Popover>
{showPrep ? <InlineMeetingPrep event={event} onOpenNote={onOpenNote} /> : null}
</div>
)
}
@ -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
</p>
</div>
<div className="flex-1 overflow-auto">
<UpcomingEvents />
<UpcomingEvents onOpenNote={onOpenNote} />
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center py-10">

View file

@ -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;

View file

@ -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
*/

View 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,
};
}

View 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('## Whos 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;
}

View 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));
}
}

View file

@ -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({