local-agentic-ai-mem 0.1.0 → 0.2.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 +16 -0
- package/dist/commands/install.js +81 -8
- package/dist/commands/uninstall-legacy.js +29 -9
- package/dist/db.js +8 -1
- package/dist/lib/compliance.js +45 -2
- package/dist/lib/files.js +32 -0
- package/dist/lib/recall.js +42 -7
- package/dist/lib/redact.js +43 -2
- package/dist/mcp/server.js +11 -1
- package/package.json +1 -1
- package/templates/hooks/stop.mjs +14 -4
- package/templates/hooks/user-prompt-submit.mjs +19 -6
package/README.md
CHANGED
|
@@ -45,6 +45,22 @@ existed locally. A memory restated later, or explicitly replaced, is marked
|
|
|
45
45
|
superseded rather than deleted — a file changing does not mean the decision
|
|
46
46
|
behind it was wrong.
|
|
47
47
|
|
|
48
|
+
## What it injects — often nothing
|
|
49
|
+
|
|
50
|
+
Most prompts have no relevant past work, and the honest answer is to say
|
|
51
|
+
nothing. Recall applies an absolute similarity floor (`0.65`; override with
|
|
52
|
+
`AGENTIC_MEMORY_THRESHOLD`) rather than always returning its best few
|
|
53
|
+
candidates. On a real corpus, genuinely related queries score 0.75–0.81, an
|
|
54
|
+
off-topic question 0.48, and the band that reads as noise sits at 0.59–0.60.
|
|
55
|
+
|
|
56
|
+
An exact match on a path, SHA, symbol or error code is injected regardless of
|
|
57
|
+
that score — that is the case embeddings are worst at, and the reason there is
|
|
58
|
+
a lexical arm at all. Matching a common word is not, or the floor never fires.
|
|
59
|
+
|
|
60
|
+
Memories written by the session currently asking are excluded. They are the
|
|
61
|
+
model's own output on a round trip, they score a near-perfect match against the
|
|
62
|
+
prompt that produced them, and they outrank everything real.
|
|
63
|
+
|
|
48
64
|
## Where it lives
|
|
49
65
|
|
|
50
66
|
By default `~/.agentic-memory/memory.db`, a single SQLite file.
|
package/dist/commands/install.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveRuntimeRoot = resolveRuntimeRoot;
|
|
3
4
|
exports.installHooks = installHooks;
|
|
4
5
|
exports.registerSettings = registerSettings;
|
|
6
|
+
exports.registerMcp = registerMcp;
|
|
5
7
|
exports.install = install;
|
|
6
8
|
/**
|
|
7
9
|
* Install: write hooks, register the MCP server, create the database.
|
|
@@ -11,8 +13,10 @@ exports.install = install;
|
|
|
11
13
|
* ask — so registering them correctly matters more than the MCP entry.
|
|
12
14
|
*/
|
|
13
15
|
const fs_1 = require("fs");
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
14
17
|
const os_1 = require("os");
|
|
15
18
|
const path_1 = require("path");
|
|
19
|
+
const PKG = "local-agentic-ai-mem";
|
|
16
20
|
const db_1 = require("../db");
|
|
17
21
|
const uninstall_legacy_1 = require("./uninstall-legacy");
|
|
18
22
|
const HOOK_EVENTS = {
|
|
@@ -28,13 +32,45 @@ function templatesDir() {
|
|
|
28
32
|
}
|
|
29
33
|
throw new Error("agentic-memory templates not found");
|
|
30
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Where the hooks should load this package from.
|
|
37
|
+
*
|
|
38
|
+
* Running through `npx` puts the package in ~/.npm/_npx/<hash>/, which npm
|
|
39
|
+
* garbage-collects. Stamping that path into the hooks means they work today
|
|
40
|
+
* and break silently whenever the cache is cleared — with no error the user
|
|
41
|
+
* would connect to memory having stopped. So when the installer is itself
|
|
42
|
+
* running from an npx cache, it installs a copy (and its native dependencies)
|
|
43
|
+
* somewhere stable first, and points the hooks there.
|
|
44
|
+
*/
|
|
45
|
+
function resolveRuntimeRoot(home = (0, os_1.homedir)()) {
|
|
46
|
+
const here = (0, path_1.resolve)(__dirname, "..", "..");
|
|
47
|
+
if (!/[/\\]_npx[/\\]/.test(here))
|
|
48
|
+
return here;
|
|
49
|
+
const runtime = (0, path_1.join)(home, ".agentic-memory", "runtime");
|
|
50
|
+
const target = (0, path_1.join)(runtime, "node_modules", PKG);
|
|
51
|
+
const version = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(here, "package.json"), "utf8")).version;
|
|
52
|
+
const installed = (0, fs_1.existsSync)((0, path_1.join)(target, "package.json"))
|
|
53
|
+
? JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(target, "package.json"), "utf8")).version
|
|
54
|
+
: null;
|
|
55
|
+
if (installed === version)
|
|
56
|
+
return target;
|
|
57
|
+
(0, fs_1.mkdirSync)(runtime, { recursive: true });
|
|
58
|
+
if (!(0, fs_1.existsSync)((0, path_1.join)(runtime, "package.json"))) {
|
|
59
|
+
(0, fs_1.writeFileSync)((0, path_1.join)(runtime, "package.json"), JSON.stringify({ name: "agentic-memory-runtime", private: true }) + "\n");
|
|
60
|
+
}
|
|
61
|
+
console.log(` Installing runtime to ${runtime} (npx's cache is not durable)…`);
|
|
62
|
+
(0, child_process_1.execFileSync)("npm", ["install", `${PKG}@${version}`, "--prefix", runtime, "--no-audit", "--no-fund", "--loglevel=error"], {
|
|
63
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
64
|
+
});
|
|
65
|
+
return target;
|
|
66
|
+
}
|
|
31
67
|
function installHooks(home = (0, os_1.homedir)()) {
|
|
32
68
|
const hooksDir = (0, path_1.join)(home, ".claude", "hooks", "agentic-memory");
|
|
33
69
|
(0, fs_1.mkdirSync)(hooksDir, { recursive: true });
|
|
34
70
|
const tpl = (0, path_1.join)(templatesDir(), "hooks");
|
|
35
71
|
// The hooks run from ~/.claude/hooks/, outside any node_modules tree, so a
|
|
36
|
-
// bare specifier would not resolve. Stamp in
|
|
37
|
-
const root = (
|
|
72
|
+
// bare specifier would not resolve. Stamp in a durable absolute root.
|
|
73
|
+
const root = resolveRuntimeRoot(home);
|
|
38
74
|
const written = [];
|
|
39
75
|
for (const f of (0, fs_1.readdirSync)(tpl)) {
|
|
40
76
|
const dest = (0, path_1.join)(hooksDir, f);
|
|
@@ -68,12 +104,49 @@ function registerSettings(home = (0, os_1.homedir)()) {
|
|
|
68
104
|
if (!already)
|
|
69
105
|
groups.push({ hooks: [{ type: "command", command }] });
|
|
70
106
|
}
|
|
71
|
-
settings.mcpServers ??= {};
|
|
72
|
-
settings.mcpServers["agentic-memory"] = {
|
|
73
|
-
command: "npx",
|
|
74
|
-
args: ["-y", "local-agentic-ai-mem", "mcp"],
|
|
75
|
-
};
|
|
76
107
|
(0, fs_1.writeFileSync)(path, JSON.stringify(settings, null, 2) + "\n");
|
|
108
|
+
registerMcp(home);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Register the MCP server where Claude Code actually reads it: ~/.claude.json.
|
|
112
|
+
*
|
|
113
|
+
* It was written into settings.json next to the hooks, which looks right and
|
|
114
|
+
* is silently inert — `claude mcp list` never showed the server, so the two
|
|
115
|
+
* escape-hatch tools did not exist while the hooks worked perfectly. Nothing
|
|
116
|
+
* reports this: there is no error for a server nobody loads.
|
|
117
|
+
*
|
|
118
|
+
* The command points at the installed runtime rather than `npx -y <pkg>`, for
|
|
119
|
+
* the same reason the hooks do — see resolveRuntimeRoot. npx would also pay a
|
|
120
|
+
* registry round trip on every session start.
|
|
121
|
+
*/
|
|
122
|
+
function registerMcp(home = (0, os_1.homedir)()) {
|
|
123
|
+
const path = (0, path_1.join)(home, ".claude.json");
|
|
124
|
+
let config = {};
|
|
125
|
+
if ((0, fs_1.existsSync)(path)) {
|
|
126
|
+
try {
|
|
127
|
+
config = JSON.parse((0, fs_1.readFileSync)(path, "utf8"));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// This file is Claude Code's own state, not ours. If it cannot be parsed,
|
|
131
|
+
// replacing it with our two keys would delete everything it holds.
|
|
132
|
+
console.log(" MCP not registered ~/.claude.json is unreadable; run: claude mcp add …");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
137
|
+
return;
|
|
138
|
+
config.mcpServers ??= {};
|
|
139
|
+
config.mcpServers["agentic-memory"] = {
|
|
140
|
+
type: "stdio",
|
|
141
|
+
command: process.execPath,
|
|
142
|
+
args: [(0, path_1.join)(resolveRuntimeRoot(home), "dist", "index.js"), "mcp"],
|
|
143
|
+
env: {},
|
|
144
|
+
};
|
|
145
|
+
// Written via a temp file: a partial write here would leave Claude Code
|
|
146
|
+
// unable to parse its own configuration.
|
|
147
|
+
const tmp = path + ".agentic-memory.tmp";
|
|
148
|
+
(0, fs_1.writeFileSync)(tmp, JSON.stringify(config, null, 2) + "\n");
|
|
149
|
+
(0, fs_1.renameSync)(tmp, path);
|
|
77
150
|
}
|
|
78
151
|
function install(opts = {}) {
|
|
79
152
|
console.log("\n agentic-memory — local-first memory for Claude Code\n");
|
|
@@ -106,7 +179,7 @@ function install(opts = {}) {
|
|
|
106
179
|
const hooks = installHooks();
|
|
107
180
|
registerSettings();
|
|
108
181
|
console.log(` Hooks installed ${hooks.length}`);
|
|
109
|
-
console.log(` MCP registered agentic-memory`);
|
|
182
|
+
console.log(` MCP registered agentic-memory (~/.claude.json)`);
|
|
110
183
|
console.log(` Database ${require("../db").resolveDbPath(process.cwd())}`);
|
|
111
184
|
console.log("\n Nothing leaves this machine. Restart Claude Code to pick it up.\n");
|
|
112
185
|
}
|
|
@@ -82,7 +82,13 @@ function removeLegacyInstall(home = (0, os_1.homedir)(), apply = true) {
|
|
|
82
82
|
if (apply)
|
|
83
83
|
(0, fs_1.rmSync)(oldRoot, { recursive: true, force: true });
|
|
84
84
|
}
|
|
85
|
-
report.settingsCleaned =
|
|
85
|
+
report.settingsCleaned =
|
|
86
|
+
cleanSettings((0, path_1.join)(claudeDir, "settings.json"), apply) +
|
|
87
|
+
// The hosted MCP entry lives here, not in settings.json. Deleting the shim
|
|
88
|
+
// file above without this left a server pointing at a path that no longer
|
|
89
|
+
// exists — so every session opened with a failed-connection warning, and
|
|
90
|
+
// the API key it carried stayed on disk.
|
|
91
|
+
cleanSettings((0, path_1.join)(home, ".claude.json"), apply);
|
|
86
92
|
return report;
|
|
87
93
|
}
|
|
88
94
|
/**
|
|
@@ -124,20 +130,34 @@ function cleanSettings(path, apply = true) {
|
|
|
124
130
|
if (Object.keys(parsed.hooks).length === 0)
|
|
125
131
|
delete parsed.hooks;
|
|
126
132
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
const sweep = (holder, dropEmpty) => {
|
|
134
|
+
if (!holder?.mcpServers || typeof holder.mcpServers !== "object")
|
|
135
|
+
return;
|
|
136
|
+
for (const key of Object.keys(holder.mcpServers)) {
|
|
137
|
+
const entry = holder.mcpServers[key];
|
|
130
138
|
const args = Array.isArray(entry?.args) ? entry.args : [];
|
|
131
139
|
if (/memoryos|claude-memory/i.test(key) || args.some(isLegacy)) {
|
|
132
|
-
delete
|
|
140
|
+
delete holder.mcpServers[key];
|
|
133
141
|
removed++;
|
|
134
142
|
}
|
|
135
143
|
}
|
|
136
|
-
if (Object.keys(
|
|
137
|
-
delete
|
|
144
|
+
if (dropEmpty && Object.keys(holder.mcpServers).length === 0)
|
|
145
|
+
delete holder.mcpServers;
|
|
146
|
+
};
|
|
147
|
+
sweep(parsed, true);
|
|
148
|
+
// ~/.claude.json also holds a per-project map, and a server registered there
|
|
149
|
+
// survives a top-level clean while still failing to connect on every start.
|
|
150
|
+
if (parsed.projects && typeof parsed.projects === "object") {
|
|
151
|
+
for (const project of Object.values(parsed.projects))
|
|
152
|
+
sweep(project, false);
|
|
153
|
+
}
|
|
154
|
+
if (apply && removed > 0) {
|
|
155
|
+
// Atomic: ~/.claude.json is Claude Code's own state, and a torn write
|
|
156
|
+
// leaves it unable to parse its configuration at all.
|
|
157
|
+
const tmp = path + ".agentic-memory.tmp";
|
|
158
|
+
(0, fs_1.writeFileSync)(tmp, JSON.stringify(parsed, null, 2) + "\n");
|
|
159
|
+
(0, fs_1.renameSync)(tmp, path);
|
|
138
160
|
}
|
|
139
|
-
if (apply && removed > 0)
|
|
140
|
-
(0, fs_1.writeFileSync)(path, JSON.stringify(parsed, null, 2) + "\n");
|
|
141
161
|
return removed;
|
|
142
162
|
}
|
|
143
163
|
/** Anything left behind that mentions the old install, for reporting. */
|
package/dist/db.js
CHANGED
|
@@ -247,6 +247,13 @@ function liveMemories(opts = {}) {
|
|
|
247
247
|
where.push("m.project_id = ?");
|
|
248
248
|
params.push(opts.projectId);
|
|
249
249
|
}
|
|
250
|
+
// A memory written by the session now asking is not past work. It is the
|
|
251
|
+
// model's own output on a round trip, and it outranks everything real
|
|
252
|
+
// because it is a near-verbatim match for the prompt that produced it.
|
|
253
|
+
if (opts.excludeSessionId) {
|
|
254
|
+
where.push("(m.session_id IS NULL OR m.session_id <> ?)");
|
|
255
|
+
params.push(opts.excludeSessionId);
|
|
256
|
+
}
|
|
250
257
|
if (opts.tier !== undefined) {
|
|
251
258
|
where.push("m.tier <= ?");
|
|
252
259
|
params.push(opts.tier);
|
|
@@ -331,7 +338,7 @@ function recordInjection(r) {
|
|
|
331
338
|
}
|
|
332
339
|
function openInjections(sessionId) {
|
|
333
340
|
return db()
|
|
334
|
-
.prepare("SELECT id, kind, files, tokens, pitfall_files FROM injections WHERE session_id = ? AND resolved_at IS NULL")
|
|
341
|
+
.prepare("SELECT id, kind, at, files, tokens, pitfall_files FROM injections WHERE session_id = ? AND resolved_at IS NULL")
|
|
335
342
|
.all(sessionId);
|
|
336
343
|
}
|
|
337
344
|
function resolveInjection(id, o) {
|
package/dist/lib/compliance.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
27
|
exports.distinctiveTokens = distinctiveTokens;
|
|
28
|
+
exports.transcriptAfter = transcriptAfter;
|
|
28
29
|
exports.readTranscript = readTranscript;
|
|
29
30
|
exports.scoreInjection = scoreInjection;
|
|
30
31
|
exports.formatReport = formatReport;
|
|
@@ -68,16 +69,58 @@ function distinctiveTokens(injected, prompt, max = 25) {
|
|
|
68
69
|
function tokenize(s) {
|
|
69
70
|
return (s || "").toLowerCase().replace(/[^a-z0-9._/-]+/g, " ").split(/\s+/).filter(Boolean);
|
|
70
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Transcript rows carry an ISO `timestamp`. Everything that needs to ask "what
|
|
74
|
+
* happened after moment X" goes through this, because the answer is wrong in
|
|
75
|
+
* two different places without it: an injection scored against the WHOLE
|
|
76
|
+
* transcript counts words the model said before it was ever shown the memory,
|
|
77
|
+
* and extraction run over the whole transcript re-mines earlier turns and
|
|
78
|
+
* writes the same memory again on every subsequent Stop.
|
|
79
|
+
*
|
|
80
|
+
* Rows with no timestamp are kept — they are session metadata that the parsers
|
|
81
|
+
* ignore anyway, and dropping them would be a silent change to what is parsed.
|
|
82
|
+
*/
|
|
83
|
+
function transcriptAfter(jsonl, iso) {
|
|
84
|
+
if (!iso)
|
|
85
|
+
return jsonl || "";
|
|
86
|
+
const cutoff = new Date(iso).getTime();
|
|
87
|
+
if (!Number.isFinite(cutoff))
|
|
88
|
+
return jsonl || "";
|
|
89
|
+
const kept = [];
|
|
90
|
+
for (const line of (jsonl || "").split("\n")) {
|
|
91
|
+
const t = line.trim();
|
|
92
|
+
if (!t.startsWith("{"))
|
|
93
|
+
continue;
|
|
94
|
+
let row;
|
|
95
|
+
try {
|
|
96
|
+
row = JSON.parse(t);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const ts = typeof row?.timestamp === "string" ? new Date(row.timestamp).getTime() : NaN;
|
|
102
|
+
if (Number.isFinite(ts) && ts <= cutoff)
|
|
103
|
+
continue;
|
|
104
|
+
kept.push(line);
|
|
105
|
+
}
|
|
106
|
+
return kept.join("\n");
|
|
107
|
+
}
|
|
71
108
|
/**
|
|
72
109
|
* Pull the three things compliance needs out of a Claude Code transcript.
|
|
73
110
|
* Tolerates partial and malformed lines — a transcript being unparseable
|
|
74
111
|
* should cost a measurement, not a session.
|
|
112
|
+
*
|
|
113
|
+
* `after` scopes it to what happened once the memory was on screen. Without
|
|
114
|
+
* it the echo signal is unfalsifiable: memories are mined from the model's own
|
|
115
|
+
* output, so a memory written in this session and injected back into it scores
|
|
116
|
+
* a perfect echo for saying nothing new. That is not a hypothetical — it was
|
|
117
|
+
* measured at cosine 1.000 against the prompt that produced it.
|
|
75
118
|
*/
|
|
76
|
-
function readTranscript(jsonl) {
|
|
119
|
+
function readTranscript(jsonl, opts = {}) {
|
|
77
120
|
const read = new Set();
|
|
78
121
|
const edited = new Set();
|
|
79
122
|
const said = [];
|
|
80
|
-
for (const line of (jsonl || "").split("\n")) {
|
|
123
|
+
for (const line of (transcriptAfter(jsonl, opts.after) || "").split("\n")) {
|
|
81
124
|
const t = line.trim();
|
|
82
125
|
if (!t.startsWith("{"))
|
|
83
126
|
continue;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.significantFiles = significantFiles;
|
|
4
|
+
/**
|
|
5
|
+
* Which touched files are worth remembering.
|
|
6
|
+
*
|
|
7
|
+
* A turn's file list is whatever the tools touched, and on a session that took
|
|
8
|
+
* screenshots or wrote a probe script that is mostly disposable paths under
|
|
9
|
+
* /tmp. They are not evidence of anything next week: the directory is gone.
|
|
10
|
+
*
|
|
11
|
+
* Keeping them is not free. The list is shown with the memory, so it spends
|
|
12
|
+
* the injection's few lines on noise, and `files_named` is the denominator of
|
|
13
|
+
* the avoided-re-read measurement — one session recorded 46 files of which 30
|
|
14
|
+
* were screenshot PNGs, which makes that number describe the screenshots.
|
|
15
|
+
*/
|
|
16
|
+
const DISPOSABLE_DIR = /(^|\/)(tmp|scratchpad|\.cache|node_modules|dist|\.next|coverage)(\/|$)/;
|
|
17
|
+
const DISPOSABLE_EXT = /\.(png|jpe?g|gif|webp|svg|ico|pdf|zip|gz|tgz|mp4|mov|lock|log|bin|woff2?|ttf)$/i;
|
|
18
|
+
function significantFiles(files, limit = 12) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const f of files || []) {
|
|
21
|
+
if (typeof f !== "string" || !f)
|
|
22
|
+
continue;
|
|
23
|
+
if (DISPOSABLE_DIR.test(f) || DISPOSABLE_EXT.test(f))
|
|
24
|
+
continue;
|
|
25
|
+
if (out.includes(f))
|
|
26
|
+
continue;
|
|
27
|
+
out.push(f);
|
|
28
|
+
if (out.length >= limit)
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
package/dist/lib/recall.js
CHANGED
|
@@ -72,34 +72,62 @@ function safeArray(json) {
|
|
|
72
72
|
return [];
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
function rankArm(rows, key, q) {
|
|
75
|
+
function rankArm(rows, key, q, simById) {
|
|
76
76
|
const scored = [];
|
|
77
77
|
for (const r of rows) {
|
|
78
78
|
const v = (0, db_1.decodeVec)(r[key] ?? null);
|
|
79
79
|
if (!v)
|
|
80
80
|
continue;
|
|
81
|
-
|
|
81
|
+
const sim = (0, db_1.cosine)(q, v);
|
|
82
|
+
if (simById)
|
|
83
|
+
simById.set(r.id, Math.max(simById.get(r.id) ?? -1, sim));
|
|
84
|
+
scored.push({ id: r.id, sim });
|
|
82
85
|
}
|
|
83
86
|
scored.sort((a, b) => b.sim - a.sim);
|
|
84
87
|
return scored.slice(0, ARM_DEPTH).map((s) => s.id);
|
|
85
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Which memories matched a *distinctive* term in the query.
|
|
91
|
+
*
|
|
92
|
+
* The lexical arm ORs every term, which is right for a fusion arm — broad
|
|
93
|
+
* recall, rank sorted out by RRF — and wrong as a reason to skip the relevance
|
|
94
|
+
* floor. "what is the capital city of Portugal" matches almost every memory on
|
|
95
|
+
* the word "the", so an unqualified lexical bypass let the whole corpus
|
|
96
|
+
* through and the floor never fired at all.
|
|
97
|
+
*
|
|
98
|
+
* Only identifier-shaped or unusually long terms qualify: a path, a SHA, an
|
|
99
|
+
* error code, a symbol name. Those are exactly the cases embeddings are worst
|
|
100
|
+
* at, and no ordinary English word reaches them.
|
|
101
|
+
*/
|
|
102
|
+
function exactMatchIds(queryText, byId) {
|
|
103
|
+
const distinctive = (queryText || "")
|
|
104
|
+
.toLowerCase()
|
|
105
|
+
.split(/[^a-z0-9_./-]+/)
|
|
106
|
+
.filter((t) => t.length > 2 && (/[._/-]/.test(t) || /\d/.test(t) || t.length >= 12));
|
|
107
|
+
if (distinctive.length === 0)
|
|
108
|
+
return new Set();
|
|
109
|
+
return new Set((0, db_1.lexicalSearch)(distinctive.join(" "), ARM_DEPTH).filter((id) => byId.has(id)));
|
|
110
|
+
}
|
|
86
111
|
function recall(opts) {
|
|
87
112
|
const limit = opts.limit ?? 5;
|
|
88
113
|
const q = opts.queryVec instanceof Float32Array ? opts.queryVec : Float32Array.from(opts.queryVec);
|
|
89
114
|
// One scan serves every arm — the whole live set for this user is small.
|
|
90
|
-
const rows = (0, db_1.liveMemories)({ tier: opts.tier ?? 2 });
|
|
115
|
+
const rows = (0, db_1.liveMemories)({ tier: opts.tier ?? 2, excludeSessionId: opts.excludeSessionId });
|
|
91
116
|
if (rows.length === 0)
|
|
92
117
|
return [];
|
|
93
118
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
119
|
+
const simById = new Map();
|
|
120
|
+
const broadLexical = (0, db_1.lexicalSearch)(opts.queryText, ARM_DEPTH).filter((id) => byId.has(id));
|
|
121
|
+
const exactIds = exactMatchIds(opts.queryText, byId);
|
|
94
122
|
const arms = [
|
|
95
|
-
rankArm(rows, "vec", q),
|
|
96
|
-
rankArm(rows, "prompt_vec", q),
|
|
97
|
-
|
|
123
|
+
rankArm(rows, "vec", q, simById),
|
|
124
|
+
rankArm(rows, "prompt_vec", q, simById),
|
|
125
|
+
broadLexical,
|
|
98
126
|
];
|
|
99
127
|
if (opts.projectId !== undefined) {
|
|
100
128
|
const scoped = rows.filter((r) => r.project_id === opts.projectId);
|
|
101
129
|
if (scoped.length)
|
|
102
|
-
arms.push(rankArm(scoped, "vec", q));
|
|
130
|
+
arms.push(rankArm(scoped, "vec", q, simById));
|
|
103
131
|
}
|
|
104
132
|
const fused = new Map();
|
|
105
133
|
for (const arm of arms) {
|
|
@@ -107,12 +135,17 @@ function recall(opts) {
|
|
|
107
135
|
fused.set(id, (fused.get(id) ?? 0) + 1 / (RRF_K + rank + 1));
|
|
108
136
|
});
|
|
109
137
|
}
|
|
138
|
+
const minSim = opts.minSim ?? 0;
|
|
110
139
|
const scored = [];
|
|
111
140
|
for (const [id, rrf] of fused) {
|
|
112
141
|
const row = byId.get(id);
|
|
113
142
|
const text = bodyOf(row);
|
|
114
143
|
if (!text)
|
|
115
144
|
continue;
|
|
145
|
+
const sim = simById.get(id) ?? 0;
|
|
146
|
+
const lexical = exactIds.has(id);
|
|
147
|
+
if (minSim > 0 && sim < minSim && !lexical)
|
|
148
|
+
continue;
|
|
116
149
|
const proven = Math.min(PROVEN_CAP, 1 + PROVEN_WEIGHT * Math.log1p(row.recall_count || 0));
|
|
117
150
|
const sameProject = opts.projectId !== undefined && row.project_id === opts.projectId;
|
|
118
151
|
scored.push({
|
|
@@ -126,6 +159,8 @@ function recall(opts) {
|
|
|
126
159
|
gitCommit: row.git_commit,
|
|
127
160
|
anchorSha: row.anchor_sha,
|
|
128
161
|
confidence: row.confidence,
|
|
162
|
+
sim,
|
|
163
|
+
lexical,
|
|
129
164
|
score: rrf *
|
|
130
165
|
recencyFactor(row) *
|
|
131
166
|
(sameProject ? SAME_PROJECT_BOOST : 1) *
|
package/dist/lib/redact.js
CHANGED
|
@@ -15,6 +15,24 @@
|
|
|
15
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
16
|
exports.redact = redact;
|
|
17
17
|
exports.redactAll = redactAll;
|
|
18
|
+
/** Real card numbers carry a check digit; almost no timestamp or id does. */
|
|
19
|
+
function luhn(digits) {
|
|
20
|
+
let sum = 0;
|
|
21
|
+
let alt = false;
|
|
22
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
23
|
+
let d = digits.charCodeAt(i) - 48;
|
|
24
|
+
if (d < 0 || d > 9)
|
|
25
|
+
return false;
|
|
26
|
+
if (alt) {
|
|
27
|
+
d *= 2;
|
|
28
|
+
if (d > 9)
|
|
29
|
+
d -= 9;
|
|
30
|
+
}
|
|
31
|
+
sum += d;
|
|
32
|
+
alt = !alt;
|
|
33
|
+
}
|
|
34
|
+
return sum % 10 === 0;
|
|
35
|
+
}
|
|
18
36
|
const PATTERNS = [
|
|
19
37
|
// Prefixed opaque tokens (sk-, ghp_, xoxb-, api_key-...)
|
|
20
38
|
[/\b(sk|pk|rk|api|token|key|bearer|ghp|gho|ghu|ghs|ghr|xox[abprs])[-_][A-Za-z0-9_-]{16,}\b/gi, "[REDACTED_TOKEN]"],
|
|
@@ -43,8 +61,31 @@ const PATTERNS = [
|
|
|
43
61
|
[/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[REDACTED_EMAIL]"],
|
|
44
62
|
// Public IPv4 only — private ranges are dev infra and useful context.
|
|
45
63
|
[/\b(?!10\.|192\.168\.|127\.|0\.|172\.(?:1[6-9]|2\d|3[01])\.)(?:\d{1,3}\.){3}\d{1,3}\b/g, "[REDACTED_IP]"],
|
|
46
|
-
|
|
47
|
-
|
|
64
|
+
// Card numbers must pass Luhn AND stand alone as a token. Neither test on
|
|
65
|
+
// its own is enough: `~/.claude.json.bak-20260830-112153` is fourteen digits
|
|
66
|
+
// with a separator, which the old pattern replaced with [REDACTED_CC] —
|
|
67
|
+
// destroying the one thing the memory existed to record. A memory that
|
|
68
|
+
// survives redaction as a lie is worse than one not kept, because it still
|
|
69
|
+
// costs an injection slot and now misdirects.
|
|
70
|
+
[
|
|
71
|
+
/(?<![A-Za-z0-9._/-])(?:\d[ -]?){13,19}(?![A-Za-z0-9._/-])/g,
|
|
72
|
+
(m) => {
|
|
73
|
+
const digits = m.replace(/[^0-9]/g, "");
|
|
74
|
+
return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? "[REDACTED_CC]" : m;
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
// Bare long hex is usually a hash or a hex key, but a 40- or 64-character
|
|
78
|
+
// one introduced as a commit is a git object id — and "anchored to a
|
|
79
|
+
// commit" is how tier 2 is defined, so redacting it removes the anchor.
|
|
80
|
+
[
|
|
81
|
+
/\b[a-f0-9]{32,}\b/gi,
|
|
82
|
+
(m, offset, whole) => {
|
|
83
|
+
const gitLength = m.length === 40 || m.length === 64;
|
|
84
|
+
const lead = whole.slice(Math.max(0, offset - 28), offset).toLowerCase();
|
|
85
|
+
const introduced = /\b(commit|sha|rev|revision|head|tag|tree|blob|anchor|at)\b[^a-z0-9]*$/.test(lead);
|
|
86
|
+
return gitLength && introduced ? m : "[REDACTED_HEX]";
|
|
87
|
+
},
|
|
88
|
+
],
|
|
48
89
|
];
|
|
49
90
|
function redact(text) {
|
|
50
91
|
if (!text || typeof text !== "string")
|
package/dist/mcp/server.js
CHANGED
|
@@ -32,6 +32,14 @@ const recall_1 = require("../lib/recall");
|
|
|
32
32
|
const redact_1 = require("../lib/redact");
|
|
33
33
|
const compose_1 = require("../lib/compose");
|
|
34
34
|
const SUPPORTED_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
35
|
+
function pkgVersion() {
|
|
36
|
+
try {
|
|
37
|
+
return require("../../package.json").version ?? "0.0.0";
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return "0.0.0";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
35
43
|
const TOOLS = [
|
|
36
44
|
{
|
|
37
45
|
name: "recall_memory",
|
|
@@ -182,7 +190,9 @@ function serve() {
|
|
|
182
190
|
respond(msg.id, {
|
|
183
191
|
protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0],
|
|
184
192
|
capabilities: { tools: {}, resources: {} },
|
|
185
|
-
|
|
193
|
+
// Read from the manifest rather than hardcoded: a pinned string
|
|
194
|
+
// here drifts from the package version silently and forever.
|
|
195
|
+
serverInfo: { name: "agentic-memory", version: pkgVersion() },
|
|
186
196
|
});
|
|
187
197
|
}
|
|
188
198
|
else if (msg.method === "ping") {
|
package/package.json
CHANGED
package/templates/hooks/stop.mjs
CHANGED
|
@@ -39,7 +39,8 @@ async function main() {
|
|
|
39
39
|
const { assignTier, gradeConfidence } = require(ROOT + '/dist/lib/tiers')
|
|
40
40
|
const { redact } = require(ROOT + '/dist/lib/redact')
|
|
41
41
|
const { openInjections, resolveInjection } = require(ROOT + '/dist/db')
|
|
42
|
-
const { readTranscript, scoreInjection } = require(ROOT + '/dist/lib/compliance')
|
|
42
|
+
const { readTranscript, scoreInjection, transcriptAfter } = require(ROOT + '/dist/lib/compliance')
|
|
43
|
+
const { significantFiles } = require(ROOT + '/dist/lib/files')
|
|
43
44
|
|
|
44
45
|
const cwd = turn.cwd || p.cwd || process.cwd()
|
|
45
46
|
|
|
@@ -50,7 +51,11 @@ async function main() {
|
|
|
50
51
|
if (p.transcript_path && existsSync(p.transcript_path)) {
|
|
51
52
|
try {
|
|
52
53
|
transcript = readFileSync(p.transcript_path, 'utf8')
|
|
53
|
-
|
|
54
|
+
// Only this turn. extractMemory over the whole transcript re-mines every
|
|
55
|
+
// earlier turn, so a second prompt in the same session writes a near
|
|
56
|
+
// duplicate of the first turn's memory — two rows, both matching the
|
|
57
|
+
// prompt that produced them, both outranking real past work.
|
|
58
|
+
extracted = extractMemory(transcriptAfter(transcript, turn.started_at))
|
|
54
59
|
} catch {}
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -60,8 +65,13 @@ async function main() {
|
|
|
60
65
|
if (transcript) {
|
|
61
66
|
try {
|
|
62
67
|
db(cwd)
|
|
63
|
-
const facts = readTranscript(transcript)
|
|
64
68
|
for (const inj of openInjections(p.session_id)) {
|
|
69
|
+
// From the moment this memory was on screen. Scoring against the whole
|
|
70
|
+
// transcript counts words said before it was ever shown — and since
|
|
71
|
+
// memories are mined from the model's own output, that made echo
|
|
72
|
+
// unfalsifiable rather than weak. `at` is SQLite UTC without a zone.
|
|
73
|
+
const at = inj.at ? inj.at.replace(' ', 'T') + 'Z' : null
|
|
74
|
+
const facts = readTranscript(transcript, { after: at })
|
|
65
75
|
resolveInjection(inj.id, scoreInjection({
|
|
66
76
|
files: JSON.parse(inj.files || '[]'),
|
|
67
77
|
tokens: JSON.parse(inj.tokens || '[]'),
|
|
@@ -72,7 +82,7 @@ async function main() {
|
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
const decisions = extracted?.decisions ?? []
|
|
75
|
-
const files =
|
|
85
|
+
const files = significantFiles([...(turn.files || []), ...(extracted?.files_touched ?? [])])
|
|
76
86
|
const composed = extracted?.content
|
|
77
87
|
? { content: extracted.content, decisions }
|
|
78
88
|
: composeAtomContent({ prompt: turn.prompt, decisions, files_touched: files, tool_calls: turn.tool_calls, git_commit: null })
|
|
@@ -12,7 +12,10 @@ const require = createRequire(import.meta.url)
|
|
|
12
12
|
const ROOT = '__AGENTIC_MEMORY_ROOT__'
|
|
13
13
|
|
|
14
14
|
const FILLER = /^(thanks|thank you|ok|okay|yes|go)\.?\??$/i
|
|
15
|
-
|
|
15
|
+
// Cosine, not the fused score. Measured on a real corpus: related queries peak
|
|
16
|
+
// at 0.75-0.81, an off-topic question at 0.48, and the band that made memory
|
|
17
|
+
// feel like noise sat at 0.59-0.60. 0.65 splits it with margin either side.
|
|
18
|
+
const THRESHOLD = parseFloat(process.env.AGENTIC_MEMORY_THRESHOLD || '') || 0.65
|
|
16
19
|
|
|
17
20
|
async function main() {
|
|
18
21
|
let raw = ''
|
|
@@ -37,17 +40,28 @@ async function main() {
|
|
|
37
40
|
const { distinctiveTokens } = require(ROOT + '/dist/lib/compliance')
|
|
38
41
|
const { embed } = require(ROOT + '/dist/lib/embed')
|
|
39
42
|
const { recall } = require(ROOT + '/dist/lib/recall')
|
|
43
|
+
const { significantFiles } = require(ROOT + '/dist/lib/files')
|
|
40
44
|
|
|
41
45
|
db(cwd || process.cwd())
|
|
42
46
|
const projectId = upsertProject(process.env.AGENTIC_MEMORY_PROJECT || basename(cwd || process.cwd()), cwd)
|
|
43
47
|
const vec = await embed(prompt)
|
|
44
|
-
const hits = recall({
|
|
45
|
-
|
|
48
|
+
const hits = recall({
|
|
49
|
+
queryText: prompt, queryVec: vec, projectId, limit: 3,
|
|
50
|
+
minSim: THRESHOLD,
|
|
51
|
+
// Memories this session wrote are this session's own output coming back.
|
|
52
|
+
excludeSessionId: session_id,
|
|
53
|
+
})
|
|
54
|
+
// Injecting nothing is a correct answer, and used to be unreachable: the
|
|
55
|
+
// guard here read `hits[0].score < THRESHOLD * hits[0].score`, which
|
|
56
|
+
// compares a number to a fraction of itself and is false for every
|
|
57
|
+
// positive score. Every prompt got the top 3 of whatever existed.
|
|
58
|
+
if (hits.length === 0) return
|
|
46
59
|
|
|
47
60
|
const lines = ['<memory>', 'Relevant past work — use it, do not narrate it.', '']
|
|
48
61
|
for (const h of hits) {
|
|
49
62
|
lines.push(`• ${h.text}`)
|
|
50
|
-
|
|
63
|
+
const shown = significantFiles(h.files, 4)
|
|
64
|
+
if (shown.length) lines.push(` files: ${shown.join(', ')}`)
|
|
51
65
|
}
|
|
52
66
|
lines.push('')
|
|
53
67
|
lines.push('If the code in front of you disagrees, the code wins.')
|
|
@@ -55,8 +69,7 @@ async function main() {
|
|
|
55
69
|
|
|
56
70
|
const text = lines.join('\n')
|
|
57
71
|
try {
|
|
58
|
-
const files = []
|
|
59
|
-
for (const h of hits) for (const f of (h.files || [])) if (!files.includes(f)) files.push(f)
|
|
72
|
+
const files = significantFiles(hits.flatMap((h) => h.files || []))
|
|
60
73
|
recordInjection({
|
|
61
74
|
sessionId: session_id, projectId, kind: 'recall',
|
|
62
75
|
memoryIds: hits.map((h) => h.id), files,
|