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 +126 -0
- package/CLAUDE.md +1 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/dashboard/data.js +7 -24
- package/scripts/dashboard/server.js +8 -21
- package/scripts/lib/safe-fs.js +162 -0
- package/scripts/memory/instincts.js +10 -5
- package/scripts/memory/store.js +143 -64
- package/scripts/terse/compress.js +7 -50
- package/scripts/terse/ledger.js +8 -2
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/ →
|
|
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",
|
|
@@ -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
|
-
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
-
//
|
|
350
|
-
|
|
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
|
}
|
package/scripts/terse/ledger.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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 }); }
|