@tpsdev-ai/flair 0.23.0 → 0.25.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/cli.js +1278 -410
- package/dist/deploy.js +16 -1
- package/dist/hook-install.js +324 -0
- package/dist/lib/auth-resolve.js +369 -0
- package/dist/lib/mcp-enable.js +787 -0
- package/dist/resources/Memory.js +93 -3
- package/dist/resources/MemoryBootstrap.js +85 -3
- package/dist/resources/Presence.js +4 -4
- package/dist/resources/RecordUsage.js +32 -80
- package/dist/resources/SemanticSearch.js +71 -3
- package/dist/resources/abstention.js +146 -0
- package/dist/resources/agent-auth.js +4 -5
- package/dist/resources/auth-middleware.js +4 -4
- package/dist/resources/ed25519-auth.js +37 -0
- package/dist/resources/embeddings-boot.js +38 -1
- package/dist/resources/mcp-tools.js +42 -5
- package/dist/resources/semantic-retrieval-core.js +24 -2
- package/dist/resources/trust-block.js +187 -0
- package/dist/resources/usage-recording.js +160 -0
- package/package.json +5 -4
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* abstention.ts — first-class recall abstention ("no memory covers this")
|
|
3
|
+
* (flair#744 slice 2).
|
|
4
|
+
*
|
|
5
|
+
* ─── What this is ────────────────────────────────────────────────────────────
|
|
6
|
+
* Weak matches presented as answers are how a memory system *causes*
|
|
7
|
+
* confabulation instead of preventing it. When the best retrieval match is
|
|
8
|
+
* below a confidence floor, the honest response is a first-class "no memory
|
|
9
|
+
* covers this" verdict — NOT the N weakest matches dressed up as an answer.
|
|
10
|
+
* This module is the PURE, Harper-free decision core the recall wrappers
|
|
11
|
+
* (SemanticSearch.post, BootstrapMemories.post) consult in their response tail,
|
|
12
|
+
* strictly downstream of retrieval + read-scope resolution.
|
|
13
|
+
*
|
|
14
|
+
* ─── Opt-in, conservative, calibration is a SEPARATE follow-up ──────────────
|
|
15
|
+
* Slice 2 ships the abstention *response shape*, decoupled from threshold
|
|
16
|
+
* *calibration* (the design round's explicit sharpening). Consumers build
|
|
17
|
+
* against the API shape now; the promote-to-default calibration on the
|
|
18
|
+
* recall-bench corpus proceeds independently (NOT this slice). Until then the
|
|
19
|
+
* mode is opt-in (`abstain` request flag, default OFF ⇒ recall is byte-identical
|
|
20
|
+
* to today) and the threshold is a deliberately CONSERVATIVE hand-set constant
|
|
21
|
+
* that errs toward returning results rather than over-abstaining.
|
|
22
|
+
*
|
|
23
|
+
* ─── The GLOBAL-threshold invariant (flair#744 Sherlock BINDING condition 2) ─
|
|
24
|
+
* The abstention threshold MUST be GLOBAL (or scope-wide) and NEVER
|
|
25
|
+
* per-principal. A per-principal threshold ("this principal's memories need
|
|
26
|
+
* higher confidence to surface") would be an authority lever and would violate
|
|
27
|
+
* the zero-authority trust spine — the same discipline as the `claimed.*`
|
|
28
|
+
* guard (flair#735). This module makes that STRUCTURAL, not just documented:
|
|
29
|
+
* 1. ABSTENTION_THRESHOLD is a single module-level constant. There is no
|
|
30
|
+
* per-principal (or any) threshold parameter — `evaluateAbstention` reads
|
|
31
|
+
* the constant directly, so there is no lever to vary it per principal.
|
|
32
|
+
* 2. The decision consults ONLY a retrieval-confidence number. Neither
|
|
33
|
+
* function below takes — or this module anywhere imports — an agentId,
|
|
34
|
+
* principal, trust tier, or any authority signal. Enforced structurally by
|
|
35
|
+
* test/unit/abstention-no-per-principal-tripwire.test.ts (the module is
|
|
36
|
+
* scanned for authority tokens, and the decision function's arity is
|
|
37
|
+
* pinned to its single numeric input).
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* ABSTENTION_THRESHOLD — the single GLOBAL retrieval-confidence floor.
|
|
41
|
+
*
|
|
42
|
+
* When the best-match absolute semantic similarity (cosine, [0,1] — see
|
|
43
|
+
* `bestSemanticSimilarity`) is below this value, opt-in recall abstains
|
|
44
|
+
* ("no memory covers this") instead of returning the N weak matches.
|
|
45
|
+
*
|
|
46
|
+
* GLOBAL / data-driven, NEVER per-principal (Sherlock binding condition 2).
|
|
47
|
+
*
|
|
48
|
+
* CONSERVATIVE hand-set value (0.15): well below the strong-match band real
|
|
49
|
+
* embeddings produce for genuinely relevant memories, and below bootstrap's
|
|
50
|
+
* own long-standing task-relevance floor (0.3, resources/MemoryBootstrap.ts's
|
|
51
|
+
* TASK_RELEVANCE_FLOOR) — so abstention fires only when there is essentially
|
|
52
|
+
* nothing semantically near the query, erring toward returning results rather
|
|
53
|
+
* than over-abstaining. Promoting abstention to the DEFAULT recall mode, and
|
|
54
|
+
* tuning this value on the recall-bench corpus, is a SEPARATE follow-up (see
|
|
55
|
+
* flair#744) — this slice ships the response shape at a safe opt-in floor, not
|
|
56
|
+
* the calibrated default.
|
|
57
|
+
*/
|
|
58
|
+
export const ABSTENTION_THRESHOLD = 0.15;
|
|
59
|
+
/**
|
|
60
|
+
* Confidence-band cut-points for `matchQuality` (flair#744 confidence-band
|
|
61
|
+
* refinement — "breadcrumbs, labeled"). Recall shouldn't be binary
|
|
62
|
+
* confident-match / nothing: a weak-but-present match is valuable if the agent
|
|
63
|
+
* KNOWS it's weak. The band classifier (resources/trust-block.ts's
|
|
64
|
+
* `classifyMatchQuality`) labels each result's absolute `_semSimilarity` as
|
|
65
|
+
* strong / moderate / breadcrumb against these two floors plus the abstention
|
|
66
|
+
* floor below:
|
|
67
|
+
*
|
|
68
|
+
* sim >= STRONG_BAND (0.55) → "strong"
|
|
69
|
+
* MODERATE_BAND (0.35) <= sim < STRONG_BAND → "moderate"
|
|
70
|
+
* ABSTENTION_THRESHOLD (0.15) <= sim < 0.35 → "breadcrumb"
|
|
71
|
+
* sim < ABSTENTION_THRESHOLD (present anyway, → "breadcrumb" (weakest present
|
|
72
|
+
* abstention off) band — NO 4th band)
|
|
73
|
+
*
|
|
74
|
+
* ─── SINGLE SOURCE OF TRUTH (Kern BINDING condition 1) ──────────────────────
|
|
75
|
+
* These two band floors live HERE, in the SAME module as ABSTENTION_THRESHOLD,
|
|
76
|
+
* so the band cut-points and the abstention floor are one source of truth and
|
|
77
|
+
* cannot drift. The classifier's breadcrumb floor is ABSTENTION_THRESHOLD
|
|
78
|
+
* ITSELF (imported, never a duplicate `0.15` literal) — the bottom of the
|
|
79
|
+
* breadcrumb band is exactly the top of abstention: below it, opt-in abstention
|
|
80
|
+
* returns "no memory covers this" instead. If recall-bench moves
|
|
81
|
+
* ABSTENTION_THRESHOLD, breadcrumb's floor moves with it, automatically.
|
|
82
|
+
*
|
|
83
|
+
* ─── CONSERVATIVE hand-set placeholders (same posture as ABSTENTION_THRESHOLD)
|
|
84
|
+
* 0.35 / 0.55 are reasonable hand-set cuts for cosine similarity on common
|
|
85
|
+
* embedding models, but the point is they're REPLACEABLE without an API change.
|
|
86
|
+
* The real cut-points come from recall-bench (which similarity band correlates
|
|
87
|
+
* with "the agent found this useful") — that calibration is a SEPARATE
|
|
88
|
+
* follow-up (see flair#744), exactly like promoting abstention to the default.
|
|
89
|
+
* The field and its API shape ship now; the numbers get tuned independently.
|
|
90
|
+
*
|
|
91
|
+
* GLOBAL constants, NEVER per-principal (Sherlock): the classifier takes exactly
|
|
92
|
+
* one numeric input and references no principal/tier — structurally guarded by
|
|
93
|
+
* test/unit/abstention-no-per-principal-tripwire.test.ts.
|
|
94
|
+
*/
|
|
95
|
+
export const MODERATE_BAND = 0.35;
|
|
96
|
+
export const STRONG_BAND = 0.55;
|
|
97
|
+
/**
|
|
98
|
+
* Pick the best absolute semantic similarity across a retrieval candidate pool.
|
|
99
|
+
*
|
|
100
|
+
* Reads ONLY the `_semSimilarity` number the retrieval core
|
|
101
|
+
* (resources/semantic-retrieval-core.ts) attaches to each semantic-leg result
|
|
102
|
+
* WHEN abstention is requested — an absolute cosine similarity in [0,1],
|
|
103
|
+
* independent of the RRF normalization / rerank that make the ranking `_score`
|
|
104
|
+
* a *relative* signal (the top RRF-fused result is normalized to 1.0 regardless
|
|
105
|
+
* of how weak the actual match is, so `_score` is unusable as a confidence
|
|
106
|
+
* floor — this is why abstention reads the absolute similarity instead).
|
|
107
|
+
*
|
|
108
|
+
* Returns null when NO candidate carries a `_semSimilarity` (no embedding-based
|
|
109
|
+
* match at all — e.g. a keyword-only degraded search, or an empty pool). Per
|
|
110
|
+
* `evaluateAbstention`, null ⇒ never abstain (conservative: a degraded recall
|
|
111
|
+
* that couldn't even judge confidence should return what it found, not a
|
|
112
|
+
* confident "nothing covers this").
|
|
113
|
+
*
|
|
114
|
+
* PURE and authority-free: reads a single numeric field off each candidate,
|
|
115
|
+
* never any principal / agentId / tier / scope. It cannot make the decision
|
|
116
|
+
* per-principal because it never sees a principal.
|
|
117
|
+
*/
|
|
118
|
+
export function bestSemanticSimilarity(candidates) {
|
|
119
|
+
let best = null;
|
|
120
|
+
for (const c of candidates) {
|
|
121
|
+
const s = c?._semSimilarity;
|
|
122
|
+
if (typeof s === "number" && Number.isFinite(s) && (best === null || s > best)) {
|
|
123
|
+
best = s;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return best;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The abstention decision. PURE, and its ONLY input is a single confidence
|
|
130
|
+
* number (or null) — there is deliberately no threshold parameter and no
|
|
131
|
+
* principal parameter, so the outcome cannot be varied per principal (Sherlock
|
|
132
|
+
* binding condition 2): the threshold is always the module-global
|
|
133
|
+
* ABSTENTION_THRESHOLD.
|
|
134
|
+
*
|
|
135
|
+
* - bestScore === null ⇒ never abstain (no embedding-based match to judge;
|
|
136
|
+
* conservative — return what was found).
|
|
137
|
+
* - bestScore < floor ⇒ abstain ("no memory covers this").
|
|
138
|
+
* - bestScore >= floor ⇒ do not abstain (return normal results).
|
|
139
|
+
*/
|
|
140
|
+
export function evaluateAbstention(bestScore) {
|
|
141
|
+
const threshold = ABSTENTION_THRESHOLD;
|
|
142
|
+
const abstained = bestScore !== null && bestScore < threshold;
|
|
143
|
+
return abstained
|
|
144
|
+
? { abstained: true, bestScore, threshold, reason: "no memory above confidence threshold" }
|
|
145
|
+
: { abstained: false, bestScore, threshold };
|
|
146
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
|
|
17
17
|
*/
|
|
18
18
|
import { databases } from "@harperfast/harper";
|
|
19
|
-
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
19
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
|
|
20
20
|
/**
|
|
21
21
|
* Shared Harper user that verified Ed25519 agents resolve to (least-privilege
|
|
22
22
|
* `flair_agent` role), replacing the old admin super_user elevation. Single
|
|
@@ -56,15 +56,14 @@ async function getAdminAgents() {
|
|
|
56
56
|
export async function isAdmin(agentId) {
|
|
57
57
|
return (await getAdminAgents()).has(agentId);
|
|
58
58
|
}
|
|
59
|
-
const HEADER_RE = /^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/;
|
|
60
59
|
async function doVerify(request) {
|
|
61
60
|
const header = request?.headers?.get?.("authorization") ??
|
|
62
61
|
request?.headers?.asObject?.authorization ??
|
|
63
62
|
"";
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
63
|
+
const parsed = parseTpsEd25519Header(header);
|
|
64
|
+
if (!parsed)
|
|
66
65
|
return null;
|
|
67
|
-
const
|
|
66
|
+
const { agentId, tsRaw, nonce, signatureB64 } = parsed;
|
|
68
67
|
const ts = Number(tsRaw);
|
|
69
68
|
const now = Date.now();
|
|
70
69
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
@@ -2,7 +2,7 @@ import { patchRecord } from "./table-helpers.js";
|
|
|
2
2
|
import { server, databases } from "@harperfast/harper";
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
4
|
import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
5
|
-
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
5
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
|
|
6
6
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
7
7
|
// --- Admin credentials ---
|
|
8
8
|
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
@@ -230,8 +230,8 @@ server.http(async (request, nextLayer) => {
|
|
|
230
230
|
return nextLayer(request);
|
|
231
231
|
}
|
|
232
232
|
// ── Ed25519 agent auth ────────────────────────────────────────────────────
|
|
233
|
-
const
|
|
234
|
-
if (!
|
|
233
|
+
const parsed = parseTpsEd25519Header(header);
|
|
234
|
+
if (!parsed) {
|
|
235
235
|
// For browser-accessible admin pages, emit `WWW-Authenticate: Basic` so
|
|
236
236
|
// the browser shows a native auth dialog instead of a bare 401 page.
|
|
237
237
|
// JSON API endpoints don't get this — they should keep the structured
|
|
@@ -260,7 +260,7 @@ server.http(async (request, nextLayer) => {
|
|
|
260
260
|
catch { /* frozen headers — annotation on the request object still applies */ }
|
|
261
261
|
return nextLayer(request);
|
|
262
262
|
}
|
|
263
|
-
const
|
|
263
|
+
const { agentId, tsRaw, nonce, signatureB64 } = parsed;
|
|
264
264
|
const ts = Number(tsRaw);
|
|
265
265
|
const now = Date.now();
|
|
266
266
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
@@ -42,6 +42,43 @@ export { b64ToArrayBuffer };
|
|
|
42
42
|
* stated "plugin-shaped, config via env" design intent.
|
|
43
43
|
*/
|
|
44
44
|
export const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
45
|
+
// ─── Auth header parsing (single shared implementation) ────────────────────
|
|
46
|
+
//
|
|
47
|
+
// One parser for the `Authorization: TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`
|
|
48
|
+
// header, used by all 3 call sites (auth-middleware.ts, agent-auth.ts,
|
|
49
|
+
// Presence.ts) so the grammar and its input bounds can't drift.
|
|
50
|
+
/**
|
|
51
|
+
* Upper bound on the accepted Authorization header length. A well-formed
|
|
52
|
+
* TPS-Ed25519 header (`TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`) is a few hundred
|
|
53
|
+
* chars at most; anything materially larger is malformed. The header is
|
|
54
|
+
* untrusted client input, so we cap its length before running the regex,
|
|
55
|
+
* keeping parse cost bounded regardless of the input's shape.
|
|
56
|
+
*/
|
|
57
|
+
export const MAX_AUTH_HEADER_LEN = 4096;
|
|
58
|
+
/**
|
|
59
|
+
* TPS-Ed25519 auth header grammar.
|
|
60
|
+
*
|
|
61
|
+
* The two colon-delimited text captures use `[^:\s]+` (not `[^:]+`) so they are
|
|
62
|
+
* disjoint from the preceding `\s+`: with no character shared between the two
|
|
63
|
+
* adjacent quantifiers there is exactly one way to split any input, so matching
|
|
64
|
+
* is linear-time on every input (including long, degenerate ones). A real
|
|
65
|
+
* agentId / nonce / signature never contains whitespace, so excluding it is
|
|
66
|
+
* behavior-preserving for well-formed headers.
|
|
67
|
+
*/
|
|
68
|
+
export const TPS_ED25519_HEADER_RE = /^TPS-Ed25519\s+([^:\s]+):(\d+):([^:\s]+):(.+)$/;
|
|
69
|
+
/**
|
|
70
|
+
* Parse a TPS-Ed25519 `Authorization` header value into its fields, or return
|
|
71
|
+
* null if it is over `MAX_AUTH_HEADER_LEN` or doesn't match the grammar.
|
|
72
|
+
* Callers treat null exactly as a header that carries no valid agent auth.
|
|
73
|
+
*/
|
|
74
|
+
export function parseTpsEd25519Header(header) {
|
|
75
|
+
if (!header || header.length > MAX_AUTH_HEADER_LEN)
|
|
76
|
+
return null;
|
|
77
|
+
const m = TPS_ED25519_HEADER_RE.exec(header);
|
|
78
|
+
if (!m)
|
|
79
|
+
return null;
|
|
80
|
+
return { agentId: m[1], tsRaw: m[2], nonce: m[3], signatureB64: m[4] };
|
|
81
|
+
}
|
|
45
82
|
// ─── Replay guard (single shared instance) ─────────────────────────────────
|
|
46
83
|
//
|
|
47
84
|
// nonceSeen is the ONE module-level singleton — the whole point of this
|
|
@@ -87,6 +87,41 @@
|
|
|
87
87
|
import { resolveModelsDir } from "./embeddings-provider.js";
|
|
88
88
|
const LOGICAL_NAME = "default";
|
|
89
89
|
const MODEL_NAME = "nomic-embed-text";
|
|
90
|
+
/**
|
|
91
|
+
* Pooling declaration (HFE 0.5.0+, harper-fabric-embeddings' `init()`
|
|
92
|
+
* `pooling` option). Verification, not override: the native addon has no
|
|
93
|
+
* pooling knob of its own — llama.cpp always pools by whatever the GGUF's
|
|
94
|
+
* own `<arch>.pooling_type` metadata says. Declaring the expectation makes
|
|
95
|
+
* HFE assert it at init and fail loudly on absent/mismatched metadata,
|
|
96
|
+
* instead of a metadata-less or mismatched conversion silently pooling the
|
|
97
|
+
* wrong way (see node_modules/harper-fabric-embeddings/README.md's `init()`
|
|
98
|
+
* table for the exact contract this PR bumps to).
|
|
99
|
+
*
|
|
100
|
+
* "mean" is nomic-embed-text-v1.5's actual pooling type — NOT assumed from
|
|
101
|
+
* the model's reputation, directly confirmed against the shipped GGUF:
|
|
102
|
+
* `node_modules/.bin/node-llama-cpp inspect gguf
|
|
103
|
+
* ~/.flair/data/models/nomic-embed-text-v1.5.Q4_K_M.gguf` reports
|
|
104
|
+
* `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
|
|
105
|
+
* `enum llama_pooling_type` maps 1 -> `LLAMA_POOLING_TYPE_MEAN` (0 = none,
|
|
106
|
+
* 1 = mean, 2 = cls, 3 = last, 4 = rank). nomic-embed-text is a
|
|
107
|
+
* NomicBertModel architecture, not Qwen3 (last-token) — flair registers no
|
|
108
|
+
* Qwen3-class embedding model today (the Qwen3-Reranker-0.6B in
|
|
109
|
+
* resources/rerank-provider.ts is a SEPARATE code path that calls
|
|
110
|
+
* node-llama-cpp directly for generative yes/no scoring, never goes through
|
|
111
|
+
* HFE's register()/init(), and has no embedding-pooling context at all — see
|
|
112
|
+
* that file's header). If a Qwen3-class (last-token-pooling) embedding model
|
|
113
|
+
* is ever registered here, it must declare `pooling: "last"`, not "mean".
|
|
114
|
+
*
|
|
115
|
+
* Applies to the bench-only `modelPath` override too (`benchModelPathOverride()`
|
|
116
|
+
* below): `FLAIR_RECALL_HARNESS_MODEL_PATH` lets an operator point this
|
|
117
|
+
* registration at an arbitrary GGUF for a Q4/Q8 bakeoff, and this constant
|
|
118
|
+
* assumes whatever file lands there is still nomic-family (mean-pooling) —
|
|
119
|
+
* true for every bakeoff run to date (same base model, different quant).
|
|
120
|
+
* HFE 0.5.0's verification is exactly the safety net that turns "someone
|
|
121
|
+
* points --model-file at a non-nomic, non-mean-pooling GGUF" into a loud
|
|
122
|
+
* boot-time failure instead of a silently-wrong pooling pass.
|
|
123
|
+
*/
|
|
124
|
+
const EMBEDDING_POOLING = "mean";
|
|
90
125
|
/**
|
|
91
126
|
* BENCH-ONLY escape hatch — NOT a production feature flag, never documented
|
|
92
127
|
* as an operator setting, and never read anywhere else in this codebase.
|
|
@@ -131,7 +166,9 @@ export async function registerEmbeddingsBackend() {
|
|
|
131
166
|
await register({
|
|
132
167
|
logicalName: LOGICAL_NAME,
|
|
133
168
|
kind: "embedding",
|
|
134
|
-
config: modelPath
|
|
169
|
+
config: modelPath
|
|
170
|
+
? { modelPath, pooling: EMBEDDING_POOLING }
|
|
171
|
+
: { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING },
|
|
135
172
|
});
|
|
136
173
|
}
|
|
137
174
|
catch (err) {
|
|
@@ -114,7 +114,16 @@ async function unwrap(value) {
|
|
|
114
114
|
async function memorySearch(agent, args) {
|
|
115
115
|
const Cls = await handler("SemanticSearch");
|
|
116
116
|
const h = new Cls(undefined, delegationContext(agent));
|
|
117
|
-
|
|
117
|
+
const body = { q: args?.query, limit: args?.limit ?? 5 };
|
|
118
|
+
// flair#744 slice 1 — opt-in inline trust block per result. Forwarded ONLY
|
|
119
|
+
// when requested so a plain search delegates a byte-identical body.
|
|
120
|
+
if (args?.includeTrust === true)
|
|
121
|
+
body.includeTrust = true;
|
|
122
|
+
// flair#744 slice 2 — opt-in abstention verdict. Forwarded ONLY when
|
|
123
|
+
// requested so a plain search delegates a byte-identical body.
|
|
124
|
+
if (args?.abstain === true)
|
|
125
|
+
body.abstain = true;
|
|
126
|
+
return unwrap(await h.post(body));
|
|
118
127
|
}
|
|
119
128
|
async function memoryStore(agent, args) {
|
|
120
129
|
const Cls = await handler("Memory");
|
|
@@ -136,6 +145,13 @@ async function memoryStore(agent, args) {
|
|
|
136
145
|
// Omitted entirely when the token carried no client_id.
|
|
137
146
|
if (agent.clientId)
|
|
138
147
|
body.claimedClient = agent.clientId;
|
|
148
|
+
// flair#744 slice A: citation-on-write — forward the optional
|
|
149
|
+
// usedMemoryIds array only when the caller actually supplied it, so an
|
|
150
|
+
// omitted citation list delegates a byte-identical body (Memory.post()
|
|
151
|
+
// consumes-and-strips this before the row is written, then credits each
|
|
152
|
+
// id post-commit through the shared usage ledger).
|
|
153
|
+
if (Array.isArray(args?.usedMemoryIds))
|
|
154
|
+
body.usedMemoryIds = args.usedMemoryIds;
|
|
139
155
|
return unwrap(await h.post(body));
|
|
140
156
|
}
|
|
141
157
|
/**
|
|
@@ -201,7 +217,11 @@ async function memoryUpdate(agent, args) {
|
|
|
201
217
|
async function memoryGet(agent, args) {
|
|
202
218
|
const Cls = await handler("Memory");
|
|
203
219
|
const h = new Cls(undefined, delegationContext(agent));
|
|
204
|
-
|
|
220
|
+
// flair#744 slice 1 — opt-in inline trust block on the returned record.
|
|
221
|
+
// Pass the opts arg ONLY when requested so a plain get() call is unchanged.
|
|
222
|
+
return unwrap(args?.includeTrust === true
|
|
223
|
+
? await h.get(args?.id, { includeTrust: true })
|
|
224
|
+
: await h.get(args?.id));
|
|
205
225
|
}
|
|
206
226
|
async function memoryDelete(agent, args) {
|
|
207
227
|
const Cls = await handler("Memory");
|
|
@@ -211,7 +231,7 @@ async function memoryDelete(agent, args) {
|
|
|
211
231
|
async function bootstrap(agent, args) {
|
|
212
232
|
const Cls = await handler("BootstrapMemories");
|
|
213
233
|
const h = new Cls(undefined, delegationContext(agent));
|
|
214
|
-
|
|
234
|
+
const body = {
|
|
215
235
|
agentId: agent.agentId,
|
|
216
236
|
maxTokens: args?.maxTokens ?? 4000,
|
|
217
237
|
currentTask: args?.currentTask,
|
|
@@ -219,7 +239,16 @@ async function bootstrap(agent, args) {
|
|
|
219
239
|
surface: args?.surface,
|
|
220
240
|
subjects: args?.subjects,
|
|
221
241
|
entities: args?.entities,
|
|
222
|
-
}
|
|
242
|
+
};
|
|
243
|
+
// flair#744 slice 1 — opt-in per-memory trust block array. Forwarded ONLY
|
|
244
|
+
// when requested so a plain bootstrap delegates a byte-identical body.
|
|
245
|
+
if (args?.includeTrust === true)
|
|
246
|
+
body.includeTrust = true;
|
|
247
|
+
// flair#744 slice 2 — opt-in task-relevance abstention verdict. Forwarded
|
|
248
|
+
// ONLY when requested so a plain bootstrap delegates a byte-identical body.
|
|
249
|
+
if (args?.abstain === true)
|
|
250
|
+
body.abstain = true;
|
|
251
|
+
return unwrap(await h.post(body));
|
|
223
252
|
}
|
|
224
253
|
async function soulSet(agent, args) {
|
|
225
254
|
const Cls = await handler("Soul");
|
|
@@ -345,6 +374,8 @@ export const TOOLS = {
|
|
|
345
374
|
properties: {
|
|
346
375
|
query: { type: "string", description: "Search query — natural language, semantic matching" },
|
|
347
376
|
limit: { type: "number", description: "Max results (default 5)" },
|
|
377
|
+
includeTrust: { type: "boolean", description: "Attach a per-result trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
|
|
378
|
+
abstain: { type: "boolean", description: "Opt into first-class abstention: when the best match is below a global confidence threshold, return { abstained: true, reason, bestScore } with no weak matches instead of the N weakest results. Default false." },
|
|
348
379
|
},
|
|
349
380
|
required: ["query"],
|
|
350
381
|
},
|
|
@@ -362,6 +393,7 @@ export const TOOLS = {
|
|
|
362
393
|
type: { type: "string", enum: ["session", "lesson", "decision", "preference", "fact", "goal"], description: "Memory type (default session)" },
|
|
363
394
|
durability: { type: "string", enum: ["permanent", "persistent", "standard", "ephemeral"], description: "permanent > persistent > standard > ephemeral (default standard)" },
|
|
364
395
|
tags: { type: "array", items: { type: "string" }, description: "Tag strings" },
|
|
396
|
+
usedMemoryIds: { type: "array", items: { type: "string" }, description: "IDs of memories that informed this write (citation-on-write). Credited via the same deduped usage ledger as record_usage. Optional." },
|
|
365
397
|
},
|
|
366
398
|
required: ["content"],
|
|
367
399
|
},
|
|
@@ -393,7 +425,10 @@ export const TOOLS = {
|
|
|
393
425
|
annotations: { readOnlyHint: true },
|
|
394
426
|
inputSchema: {
|
|
395
427
|
type: "object",
|
|
396
|
-
properties: {
|
|
428
|
+
properties: {
|
|
429
|
+
id: { type: "string", description: "Memory ID" },
|
|
430
|
+
includeTrust: { type: "boolean", description: "Attach a trust-evidence block (provenance, author, usage, freshness, supersession) to the record. Default false." },
|
|
431
|
+
},
|
|
397
432
|
required: ["id"],
|
|
398
433
|
},
|
|
399
434
|
},
|
|
@@ -430,6 +465,8 @@ export const TOOLS = {
|
|
|
430
465
|
items: { type: "string" },
|
|
431
466
|
description: "Your declared attention-plane vocabulary strings (e.g. \"issue:owner/repo#123\") for collision surfacing's 'Others in the room' block — teammates with overlapping active work. Falls back to your own most-recent workspace-state entities when omitted.",
|
|
432
467
|
},
|
|
468
|
+
includeTrust: { type: "boolean", description: "Also return a `trust` array with a per-included-memory trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
|
|
469
|
+
abstain: { type: "boolean", description: "Opt into a task-relevance abstention verdict: also return an `abstention` object ({ abstained, bestScore, threshold }) reporting whether any memory covered `currentTask` above a global confidence threshold. Default false." },
|
|
433
470
|
},
|
|
434
471
|
},
|
|
435
472
|
},
|
|
@@ -58,13 +58,18 @@ function distanceToSimilarity(distance) {
|
|
|
58
58
|
// m.content`, so dropping it here would silently regress bootstrap's
|
|
59
59
|
// "Others in the room" surface even though SemanticSearch never asserts on
|
|
60
60
|
// its absence.
|
|
61
|
-
|
|
61
|
+
// Exported so the opt-in trust-block path (flair#744 slice 1) can widen it with
|
|
62
|
+
// `provenance` ONLY when a caller requests the block — the default projection
|
|
63
|
+
// deliberately omits `provenance` (it's only needed for the trust block), and
|
|
64
|
+
// adding it unconditionally would change every existing recall response's
|
|
65
|
+
// bytes. See SemanticSearch.ts / MemoryBootstrap.ts's `includeTrust` handling.
|
|
66
|
+
export const DEFAULT_SELECT = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
|
|
62
67
|
"source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "usageCount", "lastRetrieved",
|
|
63
68
|
"promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
|
|
64
69
|
"parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject", "summary",
|
|
65
70
|
"validFrom", "validTo", "_safetyFlags"];
|
|
66
71
|
export async function retrieveCandidates(params) {
|
|
67
|
-
const { queryEmbedding: qEmb, q, conditions, limit, select = DEFAULT_SELECT, includeSuperseded = false, scoring = "raw", temporalBoost = 1.0, sinceDate = null, asOf, minScore = 0, agentId, isAllowed, hybrid, ctx, } = params;
|
|
72
|
+
const { queryEmbedding: qEmb, q, conditions, limit, select = DEFAULT_SELECT, includeSuperseded = false, scoring = "raw", temporalBoost = 1.0, sinceDate = null, asOf, minScore = 0, agentId, isAllowed, hybrid, ctx, withSemSimilarity = false, } = params;
|
|
68
73
|
const passesAllowed = (record) => !isAllowed || isAllowed(record);
|
|
69
74
|
const hnswSelect = [...select, "$distance"];
|
|
70
75
|
const results = [];
|
|
@@ -79,6 +84,11 @@ export async function retrieveCandidates(params) {
|
|
|
79
84
|
// ── (a) Semantic candidate records (best-first) ──────────────────────
|
|
80
85
|
const semRecords = [];
|
|
81
86
|
const semIds = [];
|
|
87
|
+
// flair#744 slice 2: absolute cosine similarity per semantic candidate
|
|
88
|
+
// (from the HNSW `$distance`), captured HERE before `$distance` is stripped
|
|
89
|
+
// downstream — the confidence signal the abstention decision reads (only
|
|
90
|
+
// when `withSemSimilarity`; empty/unused otherwise).
|
|
91
|
+
const semSimById = new Map();
|
|
82
92
|
if (qEmb) {
|
|
83
93
|
const semQuery = {
|
|
84
94
|
sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
|
|
@@ -108,6 +118,9 @@ export async function retrieveCandidates(params) {
|
|
|
108
118
|
continue;
|
|
109
119
|
if (!passesAllowed(record))
|
|
110
120
|
continue;
|
|
121
|
+
if (withSemSimilarity && record.$distance !== undefined) {
|
|
122
|
+
semSimById.set(record.id, distanceToSimilarity(record.$distance));
|
|
123
|
+
}
|
|
111
124
|
semRecords.push(record);
|
|
112
125
|
semIds.push(record.id);
|
|
113
126
|
}
|
|
@@ -177,12 +190,17 @@ export async function retrieveCandidates(params) {
|
|
|
177
190
|
finalScore *= temporalBoost;
|
|
178
191
|
const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
|
|
179
192
|
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
193
|
+
// flair#744 slice 2: absolute cosine confidence for the abstention
|
|
194
|
+
// decision — only for records that had a semantic (HNSW) candidate; a
|
|
195
|
+
// BM25-lexical-only match carries no cosine and contributes none.
|
|
196
|
+
const semSim = withSemSimilarity ? semSimById.get(id) : undefined;
|
|
180
197
|
results.push({
|
|
181
198
|
...record,
|
|
182
199
|
content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
|
|
183
200
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
184
201
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
185
202
|
_source: source,
|
|
203
|
+
...(semSim !== undefined ? { _semSimilarity: semSim } : {}),
|
|
186
204
|
});
|
|
187
205
|
}
|
|
188
206
|
}
|
|
@@ -241,12 +259,16 @@ export async function retrieveCandidates(params) {
|
|
|
241
259
|
const { $distance, ...rest } = record;
|
|
242
260
|
const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
|
|
243
261
|
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
262
|
+
// flair#744 slice 2: the absolute cosine (`semanticScore`, pre keyword
|
|
263
|
+
// bump) is the abstention confidence signal on this legacy/bootstrap
|
|
264
|
+
// (HNSW-leg-only) path.
|
|
244
265
|
results.push({
|
|
245
266
|
...rest,
|
|
246
267
|
content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
|
|
247
268
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
248
269
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
249
270
|
_source: source,
|
|
271
|
+
...(withSemSimilarity ? { _semSimilarity: semanticScore } : {}),
|
|
250
272
|
});
|
|
251
273
|
}
|
|
252
274
|
}
|