@tpsdev-ai/flair 0.25.4 → 0.26.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/dist/cli.js +564 -0
- package/dist/rem/runner.js +60 -3
- package/dist/resources/MemoryDedupStats.js +161 -0
- package/dist/resources/dedup-cluster.js +145 -0
- package/dist/resources/health.js +33 -1
- package/dist/resources/mcp-oauth.js +4 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6432,6 +6432,12 @@ remNightly
|
|
|
6432
6432
|
if (row.candidates) {
|
|
6433
6433
|
console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
|
|
6434
6434
|
}
|
|
6435
|
+
// row.dedup populates when step 6 (instance-wide dedup-cluster stat,
|
|
6436
|
+
// flair-quality Slice 1c) succeeded this cycle. Absent on dry-run skip
|
|
6437
|
+
// or a non-fatal failure (see Errors below — e.g. non-admin caller).
|
|
6438
|
+
if (row.dedup) {
|
|
6439
|
+
console.log(`Dedup: ${row.dedup.clusterCount} cluster${row.dedup.clusterCount === 1 ? "" : "s"} (${row.dedup.totalMemoriesInClusters} memories, largest ${row.dedup.largestClusterSize})`);
|
|
6440
|
+
}
|
|
6435
6441
|
console.log(`Duration: ${row.durationMs}ms`);
|
|
6436
6442
|
if (row.errors.length > 0) {
|
|
6437
6443
|
console.log(`Errors:`);
|
|
@@ -10148,6 +10154,564 @@ program
|
|
|
10148
10154
|
if (summary.exitCode !== 0)
|
|
10149
10155
|
process.exit(summary.exitCode);
|
|
10150
10156
|
});
|
|
10157
|
+
// ─── flair quality — pure metric computation ─────────────────────────────────
|
|
10158
|
+
// Slice 1a of the memory-quality-observability arc (ops/proposals/
|
|
10159
|
+
// flair-quality-slice1-spec.md, K&S design-approved). Same "extract the pure
|
|
10160
|
+
// decision logic" idiom as summarizeDoctorRun above (flair#721) and
|
|
10161
|
+
// derivePresenceStatus (resources/Presence.ts): the CLI action spawns a
|
|
10162
|
+
// network fetch + a long console.log sequence, which is high-effort/
|
|
10163
|
+
// low-value to drive directly in a test — this is the actual metric math,
|
|
10164
|
+
// fed a fixture-shaped /HealthDetail body.
|
|
10165
|
+
//
|
|
10166
|
+
// ZERO new server surface. Every field this reads already exists in
|
|
10167
|
+
// resources/health.ts's response (the same one `flair status`/`flair doctor`
|
|
10168
|
+
// consume) — no new query pattern, no new endpoint. That's what makes the
|
|
10169
|
+
// Sherlock "read-scope holds by construction" argument hold: quality can
|
|
10170
|
+
// never see anything status/doctor couldn't already see, because it reads
|
|
10171
|
+
// the identical payload.
|
|
10172
|
+
//
|
|
10173
|
+
// One metric from the design doc is deliberately NOT computed at full
|
|
10174
|
+
// fidelity here — it degrades gracefully into a `gaps` entry instead of
|
|
10175
|
+
// failing:
|
|
10176
|
+
// - staleness: /HealthDetail's `memories.expired` count is instance-wide
|
|
10177
|
+
// only (resources/health.ts never buckets it per agent), so --agent
|
|
10178
|
+
// does not scope this metric. The "old + never-recalled" variant isn't
|
|
10179
|
+
// computed at all — no read API exposes per-memory last-recalled data.
|
|
10180
|
+
//
|
|
10181
|
+
// Slice 1b (flair-quality-slice1b): signal density now ALSO carries citation
|
|
10182
|
+
// rate. Kern's design nod: extend /HealthDetail's per-agent aggregation
|
|
10183
|
+
// server-side (resources/health.ts sums Memory.usageCount — a field already
|
|
10184
|
+
// loaded by the existing memory loop, no new query) rather than have the CLI
|
|
10185
|
+
// join against `GET /Memory` itself — keeps quality's read footprint
|
|
10186
|
+
// identical to status/doctor's by construction. `citationRate` here is
|
|
10187
|
+
// computed CLI-side from the two numbers the server hands back
|
|
10188
|
+
// (usageCount/memoryCount), same "server aggregates, CLI computes" split as
|
|
10189
|
+
// every other metric in this file.
|
|
10190
|
+
//
|
|
10191
|
+
// Backward compatibility: an OLDER server's /HealthDetail predates
|
|
10192
|
+
// per-agent `usageCount` entirely (field is `undefined` on the row, not
|
|
10193
|
+
// `0` — Slice 1a's payload literally didn't have the key). That's detected
|
|
10194
|
+
// per-row and the whole report degrades to the Slice-1a write-volume-only
|
|
10195
|
+
// shape + a gap note, rather than silently reporting citationRate 0 as if
|
|
10196
|
+
// it were real data from a server that never sent it.
|
|
10197
|
+
//
|
|
10198
|
+
// Naming (Sherlock security finding on the design round, reaffirmed for
|
|
10199
|
+
// citation rate): "signal density" / "citation rate" describes a USAGE
|
|
10200
|
+
// PATTERN, never a trust/quality verdict. A low citation rate means "writes
|
|
10201
|
+
// exploratory content that's rarely cited", not "noisy" or "untrustworthy"
|
|
10202
|
+
// — same for "quiet agents": an ops fact (days since last write), not a
|
|
10203
|
+
// trust signal. Keep that framing in any copy touching this code.
|
|
10204
|
+
//
|
|
10205
|
+
// Slice 1c (flair-quality-slice1c-dedup-spec.md, K&S-resolved to "Option C,
|
|
10206
|
+
// server-side"): dedup-cluster count — how many near-duplicate memory
|
|
10207
|
+
// CLUSTERS exist instance-wide. Sherlock's hard security line: embeddings
|
|
10208
|
+
// are the most sensitive data in the system and must never leave the
|
|
10209
|
+
// server, so — unlike every other metric in this file — the computation
|
|
10210
|
+
// does NOT happen here. A nightly server-side REM step
|
|
10211
|
+
// (resources/MemoryDedupStats.ts, wired into src/rem/runner.ts) computes it
|
|
10212
|
+
// server-side via a bounded-k ANN sweep (reusing the HNSW-backed retrieval
|
|
10213
|
+
// core) and persists ONLY the aggregate `{clusterCount, largestClusterSize,
|
|
10214
|
+
// totalMemoriesInClusters, computedAt}` to a small server-side stat file.
|
|
10215
|
+
// /HealthDetail does a CHEAP read of that file (resources/health.ts) —
|
|
10216
|
+
// still zero new query pattern from the CLI's perspective, same "quality
|
|
10217
|
+
// reads a precomputed number from /HealthDetail" contract as every other
|
|
10218
|
+
// metric here. Nightly-stale by construction (only as fresh as the last
|
|
10219
|
+
// REM cycle) — that's an accepted trade-off, not a bug: it's a "silting up"
|
|
10220
|
+
// trend signal, not a real-time alert. Absent (fresh instance, REM never
|
|
10221
|
+
// run, or an older server) degrades to `null` + a `gaps` entry, never a
|
|
10222
|
+
// false zero.
|
|
10223
|
+
//
|
|
10224
|
+
// Slice 1d (memory-quality arc, self-referential design — no hardcoded canned
|
|
10225
|
+
// queries): recall SPOT-CHECK. Unlike every metric above, this ISN'T read
|
|
10226
|
+
// from /HealthDetail at all — it's the one metric in this file that requires
|
|
10227
|
+
// live QUERIES, because it's checking whether querying itself still works.
|
|
10228
|
+
// For a sample of the querying agent's OWN memories (fetchRecallSpotCheckData
|
|
10229
|
+
// below, GET /Memory?agentId=<self>), a CUE is derived from each memory
|
|
10230
|
+
// (deriveRecallCue — its `subject` if present, else the leading ~8 words /
|
|
10231
|
+
// first sentence of `content`; a PARTIAL cue, never the full content) and
|
|
10232
|
+
// searched for through the EXACT SAME authenticated read path `flair memory
|
|
10233
|
+
// search` uses: `api("POST", "/SemanticSearch", ...)`, which resolves auth
|
|
10234
|
+
// via the shared authedRequest() 5-tier resolver (src/lib/auth-resolve.ts) —
|
|
10235
|
+
// identical code path, so read-scope holds by construction (no new
|
|
10236
|
+
// endpoint, no cross-agent/private data, scoped to the querying agent's own
|
|
10237
|
+
// memories same as `flair memory search` always was). computeRecallSpotCheck
|
|
10238
|
+
// (below) is the pure scorer: recall@k = fraction of sampled memories whose
|
|
10239
|
+
// own id appears in its search's top-k; MRR = mean reciprocal rank (0 if
|
|
10240
|
+
// not found within k).
|
|
10241
|
+
//
|
|
10242
|
+
// Framing — this is a HEALTH SPOT-CHECK, not a benchmark and not a trust
|
|
10243
|
+
// judgment. Querying by a cue derived FROM the target memory is easier than
|
|
10244
|
+
// a real user query, so a high score means "recall is functioning", not
|
|
10245
|
+
// "recall is optimal". Its job is to catch recall CRATERING (embeddings
|
|
10246
|
+
// down, index busted) — the score collapsing toward 0 is the signal, not
|
|
10247
|
+
// fine-grained precision grading. Requires an actual agent identity to
|
|
10248
|
+
// query AS (semantic search is agent-scoped) — no identity, fewer than the
|
|
10249
|
+
// sample-size memories to sample, or a search error all degrade to `null` +
|
|
10250
|
+
// a `gaps` entry, same graceful-degradation contract as every metric here —
|
|
10251
|
+
// NEVER a false 0.0 masquerading as a real (broken) score.
|
|
10252
|
+
/** First-pass default, same "documented heuristic, not derived from data we
|
|
10253
|
+
* don't have" spirit as health.ts's own 10%-hash-fallback threshold below.
|
|
10254
|
+
* Tunable later if a real fleet shows this is too loud/quiet. */
|
|
10255
|
+
export const QUALITY_QUIET_THRESHOLD_DAYS = 7;
|
|
10256
|
+
/** Mirrors resources/health.ts's own hash-fallback warning threshold (kept as
|
|
10257
|
+
* a literal constant here rather than imported — health.ts computes its
|
|
10258
|
+
* warning string server-side, this recomputes the same judgment CLI-side
|
|
10259
|
+
* from the raw counts so quality doesn't depend on parsing warning text). */
|
|
10260
|
+
export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
|
|
10261
|
+
/** Recall spot-check (Slice 1d) defaults — how many of the querying agent's
|
|
10262
|
+
* own memories to sample, and the top-k depth each is searched at. Same
|
|
10263
|
+
* "first-pass default, tunable later" spirit as the thresholds above. */
|
|
10264
|
+
export const QUALITY_RECALL_SAMPLE_SIZE = 10;
|
|
10265
|
+
export const QUALITY_RECALL_K = 5;
|
|
10266
|
+
/**
|
|
10267
|
+
* Derive a PARTIAL search cue from a memory — used by the recall spot-check
|
|
10268
|
+
* (Slice 1d) to query for a memory without handing back its full content.
|
|
10269
|
+
* Prefers `subject` when present and non-trivial (a real word or phrase, not
|
|
10270
|
+
* empty/whitespace-only padding); otherwise falls back to the first sentence
|
|
10271
|
+
* of `content`, capped to the leading ~8 words so the cue stays a genuine
|
|
10272
|
+
* partial cue rather than the whole memory. Pure — no I/O.
|
|
10273
|
+
*/
|
|
10274
|
+
export function deriveRecallCue(memory) {
|
|
10275
|
+
const subject = (memory.subject ?? "").trim();
|
|
10276
|
+
if (subject.length >= 3)
|
|
10277
|
+
return subject;
|
|
10278
|
+
const content = (memory.content ?? "").trim();
|
|
10279
|
+
if (!content)
|
|
10280
|
+
return "";
|
|
10281
|
+
const sentenceMatch = content.match(/^[^.!?\n]+[.!?]?/);
|
|
10282
|
+
const firstSentence = (sentenceMatch ? sentenceMatch[0] : content).trim();
|
|
10283
|
+
const words = firstSentence.split(/\s+/).filter(Boolean);
|
|
10284
|
+
const cueWordLimit = 8;
|
|
10285
|
+
return words.length <= cueWordLimit ? firstSentence : words.slice(0, cueWordLimit).join(" ");
|
|
10286
|
+
}
|
|
10287
|
+
/**
|
|
10288
|
+
* Pure scorer for the recall spot-check (Slice 1d): given the ids of the
|
|
10289
|
+
* sampled memories and, for each, the list of memory ids its derived-cue
|
|
10290
|
+
* search returned (already agent-scoped via the same read path `flair
|
|
10291
|
+
* memory search` uses), compute recall@k + MRR. `perQueryResultIds[i]` is
|
|
10292
|
+
* truncated to the first `k` entries here (not assumed pre-truncated by the
|
|
10293
|
+
* caller) so a caller that over-fetches still gets a correct top-k score.
|
|
10294
|
+
* Never throws; an empty sample scores 0/0 rather than dividing by zero —
|
|
10295
|
+
* callers are expected to treat an empty sample as a `gaps` case, not a
|
|
10296
|
+
* real 0.0 score (see fetchRecallSpotCheckData / computeQualityReport).
|
|
10297
|
+
*/
|
|
10298
|
+
export function computeRecallSpotCheck(sampledIds, perQueryResultIds, k) {
|
|
10299
|
+
const sampleSize = sampledIds.length;
|
|
10300
|
+
if (sampleSize === 0) {
|
|
10301
|
+
return { recallAtK: 0, mrr: 0, sampleSize: 0, k };
|
|
10302
|
+
}
|
|
10303
|
+
let hits = 0;
|
|
10304
|
+
let reciprocalSum = 0;
|
|
10305
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
10306
|
+
const targetId = sampledIds[i];
|
|
10307
|
+
const topK = (perQueryResultIds[i] ?? []).slice(0, k);
|
|
10308
|
+
const rank = topK.indexOf(targetId);
|
|
10309
|
+
if (rank !== -1) {
|
|
10310
|
+
hits += 1;
|
|
10311
|
+
reciprocalSum += 1 / (rank + 1);
|
|
10312
|
+
}
|
|
10313
|
+
}
|
|
10314
|
+
return {
|
|
10315
|
+
recallAtK: Math.round((hits / sampleSize) * 100) / 100,
|
|
10316
|
+
mrr: Math.round((reciprocalSum / sampleSize) * 100) / 100,
|
|
10317
|
+
sampleSize,
|
|
10318
|
+
k,
|
|
10319
|
+
};
|
|
10320
|
+
}
|
|
10321
|
+
/**
|
|
10322
|
+
* Pure computation: /HealthDetail response (+ reachability) → quality report.
|
|
10323
|
+
* Never throws — every missing data source degrades to a null section + a
|
|
10324
|
+
* `gaps` entry (same graceful-degradation contract as `flair doctor`), so a
|
|
10325
|
+
* partially-populated instance still gets a partial, honest report instead
|
|
10326
|
+
* of a crash.
|
|
10327
|
+
*
|
|
10328
|
+
* `opts.recallSpotCheckData` is the ONE exception to "fed purely from
|
|
10329
|
+
* /HealthDetail": the recall spot-check (Slice 1d) requires live queries
|
|
10330
|
+
* (fetchRecallSpotCheckData, run by the `quality` command BEFORE calling
|
|
10331
|
+
* here, same "I/O happens outside, this function only computes" split as
|
|
10332
|
+
* fetchHealthDetail/computeQualityReport itself). Passing nothing degrades
|
|
10333
|
+
* to a `gaps` entry, same as every other metric.
|
|
10334
|
+
*/
|
|
10335
|
+
export function computeQualityReport(healthy, healthData, opts = {}) {
|
|
10336
|
+
const now = opts.now ?? Date.now();
|
|
10337
|
+
const agentFilter = opts.agentId ?? null;
|
|
10338
|
+
const gaps = [];
|
|
10339
|
+
// ── Instance health: migrations ──
|
|
10340
|
+
let migrationsClean = null;
|
|
10341
|
+
let haltedMigrations = [];
|
|
10342
|
+
const migList = healthData?.migrations?.migrations;
|
|
10343
|
+
if (Array.isArray(migList)) {
|
|
10344
|
+
haltedMigrations = migList
|
|
10345
|
+
.filter((m) => m?.state === "halted" || m?.state === "failed")
|
|
10346
|
+
.map((m) => ({ id: m.id, state: m.state, reason: m.reason }));
|
|
10347
|
+
migrationsClean = haltedMigrations.length === 0;
|
|
10348
|
+
}
|
|
10349
|
+
else {
|
|
10350
|
+
gaps.push({ metric: "instance.migrationsClean", reason: "no migrations block in /HealthDetail response" });
|
|
10351
|
+
}
|
|
10352
|
+
// ── Instance health: embeddings operational ──
|
|
10353
|
+
// Inferred from stored coverage stats (hash-fallback %, mixed embedding
|
|
10354
|
+
// models) — NOT a live semantic round-trip like `flair doctor` runs
|
|
10355
|
+
// (verifySemanticSearch writes a probe memory to verify recall-by-meaning,
|
|
10356
|
+
// which this read-only command must not do).
|
|
10357
|
+
let embeddingsStatus = "unknown";
|
|
10358
|
+
let embeddingsDetail = "no memory stats available";
|
|
10359
|
+
const memories = healthData?.memories;
|
|
10360
|
+
if (memories && typeof memories.total === "number") {
|
|
10361
|
+
if (memories.total === 0) {
|
|
10362
|
+
embeddingsDetail = "no memories written yet";
|
|
10363
|
+
}
|
|
10364
|
+
else {
|
|
10365
|
+
const hashFallback = memories.hashFallback ?? 0;
|
|
10366
|
+
const pct = Math.round((hashFallback / memories.total) * 100);
|
|
10367
|
+
const modelCounts = (memories.modelCounts ?? {});
|
|
10368
|
+
const realModels = Object.keys(modelCounts).filter((k) => k !== "hash-512d" && modelCounts[k] > 0);
|
|
10369
|
+
if (pct >= QUALITY_HASH_FALLBACK_DEGRADED_PCT) {
|
|
10370
|
+
embeddingsStatus = "degraded";
|
|
10371
|
+
embeddingsDetail = `${hashFallback}/${memories.total} (${pct}%) memories are hash-fallback`;
|
|
10372
|
+
}
|
|
10373
|
+
else if (realModels.length > 1) {
|
|
10374
|
+
embeddingsStatus = "degraded";
|
|
10375
|
+
embeddingsDetail = `multiple embedding models in use (${realModels.join(", ")}) — cross-model search unreliable`;
|
|
10376
|
+
}
|
|
10377
|
+
else {
|
|
10378
|
+
embeddingsStatus = "ok";
|
|
10379
|
+
embeddingsDetail = `${memories.total - hashFallback}/${memories.total} memories have real embeddings`;
|
|
10380
|
+
}
|
|
10381
|
+
}
|
|
10382
|
+
}
|
|
10383
|
+
// ── Embedding coverage ──
|
|
10384
|
+
let embeddingCoverage = null;
|
|
10385
|
+
if (memories && typeof memories.total === "number") {
|
|
10386
|
+
const total = memories.total;
|
|
10387
|
+
const hashFallback = memories.hashFallback ?? 0;
|
|
10388
|
+
const withEmbeddings = typeof memories.withEmbeddings === "number" ? memories.withEmbeddings : Math.max(0, total - hashFallback);
|
|
10389
|
+
const coveragePct = total > 0 ? Math.round((withEmbeddings / total) * 100) : 0;
|
|
10390
|
+
embeddingCoverage = { total, withEmbeddings, hashFallback, coveragePct };
|
|
10391
|
+
}
|
|
10392
|
+
else {
|
|
10393
|
+
gaps.push({ metric: "embeddingCoverage", reason: "no memory stats available in /HealthDetail response" });
|
|
10394
|
+
}
|
|
10395
|
+
// ── Staleness (instance-wide only — see module doc above) ──
|
|
10396
|
+
let staleness = null;
|
|
10397
|
+
if (memories && typeof memories.total === "number" && typeof memories.expired === "number") {
|
|
10398
|
+
const total = memories.total;
|
|
10399
|
+
const expired = memories.expired;
|
|
10400
|
+
const stalePct = total > 0 ? Math.round((expired / total) * 100) : 0;
|
|
10401
|
+
staleness = { scope: "instance", total, expired, stalePct };
|
|
10402
|
+
gaps.push({
|
|
10403
|
+
metric: "staleness",
|
|
10404
|
+
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)",
|
|
10405
|
+
});
|
|
10406
|
+
}
|
|
10407
|
+
else {
|
|
10408
|
+
gaps.push({ metric: "staleness", reason: "no expired-memory count available in /HealthDetail response" });
|
|
10409
|
+
}
|
|
10410
|
+
// ── Per-agent rows (shared source for signal density + quiet agents) ──
|
|
10411
|
+
const perAgentAll = Array.isArray(healthData?.agents?.perAgent) ? healthData.agents.perAgent : [];
|
|
10412
|
+
const havePerAgent = Array.isArray(healthData?.agents?.perAgent);
|
|
10413
|
+
const scopedAgents = agentFilter ? perAgentAll.filter((r) => r.id === agentFilter) : perAgentAll;
|
|
10414
|
+
// Detected from the UNFILTERED rows (not scopedAgents) so `--agent` never
|
|
10415
|
+
// masquerades server capability as data-scoping — vacuously true on an
|
|
10416
|
+
// empty perAgent array (nothing to prove otherwise, and both scopes render
|
|
10417
|
+
// identically empty either way).
|
|
10418
|
+
const haveUsageCount = perAgentAll.every((r) => typeof r.usageCount === "number");
|
|
10419
|
+
// ── Signal density (write volume, + citation rate when the server supports it) ──
|
|
10420
|
+
let signalDensity = null;
|
|
10421
|
+
if (havePerAgent && haveUsageCount) {
|
|
10422
|
+
signalDensity = {
|
|
10423
|
+
scope: "write-and-citation",
|
|
10424
|
+
perAgent: scopedAgents.map((r) => {
|
|
10425
|
+
const usageCount = r.usageCount ?? 0;
|
|
10426
|
+
const citationRate = r.memoryCount > 0 ? Math.round((usageCount / r.memoryCount) * 100) / 100 : 0;
|
|
10427
|
+
return { id: r.id, memoryCount: r.memoryCount, writes24h: r.writes24h, lastWriteAt: r.lastWriteAt, usageCount, citationRate };
|
|
10428
|
+
}),
|
|
10429
|
+
};
|
|
10430
|
+
}
|
|
10431
|
+
else if (havePerAgent) {
|
|
10432
|
+
signalDensity = {
|
|
10433
|
+
scope: "write-volume",
|
|
10434
|
+
perAgent: scopedAgents.map((r) => ({ id: r.id, memoryCount: r.memoryCount, writes24h: r.writes24h, lastWriteAt: r.lastWriteAt })),
|
|
10435
|
+
};
|
|
10436
|
+
gaps.push({
|
|
10437
|
+
metric: "signalDensity",
|
|
10438
|
+
reason: "citation rate unavailable — server predates per-agent usageCount in /HealthDetail; upgrade the server",
|
|
10439
|
+
});
|
|
10440
|
+
}
|
|
10441
|
+
else {
|
|
10442
|
+
gaps.push({ metric: "signalDensity", reason: "no per-agent stats available in /HealthDetail response" });
|
|
10443
|
+
}
|
|
10444
|
+
// ── Quiet agents (ops fact — not a trust signal) ──
|
|
10445
|
+
let quietAgents = null;
|
|
10446
|
+
if (havePerAgent) {
|
|
10447
|
+
const rows = scopedAgents.map((r) => {
|
|
10448
|
+
const daysSinceLastWrite = r.lastWriteAt ? Math.floor((now - new Date(r.lastWriteAt).getTime()) / 86_400_000) : null;
|
|
10449
|
+
const quiet = daysSinceLastWrite === null ? true : daysSinceLastWrite >= QUALITY_QUIET_THRESHOLD_DAYS;
|
|
10450
|
+
return { id: r.id, memoryCount: r.memoryCount, writes24h: r.writes24h, lastWriteAt: r.lastWriteAt, daysSinceLastWrite, quiet };
|
|
10451
|
+
});
|
|
10452
|
+
quietAgents = { thresholdDays: QUALITY_QUIET_THRESHOLD_DAYS, perAgent: rows, quietCount: rows.filter((r) => r.quiet).length };
|
|
10453
|
+
}
|
|
10454
|
+
else {
|
|
10455
|
+
gaps.push({ metric: "quietAgents", reason: "no per-agent stats available in /HealthDetail response" });
|
|
10456
|
+
}
|
|
10457
|
+
// ── Dedup clusters (instance-wide near-duplicate count — flair-quality
|
|
10458
|
+
// Slice 1c) — an ops/health signal, not a trust judgment (see module doc
|
|
10459
|
+
// and QualityReport['dedupClusters'] doc above). Always instance-wide;
|
|
10460
|
+
// --agent does not scope it (matches staleness's precedent — the
|
|
10461
|
+
// underlying stat has no per-agent breakdown, by design: per-memory
|
|
10462
|
+
// cluster membership is a disclosure surface Sherlock's review explicitly
|
|
10463
|
+
// ruled out storing at all).
|
|
10464
|
+
let dedupClusters = null;
|
|
10465
|
+
const dedup = healthData?.dedup;
|
|
10466
|
+
if (dedup &&
|
|
10467
|
+
typeof dedup.clusterCount === "number" &&
|
|
10468
|
+
typeof dedup.largestClusterSize === "number" &&
|
|
10469
|
+
typeof dedup.totalMemoriesInClusters === "number" &&
|
|
10470
|
+
typeof dedup.computedAt === "string") {
|
|
10471
|
+
dedupClusters = {
|
|
10472
|
+
clusterCount: dedup.clusterCount,
|
|
10473
|
+
largestClusterSize: dedup.largestClusterSize,
|
|
10474
|
+
totalMemoriesInClusters: dedup.totalMemoriesInClusters,
|
|
10475
|
+
computedAt: dedup.computedAt,
|
|
10476
|
+
};
|
|
10477
|
+
}
|
|
10478
|
+
else {
|
|
10479
|
+
gaps.push({
|
|
10480
|
+
metric: "dedupClusters",
|
|
10481
|
+
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",
|
|
10482
|
+
});
|
|
10483
|
+
}
|
|
10484
|
+
// ── Recall spot-check (flair-quality Slice 1d) — see module doc above and
|
|
10485
|
+
// QualityReport['recallSpotCheck'] doc for the full framing. Fed by
|
|
10486
|
+
// fetchRecallSpotCheckData's already-fetched raw ids (not by healthData —
|
|
10487
|
+
// this is the one metric here that needed a live query, not a
|
|
10488
|
+
// /HealthDetail read); scored by the pure computeRecallSpotCheck.
|
|
10489
|
+
let recallSpotCheck = null;
|
|
10490
|
+
const rsc = opts.recallSpotCheckData;
|
|
10491
|
+
if (rsc?.ok && rsc.sampledIds && rsc.perQueryResultIds && typeof rsc.k === "number") {
|
|
10492
|
+
const scored = computeRecallSpotCheck(rsc.sampledIds, rsc.perQueryResultIds, rsc.k);
|
|
10493
|
+
recallSpotCheck = { agentId: rsc.agentId ?? agentFilter ?? null, ...scored };
|
|
10494
|
+
}
|
|
10495
|
+
else {
|
|
10496
|
+
gaps.push({
|
|
10497
|
+
metric: "recallSpotCheck",
|
|
10498
|
+
reason: rsc?.skipReason ?? "recall spot-check not attempted — no data passed to computeQualityReport",
|
|
10499
|
+
});
|
|
10500
|
+
}
|
|
10501
|
+
return {
|
|
10502
|
+
agentFilter,
|
|
10503
|
+
instance: { up: healthy, migrationsClean, haltedMigrations, embeddingsStatus, embeddingsDetail },
|
|
10504
|
+
embeddingCoverage,
|
|
10505
|
+
staleness,
|
|
10506
|
+
signalDensity,
|
|
10507
|
+
quietAgents,
|
|
10508
|
+
dedupClusters,
|
|
10509
|
+
recallSpotCheck,
|
|
10510
|
+
gaps,
|
|
10511
|
+
};
|
|
10512
|
+
}
|
|
10513
|
+
/**
|
|
10514
|
+
* The I/O half of the recall spot-check (Slice 1d): fetch a sample of
|
|
10515
|
+
* `agentId`'s own memories and, for each, search for a cue derived from it.
|
|
10516
|
+
* Reuses the EXACT read path `flair memory search` / `flair memory list`
|
|
10517
|
+
* use — `api()` (→ authedRequest's 5-tier resolver) for both the
|
|
10518
|
+
* `GET /Memory?agentId=...` sample fetch and the `POST /SemanticSearch`
|
|
10519
|
+
* queries — so this has zero new endpoint and zero new auth mechanism; it
|
|
10520
|
+
* is scoped to `agentId`'s own memories exactly as those commands already
|
|
10521
|
+
* are. Never throws: every failure mode (no agentId, fewer than
|
|
10522
|
+
* `sampleSize` memories, a fetch/search error) returns `{ ok: false,
|
|
10523
|
+
* skipReason }` for computeQualityReport to turn into a `gaps` entry.
|
|
10524
|
+
*/
|
|
10525
|
+
async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
10526
|
+
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
10527
|
+
const k = opts.k ?? QUALITY_RECALL_K;
|
|
10528
|
+
if (!agentId) {
|
|
10529
|
+
return { ok: false, skipReason: "no agent identity to query as — pass --agent or set FLAIR_AGENT_ID" };
|
|
10530
|
+
}
|
|
10531
|
+
let all;
|
|
10532
|
+
try {
|
|
10533
|
+
const q = new URLSearchParams({ agentId }).toString();
|
|
10534
|
+
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl });
|
|
10535
|
+
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
10536
|
+
}
|
|
10537
|
+
catch (err) {
|
|
10538
|
+
return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
|
|
10539
|
+
}
|
|
10540
|
+
if (all.length < sampleSize) {
|
|
10541
|
+
return {
|
|
10542
|
+
ok: false,
|
|
10543
|
+
agentId,
|
|
10544
|
+
skipReason: `agent '${agentId}' has ${all.length} memories, fewer than the ${sampleSize} needed to sample`,
|
|
10545
|
+
};
|
|
10546
|
+
}
|
|
10547
|
+
// Deterministic sample: the sampleSize most-recently-written memories.
|
|
10548
|
+
const sorted = all.slice().sort((a, b) => {
|
|
10549
|
+
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
10550
|
+
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
10551
|
+
return tb - ta;
|
|
10552
|
+
});
|
|
10553
|
+
const sampled = sorted.slice(0, sampleSize);
|
|
10554
|
+
const sampledIds = [];
|
|
10555
|
+
const perQueryResultIds = [];
|
|
10556
|
+
try {
|
|
10557
|
+
for (const m of sampled) {
|
|
10558
|
+
const id = String(m.id);
|
|
10559
|
+
const cue = deriveRecallCue(m);
|
|
10560
|
+
const body = { agentId, q: cue, limit: k };
|
|
10561
|
+
const res = await api("POST", "/SemanticSearch", body, { baseUrl });
|
|
10562
|
+
const results = Array.isArray(res) ? res : (res?.results ?? []);
|
|
10563
|
+
sampledIds.push(id);
|
|
10564
|
+
perQueryResultIds.push(results.map((r) => String(r.id)));
|
|
10565
|
+
}
|
|
10566
|
+
}
|
|
10567
|
+
catch (err) {
|
|
10568
|
+
return { ok: false, agentId, skipReason: `recall spot-check search failed: ${err?.message ?? String(err)}` };
|
|
10569
|
+
}
|
|
10570
|
+
return { ok: true, agentId, sampledIds, perQueryResultIds, k };
|
|
10571
|
+
}
|
|
10572
|
+
// ─── flair quality ────────────────────────────────────────────────────────────
|
|
10573
|
+
program
|
|
10574
|
+
.command("quality")
|
|
10575
|
+
.description("Memory-quality report: embedding coverage, staleness, signal density, quiet agents, recall spot-check (read-only)")
|
|
10576
|
+
.option("--port <port>", "Harper HTTP port")
|
|
10577
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
10578
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
10579
|
+
.option("--json", "Output as JSON")
|
|
10580
|
+
.option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
|
|
10581
|
+
.action(async (opts) => {
|
|
10582
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
|
|
10583
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID || null;
|
|
10584
|
+
// Recall spot-check needs live queries (not just /HealthDetail), so only
|
|
10585
|
+
// attempt it when the instance is actually reachable — no point probing
|
|
10586
|
+
// memory reads against a server fetchHealthDetail already found down.
|
|
10587
|
+
const recallSpotCheckData = healthy
|
|
10588
|
+
? await fetchRecallSpotCheckData(agentId, baseUrl)
|
|
10589
|
+
: { ok: false, skipReason: "instance unreachable" };
|
|
10590
|
+
const report = computeQualityReport(healthy, healthData, { agentId, recallSpotCheckData });
|
|
10591
|
+
const mode = render.resolveOutputMode(opts);
|
|
10592
|
+
if (mode === "json") {
|
|
10593
|
+
const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...report };
|
|
10594
|
+
console.log(render.asJSON(out));
|
|
10595
|
+
if (!healthy)
|
|
10596
|
+
process.exit(1);
|
|
10597
|
+
return;
|
|
10598
|
+
}
|
|
10599
|
+
if (!healthy) {
|
|
10600
|
+
console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
|
|
10601
|
+
console.log(` URL: ${baseUrl}`);
|
|
10602
|
+
console.log(`\n Run: flair start or flair doctor`);
|
|
10603
|
+
process.exit(1);
|
|
10604
|
+
}
|
|
10605
|
+
const scopeLabel = agentId ? ` ${render.wrap(render.c.dim, `(agent: ${agentId})`)}` : "";
|
|
10606
|
+
console.log(`${render.wrap(render.c.bold, "Flair quality report")}${scopeLabel}`);
|
|
10607
|
+
console.log(render.kv("URL", baseUrl));
|
|
10608
|
+
// Instance health
|
|
10609
|
+
console.log(`\n${render.wrap(render.c.bold, "Instance health")}`);
|
|
10610
|
+
console.log(render.kv("Up", report.instance.up ? `${render.icons.ok} yes` : `${render.icons.error} no`));
|
|
10611
|
+
if (report.instance.migrationsClean === null) {
|
|
10612
|
+
console.log(render.kv("Migrations", `${render.icons.info} unknown ${render.wrap(render.c.dim, "(no data)")}`));
|
|
10613
|
+
}
|
|
10614
|
+
else if (report.instance.migrationsClean) {
|
|
10615
|
+
console.log(render.kv("Migrations", `${render.icons.ok} clean`));
|
|
10616
|
+
}
|
|
10617
|
+
else {
|
|
10618
|
+
console.log(render.kv("Migrations", `${render.icons.error} ${report.instance.haltedMigrations.length} halted/failed`));
|
|
10619
|
+
for (const m of report.instance.haltedMigrations) {
|
|
10620
|
+
console.log(` ${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
|
|
10621
|
+
}
|
|
10622
|
+
}
|
|
10623
|
+
const embIcon = report.instance.embeddingsStatus === "ok" ? render.icons.ok :
|
|
10624
|
+
report.instance.embeddingsStatus === "degraded" ? render.icons.error :
|
|
10625
|
+
render.icons.info;
|
|
10626
|
+
console.log(render.kv("Embeddings", `${embIcon} ${report.instance.embeddingsStatus} ${render.wrap(render.c.dim, `(${report.instance.embeddingsDetail})`)}`));
|
|
10627
|
+
// Embedding coverage
|
|
10628
|
+
if (report.embeddingCoverage) {
|
|
10629
|
+
const ec = report.embeddingCoverage;
|
|
10630
|
+
console.log(`\n${render.wrap(render.c.bold, "Embedding coverage")}`);
|
|
10631
|
+
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)`)}`));
|
|
10632
|
+
}
|
|
10633
|
+
// Staleness
|
|
10634
|
+
if (report.staleness) {
|
|
10635
|
+
const st = report.staleness;
|
|
10636
|
+
console.log(`\n${render.wrap(render.c.bold, "Staleness")}`);
|
|
10637
|
+
console.log(render.kv("Past validTo", `${render.wrap(render.c.bold, `${st.stalePct}%`)} ${render.wrap(render.c.dim, `(${st.expired}/${st.total}, instance-wide)`)}`));
|
|
10638
|
+
}
|
|
10639
|
+
// Signal density
|
|
10640
|
+
if (report.signalDensity) {
|
|
10641
|
+
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)")}`);
|
|
10642
|
+
if (agentId && report.signalDensity.perAgent.length === 0) {
|
|
10643
|
+
console.log(` ${render.icons.info} no data for agent '${agentId}'`);
|
|
10644
|
+
}
|
|
10645
|
+
else {
|
|
10646
|
+
const showCitation = report.signalDensity.scope === "write-and-citation";
|
|
10647
|
+
const cols = [
|
|
10648
|
+
{ label: "id", key: "id" },
|
|
10649
|
+
{ label: "memories", key: "memoryCount", align: "right" },
|
|
10650
|
+
{ label: "writes_24h", key: "writes24h", align: "right" },
|
|
10651
|
+
...(showCitation
|
|
10652
|
+
? [
|
|
10653
|
+
{ label: "citations", key: "usageCount", align: "right" },
|
|
10654
|
+
{ label: "citation_rate", key: "citationRate", align: "right" },
|
|
10655
|
+
]
|
|
10656
|
+
: []),
|
|
10657
|
+
{ label: "last_write", key: "lastWriteAt", format: (v) => render.relativeTime(v) },
|
|
10658
|
+
];
|
|
10659
|
+
console.log(render.table(cols, report.signalDensity.perAgent));
|
|
10660
|
+
if (showCitation) {
|
|
10661
|
+
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\"")}`);
|
|
10662
|
+
}
|
|
10663
|
+
else {
|
|
10664
|
+
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\"")}`);
|
|
10665
|
+
}
|
|
10666
|
+
}
|
|
10667
|
+
}
|
|
10668
|
+
// Quiet agents
|
|
10669
|
+
if (report.quietAgents) {
|
|
10670
|
+
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)`)}`);
|
|
10671
|
+
if (agentId && report.quietAgents.perAgent.length === 0) {
|
|
10672
|
+
console.log(` ${render.icons.info} no data for agent '${agentId}'`);
|
|
10673
|
+
}
|
|
10674
|
+
else {
|
|
10675
|
+
const quiet = report.quietAgents.perAgent.filter((r) => r.quiet);
|
|
10676
|
+
if (quiet.length === 0) {
|
|
10677
|
+
console.log(` ${render.icons.ok} none`);
|
|
10678
|
+
}
|
|
10679
|
+
else {
|
|
10680
|
+
for (const r of quiet) {
|
|
10681
|
+
const label = r.daysSinceLastWrite == null ? "never written" : `quiet for ${r.daysSinceLastWrite}d`;
|
|
10682
|
+
console.log(` ${render.icons.warn} ${r.id} — ${label}`);
|
|
10683
|
+
}
|
|
10684
|
+
}
|
|
10685
|
+
}
|
|
10686
|
+
}
|
|
10687
|
+
// Dedup clusters (flair-quality Slice 1c) — an ops/health signal, not a
|
|
10688
|
+
// trust judgment. Labeled with the nightly REM run that produced it, per
|
|
10689
|
+
// spec, since it's only ever as fresh as the last nightly cycle.
|
|
10690
|
+
if (report.dedupClusters) {
|
|
10691
|
+
const dc = report.dedupClusters;
|
|
10692
|
+
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})`)}`);
|
|
10693
|
+
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})`)}`));
|
|
10694
|
+
console.log(` ${render.wrap(render.c.dim, "an ops signal — near-duplicate memories piling up, not a trust judgment")}`);
|
|
10695
|
+
}
|
|
10696
|
+
// Recall spot-check (flair-quality Slice 1d) — a health SPOT-CHECK, not
|
|
10697
|
+
// a benchmark or trust judgment: catches recall cratering (embeddings/
|
|
10698
|
+
// index down), not a quality grade. See QualityReport['recallSpotCheck']
|
|
10699
|
+
// doc for the full framing.
|
|
10700
|
+
if (report.recallSpotCheck) {
|
|
10701
|
+
const rc = report.recallSpotCheck;
|
|
10702
|
+
console.log(`\n${render.wrap(render.c.bold, "Recall spot-check")} ${render.wrap(render.c.dim, `(agent ${rc.agentId ?? "—"}, health signal — not a benchmark)`)}`);
|
|
10703
|
+
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)`)}`));
|
|
10704
|
+
console.log(` ${render.wrap(render.c.dim, "catches recall cratering (embeddings/index down) — a high score means recall is functioning, not that it's optimal")}`);
|
|
10705
|
+
}
|
|
10706
|
+
// Gaps
|
|
10707
|
+
if (report.gaps.length > 0) {
|
|
10708
|
+
console.log(`\n${render.wrap(render.c.bold, "Gaps")} ${render.wrap(render.c.dim, "(degraded or unavailable from existing read APIs)")}`);
|
|
10709
|
+
for (const g of report.gaps) {
|
|
10710
|
+
console.log(` ${render.icons.info} ${g.metric}: ${g.reason}`);
|
|
10711
|
+
}
|
|
10712
|
+
}
|
|
10713
|
+
console.log("");
|
|
10714
|
+
});
|
|
10151
10715
|
// ─── flair session snapshot ──────────────────────────────────────────────────
|
|
10152
10716
|
// Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B. Snapshot a
|
|
10153
10717
|
// session jsonl + label metadata into a tar.gz under ~/.flair/snapshots/<agent>/sessions/.
|
package/dist/rem/runner.js
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
* 4. Trust-tier filter on input memories — permanently deferred (see below).
|
|
9
9
|
* 5. Distillation — call /ReflectMemories with execute:true, persist staged
|
|
10
10
|
* candidate ids to the audit row.
|
|
11
|
-
* 6.
|
|
11
|
+
* 6. Instance-wide dedup-cluster stat — call /MemoryDedupStats (flair-
|
|
12
|
+
* quality Slice 1c). NOT part of FLAIR-NIGHTLY-REM's original per-agent
|
|
13
|
+
* § 4 list — added because a near-duplicate cluster count is inherently
|
|
14
|
+
* instance-wide (dupes across agents matter), so it runs once per cycle
|
|
15
|
+
* after the per-agent passes above, rather than being scoped to
|
|
16
|
+
* opts.agentId like everything else in this file.
|
|
17
|
+
* 7. Append a row to ~/.flair/logs/rem-nightly.jsonl.
|
|
12
18
|
*
|
|
13
19
|
* Status today:
|
|
14
20
|
* - Steps 1, 2 shipped in slice-1 PR-1 (#414).
|
|
@@ -23,7 +29,14 @@
|
|
|
23
29
|
* - Step 5 (distillation) ships in this PR: calls `/ReflectMemories` with
|
|
24
30
|
* `execute: true` after maintenance succeeds. `dryRun` skips the call
|
|
25
31
|
* entirely (staging rows + spending model tokens are side effects).
|
|
26
|
-
* - Step 6
|
|
32
|
+
* - Step 6 (dedup-cluster stat, flair-quality Slice 1c): calls
|
|
33
|
+
* `/MemoryDedupStats` after distillation. `dryRun` skips it (persisting
|
|
34
|
+
* the stat file is a side effect); failure (e.g. the caller isn't admin
|
|
35
|
+
* — the resource is admin-gated) is recorded in `errors` and does not
|
|
36
|
+
* fail the cycle. See resources/MemoryDedupStats.ts for the server-side
|
|
37
|
+
* computation and resources/dedup-cluster.ts for the stat's canonical
|
|
38
|
+
* storage location (NOT this log — see those files' module docs for why).
|
|
39
|
+
* - Step 7 shipped in slice-1 PR-1 (was step 6 before this PR).
|
|
27
40
|
*
|
|
28
41
|
* The audit row's `slice` field tells readers which steps populated which
|
|
29
42
|
* counts: `slice: "1"` rows have `archived`/`expired` undefined; `slice:
|
|
@@ -268,7 +281,50 @@ export async function runNightlyCycle(opts) {
|
|
|
268
281
|
errors.push(`distillation: ${describeApiError(err?.message ?? err)}`);
|
|
269
282
|
}
|
|
270
283
|
}
|
|
271
|
-
// Step 6:
|
|
284
|
+
// Step 6 (flair-quality Slice 1c): instance-wide dedup-cluster stat.
|
|
285
|
+
// Distinct from every step above — NOT scoped to opts.agentId. Runs ONCE
|
|
286
|
+
// per cycle, after the per-agent passes (spec: "a new instance-wide step
|
|
287
|
+
// in the REM nightly runner... runs once per cycle, after the per-agent
|
|
288
|
+
// passes"). Skipped in dry-run for the same reason distillation is:
|
|
289
|
+
// persisting the stat file is a side effect. Non-fatal on failure — e.g.
|
|
290
|
+
// POST /MemoryDedupStats is admin-gated (resources/MemoryDedupStats.ts),
|
|
291
|
+
// so a non-admin agent's nightly cycle records a `dedup:` error here and
|
|
292
|
+
// otherwise completes normally; maintenance + distillation already stand.
|
|
293
|
+
//
|
|
294
|
+
// NOTE (flagged for review): if MULTIPLE agents on the same instance each
|
|
295
|
+
// run their own nightly cycle, each cycle triggers this same instance-wide
|
|
296
|
+
// sweep independently — the stat is recomputed N times a night rather than
|
|
297
|
+
// once. No cross-process guard was added (kept the smallest surface); the
|
|
298
|
+
// recomputation is idempotent (same inputs → same aggregate, just wasted
|
|
299
|
+
// work), not incorrect.
|
|
300
|
+
let dedup;
|
|
301
|
+
if (!opts.dryRun) {
|
|
302
|
+
try {
|
|
303
|
+
const dedupRaw = await opts.apiCall("POST", "/MemoryDedupStats", {});
|
|
304
|
+
const obj = (dedupRaw && typeof dedupRaw === "object") ? dedupRaw : {};
|
|
305
|
+
if (obj.error) {
|
|
306
|
+
errors.push(`dedup: ${describeApiError(obj.error)}`);
|
|
307
|
+
}
|
|
308
|
+
else if (typeof obj.clusterCount === "number" &&
|
|
309
|
+
typeof obj.largestClusterSize === "number" &&
|
|
310
|
+
typeof obj.totalMemoriesInClusters === "number" &&
|
|
311
|
+
typeof obj.computedAt === "string") {
|
|
312
|
+
dedup = {
|
|
313
|
+
clusterCount: obj.clusterCount,
|
|
314
|
+
largestClusterSize: obj.largestClusterSize,
|
|
315
|
+
totalMemoriesInClusters: obj.totalMemoriesInClusters,
|
|
316
|
+
computedAt: obj.computedAt,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
errors.push("dedup: unexpected /MemoryDedupStats response shape");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
errors.push(`dedup: ${describeApiError(err?.message ?? err)}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Step 7: log
|
|
272
328
|
const row = {
|
|
273
329
|
...baseRow,
|
|
274
330
|
slice: sliceLabel,
|
|
@@ -281,6 +337,7 @@ export async function runNightlyCycle(opts) {
|
|
|
281
337
|
archived,
|
|
282
338
|
expired,
|
|
283
339
|
candidates,
|
|
340
|
+
dedup,
|
|
284
341
|
durationMs: Date.now() - startedMs,
|
|
285
342
|
errors,
|
|
286
343
|
// `consolidated` remains undefined — this runner has no consolidation
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryDedupStats.ts — instance-wide near-duplicate CLUSTER count.
|
|
3
|
+
*
|
|
4
|
+
* POST /MemoryDedupStats — admin-only. Triggered once per REM nightly cycle
|
|
5
|
+
* (src/rem/runner.ts, after the per-agent maintenance/distillation passes —
|
|
6
|
+
* see that file's module doc for why this step is instance-wide rather than
|
|
7
|
+
* folded into the per-agent runner loop).
|
|
8
|
+
*
|
|
9
|
+
* flair-quality Slice 1c (ops/proposals/flair-quality-slice1c-dedup-spec.md).
|
|
10
|
+
* K&S split resolved to "Option C, server-side": Sherlock's hard security
|
|
11
|
+
* objection to a CLI-side computation was that embeddings are the most
|
|
12
|
+
* sensitive data in the system and must never leave the server; Kern's
|
|
13
|
+
* arch counter was that this doesn't fit REM's PER-AGENT pass (which also
|
|
14
|
+
* strips embeddings before handing memories to the model). Resolution:
|
|
15
|
+
* a SEPARATE server-side nightly job — this resource — that:
|
|
16
|
+
* 1. Reads embeddings DIRECTLY off `databases.flair.Memory` (same access
|
|
17
|
+
* HealthDetail already has — resources/health.ts's `db.flair.Memory.
|
|
18
|
+
* search({})` reads full records instance-wide for its own stats).
|
|
19
|
+
* 2. Uses them ONLY as HNSW query vectors, via `retrieveCandidates()`
|
|
20
|
+
* (resources/semantic-retrieval-core.ts) called BARE — the same
|
|
21
|
+
* in-process, no-HTTP pattern resources/MemoryBootstrap.ts already
|
|
22
|
+
* uses to reuse SemanticSearch's retrieval core without going over the
|
|
23
|
+
* wire. The embedding vector itself is never serialized into an HTTP
|
|
24
|
+
* response or returned to a caller — it lives only in this process's
|
|
25
|
+
* memory for the span of one ANN query, then is discarded.
|
|
26
|
+
* 3. Persists ONLY the aggregate `{ clusterCount, largestClusterSize,
|
|
27
|
+
* totalMemoriesInClusters, computedAt }` to REM_DEDUP_STATS_PATH
|
|
28
|
+
* (resources/dedup-cluster.ts) — never per-memory cluster membership
|
|
29
|
+
* (Sherlock: that would itself be a disclosure surface), never on
|
|
30
|
+
* Memory rows (would pollute the authority/attribution path — #735
|
|
31
|
+
* zero-authority spine).
|
|
32
|
+
*
|
|
33
|
+
* Why the stat file lives on the SERVER's own filesystem (not written by
|
|
34
|
+
* the CLI-side runner, unlike the snapshot/audit-log artifacts in
|
|
35
|
+
* src/rem/snapshot.ts and src/rem/runner.ts): the runner's `apiCall` talks
|
|
36
|
+
* to `flairUrl`, which is not guaranteed to be the same host the CLI runs
|
|
37
|
+
* on (a remote/federated Flair is a real deployment shape — see
|
|
38
|
+
* src/rem/scheduler.ts's `FLAIR_URL` substitution). resources/health.ts's
|
|
39
|
+
* HealthDetail (Part 3 of this slice) is a Resource running INSIDE the same
|
|
40
|
+
* Harper process as this one, so writing here and reading there are
|
|
41
|
+
* guaranteed to be the same file regardless of where the triggering CLI
|
|
42
|
+
* lives. The runner's own audit-log row (src/rem/runner.ts) gets a COPY of
|
|
43
|
+
* the result for convenience/history, but this file is the canonical source
|
|
44
|
+
* `/HealthDetail` reads from.
|
|
45
|
+
*
|
|
46
|
+
* Auth: allowAdmin, matching the codebase's existing convention for
|
|
47
|
+
* INSTANCE-WIDE (not self-scoped) maintenance jobs — see MemoryReindex.ts
|
|
48
|
+
* (also admin-only, also a fleet-wide sweep) and
|
|
49
|
+
* test/unit/resource-allow.test.ts's structural ADMIN_ONLY list, which this
|
|
50
|
+
* resource is added to. Per-agent nightly steps (/MemoryMaintenance,
|
|
51
|
+
* /ReflectMemories) use allowVerified because they're scoped to the calling
|
|
52
|
+
* agent's own memories; this one is never scoped that way — it reads every
|
|
53
|
+
* agent's embeddings — so it follows the admin-gated precedent instead.
|
|
54
|
+
*/
|
|
55
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
56
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
57
|
+
import { dirname } from "node:path";
|
|
58
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
59
|
+
import { retrieveCandidates } from "./semantic-retrieval-core.js";
|
|
60
|
+
import { DEDUP_COSINE_THRESHOLD_DEFAULT } from "./dedup.js";
|
|
61
|
+
import { countDedupClusters, DEDUP_CLUSTER_ANN_K_DEFAULT, DEDUP_CLUSTER_MAX_MEMORIES_DEFAULT, REM_DEDUP_STATS_PATH, } from "./dedup-cluster.js";
|
|
62
|
+
const NOT_ARCHIVED = [{ attribute: "archived", comparator: "not_equal", value: true }];
|
|
63
|
+
function persistStat(stat, pathOverride) {
|
|
64
|
+
const path = pathOverride ?? REM_DEDUP_STATS_PATH;
|
|
65
|
+
const dir = dirname(path);
|
|
66
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
67
|
+
writeFileSync(path, JSON.stringify(stat, null, 2) + "\n", { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The DB-touching sweep, factored out of post() so it takes an explicit
|
|
71
|
+
* `ctx` and `nowOverride`/`pathOverride` rather than reaching for Resource
|
|
72
|
+
* internals — kept close to post() (not exported broadly) since it needs a
|
|
73
|
+
* live Harper `databases` handle and isn't unit-testable without one; the
|
|
74
|
+
* PURE part (countDedupClusters) is what's unit-tested, in
|
|
75
|
+
* dedup-cluster.ts's own test file.
|
|
76
|
+
*/
|
|
77
|
+
async function computeAndPersist(ctx, opts) {
|
|
78
|
+
const annK = opts?.annK ?? DEDUP_CLUSTER_ANN_K_DEFAULT;
|
|
79
|
+
const maxMemories = opts?.maxMemories ?? DEDUP_CLUSTER_MAX_MEMORIES_DEFAULT;
|
|
80
|
+
// ── 1. Load the sweep set: every non-archived memory's id + embedding,
|
|
81
|
+
// instance-wide (no agentId condition — near-duplicates ACROSS agents are
|
|
82
|
+
// exactly what this stat is for). Bounded to the most-recently-created
|
|
83
|
+
// maxMemories when the instance exceeds the safety cap (see
|
|
84
|
+
// dedup-cluster.ts's doc on why this is a defensive bound, not sampling).
|
|
85
|
+
const all = [];
|
|
86
|
+
for await (const record of databases.flair.Memory.search({
|
|
87
|
+
conditions: NOT_ARCHIVED,
|
|
88
|
+
select: ["id", "embedding", "createdAt"],
|
|
89
|
+
})) {
|
|
90
|
+
if (!record?.id || !Array.isArray(record.embedding) || record.embedding.length === 0)
|
|
91
|
+
continue;
|
|
92
|
+
all.push({ id: record.id, embedding: record.embedding, createdAt: record.createdAt });
|
|
93
|
+
}
|
|
94
|
+
let sweepSet = all;
|
|
95
|
+
if (all.length > maxMemories) {
|
|
96
|
+
sweepSet = [...all]
|
|
97
|
+
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))
|
|
98
|
+
.slice(0, maxMemories);
|
|
99
|
+
}
|
|
100
|
+
// ── 2. Bounded-k ANN query per memory, via the SAME retrieval core
|
|
101
|
+
// SemanticSearch/MemoryBootstrap use, called bare (in-process, no HTTP —
|
|
102
|
+
// see module doc). Each memory's OWN embedding is the query vector; the
|
|
103
|
+
// vector never leaves this function (select: ["id"] — no embedding field
|
|
104
|
+
// comes back out, and nothing here is returned to an HTTP caller).
|
|
105
|
+
//
|
|
106
|
+
// limit: annK + 1 — a memory's own stored embedding is always its own
|
|
107
|
+
// closest match (cosine 1.0), so the unfiltered result set always
|
|
108
|
+
// contains the memory itself. Fetching one extra slot and filtering self
|
|
109
|
+
// out below (n.id === memory.id) keeps the REAL candidate-neighbor count
|
|
110
|
+
// at the documented annK, rather than silently examining annK-1.
|
|
111
|
+
const edges = [];
|
|
112
|
+
for (const memory of sweepSet) {
|
|
113
|
+
let neighbors;
|
|
114
|
+
try {
|
|
115
|
+
neighbors = await retrieveCandidates({
|
|
116
|
+
queryEmbedding: memory.embedding,
|
|
117
|
+
conditions: NOT_ARCHIVED,
|
|
118
|
+
limit: annK + 1,
|
|
119
|
+
hybrid: false,
|
|
120
|
+
scoring: "raw",
|
|
121
|
+
withSemSimilarity: true,
|
|
122
|
+
select: ["id"],
|
|
123
|
+
ctx,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// A single memory's ANN query failing (embedding engine hiccup, etc.)
|
|
128
|
+
// must not abort the whole sweep — skip it, the rest still counts.
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const n of neighbors) {
|
|
132
|
+
if (!n?.id || n.id === memory.id)
|
|
133
|
+
continue;
|
|
134
|
+
const cosine = typeof n._semSimilarity === "number" ? n._semSimilarity : 0;
|
|
135
|
+
if (cosine >= DEDUP_COSINE_THRESHOLD_DEFAULT) {
|
|
136
|
+
edges.push({ a: memory.id, b: n.id });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ── 3. Reduce edges → connected components (pure, unit-tested elsewhere).
|
|
141
|
+
const clusters = countDedupClusters(edges);
|
|
142
|
+
const computedAt = (opts?.nowOverride ?? new Date()).toISOString();
|
|
143
|
+
const result = { ...clusters, computedAt };
|
|
144
|
+
// ── 4. Persist server-side ONLY the aggregate (never per-memory edges).
|
|
145
|
+
persistStat(result, opts?.pathOverride);
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
export class MemoryDedupStats extends Resource {
|
|
149
|
+
async allowCreate() {
|
|
150
|
+
return allowAdmin(this.getContext?.());
|
|
151
|
+
}
|
|
152
|
+
async post(_data) {
|
|
153
|
+
const ctx = this.getContext?.();
|
|
154
|
+
try {
|
|
155
|
+
return await computeAndPersist(ctx);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
return new Response(JSON.stringify({ error: err?.message ?? String(err) }), { status: 500, headers: { "content-type": "application/json" } });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// ─── Instance-wide dedup-cluster counting primitives (Harper-free) ──────────
|
|
2
|
+
// flair-quality Slice 1c (ops/proposals/flair-quality-slice1c-dedup-spec.md,
|
|
3
|
+
// K&S-resolved to "Option C, server-side"). Same rationale as ./dedup.ts and
|
|
4
|
+
// ./bm25.ts: the graph algorithm here is pure math over an edge list — no
|
|
5
|
+
// Harper import, no embeddings, no DB — so it's unit-testable directly
|
|
6
|
+
// against the SHIPPED clustering logic without a live Harper.
|
|
7
|
+
//
|
|
8
|
+
// ── THE INVARIANT ────────────────────────────────────────────────────────────
|
|
9
|
+
// This module never sees embeddings and never sees memory content. It
|
|
10
|
+
// consumes a list of {a, b} edges (memory-id pairs already determined to be
|
|
11
|
+
// near-duplicates by the DB-touching sweep in resources/MemoryDedupStats.ts)
|
|
12
|
+
// and reduces them to a small aggregate: how many CONNECTED COMPONENTS
|
|
13
|
+
// (clusters) exist, how big the largest one is, and how many memories fall
|
|
14
|
+
// into any cluster. Per Kern's design note (K&S round), a cluster of 5
|
|
15
|
+
// memories is 1 cluster, not 10 pairwise edges — this module is what makes
|
|
16
|
+
// that true (union-find over the edge list, not a raw edge count).
|
|
17
|
+
//
|
|
18
|
+
// No per-memory membership is retained past the union-find pass — the
|
|
19
|
+
// public output is the aggregate ONLY (Sherlock's disclosure-surface
|
|
20
|
+
// constraint: no per-memory cluster-membership data leaves this module).
|
|
21
|
+
import { resolve } from "node:path";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
/** Re-exported home for the single-source-of-truth cosine threshold this
|
|
24
|
+
* module's caller (MemoryDedupStats.ts) gates edges on — see ./dedup.ts's
|
|
25
|
+
* DEDUP_COSINE_THRESHOLD_DEFAULT (0.95). Not redefined here; this module
|
|
26
|
+
* only counts edges, it never decides what counts as "near-duplicate". */
|
|
27
|
+
export { DEDUP_COSINE_THRESHOLD_DEFAULT } from "./dedup.js";
|
|
28
|
+
/** Bounded-k ANN fan-out per memory (Kern's efficiency framing: "bounded-k
|
|
29
|
+
* ANN query per memory... O(n·k·log n)"). 10 is generous headroom for a
|
|
30
|
+
* cosine>=0.95 near-duplicate gate — a true near-duplicate is overwhelmingly
|
|
31
|
+
* likely to be its counterpart's #1 or #2 nearest neighbor, so even a large
|
|
32
|
+
* (>10-member) cluster stays fully connected as long as each member finds
|
|
33
|
+
* AT LEAST ONE clustermate within its own top-k (transitivity does the
|
|
34
|
+
* rest via union-find). Exported so the sweep and its tests share one
|
|
35
|
+
* tunable instead of a duplicated literal. */
|
|
36
|
+
export const DEDUP_CLUSTER_ANN_K_DEFAULT = 10;
|
|
37
|
+
/** Defensive cap on how many (non-archived) memories the nightly sweep will
|
|
38
|
+
* walk in one cycle — protects wall-clock time on a very large instance.
|
|
39
|
+
* This is a SAFETY bound, not a routine sampling strategy: real-world
|
|
40
|
+
* self-hosted instances are expected to sit well under this for the
|
|
41
|
+
* foreseeable future, and the brief explicitly asked to prefer the full ANN
|
|
42
|
+
* sweep over sampling. When the instance exceeds this, the sweep covers the
|
|
43
|
+
* most-recently-created N memories (deterministic, documented) rather than
|
|
44
|
+
* a random sample. */
|
|
45
|
+
export const DEDUP_CLUSTER_MAX_MEMORIES_DEFAULT = 20_000;
|
|
46
|
+
/** Server-side stat artifact home (Slice 1c Part 2 — smallest-surface
|
|
47
|
+
* storage choice). A single small JSON file under the SERVER's own
|
|
48
|
+
* `~/.flair/`, written by MemoryDedupStats.ts's post() and read by
|
|
49
|
+
* health.ts's HealthDetail — both run INSIDE the same Harper process, so
|
|
50
|
+
* this is always the same file regardless of where the CLI/runner that
|
|
51
|
+
* TRIGGERED the sweep is running (see MemoryDedupStats.ts's module doc for
|
|
52
|
+
* why that distinction matters for remote/federated deployments). Mirrors
|
|
53
|
+
* the `REM_PAUSE_FLAG` "small well-known file directly under ~/.flair/"
|
|
54
|
+
* precedent in src/rem/runner.ts, rather than nesting under logs/ (this is
|
|
55
|
+
* a single overwritten snapshot, not an append-only log). */
|
|
56
|
+
export const REM_DEDUP_STATS_PATH = resolve(homedir(), ".flair", "rem-dedup-stats.json");
|
|
57
|
+
/** Minimal union-find (disjoint-set) with path compression + union-by-rank.
|
|
58
|
+
* Nodes are created lazily — a memory id only enters the structure when it
|
|
59
|
+
* appears in at least one edge, so a memory with zero near-duplicate edges
|
|
60
|
+
* (the common case) never shows up in the output at all. */
|
|
61
|
+
class UnionFind {
|
|
62
|
+
parent = new Map();
|
|
63
|
+
rank = new Map();
|
|
64
|
+
ensure(x) {
|
|
65
|
+
if (!this.parent.has(x)) {
|
|
66
|
+
this.parent.set(x, x);
|
|
67
|
+
this.rank.set(x, 0);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
find(x) {
|
|
71
|
+
this.ensure(x);
|
|
72
|
+
let root = x;
|
|
73
|
+
while (this.parent.get(root) !== root) {
|
|
74
|
+
root = this.parent.get(root);
|
|
75
|
+
}
|
|
76
|
+
// Path compression.
|
|
77
|
+
let cur = x;
|
|
78
|
+
while (this.parent.get(cur) !== root) {
|
|
79
|
+
const next = this.parent.get(cur);
|
|
80
|
+
this.parent.set(cur, root);
|
|
81
|
+
cur = next;
|
|
82
|
+
}
|
|
83
|
+
return root;
|
|
84
|
+
}
|
|
85
|
+
union(a, b) {
|
|
86
|
+
const ra = this.find(a);
|
|
87
|
+
const rb = this.find(b);
|
|
88
|
+
if (ra === rb)
|
|
89
|
+
return;
|
|
90
|
+
const rankA = this.rank.get(ra) ?? 0;
|
|
91
|
+
const rankB = this.rank.get(rb) ?? 0;
|
|
92
|
+
if (rankA < rankB) {
|
|
93
|
+
this.parent.set(ra, rb);
|
|
94
|
+
}
|
|
95
|
+
else if (rankA > rankB) {
|
|
96
|
+
this.parent.set(rb, ra);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
this.parent.set(rb, ra);
|
|
100
|
+
this.rank.set(ra, rankA + 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
nodes() {
|
|
104
|
+
return [...this.parent.keys()];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Reduces a near-duplicate edge list to the cluster aggregate `flair
|
|
109
|
+
* quality` renders. A "cluster" requires >=2 connected memories — a memory
|
|
110
|
+
* with no qualifying edge never enters the graph, so it's correctly excluded
|
|
111
|
+
* (never counted as a size-1 "cluster").
|
|
112
|
+
*
|
|
113
|
+
* Self-loop edges (a === b — a memory paired with itself, which the sweep
|
|
114
|
+
* should already exclude, but this function stays defensive) are ignored:
|
|
115
|
+
* they can never form or grow a cluster.
|
|
116
|
+
*
|
|
117
|
+
* Duplicate/symmetric edges (the sweep may discover the SAME pair from both
|
|
118
|
+
* directions — A's neighbor search finds B, and independently B's finds A)
|
|
119
|
+
* are harmless: union() on an already-joined pair is a no-op.
|
|
120
|
+
*/
|
|
121
|
+
export function countDedupClusters(edges) {
|
|
122
|
+
const uf = new UnionFind();
|
|
123
|
+
for (const { a, b } of edges) {
|
|
124
|
+
if (a === b)
|
|
125
|
+
continue;
|
|
126
|
+
uf.union(a, b);
|
|
127
|
+
}
|
|
128
|
+
const sizeByRoot = new Map();
|
|
129
|
+
for (const id of uf.nodes()) {
|
|
130
|
+
const root = uf.find(id);
|
|
131
|
+
sizeByRoot.set(root, (sizeByRoot.get(root) ?? 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
let clusterCount = 0;
|
|
134
|
+
let largestClusterSize = 0;
|
|
135
|
+
let totalMemoriesInClusters = 0;
|
|
136
|
+
for (const size of sizeByRoot.values()) {
|
|
137
|
+
if (size < 2)
|
|
138
|
+
continue; // shouldn't happen (a root only exists via an edge), but stay defensive
|
|
139
|
+
clusterCount++;
|
|
140
|
+
totalMemoriesInClusters += size;
|
|
141
|
+
if (size > largestClusterSize)
|
|
142
|
+
largestClusterSize = size;
|
|
143
|
+
}
|
|
144
|
+
return { clusterCount, largestClusterSize, totalMemoriesInClusters };
|
|
145
|
+
}
|
package/dist/resources/health.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { getRerankStatus } from "./rerank-provider.js";
|
|
7
7
|
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
8
8
|
import { getMigrationStatusSnapshot } from "./migrations/status.js";
|
|
9
|
+
import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
|
|
9
10
|
const db = databases;
|
|
10
11
|
const redactHome = (p) => {
|
|
11
12
|
const home = homedir();
|
|
@@ -206,7 +207,7 @@ export class HealthDetail extends Resource {
|
|
|
206
207
|
for await (const a of db.flair.Agent.search({}))
|
|
207
208
|
agents.push(a);
|
|
208
209
|
const blank = (id) => ({
|
|
209
|
-
id, memoryCount: 0, hashFallback: 0, writes24h: 0, lastWriteAt: null,
|
|
210
|
+
id, memoryCount: 0, hashFallback: 0, writes24h: 0, lastWriteAt: null, usageCount: 0,
|
|
210
211
|
});
|
|
211
212
|
const perAgentMap = new Map();
|
|
212
213
|
for (const a of agents) {
|
|
@@ -219,6 +220,7 @@ export class HealthDetail extends Resource {
|
|
|
219
220
|
continue;
|
|
220
221
|
const row = perAgentMap.get(m.agentId) ?? blank(m.agentId);
|
|
221
222
|
row.memoryCount++;
|
|
223
|
+
row.usageCount += (m.usageCount ?? 0);
|
|
222
224
|
if (!m.embeddingModel || m.embeddingModel === "hash-512d")
|
|
223
225
|
row.hashFallback++;
|
|
224
226
|
if (m.createdAt) {
|
|
@@ -499,6 +501,36 @@ export class HealthDetail extends Resource {
|
|
|
499
501
|
catch {
|
|
500
502
|
stats.rem = null;
|
|
501
503
|
}
|
|
504
|
+
// ── Dedup clusters (flair-quality Slice 1c) ──
|
|
505
|
+
// Cheap read: a single small stat file, not a recomputation. The
|
|
506
|
+
// computation itself is a nightly server-side sweep (POST
|
|
507
|
+
// /MemoryDedupStats, triggered once per REM nightly cycle — see
|
|
508
|
+
// src/rem/runner.ts + resources/MemoryDedupStats.ts's module doc) that
|
|
509
|
+
// NEVER runs inline with a GET /HealthDetail request. `null` covers both
|
|
510
|
+
// "REM hasn't computed it yet" (fresh instance) and "file unreadable" —
|
|
511
|
+
// `flair quality` renders either as an honest gap, never a false zero.
|
|
512
|
+
try {
|
|
513
|
+
const raw = await fsp.readFile(REM_DEDUP_STATS_PATH, "utf-8");
|
|
514
|
+
const parsed = JSON.parse(raw);
|
|
515
|
+
if (parsed &&
|
|
516
|
+
typeof parsed.clusterCount === "number" &&
|
|
517
|
+
typeof parsed.largestClusterSize === "number" &&
|
|
518
|
+
typeof parsed.totalMemoriesInClusters === "number" &&
|
|
519
|
+
typeof parsed.computedAt === "string") {
|
|
520
|
+
stats.dedup = {
|
|
521
|
+
clusterCount: parsed.clusterCount,
|
|
522
|
+
largestClusterSize: parsed.largestClusterSize,
|
|
523
|
+
totalMemoriesInClusters: parsed.totalMemoriesInClusters,
|
|
524
|
+
computedAt: parsed.computedAt,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
stats.dedup = null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
stats.dedup = null;
|
|
533
|
+
}
|
|
502
534
|
// ── Migrations (flair#695: zero-touch boot-keyed auto-migration) ──
|
|
503
535
|
// `{ id, rowsDone, rowsRemaining, state }` per registered migration, per
|
|
504
536
|
// flair#695 §A. `cyclePhase: "pre-hash"` is the
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import * as harper from "@harperfast/harper";
|
|
21
21
|
import { mcpOAuthEnabled, mcpAuthConfig } from "./mcp-oauth-flag.js";
|
|
22
|
-
import { mcpHandler } from "./mcp-handler.js";
|
|
23
22
|
async function defaultLoadWithMCPAuth() {
|
|
24
23
|
// Dynamic import so the dep is only required when the surface is enabled.
|
|
25
24
|
const mod = (await import("@harperfast/oauth"));
|
|
@@ -49,6 +48,9 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
49
48
|
console.error("[mcp-oauth] @harperfast/oauth has no withMCPAuth export — /mcp NOT mounted.");
|
|
50
49
|
return false;
|
|
51
50
|
}
|
|
51
|
+
// Resolve the handler lazily (injected in tests; real module otherwise) — see
|
|
52
|
+
// the top-of-file note on why it isn't a static import.
|
|
53
|
+
const handler = deps.mcpHandler ?? (await import("./mcp-handler.js")).mcpHandler;
|
|
52
54
|
// Read `server` lazily off the namespace (it's a runtime global on the Harper
|
|
53
55
|
// module, not a static named export) so this module links cleanly even where a
|
|
54
56
|
// stub build of @harperfast/harper lacks the export.
|
|
@@ -58,7 +60,7 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
58
60
|
// wrapper's iss/aud checks match the minted tokens even if this component
|
|
59
61
|
// resolves a different node_modules copy of the plugin (docs/mcp-oauth.md
|
|
60
62
|
// §"Using withMCPAuth from a different component").
|
|
61
|
-
srv.http(withMCPAuth(
|
|
63
|
+
srv.http(withMCPAuth(handler, {
|
|
62
64
|
getConfig: () => mcpAuthConfig(),
|
|
63
65
|
}), { urlPath: "/mcp" });
|
|
64
66
|
console.error(`[mcp-oauth] /mcp mounted (OAuth-guarded); issuer=${config.issuer}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"packageManager": "bun@1.3.10",
|
|
5
5
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
6
6
|
"type": "module",
|