klypix-mcp 1.62.0 → 1.64.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/README.md +27 -0
- package/bin/klypix-brain-deleted.mjs +82 -0
- package/bin/klypix-brain-history.mjs +75 -0
- package/bin/klypix-install.mjs +1 -1
- package/bin/klypix-mcp.mjs +3 -1
- package/bin/klypix-worker.mjs +8 -0
- package/package.json +2 -2
- package/src/brain-doctor.mjs +18 -1
- package/src/brain-graveyard.mjs +131 -0
- package/src/brain-history.mjs +229 -0
- package/src/klypix-format.mjs +91 -1
- package/src/merge-brains.mjs +82 -1
package/README.md
CHANGED
|
@@ -416,6 +416,32 @@ rename, so a crash mid-write leaves the previous good file intact. The lock is a
|
|
|
416
416
|
sustained contention can still lose an update. That is a deliberate trade — dropping the markers
|
|
417
417
|
was judged worse — but it is a real limit, not a guarantee.
|
|
418
418
|
|
|
419
|
+
### Restore points
|
|
420
|
+
|
|
421
|
+
Merging, tidying and gardening are lossless by contract. What none of them can undo is a
|
|
422
|
+
*deliberate-looking* deletion: you select a dozen cards, delete them, and save. That is not a bug
|
|
423
|
+
to prevent — a brain has to stay correctable, and an uncorrectable memory is worse than none — but
|
|
424
|
+
it deserves a way back, because the brain is **co-owned**: hooks, the MCP server, commit capture
|
|
425
|
+
and peers on other machines all write to it while nobody is watching, so you can destroy work you
|
|
426
|
+
never saw arrive.
|
|
427
|
+
|
|
428
|
+
So every brain write takes a restore point of the previous bytes first:
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
npx klypix-mcp brain-history list # age, card count, delta against the brain now
|
|
432
|
+
npx klypix-mcp brain-history restore <id> # and this is itself undoable
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
They live under `~/.claude/project-brain/history/`, never beside the brain — nothing lands in git,
|
|
436
|
+
in the merge driver's path, or in your diffs, and they survive deletion of the `.klypix` file
|
|
437
|
+
itself. Routine writes are deduped and throttled to one a minute; a write that **removes cards** is
|
|
438
|
+
never throttled, because that is the case they exist for. Retention is the newest 20 plus one per
|
|
439
|
+
day for 14 days, so a slow-burn mistake is still recoverable without unbounded growth. A snapshot
|
|
440
|
+
that cannot be written is logged and skipped — it never blocks your save.
|
|
441
|
+
|
|
442
|
+
Normal canvases deliberately get none of this. One human made every mark and saw every change; the
|
|
443
|
+
brain is the file where that is not true.
|
|
444
|
+
|
|
419
445
|
---
|
|
420
446
|
|
|
421
447
|
## The command line
|
|
@@ -432,6 +458,7 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
432
458
|
| `npx klypix-mcp conformance` | Launch two real MCP clients against this build and verify coordination behaviour |
|
|
433
459
|
| `npx klypix-mcp git-driver` | Register the lossless `.klypix` merge driver for a repo (`status` to check) |
|
|
434
460
|
| `npx klypix-mcp git-hook` | Wire the agent-neutral commit-capture hook: rationale-bearing `feat`/`fix`/`perf` commits from any agent, branch, or worktree card into the brain at commit time (`install`/`remove`/`status`; sessions auto-install it where the hook slots are free) |
|
|
461
|
+
| `npx klypix-mcp brain-history` | Restore points for this brain — `list` them, `restore <id>` one. Written automatically before every brain write, kept machine-local, and never throttled away for a write that removes cards |
|
|
435
462
|
| `npx klypix-mcp diff [ref]` | Card-level brain diff against a git ref, as markdown |
|
|
436
463
|
| `npx klypix-mcp pr-brief [ref]` | Brain cards referencing the files changed since a ref, as markdown |
|
|
437
464
|
| `npx klypix-mcp garden-code` | Print the human approval code `brain_garden` requires |
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `klypix-mcp brain-deleted [list|restore <id…>|purge] [--brain <path>]`
|
|
3
|
+
// The recycle bin for a brain: cards a human deleted are kept recoverable
|
|
4
|
+
// instead of destroyed. Standalone: node bin/klypix-brain-deleted.mjs <args>
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { listGraveyard, purgeGraveyard, readGraveyardCard, restoreFromGraveyard, DEFAULT_RETENTION_DAYS } from '../src/brain-graveyard.mjs';
|
|
8
|
+
import { atomicWrite } from '../src/klypix-format.mjs';
|
|
9
|
+
|
|
10
|
+
const argv = process.argv.slice(2).filter((a) => a !== 'brain-deleted');
|
|
11
|
+
const action = ['list', 'restore', 'purge'].includes(argv[0]) ? argv.shift() : 'list';
|
|
12
|
+
const brainIdx = argv.indexOf('--brain');
|
|
13
|
+
const brainPath = path.resolve(brainIdx >= 0 && argv[brainIdx + 1] ? argv.splice(brainIdx, 2)[1] : 'brain.klypix');
|
|
14
|
+
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? (argv.splice(i, 2)[1] ?? '') : null; };
|
|
15
|
+
const olderThan = flag('--older-than');
|
|
16
|
+
const all = argv.includes('--all');
|
|
17
|
+
const ids = argv.filter((a) => !a.startsWith('-'));
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(brainPath)) { console.error(`No brain at ${brainPath}.`); process.exit(1); }
|
|
20
|
+
const buf = fs.readFileSync(brainPath);
|
|
21
|
+
|
|
22
|
+
const ago = (ts) => {
|
|
23
|
+
const m = Math.max(0, Math.round((Date.now() - Number(ts || 0)) / 60000));
|
|
24
|
+
if (!ts) return 'unknown';
|
|
25
|
+
if (m < 60) return `${m}m ago`;
|
|
26
|
+
const h = Math.round(m / 60);
|
|
27
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (action === 'list') {
|
|
31
|
+
const entries = await listGraveyard(buf);
|
|
32
|
+
if (!entries.length) {
|
|
33
|
+
console.log(`Nothing deleted from ${path.basename(brainPath)}.`);
|
|
34
|
+
console.log('Cards you delete from a brain are kept here, recoverable, instead of destroyed.');
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
console.log(`${entries.length} deleted card(s) in ${path.basename(brainPath)} — newest first\n`);
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
const full = ids.includes(e.id) ? await readGraveyardCard(buf, e.id) : null;
|
|
40
|
+
console.log(` ${e.id} ${ago(e.deletedAt).padEnd(9)} ${e.area ? `[${e.area}] ` : ''}${e.preview || '(no text)'}`);
|
|
41
|
+
if (full?.content) console.log(`\n${String(full.content).split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
42
|
+
}
|
|
43
|
+
console.log(`\nFull text: npx klypix-mcp brain-deleted list <id> --brain "${brainPath}"`);
|
|
44
|
+
console.log(`Restore: npx klypix-mcp brain-deleted restore <id>`);
|
|
45
|
+
console.log(`Purge: npx klypix-mcp brain-deleted purge --older-than ${DEFAULT_RETENTION_DAYS}d (or: purge <id>, purge --all)`);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (action === 'restore') {
|
|
50
|
+
if (!ids.length) { console.error('Usage: brain-deleted restore <id…> (ids from `brain-deleted list`)'); process.exit(2); }
|
|
51
|
+
const res = await restoreFromGraveyard(buf, ids);
|
|
52
|
+
if (!res.restored.length) {
|
|
53
|
+
for (const s of res.skipped) console.error(` ${s.id}: ${s.reason}`);
|
|
54
|
+
console.error('Nothing restored.');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
await atomicWrite(brainPath, res.buffer, { reason: 'graveyard-restore' });
|
|
58
|
+
for (const r of res.restored) {
|
|
59
|
+
console.log(`Restored ${r.id}${r.reparented ? ' (its container is gone — placed at the canvas root)' : ''}`);
|
|
60
|
+
}
|
|
61
|
+
for (const s of res.skipped) console.log(`Skipped ${s.id}: ${s.reason}`);
|
|
62
|
+
console.log('If the app has this brain OPEN, close and reopen the tab so it sees the restored card.');
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// purge
|
|
67
|
+
if (!ids.length && !all && !olderThan) {
|
|
68
|
+
console.error(`Usage: brain-deleted purge --older-than ${DEFAULT_RETENTION_DAYS}d | purge <id…> | purge --all`);
|
|
69
|
+
console.error('Purge is permanent. A restore point is written first (npx klypix-mcp brain-history list).');
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
const days = olderThan ? Number(String(olderThan).replace(/d$/i, '')) : null;
|
|
73
|
+
if (olderThan && !Number.isFinite(days)) { console.error(`--older-than expects days, e.g. --older-than ${DEFAULT_RETENTION_DAYS}d`); process.exit(2); }
|
|
74
|
+
const res = await purgeGraveyard(buf, {
|
|
75
|
+
ids: ids.length ? ids : (all ? (await listGraveyard(buf)).map((e) => e.id) : null),
|
|
76
|
+
olderThanDays: ids.length || all ? null : days,
|
|
77
|
+
});
|
|
78
|
+
if (!res.purged.length) { console.log('Nothing matched — nothing purged.'); process.exit(0); }
|
|
79
|
+
await atomicWrite(brainPath, res.buffer, { reason: 'graveyard-purge' });
|
|
80
|
+
console.log(`Purged ${res.purged.length} deleted card(s) permanently from ${path.basename(brainPath)}.`);
|
|
81
|
+
console.log('Note: this removes them from the file, not from git history — a secret committed earlier is still in past commits.');
|
|
82
|
+
console.log(`The pre-purge state is a restore point: npx klypix-mcp brain-history list --brain "${brainPath}"`);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `klypix-mcp brain-history [list|restore <id>|prune] [--brain <path>]`
|
|
3
|
+
// The human surface for brain restore points. Protection nobody can see is
|
|
4
|
+
// protection nobody trusts, so `list` is the default and it says plainly what
|
|
5
|
+
// each point would give back.
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { listBrainHistory, pruneBrainHistory, restoreBrainSnapshot, historyDirFor } from '../src/brain-history.mjs';
|
|
9
|
+
|
|
10
|
+
const argv = process.argv.slice(2).filter((a) => a !== 'brain-history');
|
|
11
|
+
const action = ['list', 'restore', 'prune'].includes(argv[0]) ? argv.shift() : 'list';
|
|
12
|
+
const brainIdx = argv.indexOf('--brain');
|
|
13
|
+
const brainPath = path.resolve(brainIdx >= 0 && argv[brainIdx + 1] ? argv.splice(brainIdx, 2)[1] : 'brain.klypix');
|
|
14
|
+
const positional = argv.filter((a) => !a.startsWith('-'));
|
|
15
|
+
|
|
16
|
+
const ago = (ts) => {
|
|
17
|
+
const m = Math.max(0, Math.round((Date.now() - ts) / 60000));
|
|
18
|
+
if (m < 60) return `${m}m ago`;
|
|
19
|
+
const h = Math.round(m / 60);
|
|
20
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
21
|
+
};
|
|
22
|
+
const kb = (b) => `${(b / 1024).toFixed(0)} KB`;
|
|
23
|
+
|
|
24
|
+
// Card counts make a restore point meaningful ("this one still has the 14 cards
|
|
25
|
+
// you deleted"). Parsing ≤20 small zips is fine for a human-invoked command;
|
|
26
|
+
// a parse failure degrades to size only rather than failing the listing.
|
|
27
|
+
async function cardCount(file) {
|
|
28
|
+
try {
|
|
29
|
+
const { parseKlypix } = await import('../src/klypix-format.mjs');
|
|
30
|
+
const { struct } = await parseKlypix(fs.readFileSync(file));
|
|
31
|
+
return struct?.cards?.length ?? null;
|
|
32
|
+
} catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (action === 'list') {
|
|
36
|
+
const entries = listBrainHistory(brainPath);
|
|
37
|
+
if (!entries.length) {
|
|
38
|
+
console.log(`No restore points for ${brainPath}.`);
|
|
39
|
+
console.log(`They are written automatically before each brain write, to ${historyDirFor(brainPath)}.`);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
const liveExists = fs.existsSync(brainPath);
|
|
43
|
+
const liveCards = liveExists ? await cardCount(brainPath) : null;
|
|
44
|
+
console.log(`Restore points for ${brainPath}${liveExists ? '' : ' (the brain itself is MISSING — restore will recreate it)'}`);
|
|
45
|
+
if (liveCards != null) console.log(`current: ${liveCards} cards, ${kb(fs.statSync(brainPath).size)}\n`);
|
|
46
|
+
for (const e of entries) {
|
|
47
|
+
const cards = await cardCount(e.file);
|
|
48
|
+
const delta = cards != null && liveCards != null ? cards - liveCards : null;
|
|
49
|
+
const deltaText = delta == null ? '' : delta > 0 ? ` (+${delta} cards vs now)` : delta < 0 ? ` (${delta} cards vs now)` : ' (same card count)';
|
|
50
|
+
console.log(` ${e.id} ${ago(e.ts).padEnd(9)} ${String(cards ?? '?').padStart(5)} cards ${kb(e.bytes).padStart(8)}${e.reason ? ` [${e.reason}]` : ''}${deltaText}`);
|
|
51
|
+
}
|
|
52
|
+
console.log(`\nRestore: npx klypix-mcp brain-history restore <id> --brain "${brainPath}"`);
|
|
53
|
+
console.log('Restoring snapshots the current file first, so it is itself undoable.');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (action === 'prune') {
|
|
58
|
+
const removed = pruneBrainHistory(brainPath);
|
|
59
|
+
console.log(`Pruned ${removed} restore point(s) beyond the retention window (newest 20 + one per day for 14 days).`);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// restore
|
|
64
|
+
const id = positional[0];
|
|
65
|
+
if (!id) {
|
|
66
|
+
console.error('Usage: npx klypix-mcp brain-history restore <id> [--brain <path>]');
|
|
67
|
+
console.error('Run `npx klypix-mcp brain-history list` to see the ids.');
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
const { parseKlypix } = await import('../src/klypix-format.mjs');
|
|
71
|
+
const res = await restoreBrainSnapshot(brainPath, id, { parse: parseKlypix });
|
|
72
|
+
if (!res.ok) { console.error(`Restore failed: ${res.error}`); process.exit(1); }
|
|
73
|
+
console.log(`Restored ${brainPath} from ${res.restoredFrom} (${kb(res.bytes)}).`);
|
|
74
|
+
if (res.safetyId) console.log(`The state you just replaced was saved as ${res.safetyId} — undo with: npx klypix-mcp brain-history restore ${res.safetyId}`);
|
|
75
|
+
console.log('If the app has this brain OPEN, close and reopen the tab: its in-memory copy is now older than disk and a save would merge it back.');
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -293,7 +293,7 @@ try {
|
|
|
293
293
|
// canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
|
|
294
294
|
// file must never get a JS-comment banner) beside the flat server, which
|
|
295
295
|
// resolves it via its ./canvas-view-app.html candidate path.
|
|
296
|
-
for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
|
|
296
|
+
for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
|
|
297
297
|
const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
|
|
298
298
|
}
|
|
299
299
|
for (const [src, dst] of [
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const PKG_VERSION = (() => {
|
|
|
19
19
|
}
|
|
20
20
|
})();
|
|
21
21
|
|
|
22
|
-
const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'git-hook', 'diff', 'pr-brief', 'uninstall', 'bench']);
|
|
22
|
+
const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'git-hook', 'brain-history', 'brain-deleted', 'diff', 'pr-brief', 'uninstall', 'bench']);
|
|
23
23
|
|
|
24
24
|
const USAGE = [
|
|
25
25
|
`klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
|
|
@@ -36,6 +36,8 @@ const USAGE = [
|
|
|
36
36
|
' uninstall [--check|--yes|unlink] remove this install from the machine (--check inventories first; never deletes a .klypix)',
|
|
37
37
|
' git-driver [install|status] [repo] register the lossless .klypix merge driver for a repo (zero-command teams)',
|
|
38
38
|
' git-hook [install|remove|status] wire the agent-neutral commit-capture hook (any agent/branch/worktree → brain cards)',
|
|
39
|
+
' brain-history [list|restore <id>] restore points for this brain — undo an accidental delete, edit, or overwrite',
|
|
40
|
+
' brain-deleted [list|restore|purge] recycle bin for this brain — cards you deleted, kept recoverable',
|
|
39
41
|
' diff [ref] [--brain <path>] readable brain diff vs a git ref (default HEAD) — markdown to stdout',
|
|
40
42
|
' pr-brief [baseRef] [--brain <path>] brain decisions touching the files changed since baseRef — PR-comment markdown',
|
|
41
43
|
'',
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -121,6 +121,14 @@ await runVerb('git-driver', './klypix-git-driver.mjs');
|
|
|
121
121
|
// commits with rationale bodies card into the brain from ANY agent, branch, or
|
|
122
122
|
// worktree at commit time (the Stop hook alone is blind to other worktrees).
|
|
123
123
|
await runVerb('git-hook', './klypix-git-hook.mjs');
|
|
124
|
+
// `npx klypix-mcp brain-history` — list/restore the automatic restore points
|
|
125
|
+
// written before every brain write. The recovery path for an accidental card
|
|
126
|
+
// deletion, a destructive edit, a stale overwrite, or a deleted brain file.
|
|
127
|
+
await runVerb('brain-history', './klypix-brain-history.mjs');
|
|
128
|
+
// `npx klypix-mcp brain-deleted` — the brain's recycle bin. A human delete moves
|
|
129
|
+
// the card's bytes to graveyard/ instead of destroying them; this lists, restores
|
|
130
|
+
// and (permanently) purges them.
|
|
131
|
+
await runVerb('brain-deleted', './klypix-brain-deleted.mjs');
|
|
124
132
|
await runVerb('diff', './klypix-diff.mjs');
|
|
125
133
|
await runVerb('pr-brief', './klypix-pr-brief.mjs');
|
|
126
134
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.64.0",
|
|
4
4
|
"description": "Shared project brain and MCP coordination server for multi-agent coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"test:project-graph": "node test/project-graph.mjs",
|
|
80
80
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
81
81
|
"test:bench": "node test/bench.mjs",
|
|
82
|
-
"test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
82
|
+
"test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
83
83
|
"test:memory": "node test/memory-runtime.mjs",
|
|
84
84
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
85
85
|
"runtime": "node bin/klypix-runtime.mjs"
|
package/src/brain-doctor.mjs
CHANGED
|
@@ -41,6 +41,8 @@ let fmtLib = null;
|
|
|
41
41
|
try { fmtLib = await import('./klypix-format.mjs'); } catch { fmtLib = null; }
|
|
42
42
|
let gitCaptureLib = null;
|
|
43
43
|
try { gitCaptureLib = await import('./git-capture-install.mjs'); } catch { gitCaptureLib = null; }
|
|
44
|
+
let historyLib = null;
|
|
45
|
+
try { historyLib = await import('./brain-history.mjs'); } catch { historyLib = null; }
|
|
44
46
|
|
|
45
47
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
46
48
|
|
|
@@ -383,6 +385,15 @@ export function inspect(opts = {}) {
|
|
|
383
385
|
if (hasBrain && gitCaptureLib && typeof gitCaptureLib.gitCaptureHookStatus === 'function') {
|
|
384
386
|
try { gitCapture = gitCaptureLib.gitCaptureHookStatus(projectDir, { home }); } catch { gitCapture = { state: 'error', hooks: {} }; }
|
|
385
387
|
}
|
|
388
|
+
// Restore points (2026-08-07). Informational: their absence on a brand-new
|
|
389
|
+
// brain is normal, and a missing history must never read as machine drift.
|
|
390
|
+
let history = { available: false, count: 0, newestAt: null };
|
|
391
|
+
if (hasBrain && historyLib && typeof historyLib.listBrainHistory === 'function') {
|
|
392
|
+
try {
|
|
393
|
+
const points = historyLib.listBrainHistory(brainPath, { home });
|
|
394
|
+
history = { available: true, count: points.length, newestAt: points[0]?.ts || null };
|
|
395
|
+
} catch { history = { available: true, count: 0, newestAt: null }; }
|
|
396
|
+
}
|
|
386
397
|
const tools = inspectTools(brainDir, PKG_ROOT);
|
|
387
398
|
const env = opts.env || process.env;
|
|
388
399
|
// MCP passes the adopted live id explicitly. The CLI can inherit a host id,
|
|
@@ -477,7 +488,7 @@ export function inspect(opts = {}) {
|
|
|
477
488
|
}
|
|
478
489
|
}
|
|
479
490
|
|
|
480
|
-
return { verdict, layers, drifted, readinessWarnings, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, gitCapture, tools, peers, sessions: peers, receipts: peers.receipts, receiptSessionId, harness, npm, decayGuard, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
|
|
491
|
+
return { verdict, layers, drifted, readinessWarnings, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, gitCapture, history, tools, peers, sessions: peers, receipts: peers.receipts, receiptSessionId, harness, npm, decayGuard, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
|
|
481
492
|
}
|
|
482
493
|
|
|
483
494
|
// One-line drift summary (empty when clean) — for a footer / status line.
|
|
@@ -611,6 +622,12 @@ export function render(r, opts = {}) {
|
|
|
611
622
|
: `${c.dim}${gc.state} — installs automatically at the next session start${c.rst}`;
|
|
612
623
|
L.push(`${gmark} ${c.bold}GIT-CAP${c.rst} ${detail}`);
|
|
613
624
|
}
|
|
625
|
+
// Restore points — say the count and the age, because "you can undo an
|
|
626
|
+
// accidental delete" is only believable if the machine can show the receipts.
|
|
627
|
+
if (r.history?.available) {
|
|
628
|
+
const age = r.history.newestAt ? `${Math.max(0, Math.round((Date.now() - r.history.newestAt) / 60000))}m ago` : 'none yet';
|
|
629
|
+
L.push(`${ok} ${c.bold}HISTORY${c.rst} ${r.history.count} restore point(s) · newest ${age} ${c.dim}(npx klypix-mcp brain-history list)${c.rst}`);
|
|
630
|
+
}
|
|
614
631
|
|
|
615
632
|
// TOOLS
|
|
616
633
|
L.push(`${ok} ${c.bold}TOOLS${c.rst} ${r.tools.count} MCP verb(s)${r.tools.hash ? ` ${c.dim}[#${r.tools.hash}, ${r.tools.source}]${c.rst}` : ''}${r.tools.count ? `: ${c.dim}${r.tools.names.join(', ')}${c.rst}` : ''}`);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// brain-graveyard — the recoverable bin for cards a human deleted from a brain.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS AND NOT THE ARCHIVE CONTAINER. "Archived" in a KLYPIX brain is a
|
|
4
|
+
// containment fact: the card's parent is a container titled "Archive". Those
|
|
5
|
+
// cards are STILL in canvas.json's `order`, so they still render — this repo's
|
|
6
|
+
// own brain has 275 of them on the canvas right now. Routing deletes there
|
|
7
|
+
// would make a deleted card visibly reappear (and, because the merge's position
|
|
8
|
+
// comparator ignores parentId, reappear exactly where it was). It would also
|
|
9
|
+
// leak: `read_canvas` and `search_canvases` have no archive awareness at all,
|
|
10
|
+
// `brain_ask` includes archived cards by design, and the embedder embeds them.
|
|
11
|
+
//
|
|
12
|
+
// So a deleted card leaves `order` entirely and its bytes move to `graveyard/`,
|
|
13
|
+
// which `parseKlypix` reads into `struct.graveyard` and deliberately never
|
|
14
|
+
// merges into `struct.cards`. That one choice makes every leak impossible by
|
|
15
|
+
// construction rather than by remembering to filter in 30-odd call sites.
|
|
16
|
+
//
|
|
17
|
+
// PURGE STAYS AVAILABLE, AND HONEST. brain.klypix is git-tracked and syncs to
|
|
18
|
+
// collaborators, so "delete" is also the escape hatch for a pasted key or a
|
|
19
|
+
// personal detail. Purge removes the bytes from the working file — it cannot
|
|
20
|
+
// remove them from git history, and the caller is told so.
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import JSZip from 'jszip';
|
|
23
|
+
import { parseKlypix, shard } from './klypix-format.mjs';
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_RETENTION_DAYS = 30;
|
|
26
|
+
|
|
27
|
+
/** Deleted cards, newest first. */
|
|
28
|
+
export async function listGraveyard(buf) {
|
|
29
|
+
const { struct } = await parseKlypix(buf);
|
|
30
|
+
return struct.graveyard || [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readIndex(zip) {
|
|
34
|
+
const f = zip.file('graveyard.json');
|
|
35
|
+
if (!f) return { version: 1, entries: {} };
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(await f.async('string'));
|
|
38
|
+
return { version: 1, entries: (parsed && parsed.entries) || {} };
|
|
39
|
+
} catch { return { version: 1, entries: {} }; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeIndex(zip, index) {
|
|
43
|
+
if (Object.keys(index.entries).length) zip.file('graveyard.json', JSON.stringify(index));
|
|
44
|
+
else zip.remove('graveyard.json');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Put a deleted card back into the brain. It returns to `order` (so it renders
|
|
49
|
+
* again) at its recorded position, and leaves the bin — a card must never be in
|
|
50
|
+
* both, or the next restore would duplicate it.
|
|
51
|
+
*
|
|
52
|
+
* Its former parent may itself be gone; in that case the card is restored to
|
|
53
|
+
* the canvas root rather than into a dangling container.
|
|
54
|
+
*/
|
|
55
|
+
export async function restoreFromGraveyard(buf, ids) {
|
|
56
|
+
const zip = await JSZip.loadAsync(buf);
|
|
57
|
+
const index = await readIndex(zip);
|
|
58
|
+
const canvasFile = zip.file('canvas.json');
|
|
59
|
+
if (!canvasFile) throw new Error('Not a .klypix canvas — missing canvas.json');
|
|
60
|
+
const canvas = JSON.parse(await canvasFile.async('string'));
|
|
61
|
+
canvas.order = Array.isArray(canvas.order) ? canvas.order : [];
|
|
62
|
+
canvas.positions = canvas.positions || {};
|
|
63
|
+
const liveIds = new Set(canvas.order);
|
|
64
|
+
|
|
65
|
+
const restored = [], skipped = [];
|
|
66
|
+
for (const rawId of ids) {
|
|
67
|
+
const id = String(rawId);
|
|
68
|
+
const entry = index.entries[id];
|
|
69
|
+
const file = zip.file(`graveyard/${shard(id)}/${id}.json`);
|
|
70
|
+
if (!entry || !file) { skipped.push({ id, reason: 'not in the bin' }); continue; }
|
|
71
|
+
if (liveIds.has(id)) { skipped.push({ id, reason: 'already in the brain' }); continue; }
|
|
72
|
+
|
|
73
|
+
zip.file(`items/${shard(id)}/${id}.json`, await file.async('string'));
|
|
74
|
+
const pos = entry.pos || { x: 0, y: 0 };
|
|
75
|
+
// Re-parent only if the container still exists — a card must never point at
|
|
76
|
+
// a container that was itself deleted, which would make it unreachable.
|
|
77
|
+
const parentAlive = entry.parentId && liveIds.has(entry.parentId);
|
|
78
|
+
canvas.positions[id] = {
|
|
79
|
+
x: Number(pos.x) || 0, y: Number(pos.y) || 0,
|
|
80
|
+
...(pos.w != null ? { w: pos.w } : {}), ...(pos.h != null ? { h: pos.h } : {}),
|
|
81
|
+
zIndex: canvas.order.length,
|
|
82
|
+
parentId: parentAlive ? entry.parentId : null,
|
|
83
|
+
};
|
|
84
|
+
canvas.order.push(id);
|
|
85
|
+
liveIds.add(id);
|
|
86
|
+
zip.remove(`graveyard/${shard(id)}/${id}.json`);
|
|
87
|
+
delete index.entries[id];
|
|
88
|
+
restored.push({ id, reparented: Boolean(entry.parentId) && !parentAlive });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
zip.file('canvas.json', JSON.stringify(canvas));
|
|
92
|
+
writeIndex(zip, index);
|
|
93
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
94
|
+
await parseKlypix(buffer); // never hand back an unreadable brain
|
|
95
|
+
return { buffer, restored, skipped };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Permanently remove entries from the bin. `olderThanDays` purges by age;
|
|
100
|
+
* explicit `ids` purge regardless of age (the secret-was-pasted case).
|
|
101
|
+
*/
|
|
102
|
+
export async function purgeGraveyard(buf, { ids = null, olderThanDays = null, now = Date.now() } = {}) {
|
|
103
|
+
const zip = await JSZip.loadAsync(buf);
|
|
104
|
+
const index = await readIndex(zip);
|
|
105
|
+
const cutoff = olderThanDays != null ? now - olderThanDays * 24 * 60 * 60 * 1000 : null;
|
|
106
|
+
|
|
107
|
+
const target = [];
|
|
108
|
+
for (const [id, meta] of Object.entries(index.entries)) {
|
|
109
|
+
if (ids) { if (ids.includes(id)) target.push(id); continue; }
|
|
110
|
+
if (cutoff != null && Number(meta?.deletedAt || 0) < cutoff) target.push(id);
|
|
111
|
+
}
|
|
112
|
+
for (const id of target) {
|
|
113
|
+
zip.remove(`graveyard/${shard(id)}/${id}.json`);
|
|
114
|
+
delete index.entries[id];
|
|
115
|
+
}
|
|
116
|
+
writeIndex(zip, index);
|
|
117
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
118
|
+
await parseKlypix(buffer);
|
|
119
|
+
return { buffer, purged: target };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read a buried card's full text — so `list` can show more than a preview. */
|
|
123
|
+
export async function readGraveyardCard(buf, id) {
|
|
124
|
+
const zip = await JSZip.loadAsync(buf);
|
|
125
|
+
const f = zip.file(`graveyard/${shard(String(id))}/${String(id)}.json`);
|
|
126
|
+
if (!f) return null;
|
|
127
|
+
try { return JSON.parse(await f.async('string')); } catch { return null; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Convenience for CLI/desktop callers that work in files rather than buffers. */
|
|
131
|
+
export const readBrain = (p) => fs.readFileSync(p);
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// brain-history — restore points for a project brain.
|
|
2
|
+
//
|
|
3
|
+
// WHY THE BRAIN AND NOT EVERY CANVAS. A normal canvas is authored and observed
|
|
4
|
+
// by one human: they made every mark, so losing one is a mistake they can see
|
|
5
|
+
// and redo. A brain is CO-OWNED and mostly written while nobody is watching —
|
|
6
|
+
// hooks, the MCP server, commit capture and peers on other machines all append
|
|
7
|
+
// to it unattended. So a human can destroy work they never saw arrive, and an
|
|
8
|
+
// agent can destroy work the human never saved. Undo does not span that: it
|
|
9
|
+
// lives in one renderer session, and the writers here are separate processes.
|
|
10
|
+
//
|
|
11
|
+
// WHAT WAS AND WAS NOT ALREADY SAFE (audited 2026-08-07):
|
|
12
|
+
// safe merge / git-driver union .............. lossless by contract + tests
|
|
13
|
+
// safe tidy / arrange ......................... lossless by contract
|
|
14
|
+
// safe brain_garden ........................... archives, approval-gated
|
|
15
|
+
// safe concurrent writers ..................... advisory lock + queue
|
|
16
|
+
// safe torn or corrupt write .................. atomicWrite parse-validates
|
|
17
|
+
// UNSAFE human deletes a card, then saves ....... persisted, irreversible
|
|
18
|
+
// UNSAFE multi-select delete .................... same, larger
|
|
19
|
+
// UNSAFE destructive edit of a card's text ...... same
|
|
20
|
+
// UNSAFE the .klypix file deleted outright ...... nothing to fall back to
|
|
21
|
+
// UNSAFE an external tool overwrites the file ... no prior copy
|
|
22
|
+
// UNSAFE a stale machine's sync lands backwards . no prior copy
|
|
23
|
+
// Every UNSAFE row is one sentence: the file's previous bytes are gone. So the
|
|
24
|
+
// primitive is not a confirmation dialog (people click through those, and the
|
|
25
|
+
// brain NEEDS deletion to stay correctable) — it is keeping the previous bytes.
|
|
26
|
+
//
|
|
27
|
+
// DESIGN RULES, each one earned from a way this could make things worse:
|
|
28
|
+
// • MACHINE-LOCAL, never beside the brain. A `.klypix-history/` in the repo
|
|
29
|
+
// would land in git, in the merge driver's path, in cloud sync, and in
|
|
30
|
+
// everyone's diffs. Restore points are undo, not project content.
|
|
31
|
+
// • NEVER BLOCK A WRITE. Every entry point is best-effort and swallows its
|
|
32
|
+
// own errors: a full disk must not stop the brain from saving.
|
|
33
|
+
// • DEDUPED BY CONTENT. Identical bytes are never stored twice.
|
|
34
|
+
// • SHRINK IS ALWAYS INTERESTING. Routine writes throttle; a write that makes
|
|
35
|
+
// the brain measurably SMALLER is the exact accident this exists for and is
|
|
36
|
+
// never throttled.
|
|
37
|
+
// • BOUNDED. Newest N plus one per day, so it cannot grow without limit.
|
|
38
|
+
// • RESTORE IS ITSELF REVERSIBLE. Restoring first snapshots what is there now.
|
|
39
|
+
import fs from 'fs';
|
|
40
|
+
import os from 'os';
|
|
41
|
+
import path from 'path';
|
|
42
|
+
import crypto from 'crypto';
|
|
43
|
+
|
|
44
|
+
const KEEP_NEWEST = 20;
|
|
45
|
+
const KEEP_DAILY_DAYS = 14;
|
|
46
|
+
const THROTTLE_MS = 60 * 1000;
|
|
47
|
+
// A write that shrinks the file by more than this is treated as a deletion
|
|
48
|
+
// signal and bypasses the throttle. ZIP size tracks card count closely enough
|
|
49
|
+
// for a heuristic, and being wrong here only costs one extra snapshot.
|
|
50
|
+
const SHRINK_RATIO = 0.02;
|
|
51
|
+
|
|
52
|
+
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|
53
|
+
|
|
54
|
+
function canonPath(p) {
|
|
55
|
+
try { return (fs.realpathSync.native || fs.realpathSync)(p); }
|
|
56
|
+
catch { return path.resolve(p); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Key on the canonical path with the drive letter lowercased, matching the
|
|
60
|
+
// presence lane's convention so both agree about what "the same brain" means.
|
|
61
|
+
export function historyKeyFor(brainPath) {
|
|
62
|
+
const norm = canonPath(brainPath).replace(/\\/g, '/').replace(/^([a-z]):/i, (m, d) => `${d.toLowerCase()}:`);
|
|
63
|
+
return crypto.createHash('sha1').update(norm).digest('hex').slice(0, 16);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function historyDirFor(brainPath, home = os.homedir()) {
|
|
67
|
+
return path.join(home, '.claude', 'project-brain', 'history', historyKeyFor(brainPath));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SNAP_RE = /^(\d+)-([0-9a-f]{8})\.klypix$/;
|
|
71
|
+
|
|
72
|
+
function readEntries(dir) {
|
|
73
|
+
let names = [];
|
|
74
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const m = SNAP_RE.exec(name);
|
|
78
|
+
if (!m) continue;
|
|
79
|
+
const full = path.join(dir, name);
|
|
80
|
+
let bytes = 0;
|
|
81
|
+
try { bytes = fs.statSync(full).size; } catch { continue; }
|
|
82
|
+
out.push({ id: name.replace(/\.klypix$/, ''), ts: Number(m[1]), sha8: m[2], bytes, file: full });
|
|
83
|
+
}
|
|
84
|
+
return out.sort((a, b) => b.ts - a.ts);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Restore points for a brain, newest first. Works even if the brain is gone. */
|
|
88
|
+
export function listBrainHistory(brainPath, { home = os.homedir() } = {}) {
|
|
89
|
+
const dir = historyDirFor(brainPath, home);
|
|
90
|
+
const entries = readEntries(dir);
|
|
91
|
+
let meta = null;
|
|
92
|
+
try { meta = JSON.parse(fs.readFileSync(path.join(dir, 'source.json'), 'utf8')); } catch { /* optional */ }
|
|
93
|
+
return entries.map((e) => ({ ...e, reason: meta?.reasons?.[e.id] || null }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Newest N, plus the OLDEST surviving snapshot of each of the last KEEP_DAILY_DAYS
|
|
97
|
+
// days. Keeping the oldest-of-day (not the newest) is deliberate: after a slow
|
|
98
|
+
// accident you want the state BEFORE that day's edits, not after them.
|
|
99
|
+
function selectDoomed(entries, now) {
|
|
100
|
+
const keep = new Set(entries.slice(0, KEEP_NEWEST).map((e) => e.id));
|
|
101
|
+
const dayFloor = now - KEEP_DAILY_DAYS * 24 * 60 * 60 * 1000;
|
|
102
|
+
const oldestPerDay = new Map();
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
if (e.ts < dayFloor) continue;
|
|
105
|
+
const day = new Date(e.ts).toISOString().slice(0, 10);
|
|
106
|
+
const prev = oldestPerDay.get(day);
|
|
107
|
+
if (!prev || e.ts < prev.ts) oldestPerDay.set(day, e);
|
|
108
|
+
}
|
|
109
|
+
for (const e of oldestPerDay.values()) keep.add(e.id);
|
|
110
|
+
return entries.filter((e) => !keep.has(e.id));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function pruneBrainHistory(brainPath, { home = os.homedir(), now = Date.now() } = {}) {
|
|
114
|
+
const dir = historyDirFor(brainPath, home);
|
|
115
|
+
const doomed = selectDoomed(readEntries(dir), now);
|
|
116
|
+
let removed = 0;
|
|
117
|
+
for (const e of doomed) {
|
|
118
|
+
try { fs.unlinkSync(e.file); removed++; } catch { /* raced */ }
|
|
119
|
+
}
|
|
120
|
+
if (removed) {
|
|
121
|
+
try {
|
|
122
|
+
const metaPath = path.join(dir, 'source.json');
|
|
123
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
124
|
+
for (const e of doomed) delete meta.reasons?.[e.id];
|
|
125
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta));
|
|
126
|
+
} catch { /* metadata is a convenience */ }
|
|
127
|
+
}
|
|
128
|
+
return removed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True when a snapshot right now would be suppressed by the throttle. Callers
|
|
133
|
+
* use this to decide whether it is worth computing an accurate "does this write
|
|
134
|
+
* remove cards?" answer: outside the window a snapshot happens regardless, so
|
|
135
|
+
* nobody should pay to find out.
|
|
136
|
+
*/
|
|
137
|
+
export function wouldThrottle(brainPath, { home = os.homedir(), now = Date.now() } = {}) {
|
|
138
|
+
const entries = readEntries(historyDirFor(brainPath, home));
|
|
139
|
+
return Boolean(entries.length) && now - entries[0].ts < THROTTLE_MS;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Snapshot the brain's CURRENT on-disk bytes before something replaces them.
|
|
144
|
+
* Call immediately BEFORE a write. Never throws.
|
|
145
|
+
*
|
|
146
|
+
* `nextBytes` (the size about to be written) enables the shrink rule — pass it
|
|
147
|
+
* whenever the caller knows it; without it every eligible write is snapshotted,
|
|
148
|
+
* which is safe, just chattier.
|
|
149
|
+
*/
|
|
150
|
+
export function snapshotBrain(brainPath, { home = os.homedir(), reason = 'write', nextBytes = null, now = Date.now(), force = false } = {}) {
|
|
151
|
+
try {
|
|
152
|
+
let buf;
|
|
153
|
+
try { buf = fs.readFileSync(brainPath); } catch { return { saved: false, skipped: 'no-current-file' }; }
|
|
154
|
+
if (!buf.length) return { saved: false, skipped: 'empty' };
|
|
155
|
+
|
|
156
|
+
const dir = historyDirFor(brainPath, home);
|
|
157
|
+
const entries = readEntries(dir);
|
|
158
|
+
const sha = sha256(buf);
|
|
159
|
+
const sha8 = sha.slice(0, 8);
|
|
160
|
+
|
|
161
|
+
// Content dedup: the newest snapshot already holds these exact bytes.
|
|
162
|
+
if (entries.length && entries[0].sha8 === sha8 && entries[0].bytes === buf.length) {
|
|
163
|
+
return { saved: false, skipped: 'unchanged' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Shrink beats throttle — a write that removes content is the whole point.
|
|
167
|
+
const shrinking = nextBytes != null && buf.length > 0
|
|
168
|
+
&& (buf.length - nextBytes) / buf.length > SHRINK_RATIO;
|
|
169
|
+
if (!force && !shrinking && entries.length && now - entries[0].ts < THROTTLE_MS) {
|
|
170
|
+
return { saved: false, skipped: 'throttled' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
174
|
+
const id = `${now}-${sha8}`;
|
|
175
|
+
const dest = path.join(dir, `${id}.klypix`);
|
|
176
|
+
const tmp = `${dest}.tmp-${process.pid}`;
|
|
177
|
+
fs.writeFileSync(tmp, buf);
|
|
178
|
+
try { fs.renameSync(tmp, dest); }
|
|
179
|
+
catch (err) {
|
|
180
|
+
try { fs.renameSync(tmp, dest); }
|
|
181
|
+
catch { try { fs.unlinkSync(tmp); } catch { /* */ } throw err; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Remember where this came from (so `list` can name it even after the
|
|
185
|
+
// project moves) and why each point exists.
|
|
186
|
+
try {
|
|
187
|
+
const metaPath = path.join(dir, 'source.json');
|
|
188
|
+
let meta = { path: canonPath(brainPath), reasons: {} };
|
|
189
|
+
try { meta = { ...meta, ...JSON.parse(fs.readFileSync(metaPath, 'utf8')) }; } catch { /* first write */ }
|
|
190
|
+
meta.path = canonPath(brainPath);
|
|
191
|
+
meta.reasons = { ...(meta.reasons || {}), [id]: shrinking ? `${reason}+shrink` : reason };
|
|
192
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta));
|
|
193
|
+
} catch { /* metadata is a convenience, never a blocker */ }
|
|
194
|
+
|
|
195
|
+
pruneBrainHistory(brainPath, { home, now });
|
|
196
|
+
return { saved: true, id, path: dest, bytes: buf.length, shrinking };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return { saved: false, skipped: 'error', error: String(err?.message || err) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Put a restore point back. Snapshots the CURRENT file first (reason
|
|
204
|
+
* 'pre-restore') so a restore is never the destructive act. Verifies the
|
|
205
|
+
* snapshot still parses as a canvas before overwriting anything, if a parser
|
|
206
|
+
* is supplied — a corrupt restore point must not replace a working brain.
|
|
207
|
+
*/
|
|
208
|
+
export async function restoreBrainSnapshot(brainPath, id, { home = os.homedir(), now = Date.now(), parse = null } = {}) {
|
|
209
|
+
const dir = historyDirFor(brainPath, home);
|
|
210
|
+
const entry = readEntries(dir).find((e) => e.id === id || e.id.startsWith(id));
|
|
211
|
+
if (!entry) return { ok: false, error: `no restore point matching "${id}"` };
|
|
212
|
+
let buf;
|
|
213
|
+
try { buf = fs.readFileSync(entry.file); } catch (err) { return { ok: false, error: `unreadable restore point: ${err?.message || err}` }; }
|
|
214
|
+
if (typeof parse === 'function') {
|
|
215
|
+
try { await parse(buf); }
|
|
216
|
+
catch (err) { return { ok: false, error: `restore point does not parse as a canvas: ${err?.message || err}` }; }
|
|
217
|
+
}
|
|
218
|
+
const safety = snapshotBrain(brainPath, { home, reason: 'pre-restore', now, force: true });
|
|
219
|
+
try {
|
|
220
|
+
fs.mkdirSync(path.dirname(brainPath), { recursive: true });
|
|
221
|
+
const tmp = `${brainPath}.restore-tmp-${process.pid}`;
|
|
222
|
+
fs.writeFileSync(tmp, buf);
|
|
223
|
+
try { fs.renameSync(tmp, brainPath); }
|
|
224
|
+
catch (err) { try { fs.unlinkSync(tmp); } catch { /* */ } throw err; }
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return { ok: false, error: `write failed: ${err?.message || err}`, safetyId: safety.id || null };
|
|
227
|
+
}
|
|
228
|
+
return { ok: true, restoredFrom: entry.id, bytes: buf.length, safetyId: safety.id || null };
|
|
229
|
+
}
|
package/src/klypix-format.mjs
CHANGED
|
@@ -148,9 +148,74 @@ export const shard = (id) => id.replace(/^[a-z]+[_:]/i, '').toLowerCase().slice(
|
|
|
148
148
|
* write leaves the previous good file intact. Use this for ALL brain writes
|
|
149
149
|
* instead of fs.writeFileSync.
|
|
150
150
|
*/
|
|
151
|
-
|
|
151
|
+
// Restore points (2026-08-07). Every agent-side brain write funnels through
|
|
152
|
+
// atomicWrite, so this is the one place that can keep the PREVIOUS bytes before
|
|
153
|
+
// they are replaced — the only thing that makes an accidental card deletion,
|
|
154
|
+
// a destructive edit, or a stale overwrite recoverable at all. Dynamic +
|
|
155
|
+
// guarded: a bundle that predates brain-history.mjs must keep writing normally,
|
|
156
|
+
// and a snapshot failure (full disk, locked dir) must NEVER block a save.
|
|
157
|
+
let _historyLib;
|
|
158
|
+
async function loadHistoryLib() {
|
|
159
|
+
if (_historyLib !== undefined) return _historyLib;
|
|
160
|
+
try { _historyLib = await import(new URL('./brain-history.mjs', import.meta.url).href); }
|
|
161
|
+
catch { _historyLib = null; }
|
|
162
|
+
return _historyLib;
|
|
163
|
+
}
|
|
164
|
+
// Only brains get restore points: they are co-owned and written unattended (see
|
|
165
|
+
// brain-history.mjs). A normal canvas is one human's observed work.
|
|
166
|
+
const looksLikeBrain = (p) => /^brain\.(klypix|any)$/i.test(path.basename(p || ''));
|
|
167
|
+
|
|
168
|
+
// How many cards an archive actually HOLDS, read from canvas.json's `order`
|
|
169
|
+
// alone — one small inflate, no per-item reads: ~40ms on a 1.7 MB / 2000-card
|
|
170
|
+
// brain against ~380ms for a full parse.
|
|
171
|
+
//
|
|
172
|
+
// Two wrong answers were tried first, and both let a real 400-card deletion
|
|
173
|
+
// through during verification:
|
|
174
|
+
// • byte size — re-zipping at a different compression level can make a
|
|
175
|
+
// SMALLER brain into a BIGGER file;
|
|
176
|
+
// • item-file count — `order` is what decides which cards exist, so a write
|
|
177
|
+
// that drops ids from `order` loses them even while their item files linger
|
|
178
|
+
// in the archive as orphans.
|
|
179
|
+
// `order` is the definition of the card set, so it is the only honest signal.
|
|
180
|
+
async function countCardsCheap(buf) {
|
|
181
|
+
const zip = await JSZip.loadAsync(buf);
|
|
182
|
+
const raw = await (zip.file('canvas.json')?.async('string') ?? Promise.resolve(null));
|
|
183
|
+
if (!raw) throw new Error('no canvas.json');
|
|
184
|
+
const canvas = JSON.parse(raw);
|
|
185
|
+
if (Array.isArray(canvas.order)) return canvas.order.length; // v4
|
|
186
|
+
if (Array.isArray(canvas.items)) return canvas.items.length; // legacy v1–v3
|
|
187
|
+
throw new Error('unrecognised canvas shape');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function atomicWrite(filePath, buf, opts = {}) {
|
|
152
191
|
try { await parseKlypix(buf); }
|
|
153
192
|
catch (e) { throw new Error('refusing to write an unparseable .klypix (' + path.basename(filePath) + '): ' + (e?.message || e)); }
|
|
193
|
+
if (opts.snapshot !== false && (opts.isBrain ?? looksLikeBrain(filePath))) {
|
|
194
|
+
try {
|
|
195
|
+
const hist = await loadHistoryLib();
|
|
196
|
+
if (hist?.snapshotBrain) {
|
|
197
|
+
// Does this write REMOVE cards? Computed only when a recent
|
|
198
|
+
// restore point would otherwise throttle this one — so the
|
|
199
|
+
// common path pays nothing, and the one write that must never
|
|
200
|
+
// be skipped is never skipped.
|
|
201
|
+
let removesCards = false;
|
|
202
|
+
if (hist.wouldThrottle?.(filePath)) {
|
|
203
|
+
try {
|
|
204
|
+
const [prev, next] = await Promise.all([
|
|
205
|
+
countCardsCheap(fs.readFileSync(filePath)),
|
|
206
|
+
countCardsCheap(buf),
|
|
207
|
+
]);
|
|
208
|
+
removesCards = next < prev;
|
|
209
|
+
} catch { removesCards = true; } // can't tell → snapshot; being wrong costs one file
|
|
210
|
+
}
|
|
211
|
+
hist.snapshotBrain(filePath, {
|
|
212
|
+
reason: opts.reason || 'write',
|
|
213
|
+
nextBytes: buf.length,
|
|
214
|
+
force: removesCards,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
} catch { /* best-effort by contract — a restore point is never worth a lost save */ }
|
|
218
|
+
}
|
|
154
219
|
const tmp = filePath + '.tmp-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
155
220
|
fs.writeFileSync(tmp, buf);
|
|
156
221
|
try { fs.renameSync(tmp, filePath); } // Node uses MoveFileEx(REPLACE_EXISTING) on Windows → overwrites atomically
|
|
@@ -231,6 +296,28 @@ export async function parseKlypix(buffer) {
|
|
|
231
296
|
for (const it of canvas.items) items[it.id] = it;
|
|
232
297
|
}
|
|
233
298
|
|
|
299
|
+
// ── Graveyard: cards a human deleted, kept recoverable ───────────────────
|
|
300
|
+
// Read from `graveyard.json`, and DELIBERATELY not merged into `items`,
|
|
301
|
+
// `cards`, `order` or `positions`. That single choice is the whole safety
|
|
302
|
+
// argument: a deleted card cannot render (the renderer only ever walks
|
|
303
|
+
// `order`), cannot leak through read_canvas / search_canvases (which walk
|
|
304
|
+
// `struct.cards`), cannot be embedded or ranked into an answer, and cannot
|
|
305
|
+
// skew a count — with zero edits to any of those call sites. Contrast the
|
|
306
|
+
// Archive container, which is containment-only: its 275 cards in this
|
|
307
|
+
// repo's own brain DO render, and would have reappeared in place.
|
|
308
|
+
let graveyard = [];
|
|
309
|
+
try {
|
|
310
|
+
const gRaw = await readText('graveyard.json');
|
|
311
|
+
if (gRaw) {
|
|
312
|
+
const parsed = JSON.parse(gRaw);
|
|
313
|
+
const entries = parsed && typeof parsed === 'object' ? (parsed.entries || {}) : {};
|
|
314
|
+
graveyard = Object.entries(entries)
|
|
315
|
+
.map(([id, e]) => ({ id, ...(e && typeof e === 'object' ? e : {}) }))
|
|
316
|
+
.filter(e => e.id)
|
|
317
|
+
.sort((a, b) => Number(b.deletedAt || 0) - Number(a.deletedAt || 0));
|
|
318
|
+
}
|
|
319
|
+
} catch { /* a corrupt index must never fail the whole parse */ }
|
|
320
|
+
|
|
234
321
|
const connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
235
322
|
const titleOf = (id) => cardTitle(items[id]) || (items[id]?.type ? `${items[id].type} ${String(id).slice(0, 8)}` : String(id).slice(0, 8));
|
|
236
323
|
const assetPaths = Object.keys(zip.files).filter(p => p.startsWith('assets/') && !zip.files[p].dir);
|
|
@@ -276,6 +363,9 @@ export async function parseKlypix(buffer) {
|
|
|
276
363
|
relationship: c.relationship || null, label: c.label || null,
|
|
277
364
|
})),
|
|
278
365
|
assets: assetPaths.map(p => path.basename(p)),
|
|
366
|
+
// Recoverable deletions, newest first. Never counted in counts.cards —
|
|
367
|
+
// a deleted card is not part of the brain, it is part of its bin.
|
|
368
|
+
graveyard,
|
|
279
369
|
};
|
|
280
370
|
return { struct, zip, assetPaths, isV4, canvas, manifest };
|
|
281
371
|
}
|
package/src/merge-brains.mjs
CHANGED
|
@@ -106,8 +106,17 @@ async function loadSide(buf) {
|
|
|
106
106
|
if (p.startsWith('assets/') && !zip.files[p].dir) assets[p] = await zip.file(p).async('nodebuffer');
|
|
107
107
|
}
|
|
108
108
|
const titleById = new Map(struct.cards.map(c => [c.id, c.title || '']));
|
|
109
|
+
// Graveyard: deleted-but-recoverable cards. Carried verbatim so a merge never
|
|
110
|
+
// empties another machine's bin, and so the bytes a tombstone removes from
|
|
111
|
+
// `order` are preserved rather than destroyed.
|
|
112
|
+
const graveyard = {}; // id -> { meta, json }
|
|
113
|
+
for (const e of (struct.graveyard || [])) {
|
|
114
|
+
const f = zip.file(`graveyard/${shard(e.id)}/${e.id}.json`);
|
|
115
|
+
const { id, ...meta } = e;
|
|
116
|
+
graveyard[e.id] = { meta, json: f ? await f.async('string') : null };
|
|
117
|
+
}
|
|
109
118
|
return {
|
|
110
|
-
order, positions, items, assets, manifest,
|
|
119
|
+
order, positions, items, assets, manifest, graveyard,
|
|
111
120
|
connections: Array.isArray(canvas.connections) ? canvas.connections : [],
|
|
112
121
|
lines: Array.isArray(canvas.lines) ? canvas.lines : [],
|
|
113
122
|
strokes: Array.isArray(canvas.strokes) ? canvas.strokes : [],
|
|
@@ -152,6 +161,39 @@ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }
|
|
|
152
161
|
const conflicts = [];
|
|
153
162
|
const delta = { added: [], updated: [], archived: [], removed: [] };
|
|
154
163
|
|
|
164
|
+
// ── Graveyard (2026-08-07) ───────────────────────────────────────────────
|
|
165
|
+
// An honored tombstone still REMOVES the card from the brain — `order`,
|
|
166
|
+
// `positions` and `struct.cards` are unchanged, so every read surface, the
|
|
167
|
+
// renderer and the no-loss invariant keep their exact current semantics. What
|
|
168
|
+
// changes is that the BYTES are moved to `graveyard/` instead of destroyed,
|
|
169
|
+
// making the delete recoverable. Deliberately NOT the Archive container:
|
|
170
|
+
// archived cards are only re-parented, so they still sit in `order` and still
|
|
171
|
+
// render — a deleted card put there would visibly reappear in place.
|
|
172
|
+
const graveyard = {};
|
|
173
|
+
for (const src of [T, O]) if (src?.graveyard) for (const [gid, g] of Object.entries(src.graveyard)) {
|
|
174
|
+
// Union, never prune: one machine emptying its bin must not empty another's.
|
|
175
|
+
if (!graveyard[gid] || Number(g.meta?.deletedAt || 0) > Number(graveyard[gid].meta?.deletedAt || 0)) graveyard[gid] = g;
|
|
176
|
+
}
|
|
177
|
+
const buryCard = (id) => {
|
|
178
|
+
if (graveyard[id]) return; // already buried — keep the original stamp
|
|
179
|
+
const json = O.items[id] ?? T.items[id] ?? null;
|
|
180
|
+
if (json == null) return; // nothing to preserve
|
|
181
|
+
const pos = O.positions[id] || T.positions[id] || null;
|
|
182
|
+
let preview = '';
|
|
183
|
+
try { preview = String(JSON.parse(json)?.content || '').replace(/\s+/g, ' ').trim().slice(0, 140); } catch { /* media card */ }
|
|
184
|
+
graveyard[id] = {
|
|
185
|
+
meta: {
|
|
186
|
+
deletedAt: Date.now(), // `now` below is declared later in this scope
|
|
187
|
+
deletedBy: 'human',
|
|
188
|
+
area: (O.titleById.get(pos?.parentId) || T.titleById.get(pos?.parentId) || null),
|
|
189
|
+
parentId: pos?.parentId ?? null,
|
|
190
|
+
pos: pos ? { x: pos.x, y: pos.y, w: pos.w ?? null, h: pos.h ?? null } : null,
|
|
191
|
+
preview,
|
|
192
|
+
},
|
|
193
|
+
json,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
155
197
|
for (const id of allIds) {
|
|
156
198
|
const inO = O.items[id] != null, inT = T.items[id] != null;
|
|
157
199
|
const inB = baseItem(id) != null;
|
|
@@ -169,10 +211,35 @@ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }
|
|
|
169
211
|
conflicts.push({ id, kind: 'delete-vs-edit', kept: 'theirs' });
|
|
170
212
|
// fall through to keep from theirs below
|
|
171
213
|
} else {
|
|
214
|
+
buryCard(id); // keep the bytes; the card still leaves the brain
|
|
172
215
|
delta.removed.push(id);
|
|
173
216
|
continue; // honored delete
|
|
174
217
|
}
|
|
175
218
|
}
|
|
219
|
+
// ── The bin is a DURABLE tombstone (2026-08-07) ────────────────────────
|
|
220
|
+
// Before it existed, `deletedIds` was a per-call argument that was consumed
|
|
221
|
+
// and thrown away, so a delete could not cross machines: sync with a peer
|
|
222
|
+
// who still had the card and it came straight back, because "absent from
|
|
223
|
+
// ours" alone is deliberately never a delete. A graveyard entry is not mere
|
|
224
|
+
// absence — it is a recorded human deletion — so it is honored here.
|
|
225
|
+
//
|
|
226
|
+
// The delete-vs-edit rule is unchanged and still wins: if the other side
|
|
227
|
+
// EDITED the card after our deletion, their information is newer than our
|
|
228
|
+
// intent, so the card comes back live and leaves the bin. Without a base we
|
|
229
|
+
// cannot prove an edit, so the deletion stands (conservative: a resurrected
|
|
230
|
+
// card is visible and re-deletable; a lost one is not).
|
|
231
|
+
if (graveyard[id] && !inO) {
|
|
232
|
+
const theirsChangedSinceBase = inT && inB && !sameMeaning(T.items[id], baseItem(id));
|
|
233
|
+
if (inT && theirsChangedSinceBase) {
|
|
234
|
+
conflicts.push({ id, kind: 'delete-vs-edit', kept: 'theirs' });
|
|
235
|
+
delete graveyard[id]; // resurrected — never in the brain AND the bin
|
|
236
|
+
} else {
|
|
237
|
+
delta.removed.push(id); // the deletion propagates
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Live on our side ⇒ not deleted. Covers a restore and a re-add.
|
|
242
|
+
if (graveyard[id] && inO) delete graveyard[id];
|
|
176
243
|
|
|
177
244
|
if (!inO && !inT) continue;
|
|
178
245
|
|
|
@@ -294,6 +361,20 @@ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }
|
|
|
294
361
|
for (const id of order) zip.file(`items/${shard(id)}/${id}.json`, merged.get(id).json);
|
|
295
362
|
for (const [p, bytes] of Object.entries(assets)) zip.file(p, bytes);
|
|
296
363
|
|
|
364
|
+
// Graveyard: card bytes under graveyard/, metadata in one index. Written only
|
|
365
|
+
// when non-empty so a brain that has never had a delete keeps a byte-identical
|
|
366
|
+
// shape. Nothing here is reachable from `order`, so nothing here can render,
|
|
367
|
+
// be searched, be embedded, or be counted.
|
|
368
|
+
const graveyardEntries = {};
|
|
369
|
+
for (const [gid, g] of Object.entries(graveyard)) {
|
|
370
|
+
if (g?.json == null) continue;
|
|
371
|
+
zip.file(`graveyard/${shard(gid)}/${gid}.json`, g.json);
|
|
372
|
+
graveyardEntries[gid] = g.meta || {};
|
|
373
|
+
}
|
|
374
|
+
if (Object.keys(graveyardEntries).length) {
|
|
375
|
+
zip.file('graveyard.json', JSON.stringify({ version: 1, entries: graveyardEntries }));
|
|
376
|
+
}
|
|
377
|
+
|
|
297
378
|
const positions = {};
|
|
298
379
|
for (const id of order) positions[id] = merged.get(id).pos;
|
|
299
380
|
|