SLOPSHOPPER

knowledge-memory

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

newguardcommandstatusmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · knowledge-memory
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /knowledge-memory ⎿ knowledge-memory: Judge haiku, server "knowledge", learnings to "learning". ⎿ knowledge-memory: Nothing saved this session yet. ⎿ knowledge-memory: Last: skipped: short turn ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

knowledge-memory

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.

How it works

After each answered turn of the main thread, in the background:

  1. A small model judges the turn. Haiku (by default) reads the last exchange and the episodes already saved this session, and answers SKIP, LEARNING: <what> (reusable across projects) or PROJECT: <what> (specific to this project). Most turns are SKIP.
  2. A fork writes the episode. Only when it is not 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.
  3. The episode goes to the graph through the server's 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.

Permission

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.)

Commands

  • /knowledge-memory: what was saved this session and the last result
  • /knowledge-memory now: judge the last turn now

Options

OptionDefaultMeaning
serverknowledgeThe Graphiti MCP server's name as /mcp lists it
learningGrouplearninggroup_id for cross-project learnings
judgeModelhaikuThe model that judges each turn
mirrorAutoMemorytrueMirror auto-memory file writes to the graph

Cost

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.

Source 1 files
hooks/register.ts 211 lines
1// 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