@tpsdev-ai/flair 0.24.0 → 0.25.1

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,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.24.0",
3
+ "version": "0.25.1",
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.17",
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",