@tpsdev-ai/flair 0.20.1 → 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.
- package/README.md +39 -11
- package/SECURITY.md +52 -20
- package/config.yaml +32 -5
- package/dist/cli.js +2235 -260
- package/dist/deploy.js +96 -15
- package/dist/doctor-client.js +312 -0
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/install/clients.js +18 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/restore.js +1 -1
- package/dist/rem/runner.js +82 -12
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +121 -2
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +127 -22
- package/dist/resources/MemoryBootstrap.js +441 -85
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +69 -5
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +268 -17
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +108 -26
- package/dist/resources/SemanticSearch.js +88 -320
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +12 -1
- package/dist/resources/agent-auth.js +61 -3
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +42 -14
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +13 -7
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +456 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +73 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-check.js +167 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/agent.graphql +7 -0
- package/schemas/event.graphql +5 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +82 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- package/ui/observation-center.html +0 -385
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Auth:
|
|
10
10
|
* GET — public (returns only allowlisted fields; safe for public renderer).
|
|
11
|
+
* currentTask is additionally content-gated to verified agents only
|
|
12
|
+
* (#592) — anonymous callers get the roster with currentTask=null.
|
|
11
13
|
* POST — Ed25519 agent credential (TPS-Ed25519 header). Agent writes only its
|
|
12
14
|
* own record; cross-agent writes are rejected (403).
|
|
13
15
|
*
|
|
@@ -15,13 +17,25 @@
|
|
|
15
17
|
* - Write: per-agent Ed25519 auth. Cross-agent → 403.
|
|
16
18
|
* - Read: field-allowlisted to public-safe set. No secrets, no admin data.
|
|
17
19
|
* - currentTask is agent-authored free text → cap length, escape on render.
|
|
20
|
+
* - currentTask CONTENT gate (#592): the roster (id/displayName/role/
|
|
21
|
+
* runtime/activity/presenceStatus/lastHeartbeatAt) is genuinely
|
|
22
|
+
* public-safe and stays world-readable, but currentTask is free text that
|
|
23
|
+
* the coordination convention (`presence set --task "investigating
|
|
24
|
+
* <host>: <symptom>"`) has put customer names and preprod hostnames in.
|
|
25
|
+
* sanitizeCurrentTask() only trims/caps length — it does not redact
|
|
26
|
+
* content. get() additionally gates currentTask itself to verified
|
|
27
|
+
* in-org agents only; see get()'s inline comment.
|
|
18
28
|
*/
|
|
19
29
|
import { databases } from "@harperfast/harper";
|
|
20
|
-
import {
|
|
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";
|
|
34
|
+
import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
|
|
21
35
|
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
22
36
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
23
37
|
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
24
|
-
const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "idle"]);
|
|
38
|
+
const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "debugging", "idle"]);
|
|
25
39
|
function idleThresholdMs() {
|
|
26
40
|
const env = process.env.PRESENCE_IDLE_THRESHOLD_MS;
|
|
27
41
|
return env ? Number(env) || 90_000 : 90_000;
|
|
@@ -30,9 +44,97 @@ function offlineThresholdMs() {
|
|
|
30
44
|
const env = process.env.PRESENCE_OFFLINE_THRESHOLD_MS;
|
|
31
45
|
return env ? Number(env) || 600_000 : 600_000;
|
|
32
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
|
+
}
|
|
33
135
|
// ─── Nonce replay + crypto helpers ─────────────────────────────────────────────
|
|
34
136
|
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
35
|
-
// importEd25519Key all live in ./ed25519-auth.ts
|
|
137
|
+
// importEd25519Key all live in ./ed25519-auth.ts — the single
|
|
36
138
|
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
37
139
|
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
38
140
|
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
@@ -51,16 +153,81 @@ export function derivePresenceStatus(now, lastHeartbeatAt, idleMs, offlineMs) {
|
|
|
51
153
|
return "idle";
|
|
52
154
|
return "offline";
|
|
53
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
|
+
}
|
|
54
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.
|
|
55
216
|
const ROSTER_ALLOWLIST = new Set([
|
|
56
217
|
"id",
|
|
57
218
|
"displayName",
|
|
58
219
|
"role",
|
|
59
220
|
"runtime",
|
|
60
221
|
"activity",
|
|
222
|
+
"lastActivity",
|
|
223
|
+
"activityUpdatedAt",
|
|
224
|
+
"activityAgeMs",
|
|
225
|
+
"activityFresh",
|
|
61
226
|
"presenceStatus",
|
|
62
227
|
"currentTask",
|
|
63
228
|
"lastHeartbeatAt",
|
|
229
|
+
"flairVersion",
|
|
230
|
+
"harperVersion",
|
|
64
231
|
]);
|
|
65
232
|
function sanitizeCurrentTask(task) {
|
|
66
233
|
if (typeof task !== "string")
|
|
@@ -98,8 +265,51 @@ export class Presence extends databases.flair.Presence {
|
|
|
98
265
|
*
|
|
99
266
|
* Joins Presence records with Agent metadata and derives presenceStatus.
|
|
100
267
|
* Only allowlisted fields are returned (no secrets, no admin data).
|
|
268
|
+
*
|
|
269
|
+
* currentTask CONTENT gate (#592): Harper routes every GET — collection
|
|
270
|
+
* (`GET /Presence`) AND single-record (`GET /Presence/<id>`) — through this
|
|
271
|
+
* SAME method (REST.js: `resource.get(target, request)`, one call site for
|
|
272
|
+
* both; this override ignores `target` and always returns the full roster
|
|
273
|
+
* array), so gating once here, before the loop, covers every read path with
|
|
274
|
+
* no separate return site to miss.
|
|
275
|
+
*
|
|
276
|
+
* The gate keys off a valid TPS-Ed25519 SIGNATURE (verifyAgentRequest),
|
|
277
|
+
* NOT resolveAgentAuth()/allowVerified — and that distinction is the whole
|
|
278
|
+
* fix. /Presence is a public-passthrough in auth-middleware.ts: the
|
|
279
|
+
* middleware early-returns WITHOUT annotating tpsAgent/tpsAnonymous, so a
|
|
280
|
+
* resource-level resolver never sees a gate annotation for this path. Worse,
|
|
281
|
+
* Harper's `authorizeLocal` (config default true) auto-authorizes any
|
|
282
|
+
* *credential-less* loopback request as super_user — it injects request.user
|
|
283
|
+
* ONLY "when there is no Authorization header" (node .../server/http.js). So a
|
|
284
|
+
* bare, unauthenticated `GET /Presence` from loopback (exactly the anonymous
|
|
285
|
+
* caller the issue is about, and what the integration test exercises against
|
|
286
|
+
* a real spawned Harper) arrives with request.user = super_user and NO
|
|
287
|
+
* signature. resolveAgentAuth() would classify that as `kind:"agent"` (its
|
|
288
|
+
* super_user branch) and leak currentTask — which it did, against real
|
|
289
|
+
* Harper, even though mocked unit tests using tpsAgent annotations passed.
|
|
290
|
+
* A TPS-Ed25519 signature, by contrast, cannot be manufactured by
|
|
291
|
+
* authorizeLocal (it requires the Authorization header, which suppresses the
|
|
292
|
+
* super_user injection), so verifyAgentRequest() cleanly separates a real
|
|
293
|
+
* in-org agent from an anonymous/loopback/Basic-admin caller. Only a valid
|
|
294
|
+
* agent signature gets currentTask; everything else (anonymous, loopback
|
|
295
|
+
* super_user, Basic-admin, internal in-process) gets currentTask=null.
|
|
296
|
+
* allowRead() is UNCHANGED (still `true`) — the roster itself stays public;
|
|
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.
|
|
101
302
|
*/
|
|
102
303
|
async get() {
|
|
304
|
+
// Extract the raw request the same way post() does (getContext().request
|
|
305
|
+
// is populated for GET; fall back to the context itself). verifyAgentRequest
|
|
306
|
+
// returns the agent for a valid TPS-Ed25519 signature, else null — see the
|
|
307
|
+
// gate rationale above. Memoized per-request, so this is a no-op if any
|
|
308
|
+
// other path already verified the same request.
|
|
309
|
+
const ctx = this.getContext?.();
|
|
310
|
+
const request = ctx?.request ?? ctx;
|
|
311
|
+
const agentAuth = request ? await verifyAgentRequest(request) : null;
|
|
312
|
+
const includeVerifiedFields = agentAuth !== null;
|
|
103
313
|
const now = Date.now();
|
|
104
314
|
const idleThreshold = idleThresholdMs();
|
|
105
315
|
const offlineThreshold = offlineThresholdMs();
|
|
@@ -115,19 +325,58 @@ export class Presence extends databases.flair.Presence {
|
|
|
115
325
|
agent = await databases.flair.Agent.get(agentId);
|
|
116
326
|
}
|
|
117
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);
|
|
118
348
|
const entry = {
|
|
119
349
|
id: agentId,
|
|
120
350
|
displayName: agent?.displayName ?? agent?.name ?? agentId,
|
|
121
351
|
role: agent?.role ?? "agent",
|
|
122
352
|
runtime: agent?.runtime ?? null,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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,
|
|
131
380
|
};
|
|
132
381
|
results.push(pickAllowlisted(entry));
|
|
133
382
|
}
|
|
@@ -211,12 +460,14 @@ export class Presence extends databases.flair.Presence {
|
|
|
211
460
|
existing = await databases.flair.Presence.get(agentId);
|
|
212
461
|
}
|
|
213
462
|
catch { /* first heartbeat */ }
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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());
|
|
220
471
|
if (existing) {
|
|
221
472
|
const merged = { ...existing, ...record };
|
|
222
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
|
+
}
|