crbro-memory 2.10.0 → 2.11.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.
Files changed (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -465
  3. package/SECURITY.md +114 -12
  4. package/bin/crbro.mjs +539 -300
  5. package/dist/build-id.json +10 -0
  6. package/dist/cli/common.js +211 -0
  7. package/dist/cli/export.js +522 -0
  8. package/dist/cli/import.js +683 -0
  9. package/dist/cli/markdown.js +313 -0
  10. package/dist/cli/mcpclient.js +107 -0
  11. package/dist/cli/serve.js +453 -0
  12. package/dist/cli/view.js +358 -0
  13. package/dist/cli/viewer-page.js +378 -0
  14. package/dist/daemon/daemon.js +119 -11
  15. package/dist/daemon/endpoint.js +222 -21
  16. package/dist/daemon/lines.js +102 -13
  17. package/dist/daemon/proxy.js +480 -125
  18. package/dist/daemon/surface.js +116 -0
  19. package/dist/daemon/trace.js +73 -0
  20. package/dist/engine/atrest.js +132 -0
  21. package/dist/engine/backup.js +139 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +667 -232
  25. package/dist/engine/doctor.js +312 -14
  26. package/dist/engine/heat.js +81 -37
  27. package/dist/engine/hippocampus.js +18 -7
  28. package/dist/engine/hookrefresh.js +296 -0
  29. package/dist/engine/hookverify.js +15 -3
  30. package/dist/engine/installer.js +1022 -0
  31. package/dist/engine/installio.js +279 -0
  32. package/dist/engine/keychain.js +721 -162
  33. package/dist/engine/maintenance.js +355 -87
  34. package/dist/engine/modinstall.js +98 -45
  35. package/dist/engine/prefrontal.js +29 -6
  36. package/dist/engine/quarantine.js +204 -0
  37. package/dist/engine/secrets.js +41 -5
  38. package/dist/engine/shelf.js +25 -3
  39. package/dist/engine/synapses.js +183 -80
  40. package/dist/engine/triggers.js +141 -11
  41. package/dist/index.js +37 -3
  42. package/dist/mcp-surface.json +1 -0
  43. package/dist/miner/extractor.js +64 -17
  44. package/dist/miner/scheduler.js +144 -68
  45. package/dist/search/index.js +797 -176
  46. package/dist/search/semantic.js +208 -18
  47. package/dist/search/tokenize.js +44 -0
  48. package/dist/server.js +583 -373
  49. package/dist/sync/git.js +203 -72
  50. package/dist/sync/space.js +100 -25
  51. package/dist/sync/validate.js +204 -0
  52. package/dist/utils/budget.js +15 -2
  53. package/dist/utils/fs.js +40 -6
  54. package/dist/utils/ids.js +128 -14
  55. package/dist/utils/lock.js +94 -7
  56. package/hooks/crbro-codex.mjs +284 -0
  57. package/hooks/crbro-guard.mjs +281 -173
  58. package/hooks/crbro-hooks.shipped.json +20 -0
  59. package/hooks/crbro-lifecycle.mjs +16 -4
  60. package/hooks/crbro-subagent.mjs +180 -180
  61. package/package.json +9 -5
  62. package/dist/daemon/daemon.d.ts +0 -25
  63. package/dist/daemon/daemon.d.ts.map +0 -1
  64. package/dist/daemon/daemon.js.map +0 -1
  65. package/dist/daemon/endpoint.d.ts +0 -66
  66. package/dist/daemon/endpoint.d.ts.map +0 -1
  67. package/dist/daemon/endpoint.js.map +0 -1
  68. package/dist/daemon/lines.d.ts +0 -55
  69. package/dist/daemon/lines.d.ts.map +0 -1
  70. package/dist/daemon/lines.js.map +0 -1
  71. package/dist/daemon/proxy.d.ts +0 -55
  72. package/dist/daemon/proxy.d.ts.map +0 -1
  73. package/dist/daemon/proxy.js.map +0 -1
  74. package/dist/engine/backup.d.ts +0 -68
  75. package/dist/engine/backup.d.ts.map +0 -1
  76. package/dist/engine/backup.js.map +0 -1
  77. package/dist/engine/brain.d.ts +0 -79
  78. package/dist/engine/brain.d.ts.map +0 -1
  79. package/dist/engine/brain.js.map +0 -1
  80. package/dist/engine/cortex.d.ts +0 -682
  81. package/dist/engine/cortex.d.ts.map +0 -1
  82. package/dist/engine/cortex.js.map +0 -1
  83. package/dist/engine/dates.d.ts +0 -28
  84. package/dist/engine/dates.d.ts.map +0 -1
  85. package/dist/engine/dates.js.map +0 -1
  86. package/dist/engine/doctor.d.ts +0 -93
  87. package/dist/engine/doctor.d.ts.map +0 -1
  88. package/dist/engine/doctor.js.map +0 -1
  89. package/dist/engine/evidence.d.ts +0 -26
  90. package/dist/engine/evidence.d.ts.map +0 -1
  91. package/dist/engine/evidence.js.map +0 -1
  92. package/dist/engine/heat.d.ts +0 -28
  93. package/dist/engine/heat.d.ts.map +0 -1
  94. package/dist/engine/heat.js.map +0 -1
  95. package/dist/engine/hippocampus.d.ts +0 -60
  96. package/dist/engine/hippocampus.d.ts.map +0 -1
  97. package/dist/engine/hippocampus.js.map +0 -1
  98. package/dist/engine/hookverify.d.ts +0 -42
  99. package/dist/engine/hookverify.d.ts.map +0 -1
  100. package/dist/engine/hookverify.js.map +0 -1
  101. package/dist/engine/keychain.d.ts +0 -32
  102. package/dist/engine/keychain.d.ts.map +0 -1
  103. package/dist/engine/keychain.js.map +0 -1
  104. package/dist/engine/maintenance.d.ts +0 -218
  105. package/dist/engine/maintenance.d.ts.map +0 -1
  106. package/dist/engine/maintenance.js.map +0 -1
  107. package/dist/engine/modinstall.d.ts +0 -215
  108. package/dist/engine/modinstall.d.ts.map +0 -1
  109. package/dist/engine/modinstall.js.map +0 -1
  110. package/dist/engine/postmortem.d.ts +0 -81
  111. package/dist/engine/postmortem.d.ts.map +0 -1
  112. package/dist/engine/postmortem.js.map +0 -1
  113. package/dist/engine/prefrontal.d.ts +0 -64
  114. package/dist/engine/prefrontal.d.ts.map +0 -1
  115. package/dist/engine/prefrontal.js.map +0 -1
  116. package/dist/engine/projects.d.ts +0 -20
  117. package/dist/engine/projects.d.ts.map +0 -1
  118. package/dist/engine/projects.js.map +0 -1
  119. package/dist/engine/secrets.d.ts +0 -30
  120. package/dist/engine/secrets.d.ts.map +0 -1
  121. package/dist/engine/secrets.js.map +0 -1
  122. package/dist/engine/shelf.d.ts +0 -155
  123. package/dist/engine/shelf.d.ts.map +0 -1
  124. package/dist/engine/shelf.js.map +0 -1
  125. package/dist/engine/source.d.ts +0 -6
  126. package/dist/engine/source.d.ts.map +0 -1
  127. package/dist/engine/source.js.map +0 -1
  128. package/dist/engine/synapses.d.ts +0 -82
  129. package/dist/engine/synapses.d.ts.map +0 -1
  130. package/dist/engine/synapses.js.map +0 -1
  131. package/dist/engine/triggers.d.ts +0 -53
  132. package/dist/engine/triggers.d.ts.map +0 -1
  133. package/dist/engine/triggers.js.map +0 -1
  134. package/dist/engine/usage.d.ts +0 -61
  135. package/dist/engine/usage.d.ts.map +0 -1
  136. package/dist/engine/usage.js.map +0 -1
  137. package/dist/index.d.ts +0 -2
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js.map +0 -1
  140. package/dist/miner/extractor.d.ts +0 -12
  141. package/dist/miner/extractor.d.ts.map +0 -1
  142. package/dist/miner/extractor.js.map +0 -1
  143. package/dist/miner/index.d.ts +0 -61
  144. package/dist/miner/index.d.ts.map +0 -1
  145. package/dist/miner/index.js.map +0 -1
  146. package/dist/miner/scheduler.d.ts +0 -30
  147. package/dist/miner/scheduler.d.ts.map +0 -1
  148. package/dist/miner/scheduler.js.map +0 -1
  149. package/dist/miner/types.d.ts +0 -11
  150. package/dist/miner/types.d.ts.map +0 -1
  151. package/dist/miner/types.js.map +0 -1
  152. package/dist/search/index.d.ts +0 -328
  153. package/dist/search/index.d.ts.map +0 -1
  154. package/dist/search/index.js.map +0 -1
  155. package/dist/search/quality.d.ts +0 -18
  156. package/dist/search/quality.d.ts.map +0 -1
  157. package/dist/search/quality.js.map +0 -1
  158. package/dist/search/semantic.d.ts +0 -80
  159. package/dist/search/semantic.d.ts.map +0 -1
  160. package/dist/search/semantic.js.map +0 -1
  161. package/dist/search/synonyms.d.ts +0 -2
  162. package/dist/search/synonyms.d.ts.map +0 -1
  163. package/dist/search/synonyms.js.map +0 -1
  164. package/dist/search/tokenize.d.ts +0 -39
  165. package/dist/search/tokenize.d.ts.map +0 -1
  166. package/dist/search/tokenize.js.map +0 -1
  167. package/dist/server.d.ts +0 -41
  168. package/dist/server.d.ts.map +0 -1
  169. package/dist/server.js.map +0 -1
  170. package/dist/sync/git.d.ts +0 -36
  171. package/dist/sync/git.d.ts.map +0 -1
  172. package/dist/sync/git.js.map +0 -1
  173. package/dist/sync/materialize.d.ts +0 -36
  174. package/dist/sync/materialize.d.ts.map +0 -1
  175. package/dist/sync/materialize.js.map +0 -1
  176. package/dist/sync/ops.d.ts +0 -147
  177. package/dist/sync/ops.d.ts.map +0 -1
  178. package/dist/sync/ops.js.map +0 -1
  179. package/dist/sync/space.d.ts +0 -118
  180. package/dist/sync/space.d.ts.map +0 -1
  181. package/dist/sync/space.js.map +0 -1
  182. package/dist/types/index.d.ts +0 -369
  183. package/dist/types/index.d.ts.map +0 -1
  184. package/dist/types/index.js.map +0 -1
  185. package/dist/utils/budget.d.ts +0 -44
  186. package/dist/utils/budget.d.ts.map +0 -1
  187. package/dist/utils/budget.js.map +0 -1
  188. package/dist/utils/fs.d.ts +0 -59
  189. package/dist/utils/fs.d.ts.map +0 -1
  190. package/dist/utils/fs.js.map +0 -1
  191. package/dist/utils/hash.d.ts +0 -20
  192. package/dist/utils/hash.d.ts.map +0 -1
  193. package/dist/utils/hash.js.map +0 -1
  194. package/dist/utils/ids.d.ts +0 -47
  195. package/dist/utils/ids.d.ts.map +0 -1
  196. package/dist/utils/ids.js.map +0 -1
  197. package/dist/utils/lock.d.ts +0 -13
  198. package/dist/utils/lock.d.ts.map +0 -1
  199. package/dist/utils/lock.js.map +0 -1
  200. package/dist/utils/transcripts.d.ts +0 -63
  201. package/dist/utils/transcripts.d.ts.map +0 -1
  202. package/dist/utils/transcripts.js.map +0 -1
  203. package/dist/version.d.ts +0 -26
  204. package/dist/version.d.ts.map +0 -1
  205. package/dist/version.js.map +0 -1
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ // ─── What the server says before it does anything ────────────────
3
+ //
4
+ // `initialize`, `ping` and `tools/list` have the same answer for every
5
+ // conversation of one build: who the server is, what it can do, how it is
6
+ // meant to be used and which tools it has. A proxy that waited for a backend
7
+ // to say so kept the client in silence while it found, started or gave up on
8
+ // the daemon — past 12 s in the worst case, beyond Codex's 10 s start-up
9
+ // limit. So the proxy answers them itself, at once.
10
+ //
11
+ // The answers are never written by hand. They are taken from the real server
12
+ // (createServer, with engines that are built but never started): at build
13
+ // time into dist/mcp-surface.json, or in this process when that file is
14
+ // missing or belongs to another build. tests/daemon.startup.test.ts holds the
15
+ // proxy's answers against the real server's.
16
+ var __importDefault = (this && this.__importDefault) || function (mod) {
17
+ return (mod && mod.__esModule) ? mod : { "default": mod };
18
+ };
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.SURFACE_FILE = void 0;
21
+ exports.loadSurface = loadSurface;
22
+ exports.computeSurface = computeSurface;
23
+ exports.getSurface = getSurface;
24
+ exports.answerFromSurface = answerFromSurface;
25
+ const node_fs_1 = require("node:fs");
26
+ const node_path_1 = __importDefault(require("node:path"));
27
+ exports.SURFACE_FILE = 'mcp-surface.json';
28
+ function isSurface(v) {
29
+ return !!v && typeof v.build === 'string' && typeof v.latest === 'string' && Array.isArray(v.supported)
30
+ && v.supported.every((x) => typeof x === 'string')
31
+ && !!v.initialize && typeof v.initialize.capabilities === 'object' && typeof v.initialize.serverInfo === 'object'
32
+ && !!v.tools && Array.isArray(v.tools.tools);
33
+ }
34
+ /** The snapshot next to the compiled code, if it is this build's. */
35
+ function loadSurface(build, dir = node_path_1.default.join(__dirname, '..')) {
36
+ try {
37
+ const s = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(dir, exports.SURFACE_FILE), 'utf8'));
38
+ return isSurface(s) && s.build === build ? s : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Ask a real server, in this process. Its engines are constructed (that reads
46
+ * nothing) and never started: no brain is opened, no index loaded.
47
+ */
48
+ async function computeSurface(build) {
49
+ const { createServer, createEngines } = await import('../server.js');
50
+ const { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } = await import('@modelcontextprotocol/sdk/types.js');
51
+ const { LoopbackTransport } = await import('./lines.js');
52
+ const answers = new Map();
53
+ const transport = new LoopbackTransport(line => { const m = JSON.parse(line); if (m.id !== undefined)
54
+ answers.set(String(m.id), m); });
55
+ const server = createServer(createEngines());
56
+ await server.connect(transport);
57
+ transport.receive(JSON.stringify({
58
+ jsonrpc: '2.0', id: 'surface-init', method: 'initialize',
59
+ params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'crbro-surface', version: '0' } },
60
+ }));
61
+ transport.receive(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
62
+ transport.receive(JSON.stringify({ jsonrpc: '2.0', id: 'surface-tools', method: 'tools/list', params: {} }));
63
+ const until = Date.now() + 10_000;
64
+ while ((!answers.has('surface-init') || !answers.has('surface-tools')) && Date.now() < until) {
65
+ await new Promise(r => setImmediate(r));
66
+ }
67
+ await server.close().catch(() => undefined);
68
+ const init = answers.get('surface-init')?.result;
69
+ const tools = answers.get('surface-tools')?.result;
70
+ if (!init || !tools)
71
+ throw new Error('the server did not describe itself');
72
+ const { protocolVersion: _negotiated, ...initialize } = init;
73
+ return { build, latest: LATEST_PROTOCOL_VERSION, supported: [...SUPPORTED_PROTOCOL_VERSIONS], initialize, tools };
74
+ }
75
+ const computed = new Map();
76
+ /** The snapshot if there is one for this build, else the real server asked here (once per process). Null if neither can be had. */
77
+ async function getSurface(build) {
78
+ const s = loadSurface(build);
79
+ if (s)
80
+ return s;
81
+ let p = computed.get(build);
82
+ if (!p) {
83
+ p = computeSurface(build).catch(() => null);
84
+ computed.set(build, p);
85
+ }
86
+ return p;
87
+ }
88
+ /** Only an initialize the server itself would accept is answered here; anything else goes to it and gets its own answer. */
89
+ function wellFormedInitialize(m) {
90
+ const p = m?.params;
91
+ return !!p && typeof p === 'object' && typeof p.protocolVersion === 'string'
92
+ && !!p.capabilities && typeof p.capabilities === 'object'
93
+ && !!p.clientInfo && typeof p.clientInfo === 'object' && typeof p.clientInfo.name === 'string' && typeof p.clientInfo.version === 'string';
94
+ }
95
+ /**
96
+ * The answer the server would give to this request, or null when it is not
97
+ * one of the requests whose answer is fixed per build. The protocol version
98
+ * is negotiated exactly as the SDK does: the client's if supported, else the
99
+ * latest.
100
+ */
101
+ function answerFromSurface(s, m) {
102
+ if (!m || m.jsonrpc !== '2.0' || m.id === undefined || m.id === null || typeof m.method !== 'string')
103
+ return null;
104
+ if (m.method === 'initialize') {
105
+ if (!wellFormedInitialize(m))
106
+ return null;
107
+ const protocolVersion = s.supported.includes(m.params.protocolVersion) ? m.params.protocolVersion : s.latest;
108
+ return { jsonrpc: '2.0', id: m.id, result: { protocolVersion, ...s.initialize } };
109
+ }
110
+ if (m.method === 'ping')
111
+ return { jsonrpc: '2.0', id: m.id, result: {} };
112
+ if (m.method === 'tools/list')
113
+ return { jsonrpc: '2.0', id: m.id, result: s.tools };
114
+ return null;
115
+ }
116
+ //# sourceMappingURL=surface.js.map
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ // ─── CRBRO_STARTUP_TRACE=1: where the start-up time goes ──────────
3
+ //
4
+ // Until 2.11 "the client says CRBRO is slow to start" could not be broken
5
+ // down: not in the client, not in the server. With the variable set, each
6
+ // process writes one line per phase — milliseconds since the process started,
7
+ // the phase, a detail — to stderr and to <brain>/.daemon/startup-trace.log
8
+ // (a detached daemon has no stderr anybody reads). Phases: process start,
9
+ // initialize answered, backend connected or in-process fallback, engine
10
+ // ready, first tool result.
11
+ //
12
+ // Off, it costs one boolean test per call site: nothing is formatted, nothing
13
+ // is written.
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.TRACE_ON = void 0;
19
+ exports.traceStart = traceStart;
20
+ exports.trace = trace;
21
+ const node_fs_1 = require("node:fs");
22
+ const node_path_1 = __importDefault(require("node:path"));
23
+ const node_perf_hooks_1 = require("node:perf_hooks");
24
+ exports.TRACE_ON = (process.env.CRBRO_STARTUP_TRACE || '').trim() === '1';
25
+ const LOG_MAX_BYTES = 512 * 1024;
26
+ let file = null;
27
+ let role = 'process';
28
+ const seen = new Set();
29
+ /** Where the log goes and who is writing (proxy, daemon, classic). Writes the "process start" line. */
30
+ function traceStart(brainRoot, who) {
31
+ if (!exports.TRACE_ON)
32
+ return;
33
+ role = who;
34
+ if (brainRoot) {
35
+ try {
36
+ const dir = node_path_1.default.join(brainRoot, '.daemon');
37
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
38
+ file = node_path_1.default.join(dir, 'startup-trace.log');
39
+ // Left on for weeks, it keeps one previous file and starts again.
40
+ try {
41
+ if ((0, node_fs_1.statSync)(file).size > LOG_MAX_BYTES)
42
+ (0, node_fs_1.renameSync)(file, `${file}.1`);
43
+ }
44
+ catch { /* no log yet */ }
45
+ }
46
+ catch {
47
+ file = null;
48
+ }
49
+ }
50
+ trace('process start', `pid ${process.pid} · started ${new Date(node_perf_hooks_1.performance.timeOrigin).toISOString()}`);
51
+ }
52
+ /** One phase. `once`: only the first time this phase is reached in this process. */
53
+ function trace(phase, detail = '', once = false) {
54
+ if (!exports.TRACE_ON)
55
+ return;
56
+ if (once) {
57
+ if (seen.has(phase))
58
+ return;
59
+ seen.add(phase);
60
+ }
61
+ const line = `[crbro trace] ${role} +${node_perf_hooks_1.performance.now().toFixed(1)}ms ${phase}${detail ? ` · ${detail}` : ''}`;
62
+ try {
63
+ process.stderr.write(`${line}\n`);
64
+ }
65
+ catch { /* no stderr (detached) */ }
66
+ if (file) {
67
+ try {
68
+ (0, node_fs_1.appendFileSync)(file, `${new Date().toISOString()} [${process.pid}] ${line}\n`);
69
+ }
70
+ catch { /* the trace never breaks start-up */ }
71
+ }
72
+ }
73
+ //# sourceMappingURL=trace.js.map
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ // ─── Encryption at rest, opt-in: backups and quarantine ─────────
3
+ //
4
+ // The brain is plain JSON on purpose — people read it, diff it and fix it by
5
+ // hand. Its two copies are a different matter: a backup is made to be carried
6
+ // to a synced folder, and the quarantine holds exactly what somebody asked to
7
+ // forget, credentials included. Whoever syncs those to a cloud drive may want
8
+ // them sealed. Off by default; nothing changes for anyone who does not ask.
9
+ //
10
+ // CRBRO_ENCRYPT_AT_REST=1 seal new backups and quarantine copies
11
+ //
12
+ // The key: 32 random bytes, base64, kept in the OS keychain under
13
+ // CRBRO_AT_REST_KEY (crbro_secret / `crbro-memory secret set`), never in the
14
+ // brain and never next to the files it protects. With the switch on and no key
15
+ // CRBRO fails loudly: it never falls back to writing in clear and never deletes
16
+ // anything for lack of a key. Create the key once, explicitly:
17
+ //
18
+ // node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" \
19
+ // | npx crbro-memory secret set CRBRO_AT_REST_KEY --description "crbro - at-rest key"
20
+ //
21
+ // Lose the key and the sealed copies are gone with it: that is what sealing
22
+ // means. Clear files stay readable whatever the switch says.
23
+ //
24
+ // Format: MAGIC (8 bytes) | IV (12) | GCM tag (16) | ciphertext. AES-256-GCM
25
+ // from node:crypto, the magic bound as additional data. Readers recognise a
26
+ // sealed file by its first bytes, never by its name.
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.AtRestKeyError = exports.AT_REST_KEY_NAME = void 0;
29
+ exports.atRestEnabled = atRestEnabled;
30
+ exports.generateAtRestKey = generateAtRestKey;
31
+ exports._resetAtRestKeyCache = _resetAtRestKeyCache;
32
+ exports.loadAtRestKey = loadAtRestKey;
33
+ exports.isSealed = isSealed;
34
+ exports.seal = seal;
35
+ exports.unseal = unseal;
36
+ exports.maybeSeal = maybeSeal;
37
+ const node_crypto_1 = require("node:crypto");
38
+ const keychain_js_1 = require("./keychain.js");
39
+ exports.AT_REST_KEY_NAME = 'CRBRO_AT_REST_KEY';
40
+ const MAGIC = Buffer.from('CRBRENC1', 'ascii');
41
+ const IV_LEN = 12;
42
+ const TAG_LEN = 16;
43
+ class AtRestKeyError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'AtRestKeyError';
47
+ }
48
+ }
49
+ exports.AtRestKeyError = AtRestKeyError;
50
+ /** The switch. Only an explicit "1"/"true"/"on" turns it on. */
51
+ function atRestEnabled(env = process.env) {
52
+ return /^(1|true|on|yes)$/i.test((env['CRBRO_ENCRYPT_AT_REST'] || '').trim());
53
+ }
54
+ /** A fresh key, base64. For the person setting it up; CRBRO never stores one by itself. */
55
+ function generateAtRestKey() {
56
+ return (0, node_crypto_1.randomBytes)(32).toString('base64');
57
+ }
58
+ let cache = null;
59
+ const CACHE_MS = 60_000;
60
+ /** Tests switch keys and keychain folders between cases. */
61
+ function _resetAtRestKeyCache() {
62
+ cache = null;
63
+ }
64
+ /** The key from the keychain, or a loud error saying how to create it. */
65
+ function loadAtRestKey() {
66
+ if (cache && Date.now() - cache.at < CACHE_MS)
67
+ return cache.key;
68
+ let raw;
69
+ try {
70
+ raw = (0, keychain_js_1.getSecret)(exports.AT_REST_KEY_NAME);
71
+ }
72
+ catch (err) {
73
+ throw new AtRestKeyError(`Encryption at rest is on (CRBRO_ENCRYPT_AT_REST) but the keychain could not be read: ${err.message}. ` +
74
+ 'Nothing was written in clear and nothing was deleted.');
75
+ }
76
+ if (!raw || !raw.trim()) {
77
+ throw new AtRestKeyError(`Encryption at rest is on (CRBRO_ENCRYPT_AT_REST) but there is no ${exports.AT_REST_KEY_NAME} in the keychain. ` +
78
+ 'Nothing was written in clear and nothing was deleted. Create the key once with: ' +
79
+ `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" | npx crbro-memory secret set ${exports.AT_REST_KEY_NAME}`);
80
+ }
81
+ const key = Buffer.from(raw.trim(), 'base64');
82
+ if (key.length !== 32) {
83
+ throw new AtRestKeyError(`${exports.AT_REST_KEY_NAME} must be 32 bytes in base64 (got ${key.length} bytes). Nothing was written.`);
84
+ }
85
+ cache = { key, at: Date.now() };
86
+ return key;
87
+ }
88
+ function isSealed(data) {
89
+ return data.length >= MAGIC.length + IV_LEN + TAG_LEN && data.subarray(0, MAGIC.length).equals(MAGIC);
90
+ }
91
+ function seal(plain, key = loadAtRestKey()) {
92
+ const iv = (0, node_crypto_1.randomBytes)(IV_LEN);
93
+ const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', key, iv);
94
+ cipher.setAAD(MAGIC);
95
+ const body = Buffer.concat([cipher.update(plain), cipher.final()]);
96
+ return Buffer.concat([MAGIC, iv, cipher.getAuthTag(), body]);
97
+ }
98
+ /**
99
+ * Clear data comes back as it is; sealed data is opened with the keychain key
100
+ * whatever the switch says (turning encryption off must not orphan old copies).
101
+ */
102
+ function unseal(data, key) {
103
+ if (!isSealed(data))
104
+ return data;
105
+ const k = key ?? (() => {
106
+ try {
107
+ return loadAtRestKey();
108
+ }
109
+ catch (err) {
110
+ throw new AtRestKeyError(`This file is encrypted at rest and the key is not available: ${err.message}`);
111
+ }
112
+ })();
113
+ const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LEN);
114
+ const tag = data.subarray(MAGIC.length + IV_LEN, MAGIC.length + IV_LEN + TAG_LEN);
115
+ const body = data.subarray(MAGIC.length + IV_LEN + TAG_LEN);
116
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', k, iv);
117
+ decipher.setAAD(MAGIC);
118
+ decipher.setAuthTag(tag);
119
+ try {
120
+ return Buffer.concat([decipher.update(body), decipher.final()]);
121
+ }
122
+ catch {
123
+ throw new AtRestKeyError('Encrypted file could not be opened: wrong key or damaged file.');
124
+ }
125
+ }
126
+ /** Seal when the switch is on, else return the bytes untouched. Throws (never writes clear) if on and keyless. */
127
+ function maybeSeal(plain, env = process.env) {
128
+ if (!atRestEnabled(env))
129
+ return { data: plain, sealed: false };
130
+ return { data: seal(plain), sealed: true };
131
+ }
132
+ //# sourceMappingURL=atrest.js.map
@@ -2,6 +2,13 @@
2
2
  // ─── Brain backup ────────────────────────────────────────────────
