替模型查 Graphiti 记忆:每轮开头按你的原话、运行中按第一次读写的文件、高风险命令和报错、派出子代理时按任务,调用服务端 recall,只把过了相关度线的事实和实体注入上下文。每次召回在对话里留一行灰字,写明查询词和是否真的随请求送到了模型。

替模型查 Graphiti 记忆,把相关的直接放进上下文;不相关就什么都不加。
source_node_uuid 作中心节点,改按图距离往外看。检索和相关度判断都在服务端的 recall 工具里(graphiti-mcp overlay 0026):事实 ≥ 0.6、实体 ≥ 0.9 才返回。同一条记忆一个会话只注入一次,模型自己用 search_* 查到过的也不再注入。
每次召回留一行灰字(模型读不到):
记忆召回 #r3 · 轮首 · 查询「graphiti 检索分数线 中心节点」 · 已注入 5 条事实、1 个实体
记忆召回 · 工具后 · Read OPS-NOTES.md · 查询「ops/OPS-NOTES.md OPS-NOTES ops」 · 无相关记忆,未注入
“已注入”只在看到这段内容真的随请求发给模型后才写;被别的插件拦下、没存进对话,都会照实写出来。
/recall 看状态,/recall off / /recall on 开关,/recall last 看最近几次注入了什么。状态行显示本会话的次数和条数。
和 ~/.claude/CLAUDE.md 同一套规则:.graphiti-group-id 文件 → repo_<slug> → life。仓库没有 origin、也没有那个文件时不猜,直接停用。
一次检索报错或超过 8 秒,暂停 5 分钟;连续 3 次失败,本会话停用。
graphiti,且服务端有 recall 工具(graphiti-mcp -p24 起)。claude plugin install graphiti-recall@alex-modshooks/graphiti-recall.ts 400 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 atLeast,
4 commandQuery,
5 errorQuery,
6 fileQuery,
7 idsIn,
8 isGroupId,
9 isRisky,
10 parseRecall,
11 parseToolText,
12 promptQuery,
13 type Recalled,
14 renderBlock,
15 renderDirectory,
16 repoSlug,
17 topicOf,
18 uuidsIn,
19} from './core.ts'
20
21// Searches Graphiti for the model through the server's `recall` tool and injects what clears its
22// relevance floor: beside the user's message, after some tool results, and into a subagent's task.
23// Each recall gets one dim transcript line (`$.ui.log`, which the model never reads) naming the
24// query and whether the block really reached the model's request.
25
26const SERVER = 'graphiti'
27const MIN_VERSION = '2.1.304'
28const TURN_WAIT_MS = 6_000 // the turn starts without the block after this; it rides on the next tool result
29const TOOL_GRACE_MS = 3_000 // how long a finished tool waits for its recall
30const SLOW_MS = 8_000 // a recall slower than this, or failing, pauses recall
31const PAUSE_MS = 5 * 60_000
32const MAX_FAILS = 3
33const MID_TURN_MAX = 3
34const BUDGET = { turn: 3_000, tool: 1_200, agent: 1_200 }
35const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit'])
36const QUIET_TOOLS = /^(mcp__graphiti__|mcp__ccd_|mcp__graphiti-recall__)|^(ToolSearch|TodoWrite|Skill|AskUserQuestion|EnterPlanMode|ExitPlanMode)$/
37
38type Where = 'turn' | 'tool' | 'agent'
39type Gate = {
40 id: string
41 where: Where
42 label: string
43 query: string
44 agentId?: string
45 stored: boolean
46 steps: number
47 done: boolean
48 summary: string
49 block: string
50}
51
52let cwd = ''
53let groupId: string | null = null
54let disabledWhy: string | null = null
55let enabled = true
56let pausedUntil = 0
57let fails = 0
58let seq = 0
59let directory: string | null = null
60let midTurn = 0
61let step = -1
62let lastInjectStep = -2
63let carried: { id: string; block: string; summary: string; label: string; query: string } | null = null
64let calls = 0
65let injectedItems = 0
66const seen = new Set<string>()
67const filesSeen = new Set<string>()
68const topics = new Map<string, string>() // topic → source_node_uuid of the last recall's top fact
69const gates = new Map<string, Gate>()
70const last: Gate[] = []
71
72const now = () => Date.now()
73const label = (where: Where, what: string) => `${{ turn: '轮首', tool: '工具后', agent: '子代理' }[where]}${what ? ` · ${what}` : ''}`
74
75function line($: EngineInterface, text: string) {
76 $.ui.log(`记忆召回 ${text}`)
77}
78
79function status($: EngineInterface) {
80 $.ui.status(enabled && !disabledWhy ? `记忆 ↻ ${calls} 次 / ${injectedItems} 条` : undefined)
81}
82
83async function partition($: EngineInterface, cwd: string): Promise<string | null> {
84 const git = async (...args: string[]) => {
85 const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 5_000 })
86 return r.exitCode === 0 ? r.stdout.trim() : null
87 }
88 const top = await git('rev-parse', '--show-toplevel')
89 const fileAt = async (dir: string) => {
90 const p = `${dir}/.graphiti-group-id`
91 return (await $.fs.exists(p)) ? (await $.fs.read(p)).trim() : null
92 }
93 if (top) {
94 const pinned = await fileAt(top)
95 if (pinned) return pinned
96 const origin = await git('remote', 'get-url', 'origin')
97 if (origin) return repoSlug(origin)
98 disabledWhy = '这个仓库没有 origin,按规则要先建 .graphiti-group-id'
99 return null
100 }
101 for (let dir = cwd; dir && dir !== '/'; dir = dir.slice(0, dir.lastIndexOf('/')) || '/') {
102 const pinned = await fileAt(dir)
103 if (pinned) return pinned
104 }
105 return 'life'
106}
107
108function ready(): boolean {
109 return enabled && !disabledWhy && groupId !== null && now() >= pausedUntil
110}
111
112async function recall($: EngineInterface, query: string, center?: string): Promise<Recalled | { error: string }> {
113 const t = now()
114 try {
115 const r = await $.mcp.call(SERVER, 'recall', {
116 query,
117 group_ids: [groupId],
118 max_facts: 8,
119 max_nodes: 3,
120 ...(center ? { center_node_uuid: center } : {}),
121 })
122 const text = r.content.map(b => ('text' in b && typeof b.text === 'string' ? b.text : '')).join('')
123 const out = r.isError ? { error: text.slice(0, 160) || 'error' } : parseRecall(text)
124 const slow = now() - t > SLOW_MS
125 if ('error' in out || slow) fail($, 'error' in out ? out.error : `检索用了 ${Math.round((now() - t) / 1000)} 秒`)
126 else fails = 0
127 return out
128 } catch (err) {
129 fail($, String(err).slice(0, 160))
130 return { error: String(err).slice(0, 160) }
131 }
132}
133
134function fail($: EngineInterface, why: string) {
135 fails += 1
136 pausedUntil = now() + PAUSE_MS
137 if (fails >= MAX_FAILS) {
138 disabledWhy = `连续 ${fails} 次失败(${why})`
139 line($, `· 本会话已停用:${disabledWhy}`)
140 } else line($, `· 暂停 5 分钟:${why}`)
141 status($)
142}
143
144// Drops what this session already injected or the model already read in its own searches.
145function fresh(r: Recalled): Recalled {
146 return { facts: r.facts.filter(f => !seen.has(f.uuid)), nodes: r.nodes.filter(n => !seen.has(n.uuid)) }
147}
148
149function prepare(where: Where, what: string, query: string, r: Recalled, agentId?: string): Gate | null {
150 const kept = fresh(r)
151 if (!kept.facts.length && !kept.nodes.length) return null
152 const id = `r${++seq}`
153 const block = renderBlock(id, groupId as string, query, kept, BUDGET[where])
154 const shown = { facts: kept.facts.filter(f => block.includes(f.fact)), nodes: kept.nodes.filter(n => block.includes(`- ${n.name}`)) }
155 for (const x of [...shown.facts, ...shown.nodes]) seen.add(x.uuid)
156 const summary = `${shown.facts.length} 条事实${shown.nodes.length ? `、${shown.nodes.length} 个实体` : ''}`
157 const gate: Gate = { id, where, label: label(where, what), query, agentId, stored: false, steps: 0, done: false, summary, block }
158 gates.set(id, gate)
159 last.push(gate)
160 if (last.length > 20) last.shift()
161 return gate
162}
163
164function settle($: EngineInterface, gate: Gate, verdict: string, injected: boolean) {
165 if (gate.done) return
166 gate.done = true
167 if (injected) injectedItems += Number(gate.summary.match(/\d+/g)?.reduce((a, b) => a + Number(b), 0) ?? 0)
168 line($, `#${gate.id} · ${gate.label} · 查询「${gate.query.slice(0, 60)}」 · ${verdict}`)
169 status($)
170}
171
172function nothing($: EngineInterface, where: Where, what: string, query: string, r: Recalled | { error: string }) {
173 const verdict = 'error' in r ? `检索失败:${r.error.slice(0, 80)}` : '无相关记忆,未注入'
174 line($, `· ${label(where, what)} · 查询「${query.slice(0, 60)}」 · ${verdict}`)
175}
176
177function topFact(r: Recalled | { error: string }) {
178 return 'error' in r ? undefined : r.facts[0]?.source_node_uuid
179}
180
181export const register: Register = on => {
182 on('session.start', async ($, e, next) => {
183 const r = await next(e)
184 cwd = e.cwd
185 const version = (await $.session.version()).version
186 if (!atLeast(version, MIN_VERSION)) {
187 disabledWhy = `Claude Code ${version} 低于 ${MIN_VERSION}`
188 $.ui.toast(`graphiti-recall 未启用:${disabledWhy}`)
189 return r
190 }
191 await $.command.register({ name: 'recall', description: '记忆召回:on / off / last(最近几次注入了什么)', argumentHint: 'on|off|last' })
192 try {
193 const id = await partition($, e.cwd)
194 if (id && isGroupId(id)) groupId = id
195 else if (!disabledWhy) disabledWhy = `分区判定得到不合法的 id「${id}」`
196 } catch (err) {
197 disabledWhy = `分区判定失败:${String(err).slice(0, 120)}`
198 }
199 if (disabledWhy) {
200 line($, `· 未启用:${disabledWhy}`)
201 return r
202 }
203 try {
204 const res = await $.mcp.call(SERVER, 'get_episodes', { group_ids: [groupId], max_episodes: 20 })
205 const text = res.content.map(b => ('text' in b && typeof b.text === 'string' ? b.text : '')).join('')
206 const body = parseToolText(text) as { episodes?: { name: string; valid_at?: string; created_at?: string }[] } | null
207 const eps = (body?.episodes ?? []).map(x => ({ name: x.name, at: x.valid_at ?? x.created_at }))
208 if (eps.length) directory = renderDirectory(groupId as string, eps)
209 } catch {
210 // the first recall reports an unreachable server
211 }
212 status($)
213 return r
214 })
215
216 on('command.run', { command: 'recall' }, async ($, e) => {
217 const arg = e.args.trim()
218 if (arg === 'off') enabled = false
219 if (arg === 'on') {
220 enabled = true
221 pausedUntil = 0
222 }
223 status($)
224 if (arg === 'last') {
225 const text = last.length
226 ? last.slice(-5).map(g => `#${g.id} ${g.label} 「${g.query.slice(0, 60)}」\n${g.block}`).join('\n\n')
227 : '本会话还没有注入过记忆。'
228 return { text }
229 }
230 const state = disabledWhy ? `未启用:${disabledWhy}` : enabled ? `开启(group_id=${groupId})` : '已关闭'
231 return { text: `记忆召回:${state}。本会话 ${calls} 次检索,注入 ${injectedItems} 条。` }
232 })
233
234 on('prompt.submit', async ($, e, next) => {
235 midTurn = 0
236 if (e.command || e.isMeta) return next(e)
237 const context = [...(e.context ?? [])]
238 if (directory) {
239 context.push(directory)
240 directory = null
241 }
242 const query = ready() ? promptQuery(e.text) : null
243 if (!query) return context.length === (e.context ?? []).length ? next(e) : next({ ...e, context })
244 calls += 1
245 const topic = topicOf(query)
246 const pending = recall($, query, topic ? topics.get(topic) : undefined)
247 const early = await Promise.race([pending, $.clock.sleep(TURN_WAIT_MS).then(() => null)])
248 if (early === null) {
249 // Too slow for the turn's start: it goes on the first tool result it is ready for.
250 pending.then(r => {
251 if (topic && topFact(r)) topics.set(topic, topFact(r) as string)
252 if ('error' in r) return nothing($, 'turn', '', query, r)
253 const gate = prepare('turn', '超时后补上', query, r)
254 if (!gate) return nothing($, 'turn', '', query, r)
255 carried = { id: gate.id, block: gate.block, summary: gate.summary, label: gate.label, query }
256 })
257 line($, `· 轮首 · 查询「${query.slice(0, 60)}」 · 6 秒内未返回,结果到了再附在下一个工具结果后`)
258 return next({ ...e, context })
259 }
260 if (topic && topFact(early)) topics.set(topic, topFact(early) as string)
261 const gate = 'error' in early ? null : prepare('turn', '', query, early)
262 if (!gate) {
263 nothing($, 'turn', '', query, early)
264 return next({ ...e, context })
265 }
266 const r = await next({ ...e, context: [...context, gate.block] })
267 if (r.drop !== undefined) settle($, gate, '未注入:消息被拦下', false)
268 return r
269 }).catch(($, e, next) => next(e))
270
271 on('tool.call', async ($, e, next) => {
272 const tool = e.tool
273 // The model's own searches: what it read there is not injected again.
274 if (/^mcp__graphiti__search_/.test(tool)) {
275 const ran = await next(e)
276 if (ran.deny === undefined && ran.text) for (const u of uuidsIn(ran.text)) seen.add(u)
277 return ran
278 }
279 if (QUIET_TOOLS.test(tool) || e.agentId !== undefined || !ready()) return attachCarried(await next(e))
280
281 let what = ''
282 let query: string | null = null
283 const input = e as unknown as { file_path?: string; notebook_path?: string; command?: string }
284 const path = input.file_path ?? input.notebook_path
285 if (FILE_TOOLS.has(tool) && path && !filesSeen.has(path)) {
286 filesSeen.add(path)
287 query = fileQuery(path, cwd)
288 what = `${tool} ${path.split('/').at(-1)}`
289 } else if (tool === 'Bash' && input.command && isRisky(input.command)) {
290 query = commandQuery(input.command)
291 what = 'Bash'
292 }
293 const budgetLeft = midTurn < MID_TURN_MAX && lastInjectStep !== step
294 const topic = path ?? (query ? topicOf(query) : null)
295 const pending = query && budgetLeft ? recall($, query, topic ? topics.get(topic) : undefined) : null
296 if (pending) calls += 1
297
298 const ran = await next(e)
299 if (ran.deny !== undefined) return ran
300
301 // A tool that failed: search with its error unless something is already on the way.
302 let errorPending: Promise<Recalled | { error: string }> | null = null
303 let errorQ: string | null = null
304 if (!pending && ran.isError && budgetLeft && ran.text) {
305 errorQ = errorQuery(ran.text)
306 if (errorQ) {
307 calls += 1
308 what = `${tool} 报错`
309 errorPending = recall($, errorQ)
310 }
311 }
312 const waiting = pending ?? errorPending
313 const q = (pending ? query : errorQ) as string
314 if (!waiting) return attachCarried(ran)
315 const got = await Promise.race([waiting, $.clock.sleep(errorPending ? TURN_WAIT_MS : TOOL_GRACE_MS).then(() => null)])
316 if (got === null) {
317 line($, `· 工具后 · ${what} · 查询「${q.slice(0, 60)}」 · 超时,未注入`)
318 return attachCarried(ran)
319 }
320 if (topic && topFact(got)) topics.set(topic, topFact(got) as string)
321 const gate = 'error' in got ? null : prepare('tool', what, q, got)
322 if (!gate) {
323 nothing($, 'tool', what, q, got)
324 return attachCarried(ran)
325 }
326 midTurn += 1
327 lastInjectStep = step
328 return attachCarried({ ...ran, context: [...(ran.context ?? []), gate.block] })
329 }).catch(($, e, next) => next(e))
330
331 function attachCarried<T extends { context?: readonly string[] }>(ran: T): T {
332 if (!carried) return ran
333 const block = carried.block
334 carried = null
335 return { ...ran, context: [...(ran.context ?? []), block] }
336 }
337
338 on('agent.spawn', async ($, e, next) => {
339 if (!ready()) return next(e)
340 const query = promptQuery(`${e.description} ${e.prompt.slice(0, 300)}`)
341 if (!query) return next(e)
342 calls += 1
343 const got = await Promise.race([recall($, query), $.clock.sleep(TURN_WAIT_MS).then(() => null)])
344 const gate = got && !('error' in got) ? prepare('agent', e.description, query, got) : null
345 if (!gate) {
346 if (got) nothing($, 'agent', e.description, query, got)
347 else line($, `· 子代理 · ${e.description} · 查询「${query.slice(0, 60)}」 · 超时,未注入`)
348 return next(e)
349 }
350 const r = await next({ ...e, prompt: `${e.prompt}\n\n${gate.block}` })
351 if (r.deny === undefined && r.agentId) gate.agentId = r.agentId
352 else settle($, gate, '未注入:子代理没有启动', false)
353 return r
354 }).catch(($, e, next) => next(e))
355
356 // Gate 2: the block is stored in the conversation. A compaction starts the bookkeeping over.
357 on('session.append', async ($, e, next) => {
358 const r = await next(e)
359 if (e.door === 'compaction') {
360 seen.clear()
361 filesSeen.clear()
362 }
363 const text = JSON.stringify(e.message.content)
364 for (const id of idsIn(text)) {
365 const gate = gates.get(id)
366 if (gate && !gate.stored && (e.door === 'hook-context' || e.door === 'prompt')) gate.stored = true
367 }
368 return r
369 }).catch(($, e, next) => next(e))
370
371 // Gate 3: the block rides in a request the model receives, kept by every hook beneath.
372 on('prompt.attachment', async ($, e, next) => {
373 const r = await next(e)
374 for (const id of idsIn(e.text)) {
375 const gate = gates.get(id)
376 if (!gate || gate.done || e.origin.kind !== 'plugin') continue
377 const kept = r.text !== null && r.text.includes(`#${id}`)
378 settle($, gate, kept ? `已注入 ${gate.summary}` : '未注入:被拦下', kept)
379 }
380 return r
381 })
382
383 // Gate 4: a request went out. A subagent's task is its first prompt, so its first step settles
384 // it; a block seen stored but never sent within two steps of its loop is reported unconfirmed.
385 on('turn.step', async function* ($, e, next) {
386 if (e.agentId === undefined) step = e.index
387 for (const gate of gates.values()) {
388 if (gate.done) continue
389 if (gate.where === 'agent') {
390 if (gate.agentId && gate.agentId === e.agentId && gate.stored) settle($, gate, `已注入子代理任务 ${gate.summary}`, true)
391 continue
392 }
393 if (e.agentId !== undefined) continue
394 gate.steps += 1
395 if (gate.steps >= 2) settle($, gate, gate.stored ? '未确认:已存进对话,没看到随请求发出' : '未确认:没看到存进对话', false)
396 }
397 return yield* next(e)
398 })
399}
400hooks/core.ts 167 lines1// Pure parts of graphiti-recall: the partition rule, what to search for, how a recall reads to the
2// model. No `$` here, so tests run them directly.
3
4export type Fact = {
5 uuid: string
6 fact: string
7 source_node_uuid?: string
8 valid_at?: string | null
9 expired_at?: string | null
10 score?: number | null
11}
12export type Entity = { uuid: string; name: string; summary?: string; score?: number | null }
13export type Recalled = { facts: Fact[]; nodes: Entity[] }
14
15const ID = /^[a-zA-Z0-9_-]+$/
16
17export const isGroupId = (id: string) => ID.test(id)
18
19// Rule 2 of the partition: origin URL host + path, lowercased, `.git` stripped, every run outside
20// a-z0-9 → `-`. `git@github.com:Acme/App.git` → `repo_github-com-acme-app`.
21export function repoSlug(origin: string): string {
22 const url = origin.trim().replace(/\.git$/i, '')
23 const scp = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(url)
24 let hostPath = url
25 if (scp) hostPath = `${scp[1]}/${scp[2]}`
26 else {
27 try {
28 const u = new URL(url)
29 hostPath = `${u.hostname}${u.pathname}`
30 } catch {
31 // a local path or something unusual: slug it as it is
32 }
33 }
34 return `repo_${hostPath.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`
35}
36
37const ACK = /^(ok|okay|k|好|好的|行|嗯|嗯嗯|对|是|是的|继续|可以|没问题|收到|谢谢|thanks|thank you|yes|y|go|👍)[\s。.!!~]*$/i
38const FENCE = /```[\s\S]*?```/g
39const PASTED = /<pasted_content[^>]*>[\s\S]*?<\/pasted_content[^>]*>/g
40
41// Identifiers worth searching for even inside pasted text or code: paths, ticket numbers,
42// `backticked` names, snake_case and CamelCase words.
43export function identifiers(text: string): string[] {
44 const found = new Set<string>()
45 for (const m of text.matchAll(/`([^`\n]{2,80})`/g)) found.add(m[1] as string)
46 for (const m of text.matchAll(/(?:~|\.{1,2})?\/?[\w.@-]+(?:\/[\w.@-]+)+/g)) found.add(m[0])
47 for (const m of text.matchAll(/#\d{1,6}\b/g)) found.add(m[0])
48 for (const m of text.matchAll(/\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b|\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)+\b/g)) found.add(m[0])
49 return [...found].slice(0, 12)
50}
51
52// What a user message is searched with: its own words; code and pasted blocks give only their
53// identifiers. Null for a message that needs no recall (an acknowledgement, nothing left).
54export function promptQuery(text: string): string | null {
55 const t = text.trim()
56 if (!t || ACK.test(t)) return null
57 const quoted = [...t.matchAll(FENCE), ...t.matchAll(PASTED)].map(m => m[0]).join('\n')
58 const body = t.replace(FENCE, ' ').replace(PASTED, ' ').replace(/\s+/g, ' ').trim()
59 const ids = identifiers(quoted).filter(id => !body.includes(id))
60 const head = body.length > 300 ? body.slice(0, 300) : body
61 const q = [head, ...ids].filter(Boolean).join(' ').slice(0, 400).trim()
62 return q.length >= 2 ? q : null
63}
64
65const RISKY =
66 /\b(ssh|scp|rsync|docker|kubectl|terraform|up\.sh|deploy)\b|\bgit\s+(commit|push|merge|rebase|reset)\b|\bgh\s+(pr|issue|release)\s+(create|merge|edit|close)\b|\brm\s+-[a-zA-Z]*[rf]/
67
68export const isRisky = (command: string) => RISKY.test(command)
69
70const SECRET = /((?:token|key|secret|passw(?:or)?d|auth)[\w-]*\s*[=:]\s*)\S+/gi
71const LONG = /[A-Za-z0-9+/_=-]{32,}/g
72
73export function commandQuery(command: string): string {
74 return command.replace(SECRET, '$1***').replace(LONG, '***').replace(/\s+/g, ' ').trim().slice(0, 120)
75}
76
77export function errorQuery(text: string): string | null {
78 const line = text.split('\n').map(l => l.trim()).find(l => l.length > 3)
79 return line ? line.slice(0, 200) : null
80}
81
82const SKIP_PATH = /(^|\/)(node_modules|\.git|\.claude-plugin\/types)(\/|$)|^\/private\/tmp\/|^\/tmp\/|\/\.claude\/plugins\//
83
84// A file the session reads or edits for the first time: its path relative to the project, its
85// name, its folder. Null for files no memory is about (dependencies, scratch, caches).
86export function fileQuery(path: string, cwd: string): string | null {
87 if (SKIP_PATH.test(path)) return null
88 const rel = path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
89 const parts = rel.split('/')
90 const name = parts.at(-1) ?? rel
91 const dir = parts.at(-2)
92 return [rel, name.replace(/\.[^.]+$/, ''), dir].filter(Boolean).join(' ')
93}
94
95// The topic a later recall can follow up on with a center node: a file, else the first ticket or
96// identifier of the query.
97export function topicOf(query: string): string | null {
98 return identifiers(query)[0] ?? null
99}
100
101export function parseToolText(text: string): unknown {
102 try {
103 const body = JSON.parse(text) as Record<string, unknown>
104 return 'result' in body && body.result && typeof body.result === 'object' ? body.result : body
105 } catch {
106 return null
107 }
108}
109
110export function parseRecall(text: string): Recalled | { error: string } {
111 const body = parseToolText(text) as Record<string, unknown> | null
112 if (!body) return { error: `unreadable reply: ${text.slice(0, 120)}` }
113 if (typeof body.error === 'string') return { error: body.error }
114 const facts = Array.isArray(body.facts) ? (body.facts as Fact[]) : []
115 const nodes = Array.isArray(body.nodes) ? (body.nodes as Entity[]) : []
116 return { facts, nodes }
117}
118
119const day = (iso?: string | null) => (iso ? iso.slice(0, 10) : '')
120
121// The block the model reads. Every line is whole; lines that would pass `budget` characters are
122// left out, facts before entities.
123export function renderBlock(id: string, groupId: string, query: string, r: Recalled, budget: number): string {
124 const head = `[graphiti-recall #${id} · group_id=${groupId} · 查询:${query.slice(0, 120)}]`
125 const lines = [head, '自动召回的记忆,按相关度排列(已作废的标出,保留作历史):']
126 let used = lines.join('\n').length
127 const push = (line: string) => {
128 if (used + line.length + 1 > budget) return false
129 lines.push(line)
130 used += line.length + 1
131 return true
132 }
133 for (const f of r.facts) {
134 const when = day(f.valid_at)
135 const gone = f.expired_at ? ` [已作废 ${day(f.expired_at)}]` : ''
136 if (!push(`- ${f.fact}${when ? `(${when})` : ''}${gone}`)) break
137 }
138 if (r.nodes.length && push('实体:')) {
139 for (const n of r.nodes) {
140 const summary = (n.summary ?? '').split('\n')[0]?.slice(0, 160) ?? ''
141 if (!push(`- ${n.name}${summary ? ` — ${summary}` : ''}`)) break
142 }
143 }
144 return lines.join('\n')
145}
146
147export function renderDirectory(groupId: string, episodes: { name: string; at?: string }[]): string {
148 const lines = [`[graphiti-recall 目录 · group_id=${groupId}] 近期记忆(标题与日期,内容需要时再查):`]
149 for (const e of episodes) lines.push(`- ${day(e.at)} ${e.name}`)
150 return lines.join('\n')
151}
152
153// `a` at least `b`, both dotted version numbers.
154export function atLeast(a: string, b: string): boolean {
155 const x = a.split('.').map(Number)
156 const y = b.split('.').map(Number)
157 for (let i = 0; i < Math.max(x.length, y.length); i++) {
158 const d = (x[i] ?? 0) - (y[i] ?? 0)
159 if (d !== 0) return d > 0
160 }
161 return true
162}
163
164export const MARK = /graphiti-recall #(r\d+)/g
165export const idsIn = (text: string) => [...text.matchAll(MARK)].map(m => m[1] as string)
166export const uuidsIn = (text: string) => [...text.matchAll(/"uuid"\s*:\s*"([0-9a-f-]{36})"/g)].map(m => m[1] as string)
167