kodelyth-ecc 2.11.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,132 @@
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
+
83
+ ## v2.12.0 — `scripts/lib/safe-fs.js`: the guard the arena asked for (August 2026)
84
+
85
+ Across two arena runs the **same containment bug was confirmed four times** in
86
+ three unrelated files — `terse/compress.js`, `dashboard/data.js`,
87
+ `dashboard/server.js`. Every instance was this shape:
88
+
89
+ ```js
90
+ const abs = path.join(root, userInput);
91
+ if (!abs.startsWith(root + path.sep)) return null; // lexical only
92
+ fs.readFileSync(abs); // follows symlinks
93
+ ```
94
+
95
+ `path.join` and `path.resolve` normalise `..`, so a *textual* escape is caught.
96
+ Neither resolves symlinks. A link sitting lexically inside the root passes the
97
+ check while its target is anywhere on disk.
98
+
99
+ Four spot fixes would have been a fifth bug waiting. The arena's guard proposal
100
+ said to build the guard instead, so here it is.
101
+
102
+ ### Added — `scripts/lib/safe-fs.js`
103
+
104
+ - **`resolveContained(candidate, root)`** — canonicalises both sides, so a link
105
+ is judged by where it *points*, not where it sits. Rejects intermediate
106
+ directory symlinks and dangling links; still allows a symlinked root to serve
107
+ its own files, and a link that stays inside.
108
+ - **`statRegularFile(abs)`** — refuses symlinks and non-regular files outright,
109
+ for callers about to rewrite a file.
110
+ - **`safeConfigDir(value, fallback)`** — inspects the **raw** env value for `..`
111
+ before resolving. (The first draft resolved first and then looked for `..` —
112
+ dead code, since `path.resolve` collapses it. Its own test caught that.)
113
+ - **`writeNewFile(dest, data, mode)`** — `O_EXCL` so a dangling symlink cannot
114
+ redirect the write, plus `fchmod` so a restrictive umask cannot silently
115
+ narrow the mode.
116
+ - **`replaceFileAtomic(abs, contents, mode)`** — random temp name, rename, and
117
+ `finally`-unlink so a crash leaves no stray copy of the document.
118
+
119
+ **13 raw `realpath`/`lstat`/`openSync`/`rename` calls across four files became
120
+ zero.** All four now route through one 157-line module with **19 tests**.
121
+
122
+ ### Fixed — the ledger env var, which was never actually fixed
123
+
124
+ `KODELYTH_TERSE_DIR` was confirmed unvalidated in the first arena run, and the
125
+ run recorded it as *addressed* when no fix had been written. That accounting was
126
+ wrong. It now goes through `safeConfigDir`: a raw `..` falls back to the default,
127
+ a clean absolute path is still honoured.
128
+
129
+ **554 tests passing**, up from 535.
130
+
5
131
  ## v2.11.0 — Arena run #2: three containment bugs in the dashboard (August 2026)
6
132
 
7
133
  Pointed the arena at `scripts/dashboard` — the localhost HTTP server that serves
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/ → 535 passing tests across 29 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.11.0
1
+ 2.13.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.11.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",
@@ -19,6 +19,7 @@
19
19
  const fs = require('fs');
20
20
  const os = require('os');
21
21
  const path = require('path');
22
+ const safeFs = require('../lib/safe-fs.js');
22
23
 
23
24
  const ROOT = path.resolve(__dirname, '..', '..');
24
25
 
