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