feat: add activity injection — calendar events + open tasks in context block

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).
This commit is contained in:
garrytan-agents
2026-05-11 15:16:57 +00:00
parent f1dbe6eaa9
commit 14e85873d5
2 changed files with 335 additions and 19 deletions
+151 -1
View File
@@ -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<CalendarCache>(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<HeartbeatState>(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');
}
+184 -18
View File
@@ -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<string, unknown> = {}, flights: Record<string, unknown> = {}) {
interface WorkspaceOpts {
heartbeat?: Record<string, unknown>;
flights?: Record<string, unknown>;
calendar?: Record<string, unknown>;
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');
});
});