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 +134 -0
- package/CLAUDE.md +1 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/arena/learn.js +4 -2
- package/scripts/codex/merge-codex-config.js +2 -1
- package/scripts/codex/merge-mcp-config.js +2 -1
- package/scripts/evolve/stats.js +2 -1
- package/scripts/lib/safe-fs.js +19 -0
- package/scripts/mcp/client.js +2 -1
- package/scripts/memory/instincts.js +10 -5
- package/scripts/memory/store.js +144 -64
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/ →
|
|
29
|
+
tests/ → 572 passing tests across 30 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.14.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodelyth-ecc",
|
|
3
|
-
"version": "2.
|
|
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",
|
package/scripts/arena/learn.js
CHANGED
|
@@ -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', /\
|
|
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
|
-
['
|
|
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
|
-
|
|
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
|
-
|
|
323
|
+
safeFs.replaceFilePreservingMode(configPath, cleaned + (toAppend.length > 0 ? appendText : ''));
|
|
323
324
|
} else {
|
|
324
325
|
fs.appendFileSync(configPath, appendText, 'utf8');
|
|
325
326
|
}
|
package/scripts/evolve/stats.js
CHANGED
|
@@ -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
|
-
|
|
58
|
+
safeFs.replaceFilePreservingMode(p, JSON.stringify(data, null, 2));
|
|
58
59
|
return true;
|
|
59
60
|
} catch { return false; }
|
|
60
61
|
}
|
package/scripts/lib/safe-fs.js
CHANGED
|
@@ -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
|
};
|
package/scripts/mcp/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 ────────────────────────────────────────────────────────────────
|
package/scripts/memory/store.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
247
|
+
|
|
248
|
+
const folded = new Map(); // preserves first-seen order
|
|
191
249
|
for (const line of lines) {
|
|
192
|
-
let
|
|
193
|
-
try {
|
|
194
|
-
if (
|
|
195
|
-
if (wantSet)
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
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;
|