3
3
  // One gzipped JSON file per backup, with rotation.
4
4
  //
5
+ // 2.11: --keep is validated (a whole number ≥ 1), each new copy is read back
6
+ // before anything is rotated, the copy just written is never rotated, a
7
+ // future-dated copy no longer counts as the newest, retention is tiered
8
+ // (newest N + one per day for N days + per ISO week for 4 weeks + per month
9
+ // for 6 months), and with CRBRO_ENCRYPT_AT_REST=1 copies are sealed
10
+ // (`.json.gz.enc`, see atrest.ts).
11
+ //
5
12
  // Why a single file and not a copied directory: it is written with
6
13
  // tmp + rename, so a backup either exists whole or does not exist; a copied
7
14
  // tree interrupted half-way looks like a backup and is not one. It is also
@@ -25,9 +32,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
32
  return (mod && mod.__esModule) ? mod : { "default": mod };
26
33
  };
27
34
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.AUTO_BACKUP_INTERVAL_MS = exports.DEFAULT_KEEP = exports.BACKUP_FORMAT = void 0;
35
+ exports.DEFAULT_RETENTION = exports.AUTO_BACKUP_INTERVAL_MS = exports.DEFAULT_KEEP = exports.BACKUP_FORMAT = void 0;
36
+ exports.validateKeep = validateKeep;
29
37
  exports.resolveBackupDir = resolveBackupDir;
