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.
- package/README.md +74 -0
- package/dist/commands/import.js +232 -0
- package/dist/commands/install.js +112 -0
- package/dist/commands/status.js +29 -0
- package/dist/commands/uninstall-legacy.js +159 -0
- package/dist/db.js +359 -0
- package/dist/index.js +58 -0
- package/dist/lib/compliance.js +169 -0
- package/dist/lib/compose.js +130 -0
- package/dist/lib/conventions.js +78 -0
- package/dist/lib/embed.js +47 -0
- package/dist/lib/extract.js +297 -0
- package/dist/lib/maintain.js +81 -0
- package/dist/lib/recall.js +152 -0
- package/dist/lib/redact.js +64 -0
- package/dist/lib/tiers.js +405 -0
- package/dist/mcp/server.js +238 -0
- package/package.json +42 -0
- package/templates/hooks/post-tool-use.mjs +29 -0
- package/templates/hooks/session-start.mjs +72 -0
- package/templates/hooks/stop.mjs +113 -0
- package/templates/hooks/user-prompt-submit.mjs +72 -0
|
@@ -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(() => {})
|