@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.
@@ -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 m = HEADER_RE.exec(header);
65
- if (!m)
63
+ const parsed = parseTpsEd25519Header(header);
64
+ if (!parsed)
66
65
  return null;
67
- const [, agentId, tsRaw, nonce, signatureB64] = m;
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 m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
234
- if (!m) {
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 [, agentId, tsRaw, nonce, signatureB64] = m;
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 ? { modelPath } : { modelName: MODEL_NAME, modelsDir: resolveModelsDir() },
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
- return unwrap(await h.post({ q: args?.query, limit: args?.limit ?? 5 }));
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
- return unwrap(await h.get(args?.id));
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
- return unwrap(await h.post({
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: { id: { type: "string", description: "Memory ID" } },
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
- const DEFAULT_SELECT = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
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
  }