@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.
@@ -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
- if (auth.kind === "agent" && !auth.isAdmin && content.authorId !== auth.agentId) {
27
- return FORBIDDEN("forbidden: authorId must match authenticated agent");
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
- function b64ToArrayBuffer(b64) {
44
- const std = b64.replace(/-/g, "+").replace(/_/g, "/");
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
- // ─── HNSW vector search path ───────────────────────────────────────────
165
- if (qEmb) {
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
- if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
54
- return FORBIDDEN("forbidden: cannot write workspace state for another agent");
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
- function b64ToArrayBuffer(b64) {
31
- // Handle both standard and URL-safe base64.
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
- function b64ToArrayBuffer(b64) {
41
- // Handle both standard and URL-safe base64
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 to where Harper's
33
- // plugin loader downloaded the model (process.cwd() is the app dir).
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 use process.cwd() which points to the
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 = join(process.cwd(), "models");
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) {