crbro-memory 2.9.1 → 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 (197) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -456
  3. package/SECURITY.md +135 -8
  4. package/bin/crbro.mjs +555 -299
  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 +143 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +762 -311
  25. package/dist/engine/doctor.js +676 -0
  26. package/dist/engine/evidence.js +171 -0
  27. package/dist/engine/heat.js +81 -37
  28. package/dist/engine/hippocampus.js +50 -36
  29. package/dist/engine/hookrefresh.js +296 -0
  30. package/dist/engine/hookverify.js +15 -3
  31. package/dist/engine/installer.js +1022 -0
  32. package/dist/engine/installio.js +279 -0
  33. package/dist/engine/keychain.js +735 -144
  34. package/dist/engine/maintenance.js +355 -87
  35. package/dist/engine/modinstall.js +98 -45
  36. package/dist/engine/prefrontal.js +126 -69
  37. package/dist/engine/projects.js +80 -0
  38. package/dist/engine/quarantine.js +204 -0
  39. package/dist/engine/secrets.js +41 -5
  40. package/dist/engine/shelf.js +34 -11
  41. package/dist/engine/synapses.js +183 -80
  42. package/dist/engine/triggers.js +141 -11
  43. package/dist/index.js +37 -3
  44. package/dist/mcp-surface.json +1 -0
  45. package/dist/miner/extractor.js +64 -17
  46. package/dist/miner/scheduler.js +144 -68
  47. package/dist/search/index.js +810 -176
  48. package/dist/search/quality.js +31 -0
  49. package/dist/search/semantic.js +208 -18
  50. package/dist/search/tokenize.js +44 -0
  51. package/dist/server.js +649 -343
  52. package/dist/sync/git.js +203 -72
  53. package/dist/sync/materialize.js +33 -8
  54. package/dist/sync/space.js +286 -101
  55. package/dist/sync/validate.js +204 -0
  56. package/dist/utils/budget.js +15 -2
  57. package/dist/utils/fs.js +40 -6
  58. package/dist/utils/ids.js +128 -14
  59. package/dist/utils/lock.js +219 -52
  60. package/hooks/crbro-codex.mjs +284 -0
  61. package/hooks/crbro-guard.mjs +281 -173
  62. package/hooks/crbro-hooks.shipped.json +20 -0
  63. package/hooks/crbro-lifecycle.mjs +16 -4
  64. package/hooks/crbro-subagent.mjs +180 -180
  65. package/package.json +12 -8
  66. package/dist/daemon/daemon.d.ts +0 -25
  67. package/dist/daemon/daemon.d.ts.map +0 -1
  68. package/dist/daemon/daemon.js.map +0 -1
  69. package/dist/daemon/endpoint.d.ts +0 -66
  70. package/dist/daemon/endpoint.d.ts.map +0 -1
  71. package/dist/daemon/endpoint.js.map +0 -1
  72. package/dist/daemon/lines.d.ts +0 -55
  73. package/dist/daemon/lines.d.ts.map +0 -1
  74. package/dist/daemon/lines.js.map +0 -1
  75. package/dist/daemon/proxy.d.ts +0 -55
  76. package/dist/daemon/proxy.d.ts.map +0 -1
  77. package/dist/daemon/proxy.js.map +0 -1
  78. package/dist/engine/backup.d.ts +0 -68
  79. package/dist/engine/backup.d.ts.map +0 -1
  80. package/dist/engine/backup.js.map +0 -1
  81. package/dist/engine/brain.d.ts +0 -79
  82. package/dist/engine/brain.d.ts.map +0 -1
  83. package/dist/engine/brain.js.map +0 -1
  84. package/dist/engine/cortex.d.ts +0 -670
  85. package/dist/engine/cortex.d.ts.map +0 -1
  86. package/dist/engine/cortex.js.map +0 -1
  87. package/dist/engine/dates.d.ts +0 -28
  88. package/dist/engine/dates.d.ts.map +0 -1
  89. package/dist/engine/dates.js.map +0 -1
  90. package/dist/engine/heat.d.ts +0 -28
  91. package/dist/engine/heat.d.ts.map +0 -1
  92. package/dist/engine/heat.js.map +0 -1
  93. package/dist/engine/hippocampus.d.ts +0 -60
  94. package/dist/engine/hippocampus.d.ts.map +0 -1
  95. package/dist/engine/hippocampus.js.map +0 -1
  96. package/dist/engine/hookverify.d.ts +0 -42
  97. package/dist/engine/hookverify.d.ts.map +0 -1
  98. package/dist/engine/hookverify.js.map +0 -1
  99. package/dist/engine/keychain.d.ts +0 -32
  100. package/dist/engine/keychain.d.ts.map +0 -1
  101. package/dist/engine/keychain.js.map +0 -1
  102. package/dist/engine/maintenance.d.ts +0 -218
  103. package/dist/engine/maintenance.d.ts.map +0 -1
  104. package/dist/engine/maintenance.js.map +0 -1
  105. package/dist/engine/modinstall.d.ts +0 -215
  106. package/dist/engine/modinstall.d.ts.map +0 -1
  107. package/dist/engine/modinstall.js.map +0 -1
  108. package/dist/engine/postmortem.d.ts +0 -81
  109. package/dist/engine/postmortem.d.ts.map +0 -1
  110. package/dist/engine/postmortem.js.map +0 -1
  111. package/dist/engine/prefrontal.d.ts +0 -61
  112. package/dist/engine/prefrontal.d.ts.map +0 -1
  113. package/dist/engine/prefrontal.js.map +0 -1
  114. package/dist/engine/secrets.d.ts +0 -30
  115. package/dist/engine/secrets.d.ts.map +0 -1
  116. package/dist/engine/secrets.js.map +0 -1
  117. package/dist/engine/shelf.d.ts +0 -156
  118. package/dist/engine/shelf.d.ts.map +0 -1
  119. package/dist/engine/shelf.js.map +0 -1
  120. package/dist/engine/source.d.ts +0 -6
  121. package/dist/engine/source.d.ts.map +0 -1
  122. package/dist/engine/source.js.map +0 -1
  123. package/dist/engine/synapses.d.ts +0 -82
  124. package/dist/engine/synapses.d.ts.map +0 -1
  125. package/dist/engine/synapses.js.map +0 -1
  126. package/dist/engine/triggers.d.ts +0 -53
  127. package/dist/engine/triggers.d.ts.map +0 -1
  128. package/dist/engine/triggers.js.map +0 -1
  129. package/dist/engine/usage.d.ts +0 -61
  130. package/dist/engine/usage.d.ts.map +0 -1
  131. package/dist/engine/usage.js.map +0 -1
  132. package/dist/index.d.ts +0 -2
  133. package/dist/index.d.ts.map +0 -1
  134. package/dist/index.js.map +0 -1
  135. package/dist/miner/extractor.d.ts +0 -12
  136. package/dist/miner/extractor.d.ts.map +0 -1
  137. package/dist/miner/extractor.js.map +0 -1
  138. package/dist/miner/index.d.ts +0 -61
  139. package/dist/miner/index.d.ts.map +0 -1
  140. package/dist/miner/index.js.map +0 -1
  141. package/dist/miner/scheduler.d.ts +0 -30
  142. package/dist/miner/scheduler.d.ts.map +0 -1
  143. package/dist/miner/scheduler.js.map +0 -1
  144. package/dist/miner/types.d.ts +0 -11
  145. package/dist/miner/types.d.ts.map +0 -1
  146. package/dist/miner/types.js.map +0 -1
  147. package/dist/search/index.d.ts +0 -323
  148. package/dist/search/index.d.ts.map +0 -1
  149. package/dist/search/index.js.map +0 -1
  150. package/dist/search/semantic.d.ts +0 -80
  151. package/dist/search/semantic.d.ts.map +0 -1
  152. package/dist/search/semantic.js.map +0 -1
  153. package/dist/search/synonyms.d.ts +0 -2
  154. package/dist/search/synonyms.d.ts.map +0 -1
  155. package/dist/search/synonyms.js.map +0 -1
  156. package/dist/search/tokenize.d.ts +0 -39
  157. package/dist/search/tokenize.d.ts.map +0 -1
  158. package/dist/search/tokenize.js.map +0 -1
  159. package/dist/server.d.ts +0 -41
  160. package/dist/server.d.ts.map +0 -1
  161. package/dist/server.js.map +0 -1
  162. package/dist/sync/git.d.ts +0 -36
  163. package/dist/sync/git.d.ts.map +0 -1
  164. package/dist/sync/git.js.map +0 -1
  165. package/dist/sync/materialize.d.ts +0 -35
  166. package/dist/sync/materialize.d.ts.map +0 -1
  167. package/dist/sync/materialize.js.map +0 -1
  168. package/dist/sync/ops.d.ts +0 -142
  169. package/dist/sync/ops.d.ts.map +0 -1
  170. package/dist/sync/ops.js.map +0 -1
  171. package/dist/sync/space.d.ts +0 -116
  172. package/dist/sync/space.d.ts.map +0 -1
  173. package/dist/sync/space.js.map +0 -1
  174. package/dist/types/index.d.ts +0 -346
  175. package/dist/types/index.d.ts.map +0 -1
  176. package/dist/types/index.js.map +0 -1
  177. package/dist/utils/budget.d.ts +0 -44
  178. package/dist/utils/budget.d.ts.map +0 -1
  179. package/dist/utils/budget.js.map +0 -1
  180. package/dist/utils/fs.d.ts +0 -59
  181. package/dist/utils/fs.d.ts.map +0 -1
  182. package/dist/utils/fs.js.map +0 -1
  183. package/dist/utils/hash.d.ts +0 -20
  184. package/dist/utils/hash.d.ts.map +0 -1
  185. package/dist/utils/hash.js.map +0 -1
  186. package/dist/utils/ids.d.ts +0 -47
  187. package/dist/utils/ids.d.ts.map +0 -1
  188. package/dist/utils/ids.js.map +0 -1
  189. package/dist/utils/lock.d.ts +0 -11
  190. package/dist/utils/lock.d.ts.map +0 -1
  191. package/dist/utils/lock.js.map +0 -1
  192. package/dist/utils/transcripts.d.ts +0 -63
  193. package/dist/utils/transcripts.d.ts.map +0 -1
  194. package/dist/utils/transcripts.js.map +0 -1
  195. package/dist/version.d.ts +0 -26
  196. package/dist/version.d.ts.map +0 -1
  197. package/dist/version.js.map +0 -1
@@ -20,15 +20,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.SearchEngine = exports.RECALL_KINDS = exports.INDEX_VERSION = void 0;
22
22
  exports.recencyOf = recencyOf;
23
+ exports.splitParts = splitParts;
24
+ exports.flushOnExit = flushOnExit;
23
25
  exports.projectSlugOf = projectSlugOf;
24
26
  const orama_1 = require("@orama/orama");
25
27
  const node_path_1 = __importDefault(require("node:path"));
28
+ const node_fs_1 = require("node:fs");
26
29
  const fs_js_1 = require("../utils/fs.js");
27
30
  const hash_js_1 = require("../utils/hash.js");
28
31
  const tokenize_js_1 = require("./tokenize.js");
29
32
  const semantic_js_1 = require("./semantic.js");
30
33
  const ops_js_1 = require("../sync/ops.js");
31
34
  const shelf_js_1 = require("../engine/shelf.js");
35
+ const heat_js_1 = require("../engine/heat.js");
32
36
  /**
33
37
  * Bump when the schema changes so old on-disk indexes are rebuilt, not loaded.
34
38
  * v3: chunk removal used to rely on an Orama `where` filter that silently
@@ -39,15 +43,77 @@ const shelf_js_1 = require("../engine/shelf.js");
39
43
  * 2.0) are no longer chunked, so an index built before it must be rebuilt or
40
44
  * it would keep serving entries that were retired.
41
45
  */
