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 +78 -0
- package/CLAUDE.md +1 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/memory/instincts.js +10 -5
- package/scripts/memory/store.js +143 -64
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/ →
|
|
29
|
+
tests/ → 569 passing tests across 30 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.13.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodelyth-ecc",
|
|
3
|
-
"version": "2.
|
|
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
|
-
|
|
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
|
@@ -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:
|
|
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
|
-
|
|
246
|
+
|
|
247
|
+
const folded = new Map(); // preserves first-seen order
|
|
191
248
|
for (const line of lines) {
|
|
192
|
-
let
|
|
193
|
-
try {
|
|
194
|
-
if (
|
|
195
|
-
if (wantSet)
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
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;
|