@tpsdev-ai/flair 0.21.0 → 0.22.1

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.
Files changed (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1811 -221
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +455 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +167 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. package/ui/observation-center.html +0 -385
@@ -2,7 +2,10 @@ import { databases } from "@harperfast/harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
4
  import { localInstanceId } from "./instance-identity.js";
5
+ import { buildProvenance } from "./provenance.js";
5
6
  const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
7
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
8
+ const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
6
9
  /**
7
10
  * Relationship resource — entity-to-entity relationships with temporal validity.
8
11
  *
@@ -86,18 +89,73 @@ export class Relationship extends databases.flair.Relationship {
86
89
  operator: "and",
87
90
  });
88
91
  }
92
+ /**
93
+ * ─── Auth reconcile (relationship-write-path, folded K&S refinement) ──────
94
+ *
95
+ * Upgraded from the older `request.tpsAgent`-direct pattern to
96
+ * `resolveAgentAuth` — matching Memory.post()/put() — so this write path
97
+ * gets the SAME three-way verdict handling (anonymous denied, verified
98
+ * agent stamped from the SIGNATURE never the body, internal/admin
99
+ * unfiltered) instead of a parallel, easy-to-drift auth mechanism. K&S both
100
+ * flagged this as a real divergence (the previous code had no
101
+ * internal/admin verdict paths at all — see the doc below for why that
102
+ * never actually bit anyone in practice, but was still the wrong shape to
103
+ * build the new ergonomic write surfaces on top of).
104
+ *
105
+ * - `anonymous` → 401, same as before.
106
+ * - `agent` + non-admin: a body-supplied `agentId` that MISMATCHES the
107
+ * verified identity is rejected outright (403) rather than silently
108
+ * overwritten — a clearer signal than Memory.post()'s "validate, don't
109
+ * stamp" idiom. `content.agentId` is then ALWAYS set from `auth.agentId`
110
+ * (never left as whatever the body claimed, even when it already
111
+ * matched) — the non-negotiable "agentId comes from the verdict, never
112
+ * the body" rule, applied unconditionally rather than only on mismatch.
113
+ * - `agent` + admin: `content.agentId` passes through UNFILTERED, matching
114
+ * the existing admin-bypass idiom already used by get()/search()/
115
+ * delete() below (an admin/migration tool may legitimately write on
116
+ * another agent's behalf).
117
+ * - `internal` (no HTTP request at all — a trusted in-process call):
118
+ * `content.agentId` also passes through unchanged. No in-process
119
+ * Relationship writer exists today (openclaw's integration writes via a
120
+ * real signed HTTP PUT, landing on the `agent` branch above), so this is
121
+ * forward-looking parity with Memory.post()/put() rather than a path
122
+ * this PR's callers actually exercise — but it closes the SAME latent gap
123
+ * the old code had: `request?.tpsAgent` was falsy for BOTH an anonymous
124
+ * HTTP caller and a true internal call, so an internal caller would have
125
+ * been wrongly 401'd too. resolveAgentAuth distinguishes the two.
126
+ */
89
127
  async put(content) {
90
128
  const ctx = this.getContext?.();
91
- const request = ctx?.request ?? ctx;
92
- const authAgent = request?.tpsAgent;
93
- if (!authAgent) {
94
- return new Response(JSON.stringify({ error: "authentication required" }), {
95
- status: 401, headers: { "content-type": "application/json" },
129
+ const auth = await resolveAgentAuth(ctx);
130
+ if (auth.kind === "anonymous") {
131
+ return UNAUTH();
132
+ }
133
+ if (auth.kind === "agent") {
134
+ if (!auth.isAdmin) {
135
+ if (content?.agentId && content.agentId !== auth.agentId) {
136
+ return FORBIDDEN("cannot write a relationship owned by another agent");
137
+ }
138
+ content.agentId = auth.agentId;
139
+ }
140
+ // admin: content.agentId left as provided (unfiltered) — see doc above.
141
+ }
142
+ // internal: content.agentId left as provided (unfiltered) — see doc above.
143
+ if (!content.agentId || typeof content.agentId !== "string") {
144
+ return new Response(JSON.stringify({ error: "agentId is required" }), {
145
+ status: 400, headers: { "content-type": "application/json" },
96
146
  });
97
147
  }
98
- const rl = checkRateLimit(authAgent);
99
- if (!rl.allowed)
100
- return rateLimitResponse(rl.retryAfterMs, "relationship");
148
+ // Rate limit keyed on the RESOLVED agentId (never a client-supplied one)
149
+ // — matches Memory.post()'s intent, extended to cover every
150
+ // resolveAgentAuth path (credentialed super_user, verifyAgentRequest
151
+ // fallback), not just the gate's own `tpsAgent` annotation. Internal
152
+ // calls have no per-agent identity to key on and are trusted, so they're
153
+ // exempt — same as Memory.post()'s `if (authenticatedAgent)` guard.
154
+ if (auth.kind === "agent") {
155
+ const rl = checkRateLimit(auth.agentId);
156
+ if (!rl.allowed)
157
+ return rateLimitResponse(rl.retryAfterMs, "relationship");
158
+ }
101
159
  // Validate required fields
102
160
  if (!content.subject || typeof content.subject !== "string") {
103
161
  return new Response(JSON.stringify({ error: "subject is required (string)" }), {
@@ -114,9 +172,9 @@ export class Relationship extends databases.flair.Relationship {
114
172
  status: 400, headers: { "content-type": "application/json" },
115
173
  });
116
174
  }
117
- // Normalize
175
+ // Normalize — lowercasing is load-bearing: MemoryBootstrap.ts's attention
176
+ // read matches lowercased predicted subjects against subject/object.
118
177
  const now = new Date().toISOString();
119
- content.agentId = authAgent;
120
178
  content.subject = content.subject.toLowerCase();
121
179
  content.predicate = content.predicate.toLowerCase();
122
180
  content.object = content.object.toLowerCase();
@@ -125,6 +183,13 @@ export class Relationship extends databases.flair.Relationship {
125
183
  content.validFrom = content.validFrom || now;
126
184
  // validTo left as null/undefined for active relationships
127
185
  content.confidence = content.confidence ?? 1.0;
186
+ // Write-time provenance stamp (relationship-write-path, folded K&S
187
+ // refinement) — reuses Memory's buildProvenance EXACTLY (./provenance.ts),
188
+ // same `{v, verified, claimed?}` shape, no Relationship-specific format.
189
+ // Additive/nullable on the schema side (schemas/memory.graphql) — a
190
+ // pre-existing row with no provenance field reads back `undefined`,
191
+ // unchanged behavior (migration-equivalence gate).
192
+ content.provenance = buildProvenance(auth, content.createdAt, content);
128
193
  // Write-time originatorInstanceId stamp (federation-edge-hardening slice
129
194
  // 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
130
195
  // full contract. No-op if already set (never fires for a genuine local
@@ -135,24 +200,32 @@ export class Relationship extends databases.flair.Relationship {
135
200
  }
136
201
  return super.put(content);
137
202
  }
203
+ /**
204
+ * Same auth reconcile as put() above — resolveAgentAuth replaces the
205
+ * `request.tpsAgent`-direct pattern. K&S both independently caught that
206
+ * delete() had the identical divergence the spec text only named on
207
+ * put(): anonymous and true-internal calls were indistinguishable (both
208
+ * read as a falsy `authAgent`), and there was no admin/internal verdict
209
+ * handling. Ownership-check logic (own-agent-or-admin) is otherwise
210
+ * unchanged — see test/integration/relationship-delete-authz.test.ts's doc
211
+ * comment for why calling `super.get()` with no target argument still
212
+ * resolves the URL-bound target record (a Harper Table-resource
213
+ * invariant), not an empty/collection result.
214
+ */
138
215
  async delete(_) {
139
216
  const ctx = this.getContext?.();
140
- const request = ctx?.request ?? ctx;
141
- const authAgent = request?.tpsAgent;
142
- const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
143
- if (!authAgent) {
144
- return new Response(JSON.stringify({ error: "authentication required" }), {
145
- status: 401, headers: { "content-type": "application/json" },
146
- });
217
+ const auth = await resolveAgentAuth(ctx);
218
+ if (auth.kind === "anonymous") {
219
+ return UNAUTH();
147
220
  }
148
- // Non-admin: verify ownership before delete
149
- if (!isAdminAgent) {
150
- const existing = await super.get();
151
- if (existing?.agentId && existing.agentId !== authAgent) {
152
- return new Response(JSON.stringify({ error: "cannot delete another agent's relationship" }), {
153
- status: 403, headers: { "content-type": "application/json" },
154
- });
155
- }
221
+ // Trusted internal call or admin agent unfiltered, unchanged behavior.
222
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
223
+ return super.delete(_);
224
+ }
225
+ // Non-admin agent: verify ownership before delete.
226
+ const existing = await super.get();
227
+ if (existing?.agentId && existing.agentId !== auth.agentId) {
228
+ return FORBIDDEN("cannot delete another agent's relationship");
156
229
  }
157
230
  return super.delete(_);
158
231
  }
@@ -1,31 +1,26 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { getEmbedding, getMode } from "./embeddings-provider.js";
4
- import { patchRecord, withDetachedTxn } from "./table-helpers.js";
4
+ import { patchRecord } from "./table-helpers.js";
5
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
- import { wrapUntrusted } from "./content-safety.js";
7
6
  import { resolveReadScope } from "./memory-read-scope.js";
8
- import { cosineSimilarity } from "./dedup.js";
9
7
  import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
10
- // Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
11
- // relevance floor) lives in ./scoring.tsa Harper-free module so it can be
12
- // unit-tested directly (see test/unit/temporal-scoring.test.ts).
13
- import { compositeScore } from "./scoring.js";
14
- // BM25 + union-RRF hybrid retrieval (FLAIR-BM25-HYBRID-RETRIEVAL).
15
- // Harper-free modules so the BM25 scoring, the candidate-union RRF, and the
16
- // SECURITY conditions-filter are unit-tested against the shipped code.
17
- import { buildBM25, fuseRrfNormalized, hybridEnabled, SEM_LIMIT } from "./bm25.js";
18
- import { isAllowedBm25Candidate } from "./bm25-filter.js";
19
- // Convert HNSW cosine distance (1 - similarity) to similarity score
20
- function distanceToSimilarity(distance) {
21
- return 1 - distance;
22
- }
8
+ // The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
9
+ // (imported from ./bm25 — Harper-free so it's unit-testable). Default is ON as
10
+ // of 2026-07-08 (see ./bm25.ts's hybridEnabled() doc); set
11
+ // FLAIR_HYBRID_RETRIEVAL=false to revert to the legacy HNSW + +0.05
12
+ // keyword-bump path, byte-identical to the original pre-hybrid behavior.
13
+ import { hybridEnabled } from "./bm25.js";
14
+ // The actual HNSW/BM25 retrieval + post-retrieval filtering (temporal/
15
+ // supersede/isAllowed) now lives in the pure, side-effect-free
16
+ // retrieveCandidates() core (flair-bootstrap-scale-fix, Kern-approved
17
+ // extraction) MemoryBootstrap.ts calls the SAME core bare, without
18
+ // tripping this file's rate-limit/reranker/hit-tracking side effects. See
19
+ // resources/semantic-retrieval-core.ts's module doc for the full boundary.
20
+ import { retrieveCandidates } from "./semantic-retrieval-core.js";
23
21
  // Candidate multiplier: fetch more candidates than needed from the HNSW index
24
22
  // so composite re-ranking has enough headroom to reorder results.
25
23
  const CANDIDATE_MULTIPLIER = 5;
26
- // The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
27
- // (imported from ./bm25 — Harper-free so it's unit-testable). Flag OFF (default)
28
- // → byte-identical to the pre-hybrid HNSW + +0.05 keyword-bump behavior.
29
24
  export class SemanticSearch extends Resource {
30
25
  // Self-authorize via the Ed25519 agent verify instead of relying on the auth
31
26
  // gate's admin super_user elevation (removed in the auth reshape). Any
@@ -38,7 +33,34 @@ export class SemanticSearch extends Resource {
38
33
  return allowVerified(this.getContext?.());
39
34
  }
40
35
  async post(data) {
41
- const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
36
+ // Default scoring is "raw", not "composite" (flair#623 follow-up, measured
37
+ // 2026-07-08). recall-eval on the live corpus with BM25 hybrid retrieval
38
+ // active (default since eb26890) showed composite was net-HARMFUL at the
39
+ // time: Δp@3 (composite − raw) = -0.38 to -0.50 across repeated runs, MRR
40
+ // 0.44→0.06-0.44. Root cause: compositeScore's durability-weight ×
41
+ // recency-decay multiplier applied UNCONDITIONALLY (no relevance gate,
42
+ // unlike retrievalBoost's RBOOST_RELEVANCE_FLOOR), so a `permanent`
43
+ // -durability or freshly-created LOW-relevance record could outrank the
44
+ // objectively best-matching `persistent`/older record. Now that BM25+RRF
45
+ // fusion normalizes rawScore into a tight [0,1] band, an unbounded
46
+ // durability/recency multiplier is often bigger than the actual relevance
47
+ // gap between candidates.
48
+ //
49
+ // FIXED (flair#623 follow-up, 2026-07-08, see ./scoring.ts's
50
+ // COMPOSITE_DISCOUNT_FLOOR / COMPOSITE_RELEVANCE_FLOOR): compositeScore's
51
+ // durability/recency multiplier is now bounded to a small (~2%) nudge and
52
+ // relevance-gated, the same way RBOOST_CAP/RBOOST_RELEVANCE_FLOOR already
53
+ // bound the retrieval-popularity boost — `scoring: "composite"` no longer
54
+ // reproduces the magnet/inversion bug (recall-harness: p@3 and MRR both
55
+ // now match raw exactly on its 87-record corpus). The default REMAINS
56
+ // "raw" anyway: on that same corpus, a relevance-gated composite only
57
+ // MATCHES raw's precision, it doesn't beat it, so there is no measured
58
+ // upside to switching the default, only the (now-closed) downside risk
59
+ // for anyone who explicitly opts into "composite". Re-run
60
+ // recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
61
+ // before reconsidering this default if the compositeScore formula or
62
+ // corpus changes.
63
+ const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf } = data || {};
42
64
  // Authenticated identity lives on the Harper Resource context (getContext().request).
43
65
  // `this.request` is NOT populated on Harper v5 Resources — prior reads here
44
66
  // silently returned undefined and the defense-in-depth scope check below
@@ -92,8 +114,9 @@ export class SemanticSearch extends Resource {
92
114
  if (!qEmb && q) {
93
115
  // Always attempt embedding generation — getEmbedding() handles init internally.
94
116
  // Don't gate on getMode() which may return "none" before init completes in worker threads.
117
+ // flair#504 Phase 2: 'query' — this is a search query, not stored content.
95
118
  try {
96
- qEmb = await getEmbedding(String(q).slice(0, 8000));
119
+ qEmb = await getEmbedding(String(q).slice(0, 8000), "query");
97
120
  }
98
121
  catch { }
99
122
  }
@@ -155,7 +178,6 @@ export class SemanticSearch extends Resource {
155
178
  });
156
179
  }
157
180
  }
158
- const results = [];
159
181
  const hybrid = hybridEnabled();
160
182
  // When the reranker is on, widen the legacy HNSW fetch so it has a deeper
161
183
  // pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
@@ -167,310 +189,56 @@ export class SemanticSearch extends Resource {
167
189
  // path produced `filteredResults`.
168
190
  const rerankOn = isRerankEnabled();
169
191
  const rerankTopN = getRerankTopN();
170
- if (hybrid) {
171
- // ─── BM25 + union-RRF hybrid path ────────────────────────────────────
172
- // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
173
- // over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
174
- // by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
175
- // is fetched with those conditions, AND re-checked in-process as
176
- // defense-in-depth) so no other agent's memory is ever scored or fused.
177
- // 4. Candidate-union RRF → normalize → feed as rawScore to compositeScore.
178
- const ctx = this.getContext?.();
179
- // ── (a) Semantic candidate records (best-first) ──────────────────────
180
- // Same HNSW query as the legacy path; we keep the raw records so the BM25
181
- // pass + fusion can re-derive rawScore. No embedding → empty semantic list
182
- // (RRF degrades naturally to BM25-only).
183
- const semRecords = [];
184
- const semIds = [];
185
- if (qEmb) {
186
- const candidateLimit = limit * CANDIDATE_MULTIPLIER;
187
- const semQuery = {
188
- sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
189
- select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
190
- "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
191
- "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
192
- "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
193
- "validFrom", "validTo", "_safetyFlags", "$distance"],
194
- limit: candidateLimit,
195
- };
196
- if (conditions.length > 0)
197
- semQuery.conditions = conditions;
198
- const semResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(semQuery));
199
- for await (const record of semResults) {
200
- // Same per-record temporal gate as the legacy HNSW loop.
201
- if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
202
- continue;
203
- if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
204
- continue;
205
- if (asOf && record.validFrom && record.validFrom > asOf)
206
- continue;
207
- if (asOf && record.validTo && record.validTo <= asOf)
208
- continue;
209
- // Unconditional past-validTo exclusion (see legacy HNSW
210
- // loop below for the full rationale) — applies regardless of asOf.
211
- if (record.validTo && Date.parse(record.validTo) < Date.now())
212
- continue;
213
- semRecords.push(record);
214
- semIds.push(record.id);
215
- }
216
- }
217
- // ── (b) BM25 candidate records over the SCOPED corpus ────────────────
218
- // SECURITY: fetch the corpus WITH the same conditions[] so Harper applies
219
- // the agent boundary, then re-apply the identical predicate + temporal
220
- // filters in-process (isAllowedBm25Candidate) BEFORE building/scoring the
221
- // index. The BM25 index therefore only ever contains the caller's allowed
222
- // memories — no other agent's content/term-frequency enters scoring or
223
- // fusion. This is the conditions-filter-before-fusion gate.
224
- // Explicit select (same fields as the HNSW path, no embedding / $distance)
225
- // so the large embedding vector is never fetched into the BM25 corpus and
226
- // never spread into a result payload.
227
- const corpusSelect = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
228
- "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
229
- "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
230
- "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
231
- "validFrom", "validTo", "_safetyFlags"];
232
- const corpusQuery = conditions.length > 0
233
- ? { conditions, select: corpusSelect }
234
- : { select: corpusSelect };
235
- const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
236
- const allowedById = new Map();
237
- const bm25Docs = [];
238
- for await (const record of corpusResults) {
239
- // Defense-in-depth: re-check the SAME conditions[] + temporal filters
240
- // in-process. Even if a Harper query change ever let an out-of-scope
241
- // record through, it is dropped here BEFORE it can be BM25-scored/fused.
242
- if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
243
- continue;
244
- allowedById.set(record.id, record);
245
- bm25Docs.push({ id: record.id, content: record.content });
246
- }
247
- // Carry semantic candidates that survived their temporal gate into the
248
- // allowed map too (so a fused id always resolves to a record). Semantic
249
- // records were fetched with the SAME conditions[], so they're in-scope.
250
- for (const r of semRecords) {
251
- if (!allowedById.has(r.id)) {
252
- const { $distance, ...rest } = r;
253
- allowedById.set(r.id, rest);
254
- }
255
- }
256
- // ── (c) BM25 lexical ranking → top SEM_LIMIT (only when q present) ────
257
- let bm25Ids = [];
258
- if (q) {
259
- const bm25 = buildBM25(bm25Docs);
260
- const ranked = bm25.rank(String(q));
261
- // Drop zero-score docs (no query-term overlap → contribute nothing) and
262
- // cap at SEM_LIMIT — the production BM25 candidate window.
263
- bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
264
- }
265
- // ── (d) Candidate-union RRF → normalized [0,1] rawScore ──────────────
266
- // Union dedupes semantic ∪ bm25 ids; absent-from-a-list = 0 contribution.
267
- const fused = fuseRrfNormalized(semIds, bm25Ids);
268
- for (const [id, rrfRaw] of fused) {
269
- const record = allowedById.get(id);
270
- if (!record)
271
- continue; // should not happen — union ⊆ allowed
272
- const rawScore = rrfRaw; // already normalized to [0,1]
273
- let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
274
- if (temporalBoost > 1.0)
275
- finalScore *= temporalBoost;
276
- const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
277
- const source = record.agentId !== agentId ? record.agentId : undefined;
278
- results.push({
279
- ...record,
280
- content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
281
- _score: Math.round(finalScore * 1000) / 1000,
282
- _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
283
- _source: source,
284
- });
285
- }
286
- }
287
- else if (qEmb) {
288
- // ─── HNSW vector search path (legacy, hybrid flag OFF) ────────────────
289
- const candidateLimit = rerankOn
290
- ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN)
291
- : limit * CANDIDATE_MULTIPLIER;
292
- const query = {
293
- sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
294
- select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
295
- "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
296
- "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
297
- "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
298
- "validFrom", "validTo", "_safetyFlags", "$distance"],
299
- limit: candidateLimit,
300
- };
301
- if (conditions.length > 0) {
302
- query.conditions = conditions;
303
- }
304
- // MemoryGrant.search above left a closed transaction in ctx's chain —
305
- // detach it so Harper builds a fresh transaction for this Memory read.
306
- const ctx = this.getContext?.();
307
- const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
308
- for await (const record of memoryResults) {
309
- if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
310
- continue;
311
- if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
312
- continue;
313
- // Temporal validity: if asOf is specified, only include memories valid at that point
314
- if (asOf && record.validFrom && record.validFrom > asOf)
315
- continue;
316
- if (asOf && record.validTo && record.validTo <= asOf)
317
- continue;
318
- // A past validTo ALWAYS means the record has been closed out
319
- // (server supersede path — Memory.ts closeSupersededRecord — sets
320
- // validTo without necessarily setting `archived`). Unconditional, not
321
- // gated on `asOf`, so a server-superseded record can't resurface in
322
- // the DEFAULT recall path just because its successor isn't
323
- // co-present in this result set (the supersededIds filter further
324
- // down only catches co-presence). A record with no validTo, or a
325
- // future validTo, is unaffected.
326
- if (record.validTo && Date.parse(record.validTo) < Date.now())
327
- continue;
328
- let semanticScore;
329
- if (record.$distance !== undefined) {
330
- semanticScore = distanceToSimilarity(record.$distance);
331
- }
332
- else {
333
- // ─── Harper's cosine-sort query omits $distance for a
334
- // SINGLETON post-filter result set — the SAME quirk root-caused and
335
- // fixed for the dedup path (resources/Memory.ts
336
- // findConservativeDedupMatch / resources/dedup.ts cosineSimilarity).
337
- // Sort ORDER is still correct; only the numeric `$distance`
338
- // annotation is missing on that one record, regardless of the
339
- // query's own `limit` (reproduced here with candidateLimit=50, not
340
- // just limit=1 — the trigger is the post-filter MATCH COUNT, not the
341
- // requested limit).
342
- //
343
- // Layer 1 made this common: the no-grants agent scope used
344
- // to ALWAYS be a compound `{operator:"or", conditions:[{agentId},
345
- // {visibility=="office"}]}` condition; resolveReadScope() now emits
346
- // a PLAIN single `{agentId==X}` condition for the common (no-grants)
347
- // case, so a scoped search against an agent with exactly one
348
- // matching memory hits this Harper quirk directly. Before, the OR
349
- // wrap incidentally avoided the singleton shape. The old `?? 1`
350
- // fallback silently collapsed this to similarity 0 — read by
351
- // callers (including the clean-VM CI gate's single-memory init
352
- // probe, #533) as "embeddings not loaded", which is WRONG:
353
- // embeddings ARE loaded, only the score was computed incorrectly.
354
- //
355
- // Fix: point-lookup the record by id (a plain get(), unaffected by
356
- // the sort-query quirk — selecting "embedding" directly on the SAME
357
- // sort-by-embedding query comes back as a bare scalar, per the same
358
- // investigation above) and compute cosine similarity ourselves in JS from its
359
- // real stored `embedding` vector against this query's `qEmb`, via
360
- // the same math as the ume4 fallback (dedup.ts's cosineSimilarity).
361
- // Only done on this (rare) undefined-$distance branch — never adds
362
- // a point-lookup to the normal per-record path. If the stored
363
- // embedding is missing/empty (e.g. a legacy pre-embedding record),
364
- // cosineSimilarity returns 0 — the same safe "no match" the old
365
- // `?? 1` fallback produced, never a false-high score.
366
- const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
367
- const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
368
- semanticScore = cosineSimilarity(qEmb, storedEmbedding);
369
- }
370
- let keywordHit = false;
371
- if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
372
- keywordHit = true;
373
- }
374
- const rawScore = semanticScore + (keywordHit ? 0.05 : 0);
375
- let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
376
- if (temporalBoost > 1.0)
377
- finalScore *= temporalBoost;
378
- const { $distance, ...rest } = record;
379
- const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
380
- const source = record.agentId !== agentId ? record.agentId : undefined;
381
- results.push({
382
- ...rest,
383
- content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
384
- _score: Math.round(finalScore * 1000) / 1000,
385
- _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
386
- _source: source,
387
- });
388
- }
389
- }
390
- else {
391
- // ─── No embedding available — keyword-only fallback ──────────────────
392
- // Full scan is only used when there's no query embedding (e.g. tag-only
393
- // or subject-only searches, or when the embedding engine is unavailable).
394
- const query = conditions.length > 0 ? { conditions } : {};
395
- // MemoryGrant.search above left a closed transaction in ctx's chain —
396
- // detach it so Harper builds a fresh transaction for this Memory read.
397
- const ctx = this.getContext?.();
398
- const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
399
- for await (const record of memoryResults) {
400
- if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
401
- continue;
402
- if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
403
- continue;
404
- if (asOf && record.validFrom && record.validFrom > asOf)
405
- continue;
406
- if (asOf && record.validTo && record.validTo <= asOf)
407
- continue;
408
- // Unconditional past-validTo exclusion (see legacy HNSW
409
- // loop above for the full rationale) — applies regardless of asOf.
410
- if (record.validTo && Date.parse(record.validTo) < Date.now())
411
- continue;
412
- let keywordHit = false;
413
- if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
414
- keywordHit = true;
415
- }
416
- const rawScore = keywordHit ? 0.05 : 0;
417
- if (q && rawScore === 0)
418
- continue;
419
- const { embedding, ...rest } = record;
420
- let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, rest);
421
- if (temporalBoost > 1.0)
422
- finalScore *= temporalBoost;
423
- const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
424
- const source = record.agentId !== agentId ? record.agentId : undefined;
425
- results.push({
426
- ...rest,
427
- content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
428
- _score: Math.round(finalScore * 1000) / 1000,
429
- _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
430
- _source: source,
431
- });
432
- }
433
- }
434
- // Build superseded set and filter (unless caller opts in to see full history)
435
- let filteredResults = results;
436
- if (!includeSuperseded) {
437
- const supersededIds = new Set();
438
- for (const r of results) {
439
- if (r.supersedes)
440
- supersededIds.add(r.supersedes);
441
- }
442
- filteredResults = results.filter((r) => !supersededIds.has(r.id));
443
- }
444
- // Apply minimum score filter
445
- if (minScore > 0) {
446
- filteredResults = filteredResults.filter((r) => r._score >= minScore);
447
- }
192
+ // The overfetch policy (how many raw candidates to pull from the
193
+ // HNSW/BM25 legs relative to what the caller ultimately wants) is THIS
194
+ // wrapper's decision retrieveCandidates() never multiplies its `limit`
195
+ // param internally (see resources/semantic-retrieval-core.ts's doc), so
196
+ // every caller (this one, and MemoryBootstrap's own K formula) computes
197
+ // its own fetch depth. Hybrid's semantic leg is never rerank-widened
198
+ // (matches the pre-extraction behavior: only the legacy path widened for
199
+ // rerank).
200
+ const candidateLimit = hybrid
201
+ ? limit * CANDIDATE_MULTIPLIER
202
+ : (rerankOn ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN) : limit * CANDIDATE_MULTIPLIER);
203
+ const ctx = this.getContext?.();
204
+ let filteredResults = await retrieveCandidates({
205
+ queryEmbedding: qEmb,
206
+ q,
207
+ conditions,
208
+ limit: candidateLimit,
209
+ includeSuperseded,
210
+ scoring,
211
+ temporalBoost,
212
+ sinceDate,
213
+ asOf,
214
+ minScore,
215
+ agentId,
216
+ isAllowed: scope?.isAllowed,
217
+ hybrid,
218
+ ctx,
219
+ });
448
220
  // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
