@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 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/.
@@ -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. Append a row to ~/.flair/logs/rem-nightly.jsonl.
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 shipped in slice-1 PR-1.
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: log
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
+ }
@@ -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(mcpHandler, {
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.25.4",
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",