klypix-mcp 1.62.0 → 1.63.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.
@@ -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.');
@@ -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', '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 [
@@ -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', '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,7 @@ 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',
39
40
  ' diff [ref] [--brain <path>] readable brain diff vs a git ref (default HEAD) — markdown to stdout',
40
41
  ' pr-brief [baseRef] [--brain <path>] brain decisions touching the files changed since baseRef — PR-comment markdown',
41
42
  '',
@@ -121,6 +121,10 @@ 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');
124
128
  await runVerb('diff', './klypix-diff.mjs');
125
129
  await runVerb('pr-brief', './klypix-pr-brief.mjs');
126
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.62.0",
3
+ "version": "1.63.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/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"
@@ -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,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
+ }
@@ -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
- export async function atomicWrite(filePath, buf) {
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