kodelyth-ecc 2.12.0 → 2.13.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,84 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.13.0 — Arena run #3: eight bugs in the memory store (August 2026)
6
+
7
+ Pointed the arena at `scripts/memory` — the persistent BM25 store every other
8
+ subsystem trusts, and the one place where a bug costs the user real accumulated
9
+ work. Round 1 found **8 findings, all 8 confirmed by executed repro**. Round 2
10
+ attacked the fixes across 7 vectors and found **1 regression, which is fixed**.
11
+
12
+ ### Fixed — a single English word bricked memory search
13
+
14
+ Capturing a memory containing the word **"constructor"** crashed indexing, and
15
+ `recall()` then threw on every subsequent call:
16
+
17
+ ```js
18
+ if (!index.tokens[token]) index.tokens[token] = { docs: [], df: 0 };
19
+ index.tokens[token].docs.push(...) // .docs is undefined
20
+ ```
21
+
22
+ `index.tokens['constructor']` returns `Object.prototype.constructor` — **truthy**
23
+ — so the guard never fires. Same for `toString`, `valueOf`, `hasOwnProperty`,
24
+ `__proto__`, `isPrototypeOf`. No attacker required: writing one memory about a
25
+ constructor, or about overriding `toString`, was enough. The blast radius was
26
+ everything that reads memory — MCP server, dashboard, CLI, and the session-start
27
+ injection hook.
28
+
29
+ Fixed at 7 sites with null-prototype maps, including sanitising the index after
30
+ `JSON.parse` (which re-introduces a normal prototype). Those tokens are now
31
+ searchable, not merely non-crashing.
32
+
33
+ ### Fixed — the log is genuinely append-only now
34
+
35
+ `forget()` and `resolveMemory()` read the **entire** log, mutated it in memory,
36
+ and wrote it back with `fs.writeFileSync` — which opens with `'w'`, truncating to
37
+ zero before writing. Measured directly: sampling file size during a rewrite of a
38
+ 6.3 MB store observed it at **0.00 MB**, with 32 torn reads in 1423 samples.
39
+
40
+ Both now **append a patch row**, and `readMemories()` folds rows by id with
41
+ last-write-wins. That is what the file's own header always claimed it was. The
42
+ truncate window is gone — 0 torn reads across 1477 samples — and 8 concurrent
43
+ deletions now all apply, where previously only 3 of 8 survived.
44
+
45
+ *Honest scope:* the truncate window is proven, and a reader doing its own
46
+ read-modify-write inside it would persist the emptiness. I could **not**
47
+ reproduce a full store wipe end-to-end; what I measured was lost deletions.
48
+
49
+ ### Fixed — memories that were invisible to search, forever
50
+
51
+ The log and the index were written by separate calls with no reconciliation, and
52
+ `loadIndex()` only rebuilt when the index was *missing* or schema-invalid — never
53
+ when merely incomplete. A memory could sit in the log and return zero hits for
54
+ its own exact text, permanently. The index now stamps the log size it was built
55
+ from and rebuilds on any drift, which self-heals every cause at the price of one
56
+ `stat()`.
57
+
58
+ ### Also fixed
59
+
60
+ - `forget()` rewrote the whole store even when the id was **not found**.
61
+ - A crash mid-append left a newline-less row; the next `capture()` fused into it
62
+ and was silently lost while returning an id and reporting success.
63
+ - `capture()` double-indexed on every cold start, inflating `docCount` and giving
64
+ that memory exactly 2x its true BM25 score.
65
+ - `tags`/`files`/`gotchas` capped their *count* but not each entry's length — a
66
+ single 5 MB tag was stored whole.
67
+ - `instincts.js` had the identical rewrite pattern; it now uses
68
+ `safeFs.replaceFileAtomic`.
69
+
70
+ ### Round 2 — one regression, caught and fixed
71
+
72
+ The new fold promoted an orphan patch row to a phantom memory with
73
+ `problem: undefined`, which would have flowed into `recall()`, the dashboard, and
74
+ the injected session block. A row with no prior and no `problem` is a patch, not
75
+ a memory.
76
+
77
+ **Backward compatible:** existing logs read correctly — old full-row tombstones
78
+ fold the same way. One real duplicate id in the test store is now correctly
79
+ deduped rather than returned twice.
80
+
81
+ **569 tests passing**, up from 554.
82
+
5
83
  ## v2.12.0 — `scripts/lib/safe-fs.js`: the guard the arena asked for (August 2026)
