@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.
Files changed (117) hide show
  1. package/README.md +194 -377
  2. package/dist/cli-shim.cjs +1 -1
  3. package/dist/cli.js +1567 -366
  4. package/dist/deploy.js +241 -43
  5. package/dist/fabric-upgrade.js +77 -24
  6. package/dist/federation/scheduler.js +500 -0
  7. package/dist/fleet-verify.js +3 -3
  8. package/dist/install/clients.js +111 -53
  9. package/dist/lib/mcp-enable.js +2 -2
  10. package/dist/lib/mcp-spec.js +128 -0
  11. package/dist/lib/safe-snapshot-extract.js +231 -0
  12. package/dist/lib/scheduler-platform.js +128 -0
  13. package/dist/lib/xml-escape.js +54 -0
  14. package/dist/rem/scheduler.js +212 -55
  15. package/dist/rem/snapshot.js +13 -0
  16. package/dist/replication-convergence.js +505 -0
  17. package/dist/resources/A2AAdapter.js +1 -1
  18. package/dist/resources/Admin.js +1 -1
  19. package/dist/resources/AdminConnectors.js +1 -1
  20. package/dist/resources/AdminDashboard.js +1 -1
  21. package/dist/resources/AdminIdp.js +1 -1
  22. package/dist/resources/AdminInstance.js +1 -1
  23. package/dist/resources/AdminMemory.js +1 -1
  24. package/dist/resources/AdminPrincipals.js +1 -1
  25. package/dist/resources/Agent.js +1 -1
  26. package/dist/resources/AgentCard.js +1 -1
  27. package/dist/resources/AgentSeed.js +1 -1
  28. package/dist/resources/AttentionQuery.js +1 -1
  29. package/dist/resources/Credential.js +1 -1
  30. package/dist/resources/Federation.js +1 -1
  31. package/dist/resources/Instance.js +1 -1
  32. package/dist/resources/Integration.js +1 -1
  33. package/dist/resources/MCPClientMetadata.js +1 -1
  34. package/dist/resources/Memory.js +1 -1
  35. package/dist/resources/MemoryBootstrap.js +8 -9
  36. package/dist/resources/MemoryCandidate.js +168 -0
  37. package/dist/resources/MemoryConsolidate.js +1 -1
  38. package/dist/resources/MemoryDedupStats.js +1 -1
  39. package/dist/resources/MemoryFeed.js +1 -1
  40. package/dist/resources/MemoryGrant.js +1 -1
  41. package/dist/resources/MemoryMaintenance.js +1 -1
  42. package/dist/resources/MemoryReflect.js +2 -2
  43. package/dist/resources/MemoryReindex.js +1 -1
  44. package/dist/resources/MemoryUsage.js +1 -1
  45. package/dist/resources/OAuth.js +1 -1
  46. package/dist/resources/OAuthClient.js +1 -1
  47. package/dist/resources/OrgEvent.js +1 -1
  48. package/dist/resources/OrgEventCatchup.js +1 -1
  49. package/dist/resources/OrgEventMaintenance.js +1 -1
  50. package/dist/resources/PairingToken.js +1 -1
  51. package/dist/resources/Peer.js +1 -1
  52. package/dist/resources/Presence.js +3 -3
  53. package/dist/resources/RecordUsage.js +1 -1
  54. package/dist/resources/Relationship.js +1 -1
  55. package/dist/resources/SemanticSearch.js +18 -46
  56. package/dist/resources/SkillScan.js +1 -1
  57. package/dist/resources/Soul.js +1 -1
  58. package/dist/resources/SoulFeed.js +1 -1
  59. package/dist/resources/WorkspaceLatest.js +1 -1
  60. package/dist/resources/WorkspaceState.js +1 -1
  61. package/dist/resources/XAA.js +1 -1
  62. package/dist/resources/a2a-url.js +1 -1
  63. package/dist/resources/abstention.js +1 -1
  64. package/dist/resources/agent-auth.js +1 -1
  65. package/dist/resources/agentcard-fields.js +1 -1
  66. package/dist/resources/auth-middleware.js +1 -1
  67. package/dist/resources/embeddings-boot.js +14 -16
  68. package/dist/resources/embeddings-provider.js +15 -12
  69. package/dist/resources/federation-cleanup.js +5 -5
  70. package/dist/resources/federation-nonce-store.js +5 -5
  71. package/dist/resources/health.js +53 -25
  72. package/dist/resources/in-process.js +225 -0
  73. package/dist/resources/instance-identity.js +1 -1
  74. package/dist/resources/mcp-client-metadata-fields.js +1 -1
  75. package/dist/resources/mcp-handler.js +1 -1
  76. package/dist/resources/mcp-oauth.js +2 -2
  77. package/dist/resources/mcp-tools.js +23 -17
  78. package/dist/resources/memory-read-scope.js +1 -1
  79. package/dist/resources/memory-reflect-lib.js +3 -3
  80. package/dist/resources/memory-visibility.js +3 -3
  81. package/dist/resources/migration-boot.js +81 -11
  82. package/dist/resources/migrations/data-dir.js +205 -0
  83. package/dist/resources/migrations/embedding-stamp.js +3 -3
  84. package/dist/resources/migrations/graph-heal.js +2 -2
  85. package/dist/resources/migrations/ledger.js +1 -1
  86. package/dist/resources/migrations/progress.js +33 -0
  87. package/dist/resources/migrations/registry.js +14 -5
  88. package/dist/resources/migrations/runner.js +93 -13
  89. package/dist/resources/migrations/state.js +13 -2
  90. package/dist/resources/migrations/synthetic-test-migration.js +1 -1
  91. package/dist/resources/migrations/visibility-backfill.js +232 -0
  92. package/dist/resources/models-dir.js +18 -9
  93. package/dist/resources/record-types.js +28 -0
  94. package/dist/resources/semantic-retrieval-core.js +6 -5
  95. package/dist/resources/usage-recording.js +1 -1
  96. package/dist/src/lib/scheduler-platform.js +128 -0
  97. package/dist/src/lib/xml-escape.js +54 -0
  98. package/dist/src/rem/scheduler.js +397 -0
  99. package/docs/deploying-on-fabric.md +267 -0
  100. package/docs/deployment.md +6 -0
  101. package/docs/embedding-in-a-harper-app.md +299 -0
  102. package/docs/federation.md +61 -4
  103. package/docs/integrations.md +3 -0
  104. package/docs/mcp-clients.md +16 -7
  105. package/docs/quickstart.md +80 -54
  106. package/docs/releasing.md +72 -38
  107. package/docs/supply-chain-policy.md +40 -4
  108. package/docs/troubleshooting.md +24 -0
  109. package/docs/upgrade.md +100 -5
  110. package/package.json +4 -14
  111. package/schemas/memory.graphql +9 -1
  112. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  113. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  114. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  115. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  116. package/dist/resources/rerank-provider.js +0 -569
  117. package/docs/rerank-provisioning.md +0 -101
