@tpsdev-ai/flair 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1278 -410
- package/dist/deploy.js +16 -1
- package/dist/hook-install.js +324 -0
- package/dist/lib/auth-resolve.js +369 -0
- package/dist/lib/mcp-enable.js +787 -0
- package/dist/resources/Memory.js +93 -3
- package/dist/resources/MemoryBootstrap.js +85 -3
- package/dist/resources/Presence.js +4 -4
- package/dist/resources/RecordUsage.js +32 -80
- package/dist/resources/SemanticSearch.js +71 -3
- package/dist/resources/abstention.js +146 -0
- package/dist/resources/agent-auth.js +4 -5
- package/dist/resources/auth-middleware.js +4 -4
- package/dist/resources/ed25519-auth.js +37 -0
- package/dist/resources/embeddings-boot.js +38 -1
- package/dist/resources/mcp-tools.js +42 -5
- package/dist/resources/semantic-retrieval-core.js +24 -2
- package/dist/resources/trust-block.js +187 -0
- package/dist/resources/usage-recording.js +160 -0
- package/package.json +5 -4
|
@@ -0,0 +1,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,11 +55,11 @@
|
|
|
54
55
|
"node": ">=22"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
|
-
"@harperfast/harper": "5.1.
|
|
58
|
-
"@harperfast/oauth": "2.
|
|
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.
|
|
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",
|