@@ -305,30 +306,12 @@ function sessionsList({ coordRoot = defaultCoordRoot(), limit = 30 } = {}) {
305
306
 
306
307
  function sessionDetail({ session, coordRoot = defaultCoordRoot() } = {}) {
307
308
  if (!session || session === '..' || session === '.') return null;
308
- const dir = path.join(coordRoot, session);
309
- // Containment check ensure the resolved path stays within coordRoot.
310
- // path.join normalises "..", so a textual escape is caught here.
311
- if (!dir.startsWith(coordRoot + path.sep) && dir !== coordRoot) return null;
312
- if (!fs.existsSync(dir)) return null;
313
-
314
- // ...but a SYMLINK is not a textual escape: the link itself sits inside
315
- // coordRoot and passes the check above, while its target does not. Reading
316
- // through it hands the API task/handoff/status excerpts from anywhere on
317
- // disk. realpath resolves the link so containment is checked against the
318
- // real destination, which is the only location that matters.
319
- let realDir;
320
- try {
321
- realDir = fs.realpathSync(dir);
322
- } catch {
323
- return null; // dangling or unreadable link
324
- }
325
- let realRoot;
326
- try {
327
- realRoot = fs.realpathSync(coordRoot);
328
- } catch {
329
- realRoot = coordRoot; // root itself may legitimately not be a link
330
- }
331
- if (!realDir.startsWith(realRoot + path.sep) && realDir !== realRoot) return null;
309
+ // Containment, including symlink resolution — see scripts/lib/safe-fs.js.
310
+ // A link sitting inside coordRoot passes a lexical check while pointing
311
+ // anywhere on disk, which handed the API task/handoff/status excerpts from
312
+ // outside the root.
313
+ const dir = safeFs.resolveContained(session, coordRoot);
314
+ if (!dir) return null;
332
315
  const workers = safeReadDir(dir).filter(e => e.isDirectory());
333
316
  return {
334
317
  session,
@@ -24,6 +24,7 @@ const http = require('http');
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const os = require('os');
27
+ const safeFs = require('../lib/safe-fs.js');
27
28
  const { execFileSync } = require('child_process');
28
29
 
29
30
  const data = require('./data.js');
@@ -153,27 +154,13 @@ function resolveStatic(reqPath, baseDir = STATIC_DIR) {
153
154
  const decoded = decodeURIComponent(reqPath.replace(/^\/+/, ''));
154
155
  if (decoded.includes('..')) return null;
155
156
  if (decoded === '' || decoded === '/') return path.join(baseDir, 'index.html');
156
- const abs = path.resolve(baseDir, decoded);
157
- if (!abs.startsWith(baseDir + path.sep) && abs !== path.join(baseDir, 'index.html')) return null;
158
-
159
- // The check above is purely lexical, and path.resolve does not resolve
160
- // symlinks — so a link sitting lexically inside baseDir passes it while
161
- // readFile follows it to a target anywhere on disk. Canonicalize and re-check
162
- // against the real destination, which is the only one that matters.
163
- let real;
164
- try {
165
- real = fs.realpathSync(abs);
166
- } catch {
167
- return null; // missing file or dangling link — 404 either way
168
- }
169
- let realBase;
170
- try {
171
- realBase = fs.realpathSync(baseDir);
172
- } catch {
173
- realBase = baseDir;
174
- }
175
- if (!real.startsWith(realBase + path.sep) && real !== path.join(realBase, 'index.html')) return null;
176
- return abs;
157
+ // Containment, including symlink resolution — see scripts/lib/safe-fs.js.
158
+ // path.resolve normalises ".." but does not resolve symlinks, so a link
159
+ // sitting lexically inside baseDir used to pass while readFile followed it
160
+ // anywhere on disk.
161
+ return safeFs.resolveContained(decoded, baseDir, {
162
+ allowExact: path.join(baseDir, 'index.html'),
163
+ });
177
164
  }
178
165
 
179
166
  // ── route handlers ───────────────────────────────────────────────────────────
@@ -0,0 +1,162 @@
1
+ // scripts/lib/safe-fs.js
2
+ //
3
+ // Path containment and safe file replacement, in one place.
4
+ //
5
+ // This module exists because the same bug was confirmed four times across three
6
+ // unrelated files — `scripts/terse/compress.js`, `scripts/dashboard/data.js`,
7
+ // and `scripts/dashboard/server.js`. Every instance was the same mistake:
8
+ //
9
+ // const abs = path.join(root, userInput);
10
+ // if (!abs.startsWith(root + path.sep)) return null; // lexical only
11
+ // fs.readFileSync(abs); // follows symlinks
12
+ //
13
+ // `path.join` and `path.resolve` normalise `..`, so a *textual* escape is
14
+ // caught. Neither resolves symlinks. A link sitting lexically inside the root
15
+ // passes the check while its target is anywhere on disk, and the read follows
16
+ // it. Four spot fixes would have been a fifth bug waiting; this is the guard.
17
+ //
18
+ // Everything here fails closed: on any doubt, return null or throw.
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+
26
+ // ── Containment ─────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Resolve `candidate` and confirm it really lives inside `root`.
30
+ *
31
+ * Both sides are canonicalised, so a symlink is judged by where it POINTS, not
32
+ * where it sits. Returns the resolved absolute path, or null if it escapes, is
33
+ * missing, or cannot be read.
34
+ *
35
+ * `allowExact` names a path that may equal the root itself (some callers serve
36
+ * an index file at the boundary).
37
+ */
38
+ function resolveContained(candidate, root, { allowExact = null } = {}) {
39
+ if (!candidate || !root) return null;
40
+
41
+ const absRoot = path.resolve(root);
42
+ const abs = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(absRoot, candidate);
43
+
44
+ // Cheap lexical rejection first — catches `..` without touching the disk.
45
+ const lexicallyInside = abs === absRoot || abs.startsWith(absRoot + path.sep);
46
+ if (!lexicallyInside && abs !== allowExact) return null;
47
+
48
+ // The check that actually matters. realpath resolves every symlink in the
49
+ // path, including intermediate directories.
50
+ let real;
51
+ try {
52
+ real = fs.realpathSync(abs);
53
+ } catch {
54
+ return null; // missing, dangling link, or unreadable
55
+ }
56
+
57
+ let realRoot;
58
+ try {
59
+ realRoot = fs.realpathSync(absRoot);
60
+ } catch {
61
+ realRoot = absRoot; // the root itself may legitimately not exist yet
62
+ }
63
+
64
+ const reallyInside = real === realRoot || real.startsWith(realRoot + path.sep);
65
+ if (!reallyInside && real !== allowExact) return null;
66
+
67
+ // Callers get the pre-realpath path so user-facing output keeps the name the
68
+ // caller asked for; containment has already been proven against the target.
69
+ return abs;
70
+ }
71
+
72
+ /**
73
+ * Stat a path that must be a regular file, refusing symlinks outright.
74
+ *
75
+ * Used where the caller is about to REWRITE the file: following a link there
76
+ * means writing through it to a target the user never named, and copying its
77
+ * contents into a backup beside the link.
78
+ */
79
+ function statRegularFile(absPath) {
80
+ let st;
81
+ try {
82
+ st = fs.lstatSync(absPath);
83
+ } catch {
84
+ throw new Error(`file not found: ${absPath}`);
85
+ }
86
+ if (st.isSymbolicLink()) throw new Error(`refusing to operate on a symlink: ${absPath}`);
87
+ if (!st.isFile()) throw new Error(`not a regular file: ${absPath}`);
88
+ return st;
89
+ }
90
+
91
+ /**
92
+ * A directory path taken from configuration (an env var, usually).
93
+ *
94
+ * This is a trusted-config surface — anyone who can set your environment
95
+ * already has leverage — but an unnormalised value silently creates trees
96
+ * wherever `..` points, so normalise and reject the obvious escapes.
97
+ */
98
+ function safeConfigDir(value, fallback) {
99
+ if (!value) return fallback;
100
+ const raw = String(value);
101
+
102
+ // Inspect the RAW value, not the resolved one. path.resolve collapses `..`
103
+ // before any check could see it — resolving first and then looking for `..`
104
+ // is dead code that always passes.
105
+ if (raw.split(/[\\/]/).includes('..')) return fallback;
106
+
107
+ return path.resolve(raw);
108
+ }
109
+
110
+ // ── Writing ─────────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Create a NEW file, refusing to follow a link or overwrite anything.
114
+ *
115
+ * `wx` is O_CREAT|O_EXCL|O_WRONLY: if `dest` exists — including as a *dangling*
116
+ * symlink, which `fs.existsSync` reports as absent — the open fails instead of
117
+ * writing through the link to a path someone else chose.
118
+ *
119
+ * The mode is applied twice on purpose. `open` filters its mode argument
120
+ * through the process umask, so a 0644 original would come back 0600 under
121
+ * `umask 077`; `fchmod` ignores the umask and restores it exactly. Passing it
122
+ * to `open` as well means the file is never briefly world-readable.
123
+ */
124
+ function writeNewFile(dest, data, mode) {
125
+ const fd = fs.openSync(dest, 'wx', mode);
126
+ try {
127
+ fs.writeFileSync(fd, data);
128
+ fs.fchmodSync(fd, mode);
129
+ } finally {
130
+ fs.closeSync(fd);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Replace a file's contents atomically, preserving its permissions.
136
+ *
137
+ * Writes to a randomly-named sibling and renames over the target, so a crash
138
+ * cannot leave the user with a truncated file. The name is random rather than
139
+ * pid-based because a predictable one lets another process pre-plant a symlink
140
+ * there and capture the write. A failed rename unlinks the temp rather than
141
+ * leaving the document's contents in a stray world-readable file.
142
+ */
143
+ function replaceFileAtomic(absPath, contents, mode) {
144
+ const tmp = `${absPath}.tmp-${crypto.randomBytes(8).toString('hex')}`;
145
+ let renamed = false;
146
+ try {
147
+ writeNewFile(tmp, contents, mode);
148
+ fs.renameSync(tmp, absPath);
149
+ renamed = true;
150
+ } finally {
151
+ if (!renamed) { try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ } }
152
+ }
153
+ return absPath;
154
+ }
155
+
156
+ module.exports = {
157
+ resolveContained,
158
+ statRegularFile,
159
+ safeConfigDir,
160
+ writeNewFile,
161
+ replaceFileAtomic,
162
+ };
@@ -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;
@@ -12,6 +12,7 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const crypto = require('crypto');
15
+ const safeFs = require('../lib/safe-fs.js');
15
16
 
16
17
  // ── Substitutions: wordy connective → short form ────────────────────────────
17
18
  // These run BEFORE deletions. Order matters: "due to the fact that" must become
@@ -258,27 +259,6 @@ function compressText(source) {
258
259
  };
259
260
  }
260
261
 
261
- // Write `data` to `dest` with the given mode, refusing to follow a symlink and
262
- // refusing to overwrite anything that already exists.
263
- //
264
- // `wx` is O_CREAT|O_EXCL|O_WRONLY: if `dest` exists — including as a *dangling*
265
- // symlink, which `fs.existsSync` reports as absent — the open fails instead of
266
- // silently writing through the link to a path the attacker chose. The mode is
267
- // applied at create time so the file is never briefly world-readable.
268
- // The mode is passed to `open` so the file is never briefly world-readable, and
269
- // then applied again with fchmod: `open` filters its mode argument through the
270
- // process umask, so under `umask 077` a 0644 original would come back 0600.
271
- // fchmod ignores the umask, so the original permissions survive exactly.
272
- function writeNewFile(dest, data, mode) {
273
- const fd = fs.openSync(dest, 'wx', mode);
274
- try {
275
- fs.writeFileSync(fd, data);
276
- fs.fchmodSync(fd, mode);
277
- } finally {
278
- fs.closeSync(fd);
279
- }
280
- }
281
-
282
262
  // ── Public: compress a file, optionally write ───────────────────────────────
283
263
  //
284
264
  // Note on paths: `filePath` is deliberately unconfined — compressing
@@ -289,21 +269,9 @@ function writeNewFile(dest, data, mode) {
289
269
  function compressFile(filePath, { write = false, backup = true } = {}) {
290
270
  const abs = path.resolve(filePath);
291
271
 
292
- // lstat, not existsSync: we need to know whether this is a symlink *before*
293
- // reading through it. Following one would copy the link target's bytes into a
294
- // backup beside the link — which is how a 0600 secret ends up in a 0644 file.
295
- let st;
296
- try {
297
- st = fs.lstatSync(abs);
298
- } catch {
299
- throw new Error(`file not found: ${abs}`);
300
- }
301
- if (st.isSymbolicLink()) {
302
- throw new Error(`refusing to compress a symlink: ${abs}`);
303
- }
304
- if (!st.isFile()) {
305
- throw new Error(`not a regular file: ${abs}`);
306
- }
272
+ // Refuses symlinks: following one would copy the link target's bytes into a
273
+ // backup beside the link, which is how a 0600 secret ends up in a 0644 file.
274
+ const st = safeFs.statRegularFile(abs);
307
275
 
308
276
  // Check the size from the stat we already have, before reading. Otherwise an
309
277
  // oversized file is pulled fully into memory only to be rejected a line later.
@@ -336,7 +304,7 @@ function compressFile(filePath, { write = false, backup = true } = {}) {
336
304
  backupPath = `${abs}.pre-terse.bak`;
337
305
  for (let n = 1; ; n++) {
338
306
  try {
339
- writeNewFile(backupPath, source, mode);
307
+ safeFs.writeNewFile(backupPath, source, mode);
340
308
  break;
341
309
  } catch (err) {
342
310
  if (err.code !== 'EEXIST') throw err;
@@ -346,19 +314,8 @@ function compressFile(filePath, { write = false, backup = true } = {}) {
346
314
  }
347
315
  }
348
316
 
349
- // Write via temp + rename so a crash cannot truncate the user's file. The
350
- // suffix is random rather than the pid: a predictable name lets another
351
- // process pre-plant a symlink there and capture the write.
352
- const tmp = `${abs}.terse-tmp-${crypto.randomBytes(8).toString('hex')}`;
353
- let renamed = false;
354
- try {
355
- writeNewFile(tmp, output, mode);
356
- fs.renameSync(tmp, abs);
357
- renamed = true;
358
- } finally {
359
- // A failed rename leaves the document's content sitting in a stray file.
360
- if (!renamed) { try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ } }
361
- }
317
+ // Atomic replace preserving the original mode see scripts/lib/safe-fs.js.
318
+ safeFs.replaceFileAtomic(abs, output, mode);
362
319
 
363
320
  return { path: abs, output, stats, wrote: true, backupPath };
364
321
  }
@@ -12,9 +12,15 @@
12
12
  const fs = require('fs');
13
13
  const os = require('os');
14
14
  const path = require('path');
15
+ const safeFs = require('../lib/safe-fs.js');
15
16
 
16
- const DIR = process.env.KODELYTH_TERSE_DIR
17
- || path.join(os.homedir(), '.kodelythecc', 'terse');
17
+ // The env var is a trusted-config surface — anyone who can set your environment
18
+ // already has leverage — but an unnormalised value silently creates a ledger
19
+ // tree wherever ".." points. safeConfigDir rejects those and falls back.
20
+ const DIR = safeFs.safeConfigDir(
21
+ process.env.KODELYTH_TERSE_DIR,
22
+ path.join(os.homedir(), '.kodelythecc', 'terse'),
23
+ );
18
24
  const LEDGER = path.join(DIR, 'ledger.jsonl');
19
25
 
20
26
  function ensureDir() { fs.mkdirSync(DIR, { recursive: true }); }