@tpsdev-ai/flair 0.17.0 → 0.19.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/dist/resources/Admin.js +12 -0
- package/dist/resources/AdminConnectors.js +6 -0
- package/dist/resources/AdminDashboard.js +9 -0
- package/dist/resources/AdminIdp.js +6 -0
- package/dist/resources/AdminInstance.js +6 -0
- package/dist/resources/AdminMemory.js +8 -0
- package/dist/resources/AdminPrincipals.js +6 -0
- package/dist/resources/Integration.js +52 -2
- package/dist/resources/Memory.js +404 -45
- package/dist/resources/MemoryGrant.js +55 -2
- package/dist/resources/Relationship.js +49 -1
- package/dist/resources/SemanticSearch.js +41 -3
- package/dist/resources/Soul.js +12 -1
- package/dist/resources/WorkspaceState.js +56 -2
- package/dist/resources/dedup.js +62 -0
- package/dist/resources/health.js +17 -0
- package/dist/resources/mcp-tools.js +75 -5
- package/dist/resources/rerank-provider.js +361 -0
- package/package.json +2 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
4
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
4
5
|
/**
|
|
5
6
|
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
6
7
|
*
|
|
@@ -13,6 +14,53 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
|
13
14
|
* Admin agents can query across all agents.
|
|
14
15
|
*/
|
|
15
16
|
export class Relationship extends databases.flair.Relationship {
|
|
17
|
+
/**
|
|
18
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
19
|
+
* read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
|
|
20
|
+
* WorkspaceState.ts). Closes the same P0 leak: Harper routes
|
|
21
|
+
* `GET /Relationship/<id>` to get() and the collection describe
|
|
22
|
+
* (`GET /Relationship`) outside search(), so neither was gated before this
|
|
23
|
+
* fix — an anonymous caller got a 200 with full record content. Per-record
|
|
24
|
+
* ownership scoping happens in get() below; the collection scope is still
|
|
25
|
+
* in search().
|
|
26
|
+
*/
|
|
27
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
28
|
+
/**
|
|
29
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
30
|
+
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
31
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
32
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
33
|
+
* relationship ids.
|
|
34
|
+
*/
|
|
35
|
+
async get(target) {
|
|
36
|
+
// Collection / query reads arrive as a RequestTarget with
|
|
37
|
+
// `isCollection === true`, and are governed by search() (same owner
|
|
38
|
+
// scoping). Only a genuine by-id get is ownership-checked below — see
|
|
39
|
+
// Memory.ts's get() for the full rationale (same bug class: without this
|
|
40
|
+
// guard, a query's RequestTarget would flow into super.get(), return the
|
|
41
|
+
// whole result set, and the single-record ownership check below would
|
|
42
|
+
// find no `.agentId` on it).
|
|
43
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
44
|
+
return this.search(target);
|
|
45
|
+
}
|
|
46
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
47
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
48
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
49
|
+
if (auth.kind === "anonymous") {
|
|
50
|
+
return NOT_FOUND();
|
|
51
|
+
}
|
|
52
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
53
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
54
|
+
return super.get(target);
|
|
55
|
+
}
|
|
56
|
+
// Non-admin agent: only its own relationships.
|
|
57
|
+
const record = await super.get(target);
|
|
58
|
+
if (!record)
|
|
59
|
+
return NOT_FOUND();
|
|
60
|
+
if (record.agentId !== auth.agentId)
|
|
61
|
+
return NOT_FOUND();
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
16
64
|
async search(query) {
|
|
17
65
|
const auth = await resolveAgentAuth(this.getContext?.());
|
|
18
66
|
// Anonymous HTTP must NOT read relationships (previously `!authAgent` was
|
|
@@ -4,6 +4,7 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
|
4
4
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
5
5
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
6
6
|
import { wrapUntrusted } from "./content-safety.js";
|
|
7
|
+
import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
|
|
7
8
|
// Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
|
|
8
9
|
// relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
|
|
9
10
|
// unit-tested directly (see test/unit/temporal-scoring.test.ts).
|
|
@@ -170,6 +171,16 @@ export class SemanticSearch extends Resource {
|
|
|
170
171
|
}
|
|
171
172
|
const results = [];
|
|
172
173
|
const hybrid = hybridEnabled();
|
|
174
|
+
// When the reranker is on, widen the legacy HNSW fetch so it has a deeper
|
|
175
|
+
// pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
|
|
176
|
+
// from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
|
|
177
|
+
// headroom. Scoped to the legacy (non-hybrid) vector path below — the
|
|
178
|
+
// hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
|
|
179
|
+
// (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union (ops-i39b); the
|
|
180
|
+
// reranker still applies to its output further down regardless of which
|
|
181
|
+
// path produced `filteredResults`.
|
|
182
|
+
const rerankOn = isRerankEnabled();
|
|
183
|
+
const rerankTopN = getRerankTopN();
|
|
173
184
|
if (hybrid) {
|
|
174
185
|
// ─── BM25 + union-RRF hybrid path (ops-i39b) ─────────────────────────
|
|
175
186
|
// 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
|
|
@@ -284,8 +295,10 @@ export class SemanticSearch extends Resource {
|
|
|
284
295
|
}
|
|
285
296
|
}
|
|
286
297
|
else if (qEmb) {
|
|
287
|
-
// ─── HNSW vector search path
|
|
288
|
-
const candidateLimit =
|
|
298
|
+
// ─── HNSW vector search path (legacy, hybrid flag OFF) ────────────────
|
|
299
|
+
const candidateLimit = rerankOn
|
|
300
|
+
? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN)
|
|
301
|
+
: limit * CANDIDATE_MULTIPLIER;
|
|
289
302
|
const query = {
|
|
290
303
|
sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
|
|
291
304
|
select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
|
|
@@ -387,7 +400,32 @@ export class SemanticSearch extends Resource {
|
|
|
387
400
|
if (minScore > 0) {
|
|
388
401
|
filteredResults = filteredResults.filter((r) => r._score >= minScore);
|
|
389
402
|
}
|
|
390
|
-
|
|
403
|
+
// ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
|
|
404
|
+
// Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
|
|
405
|
+
// can't do) and reorders before the final slice. Reorders whatever
|
|
406
|
+
// `filteredResults` the retrieval stage above produced — legacy HNSW-only
|
|
407
|
+
// OR the BM25+union-RRF hybrid path (ops-i39b) — since both converge into
|
|
408
|
+
// the same `results`/`filteredResults` shape before this point; hybrid
|
|
409
|
+
// retrieves+fuses, the reranker only reorders the fused set. Still gated
|
|
410
|
+
// on `qEmb` (an embedding was actually generated); the pure keyword-only
|
|
411
|
+
// fallback (no qEmb at all) is untouched either way. The reranker
|
|
412
|
+
// overwrites `_score` with the rerank score (so margin measurement reads it)
|
|
413
|
+
// and preserves the semantic score as `_semScore`; `_rawScore` is left as-is
|
|
414
|
+
// so recall-bench's scoring:"raw" path stays reproducible. On init failure,
|
|
415
|
+
// timeout, or any throw, rerankCandidates returns the input UNCHANGED and we
|
|
416
|
+
// fall through to the existing vector-order sort — recall is never blocked.
|
|
417
|
+
if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
|
|
418
|
+
// Pre-sort by vector order so the topN fed to the reranker is the most
|
|
419
|
+
// semantically-promising slice (filteredResults isn't sorted yet here).
|
|
420
|
+
filteredResults.sort((a, b) => b._score - a._score);
|
|
421
|
+
filteredResults = await rerankCandidates(String(q), filteredResults, {
|
|
422
|
+
topN: rerankTopN,
|
|
423
|
+
budgetMs: getRerankBudgetMs(),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
filteredResults.sort((a, b) => b._score - a._score);
|
|
428
|
+
}
|
|
391
429
|
const topResults = filteredResults.slice(0, limit);
|
|
392
430
|
// Async hit tracking — don't block the response
|
|
393
431
|
const now = new Date().toISOString();
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
4
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
5
|
/**
|
|
@@ -19,6 +19,17 @@ async function enforceWriteAuth(self, data) {
|
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
21
|
export class Soul extends databases.flair.Soul {
|
|
22
|
+
/**
|
|
23
|
+
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
24
|
+
* leak: Harper routes `GET /Soul/<id>` to get() and the collection
|
|
25
|
+
* describe (`GET /Soul`) to a path outside search()/allow* — neither was
|
|
26
|
+
* gated before this fix, so an anonymous caller got a 200 with full soul
|
|
27
|
+
* content. Deliberately NO get() override / per-agent scoping on top of
|
|
28
|
+
* this: souls are identity/discovery data, intentionally readable by any
|
|
29
|
+
* verified agent — same posture as Agent.ts's allowRead. Write ownership
|
|
30
|
+
* is unaffected — enforceWriteAuth() below already gates post()/put().
|
|
31
|
+
*/
|
|
32
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
22
33
|
async post(content, context) {
|
|
23
34
|
const denied = await enforceWriteAuth(this, content);
|
|
24
35
|
if (denied)
|
|
@@ -8,15 +8,64 @@
|
|
|
8
8
|
* Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
|
|
9
9
|
*/
|
|
10
10
|
import { databases } from "@harperfast/harper";
|
|
11
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
11
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
12
12
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
13
13
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
14
|
+
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
14
15
|
export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
15
16
|
/** Auth verdict from the request context. internal = trusted in-process call;
|
|
16
17
|
* agent = verified Ed25519; anonymous = HTTP with no valid agent → deny. */
|
|
17
18
|
_auth() {
|
|
18
19
|
return resolveAgentAuth(this.getContext?.());
|
|
19
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
23
|
+
* read-gate family fix, ops-oox7 — applying the Memory.ts/Soul.ts pattern
|
|
24
|
+
* to WorkspaceState/Relationship/Integration/MemoryGrant). Closes the same
|
|
25
|
+
* P0 leak: Harper routes `GET /WorkspaceState/<id>` to get() and the
|
|
26
|
+
* collection describe (`GET /WorkspaceState`) to a path outside search(),
|
|
27
|
+
* so neither was gated before this fix — an anonymous caller got a 200 with
|
|
28
|
+
* full record content. Per-record ownership scoping happens in get() below;
|
|
29
|
+
* the collection scope is still in search().
|
|
30
|
+
*/
|
|
31
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
32
|
+
/**
|
|
33
|
+
* Override get() to scope by-id reads the same way search() scopes
|
|
34
|
+
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
35
|
+
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
36
|
+
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
37
|
+
* workspace-state ids.
|
|
38
|
+
*/
|
|
39
|
+
async get(target) {
|
|
40
|
+
// Collection / query reads — the `GET /WorkspaceState/?<query>` form and
|
|
41
|
+
// the bare collection — arrive as a RequestTarget with `isCollection ===
|
|
42
|
+
// true`, and are governed by search() (same owner scoping). Only a
|
|
43
|
+
// genuine by-id get is ownership-checked below. Without this guard,
|
|
44
|
+
// get() would receive the query's RequestTarget, super.get() would
|
|
45
|
+
// return the (truthy) result set, and the single-record ownership check
|
|
46
|
+
// would find no `.agentId` on it (see Memory.ts's get() for the full
|
|
47
|
+
// rationale — same bug class).
|
|
48
|
+
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
49
|
+
return this.search(target);
|
|
50
|
+
}
|
|
51
|
+
const auth = await this._auth();
|
|
52
|
+
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
53
|
+
// this is defense-in-depth if get() is ever reached directly.
|
|
54
|
+
if (auth.kind === "anonymous") {
|
|
55
|
+
return NOT_FOUND();
|
|
56
|
+
}
|
|
57
|
+
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
58
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
59
|
+
return super.get(target);
|
|
60
|
+
}
|
|
61
|
+
// Non-admin agent: only its own workspace-state records.
|
|
62
|
+
const record = await super.get(target);
|
|
63
|
+
if (!record)
|
|
64
|
+
return NOT_FOUND();
|
|
65
|
+
if (record.agentId !== auth.agentId)
|
|
66
|
+
return NOT_FOUND();
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
20
69
|
/**
|
|
21
70
|
* Override search() to scope collection GETs to the authenticated agent's own
|
|
22
71
|
* records. Internal calls + admin agents see all; anonymous is denied
|
|
@@ -85,7 +134,12 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
85
134
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
86
135
|
return super.delete(id);
|
|
87
136
|
}
|
|
88
|
-
|
|
137
|
+
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
138
|
+
// (a truthy Response) for a non-owner id, which would otherwise defeat
|
|
139
|
+
// the `if (!record)` check below and mis-route a genuinely-missing
|
|
140
|
+
// record into the FORBIDDEN branch instead of a clean super.delete(id)
|
|
141
|
+
// no-op. Mirrors Memory.ts's delete() — same rationale, same fix.
|
|
142
|
+
const record = await super.get(id);
|
|
89
143
|
if (!record)
|
|
90
144
|
return super.delete(id);
|
|
91
145
|
if (record.agentId !== auth.agentId) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ─── Conservative near-duplicate detection primitives (Harper-free) ─────────
|
|
2
|
+
// Memory-integrity fix (#526/#548 field regressions). This module is
|
|
3
|
+
// deliberately Harper-free — same rationale as ./bm25.ts and ./scoring.ts —
|
|
4
|
+
// so the cosine+Jaccard co-gate math is unit-testable directly against the
|
|
5
|
+
// SHIPPED code without a live Harper. The DB-touching top-1-cosine lookup
|
|
6
|
+
// (needs `databases`) lives in ./Memory.ts, built on top of these primitives.
|
|
7
|
+
//
|
|
8
|
+
// ── THE INVARIANT ────────────────────────────────────────────────────────────
|
|
9
|
+
// This module NEVER decides whether to write. It only scores a candidate
|
|
10
|
+
// match. Callers (Memory.post / Memory.put) always write the new record; a
|
|
11
|
+
// "conservative match" is surfaced to the caller as a SIGNAL on the response
|
|
12
|
+
// (deduplicated/matchedId/matchConfidence), never as a reason to skip the
|
|
13
|
+
// write. That was the #526 bug: two topically-close but DISTINCT findings —
|
|
14
|
+
// one about replication route-directionality, one about DDL/schema
|
|
15
|
+
// replication — and the second was silently dropped because raw cosine alone
|
|
16
|
+
// flagged it as a "duplicate" of the first. Requiring BOTH cosine AND lexical
|
|
17
|
+
// (Jaccard token-overlap) to clear their thresholds against the SAME single
|
|
18
|
+
// top-cosine candidate catches true near-dups while sparing topic collisions
|
|
19
|
+
// (high cosine, low lexical) — and even then, only ever as a non-suppressing
|
|
20
|
+
// signal.
|
|
21
|
+
import { tokenize } from "./bm25.js";
|
|
22
|
+
/** Default raw-cosine similarity threshold for a candidate to even be considered. */
|
|
23
|
+
export const DEDUP_COSINE_THRESHOLD_DEFAULT = 0.95;
|
|
24
|
+
/** Default Jaccard token-overlap threshold (of the top cosine candidate). */
|
|
25
|
+
export const DEDUP_LEXICAL_THRESHOLD_DEFAULT = 0.5;
|
|
26
|
+
/** Below this content length, similarity scoring is unreliable ("ok" would
|
|
27
|
+
* match "ok" trivially) — the dedup gate is bypassed entirely. */
|
|
28
|
+
export const DEDUP_MIN_CONTENT_LENGTH = 20;
|
|
29
|
+
/** Jaccard similarity of two token sets: |A∩B| / |A∪B|. An empty side yields 0
|
|
30
|
+
* (no signal — never treated as "fully similar" by vacuous set equality). */
|
|
31
|
+
export function jaccardSimilarity(tokensA, tokensB) {
|
|
32
|
+
const a = new Set(tokensA);
|
|
33
|
+
const b = new Set(tokensB);
|
|
34
|
+
if (a.size === 0 || b.size === 0)
|
|
35
|
+
return 0;
|
|
36
|
+
let intersection = 0;
|
|
37
|
+
for (const t of a)
|
|
38
|
+
if (b.has(t))
|
|
39
|
+
intersection++;
|
|
40
|
+
const union = a.size + b.size - intersection;
|
|
41
|
+
return union === 0 ? 0 : intersection / union;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Conservative match = cosine AND lexical BOTH clear their thresholds.
|
|
45
|
+
* Both gates are required — a topic collision (high cosine, low lexical: two
|
|
46
|
+
* DIFFERENT findings that merely share a topic/vocabulary) must NOT be
|
|
47
|
+
* flagged as a duplicate. Only a true near-duplicate (high cosine AND high
|
|
48
|
+
* lexical overlap) is flagged.
|
|
49
|
+
*/
|
|
50
|
+
export function isConservativeMatch(cosine, lexical, cosineThreshold = DEDUP_COSINE_THRESHOLD_DEFAULT, lexicalThreshold = DEDUP_LEXICAL_THRESHOLD_DEFAULT) {
|
|
51
|
+
return cosine >= cosineThreshold && lexical >= lexicalThreshold;
|
|
52
|
+
}
|
|
53
|
+
/** Compute the {cosine, lexical} confidence pair for a candidate against the
|
|
54
|
+
* new content's text, using the shared bm25 tokenizer. Rounded to 3dp to
|
|
55
|
+
* match the existing _score rounding convention (SemanticSearch.ts). */
|
|
56
|
+
export function computeMatchConfidence(newContent, candidateContent, cosine) {
|
|
57
|
+
const lexical = jaccardSimilarity(tokenize(newContent), tokenize(candidateContent || ""));
|
|
58
|
+
return {
|
|
59
|
+
cosine: Math.round(cosine * 1000) / 1000,
|
|
60
|
+
lexical: Math.round(lexical * 1000) / 1000,
|
|
61
|
+
};
|
|
62
|
+
}
|
package/dist/resources/health.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
|
|
|
2
2
|
import { promises as fsp } from "node:fs";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { getRerankStatus } from "./rerank-provider.js";
|
|
5
6
|
const db = databases;
|
|
6
7
|
const redactHome = (p) => {
|
|
7
8
|
const home = homedir();
|
|
@@ -515,6 +516,22 @@ export class HealthDetail extends Resource {
|
|
|
515
516
|
catch {
|
|
516
517
|
stats.bridges = null;
|
|
517
518
|
}
|
|
519
|
+
// ── Reranker ──
|
|
520
|
+
// Cross-encoder rerank stage. Reports flag/model/mode + live counters so we
|
|
521
|
+
// can see if it's silently degrading (fallbackCount climbing) in prod.
|
|
522
|
+
try {
|
|
523
|
+
const rr = getRerankStatus();
|
|
524
|
+
stats.rerank = rr;
|
|
525
|
+
if (rr.enabled && rr.state === "failed") {
|
|
526
|
+
warnings.push({ level: "warn", message: `reranker enabled but unavailable: ${rr.error ?? "init failed"} — recall falling back to vector order` });
|
|
527
|
+
}
|
|
528
|
+
if (rr.enabled && rr.rerankCount > 0 && rr.fallbackCount > rr.rerankCount) {
|
|
529
|
+
warnings.push({ level: "warn", message: `reranker falling back more than reranking (${rr.fallbackCount} fallbacks / ${rr.rerankCount} reranks) — check latency budget / topN` });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
stats.rerank = null;
|
|
534
|
+
}
|
|
518
535
|
// ── Warnings ──
|
|
519
536
|
stats.warnings = warnings;
|
|
520
537
|
// ── Process info ──
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mcp-tools.ts — the
|
|
2
|
+
* mcp-tools.ts — the 10 curated flair tools for the Model-2 custom /mcp handler.
|
|
3
3
|
*
|
|
4
|
-
* Curated BY CONSTRUCTION: this module implements exactly the
|
|
4
|
+
* Curated BY CONSTRUCTION: this module implements exactly the 10 tools that the
|
|
5
5
|
* `@tpsdev-ai/flair-mcp` stdio proxy exposes, each a thin wrapper over the
|
|
6
6
|
* existing flair Resource handler. No business logic is re-implemented — the
|
|
7
7
|
* wrapped handlers (Memory / SemanticSearch / BootstrapMemories / Soul /
|
|
8
8
|
* WorkspaceState / OrgEvent) enforce per-agent scoping/ownership via
|
|
9
9
|
* `resolveAgentAuth(getContext())`, so the MCP surface inherits the SAME security
|
|
10
10
|
* model as the signed-REST path. There is no raw CRUD surface — the only way to
|
|
11
|
-
* reach the datastore through /mcp is via one of these
|
|
11
|
+
* reach the datastore through /mcp is via one of these 10 semantic tools.
|
|
12
12
|
*
|
|
13
|
-
* memory_search · memory_store ·
|
|
14
|
-
* soul_set · soul_get · flair_workspace_set · flair_orgevent
|
|
13
|
+
* memory_search · memory_store · memory_update · memory_get · memory_delete ·
|
|
14
|
+
* bootstrap · soul_set · soul_get · flair_workspace_set · flair_orgevent
|
|
15
15
|
*
|
|
16
16
|
* ── The scoping seam ────────────────────────────────────────────────────────
|
|
17
17
|
* The /mcp handler resolves the OAuth token's `sub` → a flair `Agent` id, then
|
|
@@ -116,6 +116,58 @@ async function memoryStore(agent, args) {
|
|
|
116
116
|
tags: args?.tags,
|
|
117
117
|
}));
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
|
|
121
|
+
* integrity fix). Mirrors flair-client's MemoryApi.update() (packages/
|
|
122
|
+
* flair-client/src/client.ts), reimplemented against the resource instance
|
|
123
|
+
* API instead of HTTP since this handler calls the Memory resource directly
|
|
124
|
+
* (same pattern as memoryStore vs the flair-mcp stdio tool). Auth is enforced
|
|
125
|
+
* by Memory.get()/Memory.put()/Memory.post()'s EXISTING ownership checks — no
|
|
126
|
+
* parallel auth logic here.
|
|
127
|
+
*
|
|
128
|
+
* Default (preserveHistory unset/false): read the existing record, merge the
|
|
129
|
+
* new content on top (Harper PUT is full-record replacement — never send a
|
|
130
|
+
* bare partial), clear the stale embedding so the server regenerates it, and
|
|
131
|
+
* PUT the merged record back to the SAME id.
|
|
132
|
+
*
|
|
133
|
+
* preserveHistory: true: write a NEW id with `supersedes: id`. Memory.post()
|
|
134
|
+
* validates/authorizes the supersede (denying a cross-agent supersede without
|
|
135
|
+
* a "write" MemoryGrant) and closes the old record's validTo AFTER the new
|
|
136
|
+
* record is written (never the reverse — see resources/Memory.ts).
|
|
137
|
+
*/
|
|
138
|
+
async function memoryUpdate(agent, args) {
|
|
139
|
+
const Cls = await handler("Memory");
|
|
140
|
+
const h = new Cls(undefined, delegationContext(agent));
|
|
141
|
+
const id = args?.id;
|
|
142
|
+
const content = args?.content;
|
|
143
|
+
const preserveHistory = args?.preserveHistory === true;
|
|
144
|
+
const existing = await h.get(id);
|
|
145
|
+
if (!existing) {
|
|
146
|
+
return { error: "memory not found", status: 404 };
|
|
147
|
+
}
|
|
148
|
+
if (preserveHistory) {
|
|
149
|
+
const newId = `${agent.agentId}-${crypto.randomUUID()}`;
|
|
150
|
+
const record = {
|
|
151
|
+
...existing,
|
|
152
|
+
id: newId,
|
|
153
|
+
content,
|
|
154
|
+
supersedes: id,
|
|
155
|
+
createdAt: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
delete record.updatedAt;
|
|
158
|
+
delete record.embedding;
|
|
159
|
+
delete record.embeddingModel;
|
|
160
|
+
delete record.validFrom;
|
|
161
|
+
delete record.validTo;
|
|
162
|
+
delete record.archivedAt;
|
|
163
|
+
h.isCollection = true;
|
|
164
|
+
return unwrap(await h.post(record));
|
|
165
|
+
}
|
|
166
|
+
const merged = { ...existing, content, updatedAt: new Date().toISOString() };
|
|
167
|
+
delete merged.embedding;
|
|
168
|
+
delete merged.embeddingModel;
|
|
169
|
+
return unwrap(await h.put(merged));
|
|
170
|
+
}
|
|
119
171
|
async function memoryGet(agent, args) {
|
|
120
172
|
const Cls = await handler("Memory");
|
|
121
173
|
const h = new Cls(undefined, delegationContext(agent));
|
|
@@ -231,6 +283,24 @@ export const TOOLS = {
|
|
|
231
283
|
},
|
|
232
284
|
impl: memoryStore,
|
|
233
285
|
},
|
|
286
|
+
memory_update: {
|
|
287
|
+
def: {
|
|
288
|
+
name: "memory_update",
|
|
289
|
+
description: "Update an existing memory by ID. Dedup-bypassed (this is an intentional overwrite, not a new write). " +
|
|
290
|
+
"Default: overwrites the same id in place. Pass preserveHistory=true to instead write a new version " +
|
|
291
|
+
"linked via `supersedes`, closing the old one's validity window.",
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: "object",
|
|
294
|
+
properties: {
|
|
295
|
+
id: { type: "string", description: "ID of the memory to update" },
|
|
296
|
+
content: { type: "string", description: "New content" },
|
|
297
|
+
preserveHistory: { type: "boolean", description: "Write a new version (supersedes-linked) instead of overwriting in place (default false)" },
|
|
298
|
+
},
|
|
299
|
+
required: ["id", "content"],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
impl: memoryUpdate,
|
|
303
|
+
},
|
|
234
304
|
memory_get: {
|
|
235
305
|
def: {
|
|
236
306
|
name: "memory_get",
|