@tpsdev-ai/flair 0.24.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/resources/Memory.js +75 -3
- package/dist/resources/MemoryBootstrap.js +85 -3
- package/dist/resources/RecordUsage.js +32 -80
- package/dist/resources/SemanticSearch.js +71 -3
- package/dist/resources/abstention.js +146 -0
- 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 +3 -2
package/dist/resources/Memory.js
CHANGED
|
@@ -9,6 +9,24 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
|
9
9
|
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
|
|
10
10
|
import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
11
11
|
import { RECORD_TYPES } from "./record-types.js";
|
|
12
|
+
import { attachTrust } from "./trust-block.js";
|
|
13
|
+
import { recordCitations } from "./usage-recording.js";
|
|
14
|
+
/**
|
|
15
|
+
* flair#744 slice 1 — read the opt-in `includeTrust` flag for a by-id get.
|
|
16
|
+
* Two entry shapes: an in-process caller (resources/mcp-tools.ts's memory_get)
|
|
17
|
+
* passes it explicitly via the `opts` arg; an HTTP `GET /Memory/<id>?includeTrust=true`
|
|
18
|
+
* carries it as a query param on the RequestTarget. Defensive across the
|
|
19
|
+
* RequestTarget/URLSearchParams shapes Harper may hand us; anything other than
|
|
20
|
+
* a literal "true" reads as off, so the default response stays byte-identical.
|
|
21
|
+
*/
|
|
22
|
+
function wantsTrust(target, opts) {
|
|
23
|
+
if (opts?.includeTrust === true)
|
|
24
|
+
return true;
|
|
25
|
+
const raw = target?.get?.("includeTrust") ??
|
|
26
|
+
target?.searchParams?.get?.("includeTrust") ??
|
|
27
|
+
undefined;
|
|
28
|
+
return raw === "true" || raw === true;
|
|
29
|
+
}
|
|
12
30
|
/**
|
|
13
31
|
* Owner ids a non-admin agent may READ (resolveAllowedOwners) live in
|
|
14
32
|
* ./memory-read-scope.ts — still exported/used elsewhere (admin tooling).
|
|
@@ -439,7 +457,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
439
457
|
* with Memory's own "open-within-org" read-scope resolver above — same
|
|
440
458
|
* dispatch shape Relationship.ts/WorkspaceState.ts's get() overrides use.
|
|
441
459
|
*/
|
|
442
|
-
async get(target) {
|
|
460
|
+
async get(target, opts) {
|
|
443
461
|
// Collection / query reads — the `GET /Memory/?<query>` form and the bare
|
|
444
462
|
// collection — arrive as a RequestTarget with `isCollection === true`, and
|
|
445
463
|
// are governed by search() (same owner/grant scoping). Only a genuine by-id
|
|
@@ -451,11 +469,24 @@ export class Memory extends databases.flair.Memory {
|
|
|
451
469
|
// get (RequestTarget with isCollection false, or a bare id) falls through.
|
|
452
470
|
// makeByIdReadGate re-applies this same guard internally (delegating to
|
|
453
471
|
// this.search via `.call(this, ...)`) — kept here too as documentation of
|
|
454
|
-
// the invariant at the call site, harmless no-op double-check.
|
|
472
|
+
// the invariant at the call site, harmless no-op double-check. The trust
|
|
473
|
+
// block (flair#744) is NOT attached on the collection path — that routes to
|
|
474
|
+
// search(), which is out of this slice's by-id `get` surface.
|
|
455
475
|
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
456
476
|
return this.search(target);
|
|
457
477
|
}
|
|
458
|
-
|
|
478
|
+
const result = await memoryByIdReadGate.call(this, target, (t) => super.get(t));
|
|
479
|
+
// flair#744 slice 1 — opt-in inline trust-evidence block, attached ONLY to
|
|
480
|
+
// a genuine by-id record (never a NOT_FOUND `Response`, never null), and
|
|
481
|
+
// ONLY after the ownership/read-scope gate above has already resolved. The
|
|
482
|
+
// block informs the reader; it never re-enters an authority decision
|
|
483
|
+
// (#735-spirit zero-authority invariant). Default OFF ⇒ the record is
|
|
484
|
+
// returned untouched (attachTrust returns the same reference) ⇒
|
|
485
|
+
// byte-identical to pre-slice-1.
|
|
486
|
+
if (result && typeof result === "object" && !(result instanceof Response) && typeof result.agentId === "string") {
|
|
487
|
+
return attachTrust(result, wantsTrust(target, opts));
|
|
488
|
+
}
|
|
489
|
+
return result;
|
|
459
490
|
}
|
|
460
491
|
/**
|
|
461
492
|
* Override search() to scope collection GETs by authenticated agent.
|
|
@@ -538,6 +569,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
538
569
|
if (attr.denied)
|
|
539
570
|
return attr.denied;
|
|
540
571
|
}
|
|
572
|
+
// flair#744 slice A: citation-on-write — consume-and-strip, same
|
|
573
|
+
// discipline as `claimedClient` below. Pull the optional
|
|
574
|
+
// `usedMemoryIds` off the write body now, BEFORE anything else touches
|
|
575
|
+
// `content`, so it is NEVER persisted on the Memory record itself.
|
|
576
|
+
// Recording happens POST-COMMIT, only when this was present (see the
|
|
577
|
+
// failure-isolated recordCitations() call near the return below) —
|
|
578
|
+
// omitted ⇒ `undefined` ⇒ zero new calls, byte-identical behavior.
|
|
579
|
+
const usedMemoryIds = content?.usedMemoryIds;
|
|
580
|
+
if (content && typeof content === "object")
|
|
581
|
+
delete content.usedMemoryIds;
|
|
541
582
|
content.durability ||= "standard";
|
|
542
583
|
content.createdAt = new Date().toISOString();
|
|
543
584
|
content.updatedAt = content.createdAt;
|
|
@@ -649,6 +690,20 @@ export class Memory extends databases.flair.Memory {
|
|
|
649
690
|
// Now the safe failure state is two active records (recoverable), never
|
|
650
691
|
// a lost write — and the failure is logged, never silently swallowed.
|
|
651
692
|
await closeSupersededIfNeeded(ctx, content, "post");
|
|
693
|
+
// flair#744 slice A: citation-on-write — POST-COMMIT, fully
|
|
694
|
+
// failure-isolated. The write above already succeeded and `result` is
|
|
695
|
+
// final; crediting each cited memory through the shared usage ledger
|
|
696
|
+
// (same path as POST /RecordUsage) must never affect this response —
|
|
697
|
+
// any failure here is logged server-side and swallowed, never surfaced
|
|
698
|
+
// to the caller, never rolls back or retries the write.
|
|
699
|
+
if (usedMemoryIds !== undefined) {
|
|
700
|
+
try {
|
|
701
|
+
await recordCitations(ctx, auth, usedMemoryIds, new Date().toISOString());
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
console.error("Memory.post: citation recording failed (write already committed)", err);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
652
707
|
return buildWriteResponse(content, result, dedupMatch);
|
|
653
708
|
}
|
|
654
709
|
async put(content) {
|
|
@@ -690,6 +745,13 @@ export class Memory extends databases.flair.Memory {
|
|
|
690
745
|
if (attr.denied)
|
|
691
746
|
return attr.denied;
|
|
692
747
|
}
|
|
748
|
+
// flair#744 slice A: citation-on-write — same consume-and-strip
|
|
749
|
+
// discipline as post() above. Strip BEFORE anything else touches
|
|
750
|
+
// `content` so it is never persisted on the row; recorded post-commit
|
|
751
|
+
// below only when present.
|
|
752
|
+
const usedMemoryIds = content?.usedMemoryIds;
|
|
753
|
+
if (content && typeof content === "object")
|
|
754
|
+
delete content.usedMemoryIds;
|
|
693
755
|
const now = new Date().toISOString();
|
|
694
756
|
content.updatedAt = now;
|
|
695
757
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
@@ -821,6 +883,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
821
883
|
const result = await super.put(content);
|
|
822
884
|
// ── THEN close the superseded record (see post()) ───────────────────────
|
|
823
885
|
await closeSupersededIfNeeded(ctx, content, "put");
|
|
886
|
+
// flair#744 slice A: citation-on-write — POST-COMMIT, fully
|
|
887
|
+
// failure-isolated (see post()'s identical comment above).
|
|
888
|
+
if (usedMemoryIds !== undefined) {
|
|
889
|
+
try {
|
|
890
|
+
await recordCitations(ctx, auth, usedMemoryIds, new Date().toISOString());
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
console.error("Memory.put: citation recording failed (write already committed)", err);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
824
896
|
return buildWriteResponse(content, result, dedupMatch);
|
|
825
897
|
}
|
|
826
898
|
async delete(id) {
|
|
@@ -14,7 +14,9 @@ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent,
|
|
|
14
14
|
// bootstrap call never trips SemanticSearch's rate-limit/reranker/
|
|
15
15
|
// retrievalCount hit-tracking side effects (see resources/
|
|
16
16
|
// semantic-retrieval-core.ts's module doc for the full boundary).
|
|
17
|
-
import { retrieveCandidates } from "./semantic-retrieval-core.js";
|
|
17
|
+
import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
|
|
18
|
+
import { buildTrustBlock } from "./trust-block.js";
|
|
19
|
+
import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
|
|
18
20
|
/**
|
|
19
21
|
* POST /MemoryBootstrap
|
|
20
22
|
*
|
|
@@ -141,6 +143,8 @@ export class BootstrapMemories extends Resource {
|
|
|
141
143
|
const { agentId: bodyAgentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
|
|
142
144
|
surface, // e.g., "tps-build", "tps-review", "cli-session"
|
|
143
145
|
subjects, // e.g., ["flair", "auth"] — entities to preload context for
|
|
146
|
+
includeTrust = false, // flair#744 slice 1 — opt-in per-memory trust block
|
|
147
|
+
abstain = false, // flair#744 slice 2 — opt-in task-relevance abstention
|
|
144
148
|
} = data || {};
|
|
145
149
|
// Authenticated identity lives on getContext().request — `this.request` is
|
|
146
150
|
// NOT populated on Harper v5 Resources. Reading it returned undefined and
|
|
@@ -348,8 +352,28 @@ export class BootstrapMemories extends Resource {
|
|
|
348
352
|
memoriesAvailable = ownMemoriesAvailable;
|
|
349
353
|
// Fields every own-scoped lifecycle slice below needs — explicit (no raw
|
|
350
354
|
// `embedding`), matching the "select, no raw embedding" pushdown
|
|
351
|
-
// requirement.
|
|
352
|
-
|
|
355
|
+
// requirement. flair#744 slice 1: when the caller opts into the per-memory
|
|
356
|
+
// trust block, widen the projection with the extra stored fields the block
|
|
357
|
+
// reads (`provenance`, `usageCount`, `validFrom`) — additive, and only when
|
|
358
|
+
// requested, so a non-trust bootstrap fetches (and returns) exactly what it
|
|
359
|
+
// did before. These records are never returned raw when the block is off,
|
|
360
|
+
// so widening the select cannot change the off-path response bytes.
|
|
361
|
+
const OWN_SELECT = includeTrust
|
|
362
|
+
? ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags", "provenance", "usageCount", "validFrom"]
|
|
363
|
+
: ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags"];
|
|
364
|
+
// flair#744 slice 1 — the Memory records that became visible lines in the
|
|
365
|
+
// memory-bearing sections (permanent/recent/predicted/relevant/teammate),
|
|
366
|
+
// collected as they're added so the opt-in `trust` array below can carry a
|
|
367
|
+
// self-contained block per included memory. Stays empty (and unused) when
|
|
368
|
+
// includeTrust is off.
|
|
369
|
+
const includedTrustMemories = [];
|
|
370
|
+
// flair#744 slice 2 — the best absolute semantic similarity seen while
|
|
371
|
+
// scoring the task-relevant candidate pool (section 4). Drives the opt-in
|
|
372
|
+
// abstention verdict on the *task-relevance* surface only (bootstrap always
|
|
373
|
+
// returns identity/permanent/recent regardless — abstention is about "does
|
|
374
|
+
// any memory cover your current task", not the whole session load). Stays
|
|
375
|
+
// null when there's no currentTask / no embedding ⇒ never abstains.
|
|
376
|
+
let taskBestSimilarity = null;
|
|
353
377
|
// --- 2. Permanent memories (always included, highest priority) ---
|
|
354
378
|
// Own-scoped pushdown: `agentId==self` + `durability==permanent`, both
|
|
355
379
|
// @indexed (a seek, not a scan) — strictly narrower than the prior
|
|
@@ -400,6 +424,8 @@ export class BootstrapMemories extends Resource {
|
|
|
400
424
|
const cost = estimateTokens(line);
|
|
401
425
|
if (cost <= tokenBudget) {
|
|
402
426
|
sections.permanent.push(line);
|
|
427
|
+
if (includeTrust)
|
|
428
|
+
includedTrustMemories.push(m);
|
|
403
429
|
tokenBudget -= cost;
|
|
404
430
|
memoriesIncluded++;
|
|
405
431
|
}
|
|
@@ -472,6 +498,8 @@ export class BootstrapMemories extends Resource {
|
|
|
472
498
|
continue;
|
|
473
499
|
}
|
|
474
500
|
sections.recent.push(line);
|
|
501
|
+
if (includeTrust)
|
|
502
|
+
includedTrustMemories.push(m);
|
|
475
503
|
recentSpent += cost;
|
|
476
504
|
tokenBudget -= cost;
|
|
477
505
|
memoriesIncluded++;
|
|
@@ -512,6 +540,8 @@ export class BootstrapMemories extends Resource {
|
|
|
512
540
|
continue;
|
|
513
541
|
}
|
|
514
542
|
sections.predicted.push(line);
|
|
543
|
+
if (includeTrust)
|
|
544
|
+
includedTrustMemories.push(m);
|
|
515
545
|
predictedSpent += cost;
|
|
516
546
|
tokenBudget -= cost;
|
|
517
547
|
memoriesIncluded++;
|
|
@@ -612,7 +642,37 @@ export class BootstrapMemories extends Resource {
|
|
|
612
642
|
// even though `conditions` already scoped the query.
|
|
613
643
|
isAllowed: scope.isAllowed,
|
|
614
644
|
ctx,
|
|
645
|
+
// flair#744 slice 1: widen the projection with `provenance` (omitted
|
|
646
|
+
// by the default select) ONLY when the trust block was requested, so
|
|
647
|
+
// the block's provenance fields are populated for teammate/relevant
|
|
648
|
+
// records without changing a non-trust bootstrap's fetch.
|
|
649
|
+
select: includeTrust ? [...DEFAULT_SELECT, "provenance"] : undefined,
|
|
650
|
+
// flair#744 slice 2 + confidence-band refinement: attach the absolute
|
|
651
|
+
// cosine confidence (HNSW-leg, pre keyword bump) when abstention OR
|
|
652
|
+
// the trust block is requested — abstention reads the best of it for
|
|
653
|
+
// its verdict, and the per-memory trust block classifies each
|
|
654
|
+
// task-relevant candidate's into a `matchQuality` band (Kern BINDING
|
|
655
|
+
// condition 2: matchQuality needs `_semSimilarity`, so includeTrust
|
|
656
|
+
// must turn this on too). Only the task-relevant candidates get a
|
|
657
|
+
// similarity — permanent/recent/predicted come from raw own-scoped
|
|
658
|
+
// reads with no retrieval score, so their block's matchQuality is
|
|
659
|
+
// null (honest: those are lifecycle-window loads, not a retrieval
|
|
660
|
+
// surface — confidence bands are a retrieval-surface feature). The
|
|
661
|
+
// field is never returned raw (bootstrap renders memories as text
|
|
662
|
+
// lines + a trust array), so no strip is needed here. Neither flag ⇒
|
|
663
|
+
// candidates carry no `_semSimilarity` and the response is unchanged.
|
|
664
|
+
withSemSimilarity: abstain || includeTrust,
|
|
615
665
|
});
|
|
666
|
+
// flair#744 slice 2: the best-match confidence for the abstention
|
|
667
|
+
// decision — the max absolute cosine across the retrieved pool, read
|
|
668
|
+
// ONLY from `_semSimilarity` (never any principal/authority field). Note
|
|
669
|
+
// this floor (ABSTENTION_THRESHOLD ≈ 0.15) sits BELOW bootstrap's own
|
|
670
|
+
// long-standing TASK_RELEVANCE_FLOOR (0.3) that gates `scored` below, so
|
|
671
|
+
// an abstaining task (bestSim < 0.15) already has no candidate passing
|
|
672
|
+
// the floor — abstention only ADDS an explicit "nothing covered this"
|
|
673
|
+
// signal, it never removes a memory the reader would otherwise have seen.
|
|
674
|
+
if (abstain)
|
|
675
|
+
taskBestSimilarity = bestSemanticSimilarity(candidates);
|
|
616
676
|
// Preserve the ORIGINAL score > 0.3 floor exactly (bootstrap's own
|
|
617
677
|
// historical relevance floor — distinct from SemanticSearch's
|
|
618
678
|
// `minScore` request param — strict inequality, applied client-side;
|
|
@@ -658,6 +718,8 @@ export class BootstrapMemories extends Resource {
|
|
|
658
718
|
else {
|
|
659
719
|
sections.relevant.push(line);
|
|
660
720
|
}
|
|
721
|
+
if (includeTrust)
|
|
722
|
+
includedTrustMemories.push(m);
|
|
661
723
|
tokenBudget -= cost;
|
|
662
724
|
memoriesIncluded++;
|
|
663
725
|
}
|
|
@@ -865,8 +927,28 @@ export class BootstrapMemories extends Resource {
|
|
|
865
927
|
const context = parts.join("\n\n");
|
|
866
928
|
const soulTokens = sections.soul.reduce((sum, line) => sum + estimateTokens(line), 0);
|
|
867
929
|
const memoryTokens = maxTokens - tokenBudget;
|
|
930
|
+
// flair#744 slice 1 — opt-in per-memory trust block. Bootstrap renders
|
|
931
|
+
// memories as text lines rather than result objects, so the block is
|
|
932
|
+
// surfaced as a `trust` array of self-contained entries (each carries its
|
|
933
|
+
// own `id` to correlate to a rendered line), one per INCLUDED memory. Built
|
|
934
|
+
// HERE, in the response tail, strictly after all read-scope resolution and
|
|
935
|
+
// purely for the response — never consulted for any authority decision
|
|
936
|
+
// (#735-spirit zero-authority invariant). Default OFF ⇒ the `trust` key is
|
|
937
|
+
// absent ⇒ the response is byte-identical to pre-slice-1.
|
|
938
|
+
const trust = includeTrust
|
|
939
|
+
? includedTrustMemories.map((m) => ({ id: m.id, ...buildTrustBlock(m) }))
|
|
940
|
+
: undefined;
|
|
941
|
+
// flair#744 slice 2 — opt-in abstention verdict for the task-relevance
|
|
942
|
+
// surface. Present ONLY when `abstain` is requested (byte-identical to
|
|
943
|
+
// pre-slice-2 otherwise); scoped to whether any memory covered
|
|
944
|
+
// `currentTask` (bestScore null ⇒ no task/embedding ⇒ abstained:false). The
|
|
945
|
+
// decision reads only the confidence number — never a principal — against
|
|
946
|
+
// the single GLOBAL threshold.
|
|
947
|
+
const abstention = abstain ? evaluateAbstention(taskBestSimilarity) : undefined;
|
|
868
948
|
return {
|
|
869
949
|
context,
|
|
950
|
+
...(trust ? { trust } : {}),
|
|
951
|
+
...(abstention ? { abstention } : {}),
|
|
870
952
|
sections: {
|
|
871
953
|
soul: sections.soul.length,
|
|
872
954
|
skills: sections.skills.length,
|
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
* REPLACES `retrievalBoost` in `compositeScore` outright (see that
|
|
12
12
|
* function's doc).
|
|
13
13
|
*
|
|
14
|
+
* flair#744 slice A: the actual ledger-write core below (formerly this
|
|
15
|
+
* class's private `_recordOne()`) has moved to ./usage-recording.ts's
|
|
16
|
+
* `recordUsageContribution()` — a shared, single-implementation extraction
|
|
17
|
+
* also used by citation-on-write (resources/Memory.ts's post()/put()) so
|
|
18
|
+
* there is exactly ONE place the (agentId, memoryId) ledger logic lives.
|
|
19
|
+
* This endpoint's own request handling (auth, rate limit, batch validation,
|
|
20
|
+
* the no-enumeration response) is unchanged.
|
|
21
|
+
*
|
|
14
22
|
* WHY THIS IS ITS OWN ENDPOINT, NOT `Memory.put()` (Sherlock, K&S verdict —
|
|
15
23
|
* FLAIR-USAGE-FEEDBACK-SIGNAL.md): usage feedback is fundamentally a
|
|
16
24
|
* CROSS-AGENT write — agent B reports using agent A's memory, so the write
|
|
@@ -21,10 +29,10 @@
|
|
|
21
29
|
* memory through this surface — not just the count. So this endpoint does
|
|
22
30
|
* the narrowest possible thing instead: a TARGETED `usageCount`-only
|
|
23
31
|
* bump (read-full-record, merge just this one field, write — see
|
|
24
|
-
*
|
|
25
|
-
* bypassing the `Memory` RESOURCE class
|
|
26
|
-
* auth model is verified-agent +
|
|
27
|
-
* requirement.
|
|
32
|
+
* ./usage-recording.ts's recordUsageContribution()) against the RAW
|
|
33
|
+
* `Memory` table object, entirely bypassing the `Memory` RESOURCE class
|
|
34
|
+
* (and its ownership gate) — its own auth model is verified-agent +
|
|
35
|
+
* within-org + explicitly NO ownership requirement.
|
|
28
36
|
*
|
|
29
37
|
* WHY THIS ISN'T A @table-BACKED RESOURCE: the actual dedup ledger (one row
|
|
30
38
|
* per (agentId, memoryId) contribution) lives in the `MemoryUsage` table
|
|
@@ -37,13 +45,14 @@
|
|
|
37
45
|
* SAME class of gotcha resources/Memory.ts documents for a raw HTTP POST to
|
|
38
46
|
* `/Memory`, but here it bites even an in-process call). So the ledger row
|
|
39
47
|
* itself is written via `.put()` (upsert with the deterministic composite
|
|
40
|
-
* id — see
|
|
41
|
-
* plain action `Resource` (same shape
|
|
42
|
-
* resources/SemanticSearch.ts) rather than
|
|
43
|
-
* actually routes here. It talks to BOTH
|
|
44
|
-
* their raw table objects (bypassing each
|
|
45
|
-
* wrapper — the same "call the other table
|
|
46
|
-
* resources/Memory.ts already uses for `MemoryGrant` in
|
|
48
|
+
* id — see ./usage-recording.ts's recordUsageContribution()), and this
|
|
49
|
+
* endpoint's OWN HTTP surface lives on a plain action `Resource` (same shape
|
|
50
|
+
* as resources/MemoryReflect.ts / resources/SemanticSearch.ts) rather than
|
|
51
|
+
* extending a @table class, so POST actually routes here. It talks to BOTH
|
|
52
|
+
* `Memory` and `MemoryUsage` via their raw table objects (bypassing each
|
|
53
|
+
* resource class's own auth wrapper — the same "call the other table
|
|
54
|
+
* directly" pattern resources/Memory.ts already uses for `MemoryGrant` in
|
|
55
|
+
* hasWriteGrant()).
|
|
47
56
|
*
|
|
48
57
|
* ANTI-GAMING (Sherlock): usage feedback is a write that affects RANKING —
|
|
49
58
|
* an abuse surface (inflate usageCount to boost a memory). Three-layer
|
|
@@ -73,13 +82,16 @@
|
|
|
73
82
|
* sanitized (control-character-stripped, length-capped) and stored as-is,
|
|
74
83
|
* for audit/analytics only. Treat it as untrusted data, always.
|
|
75
84
|
*/
|
|
76
|
-
import { Resource
|
|
85
|
+
import { Resource } from "@harperfast/harper";
|
|
77
86
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
78
87
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
79
|
-
import {
|
|
88
|
+
import { recordUsageContribution, MAX_USAGE_IDS_PER_CALL } from "./usage-recording.js";
|
|
80
89
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
81
90
|
const BAD_REQUEST = (msg) => new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
82
|
-
|
|
91
|
+
// flair#744 slice A: sourced from the shared module (./usage-recording.ts)
|
|
92
|
+
// so RecordUsage.post()'s validated-batch cap and citation-on-write's
|
|
93
|
+
// advisory-batch cap can never drift apart.
|
|
94
|
+
const MAX_IDS_PER_CALL = MAX_USAGE_IDS_PER_CALL;
|
|
83
95
|
const MAX_ATTRIBUTION_LENGTH = 500;
|
|
84
96
|
/**
|
|
85
97
|
* Strip control/non-printable characters and cap length. Attribution is
|
|
@@ -148,7 +160,12 @@ export class RecordUsage extends Resource {
|
|
|
148
160
|
const now = new Date().toISOString();
|
|
149
161
|
for (const memoryId of memoryIds) {
|
|
150
162
|
try {
|
|
151
|
-
|
|
163
|
+
// flair#744 slice A: the ledger-write core now lives in
|
|
164
|
+
// ./usage-recording.ts's recordUsageContribution() — a shared,
|
|
165
|
+
// single-implementation extraction (also used by citation-on-write,
|
|
166
|
+
// resources/Memory.ts's post()/put()). Byte-identical behavior to
|
|
167
|
+
// the former private _recordOne() this replaced.
|
|
168
|
+
await recordUsageContribution(ctx, agentId, memoryId, attribution, now);
|
|
152
169
|
}
|
|
153
170
|
catch (err) {
|
|
154
171
|
// Never let one bad id fail the whole batch, and never let an
|
|
@@ -162,69 +179,4 @@ export class RecordUsage extends Resource {
|
|
|
162
179
|
// counted / were not found — see RECORDED_RESPONSE's doc.
|
|
163
180
|
return RECORDED_RESPONSE;
|
|
164
181
|
}
|
|
165
|
-
/**
|
|
166
|
-
* One (agentId, memoryId) contribution.
|
|
167
|
-
*
|
|
168
|
-
* Ledger-row-create FIRST, THEN the Memory.usageCount bump — so a crash
|
|
169
|
-
* between the two leaves the SAFE failure state (ledger row exists, count
|
|
170
|
-
* not yet bumped: a later retry just re-checks and no-ops) rather than the
|
|
171
|
-
* reverse (count bumped, no ledger row → a retry would double-count).
|
|
172
|
-
*
|
|
173
|
-
* Every discrete Harper call is wrapped INDIVIDUALLY in its own
|
|
174
|
-
* withDetachedTxn — never one wrap around a multi-step helper. A request
|
|
175
|
-
* that reads/writes MULTIPLE tables (or the same table twice) in sequence
|
|
176
|
-
* can otherwise inherit a closed transaction from a prior call's drained
|
|
177
|
-
* chain (table-helpers.ts's withDetachedTxn doc); resources/Memory.ts's
|
|
178
|
-
* closeSupersededRecord documents exactly why a single wrap around a
|
|
179
|
-
* multi-await async function does NOT protect a later call inside it —
|
|
180
|
-
* this mirrors that function's literal shape (get wrapped, then a
|
|
181
|
-
* SEPARATE put wrapped) rather than delegating to the generic
|
|
182
|
-
* patchRecord() helper, which would combine both into one un-safe wrap.
|
|
183
|
-
*
|
|
184
|
-
* The final get-then-put for the increment is a best-effort (non-atomic)
|
|
185
|
-
* read-modify-write, same class of race already accepted elsewhere in
|
|
186
|
-
* this codebase for count fields (e.g. retrievalCount's bump in
|
|
187
|
-
* SemanticSearch.ts) — a concurrent contribution from a DIFFERENT agent
|
|
188
|
-
* landing between this call's read and write could lose one increment.
|
|
189
|
-
* Re-fetching immediately before the write (rather than reusing the
|
|
190
|
-
* earlier existence-check read) narrows, without eliminating, that
|
|
191
|
-
* window. Not solved here: bounded, low-severity (an undercount, never an
|
|
192
|
-
* inflation), and orthogonal to the anti-gaming properties the cap/floor
|
|
193
|
-
* and dedup ledger actually defend.
|
|
194
|
-
*/
|
|
195
|
-
async _recordOne(ctx, agentId, memoryId, attribution, now) {
|
|
196
|
-
const ledgerId = `${agentId}:${memoryId}`;
|
|
197
|
-
// Bypasses resources/MemoryUsage.ts's own auth wrapper by design — this
|
|
198
|
-
// IS the trusted internal caller that resource's module doc describes
|
|
199
|
-
// (same "raw table object" pattern resources/Memory.ts uses for
|
|
200
|
-
// MemoryGrant).
|
|
201
|
-
const existingContribution = await withDetachedTxn(ctx, () => databases.flair.MemoryUsage.get(ledgerId)).catch(() => null);
|
|
202
|
-
if (existingContribution)
|
|
203
|
-
return; // already counted by this agent — silent no-op
|
|
204
|
-
const memoryExists = await withDetachedTxn(ctx, () => databases.flair.Memory.get(memoryId)).catch(() => null);
|
|
205
|
-
if (!memoryExists)
|
|
206
|
-
return; // no such memory — silent no-op (no enumeration)
|
|
207
|
-
const ledgerRecord = { id: ledgerId, agentId, memoryId, createdAt: now };
|
|
208
|
-
if (attribution)
|
|
209
|
-
ledgerRecord.attribution = attribution;
|
|
210
|
-
// .put(), not .post(): Harper's raw TableResource has no default post()
|
|
211
|
-
// implementation for a static-style (non-`isCollection`-instantiated)
|
|
212
|
-
// call — confirmed live ("The MemoryUsage does not have a post method
|
|
213
|
-
// implemented", statusCode 405) — the SAME class of gotcha
|
|
214
|
-
// resources/Memory.ts documents for HTTP POST, but here it bites even
|
|
215
|
-
// this in-process call. Irrelevant anyway: ledgerId is already a
|
|
216
|
-
// deterministic composite key, so this is a create-with-explicit-id —
|
|
217
|
-
// exactly what PUT (upsert) is for, not an auto-generated-id insert.
|
|
218
|
-
await withDetachedTxn(ctx, () => databases.flair.MemoryUsage.put(ledgerRecord));
|
|
219
|
-
// Targeted usageCount-ONLY bump: read-full-record, merge just this one
|
|
220
|
-
// field, write — against the RAW Memory table, NEVER Memory.put() (the
|
|
221
|
-
// resource class): that would 403 this cross-agent write via its
|
|
222
|
-
// ownership check, and bypassing that check directly would risk letting
|
|
223
|
-
// this endpoint smuggle OTHER field changes through instead of just the
|
|
224
|
-
// count (module doc's "WHY THIS IS ITS OWN ENDPOINT").
|
|
225
|
-
const fresh = await withDetachedTxn(ctx, () => databases.flair.Memory.get(memoryId)).catch(() => null);
|
|
226
|
-
if (!fresh)
|
|
227
|
-
return; // deleted between the checks above and now — no-op
|
|
228
|
-
await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...fresh, usageCount: (fresh.usageCount ?? 0) + 1 }));
|
|
229
|
-
}
|
|
230
182
|
}
|
|
@@ -17,7 +17,9 @@ import { hybridEnabled } from "./bm25.js";
|
|
|
17
17
|
// extraction) — MemoryBootstrap.ts calls the SAME core bare, without
|
|
18
18
|
// tripping this file's rate-limit/reranker/hit-tracking side effects. See
|
|
19
19
|
// resources/semantic-retrieval-core.ts's module doc for the full boundary.
|
|
20
|
-
import { retrieveCandidates } from "./semantic-retrieval-core.js";
|
|
20
|
+
import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
|
|
21
|
+
import { attachTrust } from "./trust-block.js";
|
|
22
|
+
import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
|
|
21
23
|
// Candidate multiplier: fetch more candidates than needed from the HNSW index
|
|
22
24
|
// so composite re-ranking has enough headroom to reorder results.
|
|
23
25
|
const CANDIDATE_MULTIPLIER = 5;
|
|
@@ -60,7 +62,7 @@ export class SemanticSearch extends Resource {
|
|
|
60
62
|
// recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
|
|
61
63
|
// before reconsidering this default if the compositeScore formula or
|
|
62
64
|
// corpus changes.
|
|
63
|
-
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf } = data || {};
|
|
65
|
+
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, abstain = false } = data || {};
|
|
64
66
|
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
65
67
|
// `this.request` is NOT populated on Harper v5 Resources — prior reads here
|
|
66
68
|
// silently returned undefined and the defense-in-depth scope check below
|
|
@@ -216,7 +218,41 @@ export class SemanticSearch extends Resource {
|
|
|
216
218
|
isAllowed: scope?.isAllowed,
|
|
217
219
|
hybrid,
|
|
218
220
|
ctx,
|
|
221
|
+
// flair#744 slice 1: the trust block needs `provenance`, which the
|
|
222
|
+
// default projection omits. Widen the select ONLY when the caller opts
|
|
223
|
+
// in — passing undefined otherwise keeps the default (no `provenance`)
|
|
224
|
+
// so a non-trust recall response stays byte-identical.
|
|
225
|
+
select: includeTrust ? [...DEFAULT_SELECT, "provenance"] : undefined,
|
|
226
|
+
// flair#744 slice 2 + confidence-band refinement: attach the absolute
|
|
227
|
+
// per-result cosine confidence when the caller opts into abstention OR
|
|
228
|
+
// the trust block — abstention reads the best of it for its verdict, and
|
|
229
|
+
// the trust block classifies each result's into a `matchQuality` band
|
|
230
|
+
// (Kern BINDING condition 2: matchQuality needs `_semSimilarity`, so
|
|
231
|
+
// includeTrust must also turn this on). Neither flag ⇒ result objects stay
|
|
232
|
+
// byte-identical (no `_semSimilarity` attached).
|
|
233
|
+
withSemSimilarity: abstain || includeTrust,
|
|
219
234
|
});
|
|
235
|
+
// ─── flair#744 slice 2 — first-class abstention ("no memory covers this")
|
|
236
|
+
// Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the
|
|
237
|
+
// reranker / final slice / hit-tracking, so an abstaining recall does no
|
|
238
|
+
// rerank work and never bumps retrievalCount for memories it declines to
|
|
239
|
+
// surface. The decision reads ONLY the best absolute semantic similarity
|
|
240
|
+
// (never any principal/authority signal — abstention.ts is pure and
|
|
241
|
+
// authority-free), against the single GLOBAL threshold. Default OFF ⇒ this
|
|
242
|
+
// whole block is skipped and the response is byte-identical to pre-slice-2.
|
|
243
|
+
let abstention = null;
|
|
244
|
+
if (abstain) {
|
|
245
|
+
abstention = evaluateAbstention(bestSemanticSimilarity(filteredResults));
|
|
246
|
+
if (abstention.abstained) {
|
|
247
|
+
return {
|
|
248
|
+
abstained: true,
|
|
249
|
+
reason: abstention.reason,
|
|
250
|
+
bestScore: abstention.bestScore,
|
|
251
|
+
threshold: abstention.threshold,
|
|
252
|
+
results: [],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
220
256
|
// ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
|
|
221
257
|
// Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
|
|
222
258
|
// can't do) and reorders before the final slice. Reorders whatever
|
|
@@ -248,8 +284,40 @@ export class SemanticSearch extends Resource {
|
|
|
248
284
|
lastRetrieved: now,
|
|
249
285
|
}).catch(() => { });
|
|
250
286
|
}
|
|
287
|
+
// flair#744 slice 1 — opt-in inline trust-evidence block. Assembled HERE,
|
|
288
|
+
// in the response tail, strictly AFTER read-scope resolution
|
|
289
|
+
// (retrieveCandidates + scope.isAllowed already ran) and purely for the
|
|
290
|
+
// response — it never feeds back into any authority/scope/attribution/dedup
|
|
291
|
+
// decision (the #735-spirit zero-authority invariant; structurally guarded
|
|
292
|
+
// by test/unit/trust-block-zero-authority-tripwire.test.ts). Default OFF ⇒
|
|
293
|
+
// `results` is the untouched `topResults`, byte-identical to pre-slice-1.
|
|
294
|
+
//
|
|
295
|
+
// Order matters (confidence-band refinement): attachTrust must run BEFORE
|
|
296
|
+
// the `_semSimilarity` strip below, because buildTrustBlock reads that field
|
|
297
|
+
// off the record to classify `matchQuality`. attachTrust returns a shallow
|
|
298
|
+
// copy carrying `trust` (and still-present `_semSimilarity`); the strip then
|
|
299
|
+
// drops the internal field from the copy.
|
|
300
|
+
const trusted = includeTrust ? topResults.map((r) => attachTrust(r, true)) : topResults;
|
|
301
|
+
// flair#744 slice 2 + refinement: strip the internal `_semSimilarity`
|
|
302
|
+
// confidence field from the consumer-facing results — it exists ONLY to feed
|
|
303
|
+
// the abstention decision and the matchQuality classification, never the
|
|
304
|
+
// response shape. Attached whenever abstain OR includeTrust turned
|
|
305
|
+
// withSemSimilarity on, so strip in both cases. A no-op (and byte-identical)
|
|
306
|
+
// when neither flag is set (the field was never attached).
|
|
307
|
+
const results = (abstain || includeTrust)
|
|
308
|
+
? trusted.map(({ _semSimilarity, ...r }) => r)
|
|
309
|
+
: trusted;
|
|
251
310
|
// Surface degradation warning when semantic search was unavailable
|
|
252
|
-
const response = { results
|
|
311
|
+
const response = { results };
|
|
312
|
+
// flair#744 slice 2: in opt-in abstain mode that did NOT abstain, carry the
|
|
313
|
+
// (negative) verdict so a consumer building against the abstention shape
|
|
314
|
+
// always reads a stable `abstained`/`bestScore`/`threshold`. Absent when
|
|
315
|
+
// abstain is off ⇒ byte-identical to pre-slice-2.
|
|
316
|
+
if (abstention) {
|
|
317
|
+
response.abstained = abstention.abstained;
|
|
318
|
+
response.bestScore = abstention.bestScore;
|
|
319
|
+
response.threshold = abstention.threshold;
|
|
320
|
+
}
|
|
253
321
|
if (!qEmb && q && getMode() === "none") {
|
|
254
322
|
response._warning = "semantic search unavailable — results are keyword-only";
|
|
255
323
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trust-block.ts — the opt-in, inline trust-evidence block surfaced on recall
|
|
3
|
+
* results (flair#744 slice 1: "surface what we already record at the point of
|
|
4
|
+
* decision").
|
|
5
|
+
*
|
|
6
|
+
* ─── What this is ────────────────────────────────────────────────────────────
|
|
7
|
+
* A PURE, Harper-free assembler: `buildTrustBlock(record)` maps a single
|
|
8
|
+
* Memory record's ALREADY-STORED fields into a compact, self-contained trust
|
|
9
|
+
* block that `search` (SemanticSearch), `get` (Memory.get), and `bootstrap`
|
|
10
|
+
* (BootstrapMemories) attach to each result WHEN THE CALLER OPTS IN. No new
|
|
11
|
+
* computation, no cross-record lookups, no DB access — every field below is
|
|
12
|
+
* read straight off the record the recall path already resolved. That is the
|
|
13
|
+
* whole point of slice 1: the memory layer already records per-fact trust
|
|
14
|
+
* evidence at write time; this surfaces it at read time, where the consuming
|
|
15
|
+
* agent actually decides what to repeat.
|
|
16
|
+
*
|
|
17
|
+
* ─── The zero-authority invariant (flair#744 Sherlock condition 2 / #735) ────
|
|
18
|
+
* The trust block INFORMS THE READER ONLY. It is assembled AFTER read-scope
|
|
19
|
+
* resolution, purely for the response, and MUST NEVER enter an authority /
|
|
20
|
+
* scope / attribution / dedup decision anywhere — the same discipline as the
|
|
21
|
+
* `claimed.*` zero-authority guard (flair#735). Two things keep that true:
|
|
22
|
+
* 1. This module is pure and side-effect-free: buildTrustBlock NEVER mutates
|
|
23
|
+
* its input record (it only reads), so assembling a block can never alter
|
|
24
|
+
* a record a later decision reads.
|
|
25
|
+
* 2. No authority-decision module imports it. That is structurally enforced
|
|
26
|
+
* by test/unit/trust-block-zero-authority-tripwire.test.ts (the #735-style
|
|
27
|
+
* source scan): record-type-kit.ts, memory-read-scope.ts, Memory.ts's
|
|
28
|
+
* dedup gates, RecordUsage.ts, mcp-handler.ts, and the retrieval/scoping
|
|
29
|
+
* core (semantic-retrieval-core.ts) never reference the trust block — it
|
|
30
|
+
* is assembled strictly DOWNSTREAM of scope resolution, in the response
|
|
31
|
+
* tail of each recall wrapper.
|
|
32
|
+
*
|
|
33
|
+
* ─── `claimed.*` is surfaced as a BOOLEAN only ──────────────────────────────
|
|
34
|
+
* `hasClaimedProvenance` reports only WHETHER the record carries a self-
|
|
35
|
+
* reported `claimed` sub-object — never its content. Raw `claimed.model` /
|
|
36
|
+
* `claimed.client` values are self-reported and unverified (resources/
|
|
37
|
+
* provenance.ts); exposing them as authoritative trust evidence would defeat
|
|
38
|
+
* the verified-vs-claimed distinction the block exists to draw. The advisory
|
|
39
|
+
* "there is a self-report here" bit is enough for the reader to weight it.
|
|
40
|
+
*
|
|
41
|
+
* ─── Tier is DEFERRED to a later slice (flair#744 Sherlock condition 1) ─────
|
|
42
|
+
* The block deliberately does NOT carry a derived trust `tier`. A tier is not
|
|
43
|
+
* a field on the Memory record — it lives on the AUTHOR's Agent/OAuthClient
|
|
44
|
+
* principal record (`defaultTrustTier`), so surfacing it would require a
|
|
45
|
+
* per-author lookup on the hot recall path (exactly the read-path cost the
|
|
46
|
+
* flair#744 design round ruled out) AND the scope-gate Sherlock's condition 1
|
|
47
|
+
* mandates ("include tier only when reader.scope == author.scope"). That
|
|
48
|
+
* scope-gate needs an org/scope boundary primitive that does not yet exist in
|
|
49
|
+
* flair's single-tenant "open-within-org" read model. Both are more than
|
|
50
|
+
* trivial for slice 1, so per the spec's explicit allowance the tier field is
|
|
51
|
+
* deferred whole — the scope-gate ships WITH it, when it ships. Everything
|
|
52
|
+
* else in the block (provenance, author principal, usage, freshness,
|
|
53
|
+
* supersession) is on the Memory record itself and ships now.
|
|
54
|
+
*
|
|
55
|
+
* ─── `matchQuality` confidence band (flair#744 refinement) ──────────────────
|
|
56
|
+
* When the recall path attached an absolute semantic similarity to the result
|
|
57
|
+
* (`_semSimilarity`, cosine in [0,1] — attached only on the retrieval surface,
|
|
58
|
+
* when the caller opts into the block or into abstention), the block labels it
|
|
59
|
+
* with a confidence band — strong / moderate / breadcrumb — so a weak-but-
|
|
60
|
+
* present match is TAKEN FOR WHAT IT IS, not mistaken for a confident one (the
|
|
61
|
+
* hallucination risk is undifferentiated weak matches, not weak matches per se).
|
|
62
|
+
* Derived PURELY from that one number against the global band cut-points in
|
|
63
|
+
* resources/abstention.ts (single source of truth with the abstention floor —
|
|
64
|
+
* see classifyMatchQuality). When there is no similarity to judge (a by-id
|
|
65
|
+
* `get`, or a keyword-only degraded search — no `_semSimilarity`), the band is
|
|
66
|
+
* `null`: an honest "we couldn't classify this one", never a false label.
|
|
67
|
+
*/
|
|
68
|
+
import { ABSTENTION_THRESHOLD, MODERATE_BAND, STRONG_BAND } from "./abstention.js";
|
|
69
|
+
const MS_PER_DAY = 86_400_000;
|
|
70
|
+
/**
|
|
71
|
+
* Classify a result's absolute semantic similarity (`_semSimilarity`, cosine in
|
|
72
|
+
* [0,1]) into a confidence band. PURE: its ONLY input is that one number — no
|
|
73
|
+
* principal / agentId / tier / scope — so the band is GLOBAL and can never be
|
|
74
|
+
* varied per principal (Sherlock; structurally guarded by
|
|
75
|
+
* test/unit/abstention-no-per-principal-tripwire.test.ts, same spine as the
|
|
76
|
+
* abstention decision).
|
|
77
|
+
*
|
|
78
|
+
* sim >= STRONG_BAND → "strong"
|
|
79
|
+
* MODERATE_BAND <= sim < STRONG → "moderate"
|
|
80
|
+
* ABSTENTION_THRESHOLD <= sim → "breadcrumb" (bottom of breadcrumb IS the
|
|
81
|
+
* abstention floor — one
|
|
82
|
+
* shared constant, no dup)
|
|
83
|
+
* sim < ABSTENTION_THRESHOLD → "breadcrumb" (a result present below the
|
|
84
|
+
* abstention floor — abstention
|
|
85
|
+
* off, or it slipped in — is
|
|
86
|
+
* still the WEAKEST present
|
|
87
|
+
* band; there is NO 4th band)
|
|
88
|
+
* not a finite number (null/undefined/NaN) → null (no signal to classify;
|
|
89
|
+
* never a false label)
|
|
90
|
+
*
|
|
91
|
+
* The breadcrumb floor references ABSTENTION_THRESHOLD directly (Kern BINDING
|
|
92
|
+
* condition 1): the band cut-points and the abstention floor are one source of
|
|
93
|
+
* truth (resources/abstention.ts) and cannot drift — if recall-bench moves the
|
|
94
|
+
* abstention floor, breadcrumb's floor moves with it.
|
|
95
|
+
*/
|
|
96
|
+
export function classifyMatchQuality(semSimilarity) {
|
|
97
|
+
if (typeof semSimilarity !== "number" || !Number.isFinite(semSimilarity))
|
|
98
|
+
return null;
|
|
99
|
+
if (semSimilarity >= STRONG_BAND)
|
|
100
|
+
return "strong";
|
|
101
|
+
if (semSimilarity >= MODERATE_BAND)
|
|
102
|
+
return "moderate";
|
|
103
|
+
if (semSimilarity >= ABSTENTION_THRESHOLD)
|
|
104
|
+
return "breadcrumb";
|
|
105
|
+
// Present below the abstention floor (abstention off, or a straggler): still
|
|
106
|
+
// the weakest present band — breadcrumb, never a 4th band.
|
|
107
|
+
return "breadcrumb";
|
|
108
|
+
}
|
|
109
|
+
function parseTime(value) {
|
|
110
|
+
if (typeof value !== "string" || value.length === 0)
|
|
111
|
+
return NaN;
|
|
112
|
+
return Date.parse(value);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Assemble the trust block for one Memory record. Pure: reads only, NEVER
|
|
116
|
+
* mutates `record`. `now` is injectable for deterministic tests (defaults to
|
|
117
|
+
* the current wall clock).
|
|
118
|
+
*/
|
|
119
|
+
export function buildTrustBlock(record, now = Date.now()) {
|
|
120
|
+
// ── Provenance (verified vs claimed) ──────────────────────────────────────
|
|
121
|
+
let verifiedAuthor = null;
|
|
122
|
+
let verifiedAt = null;
|
|
123
|
+
let hasClaimedProvenance = false;
|
|
124
|
+
if (typeof record.provenance === "string" && record.provenance.length > 0) {
|
|
125
|
+
try {
|
|
126
|
+
const p = JSON.parse(record.provenance);
|
|
127
|
+
if (p && typeof p === "object") {
|
|
128
|
+
if (p.verified && typeof p.verified === "object") {
|
|
129
|
+
verifiedAuthor = typeof p.verified.agentId === "string" ? p.verified.agentId : null;
|
|
130
|
+
verifiedAt = typeof p.verified.timestamp === "string" ? p.verified.timestamp : null;
|
|
131
|
+
}
|
|
132
|
+
// Boolean only — the self-reported content is deliberately NOT surfaced.
|
|
133
|
+
hasClaimedProvenance = p.claimed != null && typeof p.claimed === "object";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Malformed provenance → treated as unattributed, never throws.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ── Freshness / validity ──────────────────────────────────────────────────
|
|
141
|
+
const validFrom = typeof record.validFrom === "string" ? record.validFrom : null;
|
|
142
|
+
const validTo = typeof record.validTo === "string" ? record.validTo : null;
|
|
143
|
+
const createdAt = typeof record.createdAt === "string" ? record.createdAt : null;
|
|
144
|
+
const validToMs = parseTime(validTo);
|
|
145
|
+
const validFromMs = parseTime(validFrom);
|
|
146
|
+
let validityStatus = "valid";
|
|
147
|
+
if (Number.isFinite(validToMs) && validToMs <= now) {
|
|
148
|
+
validityStatus = "expired";
|
|
149
|
+
}
|
|
150
|
+
else if (Number.isFinite(validFromMs) && validFromMs > now) {
|
|
151
|
+
validityStatus = "future";
|
|
152
|
+
}
|
|
153
|
+
const createdMs = parseTime(createdAt);
|
|
154
|
+
const ageDays = Number.isFinite(createdMs)
|
|
155
|
+
? Math.max(0, Math.floor((now - createdMs) / MS_PER_DAY))
|
|
156
|
+
: null;
|
|
157
|
+
return {
|
|
158
|
+
author: typeof record.agentId === "string" ? record.agentId : null,
|
|
159
|
+
provenanceStatus: verifiedAuthor ? "verified" : "unattributed",
|
|
160
|
+
verifiedAuthor,
|
|
161
|
+
verifiedAt,
|
|
162
|
+
hasClaimedProvenance,
|
|
163
|
+
usageCount: typeof record.usageCount === "number" ? record.usageCount : null,
|
|
164
|
+
validityStatus,
|
|
165
|
+
validFrom,
|
|
166
|
+
validTo,
|
|
167
|
+
createdAt,
|
|
168
|
+
ageDays,
|
|
169
|
+
supersedes: typeof record.supersedes === "string" ? record.supersedes : null,
|
|
170
|
+
// flair#744 refinement — confidence band from the result's absolute
|
|
171
|
+
// similarity (null when there is no signal to judge). Pure, global,
|
|
172
|
+
// score-only: classifyMatchQuality sees ONLY the number.
|
|
173
|
+
matchQuality: classifyMatchQuality(record._semSimilarity),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Opt-in attach: the single primitive `search`/`get` use to layer the trust
|
|
178
|
+
* block onto a result. `includeTrust === false` (the default) returns the
|
|
179
|
+
* EXACT SAME record reference untouched — the clean-migration guarantee that a
|
|
180
|
+
* consumer who doesn't request the block sees a byte-identical response. When
|
|
181
|
+
* true, returns a shallow copy with `trust` added (never mutates the input).
|
|
182
|
+
*/
|
|
183
|
+
export function attachTrust(record, includeTrust, now) {
|
|
184
|
+
if (!includeTrust)
|
|
185
|
+
return record;
|
|
186
|
+
return { ...record, trust: buildTrustBlock(record, now) };
|
|
187
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usage-recording.ts — shared usage-ledger recording core (flair#744 slice A,
|
|
3
|
+
* "citation-on-write"; extracted from RecordUsage.ts's flair#683 signal so
|
|
4
|
+
* there is exactly ONE implementation of the ledger logic, not two that can
|
|
5
|
+
* drift).
|
|
6
|
+
*
|
|
7
|
+
* Two callers share this core, both crediting the SAME deduped,
|
|
8
|
+
* principal-bound `MemoryUsage` ledger + the targeted `Memory.usageCount`
|
|
9
|
+
* bump:
|
|
10
|
+
* - `POST /RecordUsage` (resources/RecordUsage.ts) — an agent explicitly
|
|
11
|
+
* reports "I used this memory" as a standalone call.
|
|
12
|
+
* - Citation-on-write (resources/Memory.ts's post()/put(), via
|
|
13
|
+
* `recordCitations()` below) — an agent cites `usedMemoryIds` inline on a
|
|
14
|
+
* memory WRITE; each cited id is credited the same way, post-commit and
|
|
15
|
+
* failure-isolated from the write itself.
|
|
16
|
+
*
|
|
17
|
+
* `recordUsageContribution()` is an EXACT extraction of what was
|
|
18
|
+
* RecordUsage.ts's private `_recordOne()` — see its doc below for the
|
|
19
|
+
* ledger-then-count ordering, the individually-wrapped `withDetachedTxn`
|
|
20
|
+
* discipline, and the accepted best-effort race on the final
|
|
21
|
+
* read-modify-write. This move changes WHERE the logic lives, not what it
|
|
22
|
+
* does — behavior is byte-identical for RecordUsage.ts's existing callers.
|
|
23
|
+
*
|
|
24
|
+
* `recordCitations()` is the NEW batch helper citation-on-write uses: the
|
|
25
|
+
* same agent-required / cap / dedup / per-id failure-isolation shape as
|
|
26
|
+
* RecordUsage.post()'s loop, parameterized so Memory.ts can drive it from a
|
|
27
|
+
* write body instead of a dedicated POST body. It NEVER reads the ledger for
|
|
28
|
+
* authority — it only writes contributions; `usedMemoryIds` must never enter
|
|
29
|
+
* an access/scope/attribution/dedup decision (flair#744 slice A invariant 3).
|
|
30
|
+
*/
|
|
31
|
+
import { databases } from "@harperfast/harper";
|
|
32
|
+
import { withDetachedTxn } from "./table-helpers.js";
|
|
33
|
+
/**
|
|
34
|
+
* Per-call cap on ids credited in one batch — shared by RecordUsage.post()'s
|
|
35
|
+
* validated `memoryIds` body (rejects a batch over the cap with a 400) and
|
|
36
|
+
* recordCitations()'s advisory `usedMemoryIds` (silently slices instead —
|
|
37
|
+
* see that function's doc for why the two differ here).
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_USAGE_IDS_PER_CALL = 20;
|
|
40
|
+
/**
|
|
41
|
+
* One (agentId, memoryId) contribution — the shared ledger-write core.
|
|
42
|
+
*
|
|
43
|
+
* Ledger-row-create FIRST, THEN the Memory.usageCount bump — so a crash
|
|
44
|
+
* between the two leaves the SAFE failure state (ledger row exists, count
|
|
45
|
+
* not yet bumped: a later retry just re-checks and no-ops) rather than the
|
|
46
|
+
* reverse (count bumped, no ledger row → a retry would double-count).
|
|
47
|
+
*
|
|
48
|
+
* Every discrete Harper call is wrapped INDIVIDUALLY in its own
|
|
49
|
+
* withDetachedTxn — never one wrap around a multi-step helper. A request
|
|
50
|
+
* that reads/writes MULTIPLE tables (or the same table twice) in sequence
|
|
51
|
+
* can otherwise inherit a closed transaction from a prior call's drained
|
|
52
|
+
* chain (table-helpers.ts's withDetachedTxn doc); resources/Memory.ts's
|
|
53
|
+
* closeSupersededRecord documents exactly why a single wrap around a
|
|
54
|
+
* multi-await async function does NOT protect a later call inside it —
|
|
55
|
+
* this mirrors that function's literal shape (get wrapped, then a
|
|
56
|
+
* SEPARATE put wrapped) rather than delegating to the generic
|
|
57
|
+
* patchRecord() helper, which would combine both into one un-safe wrap.
|
|
58
|
+
*
|
|
59
|
+
* The final get-then-put for the increment is a best-effort (non-atomic)
|
|
60
|
+
* read-modify-write, same class of race already accepted elsewhere in
|
|
61
|
+
* this codebase for count fields (e.g. retrievalCount's bump in
|
|
62
|
+
* SemanticSearch.ts) — a concurrent contribution from a DIFFERENT agent
|
|
63
|
+
* landing between this call's read and write could lose one increment.
|
|
64
|
+
* Re-fetching immediately before the write (rather than reusing the
|
|
65
|
+
* earlier existence-check read) narrows, without eliminating, that
|
|
66
|
+
* window. Not solved here: bounded, low-severity (an undercount, never an
|
|
67
|
+
* inflation), and orthogonal to the anti-gaming properties the cap/floor
|
|
68
|
+
* and dedup ledger actually defend.
|
|
69
|
+
*
|
|
70
|
+
* Called by RecordUsage.post() (explicit usage feedback) and by
|
|
71
|
+
* recordCitations() below (citation-on-write) — identical ledger semantics
|
|
72
|
+
* regardless of which surface triggered the contribution.
|
|
73
|
+
*/
|
|
74
|
+
export async function recordUsageContribution(ctx, agentId, memoryId, attribution, now) {
|
|
75
|
+
const ledgerId = `${agentId}:${memoryId}`;
|
|
76
|
+
// Bypasses resources/MemoryUsage.ts's own auth wrapper by design — this
|
|
77
|
+
// IS the trusted internal caller that resource's module doc describes
|
|
78
|
+
// (same "raw table object" pattern resources/Memory.ts uses for
|
|
79
|
+
// MemoryGrant).
|
|
80
|
+
const existingContribution = await withDetachedTxn(ctx, () => databases.flair.MemoryUsage.get(ledgerId)).catch(() => null);
|
|
81
|
+
if (existingContribution)
|
|
82
|
+
return; // already counted by this agent — silent no-op
|
|
83
|
+
const memoryExists = await withDetachedTxn(ctx, () => databases.flair.Memory.get(memoryId)).catch(() => null);
|
|
84
|
+
if (!memoryExists)
|
|
85
|
+
return; // no such memory — silent no-op (no enumeration)
|
|
86
|
+
const ledgerRecord = { id: ledgerId, agentId, memoryId, createdAt: now };
|
|
87
|
+
if (attribution)
|
|
88
|
+
ledgerRecord.attribution = attribution;
|
|
89
|
+
// .put(), not .post(): Harper's raw TableResource has no default post()
|
|
90
|
+
// implementation for a static-style (non-`isCollection`-instantiated)
|
|
91
|
+
// call — confirmed live ("The MemoryUsage does not have a post method
|
|
92
|
+
// implemented", statusCode 405) — the SAME class of gotcha
|
|
93
|
+
// resources/Memory.ts documents for HTTP POST, but here it bites even
|
|
94
|
+
// this in-process call. Irrelevant anyway: ledgerId is already a
|
|
95
|
+
// deterministic composite key, so this is a create-with-explicit-id —
|
|
96
|
+
// exactly what PUT (upsert) is for, not an auto-generated-id insert.
|
|
97
|
+
await withDetachedTxn(ctx, () => databases.flair.MemoryUsage.put(ledgerRecord));
|
|
98
|
+
// Targeted usageCount-ONLY bump: read-full-record, merge just this one
|
|
99
|
+
// field, write — against the RAW Memory table, NEVER Memory.put() (the
|
|
100
|
+
// resource class): that would 403 this cross-agent write via its
|
|
101
|
+
// ownership check, and bypassing that check directly would risk letting
|
|
102
|
+
// this write path smuggle OTHER field changes through instead of just the
|
|
103
|
+
// count (RecordUsage.ts module doc's "WHY THIS IS ITS OWN ENDPOINT").
|
|
104
|
+
const fresh = await withDetachedTxn(ctx, () => databases.flair.Memory.get(memoryId)).catch(() => null);
|
|
105
|
+
if (!fresh)
|
|
106
|
+
return; // deleted between the checks above and now — no-op
|
|
107
|
+
await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...fresh, usageCount: (fresh.usageCount ?? 0) + 1 }));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Batch citation helper — credits every id in `usedMemoryIds` through
|
|
111
|
+
* `recordFn` (the real `recordUsageContribution` by default), one contribution
|
|
112
|
+
* per unique id, capped at `MAX_USAGE_IDS_PER_CALL`.
|
|
113
|
+
*
|
|
114
|
+
* Called by Memory.ts's post()/put() POST-COMMIT, wrapped in its own
|
|
115
|
+
* try/catch at the call site — this function itself never throws (every
|
|
116
|
+
* per-id failure is caught and logged below), but callers still isolate the
|
|
117
|
+
* call as a second line of defense so a write can never be affected by
|
|
118
|
+
* citation recording (flair#744 slice A invariant 1).
|
|
119
|
+
*
|
|
120
|
+
* - `auth.kind !== "agent"` ⇒ return immediately, no-op. Internal/admin/
|
|
121
|
+
* anonymous writes have no agent identity to attribute a contribution
|
|
122
|
+
* TO — same rule RecordUsage.post() applies ("usage feedback requires a
|
|
123
|
+
* verified agent identity").
|
|
124
|
+
* - `usedMemoryIds` not a non-empty array of non-empty strings ⇒ no-op
|
|
125
|
+
* (advisory field, never a validated request body — a malformed value
|
|
126
|
+
* is silently ignored, never a 400).
|
|
127
|
+
* - Deduped within the call (`[...new Set(...)]`), THEN capped at
|
|
128
|
+
* MAX_USAGE_IDS_PER_CALL by slicing — a citation list is advisory, not a
|
|
129
|
+
* validated request body, so an oversized list is trimmed rather than
|
|
130
|
+
* rejected (unlike RecordUsage.post()'s validated `memoryIds`, which
|
|
131
|
+
* 400s over the same cap).
|
|
132
|
+
* - Each id is credited independently: one id throwing never stops the
|
|
133
|
+
* rest, and the failure is logged server-side, never surfaced to the
|
|
134
|
+
* caller (the write already committed by the time this runs).
|
|
135
|
+
*
|
|
136
|
+
* `agentId` passed to `recordFn` is ALWAYS `auth.agentId` — the resolved
|
|
137
|
+
* auth context, never anything derived from `usedMemoryIds` or any other
|
|
138
|
+
* caller-supplied input (flair#744 slice A invariant 4: no forging on
|
|
139
|
+
* behalf of another identity).
|
|
140
|
+
*/
|
|
141
|
+
export async function recordCitations(ctx, auth, usedMemoryIds, now, recordFn = recordUsageContribution) {
|
|
142
|
+
if (auth.kind !== "agent")
|
|
143
|
+
return;
|
|
144
|
+
if (!Array.isArray(usedMemoryIds) ||
|
|
145
|
+
usedMemoryIds.length === 0 ||
|
|
146
|
+
!usedMemoryIds.every((id) => typeof id === "string" && id.length > 0)) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const ids = [...new Set(usedMemoryIds)].slice(0, MAX_USAGE_IDS_PER_CALL);
|
|
150
|
+
for (const id of ids) {
|
|
151
|
+
try {
|
|
152
|
+
await recordFn(ctx, auth.agentId, id, undefined, now);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
// Never let one bad id stop the batch — same no-op-on-error discipline
|
|
156
|
+
// as RecordUsage.post()'s loop, log server-side only.
|
|
157
|
+
console.error("recordCitations: failed to credit (no-op)", { memoryId: id, err });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
|
+
"packageManager": "bun@1.3.10",
|
|
4
5
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"license": "Apache-2.0",
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
"node": ">=22"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
|
-
"@harperfast/harper": "5.1.
|
|
58
|
+
"@harperfast/harper": "5.1.22",
|
|
58
59
|
"@harperfast/oauth": "2.4.0",
|
|
59
60
|
"@types/js-yaml": "4.0.9",
|
|
60
61
|
"commander": "14.0.3",
|