local-agentic-ai-mem 0.1.1 → 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 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.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveRuntimeRoot = resolveRuntimeRoot;
4
4
  exports.installHooks = installHooks;
5
5
  exports.registerSettings = registerSettings;
6
+ exports.registerMcp = registerMcp;
6
7
  exports.install = install;
7
8
  /**
8
9
  * Install: write hooks, register the MCP server, create the database.
@@ -103,12 +104,49 @@ function registerSettings(home = (0, os_1.homedir)()) {
103
104
  if (!already)
104
105
  groups.push({ hooks: [{ type: "command", command }] });
105
106
  }
106
- settings.mcpServers ??= {};
107
- settings.mcpServers["agentic-memory"] = {
108
- command: "npx",
109
- args: ["-y", PKG, "mcp"],
110
- };
111
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);
112
150
  }
113
151
  function install(opts = {}) {
114
152
  console.log("\n agentic-memory — local-first memory for Claude Code\n");
@@ -141,7 +179,7 @@ function install(opts = {}) {
141
179
  const hooks = installHooks();
142
180
  registerSettings();
143
181
  console.log(` Hooks installed ${hooks.length}`);
144
- console.log(` MCP registered agentic-memory`);
182
+ console.log(` MCP registered agentic-memory (~/.claude.json)`);
145
183
  console.log(` Database ${require("../db").resolveDbPath(process.cwd())}`);
146
184
  console.log("\n Nothing leaves this machine. Restart Claude Code to pick it up.\n");
147
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 = cleanSettings((0, path_1.join)(claudeDir, "settings.json"), apply);
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
- if (parsed.mcpServers && typeof parsed.mcpServers === "object") {
128
- for (const key of Object.keys(parsed.mcpServers)) {
129
- const entry = parsed.mcpServers[key];
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 parsed.mcpServers[key];
140
+ delete holder.mcpServers[key];
133
141
  removed++;
134
142
  }
135
143
  }
136
- if (Object.keys(parsed.mcpServers).length === 0)
137
- delete parsed.mcpServers;
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) {
@@ -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
+ }
@@ -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
- scored.push({ id: r.id, sim: (0, db_1.cosine)(q, v) });
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
- (0, db_1.lexicalSearch)(opts.queryText, ARM_DEPTH).filter((id) => byId.has(id)),
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) *
@@ -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
- [/\b(?:\d[ -]?){13,19}\b/g, (m) => (/\d{13,19}/.test(m.replace(/[ -]/g, "")) ? "[REDACTED_CC]" : m)],
47
- [/\b[a-f0-9]{32,}\b/gi, "[REDACTED_HEX]"],
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")
@@ -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
- serverInfo: { name: "agentic-memory", version: "0.1.0" },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-agentic-ai-mem",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first memory for Claude Code. Your code and your memory never leave your machine.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -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
- extracted = extractMemory(transcript)
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 = [...new Set([...(turn.files || []), ...(extracted?.files_touched ?? [])])]
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
- const THRESHOLD = parseFloat(process.env.AGENTIC_MEMORY_THRESHOLD || '') || 0.35
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({ queryText: prompt, queryVec: vec, projectId, limit: 3 })
45
- if (hits.length === 0 || hits[0].score < THRESHOLD * hits[0].score) return
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
- if (h.files.length) lines.push(` files: ${h.files.slice(0, 4).join(', ')}`)
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,