@tpsdev-ai/flair 0.29.0 → 0.31.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 +194 -377
- package/dist/cli-shim.cjs +1 -1
- package/dist/cli.js +1567 -366
- package/dist/deploy.js +241 -43
- package/dist/fabric-upgrade.js +77 -24
- package/dist/federation/scheduler.js +500 -0
- package/dist/fleet-verify.js +3 -3
- package/dist/install/clients.js +111 -53
- package/dist/lib/mcp-enable.js +2 -2
- package/dist/lib/mcp-spec.js +128 -0
- package/dist/lib/safe-snapshot-extract.js +231 -0
- package/dist/lib/scheduler-platform.js +128 -0
- package/dist/lib/xml-escape.js +54 -0
- package/dist/rem/scheduler.js +212 -55
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/A2AAdapter.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +1 -1
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +1 -1
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +1 -1
- package/dist/resources/Credential.js +1 -1
- package/dist/resources/Federation.js +1 -1
- package/dist/resources/Instance.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +1 -1
- package/dist/resources/Memory.js +1 -1
- package/dist/resources/MemoryBootstrap.js +8 -9
- package/dist/resources/MemoryCandidate.js +168 -0
- package/dist/resources/MemoryConsolidate.js +1 -1
- package/dist/resources/MemoryDedupStats.js +1 -1
- package/dist/resources/MemoryFeed.js +1 -1
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +2 -2
- package/dist/resources/MemoryReindex.js +1 -1
- package/dist/resources/MemoryUsage.js +1 -1
- package/dist/resources/OAuth.js +1 -1
- package/dist/resources/OAuthClient.js +1 -1
- package/dist/resources/OrgEvent.js +1 -1
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/OrgEventMaintenance.js +1 -1
- package/dist/resources/PairingToken.js +1 -1
- package/dist/resources/Peer.js +1 -1
- package/dist/resources/Presence.js +3 -3
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +1 -1
- package/dist/resources/SemanticSearch.js +18 -46
- package/dist/resources/SkillScan.js +1 -1
- package/dist/resources/Soul.js +1 -1
- package/dist/resources/SoulFeed.js +1 -1
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/XAA.js +1 -1
- package/dist/resources/a2a-url.js +1 -1
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +1 -1
- package/dist/resources/auth-middleware.js +1 -1
- package/dist/resources/embeddings-boot.js +14 -16
- package/dist/resources/embeddings-provider.js +15 -12
- package/dist/resources/federation-cleanup.js +5 -5
- package/dist/resources/federation-nonce-store.js +5 -5
- package/dist/resources/health.js +53 -25
- package/dist/resources/in-process.js +225 -0
- package/dist/resources/instance-identity.js +1 -1
- package/dist/resources/mcp-client-metadata-fields.js +1 -1
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-oauth.js +2 -2
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/memory-read-scope.js +1 -1
- package/dist/resources/memory-reflect-lib.js +3 -3
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +81 -11
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/embedding-stamp.js +3 -3
- package/dist/resources/migrations/graph-heal.js +2 -2
- package/dist/resources/migrations/ledger.js +1 -1
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/registry.js +14 -5
- package/dist/resources/migrations/runner.js +93 -13
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/migrations/synthetic-test-migration.js +1 -1
- package/dist/resources/migrations/visibility-backfill.js +232 -0
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/record-types.js +28 -0
- package/dist/resources/semantic-retrieval-core.js +6 -5
- package/dist/resources/usage-recording.js +1 -1
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +397 -0
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +6 -0
- package/docs/embedding-in-a-harper-app.md +299 -0
- package/docs/federation.md +61 -4
- package/docs/integrations.md +3 -0
- package/docs/mcp-clients.md +16 -7
- package/docs/quickstart.md +80 -54
- package/docs/releasing.md +72 -38
- package/docs/supply-chain-policy.md +40 -4
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +100 -5
- package/package.json +4 -14
- package/schemas/memory.graphql +9 -1
- package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
- package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
- package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
- package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
- package/dist/resources/rerank-provider.js +0 -569
- package/docs/rerank-provisioning.md +0 -101
|
@@ -151,7 +151,21 @@ async function runCycleLocked(deps, lock) {
|
|
|
151
151
|
const candidates = [];
|
|
152
152
|
for (const migration of deps.registry.list()) {
|
|
153
153
|
if (isShortCircuited(state, migration.id, deps.runningVersion)) {
|
|
154
|
-
|
|
154
|
+
// flair#812: a short-circuit is an ASSERTION READ OFF A FILE, not an
|
|
155
|
+
// observation of the corpus — and that file is hand-editable (the
|
|
156
|
+
// documented remediation for a stuck migration is to correct it by
|
|
157
|
+
// hand). Reporting it as plain "completed" made a hand-written entry
|
|
158
|
+
// indistinguishable from a migration this boot actually verified.
|
|
159
|
+
// The skip itself is unchanged (it is the documented cheap path); it
|
|
160
|
+
// now just says what it is, so `flair doctor` never presents an
|
|
161
|
+
// unverified claim as a verified one.
|
|
162
|
+
setMigrationProgress({
|
|
163
|
+
id: migration.id,
|
|
164
|
+
rowsDone: 0,
|
|
165
|
+
rowsRemaining: 0,
|
|
166
|
+
state: "completed",
|
|
167
|
+
reason: `recorded complete at ${deps.runningVersion} in ${deps.statePath} — not re-verified this boot`,
|
|
168
|
+
});
|
|
155
169
|
continue;
|
|
156
170
|
}
|
|
157
171
|
setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "checking" });
|
|
@@ -160,12 +174,18 @@ async function runCycleLocked(deps, lock) {
|
|
|
160
174
|
pending = await migration.detect();
|
|
161
175
|
}
|
|
162
176
|
catch (err) {
|
|
177
|
+
// flair#812: a throwing detect() removes this migration from the
|
|
178
|
+
// candidate set, so a cycle where EVERY detect() throws used to reach
|
|
179
|
+
// the `nothing pending` return and look like a clean no-op. Log it
|
|
180
|
+
// with the standard marker as well as recording it in progress.
|
|
181
|
+
const reason = `detect() threw: ${err?.message ?? String(err)}`;
|
|
182
|
+
console.error(`[flair-migrations] ${migration.id}: ${reason}`);
|
|
163
183
|
setMigrationProgress({
|
|
164
184
|
id: migration.id,
|
|
165
185
|
rowsDone: 0,
|
|
166
186
|
rowsRemaining: 0,
|
|
167
187
|
state: "failed",
|
|
168
|
-
reason
|
|
188
|
+
reason,
|
|
169
189
|
});
|
|
170
190
|
continue;
|
|
171
191
|
}
|
|
@@ -176,11 +196,20 @@ async function runCycleLocked(deps, lock) {
|
|
|
176
196
|
candidates.push(migration);
|
|
177
197
|
}
|
|
178
198
|
if (candidates.length === 0) {
|
|
199
|
+
// flair#812: mark the cycle done even on the no-op path. "Nothing was
|
|
200
|
+
// pending" and "the cycle never ran" are opposite conclusions that used
|
|
201
|
+
// to produce an IDENTICAL `/HealthDetail` reading (`cyclePhase: "idle",
|
|
202
|
+
// lastCycleAt: null`), which is exactly why a totally-skipped boot cycle
|
|
203
|
+
// looked healthy for months. A cycle that reached this line has done its
|
|
204
|
+
// job, and now says so.
|
|
205
|
+
setCyclePhase("done");
|
|
179
206
|
return { ran: false, reason: "nothing pending" };
|
|
180
207
|
}
|
|
181
|
-
// ── ONE shared async pre-hash
|
|
182
|
-
//
|
|
183
|
-
//
|
|
208
|
+
// ── ONE shared async pre-hash at cycle start (K&S: computed after ready,
|
|
209
|
+
// before any migration's first write; failure → halt all pending
|
|
210
|
+
// candidates rather than guessing which ones are safe). Re-baselined
|
|
211
|
+
// below iff an earlier migration's own writes stale it for a later
|
|
212
|
+
// full-envelope gate — see the staleness comment on the loop. ──
|
|
184
213
|
setCyclePhase("pre-hash");
|
|
185
214
|
let envelope;
|
|
186
215
|
try {
|
|
@@ -195,8 +224,49 @@ async function runCycleLocked(deps, lock) {
|
|
|
195
224
|
return { ran: false, reason };
|
|
196
225
|
}
|
|
197
226
|
setCyclePhase("running");
|
|
227
|
+
// The cycle-start envelope is a valid gate BASELINE only until a migration
|
|
228
|
+
// in this same cycle writes rows: a legitimate, already-gated, ledgered
|
|
229
|
+
// write by an EARLIER migration (e.g. visibility-backfill stamping
|
|
230
|
+
// `visibility` — a SOURCE field) would otherwise read as corruption to a
|
|
231
|
+
// later migration's count+full-envelope gate and spuriously halt it
|
|
232
|
+
// ("completion gate failed: rowsRemaining=0, hash envelope mismatch" — the
|
|
233
|
+
// CI-observed failure on PR #845). That gate exists to catch writes the
|
|
234
|
+
// GATED migration didn't declare, not its predecessors' own completed
|
|
235
|
+
// work. So: track staleness conservatively (ANY rows written by any
|
|
236
|
+
// earlier migration, source-field or not — one cheap recompute beats
|
|
237
|
+
// enumerating which fields each migration touches), and re-baseline with a
|
|
238
|
+
// fresh computeCorpusEnvelope immediately BEFORE a full-envelope consumer
|
|
239
|
+
// starts — still before that migration's own first write, which is exactly
|
|
240
|
+
// the invariant-IV wording ("computed before first write and after
|
|
241
|
+
// completion; must match"). Cycles with no full-envelope consumer after a
|
|
242
|
+
// writing migration (every production cycle today — no schema-additive
|
|
243
|
+
// migration is registered outside CI) never pay for a second corpus walk.
|
|
244
|
+
let envelopeStale = false;
|
|
198
245
|
for (const migration of candidates) {
|
|
199
|
-
|
|
246
|
+
if (envelopeStale && postureFor(migration.riskClass).gate === "count+full-envelope") {
|
|
247
|
+
try {
|
|
248
|
+
envelope = await computeCorpusEnvelope(deps.getTable, deps.now);
|
|
249
|
+
envelopeStale = false;
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
// Same failure class as the cycle-start pre-hash, but scoped: only
|
|
253
|
+
// THIS migration consumes the baseline, so only it halts (the
|
|
254
|
+
// cycle-start variant halts every candidate because nothing has run
|
|
255
|
+
// yet and a failing corpus walk taints them all equally). Halted ≠
|
|
256
|
+
// bricked: it is retried on the next boot's cycle.
|
|
257
|
+
setMigrationProgress({
|
|
258
|
+
id: migration.id,
|
|
259
|
+
rowsDone: 0,
|
|
260
|
+
rowsRemaining: 0,
|
|
261
|
+
state: "halted",
|
|
262
|
+
reason: `pre-flight integrity check failed: ${err?.message ?? String(err)}`,
|
|
263
|
+
});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const wroteRows = await runOneMigration(migration, envelope, deps, lock);
|
|
268
|
+
if (wroteRows)
|
|
269
|
+
envelopeStale = true;
|
|
200
270
|
}
|
|
201
271
|
setCyclePhase("done");
|
|
202
272
|
return { ran: true };
|
|
@@ -243,6 +313,15 @@ async function haltMigration(migration, reason, ctx) {
|
|
|
243
313
|
/* best-effort — the in-memory progress + ledger event already recorded the halt */
|
|
244
314
|
}
|
|
245
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Returns whether this migration MAY have written any rows — the cycle
|
|
318
|
+
* loop's envelope-staleness signal (see runCycleLocked). Deliberately
|
|
319
|
+
* conservative: `true` whenever rows were processed OR run() threw
|
|
320
|
+
* mid-batch (a partial batch may have landed before the throw); a false
|
|
321
|
+
* `true` merely costs one extra corpus walk before the next full-envelope
|
|
322
|
+
* gate, while a false `false` would let that gate compare against a stale
|
|
323
|
+
* baseline and spuriously halt.
|
|
324
|
+
*/
|
|
246
325
|
async function runOneMigration(migration, envelope, deps, lock) {
|
|
247
326
|
const startedAt = deps.now().toISOString();
|
|
248
327
|
const state = readMigrationState(deps.statePath);
|
|
@@ -262,7 +341,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
262
341
|
hashEnvelopeMatch: null,
|
|
263
342
|
state,
|
|
264
343
|
});
|
|
265
|
-
return;
|
|
344
|
+
return false; // halted before any write
|
|
266
345
|
}
|
|
267
346
|
const estSnapshot = estimateSnapshotBytes(posture.snapshotScope, initialRemaining);
|
|
268
347
|
const estWorkingSet = estimateWorkingSetBytes(migration.riskClass, initialRemaining);
|
|
@@ -282,7 +361,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
282
361
|
hashEnvelopeMatch: null,
|
|
283
362
|
state,
|
|
284
363
|
});
|
|
285
|
-
return;
|
|
364
|
+
return false; // halted before any write
|
|
286
365
|
}
|
|
287
366
|
// ── Ladder step 3 (+ step 4 fallback): risk-scoped snapshot, or a
|
|
288
367
|
// content-only export if the snapshot mechanism itself fails. ──
|
|
@@ -307,7 +386,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
307
386
|
}
|
|
308
387
|
catch (expErr) {
|
|
309
388
|
await haltMigration(migration, `snapshot failed (${snapErr?.message ?? String(snapErr)}) and the content-only export fallback also failed (${expErr?.message ?? String(expErr)})`, { deps, startedAt, rowsDone: 0, rowsRemaining: initialRemaining, hashEnvelopeMatch: null, state });
|
|
310
|
-
return;
|
|
389
|
+
return false; // halted before any write
|
|
311
390
|
}
|
|
312
391
|
}
|
|
313
392
|
// ── Execution: throttled batches, resumable via per-row markers ──
|
|
@@ -346,7 +425,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
346
425
|
hashEnvelopeMatch: null,
|
|
347
426
|
state,
|
|
348
427
|
});
|
|
349
|
-
return;
|
|
428
|
+
return true; // conservative: a partial batch may have landed before the throw
|
|
350
429
|
}
|
|
351
430
|
// ── Completion gate — strictness per risk class ──
|
|
352
431
|
setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining: 0, state: "completing" });
|
|
@@ -363,7 +442,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
363
442
|
hashEnvelopeMatch: null,
|
|
364
443
|
state,
|
|
365
444
|
});
|
|
366
|
-
return;
|
|
445
|
+
return rowsDone > 0;
|
|
367
446
|
}
|
|
368
447
|
let hashEnvelopeMatch = null;
|
|
369
448
|
let gateOk = finalRemaining === 0;
|
|
@@ -417,7 +496,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
417
496
|
hashEnvelopeMatch,
|
|
418
497
|
state,
|
|
419
498
|
});
|
|
420
|
-
return;
|
|
499
|
+
return rowsDone > 0;
|
|
421
500
|
}
|
|
422
501
|
}
|
|
423
502
|
else if (gateOk && posture.gate === "count+old-row-envelope+new-row-presence") {
|
|
@@ -459,7 +538,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
459
538
|
hashEnvelopeMatch,
|
|
460
539
|
state,
|
|
461
540
|
});
|
|
462
|
-
return;
|
|
541
|
+
return rowsDone > 0;
|
|
463
542
|
}
|
|
464
543
|
// ── Success: ledger → state → prune ──
|
|
465
544
|
const evt = {
|
|
@@ -496,4 +575,5 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
496
575
|
}
|
|
497
576
|
pruneMigrationSnapshots(deps.snapshotRoot);
|
|
498
577
|
setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining: 0, state: "completed" });
|
|
578
|
+
return rowsDone > 0;
|
|
499
579
|
}
|
|
@@ -30,11 +30,22 @@ export function readMigrationState(path) {
|
|
|
30
30
|
if (!existsSync(path))
|
|
31
31
|
return {};
|
|
32
32
|
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
33
|
-
|
|
33
|
+
if (raw && typeof raw === "object")
|
|
34
|
+
return raw;
|
|
35
|
+
console.warn(`[flair-migrations] ${path} is not a JSON object — ignoring it and re-detecting every migration this boot`);
|
|
36
|
+
return {};
|
|
34
37
|
}
|
|
35
|
-
catch {
|
|
38
|
+
catch (err) {
|
|
36
39
|
// Corrupt/unreadable state — treat as "nothing known yet", never throw.
|
|
37
40
|
// Worst case this costs one extra (cheap, bounded) detect() call.
|
|
41
|
+
//
|
|
42
|
+
// flair#812: but SAY SO. This file is hand-edited in practice (the
|
|
43
|
+
// documented remediation for a stuck migration), and a fat-fingered
|
|
44
|
+
// edit silently discarding every recorded outcome is precisely the
|
|
45
|
+
// class of quiet failure this issue is about. The behaviour is
|
|
46
|
+
// unchanged and still safe — only the silence is fixed.
|
|
47
|
+
console.warn(`[flair-migrations] could not read ${path} (${err?.message ?? String(err)}) — ` +
|
|
48
|
+
`treating migration state as empty and re-detecting every migration this boot`);
|
|
38
49
|
return {};
|
|
39
50
|
}
|
|
40
51
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* encodes the migration id, so "is this row done" is answered entirely by
|
|
28
28
|
* re-reading the row itself (invariant IV: state lives IN the data).
|
|
29
29
|
*/
|
|
30
|
-
import { databases } from "
|
|
30
|
+
import { databases } from "harper";
|
|
31
31
|
export const SYNTHETIC_MIGRATION_ID = "synthetic-ci-schema-stamp";
|
|
32
32
|
export const RESERVED_TEST_AGENT_ID = "__flair_migration_synthetic_test_agent__";
|
|
33
33
|
export const SYNTHETIC_TARGET_MARKER = `${SYNTHETIC_MIGRATION_ID}-done`;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visibility-backfill.ts — backfills the `visibility` field on Memory rows
|
|
3
|
+
* written before flair#509 (the durability-keyed default-visibility slice),
|
|
4
|
+
* which never stamped `visibility` at all. riskClass 'derived-only':
|
|
5
|
+
* `visibility` here is a derived stamp recomputable at any time from
|
|
6
|
+
* `durability` (which remains on the row, untouched, forever) — never the
|
|
7
|
+
* only copy of anything (invariant I) — and this migration only ever writes
|
|
8
|
+
* into a field that is CURRENTLY EMPTY, so the cheapest posture applies:
|
|
9
|
+
* metadata-only snapshot, no content-hash gate (row-count + stamp
|
|
10
|
+
* convergence only, same as `embedding-stamp`/`graph-heal`).
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS EXISTS: `resources/Memory.ts`'s `defaultVisibilityForDurability`
|
|
13
|
+
* (flair#509) only stamps `visibility` on a WRITE — a row that has sat
|
|
14
|
+
* untouched since before that slice shipped has no `visibility` field at
|
|
15
|
+
* all. `src/cli.ts`'s federation push filter (`isFederationPrivateVisibility`)
|
|
16
|
+
* excludes only `visibility === "private"` — by design, per
|
|
17
|
+
* `resources/memory-visibility.ts`'s migration invariant, an ABSENT
|
|
18
|
+
* `visibility` field is NOT private and must keep syncing exactly as
|
|
19
|
+
* before (so today's single-org fleet never silently stops replicating
|
|
20
|
+
* pre-#509 rows). That is correct while every federation peer IS this same
|
|
21
|
+
* org's own fleet. A cross-organization hub pairing is coming, and at that
|
|
22
|
+
* point an absent-visibility row that should have been private (a
|
|
23
|
+
* `standard`/`ephemeral` note, never intended to leave the instance) would
|
|
24
|
+
* cross an org boundary it never should. This migration closes that gap
|
|
25
|
+
* proactively: once every row carries an explicit `visibility`, "absent"
|
|
26
|
+
* becomes the empty set, and the existing filter (unchanged, zero new
|
|
27
|
+
* behavior in it) is correct as-is for the cross-org case too.
|
|
28
|
+
*
|
|
29
|
+
* THE RULE (flair#509 — reused, not reinvented): `permanent`/`persistent`
|
|
30
|
+
* durability -> `shared`; `standard`/`ephemeral` durability, OR durability
|
|
31
|
+
* absent/unrecognised -> `private` (fail-safe: never widen access on a row
|
|
32
|
+
* whose intent this migration cannot actually determine).
|
|
33
|
+
* `deriveVisibilityFromDurability` below mirrors
|
|
34
|
+
* `resources/Memory.ts`'s (unexported) `defaultVisibilityForDurability`
|
|
35
|
+
* byte-for-byte. Deliberately DUPLICATED rather than imported: importing
|
|
36
|
+
* `resources/Memory.ts` here would drag in its module-top-level
|
|
37
|
+
* `export class Memory extends (databases as any).flair.Memory` — a class
|
|
38
|
+
* EXTENDS that only resolves once a live Harper has already registered the
|
|
39
|
+
* base `Memory` resource, which unit tests (no real Harper; see
|
|
40
|
+
* `test/unit/migrations-visibility-backfill.test.ts`) cannot satisfy. Same
|
|
41
|
+
* reasoning, same shape, as `src/cli.ts`'s `isFederationPrivateVisibility`
|
|
42
|
+
* duplicating `resources/memory-visibility.ts`'s `isPrivateVisibility`
|
|
43
|
+
* rather than importing it (see that file's comment) — both are one-line,
|
|
44
|
+
* pure, and load-bearing enough that a comment pointing at the canonical
|
|
45
|
+
* definition is the safer contract than a shared import with a landmine
|
|
46
|
+
* behind it. If this rule ever changes, both copies must change together.
|
|
47
|
+
*
|
|
48
|
+
* NEVER OVERWRITES an existing `visibility` value — that is an explicit
|
|
49
|
+
* author decision (their own call, even if it disagrees with what this
|
|
50
|
+
* migration would have derived). Only rows where `visibility` is
|
|
51
|
+
* null/undefined are eligible, and `run()` below re-verifies that on a
|
|
52
|
+
* freshly-read record immediately before writing (the actual enforcement
|
|
53
|
+
* of "never overwrite" — see the write-gate comment there), independent of
|
|
54
|
+
* how precisely the candidate-selection query below narrowed things.
|
|
55
|
+
*
|
|
56
|
+
* THE CANDIDATE QUERY — why it looks different from `embedding-stamp`'s:
|
|
57
|
+
* `visibility` is declared in `schemas/memory.graphql` but NOT `@indexed`
|
|
58
|
+
* (unlike `embeddingModel`, which flair#807 needed indexed for its own
|
|
59
|
+
* pending query). Every condition against it is therefore already a
|
|
60
|
+
* full-table-scan filter (`resources/search.ts`'s `filterByType`), whether
|
|
61
|
+
* or not the comparator is negated — the `not_equals`-forces-an-index-bypass
|
|
62
|
+
* mechanism `embedding-stamp.ts` depends on (flair#807) is simply moot for
|
|
63
|
+
* an attribute with no index to be stale in the first place. What DOES
|
|
64
|
+
* still matter, and is the same lesson flair#807 taught: a strict-equals
|
|
65
|
+
* filter (`recordValue === value`) never matches a row whose property was
|
|
66
|
+
* NEVER SET (`recordValue` reads back as `undefined`, and `undefined !==
|
|
67
|
+
* null`), so `{comparator: "equals", value: null}` alone would silently
|
|
68
|
+
* miss every truly-absent row — exactly the rows this migration exists to
|
|
69
|
+
* find. A NEGATED comparator does match them (`undefined !== "private"` is
|
|
70
|
+
* true), so the pending condition here is `visibility not_equals "private"
|
|
71
|
+
* AND visibility not_equals "shared"` — no separate `equals: null` leg
|
|
72
|
+
* needed (unlike `embedding-stamp`, whose write path can leave an
|
|
73
|
+
* intermediate explicit-null state on a failed HTTP regen; this migration's
|
|
74
|
+
* write is a single in-process read-modify-write that either fully
|
|
75
|
+
* succeeds or leaves the row in its exact prior state, so there is no
|
|
76
|
+
* intermediate state to add a leg for). This exact `not_equal`-covers-
|
|
77
|
+
* absent-and-null-and-nothing-else-with-the-value-itself semantics is
|
|
78
|
+
* already relied on, in production, by `resources/memory-read-scope.ts`'s
|
|
79
|
+
* cross-agent read-scope condition on this SAME field (`visibility !=
|
|
80
|
+
* 'private'` — "which INCLUDES records missing the field entirely") — this
|
|
81
|
+
* migration's query is not a new risk, it is the same proven mechanic.
|
|
82
|
+
*
|
|
83
|
+
* A garbage third value (something other than "private"/"shared"/absent)
|
|
84
|
+
* would also match this AND-of-two-`not_equals` condition — Harper's query
|
|
85
|
+
* algebra has no "attribute is absent" primitive to exclude that case at
|
|
86
|
+
* the query level. No code path in this codebase ever writes such a value
|
|
87
|
+
* today (confirmed by grep — every write site either omits `visibility`,
|
|
88
|
+
* defaults it via `defaultVisibilityForDurability`, or passes an operator-
|
|
89
|
+
* supplied string straight through with no allowlist, so nothing rules it
|
|
90
|
+
* out categorically either). The write-gate in `run()` is what actually
|
|
91
|
+
* protects against this, not the query: a row is only ever written to if a
|
|
92
|
+
* freshly-read `.get()` shows `visibility` is STILL null/undefined at write
|
|
93
|
+
* time. A genuinely garbage-valued row would be pulled in as a candidate,
|
|
94
|
+
* re-checked, found to already carry an explicit (if unexpected) value, and
|
|
95
|
+
* skipped — untouched, exactly like any other already-set row. Worst case
|
|
96
|
+
* this makes `countPending()` overcount relative to what `run()` will ever
|
|
97
|
+
* actually touch, which would show up as a real, visible completion-gate
|
|
98
|
+
* halt (`resources/migrations/runner.ts`'s "completion gate failed:
|
|
99
|
+
* rowsRemaining=N") rather than a silent skip or a false success — halt-
|
|
100
|
+
* don't-brick (invariant II), and worth surfacing loudly if it ever
|
|
101
|
+
* happens, since a garbage `visibility` value is itself a bug worth seeing.
|
|
102
|
+
*
|
|
103
|
+
* THE WRITE PATH — deliberately NOT `embedding-stamp`'s loopback-HTTP
|
|
104
|
+
* `PUT /Memory/:id` trick. That mechanism exists ONLY because
|
|
105
|
+
* `embedding-stamp` needs `resources/Memory.ts`'s SUBCLASS override (the
|
|
106
|
+
* `content.content && !content.embedding` regen branch) to actually fire,
|
|
107
|
+
* and the raw `databases.flair.Memory` reference (what this migration and
|
|
108
|
+
* that one both get via their table accessor) resolves to the RAW
|
|
109
|
+
* underlying table, silently skipping every subclass override — see that
|
|
110
|
+
* file's module doc for the full, empirically-confirmed mechanism. This
|
|
111
|
+
* migration needs NO subclass business logic (no dedup, no regen, no
|
|
112
|
+
* provenance stamping, no rate limiting) — it is a narrow, single-field
|
|
113
|
+
* patch, which is exactly the shape `resources/table-helpers.ts`'s
|
|
114
|
+
* `patchRecord` documents and this codebase already uses directly against
|
|
115
|
+
* the RAW table for the same kind of narrow metadata write:
|
|
116
|
+
* `resources/auth-middleware.ts`'s `backfillEmbedding()` (patches
|
|
117
|
+
* `embedding`), `resources/MemoryReflect.ts` and `resources/Memory.ts`
|
|
118
|
+
* itself (both patch `lastReflected` this exact way). Per that file's own
|
|
119
|
+
* warning, Harper's raw `.put()` is FULL RECORD REPLACEMENT — a partial
|
|
120
|
+
* object silently deletes every field not included — so `run()` below
|
|
121
|
+
* always spreads the freshly-read `existing` record before adding
|
|
122
|
+
* `visibility`, the same read-modify-write shape `patchRecord` implements
|
|
123
|
+
* (inlined here rather than calling that helper directly only because
|
|
124
|
+
* `run()` already needs its own `.get()` for the write-gate check above,
|
|
125
|
+
* and re-using that same read avoids a second, redundant round-trip).
|
|
126
|
+
*
|
|
127
|
+
* NO `recheckPending()`: that hook exists specifically to catch a stale
|
|
128
|
+
* SECONDARY INDEX diverging from live values (flair#807, `embedding-stamp`
|
|
129
|
+
* doc above) — a failure mode that requires an index to go stale in the
|
|
130
|
+
* first place. `visibility` has no index at all (every read here is
|
|
131
|
+
* already a live-record scan), so there is no analogous divergence for a
|
|
132
|
+
* safety net to guard against. Same reasoning `graph-heal.ts` (also
|
|
133
|
+
* `derived-only`, also unindexed-field-adjacent) already applies by also
|
|
134
|
+
* omitting it.
|
|
135
|
+
*/
|
|
136
|
+
import { databases } from "harper";
|
|
137
|
+
function defaultMemoryTable() {
|
|
138
|
+
return databases.flair.Memory;
|
|
139
|
+
}
|
|
140
|
+
export const VISIBILITY_BACKFILL_ID = "visibility-backfill";
|
|
141
|
+
/**
|
|
142
|
+
* flair#509's rule, reused verbatim (see module doc for why this is a
|
|
143
|
+
* deliberate duplicate of `resources/Memory.ts`'s
|
|
144
|
+
* `defaultVisibilityForDurability` rather than an import). Total function —
|
|
145
|
+
* every input maps to exactly one of the two valid outputs, and the
|
|
146
|
+
* fail-safe branch (`private`) is the ELSE of the allowlist check, so any
|
|
147
|
+
* absent/null/unrecognised/wrong-typed `durability` value falls into it by
|
|
148
|
+
* construction, never needs its own case.
|
|
149
|
+
*/
|
|
150
|
+
export function deriveVisibilityFromDurability(durability) {
|
|
151
|
+
return durability === "permanent" || durability === "persistent" ? "shared" : "private";
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Rows this migration still needs to touch: `visibility` is neither
|
|
155
|
+
* "private" nor "shared" — see module doc for why this AND-of-two-
|
|
156
|
+
* `not_equals` correctly matches both a truly-absent property and an
|
|
157
|
+
* explicit `null`, without a separate `equals: null` leg.
|
|
158
|
+
*/
|
|
159
|
+
function pendingCondition() {
|
|
160
|
+
return [
|
|
161
|
+
{ attribute: "visibility", comparator: "not_equals", value: "private" },
|
|
162
|
+
{ attribute: "visibility", comparator: "not_equals", value: "shared" },
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `getTable` is injectable so unit tests exercise this migration's full
|
|
167
|
+
* detect/countPending/run logic against an in-memory fake table (matching
|
|
168
|
+
* `embedding-stamp.ts`'s DI style) — no real Harper needed. Unlike
|
|
169
|
+
* `embedding-stamp`, there is no separate HTTP mechanism to inject: the
|
|
170
|
+
* write IS the table's own `.put()`, already covered by the same fake.
|
|
171
|
+
*/
|
|
172
|
+
export function createVisibilityBackfillMigration(getTable = defaultMemoryTable) {
|
|
173
|
+
return {
|
|
174
|
+
id: VISIBILITY_BACKFILL_ID,
|
|
175
|
+
riskClass: "derived-only",
|
|
176
|
+
affectsTables: ["Memory"],
|
|
177
|
+
async detect() {
|
|
178
|
+
const table = getTable();
|
|
179
|
+
for await (const _row of table.search({ conditions: pendingCondition(), limit: 1 })) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
},
|
|
184
|
+
async countPending() {
|
|
185
|
+
const table = getTable();
|
|
186
|
+
let n = 0;
|
|
187
|
+
for await (const _row of table.search({ conditions: pendingCondition() }))
|
|
188
|
+
n++;
|
|
189
|
+
return n;
|
|
190
|
+
},
|
|
191
|
+
async run(batchSize) {
|
|
192
|
+
const table = getTable();
|
|
193
|
+
const candidates = [];
|
|
194
|
+
for await (const row of table.search({ conditions: pendingCondition(), limit: batchSize })) {
|
|
195
|
+
candidates.push(row);
|
|
196
|
+
}
|
|
197
|
+
const touchedIds = [];
|
|
198
|
+
for (const row of candidates) {
|
|
199
|
+
const id = String(row.id ?? "");
|
|
200
|
+
if (!id)
|
|
201
|
+
continue;
|
|
202
|
+
const existing = await table.get(id);
|
|
203
|
+
if (!existing)
|
|
204
|
+
continue; // deleted since the search above — nothing to fix
|
|
205
|
+
// The write-gate: this is what actually enforces "never overwrite an
|
|
206
|
+
// existing visibility value", independent of how precisely the
|
|
207
|
+
// candidate query above narrowed things (see module doc — a
|
|
208
|
+
// garbage third value would still be pulled in as a candidate).
|
|
209
|
+
// Re-checks the FRESHLY-READ record, not the (possibly stale)
|
|
210
|
+
// search-result row, and doubles as the idempotency/concurrent-
|
|
211
|
+
// writer guard `embedding-stamp.ts` gets from its own analogous
|
|
212
|
+
// pre-write check.
|
|
213
|
+
if (existing.visibility !== undefined && existing.visibility !== null)
|
|
214
|
+
continue;
|
|
215
|
+
const derived = deriveVisibilityFromDurability(existing.durability);
|
|
216
|
+
// Never-widen invariant, asserted, not just tested: the only two
|
|
217
|
+
// values `deriveVisibilityFromDurability` can ever produce are
|
|
218
|
+
// "private"/"shared" (see its own doc), so this can never fire
|
|
219
|
+
// today — it exists to fail loudly (halting this migration via the
|
|
220
|
+
// runner's mid-batch-throw path, never a silent bad write) if a
|
|
221
|
+
// future edit to that function ever widens its return type or is
|
|
222
|
+
// bypassed via a type-unsafe call.
|
|
223
|
+
if (derived !== "private" && derived !== "shared") {
|
|
224
|
+
throw new Error(`visibility-backfill: derived an invalid visibility for row ${id} — refusing to write`);
|
|
225
|
+
}
|
|
226
|
+
await table.put({ ...existing, visibility: derived });
|
|
227
|
+
touchedIds.push(id);
|
|
228
|
+
}
|
|
229
|
+
return { processed: touchedIds.length, touchedIds };
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* models-dir.ts — the ONE place that decides where model GGUFs live.
|
|
3
3
|
*
|
|
4
|
-
* Extracted from embeddings-provider.ts (flair#815) so
|
|
5
|
-
*
|
|
4
|
+
* Extracted from embeddings-provider.ts (flair#815) so a second model
|
|
5
|
+
* consumer could share the exact same resolution WITHOUT importing
|
|
6
6
|
* embeddings-provider: several unit-isolated tests `mock.module()` the whole
|
|
7
7
|
* embeddings-provider module (with only the named exports THEY consume), and
|
|
8
8
|
* bun's module cache is process-wide — so any new cross-module import of
|
|
9
9
|
* embeddings-provider from another resource makes those mocks incomplete and
|
|
10
10
|
* kills unrelated test files at module load. This module is tiny, pure
|
|
11
|
-
* node-stdlib, and never mocked, so
|
|
12
|
-
*
|
|
11
|
+
* node-stdlib, and never mocked, so any model consumer can depend on it
|
|
12
|
+
* safely.
|
|
13
|
+
*
|
|
14
|
+
* The second consumer that motivated the split (the cross-encoder reranker)
|
|
15
|
+
* has since been removed (flair#893), leaving the embedding engine as the
|
|
16
|
+
* only caller. The module stays split anyway, and deliberately: the
|
|
17
|
+
* `mock.module()` hazard above is a property of embeddings-provider, not of
|
|
18
|
+
* the reranker, so folding this back in would re-arm it for whatever the next
|
|
19
|
+
* model consumer turns out to be. It is also the single documented source of
|
|
20
|
+
* truth for the models dir, independently tested as such.
|
|
13
21
|
*/
|
|
14
22
|
import { existsSync } from "node:fs";
|
|
15
23
|
import { homedir } from "node:os";
|
|
16
24
|
import { join } from "node:path";
|
|
17
25
|
/**
|
|
18
|
-
* Resolve the directory model GGUFs live in / download into —
|
|
19
|
-
* embedding engine (embeddings-boot.ts's `register()` options) and the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* models location
|
|
26
|
+
* Resolve the directory model GGUFs live in / download into — used by the
|
|
27
|
+
* embedding engine (embeddings-boot.ts's `register()` options), and the
|
|
28
|
+
* resolution any future model consumer should use rather than re-deriving its
|
|
29
|
+
* own. flair#815 exists because a consumer that hardcoded `<cwd>/models`
|
|
30
|
+
* silently failed open wherever Harper's cwd wasn't the models location; the
|
|
31
|
+
* fix was to give the decision exactly one home, which is this function.
|
|
23
32
|
*
|
|
24
33
|
* Resolution order (everything writable, never the read-only package dir):
|
|
25
34
|
* 1. FLAIR_MODELS_DIR — explicit operator/docker override.
|
|
@@ -315,6 +315,34 @@ export const RECORD_TYPES = {
|
|
|
315
315
|
federation: "included",
|
|
316
316
|
mcp: { toolPrefix: "soul", readVerbs: ["get"], writeVerbs: ["store"] },
|
|
317
317
|
},
|
|
318
|
+
// MemoryCandidate — flair#849. A candidate is an unreviewed draft
|
|
319
|
+
// distillation staged by the FLAIR-NIGHTLY-REM cycle (docs/rem.md);
|
|
320
|
+
// `readScope: "owner-only"` (not "open-within-org") is deliberate: a
|
|
321
|
+
// candidate must not be org-readable before a human/agent reviewer
|
|
322
|
+
// explicitly promotes or rejects it — this is the standard-review-bar
|
|
323
|
+
// choice per this file's header doc's review-gate tiering (an
|
|
324
|
+
// "open-within-org" or "none" entry would additionally require Sherlock's
|
|
325
|
+
// explicit security sign-off, which this entry does not carry). No
|
|
326
|
+
// `embedding` — the claim text has no dedicated recall surface (it isn't
|
|
327
|
+
// Memory.content). No `mcp` — no MCP tool exposes this table today.
|
|
328
|
+
MemoryCandidate: {
|
|
329
|
+
table: "MemoryCandidate",
|
|
330
|
+
ownerField: "agentId",
|
|
331
|
+
identity: "gated",
|
|
332
|
+
readScope: "owner-only",
|
|
333
|
+
attribution: { post: "validate-truthy", put: "validate-truthy" },
|
|
334
|
+
// No provenance field on the schema (schemas/memory.graphql) — nobody
|
|
335
|
+
// stamps buildProvenance() for this table.
|
|
336
|
+
provenance: false,
|
|
337
|
+
remEligible: false,
|
|
338
|
+
// Not in Federation.ts's push table map nor src/cli.ts's
|
|
339
|
+
// runFederationSyncOnce hardcoded list (`["Memory", "Soul", "Agent",
|
|
340
|
+
// "Relationship"]`) — per this file's header doc's federation section,
|
|
341
|
+
// a new type's registration must not silently inherit whatever that
|
|
342
|
+
// filter does or doesn't already exclude, so this is "excluded" by the
|
|
343
|
+
// same reasoning, not by omission.
|
|
344
|
+
federation: "excluded",
|
|
345
|
+
},
|
|
318
346
|
};
|
|
319
347
|
// ─── Composite MCP tools (the second, and only other, reviewed chokepoint) ─
|
|
320
348
|
//
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// Extracted from resources/SemanticSearch.ts's post() (Kern-approved
|
|
4
4
|
// refactor, flair#695). Before this module existed,
|
|
5
5
|
// SemanticSearch.post() was one function entangling auth resolution,
|
|
6
|
-
// rate-limiting, HNSW/BM25 retrieval, post-retrieval filtering,
|
|
7
|
-
//
|
|
6
|
+
// rate-limiting, HNSW/BM25 retrieval, post-retrieval filtering, AND
|
|
7
|
+
// retrievalCount/lastRetrieved hit-tracking side
|
|
8
8
|
// effects — so the ONLY way for MemoryBootstrap (resources/MemoryBootstrap.ts)
|
|
9
9
|
// to get bounded, HNSW-pushed-down candidates was to duplicate the retrieval
|
|
10
10
|
// logic or trip the side effects (an internal bootstrap call spuriously
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// the HNSW leg query construction (sort/select/conditions/limit), the BM25 +
|
|
17
17
|
// union-RRF hybrid fusion, the per-record temporal/expiry/supersede filters,
|
|
18
18
|
// and the scope.isAllowed() defense-in-depth re-check. It does NOT own: auth
|
|
19
|
-
// resolution, rate-limiting,
|
|
19
|
+
// resolution, rate-limiting, or the retrievalCount/lastRetrieved
|
|
20
20
|
// hit-tracking side effects — those stay in SemanticSearch.post()'s wrapper
|
|
21
21
|
// (resources/SemanticSearch.ts) so an internal caller (bootstrap) never trips
|
|
22
22
|
// them.
|
|
@@ -32,11 +32,12 @@
|
|
|
32
32
|
// Returns results AFTER all filters, sorted best-first by `_score` — bounded
|
|
33
33
|
// ONLY by the `limit` the caller chose to push down (the core never
|
|
34
34
|
// multiplies `limit` internally; any overfetch policy — SemanticSearch's
|
|
35
|
-
// CANDIDATE_MULTIPLIER,
|
|
35
|
+
// CANDIDATE_MULTIPLIER, MemoryBootstrap's K formula — is the CALLER's
|
|
36
|
+
// decision, made
|
|
36
37
|
// before calling in). Never exposes which internal leg (BM25+RRF hybrid vs.
|
|
37
38
|
// legacy HNSW-only vs. keyword-only fallback) produced a given result — the
|
|
38
39
|
// output shape is identical regardless of `hybrid`.
|
|
39
|
-
import { databases } from "
|
|
40
|
+
import { databases } from "harper";
|
|
40
41
|
import { withDetachedTxn } from "./table-helpers.js";
|
|
41
42
|
import { wrapUntrusted } from "./content-safety.js";
|
|
42
43
|
import { cosineSimilarity } from "./dedup.js";
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* memories it cannot see). So the scope gate lives HERE, not retrofitted
|
|
46
46
|
* onto recordUsageContribution() — do not "unify" the two surfaces.
|
|
47
47
|
*/
|
|
48
|
-
import { databases } from "
|
|
48
|
+
import { databases } from "harper";
|
|
49
49
|
import { withDetachedTxn } from "./table-helpers.js";
|
|
50
50
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
51
51
|
/**
|