30
38
  exports.listBackups = listBackups;
39
+ exports.retainedBackups = retainedBackups;
31
40
  exports.createBackup = createBackup;
32
41
  exports.readBackup = readBackup;
33
42
  exports.restoreBackup = restoreBackup;
@@ -37,7 +46,10 @@ const path_1 = __importDefault(require("path"));
37
46
  const os_1 = __importDefault(require("os"));
38
47
  const zlib_1 = __importDefault(require("zlib"));
39
48
  const util_1 = require("util");
49
+ const fs_js_1 = require("../utils/fs.js");
40
50
  const gzip = (0, util_1.promisify)(zlib_1.default.gzip);
51
+ /** Loaded on first use: doctor and listing never need the keychain. */
52
+ const atRest = () => import('./atrest.js');
41
53
  const gunzip = (0, util_1.promisify)(zlib_1.default.gunzip);
42
54
  exports.BACKUP_FORMAT = 1;
43
55
  exports.DEFAULT_KEEP = 7;
@@ -51,6 +63,17 @@ const SKIP_TOP = new Set(['.quarantine', '.search', '.semantic', '.device-token'
51
63
  const SKIP_ANYWHERE = new Set(['.git', 'node_modules']);
52
64
  const PREFIX = 'brain-';
53
65
  const SUFFIX = '.json.gz';
66
+ /** A copy sealed with the at-rest key (CRBRO_ENCRYPT_AT_REST=1). */
67
+ const SUFFIX_ENC = '.json.gz.enc';
68
+ /** A copy whose name is further ahead of the clock than this is future-dated. */
69
+ const FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000;
70
+ exports.DEFAULT_RETENTION = { keep: exports.DEFAULT_KEEP, daily: 7, weekly: 4, monthly: 6 };
71
+ /** An integer ≥ 1, or an error that says so. `backup --keep siete` used to mean "delete every copy". */
72
+ function validateKeep(keep) {
73
+ if (typeof keep === 'number' && Number.isInteger(keep) && keep >= 1)
74
+ return keep;
75
+ throw new Error(`--keep must be a whole number of at least 1 (got ${JSON.stringify(keep)}). Nothing was written or rotated.`);
76
+ }
54
77
  /**
55
78
  * Where backups go: a sibling of the brain they belong to — `~/.crbro` →
56
79
  * `~/.crbro-backups/brain`.
@@ -104,7 +127,10 @@ async function walk(root, rel, out) {
104
127
  }
105
128
  }
106
129
  }
107
- async function listBackups(dir) {
130
+ function isBackupName(n) {
131
+ return n.startsWith(PREFIX) && (n.endsWith(SUFFIX) || n.endsWith(SUFFIX_ENC));
132
+ }
133
+ async function listBackups(dir, now = new Date()) {
108
134
  let names;
109
135
  try {
110
136
  names = await fs_1.promises.readdir(dir);
@@ -113,23 +139,69 @@ async function listBackups(dir) {
113
139
  return [];
114
140
  }
115
141
  const found = [];
142
+ const limite = now.getTime() + FUTURE_TOLERANCE_MS;
116
143
  for (const n of names) {
117
- if (!n.startsWith(PREFIX) || !n.endsWith(SUFFIX))
144
+ if (!isBackupName(n))
118
145
  continue;
119
146
  try {
120
147
  const st = await fs_1.promises.stat(path_1.default.join(dir, n));
121
- const m = n.slice(PREFIX.length, -SUFFIX.length).match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/);
122
- const created = m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.000Z` : st.mtime.toISOString();
123
- found.push({ file: n, path: path_1.default.join(dir, n), created, size_bytes: st.size });
148
+ const m = n.slice(PREFIX.length).match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/);
149
+ let created = m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.000Z` : st.mtime.toISOString();
150
+ // A clock set ahead once must not make that copy "the newest" for
151
+ // months: it blocked every automatic copy and got the good ones rotated
152
+ // out first. Its name is not evidence; the file's own date is.
153
+ let future = false;
154
+ if (!Number.isFinite(Date.parse(created)) || Date.parse(created) > limite) {
155
+ future = true;
156
+ // A file date ahead too: it sorts as the oldest copy, never the newest.
157
+ created = new Date(st.mtimeMs <= limite ? st.mtimeMs : 0).toISOString();
158
+ }
159
+ found.push({
160
+ file: n, path: path_1.default.join(dir, n), created, size_bytes: st.size,
161
+ ...(n.endsWith(SUFFIX_ENC) ? { encrypted: true } : {}),
162
+ ...(future ? { future_dated: true } : {}),
163
+ });
124
164
  }
125
165
  catch { /* gone */ }
126
166
  }
