@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.
- package/README.md +10 -7
- package/SECURITY.md +24 -2
- package/config.yaml +32 -5
- package/dist/cli.js +1811 -221
- package/dist/deploy.js +3 -4
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/runner.js +82 -12
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +23 -0
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +52 -53
- package/dist/resources/MemoryBootstrap.js +422 -76
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +8 -1
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/Presence.js +218 -19
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +98 -25
- package/dist/resources/SemanticSearch.js +85 -317
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/WorkspaceState.js +11 -0
- package/dist/resources/agent-auth.js +60 -2
- package/dist/resources/auth-middleware.js +18 -9
- package/dist/resources/bm25.js +12 -6
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +455 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +167 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/event.graphql +5 -0
- package/schemas/memory.graphql +66 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- 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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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`
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
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();
|