@tpsdev-ai/flair 0.53.0 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1791 -15648
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1654 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +914 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1592 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/fabric-npm-install.js +87 -0
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +5 -4
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +43 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +15 -7
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
- package/package.json +9 -4
- package/schemas/agent.graphql +15 -0
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
import * as render from "../render.js";
|
|
2
|
+
import { EMBEDDING_STAMP_ID, describeStampOutstanding, resolveCurrentModelId } from "../stamp-outstanding.js";
|
|
3
|
+
let cli;
|
|
4
|
+
/** Bind the cli-locals this module depends on. */
|
|
5
|
+
export function bindCli(fns) {
|
|
6
|
+
cli = fns;
|
|
7
|
+
}
|
|
8
|
+
function api(...args) {
|
|
9
|
+
return cli.api(...args);
|
|
10
|
+
}
|
|
11
|
+
function fetchHealthDetail(...args) {
|
|
12
|
+
return cli.fetchHealthDetail(...args);
|
|
13
|
+
}
|
|
14
|
+
function publishOrgEvent(...args) {
|
|
15
|
+
return cli.publishOrgEvent(...args);
|
|
16
|
+
}
|
|
17
|
+
function relativeTime(...args) {
|
|
18
|
+
return cli.relativeTime(...args);
|
|
19
|
+
}
|
|
20
|
+
function resolveSigningAgentId(...args) {
|
|
21
|
+
return cli.resolveSigningAgentId(...args);
|
|
22
|
+
}
|
|
23
|
+
export const QUALITY_QUIET_THRESHOLD_DAYS = 7;
|
|
24
|
+
/** Mirrors resources/health.ts's own hash-fallback warning threshold (kept as
|
|
25
|
+
* a literal constant here rather than imported — health.ts computes its
|
|
26
|
+
* warning string server-side, this recomputes the same judgment CLI-side
|
|
27
|
+
* from the raw counts so quality doesn't depend on parsing warning text). */
|
|
28
|
+
export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
|
|
29
|
+
/** Recall spot-check (Slice 1d) defaults — how many of the querying agent's
|
|
30
|
+
* own memories to sample, and the top-k depth each is searched at. Same
|
|
31
|
+
* "first-pass default, tunable later" spirit as the thresholds above. */
|
|
32
|
+
export const QUALITY_RECALL_SAMPLE_SIZE = 10;
|
|
33
|
+
export const QUALITY_RECALL_K = 5;
|
|
34
|
+
/**
|
|
35
|
+
* Fields the recall spot-check and the quality-snapshot lookup actually
|
|
36
|
+
* read. Harper REST `select(...)` (same syntax adk-flair-js's listMemories
|
|
37
|
+
* already uses) projects these server-side so the nightly sweep never
|
|
38
|
+
* pulls embedding vectors inline — the defect in flair#1360 was an
|
|
39
|
+
* unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
|
|
40
|
+
* vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
|
|
41
|
+
* 10 memories. `archived` is projected so the planner can drop basemented
|
|
42
|
+
* rows before sampling (flair#857 — SemanticSearch excludes them, so an
|
|
43
|
+
* archived row in the sample is a guaranteed miss). `type` is intentionally
|
|
44
|
+
* omitted: it is not a declared Memory column (see schemas/memory.graphql);
|
|
45
|
+
* snapshot exclusion keys off `subject` (`quality-snapshot/…`).
|
|
46
|
+
*/
|
|
47
|
+
export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt", "archived"];
|
|
48
|
+
/**
|
|
49
|
+
* Extra most-recent rows fetched beyond `sampleSize` so
|
|
50
|
+
* `planRecallSpotCheck` can drop the sweep's own quality-snapshot
|
|
51
|
+
* bookkeeping and still fill a 10-row window — without scanning the
|
|
52
|
+
* table. Nightly `--emit` writes one snapshot per run; 16 is a buffer
|
|
53
|
+
* for a few extra `--emit`s in the same recency window, not a second
|
|
54
|
+
* full-table read.
|
|
55
|
+
*/
|
|
56
|
+
export const QUALITY_RECALL_SNAPSHOT_OVERFETCH = 16;
|
|
57
|
+
/**
|
|
58
|
+
* Harper REST collection path for the recall spot-check's sample fetch:
|
|
59
|
+
* agent-scoped, projected (never `embedding`), recency-sorted, bounded.
|
|
60
|
+
* `limit(start,end)` is Harper's offset window — same as
|
|
61
|
+
* packages/adk-flair-js/src/memory_service.ts.
|
|
62
|
+
*/
|
|
63
|
+
export function qualityRecallSamplePath(agentId, sampleSize = QUALITY_RECALL_SAMPLE_SIZE) {
|
|
64
|
+
const select = QUALITY_MEMORY_LIST_SELECT.join(",");
|
|
65
|
+
const end = sampleSize + QUALITY_RECALL_SNAPSHOT_OVERFETCH;
|
|
66
|
+
return `/Memory?agentId=${encodeURIComponent(agentId)}&select(${select})&sort(-createdAt)&limit(0,${end})`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Harper REST collection path for the previous quality-snapshot lookup:
|
|
70
|
+
* same projection as the sample fetch (never `embedding`). Subject is
|
|
71
|
+
* passed as a query equals (indexed) plus a client-side re-filter —
|
|
72
|
+
* Memory.search() historically did not turn bare query params into
|
|
73
|
+
* conditions beyond the signed agent scope, so the client-side filter
|
|
74
|
+
* in fetchPreviousQualitySnapshot stays as defense in depth. No `limit`:
|
|
75
|
+
* a bounded window could miss yesterday's snapshot after a busy day of
|
|
76
|
+
* writes, and without a reliable server-side subject pushdown that
|
|
77
|
+
* would silently look like a first run.
|
|
78
|
+
*/
|
|
79
|
+
export function qualitySnapshotLookupPath(agentId, subject) {
|
|
80
|
+
const select = QUALITY_MEMORY_LIST_SELECT.join(",");
|
|
81
|
+
return `/Memory?agentId=${encodeURIComponent(agentId)}&subject=${encodeURIComponent(subject)}&select(${select})&sort(-createdAt)`;
|
|
82
|
+
}
|
|
83
|
+
/** Leading-word cap on the content-derived cue. 25, matching the arm of the
|
|
84
|
+
* flair#967 A/B that was actually measured (same 10 memories, same instance,
|
|
85
|
+
* same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
|
|
86
|
+
* content cue → 1.00 / 0.78). Still a PARTIAL cue by construction — capped,
|
|
87
|
+
* never the whole memory for anything longer than the cap. */
|
|
88
|
+
const RECALL_CUE_CONTENT_WORD_LIMIT = 25;
|
|
89
|
+
/**
|
|
90
|
+
* Is `subject` DISCRIMINATIVE enough to be handed to semantic search as a
|
|
91
|
+
* query in its own right? (flair#967.)
|
|
92
|
+
*
|
|
93
|
+
* The old bar was `length >= 3`, which is a check on whether the subject
|
|
94
|
+
* EXISTS, not on whether it is a query. Measured consequence: slug-shaped
|
|
95
|
+
* subjects — `pr-1359`, `kern-2026-08-23`, the spot-check's own
|
|
96
|
+
* `quality-snapshot/127.0.0.1:9926` — carry almost no semantic signal, so
|
|
97
|
+
* searching one is a query for nothing in particular (searching `pr-1359` on
|
|
98
|
+
* rockit production returned, as top-1, a review note about PR #1275 from five
|
|
99
|
+
* days earlier). Worse, every memory sharing such a subject issues the
|
|
100
|
+
* IDENTICAL query and gets the IDENTICAL result list, so siblings must
|
|
101
|
+
* mutually displace each other and all but one are scored as misses no matter
|
|
102
|
+
* how healthy retrieval is.
|
|
103
|
+
*
|
|
104
|
+
* The rule, stated plainly — a subject is used as the cue only when it is:
|
|
105
|
+
* 1. at least 3 characters (the original bar, kept), AND
|
|
106
|
+
* 2. NOT opaque-identifier-shaped: an unspaced token carrying a digit or an
|
|
107
|
+
* identifier separator (`/ : _ . # @ \`) is a slug, not a phrase.
|
|
108
|
+
* Whitespace is the primary discriminator — `Harper 5.2 upgrade` is
|
|
109
|
+
* prose and stays a cue; `kern-2026-08-23` is not. A bare hyphen does
|
|
110
|
+
* NOT make a slug, so ordinary compounds (`two-gate`) survive, AND
|
|
111
|
+
* 3. carrying at least one alphabetic run of 3+ characters — a subject with
|
|
112
|
+
* no word in it (`---`, `42`) is not a query either.
|
|
113
|
+
*
|
|
114
|
+
* Fails CLOSED: anything that isn't clearly a phrase falls back to content,
|
|
115
|
+
* which the A/B measured as the strictly better cue. Pure — no I/O.
|
|
116
|
+
*/
|
|
117
|
+
export function isDiscriminativeSubject(subject) {
|
|
118
|
+
const s = (subject ?? "").trim();
|
|
119
|
+
if (s.length < 3)
|
|
120
|
+
return false;
|
|
121
|
+
if (!/\s/.test(s) && /[0-9/:_.#@\\]/.test(s))
|
|
122
|
+
return false;
|
|
123
|
+
if (!/[A-Za-z]{3}/.test(s))
|
|
124
|
+
return false;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Derive a PARTIAL search cue from a memory — used by the recall spot-check
|
|
129
|
+
* (Slice 1d) to query for a memory without handing back its full content.
|
|
130
|
+
* Prefers `subject` ONLY when it is discriminative (isDiscriminativeSubject
|
|
131
|
+
* above — flair#967); otherwise falls back to the first sentence of
|
|
132
|
+
* `content`, capped to the leading ~25 words so the cue stays a genuine
|
|
133
|
+
* partial cue rather than the whole memory. Pure — no I/O.
|
|
134
|
+
*/
|
|
135
|
+
export function deriveRecallCue(memory) {
|
|
136
|
+
const subject = (memory.subject ?? "").trim();
|
|
137
|
+
if (isDiscriminativeSubject(subject))
|
|
138
|
+
return subject;
|
|
139
|
+
const content = (memory.content ?? "").trim();
|
|
140
|
+
if (!content)
|
|
141
|
+
return "";
|
|
142
|
+
const sentenceMatch = content.match(/^[^.!?\n]+[.!?]?/);
|
|
143
|
+
const firstSentence = (sentenceMatch ? sentenceMatch[0] : content).trim();
|
|
144
|
+
const words = firstSentence.split(/\s+/).filter(Boolean);
|
|
145
|
+
const cueWordLimit = RECALL_CUE_CONTENT_WORD_LIMIT;
|
|
146
|
+
return words.length <= cueWordLimit ? firstSentence : words.slice(0, cueWordLimit).join(" ");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Pure scorer for the recall spot-check (Slice 1d): given the ids of the
|
|
150
|
+
* sampled memories and, for each, the list of memory ids its derived-cue
|
|
151
|
+
* search returned (already agent-scoped via the same read path `flair
|
|
152
|
+
* memory search` uses), compute recall@k + MRR. `perQueryResultIds[i]` is
|
|
153
|
+
* truncated to the first `k` entries here (not assumed pre-truncated by the
|
|
154
|
+
* caller) so a caller that over-fetches still gets a correct top-k score.
|
|
155
|
+
* Never throws; an empty sample scores 0/0 rather than dividing by zero —
|
|
156
|
+
* callers are expected to treat an empty sample as a `gaps` case, not a
|
|
157
|
+
* real 0.0 score (see fetchRecallSpotCheckData / computeQualityReport).
|
|
158
|
+
*/
|
|
159
|
+
export function computeRecallSpotCheck(sampledIds, perQueryResultIds, k) {
|
|
160
|
+
const sampleSize = sampledIds.length;
|
|
161
|
+
if (sampleSize === 0) {
|
|
162
|
+
return { recallAtK: 0, mrr: 0, sampleSize: 0, k };
|
|
163
|
+
}
|
|
164
|
+
let hits = 0;
|
|
165
|
+
let reciprocalSum = 0;
|
|
166
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
167
|
+
const targetId = sampledIds[i];
|
|
168
|
+
const topK = (perQueryResultIds[i] ?? []).slice(0, k);
|
|
169
|
+
const rank = topK.indexOf(targetId);
|
|
170
|
+
if (rank !== -1) {
|
|
171
|
+
hits += 1;
|
|
172
|
+
reciprocalSum += 1 / (rank + 1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
recallAtK: Math.round((hits / sampleSize) * 100) / 100,
|
|
177
|
+
mrr: Math.round((reciprocalSum / sampleSize) * 100) / 100,
|
|
178
|
+
sampleSize,
|
|
179
|
+
k,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Rows the spot-check writes itself, and therefore must never grade itself
|
|
183
|
+
* on — see RecallSpotCheckPlan['excludedSnapshotRows']. */
|
|
184
|
+
function isQualitySnapshotRow(m) {
|
|
185
|
+
return m?.type === "quality-snapshot" || (m?.subject ?? "").startsWith("quality-snapshot/");
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Pure planner for the recall spot-check: raw memory rows → the window to
|
|
189
|
+
* query (id + cue) plus that window's health. Extracted from
|
|
190
|
+
* fetchRecallSpotCheckData so the sampling, cue-derivation and
|
|
191
|
+
* fail-closed health rules are testable without any I/O (flair#967).
|
|
192
|
+
*
|
|
193
|
+
* Order of operations, and why:
|
|
194
|
+
* 1. drop archived rows (SemanticSearch excludes them — flair#857 — so a
|
|
195
|
+
* basemented row in the sample is a guaranteed miss, not a recall signal);
|
|
196
|
+
* 2. drop the tool's own quality-snapshot rows (never grade your own
|
|
197
|
+
* bookkeeping);
|
|
198
|
+
* 3. take the `sampleSize` most-recently-written remaining rows (unchanged —
|
|
199
|
+
* recency is still the sampling frame; see the issue's direction 3 for the
|
|
200
|
+
* stratified-sampling follow-up this deliberately does NOT take on);
|
|
201
|
+
* 4. derive each cue via deriveRecallCue;
|
|
202
|
+
* 5. judge the window: any duplicate cue, or any empty cue, makes it
|
|
203
|
+
* UNSCORABLE — reported as unhealthy, never silently scored.
|
|
204
|
+
*/
|
|
205
|
+
export function planRecallSpotCheck(memories, opts = {}) {
|
|
206
|
+
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
207
|
+
const rows = Array.isArray(memories) ? memories : [];
|
|
208
|
+
// `archived !== true` matches SemanticSearch / AdminMemory: unset and
|
|
209
|
+
// false stay in the live pool; only an explicit basement is dropped.
|
|
210
|
+
const live = rows.filter((m) => m?.archived !== true);
|
|
211
|
+
const scorable = live.filter((m) => !isQualitySnapshotRow(m ?? {}));
|
|
212
|
+
const excludedArchivedRows = rows.length - live.length;
|
|
213
|
+
const excludedSnapshotRows = live.length - scorable.length;
|
|
214
|
+
const sorted = scorable.slice().sort((a, b) => {
|
|
215
|
+
const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
216
|
+
const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
217
|
+
return tb - ta;
|
|
218
|
+
});
|
|
219
|
+
const sampled = sorted.slice(0, sampleSize).map((m) => ({ id: String(m?.id), cue: deriveRecallCue(m ?? {}) }));
|
|
220
|
+
const counts = new Map();
|
|
221
|
+
let emptyCueCount = 0;
|
|
222
|
+
for (const s of sampled) {
|
|
223
|
+
if (!s.cue) {
|
|
224
|
+
emptyCueCount += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
counts.set(s.cue, (counts.get(s.cue) ?? 0) + 1);
|
|
228
|
+
}
|
|
229
|
+
const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
|
|
230
|
+
if (duplicateCues.length === 0 && emptyCueCount === 0) {
|
|
231
|
+
return { sampled, health: { healthy: true }, excludedSnapshotRows, excludedArchivedRows };
|
|
232
|
+
}
|
|
233
|
+
const parts = [];
|
|
234
|
+
if (duplicateCues.length > 0) {
|
|
235
|
+
const shown = duplicateCues.slice(0, 3).map((c) => `"${c.length > 60 ? `${c.slice(0, 57)}...` : c}"`).join(", ");
|
|
236
|
+
const dupMemberCount = duplicateCues.reduce((n, c) => n + (counts.get(c) ?? 0), 0);
|
|
237
|
+
parts.push(`${dupMemberCount} of the ${sampled.length} sampled memories derive the same cue as another (${shown}${duplicateCues.length > 3 ? `, +${duplicateCues.length - 3} more` : ""}) — identical cues are one query with one result list, so those memories must displace each other and cannot all be found`);
|
|
238
|
+
}
|
|
239
|
+
if (emptyCueCount > 0) {
|
|
240
|
+
parts.push(`${emptyCueCount} of the ${sampled.length} sampled memories have no derivable cue (no subject and no content)`);
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
sampled,
|
|
244
|
+
health: {
|
|
245
|
+
healthy: false,
|
|
246
|
+
reason: `sample unhealthy — ${parts.join("; ")}. No score recorded for this run (flair#967: fail closed rather than publish an unscorable number).`,
|
|
247
|
+
duplicateCues,
|
|
248
|
+
emptyCueCount,
|
|
249
|
+
},
|
|
250
|
+
excludedSnapshotRows,
|
|
251
|
+
excludedArchivedRows,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Pure computation: /HealthDetail response (+ reachability) → quality report.
|
|
256
|
+
* Never throws — every missing data source degrades to a null section + a
|
|
257
|
+
* `gaps` entry (same graceful-degradation contract as `flair doctor`), so a
|
|
258
|
+
* partially-populated instance still gets a partial, honest report instead
|
|
259
|
+
* of a crash.
|
|
260
|
+
*
|
|
261
|
+
* `opts.recallSpotCheckData` is the ONE exception to "fed purely from
|
|
262
|
+
* /HealthDetail": the recall spot-check (Slice 1d) requires live queries
|
|
263
|
+
* (fetchRecallSpotCheckData, run by the `quality` command BEFORE calling
|
|
264
|
+
* here, same "I/O happens outside, this function only computes" split as
|
|
265
|
+
* fetchHealthDetail/computeQualityReport itself). Passing nothing degrades
|
|
266
|
+
* to a `gaps` entry, same as every other metric.
|
|
267
|
+
*/
|
|
268
|
+
export function computeQualityReport(healthy, healthData, opts = {}) {
|
|
269
|
+
const now = opts.now ?? Date.now();
|
|
270
|
+
const agentFilter = opts.agentId ?? null;
|
|
271
|
+
const gaps = [];
|
|
272
|
+
// ── Instance health: migrations ──
|
|
273
|
+
let migrationsClean = null;
|
|
274
|
+
let haltedMigrations = [];
|
|
275
|
+
const migList = healthData?.migrations?.migrations;
|
|
276
|
+
if (Array.isArray(migList)) {
|
|
277
|
+
haltedMigrations = migList
|
|
278
|
+
.filter((m) => m?.state === "halted" || m?.state === "failed")
|
|
279
|
+
.map((m) => ({ id: m.id, state: m.state, reason: m.reason }));
|
|
280
|
+
migrationsClean = haltedMigrations.length === 0;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
gaps.push({ metric: "instance.migrationsClean", reason: "no migrations block in /HealthDetail response" });
|
|
284
|
+
}
|
|
285
|
+
// ── Instance health: embeddings operational ──
|
|
286
|
+
// Inferred from stored coverage stats (hash-fallback %, mixed embedding
|
|
287
|
+
// models) — NOT a live semantic round-trip like `flair doctor` runs
|
|
288
|
+
// (verifySemanticSearch writes a probe memory to verify recall-by-meaning,
|
|
289
|
+
// which this read-only command must not do).
|
|
290
|
+
let embeddingsStatus = "unknown";
|
|
291
|
+
let embeddingsDetail = "no memory stats available";
|
|
292
|
+
const memories = healthData?.memories;
|
|
293
|
+
if (memories && typeof memories.total === "number") {
|
|
294
|
+
if (memories.total === 0) {
|
|
295
|
+
embeddingsDetail = "no memories written yet";
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const hashFallback = memories.hashFallback ?? 0;
|
|
299
|
+
const pct = Math.round((hashFallback / memories.total) * 100);
|
|
300
|
+
const modelCounts = (memories.modelCounts ?? {});
|
|
301
|
+
const migBlock = healthData?.migrations;
|
|
302
|
+
const stampRow = Array.isArray(migBlock?.migrations)
|
|
303
|
+
? migBlock.migrations.find((m) => m?.id === EMBEDDING_STAMP_ID)
|
|
304
|
+
: undefined;
|
|
305
|
+
const stamp = describeStampOutstanding({
|
|
306
|
+
modelCounts,
|
|
307
|
+
currentModelId: resolveCurrentModelId(modelCounts),
|
|
308
|
+
audience: "client",
|
|
309
|
+
cyclePhase: typeof migBlock?.cyclePhase === "string" ? migBlock.cyclePhase : undefined,
|
|
310
|
+
lastCycleError: typeof migBlock?.lastCycleError === "string"
|
|
311
|
+
? migBlock.lastCycleError
|
|
312
|
+
: migBlock?.lastCycleError === null
|
|
313
|
+
? null
|
|
314
|
+
: undefined,
|
|
315
|
+
migration: stampRow && typeof stampRow.state === "string"
|
|
316
|
+
? {
|
|
317
|
+
id: EMBEDDING_STAMP_ID,
|
|
318
|
+
state: stampRow.state,
|
|
319
|
+
rowsDone: stampRow.rowsDone,
|
|
320
|
+
rowsRemaining: stampRow.rowsRemaining,
|
|
321
|
+
reason: stampRow.reason,
|
|
322
|
+
}
|
|
323
|
+
: undefined,
|
|
324
|
+
});
|
|
325
|
+
if (pct >= QUALITY_HASH_FALLBACK_DEGRADED_PCT) {
|
|
326
|
+
embeddingsStatus = "degraded";
|
|
327
|
+
embeddingsDetail = `${hashFallback}/${memories.total} (${pct}%) memories are hash-fallback`;
|
|
328
|
+
}
|
|
329
|
+
else if (stamp.outstanding) {
|
|
330
|
+
embeddingsStatus = "degraded";
|
|
331
|
+
embeddingsDetail = stamp.warning;
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
embeddingsStatus = "ok";
|
|
335
|
+
embeddingsDetail = `${memories.total - hashFallback}/${memories.total} memories have real embeddings`;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// ── Embedding coverage ──
|
|
340
|
+
let embeddingCoverage = null;
|
|
341
|
+
if (memories && typeof memories.total === "number") {
|
|
342
|
+
const total = memories.total;
|
|
343
|
+
const hashFallback = memories.hashFallback ?? 0;
|
|
344
|
+
const withEmbeddings = typeof memories.withEmbeddings === "number" ? memories.withEmbeddings : Math.max(0, total - hashFallback);
|
|
345
|
+
const coveragePct = total > 0 ? Math.round((withEmbeddings / total) * 100) : 0;
|
|
346
|
+
embeddingCoverage = { total, withEmbeddings, hashFallback, coveragePct };
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
gaps.push({ metric: "embeddingCoverage", reason: "no memory stats available in /HealthDetail response" });
|
|
350
|
+
}
|
|
351
|
+
// ── Staleness (instance-wide only — see module doc above) ──
|
|
352
|
+
let staleness = null;
|
|
353
|
+
if (memories && typeof memories.total === "number" && typeof memories.expired === "number") {
|
|
354
|
+
const total = memories.total;
|
|
355
|
+
const expired = memories.expired;
|
|
356
|
+
const stalePct = total > 0 ? Math.round((expired / total) * 100) : 0;
|
|
357
|
+
staleness = { scope: "instance", total, expired, stalePct };
|
|
358
|
+
gaps.push({
|
|
359
|
+
metric: "staleness",
|
|
360
|
+
reason: "instance-wide only — /HealthDetail's `expired` count isn't broken down per agent, so --agent doesn't scope this metric; the \"old + never-recalled\" dead-weight variant also isn't computed (no read API exposes per-memory last-recalled data)",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
gaps.push({ metric: "staleness", reason: "no expired-memory count available in /HealthDetail response" });
|
|
365
|
+
}
|
|
366
|
+
// ── Per-agent rows (shared source for signal density + quiet agents) ──
|
|
367
|
+
const perAgentAll = Array.isArray(healthData?.agents?.perAgent) ? healthData.agents.perAgent : [];
|
|
368
|
+
const havePerAgent = Array.isArray(healthData?.agents?.perAgent);
|
|
369
|
+
const scopedAgents = agentFilter ? perAgentAll.filter((r) => r.id === agentFilter) : perAgentAll;
|
|
370
|
+
// Detected from the UNFILTERED rows (not scopedAgents) so `--agent` never
|
|
371
|
+
// masquerades server capability as data-scoping — vacuously true on an
|
|
372
|
+
// empty perAgent array (nothing to prove otherwise, and both scopes render
|
|
373
|
+
// identically empty either way).
|
|
374
|
+
const haveUsageCount = perAgentAll.every((r) => typeof r.usageCount === "number");
|
|
375
|
+
// ── Signal density (write volume, + citation rate when the server supports it) ──
|
|
376
|
+
let signalDensity = null;
|
|
377
|
+
if (havePerAgent && haveUsageCount) {
|
|
378
|
+
signalDensity = {
|
|
379
|
+
scope: "write-and-citation",
|
|
380
|
+
perAgent: scopedAgents.map((r) => {
|
|
381
|
+
const usageCount = r.usageCount ?? 0;
|
|
382
|
+
const citationRate = r.memoryCount > 0 ? Math.round((usageCount / r.memoryCount) * 100) / 100 : 0;
|
|
383
|
+
return { id: r.id, memoryCount: r.memoryCount, writes24h: r.writes24h, lastWriteAt: r.lastWriteAt, usageCount, citationRate };
|
|
384
|
+
}),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
else if (havePerAgent) {
|
|
388
|
+
signalDensity = {
|
|
389
|
+
scope: "write-volume",
|
|
390
|
+
perAgent: scopedAgents.map((r) => ({ id: r.id, memoryCount: r.memoryCount, writes24h: r.writes24h, lastWriteAt: r.lastWriteAt })),
|
|
391
|
+
};
|
|
392
|
+
gaps.push({
|
|
393
|
+
metric: "signalDensity",
|
|
394
|
+
reason: "citation rate unavailable — server predates per-agent usageCount in /HealthDetail; upgrade the server",
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
gaps.push({ metric: "signalDensity", reason: "no per-agent stats available in /HealthDetail response" });
|
|
399
|
+
}
|
|
400
|
+
// ── Quiet agents (ops fact — not a trust signal) ──
|
|
401
|
+
let quietAgents = null;
|
|
402
|
+
if (havePerAgent) {
|
|
403
|
+
const rows = scopedAgents.map((r) => {
|
|
404
|
+
const daysSinceLastWrite = r.lastWriteAt ? Math.floor((now - new Date(r.lastWriteAt).getTime()) / 86_400_000) : null;
|
|
405
|
+
const quiet = daysSinceLastWrite === null ? true : daysSinceLastWrite >= QUALITY_QUIET_THRESHOLD_DAYS;
|
|
406
|
+
return { id: r.id, memoryCount: r.memoryCount, writes24h: r.writes24h, lastWriteAt: r.lastWriteAt, daysSinceLastWrite, quiet };
|
|
407
|
+
});
|
|
408
|
+
quietAgents = { thresholdDays: QUALITY_QUIET_THRESHOLD_DAYS, perAgent: rows, quietCount: rows.filter((r) => r.quiet).length };
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
gaps.push({ metric: "quietAgents", reason: "no per-agent stats available in /HealthDetail response" });
|
|
412
|
+
}
|
|
413
|
+
// ── Dedup clusters (instance-wide near-duplicate count — flair-quality
|
|
414
|
+
// Slice 1c) — an ops/health signal, not a trust judgment (see module doc
|
|
415
|
+
// and QualityReport['dedupClusters'] doc above). Always instance-wide;
|
|
416
|
+
// --agent does not scope it (matches staleness's precedent — the
|
|
417
|
+
// underlying stat has no per-agent breakdown, by design: per-memory
|
|
418
|
+
// cluster membership is a disclosure surface Sherlock's review explicitly
|
|
419
|
+
// ruled out storing at all).
|
|
420
|
+
let dedupClusters = null;
|
|
421
|
+
const dedup = healthData?.dedup;
|
|
422
|
+
if (dedup &&
|
|
423
|
+
typeof dedup.clusterCount === "number" &&
|
|
424
|
+
typeof dedup.largestClusterSize === "number" &&
|
|
425
|
+
typeof dedup.totalMemoriesInClusters === "number" &&
|
|
426
|
+
typeof dedup.computedAt === "string") {
|
|
427
|
+
dedupClusters = {
|
|
428
|
+
clusterCount: dedup.clusterCount,
|
|
429
|
+
largestClusterSize: dedup.largestClusterSize,
|
|
430
|
+
totalMemoriesInClusters: dedup.totalMemoriesInClusters,
|
|
431
|
+
computedAt: dedup.computedAt,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
gaps.push({
|
|
436
|
+
metric: "dedupClusters",
|
|
437
|
+
reason: "no dedup-cluster stat yet — computed nightly by REM (see `flair rem nightly enable`); run `flair rem nightly run-once` or wait for the first scheduled cycle",
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// ── Recall spot-check (flair-quality Slice 1d) — see module doc above and
|
|
441
|
+
// QualityReport['recallSpotCheck'] doc for the full framing. Fed by
|
|
442
|
+
// fetchRecallSpotCheckData's already-fetched raw ids (not by healthData —
|
|
443
|
+
// this is the one metric here that needed a live query, not a
|
|
444
|
+
// /HealthDetail read); scored by the pure computeRecallSpotCheck.
|
|
445
|
+
let recallSpotCheck = null;
|
|
446
|
+
const rsc = opts.recallSpotCheckData;
|
|
447
|
+
if (rsc?.ok && rsc.sampledIds && rsc.perQueryResultIds && typeof rsc.k === "number") {
|
|
448
|
+
const scored = computeRecallSpotCheck(rsc.sampledIds, rsc.perQueryResultIds, rsc.k);
|
|
449
|
+
recallSpotCheck = { agentId: rsc.agentId ?? agentFilter ?? null, ...scored };
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
gaps.push({
|
|
453
|
+
metric: "recallSpotCheck",
|
|
454
|
+
reason: rsc?.skipReason ?? "recall spot-check not attempted — no data passed to computeQualityReport",
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
agentFilter,
|
|
459
|
+
instance: { up: healthy, migrationsClean, haltedMigrations, embeddingsStatus, embeddingsDetail },
|
|
460
|
+
embeddingCoverage,
|
|
461
|
+
staleness,
|
|
462
|
+
signalDensity,
|
|
463
|
+
quietAgents,
|
|
464
|
+
dedupClusters,
|
|
465
|
+
recallSpotCheck,
|
|
466
|
+
gaps,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* The I/O half of the recall spot-check (Slice 1d): fetch a sample of
|
|
471
|
+
* `agentId`'s own memories and, for each, search for a cue derived from it.
|
|
472
|
+
* Reuses the EXACT read path `flair memory search` / `flair memory list`
|
|
473
|
+
* use — `api()` (→ authedRequest's 5-tier resolver) for both the
|
|
474
|
+
* projected, bounded `GET /Memory?…&select(…)&limit(…)` sample fetch
|
|
475
|
+
* (flair#1360 — never the unfiltered collection with embeddings inline)
|
|
476
|
+
* and the `POST /SemanticSearch` queries — so this has zero new endpoint
|
|
477
|
+
* and zero new auth mechanism; it is scoped to `agentId`'s own memories
|
|
478
|
+
* exactly as those commands already are. Never throws: every failure mode
|
|
479
|
+
* (no agentId, fewer than `sampleSize` memories, a fetch/search error)
|
|
480
|
+
* returns `{ ok: false, skipReason }` for computeQualityReport to turn
|
|
481
|
+
* into a `gaps` entry.
|
|
482
|
+
*/
|
|
483
|
+
export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
484
|
+
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
485
|
+
const k = opts.k ?? QUALITY_RECALL_K;
|
|
486
|
+
const request = opts.request ?? api;
|
|
487
|
+
if (!agentId) {
|
|
488
|
+
return { ok: false, skipReason: "no agent identity to query as — pass --agent or set FLAIR_AGENT_ID" };
|
|
489
|
+
}
|
|
490
|
+
let all;
|
|
491
|
+
try {
|
|
492
|
+
const raw = await request("GET", qualityRecallSamplePath(agentId, sampleSize), undefined, { baseUrl, agentId });
|
|
493
|
+
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
|
|
497
|
+
}
|
|
498
|
+
// Deterministic sample + cue derivation + fail-closed health judgment, all
|
|
499
|
+
// pure (planRecallSpotCheck above). Archived rows (flair#857) and snapshot
|
|
500
|
+
// rows are excluded there, so the "enough memories" check has to run on the
|
|
501
|
+
// PLANNED window, not on the raw row count — an instance whose recent writes
|
|
502
|
+
// are mostly basemented or the sweep's own bookkeeping should skip with a
|
|
503
|
+
// reason, not score a short window.
|
|
504
|
+
const plan = planRecallSpotCheck(all, { sampleSize });
|
|
505
|
+
if (plan.sampled.length < sampleSize) {
|
|
506
|
+
const exclusionParts = [];
|
|
507
|
+
if (plan.excludedArchivedRows > 0) {
|
|
508
|
+
exclusionParts.push(`${plan.excludedArchivedRows} archived row(s) excluded — SemanticSearch cannot return basemented memories; restore with \`flair memory restore <id>\` if they should be live`);
|
|
509
|
+
}
|
|
510
|
+
if (plan.excludedSnapshotRows > 0) {
|
|
511
|
+
exclusionParts.push(`${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping`);
|
|
512
|
+
}
|
|
513
|
+
const excluded = exclusionParts.length > 0 ? ` (${exclusionParts.join("; ")})` : "";
|
|
514
|
+
return {
|
|
515
|
+
ok: false,
|
|
516
|
+
agentId,
|
|
517
|
+
skipReason: `agent '${agentId}' has ${plan.sampled.length} scorable memories, fewer than the ${sampleSize} needed to sample${excluded}`,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
// flair#967: a window whose cues collide cannot be scored fairly — report
|
|
521
|
+
// that fact instead of a number, and don't spend the searches either.
|
|
522
|
+
if (!plan.health.healthy) {
|
|
523
|
+
return { ok: false, agentId, skipReason: plan.health.reason, sampleHealth: plan.health };
|
|
524
|
+
}
|
|
525
|
+
const sampledIds = [];
|
|
526
|
+
const perQueryResultIds = [];
|
|
527
|
+
try {
|
|
528
|
+
for (const { id, cue } of plan.sampled) {
|
|
529
|
+
const body = { agentId, q: cue, limit: k };
|
|
530
|
+
const res = await request("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
531
|
+
const results = Array.isArray(res) ? res : (res?.results ?? []);
|
|
532
|
+
sampledIds.push(id);
|
|
533
|
+
perQueryResultIds.push(results.map((r) => String(r.id)));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
return { ok: false, agentId, skipReason: `recall spot-check search failed: ${err?.message ?? String(err)}` };
|
|
538
|
+
}
|
|
539
|
+
return { ok: true, agentId, sampledIds, perQueryResultIds, k, sampleHealth: plan.health };
|
|
540
|
+
}
|
|
541
|
+
// ─── flair quality --emit (Slice 2 of the memory-quality-observability arc:
|
|
542
|
+
// quality OrgEvents) ─────────────────────────────────────────────────────────
|
|
543
|
+
//
|
|
544
|
+
// Design (K&S-approved in the arc round, honored exactly here):
|
|
545
|
+
//
|
|
546
|
+
// - NO schema change, NO new table/resource. Events ride the existing
|
|
547
|
+
// OrgEvent surface (schemas/event.graphql: kind/scope/summary/detail/
|
|
548
|
+
// targetIds/refId — see `flair orgevent` above) via the exact same
|
|
549
|
+
// PUT /OrgEvent/{id} write shape, extracted into `publishOrgEvent()` below
|
|
550
|
+
// so both commands share one call site rather than two hand-rolled fetches.
|
|
551
|
+
// - The threshold/diff DECISION is CLI-side (diffQualitySnapshots, pure,
|
|
552
|
+
// fixture-tested below); emission is a thin write via that existing
|
|
553
|
+
// surface. Kind is one of two: `quality.threshold_crossed` (an absolute
|
|
554
|
+
// line was crossed since the last snapshot) or `quality.regression` (a
|
|
555
|
+
// metric moved backward by more than its delta threshold since the last
|
|
556
|
+
// snapshot) — see QualityEventFinding.
|
|
557
|
+
// - Snapshots are stored AS Flair memories (durability persistent, subject
|
|
558
|
+
// `quality-snapshot/<host>` — qualitySnapshotSubject() below) — free
|
|
559
|
+
// history + search, dogfoods the product, no new storage surface. Content
|
|
560
|
+
// is the COMPACT numeric core only (QualitySnapshotCore), never the full
|
|
561
|
+
// human report — see buildQualitySnapshot().
|
|
562
|
+
// - Sherlock's constraint: events carry BEHAVIORAL FACTS only, never trust
|
|
563
|
+
// judgments — "embedding coverage dropped to 85% (threshold 90%)", never
|
|
564
|
+
// "agent X is low quality". Every summary string below is written to that
|
|
565
|
+
// discipline; keep it that way in any future edit here.
|
|
566
|
+
// - Edge-triggered, not level-triggered: every threshold/regression check
|
|
567
|
+
// below fires only on the TRANSITION since the immediately-previous
|
|
568
|
+
// snapshot (e.g. quietAgents requires "was NOT quiet last snapshot, IS
|
|
569
|
+
// quiet now" — the spec's own "NEWLY quiet" wording), not on every run
|
|
570
|
+
// while a condition merely persists. Without this, a metric that stays
|
|
571
|
+
// below threshold across many `--emit` runs would re-emit an event every
|
|
572
|
+
// single run — pure noise. First run (no previous snapshot) therefore
|
|
573
|
+
// always emits nothing (diffQualitySnapshots(current, null) === []) — there
|
|
574
|
+
// is no prior state to diff against, so nothing can have "crossed" or
|
|
575
|
+
// "regressed" yet; that run only establishes the baseline.
|
|
576
|
+
// - Missing data (a null/gap section on either side of the diff) never
|
|
577
|
+
// produces an event — absence of data is a gap, not a regression. Encoded
|
|
578
|
+
// by requiring BOTH current and previous to carry a given metric before
|
|
579
|
+
// diffing it at all.
|
|
580
|
+
/** First-pass defaults for the Slice 2 diff/thresholds — same "documented
|
|
581
|
+
* heuristic, tunable later against a real fleet" spirit as
|
|
582
|
+
* QUALITY_QUIET_THRESHOLD_DAYS / QUALITY_HASH_FALLBACK_DEGRADED_PCT above.
|
|
583
|
+
* Deliberately NOT exposed as CLI flags (per the arc design: the diff
|
|
584
|
+
* decision stays CLI-side and legible in one place, not ad-hoc per
|
|
585
|
+
* invocation) — change these constants and their fixture tests together. */
|
|
586
|
+
export const QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT = 90;
|
|
587
|
+
export const QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT = 5;
|
|
588
|
+
export const QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT = 10;
|
|
589
|
+
/**
|
|
590
|
+
* RETAINED AT ITS ORIGINAL VALUE AND DELIBERATELY UNWIRED (flair#967).
|
|
591
|
+
*
|
|
592
|
+
* Nothing in diffQualitySnapshots reads this any more — the recall spot-check
|
|
593
|
+
* is report-only and emits no event at any delta (see the Slice 1d framing in
|
|
594
|
+
* the module doc for the 32-run σ = 0.291 / precision-0 measurement behind
|
|
595
|
+
* that). The constant stays, unchanged at 0.2, as the standing evidence that
|
|
596
|
+
* the fix was "remove alerting authority from a metric that never earned it",
|
|
597
|
+
* NOT "widen the gate until it stops talking" — a silenced check and a
|
|
598
|
+
* de-authorised one look identical in a changelog and are opposites in
|
|
599
|
+
* practice, and 0.2 sitting here at 0.69σ is the arithmetic that makes the
|
|
600
|
+
* difference legible. If this probe is ever re-armed, the replacement
|
|
601
|
+
* threshold must be DERIVED from the measured run-to-run variance of the
|
|
602
|
+
* FIXED cue derivation, not typed in — do not just re-reference this literal.
|
|
603
|
+
* Asserted unchanged by test/unit/quality-recall-spotcheck-967.test.ts.
|
|
604
|
+
*/
|
|
605
|
+
export const QUALITY_EVENT_RECALL_DROP_THRESHOLD = 0.2;
|
|
606
|
+
export const QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD = 0.5; // >50%
|
|
607
|
+
export const QUALITY_EVENT_DEDUP_GROWTH_ABS_THRESHOLD = 5; // AND by >= 5 clusters
|
|
608
|
+
/** Pure: full QualityReport → compact snapshot core. Any section that's
|
|
609
|
+
* `null` in the report (a gap) stays `null` in the snapshot — the diff step
|
|
610
|
+
* treats that as "no event", never a false 0. */
|
|
611
|
+
export function buildQualitySnapshot(report, computedAt) {
|
|
612
|
+
return {
|
|
613
|
+
schemaVersion: 1,
|
|
614
|
+
computedAt: computedAt ?? new Date().toISOString(),
|
|
615
|
+
agentFilter: report.agentFilter,
|
|
616
|
+
embeddingCoverage: report.embeddingCoverage ? { coveragePct: report.embeddingCoverage.coveragePct } : null,
|
|
617
|
+
staleness: report.staleness ? { stalePct: report.staleness.stalePct } : null,
|
|
618
|
+
recallSpotCheck: report.recallSpotCheck
|
|
619
|
+
? { recallAtK: report.recallSpotCheck.recallAtK, mrr: report.recallSpotCheck.mrr }
|
|
620
|
+
: null,
|
|
621
|
+
quietAgents: report.quietAgents
|
|
622
|
+
? { perAgent: report.quietAgents.perAgent.map((a) => ({ id: a.id, quiet: a.quiet, daysSinceLastWrite: a.daysSinceLastWrite })) }
|
|
623
|
+
: null,
|
|
624
|
+
dedupClusters: report.dedupClusters ? { clusterCount: report.dedupClusters.clusterCount } : null,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Pure diff: current snapshot + previous snapshot (or null on a first run)
|
|
629
|
+
* → the list of OrgEvent findings to emit. Never throws. See the module doc
|
|
630
|
+
* above for the edge-triggered / missing-data-means-no-event rules; fixture
|
|
631
|
+
* tests live in test/unit/quality-report.test.ts.
|
|
632
|
+
*/
|
|
633
|
+
export function diffQualitySnapshots(current, previous) {
|
|
634
|
+
const findings = [];
|
|
635
|
+
if (!previous)
|
|
636
|
+
return findings; // first run — nothing to diff against yet
|
|
637
|
+
// ── embedding coverage: absolute floor (edge-triggered) + delta drop ──
|
|
638
|
+
if (current.embeddingCoverage && previous.embeddingCoverage) {
|
|
639
|
+
const before = previous.embeddingCoverage.coveragePct;
|
|
640
|
+
const after = current.embeddingCoverage.coveragePct;
|
|
641
|
+
if (after < QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT && before >= QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT) {
|
|
642
|
+
findings.push({
|
|
643
|
+
kind: "quality.threshold_crossed",
|
|
644
|
+
scope: "quality",
|
|
645
|
+
summary: `embedding coverage dropped to ${after}% (threshold ${QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT}%)`,
|
|
646
|
+
detail: { metric: "embeddingCoverage.coveragePct", before, after, threshold: QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT },
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (before - after > QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT) {
|
|
650
|
+
findings.push({
|
|
651
|
+
kind: "quality.regression",
|
|
652
|
+
scope: "quality",
|
|
653
|
+
summary: `embedding coverage dropped ${before - after} points since last snapshot (${before}% → ${after}%)`,
|
|
654
|
+
detail: { metric: "embeddingCoverage.coveragePct", before, after, threshold: QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT },
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// ── staleness: absolute ceiling only (edge-triggered) ──
|
|
659
|
+
if (current.staleness && previous.staleness) {
|
|
660
|
+
const before = previous.staleness.stalePct;
|
|
661
|
+
const after = current.staleness.stalePct;
|
|
662
|
+
if (after > QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT && before <= QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT) {
|
|
663
|
+
findings.push({
|
|
664
|
+
kind: "quality.threshold_crossed",
|
|
665
|
+
scope: "quality",
|
|
666
|
+
summary: `staleness rose to ${after}% past validTo (threshold ${QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT}%)`,
|
|
667
|
+
detail: { metric: "staleness.stalePct", before, after, threshold: QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT },
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// ── recall spot-check: REPORT-ONLY, no branch here on purpose (flair#967) ──
|
|
672
|
+
//
|
|
673
|
+
// This metric used to emit two quality.regression events (recall@k and MRR,
|
|
674
|
+
// both at QUALITY_EVENT_RECALL_DROP_THRESHOLD). It no longer emits anything,
|
|
675
|
+
// at any delta. Measured, on rockit production:
|
|
676
|
+
//
|
|
677
|
+
// 32 nightly runs · population σ 0.291 · mean |run-to-run delta| 0.223
|
|
678
|
+
// threshold 0.2 → 0.69σ, i.e. BELOW the metric's own noise floor
|
|
679
|
+
// 6 findings-mails in 34 runs, replay-predicted 6/6 from these branches,
|
|
680
|
+
// all 6 oscillation → lifetime precision 0
|
|
681
|
+
//
|
|
682
|
+
// Removing an emission is not the same move as raising a threshold, and the
|
|
683
|
+
// distinction is the whole point: raising 0.2 would leave a check that still
|
|
684
|
+
// claims to detect recall regressions while detecting none, whereas this
|
|
685
|
+
// hands that job to the instrument that can actually do it — the
|
|
686
|
+
// deterministic, fixed-label, CI-gated eval in
|
|
687
|
+
// test/integration-heavy/recall-eval-gate.test.ts (test/bench/recall-eval),
|
|
688
|
+
// whose floors sit ≥2 whole queries below the measured value against a
|
|
689
|
+
// 0.000 noise band. QUALITY_EVENT_RECALL_DROP_THRESHOLD is left at 0.2,
|
|
690
|
+
// unwired, so that stays checkable rather than asserted.
|
|
691
|
+
//
|
|
692
|
+
// current.recallSpotCheck / previous.recallSpotCheck are still SNAPSHOTTED
|
|
693
|
+
// (buildQualitySnapshot above) — the history that made this diagnosis
|
|
694
|
+
// possible keeps accumulating, and `flair quality` still prints the number.
|
|
695
|
+
// ── quiet agents: per-agent, NEWLY quiet only (was false last snapshot,
|
|
696
|
+
// true now) — never re-fires for an agent that was already quiet last
|
|
697
|
+
// snapshot, and never fires for an agent absent from the previous snapshot
|
|
698
|
+
// (a brand-new agent can't have "regressed" from a state we never saw). ──
|
|
699
|
+
if (current.quietAgents && previous.quietAgents) {
|
|
700
|
+
const prevQuietById = new Map(previous.quietAgents.perAgent.map((a) => [a.id, a.quiet]));
|
|
701
|
+
for (const a of current.quietAgents.perAgent) {
|
|
702
|
+
if (a.quiet && prevQuietById.get(a.id) === false) {
|
|
703
|
+
const days = a.daysSinceLastWrite;
|
|
704
|
+
findings.push({
|
|
705
|
+
kind: "quality.threshold_crossed",
|
|
706
|
+
scope: "quality",
|
|
707
|
+
summary: days == null ? `agent ${a.id} quiet — no recorded write` : `agent ${a.id} quiet for ${days}d (threshold ${QUALITY_QUIET_THRESHOLD_DAYS}d)`,
|
|
708
|
+
detail: { metric: "quietAgents", before: false, after: true, threshold: QUALITY_QUIET_THRESHOLD_DAYS },
|
|
709
|
+
targetIds: [a.id],
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// ── dedup clusters: BOTH >50% relative growth AND >=5 absolute growth ──
|
|
715
|
+
if (current.dedupClusters && previous.dedupClusters) {
|
|
716
|
+
const before = previous.dedupClusters.clusterCount;
|
|
717
|
+
const after = current.dedupClusters.clusterCount;
|
|
718
|
+
const growth = after - before;
|
|
719
|
+
const growthPct = before > 0 ? growth / before : (after > 0 ? Infinity : 0);
|
|
720
|
+
if (growthPct > QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD && growth >= QUALITY_EVENT_DEDUP_GROWTH_ABS_THRESHOLD) {
|
|
721
|
+
findings.push({
|
|
722
|
+
kind: "quality.regression",
|
|
723
|
+
scope: "quality",
|
|
724
|
+
summary: `dedup cluster count grew from ${before} to ${after} since last snapshot`,
|
|
725
|
+
detail: { metric: "dedupClusters.clusterCount", before, after, threshold: QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD },
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return findings;
|
|
730
|
+
}
|
|
731
|
+
/** Subject convention for quality snapshots stored as Flair memories: one
|
|
732
|
+
* lineage per (agent, Flair instance) pair — an agent that runs
|
|
733
|
+
* `quality --emit` against more than one target gets independent diff
|
|
734
|
+
* baselines per target, keyed on HOST (not the full URL, so a bare port
|
|
735
|
+
* change on the same box doesn't fork the lineage; a different host/instance
|
|
736
|
+
* correctly starts its own). */
|
|
737
|
+
export function qualitySnapshotSubject(baseUrl) {
|
|
738
|
+
let host;
|
|
739
|
+
try {
|
|
740
|
+
host = new URL(baseUrl).host;
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
host = baseUrl.replace(/^[a-zA-Z]+:\/\//, "").replace(/\/.*$/, "");
|
|
744
|
+
}
|
|
745
|
+
return `quality-snapshot/${host}`;
|
|
746
|
+
}
|
|
747
|
+
/** Fetch the most recent prior quality snapshot for `agentId` at `baseUrl`,
|
|
748
|
+
* via the same signed `GET /Memory` read path fetchRecallSpotCheckData
|
|
749
|
+
* uses (self-scoped by the signed request's own agent identity — no new
|
|
750
|
+
* endpoint). Projects the same fields (never embeddings — flair#1360) and
|
|
751
|
+
* asks for `subject` as a query equals; still filters client-side by
|
|
752
|
+
* subject because Memory.search() historically did not turn bare query
|
|
753
|
+
* params into search conditions beyond the signed agentId scope (see
|
|
754
|
+
* resources/Memory.ts's search()), same client-side-filter pattern
|
|
755
|
+
* `memory list --hash-fallback` already uses. Returns null on: no prior
|
|
756
|
+
* snapshot, a fetch error, or a snapshot row whose content isn't
|
|
757
|
+
* parseable/versioned JSON (never throws — a corrupt or foreign row
|
|
758
|
+
* degrades to "no snapshot", same as a genuine first run, rather than
|
|
759
|
+
* crashing `--emit`). */
|
|
760
|
+
export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, opts = {}) {
|
|
761
|
+
const request = opts.request ?? api;
|
|
762
|
+
let all;
|
|
763
|
+
try {
|
|
764
|
+
const raw = await request("GET", qualitySnapshotLookupPath(agentId, subject), undefined, { baseUrl, agentId });
|
|
765
|
+
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
const matches = all.filter((m) => m?.subject === subject);
|
|
771
|
+
if (matches.length === 0)
|
|
772
|
+
return null;
|
|
773
|
+
matches.sort((a, b) => {
|
|
774
|
+
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
775
|
+
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
776
|
+
return tb - ta;
|
|
777
|
+
});
|
|
778
|
+
try {
|
|
779
|
+
const parsed = JSON.parse(matches[0].content);
|
|
780
|
+
if (parsed && typeof parsed === "object" && parsed.schemaVersion === 1)
|
|
781
|
+
return parsed;
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
/** Store `snapshot` as a new persistent-durability memory (subject
|
|
789
|
+
* `quality-snapshot/<host>`) — the diff baseline the NEXT `--emit` run reads
|
|
790
|
+
* back via fetchPreviousQualitySnapshot. Content is the compact JSON core
|
|
791
|
+
* only (buildQualitySnapshot's output), never the full human report. Same
|
|
792
|
+
* `PUT /Memory/{id}` write shape `memory write-task-summary` already uses.
|
|
793
|
+
* Throws on a write failure — the CLI action below is responsible for
|
|
794
|
+
* surfacing that as a clear error, same as every other write path here. */
|
|
795
|
+
async function storeQualitySnapshot(agentId, agentIdSource, baseUrl, subject, snapshot) {
|
|
796
|
+
const memId = `${agentId}-quality-snapshot-${Date.now()}`;
|
|
797
|
+
const body = {
|
|
798
|
+
id: memId,
|
|
799
|
+
agentId,
|
|
800
|
+
content: JSON.stringify(snapshot),
|
|
801
|
+
durability: "persistent",
|
|
802
|
+
tags: ["quality-snapshot"],
|
|
803
|
+
subject,
|
|
804
|
+
type: "quality-snapshot",
|
|
805
|
+
createdAt: new Date().toISOString(),
|
|
806
|
+
};
|
|
807
|
+
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId, agentIdSource });
|
|
808
|
+
if (out?.error)
|
|
809
|
+
throw new Error(String(out.error));
|
|
810
|
+
return memId;
|
|
811
|
+
}
|
|
812
|
+
// ─── flair quality ────────────────────────────────────────────────────────────
|
|
813
|
+
export function register(program) {
|
|
814
|
+
const __pkgVersion = cli.__pkgVersion;
|
|
815
|
+
// ─── flair quality — pure metric computation ─────────────────────────────────
|
|
816
|
+
// Slice 1a of the memory-quality-observability arc (ops/proposals/
|
|
817
|
+
// flair-quality-slice1-spec.md, K&S design-approved). Same "extract the pure
|
|
818
|
+
// decision logic" idiom as summarizeDoctorRun above (flair#721) and
|
|
819
|
+
// derivePresenceStatus (resources/Presence.ts): the CLI action spawns a
|
|
820
|
+
// network fetch + a long console.log sequence, which is high-effort/
|
|
821
|
+
// low-value to drive directly in a test — this is the actual metric math,
|
|
822
|
+
// fed a fixture-shaped /HealthDetail body.
|
|
823
|
+
//
|
|
824
|
+
// ZERO new server surface. Every field this reads already exists in
|
|
825
|
+
// resources/health.ts's response (the same one `flair status`/`flair doctor`
|
|
826
|
+
// consume) — no new query pattern, no new endpoint. That's what makes the
|
|
827
|
+
// Sherlock "read-scope holds by construction" argument hold: quality can
|
|
828
|
+
// never see anything status/doctor couldn't already see, because it reads
|
|
829
|
+
// the identical payload.
|
|
830
|
+
//
|
|
831
|
+
// One metric from the design doc is deliberately NOT computed at full
|
|
832
|
+
// fidelity here — it degrades gracefully into a `gaps` entry instead of
|
|
833
|
+
// failing:
|
|
834
|
+
// - staleness: /HealthDetail's `memories.expired` count is instance-wide
|
|
835
|
+
// only (resources/health.ts never buckets it per agent), so --agent
|
|
836
|
+
// does not scope this metric. The "old + never-recalled" variant isn't
|
|
837
|
+
// computed at all — no read API exposes per-memory last-recalled data.
|
|
838
|
+
//
|
|
839
|
+
// Slice 1b (flair-quality-slice1b): signal density now ALSO carries citation
|
|
840
|
+
// rate. Kern's design nod: extend /HealthDetail's per-agent aggregation
|
|
841
|
+
// server-side (resources/health.ts sums Memory.usageCount — a field already
|
|
842
|
+
// loaded by the existing memory loop, no new query) rather than have the CLI
|
|
843
|
+
// join against `GET /Memory` itself — keeps quality's read footprint
|
|
844
|
+
// identical to status/doctor's by construction. `citationRate` here is
|
|
845
|
+
// computed CLI-side from the two numbers the server hands back
|
|
846
|
+
// (usageCount/memoryCount), same "server aggregates, CLI computes" split as
|
|
847
|
+
// every other metric in this file.
|
|
848
|
+
//
|
|
849
|
+
// Backward compatibility: an OLDER server's /HealthDetail predates
|
|
850
|
+
// per-agent `usageCount` entirely (field is `undefined` on the row, not
|
|
851
|
+
// `0` — Slice 1a's payload literally didn't have the key). That's detected
|
|
852
|
+
// per-row and the whole report degrades to the Slice-1a write-volume-only
|
|
853
|
+
// shape + a gap note, rather than silently reporting citationRate 0 as if
|
|
854
|
+
// it were real data from a server that never sent it.
|
|
855
|
+
//
|
|
856
|
+
// Naming (Sherlock security finding on the design round, reaffirmed for
|
|
857
|
+
// citation rate): "signal density" / "citation rate" describes a USAGE
|
|
858
|
+
// PATTERN, never a trust/quality verdict. A low citation rate means "writes
|
|
859
|
+
// exploratory content that's rarely cited", not "noisy" or "untrustworthy"
|
|
860
|
+
// — same for "quiet agents": an ops fact (days since last write), not a
|
|
861
|
+
// trust signal. Keep that framing in any copy touching this code.
|
|
862
|
+
//
|
|
863
|
+
// Slice 1c (flair-quality-slice1c-dedup-spec.md, K&S-resolved to "Option C,
|
|
864
|
+
// server-side"): dedup-cluster count — how many near-duplicate memory
|
|
865
|
+
// CLUSTERS exist instance-wide. Sherlock's hard security line: embeddings
|
|
866
|
+
// are the most sensitive data in the system and must never leave the
|
|
867
|
+
// server, so — unlike every other metric in this file — the computation
|
|
868
|
+
// does NOT happen here. A nightly server-side REM step
|
|
869
|
+
// (resources/MemoryDedupStats.ts, wired into src/rem/runner.ts) computes it
|
|
870
|
+
// server-side via a bounded-k ANN sweep (reusing the HNSW-backed retrieval
|
|
871
|
+
// core) and persists ONLY the aggregate `{clusterCount, largestClusterSize,
|
|
872
|
+
// totalMemoriesInClusters, computedAt}` to a small server-side stat file.
|
|
873
|
+
// /HealthDetail does a CHEAP read of that file (resources/health.ts) —
|
|
874
|
+
// still zero new query pattern from the CLI's perspective, same "quality
|
|
875
|
+
// reads a precomputed number from /HealthDetail" contract as every other
|
|
876
|
+
// metric here. Nightly-stale by construction (only as fresh as the last
|
|
877
|
+
// REM cycle) — that's an accepted trade-off, not a bug: it's a "silting up"
|
|
878
|
+
// trend signal, not a real-time alert. Absent (fresh instance, REM never
|
|
879
|
+
// run, or an older server) degrades to `null` + a `gaps` entry, never a
|
|
880
|
+
// false zero.
|
|
881
|
+
//
|
|
882
|
+
// Slice 1d (memory-quality arc, self-referential design — no hardcoded canned
|
|
883
|
+
// queries): recall SPOT-CHECK. Unlike every metric above, this ISN'T read
|
|
884
|
+
// from /HealthDetail at all — it's the one metric in this file that requires
|
|
885
|
+
// live QUERIES, because it's checking whether querying itself still works.
|
|
886
|
+
// For a sample of the querying agent's OWN memories (fetchRecallSpotCheckData
|
|
887
|
+
// below, a projected+bounded GET /Memory — flair#1360: never the unfiltered
|
|
888
|
+
// collection with embeddings inline), a CUE is derived from each memory
|
|
889
|
+
// (deriveRecallCue — its `subject` if present, else the leading ~8 words /
|
|
890
|
+
// first sentence of `content`; a PARTIAL cue, never the full content) and
|
|
891
|
+
// searched for through the EXACT SAME authenticated read path `flair memory
|
|
892
|
+
// search` uses: `api("POST", "/SemanticSearch", ...)`, which resolves auth
|
|
893
|
+
// via the shared authedRequest() 5-tier resolver (src/lib/auth-resolve.ts) —
|
|
894
|
+
// identical code path, so read-scope holds by construction (no new
|
|
895
|
+
// endpoint, no cross-agent/private data, scoped to the querying agent's own
|
|
896
|
+
// memories same as `flair memory search` always was). computeRecallSpotCheck
|
|
897
|
+
// (below) is the pure scorer: recall@k = fraction of sampled memories whose
|
|
898
|
+
// own id appears in its search's top-k; MRR = mean reciprocal rank (0 if
|
|
899
|
+
// not found within k).
|
|
900
|
+
//
|
|
901
|
+
// Framing — this is a REPORT-ONLY HEALTH SPOT-CHECK, not a benchmark, not a
|
|
902
|
+
// trust judgment, and (since flair#967) not an alerting signal either.
|
|
903
|
+
// Querying by a cue derived FROM the target memory is easier than a real
|
|
904
|
+
// user query, so a high score means "recall is functioning", not "recall is
|
|
905
|
+
// optimal". NOTE (#1216): this cue-from-the-memory design is self-polluting
|
|
906
|
+
// as a recall-QUALITY metric — relevance is query/corpus overlap by
|
|
907
|
+
// construction, so near-duplicate density reads as a recall collapse
|
|
908
|
+
// (flair#967 / #857 / #996). It is deliberately NOT the recall-quality
|
|
909
|
+
// number; that authority is the deterministic, fixed-label, CI-gated eval at
|
|
910
|
+
// test/bench/recall-eval, wired as a gate in
|
|
911
|
+
// test/integration-heavy/recall-eval-gate.test.ts.
|
|
912
|
+
//
|
|
913
|
+
// flair#967 — WHY THIS METRIC NO LONGER EMITS AN EVENT. Measured on rockit
|
|
914
|
+
// production over 32 nightly runs: population σ = 0.291, mean absolute
|
|
915
|
+
// run-to-run delta = 0.223, against a QUALITY_EVENT_RECALL_DROP_THRESHOLD of
|
|
916
|
+
// 0.2. The alarm sat at 0.69σ — BELOW the metric's own noise floor, so the
|
|
917
|
+
// median night-to-night wobble already exceeded the delta that declared a
|
|
918
|
+
// regression. Replaying diffQualitySnapshots over the stored snapshot series
|
|
919
|
+
// predicts the sweep's 6 findings-mails in 34 runs exactly, 6 for 6, and all
|
|
920
|
+
// six were oscillation: lifetime precision 0. So the emission is gone. The
|
|
921
|
+
// score is still computed, still printed, still snapshotted (history and the
|
|
922
|
+
// cratering signal are both preserved) — it just no longer has the authority
|
|
923
|
+
// to page anyone, because it never once earned it. That authority stays with
|
|
924
|
+
// the deterministic CI gate above, which is fixed-label, hermetic and
|
|
925
|
+
// actually detects ranking regressions. Re-arming this probe is a data
|
|
926
|
+
// question, not a taste question: it needs a measured precision on the FIXED
|
|
927
|
+
// cue derivation first, and a threshold DERIVED from that run-to-run variance
|
|
928
|
+
// (≥2σ on the sample design), not another literal.
|
|
929
|
+
//
|
|
930
|
+
// Requires an actual agent identity to query AS (semantic search is
|
|
931
|
+
// agent-scoped) — no identity, fewer than the sample-size memories to sample,
|
|
932
|
+
// an UNHEALTHY sample (planRecallSpotCheck below: duplicate or empty cues, so
|
|
933
|
+
// the window cannot be scored fairly) or a search error all degrade to `null`
|
|
934
|
+
// + a `gaps` entry, same graceful-degradation contract as every metric here —
|
|
935
|
+
// NEVER a false 0.0 masquerading as a real (broken) score, and never a number
|
|
936
|
+
// quietly computed over a window that could not produce one.
|
|
937
|
+
/** First-pass default, same "documented heuristic, not derived from data we
|
|
938
|
+
* don't have" spirit as health.ts's own 10%-hash-fallback threshold below.
|
|
939
|
+
* Tunable later if a real fleet shows this is too loud/quiet. */
|
|
940
|
+
program
|
|
941
|
+
.command("quality")
|
|
942
|
+
.description("Memory-quality report: embedding coverage, staleness, signal density, quiet agents, recall spot-check (read-only)")
|
|
943
|
+
.option("--port <port>", "Harper HTTP port")
|
|
944
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
945
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
946
|
+
.option("--json", "Output as JSON")
|
|
947
|
+
.option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
|
|
948
|
+
.option("--emit", "Slice 2: snapshot this report, diff it against the previous quality-snapshot memory, and emit OrgEvents (quality.threshold_crossed / quality.regression) for any crossings/regressions found. Requires an agent identity (--agent or FLAIR_AGENT_ID) — the opt-in write boundary; without this flag `flair quality` remains fully read-only")
|
|
949
|
+
.action(async (opts) => {
|
|
950
|
+
const { agentId, source } = resolveSigningAgentId(opts, "quality");
|
|
951
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId, source);
|
|
952
|
+
if (opts.emit && !agentId) {
|
|
953
|
+
console.error("Error: --emit requires an agent identity. Pass --agent <id> or set FLAIR_AGENT_ID.");
|
|
954
|
+
process.exit(1);
|
|
955
|
+
}
|
|
956
|
+
// Recall spot-check needs live queries (not just /HealthDetail), so only
|
|
957
|
+
// attempt it when the instance is actually reachable — no point probing
|
|
958
|
+
// memory reads against a server fetchHealthDetail already found down.
|
|
959
|
+
const recallSpotCheckData = healthy
|
|
960
|
+
? await fetchRecallSpotCheckData(agentId, baseUrl)
|
|
961
|
+
: { ok: false, skipReason: "instance unreachable" };
|
|
962
|
+
const report = computeQualityReport(healthy, healthData, { agentId, recallSpotCheckData });
|
|
963
|
+
// ── Slice 2: --emit is the opt-in write boundary. Everything above this
|
|
964
|
+
// point is unchanged from pre-Slice-2 behavior; everything in this block
|
|
965
|
+
// only runs when the flag is passed AND the instance is reachable (an
|
|
966
|
+
// unreachable instance has nothing to diff against and no live write
|
|
967
|
+
// target — it falls through to the existing "unreachable" exit(1) below,
|
|
968
|
+
// same as always). ──
|
|
969
|
+
let emitResult = null;
|
|
970
|
+
if (opts.emit && healthy && agentId) {
|
|
971
|
+
const subject = qualitySnapshotSubject(baseUrl);
|
|
972
|
+
const previous = await fetchPreviousQualitySnapshot(agentId, baseUrl, subject);
|
|
973
|
+
const current = buildQualitySnapshot(report);
|
|
974
|
+
const findings = diffQualitySnapshots(current, previous); // [] on a first run (previous === null)
|
|
975
|
+
emitResult = { firstRun: previous === null, emittedEvents: [], snapshotId: null, errors: [] };
|
|
976
|
+
for (const finding of findings) {
|
|
977
|
+
const published = await publishOrgEvent({
|
|
978
|
+
agentId,
|
|
979
|
+
baseUrl,
|
|
980
|
+
kind: finding.kind,
|
|
981
|
+
scope: finding.scope,
|
|
982
|
+
summary: finding.summary,
|
|
983
|
+
detail: JSON.stringify(finding.detail),
|
|
984
|
+
targetIds: finding.targetIds,
|
|
985
|
+
});
|
|
986
|
+
if (published.ok) {
|
|
987
|
+
emitResult.emittedEvents.push({ ...finding, orgEventId: published.id });
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
emitResult.errors.push(`${finding.kind} (${finding.detail.metric}): ${published.error}`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
emitResult.snapshotId = await storeQualitySnapshot(agentId, source, baseUrl, subject, current);
|
|
995
|
+
}
|
|
996
|
+
catch (err) {
|
|
997
|
+
emitResult.errors.push(`snapshot store failed: ${err?.message ?? String(err)}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const mode = render.resolveOutputMode(opts);
|
|
1001
|
+
if (mode === "json") {
|
|
1002
|
+
const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...report };
|
|
1003
|
+
// flair#967: when a window was assembled, say whether it was scorable —
|
|
1004
|
+
// structurally, not only as prose inside a `gaps` reason. An unhealthy
|
|
1005
|
+
// sample is a FACT ABOUT THE RUN that a consumer must be able to read
|
|
1006
|
+
// without string-matching.
|
|
1007
|
+
if (recallSpotCheckData.sampleHealth)
|
|
1008
|
+
out.recallSampleHealth = recallSpotCheckData.sampleHealth;
|
|
1009
|
+
if (emitResult) {
|
|
1010
|
+
out.emit = { firstRun: emitResult.firstRun, snapshotId: emitResult.snapshotId, errors: emitResult.errors };
|
|
1011
|
+
out.emittedEvents = emitResult.emittedEvents.map((e) => ({
|
|
1012
|
+
kind: e.kind,
|
|
1013
|
+
scope: e.scope,
|
|
1014
|
+
summary: e.summary,
|
|
1015
|
+
detail: e.detail,
|
|
1016
|
+
targetIds: e.targetIds,
|
|
1017
|
+
orgEventId: e.orgEventId,
|
|
1018
|
+
}));
|
|
1019
|
+
}
|
|
1020
|
+
console.log(render.asJSON(out));
|
|
1021
|
+
if (!healthy)
|
|
1022
|
+
process.exit(1);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (!healthy) {
|
|
1026
|
+
console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
|
|
1027
|
+
console.log(` URL: ${baseUrl}`);
|
|
1028
|
+
console.log(`\n Run: flair start or flair doctor`);
|
|
1029
|
+
process.exit(1);
|
|
1030
|
+
}
|
|
1031
|
+
const scopeLabel = agentId ? ` ${render.wrap(render.c.dim, `(agent: ${agentId})`)}` : "";
|
|
1032
|
+
console.log(`${render.wrap(render.c.bold, "Flair quality report")}${scopeLabel}`);
|
|
1033
|
+
console.log(render.kv("URL", baseUrl));
|
|
1034
|
+
// Instance health
|
|
1035
|
+
console.log(`\n${render.wrap(render.c.bold, "Instance health")}`);
|
|
1036
|
+
console.log(render.kv("Up", report.instance.up ? `${render.icons.ok} yes` : `${render.icons.error} no`));
|
|
1037
|
+
if (report.instance.migrationsClean === null) {
|
|
1038
|
+
console.log(render.kv("Migrations", `${render.icons.info} unknown ${render.wrap(render.c.dim, "(no data)")}`));
|
|
1039
|
+
}
|
|
1040
|
+
else if (report.instance.migrationsClean) {
|
|
1041
|
+
console.log(render.kv("Migrations", `${render.icons.ok} clean`));
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
console.log(render.kv("Migrations", `${render.icons.error} ${report.instance.haltedMigrations.length} halted/failed`));
|
|
1045
|
+
for (const m of report.instance.haltedMigrations) {
|
|
1046
|
+
console.log(` ${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
const embIcon = report.instance.embeddingsStatus === "ok" ? render.icons.ok :
|
|
1050
|
+
report.instance.embeddingsStatus === "degraded" ? render.icons.error :
|
|
1051
|
+
render.icons.info;
|
|
1052
|
+
console.log(render.kv("Embeddings", `${embIcon} ${report.instance.embeddingsStatus} ${render.wrap(render.c.dim, `(${report.instance.embeddingsDetail})`)}`));
|
|
1053
|
+
// Embedding coverage
|
|
1054
|
+
if (report.embeddingCoverage) {
|
|
1055
|
+
const ec = report.embeddingCoverage;
|
|
1056
|
+
console.log(`\n${render.wrap(render.c.bold, "Embedding coverage")}`);
|
|
1057
|
+
console.log(render.kv("Coverage", `${render.wrap(render.c.bold, `${ec.coveragePct}%`)} ${render.wrap(render.c.dim, `(${ec.withEmbeddings}/${ec.total} real, ${ec.hashFallback} hash-fallback)`)}`));
|
|
1058
|
+
}
|
|
1059
|
+
// Staleness
|
|
1060
|
+
if (report.staleness) {
|
|
1061
|
+
const st = report.staleness;
|
|
1062
|
+
console.log(`\n${render.wrap(render.c.bold, "Staleness")}`);
|
|
1063
|
+
console.log(render.kv("Past validTo", `${render.wrap(render.c.bold, `${st.stalePct}%`)} ${render.wrap(render.c.dim, `(${st.expired}/${st.total}, instance-wide)`)}`));
|
|
1064
|
+
}
|
|
1065
|
+
// Signal density
|
|
1066
|
+
if (report.signalDensity) {
|
|
1067
|
+
console.log(`\n${render.wrap(render.c.bold, "Signal density")} ${render.wrap(render.c.dim, "(write + citation activity — a usage pattern, not a trust signal)")}`);
|
|
1068
|
+
if (agentId && report.signalDensity.perAgent.length === 0) {
|
|
1069
|
+
console.log(` ${render.icons.info} no data for agent '${agentId}'`);
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
const showCitation = report.signalDensity.scope === "write-and-citation";
|
|
1073
|
+
const cols = [
|
|
1074
|
+
{ label: "id", key: "id" },
|
|
1075
|
+
{ label: "memories", key: "memoryCount", align: "right" },
|
|
1076
|
+
{ label: "writes_24h", key: "writes24h", align: "right" },
|
|
1077
|
+
...(showCitation
|
|
1078
|
+
? [
|
|
1079
|
+
{ label: "citations", key: "usageCount", align: "right" },
|
|
1080
|
+
{ label: "citation_rate", key: "citationRate", align: "right" },
|
|
1081
|
+
]
|
|
1082
|
+
: []),
|
|
1083
|
+
{ label: "last_write", key: "lastWriteAt", format: (v) => render.relativeTime(v) },
|
|
1084
|
+
];
|
|
1085
|
+
console.log(render.table(cols, report.signalDensity.perAgent));
|
|
1086
|
+
if (showCitation) {
|
|
1087
|
+
console.log(` ${render.wrap(render.c.dim, "citation_rate = avg citations per memory; a low rate means \"writes exploratory content that's rarely cited\", not \"noisy\"")}`);
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
console.log(` ${render.wrap(render.c.dim, "citation rate not shown — server predates per-agent usageCount in /HealthDetail (see Gaps); low write volume means \"writes exploratory content\", not \"noisy\"")}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
// Quiet agents
|
|
1095
|
+
if (report.quietAgents) {
|
|
1096
|
+
console.log(`\n${render.wrap(render.c.bold, "Quiet agents")} ${render.wrap(render.c.dim, `(no write in ${report.quietAgents.thresholdDays}+ days — an ops fact, not a trust signal)`)}`);
|
|
1097
|
+
if (agentId && report.quietAgents.perAgent.length === 0) {
|
|
1098
|
+
console.log(` ${render.icons.info} no data for agent '${agentId}'`);
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
const quiet = report.quietAgents.perAgent.filter((r) => r.quiet);
|
|
1102
|
+
if (quiet.length === 0) {
|
|
1103
|
+
console.log(` ${render.icons.ok} none`);
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
for (const r of quiet) {
|
|
1107
|
+
const label = r.daysSinceLastWrite == null ? "never written" : `quiet for ${r.daysSinceLastWrite}d`;
|
|
1108
|
+
console.log(` ${render.icons.warn} ${r.id} — ${label}`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Dedup clusters (flair-quality Slice 1c) — an ops/health signal, not a
|
|
1114
|
+
// trust judgment. Labeled with the nightly REM run that produced it, per
|
|
1115
|
+
// spec, since it's only ever as fresh as the last nightly cycle.
|
|
1116
|
+
if (report.dedupClusters) {
|
|
1117
|
+
const dc = report.dedupClusters;
|
|
1118
|
+
console.log(`\n${render.wrap(render.c.bold, "Dedup clusters")} ${render.wrap(render.c.dim, `(as of last REM run ${render.relativeTime(dc.computedAt)}, ${dc.computedAt})`)}`);
|
|
1119
|
+
console.log(render.kv("Clusters", `${render.wrap(render.c.bold, String(dc.clusterCount))} ${render.wrap(render.c.dim, `(${dc.totalMemoriesInClusters} memories, largest cluster ${dc.largestClusterSize})`)}`));
|
|
1120
|
+
console.log(` ${render.wrap(render.c.dim, "an ops signal — near-duplicate memories piling up, not a trust judgment")}`);
|
|
1121
|
+
}
|
|
1122
|
+
// Recall spot-check (flair-quality Slice 1d) — a REPORT-ONLY health
|
|
1123
|
+
// spot-check: not a benchmark, not a trust judgment, and since flair#967
|
|
1124
|
+
// not an alerting signal either. See QualityReport['recallSpotCheck'] doc
|
|
1125
|
+
// and the Slice 1d module doc for the full framing.
|
|
1126
|
+
if (report.recallSpotCheck) {
|
|
1127
|
+
const rc = report.recallSpotCheck;
|
|
1128
|
+
console.log(`\n${render.wrap(render.c.bold, "Recall spot-check")} ${render.wrap(render.c.dim, `(agent ${rc.agentId ?? "—"}, report-only — not a benchmark, not an alert)`)}`);
|
|
1129
|
+
console.log(render.kv(`recall@${rc.k}`, `${render.wrap(render.c.bold, rc.recallAtK.toFixed(2))} ${render.wrap(render.c.dim, `(MRR ${rc.mrr.toFixed(2)}, ${rc.sampleSize} sampled)`)}`));
|
|
1130
|
+
console.log(` ${render.wrap(render.c.dim, "observability only — recall REGRESSIONS are detected by the deterministic CI gate (test/bench/recall-eval), not by this number")}`);
|
|
1131
|
+
}
|
|
1132
|
+
// Gaps
|
|
1133
|
+
if (report.gaps.length > 0) {
|
|
1134
|
+
console.log(`\n${render.wrap(render.c.bold, "Gaps")} ${render.wrap(render.c.dim, "(degraded or unavailable from existing read APIs)")}`);
|
|
1135
|
+
for (const g of report.gaps) {
|
|
1136
|
+
console.log(` ${render.icons.info} ${g.metric}: ${g.reason}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
// Events (flair-quality Slice 2 — only present when --emit was passed)
|
|
1140
|
+
if (emitResult) {
|
|
1141
|
+
console.log(`\n${render.wrap(render.c.bold, "Events")} ${render.wrap(render.c.dim, "(--emit: snapshot + diff against the previous quality-snapshot memory)")}`);
|
|
1142
|
+
if (emitResult.firstRun) {
|
|
1143
|
+
console.log(` ${render.icons.info} first run — no prior snapshot to diff against; stored a baseline, emitted nothing`);
|
|
1144
|
+
}
|
|
1145
|
+
else if (emitResult.emittedEvents.length === 0) {
|
|
1146
|
+
console.log(` ${render.icons.ok} no threshold crossings or regressions since the last snapshot`);
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
console.log(` ${render.wrap(render.c.bold, String(emitResult.emittedEvents.length))} event(s) emitted:`);
|
|
1150
|
+
for (const e of emitResult.emittedEvents) {
|
|
1151
|
+
const icon = e.kind === "quality.regression" ? render.icons.warn : render.icons.info;
|
|
1152
|
+
console.log(` ${icon} [${e.kind}] ${e.summary}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (emitResult.snapshotId) {
|
|
1156
|
+
console.log(` ${render.wrap(render.c.dim, `snapshot stored: ${emitResult.snapshotId}`)}`);
|
|
1157
|
+
}
|
|
1158
|
+
for (const err of emitResult.errors) {
|
|
1159
|
+
console.log(` ${render.icons.error} ${err}`);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
console.log("");
|
|
1163
|
+
});
|
|
1164
|
+
}
|