127
- return found.sort((a, b) => (a.file < b.file ? 1 : -1)); // newest first
167
+ return found.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : a.file < b.file ? 1 : -1)); // newest first
168
+ }
169
+ const isoWeek = (d) => {
170
+ const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
171
+ const dia = t.getUTCDay() || 7;
172
+ t.setUTCDate(t.getUTCDate() + 4 - dia);
173
+ const inicio = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
174
+ const semana = Math.ceil(((t.getTime() - inicio.getTime()) / 86_400_000 + 1) / 7);
175
+ return `${t.getUTCFullYear()}-W${String(semana).padStart(2, '0')}`;
176
+ };
177
+ /** Which copies a retention policy keeps, by file name. Pure. `all` is newest first. */
178
+ function retainedBackups(all, policy = exports.DEFAULT_RETENTION) {
179
+ const keep = new Set();
180
+ for (const b of all.slice(0, policy.keep))
181
+ keep.add(b.file);
182
+ const tier = (key, n) => {
183
+ const vistos = new Set();
184
+ for (const b of all) {
185
+ const k = key(new Date(b.created));
186
+ if (vistos.has(k))
187
+ continue;
188
+ if (vistos.size >= n)
189
+ break;
190
+ vistos.add(k);
191
+ keep.add(b.file); // newest of that bucket: `all` is newest first
192
+ }
193
+ };
194
+ tier(d => d.toISOString().slice(0, 10), policy.daily);
195
+ tier(isoWeek, policy.weekly);
196
+ tier(d => d.toISOString().slice(0, 7), policy.monthly);
197
+ return keep;
128
198
  }
