@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.
- package/README.md +10 -7
- package/SECURITY.md +24 -2
- package/config.yaml +32 -5
- package/dist/cli.js +1755 -213
- package/dist/deploy.js +3 -4
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/runner.js +82 -12
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +23 -0
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +52 -53
- package/dist/resources/MemoryBootstrap.js +422 -76
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +8 -1
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/Presence.js +218 -19
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +98 -25
- package/dist/resources/SemanticSearch.js +85 -317
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/WorkspaceState.js +11 -0
- package/dist/resources/agent-auth.js +60 -2
- package/dist/resources/auth-middleware.js +18 -9
- package/dist/resources/bm25.js +12 -6
- package/dist/resources/collision-lib.js +157 -0
- 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/health.js +97 -6
- 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-reflect-lib.js +289 -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-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/event.graphql +5 -0
- package/schemas/memory.graphql +66 -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
|
@@ -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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
+
}
|