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
@@ -5,17 +5,22 @@
5
5
  // always has. In daemon mode that process loads no index and no model: it
6
6
  // finds the daemon (or starts it), proves who it is, and from then on copies
7
7
  // lines — client to daemon, daemon to client. It reads each line only to keep
8
- // three things it needs on the worst day:
8
+ // what it needs on the worst day:
9
9
  //
10
- // · the client's `initialize`, so that if the daemon dies the handshake can
11
- // be replayed against its replacement without the client ever knowing;
10
+ // · `initialize`, `ping` and `tools/list` it answers itself, at once: their
11
+ // answers are fixed per build (surface.ts) and a client must not wait for
12
+ // a daemon to be found or started to learn them;
13
+ // · the client's `initialize`, which it replays — under an id of its own,
14
+ // answer swallowed — on every backend it attaches to, the first included;
12
15
  // · which requests are in flight, so each gets an error instead of a
13
16
  // silence that would hang the conversation until a timeout;
14
17
  // · nothing else.
15
18
  //
16
- // And if no daemon can be had at all, the backend is a server inside this
19
+ // And if no daemon can be had in time, the backend is a server inside this
17
20
  // process — CRBRO exactly as it was before 2.5. The daemon is an optimisation;
18
- // the memory is not allowed to depend on it.
21
+ // the memory is not allowed to depend on it. Nor is that fallback for life:
22
+ // the proxy keeps looking, quietly, and moves back to a daemon once one
23
+ // answers and nothing is running here.
19
24
  var __importDefault = (this && this.__importDefault) || function (mod) {
20
25
  return (mod && mod.__esModule) ? mod : { "default": mod };
21
26
  };
@@ -32,25 +37,40 @@ const lines_js_1 = require("./lines.js");
32
37
  const endpoint_js_1 = require("./endpoint.js");
33
38
  const CONNECT_TIMEOUT_MS = 1_500;
34
39
  const HANDSHAKE_TIMEOUT_MS = 4_000;
40
+ /** The whole budget for a daemon (finding it, starting it, its handshake) before serving in-process. Every attempt fits inside it. */
35
41
  const SPAWN_WAIT_MS = 12_000;
36
42
  const POLL_MS = 200;
37
- /** Daemon connections lost inside this window before the proxy stops trusting daemons for the rest of its life. */
43
+ /** Daemon connections lost inside this window before the proxy stops trying daemons for a while. */
38
44
  const FLAP_WINDOW_MS = 60_000;
39
45
  const FLAP_LIMIT = 3;
46
+ /** How long "a while" is, the first time; it doubles on each new burst, up to the cap. Never for ever. */
47
+ const FLAP_COOLDOWN_MS = 60_000;
48
+ const MAX_COOLDOWN_MS = 10 * 60_000;
49
+ /** Serving in-process: how often to look for a daemon again (doubling between the two). */
50
+ const PROBE_MIN_MS = 2_000;
51
+ const PROBE_MAX_MS = 60_000;
52
+ const PROBE_BUDGET_MS = 3_000;
53
+ /** A daemon that refused calls because it is stopping, and then did not go: how long before we stop waiting for it. */
54
+ const STOPPING_GRACE_MS = 15_000;
40
55
  /** After the client closes its end: how long calls already accepted may take to finish before we leave anyway. */
41
56
  const DRAIN_TIMEOUT_MS = 30_000;
57
+ const LOST_MESSAGE = 'CRBRO restarted while this call was running, before answering it. The call may or may not have taken effect: check (crbro_recall or crbro_inspect) before repeating a write.';
42
58
  /**
43
59
  * Connect to the daemon of this brain and build, and get through the
44
60
  * handshake. Null on any failure; 'config-mismatch' when the daemon is
45
61
  * genuine but was started with settings that are not this client's.
62
+ * `deadline` (epoch ms) bounds the whole attempt: no step waits past it.
46
63
  */