42
- exports.INDEX_VERSION = 7; // 4: keys field (1.15) · 5: entry_status (2.0) · 6: eid per chunk (2.1) · 7: session logs indexed (2.2)
43
- /** Weight given to a neuron for each *additional* chunk that matches. */
46
+ exports.INDEX_VERSION = 8; // 4: keys field (1.15) · 5: entry_status (2.0) · 6: eid per chunk (2.1) · 7: session logs indexed (2.2) · 8: own tokenizer, 4 indexed fields, long entries in parts (2.11)
47
+ /**
48
+ * Lift for each *additional* entry of a neuron that matched by keyword, in
49
+ * units of one fully matched term (the keyword engine), or of FUSED_UNIT
50
+ * with the semantic layer on (2.11): the same 0.05 the keyword engine always
51
+ * had, but no longer added raw to a fused score where it was worth fifteen
52
+ * ranks.
53
+ */
44
54
  const BREADTH_BONUS = 0.05;
45
- /** Cap on the breadth bonus — breadth is a tiebreaker, never the main signal. */
55
+ /** Cap on the breadth lift — breadth is a tiebreaker, never the main signal. */
46
56
  const BREADTH_CAP = 0.25;
57
+ /** One breadth unit on the fused (reciprocal-rank) scale: the cap is then 0.025, about a rank and a half at the top. */
58
+ const FUSED_UNIT = 0.1;
59
+ /**
60
+ * The bound project's neurons are lifted by this factor (2.11) — a boost
61
+ * inside the ranking, not a separate tier above it.
62
+ */
63
+ const PROJECT_BOOST = 1.25;
64
+ /** Fallback strong line for a vector-only match while a brain has no calibration (the 2.10 constant). */
65
+ const SEMANTIC_STRONG_DEFAULT = 0.86;
66
+ /** Strong needs the calibrated noise top (p95) plus this. */
67
+ const CALIBRATION_MARGIN = 0.01;
68
+ const CALIBRATION_SAMPLES = 40;
69
+ /** Below this many vectors a noise floor is not worth measuring. */
70
+ const CALIBRATION_MIN_VECTORS = 50;
71
+ /** Queries with no word of any brain: their best cosine is pure noise too. */
72
+ const NONSENSE = ['xqzv wkrp jjhg', 'zzkq vrrt plmx', 'qwoijd fhhq', 'bnxl ttqz wpw'];
73
+ /** Vector candidates looked at, of which at most SEMANTIC_CANDIDATES are used. */
74
+ const SEMANTIC_POOL = 160;
75
+ /** At most this many vector candidates from one neuron (2.11: diversity). */
76
+ const SEMANTIC_PER_NEURON = 4;
77
+ /** How long a query waits for the semantic model before answering keyword-only (2.11). CRBRO_SEMANTIC_WAIT_MS overrides. */
78
+ const SEMANTIC_WAIT_DEFAULT_MS = 1500;
79
+ /** The no-answer level is on unless CRBRO_NO_ANSWER=0 (2.11). */
80
+ function noAnswerEnabled() {
81
+ const v = (process.env.CRBRO_NO_ANSWER || '').toLowerCase();
82
+ return !(v === '0' || v === 'off' || v === 'false');
83
+ }
84
+ /**
85
+ * Rows kept when nothing clears the evidence bar. Three, not one: a real
86
+ * question that shares a single word with its fact looks like a distractor
87
+ * to a keyword engine, and with one row recall@3 fell from 90 % to 85 % on
88
+ * the 48-query set. The flag still says "not clearly stored".
89
+ */
90
+ const NO_ANSWER_ROWS = 3;
91
+ function semanticWaitMs() {
92
+ const v = Number(process.env.CRBRO_SEMANTIC_WAIT_MS);
93
+ return Number.isFinite(v) && v >= 0 && v <= 120_000 ? v : SEMANTIC_WAIT_DEFAULT_MS;
94
+ }
95
+ /**
96
+ * Long entries (2.11) are indexed as overlapping parts: an entry longer than
97
+ * PART_MAX characters becomes parts of about PART_TARGET with PART_OVERLAP of
98
+ * overlap, cut at a sentence or a space. BM25 no longer drowns the one line
99
+ * that matched in the length of the rest, the vector model (which sees about
100
+ * 512 tokens) sees every part, and recall returns the part that matched
101
+ * instead of the opening of a 5,000-character entry. PART_MAX equals the
102
+ * recall handler's content cap, so a part is never cut again on the way
103
+ * out. Measured on the synthetic brain: parts of 800 made the index file
104
+ * 38.5 MB, parts of about 1,000 35.2 MB, for the same benchmark results.
105
+ */
106
+ const PART_MAX = 1200;
107
+ const PART_TARGET = 1000;
108
+ const PART_OVERLAP = 120;
47
109
  /** Exponent on term coverage. >1 punishes chunks that only match one term. */
48
110
  const COVERAGE_EXPONENT = 1.5;
49
111
  /** Debounce before writing the index to disk after a learn. */
50
112
  const PERSIST_DEBOUNCE_MS = 5000;
113
+ /** How often a long-lived process compares every file even when the folders did not move (2.11). */
114
+ const FULL_CHECK_MS = 60_000;
115
+ /** How long an exiting process may spend writing its index before it leaves anyway (2.11). */
116
+ const EXIT_FLUSH_MS = 2000;
51
117
  /** How many of the top results also carry their neuron's next best chunks. */
52
118
  const ALSO_MATCHED_RESULTS = 3;
53
119
  /** Semantic fusion (opt-in): candidates asked of the vector index per query. */
