@tpsdev-ai/flair 0.17.0 → 0.18.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.
@@ -4,6 +4,232 @@ import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
4
4
  import { getEmbedding, getModelId } from "./embeddings-provider.js";
5
5
  import { scanFields, isStrictMode } from "./content-safety.js";
6
6
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
7
+ import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, isConservativeMatch, } from "./dedup.js";
8
+ const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
9
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
10
+ /**
11
+ * ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
12
+ *
13
+ * NEVER SUPPRESSES A WRITE. This gate only computes a SIGNAL — the caller
14
+ * (Memory.post / Memory.put) always proceeds to write the new record. When a
15
+ * conservative match is found, the signal is attached to the RESPONSE only
16
+ * (deduplicated/matchedId/matchConfidence). There must be no code path where
17
+ * finding a match causes the write to be skipped: that was the #526 bug (two
18
+ * topically-close but DISTINCT findings — one about replication
19
+ * route-directionality, one about DDL/schema replication — and the SECOND was
20
+ * silently dropped because the old client-side gate returned the existing
21
+ * record instead of writing).
22
+ *
23
+ * Conservative match = raw cosine >= cosineThreshold AND Jaccard token-overlap
24
+ * >= lexicalThreshold, checked ONLY against the SINGLE top-cosine candidate
25
+ * (scoped to the same agentId as the write — never cross-agent). If that one
26
+ * candidate fails either gate, there is no match; we do not fall back to the
27
+ * 2nd-most-similar candidate.
28
+ *
29
+ * Previously this lived client-side (packages/flair-client/src/client.ts,
30
+ * pre-fix) and only ran for callers that opted into `dedup:true` over HTTP
31
+ * PUT — the Model-2 /mcp handler (resources/mcp-tools.ts), which calls
32
+ * Memory.post() directly, got ZERO dedup checking. Moving the gate here makes
33
+ * it apply uniformly regardless of transport (HTTP PUT vs in-process post()).
34
+ *
35
+ * NOTE on HTTP verbs: the Memory schema only exposes PUT over HTTP (a raw
36
+ * HTTP POST /Memory returns "Memory does not have a post method implemented"
37
+ * — see src/cli.ts's `flair test` command and commit 2fa6d22 / ops-pj5).
38
+ * `Memory.post()` IS reachable, but only via an in-process resource
39
+ * instantiation (as resources/mcp-tools.ts does) — never via the real HTTP
40
+ * POST route. Because flair-client's write() (used by flair-mcp, the CLI, and
41
+ * every other integration package) issues an HTTP PUT, the actual
42
+ * field-observed bug (#526) flows through Memory.put(), not Memory.post().
43
+ * The gate below is therefore a SHARED helper invoked from both post() and
44
+ * put() — anchored in the same place the design calls out (Memory.post), but
45
+ * wired into put() too so the write path real callers actually use is
46
+ * protected. See memory-integrity-fix report for the full writeup of this
47
+ * deviation from a literal "gate lives only in Memory.post" reading.
48
+ */
49
+ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding, cosineThreshold, lexicalThreshold) {
50
+ if (!agentId || !embedding || embedding.length === 0)
51
+ return null;
52
+ try {
53
+ const query = {
54
+ sort: { attribute: "embedding", target: embedding, distance: "cosine" },
55
+ conditions: [
56
+ { attribute: "agentId", comparator: "equals", value: agentId },
57
+ { attribute: "archived", comparator: "not_equal", value: true },
58
+ ],
59
+ select: ["id", "content", "$distance"],
60
+ limit: 1,
61
+ };
62
+ let top = null;
63
+ // Detach ctx.transaction around this search — same rationale as
64
+ // Memory.search()/SemanticSearch.ts: a drained search generator can leave
65
+ // a CLOSED transaction in ctx's chain that the subsequent WRITE
66
+ // (super.post/super.put, right after this gate runs) would otherwise
67
+ // inherit. Detaching here protects that write, not this read.
68
+ const results = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
69
+ for await (const record of results) {
70
+ top = record;
71
+ break; // single top-cosine candidate only — never fall back further
72
+ }
73
+ if (!top)
74
+ return null;
75
+ const cosine = 1 - (top.$distance ?? 1);
76
+ const confidence = computeMatchConfidence(contentText, top.content, cosine);
77
+ if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
78
+ return null;
79
+ }
80
+ return { matchedId: top.id, cosine: confidence.cosine, lexical: confidence.lexical };
81
+ }
82
+ catch {
83
+ // Dedup-check failures (embedding engine down, search error, etc.) must
84
+ // NEVER block or alter the write — treat as "no match found".
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * Run the dedup gate for a create-shaped write. Mutates `content.embedding` /
90
+ * `content.embeddingModel` when it computes a fresh embedding, so the
91
+ * existing "generate embedding if missing" step later in post()/put() reuses
92
+ * it instead of recomputing. Always strips the client-forwarded hint fields
93
+ * (dedup / dedupThreshold / lexicalThreshold) so they never persist onto the
94
+ * stored record — they are passthrough tuning hints, not schema fields.
95
+ *
96
+ * Returns the match signal, or null (no match / gate not applicable).
97
+ */
98
+ async function runDedupGate(ctx, content) {
99
+ const cosineThreshold = typeof content.dedupThreshold === "number" ? content.dedupThreshold : DEDUP_COSINE_THRESHOLD_DEFAULT;
100
+ const lexicalThreshold = typeof content.lexicalThreshold === "number" ? content.lexicalThreshold : DEDUP_LEXICAL_THRESHOLD_DEFAULT;
101
+ delete content.dedup;
102
+ delete content.dedupThreshold;
103
+ delete content.lexicalThreshold;
104
+ if (typeof content.content !== "string" || content.content.length < DEDUP_MIN_CONTENT_LENGTH) {
105
+ return null;
106
+ }
107
+ let embedding = Array.isArray(content.embedding) ? content.embedding : null;
108
+ if (!embedding) {
109
+ try {
110
+ embedding = await getEmbedding(content.content);
111
+ }
112
+ catch {
113
+ embedding = null;
114
+ }
115
+ if (embedding) {
116
+ content.embedding = embedding;
117
+ content.embeddingModel = getModelId();
118
+ }
119
+ }
120
+ if (!embedding)
121
+ return null;
122
+ return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
123
+ }
124
+ /** Build the final write response: always `written: true`, always includes
125
+ * `id`, and layers the dedup collision signal on top when present. Never a
126
+ * code path where a match suppresses these base fields. */
127
+ function buildWriteResponse(content, result, dedupMatch) {
128
+ const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
129
+ const response = {
130
+ id: content.id,
131
+ ...base,
132
+ written: true,
133
+ deduplicated: !!dedupMatch,
134
+ };
135
+ if (dedupMatch) {
136
+ response.matchedId = dedupMatch.matchedId;
137
+ response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
138
+ }
139
+ return response;
140
+ }
141
+ /**
142
+ * Read-modify-write close of a superseded record, with the SAME transaction
143
+ * detachment discipline as findConservativeDedupMatch (each discrete Harper
144
+ * call individually wrapped — see withDetachedTxn's doc for why a single
145
+ * wrap around a multi-await async function would not protect the later
146
+ * call). Does NOT swallow failures (ops-a4t5 fix) — throws so the caller can
147
+ * log it. Never called before the new record is already written.
148
+ */
149
+ async function closeSupersededRecord(ctx, oldId, patch) {
150
+ const existing = await withDetachedTxn(ctx, () => databases.flair.Memory.get(oldId));
151
+ if (!existing) {
152
+ throw new Error(`supersede-close: record ${oldId} not found`);
153
+ }
154
+ await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...existing, ...patch }));
155
+ }
156
+ /** Does an agent hold a "write" grant from `ownerId`? Same MemoryGrant lookup
157
+ * pattern as Memory.search()/SemanticSearch.ts (read/search scopes) — reused
158
+ * here for the "write" scope that gates cross-agent supersede. */
159
+ async function hasWriteGrant(granteeId, ownerId) {
160
+ try {
161
+ for await (const grant of databases.flair.MemoryGrant.search({
162
+ conditions: [
163
+ { attribute: "granteeId", comparator: "equals", value: granteeId },
164
+ { attribute: "ownerId", comparator: "equals", value: ownerId },
165
+ ],
166
+ })) {
167
+ if (grant.scope === "write")
168
+ return true;
169
+ }
170
+ }
171
+ catch {
172
+ /* MemoryGrant table not yet populated — no grant */
173
+ }
174
+ return false;
175
+ }
176
+ /**
177
+ * Shared by post() AND put(): the Memory schema only exposes a working HTTP
178
+ * PUT route (a raw HTTP POST /Memory 404s with "Memory does not have a post
179
+ * method implemented" — see src/cli.ts's `flair test` command / commit
180
+ * 2fa6d22 / ops-pj5). Memory.post() IS reachable, but only via an in-process
181
+ * resource instantiation (resources/mcp-tools.ts does this). Since
182
+ * flair-client's write()/update() — used by flair-mcp, the CLI, and every
183
+ * other integration package — issue HTTP PUT, `supersedes` must be fully
184
+ * handled (validated, authorized, and closed) from BOTH entry points for the
185
+ * real-world write path to actually get the fix, not just the in-process one.
186
+ *
187
+ * Validates the `supersedes` field's shape and, for a cross-agent supersede,
188
+ * requires a "write" MemoryGrant from the target's owner (reuses the existing
189
+ * agent-auth/grant machinery — no parallel auth logic). Returns a Response to
190
+ * short-circuit with (400/403), or null to continue.
191
+ */
192
+ async function validateAndAuthorizeSupersedes(content, auth) {
193
+ if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
194
+ return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
195
+ status: 400, headers: { "Content-Type": "application/json" },
196
+ });
197
+ }
198
+ if (content.supersedes && auth.kind === "agent" && !auth.isAdmin) {
199
+ const target = await databases.flair.Memory.get(content.supersedes).catch(() => null);
200
+ if (target && target.agentId !== auth.agentId) {
201
+ if (!(await hasWriteGrant(auth.agentId, target.agentId))) {
202
+ return FORBIDDEN("forbidden: cannot supersede a memory owned by another agent without a write grant");
203
+ }
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+ /**
209
+ * Close the superseded record — called AFTER the new record has already been
210
+ * written (write-new-BEFORE-close-old, ops-a4t5 fix). Safe failure state is
211
+ * two active records (recoverable), never a tombstoned-old-with-lost-new.
212
+ * Failure is logged (observable), never silently swallowed. No-op if
213
+ * `content.supersedes` is not set.
214
+ */
215
+ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
216
+ if (!content.supersedes)
217
+ return;
218
+ try {
219
+ await closeSupersededRecord(ctx, content.supersedes, {
220
+ validTo: content.validFrom ?? content.createdAt,
221
+ updatedAt: content.createdAt ?? content.updatedAt,
222
+ });
223
+ }
224
+ catch (err) {
225
+ // Constant format string + structured data: memory ids are agent-controlled,
226
+ // so interpolating them into console.error's format position (with a trailing
227
+ // `err` arg) would let an id containing %s/%o consume/hide the real error
228
+ // (semgrep unsafe-formatstring). Keep all dynamic values in the data object.
229
+ console.error("Memory.closeSuperseded: failed to close superseded record after writing new record " +
230
+ "(ops-a4t5 — observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
231
+ }
232
+ }
7
233
  export class Memory extends databases.flair.Memory {
8
234
  /**
9
235
  * Override search() to scope collection GETs by authenticated agent.
@@ -78,20 +304,17 @@ export class Memory extends databases.flair.Memory {
78
304
  // resolveAgentAuth (reads the gate's tpsAgent annotation) — NOT context.user
79
305
  // .username, which is the fallback "admin" super_user while de-elevation is
80
306
  // dormant and would wrongly 403 every agent's own write. internal/admin → pass.
307
+ let auth;
81
308
  {
82
- const auth = await resolveAgentAuth(ctx);
309
+ auth = await resolveAgentAuth(ctx);
83
310
  // Anonymous HTTP must NOT write. Pre-flip the global gate rejected no-auth
84
311
  // upstream; with the non-rejecting gate, each write path self-enforces (same
85
312
  // rule search() applies to reads).
86
313
  if (auth.kind === "anonymous") {
87
- return new Response(JSON.stringify({ error: "authentication required" }), {
88
- status: 401, headers: { "Content-Type": "application/json" },
89
- });
314
+ return UNAUTH();
90
315
  }
91
316
  if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
92
- return new Response(JSON.stringify({ error: "forbidden: cannot write memory owned by another agent" }), {
93
- status: 403, headers: { "Content-Type": "application/json" },
94
- });
317
+ return FORBIDDEN("forbidden: cannot write memory owned by another agent");
95
318
  }
96
319
  }
97
320
  content.durability ||= "standard";
@@ -111,23 +334,16 @@ export class Memory extends databases.flair.Memory {
111
334
  catch { }
112
335
  }
113
336
  }
114
- // supersedes: optional reference to the ID of the memory this one replaces
115
- if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
116
- return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
117
- status: 400, headers: { "Content-Type": "application/json" },
118
- });
119
- }
337
+ // supersedes: optional reference to the ID of the memory this one
338
+ // replaces. Validates shape + cross-agent-write authorization (shared
339
+ // with put() see validateAndAuthorizeSupersedes doc).
340
+ const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
341
+ if (supersedesError)
342
+ return supersedesError;
120
343
  // Temporal validity: validFrom defaults to now, validTo left null for active facts.
121
- // When a memory supersedes another, close the superseded memory's validity window.
122
344
  if (!content.validFrom) {
123
345
  content.validFrom = content.createdAt;
124
346
  }
125
- if (content.supersedes) {
126
- patchRecord(databases.flair.Memory, content.supersedes, {
127
- validTo: content.validFrom,
128
- updatedAt: content.createdAt,
129
- }).catch(() => { });
130
- }
131
347
  if (content.durability === "ephemeral" && !content.expiresAt) {
132
348
  const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
133
349
  content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
@@ -147,7 +363,22 @@ export class Memory extends databases.flair.Memory {
147
363
  content._safetyFlags = safety.flags;
148
364
  }
149
365
  }
150
- // Generate embedding from content text
366
+ // Server-side conservative-duplicate gate (memory-integrity fix). A
367
+ // supersede write is an intentional version-link, not an ambiguous "is
368
+ // this a duplicate of something else" situation — bypass the gate for it
369
+ // (this also gives memory_update's preserveHistory mode dedup-bypass for
370
+ // free, without a separate flag). NEVER suppresses the write either way.
371
+ let dedupMatch = null;
372
+ if (!content.supersedes) {
373
+ dedupMatch = await runDedupGate(ctx, content);
374
+ }
375
+ else {
376
+ delete content.dedup;
377
+ delete content.dedupThreshold;
378
+ delete content.lexicalThreshold;
379
+ }
380
+ // Generate embedding from content text (no-op if the dedup gate above
381
+ // already computed one for this content).
151
382
  if (content.content && !content.embedding) {
152
383
  const vec = await getEmbedding(content.content);
153
384
  if (vec) {
@@ -155,7 +386,16 @@ export class Memory extends databases.flair.Memory {
155
386
  content.embeddingModel = getModelId();
156
387
  }
157
388
  }
158
- return super.post(content);
389
+ // ── Write the new record FIRST ──────────────────────────────────────────
390
+ const result = await super.post(content);
391
+ // ── THEN close the superseded record (ops-a4t5 fix) ─────────────────────
392
+ // Write-new-BEFORE-close-old: the previous order (close-old via a fire-
393
+ // and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
394
+ // old record and then lose the new one if the write failed afterward.
395
+ // Now the safe failure state is two active records (recoverable), never
396
+ // a lost write — and the failure is logged, never silently swallowed.
397
+ await closeSupersededIfNeeded(ctx, content, "post");
398
+ return buildWriteResponse(content, result, dedupMatch);
159
399
  }
160
400
  async put(content) {
161
401
  // Reindex migration bypass: admin-only escape hatch used by the
@@ -181,19 +421,16 @@ export class Memory extends databases.flair.Memory {
181
421
  // write memories it owns, via resolveAgentAuth (gate annotation), not
182
422
  // context.user.username (the dormant-de-elevation fallback is "admin").
183
423
  // The _reindex admin path above bypasses this.
424
+ const ctx = this.getContext?.();
425
+ let auth;
184
426
  {
185
- const octx = this.getContext?.();
186
- const auth = await resolveAgentAuth(octx);
427
+ auth = await resolveAgentAuth(ctx);
187
428
  // Anonymous HTTP must NOT write (non-rejecting gate → self-enforce here).
188
429
  if (auth.kind === "anonymous") {
189
- return new Response(JSON.stringify({ error: "authentication required" }), {
190
- status: 401, headers: { "Content-Type": "application/json" },
191
- });
430
+ return UNAUTH();
192
431
  }
193
432
  if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
194
- return new Response(JSON.stringify({ error: "forbidden: cannot write memory owned by another agent" }), {
195
- status: 403, headers: { "Content-Type": "application/json" },
196
- });
433
+ return FORBIDDEN("forbidden: cannot write memory owned by another agent");
197
434
  }
198
435
  }
199
436
  const now = new Date().toISOString();
@@ -201,6 +438,16 @@ export class Memory extends databases.flair.Memory {
201
438
  // Set defaults that post() sets — put() is also used for new records via CLI
202
439
  content.archived = content.archived ?? false;
203
440
  content.createdAt = content.createdAt ?? now;
441
+ // supersedes: optional reference to the ID of the memory this one
442
+ // replaces. Validates shape + cross-agent-write authorization (shared
443
+ // with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
444
+ // this too: it's the only HTTP-reachable create path).
445
+ const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
446
+ if (supersedesError)
447
+ return supersedesError;
448
+ if (content.supersedes && !content.validFrom) {
449
+ content.validFrom = content.createdAt;
450
+ }
204
451
  // Content safety scan on updated content + summary (ops-i2jb).
205
452
  if (content.content || content.summary) {
206
453
  const safety = scanFields(content, ["content", "summary"]);
@@ -219,7 +466,36 @@ export class Memory extends databases.flair.Memory {
219
466
  content._safetyFlags = null;
220
467
  }
221
468
  }
222
- // Re-generate embedding if content changed
469
+ // Server-side conservative-duplicate gate (memory-integrity fix). PUT is
470
+ // an upsert: only run the gate for a FRESH create (target id does not yet
471
+ // exist) that is NOT a supersede-link write. An update of an EXISTING id
472
+ // (memory_update's default same-id overwrite path) is an intentional,
473
+ // explicit overwrite, and a supersede-link write is an intentional
474
+ // version-link — neither is an ambiguous "is this a duplicate of
475
+ // something else" write, so both are dedup-bypassed automatically, no
476
+ // separate flag needed. NEVER suppresses the write either way.
477
+ let dedupMatch = null;
478
+ if (content.supersedes) {
479
+ delete content.dedup;
480
+ delete content.dedupThreshold;
481
+ delete content.lexicalThreshold;
482
+ }
483
+ else if (content.id) {
484
+ const preExisting = await databases.flair.Memory.get(content.id).catch(() => null);
485
+ if (!preExisting) {
486
+ dedupMatch = await runDedupGate(ctx, content);
487
+ }
488
+ else {
489
+ delete content.dedup;
490
+ delete content.dedupThreshold;
491
+ delete content.lexicalThreshold;
492
+ }
493
+ }
494
+ else {
495
+ dedupMatch = await runDedupGate(ctx, content);
496
+ }
497
+ // Re-generate embedding if content changed (no-op if the dedup gate above
498
+ // already computed one for this content).
223
499
  if (content.content && !content.embedding) {
224
500
  const vec = await getEmbedding(content.content);
225
501
  if (vec) {
@@ -240,7 +516,11 @@ export class Memory extends databases.flair.Memory {
240
516
  if (content.promotionStatus === "approved") {
241
517
  content.durability = "permanent";
242
518
  }
243
- return super.put(content);
519
+ // ── Write the new/updated record FIRST ──────────────────────────────────
520
+ const result = await super.put(content);
521
+ // ── THEN close the superseded record (ops-a4t5 fix; see post()) ────────
522
+ await closeSupersededIfNeeded(ctx, content, "put");
523
+ return buildWriteResponse(content, result, dedupMatch);
244
524
  }
245
525
  async delete(id) {
246
526
  const record = await this.get(id);
@@ -4,6 +4,7 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
4
4
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
5
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
6
  import { wrapUntrusted } from "./content-safety.js";
7
+ import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
7
8
  // Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
8
9
  // relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
9
10
  // unit-tested directly (see test/unit/temporal-scoring.test.ts).
@@ -170,6 +171,16 @@ export class SemanticSearch extends Resource {
170
171
  }
171
172
  const results = [];
172
173
  const hybrid = hybridEnabled();
174
+ // When the reranker is on, widen the legacy HNSW fetch so it has a deeper
175
+ // pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
176
+ // from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
177
+ // headroom. Scoped to the legacy (non-hybrid) vector path below — the
178
+ // hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
179
+ // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union (ops-i39b); the
180
+ // reranker still applies to its output further down regardless of which
181
+ // path produced `filteredResults`.
182
+ const rerankOn = isRerankEnabled();
183
+ const rerankTopN = getRerankTopN();
173
184
  if (hybrid) {
174
185
  // ─── BM25 + union-RRF hybrid path (ops-i39b) ─────────────────────────
175
186
  // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
@@ -284,8 +295,10 @@ export class SemanticSearch extends Resource {
284
295
  }
285
296
  }
286
297
  else if (qEmb) {
287
- // ─── HNSW vector search path ───────────────────────────────────────────
288
- const candidateLimit = limit * CANDIDATE_MULTIPLIER;
298
+ // ─── HNSW vector search path (legacy, hybrid flag OFF) ────────────────
299
+ const candidateLimit = rerankOn
300
+ ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN)
301
+ : limit * CANDIDATE_MULTIPLIER;
289
302
  const query = {
290
303
  sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
291
304
  select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
@@ -387,7 +400,32 @@ export class SemanticSearch extends Resource {
387
400
  if (minScore > 0) {
388
401
  filteredResults = filteredResults.filter((r) => r._score >= minScore);
389
402
  }
390
- filteredResults.sort((a, b) => b._score - a._score);
403
+ // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
404
+ // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
405
+ // can't do) and reorders before the final slice. Reorders whatever
406
+ // `filteredResults` the retrieval stage above produced — legacy HNSW-only
407
+ // OR the BM25+union-RRF hybrid path (ops-i39b) — since both converge into
408
+ // the same `results`/`filteredResults` shape before this point; hybrid
409
+ // retrieves+fuses, the reranker only reorders the fused set. Still gated
410
+ // on `qEmb` (an embedding was actually generated); the pure keyword-only
411
+ // fallback (no qEmb at all) is untouched either way. The reranker
412
+ // overwrites `_score` with the rerank score (so margin measurement reads it)
413
+ // and preserves the semantic score as `_semScore`; `_rawScore` is left as-is
414
+ // so recall-bench's scoring:"raw" path stays reproducible. On init failure,
415
+ // timeout, or any throw, rerankCandidates returns the input UNCHANGED and we
416
+ // fall through to the existing vector-order sort — recall is never blocked.
417
+ if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
418
+ // Pre-sort by vector order so the topN fed to the reranker is the most
419
+ // semantically-promising slice (filteredResults isn't sorted yet here).
420
+ filteredResults.sort((a, b) => b._score - a._score);
421
+ filteredResults = await rerankCandidates(String(q), filteredResults, {
422
+ topN: rerankTopN,
423
+ budgetMs: getRerankBudgetMs(),
424
+ });
425
+ }
426
+ else {
427
+ filteredResults.sort((a, b) => b._score - a._score);
428
+ }
391
429
  const topResults = filteredResults.slice(0, limit);
392
430
  // Async hit tracking — don't block the response
393
431
  const now = new Date().toISOString();
@@ -0,0 +1,62 @@
1
+ // ─── Conservative near-duplicate detection primitives (Harper-free) ─────────
2
+ // Memory-integrity fix (#526/#548 field regressions). This module is
3
+ // deliberately Harper-free — same rationale as ./bm25.ts and ./scoring.ts —
4
+ // so the cosine+Jaccard co-gate math is unit-testable directly against the
5
+ // SHIPPED code without a live Harper. The DB-touching top-1-cosine lookup
6
+ // (needs `databases`) lives in ./Memory.ts, built on top of these primitives.
7
+ //
8
+ // ── THE INVARIANT ────────────────────────────────────────────────────────────
9
+ // This module NEVER decides whether to write. It only scores a candidate
10
+ // match. Callers (Memory.post / Memory.put) always write the new record; a
11
+ // "conservative match" is surfaced to the caller as a SIGNAL on the response
12
+ // (deduplicated/matchedId/matchConfidence), never as a reason to skip the
13
+ // write. That was the #526 bug: two topically-close but DISTINCT findings —
14
+ // one about replication route-directionality, one about DDL/schema
15
+ // replication — and the second was silently dropped because raw cosine alone
16
+ // flagged it as a "duplicate" of the first. Requiring BOTH cosine AND lexical
17
+ // (Jaccard token-overlap) to clear their thresholds against the SAME single
18
+ // top-cosine candidate catches true near-dups while sparing topic collisions
19
+ // (high cosine, low lexical) — and even then, only ever as a non-suppressing
20
+ // signal.
21
+ import { tokenize } from "./bm25.js";
22
+ /** Default raw-cosine similarity threshold for a candidate to even be considered. */
23
+ export const DEDUP_COSINE_THRESHOLD_DEFAULT = 0.95;
24
+ /** Default Jaccard token-overlap threshold (of the top cosine candidate). */
25
+ export const DEDUP_LEXICAL_THRESHOLD_DEFAULT = 0.5;
26
+ /** Below this content length, similarity scoring is unreliable ("ok" would
27
+ * match "ok" trivially) — the dedup gate is bypassed entirely. */
28
+ export const DEDUP_MIN_CONTENT_LENGTH = 20;
29
+ /** Jaccard similarity of two token sets: |A∩B| / |A∪B|. An empty side yields 0
30
+ * (no signal — never treated as "fully similar" by vacuous set equality). */
31
+ export function jaccardSimilarity(tokensA, tokensB) {
32
+ const a = new Set(tokensA);
33
+ const b = new Set(tokensB);
34
+ if (a.size === 0 || b.size === 0)
35
+ return 0;
36
+ let intersection = 0;
37
+ for (const t of a)
38
+ if (b.has(t))
39
+ intersection++;
40
+ const union = a.size + b.size - intersection;
41
+ return union === 0 ? 0 : intersection / union;
42
+ }
43
+ /**
44
+ * Conservative match = cosine AND lexical BOTH clear their thresholds.
45
+ * Both gates are required — a topic collision (high cosine, low lexical: two
46
+ * DIFFERENT findings that merely share a topic/vocabulary) must NOT be
47
+ * flagged as a duplicate. Only a true near-duplicate (high cosine AND high
48
+ * lexical overlap) is flagged.
49
+ */
50
+ export function isConservativeMatch(cosine, lexical, cosineThreshold = DEDUP_COSINE_THRESHOLD_DEFAULT, lexicalThreshold = DEDUP_LEXICAL_THRESHOLD_DEFAULT) {
51
+ return cosine >= cosineThreshold && lexical >= lexicalThreshold;
52
+ }
53
+ /** Compute the {cosine, lexical} confidence pair for a candidate against the
54
+ * new content's text, using the shared bm25 tokenizer. Rounded to 3dp to
55
+ * match the existing _score rounding convention (SemanticSearch.ts). */
56
+ export function computeMatchConfidence(newContent, candidateContent, cosine) {
57
+ const lexical = jaccardSimilarity(tokenize(newContent), tokenize(candidateContent || ""));
58
+ return {
59
+ cosine: Math.round(cosine * 1000) / 1000,
60
+ lexical: Math.round(lexical * 1000) / 1000,
61
+ };
62
+ }
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
2
2
  import { promises as fsp } from "node:fs";
3
3
  import { homedir, platform } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { getRerankStatus } from "./rerank-provider.js";
5
6
  const db = databases;
6
7
  const redactHome = (p) => {
7
8
  const home = homedir();
@@ -515,6 +516,22 @@ export class HealthDetail extends Resource {
515
516
  catch {
516
517
  stats.bridges = null;
517
518
  }
519
+ // ── Reranker ──
520
+ // Cross-encoder rerank stage. Reports flag/model/mode + live counters so we
521
+ // can see if it's silently degrading (fallbackCount climbing) in prod.
522
+ try {
523
+ const rr = getRerankStatus();
524
+ stats.rerank = rr;
525
+ if (rr.enabled && rr.state === "failed") {
526
+ warnings.push({ level: "warn", message: `reranker enabled but unavailable: ${rr.error ?? "init failed"} — recall falling back to vector order` });
527
+ }
528
+ if (rr.enabled && rr.rerankCount > 0 && rr.fallbackCount > rr.rerankCount) {
529
+ warnings.push({ level: "warn", message: `reranker falling back more than reranking (${rr.fallbackCount} fallbacks / ${rr.rerankCount} reranks) — check latency budget / topN` });
530
+ }
531
+ }
532
+ catch {
533
+ stats.rerank = null;
534
+ }
518
535
  // ── Warnings ──
519
536
  stats.warnings = warnings;
520
537
  // ── Process info ──
@@ -1,17 +1,17 @@
1
1
  /**
2
- * mcp-tools.ts — the 9 curated flair tools for the Model-2 custom /mcp handler.
2
+ * mcp-tools.ts — the 10 curated flair tools for the Model-2 custom /mcp handler.
3
3
  *
4
- * Curated BY CONSTRUCTION: this module implements exactly the 9 tools that the
4
+ * Curated BY CONSTRUCTION: this module implements exactly the 10 tools that the
5
5
  * `@tpsdev-ai/flair-mcp` stdio proxy exposes, each a thin wrapper over the
6
6
  * existing flair Resource handler. No business logic is re-implemented — the
7
7
  * wrapped handlers (Memory / SemanticSearch / BootstrapMemories / Soul /
8
8
  * WorkspaceState / OrgEvent) enforce per-agent scoping/ownership via
9
9
  * `resolveAgentAuth(getContext())`, so the MCP surface inherits the SAME security
10
10
  * model as the signed-REST path. There is no raw CRUD surface — the only way to
11
- * reach the datastore through /mcp is via one of these 9 semantic tools.
11
+ * reach the datastore through /mcp is via one of these 10 semantic tools.
12
12
  *
13
- * memory_search · memory_store · memory_get · memory_delete · bootstrap ·
14
- * soul_set · soul_get · flair_workspace_set · flair_orgevent
13
+ * memory_search · memory_store · memory_update · memory_get · memory_delete ·
14
+ * bootstrap · soul_set · soul_get · flair_workspace_set · flair_orgevent
15
15
  *
16
16
  * ── The scoping seam ────────────────────────────────────────────────────────
17
17
  * The /mcp handler resolves the OAuth token's `sub` → a flair `Agent` id, then
@@ -116,6 +116,58 @@ async function memoryStore(agent, args) {
116
116
  tags: args?.tags,
117
117
  }));
118
118
  }
119
+ /**
120
+ * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
121
+ * integrity fix). Mirrors flair-client's MemoryApi.update() (packages/
122
+ * flair-client/src/client.ts), reimplemented against the resource instance
123
+ * API instead of HTTP since this handler calls the Memory resource directly
124
+ * (same pattern as memoryStore vs the flair-mcp stdio tool). Auth is enforced
125
+ * by Memory.get()/Memory.put()/Memory.post()'s EXISTING ownership checks — no
126
+ * parallel auth logic here.
127
+ *
128
+ * Default (preserveHistory unset/false): read the existing record, merge the
129
+ * new content on top (Harper PUT is full-record replacement — never send a
130
+ * bare partial), clear the stale embedding so the server regenerates it, and
131
+ * PUT the merged record back to the SAME id.
132
+ *
133
+ * preserveHistory: true: write a NEW id with `supersedes: id`. Memory.post()
134
+ * validates/authorizes the supersede (denying a cross-agent supersede without
135
+ * a "write" MemoryGrant) and closes the old record's validTo AFTER the new
136
+ * record is written (never the reverse — see resources/Memory.ts).
137
+ */
138
+ async function memoryUpdate(agent, args) {
139
+ const Cls = await handler("Memory");
140
+ const h = new Cls(undefined, delegationContext(agent));
141
+ const id = args?.id;
142
+ const content = args?.content;
143
+ const preserveHistory = args?.preserveHistory === true;
144
+ const existing = await h.get(id);
145
+ if (!existing) {
146
+ return { error: "memory not found", status: 404 };
147
+ }
148
+ if (preserveHistory) {
149
+ const newId = `${agent.agentId}-${crypto.randomUUID()}`;
150
+ const record = {
151
+ ...existing,
152
+ id: newId,
153
+ content,
154
+ supersedes: id,
155
+ createdAt: new Date().toISOString(),
156
+ };
157
+ delete record.updatedAt;
158
+ delete record.embedding;
159
+ delete record.embeddingModel;
160
+ delete record.validFrom;
161
+ delete record.validTo;
162
+ delete record.archivedAt;
163
+ h.isCollection = true;
164
+ return unwrap(await h.post(record));
165
+ }
166
+ const merged = { ...existing, content, updatedAt: new Date().toISOString() };
167
+ delete merged.embedding;
168
+ delete merged.embeddingModel;
169
+ return unwrap(await h.put(merged));
170
+ }
119
171
  async function memoryGet(agent, args) {
120
172
  const Cls = await handler("Memory");
121
173
  const h = new Cls(undefined, delegationContext(agent));
@@ -231,6 +283,24 @@ export const TOOLS = {
231
283
  },
232
284
  impl: memoryStore,
233
285
  },
286
+ memory_update: {
287
+ def: {
288
+ name: "memory_update",
289
+ description: "Update an existing memory by ID. Dedup-bypassed (this is an intentional overwrite, not a new write). " +
290
+ "Default: overwrites the same id in place. Pass preserveHistory=true to instead write a new version " +
291
+ "linked via `supersedes`, closing the old one's validity window.",
292
+ inputSchema: {
293
+ type: "object",
294
+ properties: {
295
+ id: { type: "string", description: "ID of the memory to update" },
296
+ content: { type: "string", description: "New content" },
297
+ preserveHistory: { type: "boolean", description: "Write a new version (supersedes-linked) instead of overwriting in place (default false)" },
298
+ },
299
+ required: ["id", "content"],
300
+ },
301
+ },
302
+ impl: memoryUpdate,
303
+ },
234
304
  memory_get: {
235
305
  def: {
236
306
  name: "memory_get",
@@ -0,0 +1,361 @@
1
+ /**
2
+ * rerank-provider.ts
3
+ *
4
+ * In-process cross-encoder reranker for Flair's recall path. Mirrors the
5
+ * embeddings-provider.ts shape: dynamic import of node-llama-cpp (deferred to
6
+ * first use to dodge Harper 5.x's VM-sandbox linker race), lazy singleton init,
7
+ * and graceful passthrough on ANY failure — the reranker is best-effort and must
8
+ * NEVER block or break recall.
9
+ *
10
+ * Two inference modes (selected by FLAIR_RERANK_MODEL):
11
+ *
12
+ * 1. "qwen3-reranker-0.6b-q8" (DEFAULT, quality) — Qwen3-Reranker-0.6B is a
13
+ * causal-LM reranker, NOT a rank-pooling cross-encoder. Its GGUF reports
14
+ * supportsRanking=false, so node-llama-cpp's createRankingContext() rejects
15
+ * it. The correct path is generative: format query+doc into the official
16
+ * instruction prompt ending at the assistant turn, evaluate, and read the
17
+ * next-token probability of "yes" vs "no":
18
+ * score = P(yes) / (P(yes) + P(no))
19
+ * Implemented via seq.controlledEvaluate with generateNext probabilities.
20
+ * (Validated offline in RERANK-PILOT-RESULTS.md: 4/4 target cases flipped
21
+ * positive, p@1 6/8→8/8, mean margin 0.028→0.688, ~1.2s / 16 docs on rockit.)
22
+ *
23
+ * 2. "jina-reranker-v2" (latency fallback) — jina-reranker-v2 IS a rank-pooling
24
+ * cross-encoder; its gpustack GGUF loads via createRankingContext()/rankAll
25
+ * (supportsRanking=true). ~145ms / 16 docs but weaker (7/8, leaves the
26
+ * hardest consensus case negative). Selectable where latency is tight.
27
+ *
28
+ * Serving path = the same in-process node-llama-cpp the embedding engine ships.
29
+ * No Ollama (no logprobs, no rerank endpoint — verified), no network hop, no
30
+ * new auth boundary.
31
+ *
32
+ * GGUF files are NOT committed; they are provisioned into models/ alongside the
33
+ * embedding GGUF. See docs/rerank-provisioning.md for download sources.
34
+ */
35
+ import { join } from "node:path";
36
+ // Known reranker models → GGUF filename + inference mode. The default is the
37
+ // quality model (Qwen3 generative). jina is the latency model (rank API).
38
+ const MODELS = {
39
+ "qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
40
+ "jina-reranker-v2": { file: "jina-reranker-v2-base.Q8_0.gguf", mode: "rank" },
41
+ };
42
+ const DEFAULT_MODEL = "qwen3-reranker-0.6b-q8";
43
+ // Official Qwen3-Reranker prompt scaffold (generative yes/no judgement).
44
+ const QWEN_PREFIX = '<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".<|im_end|>\n<|im_start|>user\n';
45
+ const QWEN_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n";
46
+ const QWEN_INSTRUCT = "Given a web search query, retrieve relevant passages that answer the query";
47
+ function buildQwenPrompt(q, doc) {
48
+ return `${QWEN_PREFIX}<Instruct>: ${QWEN_INSTRUCT}\n<Query>: ${q}\n<Document>: ${doc}${QWEN_SUFFIX}`;
49
+ }
50
+ // Cap per-document content fed to the reranker. Short atomic notes are the norm;
51
+ // this bounds context blow-up + latency on the occasional huge memory.
52
+ const MAX_DOC_CHARS = 2000;
53
+ let _state = "uninitialized";
54
+ let _initError;
55
+ let _warnedOnce = false;
56
+ let _modelKey = "";
57
+ let _mode;
58
+ // node-llama-cpp handles (kept alive as a singleton like the embedding engine).
59
+ let _nlc = null;
60
+ let _llama = null;
61
+ let _model = null;
62
+ // generative mode handles
63
+ let _ctx = null;
64
+ let _seq = null;
65
+ let _yesToks = [];
66
+ let _noToks = [];
67
+ // rank mode handle
68
+ let _rankCtx = null;
69
+ // Diagnostics surfaced via getRerankStatus() / health.ts.
70
+ let _lastLatencyMs = null;
71
+ let _fallbackCount = 0;
72
+ let _rerankCount = 0;
73
+ function resolveModelKey() {
74
+ const requested = process.env.FLAIR_RERANK_MODEL?.trim();
75
+ if (requested && MODELS[requested])
76
+ return requested;
77
+ return DEFAULT_MODEL;
78
+ }
79
+ /** Discover the platform addon binary the same way embeddings-provider.ts does. */
80
+ async function findAddonPath() {
81
+ const { existsSync } = await import("node:fs");
82
+ const platforms = ["linux-x64", "mac-arm64-metal", "mac-arm64", "win-x64", "linux-arm64", "mac-x64"];
83
+ for (const platform of platforms) {
84
+ const candidate = join(process.cwd(), "node_modules", "@node-llama-cpp", platform, "bins", platform, "llama-addon.node");
85
+ if (existsSync(candidate))
86
+ return candidate;
87
+ }
88
+ return undefined;
89
+ }
90
+ async function ensureInit() {
91
+ if (_state === "ready")
92
+ return;
93
+ if (_state === "failed")
94
+ return; // already logged once — don't thrash
95
+ try {
96
+ _modelKey = resolveModelKey();
97
+ const spec = MODELS[_modelKey];
98
+ _mode = spec.mode;
99
+ const { existsSync } = await import("node:fs");
100
+ const modelPath = join(process.cwd(), "models", spec.file);
101
+ if (!existsSync(modelPath)) {
102
+ throw new Error(`reranker GGUF not found: models/${spec.file} (provision per docs/rerank-provisioning.md)`);
103
+ }
104
+ // Dynamic import — deferred to avoid Harper 5.x VM linker race (same reason
105
+ // embeddings-provider.ts defers harper-fabric-embeddings).
106
+ if (!_nlc)
107
+ _nlc = await import("node-llama-cpp");
108
+ // node-llama-cpp finds its prebuilt binary from process.cwd()/node_modules
109
+ // when run inside the Flair app dir (Harper's runtime cwd). We don't need to
110
+ // pass the addon path explicitly to getLlama() — but we surface it for
111
+ // diagnostics and as a guard that a binary exists at all.
112
+ const addonPath = await findAddonPath();
113
+ if (!addonPath) {
114
+ throw new Error("no @node-llama-cpp platform addon found under node_modules");
115
+ }
116
+ _llama = await _nlc.getLlama();
117
+ _model = await _llama.loadModel({ modelPath });
118
+ if (_mode === "generative") {
119
+ _ctx = await _model.createContext({ contextSize: 1024 });
120
+ _seq = _ctx.getSequence();
121
+ // Resolve yes/no token ids (both case variants — the post-</think>
122
+ // position puts mass on "yes"/"Yes").
123
+ _yesToks = [_model.tokenize("yes", false)[0], _model.tokenize("Yes", false)[0]].filter((t) => t != null);
124
+ _noToks = [_model.tokenize("no", false)[0], _model.tokenize("No", false)[0]].filter((t) => t != null);
125
+ if (_yesToks.length === 0 || _noToks.length === 0) {
126
+ throw new Error("failed to resolve yes/no token ids for generative reranker");
127
+ }
128
+ }
129
+ else {
130
+ // rank-pooling cross-encoder (jina). Reject if the GGUF isn't a ranking model.
131
+ if (!_model.fileInsights?.supportsRanking) {
132
+ throw new Error(`model ${spec.file} does not support ranking (not a rank-pooling cross-encoder)`);
133
+ }
134
+ // Context must fit query + the longest document (jina concatenates them).
135
+ // 512 is too small for real memories; 2048 covers MAX_DOC_CHARS + query.
136
+ _rankCtx = await _model.createRankingContext({ contextSize: 2048 });
137
+ }
138
+ _state = "ready";
139
+ }
140
+ catch (err) {
141
+ _state = "failed";
142
+ _initError = err?.message || String(err);
143
+ if (!_warnedOnce) {
144
+ console.warn(`[rerank] WARN: reranker unavailable, recall falls back to vector order. Error: ${_initError}`);
145
+ _warnedOnce = true;
146
+ }
147
+ // Best-effort cleanup of any partial handles.
148
+ await disposeHandles().catch(() => { });
149
+ }
150
+ }
151
+ async function disposeHandles() {
152
+ try {
153
+ if (_rankCtx) {
154
+ await _rankCtx.dispose();
155
+ _rankCtx = null;
156
+ }
157
+ if (_ctx) {
158
+ await _ctx.dispose();
159
+ _ctx = null;
160
+ _seq = null;
161
+ }
162
+ if (_model) {
163
+ await _model.dispose();
164
+ _model = null;
165
+ }
166
+ }
167
+ catch { /* ignore */ }
168
+ }
169
+ /** Reset (or recreate) the shared generative sequence to a clean KV state. */
170
+ async function resetSequence() {
171
+ try {
172
+ if (_seq && typeof _seq.clearHistory === "function") {
173
+ await _seq.clearHistory();
174
+ return;
175
+ }
176
+ }
177
+ catch { /* fall through to recreate */ }
178
+ // clearHistory unavailable or threw — recreate the sequence from the context.
179
+ try {
180
+ _seq?.dispose?.();
181
+ }
182
+ catch { /* ignore */ }
183
+ _seq = _ctx.getSequence();
184
+ }
185
+ /** Generative yes/no score for one (query, doc) pair. */
186
+ async function scoreGenerative(q, doc) {
187
+ const tokens = _model.tokenize(buildQwenPrompt(q, doc.slice(0, MAX_DOC_CHARS)), true);
188
+ // Reset the sequence so each pair is scored independently (deterministic) and
189
+ // a prior eval can't poison this one ("Eval has failed" after a bad state).
190
+ await resetSequence();
191
+ const input = tokens.map((t, i) => i === tokens.length - 1 ? [t, { generateNext: { probabilities: true } }] : t);
192
+ const out = await _seq.controlledEvaluate(input);
193
+ const lastDefined = [...out].reverse().find((x) => x !== undefined);
194
+ const probs = lastDefined?.next?.probabilities;
195
+ // Defensive: under some runtimes (observed in Harper's resource worker with a
196
+ // second native llama backend already resident — HFE's raw-addon embedding
197
+ // engine) controlledEvaluate returns an empty result array (no decoded
198
+ // logits). Treat that as "can't score" and signal the caller to fall open,
199
+ // rather than silently returning 0 (which would corrupt the ranking).
200
+ if (out.length === 0 || !probs) {
201
+ throw new Error("generative reranker produced no logits (empty controlledEvaluate result)");
202
+ }
203
+ const pYes = _yesToks.reduce((s, t) => s + (probs.get(t) ?? 0), 0);
204
+ const pNo = _noToks.reduce((s, t) => s + (probs.get(t) ?? 0), 0);
205
+ return pYes + pNo > 0 ? pYes / (pYes + pNo) : 0;
206
+ }
207
+ // Serialize all engine work. node-llama-cpp's context/sequence is single-use:
208
+ // concurrent controlledEvaluate calls on the shared sequence corrupt the KV
209
+ // cache ("Eval has failed"). Harper calls SemanticSearch.post() concurrently,
210
+ // so we funnel every rerank through one in-process queue. Each search still
211
+ // gets the full engine; they just don't overlap on the hardware. The latency
212
+ // budget in rerankCandidates bounds how long any one search waits.
213
+ let _engineChain = Promise.resolve();
214
+ function runExclusive(fn) {
215
+ const next = _engineChain.then(fn, fn);
216
+ // Keep the chain alive regardless of this task's outcome.
217
+ _engineChain = next.then(() => undefined, () => undefined);
218
+ return next;
219
+ }
220
+ /**
221
+ * Score query against a batch of documents. Returns an array of scores in the
222
+ * same order as `docs`, in [0,1]. Higher = more relevant. Throws on engine
223
+ * error (caller catches and falls back to vector order). Engine access is
224
+ * serialized — see runExclusive.
225
+ */
226
+ export async function rerankScores(query, docs) {
227
+ await ensureInit();
228
+ if (_state !== "ready")
229
+ throw new Error("reranker not ready");
230
+ if (_mode === "rank") {
231
+ const trimmed = docs.map((d) => String(d ?? "").slice(0, MAX_DOC_CHARS));
232
+ return runExclusive(() => _rankCtx.rankAll(query, trimmed));
233
+ }
234
+ // generative — score sequentially under the engine lock (single shared
235
+ // sequence; deterministic). The whole batch holds the lock for one search so
236
+ // its sequence resets aren't interleaved with another search's.
237
+ return runExclusive(async () => {
238
+ const scores = [];
239
+ for (const d of docs) {
240
+ scores.push(await scoreGenerative(query, String(d ?? "")));
241
+ }
242
+ return scores;
243
+ });
244
+ }
245
+ /**
246
+ * Rerank `candidates` (each must carry `content`) against `query`. Reorders in
247
+ * place by rerank score and overwrites `_score` with it (so downstream margin
248
+ * measurement reads the rerank score); preserves the original semantic score as
249
+ * `_semScore`. `_rawScore` is intentionally NOT touched (recall-bench's raw mode
250
+ * must stay reproducible).
251
+ *
252
+ * FAIL-OPEN: on init failure, timeout, or any throw, returns the input array
253
+ * UNCHANGED (caller then applies the existing vector-order sort). Never throws.
254
+ */
255
+ export async function rerankCandidates(query, candidates, opts) {
256
+ const top = candidates.slice(0, Math.max(0, opts.topN));
257
+ if (top.length < 2)
258
+ return candidates;
259
+ const t0 = Date.now();
260
+ let timer;
261
+ try {
262
+ const docs = top.map((c) => String(c.content ?? ""));
263
+ const timeout = new Promise((resolve) => {
264
+ timer = setTimeout(() => resolve(null), Math.max(1, opts.budgetMs));
265
+ });
266
+ // Swallow a LATE rejection: if the budget wins the race, the scoring promise
267
+ // is still pending and may reject afterwards — attach a no-op catch so it
268
+ // doesn't surface as an unhandled rejection.
269
+ const scoring = rerankScores(query, docs);
270
+ scoring.catch(() => { });
271
+ const scores = await Promise.race([scoring, timeout]);
272
+ if (timer)
273
+ clearTimeout(timer);
274
+ if (scores === null) {
275
+ // Budget exceeded — abandon rerank, keep vector order. No partial reorder.
276
+ _fallbackCount++;
277
+ return candidates;
278
+ }
279
+ // Attach rerank score, preserve the semantic score for diagnostics.
280
+ const reranked = top.map((c, i) => {
281
+ const sem = c._score;
282
+ c._semScore = sem;
283
+ c._score = Math.round((scores[i] ?? 0) * 1000) / 1000;
284
+ return c;
285
+ });
286
+ reranked.sort((a, b) => (b._score ?? 0) - (a._score ?? 0));
287
+ // Candidates beyond topN (not reranked) keep their vector position AFTER the
288
+ // reranked block — they were already the tail of the vector-ordered pool.
289
+ const tail = candidates.slice(top.length);
290
+ _lastLatencyMs = Date.now() - t0;
291
+ _rerankCount++;
292
+ return [...reranked, ...tail];
293
+ }
294
+ catch (err) {
295
+ if (timer)
296
+ clearTimeout(timer);
297
+ _fallbackCount++;
298
+ if (!_warnedOnce) {
299
+ console.warn(`[rerank] WARN: rerank threw, falling back to vector order. Error: ${err?.message || err}`);
300
+ _warnedOnce = true;
301
+ }
302
+ return candidates;
303
+ }
304
+ }
305
+ /** Is the master flag on? (Default OFF.) */
306
+ export function isRerankEnabled() {
307
+ return process.env.FLAIR_RERANK_ENABLED === "true";
308
+ }
309
+ /** Candidate count fed to the reranker. Caps the HNSW fetch. */
310
+ export function getRerankTopN() {
311
+ const v = Number(process.env.FLAIR_RERANK_TOPN);
312
+ return Number.isFinite(v) && v > 0 ? Math.floor(v) : 50;
313
+ }
314
+ /** Hard latency budget for the whole rerank stage (ms). */
315
+ export function getRerankBudgetMs() {
316
+ const v = Number(process.env.FLAIR_RERANK_BUDGET_MS);
317
+ return Number.isFinite(v) && v > 0 ? Math.floor(v) : 2500;
318
+ }
319
+ /** Skip rerank below this many candidates (nothing to reorder). */
320
+ export function getRerankMinCandidates() {
321
+ const v = Number(process.env.FLAIR_RERANK_MIN_CANDIDATES);
322
+ return Number.isFinite(v) && v >= 2 ? Math.floor(v) : 2;
323
+ }
324
+ /** Status surface for health.ts. */
325
+ export function getRerankStatus() {
326
+ return {
327
+ enabled: isRerankEnabled(),
328
+ model: _modelKey || resolveModelKey(),
329
+ mode: _mode ?? "uninitialized",
330
+ state: _state,
331
+ topN: getRerankTopN(),
332
+ budgetMs: getRerankBudgetMs(),
333
+ lastLatencyMs: _lastLatencyMs,
334
+ rerankCount: _rerankCount,
335
+ fallbackCount: _fallbackCount,
336
+ error: _initError,
337
+ };
338
+ }
339
+ // ── Test seam ────────────────────────────────────────────────────────────────
340
+ // Exported pure helpers so the deterministic scoring path can be unit-tested
341
+ // without loading a 600MB GGUF (mirrors the pilot's deterministic approach:
342
+ // given fixed yes/no probabilities, the score + reorder math is exact).
343
+ /** P(yes)/(P(yes)+P(no)) — the generative reranker's scoring function. */
344
+ export function yesNoScore(pYes, pNo) {
345
+ return pYes + pNo > 0 ? pYes / (pYes + pNo) : 0;
346
+ }
347
+ /**
348
+ * Pure reorder used by rerankCandidates: attach scores, overwrite `_score`,
349
+ * preserve `_semScore`, leave `_rawScore` untouched, sort desc, append the
350
+ * non-reranked tail. Exported for deterministic unit tests.
351
+ */
352
+ export function applyRerank(candidates, scores, topN) {
353
+ const top = candidates.slice(0, Math.max(0, topN));
354
+ const reranked = top.map((c, i) => {
355
+ c._semScore = c._score;
356
+ c._score = Math.round((scores[i] ?? 0) * 1000) / 1000;
357
+ return c;
358
+ });
359
+ reranked.sort((a, b) => (b._score ?? 0) - (a._score ?? 0));
360
+ return [...reranked, ...candidates.slice(top.length)];
361
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -62,6 +62,7 @@
62
62
  "harper-fabric-embeddings": "0.2.3",
63
63
  "jose": "6.2.2",
64
64
  "js-yaml": "4.1.1",
65
+ "node-llama-cpp": "3.18.1",
65
66
  "tar": "7.5.13",
66
67
  "tweetnacl": "1.0.3"
67
68
  },