@gamaze/hicortex 0.15.2 → 0.16.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/README.md +3 -1
- package/dist/consolidate.d.ts +4 -3
- package/dist/consolidate.js +33 -12
- package/dist/db.js +83 -3
- package/dist/eval/recall-sweep.d.ts +82 -0
- package/dist/eval/recall-sweep.js +1001 -0
- package/dist/index.js +13 -4
- package/dist/init.d.ts +1 -1
- package/dist/init.js +53 -100
- package/dist/llm.d.ts +3 -1
- package/dist/llm.js +18 -4
- package/dist/mcp-server.js +50 -26
- package/dist/memory-instructions.js +2 -2
- package/dist/recall-hook-cli.d.ts +1 -1
- package/dist/recall-hook-cli.js +7 -2
- package/dist/recall-index.d.ts +37 -3
- package/dist/recall-index.js +62 -15
- package/dist/recall-registry.d.ts +39 -1
- package/dist/recall-registry.js +52 -1
- package/dist/retrieval.d.ts +88 -1
- package/dist/retrieval.js +204 -26
- package/dist/schema-prototypes.d.ts +15 -0
- package/dist/schema-prototypes.js +24 -0
- package/dist/storage.d.ts +56 -3
- package/dist/storage.js +93 -17
- package/dist/types.d.ts +4 -0
- package/dist/uninstall.js +18 -4
- package/hermes-plugin/hicortex/client.py +8 -4
- package/hermes-plugin/hicortex/config.py +11 -0
- package/hermes-plugin/hicortex/provider.py +11 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/skills/hicortex-activate/SKILL.md +0 -53
- package/skills/hicortex-learn/SKILL.md +0 -40
package/dist/retrieval.js
CHANGED
|
@@ -52,12 +52,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
52
52
|
};
|
|
53
53
|
})();
|
|
54
54
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
-
exports.DEFAULT_DECAY_HALF_LIFE_DAYS = void 0;
|
|
55
|
+
exports.SESSION_INTENT_ALPHA = exports.DEFAULT_DECAY_HALF_LIFE_DAYS = void 0;
|
|
56
56
|
exports.decayConstantForHalfLife = decayConstantForHalfLife;
|
|
57
57
|
exports.configureDecay = configureDecay;
|
|
58
58
|
exports.configureRecall = configureRecall;
|
|
59
59
|
exports.configureScoring = configureScoring;
|
|
60
60
|
exports.getScoringWeights = getScoringWeights;
|
|
61
|
+
exports.configureSessionIntent = configureSessionIntent;
|
|
62
|
+
exports.getSessionIntent = getSessionIntent;
|
|
63
|
+
exports.blendQueryVector = blendQueryVector;
|
|
61
64
|
exports.findSupersededIds = findSupersededIds;
|
|
62
65
|
exports.l2ToCosine = l2ToCosine;
|
|
63
66
|
exports.effectiveStrength = effectiveStrength;
|
|
@@ -65,6 +68,7 @@ exports.computeScore = computeScore;
|
|
|
65
68
|
exports.retrieve = retrieve;
|
|
66
69
|
exports.searchRecent = searchRecent;
|
|
67
70
|
const storage = __importStar(require("./storage.js"));
|
|
71
|
+
const schema_prototypes_js_1 = require("./schema-prototypes.js");
|
|
68
72
|
/** Default decay half-life (days) at importance 0.5. #192: was 0.0005/h
|
|
69
73
|
* (~115-day half-life at base 0.5) — aggressive enough to bury the long tail
|
|
70
74
|
* in ranking. Long-term remembering is the product; time preference stays,
|
|
@@ -124,19 +128,45 @@ const SCORING_DEFAULTS = {
|
|
|
124
128
|
freshnessBoostDays: 7,
|
|
125
129
|
freshnessBoostWeight: 0.15,
|
|
126
130
|
supersededDemotion: 0.5,
|
|
131
|
+
projectAffinity: 0.15,
|
|
132
|
+
domainAffinity: 0.15,
|
|
133
|
+
// #205 defaults: rrfK + rrfCompositeWeight match the pre-#205 hardcoded
|
|
134
|
+
// values (60 and 0.8) so the no-config path is byte-identical to 0.15.3
|
|
135
|
+
// except for the FTS per-list weight (1.0 → 0.5) — the one deliberate
|
|
136
|
+
// nudge toward vector that the recall-sweep eval gates. The eval showed
|
|
137
|
+
// 0.7 was too timid (Q4 marine contamination persisted) and 0.5 is the
|
|
138
|
+
// bisection point where BM25F + composite-affinity finally flip the
|
|
139
|
+
// token-exact marine body match below the same-scope hardware field
|
|
140
|
+
// (Q4 ON contamination 0.20 → 0.00). 0.5 is still "conservative" — FTS
|
|
141
|
+
// contributes half its RRF share, enough that pure-keyword queries (the
|
|
142
|
+
// focused-family "login/CORS/webhook" turns) keep recall@5 = 1.0.
|
|
143
|
+
rrfK: 60,
|
|
144
|
+
rrfCompositeWeight: 0.8,
|
|
145
|
+
rrfFtsWeight: 0.5,
|
|
146
|
+
rrfVectorWeight: 1.0,
|
|
127
147
|
};
|
|
128
148
|
let scoringWeights = { ...SCORING_DEFAULTS };
|
|
129
149
|
/**
|
|
130
150
|
* Configure scoring weights + ranking knobs from config. Called at boot by the
|
|
131
151
|
* server and the nightly (alongside configureDecay/configureRecall) so
|
|
132
152
|
* retrieval and consolidation rank identically. Invalid/absent values keep the
|
|
133
|
-
* shipped default per key. Returns the resolved set for logging/tests.
|
|
153
|
+
* shipped default per key. Returns the resolved set for logging/tests. Also
|
|
154
|
+
* pushes the #205 BM25F field weights into storage (storage.configureBm25Fts)
|
|
155
|
+
* so searchFts ranks with the same config — BM25F weights live in storage.ts
|
|
156
|
+
* (next to the FTS column declaration they mirror) but are read here from the
|
|
157
|
+
* SAME config object for one-place tuning.
|
|
134
158
|
*/
|
|
135
159
|
function configureScoring(config) {
|
|
136
160
|
const num = (key, dflt, min, max) => {
|
|
137
161
|
const v = Number(config?.[key]);
|
|
138
162
|
return Number.isFinite(v) && v >= min && v <= max ? v : dflt;
|
|
139
163
|
};
|
|
164
|
+
// #205 BM25F weights use a [0, ∞) range (no upper bound — a field can dominate
|
|
165
|
+
// if the operator wills it; 0 drops the field entirely). Invalid ⇒ default.
|
|
166
|
+
const numW = (key, dflt) => {
|
|
167
|
+
const v = Number(config?.[key]);
|
|
168
|
+
return Number.isFinite(v) && v >= 0 ? v : dflt;
|
|
169
|
+
};
|
|
140
170
|
scoringWeights = {
|
|
141
171
|
similarity: num("scoreSimilarityWeight", SCORING_DEFAULTS.similarity, 0, 1),
|
|
142
172
|
strength: num("scoreStrengthWeight", SCORING_DEFAULTS.strength, 0, 1),
|
|
@@ -145,13 +175,80 @@ function configureScoring(config) {
|
|
|
145
175
|
freshnessBoostDays: num("freshnessBoostDays", SCORING_DEFAULTS.freshnessBoostDays, 0, 365),
|
|
146
176
|
freshnessBoostWeight: num("freshnessBoostWeight", SCORING_DEFAULTS.freshnessBoostWeight, 0, 1),
|
|
147
177
|
supersededDemotion: num("supersededDemotion", SCORING_DEFAULTS.supersededDemotion, 0, 1),
|
|
178
|
+
projectAffinity: num("projectAffinityWeight", SCORING_DEFAULTS.projectAffinity, 0, 1),
|
|
179
|
+
domainAffinity: num("domainAffinityWeight", SCORING_DEFAULTS.domainAffinity, 0, 1),
|
|
180
|
+
rrfK: numW("rrfK", SCORING_DEFAULTS.rrfK),
|
|
181
|
+
rrfCompositeWeight: num("rrfCompositeWeight", SCORING_DEFAULTS.rrfCompositeWeight, 0, 1),
|
|
182
|
+
rrfFtsWeight: numW("rrfFtsWeight", SCORING_DEFAULTS.rrfFtsWeight),
|
|
183
|
+
rrfVectorWeight: numW("rrfVectorWeight", SCORING_DEFAULTS.rrfVectorWeight),
|
|
148
184
|
};
|
|
185
|
+
// #205: push BM25F field weights into storage so searchFts uses them. Same
|
|
186
|
+
// config object, one tuning surface; storage owns the module-level mirror
|
|
187
|
+
// next to the FTS column declaration (the positional order matters there).
|
|
188
|
+
storage.configureBm25Fts({
|
|
189
|
+
bm25WeightBody: numW("bm25WeightBody", 1.0),
|
|
190
|
+
bm25WeightProject: numW("bm25WeightProject", 2.0),
|
|
191
|
+
bm25WeightDomain: numW("bm25WeightDomain", 2.0),
|
|
192
|
+
});
|
|
149
193
|
return { ...scoringWeights };
|
|
150
194
|
}
|
|
151
195
|
/** Current resolved weights (tests + status output). */
|
|
152
196
|
function getScoringWeights() {
|
|
153
197
|
return { ...scoringWeights };
|
|
154
198
|
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Session-intent keying (#192, 0.15.3). ONE config knob:
|
|
201
|
+
// sessionIntentWeight 0.33 blend weight of the rolling centroid in the
|
|
202
|
+
// search vector: query = (1-w)·prompt + w·centroid.
|
|
203
|
+
// 0 = DISABLED (pure prompt, the kill-switch —
|
|
204
|
+
// current behavior). Range [0, 1].
|
|
205
|
+
//
|
|
206
|
+
// The EMA rate α is a shipped constant (SESSION_INTENT_ALPHA, 0.4), not a
|
|
207
|
+
// second knob — owner directive 0.15.3: one knob is enough to tune/disable;
|
|
208
|
+
// exposing α was speculative generality.
|
|
209
|
+
//
|
|
210
|
+
// The centroid itself lives on SessionRecallRegistry; retrieval only needs to
|
|
211
|
+
// ACCEPT a pre-blended query vector (options.queryEmbedding) so the recall
|
|
212
|
+
// closure can do the one-embed-per-recall + blend without retrieve()
|
|
213
|
+
// re-embedding. /search and other unblended callers omit queryEmbedding and
|
|
214
|
+
// get pure-prompt behavior unchanged.
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
/** EMA rate for the session-intent centroid: centroid_new = (1-α)·old + α·prompt. */
|
|
217
|
+
exports.SESSION_INTENT_ALPHA = 0.4;
|
|
218
|
+
const SESSION_INTENT_DEFAULT_WEIGHT = 0.33;
|
|
219
|
+
let sessionIntentWeight = SESSION_INTENT_DEFAULT_WEIGHT;
|
|
220
|
+
/**
|
|
221
|
+
* Configure session-intent keying from config. Called at server boot next to
|
|
222
|
+
* configureScoring (the nightly does no recall, so it does not need this).
|
|
223
|
+
* Reads only `sessionIntentWeight` ([0,1]; 0 = disabled). Invalid/out-of-range
|
|
224
|
+
* values keep the shipped default. Returns `{ weight, alpha }` — alpha is the
|
|
225
|
+
* fixed constant, surfaced so the recall closure passes it to the registry in
|
|
226
|
+
* one call.
|
|
227
|
+
*/
|
|
228
|
+
function configureSessionIntent(config) {
|
|
229
|
+
const v = Number(config?.sessionIntentWeight);
|
|
230
|
+
sessionIntentWeight =
|
|
231
|
+
Number.isFinite(v) && v >= 0 && v <= 1 ? v : SESSION_INTENT_DEFAULT_WEIGHT;
|
|
232
|
+
return { weight: sessionIntentWeight, alpha: exports.SESSION_INTENT_ALPHA };
|
|
233
|
+
}
|
|
234
|
+
/** Current resolved session-intent weight + the shipped alpha (closure + tests). */
|
|
235
|
+
function getSessionIntent() {
|
|
236
|
+
return { weight: sessionIntentWeight, alpha: exports.SESSION_INTENT_ALPHA };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Blend the prompt embedding with the session-intent centroid for the vector
|
|
240
|
+
* search: `query = l2Normalize((1-w)·prompt + w·centroid)`. Returns the prompt
|
|
241
|
+
* UNCHANGED when `centroid` is undefined (first turn — no behavior change) or
|
|
242
|
+
* `weight` is 0 (the kill-switch — pure prompt). Extracted from the
|
|
243
|
+
* /recall-index closure (mcp-server.ts) so the exact blend decision is
|
|
244
|
+
* unit-testable directly, locking the ternary against a refactor without a
|
|
245
|
+
* closure-integration harness.
|
|
246
|
+
*/
|
|
247
|
+
function blendQueryVector(promptEmb, centroid, weight) {
|
|
248
|
+
return centroid && weight > 0
|
|
249
|
+
? (0, schema_prototypes_js_1.l2Normalize)((0, schema_prototypes_js_1.weightedAdd)(promptEmb, 1 - weight, centroid, weight))
|
|
250
|
+
: promptEmb;
|
|
251
|
+
}
|
|
155
252
|
/**
|
|
156
253
|
* Ids among `candidateIds` that have been superseded by a later memory — i.e.
|
|
157
254
|
* they are the SOURCE of a `superseded_by` link (stageSupersession links
|
|
@@ -176,7 +273,12 @@ function findSupersededIds(db, candidateIds) {
|
|
|
176
273
|
* these candidates to cosine 0.875, outranking most true vector matches.
|
|
177
274
|
*/
|
|
178
275
|
const DEFAULT_GRAPH_DISTANCE = 1.0;
|
|
179
|
-
|
|
276
|
+
// RRF_K is no longer a module constant (#205): it lives in scoringWeights.rrfK
|
|
277
|
+
// (default 60, the pre-#205 hardcoded value) and is read at every retrieve()
|
|
278
|
+
// call so config changes apply without a restart. The DEFAULT_RRF_K here is a
|
|
279
|
+
// fallback for reciprocalRankFusion's optional k argument (tests + the rare
|
|
280
|
+
// non-retrieve caller), NOT the production path.
|
|
281
|
+
const DEFAULT_RRF_K = 60;
|
|
180
282
|
/**
|
|
181
283
|
* Convert an L2 distance (as returned by sqlite-vec's vec0 `distance`) to
|
|
182
284
|
* cosine similarity. Valid because our embeddings are L2-normalized
|
|
@@ -228,10 +330,6 @@ function effectiveStrength(baseStrength, lastAccessed, now, options) {
|
|
|
228
330
|
const floor = baseStrength * importance * 0.1;
|
|
229
331
|
return floor + (baseStrength - floor) * Math.pow(decayRate, hours);
|
|
230
332
|
}
|
|
231
|
-
/**
|
|
232
|
-
* Return a composite relevance score in [0, 1] for a candidate memory.
|
|
233
|
-
* Exported for exact-value tests of the similarity component (#145).
|
|
234
|
-
*/
|
|
235
333
|
function computeScore(memory, distance, connectionCount, maxConnections, now, options) {
|
|
236
334
|
// TRUE cosine similarity (#145). The old `1 − distance` compressed real
|
|
237
335
|
// cosines (cos 0.8 scored 0.37) and the 0-clamp at that scale flattened
|
|
@@ -270,6 +368,35 @@ function computeScore(memory, distance, connectionCount, maxConnections, now, op
|
|
|
270
368
|
const freshness = 1 - ageDays / scoringWeights.freshnessBoostDays;
|
|
271
369
|
score += freshness * scoringWeights.freshnessBoostWeight;
|
|
272
370
|
}
|
|
371
|
+
// #203 soft affinity (retrieval scoping). Two graded, additive terms — both
|
|
372
|
+
// ZERO when the scope is absent (byte-identical ranking) and ZERO for a
|
|
373
|
+
// non-matching memory (never a penalty). Project affinity is a flat boost on
|
|
374
|
+
// exact project match; domain affinity is max(overlapping tag weight) × the
|
|
375
|
+
// domain weight. NULL tag weights (not yet computed by the nightly
|
|
376
|
+
// reconsolidation) count as 0 — we never invent a boost from missing
|
|
377
|
+
// association strength. Affinity rides the 0.8 composite side only (the RRF
|
|
378
|
+
// 0.2 side is #205 territory and untouched here).
|
|
379
|
+
const scope = options?.scope;
|
|
380
|
+
if (scope) {
|
|
381
|
+
if (scope.project && memory.project === scope.project) {
|
|
382
|
+
score += scoringWeights.projectAffinity;
|
|
383
|
+
}
|
|
384
|
+
const domains = scope.missionDomains;
|
|
385
|
+
if (domains && domains.length > 0 && options.tagWeights && options.tagWeights.length > 0) {
|
|
386
|
+
const domainSet = domains.length === 1 ? null : new Set(domains);
|
|
387
|
+
let maxWeight = 0;
|
|
388
|
+
for (const tw of options.tagWeights) {
|
|
389
|
+
const overlaps = domainSet ? domainSet.has(tw.tag) : tw.tag === domains[0];
|
|
390
|
+
if (overlaps) {
|
|
391
|
+
const w = tw.weight ?? 0;
|
|
392
|
+
if (w > maxWeight)
|
|
393
|
+
maxWeight = w;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (maxWeight > 0)
|
|
397
|
+
score += maxWeight * scoringWeights.domainAffinity;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
273
400
|
// Superseded demotion (#191 Phase B): a memory whose decision was reversed by
|
|
274
401
|
// a later one keeps its content and strength but must not outrank the
|
|
275
402
|
// decision that replaced it. Applied as an explicit multiplier here rather
|
|
@@ -325,6 +452,7 @@ function formatResult(memory, score, effStr, connections, provenance) {
|
|
|
325
452
|
access_count: memory.access_count ?? 0,
|
|
326
453
|
memory_type: memory.memory_type ?? "episode",
|
|
327
454
|
project: memory.project ?? null,
|
|
455
|
+
source_agent: memory.source_agent ?? null,
|
|
328
456
|
created_at: memory.created_at ?? "",
|
|
329
457
|
connections,
|
|
330
458
|
similarity: provenance ? provenance.similarity : undefined,
|
|
@@ -350,12 +478,28 @@ function strengthen(db, memories, now) {
|
|
|
350
478
|
// ---------------------------------------------------------------------------
|
|
351
479
|
// Reciprocal Rank Fusion
|
|
352
480
|
// ---------------------------------------------------------------------------
|
|
353
|
-
|
|
481
|
+
/**
|
|
482
|
+
* Reciprocal Rank Fusion (#205 per-list weights).
|
|
483
|
+
*
|
|
484
|
+
* Each list contributes `weight / (k + rank + 1)` per item. The pre-#205 form
|
|
485
|
+
* (symmetric 1.0 weight on every list) is recovered by omitting `weight`:
|
|
486
|
+
* `{ ids }` defaults to weight 1.0 — so callers that don't care about per-list
|
|
487
|
+
* rebalancing (tests, alternative uses) keep working unchanged.
|
|
488
|
+
*
|
|
489
|
+
* Per-list weights are the #205 lever for "nudging toward vector": FTS was
|
|
490
|
+
* winning cross-scope collisions on raw token overlap (marine "battery" beat
|
|
491
|
+
* hardware "battery" because the marine row had a tighter token match), so the
|
|
492
|
+
* shipped default drops FTS to 0.5 while vector stays at 1.0 (0.7 was too
|
|
493
|
+
* timid — Q4 marine contamination persisted; see SCORING_DEFAULTS). The composite
|
|
494
|
+
* score (which carries the #203 affinity boost) then breaks the tie in scope.
|
|
495
|
+
*/
|
|
496
|
+
function reciprocalRankFusion(rankedLists, k = DEFAULT_RRF_K) {
|
|
354
497
|
const scores = new Map();
|
|
355
|
-
for (const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
498
|
+
for (const list of rankedLists) {
|
|
499
|
+
const w = list.weight ?? 1.0;
|
|
500
|
+
for (let rank = 0; rank < list.ids.length; rank++) {
|
|
501
|
+
const mid = list.ids[rank];
|
|
502
|
+
scores.set(mid, (scores.get(mid) ?? 0) + w / (k + rank + 1));
|
|
359
503
|
}
|
|
360
504
|
}
|
|
361
505
|
return scores;
|
|
@@ -363,26 +507,42 @@ function reciprocalRankFusion(rankedLists, k = RRF_K) {
|
|
|
363
507
|
/**
|
|
364
508
|
* Main retrieval: BM25 + vector search with RRF fusion, graph traversal,
|
|
365
509
|
* and composite scoring. Strengthens accessed memories.
|
|
510
|
+
*
|
|
511
|
+
* #203 retrieval scoping: `project` and `missionDomains` are SOFT affinity
|
|
512
|
+
* terms in computeScore (zero-boost neutral, never a penalty), NOT filters.
|
|
513
|
+
* `privacy` remains a hard filter (security boundary). `sourceAgent` remains a
|
|
514
|
+
* hard filter (kept for completeness; no production caller currently passes
|
|
515
|
+
* it). When neither project nor missionDomains is sent, scoring is byte-
|
|
516
|
+
* identical to pre-#203 — the kill-switch / no-op guarantee.
|
|
366
517
|
*/
|
|
367
518
|
async function retrieve(db, embedFn, query, options) {
|
|
368
519
|
const limit = options?.limit ?? recallDefaults.searchLimit;
|
|
369
520
|
const project = options?.project;
|
|
370
521
|
const privacy = options?.privacy;
|
|
371
522
|
const sourceAgent = options?.sourceAgent;
|
|
523
|
+
const missionDomains = options?.missionDomains;
|
|
372
524
|
const now = new Date();
|
|
373
|
-
//
|
|
374
|
-
|
|
525
|
+
// #203 affinity scope — passed to computeScore for every candidate. Built
|
|
526
|
+
// once; absent fields yield no boost (zero-boost neutral).
|
|
527
|
+
const scope = project || (missionDomains && missionDomains.length > 0)
|
|
528
|
+
? { project: project ?? undefined, missionDomains }
|
|
529
|
+
: undefined;
|
|
530
|
+
// 1. Embed — or reuse the caller-provided vector (session-intent blend).
|
|
531
|
+
const queryEmbedding = options?.queryEmbedding ?? (await embedFn(query));
|
|
375
532
|
// 2. Dual retrieval — vector + BM25.
|
|
376
533
|
// #192: sqlite-vec can't push filters into the KNN, so filtered queries must
|
|
377
534
|
// over-fetch — the old flat limit*3 intersected a global top-15 with (for the
|
|
378
535
|
// median project) ~1% of the corpus, starving every filtered query.
|
|
379
|
-
|
|
536
|
+
// #203: project is NO LONGER a filter (soft affinity now), so it does not
|
|
537
|
+
// trigger over-fetch; privacy/sourceAgent still do (they remain hard filters).
|
|
538
|
+
const filtered = Boolean(privacy || sourceAgent);
|
|
380
539
|
const fetchLimit = filtered ? Math.min(limit * 20, 200) : limit * 3;
|
|
381
540
|
let vecCandidates = storage.vectorSearch(db, queryEmbedding, fetchLimit, []);
|
|
382
541
|
let ftsCandidates = [];
|
|
383
542
|
try {
|
|
384
|
-
// privacy/sourceAgent
|
|
385
|
-
|
|
543
|
+
// privacy/sourceAgent are pushed into the FTS SQL (hard filters). project
|
|
544
|
+
// is NOT (it is a soft affinity boost in computeScore as of #203).
|
|
545
|
+
ftsCandidates = storage.searchFts(db, query, fetchLimit, privacy, sourceAgent);
|
|
386
546
|
}
|
|
387
547
|
catch {
|
|
388
548
|
// FTS5 search can fail on special characters; fall back to vector-only
|
|
@@ -390,20 +550,25 @@ async function retrieve(db, embedFn, query, options) {
|
|
|
390
550
|
if (vecCandidates.length === 0 && ftsCandidates.length === 0) {
|
|
391
551
|
return [];
|
|
392
552
|
}
|
|
393
|
-
// Post-filter vector candidates (sqlite-vec can't filter)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
553
|
+
// Post-filter vector candidates (sqlite-vec can't filter). privacy stays a
|
|
554
|
+
// hard filter (security boundary); project was removed here (#203 — it is now
|
|
555
|
+
// scored, not filtered). sourceAgent stays (see options doc).
|
|
397
556
|
if (privacy) {
|
|
398
557
|
vecCandidates = vecCandidates.filter((c) => privacy.includes(c.privacy));
|
|
399
558
|
}
|
|
400
559
|
if (sourceAgent) {
|
|
401
560
|
vecCandidates = vecCandidates.filter((c) => c.source_agent === sourceAgent);
|
|
402
561
|
}
|
|
403
|
-
// 3. RRF fusion
|
|
562
|
+
// 3. RRF fusion (#205: per-list weights + config-driven k). The vector list
|
|
563
|
+
// carries the composite-side affinity in the next step, so we let it dominate
|
|
564
|
+
// RRF too — the FTS list is down-weighted to break token-collision ties that
|
|
565
|
+
// the affinity alone cannot reach (marine "battery" vs hardware "battery").
|
|
404
566
|
const vecRanked = vecCandidates.map((c) => c.id);
|
|
405
567
|
const ftsRanked = ftsCandidates.map((c) => c.id);
|
|
406
|
-
const rrfScores = reciprocalRankFusion([
|
|
568
|
+
const rrfScores = reciprocalRankFusion([
|
|
569
|
+
{ ids: vecRanked, weight: scoringWeights.rrfVectorWeight },
|
|
570
|
+
{ ids: ftsRanked, weight: scoringWeights.rrfFtsWeight },
|
|
571
|
+
], scoringWeights.rrfK);
|
|
407
572
|
// Build unified candidate map (with retrieval-channel provenance, #192)
|
|
408
573
|
const candidateMap = new Map();
|
|
409
574
|
for (const c of vecCandidates) {
|
|
@@ -427,8 +592,8 @@ async function retrieve(db, embedFn, query, options) {
|
|
|
427
592
|
const mem = storage.getMemory(db, gid);
|
|
428
593
|
if (!mem)
|
|
429
594
|
continue;
|
|
430
|
-
|
|
431
|
-
|
|
595
|
+
// #203: project check removed — project is a soft affinity in computeScore,
|
|
596
|
+
// not a filter. privacy (security) and sourceAgent stay as hard filters.
|
|
432
597
|
if (privacy && !privacy.includes(mem.privacy))
|
|
433
598
|
continue;
|
|
434
599
|
if (sourceAgent && mem.source_agent !== sourceAgent)
|
|
@@ -444,10 +609,19 @@ async function retrieve(db, embedFn, query, options) {
|
|
|
444
609
|
// One query for the whole candidate set (#191 Phase B): superseded memories
|
|
445
610
|
// are demoted in computeScore rather than strength-penalized.
|
|
446
611
|
const supersededIds = findSupersededIds(db, [...candidateMap.keys()]);
|
|
612
|
+
// #203: ONE batched load of every candidate's graded domain tags — fed to
|
|
613
|
+
// computeScore for domain affinity. Only needed when the scope carries
|
|
614
|
+
// missionDomains; absent otherwise (skips the query entirely on /search and
|
|
615
|
+
// other unscoped callers — byte-identical to pre-#203).
|
|
616
|
+
const tagWeightsByMemory = scope?.missionDomains && scope.missionDomains.length > 0
|
|
617
|
+
? storage.getMemoryTagsWeightedBatched(db, [...candidateMap.keys()])
|
|
618
|
+
: undefined;
|
|
447
619
|
for (const [mid, { mem, distance, source }] of candidateMap) {
|
|
448
620
|
const connCount = connectionCounts.get(mid) ?? 0;
|
|
449
621
|
const composite = computeScore(mem, distance, connCount, maxConnections, now, {
|
|
450
622
|
superseded: supersededIds.has(mid),
|
|
623
|
+
scope,
|
|
624
|
+
tagWeights: tagWeightsByMemory?.get(mid),
|
|
451
625
|
});
|
|
452
626
|
const effStr = effectiveStrength(mem.base_strength ?? 0.5, mem.last_accessed, now, {
|
|
453
627
|
accessCount: mem.access_count ?? 0,
|
|
@@ -455,7 +629,11 @@ async function retrieve(db, embedFn, query, options) {
|
|
|
455
629
|
});
|
|
456
630
|
const rrf = rrfScores.get(mid) ?? 0;
|
|
457
631
|
const normalizedRrf = maxRrf > 0 ? rrf / maxRrf : 0;
|
|
458
|
-
|
|
632
|
+
// #205: composite/RRF blend is now config-driven (rrfCompositeWeight, 0.8
|
|
633
|
+
// default = pre-#205 behavior). The RRF share is the complement so the two
|
|
634
|
+
// always sum to 1.0 — the knob tunes the BALANCE, not the total.
|
|
635
|
+
const compositeWeight = scoringWeights.rrfCompositeWeight;
|
|
636
|
+
const finalScore = composite * compositeWeight + normalizedRrf * (1 - compositeWeight);
|
|
459
637
|
// Measured cosine only for vector-matched candidates; FTS/graph hits carry
|
|
460
638
|
// the neutral placeholder distance, which is not a real similarity.
|
|
461
639
|
const similarity = source === "vector" || source === "both"
|
|
@@ -38,6 +38,21 @@ export declare function blobToVec(buf: Buffer): Float32Array;
|
|
|
38
38
|
* is returned as an all-zero copy rather than dividing by zero.
|
|
39
39
|
*/
|
|
40
40
|
export declare function l2Normalize(vec: Float32Array): Float32Array;
|
|
41
|
+
/**
|
|
42
|
+
* Weighted sum of two vectors into a NEW Float32Array (inputs untouched). The
|
|
43
|
+
* result is NOT renormalized — callers normalize explicitly via `l2Normalize`
|
|
44
|
+
* when they need a unit vector (both call sites below do, because embeddings
|
|
45
|
+
* are L2-normalized and the blend must stay on the unit sphere to keep cosine
|
|
46
|
+
* meaningful). Throws on a dimension mismatch rather than silently truncating:
|
|
47
|
+
* the embedding dim is fixed at 384 in practice, so a mismatch signals a
|
|
48
|
+
* mid-process model swap or a bug, which must surface (CLAUDE.md: fail
|
|
49
|
+
* explicitly), not get quietly papered over.
|
|
50
|
+
*
|
|
51
|
+
* Used by the session-intent centroid EMA and the recall query blend (#192
|
|
52
|
+
* session-intent keying): `weightedAdd(a, 1-α, b, α)` is the EMA step,
|
|
53
|
+
* `weightedAdd(prompt, 1-w, centroid, w)` is the blended search vector.
|
|
54
|
+
*/
|
|
55
|
+
export declare function weightedAdd(a: Float32Array, wA: number, b: Float32Array, wB: number): Float32Array;
|
|
41
56
|
/**
|
|
42
57
|
* Association weight of a memory for a tag = cosine(memory embedding, domain
|
|
43
58
|
* prototype). Both inputs are L2-normalized (embedder.ts normalizes memory
|
|
@@ -28,6 +28,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
28
28
|
exports.PROTOTYPE_MIN_MEMBERS = void 0;
|
|
29
29
|
exports.blobToVec = blobToVec;
|
|
30
30
|
exports.l2Normalize = l2Normalize;
|
|
31
|
+
exports.weightedAdd = weightedAdd;
|
|
31
32
|
exports.tagWeight = tagWeight;
|
|
32
33
|
exports.compartmentSet = compartmentSet;
|
|
33
34
|
exports.derivePrimary = derivePrimary;
|
|
@@ -70,6 +71,29 @@ function l2Normalize(vec) {
|
|
|
70
71
|
out[i] = vec[i] / norm;
|
|
71
72
|
return out;
|
|
72
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Weighted sum of two vectors into a NEW Float32Array (inputs untouched). The
|
|
76
|
+
* result is NOT renormalized — callers normalize explicitly via `l2Normalize`
|
|
77
|
+
* when they need a unit vector (both call sites below do, because embeddings
|
|
78
|
+
* are L2-normalized and the blend must stay on the unit sphere to keep cosine
|
|
79
|
+
* meaningful). Throws on a dimension mismatch rather than silently truncating:
|
|
80
|
+
* the embedding dim is fixed at 384 in practice, so a mismatch signals a
|
|
81
|
+
* mid-process model swap or a bug, which must surface (CLAUDE.md: fail
|
|
82
|
+
* explicitly), not get quietly papered over.
|
|
83
|
+
*
|
|
84
|
+
* Used by the session-intent centroid EMA and the recall query blend (#192
|
|
85
|
+
* session-intent keying): `weightedAdd(a, 1-α, b, α)` is the EMA step,
|
|
86
|
+
* `weightedAdd(prompt, 1-w, centroid, w)` is the blended search vector.
|
|
87
|
+
*/
|
|
88
|
+
function weightedAdd(a, wA, b, wB) {
|
|
89
|
+
if (a.length !== b.length) {
|
|
90
|
+
throw new Error(`weightedAdd: dimension mismatch (${a.length} vs ${b.length}) — expected equal-length L2-normalized embeddings`);
|
|
91
|
+
}
|
|
92
|
+
const out = new Float32Array(a.length);
|
|
93
|
+
for (let i = 0; i < a.length; i++)
|
|
94
|
+
out[i] = a[i] * wA + b[i] * wB;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
73
97
|
/**
|
|
74
98
|
* Association weight of a memory for a tag = cosine(memory embedding, domain
|
|
75
99
|
* prototype). Both inputs are L2-normalized (embedder.ts normalizes memory
|
package/dist/storage.d.ts
CHANGED
|
@@ -89,6 +89,17 @@ export declare function getMemoryTagsWeighted(db: Database.Database, memoryId: s
|
|
|
89
89
|
tag: string;
|
|
90
90
|
weight: number | null;
|
|
91
91
|
}>;
|
|
92
|
+
/**
|
|
93
|
+
* Batched weighted-tag load for a candidate set (#203 domain affinity). ONE
|
|
94
|
+
* query for the whole set — never call getMemoryTagsWeighted per-candidate in
|
|
95
|
+
* a ranking loop. Returns a Map keyed by memory_id; memories with no tags are
|
|
96
|
+
* simply absent from the map (caller treats missing as "no domain boost").
|
|
97
|
+
* Ordering within each memory mirrors getMemoryTagsWeighted (weight DESC).
|
|
98
|
+
*/
|
|
99
|
+
export declare function getMemoryTagsWeightedBatched(db: Database.Database, memoryIds: string[]): Map<string, Array<{
|
|
100
|
+
tag: string;
|
|
101
|
+
weight: number | null;
|
|
102
|
+
}>>;
|
|
92
103
|
/**
|
|
93
104
|
* Read the stored embedding for a memory from memory_vectors.
|
|
94
105
|
* Returns null when the row is missing (caller falls back to re-embedding).
|
|
@@ -106,10 +117,52 @@ export declare function vectorSearch(db: Database.Database, queryEmbedding: Floa
|
|
|
106
117
|
distance: number;
|
|
107
118
|
}>;
|
|
108
119
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
120
|
+
* BM25F field weights (config-driven via {@link configureBm25Fts}, called from
|
|
121
|
+
* retrieval.configureScoring at boot). The order mirrors the FTS5 column
|
|
122
|
+
* declaration in db.ts (content, project, domain) — `bm25(memories_fts, …)`
|
|
123
|
+
* takes weights POSITIONALLY, so a new FTS column MUST be added here in the
|
|
124
|
+
* same position or the weighting silently shifts. Defaults favor scope fields
|
|
125
|
+
* (project/domain) over body so cross-scope noise that wins on raw token
|
|
126
|
+
* frequency (the marine "battery" memory on a hardware query) is demoted
|
|
127
|
+
* without excluding it — the same "graded, never binary" discipline as
|
|
128
|
+
* computeScore's affinity terms.
|
|
129
|
+
*/
|
|
130
|
+
export interface Bm25Weights {
|
|
131
|
+
body: number;
|
|
132
|
+
project: number;
|
|
133
|
+
domain: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Configure BM25F weights from config. Called by retrieval.configureScoring
|
|
137
|
+
* (which itself is called at server + nightly boot) so storage and retrieval
|
|
138
|
+
* rank identically. Invalid/out-of-range values keep the shipped default per
|
|
139
|
+
* key. Range [0, ∞) — a 0 weight effectively drops that field from the score;
|
|
140
|
+
* negative values are rejected (BM25F sign semantics break otherwise). Returns
|
|
141
|
+
* the resolved set for logging/tests.
|
|
111
142
|
*/
|
|
112
|
-
export declare function
|
|
143
|
+
export declare function configureBm25Fts(config?: Record<string, unknown> | null): Bm25Weights;
|
|
144
|
+
/** Current resolved weights (tests + status output). */
|
|
145
|
+
export declare function getBm25Weights(): Bm25Weights;
|
|
146
|
+
/**
|
|
147
|
+
* Full-text search using FTS5 fielded BM25 (BM25F) ranking.
|
|
148
|
+
* Returns memories with a rank field (lower is better — see sign note below).
|
|
149
|
+
*
|
|
150
|
+
* `project` is NOT a filter here (#203): the hard project WHERE from #192 was
|
|
151
|
+
* removed — project is now a soft affinity boost in retrieval.computeScore AND
|
|
152
|
+
* a weighted field in BM25F (#205). `privacy` stays a hard filter (security
|
|
153
|
+
* boundary, not a relevance signal). `sourceAgent` stays a hard filter (kept
|
|
154
|
+
* for completeness; no production caller of retrieve() currently passes it).
|
|
155
|
+
*
|
|
156
|
+
* #205 sign handling: FTS5's `bm25(table, w0, w1, …)` returns a NEGATIVE score
|
|
157
|
+
* where MORE-negative = better match (it is 1 − the normalized BM25 score,
|
|
158
|
+
* which is itself positive — the negation is the FTS5 convention so that
|
|
159
|
+
* `ORDER BY bm25(…)` ASC gives best-first, matching the legacy `ORDER BY
|
|
160
|
+
* fts.rank` direction). Higher field weight ⇒ that column contributes MORE to
|
|
161
|
+
* the per-row score ⇒ matches in that field float up. We bind weights
|
|
162
|
+
* positionally as parameters (NOT string-interpolated) so query-planner
|
|
163
|
+
* caching is unaffected and the config path is the only editor.
|
|
164
|
+
*/
|
|
165
|
+
export declare function searchFts(db: Database.Database, query: string, limit?: number, privacy?: string[], sourceAgent?: string): Array<Memory & {
|
|
113
166
|
rank: number;
|
|
114
167
|
}>;
|
|
115
168
|
/**
|
package/dist/storage.js
CHANGED
|
@@ -15,8 +15,11 @@ exports.deleteMemory = deleteMemory;
|
|
|
15
15
|
exports.setMemoryTags = setMemoryTags;
|
|
16
16
|
exports.getMemoryTags = getMemoryTags;
|
|
17
17
|
exports.getMemoryTagsWeighted = getMemoryTagsWeighted;
|
|
18
|
+
exports.getMemoryTagsWeightedBatched = getMemoryTagsWeightedBatched;
|
|
18
19
|
exports.getStoredEmbedding = getStoredEmbedding;
|
|
19
20
|
exports.vectorSearch = vectorSearch;
|
|
21
|
+
exports.configureBm25Fts = configureBm25Fts;
|
|
22
|
+
exports.getBm25Weights = getBm25Weights;
|
|
20
23
|
exports.searchFts = searchFts;
|
|
21
24
|
exports.addLink = addLink;
|
|
22
25
|
exports.getLinks = getLinks;
|
|
@@ -244,6 +247,33 @@ function getMemoryTagsWeighted(db, memoryId) {
|
|
|
244
247
|
.all(memoryId);
|
|
245
248
|
return rows;
|
|
246
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Batched weighted-tag load for a candidate set (#203 domain affinity). ONE
|
|
252
|
+
* query for the whole set — never call getMemoryTagsWeighted per-candidate in
|
|
253
|
+
* a ranking loop. Returns a Map keyed by memory_id; memories with no tags are
|
|
254
|
+
* simply absent from the map (caller treats missing as "no domain boost").
|
|
255
|
+
* Ordering within each memory mirrors getMemoryTagsWeighted (weight DESC).
|
|
256
|
+
*/
|
|
257
|
+
function getMemoryTagsWeightedBatched(db, memoryIds) {
|
|
258
|
+
const out = new Map();
|
|
259
|
+
if (memoryIds.length === 0)
|
|
260
|
+
return out;
|
|
261
|
+
const placeholders = memoryIds.map(() => "?").join(", ");
|
|
262
|
+
const rows = db
|
|
263
|
+
.prepare(`SELECT memory_id, tag, weight FROM memory_tags
|
|
264
|
+
WHERE memory_id IN (${placeholders})
|
|
265
|
+
ORDER BY memory_id, (weight IS NULL) ASC, weight DESC, rowid ASC`)
|
|
266
|
+
.all(...memoryIds);
|
|
267
|
+
for (const r of rows) {
|
|
268
|
+
let arr = out.get(r.memory_id);
|
|
269
|
+
if (!arr) {
|
|
270
|
+
arr = [];
|
|
271
|
+
out.set(r.memory_id, arr);
|
|
272
|
+
}
|
|
273
|
+
arr.push({ tag: r.tag, weight: r.weight });
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
247
277
|
/**
|
|
248
278
|
* Read the stored embedding for a memory from memory_vectors.
|
|
249
279
|
* Returns null when the row is missing (caller falls back to re-embedding).
|
|
@@ -286,14 +316,56 @@ function vectorSearch(db, queryEmbedding, limit = 10, excludeIds = []) {
|
|
|
286
316
|
}
|
|
287
317
|
return results;
|
|
288
318
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
319
|
+
const BM25_DEFAULTS = {
|
|
320
|
+
body: 1.0,
|
|
321
|
+
project: 2.0,
|
|
322
|
+
domain: 2.0,
|
|
323
|
+
};
|
|
324
|
+
let bm25Weights = { ...BM25_DEFAULTS };
|
|
292
325
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
326
|
+
* Configure BM25F weights from config. Called by retrieval.configureScoring
|
|
327
|
+
* (which itself is called at server + nightly boot) so storage and retrieval
|
|
328
|
+
* rank identically. Invalid/out-of-range values keep the shipped default per
|
|
329
|
+
* key. Range [0, ∞) — a 0 weight effectively drops that field from the score;
|
|
330
|
+
* negative values are rejected (BM25F sign semantics break otherwise). Returns
|
|
331
|
+
* the resolved set for logging/tests.
|
|
295
332
|
*/
|
|
296
|
-
function
|
|
333
|
+
function configureBm25Fts(config) {
|
|
334
|
+
const num = (key, dflt) => {
|
|
335
|
+
const v = Number(config?.[key]);
|
|
336
|
+
return Number.isFinite(v) && v >= 0 ? v : dflt;
|
|
337
|
+
};
|
|
338
|
+
bm25Weights = {
|
|
339
|
+
body: num("bm25WeightBody", BM25_DEFAULTS.body),
|
|
340
|
+
project: num("bm25WeightProject", BM25_DEFAULTS.project),
|
|
341
|
+
domain: num("bm25WeightDomain", BM25_DEFAULTS.domain),
|
|
342
|
+
};
|
|
343
|
+
return { ...bm25Weights };
|
|
344
|
+
}
|
|
345
|
+
/** Current resolved weights (tests + status output). */
|
|
346
|
+
function getBm25Weights() {
|
|
347
|
+
return { ...bm25Weights };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Full-text search using FTS5 fielded BM25 (BM25F) ranking.
|
|
351
|
+
* Returns memories with a rank field (lower is better — see sign note below).
|
|
352
|
+
*
|
|
353
|
+
* `project` is NOT a filter here (#203): the hard project WHERE from #192 was
|
|
354
|
+
* removed — project is now a soft affinity boost in retrieval.computeScore AND
|
|
355
|
+
* a weighted field in BM25F (#205). `privacy` stays a hard filter (security
|
|
356
|
+
* boundary, not a relevance signal). `sourceAgent` stays a hard filter (kept
|
|
357
|
+
* for completeness; no production caller of retrieve() currently passes it).
|
|
358
|
+
*
|
|
359
|
+
* #205 sign handling: FTS5's `bm25(table, w0, w1, …)` returns a NEGATIVE score
|
|
360
|
+
* where MORE-negative = better match (it is 1 − the normalized BM25 score,
|
|
361
|
+
* which is itself positive — the negation is the FTS5 convention so that
|
|
362
|
+
* `ORDER BY bm25(…)` ASC gives best-first, matching the legacy `ORDER BY
|
|
363
|
+
* fts.rank` direction). Higher field weight ⇒ that column contributes MORE to
|
|
364
|
+
* the per-row score ⇒ matches in that field float up. We bind weights
|
|
365
|
+
* positionally as parameters (NOT string-interpolated) so query-planner
|
|
366
|
+
* caching is unaffected and the config path is the only editor.
|
|
367
|
+
*/
|
|
368
|
+
function searchFts(db, query, limit = 10, privacy, sourceAgent) {
|
|
297
369
|
const conditions = ["memories_fts MATCH ?"];
|
|
298
370
|
const params = [query];
|
|
299
371
|
if (privacy && privacy.length > 0) {
|
|
@@ -305,23 +377,27 @@ function searchFts(db, query, limit = 10, privacy, sourceAgent, project) {
|
|
|
305
377
|
conditions.push("m.source_agent = ?");
|
|
306
378
|
params.push(sourceAgent);
|
|
307
379
|
}
|
|
308
|
-
// #192: project is pushed into SQL (like privacy/sourceAgent) instead of
|
|
309
|
-
// being post-filtered by the caller — post-filtering a global top-N against
|
|
310
|
-
// a small project starves the result set regardless of corpus content.
|
|
311
|
-
if (project) {
|
|
312
|
-
conditions.push("m.project = ?");
|
|
313
|
-
params.push(project);
|
|
314
|
-
}
|
|
315
380
|
const where = conditions.join(" AND ");
|
|
316
|
-
|
|
381
|
+
// SQLite binds `?` parameters in LEXICAL SQL order (left-to-right) — the
|
|
382
|
+
// `bm25(memories_fts, ?, ?, ?)` in the SELECT clause comes BEFORE the WHERE
|
|
383
|
+
// and LIMIT `?`s, so the weights must be pushed FIRST. Get this order wrong
|
|
384
|
+
// and FTS5 ends up with a numeric weight as its MATCH expression (parsed as
|
|
385
|
+
// FTS5 query syntax → "syntax error near '.'" on the decimal point).
|
|
386
|
+
const boundParams = [
|
|
387
|
+
bm25Weights.body,
|
|
388
|
+
bm25Weights.project,
|
|
389
|
+
bm25Weights.domain,
|
|
390
|
+
...params,
|
|
391
|
+
limit,
|
|
392
|
+
];
|
|
317
393
|
const rows = db
|
|
318
|
-
.prepare(`SELECT m.*,
|
|
394
|
+
.prepare(`SELECT m.*, bm25(memories_fts, ?, ?, ?) AS rank
|
|
319
395
|
FROM memories_fts fts
|
|
320
396
|
JOIN memories m ON m.rowid = fts.rowid
|
|
321
397
|
WHERE ${where}
|
|
322
|
-
ORDER BY
|
|
398
|
+
ORDER BY rank
|
|
323
399
|
LIMIT ?`)
|
|
324
|
-
.all(...
|
|
400
|
+
.all(...boundParams);
|
|
325
401
|
return rows.map((r) => {
|
|
326
402
|
const rank = r.rank;
|
|
327
403
|
const mem = rowToMemory(r);
|