47
64
  async function connectDaemon(brainRoot, build = (0, endpoint_js_1.buildId)(), options = {}) {
65
+ const left = () => (options.deadline === undefined ? Infinity : options.deadline - Date.now());
66
+ if (left() <= 0)
67
+ return null;
48
68
  const state = await (0, endpoint_js_1.readState)(brainRoot, build);
49
69
  if (!state)
50
70
  return null;
51
71
  const socket = await new Promise(resolve => {
52
72
  const s = node_net_1.default.connect(state.endpoint);
53
- const t = setTimeout(() => { s.destroy(); resolve(null); }, CONNECT_TIMEOUT_MS);
73
+ const t = setTimeout(() => { s.destroy(); resolve(null); }, Math.max(1, Math.min(CONNECT_TIMEOUT_MS, left())));
54
74
  s.once('connect', () => { clearTimeout(t); resolve(s); });
55
75
  s.once('error', () => { clearTimeout(t); resolve(null); });
56
76
  });
@@ -62,8 +82,11 @@ async function connectDaemon(brainRoot, build = (0, endpoint_js_1.buildId)(), op
62
82
  socket.on('data', chunk => lines.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk));
63
83
  socket.on('error', () => undefined);
64
84
  const next = () => new Promise(resolve => {
65
- const t = setTimeout(() => resolve(null), HANDSHAKE_TIMEOUT_MS);
66
- const done = (v) => { clearTimeout(t); socket.off('close', onClose); resolve(v); };
85
+ let settled = false;
86
+ const done = (v) => { if (settled)
87
+ return; settled = true; clearTimeout(t); socket.off('close', onClose); resolve(v); };
88
+ // An answer that arrived while this loop was busy is read before the timeout is believed.
89
+ const t = setTimeout(() => setImmediate(() => done(null)), Math.max(1, Math.min(HANDSHAKE_TIMEOUT_MS, left())));
67
90
  const onClose = () => done(null);
68
91
  socket.once('close', onClose);
69
92
  sink = line => { sink = () => undefined; try {
@@ -123,37 +146,7 @@ function spawnDetachedDaemon(brainRoot) {
123
146
  child.on('error', () => undefined);
124
147
  child.unref();
125
148
  }
126
- /** 'unusable': a daemon exists but is not for us, or cannot be had — do not wait for one, and do not ask again. */
127
- async function daemonBackend(o, ev, resumed) {
128
- const build = o.build ?? (0, endpoint_js_1.buildId)();
129
- let c = await connectDaemon(o.brainRoot, build, { resumed });
130
- if (!c && o.spawnDaemon) {
131
- const since = Date.now() - 1_000;
132
- try {
133
- await o.spawnDaemon();
134
- }
135
- catch (err) {
136
- o.log?.(`could not start a daemon: ${err.message}`);
137
- }
138
- const until = Date.now() + (o.spawnWaitMs ?? SPAWN_WAIT_MS);
139
- while (!c && Date.now() < until) {
140
- await new Promise(r => setTimeout(r, POLL_MS));
141
- c = await connectDaemon(o.brainRoot, build, { resumed });
142
- if (!c) {
143
- const why = await (0, endpoint_js_1.blockedSince)(o.brainRoot, build, since);
144
- if (why) {
145
- o.log?.(`the daemon could not take its endpoint (${why}): serving from this process`);
146
- return 'unusable';
147
- }
148
- }
149
- }
150
- }
151
- if (c === 'config-mismatch') {
152
- o.log?.('a daemon is running with other settings than this client\'s (CRBRO_SEMANTIC, CRBRO_BACKUP_DIR…): serving from this process so they keep their meaning');
153
- return 'unusable';
154
- }
155
- if (!c)
156
- return null;
149
+ function linkBackend(c, ev) {
157
150
  const { socket, reader } = c;
158
151
  let closing = false;
159
152
  reader(ev.onLine);
@@ -175,13 +168,47 @@ async function daemonBackend(o, ev, resumed) {
175
168
  }),
176
169
  };
177
170
  }
171
+ /** 'unusable': a daemon exists but is not for us, or cannot be had — do not wait for one. */
172
+ async function daemonBackend(a, ev) {
173
+ let c = await connectDaemon(a.brainRoot, a.build, { resumed: a.resumed, deadline: a.deadline });
174
+ if (!c && a.spawn) {
175
+ const since = Date.now() - 1_000;
176
+ try {
177
+ await a.spawn();
178
+ }
179
+ catch (err) {
180
+ a.log(`could not start a daemon: ${err.message}`);
181
+ }
182
+ a.trace('daemon launched', '', true);
183
+ while (!c && Date.now() < a.deadline) {
184
+ await new Promise(r => setTimeout(r, Math.max(1, Math.min(POLL_MS, a.deadline - Date.now()))));
185
+ c = await connectDaemon(a.brainRoot, a.build, { resumed: a.resumed, deadline: a.deadline });
186
+ if (!c) {
187
+ const why = await (0, endpoint_js_1.blockedSince)(a.brainRoot, a.build, since);
188
+ if (why) {
189
+ a.log(`the daemon could not start (${why}): serving from this process`);
190
+ return 'unusable';
191
+ }
192
+ }
193
+ }
194
+ }
195
+ if (c === 'config-mismatch') {
196
+ if (!a.quiet)
197
+ a.log('a daemon is running with other settings than this client\'s (CRBRO_SEMANTIC, CRBRO_BACKUP_DIR…): serving from this process so they keep their meaning');
198
+ return 'unusable';
199
+ }
200
+ if (!c)
201
+ return null;
202
+ return linkBackend(c, ev);
203
+ }
178
204
  /** CRBRO as it was before daemons: the whole server, in this process. */
179
205
  async function localBackend(ev, resumed) {
180
206
  const { createEngines, createServer } = await import('../server.js');
181
207
  const { sessionScope, newTally } = await import('../engine/cortex.js');
182
208
  const engines = createEngines();
183
209
  const transport = new lines_js_1.LoopbackTransport(ev.onLine);
184
- await createServer(engines).connect(transport);
210
+ const server = createServer(engines);
211
+ await server.connect(transport);
185
212
  // After a lost daemon this server starts with an empty tally that the
186
213
  // conversation's earlier writes are not in: mark it, so consolidate says so.
187
214
  const scope = resumed ? { tally: newTally(), resumed: true } : null;
@@ -194,47 +221,102 @@ async function localBackend(ev, resumed) {
194
221
  await engines.searchEngine.flush();
195
222
  }
196
223
  catch { /* derived data */ }
224
+ try {
225
+ await server.close();
226
+ }
227
+ catch { /* leaving anyway */ }
197
228
  },
198
229
  };
199
230
  }
231
+ const isRequest = (m) => !!m && typeof m.method === 'string' && m.id !== undefined && m.id !== null;
232
+ const requestIdOf = (line) => {
233
+ try {
234
+ const q = JSON.parse(line);
235
+ return isRequest(q) ? q.id : null;
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ };
200
241
  function runProxy(o) {
201
242
  const log = o.log ?? (() => undefined);
243
+ const trace = o.trace ?? (() => undefined);
202
244
  const isEnabled = o.isEnabled ?? (() => true);
203
245
  const makeLocal = o.local ?? localBackend;
246
+ // Once, here: a proxy that recomputed it at every reconnection could pick up
247
+ // a newer release unpacked in place and launch a daemon of code it never ran.
248
+ const build = o.build ?? (0, endpoint_js_1.buildId)();
249
+ const budgetMs = o.spawnWaitMs ?? SPAWN_WAIT_MS;
250
+ const probeMin = o.probeMs?.min ?? PROBE_MIN_MS;
251
+ const probeMax = o.probeMs?.max ?? PROBE_MAX_MS;
252
+ // ── The backend, and where it is in its own handshake ──
204
253
  let backend = null;
205
- let connecting = true;
254
+ let gen = 0; // every backend attempt gets a number; only the active one is listened to
255
+ let activeGen = -1;
256
+ let pendingGen = -1; // a daemon link held while moving over to it
257
+ let pending = null;
258
+ let phase = 'none';
259
+ let backendInitDone = false;
260
+ let acquiring = true;
261
+ let switching = false;
206
262
  let inputEnded = false; // the client closed its end: no more requests, but what it asked for is still owed
207
263
  let ended = false; // we are done: nothing more is written, nothing more is started
208
264
  let reconnects = 0;
209
- let trustDaemons = true;
265
+ let switches = 0;
266
+ let distrustUntil = 0;
267
+ let cooldownMs = o.flapCooldownMs ?? FLAP_COOLDOWN_MS;
210
268
  const losses = [];
211
- let initialize = null;
212
- let initialized = null;
213
- const inflight = new Set();
214
- const waiting = []; // client lines held while there is no usable backend
215
- let replayId = null; // id of the initialize we are replaying, while its answer is pending
269
+ // ── What the client said that we must remember ──
270
+ let surface = o.surface;
271
+ const early = []; // lines that arrived before we knew whether we have a surface
272
+ let clientInit = null;
273
+ let initAnswered = false; // the client has its answer to initialize (ours, or a backend's)
274
+ let clientInitialized = null;
275
+ const inflight = new Map(); // sent to the backend, unanswered: id → the line, should it need sending again
276
+ const waiting = []; // client lines held while there is no ready backend
277
+ const toolCalls = new Set();
278
+ let replayId = null;
216
279
  let replays = 0;
280
+ // The client's last crbro_boot that succeeded: the project it bound lives on
281
+ // the backend that served it, so a new backend gets it again (2.11).
282
+ let lastBoot = null;
283
+ const bootCalls = new Map(); // crbro_boot calls sent, unanswered → their params
284
+ let rebootId = null;
285
+ let requeueAt = 0; // where the next call a stopping daemon never started goes back in line
217
286
  let drainTimer = null;
287
+ let probeTimer = null;
288
+ let probeDelay = probeMin;
289
+ let stoppingTimer = null;
290
+ let idleWaiters = [];
218
291
  let resolveClosed;
219
292
  let rejectClosed;
220
293
  const closed = new Promise((res, rej) => { resolveClosed = res; rejectClosed = rej; });
221
294
  const toClient = (line) => { if (!ended)
222
295
  o.output.write(`${line}\n`); };
296
+ const ready = () => backend !== null && phase === 'ready' && !switching;
297
+ const owed = () => inflight.size > 0 || waiting.some(l => requestIdOf(l) !== null) || early.some(l => requestIdOf(l) !== null);
298
+ const daemonsWanted = () => isEnabled() && Date.now() >= distrustUntil;
223
299
  /** Leave — but only once every call the client made has been answered and written out. */
224
300
  const maybeFinish = () => {
225
- if (ended || !inputEnded || connecting || waiting.length > 0 || inflight.size > 0)
301
+ if (ended || !inputEnded || owed())
226
302
  return;
227
303
  void shutdown();
228
304
  };
229
305
  const shutdown = async () => {
230
306
  if (ended)
231
307
  return;
232
- if (drainTimer)
233
- clearTimeout(drainTimer);
308
+ ended = true;
309
+ for (const t of [drainTimer, probeTimer, stoppingTimer])
310
+ if (t)
311
+ clearTimeout(t);
234
312
  try {
235
313
  await backend?.close();
236
314
  }
237
315
  catch { /* leaving anyway */ }
316
+ try {
317
+ await pending?.close();
318
+ }
319
+ catch { /* leaving anyway */ }
238
320
  // stdout on a Windows pipe is asynchronous: the caller exits the process
239
321
  // next, and the last answer must be out of our hands before it does.
240
322
  await new Promise(resolve => { try {
@@ -243,9 +325,54 @@ function runProxy(o) {
243
325
  catch {
244
326
  resolve();
245
327
  } });
246
- ended = true;
247
328
  resolveClosed();
248
329
  };
330
+ const settleIdle = () => {
331
+ if (inflight.size > 0 || phase === 'handshaking')
332
+ return;
333
+ const w = idleWaiters;
334
+ idleWaiters = [];
335
+ for (const f of w)
336
+ f();
337
+ };
338
+ const whenIdle = () => new Promise(resolve => {
339
+ if (inflight.size === 0 && phase !== 'handshaking')
340
+ resolve();
341
+ else
342
+ idleWaiters.push(resolve);
343
+ });
344
+ const forward = (line) => {
345
+ const id = requestIdOf(line);
346
+ if (id !== null)
347
+ inflight.set(id, line);
348
+ backend.send(line);
349
+ };
350
+ const drain = () => {
351
+ while (ready() && waiting.length)
352
+ forward(waiting.shift());
353
+ maybeFinish();
354
+ };
355
+ /** Bring the backend up to where the client is: the client's initialize (ours to answer, its answer swallowed), then initialized. */
356
+ const handshake = () => {
357
+ if (!backend || backendInitDone || phase === 'handshaking')
358
+ return;
359
+ if (!clientInit || !initAnswered) {
360
+ // Nothing to replay yet; or the client's own initialize is still queued and is about to reach this backend itself.
361
+ phase = 'ready';
362
+ drain();
363
+ return;
364
+ }
365
+ phase = 'handshaking';
366
+ replayId = `crbro-replay-${++replays}`;
367
+ backend.send(JSON.stringify({ ...clientInit, id: replayId }));
368
+ };
369
+ /** The backend is where the client is: let the queue through. */
370
+ const backendReady = () => {
371
+ phase = 'ready';
372
+ trace('backend ready', backend?.kind ?? '', true);
373
+ settleIdle();
374
+ drain();
375
+ };
249
376
  const fromBackend = (line) => {
250
377
  let m = null;
251
378
  try {
@@ -253,135 +380,244 @@ function runProxy(o) {
253
380
  }
254
381
  catch { /* not ours to judge: pass it on */ }
255
382
  if (m && replayId !== null && m.id === replayId && m.method === undefined) {
256
- // The replacement answered the replayed handshake. The client already
257
- // has the original answer; this one stops here.
383
+ // The backend answered the handshake we made for the client. The client
384
+ // already has its answer; this one stops here.
258
385
  replayId = null;
259
- if (initialized && backend)
260
- backend.send(initialized);
261
- connecting = false;
262
- drain();
386
+ backendInitDone = true;
387
+ if (m.error)
388
+ log(`the backend refused the replayed initialize: ${m.error?.message ?? 'no reason'}`);
389
+ if (clientInitialized && backend)
390
+ backend.send(clientInitialized);
391
+ if (lastBoot && backend) {
392
+ // The project crbro_boot bound for this conversation lived on the
393
+ // backend that is gone: boot again with the same arguments, answer
394
+ // swallowed, before anything queued reaches the new one.
395
+ rebootId = `crbro-reboot-${replays}`;
396
+ backend.send(JSON.stringify({ jsonrpc: '2.0', id: rebootId, method: 'tools/call', params: lastBoot }));
397
+ return;
398
+ }
399
+ backendReady();
400
+ return;
401
+ }
402
+ if (m && rebootId !== null && m.id === rebootId && m.method === undefined) {
403
+ rebootId = null;
404
+ if (m.error || m.result?.isError)
405
+ log('the replayed crbro_boot failed: this conversation may have lost its project binding');
406
+ backendReady();
263
407
  return;
264
408
  }
265
- if (m && m.id !== undefined && m.id !== null && m.method === undefined)
409
+ if (m && m.method === undefined && m.id !== undefined && m.id !== null) {
410
+ if (m.error?.data?.crbro === lines_js_1.NOT_STARTED && inflight.has(m.id)) {
411
+ // The daemon is stopping and never ran this call: it goes to whoever serves next, first in line.
412
+ waiting.splice(requeueAt++, 0, inflight.get(m.id));
413
+ inflight.delete(m.id);
414
+ stopping();
415
+ settleIdle();
416
+ return;
417
+ }
266
418
  inflight.delete(m.id);
419
+ if (bootCalls.has(m.id)) {
420
+ const p = bootCalls.get(m.id);
421
+ bootCalls.delete(m.id);
422
+ if (!m.error && !m.result?.isError)
423
+ lastBoot = p;
424
+ }
425
+ if (!initAnswered && clientInit && m.id === clientInit.id) {
426
+ initAnswered = true;
427
+ backendInitDone = true;
428
+ trace('initialize answered', `by the ${backend?.kind ?? 'backend'}`, true);
429
+ }
430
+ if (toolCalls.delete(m.id))
431
+ trace('first tool result', backend?.kind ?? '', true);
432
+ settleIdle();
433
+ }
267
434
  toClient(line);
268
435
  maybeFinish();
269
436
  };
270
- const drain = () => {
271
- while (backend && !connecting && waiting.length)
272
- backend.send(waiting.shift());
273
- maybeFinish();
437
+ /** The daemon said it is stopping: hold new lines until it is gone, and do not wait for it for ever. */
438
+ const stopping = () => {
439
+ if (phase === 'stopping')
440
+ return;
441
+ phase = 'stopping';
442
+ const g = activeGen;
443
+ stoppingTimer = setTimeout(() => {
444
+ stoppingTimer = null;
445
+ if (activeGen !== g || ended)
446
+ return;
447
+ log('the daemon said it was stopping and did not go: leaving it');
448
+ const b = backend;
449
+ void lost();
450
+ void b?.close().catch(() => undefined);
451
+ }, STOPPING_GRACE_MS);
452
+ stoppingTimer.unref();
274
453
  };
275
- const fromClient = (line) => {
454
+ const handleClient = (line) => {
276
455
  let m = null;
277
456
  try {
278
457
  m = JSON.parse(line);
279
458
  }
280
459
  catch { /* the backend will answer the parse error */ }
281
- if (m?.method === 'initialize')
282
- initialize = m;
283
- else if (m?.method === 'notifications/initialized')
284
- initialized = line;
285
- if (m && m.method !== undefined && m.id !== undefined && m.id !== null)
286
- inflight.add(m.id);
287
- if (backend && !connecting)
288
- backend.send(line);
460
+ if (m && surface) {
461
+ const { answerFromSurface } = surfaceApi;
462
+ const a = answerFromSurface(surface, m);
463
+ if (a) {
464
+ toClient(JSON.stringify(a));
465
+ if (m.method === 'initialize') {
466
+ clientInit = m;
467
+ initAnswered = true;
468
+ trace('initialize answered', 'by the proxy', true);
469
+ if (backend && phase === 'ready' && !backendInitDone)
470
+ handshake();
471
+ }
472
+ maybeFinish();
473
+ return;
474
+ }
475
+ }
476
+ if (isRequest(m) && m.method === 'initialize') {
477
+ clientInit = m;
478
+ initAnswered = false;
479
+ }
480
+ if (m && m.method === 'notifications/initialized' && m.id === undefined) {
481
+ clientInitialized = line;
482
+ // Ours to send, right after the replayed initialize: never ahead of it.
483
+ if (initAnswered && !backendInitDone)
484
+ return;
485
+ }
486
+ if (isRequest(m) && m.method === 'tools/call') {
487
+ toolCalls.add(m.id);
488
+ if (m.params?.name === 'crbro_boot')
489
+ bootCalls.set(m.id, m.params);
490
+ }
491
+ if (ready())
492
+ forward(line);
289
493
  else
290
494
  waiting.push(line);
291
495
  };
292
- const events = {
293
- onLine: fromBackend,
294
- onClose: () => { void lost(); },
496
+ let surfaceApi = null;
497
+ const fromClient = (line) => {
498
+ if (surface === undefined || (surface && !surfaceApi)) {
499
+ early.push(line);
500
+ return;
501
+ }
502
+ handleClient(line);
295
503
  };
504
+ const eventsFor = (g) => ({
505
+ onLine: line => { if (g === activeGen)
506
+ fromBackend(line); },
507
+ onClose: () => {
508
+ if (g === activeGen)
509
+ void lost();
510
+ else if (g === pendingGen) {
511
+ pendingGen = -1;
512
+ pending = null;
513
+ }
514
+ },
515
+ });
296
516
  /** No backend of any kind could be had. Say so on every call that is owed an answer, and stop — as the classic server does when it cannot start. */
297
517
  const fatal = (err) => {
298
518
  if (ended)
299
519
  return;
300
520
  log(`no backend could be started: ${err.message}`);
301
- const ids = new Set(inflight);
302
- for (const l of waiting) {
303
- try {
304
- const q = JSON.parse(l);
305
- if (q?.method !== undefined && q.id !== undefined && q.id !== null)
306
- ids.add(q.id);
307
- }
308
- catch { /* not a request */ }
521
+ const ids = new Set(inflight.keys());
522
+ for (const l of [...waiting, ...early]) {
523
+ const id = requestIdOf(l);
524
+ if (id !== null)
525
+ ids.add(id);
309
526
  }
310
527
  for (const id of ids)
311
528
  toClient(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: `CRBRO could not start: ${err.message}` } }));
312
529
  inflight.clear();
313
530
  waiting.length = 0;
531
+ early.length = 0;
314
532
  ended = true;
533
+ for (const t of [drainTimer, probeTimer, stoppingTimer])
534
+ if (t)
535
+ clearTimeout(t);
315
536
  rejectClosed(err);
316
537
  };
538
+ const activate = (b, g) => {
539
+ backend = b;
540
+ activeGen = g;
541
+ phase = 'none';
542
+ requeueAt = 0;
543
+ backendInitDone = false;
544
+ replayId = null;
545
+ rebootId = null;
546
+ trace(b.kind === 'daemon' ? 'backend connected' : 'in-process fallback', b.kind === 'local' ? 'engine ready' : '', true);
547
+ if (b.kind === 'local')
548
+ scheduleProbe();
549
+ else
550
+ probeDelay = probeMin;
551
+ handshake();
552
+ };
317
553
  async function acquire(first) {
318
- connecting = true;
554
+ acquiring = true;
319
555
  backend = null;
556
+ activeGen = -1;
557
+ phase = 'none';
320
558
  let b = null;
559
+ const g = ++gen;
321
560
  // Asked every time, not once at launch: `crbro daemon off` removes the
322
561
  // flag and stops the daemon, and a proxy that only knew how to respawn one
323
562
  // undid it within 200 ms.
324
- if (trustDaemons && isEnabled()) {
325
- const d = await daemonBackend(o, events, !first);
563
+ if (daemonsWanted()) {
564
+ const d = await daemonBackend({
565
+ brainRoot: o.brainRoot, build, resumed: !first, deadline: Date.now() + budgetMs,
566
+ spawn: o.spawnDaemon, log, trace,
567
+ }, eventsFor(g));
326
568
  if (d === 'unusable')
327
- trustDaemons = false;
328
- else
569
+ distrust(FLAP_COOLDOWN_MS);
570
+ else if (d)
329
571
  b = d;
330
- }
331
- if (!b) {
332
- if (trustDaemons && isEnabled())
572
+ else
333
573
  log('no daemon reachable: serving from this process');
334
- b = await makeLocal(events, !first);
335
574
  }
575
+ if (!b)
576
+ b = await makeLocal(eventsFor(g), !first);
577
+ acquiring = false;
336
578
  if (ended) {
337
579
  await b.close();
338
580
  return;
339
581
  }
340
- backend = b;
341
- if (!first && initialize) {
342
- // Same request the client once sent, under an id of our own.
343
- replayId = `crbro-replay-${++replays}`;
344
- b.send(JSON.stringify({ ...initialize, id: replayId }));
345
- return; // `connecting` stays true until the replayed handshake is answered
346
- }
347
- connecting = false;
348
- drain();
582
+ activate(b, g);
349
583
  }
584
+ const distrust = (ms) => { distrustUntil = Math.max(distrustUntil, Date.now() + ms); };
350
585
  async function lost() {
351
586
  if (ended)
352
587
  return;
588
+ if (stoppingTimer) {
589
+ clearTimeout(stoppingTimer);
590
+ stoppingTimer = null;
591
+ }
353
592
  reconnects++;
354
593
  const now = Date.now();
355
594
  losses.push(now);
356
595
  while (losses.length && now - losses[0] > FLAP_WINDOW_MS)
357
596
  losses.shift();
358
597
  if (losses.length >= FLAP_LIMIT) {
359
- trustDaemons = false;
360
- log('daemon lost three times in a minute: staying in-process');
361
- }
362
- // Whatever was waiting for an answer will not get one from a dead process.
363
- // What never left this proxy is not in that position: it is still queued,
364
- // and answering it here too would hand the client two replies to one id.
365
- const queued = new Set();
366
- for (const line of waiting) {
367
- try {
368
- const q = JSON.parse(line);
369
- if (q?.id !== undefined && q.id !== null)
370
- queued.add(q.id);
371
- }
372
- catch { /* not a request */ }
598
+ // A daemon that keeps dying is not worth the client's time for a while — a while, not for ever.
599
+ distrust(cooldownMs);
600
+ log(`daemon lost ${losses.length} times in a minute: serving in-process for ${Math.round(cooldownMs / 1000)} s, then looking again`);
601
+ cooldownMs = Math.min(cooldownMs * 2, MAX_COOLDOWN_MS);
602
+ losses.length = 0;
373
603
  }
374
- for (const id of [...inflight]) {
375
- if (queued.has(id))
376
- continue;
377
- inflight.delete(id);
378
- toClient(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: 'CRBRO restarted while this call was running. Nothing was lost on disk; call it again.' } }));
604
+ // Whatever was waiting for an answer will not get one from a dead process,
605
+ // and nobody can say whether it ran: say exactly that. What never left this
606
+ // proxy (still queued) is not in that position and goes to the next backend.
607
+ for (const id of [...inflight.keys()]) {
608
+ toolCalls.delete(id);
609
+ toClient(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: LOST_MESSAGE } }));
379
610
  }
611
+ inflight.clear();
380
612
  replayId = null;
613
+ rebootId = null;
381
614
  backend = null;
615
+ activeGen = -1;
616
+ phase = 'none';
617
+ backendInitDone = false;
618
+ settleIdle();
382
619
  // The client has gone and is owed nothing more: a replacement would serve nobody.
383
- if (inputEnded && waiting.length === 0 && inflight.size === 0) {
384
- connecting = false;
620
+ if (inputEnded && !owed()) {
385
621
  void shutdown();
386
622
  return;
387
623
  }
@@ -392,7 +628,98 @@ function runProxy(o) {
392
628
  fatal(err);
393
629
  }
394
630
  }
395
- const reader = new lines_js_1.LineReader(fromClient, () => undefined);
631
+ // ── Serving in-process: keep looking for a daemon, and move back to one ──
632
+ function scheduleProbe() {
633
+ if (probeTimer || ended)
634
+ return;
635
+ probeTimer = setTimeout(() => { probeTimer = null; void probe(); }, probeDelay);
636
+ probeTimer.unref();
637
+ probeDelay = Math.min(probeDelay * 2, probeMax);
638
+ }
639
+ async function probe() {
640
+ if (ended || !backend || backend.kind !== 'local' || switching)
641
+ return;
642
+ if (!daemonsWanted()) {
643
+ scheduleProbe();
644
+ return;
645
+ }
646
+ const g = ++gen;
647
+ pendingGen = g;
648
+ const d = await daemonBackend({
649
+ brainRoot: o.brainRoot, build, resumed: true, deadline: Date.now() + PROBE_BUDGET_MS,
650
+ spawn: null, log, trace, quiet: true,
651
+ }, eventsFor(g)).catch(() => null);
652
+ if (!d || d === 'unusable' || pendingGen !== g || ended || !backend || backend.kind !== 'local') {
653
+ if (pendingGen === g)
654
+ pendingGen = -1;
655
+ if (d && d !== 'unusable')
656
+ await d.close();
657
+ if (d === 'unusable')
658
+ distrust(FLAP_COOLDOWN_MS);
659
+ scheduleProbe();
660
+ return;
661
+ }
662
+ pending = d;
663
+ await switchTo(d, g);
664
+ }
665
+ /**
666
+ * Move the conversation from this process's server to the daemon. Only when
667
+ * nothing is running here (new lines wait meanwhile), and the in-process
668
+ * index is flushed BEFORE the daemon serves: the daemon catches up with
669
+ * what this process wrote (it refreshes from disk for a resumed
670
+ * connection), and its own later save is a superset, never overwritten by
671
+ * ours. The daemon's tally of this conversation starts empty, as after a
672
+ * lost daemon: consolidate says so (`resumed`).
673
+ */
674
+ async function switchTo(d, g) {
675
+ switching = true;
676
+ await whenIdle();
677
+ const old = backend;
678
+ const stillThere = () => pendingGen === g && !ended;
679
+ if (!stillThere() || !old) {
680
+ switching = false;
681
+ if (pendingGen === g) {
682
+ pendingGen = -1;
683
+ pending = null;
684
+ }
685
+ await d.close().catch(() => undefined);
686
+ if (!ended) {
687
+ drain();
688
+ scheduleProbe();
689
+ }
690
+ return;
691
+ }
692
+ activeGen = -1; // nothing is in flight: nothing more is owed by the old server
693
+ await old.close().catch(() => undefined);
694
+ const linkAlive = pendingGen === g;
695
+ pendingGen = -1;
696
+ pending = null;
697
+ switching = false;
698
+ backend = null;
699
+ if (ended) {
700
+ await d.close().catch(() => undefined);
701
+ return;
702
+ }
703
+ if (!linkAlive) {
704
+ // The daemon went away while we flushed: back to the start, as after any lost backend.
705
+ try {
706
+ await acquire(false);
707
+ }
708
+ catch (err) {
709
+ fatal(err);
710
+ }
711
+ return;
712
+ }
713
+ switches++;
714
+ log('a daemon answers again: this conversation moved back to it from the in-process server');
715
+ trace('moved back to the daemon');
716
+ activate(d, g);
717
+ }
718
+ const reader = new lines_js_1.LineReader(fromClient, head => {
719
+ const id = (0, lines_js_1.idFromHead)(head);
720
+ log(`refused a message larger than ${lines_js_1.MAX_LINE_BYTES / 1048576} MB${id !== null ? ` (id ${JSON.stringify(id)})` : ''}`);
721
+ toClient(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32600, message: `Invalid Request: a message larger than ${lines_js_1.MAX_LINE_BYTES / 1048576} MB was not read.` } }));
722
+ });
396
723
  o.input.on('data', (chunk) => reader.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk));
397
724
  const onInputEnd = () => {
398
725
  if (inputEnded)
@@ -406,7 +733,35 @@ function runProxy(o) {
406
733
  };
407
734
  o.input.on('end', onInputEnd);
408
735
  o.input.on('close', onInputEnd);
409
- void acquire(true).catch(err => fatal(err));
410
- return { closed, backendKind: () => (backend ? backend.kind : 'none'), reconnects: () => reconnects };
736
+ // ── The surface: given, or found now; lines that came meanwhile are handled in order ──
737
+ const surfaceReady = (async () => {
738
+ try {
739
+ surfaceApi = await import('./surface.js');
740
+ if (surface === undefined)
741
+ surface = await surfaceApi.getSurface(build);
742
+ }
743
+ catch {
744
+ if (surface === undefined)
745
+ surface = null;
746
+ }
747
+ if (!surfaceApi)
748
+ surface = null;
749
+ for (const line of early.splice(0))
750
+ handleClient(line);
751
+ maybeFinish();
752
+ })();
753
+ void acquire(true).catch(async (err) => {
754
+ // initialize is the proxy's to answer even when nothing can serve: a
755
+ // backend that fails faster than the surface is computed in-process (a
756
+ // build without its snapshot) must not turn that answer into an error.
757
+ await Promise.race([surfaceReady, new Promise(r => { const t = setTimeout(r, 5_000); t.unref(); })]);
758
+ fatal(err);
759
+ });
760
+ return {
761
+ closed,
762
+ backendKind: () => (backend ? backend.kind : 'none'),
763
+ reconnects: () => reconnects,
764
+ switches: () => switches,
765
+ };
411
766
  }
412
767
  //# sourceMappingURL=proxy.js.map