@tpsdev-ai/flair 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -9
- package/dist/cli-shim.cjs +66 -0
- package/dist/cli.js +669 -417
- package/dist/fabric-upgrade.js +367 -0
- package/dist/install/clients.js +132 -81
- package/dist/resources/A2AAdapter.js +31 -5
- package/dist/resources/OrgEvent.js +12 -2
- package/dist/resources/Presence.js +3 -9
- package/dist/resources/SemanticSearch.js +124 -2
- package/dist/resources/WorkspaceState.js +12 -2
- package/dist/resources/agent-auth.js +3 -10
- package/dist/resources/auth-middleware.js +3 -10
- package/dist/resources/b64.js +40 -0
- package/dist/resources/bm25-filter.js +84 -0
- package/dist/resources/bm25.js +130 -0
- package/dist/resources/embeddings-provider.js +41 -6
- package/package.json +10 -10
|
@@ -23,8 +23,18 @@ export class OrgEvent extends databases.flair.OrgEvent {
|
|
|
23
23
|
const auth = await this._auth();
|
|
24
24
|
if (auth.kind === "anonymous")
|
|
25
25
|
return UNAUTH();
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// No-forge attribution: a non-admin agent's events are ALWAYS attributed to
|
|
27
|
+
// its authenticated identity (from the Ed25519 signature), never the body —
|
|
28
|
+
// an agent can only publish AS itself. We overwrite `authorId` rather than
|
|
29
|
+
// 403'ing a mismatch so a CLI client never has to echo its own id into the
|
|
30
|
+
// body (mirrors A2A message/send's "sender must match params.agentId" guard
|
|
31
|
+
// and Presence's "agentId from signature, NOT from body"). Admin agents may
|
|
32
|
+
// publish on behalf of another agent (body authorId honored, else their own).
|
|
33
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
34
|
+
content.authorId = auth.agentId;
|
|
35
|
+
}
|
|
36
|
+
else if (auth.kind === "agent" && auth.isAdmin) {
|
|
37
|
+
content.authorId ||= auth.agentId;
|
|
28
38
|
}
|
|
29
39
|
if (!content.id)
|
|
30
40
|
content.id = `${content.authorId}-${new Date().toISOString()}`;
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { databases } from "@harperfast/harper";
|
|
20
20
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
21
|
+
import { b64ToArrayBuffer } from "./b64.js";
|
|
21
22
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
22
23
|
const WINDOW_MS = 30_000;
|
|
23
24
|
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
@@ -40,15 +41,8 @@ function pruneNonces() {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const bin = atob(std);
|
|
46
|
-
const buf = new ArrayBuffer(bin.length);
|
|
47
|
-
const view = new Uint8Array(buf);
|
|
48
|
-
for (let i = 0; i < bin.length; i++)
|
|
49
|
-
view[i] = bin.charCodeAt(i);
|
|
50
|
-
return buf;
|
|
51
|
-
}
|
|
44
|
+
// b64ToArrayBuffer lives in ./b64.ts (shared with auth-middleware.ts + agent-auth.ts
|
|
45
|
+
// so the base64/base64url decoder can't drift across the three auth call sites).
|
|
52
46
|
const keyCache = new Map();
|
|
53
47
|
async function importEd25519Key(publicKeyStr) {
|
|
54
48
|
if (keyCache.has(publicKeyStr))
|
|
@@ -8,6 +8,11 @@ import { wrapUntrusted } from "./content-safety.js";
|
|
|
8
8
|
// relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
|
|
9
9
|
// unit-tested directly (see test/unit/temporal-scoring.test.ts).
|
|
10
10
|
import { compositeScore } from "./scoring.js";
|
|
11
|
+
// BM25 + union-RRF hybrid retrieval (ops-i39b / FLAIR-BM25-HYBRID-RETRIEVAL).
|
|
12
|
+
// Harper-free modules so the BM25 scoring, the candidate-union RRF, and the
|
|
13
|
+
// SECURITY conditions-filter are unit-tested against the shipped code.
|
|
14
|
+
import { buildBM25, fuseRrfNormalized, hybridEnabled, SEM_LIMIT } from "./bm25.js";
|
|
15
|
+
import { isAllowedBm25Candidate } from "./bm25-filter.js";
|
|
11
16
|
// Convert HNSW cosine distance (1 - similarity) to similarity score
|
|
12
17
|
function distanceToSimilarity(distance) {
|
|
13
18
|
return 1 - distance;
|
|
@@ -15,6 +20,9 @@ function distanceToSimilarity(distance) {
|
|
|
15
20
|
// Candidate multiplier: fetch more candidates than needed from the HNSW index
|
|
16
21
|
// so composite re-ranking has enough headroom to reorder results.
|
|
17
22
|
const CANDIDATE_MULTIPLIER = 5;
|
|
23
|
+
// The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
|
|
24
|
+
// (imported from ./bm25 — Harper-free so it's unit-testable). Flag OFF (default)
|
|
25
|
+
// → byte-identical to the pre-hybrid HNSW + +0.05 keyword-bump behavior.
|
|
18
26
|
export class SemanticSearch extends Resource {
|
|
19
27
|
// Self-authorize via the Ed25519 agent verify instead of relying on the auth
|
|
20
28
|
// gate's admin super_user elevation (removed in the auth reshape). Any
|
|
@@ -161,8 +169,122 @@ export class SemanticSearch extends Resource {
|
|
|
161
169
|
}
|
|
162
170
|
}
|
|
163
171
|
const results = [];
|
|
164
|
-
|
|
165
|
-
if (
|
|
172
|
+
const hybrid = hybridEnabled();
|
|
173
|
+
if (hybrid) {
|
|
174
|
+
// ─── BM25 + union-RRF hybrid path (ops-i39b) ─────────────────────────
|
|
175
|
+
// 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
|
|
176
|
+
// over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
|
|
177
|
+
// by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
|
|
178
|
+
// is fetched with those conditions, AND re-checked in-process as
|
|
179
|
+
// defense-in-depth) so no other agent's memory is ever scored or fused.
|
|
180
|
+
// 4. Candidate-union RRF → normalize → feed as rawScore to compositeScore.
|
|
181
|
+
const ctx = this.getContext?.();
|
|
182
|
+
// ── (a) Semantic candidate records (best-first) ──────────────────────
|
|
183
|
+
// Same HNSW query as the legacy path; we keep the raw records so the BM25
|
|
184
|
+
// pass + fusion can re-derive rawScore. No embedding → empty semantic list
|
|
185
|
+
// (RRF degrades naturally to BM25-only).
|
|
186
|
+
const semRecords = [];
|
|
187
|
+
const semIds = [];
|
|
188
|
+
if (qEmb) {
|
|
189
|
+
const candidateLimit = limit * CANDIDATE_MULTIPLIER;
|
|
190
|
+
const semQuery = {
|
|
191
|
+
sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
|
|
192
|
+
select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
|
|
193
|
+
"source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
|
|
194
|
+
"promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
|
|
195
|
+
"parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
|
|
196
|
+
"validFrom", "validTo", "_safetyFlags", "$distance"],
|
|
197
|
+
limit: candidateLimit,
|
|
198
|
+
};
|
|
199
|
+
if (conditions.length > 0)
|
|
200
|
+
semQuery.conditions = conditions;
|
|
201
|
+
const semResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(semQuery));
|
|
202
|
+
for await (const record of semResults) {
|
|
203
|
+
// Same per-record temporal gate as the legacy HNSW loop.
|
|
204
|
+
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
205
|
+
continue;
|
|
206
|
+
if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
|
|
207
|
+
continue;
|
|
208
|
+
if (asOf && record.validFrom && record.validFrom > asOf)
|
|
209
|
+
continue;
|
|
210
|
+
if (asOf && record.validTo && record.validTo <= asOf)
|
|
211
|
+
continue;
|
|
212
|
+
semRecords.push(record);
|
|
213
|
+
semIds.push(record.id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// ── (b) BM25 candidate records over the SCOPED corpus ────────────────
|
|
217
|
+
// SECURITY: fetch the corpus WITH the same conditions[] so Harper applies
|
|
218
|
+
// the agent boundary, then re-apply the identical predicate + temporal
|
|
219
|
+
// filters in-process (isAllowedBm25Candidate) BEFORE building/scoring the
|
|
220
|
+
// index. The BM25 index therefore only ever contains the caller's allowed
|
|
221
|
+
// memories — no other agent's content/term-frequency enters scoring or
|
|
222
|
+
// fusion. This is the conditions-filter-before-fusion gate.
|
|
223
|
+
// Explicit select (same fields as the HNSW path, no embedding / $distance)
|
|
224
|
+
// so the large embedding vector is never fetched into the BM25 corpus and
|
|
225
|
+
// never spread into a result payload.
|
|
226
|
+
const corpusSelect = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
|
|
227
|
+
"source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
|
|
228
|
+
"promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
|
|
229
|
+
"parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
|
|
230
|
+
"validFrom", "validTo", "_safetyFlags"];
|
|
231
|
+
const corpusQuery = conditions.length > 0
|
|
232
|
+
? { conditions, select: corpusSelect }
|
|
233
|
+
: { select: corpusSelect };
|
|
234
|
+
const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
|
|
235
|
+
const allowedById = new Map();
|
|
236
|
+
const bm25Docs = [];
|
|
237
|
+
for await (const record of corpusResults) {
|
|
238
|
+
// Defense-in-depth: re-check the SAME conditions[] + temporal filters
|
|
239
|
+
// in-process. Even if a Harper query change ever let an out-of-scope
|
|
240
|
+
// record through, it is dropped here BEFORE it can be BM25-scored/fused.
|
|
241
|
+
if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
|
|
242
|
+
continue;
|
|
243
|
+
allowedById.set(record.id, record);
|
|
244
|
+
bm25Docs.push({ id: record.id, content: record.content });
|
|
245
|
+
}
|
|
246
|
+
// Carry semantic candidates that survived their temporal gate into the
|
|
247
|
+
// allowed map too (so a fused id always resolves to a record). Semantic
|
|
248
|
+
// records were fetched with the SAME conditions[], so they're in-scope.
|
|
249
|
+
for (const r of semRecords) {
|
|
250
|
+
if (!allowedById.has(r.id)) {
|
|
251
|
+
const { $distance, ...rest } = r;
|
|
252
|
+
allowedById.set(r.id, rest);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// ── (c) BM25 lexical ranking → top SEM_LIMIT (only when q present) ────
|
|
256
|
+
let bm25Ids = [];
|
|
257
|
+
if (q) {
|
|
258
|
+
const bm25 = buildBM25(bm25Docs);
|
|
259
|
+
const ranked = bm25.rank(String(q));
|
|
260
|
+
// Drop zero-score docs (no query-term overlap → contribute nothing) and
|
|
261
|
+
// cap at SEM_LIMIT — the production BM25 candidate window.
|
|
262
|
+
bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
|
|
263
|
+
}
|
|
264
|
+
// ── (d) Candidate-union RRF → normalized [0,1] rawScore ──────────────
|
|
265
|
+
// Union dedupes semantic ∪ bm25 ids; absent-from-a-list = 0 contribution.
|
|
266
|
+
const fused = fuseRrfNormalized(semIds, bm25Ids);
|
|
267
|
+
for (const [id, rrfRaw] of fused) {
|
|
268
|
+
const record = allowedById.get(id);
|
|
269
|
+
if (!record)
|
|
270
|
+
continue; // should not happen — union ⊆ allowed
|
|
271
|
+
const rawScore = rrfRaw; // already normalized to [0,1]
|
|
272
|
+
let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
|
|
273
|
+
if (temporalBoost > 1.0)
|
|
274
|
+
finalScore *= temporalBoost;
|
|
275
|
+
const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
|
|
276
|
+
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
277
|
+
results.push({
|
|
278
|
+
...record,
|
|
279
|
+
content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
|
|
280
|
+
_score: Math.round(finalScore * 1000) / 1000,
|
|
281
|
+
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
282
|
+
_source: source,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else if (qEmb) {
|
|
287
|
+
// ─── HNSW vector search path ───────────────────────────────────────────
|
|
166
288
|
const candidateLimit = limit * CANDIDATE_MULTIPLIER;
|
|
167
289
|
const query = {
|
|
168
290
|
sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
|
|
@@ -50,8 +50,18 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
50
50
|
// there was no authenticated agent, so anonymous could write any record).
|
|
51
51
|
if (auth.kind === "anonymous")
|
|
52
52
|
return UNAUTH();
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// No-forge: a non-admin agent's workspace record is ALWAYS attributed to the
|
|
54
|
+
// authenticated identity (from the Ed25519 signature), never the body. We do
|
|
55
|
+
// NOT trust `content.agentId` — overwriting it (rather than 403'ing a
|
|
56
|
+
// mismatch) mirrors Presence.post(): "agentId from signature, NOT from body".
|
|
57
|
+
// An admin may write on behalf of another agent (content.agentId honored if
|
|
58
|
+
// present, else defaults to the admin's own id). Internal in-process callers
|
|
59
|
+
// keep whatever agentId they pass.
|
|
60
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
61
|
+
content.agentId = auth.agentId;
|
|
62
|
+
}
|
|
63
|
+
else if (auth.kind === "agent" && auth.isAdmin) {
|
|
64
|
+
content.agentId ||= auth.agentId;
|
|
55
65
|
}
|
|
56
66
|
content.createdAt = new Date().toISOString();
|
|
57
67
|
content.timestamp ||= content.createdAt;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
|
|
17
17
|
*/
|
|
18
18
|
import { databases } from "@harperfast/harper";
|
|
19
|
+
import { b64ToArrayBuffer } from "./b64.js";
|
|
19
20
|
const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
20
21
|
/**
|
|
21
22
|
* Shared Harper user that verified Ed25519 agents resolve to (least-privilege
|
|
@@ -27,16 +28,8 @@ export const FLAIR_AGENT_USERNAME = "flair-agent";
|
|
|
27
28
|
// Replay protection: remember recently-seen (agent:nonce), pruned by the window.
|
|
28
29
|
const nonceSeen = new Map();
|
|
29
30
|
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
33
|
-
const bin = atob(std);
|
|
34
|
-
const buf = new ArrayBuffer(bin.length);
|
|
35
|
-
const view = new Uint8Array(buf);
|
|
36
|
-
for (let i = 0; i < bin.length; i++)
|
|
37
|
-
view[i] = bin.charCodeAt(i);
|
|
38
|
-
return buf;
|
|
39
|
-
}
|
|
31
|
+
// b64ToArrayBuffer lives in ./b64.ts (shared with auth-middleware.ts + Presence.ts
|
|
32
|
+
// so the base64/base64url decoder can't drift across the three auth call sites).
|
|
40
33
|
const keyCache = new Map();
|
|
41
34
|
async function importEd25519Key(publicKeyStr) {
|
|
42
35
|
const cached = keyCache.get(publicKeyStr);
|
|
@@ -2,6 +2,7 @@ import { patchRecord } from "./table-helpers.js";
|
|
|
2
2
|
import { server, databases } from "@harperfast/harper";
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
4
|
import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
5
|
+
import { b64ToArrayBuffer } from "./b64.js";
|
|
5
6
|
// --- Admin credentials ---
|
|
6
7
|
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
7
8
|
// (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
|
|
@@ -37,16 +38,8 @@ const nonceSeen = new Map();
|
|
|
37
38
|
// auth reshape this gate and the per-resource allow* helpers must agree on who's
|
|
38
39
|
// an admin — one implementation guarantees they can't diverge.
|
|
39
40
|
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const std = b64.replace(/-/g, '+').replace(/_/g, '/');
|
|
43
|
-
const bin = atob(std);
|
|
44
|
-
const buf = new ArrayBuffer(bin.length);
|
|
45
|
-
const view = new Uint8Array(buf);
|
|
46
|
-
for (let i = 0; i < bin.length; i++)
|
|
47
|
-
view[i] = bin.charCodeAt(i);
|
|
48
|
-
return buf;
|
|
49
|
-
}
|
|
41
|
+
// b64ToArrayBuffer lives in ./b64.ts (shared with agent-auth.ts + Presence.ts so
|
|
42
|
+
// the base64/base64url decoder can't drift across the three auth call sites).
|
|
50
43
|
const keyCache = new Map();
|
|
51
44
|
async function importEd25519Key(publicKeyStr) {
|
|
52
45
|
if (keyCache.has(publicKeyStr))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* b64 — single shared base64 / base64url decoder for Ed25519 auth.
|
|
3
|
+
*
|
|
4
|
+
* This was previously copy-pasted into three files (auth-middleware.ts,
|
|
5
|
+
* agent-auth.ts, Presence.ts) and they drifted: some copies fed url-safe input
|
|
6
|
+
* straight to `atob`, which rejects `-`/`_` ("Invalid character") and 401s any
|
|
7
|
+
* agent whose public key (or signature) is base64url-encoded. Found in the
|
|
8
|
+
* Rivet × krais cross-org dogfood: an Agent registered with a base64url pubkey
|
|
9
|
+
* containing `-`/`_` failed signature verification. One shared decoder so the
|
|
10
|
+
* three call sites can't diverge again (same lesson as HarperFast/harper#1466).
|
|
11
|
+
*
|
|
12
|
+
* Accepts BOTH standard base64 (`+` `/`, with optional `=` padding) AND
|
|
13
|
+
* base64url (`-` `_`, padded or unpadded). Standard input is unchanged.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Decode a base64 OR base64url string to an ArrayBuffer.
|
|
17
|
+
*
|
|
18
|
+
* Normalizes url-safe alphabet (`-`→`+`, `_`→`/`) and right-pads with `=` to a
|
|
19
|
+
* length that is a multiple of 4 before calling `atob`, so unpadded base64url
|
|
20
|
+
* (the common JWK / Buffer.toString('base64url') form — e.g. a 32-byte key is
|
|
21
|
+
* 43 chars, a 64-byte signature is 86 chars) decodes correctly regardless of
|
|
22
|
+
* how lenient the host runtime's `atob` is about missing padding.
|
|
23
|
+
*/
|
|
24
|
+
export function b64ToArrayBuffer(b64) {
|
|
25
|
+
// Normalize base64url → standard base64 alphabet.
|
|
26
|
+
let std = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
27
|
+
// Right-pad to a multiple of 4 so atob accepts unpadded base64url input.
|
|
28
|
+
const remainder = std.length % 4;
|
|
29
|
+
if (remainder === 2)
|
|
30
|
+
std += "==";
|
|
31
|
+
else if (remainder === 3)
|
|
32
|
+
std += "=";
|
|
33
|
+
// remainder === 1 is not a valid base64 length; let atob throw as before.
|
|
34
|
+
const bin = atob(std);
|
|
35
|
+
const buf = new ArrayBuffer(bin.length);
|
|
36
|
+
const view = new Uint8Array(buf);
|
|
37
|
+
for (let i = 0; i < bin.length; i++)
|
|
38
|
+
view[i] = bin.charCodeAt(i);
|
|
39
|
+
return buf;
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// ─── BM25 candidate SECURITY filter (the hard cross-agent trust boundary) ────
|
|
2
|
+
// Per spec FLAIR-BM25-HYBRID-RETRIEVAL §26 + Sherlock's gate (§45-46):
|
|
3
|
+
//
|
|
4
|
+
// "apply the SAME conditions[] filter (agent scoping, archived exclusion,
|
|
5
|
+
// tag/subject) to the BM25 candidates BEFORE fusion. BM25-scoring other
|
|
6
|
+
// agents' private memories and filtering only after fusion would leak
|
|
7
|
+
// term-frequency / content metadata across agent boundaries. The conditions
|
|
8
|
+
// filter MUST precede the union/fusion."
|
|
9
|
+
//
|
|
10
|
+
// The HNSW path gets this filter FOR FREE — Harper applies conditions[] inside
|
|
11
|
+
// Memory.search(). The BM25 pass runs in-process over the corpus, so it MUST
|
|
12
|
+
// re-apply the IDENTICAL predicate itself, BEFORE any scoring is fused or even
|
|
13
|
+
// returned. This module is the single source of that predicate.
|
|
14
|
+
//
|
|
15
|
+
// Harper-free + pure so the security test exercises the SHIPPED predicate
|
|
16
|
+
// directly (test/unit/bm25-security.test.ts).
|
|
17
|
+
//
|
|
18
|
+
// The condition shapes mirror EXACTLY what SemanticSearch.ts builds:
|
|
19
|
+
// - leaf: { attribute, comparator, value }
|
|
20
|
+
// - group: { operator: "or", conditions: [...] }
|
|
21
|
+
// supported comparators: "equals", "not_equal" (the only ones SemanticSearch
|
|
22
|
+
// emits into conditions[]). An unknown comparator/shape is treated as NON-matching
|
|
23
|
+
// (fail closed — never leak on an unrecognized condition).
|
|
24
|
+
function isGroup(c) {
|
|
25
|
+
return c.operator !== undefined && Array.isArray(c.conditions);
|
|
26
|
+
}
|
|
27
|
+
// Evaluate a single leaf condition against a record. `tags` is array-valued in
|
|
28
|
+
// Harper; "equals" on an array attribute is membership (matches Harper's
|
|
29
|
+
// array-attribute equals semantics used by the tag filter).
|
|
30
|
+
function matchLeaf(cond, record) {
|
|
31
|
+
const actual = record?.[cond.attribute];
|
|
32
|
+
switch (cond.comparator) {
|
|
33
|
+
case "equals":
|
|
34
|
+
if (Array.isArray(actual))
|
|
35
|
+
return actual.includes(cond.value);
|
|
36
|
+
return actual === cond.value;
|
|
37
|
+
case "not_equal":
|
|
38
|
+
// Harper "not_equal" semantics used for `archived not_equal true`: a record
|
|
39
|
+
// WITHOUT the field (undefined) is included — it is "not equal" to true.
|
|
40
|
+
if (Array.isArray(actual))
|
|
41
|
+
return !actual.includes(cond.value);
|
|
42
|
+
return actual !== cond.value;
|
|
43
|
+
default:
|
|
44
|
+
// Unknown comparator → fail closed (do NOT pass a record on a condition we
|
|
45
|
+
// can't evaluate; that could leak across the agent boundary).
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function matchCondition(cond, record) {
|
|
50
|
+
if (isGroup(cond)) {
|
|
51
|
+
const results = cond.conditions.map((c) => matchCondition(c, record));
|
|
52
|
+
return cond.operator === "or" ? results.some(Boolean) : results.every(Boolean);
|
|
53
|
+
}
|
|
54
|
+
return matchLeaf(cond, record);
|
|
55
|
+
}
|
|
56
|
+
// AND across the top-level conditions[] (Harper's default for a conditions array
|
|
57
|
+
// — same as SemanticSearch passing them as the implicit-AND query.conditions).
|
|
58
|
+
export function matchesConditions(conditions, record) {
|
|
59
|
+
for (const c of conditions) {
|
|
60
|
+
if (!matchCondition(c, record))
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
export function passesRecordFilters(record, f = {}) {
|
|
66
|
+
const now = f.now ?? Date.now();
|
|
67
|
+
if (record?.expiresAt && Date.parse(record.expiresAt) < now)
|
|
68
|
+
return false;
|
|
69
|
+
if (f.sinceDate && record?.createdAt && new Date(record.createdAt) < f.sinceDate)
|
|
70
|
+
return false;
|
|
71
|
+
if (f.asOf && record?.validFrom && record.validFrom > f.asOf)
|
|
72
|
+
return false;
|
|
73
|
+
if (f.asOf && record?.validTo && record.validTo <= f.asOf)
|
|
74
|
+
return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
// The full BM25-candidate security filter: a record is a valid BM25 candidate
|
|
78
|
+
// ONLY if it passes BOTH the agent-scoping/archived/tag/subject conditions[] AND
|
|
79
|
+
// the per-record temporal filters — IDENTICAL to the HNSW candidate gate. Apply
|
|
80
|
+
// this to the corpus BEFORE building/scoring the union (defense at the boundary,
|
|
81
|
+
// not after fusion).
|
|
82
|
+
export function isAllowedBm25Candidate(record, conditions, timeFilters = {}) {
|
|
83
|
+
return matchesConditions(conditions, record) && passesRecordFilters(record, timeFilters);
|
|
84
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// ─── BM25 + union-RRF hybrid retrieval (Harper-free, unit-testable) ──────────
|
|
2
|
+
// Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved, ops-i39b). This module is
|
|
3
|
+
// deliberately Harper-free — same rationale as ./scoring.ts — so the BM25
|
|
4
|
+
// scoring, the candidate-union RRF fusion, and the security conditions-filter
|
|
5
|
+
// can be unit-tested directly against the SHIPPED code without a live Harper.
|
|
6
|
+
//
|
|
7
|
+
// Ported from the pilot ops/tools/agent-fabric/bm25-rrf-pilot.mjs (commit
|
|
8
|
+
// 5552320), which validated: BM25 alone recovers 5/6 severe misses into top-3;
|
|
9
|
+
// candidate-UNION RRF (NOT naive whole-corpus RRF) recovers 4/6 into top-10 with
|
|
10
|
+
// no regression on the within-cluster gate (p@3 holds 0.88).
|
|
11
|
+
// ─── Feature flag: BM25 + union-RRF hybrid retrieval ────────────────────────
|
|
12
|
+
// Flag OFF (default) → SemanticSearch behavior is byte-identical to the
|
|
13
|
+
// pre-hybrid path (HNSW + the +0.05 exact-substring keyword bump). Flag ON → the
|
|
14
|
+
// hybrid path. Toggle with FLAIR_HYBRID_RETRIEVAL=true (also "1" / "on"). Read
|
|
15
|
+
// per-call so it can be flipped without a rebuild and set per-case in tests.
|
|
16
|
+
// Lives here (Harper-free) so it's unit-testable.
|
|
17
|
+
export function hybridEnabled() {
|
|
18
|
+
const v = (process.env.FLAIR_HYBRID_RETRIEVAL ?? "").toLowerCase();
|
|
19
|
+
return v === "true" || v === "1" || v === "on";
|
|
20
|
+
}
|
|
21
|
+
// BM25 parameters (Kern-approved): k1≈1.2, b≈0.75; standard IDF + BM25.
|
|
22
|
+
export const BM25_K1 = 1.2;
|
|
23
|
+
export const BM25_B = 0.75;
|
|
24
|
+
// RRF constant (Cormack et al. 2009 default). A doc absent from a list
|
|
25
|
+
// contributes 0 from that list (rank = ∞).
|
|
26
|
+
export const RRF_K = 60;
|
|
27
|
+
// BM25 candidate window — top-N by BM25 score fused into the union (spec §35:
|
|
28
|
+
// "BM25 uses a fixed SEM_LIMIT=50"). Independent of CANDIDATE_MULTIPLIER (the
|
|
29
|
+
// HNSW fetch size, which is left untouched).
|
|
30
|
+
export const SEM_LIMIT = 50;
|
|
31
|
+
// Tokenize: lowercase, split on non-alphanumeric, drop trivial stopwords and
|
|
32
|
+
// 1-char tokens. Standard, language-agnostic enough for the corpus.
|
|
33
|
+
const STOP = new Set(("a an the and or but of to in on at for with from by as is are was were be been being " +
|
|
34
|
+
"this that these those it its do does did so if then than when how what why who whom which while " +
|
|
35
|
+
"i you he she we they them his her our your their not no yes can will would should could may might " +
|
|
36
|
+
"have has had get got into out over under again about up down off all any each").split(" "));
|
|
37
|
+
export function tokenize(text) {
|
|
38
|
+
return ((text || "").toLowerCase().match(/[a-z0-9]+/g) || []).filter((t) => t.length > 1 && !STOP.has(t));
|
|
39
|
+
}
|
|
40
|
+
// Build a BM25 index over docs[].content. Standard Robertson/Sparck-Jones BM25
|
|
41
|
+
// with the +1 IDF variant (always non-negative — the common Lucene/Elasticsearch
|
|
42
|
+
// form).
|
|
43
|
+
export function buildBM25(docs) {
|
|
44
|
+
const N = docs.length;
|
|
45
|
+
const docTokens = docs.map((d) => tokenize(d.content || ""));
|
|
46
|
+
const docLen = docTokens.map((t) => t.length);
|
|
47
|
+
const avgdl = docLen.reduce((s, x) => s + x, 0) / (N || 1);
|
|
48
|
+
const tfPerDoc = docTokens.map((toks) => {
|
|
49
|
+
const tf = new Map();
|
|
50
|
+
for (const t of toks)
|
|
51
|
+
tf.set(t, (tf.get(t) || 0) + 1);
|
|
52
|
+
return tf;
|
|
53
|
+
});
|
|
54
|
+
const df = new Map();
|
|
55
|
+
for (const tf of tfPerDoc)
|
|
56
|
+
for (const term of tf.keys())
|
|
57
|
+
df.set(term, (df.get(term) || 0) + 1);
|
|
58
|
+
const idf = new Map();
|
|
59
|
+
for (const [term, n] of df)
|
|
60
|
+
idf.set(term, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
|
|
61
|
+
function rank(query) {
|
|
62
|
+
const qToks = [...new Set(tokenize(query))];
|
|
63
|
+
const scored = docs.map((d, i) => {
|
|
64
|
+
const tf = tfPerDoc[i];
|
|
65
|
+
const dl = docLen[i];
|
|
66
|
+
let s = 0;
|
|
67
|
+
for (const term of qToks) {
|
|
68
|
+
const f = tf.get(term);
|
|
69
|
+
if (!f)
|
|
70
|
+
continue;
|
|
71
|
+
const numer = f * (BM25_K1 + 1);
|
|
72
|
+
const denom = f + BM25_K1 * (1 - BM25_B + BM25_B * (dl / (avgdl || 1)));
|
|
73
|
+
s += (idf.get(term) || 0) * (numer / denom);
|
|
74
|
+
}
|
|
75
|
+
return { id: d.id, score: s };
|
|
76
|
+
});
|
|
77
|
+
scored.sort((a, b) => b.score - a.score);
|
|
78
|
+
return scored;
|
|
79
|
+
}
|
|
80
|
+
return { rank, get N() { return N; }, get avgdl() { return avgdl; } };
|
|
81
|
+
}
|
|
82
|
+
// ─── Reciprocal Rank Fusion over a candidate UNION ──────────────────────────
|
|
83
|
+
// rankings: array of ordered id-lists (best-first). A doc absent from a list
|
|
84
|
+
// contributes 0 from that list. `universe` = the id set fused over — for the
|
|
85
|
+
// production hybrid this is the candidate UNION (semantic ∪ bm25), NOT the whole
|
|
86
|
+
// corpus (naive whole-corpus RRF FAILS — the broken semantic list floods the
|
|
87
|
+
// fusion and buries BM25's rank-1 hits; pilot confirmed 0/6).
|
|
88
|
+
//
|
|
89
|
+
// Returns a Map id → raw RRF score. Caller normalizes + sorts.
|
|
90
|
+
export function rrfScores(rankings, universe) {
|
|
91
|
+
const score = new Map();
|
|
92
|
+
for (const id of universe)
|
|
93
|
+
score.set(id, 0);
|
|
94
|
+
for (const list of rankings) {
|
|
95
|
+
list.forEach((id, idx) => {
|
|
96
|
+
if (!score.has(id))
|
|
97
|
+
return; // doc not in this universe (union mode)
|
|
98
|
+
score.set(id, (score.get(id) || 0) + 1 / (RRF_K + idx + 1)); // idx+1 = 1-based rank
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return score;
|
|
102
|
+
}
|
|
103
|
+
// Fuse semantic + BM25 candidate id-lists via candidate-union RRF and return a
|
|
104
|
+
// per-id score normalized to [0,1] (rrf / max_rrf_in_union). This normalized
|
|
105
|
+
// value is the rawScore fed to compositeScore so durability/recency/rBoost and
|
|
106
|
+
// the RBOOST_RELEVANCE_FLOOR / minScore thresholds still apply unchanged.
|
|
107
|
+
//
|
|
108
|
+
// semIds — semantic candidate ids, best-first (from the HNSW pass).
|
|
109
|
+
// bm25Ids — BM25 candidate ids, best-first, already sliced to SEM_LIMIT and
|
|
110
|
+
// already SECURITY-FILTERED (see filterBm25Candidates) BEFORE this call.
|
|
111
|
+
//
|
|
112
|
+
// The union dedupes ids across both lists. Absent-from-a-list = 0 contribution.
|
|
113
|
+
export function fuseRrfNormalized(semIds, bm25Ids) {
|
|
114
|
+
const union = new Set([...semIds, ...bm25Ids]);
|
|
115
|
+
const raw = rrfScores([semIds, bm25Ids], union);
|
|
116
|
+
let maxRrf = 0;
|
|
117
|
+
for (const v of raw.values())
|
|
118
|
+
if (v > maxRrf)
|
|
119
|
+
maxRrf = v;
|
|
120
|
+
const norm = new Map();
|
|
121
|
+
if (maxRrf <= 0) {
|
|
122
|
+
// Degenerate (empty union) — nothing to normalize.
|
|
123
|
+
for (const [id] of raw)
|
|
124
|
+
norm.set(id, 0);
|
|
125
|
+
return norm;
|
|
126
|
+
}
|
|
127
|
+
for (const [id, v] of raw)
|
|
128
|
+
norm.set(id, v / maxRrf);
|
|
129
|
+
return norm;
|
|
130
|
+
}
|
|
@@ -9,6 +9,39 @@
|
|
|
9
9
|
* the VM linker entirely.
|
|
10
10
|
*/
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the directory the embeddings model lives in / downloads into.
|
|
16
|
+
*
|
|
17
|
+
* Resolution order (everything writable, never the read-only package dir):
|
|
18
|
+
* 1. FLAIR_MODELS_DIR — explicit operator/docker override.
|
|
19
|
+
* 2. <ROOTPATH>/models — Harper's data dir (Flair passes ROOTPATH =
|
|
20
|
+
* ~/.flair/data when it spawns Harper). User-
|
|
21
|
+
* owned and writable even on sudo-global installs.
|
|
22
|
+
* 3. <cwd>/models — ONLY if a model already lives there. Backward
|
|
23
|
+
* compat for existing writable installs that
|
|
24
|
+
* downloaded into the package dir before this fix;
|
|
25
|
+
* never used as a download target on fresh installs.
|
|
26
|
+
* 4. ~/.flair/data/models — last-resort default when ROOTPATH is unset.
|
|
27
|
+
*
|
|
28
|
+
* The chosen dir is always writable, so the embeddings engine can download the
|
|
29
|
+
* model on first use without hitting EACCES on a root-owned package dir.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveModelsDir() {
|
|
32
|
+
const override = process.env.FLAIR_MODELS_DIR;
|
|
33
|
+
if (override)
|
|
34
|
+
return override;
|
|
35
|
+
const rootPath = process.env.ROOTPATH;
|
|
36
|
+
if (rootPath)
|
|
37
|
+
return join(rootPath, "models");
|
|
38
|
+
// Backward compat: a prior (writable) install may have the model cached in
|
|
39
|
+
// the package dir. Reuse it rather than re-downloading — but only if present.
|
|
40
|
+
const cwdModels = join(process.cwd(), "models");
|
|
41
|
+
if (existsSync(cwdModels))
|
|
42
|
+
return cwdModels;
|
|
43
|
+
return join(homedir(), ".flair", "data", "models");
|
|
44
|
+
}
|
|
12
45
|
let _state = "uninitialized";
|
|
13
46
|
let _initError;
|
|
14
47
|
let _warnedOnce = false;
|
|
@@ -29,19 +62,21 @@ async function ensureInit() {
|
|
|
29
62
|
return;
|
|
30
63
|
}
|
|
31
64
|
catch {
|
|
32
|
-
// Not initialized — init with modelsDir pointing
|
|
33
|
-
//
|
|
65
|
+
// Not initialized — init with modelsDir pointing at a USER-WRITABLE
|
|
66
|
+
// location. On a sudo/root-owned global install the Flair package dir
|
|
67
|
+
// (process.cwd()) is root-owned, so a model download into <cwd>/models
|
|
68
|
+
// fails with EACCES and semantic search silently dies (ops-am0v). The
|
|
69
|
+
// model — and everything else Flair writes — must live under ~/.flair.
|
|
70
|
+
//
|
|
34
71
|
// NOTE: import.meta.dirname and __dirname are both undefined in Harper v5's
|
|
35
|
-
// VM sandbox / worker threads, so we
|
|
36
|
-
// Flair application directory.
|
|
72
|
+
// VM sandbox / worker threads, so we resolve from env + process.cwd().
|
|
37
73
|
try {
|
|
38
74
|
if (!_hfe) {
|
|
39
75
|
_hfe = await import("harper-fabric-embeddings");
|
|
40
76
|
}
|
|
41
|
-
const modelsDir =
|
|
77
|
+
const modelsDir = resolveModelsDir();
|
|
42
78
|
// Find the native addon binary explicitly to avoid __dirname-dependent
|
|
43
79
|
// discovery in @node-llama-cpp which fails in Harper's VM sandbox.
|
|
44
|
-
const { existsSync } = await import("node:fs");
|
|
45
80
|
const platforms = ["linux-x64", "mac-arm64-metal", "mac-arm64", "win-x64"];
|
|
46
81
|
let addonPath;
|
|
47
82
|
for (const platform of platforms) {
|