@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.
package/dist/resources/Memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/resources/health.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
11
|
+
* reach the datastore through /mcp is via one of these 10 semantic tools.
|
|
12
12
|
*
|
|
13
|
-
* memory_search · memory_store ·
|
|
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.
|
|
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
|
},
|