@tpsdev-ai/flair 0.20.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -11
- package/SECURITY.md +52 -20
- package/config.yaml +32 -5
- package/dist/cli.js +2235 -260
- package/dist/deploy.js +96 -15
- package/dist/doctor-client.js +312 -0
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/install/clients.js +18 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/restore.js +1 -1
- package/dist/rem/runner.js +82 -12
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +121 -2
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +127 -22
- package/dist/resources/MemoryBootstrap.js +441 -85
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +69 -5
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +268 -17
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +108 -26
- package/dist/resources/SemanticSearch.js +88 -320
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +12 -1
- package/dist/resources/agent-auth.js +61 -3
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +42 -14
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +13 -7
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- 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/federation-nonce-store.js +195 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/instance-identity.js +53 -0
- 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-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/memory-visibility.js +37 -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 +456 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +73 -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-check.js +167 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/agent.graphql +7 -0
- package/schemas/event.graphql +5 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +82 -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
|
@@ -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
|
|
@@ -81,10 +103,10 @@ export class SemanticSearch extends Resource {
|
|
|
81
103
|
? authenticatedAgent
|
|
82
104
|
: bodyAgentId;
|
|
83
105
|
// Read-scope: own (any visibility) + granted owners' SHARED memories only
|
|
84
|
-
// (
|
|
106
|
+
// (Layer 1). Centralized in resolveReadScope() — this used to be
|
|
85
107
|
// an inline grant-resolution loop here PLUS a `visibility === "office"`
|
|
86
108
|
// global OR-clause below that leaked ANY authenticated agent's read of
|
|
87
|
-
// ANY other agent's office-visible memories
|
|
109
|
+
// ANY other agent's office-visible memories. Both are gone;
|
|
88
110
|
// this is the ONE scoping resolution for this endpoint now.
|
|
89
111
|
const scope = agentId ? await resolveReadScope(agentId) : null;
|
|
90
112
|
// Generate query embedding
|
|
@@ -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,322 +178,67 @@ 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
|
|
162
184
|
// from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
|
|
163
185
|
// headroom. Scoped to the legacy (non-hybrid) vector path below — the
|
|
164
186
|
// hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
|
|
165
|
-
// (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union
|
|
187
|
+
// (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union; the
|
|
166
188
|
// reranker still applies to its output further down regardless of which
|
|
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
|
-
// ops-9rc6: 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
|
-
// ops-9rc6: 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
|
-
// ─── ops-syzm: 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 in ops-ume4 (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
|
-
// ops-2dm3 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 ops-ume4's
|
|
358
|
-
// findings) 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
|
-
// ops-9rc6: 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();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Resource } from "@harperfast/harper";
|
|
2
|
+
import { allowVerified } from "./agent-auth.js";
|
|
2
3
|
import { scanSkillContent } from "./scan/skill-scanner.js";
|
|
3
4
|
/**
|
|
4
5
|
* POST /SkillScan/
|
|
@@ -30,6 +31,20 @@ import { scanSkillContent } from "./scan/skill-scanner.js";
|
|
|
30
31
|
* weakening detection on actual shell content.
|
|
31
32
|
*/
|
|
32
33
|
export class SkillScan extends Resource {
|
|
34
|
+
/**
|
|
35
|
+
* allowCreate()=allowVerified (authorizeLocal-escalation-class follow-up to
|
|
36
|
+
* #601/#604/#609/#612 — flair#614's backstop found this resource had NO
|
|
37
|
+
* allow* at all). The docstring above already says "Auth: any authenticated
|
|
38
|
+
* agent" — this was never actually enforced; Harper's own default
|
|
39
|
+
* (`user?.role.permission.super_user`, satisfiable only by a genuine admin
|
|
40
|
+
* OR authorizeLocal's forged loopback super_user) silently stood in
|
|
41
|
+
* instead. allowVerified matches the documented intent: any verified agent
|
|
42
|
+
* (not admin-only — this is a stateless text scanner, no agent/memory data
|
|
43
|
+
* touched), anonymous denied.
|
|
44
|
+
*/
|
|
45
|
+
async allowCreate() {
|
|
46
|
+
return allowVerified(this.getContext?.());
|
|
47
|
+
}
|
|
33
48
|
async post(data, _context) {
|
|
34
49
|
const { content } = data || {};
|
|
35
50
|
if (!content || typeof content !== "string") {
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
|
+
import { localInstanceId } from "./instance-identity.js";
|
|
3
4
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
5
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
@@ -37,6 +38,14 @@ export class Soul extends databases.flair.Soul {
|
|
|
37
38
|
content.durability ||= "permanent";
|
|
38
39
|
content.createdAt = new Date().toISOString();
|
|
39
40
|
content.updatedAt = content.createdAt;
|
|
41
|
+
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
42
|
+
// 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
|
|
43
|
+
// full contract. No-op if already set (never fires for a genuine local
|
|
44
|
+
// write; a federation-synced record never reaches this method — the
|
|
45
|
+
// merge path writes via the raw table object, bypassing this class).
|
|
46
|
+
if (content.originatorInstanceId == null) {
|
|
47
|
+
content.originatorInstanceId = await localInstanceId();
|
|
48
|
+
}
|
|
40
49
|
return super.post(content, context);
|
|
41
50
|
}
|
|
42
51
|
async put(content, context) {
|
|
@@ -44,6 +53,11 @@ export class Soul extends databases.flair.Soul {
|
|
|
44
53
|
if (denied)
|
|
45
54
|
return denied;
|
|
46
55
|
content.updatedAt = new Date().toISOString();
|
|
56
|
+
// Write-time originatorInstanceId stamp — see post() above / Memory.ts's
|
|
57
|
+
// stampOriginatorInstanceId doc. No-op if already set.
|
|
58
|
+
if (content.originatorInstanceId == null) {
|
|
59
|
+
content.originatorInstanceId = await localInstanceId();
|
|
60
|
+
}
|
|
47
61
|
return super.put(content, context);
|
|
48
62
|
}
|
|
49
63
|
}
|
|
@@ -16,7 +16,7 @@ export class WorkspaceLatest extends Resource {
|
|
|
16
16
|
async get(pathInfo) {
|
|
17
17
|
// Harper v5 does not populate this.context / this.request on Resource
|
|
18
18
|
// subclasses — getContext() is the only reliable path to the gate's
|
|
19
|
-
// tpsAgent/tpsAgentIsAdmin annotations (
|
|
19
|
+
// tpsAgent/tpsAgentIsAdmin annotations (the previous
|
|
20
20
|
// `(this as any).context?.request ?? (this as any).request` read was always
|
|
21
21
|
// undefined, so the ownership check below never ran — fail-open cross-agent
|
|
22
22
|
// read).
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { databases } from "@harperfast/harper";
|
|
11
11
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
12
|
+
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
12
13
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
13
14
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
14
15
|
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
@@ -20,7 +21,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
23
|
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
23
|
-
* read-gate family fix
|
|
24
|
+
* read-gate family fix — applying the Memory.ts/Soul.ts pattern
|
|
24
25
|
* to WorkspaceState/Relationship/Integration/MemoryGrant). Closes the same
|
|
25
26
|
* P0 leak: Harper routes `GET /WorkspaceState/<id>` to get() and the
|
|
26
27
|
* collection describe (`GET /WorkspaceState`) to a path outside search(),
|
|
@@ -114,6 +115,12 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
114
115
|
}
|
|
115
116
|
content.createdAt = new Date().toISOString();
|
|
116
117
|
content.timestamp ||= content.createdAt;
|
|
118
|
+
// attention-plane vocabulary gate (flair#675): `entities`, if present,
|
|
119
|
+
// must be well-formed vocabulary strings — see resources/entity-vocab.ts.
|
|
120
|
+
// Field is additive/optional; absent entities is not an error.
|
|
121
|
+
const entitiesError = invalidEntitiesResponse(content.entities);
|
|
122
|
+
if (entitiesError)
|
|
123
|
+
return entitiesError;
|
|
117
124
|
return super.post(content);
|
|
118
125
|
}
|
|
119
126
|
async put(content) {
|
|
@@ -123,6 +130,10 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
123
130
|
if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
|
|
124
131
|
return FORBIDDEN("forbidden: cannot write workspace state for another agent");
|
|
125
132
|
}
|
|
133
|
+
// attention-plane vocabulary gate (flair#675) — same as post() above.
|
|
134
|
+
const entitiesError = invalidEntitiesResponse(content.entities);
|
|
135
|
+
if (entitiesError)
|
|
136
|
+
return entitiesError;
|
|
126
137
|
return super.put(content);
|
|
127
138
|
}
|
|
128
139
|
async delete(id) {
|