CortexDB inside Claude Code: haiku-planned auto-recall shown above the prompt, the brain on the status line, and durable facts captured while the session is…

AI memory and a knowledge graph in one SQLite file. Pure Go, no service to run, works without an embedding model.

| Go library | go get github.com/liliang-cn/cortexdb/v2 |
| Claude Code | /plugin marketplace add liliang-cn/cortexdb, then /plugin install cortexdb@cortexdb |
| Codex | codex plugin marketplace add liliang-cn/cortexdb && codex plugin add cortexdb@cortexdb |
| Claude Code mod (optional) | /plugin install cortexdb-live@cortexdb |
| Shared-brain server | go install github.com/liliang-cn/cortexdb/v2/cmd/cortexdb-grpc@latest |
| Clients | cargo add cortexdb-client · pip install cortexdb-client · npm install cortexdb-client |
db, _ := cortexdb.Open(cortexdb.DefaultConfig("brain.db"))
defer db.Close()
brain := db.KnowledgeMemory()
_, _ = brain.Remember(ctx, cortexdb.KnowledgeMemoryRememberRequest{Content: "Alice prefers tabs.", Scope: "user"})
rec, _ := brain.Recall(ctx, cortexdb.KnowledgeMemoryRecallRequest{Query: "what does Alice prefer?"})
fmt.Println(rec.ContextPack.Text)
The plugin gives Claude Code and Codex one global brain at ~/.cortexdb/cortexdb.db, /remember, /recall and an auto-recall hook. Point several agents or machines at one cortexdb-grpc and they share the same memory and graph.
In Codex, review and trust the plugin hooks in /hooks to activate automatic recall. See the plugin guide for setup and cortexdb-mcp --doctor --self-test diagnostics (v2.119.0+).
cortexdb-live is an optional mod that runs inside Claude Code on top of the cortexdb plugin. Install it after the plugin:
/plugin install cortexdb-live@cortexdb
The mod calls three of the plugin's tools itself, and a hook gets no permission prompt, so allow them once in ~/.claude/settings.json (not needed in bypass mode):
"permissions": {
"allow": [
"mcp__plugin_cortexdb_cortexdb__knowledge_memory_recall",
"mcp__plugin_cortexdb_cortexdb__graph_statistics",
"mcp__plugin_cortexdb_cortexdb__memory_save"
]
}
Without them the status line names the missing rules.
/cortexdb-show brings the hidden one back.auto:<session>:<slug>). Each later pass replaces a memory under the same slug rather than adding a copy. Older memories a new one makes untrue (a host moved, a decision reversed) are marked superseded: kept, but no longer recalled as current.Haiku runs on Claude Code's own model access, so no API key is needed. Language: /config → cortexdb-live → language (auto, zh, en). The mod honours the plugin's own switches: cortexdb-recall --disable and cortexdb-session-end --disable turn recall and capture off for both. While the mod runs, the plugin's shell recall and capture hooks stand down, so nothing is done twice. Codex keeps those hooks. Mods are an early-access Claude Code feature (built against 2.1.290).
EmbeddedConfig for small devices: an SQ8 index, bounded SQLite caches, snapshots that survive a power cut — 100k 768-d vectors open in 0.28 s with a 193 MB heapserve_graph_3d: a live 3D view to find, ask, query and expand, on desktop or phone — or the same brain as a library, each memory a book on its project's shelfimport_agent_memory: bring in what Claude Code and Codex already remember — memory notes, CLAUDE.md / AGENTS.md, Codex's memories, and optionally past sessions distilled into memories — into a local or shared brainpostgres:// DSN| Variable | Meaning |
|---|---|
CORTEXDB_PATH | Database file (default ~/.cortexdb/cortexdb.db) |
CORTEXDB_REMOTE | Use a shared cortexdb-grpc at host:port instead of a local file |
CORTEXDB_GRPC_TOKEN | Bearer token for that server |
CORTEXDB_GRPC_ADDR | Server listen address (default 127.0.0.1:47821) |
CORTEXDB_EMBED_BASE_URL | OpenAI-compatible embeddings endpoint; unset = lexical mode |
CORTEXDB_EMBED_MODEL / CORTEXDB_EMBED_DIM | Embedding model and dimension |
Website · Guide (full feature reference) · Examples · Changelog · 中文
MIT
hooks/register.tsx 710 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { CaptureMark, CapturedMemory, Recall, RecallHit, RecallPlan } from '../types'
5
6const last = atom({ plugin: 'cortexdb-live', key: 'last' } as const, null)
7const isOpen = atom({ plugin: 'cortexdb-live', key: 'isOpen' } as const, false)
8const isHidden = atom({ plugin: 'cortexdb-live', key: 'isHidden' } as const, false)
9const captureMark = atom({ plugin: 'cortexdb-live', key: 'capture' } as const, null)
10
11// The server the cortexdb plugin's manifest starts. Inside that plugin
12// $.mcp.connect answers with it; from a mod of its own it is this name.
13const FALLBACK_SERVER = 'plugin:cortexdb:cortexdb'
14// The shell hook's block starts with this; when the recall here answered, that
15// one is the same question asked twice.
16const SHELL_HEADER = 'Relevant CortexDB memories for this prompt'
17const TOP_K = 3
18const SNIPPET = 220
19// Planning is worth a second, not more: past this the lexical plan stands in.
20const PLAN_TIMEOUT_MS = 2500
21// A recall runs before every prompt: past this the prompt goes on without it.
22const RECALL_BUDGET_MS = 6000
23const STATS_EVERY_MS = 5 * 60 * 1000
24const CONNECT_RETRY_MS = 5 * 1000
25// session.end gets 1.5 s, too little for a model call, so a session is
26// captured while it is idle instead: this long after its last turn.
27const CAPTURE_IDLE_MS = 90 * 1000
28const CAPTURE_MIN_USER_TURNS = 2
29const PLANNER = 'haiku'
30const SHOW_COMMAND = 'cortexdb-show'
31
32const STRINGS = {
33 zh: {
34 recalled: (n: number) => `🧠 已召回 ${n} 条`,
35 detail: (m: number, k: number, s: string) => `(记忆 ${m} · 知识 ${k} · ${s}s)`,
36 expand: '展开',
37 collapse: '收起',
38 hide: '隐藏',
39 memory: '记忆',
40 knowledge: '知识',
41 query: '查询',
42 entities: '实体',
43 nodes: (n: string) => `${n} 节点`,
44 recallTime: (s: string) => `召回 ${s}s`,
45 skipped: '这条不用召回',
46 unreachable: '连不上',
47 recallFailed: '召回失败',
48 denied: (tools: string) => `没有调用 CortexDB 工具的权限,请在 settings.json 的 permissions.allow 里允许:${tools}`,
49 local: '本地大脑',
50 captured: (n: number, retired: number) => `CortexDB 记下了 ${n} 条新记忆${retired > 0 ? `,替换了 ${retired} 条过时的` : ''}`,
51 showCommand: '重新显示上一条 prompt 的 CortexDB 召回结果(隐藏之后用)',
52 shown: '已在输入框上方重新显示召回结果。',
53 nothingShown: '上一条 prompt 没有召回到内容。',
54 },
55 en: {
56 recalled: (n: number) => `🧠 Recalled ${n}`,
57 detail: (m: number, k: number, s: string) => ` (memories ${m} · knowledge ${k} · ${s}s)`,
58 expand: 'Expand',
59 collapse: 'Collapse',
60 hide: 'Hide',
61 memory: 'memory',
62 knowledge: 'knowledge',
63 query: 'Query',
64 entities: 'Entities',
65 nodes: (n: string) => `${n} nodes`,
66 recallTime: (s: string) => `recall ${s}s`,
67 skipped: 'no recall needed',
68 unreachable: 'unreachable',
69 recallFailed: 'recall failed',
70 denied: (tools: string) => `not allowed to call CortexDB tools; allow them in settings.json permissions.allow: ${tools}`,
71 local: 'local brain',
72 captured: (n: number, retired: number) => `CortexDB saved ${n} new memories${retired > 0 ? `, retired ${retired} outdated` : ''}`,
73 showCommand: "Show what CortexDB recalled for the last prompt again (after Hide)",
74 shown: 'The recall is shown above the prompt again.',
75 nothingShown: 'The last prompt recalled nothing.',
76 },
77}
78
79type Lang = keyof typeof STRINGS
80type Brain = { where?: string; nodes?: number; error?: string; recallMs?: number; skipped?: boolean }
81
82let server: string | undefined
83let lang: Lang = 'en'
84let brain: Brain = {}
85let idleTimer: { cancel: () => void } | undefined
86let isCapturing = false
87
88const t = () => STRINGS[lang]
89
90// zh when anything the person chose says Chinese; an explicit Claude Code
91// language setting that says something else wins over the locale.
92async function resolveLang($: EngineInterface, options: PluginOptions): Promise<Lang> {
93 if (options.language === 'zh' || options.language === 'en') return options.language
94 const isZh = (value: unknown) => typeof value === 'string' && /^zh|chinese|中文|汉语|简体|繁體/i.test(value.trim())
95 // Claude Code's own language row reads English until someone sets it, so
96 // only a Chinese value there decides; anything else falls to the locale.
97 const setting = (await $.config.list().catch(() => [])).find(row => row.key === 'language')?.value
98 if (isZh(setting)) return 'zh'
99 for (const value of [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')]) {
100 if (isZh(value)) return 'zh'
101 }
102 // macOS keeps the UI language here, while Terminal's LANG often says en_US.
103 const apple = await $.process.run(['defaults', 'read', '-g', 'AppleLanguages']).catch(() => undefined)
104 const first = apple?.exitCode === 0 ? apple.stdout.match(/[A-Za-z]{2,3}(-[A-Za-z0-9]+)*/)?.[0] : undefined
105 return isZh(first) ? 'zh' : 'en'
106}
107
108async function serverOf($: EngineInterface) {
109 if (server) return server
110 const own = await $.mcp.connect('cortexdb').catch(() => undefined)
111 server = own?.isConnected ? own.server : FALLBACK_SERVER
112 return server
113}
114
115async function callTool($: EngineInterface, tool: string, args: Record<string, unknown>) {
116 const result = await $.mcp.call(await serverOf($), tool, args)
117 const text = result.content.find(block => block.type === 'text')?.text ?? ''
118 if (result.isError) throw new Error(text.slice(0, 200) || `${tool} failed`)
119 return JSON.parse(text) as unknown
120}
121
122function showStatus($: EngineInterface) {
123 const parts = [`🧠 ${brain.where ?? t().local}`]
124 if (brain.error) parts.push(`⚠ ${brain.error}`)
125 else {
126 if (brain.nodes !== undefined) parts.push(t().nodes(brain.nodes.toLocaleString('en-US')))
127 if (brain.skipped) parts.push(t().skipped)
128 else if (brain.recallMs !== undefined) parts.push(t().recallTime((brain.recallMs / 1000).toFixed(1)))
129 }
130 $.ui.status(parts.join(' · '))
131}
132
133async function refreshStats($: EngineInterface) {
134 try {
135 const stats = (await callTool($, 'graph_statistics', {})) as { node_count?: number }
136 brain = { ...brain, nodes: stats.node_count, error: undefined }
137 } catch (err) {
138 if (isConnecting(err)) {
139 // The session starts before its MCP servers finish connecting: the
140 // first ask can come too early, which says nothing about the brain.
141 $.clock.after(CONNECT_RETRY_MS, () => void refreshStats($))
142 return
143 }
144 brain = { ...brain, error: isDenied(err) ? t().denied(TOOLS_TO_ALLOW) : `${t().unreachable}: ${messageOf(err)}` }
145 }
146 showStatus($)
147}
148
149// The switches `cortexdb-recall` and `cortexdb-session-end` write, shared with
150// the shell hooks so one command turns a feature off everywhere.
151async function sentinel($: EngineInterface, name: 'autorecall' | 'autocapture') {
152 const cache = (await $.env.get('XDG_CACHE_HOME')) ?? `${await $.env.get('HOME')}/.cache`
153 try {
154 return String(await $.fs.read(`${cache}/cortexdb/${name}`)).trim()
155 } catch {
156 return undefined
157 }
158}
159
160// --- recall -----------------------------------------------------------------
161
162const PLAN_SYSTEM = `You plan a lookup in a long-term memory and knowledge-graph store that a coding assistant keeps across sessions: user preferences, project decisions, deployments, hosts, versions, lessons.
163
164Given the user's new message (and the assistant's previous reply for context), answer with JSON only:
165{"skip":false,"query":"...","keywords":["..."],"alternate_queries":["..."],"entity_names":["..."],"retrieval_mode":"auto"}
166
167- skip: true only when the message needs nothing remembered: a bare acknowledgement or go-ahead ("ok", "好", "发", "继续", "👌") whose subject the previous reply already settles.
168- query: the message restated as a self-contained lookup.
169- keywords: up to 12 terms the stored text would contain: names, project and host names, versions, abbreviations, synonyms, and BOTH Chinese and English forms of each concept.
170- alternate_queries: up to 3 other phrasings.
171- entity_names: up to 6 exact names of concrete things (projects, services, hosts, people, tools).
172- retrieval_mode: "graph" for relational questions (who uses X, what X depends on, how A relates to B), else "auto".`
173
174type PlanReply = {
175 skip?: boolean
176 query?: string
177 keywords?: string[]
178 alternate_queries?: string[]
179 entity_names?: string[]
180 retrieval_mode?: string
181}
182
183type Planned = { args: Record<string, unknown>; plan: RecallPlan; skip: boolean }
184
185async function planRecall($: EngineInterface, prompt: string): Promise<Planned> {
186 const started = await $.clock.now()
187 const lexical = keywordsOf(prompt)
188 const fallback: Planned = {
189 args: { query: prompt, keywords: lexical },
190 plan: { by: 'lexical', keywords: lexical, entities: [], mode: 'auto', ms: 0 },
191 skip: false,
192 }
193
194 const history = await $.session.messages().catch(() => [])
195 const previous = Array.isArray(history)
196 ? history.filter(m => m.role === 'assistant' && m.text.trim() !== '').at(-1)?.text
197 : undefined
198 const reply = await $.model.complete({
199 model: PLANNER,
200 system: PLAN_SYSTEM,
201 prompt: `${previous ? `Previous assistant reply:\n${clip(previous, 800)}\n\n` : ''}New user message:\n${clip(prompt, 2000)}`,
202 maxTokens: 500,
203 timeoutMs: PLAN_TIMEOUT_MS,
204 })
205 if (!reply.isAnswered) return fallback
206 const planned = parseJSON<PlanReply>(reply.text)
207 if (!planned) return fallback
208
209 const keywords = [...new Set([...strings(planned.keywords, 12), ...lexical])].slice(0, 24)
210 const entities = strings(planned.entity_names, 6)
211 const mode = planned.retrieval_mode === 'graph' || planned.retrieval_mode === 'lexical' ? planned.retrieval_mode : 'auto'
212 return {
213 args: {
214 query: typeof planned.query === 'string' && planned.query.trim() !== '' ? planned.query : prompt,
215 keywords,
216 alternate_queries: strings(planned.alternate_queries, 3),
217 entity_names: entities,
218 retrieval_mode: mode,
219 },
220 plan: { by: 'haiku', keywords: strings(planned.keywords, 12), entities, mode, ms: (await $.clock.now()) - started },
221 skip: planned.skip === true,
222 }
223}
224
225async function recall($: EngineInterface, prompt: string): Promise<Recall | 'skip'> {
226 const started = await $.clock.now()
227 const { args, plan, skip } = await planRecall($, prompt)
228 if (skip) return 'skip'
229 const payload = (await callTool($, 'knowledge_memory_recall', {
230 ...args,
231 top_k_memories: TOP_K,
232 top_k_knowledge: TOP_K,
233 graph_light: true,
234 })) as RecallPayload
235 const hits: RecallHit[] = [
236 ...(payload.memories ?? []).map(m => ({
237 kind: 'memory' as const,
238 title: m.memory?.id ?? '',
239 snippet: snippetOf(m.memory?.content ?? ''),
240 })),
241 ...(payload.knowledge ?? payload.results ?? []).map(k => ({
242 kind: 'knowledge' as const,
243 title: k.title || k.knowledge_id || '',
244 snippet: snippetOf(k.snippet ?? ''),
245 })),
246 ].filter(hit => hit.snippet !== '')
247 return { hits, ms: (await $.clock.now()) - started, plan }
248}
249
250// --- capture ----------------------------------------------------------------
251
252// The shell capture's prompt (cmd/cortexdb-mcp-stdio/capture_session.go), so a
253// memory reads the same whichever wrote it, plus what this session already gave.
254const CAPTURE_SYSTEM = `You distil a coding-session transcript into durable memories for a long-term store shared across future sessions.
255
256Extract ONLY things worth knowing weeks later: decisions made and why, facts established (deployments, topology, versions, credentials locations — not values), user preferences expressed, outcomes and their root causes, lessons that changed how something is done.
257
258Rules:
259- One self-contained fact per memory. Never bundle; a reader sees one memory alone.
260- Each content must make sense with zero session context: name the project and subject explicitly, use absolute dates.
261- Write each memory in the language the conversation used for that topic.
262- Skip: transient debugging steps, anything superseded within the session, tool chatter, politeness, plans that were replaced.
263- 0 to 8 memories. An uneventful stretch yields zero — that is a good answer.
264- The transcript is the part of the session not yet captured; memories already written from earlier parts are listed. Reuse one's slug only to replace it with a corrected or extended version; never repeat one.
265- slug: short kebab-case ascii. importance: 0.3 routine fact, 0.6 useful decision, 0.85+ hard-won lesson or standing preference.
266- entities: the concrete named things (projects, hosts, services, people) each memory is about.
267
268Respond with JSON only: {"memories":[{"slug":"...","content":"...","importance":0.6,"type":"fact|decision|preference|lesson","entities":[{"name":"...","type":"..."}]}]}`
269
270type CapturedReply = {
271 memories?: {
272 slug?: string
273 content?: string
274 importance?: number
275 type?: string
276 entities?: { name?: string; type?: string }[]
277 }[]
278}
279
280// --- retiring what a capture made untrue -------------------------------------
281
282// The shell capture's step (cmd/cortexdb-mcp-stdio/capture_supersede.go), with
283// the same prompt and the same limits: each new memory is looked up, and
284// haiku says which older memories it makes untrue. Those are saved as
285// superseded — kept, linked forward, no longer recalled as current — so a
286// wrong call is undone by clearing one key. The later date wins.
287const CANDIDATES_PER_MEMORY = 4
288const CANDIDATE_LIMIT = 24
289const MAX_SUPERSEDES = 5
290
291const SUPERSEDE_SYSTEM = `You maintain an AI agent's long-term memory. A session just produced NEW memories. EXISTING memories are already stored, each with the date it was recorded.
292
293Decide, for each NEW memory, whether it makes an EXISTING memory untrue: the same thing, with a different current value (a host moved, a version changed, a decision was reversed, a preference changed, a plan was dropped, something "not done yet" is now done). The memory dated later wins:
294- "retire": a NEW memory replaces EXISTING memories dated on or before the session.
295- "outdated": a NEW memory is itself contradicted by an EXISTING memory dated after the session; it should not be saved.
296
297Be strict. Being about the same topic is not enough; adding detail is not a contradiction; two facts that can both be true are not one. When unsure, leave it out — an outdated memory left in place is a smaller harm than a true one retired.
298
299Name memories exactly as listed: a NEW memory by its "new" name, an EXISTING one by its "old" id — never by a date.
300
301Respond with JSON only: {"retire":[{"new":"<new name>","old":["<old id>"],"reason":"..."}],"outdated":[{"new":"<new name>","by":"<old id>"}]}`
302
303type Fresh = { slug: string; content: string; entities: { name: string; type: string }[] }
304type Candidate = { id: string; content: string; date: string }
305type Retirement = { retire: Map<string, string[]>; reason: Map<string, string>; outdated: Set<string> }
306
307const noRetirement = (): Retirement => ({ retire: new Map(), reason: new Map(), outdated: new Set() })
308
309// A memory speaks for the day of its session when it says, else the day it
310// was stored. One with neither is never compared: no one can say which is later.
311const dateOf = (memory: { metadata?: { date?: unknown }; created_at?: string }) => {
312 const date = typeof memory.metadata?.date === 'string' ? memory.metadata.date : memory.created_at ?? ''
313 return /^\d{4}-\d{2}-\d{2}/.test(date) ? date.slice(0, 10) : ''
314}
315
316async function candidatesFor($: EngineInterface, fresh: Fresh[], ownPrefix: string) {
317 const seen = new Set<string>()
318 const out: Candidate[] = []
319 for (const m of fresh) {
320 let payload: RecallPayload
321 try {
322 payload = (await callTool($, 'knowledge_memory_recall', {
323 query: clip(m.content, 400),
324 entity_names: m.entities.map(e => e.name),
325 top_k_memories: CANDIDATES_PER_MEMORY,
326 disable_knowledge: true,
327 graph_light: true,
328 })) as RecallPayload
329 } catch {
330 continue // finding nothing to retire is the safe failure
331 }
332 for (const hit of payload.memories ?? []) {
333 const id = hit.memory?.id ?? ''
334 const date = hit.memory ? dateOf(hit.memory) : ''
335 // This session's own memories are rewritten by slug, not superseded.
336 if (id === '' || date === '' || seen.has(id) || id.startsWith(ownPrefix)) continue
337 seen.add(id)
338 out.push({ id, content: hit.memory?.content ?? '', date })
339 if (out.length >= CANDIDATE_LIMIT) return out
340 }
341 }
342 return out
343}
344
345type RetireReply = {
346 retire?: { new?: string; old?: unknown; reason?: string }[]
347 outdated?: { new?: string; by?: string }[]
348}
349
350// Models embellish names ("later-deploy (2026-11-02)"); read back only the
351// names that were given, never one made up or a date.
352const nameIn = (said: string, names: string[]) => {
353 const s = said.trim()
354 let best = ''
355 for (const n of names) {
356 if (s === n) return n
357 if (n.length > best.length && (s.startsWith(`${n} `) || s.startsWith(`${n}(`) || s.includes(`"${n}"`))) best = n
358 }
359 return best
360}
361
362async function planRetirement($: EngineInterface, fresh: Fresh[], ownPrefix: string, session: string) {
363 const plan = noRetirement()
364 const candidates = await candidatesFor($, fresh, ownPrefix)
365 if (fresh.length === 0 || candidates.length === 0) return plan
366 const prompt = [
367 `The session is dated ${session}.`,
368 '',
369 'NEW memories:',
370 ...fresh.map(m => `- new ${JSON.stringify(m.slug)}: ${clip(m.content, 400)}`),
371 '',
372 'EXISTING memories:',
373 ...candidates.map(c => `- old ${JSON.stringify(c.id)}, recorded ${c.date}: ${clip(c.content.trim(), 400)}`),
374 ].join('\n')
375 const reply = await $.model.complete({ model: PLANNER, system: SUPERSEDE_SYSTEM, prompt, maxTokens: 1500, timeoutMs: 60 * 1000 })
376 if (!reply.isAnswered) return plan
377 const out = parseJSON<RetireReply>(reply.text)
378 if (!out) return plan
379
380 const slugs = fresh.map(m => m.slug)
381 const ids = candidates.map(c => c.id)
382 const byId = new Map(candidates.map(c => [c.id, c]))
383 for (const o of out.outdated ?? []) {
384 const slug = nameIn(o.new ?? '', slugs)
385 const later = byId.get(nameIn(o.by ?? '', ids))
386 if (slug && later && later.date > session) plan.outdated.add(slug)
387 }
388 let budget = MAX_SUPERSEDES
389 const retired = new Set<string>()
390 for (const r of out.retire ?? []) {
391 const slug = nameIn(r.new ?? '', slugs)
392 if (!slug || plan.outdated.has(slug)) continue
393 const chosen = strings(r.old, CANDIDATE_LIMIT)
394 .map(said => nameIn(said, ids))
395 .filter(id => {
396 const c = byId.get(id)
397 if (!c || retired.has(id) || c.date > session || budget === 0) return false
398 retired.add(id)
399 budget -= 1
400 return true
401 })
402 if (chosen.length === 0) continue
403 plan.retire.set(slug, [...(plan.retire.get(slug) ?? []), ...chosen])
404 if (r.reason?.trim()) plan.reason.set(slug, clip(r.reason.trim(), 300))
405 }
406 return plan
407}
408
409type Message = { role: string; text: string; toolUses: readonly unknown[] }
410
411const keyOf = (m: Message) => `${m.role}:${m.toolUses.length}:${m.text.slice(0, 160)}`
412
413async function capture($: EngineInterface) {
414 if (isCapturing || (await sentinel($, 'autocapture'))?.startsWith('off')) return
415 isCapturing = true
416 try {
417 const sessionId = await $.session.id()
418 const history = await $.session.messages()
419 if (!Array.isArray(history) || history.length === 0) return
420 const kept = await read($, captureMark)
421 const mark: CaptureMark = kept?.sessionId === sessionId ? kept : { sessionId, lastKey: '', memories: [] }
422 const from = mark.lastKey === '' ? -1 : history.map(keyOf).lastIndexOf(mark.lastKey)
423 const fresh = history.slice(from + 1)
424
425 const { digest, userTurns } = digestOf(fresh)
426 if (userTurns < CAPTURE_MIN_USER_TURNS) return
427
428 const already = mark.memories.map(m => `- ${m.slug}: ${clip(m.content, 300)}`).join('\n')
429 const reply = await $.model.complete({
430 model: PLANNER,
431 system: CAPTURE_SYSTEM,
432 prompt: `Today is ${today()}.\n\nAlready captured from this session:\n${already || '(none)'}\n\nTranscript not yet captured:\n${digest}`,
433 maxTokens: 3000,
434 timeoutMs: 90 * 1000,
435 })
436 if (!reply.isAnswered) return
437 const out = parseJSON<CapturedReply>(reply.text)
438 if (!out) return
439
440 // Stable per session and slug, as the shell capture's: capturing again
441 // replaces a memory instead of stacking a second copy.
442 const ownPrefix = `auto:${sessionId.slice(0, 8)}:`
443 const drafts: (Fresh & { importance?: number; type?: string })[] = []
444 for (const m of out.memories ?? []) {
445 const content = (m.content ?? '').trim()
446 const slug = slugOf(m.slug || content)
447 if (content === '' || slug === '') continue
448 const entities = (m.entities ?? [])
449 .map(entity => ({ name: (entity.name ?? '').trim(), type: entity.type ?? '' }))
450 .filter(entity => entity.name !== '')
451 drafts.push({ slug, content, entities, importance: m.importance, type: m.type })
452 }
453 // Failing here saves everything and retires nothing, as capture always did.
454 const plan = await planRetirement($, drafts, ownPrefix, today()).catch(noRetirement)
455
456 const written: CapturedMemory[] = []
457 let retiredCount = 0
458 for (const m of drafts) {
459 if (plan.outdated.has(m.slug)) continue
460 const supersedes = plan.retire.get(m.slug) ?? []
461 const reason = plan.reason.get(m.slug)
462 await callTool($, 'memory_save', {
463 memory_id: ownPrefix + m.slug,
464 scope: 'global',
465 content: m.content,
466 importance: Math.min(1, Math.max(0, m.importance ?? 0.5)),
467 metadata: {
468 source: 'auto-capture',
469 session: sessionId,
470 date: today(),
471 type: m.type || 'fact',
472 model: PLANNER,
473 ...(reason ? { supersede_reason: reason } : {}),
474 },
475 entities: m.entities,
476 ...(supersedes.length > 0 ? { supersedes } : {}),
477 })
478 written.push({ slug: m.slug, content: m.content })
479 retiredCount += supersedes.length
480 }
481
482 const end = fresh.at(-1)
483 const memories = [...mark.memories.filter(m => !written.some(w => w.slug === m.slug)), ...written]
484 await update($, captureMark, () => ({ sessionId, lastKey: end ? keyOf(end) : mark.lastKey, memories }))
485 if (written.length > 0) $.ui.toast(t().captured(written.length, retiredCount))
486 } catch (err) {
487 $.ui.log(`cortexdb-live: capture failed: ${messageOf(err)}`)
488 } finally {
489 isCapturing = false
490 }
491}
492
493function scheduleCapture($: EngineInterface) {
494 idleTimer?.cancel()
495 idleTimer = $.clock.after(CAPTURE_IDLE_MS, () => void capture($))
496}
497
498// --- hooks ------------------------------------------------------------------
499
500export const register: Register = (on, options) => {
501 on('session.start', async ($, e, next) => {
502 lang = await resolveLang($, options)
503 const remote = await $.env.get('CORTEXDB_REMOTE')
504 const path = await $.env.get('CORTEXDB_PATH')
505 brain = { where: remote ? remote.replace(/:\d+$/, '') : path ? path.split('/').pop() : undefined }
506 // The plugin's shell hooks stand down for a session this module answers.
507 await $.env.set('CORTEXDB_RECALL_BY_MOD', '1')
508 await $.env.set('CORTEXDB_CAPTURE_BY_MOD', '1')
509 await $.command.register({ name: SHOW_COMMAND, description: t().showCommand })
510 void refreshStats($)
511 $.clock.every(STATS_EVERY_MS, () => void refreshStats($))
512 return next(e)
513 })
514
515 // Hide lasts until the next recall; this brings back the one hidden now.
516 on('command.run', { command: SHOW_COMMAND }, async $ => {
517 const found = await read($, last)
518 if (!found || found.hits.length === 0) return { text: t().nothingShown }
519 await update($, isHidden, () => false)
520 await update($, isOpen, () => true)
521 return { text: t().shown }
522 })
523
524 on('classic.UserPromptSubmit', async ($, e, next) => {
525 const prompt = e.prompt.trim()
526 const asked =
527 prompt !== '' && (await sentinel($, 'autorecall'))?.startsWith('on')
528 ? Promise.race([
529 recall($, prompt).catch(err => {
530 brain = { ...brain, error: isDenied(err) ? t().denied(TOOLS_TO_ALLOW) : `${t().recallFailed}: ${messageOf(err)}` }
531 return undefined
532 }),
533 $.clock.sleep(RECALL_BUDGET_MS).then(() => undefined),
534 ])
535 : Promise.resolve(undefined)
536
537 const [result, found] = await Promise.all([next(e), asked])
538 if (!found) {
539 showStatus($)
540 return result
541 }
542
543 const others = (result.additionalContext ?? []).filter(text => !text.startsWith(SHELL_HEADER))
544 if (found === 'skip') {
545 brain = { ...brain, error: undefined, skipped: true }
546 showStatus($)
547 await update($, last, () => null)
548 return { ...result, additionalContext: others }
549 }
550
551 brain = { ...brain, error: undefined, skipped: false, recallMs: found.ms }
552 if (brain.nodes === undefined) void refreshStats($)
553 showStatus($)
554 await update($, last, () => found)
555 await update($, isHidden, () => false)
556 const mine = contextOf(found.hits)
557 return { ...result, additionalContext: mine ? [...others, mine] : others }
558 }).catch(($, e, next) => next(e))
559
560 on('turn.complete', ($, e, next) => {
561 if (e.agentId === undefined) scheduleCapture($)
562 return next(e)
563 })
564
565 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
566 const found = await read($, last)
567 if (e.props.hasSurvey || !found || found.hits.length === 0 || (await read($, isHidden))) {
568 return next(e)
569 }
570
571 const { Box, Button, Text } = $.ui.resolve(e)
572 const s = t()
573 const open = await read($, isOpen)
574 const memories = found.hits.filter(hit => hit.kind === 'memory').length
575 const knowledge = found.hits.length - memories
576 const plan = found.plan
577
578 return (
579 <Box flexDirection="column">
580 <Box flexDirection="row" gap={1}>
581 <Text>
582 {s.recalled(found.hits.length)}
583 <Text dimColor>{s.detail(memories, knowledge, (found.ms / 1000).toFixed(1))}</Text>
584 </Text>
585 <Button key="toggle" plain label={open ? s.collapse : s.expand} onPress={() => update($, isOpen, v => !v)} />
586 <Button key="hide" plain label={s.hide} onPress={() => update($, isHidden, () => true)} />
587 </Box>
588 {open && (
589 <Text wrap="truncate-end" dimColor>
590 {' '}
591 {s.query} ({plan.by}): {plan.keywords.join(' · ')}
592 {plan.entities.length > 0 ? ` | ${s.entities}: ${plan.entities.join(', ')}` : ''}
593 </Text>
594 )}
595 {open &&
596 found.hits.map(hit => (
597 <Text wrap="truncate-end" dimColor>
598 {' '}
599 {hit.kind === 'memory' ? s.memory : s.knowledge} <Text bold>{hit.title}</Text> {hit.snippet}
600 </Text>
601 ))}
602 </Box>
603 )
604 })
605}
606
607// --- text -------------------------------------------------------------------
608
609type RecallPayload = {
610 memories?: { memory?: { id?: string; content?: string; metadata?: { date?: unknown }; created_at?: string } }[]
611 knowledge?: { knowledge_id?: string; title?: string; snippet?: string }[]
612 results?: { knowledge_id?: string; title?: string; snippet?: string }[]
613}
614
615// The text the model reads: the same block the shell hook writes, so an agent
616// sees one thing whichever of the two answered.
617const contextOf = (hits: RecallHit[]) => {
618 if (hits.length === 0) return ''
619 const lines = hits.map(hit => `- ${hit.title}: ${hit.snippet}`)
620 return [
621 `${SHELL_HEADER} (retrieved automatically — verify before relying on them):`,
622 ...lines,
623 '(If this exchange states a durable preference, decision, or fact, save it with memory_save / knowledge_save.)',
624 ].join('\n')
625}
626
627// The shell capture's digestTranscript: what the person typed and what the
628// assistant said, injected machinery stripped, head kept and the tail favoured.
629const NOISE =
630 /<(system-reminder|local-command-caveat|command-name|command-message|command-args|local-command-stdout|task-notification)>[\s\S]*?<\/\1>|\[SYSTEM NOTIFICATION[^\]]*\][\s\S]*/g
631
632const digestOf = (messages: readonly Message[]) => {
633 const lines: string[] = []
634 let userTurns = 0
635 for (const m of messages) {
636 if (m.role === 'user') {
637 const text = m.text.replace(NOISE, '').trim()
638 if (text === '') continue
639 userTurns += 1
640 lines.push(`USER: ${clip(text, 2000)}`)
641 } else if (m.text.trim() !== '') {
642 lines.push(`ASSISTANT: ${clip(m.text, 1200)}`)
643 }
644 }
645 const head = 6000
646 const tail = 22000
647 const all = lines.join('\n')
648 const digest = all.length > head + tail ? `${all.slice(0, head)}\n[...trimmed...]\n${all.slice(-tail)}` : all
649 return { digest, userTurns }
650}
651
652// Models fence JSON on a whim and the odd reply loses its last closer.
653function parseJSON<T>(raw: string): T | undefined {
654 const start = raw.indexOf('{')
655 const end = raw.lastIndexOf('}')
656 if (start < 0) return undefined
657 const body = raw.slice(start, end > start ? end + 1 : undefined)
658 for (const candidate of [body, `${body}}`, `${body}]}`, `${body}}]}`]) {
659 try {
660 return JSON.parse(candidate) as T
661 } catch {
662 // try the next repair
663 }
664 }
665 return undefined
666}
667
668const strings = (list: unknown, max: number) =>
669 Array.isArray(list) ? list.filter((s): s is string => typeof s === 'string' && s.trim() !== '').slice(0, max) : []
670
671const snippetOf = (content: string) => clip(content.split(/\s+/).filter(Boolean).join(' '), SNIPPET)
672
673const clip = (text: string, max: number) => {
674 const chars = Array.from(text)
675 return chars.length <= max ? text : `${chars.slice(0, max).join('').trim()}…`
676}
677
678const slugOf = (text: string) =>
679 text
680 .toLowerCase()
681 .replace(/[^a-z0-9]+/g, '-')
682 .replace(/^-+|-+$/g, '')
683 .slice(0, 60)
684
685const today = () => new Date().toISOString().slice(0, 10)
686
687const messageOf = (err: unknown) => String((err as Error)?.message ?? err).slice(0, 80)
688
689// A call the module makes goes through the session's permission rules like
690// the model's, and a hook has no prompt to ask with: without an allow rule the
691// call is denied, which says nothing about the brain itself.
692const isConnecting = (err: unknown) => /no connected MCP tool/i.test(String((err as Error)?.message ?? err))
693
694const isDenied = (err: unknown) => /refused|denied|permission/i.test(String((err as Error)?.message ?? err))
695
696// The tools this module calls, as permissions.allow spells them.
697const TOOLS_TO_ALLOW = ['knowledge_memory_recall', 'graph_statistics', 'memory_save']
698 .map(tool => `mcp__plugin_cortexdb_cortexdb__${tool}`)
699 .join(', ')
700
701// The shell hook's keywordsFromPrompt: letter and digit runs, lowercased,
702// deduped, two characters or more; a CJK run stays one token.
703const keywordsOf = (prompt: string) => {
704 const seen = new Set<string>()
705 for (const word of prompt.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
706 if (Array.from(word).length >= 2) seen.add(word)
707 }
708 return [...seen]
709}
710types/index.d.ts 22 lines1export type RecallHit = { kind: 'memory' | 'knowledge'; title: string; snippet: string }
2
3export type RecallPlan = { by: 'haiku' | 'lexical'; keywords: string[]; entities: string[]; mode: string; ms: number }
4
5export type Recall = { hits: RecallHit[]; ms: number; plan: RecallPlan }
6
7export type CapturedMemory = { slug: string; content: string }
8
9/** How far this session has been captured: the last message read, and what it wrote. */
10export type CaptureMark = { sessionId: string; lastKey: string; memories: CapturedMemory[] }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'cortexdb-live': {
15 last: Recall | null
16 isOpen: boolean
17 isHidden: boolean
18 capture: CaptureMark | null
19 }
20 }
21}
22