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

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