6
84
 
7
85
  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/ → 569 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.13.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.12.0",
3
+ "version": "2.13.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",
@@ -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 ────────────────────────────────────────────────────────────────
@@ -72,8 +72,23 @@ function newMemoryId() {
72
72
  }
73
73
 
74
74
  // ── Index ────────────────────────────────────────────────────────────────────
75
+
76
+ // Any map keyed by user text MUST have a null prototype.
77
+ //
78
+ // With a normal object, `map['constructor']` returns Object.prototype's
79
+ // constructor — which is TRUTHY — so a `if (!map[key])` guard silently does not
80
+ // fire, and the code then reads `.docs` off a function and throws. The word
81
+ // "constructor" is ordinary programming vocabulary, so capturing a memory that
82
+ // merely mentions it used to break indexing AND poison recall() for good. Same
83
+ // for toString, valueOf, hasOwnProperty, __proto__, isPrototypeOf.
84
+ function nullMap(source) {
85
+ const m = Object.create(null);
86
+ if (source) for (const k of Object.keys(source)) m[k] = source[k];
87
+ return m;
88
+ }
89
+
75
90
  function emptyIndex() {
76
- return { tokens: {}, docCount: 0, avgDocLength: 0, totalLength: 0 };
91
+ return { tokens: nullMap(), docCount: 0, avgDocLength: 0, totalLength: 0 };
77
92
  }
78
93
 
79
94
  // A valid index for the current schema MUST have a `tokens` object and a
@@ -97,6 +112,19 @@ function loadIndex() {
97
112
  } catch {
98
113
  return rebuildIndex();
99
114
  }