@@ -7,7 +7,7 @@
7
7
  * The previous mechanism (removed by this change) delivered the registration
8
8
  * as a `models.embedding.default` block via the `HARPER_CONFIG` env var
9
9
  * (src/cli.ts's old `buildEmbeddingsHarperConfigEnv`) — a "merge layer" that
10
- * Harper's environment-manager (`@harperfast/harper`'s
10
+ * Harper's environment-manager (`harper`'s
11
11
  * `config/harperConfigEnvVars.js`) reasserts on every boot AND PERSISTS into
12
12
  * the instance-root `harper-config.yaml`. That file proved to be config-as-
13
13
  * STATE, not config-as-intent: `flair#694`'s downgrade-and-revert CI lane
@@ -28,14 +28,14 @@
28
28
  * to), leaving `models.embedding.default: {}` — an empty shell —
29
29
  * persisted to disk.
30
30
  * 3. The next boot's config schema validator
31
- * (`@harperfast/harper`'s `validation/configValidator.js`) resolves the
31
+ * (`harper`'s `validation/configValidator.js`) resolves the
32
32
  * entry's backend via `Joi.alternatives().conditional('.backend', {...,
33
33
  * otherwise: unknownBackendEntrySchema})`; with `backend` absent, that
34
34
  * falls through to `unknownBackendEntrySchema = Joi.object({backend:
35
35
  * string.required()})`, which throws exactly: "Harper config file
36
36
  * validation error: 'models.embedding.default.backend' is required" —
37
- * Harper refuses to boot. Confirmed byte-identical in both @harperfast/
38
- * harper 5.1.15 and 5.1.17 (this is upstream env-var-config behavior,
37
+ * Harper refuses to boot. Confirmed byte-identical in both harper
38
+ * 5.1.15 and 5.1.17 (this is upstream env-var-config behavior,
39
39
  * not a schema difference between versions), and reproduced locally by
40
40
  * replaying the exact downgrade-and-revert sequence.
41
41
  *
@@ -98,19 +98,17 @@ const MODEL_NAME = "nomic-embed-text";
98
98
  * table for the exact contract this PR bumps to).
99
99
  *
100
100
  * "mean" is nomic-embed-text-v1.5's actual pooling type — NOT assumed from
101
- * the model's reputation, directly confirmed against the shipped GGUF:
102
- * `node_modules/.bin/node-llama-cpp inspect gguf
103
- * ~/.flair/data/models/nomic-embed-text-v1.5.Q4_K_M.gguf` reports
104
- * `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
101
+ * the model's reputation, directly confirmed against the shipped GGUF by
102
+ * reading its metadata: `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
105
103
  * `enum llama_pooling_type` maps 1 -> `LLAMA_POOLING_TYPE_MEAN` (0 = none,
106
- * 1 = mean, 2 = cls, 3 = last, 4 = rank). nomic-embed-text is a
107
- * NomicBertModel architecture, not Qwen3 (last-token) flair registers no
108
- * Qwen3-class embedding model today (the Qwen3-Reranker-0.6B in
109
- * resources/rerank-provider.ts is a SEPARATE code path that calls
110
- * node-llama-cpp directly for generative yes/no scoring, never goes through
111
- * HFE's register()/init(), and has no embedding-pooling context at all — see
112
- * that file's header). If a Qwen3-class (last-token-pooling) embedding model
113
- * is ever registered here, it must declare `pooling: "last"`, not "mean".
104
+ * 1 = mean, 2 = cls, 3 = last, 4 = rank). (That reading was originally taken
105
+ * with `node-llama-cpp inspect gguf`, which flair no longer depends on —
106
+ * flair#893. Any GGUF inspector reports the same metadata field; install the
107
+ * CLI ad hoc if the check needs repeating.) nomic-embed-text is a
108
+ * NomicBertModel architecture, not Qwen3 (last-token), and flair registers no
109
+ * Qwen3-class embedding model today. If a Qwen3-class (last-token-pooling)
110
+ * embedding model is ever registered here, it must declare `pooling: "last"`,
111
+ * not "mean".
114
112
  *
115
113
  * Applies to the bench-only `modelPath` override too (`benchModelPathOverride()`
116
114
  * below): `FLAIR_RECALL_HARNESS_MODEL_PATH` lets an operator point this
@@ -36,11 +36,11 @@
36
36
  * what this migration removes: `models.embed` is a process-wide singleton
37
37
  * Harper initializes at boot, off the first-call dynamic-import race.
38
38
  *
39
- * `@harperfast/harper` is dynamic-imported here (deferred to first actual
39
+ * `harper` is dynamic-imported here (deferred to first actual
40
40
  * getEmbedding()/getMode()/getStatus() call), NOT statically imported like
41
41
  * every other resource's `Resource`/`databases`/`server` — those work
42
42
  * unmocked-import-free only because every test that (transitively) imports
43
- * them already mocks `@harperfast/harper` first. Statically importing
43
+ * them already mocks `harper` first. Statically importing
44
44
  * `models` here would make *any* test that imports this file for
45
45
  * `resolveModelsDir()` alone (test/unit/embeddings-models-dir.test.ts, which
46
46
  * has no reason to know about Harper at all) also eagerly load Harper's real
@@ -152,7 +152,7 @@ const EMBEDDING_PREFIXES_ENABLED = true; // Phase 2 prefix flip ON — re-baseli
152
152
  * embedding call, so this lets it override the ONE thing that matters
153
153
  * (whether `models.embed` receives `inputType`, and whether `getModelId()`
154
154
  * stamps the suffix) via the env var it already forwards to `startHarper()`
155
- * (the same mechanism `FLAIR_HYBRID_RETRIEVAL`/`FLAIR_RERANK_ENABLED` use).
155
+ * (the same mechanism `FLAIR_HYBRID_RETRIEVAL` uses).
156
156
  *
157
157
  * Bidirectional, not two separate force-on/force-off hatches: THE GATE has
158
158
  * flipped direction once already (off→on, this PR) and may again in the
@@ -186,7 +186,7 @@ function prefixesEnabled() {
186
186
  * Build the options object passed to `models.embed()`. Pulled out as its own
187
187
  * pure, harper-free function so the value-forwarding (and the reject-a-
188
188
  * wrong-value guard) is unit-testable without touching the deferred
189
- * `@harperfast/harper` import this file's header explains — see
189
+ * `harper` import this file's header explains — see
190
190
  * test/unit/embeddings-provider-input-type.test.ts.
191
191
  *
192
192
  * Gate ON (default): rejects anything other than the literal
@@ -217,7 +217,7 @@ let _modelsApi;
217
217
  /**
218
218
  * Resolve (and cache) Harper's `models` facade via a deferred import — see file header.
219
219
  *
220
- * NOTE for anyone using this as a reference: `models` (the `@harperfast/harper`
220
+ * NOTE for anyone using this as a reference: `models` (the `harper`
221
221
  * package export) and a component's `scope.models` are the SAME boot-time
222
222
  * singleton — Harper's jsLoader hands components `scope.models = <the global
