kodelyth-ecc 2.12.0 → 2.14.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/CHANGELOG.md CHANGED
@@ -2,6 +2,140 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.14.0 — A false recurring class, and five more atomic writes (August 2026)
6
+
7
+ ### Fixed — the guard proposal was pointing at the wrong thing
8
+
9
+ After three arena runs the classifier reported **`resource-exhaustion` ×5** as the
10
+ top recurring class. Four of those five were not resource exhaustion at all:
11
+
12
+ ```
13
+ resource-exhaustion <- Prototype-key collision crashes indexing
14
+ resource-exhaustion <- A crash mid-append fuses the next memory into the torn row
15
+ resource-exhaustion <- A patch row surfaced as a phantom memory
16
+ ```
17
+
18
+ The pattern contained the bare word `memory`, which matches every finding about
19
+ the memory *store*. Acting on it would have meant building a guard nobody needed
20
+ — exactly the waste the proposal mechanism exists to prevent. This is the second
21
+ generic-word collision in this classifier; `permission` was the first.
22
+
23
+ `resource-exhaustion` now requires an actual exhaustion signal (`heap`, `rss`,
24
+ `oom`, `memory leak/usage/growth`, `allocates N`). A new **`data-integrity`**
25
+ class covers the double-index / desync / phantom-row / torn-row family, and
26
+ prototype-key findings now classify as `input-validation`. The 5 mis-tagged
27
+ memories already in the store were re-tagged.
28
+
29
+ The corrected picture across three runs:
30
+
31
+ | class | count | status |
32
+ |---|---|---|
33
+ | `filesystem-symlink` | 4 | already guarded by `scripts/lib/safe-fs.js` |
34
+ | `data-integrity` | 4 | all four in one file, all fixed |
35
+ | `input-validation` | 2 | fixed |
36
+ | everything else | 1 each | — |
37
+
38
+ **No new abstraction was built for `data-integrity`.** All four members live in
39
+ `store.js` and are already fixed, and no other subsystem keeps derived state with
40
+ the same drift — so a shared guard would have been speculative.
41
+
42
+ ### Added — `replaceFilePreservingMode`, applied to five real writers
43
+
44
+ What the sweep *did* find is the durability pattern in files holding state worth
45
+ keeping. `fs.writeFileSync` opens with `'w'`, truncating to zero before writing,
46
+ so a crash or a full disk part-way through leaves a truncated file and no copy of
47
+ the original:
48
+
49
+ - `scripts/codex/merge-mcp-config.js` — the user's **Codex IDE config**
50
+ - `scripts/codex/merge-codex-config.js` — the user's **Codex IDE config**
51
+ - `scripts/evolve/stats.js` — accumulated reuse and routing-miss stats
52
+ - `scripts/mcp/client.js` — the MCP server registry
53
+ - `scripts/memory/store.js` — the BM25 index
54
+
55
+ All five now write through `safeFs.replaceFilePreservingMode`, which keeps the
56
+ file's existing permissions and renames atomically. A crash leaves the original
57
+ completely untouched.
58
+
59
+ **572 tests passing**, up from 569.
60
+
61
+ ## v2.13.0 — Arena run #3: eight bugs in the memory store (August 2026)
62
+
63
+ Pointed the arena at `scripts/memory` — the persistent BM25 store every other
64
+ subsystem trusts, and the one place where a bug costs the user real accumulated
65
+ work. Round 1 found **8 findings, all 8 confirmed by executed repro**. Round 2
66
+ attacked the fixes across 7 vectors and found **1 regression, which is fixed**.
67
+
68
+ ### Fixed — a single English word bricked memory search
69
+
70
+ Capturing a memory containing the word **"constructor"** crashed indexing, and
71
+ `recall()` then threw on every subsequent call:
72
+
73
+ ```js
74
+ if (!index.tokens[token]) index.tokens[token] = { docs: [], df: 0 };
75
+ index.tokens[token].docs.push(...) // .docs is undefined
76
+ ```
77
+
78
+ `index.tokens['constructor']` returns `Object.prototype.constructor` — **truthy**
79
+ — so the guard never fires. Same for `toString`, `valueOf`, `hasOwnProperty`,
80
+ `__proto__`, `isPrototypeOf`. No attacker required: writing one memory about a
81
+ constructor, or about overriding `toString`, was enough. The blast radius was
82
+ everything that reads memory — MCP server, dashboard, CLI, and the session-start
83
+ injection hook.
84
+
85
+ Fixed at 7 sites with null-prototype maps, including sanitising the index after
86
+ `JSON.parse` (which re-introduces a normal prototype). Those tokens are now
87
+ searchable, not merely non-crashing.
88
+
89
+ ### Fixed — the log is genuinely append-only now
90
+
91
+ `forget()` and `resolveMemory()` read the **entire** log, mutated it in memory,
92
+ and wrote it back with `fs.writeFileSync` — which opens with `'w'`, truncating to
93
+ zero before writing. Measured directly: sampling file size during a rewrite of a
94
+ 6.3 MB store observed it at **0.00 MB**, with 32 torn reads in 1423 samples.
95
+
96
+ Both now **append a patch row**, and `readMemories()` folds rows by id with
97
+ last-write-wins. That is what the file's own header always claimed it was. The
98
+ truncate window is gone — 0 torn reads across 1477 samples — and 8 concurrent
99
+ deletions now all apply, where previously only 3 of 8 survived.
100
+
101
+ *Honest scope:* the truncate window is proven, and a reader doing its own
102
+ read-modify-write inside it would persist the emptiness. I could **not**
103
+ reproduce a full store wipe end-to-end; what I measured was lost deletions.
104
+
105
+ ### Fixed — memories that were invisible to search, forever
106
+
107
+ The log and the index were written by separate calls with no reconciliation, and
108
+ `loadIndex()` only rebuilt when the index was *missing* or schema-invalid — never
109
+ when merely incomplete. A memory could sit in the log and return zero hits for
110
+ its own exact text, permanently. The index now stamps the log size it was built
111
+ from and rebuilds on any drift, which self-heals every cause at the price of one
112
+ `stat()`.
113
+
114
+ ### Also fixed
115
+
116
+ - `forget()` rewrote the whole store even when the id was **not found**.
117
+ - A crash mid-append left a newline-less row; the next `capture()` fused into it
118
+ and was silently lost while returning an id and reporting success.
119
+ - `capture()` double-indexed on every cold start, inflating `docCount` and giving
120
+ that memory exactly 2x its true BM25 score.
121
+ - `tags`/`files`/`gotchas` capped their *count* but not each entry's length — a
122
+ single 5 MB tag was stored whole.
123
+ - `instincts.js` had the identical rewrite pattern; it now uses
124
+ `safeFs.replaceFileAtomic`.
125
+
126
+ ### Round 2 — one regression, caught and fixed
127
+
128
+ The new fold promoted an orphan patch row to a phantom memory with
129
+ `problem: undefined`, which would have flowed into `recall()`, the dashboard, and
130
+ the injected session block. A row with no prior and no `problem` is a patch, not
131
+ a memory.
132
+
133
+ **Backward compatible:** existing logs read correctly — old full-row tombstones
134
+ fold the same way. One real duplicate id in the test store is now correctly
135
+ deduped rather than returned twice.
136
+
137
+ **569 tests passing**, up from 554.
138
+
5
139
  ## v2.12.0 — `scripts/lib/safe-fs.js`: the guard the arena asked for (August 2026)