@@ -124,13 +190,14 @@ exports.RECALL_KINDS = ['fact', 'decision', 'pattern', 'preference', 'error', 'd
124
190
  * line matters, and entry_id reads it whole through crbro_inspect.
125
191
  */
126
192
  const ALSO_PREVIEW = 300;
127
- function alsoLine(text, kind, added, eid, from = {}) {
193
+ function alsoLine(text, kind, added, eid, from = {}, entryChars) {
128
194
  return {
129
195
  ...(eid ? { entry_id: eid } : {}),
130
196
  kind,
131
197
  added,
132
198
  preview: text.length > ALSO_PREVIEW ? `${text.slice(0, ALSO_PREVIEW).trimEnd()}…` : text,
133
- chars: text.length,
199
+ // The whole entry's length, also when the line is one part of a long entry.
200
+ chars: entryChars || text.length,
134
201
  // Same rule as the winning entry: said only when the line is not the user's own.
135
202
  ...(from.origin ? { origin: from.origin } : {}),
136
203
  ...(from.by ? { by: from.by } : {}),
@@ -151,19 +218,29 @@ function staleView(s) {
151
218
  ...(s.age_from ? { age_from: s.age_from } : {}),
152
219
  };
153
220
  }
221
+ /**
222
+ * What Orama INDEXES: only the four fields a search reads. Every document
223
+ * also carries, stored but not indexed (2.11), its `id` ("<neuron_id>#<hash>",
224
+ * Orama's own key), `neuron`, `kind` (header | fact | decision | pattern |
225
+ * preference | error | debt | map | session), `eid` (the entry's own id:
226
+ * factId for facts, entryId otherwise, 'map', '' for the header), `domain`,
227
+ * `added`, `heat` and, for a part of a long entry, `part`/`parts`/`offset`/
228
+ * `entry_chars`. Until 2.10 all of them were searchable and sortable
229
+ * strings: on a 7,386-entry brain the index file was 77.7 MB for 9.25 MB of
230
+ * neurons, most of it term frequencies, radix trees and sort structures for
231
+ * ids and dates nobody searched. They are filtered in JS (see searchTerm),
232
+ * never with an Orama `where`.
233
+ */
154
234
  const SCHEMA = {
155
- id: 'string', // "<neuron_id>#<hash>" — must be `id` for Orama
156
- neuron: 'string',
157
235
  name: 'string',
158
236
  text: 'string',
159
237
  keys: 'string', // aliases a future question may use — facts only (1.15)
160
- kind: 'string', // header | fact | decision | pattern | preference
161
- eid: 'string', // the entry's own id: factId for facts, entryId otherwise, 'map', '' for the header (2.1)
162
- domain: 'string',
163
238
  tags: 'string',
164
- added: 'string',
165
- heat: 'number',
166
239
  };
240
+ /** A fresh Orama database with CRBRO's tokenizer and no sort structures (nothing sorts by a field). */
241
+ function newDb() {
242
+ return (0, orama_1.create)({ schema: SCHEMA, components: { tokenizer: (0, tokenize_js_1.createIndexTokenizer)() }, sort: { enabled: false } });
243
+ }
167
244
  const SESSION_MATCHES = 3;
168
245
  const SESSION_CHUNK = 700;
169
246
  /** Best chunk per session, top sessions by coverage-weighted score; total is how many days had a hit. */
@@ -220,6 +297,109 @@ function sessionChunks(summary) {
220
297
  }
221
298
  return out;
222
299
  }
300
+ /** The fields of a stored document a hit carries, without score. */
301
+ function chunkFields(doc) {
302
+ return {
303
+ neuron: doc.neuron, name: doc.name, domain: doc.domain, text: doc.text,
304
+ kind: doc.kind, eid: doc.eid || '', added: doc.added, heat: doc.heat || 0,
305
+ ...(typeof doc.parts === 'number' ? { part: doc.part, parts: doc.parts, offset: doc.offset, entry_chars: doc.entry_chars } : {}),
306
+ };
307
+ }
308
+ /**
309
+ * Ranks for reciprocal-rank fusion counted per neuron (2.11): the first
310
+ * chunk of each neuron takes the next free rank, every later chunk of the
311
+ * same neuron sits one place behind its neuron's last one. Input in score order.
312
+ */
313
+ function rankPerNeuron(list) {
314
+ const out = new Map();
315
+ const ultimo = new Map();
316
+ let siguiente = 1;
317
+ for (const { id, neuron } of list) {
318
+ const prev = ultimo.get(neuron);
319
+ const r = prev === undefined ? siguiente++ : prev + 1;
320
+ ultimo.set(neuron, r);
321
+ out.set(id, r);
322
+ }
323
+ return out;
324
+ }
325
+ /** File dates for many paths, a bounded number at a time; 0 for a file that is gone. */
326
+ async function mtimesOf(files, width = 64) {
327
+ const out = new Array(files.length).fill(0);
328
+ let next = 0;
329
+ const worker = async () => {
330
+ while (next < files.length) {
331
+ const i = next++;
332
+ out[i] = await (0, fs_js_1.fileMtime)(files[i]);
333
+ }
334
+ };
335
+ await Promise.all(Array.from({ length: Math.min(width, files.length) }, worker));
336
+ return out;
337
+ }
338
+ /** One chunk per entry, the best-scoring part of a long one; order kept. Header chunks pass through. */
339
+ function unaPorEntrada(chunks) {
340
+ const vistas = new Set();
341
+ const out = [];
342
+ for (const c of chunks) {
343
+ if (c.parts && c.eid) {
344
+ const k = `${c.kind}:${c.eid}`;
345
+ if (vistas.has(k))
346
+ continue;
347
+ vistas.add(k);
348
+ }
349
+ out.push(c);
350
+ }
351
+ return out;
352
+ }
353
+ /** Cut a long entry into overlapping parts at sentence or word boundaries. Each part is an exact slice. */
354
+ function splitParts(text) {
355
+ if (text.length <= PART_MAX)
356
+ return [{ offset: 0, text }];
357
+ const out = [];
358
+ let start = 0;
359
+ while (start < text.length) {
360
+ let end = Math.min(text.length, start + PART_TARGET);
361
+ if (text.length - end < PART_TARGET / 5)
362
+ end = text.length; // no tiny last part (and none over PART_MAX)
363
+ if (end < text.length) {
364
+ const ventana = text.slice(start + Math.floor(PART_TARGET * 0.6), end);
365
+ const frase = Math.max(ventana.lastIndexOf('. '), ventana.lastIndexOf('\n'), ventana.lastIndexOf('; '));
366
+ const espacio = ventana.lastIndexOf(' ');
367
+ const corte = frase >= 0 ? frase + 1 : espacio;
368
+ if (corte > 0)
369
+ end = start + Math.floor(PART_TARGET * 0.6) + corte;
370
+ }
371
+ out.push({ offset: start, text: text.slice(start, end) });
372
+ if (end >= text.length)
373
+ break;
374
+ // Next part starts PART_OVERLAP back, at a word boundary.
375
+ let next = Math.max(start + 1, end - PART_OVERLAP);
376
+ const sp = text.indexOf(' ', next);
377
+ if (sp > 0 && sp < end)
378
+ next = sp + 1;
379
+ start = next;
380
+ }
381
+ return out;
382
+ }
383
+ /** The full text of an entry by its id, as it is indexed (a decision with its rationale). Null when gone or retired. */
384
+ function entryTextById(neuron, kind, eid) {
385
+ switch (kind) {
386
+ case 'fact': {
387
+ const f = (neuron.facts || []).find(x => !!x && !isRetired(x) && (x.id || (0, hash_js_1.factId)(x.text)) === eid);
388
+ return f ? f.text : null;
389
+ }
390
+ case 'decision': {
391
+ const d = (neuron.decisions || []).find(x => !!x && (x.id || (0, ops_js_1.entryId)(x.text)) === eid);
392
+ return d ? (d.rationale ? `${d.text} — ${d.rationale}` : d.text) : null;
393
+ }
394
+ case 'map':
395
+ return neuron.map?.text || null;
396
+ case 'pattern': return (neuron.patterns || []).find(t => (0, ops_js_1.entryId)(t) === eid) ?? null;
397
+ case 'preference': return (neuron.preferences || []).find(t => (0, ops_js_1.entryId)(t) === eid) ?? null;
398
+ case 'error': return (neuron.errors || []).find(t => (0, ops_js_1.entryId)(t) === eid) ?? null;
399
+ case 'debt': return (neuron.debts || []).find(t => (0, ops_js_1.entryId)(t) === eid) ?? null;
400
+ default: return null;
401
+ }
402
+ }
223
403
  class SearchEngine {
224
404
  brain;
225
405
  db = null;
@@ -256,6 +436,17 @@ class SearchEngine {
256
436
  rebuilding = null;
257
437
  /** When the cortex was last read into this index, in ms. Files newer than this are somebody else's writes. */
258
438
  syncedAt = 0;
439
+ /** File date of each neuron / "session:<id>" as indexed (see StoredIndex.mtimes). */
440
+ indexedMtime = new Map();
441
+ /** Folder dates of cortex and hippocampus at the last refresh: a write by anyone renames a file into them. */
442
+ dirsSeen = { cortex: -1, diary: -1 };
443
+ /** Last full file-by-file comparison, for writers that edit in place without touching the folder. */
444
+ lastFullCheck = 0;
445
+ /** One refresh at a time. */
446
+ refreshing = null;
447
+ /** The semantic calibration loaded with the index or measured since. */
448
+ calibration = null;
449
+ calibrating = null;
259
450
  constructor(brain) {
260
451
  this.brain = brain;
261
452
  if ((0, semantic_js_1.semanticEnabled)()) {
@@ -294,13 +485,50 @@ class SearchEngine {
294
485
  }
295
486
  const indexAt = await (0, fs_js_1.fileMtime)(this.brain.paths.chunksIndex());
296
487
  if ((await this.loadOrRebuild()) === 'loaded') {
297
- // A loaded index is as old as its file. isStale() forgives one second, and
298
- // a process killed inside the 5 s persist debounce leaves exactly that:
299
- // a neuron on disk, newer than the index by less than the slack, that no
300
- // rebuild will ever pick up. Catch up from the file's own timestamp.
488
+ // A loaded index is as old as its file. Entries without a recorded file
489
+ // date (an index from 2.10) are judged against it with a second of
490
+ // slack; the rest by their exact date.
301
491
  this.syncedAt = indexAt;
302
492
  await this.refresh();
303
493
  }
494
+ this.reconcileVectors();
495
+ }
496
+ /**
497
+ * Make the vectors match the index (2.11), in the background. Vectors were
498
+ * only ever computed for chunks inserted in this process, so a vectors file
499
+ * dropped at load (torn by a crash, another model) was never refilled and
500
+ * the layer stayed empty or partial until a full rebuild; and vectors of
501
+ * chunks that left the index were never pruned, taking candidate seats.
502
+ */
503
+ reconcileVectors() {
504
+ if (!this.semantic || !this.db)
505
+ return;
506
+ const docs = Object.values((this.db?.data?.docs?.docs) || {});
507
+ const quiero = new Map();
508
+ for (const d of docs)
509
+ if (d && d.id && d.kind !== 'header' && d.kind !== 'session')
510
+ quiero.set(String(d.id), String(d.text || ''));
511
+ const tengo = new Set(this.semantic.idList());
512
+ const huerfanos = [...tengo].filter(id => !quiero.has(id));
513
+ if (huerfanos.length > 0)
514
+ this.semantic.remove(huerfanos);
515
+ let faltan = 0;
516
+ for (const [id, text] of quiero)
517
+ if (!tengo.has(id)) {
518
+ this.pendingEmbed.set(id, text);
519
+ faltan++;
520
+ }
521
+ if (faltan === 0 && huerfanos.length === 0)
522
+ return;
523
+ const previa = this.embedding ?? Promise.resolve();
524
+ this.embedding = previa.then(() => this.flushEmbeddings()).then(() => this.markDirty()).catch(() => undefined);
525
+ }
526
+ /** Vectors held vs chunks that should have one (status, tests). */
527
+ semanticCoverage() {
528
+ if (!this.semantic || !this.db)
529
+ return null;
530
+ const docs = Object.values((this.db?.data?.docs?.docs) || {});
531
+ return { vectors: this.semantic.count(), chunks: docs.filter(d => d && d.kind !== 'header' && d.kind !== 'session').length };
304
532
  }
305
533
  async loadOrRebuild() {
306
534
  if (this.semantic) {
@@ -314,12 +542,22 @@ class SearchEngine {
314
542
  if (await (0, fs_js_1.fileExists)(indexPath)) {
315
543
  try {
316
544
  const stored = await (0, fs_js_1.readJSON)(indexPath);
317
- if (stored && stored.v === exports.INDEX_VERSION && stored.data && !(await this.isStale())) {
318
- this.db = (0, orama_1.create)({ schema: SCHEMA });
545
+ // An index that is merely BEHIND the files is loaded and caught up by
546
+ // refresh() (2.11). It used to be rebuilt from scratch whenever any
547
+ // neuron or day log was newer than it — and a plain read rewrites a
548
+ // neuron (access count) — so a boot after another process's work paid
549
+ // a full rebuild: crbro_boot 5.9 s instead of 1.57 s on a 561-neuron
550
+ // brain. Only a different version, a corrupt file or an unknown shape
551
+ // rebuild now.
552
+ if (stored && stored.v === exports.INDEX_VERSION && stored.data) {
553
+ this.db = newDb();
319
554
  (0, orama_1.load)(this.db, stored.data);
320
555
  this.docCount = this.countDocs(stored.data);
321
- if (this.recoverChunkMap(stored.data))
556
+ if (this.recoverChunkMap(stored.data)) {
557
+ this.indexedMtime = new Map(Object.entries(stored.mtimes || {}).filter(([, v]) => typeof v === 'number'));
558
+ this.calibration = stored.calibration && stored.calibration.model ? stored.calibration : null;
322
559
  return 'loaded';
560
+ }
323
561
  // Could not tell which chunk belongs to which neuron — without
324
562
  // that, removal is blind, so a rebuild is the only safe answer.
325
563
  }
@@ -331,6 +569,25 @@ class SearchEngine {
331
569
  await this.rebuild();
332
570
  return 'rebuilt';
333
571
  }
572
+ /**
573
+ * Catch up with writes made by anyone else before answering (2.11). A
574
+ * process that stays up — a classic stdio server for a whole conversation,
575
+ * the daemon — only refreshed at boot, so what another session learned was
576
+ * invisible to it until it restarted. Every writer renames a file into the
577
+ * cortex or the diary folder, which moves the folder's own date: two stats
578
+ * per search say whether anything changed, and only then do we compare the
579
+ * files one by one. Once a minute we compare anyway, for a writer that
580
+ * edits a file in place.
581
+ */
582
+ async catchUp() {
583
+ if (!this.db || this.rebuilding)
584
+ return;
585
+ const [cortex, diary] = await Promise.all([(0, fs_js_1.fileMtime)(this.brain.paths.cortex), (0, fs_js_1.fileMtime)(this.brain.paths.hippocampus)]);
586
+ const moved = cortex !== this.dirsSeen.cortex || diary !== this.dirsSeen.diary;
587
+ if (!moved && Date.now() - this.lastFullCheck < FULL_CHECK_MS)
588
+ return;
589
+ await this.refresh();
590
+ }
334
591
  /**
335
592
  * Bring the in-memory index up to date with the disk: re-index the neurons
336
593
  * and day logs whose files changed since the last sync, drop the chunks of
@@ -344,33 +601,62 @@ class SearchEngine {
344
601
  await this.init();
345
602
  return { reindexed: 0, removed: 0 };
346
603
  }
604
+ if (this.refreshing)
605
+ return this.refreshing;
606
+ this.refreshing = this.doRefresh().finally(() => { this.refreshing = null; });
607
+ return this.refreshing;
608
+ }
609
+ /**
610
+ * Has this file changed since it was indexed? Exact file dates when we
611
+ * recorded one (2.11); for an entry without a record (an index written by
612
+ * 2.10), the old rule: newer than the last sync, with a second of slack.
613
+ */
614
+ changed(key, mtime, desde) {
615
+ const known = this.indexedMtime.get(key);
616
+ return known !== undefined ? known !== mtime : mtime > desde;
617
+ }
618
+ async doRefresh() {
347
619
  await this.settled();
348
- const desde = this.syncedAt - 1000; // the same second of slack isStale() allows
620
+ const desde = this.syncedAt - 1000; // one second of slack for entries without a recorded date
349
621
  const empezado = Date.now();
622
+ // Taken BEFORE listing: a write landing during this pass moves the folder
623
+ // again and the next search looks again.
624
+ const [dirCortex, dirDiary] = await Promise.all([(0, fs_js_1.fileMtime)(this.brain.paths.cortex), (0, fs_js_1.fileMtime)(this.brain.paths.hippocampus)]);
350
625
  let reindexed = 0, removed = 0;
351
626
  const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
352
627
  const vivos = new Set(ids);
353
- for (const id of ids) {
628
+ let conCalor = null;
629
+ // Every date first, many at a time (2.11): one stat after another was 563
630
+ // round trips through the I/O pool, ~20 s on a busy machine (measured while
631
+ // the semantic layer filled vectors), and every search waited for it.
632
+ const fechas = await mtimesOf(ids.map(id => this.brain.paths.neuron(id)));
633
+ for (const [n, id] of ids.entries()) {
354
634
  // Unchanged AND already in the index. A neuron with no chunks at all is
355
635
  // not indexed, whatever its date says: every neuron has at least its
356
636
  // header chunk. That is what an index file written by ANOTHER process
357
637
  // looks like from here — newer than the neuron, and without it — and the
358
638
  // file date alone would never bring it back.
359
- if (this.chunksByNeuron.has(id) && (await (0, fs_js_1.fileMtime)(this.brain.paths.neuron(id))) <= desde)
639
+ const mtime = fechas[n];
640
+ if (this.chunksByNeuron.has(id) && !this.changed(id, mtime, desde))
360
641
  continue;
361
642
  try {
362
643
  const neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
363
644
  if (!neuron)
364
645
  continue;
365
- await this.reindexInPlace(neuron);
646
+ conCalor ??= await this.heatNow();
647
+ await this.reindexInPlace(conCalor(neuron));
648
+ this.indexedMtime.set(id, mtime);
366
649
  reindexed++;
367
650
  }
368
651
  catch { /* unreadable: leave what we have */ }
369
652
  }
370
653
  const diarios = await (0, fs_js_1.listJSONFiles)(this.brain.paths.hippocampus);
371
654
  const diasVivos = new Set(diarios.map(d => `session:${d}`));
372
- for (const id of diarios) {
373
- if ((await (0, fs_js_1.fileMtime)(this.brain.paths.session(id))) <= desde)
655
+ const fechasDia = await mtimesOf(diarios.map(id => this.brain.paths.session(id)));
656
+ for (const [n, id] of diarios.entries()) {
657
+ const key = `session:${id}`;
658
+ const mtime = fechasDia[n];
659
+ if (!this.changed(key, mtime, desde))
374
660
  continue;
375
661
  try {
376
662
  const log = await (0, fs_js_1.readJSON)(this.brain.paths.session(id));
@@ -379,6 +665,7 @@ class SearchEngine {
379
665
  await this.removeNeuronChunks(`session:${log.session_id}`);
380
666
  if (log.summary)
381
667
  await this.insertSessionChunks(log);
668
+ this.indexedMtime.set(key, mtime);
382
669
  reindexed++;
383
670
  }
384
671
  catch { /* skip */ }
@@ -394,9 +681,12 @@ class SearchEngine {
394
681
  if (await (0, fs_js_1.fileExists)(file))
395
682
  continue;
396
683
  await this.removeNeuronChunks(key);
684
+ this.indexedMtime.delete(key);
397
685
  removed++;
398
686
  }
399
687
  this.syncedAt = empezado;
688
+ this.dirsSeen = { cortex: dirCortex, diary: dirDiary };
689
+ this.lastFullCheck = empezado;
400
690
  if (reindexed > 0 || removed > 0) {
401
691
  this.markDirty();
402
692
  // Off the boot path, like rebuild(): every client waits on init(), and
@@ -407,27 +697,6 @@ class SearchEngine {
407
697
  }
408
698
  return { reindexed, removed };
409
699
  }
410
- /**
411
- * Has the cortex moved on without the index?
412
- *
413
- * This used to be impossible to notice: init() only rebuilt when the file
414
- * was missing, the version had changed or the JSON was corrupt — never
415
- * because it had simply fallen behind. So a lost flush, or a second client
416
- * writing neurons while this one held a stale index in memory, left those
417
- * facts invisible to recall indefinitely. On the reference brain the index
418
- * was five and a half hours behind the newest neuron, and a term saved that
419
- * afternoon returned nothing at all.
420
- */
421
- async isStale() {
422
- const indexAt = await (0, fs_js_1.fileMtime)(this.brain.paths.chunksIndex());
423
- if (indexAt === 0)
424
- return true;
425
- const cortexAt = await (0, fs_js_1.newestMtime)(this.brain.paths.cortex);
426
- // The diary is indexed too (2.2): a log written by another process counts.
427
- const diaryAt = await (0, fs_js_1.newestMtime)(this.brain.paths.hippocampus);
428
- // One second of slack: a write landing during a rebuild is not staleness.
429
- return Math.max(cortexAt, diaryAt) > indexAt + 1000;
430
- }
431
700
  /**
432
701
  * Rebuild the whole index from the cortex.
433
702
  * One bad neuron must not take the server down with it, so every neuron
@@ -445,18 +714,38 @@ class SearchEngine {
445
714
  if (this.rebuilding)
446
715
  await this.rebuilding.catch(() => undefined);
447
716
  }
717
+ /**
718
+ * Heat as the derived heat state has it (2.11): neuron files are no longer
719
+ * rewritten when their heat moves, so the value stored in a file is only a
720
+ * fallback for a neuron the state does not know yet.
721
+ */
722
+ async heatNow() {
723
+ const estado = await (0, heat_js_1.readHeatState)(this.brain);
724
+ const calor = estado?.neurons || {};
725
+ return (n) => {
726
+ const h = calor[n.id]?.heat;
727
+ return typeof h === 'number' && h !== n.heat ? { ...n, heat: h } : n;
728
+ };
729
+ }
448
730
  async doRebuild() {
449
731
  this.syncedAt = Date.now();
450
- this.db = (0, orama_1.create)({ schema: SCHEMA });
732
+ this.lastFullCheck = this.syncedAt;
733
+ this.dirsSeen = { cortex: await (0, fs_js_1.fileMtime)(this.brain.paths.cortex), diary: await (0, fs_js_1.fileMtime)(this.brain.paths.hippocampus) };
734
+ this.db = newDb();
451
735
  this.docCount = 0;
452
736
  this.chunksByNeuron.clear();
737
+ this.indexedMtime.clear();
453
738
  const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
739
+ const conCalor = await this.heatNow();
454
740
  for (const id of ids) {
455
741
  try {
742
+ // Dated before reading: a write in between is simply indexed again later.
743
+ const mtime = await (0, fs_js_1.fileMtime)(this.brain.paths.neuron(id));
456
744
  const neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
457
745
  if (!neuron)
458
746
  continue;
459
- await this.insertNeuronChunks(neuron);
747
+ await this.insertNeuronChunks(conCalor(neuron));
748
+ this.indexedMtime.set(id, mtime);
460
749
  }
461
750
  catch {
462
751
  // Skip unreadable neuron, keep going.
@@ -465,9 +754,11 @@ class SearchEngine {
465
754
  // The diary too (2.2): every session log, as paragraphs.
466
755
  for (const id of await (0, fs_js_1.listJSONFiles)(this.brain.paths.hippocampus)) {
467
756
  try {
757
+ const mtime = await (0, fs_js_1.fileMtime)(this.brain.paths.session(id));
468
758
  const log = await (0, fs_js_1.readJSON)(this.brain.paths.session(id));
469
759
  if (log?.summary)
470
760
  await this.insertSessionChunks(log);
761
+ this.indexedMtime.set(`session:${id}`, mtime);
471
762
  }
472
763
  catch {
473
764
  // Skip unreadable log, keep going.
@@ -477,7 +768,7 @@ class SearchEngine {
477
768
  // hold boot for minutes on a big brain. Recall serves what is embedded so
478
769
  // far; the vectors go to disk with the next index write.
479
770
  this.embedding = this.flushEmbeddings().then(() => this.markDirty()).catch(() => undefined);
480
- await this.persist();
771
+ await this.writeIndex();
481
772
  await this.dropLegacyIndex();
482
773
  return this.docCount;
483
774
  }
@@ -491,6 +782,9 @@ class SearchEngine {
491
782
  return;
492
783
  await this.settled();
493
784
  await this.reindexInPlace(neuron);
785
+ // The file as it is now is what was just indexed (the cortex calls this
786
+ // right after writing it): recorded, so the next refresh skips it.
787
+ this.indexedMtime.set(neuron.id, await (0, fs_js_1.fileMtime)(this.brain.paths.neuron(neuron.id)));
494
788
  // Ids are content hashes: only the neuron's NEW lines get embedded.
495
789
  await this.flushEmbeddings();
496
790
  this.markDirty();
@@ -523,6 +817,7 @@ class SearchEngine {
523
817
  if (!this.chunksByNeuron.has(neuronId))
524
818
  return;
525
819
  await this.removeNeuronChunks(neuronId);
820
+ this.indexedMtime.delete(neuronId);
526
821
  this.markDirty();
527
822
  }
528
823
  /** Wait for the background embedding job of a rebuild, if one is running. */
@@ -530,18 +825,37 @@ class SearchEngine {
530
825
  if (this.embedding)
531
826
  await this.embedding;
532
827
  }
533
- /** Write the index to disk now. */
828
+ /**
829
+ * Write the index to disk now. First catch up with whatever another
830
+ * process wrote (2.11): the file is shared, and a long-lived process that
831
+ * had not seen a later write used to overwrite the other's newer index
832
+ * with its older view — after which that write looked indexed to every
833
+ * loader. Catching up first makes what we write a superset of the disk;
834
+ * the per-file dates written with it (StoredIndex.mtimes) make any
835
+ * remaining race visible to the next loader instead of permanent.
836
+ */
534
837
  async persist() {
838
+ if (!this.db)
839
+ return;
840
+ await this.catchUp().catch(() => undefined);
841
+ await this.writeIndex();
842
+ }
843
+ storedPayload() {
844
+ return {
845
+ v: exports.INDEX_VERSION, data: (0, orama_1.save)(this.db), mtimes: Object.fromEntries(this.indexedMtime),
846
+ ...(this.calibration ? { calibration: this.calibration } : {}),
847
+ };
848
+ }
849
+ async writeIndex() {
535
850
  if (!this.db)
536
851
  return;
537
852
  if (this.persistTimer) {
538
853
  clearTimeout(this.persistTimer);
539
854
  this.persistTimer = null;
540
855
  }
541
- const payload = { v: exports.INDEX_VERSION, data: (0, orama_1.save)(this.db) };
542
856
  // Not pretty-printed: this file reaches tens of MB and indentation costs
543
857
  // ~25% of both size and write time for something no human reads.
544
- await (0, fs_js_1.writeJSON)(this.brain.paths.chunksIndex(), payload, { pretty: false });
858
+ await (0, fs_js_1.writeJSON)(this.brain.paths.chunksIndex(), this.storedPayload(), { pretty: false });
545
859
  this.dirty = false;
546
860
  if (this.semantic) {
547
861
  try {
@@ -550,6 +864,24 @@ class SearchEngine {
550
864
  catch { /* vectors are derived data; recall survives */ }
551
865
  }
552
866
  }
867
+ /**
868
+ * Write synchronously, for the 'exit' event, where nothing async runs any
869
+ * more. Only when there is something unsaved; never throws.
870
+ */
871
+ flushSync() {
872
+ if (!this.db || !this.dirty)
873
+ return;
874
+ try {
875
+ const file = this.brain.paths.chunksIndex();
876
+ const tmp = `${file}.${process.pid}.exit.tmp`;
877
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(this.storedPayload()), 'utf-8');
878
+ (0, node_fs_1.renameSync)(tmp, file);
879
+ this.dirty = false;
880
+ }
881
+ catch { /* best effort: the next boot refreshes from the files */ }
882
+ }
883
+ /** Is there anything in memory the file does not have yet? */
884
+ isDirty() { return this.dirty; }
553
885
  /** Flush if there are unsaved changes. Called on consolidate/shutdown. */
554
886
  async flush() {
555
887
  if (this.dirty)
@@ -564,13 +896,35 @@ class SearchEngine {
564
896
  * the sum of its normalised hits weighted by how much of the query it
565
897
  * covers. This is what stops one very common term from deciding the ranking
566
898
  * and what makes a chunk matching five of six terms beat a scrap matching one.
899
+ *
900
+ * relevance_score (2.11) means one thing in every mode: the ranking score on
901
+ * a 0–1 scale where 1 is the best this query can score — keyword engine:
902
+ * every term matched at its best hit (rarity-weighted share of the query);
903
+ * with the semantic layer: first in both the keyword and the vector
904
+ * ranking; with several phrasings: 1 when every phrasing scores 1. Recency
905
+ * and breadth can lift it a few percent above 1. It orders the list; it is
906
+ * not a truth value, and `confidence` is the label to read.
567
907
  */
568
908
  async search(query, options) {
909
+ // A background pass of the semantic layer waits while a search runs
910
+ // (2.11): its passages are tokenised on this thread, and every await of
911
+ // a search used to queue behind one — ~2 s a recall, measured, for the
912
+ // minutes a pass lasted after an upgrade.
913
+ const release = typeof this.semantic?.hold === 'function' ? this.semantic.hold() : undefined;
914
+ try {
915
+ return await this.searchNow(query, options);
916
+ }
917
+ finally {
918
+ release?.();
919
+ }
920
+ }
921
+ async searchNow(query, options) {
569
922
  if (!this.db)
570
923
  await this.init();
571
924
  if (!this.db)
572
925
  return [];
573
926
  await this.settled();
927
+ await this.catchUp().catch(() => undefined);
574
928
  const limit = options?.limit || 10;
575
929
  const terms = (0, tokenize_js_1.queryTerms)(query);
576
930
  if (terms.length === 0)
@@ -581,16 +935,26 @@ class SearchEngine {
581
935
  const desde = (options?.since || '').slice(0, 10);
582
936
  const clases = options?.kinds && options.kinds.length > 0 ? new Set(options.kinds) : null;
583
937
  const sinFecha = new Set();
938
+ const allowed = options?.neuronIds ? new Set(options.neuronIds) : null;
939
+ const owned = new Set(options?.globalExcludedNeurons || []);
584
940
  const admite = (doc) => {
941
+ if (allowed && !allowed.has(doc.neuron || '')) {
942
+ const global = options?.includeGlobal && (doc.kind === 'preference' ||
943
+ (!!doc.neuron && /^(tech_|process_)/.test(doc.neuron) && !owned.has(doc.neuron) &&
944
+ ['pattern', 'error', 'debt'].includes(doc.kind || '')));
945
+ if (!global)
946
+ return false;
947
+ }
585
948
  if (clases && !clases.has(String(doc.kind || '')))
586
949
  return false;
587
950
  if (desde) {
588
951
  if (doc.kind === 'header')
589
952
  return false; // dated by the neuron's birth, which says nothing about its content
590
953
  const dia = String(doc.added || '').slice(0, 10);
954
+ // Counted per entry, not per part of a long one.
591
955
  if (!dia) {
592
956
  if (doc.id)
593
- sinFecha.add(String(doc.id));
957
+ sinFecha.add(String(doc.eid ? `${doc.neuron}#${doc.eid}` : doc.id));
594
958
  return false;
595
959
  }
596
960
  if (dia < desde)
@@ -621,58 +985,61 @@ class SearchEngine {
621
985
  for (const [t, v] of pesoTermino)
622
986
  pesoTermino.set(t, max > 0 ? v / max : 1);
623
987
  }
988
+ // The most a chunk can collect: every term at its best hit. A term found
989
+ // nowhere counts as the rarest (weight 1): a question whose words are
990
+ // mostly absent cannot score near 1 on the one word that is there.
991
+ const ideal = porTermino.reduce((s, { term }) => s + (pesoTermino.get(term) ?? 1), 0) + (terms.length - porTermino.length) || 1;
624
992
  for (const { term, hits } of porTermino) {
625
- const best = hits[0].score || 1;
993
+ // The term's ceiling is its best CONTENT hit (2.11). It used to be the
994
+ // first hit of any kind: a neuron NAME carrying the word (boosted x2) or
995
+ // a 700-character day-log paragraph set the 1.0, and every entry that
996
+ // answers was worth half of that — so how much a term weighed depended
997
+ // on which header happened to contain it. Day logs have their own
998
+ // ceiling; a header can reach 1, never more.
999
+ const best = hits.find(h => !['header', 'session'].includes(h.document.kind))?.score || hits[0].score || 1;
1000
+ const bestSession = hits.find(h => h.document.kind === 'session')?.score || 1;
626
1001
  const w = pesoTermino.get(term) ?? 1;
627
1002
  for (const hit of hits) {
628
1003
  const doc = hit.document;
629
1004
  const key = doc.id;
630
- const normalised = best > 0 ? (hit.score / best) * w : 0;
631
1005
  // Session logs are searched, but never as neurons: they keep a list
632
1006
  // of their own, so a long narrative cannot outrank the fact that
633
1007
  // answers, and the neuron path below never sees them.
634
1008
  if (doc.kind === 'session') {
1009
+ if (allowed)
1010
+ continue; // A daily narrative has no reliable project boundary.
635
1011
  if (clases)
636
1012
  continue; // a day log is not an entry kind
637
1013
  if (desde && String(doc.added || '').slice(0, 10) < desde)
638
1014
  continue;
1015
+ const normalisedSession = (hit.score / bestSession) * w;
639
1016
  const prev = perSession.get(key);
640
1017
  if (prev) {
641
- prev.score += normalised;
1018
+ prev.score += normalisedSession;
642
1019
  prev.matched += 1;
643
1020
  }
644
1021
  else
645
- perSession.set(key, { session: String(doc.name || ''), date: String(doc.added || ''), text: String(doc.text || ''), eid: String(doc.eid || ''), score: normalised, matched: 1 });
1022
+ perSession.set(key, { session: String(doc.name || ''), date: String(doc.added || ''), text: String(doc.text || ''), eid: String(doc.eid || ''), score: normalisedSession, matched: 1 });
646
1023
  continue;
647
1024
  }
648
1025
  if (!admite(doc))
649
1026
  continue;
1027
+ const normalised = best > 0 ? Math.min(1, hit.score / best) * w : 0;
650
1028
  const existing = perChunk.get(key);
651
1029
  if (existing) {
652
1030
  existing.score += normalised;
653
1031
  existing.matched += 1;
654
1032
  }
655
1033
  else {
656
- perChunk.set(key, {
657
- neuron: doc.neuron,
658
- name: doc.name,
659
- domain: doc.domain,
660
- text: doc.text,
661
- kind: doc.kind,
662
- eid: doc.eid || '',
663
- added: doc.added,
664
- heat: doc.heat || 0,
665
- score: normalised,
666
- matched: 1,
667
- });
1034
+ perChunk.set(key, { ...chunkFields(doc), score: normalised, matched: 1 });
668
1035
  }
669
1036
  }
670
1037
  }
671
1038
  // Coverage weighting: a chunk matching five of six terms beats a scrap
672
- // matching one.
1039
+ // matching one. Then onto the 0–1 scale.
673
1040
  for (const chunk of perChunk.values()) {
674
1041
  const coverage = chunk.matched / terms.length;
675
- chunk.score = chunk.score * Math.pow(coverage, COVERAGE_EXPONENT);
1042
+ chunk.score = chunk.score * Math.pow(coverage, COVERAGE_EXPONENT) / ideal;
676
1043
  }
677
1044
  // Recency: a few percent on the lexical score, so near-ties fall to the
678
1045
  // newer telling. Before fusion on purpose — see RECENCY_WEIGHT_DEFAULT.
@@ -686,9 +1053,18 @@ class SearchEngine {
686
1053
  }
687
1054
  // Semantic fusion — only when the opt-in layer is installed, enabled and
688
1055
  // holds vectors. Off, this is a no-op and the scores above stand.
1056
+ let semantic;
689
1057
  if (this.semantic && this.semantic.ready() && this.semantic.count() > 0) {
690
- await this.fuseSemantic(query, perChunk, options?.domain, admite);
691
- }
1058
+ // While a large pass fills vectors in the background (after an upgrade
1059
+ // split long entries into parts, thousands of them), the keyword engine
1060
+ // answers alone and says so: measured on a 563-neuron brain, a recall
1061
+ // that waited for the model during the pass took 1.5–3 s instead of
1062
+ // 60 ms, for the minutes the pass lasted (2.11).
1063
+ semantic = this.semantic.backfilling?.() ? 'warming' : await this.fuseSemantic(query, perChunk, options?.domain, admite);
1064
+ }
1065
+ const unidad = semantic === 'used' ? FUSED_UNIT : 1 / ideal;
1066
+ if (options?.stats && semantic)
1067
+ options.stats.semantic = semantic;
692
1068
  if (options?.stats && desde)
693
1069
  options.stats.undated_skipped = sinFecha.size;
694
1070
  if (options?.stats) {
@@ -713,14 +1089,23 @@ class SearchEngine {
713
1089
  // shown five results cannot tell "five matched" from "five of forty".
714
1090
  if (options?.stats)
715
1091
  options.stats.matched_neurons = byNeuron.size;
716
- return this.materializeResults([...byNeuron.values()], limit, terms.length);
1092
+ return this.materializeResults([...byNeuron.values()], limit, terms.length, options?.preferNeurons, options?.stats, unidad);
717
1093
  }
718
1094
  /**
719
- * Several phrasings of one question, searched together and fused by
720
- * reciprocal rank. The caller is a language model: it knows the synonyms a
721
- * keyword index does not, and asking three ways costs it nothing. Rank 1
722
- * in every list scores 1.0; a neuron seen by one list out of three scores
723
- * about a third. One phrasing behaves exactly like search().
1095
+ * Several phrasings of one question, searched together and fused (2.11).
1096
+ * The caller is a language model: it knows the synonyms a keyword index
1097
+ * does not, and asking three ways costs it nothing.
1098
+ *
1099
+ * Fusion is by score, not by rank: 0.7 x the neuron's best score in any
1100
+ * phrasing + 0.3 x its mean over all phrasings. Reciprocal rank (until
1101
+ * 2.10) gave a neuron sitting second to sixth in every list 0.9+ whatever
1102
+ * its lines covered, because every list is filled to twenty rows. The row
1103
+ * shown is ONE phrasing's row — a strong one if any phrasing found the
1104
+ * neuron strongly, else the best-scoring — and its confidence,
1105
+ * matched_terms, query_terms and semantic_score are all that row's own:
1106
+ * the label never contradicts the numbers beside it (until 2.10 "strong"
1107
+ * was an OR over phrasings next to another row's 2-of-7). One phrasing
1108
+ * behaves exactly like search().
724
1109
  */
725
1110
  async searchMany(queries, options = {}) {
726
1111
  const distintas = [...new Set(queries.map(q => (q || '').trim()).filter(Boolean))];
@@ -734,80 +1119,98 @@ class SearchEngine {
734
1119
  if (options.stats) {
735
1120
  // Session hits: a day that several phrasings point at ranks first, then
736
1121
  // by accumulated coverage (matched over query terms, so 2/2 beats 3/8).
737
- // The row kept is the best-covered one, strong if any phrasing found it
738
- // so — and the same phrasings in any order give the same list.
1122
+ // The row kept is a strong one when any phrasing found the day strongly,
1123
+ // else the best-covered — and its label is its own.
739
1124
  const cobertura = (m) => (m.query_terms > 0 ? m.matched_terms / m.query_terms : 0);
1125
+ const mejorFila = (a, b) => (a.confidence === 'strong') !== (b.confidence === 'strong') ? (a.confidence === 'strong' ? a : b)
1126
+ : (cobertura(b) > cobertura(a) || (cobertura(b) === cobertura(a) && b.matched_terms > a.matched_terms)) ? b : a;
740
1127
  const fusion = new Map();
741
1128
  for (const s of porConsulta) {
742
1129
  for (const m of s.sessions || []) {
743
1130
  const f = fusion.get(m.session_id);
744
1131
  if (!f) {
745
- fusion.set(m.session_id, { row: m, cobertura: cobertura(m), frases: 1, strong: m.confidence === 'strong' });
1132
+ fusion.set(m.session_id, { row: m, cobertura: cobertura(m), frases: 1 });
746
1133
  continue;
747
1134
  }
748
1135
  f.cobertura += cobertura(m);
749
1136
  f.frases += 1;
750
- f.strong = f.strong || m.confidence === 'strong';
751
- if (cobertura(m) > cobertura(f.row) || (cobertura(m) === cobertura(f.row) && m.matched_terms > f.row.matched_terms))
752
- f.row = m;
1137
+ f.row = mejorFila(f.row, m);
753
1138
  }
754
1139
  }
755
1140
  options.stats.sessions = [...fusion.values()]
756
1141
  .sort((a, b) => b.frases - a.frases || b.cobertura - a.cobertura || (a.row.session_id < b.row.session_id ? 1 : -1))
757
1142
  .slice(0, SESSION_MATCHES)
758
- .map(f => ({ ...f.row, confidence: f.strong ? 'strong' : f.row.confidence }));
1143
+ .map(f => f.row);
759
1144
  // Each phrasing counted its own days: the union is at least the largest count.
760
1145
  options.stats.sessions_total = Math.max(fusion.size, ...porConsulta.map(s => s.sessions_total || 0));
761
1146
  if (options.since)
762
1147
  options.stats.undated_skipped = Math.max(0, ...porConsulta.map(s => s.undated_skipped || 0));
763
- }
1148
+ const sem = porConsulta.map(s => s.semantic).find(Boolean);
1149
+ if (sem)
1150
+ options.stats.semantic = sem;
1151
+ }
1152
+ // One key per entry everywhere (2.11): the entry id, or the text when
1153
+ // there is none. The old map keyed the same line by its full text in one
1154
+ // place and by its id in another, so one entry could fill two of the
1155
+ // three also_matched slots.
1156
+ const claveFila = (r) => r.entry_id || `text:${r.matching_content}`;
1157
+ const claveAlso = (a) => a.entry_id || `text:${a.preview}`;
764
1158
  const fused = new Map();
765
- const alsoOf = (r) => alsoLine(r.matching_content, r.matched_kind || '', r.matched_added || '', r.entry_id, r);
766
1159
  for (const lista of listas) {
767
- lista.forEach((r, i) => {
768
- const gain = 1 / (RRF_K + i + 1);
769
- let f = fused.get(r.neuron_id);
770
- if (!f) {
771
- f = { score: gain, best: r, bestRank: i, strong: r.confidence === 'strong', also: new Map() };
772
- fused.set(r.neuron_id, f);
773
- }
1160
+ for (const r of lista) {
1161
+ const f = fused.get(r.neuron_id);
1162
+ if (!f)
1163
+ fused.set(r.neuron_id, { rows: [r], max: r.relevance_score, sum: r.relevance_score });
774
1164
  else {
775
- f.score += gain;
776
- f.strong = f.strong || r.confidence === 'strong';
777
- if (i < f.bestRank) {
778
- // The other list's answer becomes an also_matched line of the winner.
779
- if (f.best.matching_content !== r.matching_content)
780
- f.also.set(f.best.matching_content, alsoOf(f.best));
781
- f.best = r;
782
- f.bestRank = i;
783
- }
784
- else if (r.matching_content !== f.best.matching_content) {
785
- f.also.set(r.matching_content, alsoOf(r));
786
- }
1165
+ f.rows.push(r);
1166
+ f.max = Math.max(f.max, r.relevance_score);
1167
+ f.sum += r.relevance_score;
787
1168
  }
788
- for (const a of r.also_matched || [])
789
- f.also.set(a.entry_id || a.preview, a);
790
- });
1169
+ }
791
1170
  }
792
- const escala = (RRF_K + 1) / listas.length;
793
- if (options.stats)
1171
+ const n = listas.length;
1172
+ const preferred = new Set(options.preferNeurons || []);
1173
+ const fusedScore = (f) => 0.7 * f.max + 0.3 * (f.sum / n);
1174
+ const orden = (id, f) => fusedScore(f) * (preferred.has(id) ? PROJECT_BOOST : 1);
1175
+ if (options.stats) {
794
1176
  options.stats.matched_neurons = fused.size;
795
- return [...fused.values()]
796
- .sort((a, b) => b.score - a.score)
797
- .slice(0, limit)
798
- .map(f => {
799
- const r = { ...f.best, relevance_score: Math.round(f.score * escala * 1000) / 1000 };
800
- r.confidence = f.strong ? 'strong' : 'weak';
801
- const propio = alsoLine(r.matching_content, r.matched_kind || '', r.matched_added || '', r.entry_id);
802
- const also = [...f.also.values()]
803
- .filter(a => (a.entry_id && propio.entry_id) ? a.entry_id !== propio.entry_id : a.preview !== propio.preview)
804
- .slice(0, 3);
805
- if (also.length)
806
- r.also_matched = also;
1177
+ options.stats.no_answer = porConsulta.every(s => s.no_answer === true);
1178
+ }
1179
+ const rows = [...fused.entries()]
1180
+ .sort((a, b) => orden(b[0], b[1]) - orden(a[0], a[1]) || (a[0] < b[0] ? -1 : 1))
1181
+ .slice(0, options.stats?.no_answer ? Math.min(limit, NO_ANSWER_ROWS) : limit)
1182
+ .map(([, f]) => {
1183
+ const shown = f.rows.reduce((a, b) => (a.confidence === 'strong') !== (b.confidence === 'strong') ? (a.confidence === 'strong' ? a : b)
1184
+ : b.relevance_score > a.relevance_score ? b : a);
1185
+ const r = { ...shown, relevance_score: Math.round(fusedScore(f) * 1000) / 1000 };
1186
+ // The other phrasings' lines, then the also lines, one per entry,
1187
+ // never the shown entry itself.
1188
+ const vistas = new Set([claveFila(r)]);
1189
+ const also = [];
1190
+ const otras = f.rows.filter(x => x !== shown).sort((a, b) => b.relevance_score - a.relevance_score);
1191
+ for (const x of otras) {
1192
+ const k = claveFila(x);
1193
+ if (vistas.has(k))
1194
+ continue;
1195
+ vistas.add(k);
1196
+ also.push(alsoLine(x.matching_content, x.matched_kind || '', x.matched_added || '', x.entry_id, x));
1197
+ }
1198
+ for (const x of [shown, ...otras]) {
1199
+ for (const a of x.also_matched || []) {
1200
+ const k = claveAlso(a);
1201
+ if (vistas.has(k))
1202
+ continue;
1203
+ vistas.add(k);
1204
+ also.push(a);
1205
+ }
1206
+ }
1207
+ if (also.length && !options.stats?.no_answer)
1208
+ r.also_matched = also.slice(0, 3);
807
1209
  else
808
1210
  delete r.also_matched;
809
1211
  return r;
810
1212
  });
1213
+ return rows;
811
1214
  }
812
1215
  /**
813
1216
  * searchMany plus the one number the list alone cannot carry: how many
@@ -822,6 +1225,8 @@ class SearchEngine {
822
1225
  results, matched_neurons: stats.matched_neurons ?? results.length, sessions,
823
1226
  sessions_total: stats.sessions_total ?? sessions.length,
824
1227
  ...(stats.undated_skipped !== undefined ? { undated_skipped: stats.undated_skipped } : {}),
1228
+ ...(stats.no_answer ? { no_answer: true } : {}),
1229
+ ...(stats.semantic ? { semantic: stats.semantic } : {}),
825
1230
  };
826
1231
  }
827
1232
  /**
@@ -830,46 +1235,80 @@ class SearchEngine {
830
1235
  * ranked first by both scores 1.0; one seen by a single list scores about
831
1236
  * half. Vector-only candidates below the cosine floor are dropped, and the
832
1237
  * header chunks (name + tags) are never surfaced by vector alone.
1238
+ *
1239
+ * Diversity (2.11): ranks are counted per NEURON. A neuron's best chunk
1240
+ * takes the next free rank and its other chunks queue behind it, so a
1241
+ * neuron with hundreds of lines on the topic cannot push every other
1242
+ * neuron down the list by its size; and at most SEMANTIC_PER_NEURON vector
1243
+ * candidates come from one neuron, so it cannot take all the seats either.
1244
+ *
1245
+ * Returns how the layer took part: 'used', or 'warming' when the model was
1246
+ * not loaded within the bounded wait and this answer is keyword-only.
833
1247
  */
834
1248
  async fuseSemantic(query, perChunk, domain, admite = () => true) {
835
1249
  let sem;
836
1250
  try {
837
- sem = await this.semantic.query(query, SEMANTIC_CANDIDATES);
1251
+ const pedido = this.semantic.query(query, SEMANTIC_POOL);
1252
+ // Bounded wait (2.11): the first recall after a cold start used to wait
1253
+ // for the whole model load (~13 s), or a download, with no limit. Past
1254
+ // the wait this answer is keyword-only and says so; the load goes on.
1255
+ const espera = semanticWaitMs();
1256
+ let reloj = null;
1257
+ const tarde = new Promise(resolve => { reloj = setTimeout(() => resolve(null), espera); });
1258
+ const r = await Promise.race([pedido, tarde]);
1259
+ if (reloj)
1260
+ clearTimeout(reloj);
1261
+ if (r === null) {
1262
+ pedido.catch(() => undefined);
1263
+ return 'warming';
1264
+ }
1265
+ sem = r;
838
1266
  }
839
1267
  catch {
840
- return; // the semantic layer never breaks recall
1268
+ return undefined; // the semantic layer never breaks recall
841
1269
  }
842
1270
  const floor = semanticFloor();
1271
+ // A vector whose chunk left the index (an orphan) never takes a seat.
843
1272
  sem = sem.filter(h => h.score >= floor);
844
1273
  if (sem.length === 0)
845
- return;
1274
+ return 'used';
1275
+ this.maybeCalibrate();
1276
+ const elegidos = [];
1277
+ const porNeurona = new Map();
846
1278
  for (const h of sem) {
847
- if (perChunk.has(h.id))
848
- continue;
849
- let doc;
850
- try {
851
- doc = await (0, orama_1.getByID)(this.db, h.id);
852
- }
853
- catch {
854
- doc = undefined;
1279
+ if (elegidos.length >= SEMANTIC_CANDIDATES)
1280
+ break;
1281
+ let doc = perChunk.get(h.id);
1282
+ if (!doc) {
1283
+ try {
1284
+ doc = await (0, orama_1.getByID)(this.db, h.id);
1285
+ }
1286
+ catch {
1287
+ doc = undefined;
1288
+ }
1289
+ if (!doc)
1290
+ continue;
1291
+ if (doc.kind === 'header' || doc.kind === 'session')
1292
+ continue;
1293
+ if (domain && doc.domain !== domain)
1294
+ continue;
1295
+ if (!admite(doc))
1296
+ continue;
855
1297
  }
856
- if (!doc || doc.kind === 'header' || doc.kind === 'session')
857
- continue;
858
- if (domain && doc.domain !== domain)
1298
+ const k = porNeurona.get(doc.neuron) || 0;
1299
+ if (k >= SEMANTIC_PER_NEURON)
859
1300
  continue;
860
- if (!admite(doc))
861
- continue;
862
- perChunk.set(h.id, {
863
- neuron: doc.neuron, name: doc.name, domain: doc.domain, text: doc.text,
864
- kind: doc.kind, eid: doc.eid || '', added: doc.added, heat: doc.heat || 0, score: 0, matched: 0,
865
- });
1301
+ porNeurona.set(doc.neuron, k + 1);
1302
+ elegidos.push(h);
1303
+ if (!perChunk.has(h.id))
1304
+ perChunk.set(h.id, { ...chunkFields(doc), score: 0, matched: 0 });
866
1305
  }
867
1306
  const lex = [...perChunk.entries()]
868
1307
  .filter(([, c]) => c.matched > 0)
869
1308
  .sort((a, b) => b[1].score - a[1].score);
870
- const lexRank = new Map(lex.map(([id], i) => [id, i + 1]));
871
- const semRank = new Map(sem.map((h, i) => [h.id, i + 1]));
872
- const semScore = new Map(sem.map(h => [h.id, h.score]));
1309
+ const lexRank = rankPerNeuron(lex.map(([id, c]) => ({ id, neuron: c.neuron })));
1310
+ const semRank = rankPerNeuron(elegidos.map(h => ({ id: h.id, neuron: perChunk.get(h.id).neuron })));
1311
+ const semScore = new Map(elegidos.map(h => [h.id, h.score]));
873
1312
  const top = 2 / (RRF_K + 1);
874
1313
  for (const [id, chunk] of perChunk) {
875
1314
  const l = lexRank.get(id);
@@ -878,6 +1317,7 @@ class SearchEngine {
878
1317
  if (s !== undefined)
879
1318
  chunk.semantic = semScore.get(id);
880
1319
  }
1320
+ return 'used';
881
1321
  }
882
1322
  /** Embed the chunks inserted since the last pass. Never throws. */
883
1323
  async flushEmbeddings() {
@@ -894,6 +1334,80 @@ class SearchEngine {
894
1334
  // Model missing or failing: recall stays lexical, indexing is unharmed.
895
1335
  }
896
1336
  }
1337
+ /**
1338
+ * The cosine at or above which a vector-only match is strong on this
1339
+ * brain: the calibrated one when measured, else the old fixed line.
1340
+ */
1341
+ semanticStrong() {
1342
+ const c = this.calibration;
1343
+ return c && c.model === (0, semantic_js_1.semanticModel)() ? c.strong : SEMANTIC_STRONG_DEFAULT;
1344
+ }
1345
+ /**
1346
+ * Measure this brain's semantic noise floor, in the background, when there
1347
+ * is none or the vector count moved by more than a quarter since (2.11).
1348
+ * Random questions are built from words of random chunks that do not go
1349
+ * together; the best cosine each reaches is what "nothing here" scores on
1350
+ * this brain. Strong then needs noise_p95 + CALIBRATION_MARGIN.
1351
+ */
1352
+ maybeCalibrate() {
1353
+ if (!this.semantic || this.calibrating || !this.db)
1354
+ return;
1355
+ const n = this.semantic.count();
1356
+ const c = this.calibration;
1357
+ if (n < CALIBRATION_MIN_VECTORS)
1358
+ return;
1359
+ if (c && c.model === (0, semantic_js_1.semanticModel)() && Math.abs(n - c.vectors) <= c.vectors * 0.25)
1360
+ return;
1361
+ this.calibrating = this.calibrate().then(() => undefined, () => undefined).finally(() => { this.calibrating = null; });
1362
+ }
1363
+ /** Exposed for tests and the benchmark: measure now and keep the result. */
1364
+ async calibrate(samples = CALIBRATION_SAMPLES) {
1365
+ if (!this.semantic || !this.db)
1366
+ return null;
1367
+ const docs = Object.values((this.db?.data?.docs?.docs) || {});
1368
+ const palabras = [];
1369
+ for (const d of docs) {
1370
+ if (!d || d.kind === 'header' || d.kind === 'session')
1371
+ continue;
1372
+ for (const w of String(d.text || '').split(/\s+/))
1373
+ if (w.length >= 5 && /^[\p{L}]+$/u.test(w))
1374
+ palabras.push(w);
1375
+ if (palabras.length > 20000)
1376
+ break;
1377
+ }
1378
+ if (palabras.length < 50)
1379
+ return null;
1380
+ // Deterministic per brain size: the same brain calibrates the same way.
1381
+ let s = (docs.length * 2654435761) >>> 0 || 1;
1382
+ const rnd = () => { s = (Math.imul(s ^ (s >>> 15), 1 | s) + 0x6D2B79F5) >>> 0; return s / 4294967296; };
1383
+ const tops = [];
1384
+ for (let i = 0; i < samples; i++) {
1385
+ const q = Array.from({ length: 3 }, () => palabras[Math.floor(rnd() * palabras.length)]).join(' ');
1386
+ const hits = await this.semantic.query(q, 1);
1387
+ if (hits[0])
1388
+ tops.push(hits[0].score);
1389
+ }
1390
+ // Nonsense with no word of the brain at all, too: what "xqzv wkrp" reaches.
1391
+ for (const q of NONSENSE) {
1392
+ const hits = await this.semantic.query(q, 1);
1393
+ if (hits[0])
1394
+ tops.push(hits[0].score);
1395
+ }
1396
+ if (tops.length < 5)
1397
+ return null;
1398
+ tops.sort((a, b) => a - b);
1399
+ const q = (p) => tops[Math.min(tops.length - 1, Math.floor(p * tops.length))];
1400
+ const cal = {
1401
+ model: (0, semantic_js_1.semanticModel)(), vectors: this.semantic.count(), samples: tops.length,
1402
+ noise_p50: Math.round(q(0.5) * 1000) / 1000, noise_p95: Math.round(q(0.95) * 1000) / 1000,
1403
+ strong: Math.round((q(0.95) + CALIBRATION_MARGIN) * 1000) / 1000, at: new Date().toISOString(),
1404
+ };
1405
+ this.calibration = cal;
1406
+ this.markDirty();
1407
+ return cal;
1408
+ }
1409
+ /** The calibration in use, if any (status and tests). */
1410
+ semanticCalibration() { return this.calibration; }
897
1411
  /**
898
1412
  * Turn per-neuron chunk lists into results, validating each chunk against
899
1413
  * the neuron on disk before serving it.
@@ -907,10 +1421,15 @@ class SearchEngine {
907
1421
  * fall back to its next valid chunk instead. Stale neurons are quietly
908
1422
  * re-indexed; chunks of deleted neurons are removed on the spot.
909
1423
  */
910
- async materializeResults(porNeurona, limit, queryTermCount) {
1424
+ async materializeResults(porNeurona, limit, queryTermCount, preferNeurons, stats, breadthUnit = 1) {
911
1425
  // Only the plausible head pays the neuron reads: nothing below it could
912
- // reach the answer even if everything above dropped out.
913
- porNeurona.sort((a, b) => b[0].score - a[0].score);
1426
+ // reach the answer even if everything above dropped out. The project's
1427
+ // neurons get a bounded lift (2.11), not the head of the line: until
1428
+ // 2.10 any project neuron, matching one word, ranked above the best
1429
+ // global match.
1430
+ const preferred = new Set(preferNeurons || []);
1431
+ const lift = (neuron) => (preferred.has(neuron) ? PROJECT_BOOST : 1);
1432
+ porNeurona.sort((a, b) => b[0].score * lift(b[0].neuron) - a[0].score * lift(a[0].neuron));
914
1433
  const candidatos = porNeurona.slice(0, Math.max(limit * 3, limit + 8));
915
1434
  const resultados = [];
916
1435
  // Shelf life (staleness): judged here, where the neuron file is already
@@ -938,6 +1457,9 @@ class SearchEngine {
938
1457
  equipo = { espacios };
939
1458
  return equipo;
940
1459
  };
1460
+ const strongCut = this.semanticStrong();
1461
+ // A vector match is evidence only above this brain's noise: the strong line.
1462
+ const evidenceCut = strongCut;
941
1463
  for (const chunks of candidatos) {
942
1464
  const neuronId = chunks[0].neuron;
943
1465
  let neuron = null;
@@ -957,7 +1479,9 @@ class SearchEngine {
957
1479
  const vivos = [];
958
1480
  let huboRancio = false;
959
1481
  for (const chunk of chunks) {
960
- if (!legible || neuron === null || this.chunkVive(chunk, neuron))
1482
+ // A part of a long entry is checked against the whole entry it came from.
1483
+ const entero = neuron && chunk.parts ? this.wholeOf(chunk, neuron) : chunk;
1484
+ if (!legible || neuron === null || (entero && this.chunkVive(entero, neuron)))
961
1485
  vivos.push(chunk);
962
1486
  else
963
1487
  huboRancio = true;
@@ -972,67 +1496,107 @@ class SearchEngine {
972
1496
  // the neuron, but it never speaks for it while a live content chunk
973
1497
  // matched too. Measured on the blind benchmark, letting it win cost 3 of
974
1498
  // the 13 misses — this rule alone moves recall@1 from 56% to 60%.
975
- const contenido = vivos.filter(c => c.kind !== 'header');
1499
+ // One line per entry: the best part of a long entry speaks for it.
1500
+ const contenido = unaPorEntrada(vivos.filter(c => c.kind !== 'header'));
976
1501
  const elegido = contenido[0] || vivos[0];
977
- // Breadth says "this neuron is about the question". A digest (2.5: the
978
- // lines a bulk import left, folded together) is about nothing: twenty of
979
- // its lines matching "verify" is a property of the pile, and on the
980
- // reference brain it put the pile above the project that answered.
1502
+ // Breadth says "this neuron is about the question" — counted from
1503
+ // LEXICAL evidence only (2.11), one per entry, in the unit of the
1504
+ // score's own scale. Until 2.10 it was +0.05 per chunk up to +0.25 on
1505
+ // any scale and counted vector-only neighbours too: with the semantic
1506
+ // layer on, where two ranks sit ~1.6 % apart, a neuron with hundreds of
1507
+ // lines on the topic won by size. A digest (2.5: the lines a bulk
1508
+ // import left, folded together) is about nothing.
981
1509
  const esDigest = !!neuron && (neuron.tags || []).includes('digest');
982
- const breadth = esDigest ? 0 : Math.min((chunks.length - 1) * BREADTH_BONUS, BREADTH_CAP);
1510
+ const lexicas = contenido.filter(c => c.matched > 0).length;
1511
+ const breadth = esDigest ? 0 : Math.min(Math.max(0, lexicas - 1) * BREADTH_BONUS, BREADTH_CAP) * breadthUnit;
983
1512
  // A keyword engine answers almost any question with something. The
984
1513
  // label says how much of the question the answer actually covers, so
985
1514
  // the reader can tell a hit from a coincidence.
1515
+ const coverage = queryTermCount > 0 ? elegido.matched / queryTermCount : 0;
986
1516
  const lexicalStrong = queryTermCount <= 1
987
1517
  ? elegido.matched >= 1
988
- : elegido.matched >= 2 && elegido.matched / queryTermCount >= 0.5;
989
- const strong = lexicalStrong || (elegido.semantic ?? 0) >= SEMANTIC_STRONG;
1518
+ : elegido.matched >= 2 && coverage >= 0.5;
1519
+ const strong = lexicalStrong || (elegido.semantic ?? 0) >= strongCut;
1520
+ // The minimum evidence bar (2.11): below it the row is a coincidence —
1521
+ // one word of a long question, or a vector inside this brain's noise.
1522
+ const evidence = lexicalStrong || coverage >= 0.5 || (elegido.matched >= 2 && coverage >= 1 / 3)
1523
+ || (elegido.semantic ?? 0) >= evidenceCut;
1524
+ const relevancia = vivos[0].score + breadth;
1525
+ const conPadre = neuron && elegido.parts ? (this.wholeOf(elegido, neuron) || elegido) : elegido;
990
1526
  resultados.push({
991
1527
  r: {
992
1528
  neuron_id: elegido.neuron,
993
1529
  name: elegido.name,
994
1530
  domain: elegido.domain,
995
- relevance_score: Math.round((vivos[0].score + breadth) * 1000) / 1000,
1531
+ relevance_score: Math.round(relevancia * 1000) / 1000,
996
1532
  matching_content: elegido.text,
997
1533
  matched_kind: elegido.kind,
998
1534
  matched_added: elegido.added || '',
999
1535
  ...(elegido.eid ? { entry_id: elegido.eid } : {}),
1536
+ ...(elegido.parts ? { matched_part: { part: (elegido.part ?? 0) + 1, parts: elegido.parts, offset: elegido.offset ?? 0, entry_chars: elegido.entry_chars ?? 0 } } : {}),
1000
1537
  heat: elegido.heat,
1001
1538
  matched_terms: elegido.matched,
1002
1539
  query_terms: queryTermCount,
1003
1540
  confidence: strong ? 'strong' : 'weak',
1004
1541
  ...(elegido.semantic !== undefined ? { semantic_score: elegido.semantic } : {}),
1005
1542
  ...(neuron?.map?.text ? { has_map: true } : {}),
1006
- ...(neuron ? await this.originOf(elegido, neuron, contextoEquipo) : {}),
1007
- ...(vida && neuron ? (() => { const s = staleView(this.stalenessOf(elegido, neuron, vida)); return s ? { staleness: s } : {}; })() : {}),
1543
+ ...(neuron ? await this.originOf(conPadre, neuron, contextoEquipo) : {}),
1544
+ ...(vida && neuron ? (() => { const s = staleView(this.stalenessOf(conPadre, neuron, vida)); return s ? { staleness: s } : {}; })() : {}),
1008
1545
  },
1009
1546
  extra: contenido.filter(c => c !== elegido).slice(0, 2),
1010
1547
  neuron,
1548
+ orden: relevancia * lift(elegido.neuron),
1549
+ evidence,
1011
1550
  });
1012
1551
  }
1013
1552
  // Synapses do not move this ranking, on purpose: lifting a neuron whose
1014
1553
  // neighbour also scored was measured on 633 queries over the reference
1015
1554
  // brain and changed recall@3 by 0.15 points at most. Pre-registration and
1016
1555
  // numbers in benchmarks/synapse-activation/; mechanism in commit 69917ce.
1017
- resultados.sort((a, b) => b.r.relevance_score - a.r.relevance_score);
1018
- const top = resultados.slice(0, limit);
1556
+ resultados.sort((a, b) => b.orden - a.orden);
1557
+ // No answer (2.11): when no row clears the evidence bar, say so instead
1558
+ // of filling the list with coincidences (an unanswerable question used to
1559
+ // come back as five weak rows and ~12,000 characters). The closest rows
1560
+ // stay, weak and without also_matched, so the caller can see what the
1561
+ // nearest things were.
1562
+ const sinRespuesta = noAnswerEnabled() && resultados.length > 0 && !resultados.some(x => x.evidence);
1563
+ if (stats)
1564
+ stats.no_answer = sinRespuesta;
1565
+ const top = resultados.slice(0, sinRespuesta ? Math.min(limit, NO_ANSWER_ROWS) : limit);
1019
1566
  // The neuron's next best lines, for the head of the list only: the line
1020
1567
  // you need is not always the one that scored highest. Measured, it lifts
1021
1568
  // fact-level recall@3 from 73% to 79%, and it costs tokens only where
1022
1569
  // they can still change the answer.
1023
- for (const { r, extra, neuron } of top.slice(0, ALSO_MATCHED_RESULTS)) {
1570
+ for (const { r, extra, neuron } of sinRespuesta ? [] : top.slice(0, ALSO_MATCHED_RESULTS)) {
1024
1571
  if (extra.length > 0) {
1025
1572
  // Previews, not bodies: the also lines were 46% of a recall's cost as
1026
1573
  // full text. The id reads any of them whole. Each line carries its own
1027
1574
  // origin: a teammate's line next to an own winner is still a teammate's.
1028
- r.also_matched = await Promise.all(extra.map(async (c) => alsoLine(c.text, c.kind, c.added || '', c.eid, {
1029
- ...(neuron ? await this.originOf(c, neuron, contextoEquipo) : {}),
1030
- ...(vida && neuron ? { staleness: this.stalenessOf(c, neuron, vida) ?? undefined } : {}),
1031
- })));
1575
+ r.also_matched = await Promise.all(extra.map(async (c) => {
1576
+ const padre = neuron && c.parts ? (this.wholeOf(c, neuron) || c) : c;
1577
+ return alsoLine(c.text, c.kind, c.added || '', c.eid, {
1578
+ ...(neuron ? await this.originOf(padre, neuron, contextoEquipo) : {}),
1579
+ ...(vida && neuron ? { staleness: this.stalenessOf(padre, neuron, vida) ?? undefined } : {}),
1580
+ }, c.entry_chars);
1581
+ }));
1032
1582
  }
1033
1583
  }
1034
1584
  return top.map(x => x.r);
1035
1585
  }
1586
+ /**
1587
+ * The whole entry a part of a long one came from, as a chunk with the full
1588
+ * text — what chunkVive, originOf and stalenessOf compare against. Null
1589
+ * when the entry is gone or no longer contains the part where it was.
1590
+ */
1591
+ wholeOf(chunk, neuron) {
1592
+ const texto = entryTextById(neuron, chunk.kind, chunk.eid);
1593
+ if (texto === null)
1594
+ return null;
1595
+ const off = chunk.offset ?? 0;
1596
+ if (texto.slice(off, off + chunk.text.length) !== chunk.text)
1597
+ return null;
1598
+ return { ...chunk, text: texto, parts: undefined };
1599
+ }
1036
1600
  /**
1037
1601
  * Where the matching entry came from — but only when it is NOT the user's
1038
1602
  * own, so the common case costs nothing: no field, no token. Read from what
@@ -1208,7 +1772,7 @@ class SearchEngine {
1208
1772
  continue;
1209
1773
  if (isRetired(fact))
1210
1774
  continue;
1211
- await this.put({
1775
+ await this.putEntry({
1212
1776
  ...base,
1213
1777
  id: (0, hash_js_1.chunkId)(neuron.id, fact.text),
1214
1778
  text: fact.text,
@@ -1226,7 +1790,7 @@ class SearchEngine {
1226
1790
  const text = decision.rationale
1227
1791
  ? `${decision.text} — ${decision.rationale}`
1228
1792
  : decision.text;
1229
- await this.put({
1793
+ await this.putEntry({
1230
1794
  ...base,
1231
1795
  id: (0, hash_js_1.chunkId)(neuron.id, text),
1232
1796
  text,
@@ -1245,7 +1809,7 @@ class SearchEngine {
1245
1809
  continue;
1246
1810
  if (entryRetired(neuron, pattern))
1247
1811
  continue;
1248
- await this.put({
1812
+ await this.putEntry({
1249
1813
  ...base,
1250
1814
  id: (0, hash_js_1.chunkId)(neuron.id, pattern),
1251
1815
  text: pattern,
@@ -1257,7 +1821,7 @@ class SearchEngine {
1257
1821
  for (const pref of neuron.preferences || []) {
1258
1822
  if (!pref)
1259
1823
  continue;
1260
- await this.put({
1824
+ await this.putEntry({
1261
1825
  ...base,
1262
1826
  id: (0, hash_js_1.chunkId)(neuron.id, pref),
1263
1827
  text: pref,
@@ -1271,7 +1835,7 @@ class SearchEngine {
1271
1835
  continue;
1272
1836
  if (entryRetired(neuron, err))
1273
1837
  continue;
1274
- await this.put({
1838
+ await this.putEntry({
1275
1839
  ...base,
1276
1840
  id: (0, hash_js_1.chunkId)(neuron.id, `error:${err}`),
1277
1841
  text: err,
@@ -1285,7 +1849,7 @@ class SearchEngine {
1285
1849
  continue;
1286
1850
  if (entryRetired(neuron, debt))
1287
1851
  continue;
1288
- await this.put({
1852
+ await this.putEntry({
1289
1853
  ...base,
1290
1854
  id: (0, hash_js_1.chunkId)(neuron.id, `debt:${debt}`),
1291
1855
  text: debt,
@@ -1295,7 +1859,7 @@ class SearchEngine {
1295
1859
  });
1296
1860
  }
1297
1861
  if (neuron.map && neuron.map.text) {
1298
- await this.put({
1862
+ await this.putEntry({
1299
1863
  ...base,
1300
1864
  id: (0, hash_js_1.chunkId)(neuron.id, `map:${neuron.map.text}`),
1301
1865
  text: neuron.map.text,
@@ -1327,6 +1891,7 @@ class SearchEngine {
1327
1891
  if (!this.db)
1328
1892
  return [];
1329
1893
  await this.settled();
1894
+ await this.catchUp().catch(() => undefined);
1330
1895
  // Orama keeps "crbro-memory" and "crbro_memory" as single tokens and
1331
1896
  // matches by prefix, so the project's first word finds every spelling;
1332
1897
  // which field actually names the project is decided below, in our terms.
@@ -1399,6 +1964,7 @@ class SearchEngine {
1399
1964
  await this.removeNeuronChunks(`session:${log.session_id}`);
1400
1965
  if (log.summary)
1401
1966
  await this.insertSessionChunks(log);
1967
+ this.indexedMtime.set(`session:${log.session_id}`, await (0, fs_js_1.fileMtime)(this.brain.paths.session(log.session_id)));
1402
1968
  this.markDirty();
1403
1969
  }
1404
1970
  async insertSessionChunks(log) {
@@ -1419,6 +1985,22 @@ class SearchEngine {
1419
1985
  });
1420
1986
  }
1421
1987
  }
1988
+ /**
1989
+ * Insert one entry: whole when short, as overlapping parts when longer than
1990
+ * PART_MAX (2.11). Every part keeps the entry's kind, eid, date and keys;
1991
+ * its id derives from the whole entry's id, so editing the entry replaces
1992
+ * all of its parts.
1993
+ */
1994
+ async putEntry(doc) {
1995
+ const partes = splitParts(doc.text);
1996
+ if (partes.length === 1) {
1997
+ await this.put(doc);
1998
+ return;
1999
+ }
2000
+ for (const [i, p] of partes.entries()) {
2001
+ await this.put({ ...doc, id: (0, hash_js_1.chunkId)(doc.neuron, `${doc.id}#part${i}`), text: p.text, part: i, parts: partes.length, offset: p.offset, entry_chars: doc.text.length });
2002
+ }
2003
+ }
1422
2004
  async put(doc) {
1423
2005
  try {
1424
2006
  await (0, orama_1.insert)(this.db, doc);
@@ -1544,6 +2126,58 @@ class SearchEngine {
1544
2126
  }
1545
2127
  }
1546
2128
  exports.SearchEngine = SearchEngine;
2129
+ /**
2130
+ * Classic stdio mode (2.11): write a dirty index before the process leaves.
2131
+ * The index is written five seconds after the last change on a timer that,
2132
+ * on purpose, never keeps the process alive — so a client that closed the
2133
+ * pipe within those five seconds left the next boot behind the files. Now:
2134
+ * stdin ending or closing, SIGINT and SIGTERM flush (at most EXIT_FLUSH_MS,
2135
+ * then leave anyway), and a plain 'exit' writes synchronously if anything is
2136
+ * still unsaved. Not for the daemon, which has its own shutdown, nor for a
2137
+ * proxy. Returns a function that removes the handlers (for tests).
2138
+ */
2139
+ function flushOnExit(engine, opts = {}) {
2140
+ const input = opts.input ?? process.stdin;
2141
+ const exit = opts.exit ?? ((code) => process.exit(code));
2142
+ let leaving = false;
2143
+ const leave = (code) => {
2144
+ if (leaving)
2145
+ return;
2146
+ leaving = true;
2147
+ let done = false;
2148
+ const go = () => { if (!done) {
2149
+ done = true;
2150
+ exit(code);
2151
+ } };
2152
+ const timer = setTimeout(go, EXIT_FLUSH_MS);
2153
+ if (typeof timer.unref === 'function')
2154
+ timer.unref();
2155
+ engine.flush().catch(() => undefined).finally(() => { clearTimeout(timer); go(); });
2156
+ };
2157
+ const onEnd = () => leave(0);
2158
+ const onInt = () => leave(130);
2159
+ const onTerm = () => leave(143);
2160
+ // A flush already under way (or abandoned at its deadline) is not repeated synchronously.
2161
+ const onExit = () => { if (!leaving)
2162
+ engine.flushSync(); };
2163
+ input.once('end', onEnd);
2164
+ input.once('close', onEnd);
2165
+ const signals = opts.signals !== false;
2166
+ if (signals) {
2167
+ process.once('SIGINT', onInt);
2168
+ process.once('SIGTERM', onTerm);
2169
+ }
2170
+ process.once('exit', onExit);
2171
+ return () => {
2172
+ input.off('end', onEnd);
2173
+ input.off('close', onEnd);
2174
+ if (signals) {
2175
+ process.off('SIGINT', onInt);
2176
+ process.off('SIGTERM', onTerm);
2177
+ }
2178
+ process.off('exit', onExit);
2179
+ };
2180
+ }
1547
2181
  /** A fact that has been superseded or retracted must not surface in recall. */
1548
2182
  /**
1549
2183
  * The project a `crbro_boot project=` value names: the last segment of a