@tpsdev-ai/flair 0.51.2 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1037 -566
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/launchd-repair.js +198 -0
- package/dist/lib/stabilize-mqtt-network.js +123 -0
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +6 -5
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
- package/templates/launchd/start-flair-with-admin-pass.sh +73 -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):
|
|
375
|
-
* `<base>+searchprefix` (see `
|
|
376
|
-
* reachable now via the bench-only
|
|
377
|
-
*
|
|
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
|
-
|
|
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?.
|
|
109
|
+
record.principalId !== record.data?.[ownerField]) {
|
|
88
110
|
return "principal_mismatch";
|
|
89
111
|
}
|
|
90
112
|
return null;
|
package/dist/resources/health.js
CHANGED
|
@@ -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
|
|
252
|
-
|
|
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
|
+
}
|