6
140
 
7
141
  Across two arena runs the **same containment bug was confirmed four times** in
package/CLAUDE.md CHANGED
@@ -26,7 +26,7 @@ scripts/ → Node.js utilities: MCP server, dashboard, swarm, replay, router
26
26
  bundles/ → 3 power bundles (indie-hacker, red-team, enterprise)
27
27
  actions/ → GitHub Action (CI/CD integration for PR review)
28
28
  docs/ → Feature docs (arena.md, mcp.md, dashboard.md, swarm.md, replay.md, evolve.md, supply-chain.md)
29
- tests/ → 554 passing tests across 30 test files
29
+ tests/ → 572 passing tests across 30 test files
30
30
  ```
31
31
 
32
32
  ## Running Tests
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.12.0
1
+ 2.14.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Production-grade AI coding toolkit — 70 agents (incl. devil-mode adversarial crew), 194 skills, 97 commands, parallel multi-agent commands, semantic intent routing, self-learning memory, and a built-in MCP server (16 tools / 6 prompts / 377 resources) that bridges to Claude Desktop, LangGraph, AutoGen, CrewAI, and OpenAI Agents SDK. Works with Claude Code, Windsurf, Cursor, Codex, Antigravity, OpenCode, Cline, RooCode, Aider, Kimi, and Gemini CLI.",
5
5
  "author": "Kodelyth <github.com/sifxprime>",
6
6
  "license": "MIT",
@@ -35,14 +35,15 @@ const CLASSES = [
35
35
  ['redos', /\bredos|backtrack|quadratic|catastrophic|O\(n\^?2\)|unanchored\b/i],
36
36
  ['path-traversal', /\btraversal|confinement|arbitrary (?:write|path)|escape the root\b/i],
37
37
  ['race-condition', /\btoctou|race condition|check.to.use\b/i],
38
- ['resource-exhaustion', /\bmemory|heap|rss|oom|amplification|exhaust\b/i],
38
+ ['resource-exhaustion', /\bheap\b|\brss\b|\boom\b|amplification|exhaust|out of memory|memory (?:leak|usage|growth|pressure)|allocates? ~?\d/i],
39
39
  ['missing-limit', /\bno (?:input )?(?:size )?cap|unbounded|no limit|missing limit\b/i],
40
40
  ['temp-file-handling', /\btemp(?:orary)? file|tmp file|leftover|predictable (?:name|filename)\b/i],
41
41
  ['prompt-injection', /\binjection|jailbreak|untrusted (?:text|content|input)|system.prompt leak|inert data|trust.boundary\b/i],
42
42
  ['secret-exposure', /\bsecret|credential|api key|token leak|password\b/i],
43
43
  ['semantic-corruption', /\bsemantic|meaning|threshold|negation|inverts?|widen/i],
44
44
  ['idempotency', /\bidempoten|fixed point|second run|re-?run\b/i],
45
- ['input-validation', /validat|wrong shape|silently accept|malformed|type ?error|unsanitiz/i],
45
+ ['data-integrity', /\bdouble-?index|desync|out of sync|phantom|corrupt|torn row|lost update|inconsisten|silently lost\b/i],
46
+ ['input-validation', /validat|wrong shape|silently accept|malformed|type ?error|unsanitiz|prototype[- ]key|__proto__|prototype pollution/i],
46
47
  ['access-control', /\bbypass(?:es|ed)? the|rebinding|host header|allowlist|authoriz|access control\b/i],
47
48
  ['supply-chain', /\btyposquat|lockfile|install script|dependency confusion\b/i],
48
49
  ];
@@ -229,6 +230,7 @@ const GUARD_ADVICE = {
229
230
  'secret-exposure': 'Add a secret scan to the pre-commit hook for this path.',
230
231
  'semantic-corruption': 'Add golden tests asserting that meaning-bearing tokens survive transformation.',
231
232
  'idempotency': 'Assert f(f(x)) === f(x) in the test suite for every transform.',
233
+ 'data-integrity': 'Derive the secondary structure from the primary one and add a cheap staleness check, so drift self-heals instead of persisting.',
232
234
  'input-validation': 'Validate argument shape at every public boundary and throw — never silently coerce to a plausible default.',
233
235
  'access-control': 'Deny by default: an allowlist of permitted values, with every absent or empty case treated as invalid rather than waved through.',
234
236
  'supply-chain': 'Pin and verify dependencies; add a lockfile-drift check to CI.',
@@ -13,6 +13,7 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
+ const safeFs = require('../lib/safe-fs.js');
16
17
 
17
18
  let TOML;
18
19
  try {
@@ -310,7 +311,7 @@ function main() {
310
311
  return;
311
312
  }
312
313
 
313
- fs.writeFileSync(configPath, nextRaw, 'utf8');
314
+ safeFs.replaceFilePreservingMode(configPath, nextRaw);
314
315
  log('Done. Baseline Codex settings merged.');
315
316
  }
316
317
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
+ const safeFs = require('../lib/safe-fs.js');
22
23
  const { parseDisabledMcpServers } = require('../lib/mcp-config');
23
24
 
24
25
  let TOML;
@@ -319,7 +320,7 @@ function main() {
319
320
  if (updateMcp || hasRemovals) {
320
321
  for (const label of toRemoveLog) log(` [update] ${label}`);
321
322
  const cleaned = raw.replace(/\n+$/, '\n');
322
- fs.writeFileSync(configPath, cleaned + (toAppend.length > 0 ? appendText : ''), 'utf8');
323
+ safeFs.replaceFilePreservingMode(configPath, cleaned + (toAppend.length > 0 ? appendText : ''));
323
324
  } else {
324
325
  fs.appendFileSync(configPath, appendText, 'utf8');
325
326
  }
@@ -29,6 +29,7 @@ const fs = require('fs');
29
29
  const os = require('os');
30
30
  const path = require('path');
31
31
  const crypto = require('crypto');
32
+ const safeFs = require('../lib/safe-fs.js');
32
33
 
33
34
  const DEFAULT_DIR = process.env.KODELYTH_EVOLVE_DIR
34
35
  || path.join(os.homedir(), '.kodelythecc', 'evolve');
@@ -54,7 +55,7 @@ function safeReadJson(p, fallback) {
54
55
 
55
56
  function safeWriteJson(p, data) {
56
57
  try {
57
- fs.writeFileSync(p, JSON.stringify(data, null, 2));
58
+ safeFs.replaceFilePreservingMode(p, JSON.stringify(data, null, 2));
58
59
  return true;
59
60
  } catch { return false; }
60
61
  }
@@ -153,10 +153,29 @@ function replaceFileAtomic(absPath, contents, mode) {
153
153
  return absPath;
154
154
  }
155
155
 
156
+ /**
157
+ * Replace an existing file's contents atomically, keeping whatever permissions
158
+ * it already had (or `fallbackMode` when it does not exist yet).
159
+ *
160
+ * This is the safe replacement for `fs.writeFileSync(path, data)` on any file
161
+ * that holds state worth keeping — a user's IDE config, an accumulated stats
162
+ * file, a registry. `writeFileSync` opens with 'w', truncating to zero before
163
+ * writing, so a crash or a full disk part-way through leaves the user with a
164
+ * truncated file and no copy of the original anywhere.
165
+ */
166
+ function replaceFilePreservingMode(absPath, contents, fallbackMode = 0o644) {
167
+ let mode = fallbackMode;
168
+ try {
169
+ mode = fs.statSync(absPath).mode & 0o7777;
170
+ } catch { /* new file — use the fallback */ }
171
+ return replaceFileAtomic(absPath, contents, mode);
172
+ }
173
+
156
174
  module.exports = {
157
175
  resolveContained,
158
176
  statRegularFile,
159
177
  safeConfigDir,
160
178
  writeNewFile,
161
179
  replaceFileAtomic,
180
+ replaceFilePreservingMode,
162
181
  };
@@ -30,6 +30,7 @@
30
30
  const fs = require('fs');
31
31
  const os = require('os');
32
32
  const path = require('path');
33
+ const safeFs = require('../lib/safe-fs.js');
33
34
 
34
35
  const REGISTRY_DIR = process.env.KODELYTH_MCP_CLIENT_DIR
35
36
  || path.join(os.homedir(), '.kodelythecc');
@@ -54,7 +55,7 @@ function loadRegistry() {
54
55
 
55
56
  function saveRegistry(reg) {
56
57
  ensureDir(REGISTRY_DIR);
57
- fs.writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2) + '\n');
58
+ safeFs.replaceFilePreservingMode(REGISTRY_FILE, JSON.stringify(reg, null, 2) + '\n');
58
59
  }
59
60
 
60
61
  // ── Registry mutations ───────────────────────────────────────────────────────
@@ -31,6 +31,7 @@ const fs = require('fs');
31
31
  const os = require('os');
32
32
  const path = require('path');
33
33
  const crypto = require('crypto');
34
+ const safeFs = require('../lib/safe-fs.js');
34
35
 
35
36
  const MEMORY_DIR = process.env.KODELYTH_MEMORY_DIR
36
37
  || path.join(os.homedir(), '.kodelythecc', 'memory');
@@ -95,11 +96,15 @@ function loadAll() {
95
96
 
96
97
  function saveAll(instincts) {
97
98
  ensureDir();
98
- fs.writeFileSync(
99
- INSTINCTS_FILE,
100
- instincts.map(i => JSON.stringify(i)).join('\n') + '\n',
101
- 'utf8'
102
- );
99
+ // Atomic replace, not writeFileSync. 'w' truncates to zero before writing, so
100
+ // a crash or a concurrent reader can observe an empty file — the same defect
101
+ // that was measured wiping the memory log mid-rewrite. rename(2) is atomic:
102
+ // a reader sees either the old file or the new one, never a torn one.
103
+ const body = instincts.map(i => JSON.stringify(i)).join('\n') + '\n';
104
+ const mode = (() => {
105
+ try { return fs.statSync(INSTINCTS_FILE).mode & 0o7777; } catch { return 0o644; }
106
+ })();
107
+ safeFs.replaceFileAtomic(INSTINCTS_FILE, body, mode);
103
108
  }
104
109
 
105
110
  // ── Public API ────────────────────────────────────────────────────────────────
@@ -18,6 +18,7 @@ const fs = require('fs');
18
18
  const os = require('os');
19
19
  const path = require('path');
20
20
  const crypto = require('crypto');
21
+ const safeFs = require('../lib/safe-fs.js');
21
22
 
22
23
  // Auto-migrate legacy ~/.kodelyth/ → ~/.kodelythecc/ before we touch any path.
23
24
  try { require('../migrate-legacy').main(); } catch { /* best-effort */ }
@@ -72,8 +73,23 @@ function newMemoryId() {
72
73
  }
73
74
 
74
75
  // ── Index ────────────────────────────────────────────────────────────────────
76
+
77
+ // Any map keyed by user text MUST have a null prototype.
78
+ //
79
+ // With a normal object, `map['constructor']` returns Object.prototype's
80
+ // constructor — which is TRUTHY — so a `if (!map[key])` guard silently does not
81
+ // fire, and the code then reads `.docs` off a function and throws. The word
82
+ // "constructor" is ordinary programming vocabulary, so capturing a memory that
83
+ // merely mentions it used to break indexing AND poison recall() for good. Same
84
+ // for toString, valueOf, hasOwnProperty, __proto__, isPrototypeOf.
85
+ function nullMap(source) {
86
+ const m = Object.create(null);
87
+ if (source) for (const k of Object.keys(source)) m[k] = source[k];
88
+ return m;
89
+ }
90
+
75
91
  function emptyIndex() {
76
- return { tokens: {}, docCount: 0, avgDocLength: 0, totalLength: 0 };
92
+ return { tokens: nullMap(), docCount: 0, avgDocLength: 0, totalLength: 0 };
77
93
  }
78
94
 
79
95
  // A valid index for the current schema MUST have a `tokens` object and a
@@ -97,6 +113,19 @@ function loadIndex() {
97
113
  } catch {
98
114
  return rebuildIndex();
99
115
  }
116
+ // JSON.parse always produces objects with Object.prototype, so an index read
117
+ // back from disk carries the hazard again. Rebuild the token map without one.
118
+ if (isValidIndexSchema(parsed)) parsed.tokens = nullMap(parsed.tokens);
119
+
120
+ // Self-heal every way the two files can drift apart — a lost update from
121
+ // concurrent captures, a failed index write (EACCES/ENOSPC), a hand-edited
122
+ // log. Without this a memory could sit in the log and be invisible to
123
+ // recall() forever, which for a recall-driven store is indistinguishable
124
+ // from having lost it.
125
+ if (isValidIndexSchema(parsed) && parsed.logSize !== logSize()) {
126
+ return rebuildIndex();
127
+ }
128
+
100
129
  if (!isValidIndexSchema(parsed)) {
101
130
  // Stale or foreign schema on disk — rebuild from the source of truth
102
131
  // (the canonical rebuildIndex defined below rebuilds AND persists a valid index).
@@ -105,9 +134,28 @@ function loadIndex() {
105
134
  return parsed;
106
135
  }
107
136
 
137
+ // Stamp the log size this index was built from. The index is DERIVED state, so
138
+ // the cheapest correct thing is to notice when it no longer matches its source
139
+ // and rebuild — rather than trying to keep two files transactionally in sync.
140
+ function logSize() {
141
+ try { return fs.statSync(PATHS.log).size; } catch { return 0; }
142
+ }
143
+
108
144
  function saveIndex(index) {
109
145
  ensureDir(PATHS.dir);
110
- fs.writeFileSync(PATHS.index, JSON.stringify(index, null, 2));
146
+ safeFs.replaceFilePreservingMode(PATHS.index, JSON.stringify({ ...index, logSize: logSize() }, null, 2));
147
+ }
148
+
149
+ // A patch row does not change any searchable text, so the index stays valid —
150
+ // it just needs to learn the log's new size or the staleness check would force
151
+ // a needless rebuild on the next read.
152
+ function restampIndex() {
153
+ try {
154
+ if (!fs.existsSync(PATHS.index)) return;
155
+ const idx = JSON.parse(fs.readFileSync(PATHS.index, 'utf8'));
156
+ idx.logSize = logSize();
157
+ fs.writeFileSync(PATHS.index, JSON.stringify(idx, null, 2));
158
+ } catch { /* a broken index is rebuilt on the next load anyway */ }
111
159
  }
112
160
 
113
161
  function indexMemory(index, memory) {
@@ -116,7 +164,7 @@ function indexMemory(index, memory) {
116
164
  const length = tokens.length;
117
165
  if (length === 0) return index;
118
166
 
119
- const tokenFreq = {};
167
+ const tokenFreq = nullMap();
120
168
  for (const token of tokens) {
121
169
  tokenFreq[token] = (tokenFreq[token] || 0) + 1;
122
170
  }
@@ -147,7 +195,7 @@ function search(query, options = {}) {
147
195
  const b = 0.75;
148
196
  const N = index.docCount;
149
197
  const avgDl = index.avgDocLength || 1;
150
- const scores = {};
198
+ const scores = nullMap();
151
199
 
152
200
  for (const token of tokens) {
153
201
  const entry = index.tokens[token];
@@ -183,27 +231,81 @@ function search(query, options = {}) {
183
231
  }
184
232
 
185
233
  // ── Memory I/O ───────────────────────────────────────────────────────────────
234
+ // The log is append-only, so a memory may appear more than once: the original
235
+ // row, then PATCH rows appended by forget()/resolveMemory(). Fold by id with
236
+ // last-write-wins, then drop anything a patch marked deleted.
237
+ //
238
+ // This is what lets mutation be an append instead of a full-file rewrite. The
239
+ // rewrite was the bug: fs.writeFileSync opens with 'w', truncating to zero
240
+ // before writing, and a 6.3 MB store takes several syscalls to write back. A
241
+ // concurrent reader was measured observing the store at 0 bytes mid-write, and
242
+ // any reader doing its own read-modify-write would then persist that emptiness.
186
243
  function readMemories(ids = null) {
187
244
  if (!fs.existsSync(PATHS.log)) return ids ? {} : [];
188
245
  const wantSet = ids ? new Set(ids) : null;
189
246
  const lines = fs.readFileSync(PATHS.log, 'utf8').split('\n').filter(Boolean);
190
- const out = ids ? {} : [];
247
+
248
+ const folded = new Map(); // preserves first-seen order
191
249
  for (const line of lines) {
192
- let memory;
193
- try { memory = JSON.parse(line); } catch { continue; }
194
- if (memory.deleted) continue;
195
- if (wantSet) {
196
- if (wantSet.has(memory.id)) out[memory.id] = memory;
197
- } else {
198
- out.push(memory);
199
- }
250
+ let row;
251
+ try { row = JSON.parse(line); } catch { continue; }
252
+ if (!row || !row.id) continue;
253
+ if (wantSet && !wantSet.has(row.id)) continue;
254
+ const prior = folded.get(row.id);
255
+ if (prior) { folded.set(row.id, { ...prior, ...row }); continue; }
256
+ // No prior row means this is an orphan patch — a tombstone or a {resolved}
257
+ // marker whose original is missing (a hand-edited or truncated log). It is
258
+ // not a memory: promoting it would surface a phantom with problem=undefined
259
+ // into recall(), the dashboard, and the injected memory block.
260
+ if (row.problem === undefined) continue;
261
+ folded.set(row.id, row);
200
262
  }
263
+
264
+ const live = [...folded.values()].filter(m => !m.deleted);
265
+ if (!ids) return live;
266
+ const out = {};
267
+ for (const m of live) out[m.id] = m;
201
268
  return out;
202
269
  }
203
270
 
271
+ // Append a patch row for an existing memory. Returns false when the id is
272
+ // unknown, so callers keep their found/not-found contract.
273
+ function appendPatch(memoryId, patch) {
274
+ if (!fs.existsSync(PATHS.log)) return false;
275
+ const existing = readMemories([memoryId])[memoryId];
276
+ if (!existing) return false;
277
+ ensureDir(PATHS.dir);
278
+ fs.appendFileSync(PATHS.log, JSON.stringify({ id: memoryId, ...patch }) + '\n');
279
+ return true;
280
+ }
281
+
282
+ // If a previous append was interrupted (crash, SIGKILL, ENOSPC) the log ends
283
+ // mid-row with no terminator. Appending straight onto that fragment fuses two
284
+ // records into one unparseable line, so readMemories drops BOTH — and capture()
285
+ // still returns an id, telling the caller a memory was saved that does not
286
+ // exist. A leading newline quarantines the fragment on its own line, where the
287
+ // existing JSON.parse skip handles it correctly.
288
+ function endsWithNewline() {
289
+ try {
290
+ const { size } = fs.statSync(PATHS.log);
291
+ if (size === 0) return true;
292
+ const fd = fs.openSync(PATHS.log, 'r');
293
+ try {
294
+ const buf = Buffer.alloc(1);
295
+ fs.readSync(fd, buf, 0, 1, size - 1);
296
+ return buf[0] === 0x0a;
297
+ } finally {
298
+ fs.closeSync(fd);
299
+ }
300
+ } catch {
301
+ return true; // no file yet
302
+ }
303
+ }
304
+
204
305
  function appendMemory(memory) {
205
306
  ensureDir(PATHS.dir);
206
- fs.appendFileSync(PATHS.log, JSON.stringify(memory) + '\n');
307
+ const prefix = endsWithNewline() ? '' : '\n';
308
+ fs.appendFileSync(PATHS.log, prefix + JSON.stringify(memory) + '\n');
207
309
  }
208
310
 
209
311
  // ── Public API ───────────────────────────────────────────────────────────────
@@ -225,16 +327,24 @@ function capture({
225
327
  captured_at: new Date().toISOString(),
226
328
  problem: String(problem).slice(0, 500),
227
329
  approach: String(approach).slice(0, 2000),
228
- tags: Array.from(new Set(tags.map(String))).slice(0, 20),
330
+ // The COUNT was capped but not the length of each tag, so a single
331
+ // multi-megabyte tag was stored whole — bloating the log, the index, and
332
+ // the memory block injected at session start. Bound both.
333
+ tags: Array.from(new Set(tags.map(t => String(t).slice(0, 60)))).slice(0, 20),
229
334
  project: project ? projectHash(project) : null,
230
335
  project_path: project,
231
336
  language,
232
- files: files.slice(0, 20),
233
- gotchas: gotchas.slice(0, 10),
337
+ files: files.slice(0, 20).map(f => String(f).slice(0, 500)),
338
+ gotchas: gotchas.slice(0, 10).map(g => String(g).slice(0, 500)),
234
339
  source,
235
340
  };
236
- appendMemory(memory);
341
+ // Load the index BEFORE writing the row. If index.json is missing or invalid,
342
+ // loadIndex() falls through to rebuildIndex(), which re-reads the log — and if
343
+ // the new row were already there, it would be indexed once by the rebuild and
344
+ // again by indexMemory() below. That inflated docCount permanently and gave the
345
+ // doubled document exactly 2x its true BM25 score, on every cold start.
237
346
  const index = loadIndex();
347
+ appendMemory(memory);
238
348
  saveIndex(indexMemory(index, memory));
239
349
  return memory;
240
350
  }
@@ -262,22 +372,10 @@ function listAll() {
262
372
  }
263
373
 
264
374
  function forget(memoryId) {
265
- if (!fs.existsSync(PATHS.log)) return false;
266
- const lines = fs.readFileSync(PATHS.log, 'utf8').split('\n').filter(Boolean);
267
- let found = false;
268
- const updated = lines.map(line => {
269
- try {
270
- const m = JSON.parse(line);
271
- if (m.id === memoryId) {
272
- found = true;
273
- return JSON.stringify({ ...m, deleted: true, deleted_at: new Date().toISOString() });
274
- }
275
- return line;
276
- } catch {
277
- return line;
278
- }
279
- });
280
- fs.writeFileSync(PATHS.log, updated.join('\n') + '\n');
375
+ const found = appendPatch(memoryId, { deleted: true, deleted_at: new Date().toISOString() });
376
+ // Only rebuild when something actually changed. The old code rewrote the whole
377
+ // store even when the id was not found, paying the full destruction window for
378
+ // a no-op delete.
281
379
  if (found) rebuildIndex();
282
380
  return found;
283
381
  }
@@ -291,28 +389,10 @@ function forget(memoryId) {
291
389
  // Side-effect: also propagates to instincts.js if the instinct module exists,
292
390
  // so low-confidence instincts sourced from the same session get downgraded.
293
391
  function resolveMemory(memoryId, resolved) {
294
- if (!fs.existsSync(PATHS.log)) return false;
295
- const lines = fs.readFileSync(PATHS.log, 'utf8').split('\n').filter(Boolean);
296
- let found = false;
297
- const updated = lines.map(line => {
298
- try {
299
- const m = JSON.parse(line);
300
- if (m.id === memoryId && !m.deleted) {
301
- found = true;
302
- return JSON.stringify({
303
- ...m,
304
- resolved,
305
- resolved_at: new Date().toISOString(),
306
- });
307
- }
308
- return line;
309
- } catch {
310
- return line;
311
- }
312
- });
313
- if (found) {
314
- fs.writeFileSync(PATHS.log, updated.join('\n') + '\n');
315
- }
392
+ const found = appendPatch(memoryId, { resolved, resolved_at: new Date().toISOString() });
393
+ // This hook fires on every Edit/Write, so a full rebuild here would be costly
394
+ // on a large store. The patch adds no searchable text, so restamping is enough.
395
+ if (found) restampIndex();
316
396
  return found;
317
397
  }
318
398
 
@@ -323,13 +403,13 @@ function findMemoriesForFile(filePath, options = {}) {
323
403
  if (!fs.existsSync(PATHS.log)) return [];
324
404
 
325
405
  const normFile = path.normalize(filePath);
326
- const lines = fs.readFileSync(PATHS.log, 'utf8').split('\n').filter(Boolean);
406
+ // Go through readMemories so patch rows are folded in. Reading the log
407
+ // line-by-line here meant an appended `{resolved}` patch was never applied to
408
+ // its original row, so a resolved memory kept being returned.
327
409
  const matches = [];
328
410
 
329
- for (const line of lines) {
330
- let m;
331
- try { m = JSON.parse(line); } catch { continue; }
332
- if (m.deleted || m.resolved !== undefined) continue; // skip already resolved
411
+ for (const m of readMemories()) {
412
+ if (m.resolved !== undefined) continue; // skip already resolved
333
413
  if (!Array.isArray(m.files) || m.files.length === 0) continue;
334
414
  if (projectRoot && m.project_path && m.project_path !== projectRoot) continue;
335
415
 
@@ -399,9 +479,9 @@ function rebuildIndex() {
399
479
 
400
480
  function stats() {
401
481
  const memories = readMemories();
402
- const byProject = {};
403
- const byLanguage = {};
404
- const byTag = {};
482
+ const byProject = nullMap();
483
+ const byLanguage = nullMap();
484
+ const byTag = nullMap();
405
485
  for (const m of memories) {
406
486
  if (m.project) byProject[m.project] = (byProject[m.project] || 0) + 1;
407
487
  if (m.language) byLanguage[m.language] = (byLanguage[m.language] || 0) + 1;