@tpsdev-ai/flair 0.51.2 → 0.52.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.
Files changed (71) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +575 -547
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/upgrade-exec-path.js +257 -0
  10. package/dist/lib/upgrade-plain-tree.js +558 -0
  11. package/dist/rem/promote-policy.js +204 -0
  12. package/dist/rem/restore.js +55 -15
  13. package/dist/rem/runner.js +203 -20
  14. package/dist/resources/AdminMemory.js +2 -1
  15. package/dist/resources/AgentSeed.js +26 -10
  16. package/dist/resources/Asset.js +203 -0
  17. package/dist/resources/AutoPromoteCandidates.js +2 -4
  18. package/dist/resources/Credential.js +14 -0
  19. package/dist/resources/Federation.js +80 -0
  20. package/dist/resources/Integration.js +12 -0
  21. package/dist/resources/Memory.js +158 -60
  22. package/dist/resources/MemoryBootstrap.js +63 -20
  23. package/dist/resources/MemoryCandidate.js +12 -0
  24. package/dist/resources/MemoryConsolidate.js +2 -1
  25. package/dist/resources/MemoryDedupStats.js +17 -2
  26. package/dist/resources/MemoryFeed.js +30 -0
  27. package/dist/resources/MemoryGrant.js +14 -0
  28. package/dist/resources/MemoryReflect.js +75 -17
  29. package/dist/resources/Message.js +190 -0
  30. package/dist/resources/OrgEvent.js +12 -0
  31. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  32. package/dist/resources/RecordUsage.js +1 -1
  33. package/dist/resources/Relationship.js +12 -0
  34. package/dist/resources/SemanticSearch.js +45 -13
  35. package/dist/resources/Soul.js +54 -18
  36. package/dist/resources/WorkspaceState.js +12 -0
  37. package/dist/resources/auth-middleware.js +17 -44
  38. package/dist/resources/authority-field-guard.js +37 -0
  39. package/dist/resources/bm25-index-service.js +1 -1
  40. package/dist/resources/bm25-index.js +50 -11
  41. package/dist/resources/embedding-space-guard.js +238 -0
  42. package/dist/resources/embeddings-provider.js +32 -5
  43. package/dist/resources/federation-classify.js +23 -1
  44. package/dist/resources/health.js +11 -2
  45. package/dist/resources/hit-tracking.js +244 -0
  46. package/dist/resources/mcp-tools.js +272 -7
  47. package/dist/resources/memory-reflect-lib.js +111 -0
  48. package/dist/resources/migrations/embedding-stamp.js +22 -4
  49. package/dist/resources/owner-field-guard.js +62 -0
  50. package/dist/resources/promotion-stamp.js +29 -0
  51. package/dist/resources/record-owner-guard.js +71 -5
  52. package/dist/resources/record-types.js +30 -7
  53. package/dist/resources/relay-lib.js +205 -0
  54. package/dist/resources/relay-ops.js +294 -0
  55. package/dist/resources/skill-write.js +120 -0
  56. package/dist/resources/soul-adk-guard.js +68 -0
  57. package/dist/resources/soul-write-policy.js +63 -0
  58. package/dist/resources/table-helpers.js +2 -0
  59. package/dist/resources/usage-recording.js +3 -3
  60. package/dist/src/rem/promote-policy.js +204 -0
  61. package/docs/api-reference.md +374 -0
  62. package/docs/auth.md +52 -0
  63. package/docs/federation.md +4 -0
  64. package/docs/integrations.md +6 -6
  65. package/docs/mcp-clients.md +16 -1
  66. package/docs/releasing.md +11 -8
  67. package/docs/rem.md +20 -2
  68. package/docs/upgrade.md +47 -2
  69. package/package.json +6 -5
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * embedding-space-guard.ts — the QUERY-TIME vector-space uniformity gate
3
+ * (embedding-provider-seam design §3, slice 1; K&S-final).
4
+ *
5
+ * ─── The bug this closes ────────────────────────────────────────────────────
6
+ * There is NO query-time embedding-space guard today. Search cosines a query
7
+ * against every stored vector regardless of its `embeddingModel` stamp;
8
+ * Harper's `cosineDistance` silently zero-pads a mismatched-dimension vector
9
+ * and returns a garbage score instead of throwing (same-dims/different-space
10
+ * is silent garbage too), and `health.ts` only *warns*. So a mixed-space
11
+ * corpus — the transient state during ANY re-embed / model change — serves
12
+ * silently-wrong recall. This module makes a mismatched-space cosine
13
+ * impossible by construction on BOTH runtime cosine legs (recall + write-time
14
+ * dedup), the load-bearing safety piece Kern made a condition of sign-off.
15
+ *
16
+ * ─── Shape ──────────────────────────────────────────────────────────────────
17
+ * A PURE core (`normalizeStamp` / `currentSpaceRawForms` / `isUniformStampSet`
18
+ * — Harper-free, unit-testable directly, same discipline as dedup.ts / bm25.ts)
19
+ * plus a boot-computed + write-maintained "corpus uniform in the current space"
20
+ * LATCH so the gate costs ~nothing per query:
21
+ * - boot pre-warm scans the DISTINCT active stamps once (module side effect,
22
+ * same convention as embeddings-boot.ts / migration-boot.ts);
23
+ * - a write persisting a FOREIGN stamp (federation / replication / an
24
+ * explicit-stamp PUT) trips the latch immediately, no rescan;
25
+ * - while tripped, a consult re-verifies at most once per RECHECK_MS, so a
26
+ * completed re-embed REOPENS the gate automatically (any re-embed path —
27
+ * the boot migration OR a live `flair reembed`), with no cross-process
28
+ * signal needed.
29
+ * Both runtime legs consult the SAME `isEmbeddingSpaceUniform()` — a single
30
+ * chokepoint (like `prefixesEnabled()`), never scattered per-call-site checks.
31
+ *
32
+ * ─── Why the harper import is deferred ──────────────────────────────────────
33
+ * `harper` is dynamic-imported inside the scan only (never a top-level import)
34
+ * — exactly the reason embeddings-provider.ts defers it: a static
35
+ * `import { databases } from "harper"` would make ANY test that imports this
36
+ * file for its pure core alone eagerly load Harper's real `dist/index.js`,
37
+ * which throws at module scope outside a real Harper boot. The pure exports
38
+ * carry no harper dependency at all.
39
+ */
40
+ import { EMBEDDING_ENGINE, getModelId } from "./embeddings-provider.js";
41
+ // ─── Pure core (Harper-free) ────────────────────────────────────────────────
42
+ /**
43
+ * Stamps that denote "no real vector space" and so never contribute to the
44
+ * uniformity set: an absent/empty stamp, and the legacy `hash-512d`
45
+ * hash-fallback marker (a row with no genuine model embedding). A missing or
46
+ * empty stored embedding cosines to a safe 0 (retrieval-core / dedup both
47
+ * guard `Array.isArray(embedding) ? … : []`), never a cross-space garbage
48
+ * score, so excluding these mirrors health.ts's own `realModels` filter.
49
+ */
50
+ const NON_SPACE_STAMPS = new Set(["hash-512d"]);
51
+ /**
52
+ * Canonicalize an `embeddingModel` stamp to its ENGINE-QUALIFIED space id, or
53
+ * `null` for a no-vector-space stamp. This is the one-time bare-name → `gguf:`
54
+ * equivalence: today's corpus is stamped with the BARE nomic name (no engine
55
+ * prefix), which denotes the SAME space as the engine-qualified id
56
+ * `getModelId()` now writes — so a bare stamp must NOT read as a foreign space
57
+ * and false-trip the gate. A qualified stamp (any engine, contains `:`) is
58
+ * already canonical; a bare stamp (no `:`) is the legacy gguf form and gets the
59
+ * `<engine>:` prefix. `getModelId()` guarantees a model id never itself
60
+ * contains `:` (it rejects a `FLAIR_EMBEDDING_MODEL` override that does), so a
61
+ * single `:` unambiguously separates engine from model.
62
+ */
63
+ export function normalizeStamp(stamp) {
64
+ if (stamp == null)
65
+ return null;
66
+ const s = String(stamp).trim();
67
+ if (s === "" || NON_SPACE_STAMPS.has(s))
68
+ return null;
69
+ return s.includes(":") ? s : `${EMBEDDING_ENGINE}:${s}`;
70
+ }
71
+ /** The bare model id of an engine-qualified stamp (`gguf:x` → `x`); a stamp
72
+ * with no engine prefix is returned unchanged. Inverse of the prefixing in
73
+ * `normalizeStamp`. */
74
+ export function stripEnginePrefix(stamp) {
75
+ const i = stamp.indexOf(":");
76
+ return i === -1 ? stamp : stamp.slice(i + 1);
77
+ }
78
+ /**
79
+ * The RAW stamp forms that all denote the CURRENT embedding space: the
80
+ * engine-qualified id `getModelId()` returns, plus its one-time bare-name
81
+ * equivalent. A stamp comparator (the migration's staleCondition, the CLI's
82
+ * `--stale-only`) treats a row as current-space iff its stamp is one of these,
83
+ * so a bare-stamped legacy row is never re-embedded as "stale". De-duplicated
84
+ * so a bare `currentModelId` (a unit-test injection) yields a single form.
85
+ */
86
+ export function currentSpaceRawForms(currentModelId) {
87
+ return [...new Set([currentModelId, stripEnginePrefix(currentModelId)])];
88
+ }
89
+ /** Pure: is `stamp` one of the current-space raw forms? (metadata-string
90
+ * compare only — never a vector-byte comparison; see
91
+ * embedding-identity-tripwire.test.ts / flair#749). */
92
+ export function isCurrentSpaceStamp(stamp, currentModelId) {
93
+ const n = normalizeStamp(stamp);
94
+ return n !== null && n === normalizeStamp(currentModelId);
95
+ }
96
+ /**
97
+ * Pure: is every active (real-vector) stamp in the set the current space? An
98
+ * empty set (fresh store) is uniform. No-vector stamps (null / `hash-512d`)
99
+ * are ignored — they carry no cross-space cosine hazard.
100
+ */
101
+ export function isUniformStampSet(distinctStamps, currentModelId) {
102
+ const current = normalizeStamp(currentModelId);
103
+ for (const raw of distinctStamps) {
104
+ const n = normalizeStamp(raw);
105
+ if (n === null)
106
+ continue;
107
+ if (n !== current)
108
+ return false;
109
+ }
110
+ return true;
111
+ }
112
+ async function defaultTableGetter() {
113
+ const { databases } = await import("harper");
114
+ return databases.flair.Memory;
115
+ }
116
+ let _tableGetter = defaultTableGetter;
117
+ let _modelIdGetter = getModelId;
118
+ // `undefined` = not yet computed (pre-boot). Consult treats this as "scan
119
+ // now"; a scan failure leaves it undefined so the next consult retries rather
120
+ // than caching a verdict we could not compute.
121
+ let _uniform = undefined;
122
+ let _activeStamps = []; // normalized, capped — for the degrade diagnostic only
123
+ let _lastScanAt = 0;
124
+ let _scanning;
125
+ // While the latch is TRIPPED, re-verify at most this often, so a completed
126
+ // re-embed reopens the gate without paying a scan on every degraded query.
127
+ // The happy (uniform) path never scans here at all — it returns in O(1).
128
+ const RECHECK_MS = 30_000;
129
+ const MAX_DIAG_STAMPS = 12;
130
+ async function scan() {
131
+ const current = normalizeStamp(_modelIdGetter());
132
+ const seen = new Set();
133
+ let uniform = true;
134
+ try {
135
+ const table = await _tableGetter();
136
+ // Project ONLY the stamp — never the vector — so the boot scan is far
137
+ // cheaper than health.ts's existing full-record corpus read.
138
+ for await (const row of table.search({ select: ["embeddingModel"] })) {
139
+ const n = normalizeStamp(row.embeddingModel);
140
+ if (n === null)
141
+ continue;
142
+ if (seen.size < MAX_DIAG_STAMPS)
143
+ seen.add(n);
144
+ if (n !== current)
145
+ uniform = false;
146
+ }
147
+ _activeStamps = [...seen];
148
+ _uniform = uniform;
149
+ _lastScanAt = Date.now();
150
+ }
151
+ catch {
152
+ // No live table yet (boot race) — leave the prior verdict untouched and
153
+ // retry on the next consult. Never cache a verdict we couldn't compute.
154
+ }
155
+ }
156
+ function runScan() {
157
+ if (!_scanning)
158
+ _scanning = scan().finally(() => { _scanning = undefined; });
159
+ return _scanning;
160
+ }
161
+ /**
162
+ * THE single chokepoint. Returns true when the store is uniform in the current
163
+ * embedding space (safe to cosine the query/candidate against stored vectors),
164
+ * false when it is not (a mixed-space corpus — the caller must NOT cosine).
165
+ *
166
+ * O(1) on the happy path: once known-uniform, no scan, no allocation. Only
167
+ * (re)scans when the latch is unknown (pre-boot) or tripped-and-stale.
168
+ */
169
+ export async function isEmbeddingSpaceUniform() {
170
+ if (_uniform === true)
171
+ return true;
172
+ if (_uniform === undefined || Date.now() - _lastScanAt >= RECHECK_MS) {
173
+ await runScan();
174
+ }
175
+ return _uniform ?? true;
176
+ }
177
+ /**
178
+ * Write-maintained trip: a persisted FOREIGN-space stamp closes the gate
179
+ * immediately, no rescan. A normal local write always stamps the current id
180
+ * (`getModelId()`), so it never trips; only a federation/replication write or
181
+ * an explicit-stamp PUT carrying another space's stamp does.
182
+ */
183
+ export function noteWriteStamp(stamp) {
184
+ const n = normalizeStamp(stamp);
185
+ if (n === null)
186
+ return;
187
+ if (n !== normalizeStamp(_modelIdGetter())) {
188
+ if (!_activeStamps.includes(n) && _activeStamps.length < MAX_DIAG_STAMPS)
189
+ _activeStamps.push(n);
190
+ _uniform = false;
191
+ }
192
+ }
193
+ /**
194
+ * Force a fresh corpus scan and return the resulting uniformity. The explicit
195
+ * "re-embed completion clears it" primitive — used by the boot pre-warm, and
196
+ * callable after a bulk re-embed to reopen the gate promptly rather than
197
+ * waiting out RECHECK_MS.
198
+ */
199
+ export async function recomputeLatch() {
200
+ _lastScanAt = 0; // bypass the throttle
201
+ await scan();
202
+ return _uniform ?? true;
203
+ }
204
+ /**
205
+ * Structured diagnostic for the degrade path — the current space id and the
206
+ * (capped) set of distinct active stamps observed, so the recall leg's
207
+ * `_warning` can name both spaces + the `flair reembed` remedy. Metadata
208
+ * strings only, never vectors.
209
+ */
210
+ export function spaceGuardDiagnostics() {
211
+ return { current: normalizeStamp(_modelIdGetter()) ?? _modelIdGetter(), found: [..._activeStamps] };
212
+ }
213
+ // ─── Boot pre-warm (module side effect) ──────────────────────────────────────
214
+ // Best-effort, single-shot, deferred to after the current synchronous load
215
+ // phase (same setImmediate convention as migration-boot.ts). If it fires
216
+ // before the Memory table is live, the scan silently no-ops and the first real
217
+ // consult computes the latch lazily — correctness never depends on this
218
+ // pre-warm, only the first query's latency does.
219
+ let _prewarmScheduled = false;
220
+ export function scheduleSpaceGuardPrewarm() {
221
+ if (_prewarmScheduled)
222
+ return;
223
+ _prewarmScheduled = true;
224
+ setImmediate(() => { void recomputeLatch().catch(() => { }); });
225
+ }
226
+ // Test-only seams — mirror embeddings-boot.ts's `_resetEmbeddingsBackendRegistrationForTests`.
227
+ export function _setGuardTableGetterForTests(fn) { _tableGetter = fn; }
228
+ export function _setGuardModelIdForTests(fn) { _modelIdGetter = fn; }
229
+ export function _resetGuardForTests() {
230
+ _tableGetter = defaultTableGetter;
231
+ _modelIdGetter = getModelId;
232
+ _uniform = undefined;
233
+ _activeStamps = [];
234
+ _lastScanAt = 0;
235
+ _scanning = undefined;
236
+ _prewarmScheduled = false;
237
+ }
238
+ scheduleSpaceGuardPrewarm();
@@ -367,14 +367,31 @@ export function getMode() {
367
367
  * mass "stale" transition is this flip's intended payload, not a bug.
368
368
  */
369
369
  const EMBEDDING_VARIANT = "searchprefix";
370
+ /**
371
+ * The embedding ENGINE identity carried by every model-id stamp
372
+ * (embedding-provider-seam design, slice 1). `gguf` is the only engine today
373
+ * (harper-fabric-embeddings' GGUF backend); the seam that SELECTS an
374
+ * alternative engine is slice 2 — this slice only QUALIFIES the stamp so the
375
+ * vector-space guard can tell two engines apart even when they share a model
376
+ * name/dims (dims-only would silently pass a mixed comparison — Sherlock
377
+ * binding req #3). Kept next to getModelId() so the stamp and
378
+ * resources/embedding-space-guard.ts's one-time bare-name → `gguf:`
379
+ * equivalence read the SAME constant and cannot drift.
380
+ */
381
+ export const EMBEDDING_ENGINE = "gguf";
370
382
  /**
371
383
  * Get the current embedding model identifier.
372
384
  * Used for stamping memories and detecting stale embeddings. Reads the SAME
373
385
  * `prefixesEnabled()` chokepoint `buildEmbedOptions()` does (see THE GATE's
374
- * doc above `EMBEDDING_PREFIXES_ENABLED`) — gate on (default): bumps to
375
- * `<base>+searchprefix` (see `EMBEDDING_VARIANT` above). Gate off (only
376
- * reachable now via the bench-only override, see `harnessPrefixOverride()`):
377
- * bare base id, no suffix. A prefixed vector and an unprefixed vector of the
386
+ * doc above `EMBEDDING_PREFIXES_ENABLED`) — gate on (default): returns the
387
+ * engine-qualified `<engine>:<base>+searchprefix` (see `EMBEDDING_ENGINE` /
388
+ * `EMBEDDING_VARIANT` above). Gate off (only reachable now via the bench-only
389
+ * override, see `harnessPrefixOverride()`): `<engine>:<base>`, no suffix. The
390
+ * `<engine>:` prefix is new in embedding-space-guard slice 1; a legacy BARE
391
+ * stamp (no prefix) denotes the SAME gguf space and is reconciled by
392
+ * resources/embedding-space-guard.ts's normalizeStamp() and the migration/CLI
393
+ * stale comparators, so today's corpus is NOT read as stale. A prefixed vector
394
+ * and an unprefixed vector of the
378
395
  * SAME text are genuinely different vectors (dedup must not short-circuit
379
396
  * across them), and `--stale-only` needs a distinct string to target the
380
397
  * rows that still need re-embedding. `+` is URL-safe and doesn't collide
@@ -384,7 +401,17 @@ const EMBEDDING_VARIANT = "searchprefix";
384
401
  */
385
402
  export function getModelId() {
386
403
  const base = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
387
- return prefixesEnabled() ? `${base}+${EMBEDDING_VARIANT}` : base;
404
+ // The bare-name override must not collide with the `<engine>:<model>` stamp
405
+ // format — a ':' in the base makes the qualified stamp ambiguous
406
+ // (`gguf:a:b`) and would break the guard's engine/model split. Fail loudly at
407
+ // the stamp site rather than silently writing an un-parseable id (a shipped
408
+ // config default is a trust anchor: derive or fail, never fail-open).
409
+ if (base.includes(":")) {
410
+ throw new Error(`[embeddings] FLAIR_EMBEDDING_MODEL must not contain ':' — it is reserved for the ` +
411
+ `<engine>:<model> embedding stamp (embedding-space-guard slice 1); got ${JSON.stringify(base)}`);
412
+ }
413
+ const suffix = prefixesEnabled() ? `+${EMBEDDING_VARIANT}` : "";
414
+ return `${EMBEDDING_ENGINE}:${base}${suffix}`;
388
415
  }
389
416
  /**
390
417
  * Get embedding engine status for diagnostics.
@@ -23,7 +23,28 @@ export const FEDERATION_TABLE_POLICY = {
23
23
  Soul: { principalOwning: false },
24
24
  Agent: { principalOwning: false },
25
25
  Relationship: { principalOwning: false },
26
+ // Flair Relay S1 (flair#1521): the Message envelope's owning principal is the
27
+ // SENDER (`from`), not `agentId` — see PRINCIPAL_OWNER_FIELD below. S1 does
28
+ // not sync Message cross-host (the spoke push list in src/cli.ts is a separate
29
+ // hardcoded set), but the policy + owner-field land now so S2 is not a schema
30
+ // or policy migration (the design's ship-order, flair#1521 §12).
31
+ Message: { principalOwning: true },
26
32
  };
33
+ /**
34
+ * Per-table owning-principal field. `checkPrincipalEntitlement` used to hardcode
35
+ * the owner as `data.agentId` (correct for Memory, the only principal-owning
36
+ * table then). Message owns by `from`, so the owner field is now a per-table
37
+ * map rather than a literal. Tables absent here fall back to `agentId` — only
38
+ * principal-owning tables ever reach the check, and Memory keeps its field.
39
+ */
40
+ export const PRINCIPAL_OWNER_FIELD = {
41
+ Memory: "agentId",
42
+ Message: "from",
43
+ };
44
+ /** The owning-principal field for a table (default `agentId`). */
45
+ export function principalOwnerField(table) {
46
+ return PRINCIPAL_OWNER_FIELD[table] ?? "agentId";
47
+ }
27
48
  export const FEDERATION_SYNC_TABLES = Object.keys(FEDERATION_TABLE_POLICY);
28
49
  export const PRINCIPAL_OWNING_TABLES = new Set(FEDERATION_SYNC_TABLES.filter((t) => FEDERATION_TABLE_POLICY[t].principalOwning));
29
50
  /** Wire `v` when present; assume 1 when absent (today's records omit it). */
@@ -80,11 +101,12 @@ export function checkPrincipalEntitlement(record, opts = {}) {
80
101
  if (!PRINCIPAL_OWNING_TABLES.has(record.table)) {
81
102
  return null;
82
103
  }
104
+ const ownerField = principalOwnerField(record.table);
83
105
  const v = recordSignatureVersion(record);
84
106
  if (v >= 2) {
85
107
  if (typeof record.principalId !== "string" ||
86
108
  record.principalId.length === 0 ||
87
- record.principalId !== record.data?.agentId) {
109
+ record.principalId !== record.data?.[ownerField]) {
88
110
  return "principal_mismatch";
89
111
  }
90
112
  return null;
@@ -10,6 +10,7 @@ import { resolveMigrationDataDirForRead } from "./migrations/data-dir.js";
10
10
  import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
11
11
  import { hybridEnabled } from "./bm25.js";
12
12
  import { bm25IndexEnabled, bm25IndexStatus } from "./bm25-index-service.js";
13
+ import { normalizeStamp } from "./embedding-space-guard.js";
13
14
  import { buildPublicHealthBody, resolveSearchReadiness } from "./search-readiness.js";
14
15
  const db = databases;
15
16
  const redactHome = (p) => {
@@ -248,8 +249,16 @@ export class HealthDetail extends Resource {
248
249
  });
249
250
  }
250
251
  }
251
- // Mixed embedding models — searches across vector spaces return garbage.
252
- if (realModels.length > 1) {
252
+ // Mixed embedding SPACES — searches across vector spaces return garbage.
253
+ // Count distinct SPACES, not raw stamps: after the engine-qualified stamp
254
+ // (embedding-space-guard slice 1) a healthy corpus can carry BOTH the
255
+ // qualified id (`gguf:…`) and its bare-name equivalent (today's rows) for
256
+ // the SAME space — normalize so that pairing is one space, not a false
257
+ // "mixed" warning. A genuine multi-space corpus (a real mid-flight
258
+ // re-embed) still trips it, and that is exactly when the query-time guard
259
+ // degrades recall to keyword-only.
260
+ const distinctSpaces = new Set(realModels.map((k) => normalizeStamp(k)).filter((s) => s !== null));
261
+ if (distinctSpaces.size > 1) {
253
262
  const list = realModels.map((k) => `${k}:${modelCounts[k]}`).join(", ");
254
263
  warnings.push({
255
264
  level: "warn",
@@ -0,0 +1,244 @@
1
+ /**
2
+ * hit-tracking.ts — search-hit counters without rewriting Memory (flair#1528).
3
+ *
4
+ * SemanticSearch used to `patchRecord` every returned hit:
5
+ * retrievalCount: (row.retrievalCount || 0) + 1
6
+ * that is a full-row Harper put (embeddings included). Concurrent searches
7
+ * of the same hits lost increments (read-modify-write on the retrieved
8
+ * snapshot) and still wrote storage on a read path.
9
+ *
10
+ * This module is the remaining #1528 slice (BM25 metadata-skip already
11
+ * landed in #1545 and is not revisited here):
12
+ * - increments go to MemoryHitStat, a small per-id row, never Memory
13
+ * - per-id promise tails serialize flushes and coalesce pending deltas
14
+ * so overlapping hits add instead of racing
15
+ * - GET /Memory/{id}, Memory.search, AdminMemory detail, and consolidate
16
+ * overlay the stat row so retrievalCount / lastRetrieved stay the
17
+ * observable Memory fields they have always been
18
+ *
19
+ * Fire-and-forget from SemanticSearch: a failed flush is swallowed, same
20
+ * best-effort class as the previous `.catch(() => {})` patch. Bootstrap
21
+ * still does not call this module.
22
+ *
23
+ * Every Harper call is wrapped individually in withDetachedTxn — never one
24
+ * wrap around a multi-await helper. Search already read Memory on the
25
+ * request context; a later HitStat get/put on that closed chain would
26
+ * inherit the closed transaction (table-helpers.ts / usage-recording.ts).
27
+ */
28
+ import { databases } from "harper";
29
+ import { withDetachedTxn } from "./table-helpers.js";
30
+ function asRow(value, id) {
31
+ if (!value)
32
+ return null;
33
+ return {
34
+ id,
35
+ retrievalCount: Number(value.retrievalCount) || 0,
36
+ lastRetrieved: value.lastRetrieved,
37
+ };
38
+ }
39
+ export class HitTracker {
40
+ tables;
41
+ pending = new Map();
42
+ tails = new Map();
43
+ cache = new Map();
44
+ metricsState = { writes: 0, successfulPuts: 0, failedPuts: 0 };
45
+ constructor(tables) {
46
+ this.tables = tables;
47
+ }
48
+ get metrics() {
49
+ return { ...this.metricsState };
50
+ }
51
+ noteHits(ids, now, ctx) {
52
+ for (const id of ids) {
53
+ if (typeof id !== "string" || id.length === 0)
54
+ continue;
55
+ const prev = this.pending.get(id);
56
+ if (prev) {
57
+ prev.delta += 1;
58
+ prev.lastRetrieved = now;
59
+ }
60
+ else {
61
+ this.pending.set(id, { delta: 1, lastRetrieved: now });
62
+ }
63
+ this.enqueueFlush(id, ctx);
64
+ }
65
+ }
66
+ async apply(record, ctx) {
67
+ if (!record || typeof record !== "object")
68
+ return record;
69
+ const id = record.id;
70
+ if (typeof id !== "string" || id.length === 0)
71
+ return record;
72
+ const stat = await this.statFor(id, ctx);
73
+ if (!stat)
74
+ return record;
75
+ const current = record;
76
+ if (current.retrievalCount === stat.retrievalCount && current.lastRetrieved === stat.lastRetrieved) {
77
+ return record;
78
+ }
79
+ return { ...record, retrievalCount: stat.retrievalCount, lastRetrieved: stat.lastRetrieved };
80
+ }
81
+ async statFor(id, ctx) {
82
+ const cached = this.cache.get(id);
83
+ if (cached)
84
+ return cached;
85
+ const read = await this.readStat(id, ctx);
86
+ if (read.kind !== "hit")
87
+ return null;
88
+ this.cache.set(id, read.row);
89
+ return read.row;
90
+ }
91
+ async whenIdle() {
92
+ for (let spins = 0; spins < 32; spins++) {
93
+ for (const id of this.pending.keys())
94
+ this.enqueueFlush(id);
95
+ if (this.tails.size === 0 && ![...this.pending.values()].some((p) => p.delta > 0))
96
+ return;
97
+ await Promise.all([...this.tails.values()]);
98
+ }
99
+ }
100
+ async clear(id, ctx) {
101
+ this.pending.delete(id);
102
+ this.cache.delete(id);
103
+ if (!this.tables.stats.delete)
104
+ return;
105
+ await withDetachedTxn(ctx, () => this.tables.stats.delete(id));
106
+ }
107
+ enqueueFlush(id, ctx) {
108
+ const prev = this.tails.get(id) ?? Promise.resolve();
109
+ const next = prev
110
+ .then(() => this.runFlush(id, ctx))
111
+ .catch(() => { })
112
+ .finally(() => {
113
+ if (this.tails.get(id) === next)
114
+ this.tails.delete(id);
115
+ });
116
+ this.tails.set(id, next);
117
+ }
118
+ async runFlush(id, ctx) {
119
+ const taken = this.pending.get(id);
120
+ if (!taken || taken.delta <= 0)
121
+ return;
122
+ this.pending.delete(id);
123
+ const read = await this.readStat(id, ctx);
124
+ // A get error is not a miss. Memory.retrievalCount is frozen once
125
+ // increments moved off the Memory row — seeding from it and putting
126
+ // would overwrite the unread committed HitStat count (Bugbot on #1565).
127
+ if (read.kind === "error") {
128
+ this.requeue(id, taken);
129
+ return;
130
+ }
131
+ let base = read.kind === "hit" ? read.row.retrievalCount : null;
132
+ if (base == null) {
133
+ const memory = await withDetachedTxn(ctx, () => this.tables.memory.get(id)).catch(() => null);
134
+ base = memory?.retrievalCount ?? 0;
135
+ }
136
+ const row = {
137
+ id,
138
+ retrievalCount: base + taken.delta,
139
+ lastRetrieved: taken.lastRetrieved,
140
+ };
141
+ this.metricsState.writes += 1;
142
+ try {
143
+ await withDetachedTxn(ctx, () => this.tables.stats.put(row));
144
+ this.metricsState.successfulPuts += 1;
145
+ this.cache.set(id, row);
146
+ }
147
+ catch {
148
+ this.metricsState.failedPuts += 1;
149
+ this.requeue(id, taken);
150
+ }
151
+ }
152
+ requeue(id, taken) {
153
+ const again = this.pending.get(id);
154
+ if (again) {
155
+ again.delta += taken.delta;
156
+ if (taken.lastRetrieved > (again.lastRetrieved ?? ""))
157
+ again.lastRetrieved = taken.lastRetrieved;
158
+ }
159
+ else {
160
+ this.pending.set(id, taken);
161
+ }
162
+ }
163
+ async readStat(id, ctx) {
164
+ try {
165
+ const raw = await withDetachedTxn(ctx, () => this.tables.stats.get(id));
166
+ const row = asRow(raw, id);
167
+ return row ? { kind: "hit", row } : { kind: "miss" };
168
+ }
169
+ catch {
170
+ return { kind: "error" };
171
+ }
172
+ }
173
+ }
174
+ function liveTables() {
175
+ const statsTable = () => databases.flair?.MemoryHitStat;
176
+ const memoryTable = () => databases.flair?.Memory;
177
+ return {
178
+ stats: {
179
+ get: async (id) => {
180
+ const table = statsTable();
181
+ if (!table?.get)
182
+ return null;
183
+ return table.get(id);
184
+ },
185
+ put: async (row) => {
186
+ const table = statsTable();
187
+ if (!table?.put)
188
+ return;
189
+ return table.put(row);
190
+ },
191
+ delete: async (id) => {
192
+ const table = statsTable();
193
+ if (!table?.delete)
194
+ return;
195
+ return table.delete(id);
196
+ },
197
+ },
198
+ memory: {
199
+ get: async (id) => {
200
+ const table = memoryTable();
201
+ if (!table?.get)
202
+ return null;
203
+ return table.get(id);
204
+ },
205
+ },
206
+ };
207
+ }
208
+ let live;
209
+ export function liveHitTracker() {
210
+ live ??= new HitTracker(liveTables());
211
+ return live;
212
+ }
213
+ /** Fire-and-forget increment for SemanticSearch's returned hit ids. */
214
+ export function noteSearchHits(ids, now, ctx) {
215
+ liveHitTracker().noteHits(ids, now, ctx);
216
+ }
217
+ /** Overlay MemoryHitStat onto a Memory-shaped record. */
218
+ export function applyHitStats(record, ctx) {
219
+ return liveHitTracker().apply(record, ctx);
220
+ }
221
+ /** Overlay an async-iterable / thenable Memory search result. */
222
+ export function overlayHitStatsResult(result, ctx) {
223
+ if (result && typeof result.then === "function") {
224
+ return result.then((value) => overlayHitStatsResult(value, ctx));
225
+ }
226
+ if (!result || result instanceof Response)
227
+ return result;
228
+ if (typeof result[Symbol.asyncIterator] === "function") {
229
+ const tracker = liveHitTracker();
230
+ return (async function* overlayHits() {
231
+ for await (const row of result) {
232
+ yield await tracker.apply(row, ctx);
233
+ }
234
+ })();
235
+ }
236
+ return liveHitTracker().apply(result, ctx);
237
+ }
238
+ export function clearHitStats(id, ctx) {
239
+ return liveHitTracker().clear(id, ctx);
240
+ }
241
+ /** Test-only: drop the process singleton so the next call rebuilds it. */
242
+ export function resetLiveHitTracker() {
243
+ live = undefined;
244
+ }