mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 20:50:34 +00:00
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:
+151
-1
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user