129
199
  async function createBackup(paths, opts = {}) {
130
200
  const dir = opts.dir || resolveBackupDir(paths.root);
131
- const keep = Math.max(1, opts.keep ?? exports.DEFAULT_KEEP);
201
+ const keep = opts.keep === undefined ? exports.DEFAULT_KEEP : validateKeep(opts.keep);
202
+ const retention = { ...exports.DEFAULT_RETENTION, daily: keep, ...opts.retention, keep };
132
203
  const when = opts.now || new Date();
204
+ const env = opts.env || process.env;
133
205
  const files = {};
134
206
  await walk(paths.root, '', files);
135
207
  const keys = Object.keys(files);
@@ -151,28 +223,74 @@ async function createBackup(paths, opts = {}) {
151
223
  // An empty bundle would rotate a real backup out. Refuse instead.
152
224
  throw new Error(`Nothing to back up at ${paths.root}: no neurons and no sessions found.`);
153
225
  }
226
+ // A neuron that does not parse is copied as it is — its bytes may be all
227
+ // that is left of it — and named, so nobody restores it believing it fine.
228
+ const unreadable = [];
229
+ for (const k of keys) {
230
+ if (!k.startsWith('cortex/') || !k.endsWith('.json'))
231
+ continue;
232
+ try {
233
+ JSON.parse((0, fs_js_1.stripBOM)(files[k]));
234
+ }
235
+ catch {
236
+ unreadable.push(k);
237
+ }
238
+ }
239
+ const gz = await gzip(Buffer.from(JSON.stringify(bundle), 'utf8'), { level: 6 });
240
+ // Sealed when encryption at rest is on; throws before writing anything when
241
+ // it is on and the key is missing — never a clear copy instead.
242
+ const { maybeSeal } = await atRest();
243
+ const { data: packed, sealed } = maybeSeal(gz, env);
154
244
  await fs_1.promises.mkdir(dir, { recursive: true });
155
- const name = `${PREFIX}${stamp(when)}${SUFFIX}`;
245
+ const name = `${PREFIX}${stamp(when)}${sealed ? SUFFIX_ENC : SUFFIX}`;
156
246
  const target = path_1.default.join(dir, name);
157
247
  const tmp = `${target}.tmp-${process.pid}`;
158
- const packed = await gzip(Buffer.from(JSON.stringify(bundle), 'utf8'), { level: 6 });
159
- await fs_1.promises.writeFile(tmp, packed);
248
+ const fh = await fs_1.promises.open(tmp, 'w');
249
+ try {
250
+ await fh.writeFile(packed);
251
+ await fh.sync();
252
+ }
253
+ finally {
254
+ await fh.close();
255
+ }
160
256
  await fs_1.promises.rename(tmp, target);
161
- // Rotation only ever removes files this module named, and only after the new
162
- // one is safely on disk.
163
- const all = await listBackups(dir);
257
+ // Read it back before trusting it. A copy cut short by a crash or a full
258
+ // disk used to be followed straight away by the rotation of the oldest GOOD
259
+ // one. A copy that does not read back is removed (it would otherwise count
260
+ // as the newest) and nothing is rotated.
261
+ try {
262
+ const back = await readBackup(target);
263
+ if (Object.keys(back.files).length !== bundle.counts.files) {
264
+ throw new Error(`holds ${Object.keys(back.files).length} files, expected ${bundle.counts.files}`);
265
+ }
266
+ }
267
+ catch (err) {
268
+ await fs_1.promises.unlink(target).catch(() => { });
269
+ throw new Error(`The new backup did not read back (${err.message}); it was removed and nothing was rotated.`);
270
+ }
271
+ // Rotation only ever removes files this module named, never the one just
272
+ // written, and only after that one has been read back.
273
+ const all = await listBackups(dir, when);
274
+ const guardar = retainedBackups(all, retention);
275
+ guardar.add(name);
164
276
  const rotated = [];
165
- for (const old of all.slice(keep)) {
277
+ for (const old of all) {
278
+ if (guardar.has(old.file))
279
+ continue;
166
280
  try {
167
281
  await fs_1.promises.unlink(old.path);
168
282
  rotated.push(old.file);
169
283
  }
170
284
  catch { /* keep going */ }
171
285
  }
172
- return { file: name, path: target, created: bundle.created, size_bytes: packed.length, counts: bundle.counts, rotated_out: rotated, dir };
286
+ return {
287
+ file: name, path: target, created: bundle.created, size_bytes: packed.length, counts: bundle.counts, rotated_out: rotated, dir,
288
+ verified: true, unreadable, retention, ...(sealed ? { encrypted: true } : {}),
289
+ };
173
290
  }
174
291
  async function readBackup(file) {
175
- const raw = await fs_1.promises.readFile(file);
292
+ const bytes = await fs_1.promises.readFile(file);
293
+ const raw = (await atRest()).unseal(bytes);
176
294
  const bundle = JSON.parse((await gunzip(raw)).toString('utf8'));
177
295
  if (!bundle || typeof bundle !== 'object' || !bundle.files || typeof bundle.files !== 'object') {
178
296
  throw new Error('Not a CRBRO backup: no files section.');
@@ -218,11 +336,13 @@ async function autoBackupIfDue(paths, opts = {}) {
218
336
  try {
219
337
  const dir = opts.dir || resolveBackupDir(paths.root, env);
220
338
  const now = opts.now || new Date();
221
- const newest = (await listBackups(dir))[0];
339
+ // A future-dated copy is listed by its file date, so it cannot hold
340
+ // automatic copies off until the clock catches up with its name.
341
+ const newest = (await listBackups(dir, now))[0];
222
342
  if (newest && now.getTime() - Date.parse(newest.created) < exports.AUTO_BACKUP_INTERVAL_MS) {
223
343
  return { made: false, reason: 'recent backup exists', file: newest.file };
224
344
  }
225
- const r = await createBackup(paths, { dir, now });
345
+ const r = await createBackup(paths, { dir, now, env });
226
346
  return { made: true, reason: newest ? 'older than 24h' : 'first backup', file: r.file };
227
347
  }
228
348
  catch (err) {
@@ -66,6 +66,11 @@ const MANIFEST_FILE = 'manifest.json';
66
66
  // crbro_inspect view=status.
67
67
  const CRBRO_VERSION = '1.0.0';
68
68
  // ─── Paths ───────────────────────────────────────────────────────
69
+ function fileId(id) {
70
+ if (typeof id !== 'string' || !id || /[\\/:\0]/.test(id))
71
+ throw new Error(`Invalid id: ${JSON.stringify(String(id).slice(0, 80))}`);
72
+ return id;
73
+ }
69
74
  class BrainPaths {
70
75
  root;
71
76
  cortex;
@@ -94,11 +99,13 @@ class BrainPaths {
94
99
  manifest() {
95
100
  return node_path_1.default.join(this.root, MANIFEST_FILE);
96
101
  }
102
+ // An id is a file name, never a path: "../../x" from a tool argument (and,
103
+ // with serve --http, from the network) must not read or write outside the brain.
97
104
  neuron(id) {
98
- return node_path_1.default.join(this.cortex, `${id}.json`);
105
+ return node_path_1.default.join(this.cortex, `${fileId(id)}.json`);
99
106
  }
100
107
  synapse(id) {
101
- return node_path_1.default.join(this.synapses, `${id}.json`);
108
+ return node_path_1.default.join(this.synapses, `${fileId(id)}.json`);
102
109
  }
103
110
  session(id) {
104
111
  return node_path_1.default.join(this.hippocampus, `${id}.json`);
@@ -324,19 +331,28 @@ class Brain {
324
331
  for (const file of files) {
325
332
  if (!file.startsWith('protocol_') || !file.endsWith('.json'))
326
333
  continue;
327
- const neuron = await (0, fs_js_1.readJSON)(node_path_1.default.join(cortexDir, file));
334
+ // One unreadable protocol file costs that protocol only (2.11): it
335
+ // used to end the loop, and every protocol after it left the boot.
336
+ let neuron = null;
337
+ try {
338
+ neuron = await (0, fs_js_1.readJSON)(node_path_1.default.join(cortexDir, file));
339
+ }
340
+ catch {
341
+ continue;
342
+ }
328
343
  if (!neuron)
329
344
  continue;
345
+ const tags = Array.isArray(neuron.tags) ? neuron.tags : [];
330
346
  // Extract priority from tags (e.g., "priority:10")
331
- const priorityTag = neuron.tags.find(t => t.startsWith('priority:'));
347
+ const priorityTag = tags.find(t => t.startsWith('priority:'));
332
348
  const priority = priorityTag ? parseInt(priorityTag.split(':')[1]) : 5;
333
349
  // Extract source from tags (e.g., "source:zero-deck")
334
- const sourceTag = neuron.tags.find(t => t.startsWith('source:'));
350
+ const sourceTag = tags.find(t => t.startsWith('source:'));
335
351
  const source = sourceTag ? sourceTag.split(':')[1] : 'manual';
336
352
  // Combine the ACTIVE facts into a single instruction block. Without
337
353
  // the filter, correcting a protocol via supersedes injected both the
338
354
  // old and the new wording into every session, side by side.
339
- const instructions = neuron.facts
355
+ const instructions = (neuron.facts || [])
340
356
  .filter(f => !f.status || f.status === 'active')
341
357
  .map(f => f.text)
342
358
  .join('\n\n');