223
223
  * models>` (two accessors, one object; the model registry lives on that singleton).
@@ -229,19 +229,22 @@ let _modelsApi;
229
229
  async function getModelsApi() {
230
230
  if (_modelsApi)
231
231
  return _modelsApi;
232
- const harper = await import("@harperfast/harper");
232
+ const harper = await import("harper");
233
233
  _modelsApi = harper.models;
234
234
  return _modelsApi;
235
235
  }
236
236
  /**
237
237
  * Resolve the directory the embeddings model lives in / downloads into.
238
238
  *
239
- * Implementation moved to `resources/models-dir.ts` (flair#815) so the
240
- * reranker can share the exact same resolution without importing this module
241
- * (several unit-isolated tests `mock.module()` this file wholesale — see
242
- * models-dir.ts's header). Re-exported here so existing consumers keep their
243
- * import site: `resources/embeddings-boot.ts` (flair#694), which builds the
244
- * `modelsDir` it passes to harper-fabric-embeddings' `register()`, and
239
+ * Implementation lives in `resources/models-dir.ts` (extracted in flair#815)
240
+ * so any model consumer can share the exact same resolution without importing
241
+ * this module — several unit-isolated tests `mock.module()` this file
242
+ * wholesale, and an incomplete stub then breaks every file that imports it.
243
+ * See models-dir.ts's header for why that hazard makes the tiny standalone
244
+ * module worth keeping even now that embeddings are its only consumer.
245
+ * Re-exported here so existing consumers keep their import site:
246
+ * `resources/embeddings-boot.ts` (flair#694), which builds the `modelsDir` it
247
+ * passes to harper-fabric-embeddings' `register()`, and
245
248
  * test/unit/embeddings-models-dir.test.ts, which pins the resolution order.
246
249
  */
247
250
  export { resolveModelsDir } from "./models-dir.js";
@@ -11,13 +11,13 @@ let cleanupTimer = null;
11
11
  * and performs housekeeping on expired/unconsumed token records.
12
12
  *
13
13
  * In test environments, callers pass mock serverOp/db via `opts` so this
14
- * module never imports @harperfast/harper at the top level (which would
14
+ * module never imports harper at the top level (which would
15
15
  * crash when STORAGE_PATH isn't set).
16
16
  */
17
17
  export async function initFederationCleanup(opts) {
18
18
  const immediate = opts?.immediateTick ?? true;
19
19
  // Resolve server / databases: use caller-supplied mocks when available,
20
- // otherwise lazy-import @harperfast/harper at call time.
20
+ // otherwise lazy-import harper at call time.
21
21
  let svr;
22
22
  let db;
23
23
  if (opts?.serverOp && opts?.db) {
@@ -26,12 +26,12 @@ export async function initFederationCleanup(opts) {
26
26
  }
27
27
  else {
28
28
  try {
29
- const harper = await import("@harperfast/harper");
29
+ const harper = await import("harper");
30
30
  svr = opts?.serverOp ?? harper.server.operation;
31
31
  db = opts?.db ?? harper.databases;
32
32
  }
33
33
  catch (err) {
34
- console.error("[federation-cleanup] failed to load @harperfast/harper:", err?.message ?? err);
34
+ console.error("[federation-cleanup] failed to load harper:", err?.message ?? err);
35
35
  return;
36
36
  }
37
37
  }
@@ -94,7 +94,7 @@ export async function runCleanupTick(opts = {}) {
94
94
  db = opts.db;
95
95
  }
96
96
  else {
97
- const harper = await import("@harperfast/harper");
97
+ const harper = await import("harper");
98
98
  svr = opts.serverOp ?? harper.server.operation;
99
99
  db = opts.db ?? harper.databases;
100
100
  }
@@ -44,7 +44,7 @@ async function resolveDb(opts, cache) {
44
44
  if (opts.db)
45
45
  return opts.db;
46
46
  if (!cache.db) {
47
- cache.db = import("@harperfast/harper").then((h) => h.databases);
47
+ cache.db = import("harper").then((h) => h.databases);
48
48
  }
49
49
  return cache.db;
50
50
  }
@@ -123,7 +123,7 @@ export async function runNonceCleanupTick(opts = {}) {
123
123
  db = opts.db;
124
124
  }
125
125
  else {
126
- const harper = await import("@harperfast/harper");
126
+ const harper = await import("harper");
127
127
  db = harper.databases;
128
128
  }
129
129
  const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
@@ -159,7 +159,7 @@ export async function runNonceCleanupTick(opts = {}) {
159
159
  * Initialise the periodic Nonce-table eviction sweep (5-min cadence).
160
160
  *
161
161
  * In test environments, callers pass a mock `db` via `opts` so this module
162
- * never imports @harperfast/harper at the top level (mirrors
162
+ * never imports harper at the top level (mirrors
163
163
  * federation-cleanup.ts's `initFederationCleanup`).
164
164
  */
165
165
  export async function initNonceStoreCleanup(opts) {
@@ -170,11 +170,11 @@ export async function initNonceStoreCleanup(opts) {
170
170
  }
171
171
  else {
172
172
  try {
173
- const harper = await import("@harperfast/harper");
173
+ const harper = await import("harper");
174
174
  db = harper.databases;
175
175
  }
176
176
  catch (err) {
177
- console.error("[federation-nonce-store] failed to load @harperfast/harper:", err?.message ?? err);
177
+ console.error("[federation-nonce-store] failed to load harper:", err?.message ?? err);
178
178
  return;
179
179
  }
180
180
  }
@@ -1,11 +1,11 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { promises as fsp, existsSync, readFileSync } from "node:fs";
3
3
  import { homedir, platform } from "node:os";
4
4
  import { join, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { getRerankStatus } from "./rerank-provider.js";
7
6
  import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
8
7
  import { getMigrationStatusSnapshot } from "./migrations/status.js";
8
+ import { resolveMigrationDataDirForRead } from "./migrations/data-dir.js";
9
9
  import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
10
10
  const db = databases;
11
11
  const redactHome = (p) => {
@@ -452,13 +452,33 @@ export class HealthDetail extends Resource {
452
452
  const lastLightAt = findLast("light");
453
453
  const lastRapidAt = findLast("rapid");
454
454
  const lastRestorativeAt = findLast("restorative");
455
+ // File presence only proves the scheduler templates were written —
456
+ // NOT that launchctl/systemctl actually loaded the job. A
457
+ // `systemctl --user enable --now` that fails (e.g. no session bus)
458
+ // still leaves the timer file on disk, so file-existence alone would
459
+ // report "enabled" for a scheduler that has nothing scheduled
460
+ // (flair#850). Query genuine active state once the file is known to
461
+ // exist; async + short-timeout so a slow/hung service manager can't
462
+ // stall this request.
455
463
  let nightlyEnabled = null;
464
+ let nightlyInstalled = false;
456
465
  const plat = platform();
457
- if (plat === "darwin") {
458
- nightlyEnabled = await exists(join(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist"));
459
- }
460
- else if (plat === "linux") {
461
- nightlyEnabled = await exists(join(homedir(), ".config", "systemd", "user", "flair-rem-nightly.timer"));
466
+ if (plat === "darwin" || plat === "linux") {
467
+ nightlyInstalled = plat === "darwin"
468
+ ? await exists(join(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist"))
469
+ : await exists(join(homedir(), ".config", "systemd", "user", "flair-rem-nightly.timer"));
470
+ if (nightlyInstalled) {
471
+ try {
472
+ const { queryActiveStateAsync } = await import("../src/rem/scheduler.js");
473
+ nightlyEnabled = await queryActiveStateAsync(plat);
474
+ }
475
+ catch {
476
+ nightlyEnabled = null;
477
+ }
478
+ }
479
+ else {
480
+ nightlyEnabled = false;
481
+ }
462
482
  }
463
483
  const nightlyRecords = await tailJsonl(nightlyLog);
464
484
  const lastNightlyRec = nightlyRecords[nightlyRecords.length - 1];
@@ -496,6 +516,9 @@ export class HealthDetail extends Resource {
496
516
  if (nightlyEnabled && lastNightlyAt && nowMs - new Date(lastNightlyAt).getTime() > 48 * 3600 * 1000) {
497
517
  warnings.push({ level: "warn", message: "nightly REM hasn't run in >48h" });
498
518
  }
519
+ if (nightlyInstalled && nightlyEnabled === false) {
520
+ warnings.push({ level: "warn", message: "REM nightly scheduler files are written but not active — nothing is scheduled (see `flair rem nightly status`)" });
521
+ }
499
522
  }
500
523
  }
501
524
  catch {
@@ -537,12 +560,21 @@ export class HealthDetail extends Resource {
537
560
  // "pre-flight integrity check in progress" state the K&S verdict calls
538
561
  // for; a halted migration surfaces here (with `reason`) AND as a
539
562
  // warning below so it's visible without a separate lookup.
563
+ //
564
+ // flair#812: the dataDir is resolved by the shared, READ-ONLY resolver
565
+ // (resources/migrations/data-dir.ts) rather than the old
566
+ // `HDB_ROOT ?? homedir()/.flair/data` expression duplicated here — that
567
+ // env var is never set by anything, so the left branch was dead and the
568
+ // path silently disagreed with wherever the boot cycle actually wrote.
569
+ // `lastCycleError` is surfaced so `flair doctor` can name WHY a cycle
570
+ // didn't run, not merely that it didn't.
540
571
  try {
541
- const migrationsDataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
572
+ const migrationsDataDir = resolveMigrationDataDirForRead();
542
573
  const snapshot = getMigrationStatusSnapshot(migrationsDataDir);
543
574
  stats.migrations = {
544
575
  cyclePhase: snapshot.cyclePhase,
545
576
  lastCycleAt: snapshot.lastCycleAt ?? null,
577
+ lastCycleError: snapshot.lastCycleError ?? null,
546
578
  migrations: snapshot.migrations,
547
579
  };
548
580
  for (const m of snapshot.migrations) {
@@ -550,13 +582,25 @@ export class HealthDetail extends Resource {
550
582
  warnings.push({ level: "warn", message: `migration '${m.id}' ${m.state}${m.reason ? `: ${m.reason}` : ""} — see \`flair doctor\`` });
