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.
- package/LICENSE +21 -21
- package/README.md +256 -456
- package/SECURITY.md +135 -8
- package/bin/crbro.mjs +555 -299
- package/dist/build-id.json +10 -0
- package/dist/cli/common.js +211 -0
- package/dist/cli/export.js +522 -0
- package/dist/cli/import.js +683 -0
- package/dist/cli/markdown.js +313 -0
- package/dist/cli/mcpclient.js +107 -0
- package/dist/cli/serve.js +453 -0
- package/dist/cli/view.js +358 -0
- package/dist/cli/viewer-page.js +378 -0
- package/dist/daemon/daemon.js +119 -11
- package/dist/daemon/endpoint.js +222 -21
- package/dist/daemon/lines.js +102 -13
- package/dist/daemon/proxy.js +480 -125
- package/dist/daemon/surface.js +116 -0
- package/dist/daemon/trace.js +73 -0
- package/dist/engine/atrest.js +132 -0
- package/dist/engine/backup.js +143 -19
- package/dist/engine/brain.js +22 -6
- package/dist/engine/codexconfig.js +476 -0
- package/dist/engine/cortex.js +762 -311
- package/dist/engine/doctor.js +676 -0
- package/dist/engine/evidence.js +171 -0
- package/dist/engine/heat.js +81 -37
- package/dist/engine/hippocampus.js +50 -36
- package/dist/engine/hookrefresh.js +296 -0
- package/dist/engine/hookverify.js +15 -3
- package/dist/engine/installer.js +1022 -0
- package/dist/engine/installio.js +279 -0
- package/dist/engine/keychain.js +735 -144
- package/dist/engine/maintenance.js +355 -87
- package/dist/engine/modinstall.js +98 -45
- package/dist/engine/prefrontal.js +126 -69
- package/dist/engine/projects.js +80 -0
- package/dist/engine/quarantine.js +204 -0
- package/dist/engine/secrets.js +41 -5
- package/dist/engine/shelf.js +34 -11
- package/dist/engine/synapses.js +183 -80
- package/dist/engine/triggers.js +141 -11
- package/dist/index.js +37 -3
- package/dist/mcp-surface.json +1 -0
- package/dist/miner/extractor.js +64 -17
- package/dist/miner/scheduler.js +144 -68
- package/dist/search/index.js +810 -176
- package/dist/search/quality.js +31 -0
- package/dist/search/semantic.js +208 -18
- package/dist/search/tokenize.js +44 -0
- package/dist/server.js +649 -343
- package/dist/sync/git.js +203 -72
- package/dist/sync/materialize.js +33 -8
- package/dist/sync/space.js +286 -101
- package/dist/sync/validate.js +204 -0
- package/dist/utils/budget.js +15 -2
- package/dist/utils/fs.js +40 -6
- package/dist/utils/ids.js +128 -14
- package/dist/utils/lock.js +219 -52
- package/hooks/crbro-codex.mjs +284 -0
- package/hooks/crbro-guard.mjs +281 -173
- package/hooks/crbro-hooks.shipped.json +20 -0
- package/hooks/crbro-lifecycle.mjs +16 -4
- package/hooks/crbro-subagent.mjs +180 -180
- package/package.json +12 -8
- package/dist/daemon/daemon.d.ts +0 -25
- package/dist/daemon/daemon.d.ts.map +0 -1
- package/dist/daemon/daemon.js.map +0 -1
- package/dist/daemon/endpoint.d.ts +0 -66
- package/dist/daemon/endpoint.d.ts.map +0 -1
- package/dist/daemon/endpoint.js.map +0 -1
- package/dist/daemon/lines.d.ts +0 -55
- package/dist/daemon/lines.d.ts.map +0 -1
- package/dist/daemon/lines.js.map +0 -1
- package/dist/daemon/proxy.d.ts +0 -55
- package/dist/daemon/proxy.d.ts.map +0 -1
- package/dist/daemon/proxy.js.map +0 -1
- package/dist/engine/backup.d.ts +0 -68
- package/dist/engine/backup.d.ts.map +0 -1
- package/dist/engine/backup.js.map +0 -1
- package/dist/engine/brain.d.ts +0 -79
- package/dist/engine/brain.d.ts.map +0 -1
- package/dist/engine/brain.js.map +0 -1
- package/dist/engine/cortex.d.ts +0 -670
- package/dist/engine/cortex.d.ts.map +0 -1
- package/dist/engine/cortex.js.map +0 -1
- package/dist/engine/dates.d.ts +0 -28
- package/dist/engine/dates.d.ts.map +0 -1
- package/dist/engine/dates.js.map +0 -1
- package/dist/engine/heat.d.ts +0 -28
- package/dist/engine/heat.d.ts.map +0 -1
- package/dist/engine/heat.js.map +0 -1
- package/dist/engine/hippocampus.d.ts +0 -60
- package/dist/engine/hippocampus.d.ts.map +0 -1
- package/dist/engine/hippocampus.js.map +0 -1
- package/dist/engine/hookverify.d.ts +0 -42
- package/dist/engine/hookverify.d.ts.map +0 -1
- package/dist/engine/hookverify.js.map +0 -1
- package/dist/engine/keychain.d.ts +0 -32
- package/dist/engine/keychain.d.ts.map +0 -1
- package/dist/engine/keychain.js.map +0 -1
- package/dist/engine/maintenance.d.ts +0 -218
- package/dist/engine/maintenance.d.ts.map +0 -1
- package/dist/engine/maintenance.js.map +0 -1
- package/dist/engine/modinstall.d.ts +0 -215
- package/dist/engine/modinstall.d.ts.map +0 -1
- package/dist/engine/modinstall.js.map +0 -1
- package/dist/engine/postmortem.d.ts +0 -81
- package/dist/engine/postmortem.d.ts.map +0 -1
- package/dist/engine/postmortem.js.map +0 -1
- package/dist/engine/prefrontal.d.ts +0 -61
- package/dist/engine/prefrontal.d.ts.map +0 -1
- package/dist/engine/prefrontal.js.map +0 -1
- package/dist/engine/secrets.d.ts +0 -30
- package/dist/engine/secrets.d.ts.map +0 -1
- package/dist/engine/secrets.js.map +0 -1
- package/dist/engine/shelf.d.ts +0 -156
- package/dist/engine/shelf.d.ts.map +0 -1
- package/dist/engine/shelf.js.map +0 -1
- package/dist/engine/source.d.ts +0 -6
- package/dist/engine/source.d.ts.map +0 -1
- package/dist/engine/source.js.map +0 -1
- package/dist/engine/synapses.d.ts +0 -82
- package/dist/engine/synapses.d.ts.map +0 -1
- package/dist/engine/synapses.js.map +0 -1
- package/dist/engine/triggers.d.ts +0 -53
- package/dist/engine/triggers.d.ts.map +0 -1
- package/dist/engine/triggers.js.map +0 -1
- package/dist/engine/usage.d.ts +0 -61
- package/dist/engine/usage.d.ts.map +0 -1
- package/dist/engine/usage.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/miner/extractor.d.ts +0 -12
- package/dist/miner/extractor.d.ts.map +0 -1
- package/dist/miner/extractor.js.map +0 -1
- package/dist/miner/index.d.ts +0 -61
- package/dist/miner/index.d.ts.map +0 -1
- package/dist/miner/index.js.map +0 -1
- package/dist/miner/scheduler.d.ts +0 -30
- package/dist/miner/scheduler.d.ts.map +0 -1
- package/dist/miner/scheduler.js.map +0 -1
- package/dist/miner/types.d.ts +0 -11
- package/dist/miner/types.d.ts.map +0 -1
- package/dist/miner/types.js.map +0 -1
- package/dist/search/index.d.ts +0 -323
- package/dist/search/index.d.ts.map +0 -1
- package/dist/search/index.js.map +0 -1
- package/dist/search/semantic.d.ts +0 -80
- package/dist/search/semantic.d.ts.map +0 -1
- package/dist/search/semantic.js.map +0 -1
- package/dist/search/synonyms.d.ts +0 -2
- package/dist/search/synonyms.d.ts.map +0 -1
- package/dist/search/synonyms.js.map +0 -1
- package/dist/search/tokenize.d.ts +0 -39
- package/dist/search/tokenize.d.ts.map +0 -1
- package/dist/search/tokenize.js.map +0 -1
- package/dist/server.d.ts +0 -41
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/sync/git.d.ts +0 -36
- package/dist/sync/git.d.ts.map +0 -1
- package/dist/sync/git.js.map +0 -1
- package/dist/sync/materialize.d.ts +0 -35
- package/dist/sync/materialize.d.ts.map +0 -1
- package/dist/sync/materialize.js.map +0 -1
- package/dist/sync/ops.d.ts +0 -142
- package/dist/sync/ops.d.ts.map +0 -1
- package/dist/sync/ops.js.map +0 -1
- package/dist/sync/space.d.ts +0 -116
- package/dist/sync/space.d.ts.map +0 -1
- package/dist/sync/space.js.map +0 -1
- package/dist/types/index.d.ts +0 -346
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/utils/budget.d.ts +0 -44
- package/dist/utils/budget.d.ts.map +0 -1
- package/dist/utils/budget.js.map +0 -1
- package/dist/utils/fs.d.ts +0 -59
- package/dist/utils/fs.d.ts.map +0 -1
- package/dist/utils/fs.js.map +0 -1
- package/dist/utils/hash.d.ts +0 -20
- package/dist/utils/hash.d.ts.map +0 -1
- package/dist/utils/hash.js.map +0 -1
- package/dist/utils/ids.d.ts +0 -47
- package/dist/utils/ids.d.ts.map +0 -1
- package/dist/utils/ids.js.map +0 -1
- package/dist/utils/lock.d.ts +0 -11
- package/dist/utils/lock.d.ts.map +0 -1
- package/dist/utils/lock.js.map +0 -1
- package/dist/utils/transcripts.d.ts +0 -63
- package/dist/utils/transcripts.d.ts.map +0 -1
- package/dist/utils/transcripts.js.map +0 -1
- package/dist/version.d.ts +0 -26
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
package/dist/search/index.js
CHANGED
|
@@ -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 =
|
|
43
|
-
/**
|
|
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
|
|
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
|
-
|
|
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.
|
|
298
|
-
//
|
|
299
|
-
//
|
|
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
|
-
|
|
318
|
-
|
|
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; //
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
373
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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(),
|
|
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
|
-
|
|
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 +=
|
|
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:
|
|
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
|
-
|
|
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
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
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
|
|
738
|
-
//
|
|
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
|
|
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.
|
|
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 =>
|
|
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
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
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.
|
|
776
|
-
f.
|
|
777
|
-
|
|
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
|
-
|
|
789
|
-
f.also.set(a.entry_id || a.preview, a);
|
|
790
|
-
});
|
|
1169
|
+
}
|
|
791
1170
|
}
|
|
792
|
-
const
|
|
793
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
.
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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 (
|
|
848
|
-
|
|
849
|
-
let doc;
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
if (domain && doc.domain !== domain)
|
|
1298
|
+
const k = porNeurona.get(doc.neuron) || 0;
|
|
1299
|
+
if (k >= SEMANTIC_PER_NEURON)
|
|
859
1300
|
continue;
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
perChunk.
|
|
863
|
-
|
|
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 =
|
|
871
|
-
const semRank =
|
|
872
|
-
const semScore = new Map(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
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
|
|
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 &&
|
|
989
|
-
const strong = lexicalStrong || (elegido.semantic ?? 0) >=
|
|
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(
|
|
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(
|
|
1007
|
-
...(vida && neuron ? (() => { const s = staleView(this.stalenessOf(
|
|
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.
|
|
1018
|
-
|
|
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) =>
|
|
1029
|
-
|
|
1030
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|