449
221
  // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
450
222
  // can't do) and reorders before the final slice. Reorders whatever
451
- // `filteredResults` the retrieval stage above produced — legacy HNSW-only
452
- // OR the BM25+union-RRF hybrid path — since both converge into
453
- // the same `results`/`filteredResults` shape before this point; hybrid
454
- // retrieves+fuses, the reranker only reorders the fused set. Still gated
455
- // on `qEmb` (an embedding was actually generated); the pure keyword-only
456
- // fallback (no qEmb at all) is untouched either way. The reranker
457
- // overwrites `_score` with the rerank score (so margin measurement reads it)
458
- // and preserves the semantic score as `_semScore`; `_rawScore` is left as-is
459
- // so recall-bench's scoring:"raw" path stays reproducible. On init failure,
460
- // timeout, or any throw, rerankCandidates returns the input UNCHANGED and we
461
- // fall through to the existing vector-order sort — recall is never blocked.
223
+ // `filteredResults` retrieveCandidates() produced — legacy HNSW-only OR
224
+ // the BM25+union-RRF hybrid path — since both converge into the same
225
+ // shape (retrieveCandidates never exposes which leg produced a result).
226
+ // Still gated on `qEmb` (an embedding was actually generated); the pure
227
+ // keyword-only fallback (no qEmb at all) is untouched either way. The
228
+ // reranker overwrites `_score` with the rerank score (so margin
229
+ // measurement reads it) and preserves the semantic score as `_semScore`;
230
+ // `_rawScore` is left as-is so recall-bench's scoring:"raw" path stays
231
+ // reproducible. On init failure, timeout, or any throw, rerankCandidates
232
+ // returns the input UNCHANGED and we fall through to retrieveCandidates'
233
+ // own vector-order sort — recall is never blocked. retrieveCandidates
234
+ // already returns its output sorted best-first, so the non-rerank branch
235
+ // needs no additional sort here.
462
236
  if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
463
- // Pre-sort by vector order so the topN fed to the reranker is the most
464
- // semantically-promising slice (filteredResults isn't sorted yet here).
465
- filteredResults.sort((a, b) => b._score - a._score);
466
237
  filteredResults = await rerankCandidates(String(q), filteredResults, {
467
238
  topN: rerankTopN,
468
239
  budgetMs: getRerankBudgetMs(),
469
240
  });
470
241
  }
471
- else {
472
- filteredResults.sort((a, b) => b._score - a._score);
473
- }
474
242
  const topResults = filteredResults.slice(0, limit);
475
243
  // Async hit tracking — don't block the response
476
244
  const now = new Date().toISOString();