@tpsdev-ai/flair 0.21.0 → 0.22.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.
Files changed (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1755 -213
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +456 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +73 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. package/ui/observation-center.html +0 -385
@@ -27,11 +27,15 @@
27
27
  * in-org agents only; see get()'s inline comment.
28
28
  */
29
29
  import { databases } from "@harperfast/harper";
30
+ import { existsSync, readFileSync } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import { createRequire } from "node:module";
30
34
  import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
31
35
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
32
36
  // ─── Constants ────────────────────────────────────────────────────────────────
33
37
  const CURRENT_TASK_MAX_LENGTH = 200;
34
- const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "idle"]);
38
+ const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "debugging", "idle"]);
35
39
  function idleThresholdMs() {
36
40
  const env = process.env.PRESENCE_IDLE_THRESHOLD_MS;
37
41
  return env ? Number(env) || 90_000 : 90_000;
@@ -40,6 +44,94 @@ function offlineThresholdMs() {
40
44
  const env = process.env.PRESENCE_OFFLINE_THRESHOLD_MS;
41
45
  return env ? Number(env) || 600_000 : 600_000;
42
46
  }
47
+ // ─── Version stamping (flair#639) ──────────────────────────────────────────────
48
+ //
49
+ // Auto-presence (packages/flair-mcp/src/presence.ts, flair#608) gives agents a
50
+ // heartbeat, but the record never said which Flair *instance* served it —
51
+ // fleet skew across hosts was only discoverable by probing each one by hand.
52
+ // Every heartbeat now stamps the RUNNING server's own flair + harper versions,
53
+ // so `flair doctor`'s fleet-presence section (src/cli.ts + src/fleet-presence.ts)
54
+ // can flag stale instances org-relatively, no per-host probing required.
55
+ /**
56
+ * Resolve the running @tpsdev-ai/flair version from package.json (duplicated
57
+ * from resources/health.ts / admin-layout.ts / AdminInstance.ts — same "keep
58
+ * in sync" idiom noted in those files: each resource module keeps its own
59
+ * copy for dependency isolation rather than importing a shared helper).
60
+ * `process.env.npm_package_version` is only populated inside `npm run`, so
61
+ * reading package.json relative to THIS running module is the only way to
62
+ * report the version of the code that's actually executing.
63
+ */
64
+ function resolveVersion() {
65
+ try {
66
+ const here = dirname(fileURLToPath(import.meta.url));
67
+ const candidates = [
68
+ join(here, "..", "..", "package.json"),
69
+ join(here, "..", "package.json"),
70
+ ];
71
+ for (const p of candidates) {
72
+ if (existsSync(p)) {
73
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
74
+ if (pkg.version)
75
+ return pkg.version;
76
+ }
77
+ }
78
+ }
79
+ catch { /* fall through */ }
80
+ return process.env.npm_package_version ?? "dev";
81
+ }
82
+ /**
83
+ * Resolve the running @harperfast/harper version. Unlike flair's own
84
+ * package.json (readable via a plain relative path above), Harper's package
85
+ * only exports "." → dist/index.js — there's no "./package.json" subpath, so
86
+ * requiring it directly throws (Node's exports-map enforcement). Resolve the
87
+ * main entry via createRequire instead, then walk up from dist/index.js to
88
+ * the package root's package.json. Returns null (not a placeholder string)
89
+ * on failure so callers/readers can tell "genuinely unknown" apart from a
90
+ * real version — same tri-state as fabric-upgrade.ts's last-resort Harper
91
+ * version lookup.
92
+ */
93
+ function resolveHarperVersion() {
94
+ try {
95
+ const req = createRequire(import.meta.url);
96
+ const mainPath = req.resolve("@harperfast/harper");
97
+ const pkgPath = join(dirname(mainPath), "..", "package.json");
98
+ if (existsSync(pkgPath)) {
99
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
100
+ if (typeof pkg.version === "string")
101
+ return pkg.version;
102
+ }
103
+ }
104
+ catch { /* fall through */ }
105
+ return null;
106
+ }
107
+ /**
108
+ * Build the record to persist for a presence heartbeat. Pure — no Harper
109
+ * calls, no version resolution of its own — so the record SHAPE (and post()'s
110
+ * merge-vs-insert plumbing around it) can be unit-tested without a live
111
+ * Harper or the real filesystem. Callers pass already-resolved versions
112
+ * (resolveVersion()/resolveHarperVersion() above), so tests can pin them
113
+ * directly. Mirrors buildPresenceBody() in packages/flair-mcp/src/presence.ts
114
+ * — same "exported pure builder, exported for tests" idiom, server side.
115
+ */
116
+ export function buildPresenceRecord(agentId, now, currentTask, activity, existingActivity, existingActivityUpdatedAt, flairVersion, harperVersion) {
117
+ // A heartbeat "asserts" what the agent is doing when it carries an activity
118
+ // and/or a (non-empty) currentTask. Only such a beat re-stamps
119
+ // activityUpdatedAt to `now`; a pure liveness beat (neither field) refreshes
120
+ // lastHeartbeatAt but PRESERVES the prior stamp — so activity ages on its own
121
+ // and lapses to last-known once the agent stops asserting it, with no manual
122
+ // clear (natural-presence). Backward compatible: today every real heartbeat
123
+ // carries activity, so this simply keeps activity fresh while an agent works.
124
+ const asserted = activity !== undefined || sanitizeCurrentTask(currentTask) !== null;
125
+ return {
126
+ agentId,
127
+ lastHeartbeatAt: now,
128
+ currentTask: sanitizeCurrentTask(currentTask),
129
+ activity: activity ?? (existingActivity ?? "idle"),
130
+ activityUpdatedAt: asserted ? now : (existingActivityUpdatedAt ?? null),
131
+ flairVersion,
132
+ harperVersion,
133
+ };
134
+ }
43
135
  // ─── Nonce replay + crypto helpers ─────────────────────────────────────────────
44
136
  // WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
45
137
  // importEd25519Key all live in ./ed25519-auth.ts — the single
@@ -61,16 +153,81 @@ export function derivePresenceStatus(now, lastHeartbeatAt, idleMs, offlineMs) {
61
153
  return "idle";
62
154
  return "offline";
63
155
  }
156
+ // ─── Activity decay (natural-presence) ─────────────────────────────────────────
157
+ //
158
+ // Presence is a LIVENESS BEACON, not a sticky status board: an offline agent
159
+ // has, by definition, no *current* activity, so returning its last `activity`
160
+ // / `currentTask` as if live is a lie (an agent offline 13 days still read as
161
+ // `activity: "debugging"`; another showed a finished-task `currentTask` 20h
162
+ // after going dark). These two pure functions decide "is this record's
163
+ // activity still CURRENT, or has it lapsed into last-known?" — same offline
164
+ // threshold, and the SAME graceful-degradation-on-old-records discipline, as
165
+ // derivePresenceStatus() above and flair#639's version staleness.
166
+ /**
167
+ * Resolve the timestamp used to judge activity freshness. Prefer the per-field
168
+ * `activityUpdatedAt` stamp (additive/nullable, mirrors flair#639); fall back
169
+ * to `lastHeartbeatAt` for records written before this feature existed — so an
170
+ * old row degrades to "activity is exactly as fresh as its heartbeat" (never
171
+ * claiming activity is FRESHER than the beat that carried it) rather than
172
+ * reading as permanently stale or throwing. Returns null only when NEITHER is
173
+ * a finite number (nothing to judge against → treated as stale by callers).
174
+ */
175
+ export function activityFreshnessAt(activityUpdatedAt, lastHeartbeatAt) {
176
+ if (typeof activityUpdatedAt === "number" && Number.isFinite(activityUpdatedAt))
177
+ return activityUpdatedAt;
178
+ if (typeof lastHeartbeatAt === "number" && Number.isFinite(lastHeartbeatAt))
179
+ return lastHeartbeatAt;
180
+ return null;
181
+ }
182
+ /**
183
+ * Pure decay rule: is a record's activity/currentTask still CURRENT? Activity
184
+ * is fresh while its freshness stamp is younger than the SAME offline
185
+ * threshold that flips presenceStatus to "offline" — so an offline agent can
186
+ * never present a live-looking activity label. Because the stamp only moves
187
+ * forward when a heartbeat actually carries activity (see buildPresenceRecord),
188
+ * activity also lapses INDEPENDENTLY of raw liveness: an agent that keeps
189
+ * heartbeating liveness but stops asserting what it's doing decays to
190
+ * last-known on its own, no manual "clear" call needed. Clock skew (a stamp in
191
+ * the future) is treated as fresh, matching derivePresenceStatus().
192
+ */
193
+ export function isActivityFresh(now, activityUpdatedAt, lastHeartbeatAt, offlineMs) {
194
+ const offline = offlineMs ?? offlineThresholdMs();
195
+ const at = activityFreshnessAt(activityUpdatedAt, lastHeartbeatAt);
196
+ if (at == null)
197
+ return false;
198
+ const elapsed = now - at;
199
+ if (elapsed < 0)
200
+ return true;
201
+ return elapsed < offline;
202
+ }
64
203
  // ─── Public-safe field allowlist ──────────────────────────────────────────────
204
+ // flairVersion/harperVersion (flair#639) are content-gated the SAME way as
205
+ // currentTask (see get()'s includeVerifiedFields) even though version
206
+ // numbers aren't free text — precedent is HealthDetail vs the public /Health
207
+ // endpoint (resources/health.ts): version info is deliberately withheld from
208
+ // anonymous callers to avoid fingerprinting a publicly exposed instance for
209
+ // known-CVE targeting. Anonymous readers get null for both, same as currentTask.
210
+ // activity/lastActivity/activityUpdatedAt/activityAgeMs/activityFresh
211
+ // (natural-presence) are all public-safe: `activity` and `lastActivity` are a
212
+ // fixed vocabulary label (coding|reviewing|planning|debugging|idle), the two timestamps
213
+ // and the boolean are liveness metadata of the same class as the already-public
214
+ // lastHeartbeatAt. Only `currentTask` (free text) stays content-gated to
215
+ // verified readers — see get()'s includeVerifiedFields.
65
216
  const ROSTER_ALLOWLIST = new Set([
66
217
  "id",
67
218
  "displayName",
68
219
  "role",
69
220
  "runtime",
70
221
  "activity",
222
+ "lastActivity",
223
+ "activityUpdatedAt",
224
+ "activityAgeMs",
225
+ "activityFresh",
71
226
  "presenceStatus",
72
227
  "currentTask",
73
228
  "lastHeartbeatAt",
229
+ "flairVersion",
230
+ "harperVersion",
74
231
  ]);
75
232
  function sanitizeCurrentTask(task) {
76
233
  if (typeof task !== "string")
@@ -138,6 +295,10 @@ export class Presence extends databases.flair.Presence {
138
295
  * super_user, Basic-admin, internal in-process) gets currentTask=null.
139
296
  * allowRead() is UNCHANGED (still `true`) — the roster itself stays public;
140
297
  * only the free-text field is gated, per the issue's field-level option.
298
+ *
299
+ * flairVersion/harperVersion (flair#639) ride the SAME gate, renamed to
300
+ * includeVerifiedFields since it now covers more than currentTask — see the
301
+ * ROSTER_ALLOWLIST comment above for why version numbers are gated too.
141
302
  */
142
303
  async get() {
143
304
  // Extract the raw request the same way post() does (getContext().request
@@ -148,7 +309,7 @@ export class Presence extends databases.flair.Presence {
148
309
  const ctx = this.getContext?.();
149
310
  const request = ctx?.request ?? ctx;
150
311
  const agentAuth = request ? await verifyAgentRequest(request) : null;
151
- const includeCurrentTask = agentAuth !== null;
312
+ const includeVerifiedFields = agentAuth !== null;
152
313
  const now = Date.now();
153
314
  const idleThreshold = idleThresholdMs();
154
315
  const offlineThreshold = offlineThresholdMs();
@@ -164,22 +325,58 @@ export class Presence extends databases.flair.Presence {
164
325
  agent = await databases.flair.Agent.get(agentId);
165
326
  }
166
327
  catch { /* agent may not exist or be deactivated */ }
328
+ const lastHeartbeatAt = typeof row?.lastHeartbeatAt === "number"
329
+ ? row.lastHeartbeatAt
330
+ : Number(row?.lastHeartbeatAt ?? 0);
331
+ // activityUpdatedAt is BigInt in the schema (Harper may hand it back as
332
+ // number|string|bigint) and absent on pre-feature records — normalize
333
+ // to a finite number or null so activityFreshnessAt() can fall back.
334
+ const activityUpdatedAt = row?.activityUpdatedAt == null
335
+ ? null
336
+ : (typeof row.activityUpdatedAt === "number" ? row.activityUpdatedAt : Number(row.activityUpdatedAt));
337
+ // NATURAL PRESENCE: bind activity/currentTask to freshness. A stale
338
+ // record has no *current* activity — present the last-known label under
339
+ // dedicated fields, never as the live `activity`. `activity` is the
340
+ // CURRENT truth (falls to "idle" — the "nothing right now" vocabulary
341
+ // value — once stale); `lastActivity` preserves the raw last-known
342
+ // label so a reader can render "offline (was: coding)".
343
+ const rawActivity = (typeof row?.activity === "string" && row.activity.length > 0)
344
+ ? row.activity
345
+ : "idle";
346
+ const activityFresh = isActivityFresh(now, activityUpdatedAt, lastHeartbeatAt, offlineThreshold);
347
+ const freshnessAt = activityFreshnessAt(activityUpdatedAt, lastHeartbeatAt);
167
348
  const entry = {
168
349
  id: agentId,
169
350
  displayName: agent?.displayName ?? agent?.name ?? agentId,
170
351
  role: agent?.role ?? "agent",
171
352
  runtime: agent?.runtime ?? null,
172
- activity: row?.activity ?? "idle",
173
- presenceStatus: derivePresenceStatus(now, typeof row?.lastHeartbeatAt === "number"
174
- ? row.lastHeartbeatAt
175
- : Number(row?.lastHeartbeatAt ?? 0), idleThreshold, offlineThreshold),
176
- // Anonymous/unverified readers get `null` here (key stays present,
177
- // schema-stable) instead of the sanitized task text see the
178
- // currentTask CONTENT gate doc above get().
179
- currentTask: includeCurrentTask ? sanitizeCurrentTask(row?.currentTask) : null,
180
- lastHeartbeatAt: typeof row?.lastHeartbeatAt === "number"
181
- ? row.lastHeartbeatAt
182
- : Number(row?.lastHeartbeatAt ?? 0),
353
+ // Current activity only truthful while fresh; "idle" once decayed.
354
+ activity: activityFresh ? rawActivity : "idle",
355
+ // Last-known activity label regardless of freshness (public-safe, same
356
+ // vocabulary as activity) enables "was: <activity>" rendering.
357
+ lastActivity: rawActivity,
358
+ // When activity was last asserted (resolved; falls back to
359
+ // lastHeartbeatAt for pre-feature records see activityFreshnessAt).
360
+ activityUpdatedAt: freshnessAt,
361
+ // How stale that assertion is, so a reader can show "was active Xh
362
+ // ago" without needing the server's clock or the threshold.
363
+ activityAgeMs: freshnessAt != null ? Math.max(0, now - freshnessAt) : null,
364
+ // The server's verdict — makes staleness impossible to misread.
365
+ activityFresh,
366
+ presenceStatus: derivePresenceStatus(now, lastHeartbeatAt, idleThreshold, offlineThreshold),
367
+ // currentTask is current only when the reader is verified AND the
368
+ // record is fresh. A stale record's task (a finished-task string it
369
+ // never cleared) is NOT current → null, same as the anonymous case.
370
+ // Anonymous/unverified readers always get null (key stays present,
371
+ // schema-stable) — see the currentTask CONTENT gate doc above get().
372
+ currentTask: (includeVerifiedFields && activityFresh) ? sanitizeCurrentTask(row?.currentTask) : null,
373
+ lastHeartbeatAt,
374
+ // flair#639: absent on records written before this feature (older
375
+ // instance, never re-heartbeated since upgrading) — `row?.field ??
376
+ // null` tolerates that directly, same as every other optional field
377
+ // here. Gated to verified readers; see ROSTER_ALLOWLIST comment.
378
+ flairVersion: includeVerifiedFields ? (row?.flairVersion ?? null) : null,
379
+ harperVersion: includeVerifiedFields ? (row?.harperVersion ?? null) : null,
183
380
  };
184
381
  results.push(pickAllowlisted(entry));
185
382
  }
@@ -263,12 +460,14 @@ export class Presence extends databases.flair.Presence {
263
460
  existing = await databases.flair.Presence.get(agentId);
264
461
  }
265
462
  catch { /* first heartbeat */ }
266
- const record = {
267
- agentId,
268
- lastHeartbeatAt: now,
269
- currentTask: sanitizeCurrentTask(currentTask),
270
- activity: activity ?? (existing?.activity ?? "idle"),
271
- };
463
+ // flair#639: stamp THIS server's own running versions on every
464
+ // heartbeat — an upgrade takes effect on the instance's very next
465
+ // heartbeat, no separate migration needed.
466
+ const record = buildPresenceRecord(agentId, now, currentTask, activity, existing?.activity,
467
+ // Preserve the prior activity-freshness stamp when THIS beat asserts no
468
+ // activity/task (pure liveness) — normalize the BigInt-ish stored value
469
+ // to a number so buildPresenceRecord's `?? null` fallback behaves.
470
+ existing?.activityUpdatedAt == null ? null : Number(existing.activityUpdatedAt), resolveVersion(), resolveHarperVersion());
272
471
  if (existing) {
273
472
  const merged = { ...existing, ...record };
274
473
  await databases.flair.Presence.put(merged);
@@ -0,0 +1,230 @@
1
+ /**
2
+ * POST /RecordUsage — the usage-feedback signal (flair#683).
3
+ *
4
+ * A generic "record that a memory was actually used" surface: an agent that
5
+ * grounded an answer or decision on a recalled memory reports it here.
6
+ * Distinct from — and NEVER wired to — retrieval: `Memory.retrievalCount`
7
+ * (bumped on every SemanticSearch hit, resources/SemanticSearch.ts:~551) is
8
+ * the WEAK, self-reinforcing signal root-caused in flair#623 ("a search hit
9
+ * counted as usage"); `Memory.usageCount` (this endpoint's only writer) is
10
+ * the STRONG signal driving `usageBoost` in resources/scoring.ts, which
11
+ * REPLACES `retrievalBoost` in `compositeScore` outright (see that
12
+ * function's doc).
13
+ *
14
+ * WHY THIS IS ITS OWN ENDPOINT, NOT `Memory.put()` (Sherlock, K&S verdict —
15
+ * FLAIR-USAGE-FEEDBACK-SIGNAL.md): usage feedback is fundamentally a
16
+ * CROSS-AGENT write — agent B reports using agent A's memory, so the write
17
+ * target is A's record, not B's own. `Memory.put()`'s ownership check
18
+ * (resources/Memory.ts: "a non-admin agent may only write memories it
19
+ * owns") would 403 every single legitimate call. Bypassing that check
20
+ * instead would open the door to modifying ANY field on another agent's
21
+ * memory through this surface — not just the count. So this endpoint does
22
+ * the narrowest possible thing instead: a TARGETED `usageCount`-only
23
+ * bump (read-full-record, merge just this one field, write — see
24
+ * _recordOne() below) against the RAW `Memory` table object, entirely
25
+ * bypassing the `Memory` RESOURCE class (and its ownership gate) — its own
26
+ * auth model is verified-agent + within-org + explicitly NO ownership
27
+ * requirement.
28
+ *
29
+ * WHY THIS ISN'T A @table-BACKED RESOURCE: the actual dedup ledger (one row
30
+ * per (agentId, memoryId) contribution) lives in the `MemoryUsage` table
31
+ * (schemas/memory.graphql), guarded by resources/MemoryUsage.ts. But this
32
+ * endpoint's whole shape is "POST a list of ids that got used" — and Harper's
33
+ * base TableResource has no default `post()` implementation for a
34
+ * static-style raw-table call outside an `isCollection`-instantiated
35
+ * resource (confirmed live: `databases.flair.MemoryUsage.post(...)` throws
36
+ * "The MemoryUsage does not have a post method implemented", HTTP 405 — the
37
+ * SAME class of gotcha resources/Memory.ts documents for a raw HTTP POST to
38
+ * `/Memory`, but here it bites even an in-process call). So the ledger row
39
+ * itself is written via `.put()` (upsert with the deterministic composite
40
+ * id — see _recordOne()), and this endpoint's OWN HTTP surface lives on a
41
+ * plain action `Resource` (same shape as resources/MemoryReflect.ts /
42
+ * resources/SemanticSearch.ts) rather than extending a @table class, so POST
43
+ * actually routes here. It talks to BOTH `Memory` and `MemoryUsage` via
44
+ * their raw table objects (bypassing each resource class's own auth
45
+ * wrapper — the same "call the other table directly" pattern
46
+ * resources/Memory.ts already uses for `MemoryGrant` in hasWriteGrant()).
47
+ *
48
+ * ANTI-GAMING (Sherlock): usage feedback is a write that affects RANKING —
49
+ * an abuse surface (inflate usageCount to boost a memory). Three-layer
50
+ * defense:
51
+ * 1. Rate limiter — a ~30 RPM bucket per agent (resources/rate-limiter.ts,
52
+ * "usage" bucket), same shape as every other write path here.
53
+ * 2. Dedup bound — each (agentId, memoryId) pair contributes AT MOST 1 to
54
+ * usageCount, enforced by the MemoryUsage ledger (a fresh ledger row
55
+ * required before any increment; a repeat call is a silent no-op).
56
+ * 3. The capped, floor-gated `usageBoost` itself (resources/scoring.ts) —
57
+ * even a fully-gamed usageCount can only nudge a score by +10% above
58
+ * the relevance floor; it's a tie-breaker, never an override. Sybil
59
+ * (many distinct agent identities) has a bounded blast radius per
60
+ * identity, and Ed25519-verified identity isn't free to mint at scale.
61
+ *
62
+ * NO ID ENUMERATION (Sherlock): the response is IDENTICAL — `{ recorded:
63
+ * true }` — for every syntactically-valid input, regardless of whether a
64
+ * given id was a fresh increment, an already-counted no-op (this agent
65
+ * already contributed), or a not-found no-op (no such memory). A caller
66
+ * cannot distinguish "that id doesn't exist" from "you already used it" by
67
+ * inspecting the response; per-id/per-batch success is deliberately never
68
+ * reported (see RECORDED_RESPONSE's doc below for why even partial-batch
69
+ * counts would leak information).
70
+ *
71
+ * ATTRIBUTION is OPAQUE (Sherlock): an optional free-text hint about what
72
+ * used the memory. Never parsed, never fed to an LLM, never rendered —
73
+ * sanitized (control-character-stripped, length-capped) and stored as-is,
74
+ * for audit/analytics only. Treat it as untrusted data, always.
75
+ */
76
+ import { Resource, databases } from "@harperfast/harper";
77
+ import { resolveAgentAuth } from "./agent-auth.js";
78
+ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
79
+ import { withDetachedTxn } from "./table-helpers.js";
80
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
81
+ const BAD_REQUEST = (msg) => new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } });
82
+ const MAX_IDS_PER_CALL = 20;
83
+ const MAX_ATTRIBUTION_LENGTH = 500;
84
+ /**
85
+ * Strip control/non-printable characters and cap length. Attribution is
86
+ * OPAQUE (module doc) — this is baseline hygiene against a caller smuggling
87
+ * something (terminal escapes, a giant blob) into stored data, not content
88
+ * moderation or safety scanning (content-safety.ts's scanContent() is for
89
+ * memory CONTENT that gets injected into agent context via bootstrap;
90
+ * attribution is never surfaced that way, so that scanner doesn't apply
91
+ * here — deliberately a separate, narrower sanitizer).
92
+ */
93
+ function sanitizeAttribution(raw) {
94
+ if (typeof raw !== "string" || raw.length === 0)
95
+ return undefined;
96
+ const cleaned = raw
97
+ .replace(/[\x00-\x1F\x7F-\x9F]/g, " ") // strip C0/C1 control chars
98
+ .replace(/\s+/g, " ")
99
+ .trim();
100
+ if (!cleaned)
101
+ return undefined;
102
+ return cleaned.slice(0, MAX_ATTRIBUTION_LENGTH);
103
+ }
104
+ /**
105
+ * The INVARIANT response for any syntactically-valid request, regardless of
106
+ * outcome (fresh increment / already-counted / not-found — see module doc's
107
+ * "NO ID ENUMERATION"). Deliberately does not report per-id or even
108
+ * aggregate success counts: for a batch of N ids where one is fake, a
109
+ * response like `{ recorded: 4, of: 5 }` would let a caller binary-search
110
+ * which id in a batch doesn't exist just as surely as a per-id breakdown
111
+ * would. The only externally observable state change is the actual
112
+ * `usageCount` on a memory the caller is independently authorized to READ
113
+ * (via Memory.get/search/SemanticSearch) — a SEPARATE, already-scoped call,
114
+ * never this endpoint.
115
+ */
116
+ const RECORDED_RESPONSE = { recorded: true };
117
+ export class RecordUsage extends Resource {
118
+ // Any verified agent may report usage — any further scoping (which memory,
119
+ // whether it's a dup) is enforced inside post() itself, never here.
120
+ async allowCreate() {
121
+ return (await resolveAgentAuth(this.getContext?.())).kind !== "anonymous";
122
+ }
123
+ async post(data) {
124
+ const ctx = this.getContext?.();
125
+ const auth = await resolveAgentAuth(ctx);
126
+ if (auth.kind === "anonymous")
127
+ return UNAUTH();
128
+ // Usage feedback attributes a contribution to a SPECIFIC agent identity
129
+ // (the MemoryUsage ledger's dedup key) — a trusted internal call with no
130
+ // per-agent identity, or an admin acting with no agent context, has
131
+ // nothing to attribute a contribution TO. Agent-facing only.
132
+ if (auth.kind !== "agent") {
133
+ return BAD_REQUEST("usage feedback requires a verified agent identity");
134
+ }
135
+ const agentId = auth.agentId;
136
+ const rl = checkRateLimit(agentId, "usage");
137
+ if (!rl.allowed)
138
+ return rateLimitResponse(rl.retryAfterMs, "usage");
139
+ const rawIds = data?.memoryIds ?? (typeof data?.memoryId === "string" ? [data.memoryId] : undefined);
140
+ if (!Array.isArray(rawIds) || rawIds.length === 0 || !rawIds.every((id) => typeof id === "string" && id.length > 0)) {
141
+ return BAD_REQUEST("memoryIds must be a non-empty array of memory id strings");
142
+ }
143
+ if (rawIds.length > MAX_IDS_PER_CALL) {
144
+ return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
145
+ }
146
+ const memoryIds = [...new Set(rawIds)]; // dedupe within THIS call too
147
+ const attribution = sanitizeAttribution(data?.attribution);
148
+ const now = new Date().toISOString();
149
+ for (const memoryId of memoryIds) {
150
+ try {
151
+ await this._recordOne(ctx, agentId, memoryId, attribution, now);
152
+ }
153
+ catch (err) {
154
+ // Never let one bad id fail the whole batch, and never let an
155
+ // internal error leak existence information either — log
156
+ // server-side, collapse to the same no-op the response already
157
+ // returns for every other outcome.
158
+ console.error("RecordUsage.post: failed to record usage (treated as no-op)", { memoryId, err });
159
+ }
160
+ }
161
+ // Deliberately does NOT report which ids succeeded / were already
162
+ // counted / were not found — see RECORDED_RESPONSE's doc.
163
+ return RECORDED_RESPONSE;
164
+ }
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
+ }