@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,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.23.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,11 +55,11 @@
54
55
  "node": ">=22"
55
56
  },
56
57
  "dependencies": {
57
- "@harperfast/harper": "5.1.17",
58
- "@harperfast/oauth": "2.2.0",
58
+ "@harperfast/harper": "5.1.22",
59
+ "@harperfast/oauth": "2.4.0",
59
60
  "@types/js-yaml": "4.0.9",
60
61
  "commander": "14.0.3",
61
- "harper-fabric-embeddings": "^0.4.0",
62
+ "harper-fabric-embeddings": "^0.5.0",
62
63
  "jose": "6.2.2",
63
64
  "js-yaml": "4.1.1",
64
65
  "node-llama-cpp": "3.18.1",