Saves what each turn teaches straight into a Graphiti knowledge graph: a Haiku check after every turn, a cached fork writes the episode

Saves what a Claude Code session learns straight into a Graphiti knowledge graph, a little after every turn, instead of in one big batch at the next session start.
This depends on a Graphiti MCP server connected to Claude Code. Without one it does nothing useful, but the pattern (cheap judge, cached fork, MCP write) is easy to point at another store.
After each answered turn of the main thread, in the background:
SKIP, LEARNING: <what> (reusable across projects) or PROJECT: <what> (specific to this project). Most turns are SKIP.SKIP: a tool-less $.model.fork replays the session's own transcript from the prompt cache, so it writes with the full context at a fraction of the price, and replies with a title and a short self-contained markdown body.add_memory tool: learnings to learningGroup, project facts to project_<name>.Claude Code's own file-based auto-memory is mirrored too: each Write or Edit of a file under ~/.claude/projects/<project>/memory/ is sent to the graph as project-memory: <project>/<file> in project_<name>, as soon as it lands.
The status line shows the last save. Nothing is added to the conversation.
The episode is written with the session's own MCP connection, so it goes through your permission rules. Allow the tool once, or every save will ask:
{ "permissions": { "allow": ["mcp__knowledge__add_memory"] } }
(Use your server's name in place of knowledge.)
/knowledge-memory: what was saved this session and the last result/knowledge-memory now: judge the last turn now| Option | Default | Meaning |
|---|---|---|
server | knowledge | The Graphiti MCP server's name as /mcp lists it |
learningGroup | learning | group_id for cross-project learnings |
judgeModel | haiku | The model that judges each turn |
mirrorAutoMemory | true | Mirror auto-memory file writes to the graph |
One small judge call per turn (the last exchange, capped at 12k characters). On a flagged turn, one fork that reads the conversation from the prompt cache and writes a few hundred tokens. Graphiti's own entity extraction runs on the server, on whatever model it is configured with.
hooks/register.ts 211 lines1// knowledge-memory: saves what a session learns straight into a Graphiti
2// knowledge graph, a little after every turn, instead of in bulk at the next
3// session start.
4//
5// After each main-thread turn:
6// 1. A small model (Haiku by default) reads the last exchange and answers
7// SKIP, LEARNING: <what> (reusable across projects) or PROJECT: <what>
8// (specific to this project).
9// 2. Only when it is not SKIP, a tool-less $.model.fork drafts one episode.
10// The fork replays the session's own transcript from the prompt cache, so
11// it has the full context at a fraction of the price.
12// 3. The episode goes to the graph's MCP server through $.mcp.call:
13// `learningGroup` for learnings, `project_<name>` for project facts.
14//
15// It also mirrors Claude Code's file-based auto-memory: every Write or Edit of
16// a file under ~/.claude/projects/<project>/memory/ is sent to the graph as
17// soon as it lands, under the same `project_<name>` group.
18
19import type { EngineInterface, Register, SessionMessage } from 'claude-code'
20
21const JUDGE = `You decide whether one turn of a coding session taught something worth keeping in a long-term knowledge graph.
22
23Answer LEARNING when the turn established something reusable beyond this project: a root cause found while debugging, a tool or API gotcha, a working technique, a decision with its reason, a correction of a wrong assumption.
24Answer PROJECT when it established a lasting fact about this project: its setup, a constraint, a decision, where something lives, how it is deployed.
25Answer SKIP for routine work, questions, plans not yet carried out, chit-chat, anything only this conversation needs, and anything listed under "already saved".
26
27Answer with exactly one line: "LEARNING: <what to save, under 20 words>", "PROJECT: <what to save, under 20 words>" or "SKIP".`
28
29const writerPrompt = (kind: string, what: string) => `[knowledge-memory] This message is from the knowledge-memory plugin, not the user. Do not use tools; answer in text only.
30
31A classifier flagged the latest turn for the knowledge graph as a ${kind}: ${what}
32
33Write it as one self-contained episode a future session can use without this conversation: the context, what was found or decided, the fix or the reason, and concrete names (tools, files, commands, versions, hosts). Leave out filler and the story of the conversation.
34
35Reply with only a JSON object: {"name": "<title under 80 characters>", "body": "<markdown, 3 to 15 lines>"}. If on reflection nothing is worth keeping, reply {"name": "", "body": ""}.`
36
37type Saved = { at: number; group: string; name: string }
38
39let server = 'knowledge'
40let learningGroup = 'learning'
41let judgeModel = 'haiku'
42let mirrorAutoMemory = true
43
44let saved: Saved[] = [] // this session's episodes, newest last
45let lastResult = 'nothing yet'
46let isBusy = false
47let isPending = false
48
49// "C--Dev-RagBase2" -> "Dev-RagBase2" -> "RagBase2"; the same rule the old sync hook used
50export const projectName = (dirName: string): string =>
51 dirName.replace(/^[A-Z]--/, '').replace(/^(dev|Users-[^-]+)-/, '') || dirName
52
53const projectSlug = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-')
54
55// ~/.claude/projects/<dir>/memory/<file>.md, either slash
56const AUTO_MEMORY = /[\\/]\.claude[\\/]projects[\\/]([^\\/]+)[\\/]memory[\\/]([^\\/]+\.md)$/i
57
58export const autoMemoryFile = (path: string) => {
59 const m = AUTO_MEMORY.exec(path)
60 return m && m[2] !== 'MEMORY.md' ? { project: projectName(m[1]!), file: m[2]! } : null
61}
62
63// A slash command's own rows (its record and output) are no exchange to judge
64const isCommandRow = (m: SessionMessage) => /^\s*<(local-)?command-/.test(m.text)
65
66export const lastExchange = (all: SessionMessage[]) => {
67 const messages = all.filter(m => !isCommandRow(m))
68 const start = messages.findLastIndex(m => m.role === 'user' && m.text.trim() !== '' && !m.toolResults?.length)
69 const text = messages
70 .slice(Math.max(0, start))
71 .flatMap(m => [...(m.text.trim() ? [`${m.role.toUpperCase()}: ${m.text.trim()}`] : []), ...m.toolUses.map(t => `TOOL: ${t.tool}`)])
72 .join('\n')
73 return text.length > 12000 ? `${text.slice(0, 4000)}\n…\n${text.slice(-8000)}` : text
74}
75
76export const parseVerdict = (text: string) => {
77 const m = /^(LEARNING|PROJECT):\s*(.+)$/m.exec(text.trim())
78 return m ? { kind: m[1] as 'LEARNING' | 'PROJECT', what: m[2]!.trim() } : null
79}
80
81export const parseEpisode = (text: string) => {
82 const json = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)) as { name?: unknown; body?: unknown }
83 const name = typeof json.name === 'string' ? json.name.trim().slice(0, 120) : ''
84 const body = typeof json.body === 'string' ? json.body.trim() : ''
85 return name && body ? { name, body } : null
86}
87
88async function addEpisode($: EngineInterface, args: { name: string; body: string; group: string; source: string }) {
89 const r = await $.mcp.call(server, 'add_memory', {
90 name: args.name,
91 episode_body: args.body.slice(0, 5000),
92 group_id: args.group,
93 source: 'text',
94 source_description: args.source,
95 })
96 if (r.isError) throw new Error(`add_memory on "${server}" failed`)
97 saved = [...saved, { at: await $.clock.now(), group: args.group, name: args.name }].slice(-20)
98}
99
100async function considerTurn($: EngineInterface): Promise<string> {
101 const exchange = lastExchange(await $.session.messages())
102 if (exchange.length < 200) return (lastResult = 'skipped: short turn')
103
104 const already = saved.map(s => `- ${s.name}`).join('\n') || '(none)'
105 const verdict = await $.model.complete({
106 model: judgeModel,
107 system: JUDGE,
108 prompt: `<already_saved>\n${already}\n</already_saved>\n\n<turn>\n${exchange}\n</turn>`,
109 maxTokens: 60,
110 timeoutMs: 20_000,
111 })
112 if (!verdict.isAnswered) return (lastResult = `judge failed: ${verdict.reason}`)
113 const flagged = parseVerdict(verdict.text)
114 if (!flagged) return (lastResult = `skipped: judge said "${verdict.text.trim().slice(0, 200)}"`)
115
116 const reply = await $.model.fork({ prompt: writerPrompt(flagged.kind.toLowerCase(), flagged.what) })
117 if (!reply.isAnswered) return (lastResult = `writer failed: ${reply.reason}`)
118 let episode: ReturnType<typeof parseEpisode>
119 try {
120 episode = parseEpisode(reply.text)
121 } catch {
122 return (lastResult = 'writer did not answer with JSON')
123 }
124 if (!episode) return (lastResult = 'skipped by the writer')
125
126 const project = projectName(projectSlug(await $.session.cwd()))
127 const group = flagged.kind === 'LEARNING' ? learningGroup : `project_${project}`
128 await addEpisode($, { ...episode, group, source: `knowledge-memory | Project: ${project}` })
129 $.ui.status(`🧠 saved to ${group}: ${episode.name}`)
130 return (lastResult = `saved to ${group}: ${episode.name}`)
131}
132
133// One run at a time; a turn that ends during a run is considered once it finishes
134async function consider($: EngineInterface): Promise<void> {
135 if (isBusy) {
136 isPending = true
137 return
138 }
139 isBusy = true
140 try {
141 lastResult = await considerTurn($)
142 } catch (err) {
143 lastResult = `failed: ${err instanceof Error ? err.message : String(err)}`
144 } finally {
145 isBusy = false
146 if (isPending) {
147 isPending = false
148 void consider($)
149 }
150 }
151}
152
153async function mirror($: EngineInterface, path: string): Promise<void> {
154 const hit = autoMemoryFile(path)
155 if (!hit) return
156 const body = await $.fs.read(path)
157 await addEpisode($, {
158 name: `project-memory: ${hit.project}/${hit.file}`,
159 body,
160 group: `project_${hit.project}`,
161 source: `Project: ${hit.project} | File: ${hit.file}`,
162 })
163 lastResult = `mirrored ${hit.project}/${hit.file}`
164}
165
166export const register: Register = (on, options) => {
167 server = String(options.server || 'knowledge')
168 learningGroup = String(options.learningGroup || 'learning')
169 judgeModel = String(options.judgeModel || 'haiku')
170 mirrorAutoMemory = options.mirrorAutoMemory !== false
171
172 on('session.start', async ($, e, next) => {
173 await $.command.register({
174 name: 'knowledge-memory',
175 description: 'Show what knowledge-memory saved this session; "/knowledge-memory now" considers the last turn now',
176 argumentHint: '[now]',
177 })
178 return next(e)
179 })
180
181 // After each answered main-thread turn, in the background
182 on('turn.complete', async ($, e, next) => {
183 const result = await next(e)
184 if (e.agentId === undefined && e.reason === 'answer') void consider($)
185 return result
186 })
187
188 // Mirror auto-memory writes to the graph once the write has landed
189 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
190 const result = await next(e)
191 if (mirrorAutoMemory && !result.isError) void mirror($, e.file_path).catch(() => { lastResult = 'mirror failed' })
192 return result
193 }).catch(($, e, next) => next(e)) // never stand between Claude and a write
194
195 on('command.run', { command: 'knowledge-memory' }, async ($, e) => {
196 if ((e.args ?? '').trim() === 'now') {
197 if (isBusy) return { text: 'A run is already considering the last turn; /knowledge-memory shows its result.' }
198 await consider($)
199 return { text: lastResult }
200 }
201 const lines = saved.map(s => `- [${s.group}] ${s.name}`)
202 return {
203 text: [
204 `Judge ${judgeModel}, server "${server}", learnings to "${learningGroup}".`,
205 lines.length ? `Saved this session:\n${lines.join('\n')}` : 'Nothing saved this session yet.',
206 `Last: ${lastResult}`,
207 ].join('\n'),
208 }
209 })
210}
211