551
583
  }
552
584
  }
585
+ // The boot trigger sets `scheduled` synchronously at module load, so
586
+ // `idle` here means resources/migration-boot.js never loaded — the
587
+ // instance is running a build whose migration trigger is absent or
588
+ // failed to import, and NO migration will ever run on it.
589
+ if (snapshot.cyclePhase === "idle" && snapshot.migrations.length > 0) {
590
+ warnings.push({
591
+ level: "warn",
592
+ message: "migration boot cycle never fired on this instance (cyclePhase=idle) — no migration will run until this is resolved; see `flair doctor`",
593
+ });
594
+ }
553
595
  }
554
596
  catch {
555
597
  stats.migrations = null;
556
598
  }
557
599
  // ── Disk ──
600
+ // flair#812: same shared read-only resolution as the migrations section
601
+ // above, so `disk.dataDir` names the directory migrations actually use.
558
602
  try {
559
- const dataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
603
+ const dataDir = resolveMigrationDataDirForRead();
560
604
  const snapshotDir = join(homedir(), ".flair", "snapshots");
561
605
  const dirSize = async (root, maxDepth = 6) => {
562
606
  if (!(await exists(root)))
@@ -635,22 +679,6 @@ export class HealthDetail extends Resource {
635
679
  catch {
636
680
  stats.bridges = null;
637
681
  }
638
- // ── Reranker ──
639
- // Cross-encoder rerank stage. Reports flag/model/mode + live counters so we
640
- // can see if it's silently degrading (fallbackCount climbing) in prod.
641
- try {
642
- const rr = getRerankStatus();
643
- stats.rerank = rr;
644
- if (rr.enabled && rr.state === "failed") {
645
- warnings.push({ level: "warn", message: `reranker enabled but unavailable: ${rr.error ?? "init failed"} — recall falling back to vector order` });
646
- }
647
- if (rr.enabled && rr.rerankCount > 0 && rr.fallbackCount > rr.rerankCount) {
648
- warnings.push({ level: "warn", message: `reranker falling back more than reranking (${rr.fallbackCount} fallbacks / ${rr.rerankCount} reranks) — check latency budget / topN` });
649
- }
650
- }
651
- catch {
652
- stats.rerank = null;
653
- }
654
682
  // ── Warnings ──
655
683
  stats.warnings = warnings;
656
684
  // ── Process info ──
@@ -0,0 +1,225 @@
1
+ /**
2
+ * ─── The in-process call seam (one incantation, one place) ───────────────────
3
+ *
4
+ * How code running INSIDE the Harper process invokes a flair resource AS a
5
+ * specific agent — flair's own native MCP handler (resources/mcp-tools.ts), and
6
+ * any co-located application component that loads flair as a sub-component and
7
+ * calls it directly instead of over HTTP.
8
+ *
9
+ * Three things an in-process caller has to get right, none of which is visible
10
+ * from a resource's own source and none of which used to fail in a way that
11
+ * points at the cause:
12
+ *
13
+ * 1. **Identity.** Every flair resource resolves its caller through
14
+ * resources/agent-auth.ts's `resolveAgentAuth()`, whose FIRST hop is the
15
+ * `tpsAgent` annotation the HTTP auth middleware stamps on a verified
16
+ * request. There is no HTTP request in-process, so the caller supplies that
17
+ * annotation itself — {@link agentContext}.
18
+ *
19
+ * 2. **Not accidentally becoming an administrator.** A context with no usable
20
+ * agent id resolves to flair's trusted `internal` verdict, which is
21
+ * UNFILTERED. See the safety-design block below: this module's API shape is
22
+ * built around making that unreachable by accident.
23
+ *
24
+ * 3. **Collection binding.** A resource's `post()` — the create path — only
25
+ * works on a resource instance Harper has marked as a COLLECTION. That mark
26
+ * is a PRIVATE field (`#isCollection`) set inside Harper's own
27
+ * `getResource()`; the public `isCollection` is a GETTER WITH NO SETTER on
28
+ * `Resource.prototype`. So the obvious `new Cls(undefined, ctx)` +
29
+ * `h.isCollection = true` does not "not work" quietly — under ESM (always
30
+ * strict mode) the assignment throws
31
+ * `TypeError: Cannot set property isCollection ... which has only a getter`,
32
+ * and dropping the assignment instead yields
33
+ * `405 The <X> does not have a post method implemented` from Harper's base
34
+ * `Resource.post()`. {@link collectionResource} is the one supported way in:
35
+ * hand `getResource()` an `{ isCollection: true }` option and let Harper set
36
+ * its own private field.
37
+ *
38
+ * flair itself got (3) wrong in four MCP tool paths before this module existed —
39
+ * the same mistake a consumer reading only the resource classes would make.
40
+ *
41
+ * ─── SAFETY DESIGN: the dangerous thing has to look dangerous ────────────────
42
+ *
43
+ * MEASURED, against the real resolver:
44
+ *
45
+ * resolveAgentAuth({ request: { tpsAgent: undefined } }) -> { kind: "internal" }
46
+ * resolveAgentAuth({ request: { tpsAgent: "" } }) -> { kind: "internal" }
47
+ * allowAdmin({ request: { tpsAgent: undefined } }) -> true
48
+ *
49
+ * `resolveAgentAuth` tests `tpsAgent` for TRUTHINESS, so a missing or empty id
50
+ * is indistinguishable from "no identity was supplied at all" — and that is the
51
+ * trusted verdict. The consequence is that the most ordinary application bug
52
+ * there is — `agentContext(session.agentId)` where the field is undefined, a
53
+ * typo'd property, a lookup that found nothing — would not fail closed. It
54
+ * would silently grant unfiltered cross-agent reads and writes, and pass the
55
+ * admin-only gate on admin-only resources. No error, no 403, no log line.
56
+ *
57
+ * That is the same defect class as a check that cannot fail: **the failure mode
58
+ * of "I forgot the id" must never be "you are now an administrator."** So:
59
+ *
60
+ * - {@link agentContext} THROWS on a missing, empty or blank id. It is always
61
+ * a programming error, and an exception is the only outcome that cannot be
62
+ * mistaken for success.
63
+ * - {@link agentContext} takes NO options. It is structurally incapable of
64
+ * producing an admin context, so spreading a caller-influenced object into
65
+ * its arguments cannot escalate.
66
+ * - Admin is a SEPARATE, NAMED export ({@link adminContext}), and so is the
67
+ * unfiltered maintenance verdict ({@link internalContext}). Both are
68
+ * greppable: `git grep -n "adminContext\|internalContext"` enumerates every
69
+ * privileged call site in a codebase.
70
+ * - {@link collectionResource} REQUIRES a context and throws without one, so
71
+ * the privileged path can never be reached by leaving an argument off. The
72
+ * dangerous path is now the LONGEST path, not the shortest.
73
+ *
74
+ * These guards are runtime, not types, on purpose: an embedding application may
75
+ * be plain JavaScript, where a `string` parameter annotation buys exactly
76
+ * nothing. test/integration/in-process-agents.test.ts pins both the guards and
77
+ * the hazard they exist for, against a real Harper.
78
+ *
79
+ * ── Deliberately dependency-free ─────────────────────────────────────────────
80
+ * No `import { databases } from "harper"` (see resources/memory-visibility.ts
81
+ * for why that import is load-bearing to avoid): these helpers are pure shape,
82
+ * and an embedding app may want them before any table is resolved.
83
+ */
84
+ /**
85
+ * Thrown when an in-process call is built in a way that would have silently
86
+ * escalated. Its own type so a caller can distinguish "I wired this wrong" from
87
+ * a resource's business-logic refusal (which arrives as a `Response`, not a
88
+ * throw).
89
+ */
90
+ export class InProcessContextError extends Error {
91
+ constructor(message) {
92
+ super(message);
93
+ this.name = "InProcessContextError";
94
+ }
95
+ }
96
+ /** Reject anything that would resolve to the trusted `internal` verdict, or to a nonsense id. */
97
+ function requireAgentId(agentId, fnName) {
98
+ if (typeof agentId !== "string" || agentId.trim() === "") {
99
+ const got = agentId === undefined ? "undefined"
100
+ : agentId === null ? "null"
101
+ : typeof agentId !== "string" ? `a ${typeof agentId}`
102
+ : agentId === "" ? "an empty string"
103
+ : "a blank string";
104
+ throw new InProcessContextError(`${fnName}() requires a non-empty agent id, but received ${got}. ` +
105
+ "This is refused rather than defaulted because flair resolves a missing or empty " +
106
+ "tpsAgent to its trusted `internal` verdict, which reads and writes UNFILTERED across " +
107
+ "every agent and passes the admin-only gate — so returning a context here would have " +
108
+ "turned a missing id into silent administrator access. Resolve the agent id from your " +
109
+ "own server-side state before calling, and never from request data.");
110
+ }
111
+ return agentId;
112
+ }
113
+ /**
114
+ * The resource context that makes an in-process call act as ONE SPECIFIC agent.
115
+ * This is the call an application makes; the other two constructors below are
116
+ * privileged and deliberately harder to type.
117
+ *
118
+ * **`agentId` is asserted, not proven.** flair reads it and acts as that agent:
119
+ * no signature, no lookup against the `Agent` table, no registration
120
+ * requirement. That is right for a caller already inside the trust boundary —
121
+ * it could write the raw table anyway — and it is exactly why the id must come
122
+ * from YOUR OWN server-side state (the session you authenticated, the job
123
+ * record you dequeued) and **never from request data**. An agent id that
124
+ * reaches here from a body field, a query param, or a header you did not verify
125
+ * yourself is privilege escalation with no error and no trace.
126
+ *
127
+ * Throws {@link InProcessContextError} on a missing, empty or blank id — see the
128
+ * safety-design block at the top of this file for why that is not merely
129
+ * defensive.
130
+ *
131
+ * Takes no options, and in particular no way to ask for admin: use
132
+ * {@link adminContext} for that, so the escalation is a word you had to type.
133
+ *
134
+ * ```ts
135
+ * const Memory = server.resources.get("Memory").Resource;
136
+ * const h = await collectionResource(Memory, agentContext("planner"));
137
+ * const { id } = await h.post({ agentId: "planner", content: "…" });
138
+ * ```
139
+ */
140
+ export function agentContext(agentId) {
141
+ return {
142
+ request: {
143
+ tpsAgent: requireAgentId(agentId, "agentContext"),
144
+ tpsAgentIsAdmin: false,
145
+ },
146
+ };
147
+ }
148
+ /**
149
+ * Like {@link agentContext}, but with flair-admin authority: unfiltered
150
+ * cross-agent reads, and writes attributed to agents other than this one.
151
+ *
152
+ * Asserted, never checked — nothing validates that the named agent is actually
153
+ * an admin principal. **Treat a call to this exactly as you would a root
154
+ * shell:** provisioning and maintenance only, never a request handler's
155
+ * default, and never with an id or a flag derived from anything a caller
156
+ * supplied.
157
+ *
158
+ * It is a separate export rather than an option so that (a) no options object
159
+ * spread into {@link agentContext} can escalate, and (b) every privileged call
160
+ * site in a codebase is findable by name.
161
+ *
162
+ * Throws {@link InProcessContextError} on a missing, empty or blank id.
163
+ */
164
+ export function adminContext(agentId) {
165
+ return {
166
+ request: {
167
+ tpsAgent: requireAgentId(agentId, "adminContext"),
168
+ tpsAgentIsAdmin: true,
169
+ },
170
+ };
171
+ }
172
+ /**
173
+ * The TRUSTED, UNATTRIBUTED, UNFILTERED context — flair's `internal` verdict.
174
+ * Reads see every agent's private records; writes are owned by nobody.
175
+ *
176
+ * This exists for work that is genuinely infrastructure rather than an agent's:
177
+ * provisioning a principal, a migration, a maintenance sweep. It is the same
178
+ * verdict a context-less call used to fall into by accident; naming it means an
179
+ * application can no longer reach it by forgetting an argument, and means
180
+ * `git grep internalContext` enumerates every place flair or an embedder took
181
+ * that authority deliberately.
182
+ *
183
+ * There is no id to validate: the verdict comes precisely from the ABSENCE of
184
+ * an identity annotation, so the returned object carries none. The marker field
185
+ * is inert — it documents intent at a debugger breakpoint and is read by
186
+ * nothing.
187
+ *
188
+ * ```ts
189
+ * // Registering a principal — infrastructure, not an agent's own write.
190
+ * const h = await collectionResource(Agent, internalContext());
191
+ * await h.post({ id, name: id, publicKey });
192
+ * ```
193
+ */
194
+ export function internalContext() {
195
+ return { request: {}, __flairInternal: true };
196
+ }
197
+ /**
198
+ * A resource instance bound to `context` and marked as a COLLECTION, so its
199
+ * `post()` (the create path) works — see this module's header for why that mark
200
+ * cannot be applied from outside Harper.
201
+ *
202
+ * `context` is REQUIRED. Passing nothing used to yield the unfiltered
203
+ * `internal` verdict, which made the privileged path the shortest one to type;
204
+ * it now throws. Pass {@link agentContext} for an agent's own work,
205
+ * {@link adminContext} or {@link internalContext} when the authority is
206
+ * genuinely intended.
207
+ *
208
+ * Reads do not need this — `Cls.get(id, context)` and `Cls.search(query,
209
+ * context)` (Harper's static resource methods) already thread the context and
210
+ * start their own transaction.
211
+ */
212
+ export async function collectionResource(Cls, context) {
213
+ if (context == null || typeof context !== "object") {
214
+ throw new InProcessContextError("collectionResource() requires an explicit context, but received " +
215
+ (context === undefined ? "undefined" : context === null ? "null" : `a ${typeof context}`) +
216
+ ". Omitting it would resolve to flair's trusted `internal` verdict — unfiltered reads and " +
217
+ "unattributed writes across every agent — so the privileged path is never the one you get " +
218
+ "by leaving an argument off. Pass agentContext(id) to act as an agent, or internalContext() " +
219
+ "if this really is infrastructure work.");
220
+ }
221
+ // `{}` is a sufficient RequestTarget here: getResource() reads only `.id` off
222
+ // it (undefined ⇒ Harper mints the primary key), and takes the collection
223
+ // mark from the options argument.
224
+ return (await Cls.getResource({}, context, { isCollection: true }));
225
+ }
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  /**
3
3
  * ─── Local instance identity (federation-edge-hardening slice 1) ────────────
4
4
  *
@@ -39,7 +39,7 @@
39
39
  * `allowedHosts` configured → rejected; leaked private `d` in a JWK →
40
40
  * rejected by the plugin even if our own build-time guard were bypassed).
41
41
  *
42
- * Kept free of any @harperfast/harper import (mirrors agentcard-fields.ts)
42
+ * Kept free of any harper import (mirrors agentcard-fields.ts)
43
43
  * so the document shape is unit-testable without spinning up Harper.
44
44
  */
45
45
  /** The grant this agent's CIMD document targets — formalized by oauth#161, accepted by the published 2.2.0 validator. See module header. */
@@ -23,7 +23,7 @@
23
23
  * Harper HTTP listeners return `{ status, body, headers? }`. MCP messages are
24
24
  * JSON-RPC 2.0, so we serialize the JSON-RPC response object as the body.
25
25
  */
26
- import { databases } from "@harperfast/harper";
26
+ import { databases } from "harper";
27
27
  import { randomBytes } from "node:crypto";
28
28
  import { TOOLS, listToolDefs } from "./mcp-tools.js";
29
29
  // The MCP protocol revision we implement (initialize handshake).
@@ -17,7 +17,7 @@
17
17
  * only meaningful when an operator has set the issuer + enabled the flag (see
18
18
  * docs). The plugin serves DCR / authorize / token / JWKS / discovery.
19
19
  */
20
- import * as harper from "@harperfast/harper";
20
+ import * as harper from "harper";
21
21
  import { mcpOAuthEnabled, mcpAuthConfig } from "./mcp-oauth-flag.js";
22
22
  async function defaultLoadWithMCPAuth() {
23
23
  // Dynamic import so the dep is only required when the surface is enabled.
@@ -53,7 +53,7 @@ export async function registerMcpOAuthRoute(deps = {}) {
53
53
  const handler = deps.mcpHandler ?? (await import("./mcp-handler.js")).mcpHandler;
54
54
  // Read `server` lazily off the namespace (it's a runtime global on the Harper
55
55
  // module, not a static named export) so this module links cleanly even where a
56
- // stub build of @harperfast/harper lacks the export.
56
+ // stub build of harper lacks the export.
57
57
  const srv = deps.server ?? (harper.server);
58
58
  // Primary registration: urlPath subroute → own chain (flair's auth-middleware
59
59
  // does not run here). `getConfig` pins iss/resource to the AS's values so the