115
+ // JSON.parse always produces objects with Object.prototype, so an index read
116
+ // back from disk carries the hazard again. Rebuild the token map without one.
117
+ if (isValidIndexSchema(parsed)) parsed.tokens = nullMap(parsed.tokens);
118
+
119
+ // Self-heal every way the two files can drift apart — a lost update from
120
+ // concurrent captures, a failed index write (EACCES/ENOSPC), a hand-edited
121
+ // log. Without this a memory could sit in the log and be invisible to
122
+ // recall() forever, which for a recall-driven store is indistinguishable
123
+ // from having lost it.
124
+ if (isValidIndexSchema(parsed) && parsed.logSize !== logSize()) {
125
+ return rebuildIndex();
126
+ }
127
+
100
128
  if (!isValidIndexSchema(parsed)) {
101
129
  // Stale or foreign schema on disk — rebuild from the source of truth
102
130
  // (the canonical rebuildIndex defined below rebuilds AND persists a valid index).
@@ -105,9 +133,28 @@ function loadIndex() {
105
133
  return parsed;
106
134
  }
107
135
 
136
+ // Stamp the log size this index was built from. The index is DERIVED state, so
137
+ // the cheapest correct thing is to notice when it no longer matches its source
138
+ // and rebuild — rather than trying to keep two files transactionally in sync.
139
+ function logSize() {
140
+ try { return fs.statSync(PATHS.log).size; } catch { return 0; }
141
+ }
142
+
108
143
  function saveIndex(index) {
109
144
  ensureDir(PATHS.dir);
110
- fs.writeFileSync(PATHS.index, JSON.stringify(index, null, 2));
145
+ fs.writeFileSync(PATHS.index, JSON.stringify({ ...index, logSize: logSize() }, null, 2));
146
+ }
147
+
148
+ // A patch row does not change any searchable text, so the index stays valid —
149
+ // it just needs to learn the log's new size or the staleness check would force
150
+ // a needless rebuild on the next read.
151
+ function restampIndex() {
152
+ try {
153
+ if (!fs.existsSync(PATHS.index)) return;
154
+ const idx = JSON.parse(fs.readFileSync(PATHS.index, 'utf8'));
155
+ idx.logSize = logSize();
156
+ fs.writeFileSync(PATHS.index, JSON.stringify(idx, null, 2));
157
+ } catch { /* a broken index is rebuilt on the next load anyway */ }
111
158
  }
112
159
 
113
160
  function indexMemory(index, memory) {
@@ -116,7 +163,7 @@ function indexMemory(index, memory) {
116
163
  const length = tokens.length;
117
164
  if (length === 0) return index;
118
165
 
119
- const tokenFreq = {};
166
+ const tokenFreq = nullMap();
120
167
  for (const token of tokens) {
121
168
  tokenFreq[token] = (tokenFreq[token] || 0) + 1;
122
169
  }
@@ -147,7 +194,7 @@ function search(query, options = {}) {
147
194
  const b = 0.75;
148
195
  const N = index.docCount;
149
196
  const avgDl = index.avgDocLength || 1;
150
- const scores = {};
197
+ const scores = nullMap();
151
198
 
152
199
  for (const token of tokens) {
153
200
  const entry = index.tokens[token];
@@ -183,27 +230,81 @@ function search(query, options = {}) {
183
230
  }
184
231
 
185
232
  // ── Memory I/O ───────────────────────────────────────────────────────────────
233
+ // The log is append-only, so a memory may appear more than once: the original
234
+ // row, then PATCH rows appended by forget()/resolveMemory(). Fold by id with
235
+ // last-write-wins, then drop anything a patch marked deleted.
236
+ //
237
+ // This is what lets mutation be an append instead of a full-file rewrite. The
238
+ // rewrite was the bug: fs.writeFileSync opens with 'w', truncating to zero
239
+ // before writing, and a 6.3 MB store takes several syscalls to write back. A
240
+ // concurrent reader was measured observing the store at 0 bytes mid-write, and
241
+ // any reader doing its own read-modify-write would then persist that emptiness.
186
242
  function readMemories(ids = null) {
187
243
  if (!fs.existsSync(PATHS.log)) return ids ? {} : [];
188
244
  const wantSet = ids ? new Set(ids) : null;
189
245
  const lines = fs.readFileSync(PATHS.log, 'utf8').split('\n').filter(Boolean);
190
- const out = ids ? {} : [];
246
+
247
+ const folded = new Map(); // preserves first-seen order
191
248
  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
- }
249
+ let row;
250
+ try { row = JSON.parse(line); } catch { continue; }
251
+ if (!row || !row.id) continue;
252
+ if (wantSet && !wantSet.has(row.id)) continue;
253
+ const prior = folded.get(row.id);
254
+ if (prior) { folded.set(row.id, { ...prior, ...row }); continue; }
255
+ // No prior row means this is an orphan patch — a tombstone or a {resolved}
256
+ // marker whose original is missing (a hand-edited or truncated log). It is
257
+ // not a memory: promoting it would surface a phantom with problem=undefined
258
+ // into recall(), the dashboard, and the injected memory block.
259
+ if (row.problem === undefined) continue;
260
+ folded.set(row.id, row);
200
261
  }
262
+
263
+ const live = [...folded.values()].filter(m => !m.deleted);
264
+ if (!ids) return live;
265
+ const out = {};
266
+ for (const m of live) out[m.id] = m;
201
267
  return out;
202
268
  }
203
269
 
270
+ // Append a patch row for an existing memory. Returns false when the id is
271
+ // unknown, so callers keep their found/not-found contract.
272
+ function appendPatch(memoryId, patch) {
273
+ if (!fs.existsSync(PATHS.log)) return false;
274
+ const existing = readMemories([memoryId])[memoryId];
275
+ if (!existing) return false;
276
+ ensureDir(PATHS.dir);
277
+ fs.appendFileSync(PATHS.log, JSON.stringify({ id: memoryId, ...patch }) + '\n');
278
+ return true;
279
+ }
280
+
281
+ // If a previous append was interrupted (crash, SIGKILL, ENOSPC) the log ends
282
+ // mid-row with no terminator. Appending straight onto that fragment fuses two
283
+ // records into one unparseable line, so readMemories drops BOTH — and capture()
284
+ // still returns an id, telling the caller a memory was saved that does not
285
+ // exist. A leading newline quarantines the fragment on its own line, where the
286
+ // existing JSON.parse skip handles it correctly.
287
+ function endsWithNewline() {
288
+ try {
289
+ const { size } = fs.statSync(PATHS.log);
290
+ if (size === 0) return true;
291
+ const fd = fs.openSync(PATHS.log, 'r');
292
+ try {
293
+ const buf = Buffer.alloc(1);
294
+ fs.readSync(fd, buf, 0, 1, size - 1);
295
+ return buf[0] === 0x0a;
296
+ } finally {
297
+ fs.closeSync(fd);
298
+ }
299
+ } catch {
300
+ return true; // no file yet
301
+ }
302
+ }
303
+
204
304
  function appendMemory(memory) {
205
305
  ensureDir(PATHS.dir);
206
- fs.appendFileSync(PATHS.log, JSON.stringify(memory) + '\n');
306
+ const prefix = endsWithNewline() ? '' : '\n';
307
+ fs.appendFileSync(PATHS.log, prefix + JSON.stringify(memory) + '\n');
207
308
  }
208
309
 
209
310
  // ── Public API ───────────────────────────────────────────────────────────────
@@ -225,16 +326,24 @@ function capture({
225
326
  captured_at: new Date().toISOString(),
226
327
  problem: String(problem).slice(0, 500),
227
328
  approach: String(approach).slice(0, 2000),
228
- tags: Array.from(new Set(tags.map(String))).slice(0, 20),
329
+ // The COUNT was capped but not the length of each tag, so a single
330
+ // multi-megabyte tag was stored whole — bloating the log, the index, and
331
+ // the memory block injected at session start. Bound both.
332
+ tags: Array.from(new Set(tags.map(t => String(t).slice(0, 60)))).slice(0, 20),
229
333
  project: project ? projectHash(project) : null,
230
334
  project_path: project,
231
335
  language,
232
- files: files.slice(0, 20),
233
- gotchas: gotchas.slice(0, 10),
336
+ files: files.slice(0, 20).map(f => String(f).slice(0, 500)),
337
+ gotchas: gotchas.slice(0, 10).map(g => String(g).slice(0, 500)),
234
338
  source,
235
339
  };
236
- appendMemory(memory);
340
+ // Load the index BEFORE writing the row. If index.json is missing or invalid,
341
+ // loadIndex() falls through to rebuildIndex(), which re-reads the log — and if
342
+ // the new row were already there, it would be indexed once by the rebuild and
343
+ // again by indexMemory() below. That inflated docCount permanently and gave the
344
+ // doubled document exactly 2x its true BM25 score, on every cold start.
237
345
  const index = loadIndex();
346
+ appendMemory(memory);
238
347
  saveIndex(indexMemory(index, memory));
239
348
  return memory;
240
349
  }
@@ -262,22 +371,10 @@ function listAll() {
262
371
  }
263
372
 
264
373
  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');
374
+ const found = appendPatch(memoryId, { deleted: true, deleted_at: new Date().toISOString() });
375
+ // Only rebuild when something actually changed. The old code rewrote the whole
376
+ // store even when the id was not found, paying the full destruction window for
377
+ // a no-op delete.
281
378
  if (found) rebuildIndex();
282
379
  return found;
283
380
  }
@@ -291,28 +388,10 @@ function forget(memoryId) {
291
388
  // Side-effect: also propagates to instincts.js if the instinct module exists,
292
389
  // so low-confidence instincts sourced from the same session get downgraded.
293
390
  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
- }
391
+ const found = appendPatch(memoryId, { resolved, resolved_at: new Date().toISOString() });
392
+ // This hook fires on every Edit/Write, so a full rebuild here would be costly
393
+ // on a large store. The patch adds no searchable text, so restamping is enough.
394
+ if (found) restampIndex();
316
395
  return found;
317
396
  }
318
397
 
@@ -323,13 +402,13 @@ function findMemoriesForFile(filePath, options = {}) {
323
402
  if (!fs.existsSync(PATHS.log)) return [];
324
403
 
325
404
  const normFile = path.normalize(filePath);
326
- const lines = fs.readFileSync(PATHS.log, 'utf8').split('\n').filter(Boolean);
405
+ // Go through readMemories so patch rows are folded in. Reading the log
406
+ // line-by-line here meant an appended `{resolved}` patch was never applied to
407
+ // its original row, so a resolved memory kept being returned.
327
408
  const matches = [];
328
409
 
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
410
+ for (const m of readMemories()) {
411
+ if (m.resolved !== undefined) continue; // skip already resolved
333
412
  if (!Array.isArray(m.files) || m.files.length === 0) continue;
334
413
  if (projectRoot && m.project_path && m.project_path !== projectRoot) continue;
335
414
 
@@ -399,9 +478,9 @@ function rebuildIndex() {
399
478
 
400
479
  function stats() {
401
480
  const memories = readMemories();
402
- const byProject = {};
403
- const byLanguage = {};
404
- const byTag = {};
481
+ const byProject = nullMap();
482
+ const byLanguage = nullMap();
483
+ const byTag = nullMap();
405
484
  for (const m of memories) {
406
485
  if (m.project) byProject[m.project] = (byProject[m.project] || 0) + 1;
407
486
  if (m.language) byLanguage[m.language] = (byLanguage[m.language] || 0) + 1;