crbro-memory 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +256 -465
- package/SECURITY.md +114 -12
- package/bin/crbro.mjs +539 -300
- 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 +139 -19
- package/dist/engine/brain.js +22 -6
- package/dist/engine/codexconfig.js +476 -0
- package/dist/engine/cortex.js +667 -232
- package/dist/engine/doctor.js +312 -14
- package/dist/engine/heat.js +81 -37
- package/dist/engine/hippocampus.js +18 -7
- 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 +721 -162
- package/dist/engine/maintenance.js +355 -87
- package/dist/engine/modinstall.js +98 -45
- package/dist/engine/prefrontal.js +29 -6
- package/dist/engine/quarantine.js +204 -0
- package/dist/engine/secrets.js +41 -5
- package/dist/engine/shelf.js +25 -3
- 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 +797 -176
- package/dist/search/semantic.js +208 -18
- package/dist/search/tokenize.js +44 -0
- package/dist/server.js +583 -373
- package/dist/sync/git.js +203 -72
- package/dist/sync/space.js +100 -25
- 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 +94 -7
- 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 +9 -5
- 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 -682
- 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/doctor.d.ts +0 -93
- package/dist/engine/doctor.d.ts.map +0 -1
- package/dist/engine/doctor.js.map +0 -1
- package/dist/engine/evidence.d.ts +0 -26
- package/dist/engine/evidence.d.ts.map +0 -1
- package/dist/engine/evidence.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 -64
- package/dist/engine/prefrontal.d.ts.map +0 -1
- package/dist/engine/prefrontal.js.map +0 -1
- package/dist/engine/projects.d.ts +0 -20
- package/dist/engine/projects.d.ts.map +0 -1
- package/dist/engine/projects.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 -155
- 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 -328
- package/dist/search/index.d.ts.map +0 -1
- package/dist/search/index.js.map +0 -1
- package/dist/search/quality.d.ts +0 -18
- package/dist/search/quality.d.ts.map +0 -1
- package/dist/search/quality.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 -36
- package/dist/sync/materialize.d.ts.map +0 -1
- package/dist/sync/materialize.js.map +0 -1
- package/dist/sync/ops.d.ts +0 -147
- package/dist/sync/ops.d.ts.map +0 -1
- package/dist/sync/ops.js.map +0 -1
- package/dist/sync/space.d.ts +0 -118
- package/dist/sync/space.d.ts.map +0 -1
- package/dist/sync/space.js.map +0 -1
- package/dist/types/index.d.ts +0 -369
- 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 -13
- 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)
|
|
@@ -597,9 +951,10 @@ class SearchEngine {
|
|
|
597
951
|
if (doc.kind === 'header')
|
|
598
952
|
return false; // dated by the neuron's birth, which says nothing about its content
|
|
599
953
|
const dia = String(doc.added || '').slice(0, 10);
|
|
954
|
+
// Counted per entry, not per part of a long one.
|
|
600
955
|
if (!dia) {
|
|
601
956
|
if (doc.id)
|
|
602
|
-
sinFecha.add(String(doc.id));
|
|
957
|
+
sinFecha.add(String(doc.eid ? `${doc.neuron}#${doc.eid}` : doc.id));
|
|
603
958
|
return false;
|
|
604
959
|
}
|
|
605
960
|
if (dia < desde)
|
|
@@ -630,13 +985,23 @@ class SearchEngine {
|
|
|
630
985
|
for (const [t, v] of pesoTermino)
|
|
631
986
|
pesoTermino.set(t, max > 0 ? v / max : 1);
|
|
632
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;
|
|
633
992
|
for (const { term, hits } of porTermino) {
|
|
634
|
-
|
|
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;
|
|
635
1001
|
const w = pesoTermino.get(term) ?? 1;
|
|
636
1002
|
for (const hit of hits) {
|
|
637
1003
|
const doc = hit.document;
|
|
638
1004
|
const key = doc.id;
|
|
639
|
-
const normalised = best > 0 ? (hit.score / best) * w : 0;
|
|
640
1005
|
// Session logs are searched, but never as neurons: they keep a list
|
|
641
1006
|
// of their own, so a long narrative cannot outrank the fact that
|
|
642
1007
|
// answers, and the neuron path below never sees them.
|
|
@@ -647,43 +1012,34 @@ class SearchEngine {
|
|
|
647
1012
|
continue; // a day log is not an entry kind
|
|
648
1013
|
if (desde && String(doc.added || '').slice(0, 10) < desde)
|
|
649
1014
|
continue;
|
|
1015
|
+
const normalisedSession = (hit.score / bestSession) * w;
|
|
650
1016
|
const prev = perSession.get(key);
|
|
651
1017
|
if (prev) {
|
|
652
|
-
prev.score +=
|
|
1018
|
+
prev.score += normalisedSession;
|
|
653
1019
|
prev.matched += 1;
|
|
654
1020
|
}
|
|
655
1021
|
else
|
|
656
|
-
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 });
|
|
657
1023
|
continue;
|
|
658
1024
|
}
|
|
659
1025
|
if (!admite(doc))
|
|
660
1026
|
continue;
|
|
1027
|
+
const normalised = best > 0 ? Math.min(1, hit.score / best) * w : 0;
|
|
661
1028
|
const existing = perChunk.get(key);
|
|
662
1029
|
if (existing) {
|
|
663
1030
|
existing.score += normalised;
|
|
664
1031
|
existing.matched += 1;
|
|
665
1032
|
}
|
|
666
1033
|
else {
|
|
667
|
-
perChunk.set(key, {
|
|
668
|
-
neuron: doc.neuron,
|
|
669
|
-
name: doc.name,
|
|
670
|
-
domain: doc.domain,
|
|
671
|
-
text: doc.text,
|
|
672
|
-
kind: doc.kind,
|
|
673
|
-
eid: doc.eid || '',
|
|
674
|
-
added: doc.added,
|
|
675
|
-
heat: doc.heat || 0,
|
|
676
|
-
score: normalised,
|
|
677
|
-
matched: 1,
|
|
678
|
-
});
|
|
1034
|
+
perChunk.set(key, { ...chunkFields(doc), score: normalised, matched: 1 });
|
|
679
1035
|
}
|
|
680
1036
|
}
|
|
681
1037
|
}
|
|
682
1038
|
// Coverage weighting: a chunk matching five of six terms beats a scrap
|
|
683
|
-
// matching one.
|
|
1039
|
+
// matching one. Then onto the 0–1 scale.
|
|
684
1040
|
for (const chunk of perChunk.values()) {
|
|
685
1041
|
const coverage = chunk.matched / terms.length;
|
|
686
|
-
chunk.score = chunk.score * Math.pow(coverage, COVERAGE_EXPONENT);
|
|
1042
|
+
chunk.score = chunk.score * Math.pow(coverage, COVERAGE_EXPONENT) / ideal;
|
|
687
1043
|
}
|
|
688
1044
|
// Recency: a few percent on the lexical score, so near-ties fall to the
|
|
689
1045
|
// newer telling. Before fusion on purpose — see RECENCY_WEIGHT_DEFAULT.
|
|
@@ -697,9 +1053,18 @@ class SearchEngine {
|
|
|
697
1053
|
}
|
|
698
1054
|
// Semantic fusion — only when the opt-in layer is installed, enabled and
|
|
699
1055
|
// holds vectors. Off, this is a no-op and the scores above stand.
|
|
1056
|
+
let semantic;
|
|
700
1057
|
if (this.semantic && this.semantic.ready() && this.semantic.count() > 0) {
|
|
701
|
-
|
|
702
|
-
|
|
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;
|
|
703
1068
|
if (options?.stats && desde)
|
|
704
1069
|
options.stats.undated_skipped = sinFecha.size;
|
|
705
1070
|
if (options?.stats) {
|
|
@@ -724,14 +1089,23 @@ class SearchEngine {
|
|
|
724
1089
|
// shown five results cannot tell "five matched" from "five of forty".
|
|
725
1090
|
if (options?.stats)
|
|
726
1091
|
options.stats.matched_neurons = byNeuron.size;
|
|
727
|
-
return this.materializeResults([...byNeuron.values()], limit, terms.length, options?.preferNeurons);
|
|
1092
|
+
return this.materializeResults([...byNeuron.values()], limit, terms.length, options?.preferNeurons, options?.stats, unidad);
|
|
728
1093
|
}
|
|
729
1094
|
/**
|
|
730
|
-
* Several phrasings of one question, searched together and fused
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
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().
|
|
735
1109
|
*/
|
|
736
1110
|
async searchMany(queries, options = {}) {
|
|
737
1111
|
const distintas = [...new Set(queries.map(q => (q || '').trim()).filter(Boolean))];
|
|
@@ -745,81 +1119,98 @@ class SearchEngine {
|
|
|
745
1119
|
if (options.stats) {
|
|
746
1120
|
// Session hits: a day that several phrasings point at ranks first, then
|
|
747
1121
|
// by accumulated coverage (matched over query terms, so 2/2 beats 3/8).
|
|
748
|
-
// The row kept is
|
|
749
|
-
//
|
|
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.
|
|
750
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;
|
|
751
1127
|
const fusion = new Map();
|
|
752
1128
|
for (const s of porConsulta) {
|
|
753
1129
|
for (const m of s.sessions || []) {
|
|
754
1130
|
const f = fusion.get(m.session_id);
|
|
755
1131
|
if (!f) {
|
|
756
|
-
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 });
|
|
757
1133
|
continue;
|
|
758
1134
|
}
|
|
759
1135
|
f.cobertura += cobertura(m);
|
|
760
1136
|
f.frases += 1;
|
|
761
|
-
f.
|
|
762
|
-
if (cobertura(m) > cobertura(f.row) || (cobertura(m) === cobertura(f.row) && m.matched_terms > f.row.matched_terms))
|
|
763
|
-
f.row = m;
|
|
1137
|
+
f.row = mejorFila(f.row, m);
|
|
764
1138
|
}
|
|
765
1139
|
}
|
|
766
1140
|
options.stats.sessions = [...fusion.values()]
|
|
767
1141
|
.sort((a, b) => b.frases - a.frases || b.cobertura - a.cobertura || (a.row.session_id < b.row.session_id ? 1 : -1))
|
|
768
1142
|
.slice(0, SESSION_MATCHES)
|
|
769
|
-
.map(f =>
|
|
1143
|
+
.map(f => f.row);
|
|
770
1144
|
// Each phrasing counted its own days: the union is at least the largest count.
|
|
771
1145
|
options.stats.sessions_total = Math.max(fusion.size, ...porConsulta.map(s => s.sessions_total || 0));
|
|
772
1146
|
if (options.since)
|
|
773
1147
|
options.stats.undated_skipped = Math.max(0, ...porConsulta.map(s => s.undated_skipped || 0));
|
|
774
|
-
|
|
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}`;
|
|
775
1158
|
const fused = new Map();
|
|
776
|
-
const alsoOf = (r) => alsoLine(r.matching_content, r.matched_kind || '', r.matched_added || '', r.entry_id, r);
|
|
777
1159
|
for (const lista of listas) {
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
f = { score: gain, best: r, bestRank: i, strong: r.confidence === 'strong', also: new Map() };
|
|
783
|
-
fused.set(r.neuron_id, f);
|
|
784
|
-
}
|
|
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 });
|
|
785
1164
|
else {
|
|
786
|
-
f.
|
|
787
|
-
f.
|
|
788
|
-
|
|
789
|
-
// The other list's answer becomes an also_matched line of the winner.
|
|
790
|
-
if (f.best.matching_content !== r.matching_content)
|
|
791
|
-
f.also.set(f.best.matching_content, alsoOf(f.best));
|
|
792
|
-
f.best = r;
|
|
793
|
-
f.bestRank = i;
|
|
794
|
-
}
|
|
795
|
-
else if (r.matching_content !== f.best.matching_content) {
|
|
796
|
-
f.also.set(r.matching_content, alsoOf(r));
|
|
797
|
-
}
|
|
1165
|
+
f.rows.push(r);
|
|
1166
|
+
f.max = Math.max(f.max, r.relevance_score);
|
|
1167
|
+
f.sum += r.relevance_score;
|
|
798
1168
|
}
|
|
799
|
-
|
|
800
|
-
f.also.set(a.entry_id || a.preview, a);
|
|
801
|
-
});
|
|
1169
|
+
}
|
|
802
1170
|
}
|
|
803
|
-
const
|
|
1171
|
+
const n = listas.length;
|
|
804
1172
|
const preferred = new Set(options.preferNeurons || []);
|
|
805
|
-
|
|
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) {
|
|
806
1176
|
options.stats.matched_neurons = fused.size;
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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);
|
|
819
1209
|
else
|
|
820
1210
|
delete r.also_matched;
|
|
821
1211
|
return r;
|
|
822
1212
|
});
|
|
1213
|
+
return rows;
|
|
823
1214
|
}
|
|
824
1215
|
/**
|
|
825
1216
|
* searchMany plus the one number the list alone cannot carry: how many
|
|
@@ -834,6 +1225,8 @@ class SearchEngine {
|
|
|
834
1225
|
results, matched_neurons: stats.matched_neurons ?? results.length, sessions,
|
|
835
1226
|
sessions_total: stats.sessions_total ?? sessions.length,
|
|
836
1227
|
...(stats.undated_skipped !== undefined ? { undated_skipped: stats.undated_skipped } : {}),
|
|
1228
|
+
...(stats.no_answer ? { no_answer: true } : {}),
|
|
1229
|
+
...(stats.semantic ? { semantic: stats.semantic } : {}),
|
|
837
1230
|
};
|
|
838
1231
|
}
|
|
839
1232
|
/**
|
|
@@ -842,46 +1235,80 @@ class SearchEngine {
|
|
|
842
1235
|
* ranked first by both scores 1.0; one seen by a single list scores about
|
|
843
1236
|
* half. Vector-only candidates below the cosine floor are dropped, and the
|
|
844
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.
|
|
845
1247
|
*/
|
|
846
1248
|
async fuseSemantic(query, perChunk, domain, admite = () => true) {
|
|
847
1249
|
let sem;
|
|
848
1250
|
try {
|
|
849
|
-
|
|
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;
|
|
850
1266
|
}
|
|
851
1267
|
catch {
|
|
852
|
-
return; // the semantic layer never breaks recall
|
|
1268
|
+
return undefined; // the semantic layer never breaks recall
|
|
853
1269
|
}
|
|
854
1270
|
const floor = semanticFloor();
|
|
1271
|
+
// A vector whose chunk left the index (an orphan) never takes a seat.
|
|
855
1272
|
sem = sem.filter(h => h.score >= floor);
|
|
856
1273
|
if (sem.length === 0)
|
|
857
|
-
return;
|
|
1274
|
+
return 'used';
|
|
1275
|
+
this.maybeCalibrate();
|
|
1276
|
+
const elegidos = [];
|
|
1277
|
+
const porNeurona = new Map();
|
|
858
1278
|
for (const h of sem) {
|
|
859
|
-
if (
|
|
860
|
-
|
|
861
|
-
let doc;
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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;
|
|
867
1297
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
if (domain && doc.domain !== domain)
|
|
1298
|
+
const k = porNeurona.get(doc.neuron) || 0;
|
|
1299
|
+
if (k >= SEMANTIC_PER_NEURON)
|
|
871
1300
|
continue;
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
perChunk.
|
|
875
|
-
|
|
876
|
-
kind: doc.kind, eid: doc.eid || '', added: doc.added, heat: doc.heat || 0, score: 0, matched: 0,
|
|
877
|
-
});
|
|
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 });
|
|
878
1305
|
}
|
|
879
1306
|
const lex = [...perChunk.entries()]
|
|
880
1307
|
.filter(([, c]) => c.matched > 0)
|
|
881
1308
|
.sort((a, b) => b[1].score - a[1].score);
|
|
882
|
-
const lexRank =
|
|
883
|
-
const semRank =
|
|
884
|
-
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]));
|
|
885
1312
|
const top = 2 / (RRF_K + 1);
|
|
886
1313
|
for (const [id, chunk] of perChunk) {
|
|
887
1314
|
const l = lexRank.get(id);
|
|
@@ -890,6 +1317,7 @@ class SearchEngine {
|
|
|
890
1317
|
if (s !== undefined)
|
|
891
1318
|
chunk.semantic = semScore.get(id);
|
|
892
1319
|
}
|
|
1320
|
+
return 'used';
|
|
893
1321
|
}
|
|
894
1322
|
/** Embed the chunks inserted since the last pass. Never throws. */
|
|
895
1323
|
async flushEmbeddings() {
|
|
@@ -906,6 +1334,80 @@ class SearchEngine {
|
|
|
906
1334
|
// Model missing or failing: recall stays lexical, indexing is unharmed.
|
|
907
1335
|
}
|
|
908
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; }
|
|
909
1411
|
/**
|
|
910
1412
|
* Turn per-neuron chunk lists into results, validating each chunk against
|
|
911
1413
|
* the neuron on disk before serving it.
|
|
@@ -919,11 +1421,15 @@ class SearchEngine {
|
|
|
919
1421
|
* fall back to its next valid chunk instead. Stale neurons are quietly
|
|
920
1422
|
* re-indexed; chunks of deleted neurons are removed on the spot.
|
|
921
1423
|
*/
|
|
922
|
-
async materializeResults(porNeurona, limit, queryTermCount, preferNeurons) {
|
|
1424
|
+
async materializeResults(porNeurona, limit, queryTermCount, preferNeurons, stats, breadthUnit = 1) {
|
|
923
1425
|
// Only the plausible head pays the neuron reads: nothing below it could
|
|
924
|
-
// reach the answer even if everything above dropped out.
|
|
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.
|
|
925
1430
|
const preferred = new Set(preferNeurons || []);
|
|
926
|
-
|
|
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));
|
|
927
1433
|
const candidatos = porNeurona.slice(0, Math.max(limit * 3, limit + 8));
|
|
928
1434
|
const resultados = [];
|
|
929
1435
|
// Shelf life (staleness): judged here, where the neuron file is already
|
|
@@ -951,6 +1457,9 @@ class SearchEngine {
|
|
|
951
1457
|
equipo = { espacios };
|
|
952
1458
|
return equipo;
|
|
953
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;
|
|
954
1463
|
for (const chunks of candidatos) {
|
|
955
1464
|
const neuronId = chunks[0].neuron;
|
|
956
1465
|
let neuron = null;
|
|
@@ -970,7 +1479,9 @@ class SearchEngine {
|
|
|
970
1479
|
const vivos = [];
|
|
971
1480
|
let huboRancio = false;
|
|
972
1481
|
for (const chunk of chunks) {
|
|
973
|
-
|
|
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)))
|
|
974
1485
|
vivos.push(chunk);
|
|
975
1486
|
else
|
|
976
1487
|
huboRancio = true;
|
|
@@ -985,67 +1496,107 @@ class SearchEngine {
|
|
|
985
1496
|
// the neuron, but it never speaks for it while a live content chunk
|
|
986
1497
|
// matched too. Measured on the blind benchmark, letting it win cost 3 of
|
|
987
1498
|
// the 13 misses — this rule alone moves recall@1 from 56% to 60%.
|
|
988
|
-
|
|
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'));
|
|
989
1501
|
const elegido = contenido[0] || vivos[0];
|
|
990
|
-
// Breadth says "this neuron is about the question"
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
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.
|
|
994
1509
|
const esDigest = !!neuron && (neuron.tags || []).includes('digest');
|
|
995
|
-
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;
|
|
996
1512
|
// A keyword engine answers almost any question with something. The
|
|
997
1513
|
// label says how much of the question the answer actually covers, so
|
|
998
1514
|
// the reader can tell a hit from a coincidence.
|
|
1515
|
+
const coverage = queryTermCount > 0 ? elegido.matched / queryTermCount : 0;
|
|
999
1516
|
const lexicalStrong = queryTermCount <= 1
|
|
1000
1517
|
? elegido.matched >= 1
|
|
1001
|
-
: elegido.matched >= 2 &&
|
|
1002
|
-
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;
|
|
1003
1526
|
resultados.push({
|
|
1004
1527
|
r: {
|
|
1005
1528
|
neuron_id: elegido.neuron,
|
|
1006
1529
|
name: elegido.name,
|
|
1007
1530
|
domain: elegido.domain,
|
|
1008
|
-
relevance_score: Math.round(
|
|
1531
|
+
relevance_score: Math.round(relevancia * 1000) / 1000,
|
|
1009
1532
|
matching_content: elegido.text,
|
|
1010
1533
|
matched_kind: elegido.kind,
|
|
1011
1534
|
matched_added: elegido.added || '',
|
|
1012
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 } } : {}),
|
|
1013
1537
|
heat: elegido.heat,
|
|
1014
1538
|
matched_terms: elegido.matched,
|
|
1015
1539
|
query_terms: queryTermCount,
|
|
1016
1540
|
confidence: strong ? 'strong' : 'weak',
|
|
1017
1541
|
...(elegido.semantic !== undefined ? { semantic_score: elegido.semantic } : {}),
|
|
1018
1542
|
...(neuron?.map?.text ? { has_map: true } : {}),
|
|
1019
|
-
...(neuron ? await this.originOf(
|
|
1020
|
-
...(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 } : {}; })() : {}),
|
|
1021
1545
|
},
|
|
1022
1546
|
extra: contenido.filter(c => c !== elegido).slice(0, 2),
|
|
1023
1547
|
neuron,
|
|
1548
|
+
orden: relevancia * lift(elegido.neuron),
|
|
1549
|
+
evidence,
|
|
1024
1550
|
});
|
|
1025
1551
|
}
|
|
1026
1552
|
// Synapses do not move this ranking, on purpose: lifting a neuron whose
|
|
1027
1553
|
// neighbour also scored was measured on 633 queries over the reference
|
|
1028
1554
|
// brain and changed recall@3 by 0.15 points at most. Pre-registration and
|
|
1029
1555
|
// numbers in benchmarks/synapse-activation/; mechanism in commit 69917ce.
|
|
1030
|
-
resultados.sort((a, b) =>
|
|
1031
|
-
|
|
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);
|
|
1032
1566
|
// The neuron's next best lines, for the head of the list only: the line
|
|
1033
1567
|
// you need is not always the one that scored highest. Measured, it lifts
|
|
1034
1568
|
// fact-level recall@3 from 73% to 79%, and it costs tokens only where
|
|
1035
1569
|
// they can still change the answer.
|
|
1036
|
-
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)) {
|
|
1037
1571
|
if (extra.length > 0) {
|
|
1038
1572
|
// Previews, not bodies: the also lines were 46% of a recall's cost as
|
|
1039
1573
|
// full text. The id reads any of them whole. Each line carries its own
|
|
1040
1574
|
// origin: a teammate's line next to an own winner is still a teammate's.
|
|
1041
|
-
r.also_matched = await Promise.all(extra.map(async (c) =>
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
+
}));
|
|
1045
1582
|
}
|
|
1046
1583
|
}
|
|
1047
1584
|
return top.map(x => x.r);
|
|
1048
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
|
+
}
|
|
1049
1600
|
/**
|
|
1050
1601
|
* Where the matching entry came from — but only when it is NOT the user's
|
|
1051
1602
|
* own, so the common case costs nothing: no field, no token. Read from what
|
|
@@ -1221,7 +1772,7 @@ class SearchEngine {
|
|
|
1221
1772
|
continue;
|
|
1222
1773
|
if (isRetired(fact))
|
|
1223
1774
|
continue;
|
|
1224
|
-
await this.
|
|
1775
|
+
await this.putEntry({
|
|
1225
1776
|
...base,
|
|
1226
1777
|
id: (0, hash_js_1.chunkId)(neuron.id, fact.text),
|
|
1227
1778
|
text: fact.text,
|
|
@@ -1239,7 +1790,7 @@ class SearchEngine {
|
|
|
1239
1790
|
const text = decision.rationale
|
|
1240
1791
|
? `${decision.text} — ${decision.rationale}`
|
|
1241
1792
|
: decision.text;
|
|
1242
|
-
await this.
|
|
1793
|
+
await this.putEntry({
|
|
1243
1794
|
...base,
|
|
1244
1795
|
id: (0, hash_js_1.chunkId)(neuron.id, text),
|
|
1245
1796
|
text,
|
|
@@ -1258,7 +1809,7 @@ class SearchEngine {
|
|
|
1258
1809
|
continue;
|
|
1259
1810
|
if (entryRetired(neuron, pattern))
|
|
1260
1811
|
continue;
|
|
1261
|
-
await this.
|
|
1812
|
+
await this.putEntry({
|
|
1262
1813
|
...base,
|
|
1263
1814
|
id: (0, hash_js_1.chunkId)(neuron.id, pattern),
|
|
1264
1815
|
text: pattern,
|
|
@@ -1270,7 +1821,7 @@ class SearchEngine {
|
|
|
1270
1821
|
for (const pref of neuron.preferences || []) {
|
|
1271
1822
|
if (!pref)
|
|
1272
1823
|
continue;
|
|
1273
|
-
await this.
|
|
1824
|
+
await this.putEntry({
|
|
1274
1825
|
...base,
|
|
1275
1826
|
id: (0, hash_js_1.chunkId)(neuron.id, pref),
|
|
1276
1827
|
text: pref,
|
|
@@ -1284,7 +1835,7 @@ class SearchEngine {
|
|
|
1284
1835
|
continue;
|
|
1285
1836
|
if (entryRetired(neuron, err))
|
|
1286
1837
|
continue;
|
|
1287
|
-
await this.
|
|
1838
|
+
await this.putEntry({
|
|
1288
1839
|
...base,
|
|
1289
1840
|
id: (0, hash_js_1.chunkId)(neuron.id, `error:${err}`),
|
|
1290
1841
|
text: err,
|
|
@@ -1298,7 +1849,7 @@ class SearchEngine {
|
|
|
1298
1849
|
continue;
|
|
1299
1850
|
if (entryRetired(neuron, debt))
|
|
1300
1851
|
continue;
|
|
1301
|
-
await this.
|
|
1852
|
+
await this.putEntry({
|
|
1302
1853
|
...base,
|
|
1303
1854
|
id: (0, hash_js_1.chunkId)(neuron.id, `debt:${debt}`),
|
|
1304
1855
|
text: debt,
|
|
@@ -1308,7 +1859,7 @@ class SearchEngine {
|
|
|
1308
1859
|
});
|
|
1309
1860
|
}
|
|
1310
1861
|
if (neuron.map && neuron.map.text) {
|
|
1311
|
-
await this.
|
|
1862
|
+
await this.putEntry({
|
|
1312
1863
|
...base,
|
|
1313
1864
|
id: (0, hash_js_1.chunkId)(neuron.id, `map:${neuron.map.text}`),
|
|
1314
1865
|
text: neuron.map.text,
|
|
@@ -1340,6 +1891,7 @@ class SearchEngine {
|
|
|
1340
1891
|
if (!this.db)
|
|
1341
1892
|
return [];
|
|
1342
1893
|
await this.settled();
|
|
1894
|
+
await this.catchUp().catch(() => undefined);
|
|
1343
1895
|
// Orama keeps "crbro-memory" and "crbro_memory" as single tokens and
|
|
1344
1896
|
// matches by prefix, so the project's first word finds every spelling;
|
|
1345
1897
|
// which field actually names the project is decided below, in our terms.
|
|
@@ -1412,6 +1964,7 @@ class SearchEngine {
|
|
|
1412
1964
|
await this.removeNeuronChunks(`session:${log.session_id}`);
|
|
1413
1965
|
if (log.summary)
|
|
1414
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)));
|
|
1415
1968
|
this.markDirty();
|
|
1416
1969
|
}
|
|
1417
1970
|
async insertSessionChunks(log) {
|
|
@@ -1432,6 +1985,22 @@ class SearchEngine {
|
|
|
1432
1985
|
});
|
|
1433
1986
|
}
|
|
1434
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
|
+
}
|
|
1435
2004
|
async put(doc) {
|
|
1436
2005
|
try {
|
|
1437
2006
|
await (0, orama_1.insert)(this.db, doc);
|
|
@@ -1557,6 +2126,58 @@ class SearchEngine {
|
|
|
1557
2126
|
}
|
|
1558
2127
|
}
|
|
1559
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
|
+
}
|
|
1560
2181
|
/** A fact that has been superseded or retracted must not surface in recall. */
|
|
1561
2182
|
/**
|
|
1562
2183
|
* The project a `crbro_boot project=` value names: the last segment of a
|