From 14e85873d53dfc40c2d743b1557f1746835b7a68 Mon Sep 17 00:00:00 2001 From: garrytan-agents Date: Mon, 11 May 2026 15:16:57 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20activity=20injection=20=E2=80=94?= =?UTF-8?q?=20calendar=20events=20+=20open=20tasks=20in=20context=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads memory/calendar-cache.json and ops/tasks.md to inject: - **Right now:** current meeting (with attendees) from calendar - **Coming up:** next 3 events within 4-hour window - **Open tasks:** unchecked items from Today section - Stale calendar warning when cache is >6 hours old Skips all-day events and generic markers (Home, OOO, Out of Office). Caps upcoming events at 3 and tasks at 5 to keep prompt lean. 15 tests passing (was 9). --- src/core/context-engine.ts | 152 ++++++++++++++++++++++++++- test/context-engine.test.ts | 202 ++++++++++++++++++++++++++++++++---- 2 files changed, 335 insertions(+), 19 deletions(-) diff --git a/src/core/context-engine.ts b/src/core/context-engine.ts index 9403355eb..970e45677 100644 --- a/src/core/context-engine.ts +++ b/src/core/context-engine.ts @@ -146,6 +146,25 @@ interface FlightData { }>; } +interface CalendarEvent { + id?: string; + summary?: string; + start?: string; + end?: string; + description?: string; + attendees?: string[]; +} + +interface CalendarCache { + lastUpdated?: string; + events?: CalendarEvent[]; +} + +interface TaskFile { + raw: string; + todayItems: string[]; +} + interface LiveContext { now: string; timezone: string; @@ -159,6 +178,10 @@ interface LiveContext { garryAwake: boolean; isQuietHours: boolean; activeTravel: string | null; + currentEvent: CalendarEvent | null; + nextEvents: CalendarEvent[]; + todayTasks: string[]; + calendarStale: boolean; } // ── Context Generation (deterministic, <5ms) ──────────────────────────── @@ -210,10 +233,90 @@ function resolveLocation(workspaceDir: string): { city: string; tz: string; sour return { city: DEFAULT_HOME, tz: DEFAULT_TZ, source: 'default' }; } +/** Parse a calendar event time string into a Date. Handles ISO and date-only formats. */ +function parseEventTime(timeStr: string | undefined): Date | null { + if (!timeStr) return null; + const d = new Date(timeStr); + return isNaN(d.getTime()) ? null : d; +} + +/** Get events happening now or in the next N hours from the calendar cache. */ +function resolveActivity( + workspaceDir: string, + nowMs: number, +): { currentEvent: CalendarEvent | null; nextEvents: CalendarEvent[]; calendarStale: boolean } { + const cache = loadJsonFile(join(workspaceDir, 'memory', 'calendar-cache.json')); + if (!cache?.events?.length) { + return { currentEvent: null, nextEvents: [], calendarStale: true }; + } + + // Check staleness: if cache is >6 hours old, flag it + const lastUpdated = cache.lastUpdated ? new Date(cache.lastUpdated).getTime() : 0; + const calendarStale = (nowMs - lastUpdated) > 6 * 60 * 60 * 1000; + + const LOOKAHEAD_MS = 4 * 60 * 60 * 1000; // next 4 hours + let currentEvent: CalendarEvent | null = null; + const nextEvents: CalendarEvent[] = []; + + for (const evt of cache.events) { + // Skip all-day events (date-only, no 'T' in start) + if (evt.start && !evt.start.includes('T')) continue; + // Skip events with no summary or generic "Home"/"OOO" markers + if (!evt.summary) continue; + const lower = evt.summary.toLowerCase(); + if (lower === 'home' || lower === 'ooo' || lower.startsWith('out of office')) continue; + + const startMs = parseEventTime(evt.start)?.getTime(); + const endMs = parseEventTime(evt.end)?.getTime(); + if (!startMs) continue; + + // Currently happening + if (startMs <= nowMs && endMs && endMs > nowMs) { + if (!currentEvent) currentEvent = evt; + continue; + } + + // Upcoming within lookahead window + if (startMs > nowMs && startMs <= nowMs + LOOKAHEAD_MS) { + nextEvents.push(evt); + } + } + + // Sort next events by start time, limit to 3 + nextEvents.sort((a, b) => { + const aMs = parseEventTime(a.start)?.getTime() ?? 0; + const bMs = parseEventTime(b.start)?.getTime() ?? 0; + return aMs - bMs; + }); + + return { currentEvent, nextEvents: nextEvents.slice(0, 3), calendarStale }; +} + +/** Extract open tasks from ops/tasks.md "## Today" section. */ +function resolveTodayTasks(workspaceDir: string): string[] { + try { + const raw = readFileSync(join(workspaceDir, 'ops', 'tasks.md'), 'utf8'); + const todayMatch = raw.match(/## Today[\s\S]*?(?=\n## |$)/); + if (!todayMatch) return []; + + const lines = todayMatch[0].split('\n'); + const open: string[] = []; + for (const line of lines) { + // Match unchecked task lines: - [ ] **task name** ... + const m = line.match(/^\s*-\s*\[ \]\s*\*\*(.+?)\*\*/); + if (m) open.push(m[1].trim()); + } + return open.slice(0, 5); // cap at 5 to keep prompt lean + } catch { + return []; + } +} + function generateLiveContext(workspaceDir: string): LiveContext { const location = resolveLocation(workspaceDir); const time = getTimeInTz(location.tz); const hb = loadJsonFile(join(workspaceDir, 'memory', 'heartbeat-state.json')); + const nowMs = Date.now(); const garryAwake = hb?.garryAwake ?? true; const isQuietHours = !garryAwake && (time.hour >= 23 || time.hour < 8); @@ -235,6 +338,12 @@ function generateLiveContext(workspaceDir: string): LiveContext { ? `${activeFlight.flightNumber}: ${activeFlight.origin}→${activeFlight.destination}` : null; + // Calendar activity + const { currentEvent, nextEvents, calendarStale } = resolveActivity(workspaceDir, nowMs); + + // Open tasks + const todayTasks = resolveTodayTasks(workspaceDir); + return { now: time.iso, timezone: location.tz, @@ -244,9 +353,28 @@ function generateLiveContext(workspaceDir: string): LiveContext { garryAwake, isQuietHours, activeTravel, + currentEvent, + nextEvents, + todayTasks, + calendarStale, }; } +function formatEventShort(evt: CalendarEvent, tz: string): string { + const name = evt.summary ?? 'Untitled'; + let time = ''; + if (evt.start?.includes('T')) { + try { + const d = new Date(evt.start); + time = d.toLocaleTimeString('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit', hour12: true }); + } catch { /* fall through */ } + } + const attendeeStr = evt.attendees?.length + ? ` (with ${evt.attendees.slice(0, 3).join(', ')}${evt.attendees.length > 3 ? ` +${evt.attendees.length - 3}` : ''})` + : ''; + return time ? `${time} — ${name}${attendeeStr}` : `${name}${attendeeStr}`; +} + function formatContextBlock(ctx: LiveContext): string { const lines: string[] = [ `## Live Context (deterministic, injected by gbrain-context engine)`, @@ -265,8 +393,30 @@ function formatContextBlock(ctx: LiveContext): string { lines.push(`- **Garry awake:** no (quiet hours ${ctx.isQuietHours ? 'active' : 'paused'})`); } + // Current activity + if (ctx.currentEvent) { + lines.push(`- **Right now:** ${formatEventShort(ctx.currentEvent, ctx.timezone)}`); + } + + // Upcoming events + if (ctx.nextEvents.length > 0) { + lines.push(`- **Coming up:**`); + for (const evt of ctx.nextEvents) { + lines.push(` - ${formatEventShort(evt, ctx.timezone)}`); + } + } + + // Open tasks (if any) + if (ctx.todayTasks.length > 0) { + lines.push(`- **Open tasks:** ${ctx.todayTasks.join(' · ')}`); + } + + if (ctx.calendarStale) { + lines.push(`- ⚠️ Calendar cache >6h old — verify events via ClawVisor if time-sensitive`); + } + lines.push(''); - lines.push('> This block is computed on every turn. Trust it over compaction summaries for time/location.'); + lines.push('> This block is computed on every turn. Trust it over compaction summaries for time/location/activity.'); return lines.join('\n'); } diff --git a/test/context-engine.test.ts b/test/context-engine.test.ts index bf4267c35..3439dffc1 100644 --- a/test/context-engine.test.ts +++ b/test/context-engine.test.ts @@ -15,11 +15,25 @@ import { join } from 'path'; import { tmpdir } from 'os'; import { createGBrainContextEngine, ENGINE_ID, ENGINE_NAME } from '../src/core/context-engine.ts'; -function makeWorkspace(heartbeat: Record = {}, flights: Record = {}) { +interface WorkspaceOpts { + heartbeat?: Record; + flights?: Record; + calendar?: Record; + tasks?: string; +} + +function makeWorkspace(opts: WorkspaceOpts = {}) { const dir = mkdtempSync(join(tmpdir(), 'gbrain-ce-test-')); mkdirSync(join(dir, 'memory'), { recursive: true }); - writeFileSync(join(dir, 'memory', 'heartbeat-state.json'), JSON.stringify(heartbeat)); - writeFileSync(join(dir, 'memory', 'upcoming-flights.json'), JSON.stringify(flights)); + mkdirSync(join(dir, 'ops'), { recursive: true }); + writeFileSync(join(dir, 'memory', 'heartbeat-state.json'), JSON.stringify(opts.heartbeat ?? {})); + writeFileSync(join(dir, 'memory', 'upcoming-flights.json'), JSON.stringify(opts.flights ?? {})); + if (opts.calendar) { + writeFileSync(join(dir, 'memory', 'calendar-cache.json'), JSON.stringify(opts.calendar)); + } + if (opts.tasks) { + writeFileSync(join(dir, 'ops', 'tasks.md'), opts.tasks); + } return dir; } @@ -40,11 +54,13 @@ describe('gbrain-context engine', () => { it('injects systemPromptAddition on assemble', async () => { tmpDir = makeWorkspace({ - garryAwake: true, - currentLocation: { - city: 'Markham', - timezone: 'America/Toronto', - source: 'garry-confirmed', + heartbeat: { + garryAwake: true, + currentLocation: { + city: 'Markham', + timezone: 'America/Toronto', + source: 'garry-confirmed', + }, }, }); const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); @@ -65,7 +81,7 @@ describe('gbrain-context engine', () => { }); it('uses US/Pacific when no location set', async () => { - tmpDir = makeWorkspace({}); + tmpDir = makeWorkspace(); const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); const result = await engine.assemble({ @@ -79,7 +95,7 @@ describe('gbrain-context engine', () => { }); it('passes messages through unchanged', async () => { - tmpDir = makeWorkspace({ garryAwake: true }); + tmpDir = makeWorkspace({ heartbeat: { garryAwake: true } }); const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); const messages = [ @@ -109,10 +125,9 @@ describe('gbrain-context engine', () => { it('detects quiet hours when garryAwake is false and hour is late', async () => { tmpDir = makeWorkspace({ - garryAwake: false, - currentLocation: { - city: 'San Francisco', - timezone: 'US/Pacific', + heartbeat: { + garryAwake: false, + currentLocation: { city: 'San Francisco', timezone: 'US/Pacific' }, }, }); const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); @@ -122,15 +137,16 @@ describe('gbrain-context engine', () => { messages: [], }); - // We can't control the actual time, but we can verify the structure expect(result.systemPromptAddition).toBeDefined(); expect(result.systemPromptAddition).toContain('Live Context'); }); it('reports day of week as a real weekday name', async () => { tmpDir = makeWorkspace({ - garryAwake: true, - currentLocation: { city: 'Tokyo', timezone: 'Asia/Tokyo' }, + heartbeat: { + garryAwake: true, + currentLocation: { city: 'Tokyo', timezone: 'Asia/Tokyo' }, + }, }); const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); @@ -160,7 +176,7 @@ describe('gbrain-context engine', () => { }); it('estimates tokens from message content', async () => { - tmpDir = makeWorkspace({ garryAwake: true }); + tmpDir = makeWorkspace({ heartbeat: { garryAwake: true } }); const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); const messages = [ @@ -176,4 +192,154 @@ describe('gbrain-context engine', () => { expect(result.estimatedTokens).toBeGreaterThanOrEqual(90); expect(result.estimatedTokens).toBeLessThanOrEqual(110); }); + + // ── Activity / Calendar tests ────────────────────────────────────────── + + it('injects current event when calendar has an active meeting', async () => { + const now = new Date(); + const start = new Date(now.getTime() - 15 * 60 * 1000).toISOString(); // started 15 min ago + const end = new Date(now.getTime() + 30 * 60 * 1000).toISOString(); // ends in 30 min + + tmpDir = makeWorkspace({ + heartbeat: { garryAwake: true }, + calendar: { + lastUpdated: new Date().toISOString(), + events: [ + { summary: '1:1 with Diana', start, end, attendees: ['diana@ycombinator.com'] }, + ], + }, + }); + const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); + + const result = await engine.assemble({ + sessionId: 'test-session', + messages: [], + }); + + expect(result.systemPromptAddition).toContain('Right now'); + expect(result.systemPromptAddition).toContain('1:1 with Diana'); + expect(result.systemPromptAddition).toContain('diana@ycombinator.com'); + }); + + it('injects upcoming events within 4-hour window', async () => { + const now = new Date(); + const soon = new Date(now.getTime() + 60 * 60 * 1000).toISOString(); // 1 hour from now + const later = new Date(now.getTime() + 3 * 60 * 60 * 1000).toISOString(); // 3 hours from now + const tooFar = new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString(); // 5 hours out + + tmpDir = makeWorkspace({ + heartbeat: { garryAwake: true }, + calendar: { + lastUpdated: new Date().toISOString(), + events: [ + { summary: 'Office Hours — Batch W26', start: soon, end: new Date(new Date(soon).getTime() + 30 * 60 * 1000).toISOString() }, + { summary: 'GP Lunch', start: later, end: new Date(new Date(later).getTime() + 60 * 60 * 1000).toISOString() }, + { summary: 'Evening dinner', start: tooFar, end: new Date(new Date(tooFar).getTime() + 2 * 60 * 60 * 1000).toISOString() }, + ], + }, + }); + const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); + + const result = await engine.assemble({ + sessionId: 'test-session', + messages: [], + }); + + expect(result.systemPromptAddition).toContain('Coming up'); + expect(result.systemPromptAddition).toContain('Office Hours'); + expect(result.systemPromptAddition).toContain('GP Lunch'); + // 5 hours out should be excluded + expect(result.systemPromptAddition).not.toContain('Evening dinner'); + }); + + it('skips all-day and generic events (Home, OOO)', async () => { + const now = new Date(); + const soon = new Date(now.getTime() + 60 * 60 * 1000).toISOString(); + + tmpDir = makeWorkspace({ + heartbeat: { garryAwake: true }, + calendar: { + lastUpdated: new Date().toISOString(), + events: [ + { summary: 'Home', start: '2026-05-11' }, // all-day, no T + { summary: 'OOO', start: '2026-05-11' }, + { summary: 'Out of Office - Funeral', start: '2026-05-11' }, + { summary: 'Real Meeting', start: soon, end: new Date(new Date(soon).getTime() + 30 * 60 * 1000).toISOString() }, + ], + }, + }); + const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); + + const result = await engine.assemble({ + sessionId: 'test-session', + messages: [], + }); + + expect(result.systemPromptAddition).not.toContain('Home'); + expect(result.systemPromptAddition).not.toContain('OOO'); + expect(result.systemPromptAddition).not.toContain('Out of Office'); + expect(result.systemPromptAddition).toContain('Real Meeting'); + }); + + it('flags stale calendar cache', async () => { + const staleTime = new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(); // 8 hours old + + tmpDir = makeWorkspace({ + heartbeat: { garryAwake: true }, + calendar: { + lastUpdated: staleTime, + events: [], + }, + }); + const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); + + const result = await engine.assemble({ + sessionId: 'test-session', + messages: [], + }); + + expect(result.systemPromptAddition).toContain('Calendar cache >6h old'); + }); + + it('injects open tasks from ops/tasks.md', async () => { + tmpDir = makeWorkspace({ + heartbeat: { garryAwake: true }, + tasks: `# Current Tasks\n\n## Today\n\n- [ ] **DM Technium re: Hermes PR** — needs merge\n- [ ] **Post open source manifesto** — from YC Labs\n- [x] ~~Reply to Bob McGrew~~ — DONE\n\n## Next up\n- [ ] Something later`, + }); + const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); + + const result = await engine.assemble({ + sessionId: 'test-session', + messages: [], + }); + + expect(result.systemPromptAddition).toContain('Open tasks'); + expect(result.systemPromptAddition).toContain('DM Technium'); + expect(result.systemPromptAddition).toContain('Post open source manifesto'); + // Completed task should NOT appear + expect(result.systemPromptAddition).not.toContain('Reply to Bob'); + // "Next up" section tasks should NOT appear + expect(result.systemPromptAddition).not.toContain('Something later'); + }); + + it('no activity section when calendar is empty and no tasks', async () => { + tmpDir = makeWorkspace({ + heartbeat: { garryAwake: true }, + calendar: { + lastUpdated: new Date().toISOString(), + events: [], + }, + tasks: '# Current Tasks\n\n## Today\n\nAll done!', + }); + const engine = createGBrainContextEngine({ workspaceDir: tmpDir }); + + const result = await engine.assemble({ + sessionId: 'test-session', + messages: [], + }); + + expect(result.systemPromptAddition).not.toContain('Right now'); + expect(result.systemPromptAddition).not.toContain('Coming up'); + expect(result.systemPromptAddition).not.toContain('Open tasks'); + }); });