local-agentic-ai-mem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serve = serve;
4
+ /**
5
+ * MCP server, speaking stdio and reading the local SQLite file directly.
6
+ *
7
+ * The hosted version shipped a shim that forwarded every call to an API. This
8
+ * is the server: no network hop, no API key, no account. That removes the
9
+ * whole class of credential problems along with roughly 200ms of round trip.
10
+ *
11
+ * Design note on the tool surface. Tools are *pull* — the model has to choose
12
+ * to call one, which is precisely how memory gets ignored. So the primary
13
+ * path is the hooks, which *push* context in unconditionally, and the tools
14
+ * here are deliberately few:
15
+ *
16
+ * recall_memory an escape hatch for an explicit "what did we decide about
17
+ * X?" that the automatic injection did not cover
18
+ * save_memory a deliberate act, which genuinely wants to be a tool
19
+ *
20
+ * `get_preferences` is gone. Project facts arrive at SessionStart whether or
21
+ * not anything asks for them, and every redundant tool is another chance to
22
+ * spend a turn fetching something the model already has. The same facts are
23
+ * also exposed as an MCP *resource*, which a client can attach without the
24
+ * model deciding anything.
25
+ */
26
+ const path_1 = require("path");
27
+ const fs_1 = require("fs");
28
+ const child_process_1 = require("child_process");
29
+ const db_1 = require("../db");
30
+ const embed_1 = require("../lib/embed");
31
+ const recall_1 = require("../lib/recall");
32
+ const redact_1 = require("../lib/redact");
33
+ const compose_1 = require("../lib/compose");
34
+ const SUPPORTED_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
35
+ const TOOLS = [
36
+ {
37
+ name: "recall_memory",
38
+ description: "Look up past work: decisions, what was tried and abandoned, and context that is not in the code. Relevant memory is injected automatically at session start and on each prompt, so use this only when you need something specific that was not already provided.",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ query: { type: "string", description: "Topic, error message, file, or decision to look for" },
43
+ limit: { type: "number", description: "Results to return (default 5, max 20)" },
44
+ scope: {
45
+ type: "string",
46
+ enum: ["auto", "project", "all"],
47
+ description: "auto (default) ranks this project higher but still returns matches elsewhere.",
48
+ },
49
+ },
50
+ required: ["query"],
51
+ },
52
+ },
53
+ {
54
+ name: "save_memory",
55
+ description: "Record something that should still be true next session — a decision and its reasoning, an approach that failed and why, or context a human gave you that is not in the code.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: { note: { type: "string", description: "What to remember" } },
59
+ required: ["note"],
60
+ },
61
+ },
62
+ ];
63
+ function projectSlug() {
64
+ return process.env.AGENTIC_MEMORY_PROJECT || (0, path_1.basename)(process.cwd());
65
+ }
66
+ /**
67
+ * A memory is only trustworthy if the model can tell fresh from stale. Without
68
+ * this signal it has to distrust everything equally, which is the same as
69
+ * ignoring it.
70
+ */
71
+ function freshness(files, anchorSha) {
72
+ if (!anchorSha || files.length === 0)
73
+ return null;
74
+ if (!(0, fs_1.existsSync)(".git"))
75
+ return null;
76
+ try {
77
+ for (const f of files.slice(0, 4)) {
78
+ if (!(0, fs_1.existsSync)(f))
79
+ continue;
80
+ const current = (0, child_process_1.execFileSync)("git", ["log", "-1", "--format=%H", "--", f], {
81
+ encoding: "utf8",
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ timeout: 1500,
84
+ }).trim();
85
+ if (current && current !== anchorSha)
86
+ return "file changed since — verify before relying on this";
87
+ }
88
+ return "still current";
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function respond(id, result) {
95
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
96
+ }
97
+ function respondError(id, message, code = -32603) {
98
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
99
+ }
100
+ function textResult(payload) {
101
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
102
+ }
103
+ async function callTool(name, args) {
104
+ const slug = projectSlug();
105
+ const projectId = (0, db_1.upsertProject)(slug, process.cwd());
106
+ if (name === "recall_memory") {
107
+ const query = String(args?.query ?? "").trim();
108
+ if (!query)
109
+ throw new Error("query is required");
110
+ const limit = Math.min(Math.max(Number(args?.limit) || 5, 1), 20);
111
+ const scope = args?.scope === "project" || args?.scope === "all" ? args.scope : "auto";
112
+ const vec = await (0, embed_1.embed)(query);
113
+ const results = (0, recall_1.recall)({
114
+ queryText: query,
115
+ queryVec: vec,
116
+ projectId: scope === "all" ? undefined : projectId,
117
+ limit,
118
+ });
119
+ const scoped = scope === "project" ? results.filter((r) => r.projectId === projectId) : results;
120
+ return textResult(scoped.map((r) => ({
121
+ date: r.date.slice(0, 10),
122
+ project: r.project,
123
+ memory: r.text,
124
+ decisions: r.decisions.slice(0, 6),
125
+ files: r.files.slice(0, 10),
126
+ confidence: r.confidence,
127
+ ...(freshness(r.files, r.anchorSha) ? { freshness: freshness(r.files, r.anchorSha) } : {}),
128
+ })));
129
+ }
130
+ if (name === "save_memory") {
131
+ const raw = String(args?.note ?? "").trim();
132
+ if (!raw)
133
+ throw new Error("note is required");
134
+ if (raw.length > 10_000)
135
+ throw new Error("note too long (max 10 000 chars)");
136
+ const note = (0, redact_1.redact)(raw);
137
+ const [vec, promptVec] = await Promise.all([(0, embed_1.embed)((0, compose_1.indexText)(null, note)), (0, embed_1.embed)(note)]);
138
+ (0, db_1.insertMemory)({
139
+ projectId,
140
+ prompt: note,
141
+ content: note,
142
+ decisions: [],
143
+ files: [],
144
+ toolCalls: [],
145
+ gitCommit: null,
146
+ anchorSha: null,
147
+ confidence: "stated",
148
+ // A deliberate note is tier 2 by definition: a human decided it mattered.
149
+ tier: 2,
150
+ startedAt: new Date().toISOString(),
151
+ sessionId: null,
152
+ vec,
153
+ promptVec,
154
+ });
155
+ return { content: [{ type: "text", text: "Saved." }] };
156
+ }
157
+ throw new Error(`Unknown tool: ${name}`);
158
+ }
159
+ function serve() {
160
+ (0, db_1.db)(process.cwd());
161
+ process.stdin.setEncoding("utf8");
162
+ let buffer = "";
163
+ process.stdin.on("data", async (chunk) => {
164
+ buffer += chunk;
165
+ const lines = buffer.split("\n");
166
+ buffer = lines.pop() || "";
167
+ for (const line of lines) {
168
+ if (!line.trim())
169
+ continue;
170
+ let msg;
171
+ try {
172
+ msg = JSON.parse(line);
173
+ }
174
+ catch {
175
+ continue;
176
+ }
177
+ // JSON-RPC notifications carry no id and must never be answered.
178
+ const isNotification = msg.id === undefined || msg.id === null;
179
+ try {
180
+ if (msg.method === "initialize") {
181
+ const asked = msg.params?.protocolVersion;
182
+ respond(msg.id, {
183
+ protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0],
184
+ capabilities: { tools: {}, resources: {} },
185
+ serverInfo: { name: "agentic-memory", version: "0.1.0" },
186
+ });
187
+ }
188
+ else if (msg.method === "ping") {
189
+ respond(msg.id, {});
190
+ }
191
+ else if (msg.method === "tools/list") {
192
+ respond(msg.id, { tools: TOOLS });
193
+ }
194
+ else if (msg.method === "resources/list") {
195
+ // Facts as a resource, so a client can attach them without the model
196
+ // choosing to call anything.
197
+ respond(msg.id, {
198
+ resources: [
199
+ {
200
+ uri: `agentic-memory://project/${projectSlug()}/facts`,
201
+ name: `What is true about ${projectSlug()}`,
202
+ description: "Established conventions, hot files and constraints for this project.",
203
+ mimeType: "text/markdown",
204
+ },
205
+ ],
206
+ });
207
+ }
208
+ else if (msg.method === "resources/read") {
209
+ const projectId = (0, db_1.upsertProject)(projectSlug(), process.cwd());
210
+ const facts = (0, db_1.getFacts)(projectId);
211
+ const body = facts.length
212
+ ? facts.map((f) => `- [${f.kind}] ${f.fact}`).join("\n")
213
+ : "_No established facts for this project yet._";
214
+ respond(msg.id, {
215
+ contents: [
216
+ {
217
+ uri: String(msg.params?.uri ?? ""),
218
+ mimeType: "text/markdown",
219
+ text: `# What is true about ${projectSlug()}\n\n${body}\n`,
220
+ },
221
+ ],
222
+ });
223
+ }
224
+ else if (msg.method === "tools/call") {
225
+ const res = await callTool(msg.params?.name, msg.params?.arguments ?? {});
226
+ respond(msg.id, res);
227
+ }
228
+ else if (!isNotification) {
229
+ respondError(msg.id, `Method not found: ${msg.method}`, -32601);
230
+ }
231
+ }
232
+ catch (e) {
233
+ if (!isNotification)
234
+ respondError(msg.id, e?.message ?? String(e));
235
+ }
236
+ }
237
+ });
238
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "local-agentic-ai-mem",
3
+ "version": "0.1.0",
4
+ "description": "Local-first memory for Claude Code. Your code and your memory never leave your machine.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "agentic-memory": "dist/index.js",
9
+ "local-agentic-ai-mem": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "templates",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "vitest run",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "claude",
26
+ "claude-code",
27
+ "mcp",
28
+ "memory",
29
+ "local-first",
30
+ "sqlite"
31
+ ],
32
+ "dependencies": {
33
+ "better-sqlite3": "^11.0.0",
34
+ "fastembed": "^1.14.4"
35
+ },
36
+ "devDependencies": {
37
+ "@types/better-sqlite3": "^7.6.0",
38
+ "@types/node": "^22.0.0",
39
+ "typescript": "^5.0.0",
40
+ "vitest": "^3.2.4"
41
+ }
42
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // agentic-memory — PostToolUse. Records what the turn actually did.
3
+ import { readFileSync, writeFileSync, existsSync } from 'fs'
4
+ import { homedir } from 'os'
5
+ import { join } from 'path'
6
+
7
+ async function main() {
8
+ let raw = ''
9
+ process.stdin.setEncoding('utf8')
10
+ for await (const c of process.stdin) raw += c
11
+ let p = {}
12
+ try { p = JSON.parse(raw) } catch { return }
13
+ if (!p.session_id || !p.tool_name) return
14
+
15
+ const file = join(homedir(), '.agentic-memory', 'turns', p.session_id + '.json')
16
+ if (!existsSync(file)) return
17
+ try {
18
+ const turn = JSON.parse(readFileSync(file, 'utf8'))
19
+ const input = p.tool_input || {}
20
+ const target = input.file_path || input.path || input.command || input.pattern || ''
21
+ turn.tool_calls.push({ tool: p.tool_name, target: String(target).slice(0, 300) })
22
+ if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(p.tool_name) && input.file_path) {
23
+ if (!turn.files.includes(input.file_path)) turn.files.push(input.file_path)
24
+ }
25
+ if (turn.tool_calls.length > 400) turn.tool_calls = turn.tool_calls.slice(-400)
26
+ writeFileSync(file, JSON.stringify(turn))
27
+ } catch {}
28
+ }
29
+ main().catch(() => {})
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // agentic-memory — SessionStart.
3
+ //
4
+ // Injects tier 1: the small set of things known to be true about this project.
5
+ // Always, whether or not anything asks. Tools are pull and get ignored; hooks
6
+ // are push. Measured at 14.5% coverage on held-out future work for under a
7
+ // kilobyte — more than searching the entire corpus achieved on its own.
8
+ import { createRequire } from 'module'
9
+ const require = createRequire(import.meta.url)
10
+ // Stamped at install time — these hooks run outside any node_modules tree.
11
+ const ROOT = '__AGENTIC_MEMORY_ROOT__'
12
+
13
+ async function main() {
14
+ let raw = ''
15
+ process.stdin.setEncoding('utf8')
16
+ for await (const c of process.stdin) raw += c
17
+ let payload = {}
18
+ try { payload = JSON.parse(raw) } catch {}
19
+ if (payload.source === 'clear' || payload.source === 'compact') return
20
+
21
+ const { db, upsertProject, getFacts, recordInjection } = require(ROOT + '/dist/db')
22
+ const { distinctiveTokens } = require(ROOT + '/dist/lib/compliance')
23
+ const { maintain } = require(ROOT + '/dist/lib/maintain')
24
+ const { basename } = require('path')
25
+
26
+ const cwd = payload.cwd || process.cwd()
27
+ db(cwd)
28
+ const slug = process.env.AGENTIC_MEMORY_PROJECT || basename(cwd)
29
+ const projectId = upsertProject(slug, cwd)
30
+
31
+ // Opportunistic maintenance — there is no cron on a laptop.
32
+ try { maintain() } catch {}
33
+
34
+ const facts = getFacts(projectId)
35
+ if (facts.length === 0) return
36
+
37
+ const lines = [`# What is true about ${slug}`, '']
38
+ for (const f of facts) {
39
+ lines.push(`- [${f.kind}] ${f.fact}${f.confidence === 'pushed' ? ' ✓' : ''}`)
40
+ }
41
+ lines.push('')
42
+ lines.push('These were derived from your own past sessions. Treat them as current')
43
+ lines.push('unless the code in front of you says otherwise.')
44
+
45
+ const text = lines.join('\n')
46
+
47
+ // Record what went in, so the Stop hook can ask what the session did with
48
+ // it. Without this the only thing ever measured is retrieval.
49
+ try {
50
+ const files = []
51
+ const pitfallFiles = []
52
+ for (const f of facts) {
53
+ let fs = []
54
+ try { fs = JSON.parse(f.files || '[]') } catch {}
55
+ for (const x of fs) {
56
+ if (!files.includes(x)) files.push(x)
57
+ if (f.kind === 'pitfall' && !pitfallFiles.includes(x)) pitfallFiles.push(x)
58
+ }
59
+ }
60
+ recordInjection({
61
+ sessionId: payload.session_id || 'unknown',
62
+ projectId, kind: 'facts', memoryIds: [],
63
+ files, tokens: distinctiveTokens(text, ''), pitfallFiles,
64
+ })
65
+ } catch {}
66
+
67
+ process.stdout.write(JSON.stringify({
68
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text },
69
+ }))
70
+ process.stderr.write(`\x1b[36m🧠 ${facts.length} project facts loaded for "${slug}"\x1b[0m\n`)
71
+ }
72
+ main().catch(() => {})
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // agentic-memory — Stop. Finalise the turn and store it, or discard it.
3
+ //
4
+ // The gate is what the turn produced, not how long the prompt was. A turn that
5
+ // changed nothing and concluded nothing is not a memory.
6
+ import { createRequire } from 'module'
7
+ import { readFileSync, existsSync, unlinkSync } from 'fs'
8
+ import { execFileSync } from 'child_process'
9
+ import { homedir } from 'os'
10
+ import { join, basename } from 'path'
11
+ const require = createRequire(import.meta.url)
12
+ // Stamped at install time — these hooks run outside any node_modules tree.
13
+ const ROOT = '__AGENTIC_MEMORY_ROOT__'
14
+
15
+ function git(args, cwd) {
16
+ try {
17
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim()
18
+ } catch { return '' }
19
+ }
20
+
21
+ async function main() {
22
+ let raw = ''
23
+ process.stdin.setEncoding('utf8')
24
+ for await (const c of process.stdin) raw += c
25
+ let p = {}
26
+ try { p = JSON.parse(raw) } catch { return }
27
+ if (!p.session_id || p.reason === 'clear' || p.reason === 'resume') return
28
+
29
+ const file = join(homedir(), '.agentic-memory', 'turns', p.session_id + '.json')
30
+ if (!existsSync(file)) return
31
+ let turn
32
+ try { turn = JSON.parse(readFileSync(file, 'utf8')) } catch { return }
33
+ unlinkSync(file)
34
+
35
+ const { db, upsertProject, insertMemory } = require(ROOT + '/dist/db')
36
+ const { embed } = require(ROOT + '/dist/lib/embed')
37
+ const { extractMemory } = require(ROOT + '/dist/lib/extract')
38
+ const { composeAtomContent, indexText } = require(ROOT + '/dist/lib/compose')
39
+ const { assignTier, gradeConfidence } = require(ROOT + '/dist/lib/tiers')
40
+ const { redact } = require(ROOT + '/dist/lib/redact')
41
+ const { openInjections, resolveInjection } = require(ROOT + '/dist/db')
42
+ const { readTranscript, scoreInjection } = require(ROOT + '/dist/lib/compliance')
43
+
44
+ const cwd = turn.cwd || p.cwd || process.cwd()
45
+
46
+ // Prefer the transcript — it holds Claude's own statements about what
47
+ // happened, which is where the durable knowledge is.
48
+ let extracted = null
49
+ let transcript = null
50
+ if (p.transcript_path && existsSync(p.transcript_path)) {
51
+ try {
52
+ transcript = readFileSync(p.transcript_path, 'utf8')
53
+ extracted = extractMemory(transcript)
54
+ } catch {}
55
+ }
56
+
57
+ // Close out whatever was injected this session. Done before the save-or-skip
58
+ // decision below, because a session that produced no memory still tells us
59
+ // whether the memory it was given got used.
60
+ if (transcript) {
61
+ try {
62
+ db(cwd)
63
+ const facts = readTranscript(transcript)
64
+ for (const inj of openInjections(p.session_id)) {
65
+ resolveInjection(inj.id, scoreInjection({
66
+ files: JSON.parse(inj.files || '[]'),
67
+ tokens: JSON.parse(inj.tokens || '[]'),
68
+ pitfallFiles: JSON.parse(inj.pitfall_files || '[]'),
69
+ }, facts))
70
+ }
71
+ } catch {}
72
+ }
73
+
74
+ const decisions = extracted?.decisions ?? []
75
+ const files = [...new Set([...(turn.files || []), ...(extracted?.files_touched ?? [])])]
76
+ const composed = extracted?.content
77
+ ? { content: extracted.content, decisions }
78
+ : composeAtomContent({ prompt: turn.prompt, decisions, files_touched: files, tool_calls: turn.tool_calls, git_commit: null })
79
+
80
+ if (!composed) {
81
+ process.stderr.write('\x1b[90m· nothing durable to remember\x1b[0m\n')
82
+ return
83
+ }
84
+
85
+ const head = git(['rev-parse', 'HEAD'], cwd)
86
+ const upstream = git(['rev-parse', '--abbrev-ref', '@{u}'], cwd)
87
+ const pushed = upstream ? git(['branch', '-r', '--contains', head], cwd) !== '' : false
88
+
89
+ const content = redact(composed.content)
90
+ const prompt = redact(turn.prompt)
91
+ const safeDecisions = (composed.decisions || []).map(redact)
92
+
93
+ const [vec, promptVec] = await Promise.all([embed(indexText(prompt, content)), embed(prompt)])
94
+
95
+ db(cwd)
96
+ const slug = process.env.AGENTIC_MEMORY_PROJECT || basename(cwd)
97
+ const projectId = upsertProject(slug, cwd)
98
+
99
+ const atom = { prompt, decisions: safeDecisions, files_touched: files, tool_calls: turn.tool_calls, git_commit: head || null }
100
+ insertMemory({
101
+ projectId, prompt, content,
102
+ decisions: safeDecisions, files, toolCalls: turn.tool_calls,
103
+ gitCommit: head || null, anchorSha: head || null,
104
+ confidence: gradeConfidence({ git_commit: head || null, git_pushed: pushed }),
105
+ tier: assignTier(atom),
106
+ startedAt: turn.started_at || new Date().toISOString(),
107
+ sessionId: p.session_id,
108
+ vec, promptVec,
109
+ })
110
+
111
+ process.stderr.write(`\x1b[36m🧠 remembered — ${content.slice(0, 64)}\x1b[0m\n`)
112
+ }
113
+ main().catch(() => {})
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // agentic-memory — UserPromptSubmit.
3
+ //
4
+ // Records the turn and injects relevant past work. This is the push path that
5
+ // replaces asking the model to call a search tool.
6
+ import { createRequire } from 'module'
7
+ import { writeFileSync, mkdirSync } from 'fs'
8
+ import { homedir } from 'os'
9
+ import { join, basename } from 'path'
10
+ const require = createRequire(import.meta.url)
11
+ // Stamped at install time — these hooks run outside any node_modules tree.
12
+ const ROOT = '__AGENTIC_MEMORY_ROOT__'
13
+
14
+ const FILLER = /^(thanks|thank you|ok|okay|yes|go)\.?\??$/i
15
+ const THRESHOLD = parseFloat(process.env.AGENTIC_MEMORY_THRESHOLD || '') || 0.35
16
+
17
+ async function main() {
18
+ let raw = ''
19
+ process.stdin.setEncoding('utf8')
20
+ for await (const c of process.stdin) raw += c
21
+ let payload = {}
22
+ try { payload = JSON.parse(raw) } catch { return }
23
+ const { session_id, prompt, cwd } = payload
24
+ if (!session_id || !prompt) return
25
+
26
+ // Open the turn so the Stop hook can finalise it.
27
+ const dir = join(homedir(), '.agentic-memory', 'turns')
28
+ mkdirSync(dir, { recursive: true })
29
+ writeFileSync(join(dir, session_id + '.json'), JSON.stringify({
30
+ prompt, cwd: cwd || process.cwd(), started_at: new Date().toISOString(), tool_calls: [], files: [],
31
+ }))
32
+
33
+ if (prompt.trim().length < 12 || FILLER.test(prompt.trim())) return
34
+
35
+ try {
36
+ const { db, upsertProject, recordInjection } = require(ROOT + '/dist/db')
37
+ const { distinctiveTokens } = require(ROOT + '/dist/lib/compliance')
38
+ const { embed } = require(ROOT + '/dist/lib/embed')
39
+ const { recall } = require(ROOT + '/dist/lib/recall')
40
+
41
+ db(cwd || process.cwd())
42
+ const projectId = upsertProject(process.env.AGENTIC_MEMORY_PROJECT || basename(cwd || process.cwd()), cwd)
43
+ const vec = await embed(prompt)
44
+ const hits = recall({ queryText: prompt, queryVec: vec, projectId, limit: 3 })
45
+ if (hits.length === 0 || hits[0].score < THRESHOLD * hits[0].score) return
46
+
47
+ const lines = ['<memory>', 'Relevant past work — use it, do not narrate it.', '']
48
+ for (const h of hits) {
49
+ lines.push(`• ${h.text}`)
50
+ if (h.files.length) lines.push(` files: ${h.files.slice(0, 4).join(', ')}`)
51
+ }
52
+ lines.push('')
53
+ lines.push('If the code in front of you disagrees, the code wins.')
54
+ lines.push('</memory>')
55
+
56
+ const text = lines.join('\n')
57
+ try {
58
+ const files = []
59
+ for (const h of hits) for (const f of (h.files || [])) if (!files.includes(f)) files.push(f)
60
+ recordInjection({
61
+ sessionId: session_id, projectId, kind: 'recall',
62
+ memoryIds: hits.map((h) => h.id), files,
63
+ tokens: distinctiveTokens(text, prompt), pitfallFiles: [],
64
+ })
65
+ } catch {}
66
+
67
+ process.stdout.write(JSON.stringify({
68
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text },
69
+ }))
70
+ } catch {}
71
+ }
72
+ main().catch(() => {})