Skip to content

Commit e053e13

Browse files
author
StackMemory Bot (CLI)
committed
feat(hooks): add Graphiti knowledge graph hooks and session summary
Wire session events into Graphiti for persistent knowledge graph updates. Add session summary generation for hook-based context capture.
1 parent 854a891 commit e053e13

4 files changed

Lines changed: 503 additions & 2 deletions

File tree

src/hooks/__tests__/graphiti-hooks.test.ts

Lines changed: 267 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { GraphitiHooks } from '../graphiti-hooks.js';
33
import { HookEventEmitter } from '../events.js';
4-
import type { HookEvent, FileChangeEvent } from '../events.js';
4+
import type {
5+
HookEvent,
6+
FileChangeEvent,
7+
InputIdleEvent,
8+
ContextSwitchEvent,
9+
SuggestionReadyEvent,
10+
AgentStartEvent,
11+
AgentCompleteEvent,
12+
AgentErrorEvent,
13+
} from '../events.js';
514

615
// Mock logger to suppress output
716
vi.mock('../../core/monitoring/logger.js', () => ({
@@ -51,14 +60,23 @@ describe('GraphitiHooks', () => {
5160
// ── register ──
5261

5362
describe('register', () => {
54-
it('registers handlers for session_start, file_change, session_end', () => {
63+
it('registers handlers for all 11 hook events', () => {
5564
const hooks = makeHooks();
5665
hooks.register(emitter);
5766

5867
const events = emitter.getRegisteredEvents();
68+
expect(events).toHaveLength(11);
5969
expect(events).toContain('session_start');
6070
expect(events).toContain('file_change');
6171
expect(events).toContain('session_end');
72+
expect(events).toContain('input_idle');
73+
expect(events).toContain('context_switch');
74+
expect(events).toContain('prompt_submit');
75+
expect(events).toContain('tool_use');
76+
expect(events).toContain('suggestion_ready');
77+
expect(events).toContain('agent_start');
78+
expect(events).toContain('agent_complete');
79+
expect(events).toContain('agent_error');
6280
});
6381

6482
it('skips registration when enabled=false', () => {
@@ -184,6 +202,253 @@ describe('GraphitiHooks', () => {
184202
});
185203
});
186204

205+
// ── onInputIdle ──
206+
207+
describe('onInputIdle', () => {
208+
it('records idle duration and last input', async () => {
209+
const hooks = makeHooks();
210+
const client = mockClient(hooks);
211+
client.upsertEpisode.mockResolvedValue({ id: 'ep-idle' });
212+
hooks.register(emitter);
213+
214+
const event: InputIdleEvent = {
215+
type: 'input_idle',
216+
timestamp: Date.now(),
217+
data: { idleDuration: 30000, lastInput: 'save file' },
218+
};
219+
await emitter.emitHook(event);
220+
221+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
222+
const episode = client.upsertEpisode.mock.calls[0][0];
223+
expect(episode.type).toBe('input_idle');
224+
expect(episode.content).toEqual({
225+
idleDuration: 30000,
226+
lastInput: 'save file',
227+
});
228+
expect(episode.source).toBe('stackmemory');
229+
});
230+
231+
it('handles missing lastInput', async () => {
232+
const hooks = makeHooks();
233+
const client = mockClient(hooks);
234+
client.upsertEpisode.mockResolvedValue({ id: 'ep-idle2' });
235+
hooks.register(emitter);
236+
237+
const event: InputIdleEvent = {
238+
type: 'input_idle',
239+
timestamp: Date.now(),
240+
data: { idleDuration: 5000 },
241+
};
242+
await emitter.emitHook(event);
243+
244+
const episode = client.upsertEpisode.mock.calls[0][0];
245+
expect(episode.content.lastInput).toBeUndefined();
246+
});
247+
});
248+
249+
// ── onContextSwitch ──
250+
251+
describe('onContextSwitch', () => {
252+
it('records branch and project changes', async () => {
253+
const hooks = makeHooks();
254+
const client = mockClient(hooks);
255+
client.upsertEpisode.mockResolvedValue({ id: 'ep-ctx' });
256+
hooks.register(emitter);
257+
258+
const event: ContextSwitchEvent = {
259+
type: 'context_switch',
260+
timestamp: Date.now(),
261+
data: {
262+
fromBranch: 'main',
263+
toBranch: 'feature/foo',
264+
fromProject: 'proj-a',
265+
toProject: 'proj-b',
266+
},
267+
};
268+
await emitter.emitHook(event);
269+
270+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
271+
const episode = client.upsertEpisode.mock.calls[0][0];
272+
expect(episode.type).toBe('context_switch');
273+
expect(episode.content).toEqual({
274+
fromBranch: 'main',
275+
toBranch: 'feature/foo',
276+
fromProject: 'proj-a',
277+
toProject: 'proj-b',
278+
});
279+
});
280+
});
281+
282+
// ── onPromptSubmit ──
283+
284+
describe('onPromptSubmit', () => {
285+
it('records prompt data as episode', async () => {
286+
const hooks = makeHooks();
287+
const client = mockClient(hooks);
288+
client.upsertEpisode.mockResolvedValue({ id: 'ep-prompt' });
289+
hooks.register(emitter);
290+
291+
const event: HookEvent = {
292+
type: 'prompt_submit',
293+
timestamp: Date.now(),
294+
data: { prompt: 'fix the bug', tokens: 42 },
295+
};
296+
await emitter.emitHook(event);
297+
298+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
299+
const episode = client.upsertEpisode.mock.calls[0][0];
300+
expect(episode.type).toBe('prompt_submit');
301+
expect(episode.content).toEqual({ prompt: 'fix the bug', tokens: 42 });
302+
expect(episode.source).toBe('stackmemory');
303+
});
304+
});
305+
306+
// ── onToolUse ──
307+
308+
describe('onToolUse', () => {
309+
it('records tool use data as episode', async () => {
310+
const hooks = makeHooks();
311+
const client = mockClient(hooks);
312+
client.upsertEpisode.mockResolvedValue({ id: 'ep-tool' });
313+
hooks.register(emitter);
314+
315+
const event: HookEvent = {
316+
type: 'tool_use',
317+
timestamp: Date.now(),
318+
data: { tool: 'Read', file: '/src/index.ts' },
319+
};
320+
await emitter.emitHook(event);
321+
322+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
323+
const episode = client.upsertEpisode.mock.calls[0][0];
324+
expect(episode.type).toBe('tool_use');
325+
expect(episode.content).toEqual({ tool: 'Read', file: '/src/index.ts' });
326+
});
327+
});
328+
329+
// ── onSuggestionReady ──
330+
331+
describe('onSuggestionReady', () => {
332+
it('records source, confidence, preview but omits full suggestion', async () => {
333+
const hooks = makeHooks();
334+
const client = mockClient(hooks);
335+
client.upsertEpisode.mockResolvedValue({ id: 'ep-suggest' });
336+
hooks.register(emitter);
337+
338+
const event: SuggestionReadyEvent = {
339+
type: 'suggestion_ready',
340+
timestamp: Date.now(),
341+
data: {
342+
suggestion: 'full suggestion text that should be omitted',
343+
source: 'context-retriever',
344+
confidence: 0.85,
345+
preview: 'fix authentication...',
346+
},
347+
};
348+
await emitter.emitHook(event);
349+
350+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
351+
const episode = client.upsertEpisode.mock.calls[0][0];
352+
expect(episode.type).toBe('suggestion_ready');
353+
expect(episode.content).toEqual({
354+
source: 'context-retriever',
355+
confidence: 0.85,
356+
preview: 'fix authentication...',
357+
});
358+
// Full suggestion text should NOT be in the episode
359+
expect(episode.content.suggestion).toBeUndefined();
360+
});
361+
});
362+
363+
// ── onAgentStart ──
364+
365+
describe('onAgentStart', () => {
366+
it('maps agent_start to episode with agentType and task', async () => {
367+
const hooks = makeHooks();
368+
const client = mockClient(hooks);
369+
client.upsertEpisode.mockResolvedValue({ id: 'ep-agent-start' });
370+
hooks.register(emitter);
371+
372+
const event: AgentStartEvent = {
373+
type: 'agent_start',
374+
timestamp: Date.now(),
375+
data: {
376+
agentType: 'research',
377+
workDir: '/tmp/sm-research-abc',
378+
task: 'How does FTS5 work?',
379+
},
380+
};
381+
await emitter.emitHook(event);
382+
383+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
384+
const episode = client.upsertEpisode.mock.calls[0][0];
385+
expect(episode.type).toBe('agent_start');
386+
expect(episode.content).toEqual({
387+
agentType: 'research',
388+
task: 'How does FTS5 work?',
389+
});
390+
expect(episode.source).toBe('stackmemory');
391+
});
392+
});
393+
394+
// ── onAgentComplete ──
395+
396+
describe('onAgentComplete', () => {
397+
it('maps agent_complete to episode with all fields', async () => {
398+
const hooks = makeHooks();
399+
const client = mockClient(hooks);
400+
client.upsertEpisode.mockResolvedValue({ id: 'ep-agent-done' });
401+
hooks.register(emitter);
402+
403+
const event: AgentCompleteEvent = {
404+
type: 'agent_complete',
405+
timestamp: Date.now(),
406+
data: {
407+
agentType: 'maintain',
408+
workDir: '/tmp/sm-maint-abc',
409+
exitCode: 0,
410+
timedOut: false,
411+
patchPath: '/repo/.stackmemory/patches/fix.patch',
412+
validated: true,
413+
},
414+
};
415+
await emitter.emitHook(event);
416+
417+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
418+
const episode = client.upsertEpisode.mock.calls[0][0];
419+
expect(episode.type).toBe('agent_complete');
420+
expect(episode.content.agentType).toBe('maintain');
421+
expect(episode.content.validated).toBe(true);
422+
expect(episode.content.patchPath).toBeDefined();
423+
});
424+
});
425+
426+
// ── onAgentError ──
427+
428+
describe('onAgentError', () => {
429+
it('maps agent_error to episode', async () => {
430+
const hooks = makeHooks();
431+
const client = mockClient(hooks);
432+
client.upsertEpisode.mockResolvedValue({ id: 'ep-agent-err' });
433+
hooks.register(emitter);
434+
435+
const event: AgentErrorEvent = {
436+
type: 'agent_error',
437+
timestamp: Date.now(),
438+
data: { agentType: 'spec-run', error: 'git clone failed' },
439+
};
440+
await emitter.emitHook(event);
441+
442+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
443+
const episode = client.upsertEpisode.mock.calls[0][0];
444+
expect(episode.type).toBe('agent_error');
445+
expect(episode.content).toEqual({
446+
agentType: 'spec-run',
447+
error: 'git clone failed',
448+
});
449+
});
450+
});
451+
187452
// ── Error resilience ──
188453

189454
describe('error resilience', () => {

src/hooks/events.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export type HookEventType =
1414
| 'prompt_submit'
1515
| 'tool_use'
1616
| 'suggestion_ready'
17+
| 'agent_start'
18+
| 'agent_complete'
19+
| 'agent_error'
1720
| 'error';
1821

1922
export interface HookEvent {
@@ -59,11 +62,41 @@ export interface SuggestionReadyEvent extends HookEvent {
5962
};
6063
}
6164

65+
export type AgentType = 'research' | 'maintain' | 'spec-run';
66+
67+
export interface AgentStartEvent extends HookEvent {
68+
type: 'agent_start';
69+
data: { agentType: AgentType; workDir: string; task: string };
70+
}
71+
72+
export interface AgentCompleteEvent extends HookEvent {
73+
type: 'agent_complete';
74+
data: {
75+
agentType: AgentType;
76+
workDir: string;
77+
exitCode: number | null;
78+
timedOut: boolean;
79+
frameId?: string;
80+
patchPath?: string;
81+
validated?: boolean;
82+
branch?: string;
83+
validation?: { lint: boolean; test: boolean; build: boolean };
84+
};
85+
}
86+
87+
export interface AgentErrorEvent extends HookEvent {
88+
type: 'agent_error';
89+
data: { agentType: AgentType; error: string; workDir?: string };
90+
}
91+
6292
export type HookEventData =
6393
| FileChangeEvent
6494
| InputIdleEvent
6595
| ContextSwitchEvent
6696
| SuggestionReadyEvent
97+
| AgentStartEvent
98+
| AgentCompleteEvent
99+
| AgentErrorEvent
67100
| HookEvent;
68101

69102
export type HookHandler = (event: HookEventData) => Promise<void> | void;

0 commit comments

Comments
 (0)