@tpsdev-ai/flair 0.53.0 → 0.54.2

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 (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1665 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +931 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1628 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/engine-version.js +12 -4
  43. package/dist/fabric-npm-install.js +87 -0
  44. package/dist/fabric-upgrade.js +30 -15
  45. package/dist/federation-verify.js +498 -0
  46. package/dist/fleet-verify.js +144 -21
  47. package/dist/install/clients.js +167 -0
  48. package/dist/lib/auth-resolve.js +76 -1
  49. package/dist/lib/daemon-liveness.js +131 -2
  50. package/dist/lib/doctor-config-path.js +61 -0
  51. package/dist/lib/doctor-federation-driver.js +189 -0
  52. package/dist/lib/doctor-run.js +40 -0
  53. package/dist/lib/entity-vocab-cli.js +3 -3
  54. package/dist/lib/federation-pair-identity.js +47 -0
  55. package/dist/lib/launchd-repair.js +5 -4
  56. package/dist/lib/npm-registry.js +578 -0
  57. package/dist/lib/ops-api-bind.js +115 -0
  58. package/dist/lib/owned-pins.js +219 -0
  59. package/dist/lib/uninstall-purge.js +218 -0
  60. package/dist/rem/restore.js +8 -10
  61. package/dist/resources/AgentReadPosition.js +74 -0
  62. package/dist/resources/Federation.js +8 -2
  63. package/dist/resources/Memory.js +4 -3
  64. package/dist/resources/MemoryBootstrap.js +41 -25
  65. package/dist/resources/MemoryCandidate.js +5 -6
  66. package/dist/resources/OrgEventCatchup.js +126 -47
  67. package/dist/resources/agent-read-position-lib.js +83 -0
  68. package/dist/resources/agent-read-position.js +120 -0
  69. package/dist/resources/embeddings-boot.js +32 -0
  70. package/dist/resources/federation-peer-liveness.js +73 -0
  71. package/dist/resources/health.js +68 -19
  72. package/dist/resources/mcp-tools.js +48 -279
  73. package/dist/resources/memory-visibility.js +3 -3
  74. package/dist/resources/migration-boot.js +59 -18
  75. package/dist/resources/migrations/embedding-stamp.js +20 -1
  76. package/dist/resources/migrations/recheck.js +43 -0
  77. package/dist/resources/migrations/runner.js +6 -1
  78. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  79. package/dist/resources/migrations/visibility-backfill.js +2 -2
  80. package/dist/resources/org-event-catchup-lib.js +47 -0
  81. package/dist/resources/record-owner-guard.js +1 -0
  82. package/dist/resources/tool-descriptors/index.js +669 -0
  83. package/dist/stamp-migration-verify.js +163 -0
  84. package/dist/stamp-outstanding.js +144 -0
  85. package/dist/version-check.js +29 -8
  86. package/docs/api-reference.md +4 -2
  87. package/docs/deploying-on-fabric.md +11 -10
  88. package/docs/deployment.md +3 -1
  89. package/docs/federation.md +19 -0
  90. package/docs/hosted-on-fabric.md +3 -3
  91. package/docs/quickstart.md +2 -1
  92. package/docs/releasing.md +20 -6
  93. package/docs/spoke-bringup.md +10 -5
  94. package/docs/standalone-local.md +3 -1
  95. package/docs/upgrade.md +25 -6
  96. package/package.json +4 -4
  97. package/schemas/agent.graphql +15 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * stamp-migration-verify.ts — post-deploy / `upgrade --target` check that
3
+ * embedding-stamp actually converged (flair#1073).
4
+ *
5
+ * Route verify (`verifyDeployServing`) proves the component is serving.
6
+ * That is not enough: a Fabric upgrade can serve new writes (prefixed
7
+ * stamps) while pre-flip rows stay stale, and `flair status` used to
8
+ * report only a generic mixed-models warning. This polls authenticated
9
+ * /HealthDetail until the corpus is a single current space (and
10
+ * embedding-stamp is not halted/failed), the same way route verify polls
11
+ * until /Memory is non-404.
12
+ */
13
+ import { EMBEDDING_STAMP_ID, stampMigrationConverged, } from "./stamp-outstanding.js";
14
+ export const DEFAULT_STAMP_VERIFY_TIMEOUT_MS = 600_000;
15
+ export const STAMP_VERIFY_POLL_INTERVAL_MS = 2_000;
16
+ function basicAuthHeader(user, password) {
17
+ return "Basic " + Buffer.from(`${user}:${password}`, "utf8").toString("base64");
18
+ }
19
+ /**
20
+ * Parse /HealthDetail into a verify snapshot. `memories: null` (HealthDetail
21
+ * 200 after the Memory walk threw) and a missing `modelCounts` field stay
22
+ * `undefined` — that is unread, not an empty corpus. An explicit
23
+ * `modelCounts: {}` is the valid empty-store case (Bugbot Medium on #1606).
24
+ */
25
+ export function snapshotFromHealthDetail(body) {
26
+ const rec = body && typeof body === "object" ? body : {};
27
+ const memories = rec.memories && typeof rec.memories === "object" ? rec.memories : null;
28
+ const migrationsBlock = rec.migrations && typeof rec.migrations === "object" ? rec.migrations : {};
29
+ const modelCounts = memories && memories.modelCounts && typeof memories.modelCounts === "object"
30
+ ? memories.modelCounts
31
+ : undefined;
32
+ const rawList = Array.isArray(migrationsBlock.migrations) ? migrationsBlock.migrations : undefined;
33
+ const migrations = rawList
34
+ ?.filter((m) => !!m && typeof m === "object" && typeof m.id === "string")
35
+ .map((m) => ({
36
+ id: m.id,
37
+ state: String(m.state ?? ""),
38
+ rowsDone: typeof m.rowsDone === "number" ? m.rowsDone : undefined,
39
+ rowsRemaining: typeof m.rowsRemaining === "number" ? m.rowsRemaining : undefined,
40
+ reason: typeof m.reason === "string" ? m.reason : undefined,
41
+ }));
42
+ const warnings = Array.isArray(rec.warnings)
43
+ ? rec.warnings
44
+ .map((w) => (w && typeof w === "object" && typeof w.message === "string" ? w.message : null))
45
+ .filter((m) => m !== null)
46
+ : undefined;
47
+ return {
48
+ modelCounts,
49
+ cyclePhase: typeof migrationsBlock.cyclePhase === "string" ? migrationsBlock.cyclePhase : undefined,
50
+ lastCycleError: typeof migrationsBlock.lastCycleError === "string" ? migrationsBlock.lastCycleError : migrationsBlock.lastCycleError === null ? null : undefined,
51
+ migrations,
52
+ warnings,
53
+ };
54
+ }
55
+ /**
56
+ * Read the current-space id from the HealthDetail warning or from the
57
+ * dominant +searchprefix / gguf: stamp. Fallback: the most common real
58
+ * stamp — verify then treats any other space as stale.
59
+ */
60
+ export function inferCurrentModelId(snapshot) {
61
+ const counts = snapshot.modelCounts ?? {};
62
+ let best = null;
63
+ let prefixed = null;
64
+ for (const [stamp, n] of Object.entries(counts)) {
65
+ if (stamp === "hash-512d" || typeof n !== "number" || n <= 0)
66
+ continue;
67
+ if (!best || n > best.n)
68
+ best = { stamp, n };
69
+ if (stamp.includes("+searchprefix") && (!prefixed || n > prefixed.n))
70
+ prefixed = { stamp, n };
71
+ }
72
+ return prefixed?.stamp ?? best?.stamp ?? "nomic-embed-text-v1.5-Q4_K_M+searchprefix";
73
+ }
74
+ export function evaluateStampSnapshot(snapshot) {
75
+ // Unread corpus (HealthDetail omitted modelCounts, including memories: null)
76
+ // is not an empty store. Reject before stampMigrationConverged can treat
77
+ // `undefined` as `{}` and report success (Bugbot Medium on #1606).
78
+ if (snapshot.modelCounts === undefined) {
79
+ return {
80
+ converged: false,
81
+ detail: "HealthDetail did not include memories.modelCounts",
82
+ };
83
+ }
84
+ const outstandingWarning = snapshot.warnings?.find((w) => w.includes(`migration '${EMBEDDING_STAMP_ID}' is outstanding`) || w.includes("duplicate detection is inactive"));
85
+ if (outstandingWarning) {
86
+ return { converged: false, detail: outstandingWarning };
87
+ }
88
+ const counts = snapshot.modelCounts;
89
+ const realStamps = Object.entries(counts).filter(([k, n]) => k !== "hash-512d" && typeof n === "number" && n > 0);
90
+ // Production getModelId() stamps +searchprefix. A corpus that is entirely
91
+ // on the pre-flip bare id is the #1073 failure mode — do not treat a
92
+ // single-space bare corpus as converged just because inferCurrentModelId
93
+ // would pick that majority stamp as "current."
94
+ if (realStamps.length > 0 && realStamps.every(([k]) => !k.includes("+searchprefix"))) {
95
+ const list = realStamps.map(([k, n]) => `${k}:${n}`).join(", ");
96
+ return {
97
+ converged: false,
98
+ detail: `migration '${EMBEDDING_STAMP_ID}' is outstanding — corpus has no +searchprefix stamp yet (${list})`,
99
+ };
100
+ }
101
+ const currentModelId = inferCurrentModelId(snapshot);
102
+ const migration = snapshot.migrations?.find((m) => m.id === EMBEDDING_STAMP_ID);
103
+ return stampMigrationConverged({
104
+ modelCounts: counts,
105
+ currentModelId,
106
+ migration,
107
+ cyclePhase: snapshot.cyclePhase,
108
+ lastCycleError: snapshot.lastCycleError,
109
+ });
110
+ }
111
+ async function readHealthDetail(baseUrl, auth, fetchImpl) {
112
+ const url = `${baseUrl.replace(/\/+$/, "")}/HealthDetail`;
113
+ try {
114
+ const res = await fetchImpl(url, {
115
+ headers: { Authorization: auth, Accept: "application/json" },
116
+ signal: AbortSignal.timeout(15_000),
117
+ });
118
+ if (!res.ok) {
119
+ return { ok: false, status: res.status, error: `GET /HealthDetail returned ${res.status}` };
120
+ }
121
+ const body = await res.json();
122
+ return { ok: true, status: res.status, snapshot: snapshotFromHealthDetail(body) };
123
+ }
124
+ catch (err) {
125
+ return { ok: false, status: 0, error: err?.message ?? String(err) };
126
+ }
127
+ }
128
+ /**
129
+ * Poll /HealthDetail until embedding-stamp has converged, or throw.
130
+ * Skipped by the caller when verify is disabled (`--no-verify`).
131
+ */
132
+ export async function verifyStampMigrationConverged(o) {
133
+ const { baseUrl, fabricUser, fabricPassword, timeoutMs = DEFAULT_STAMP_VERIFY_TIMEOUT_MS, pollIntervalMs = STAMP_VERIFY_POLL_INTERVAL_MS, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), onProgress, } = o;
134
+ const auth = basicAuthHeader(fabricUser, fabricPassword);
135
+ onProgress?.(`verifying ${EMBEDDING_STAMP_ID} converged on ${baseUrl}...`);
136
+ const deadline = Date.now() + timeoutMs;
137
+ let last = await readHealthDetail(baseUrl, auth, fetchImpl);
138
+ for (;;) {
139
+ if (last.ok && last.snapshot) {
140
+ const verdict = evaluateStampSnapshot(last.snapshot);
141
+ if (verdict.converged) {
142
+ onProgress?.(`embedding-stamp converged`);
143
+ return;
144
+ }
145
+ onProgress?.(verdict.detail);
146
+ }
147
+ else {
148
+ onProgress?.(last.error ?? "HealthDetail not readable yet");
149
+ }
150
+ if (Date.now() >= deadline)
151
+ break;
152
+ await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
153
+ last = await readHealthDetail(baseUrl, auth, fetchImpl);
154
+ }
155
+ if (!last.ok) {
156
+ throw new Error(`deploy verification: embedding-stamp did not converge within ${timeoutMs}ms — ` +
157
+ `could not read /HealthDetail (${last.error ?? `HTTP ${last.status}`}). ` +
158
+ `The component may still be restarting; re-run without --no-verify, or ` +
159
+ `\`flair status --json\` and look for migration '${EMBEDDING_STAMP_ID}'.`);
160
+ }
161
+ const verdict = last.snapshot ? evaluateStampSnapshot(last.snapshot) : { detail: "no HealthDetail snapshot" };
162
+ throw new Error(`deploy verification: embedding-stamp did not converge within ${timeoutMs}ms — ${verdict.detail}`);
163
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * stamp-outstanding.ts — CLI-side copy of the corpus-derived
3
+ * "is embedding-stamp still pending?" helper (flair#1073).
4
+ *
5
+ * INLINED, not imported from resources/: cross-boundary imports from src/
6
+ * into resources/ do not survive npm packaging. tsconfig.cli.json compiles
7
+ * with `rootDir: "src"`, so dist/cli.js has no resources/ module it can
8
+ * resolve at the same relative path. Same reason as entity-vocab-cli.ts
9
+ * and the getModelId() literals in src/cli.ts.
10
+ *
11
+ * Canonical module: resources/migrations/stamp-outstanding.ts (Harper
12
+ * HealthDetail / embedding-stamp). The two files MUST stay in sync —
13
+ * test/unit/stamp-outstanding-cli-parity.test.ts pins export names and a
14
+ * known-answer table. Drift fails CI rather than shipping.
15
+ */
16
+ export const EMBEDDING_STAMP_ID = "embedding-stamp";
17
+ const NON_SPACE_STAMPS = new Set(["hash-512d"]);
18
+ const DEFAULT_ENGINE = "gguf";
19
+ export function normalizeStampForOutstanding(stamp) {
20
+ if (stamp == null)
21
+ return null;
22
+ const s = String(stamp).trim();
23
+ if (s === "" || NON_SPACE_STAMPS.has(s))
24
+ return null;
25
+ return s.includes(":") ? s : `${DEFAULT_ENGINE}:${s}`;
26
+ }
27
+ /**
28
+ * Current-space id for a client that only has `modelCounts` (no live
29
+ * `getModelId()`). Prefers an existing `+searchprefix` stamp so a healthy
30
+ * bare+`gguf:` pair of the SAME space does not invent a foreign current.
31
+ * A uniformly pre-flip corpus has no suffix — append `+searchprefix` so
32
+ * those rows read as stale.
33
+ */
34
+ export function resolveCurrentModelId(modelCounts, explicit) {
35
+ const trimmed = explicit?.trim();
36
+ if (trimmed)
37
+ return trimmed;
38
+ let prefixed;
39
+ let anyReal;
40
+ for (const [stamp, n] of Object.entries(modelCounts)) {
41
+ if (NON_SPACE_STAMPS.has(stamp) || typeof n !== "number" || n <= 0)
42
+ continue;
43
+ anyReal ??= stamp;
44
+ if (stamp.includes("+searchprefix")) {
45
+ if (!prefixed || stamp.includes(":"))
46
+ prefixed = stamp;
47
+ }
48
+ }
49
+ if (prefixed)
50
+ return prefixed;
51
+ if (anyReal) {
52
+ const bare = anyReal.includes(":") ? anyReal.slice(anyReal.indexOf(":") + 1) : anyReal;
53
+ return `${DEFAULT_ENGINE}:${bare}+searchprefix`;
54
+ }
55
+ return `${DEFAULT_ENGINE}:nomic-embed-text-v1.5-Q4_K_M+searchprefix`;
56
+ }
57
+ export function countStampSpaces(modelCounts, currentModelId) {
58
+ const currentSpace = normalizeStampForOutstanding(currentModelId);
59
+ let currentCount = 0;
60
+ let staleCount = 0;
61
+ const staleStamps = [];
62
+ for (const [raw, n] of Object.entries(modelCounts)) {
63
+ if (typeof n !== "number" || n <= 0)
64
+ continue;
65
+ const space = normalizeStampForOutstanding(raw);
66
+ if (space === null)
67
+ continue;
68
+ if (currentSpace !== null && space === currentSpace) {
69
+ currentCount += n;
70
+ }
71
+ else {
72
+ staleCount += n;
73
+ staleStamps.push(`${raw}:${n}`);
74
+ }
75
+ }
76
+ return { currentCount, staleCount, staleStamps, currentSpace };
77
+ }
78
+ function runnerAnnotation(input) {
79
+ const mig = input.migration;
80
+ const phase = input.cyclePhase;
81
+ if (input.lastCycleError) {
82
+ return ` last boot cycle failed (${input.lastCycleError})`;
83
+ }
84
+ if (phase === "idle") {
85
+ return " migration boot cycle never fired on this instance — no migration will run until this is resolved";
86
+ }
87
+ if (mig?.state === "running" || mig?.state === "checking" || mig?.state === "preflight" || mig?.state === "snapshotting" || mig?.state === "completing") {
88
+ const rem = typeof mig.rowsRemaining === "number" ? `, ${mig.rowsRemaining} remaining` : "";
89
+ return ` in progress (${mig.state}${rem})`;
90
+ }
91
+ if (mig?.state === "halted" || mig?.state === "failed") {
92
+ return ` ${mig.state}${mig.reason ? `: ${mig.reason}` : ""} — see \`flair doctor\``;
93
+ }
94
+ if (mig?.state === "completed" || phase === "done") {
95
+ return " the last cycle marked it complete without converging; it will retry automatically";
96
+ }
97
+ if (input.audience === "client") {
98
+ return " the Harper process applies it on its next migration cycle — this CLI does not";
99
+ }
100
+ return " it will apply automatically on this process's next migration cycle";
101
+ }
102
+ export function describeStampOutstanding(input) {
103
+ const { currentCount, staleCount, staleStamps } = countStampSpaces(input.modelCounts, input.currentModelId);
104
+ if (staleCount === 0) {
105
+ return { outstanding: false, staleCount: 0, currentCount };
106
+ }
107
+ const list = staleStamps.join(", ");
108
+ const warning = `migration '${EMBEDDING_STAMP_ID}' is outstanding ` +
109
+ `(${staleCount} row${staleCount === 1 ? "" : "s"} still on ${list}` +
110
+ `${currentCount > 0 ? `, ${currentCount} already current` : ""}) — ` +
111
+ `cross-model search is unreliable and duplicate detection is inactive ` +
112
+ `until the re-embed completes;` +
113
+ runnerAnnotation(input);
114
+ return {
115
+ outstanding: true,
116
+ migrationId: EMBEDDING_STAMP_ID,
117
+ staleCount,
118
+ currentCount,
119
+ staleStamps,
120
+ warning,
121
+ };
122
+ }
123
+ export function stampMigrationConverged(input) {
124
+ const described = describeStampOutstanding(input);
125
+ if (described.outstanding) {
126
+ return { converged: false, detail: described.warning };
127
+ }
128
+ const mig = input.migration;
129
+ if (mig?.state === "halted" || mig?.state === "failed") {
130
+ return {
131
+ converged: false,
132
+ detail: `migration '${EMBEDDING_STAMP_ID}' ${mig.state}${mig.reason ? `: ${mig.reason}` : ""}`,
133
+ };
134
+ }
135
+ if (mig &&
136
+ (mig.state === "running" ||
137
+ mig.state === "checking" ||
138
+ mig.state === "preflight" ||
139
+ mig.state === "snapshotting" ||
140
+ mig.state === "completing")) {
141
+ return { converged: false, detail: `migration '${EMBEDDING_STAMP_ID}' still ${mig.state}` };
142
+ }
143
+ return { converged: true, detail: "embedding-stamp converged (corpus is a single current space)" };
144
+ }
@@ -32,6 +32,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
33
  import { dirname, join } from "node:path";
34
34
  import { parseSemverCore } from "./fabric-upgrade.js";
35
+ import { fetchLatestVersion } from "./lib/npm-registry.js";
35
36
  export const FLAIR_PKG_NAME = "@tpsdev-ai/flair";
36
37
  export const DEFAULT_CACHE_PATH = join(homedir(), ".flair", ".version-check-cache.json");
37
38
  /** How long a cached "latest" answer is trusted before we re-hit the registry. */
@@ -65,19 +66,39 @@ function writeCacheFile(path, entry) {
65
66
  }
66
67
  async function defaultFetchLatest(timeoutMs) {
67
68
  try {
68
- const res = await fetch(`https://registry.npmjs.org/${FLAIR_PKG_NAME}/latest`, {
69
- signal: AbortSignal.timeout(timeoutMs),
70
- });
71
- if (!res.ok)
69
+ // flair#1688: honour the registry npm is configured to use (scoped
70
+ // `@scope:registry`, project/user/global .npmrc, env `npm_config_registry`)
71
+ // rather than a hardcoded public host — a private mirror must not be
72
+ // silently bypassed by the update check.
73
+ //
74
+ // flair#1692: `fetchLatestVersion` validates the URL scheme, refuses
75
+ // redirects, applies npm's transport where a bare fetch cannot, and —
76
+ // critically — validates the returned value as strict semver before it can
77
+ // ever be used as an install spec.
78
+ const result = await fetchLatestVersion(FLAIR_PKG_NAME, { timeoutMs });
79
+ if (result.kind === "ok")
80
+ return result.version;
81
+ if (result.kind === "invalid") {
82
+ // A hostile/compromised registry can return a URL or tag as `latest`;
83
+ // npm would install `pkg@<url>` as a remote tarball. Refuse, and say so
84
+ // rather than silently treating it as "no update".
85
+ console.error(`flair version check: the configured registry returned a non-semver "latest" (${JSON.stringify(result.value)}) ` +
86
+ `from ${result.registry.url} — refusing to use it. Check \`registry\` / \`@scope:registry\` (source: ${result.registry.source}).`);
72
87
  return null;
73
- const data = (await res.json());
74
- return typeof data?.version === "string" ? data.version : null;
75
- }
76
- catch {
88
+ }
89
+ if (result.kind === "refused") {
90
+ console.error(result.message);
91
+ return null;
92
+ }
77
93
  // Offline, DNS failure, timeout, registry 5xx, bad JSON — all the same:
78
94
  // we couldn't determine "latest" over the network this time.
79
95
  return null;
80
96
  }
97
+ catch {
98
+ // Defense-in-depth: the helper already swallows its failure modes. This
99
+ // guards the status/doctor never-throws contract even if it does not.
100
+ return null;
101
+ }
81
102
  }
82
103
  export function defaultVersionCheckDeps() {
83
104
  return {
@@ -154,7 +154,8 @@ Skill-tagged Memory rows embed from `trigger` (the recall signal), not
154
154
  | GET / POST / PUT | `/WorkspaceState` | Ed25519 | Owner-only. POST stamps `agentId`; PUT rejects a mismatch. |
155
155
  | GET | `/WorkspaceLatest` | Ed25519 | Latest workspace row for the caller. |
156
156
  | GET / POST / PUT | `/OrgEvent` | Ed25519 | Any verified agent reads every event. Writes stamp `authorId`. |
157
- | GET | `/OrgEventCatchup` | Ed25519 | Catch-up feed for the caller. |
157
+ | GET / POST | `/OrgEventCatchup` | Ed25519 | Catch-up feed for the caller. `since` is optional (defaults to the per-agent watermark). GET pages; POST acks `{ position }` (advance-on-ack). |
158
+ | GET / POST | `/AgentReadPosition` | Ed25519 | Owner-only watermark (`stream`, default `org-event`). Foundation for light-comms catch-up. |
158
159
  | POST | `/OrgEventMaintenance` | Ed25519 / admin | Expire / sweep org events. |
159
160
  | POST | `/AttentionQuery` | Ed25519 | Cross-table “what touches entity E”. Entity strings: [docs/entity-vocabulary.md](entity-vocabulary.md). |
160
161
 
@@ -163,7 +164,7 @@ Skill-tagged Memory rows embed from `trigger` (the recall signal), not
163
164
  | Method | Path | Auth | Notes |
164
165
  |--------|------|------|-------|
165
166
  | GET | `/FederationInstance` | Admin Basic | Local instance identity (CLI / admin). Peers do not call this during pair. |
166
- | POST | `/FederationPair` | Pairing token + body-sig | Public at the Harper role gate. Handler validates token, signature, anti-replay. Fabric uses the bootstrap-user triple from `flair federation token`. |
167
+ | POST | `/FederationPair` | Pairing token + body-sig | Public at the Harper role gate. Handler validates token, signature, anti-replay. Response includes `instance {id, publicKey}` when this hub has an Instance row; `instance` is null if it does not (flair#839). The spoke CLI must not store an empty hub key (flair#822). Fabric uses the bootstrap-user triple from `flair federation token`. |
167
168
  | POST | `/FederationSync` | Peer body-sig | Public at the role gate. Merge Memory / Soul / Agent / Relationship (and classifier-ready Message). Originator + per-record signature checks. |
168
169
  | GET | `/FederationPeers` | Admin Basic | Known peers. |
169
170
  | GET / write | `/Instance` | Read: Ed25519. Write: admin | Instance row (`flair_…` id, role hub/spoke). |
@@ -355,6 +356,7 @@ ed25519 / idp) and **Integration** (legacy platform connection).
355
356
  | **Asset** | memory.graphql | yes | Blob (`contentType`, `data`) owned by `agentId`, linked by `memoryId` |
356
357
  | **WorkspaceState** | workspace.graphql | yes | Current work (`ref`, `provider`, `phase`, `entities`) |
357
358
  | **OrgEvent** | event.graphql | yes | Org-visible event (`authorId`, `kind`, `summary`, `entities`) |
359
+ | **AgentReadPosition** | agent.graphql | no | Per-agent watermark (`agentId`, `stream`, `position`). HTTP via `/AgentReadPosition`, not raw-table REST. |
358
360
  | **Message** | message.graphql | yes | Signed envelope (`from`, `to`, `threadId`, `seq`, `state`, `signature`) |
359
361
  | **OAuthClient** | oauth.graphql | yes | Registered OAuth clients |
360
362
  | **OAuthAuthCode** | oauth.graphql | no | Single-use codes + PKCE |
@@ -241,7 +241,9 @@ Provision once, from one place, before serving multi-region traffic: step 2's
241
241
  | `flair fleet verify --target <url>` | health, auth, version across origin + Flair peers |
242
242
  | `flair federation status\|verify\|reachability --target <url>` | peer table, sync recency, probes |
243
243
 
244
- `fleet verify` exit codes: 1 origin failed, 2 peer version skew, 3 peer unverifiable.
244
+ `fleet verify` exit codes: 1 origin failed, 2 reachable peer diverged (wrong version), 3 reachable peer unreachable/auth-failed. Unverifiable peers (no endpoint on file) warn and exit 0.
245
+
246
+ `federation verify` is a different question (did this canary land). Exit 0 includes unverifiable peers (401/403, unreachable); exit 1 is a reachable peer missing the canary. It pushes before probing so a spoke with no sync daemon can still check.
245
247
 
246
248
  > **A credential mismatch renders as an empty section, not an error.** `flair status`
247
249
  > reads `/HealthDetail` with `FLAIR_ADMIN_PASS` / `HDB_ADMIN_PASSWORD` / a pinned agent
@@ -299,17 +301,16 @@ without this re-flip will appear healthy (`/Health` green) while its MCP tools a
299
301
  dark to every connected client. If you rely on MCP, add the re-flip to your upgrade
300
302
  runbook.
301
303
 
302
- ### Known hazard: unbounded npm cache
304
+ ### npm cache on the node
303
305
 
304
- **Open [flair#886](https://github.com/tpsdev-ai/flair/issues/886).** Every deploy runs a
305
- server-side `npm install` using the node's default cache. npm never evicts it, so it
306
- grows until it fills the quota.
306
+ `flair deploy` and `flair upgrade --target` pass Harper an `install_command` that runs
307
+ `npm install --cache <tmp>` and deletes that directory when the install finishes. The
308
+ node's `~/.npm/_cacache` is no longer the install cache, so repeated deploys do not
309
+ accumulate tarballs ([flair#886](https://github.com/tpsdev-ai/flair/issues/886)).
307
310
 
308
- No in-product mitigation: no cache flag, no alternate location, no cleanup. One install
309
- per deploy bounds the *rate*, not the total. Harper consults `install_command` only when
310
- `node_modules` is absent and `deploy_component` has no force-reinstall option, so the
311
- obvious fix isn't available — and clearing the cache needs node access this shape
312
- doesn't give you.
311
+ Harper still consults `install_command` only when `node_modules` is absent that is
312
+ exactly when a fresh extract runs the install, which is every deploy (Harper replaces
313
+ the component directory). There is nothing for an operator to clean.
313
314
 
314
315
  ### Backup and rollback
315
316
 
@@ -194,6 +194,7 @@ These are read by the Harper process at boot (same places as the table above: la
194
194
  | Variable | Default | What it does |
195
195
  |----------|---------|--------------|
196
196
  | `FLAIR_EMBED_THREADS` | `max(1, availableParallelism() − 1)` | CPU threads for in-process embedding (harper-fabric-embeddings / llama.cpp). Host-aware so a 4-core box does not inherit HFE's fixed 6, and an 8-vCPU ingest host is not stuck at 6 idle cores. One core is left for Harper's event loop and the OS. `availableParallelism()` respects a container CPU quota. Set a positive integer to pin. Invalid values fall back to the default. |
197
+ | `FLAIR_EMBED_GPU_LAYERS` | unset (HFE default `0`, CPU only) | Layers to offload to the GPU. **Unset omits the field** so harper-fabric-embeddings keeps its own default of 0. This is a pin for measurement (the ingest-throughput bench, flair#1436), not a new product default — #1437 is the detect-and-default decision. `0` = CPU; `99` = full offload (Metal on mac-arm64). Invalid values fall through to omit. |
197
198
  | `FLAIR_HYBRID_RETRIEVAL` | `true` | Hybrid BM25 + vector retrieval. Set `false` / `0` / `off` to revert to the legacy HNSW + keyword-bump path. |
198
199
  | `FLAIR_MODELS_DIR` | `<data-dir>/models` | Directory the embedding GGUF is loaded from (and downloaded into on first boot). Point this at a pre-seeded directory to skip the HuggingFace download; see [troubleshooting.md](troubleshooting.md). |
199
200
 
@@ -216,6 +217,7 @@ Always backup before upgrades.
216
217
  ## Uninstall
217
218
 
218
219
  ```bash
219
- flair uninstall # stops server, removes ~/.flair/, removes launchd/systemd service
220
+ flair uninstall # stop the server and remove the launchd/systemd service; keep data and keys
221
+ flair uninstall --purge # also remove ~/.flair (data, keys, secrets), schedulers, and client wiring
220
222
  npm uninstall -g @tpsdev-ai/flair
221
223
  ```
@@ -120,6 +120,24 @@ knob. The first sync runs immediately on enable.
120
120
  `flair federation watch` is unchanged and still the right tool for an
121
121
  interactive "watch it sync while I debug" session.
122
122
 
123
+ ### Did the canary actually land?
124
+
125
+ `flair federation verify` writes a tagged memory, **pushes it** (so a
126
+ freshly paired spoke with no sync daemon can still pass), then probes each
127
+ peer. It uses the same couldn't-check-≠-failed split as `flair fleet verify`
128
+ (flair#988 / #823):
129
+
130
+ | What the probe saw | Verdict | Exit |
131
+ |---|---|---|
132
+ | Canary found | OK | 0 |
133
+ | HTTP 401/403, unreachable, or no endpoint | UNVERIFIABLE (warning) | 0 |
134
+ | Reachable peer answered 200 without the canary after a successful push | FAIL | 1 |
135
+ | Revoked peer | UNVERIFIABLE (warning; not probed) | 0 |
136
+
137
+ A 401 is "could not authenticate to that peer," not "sync failed." Do not
138
+ treat unverifiable as a pass that hides a reachable peer on the wrong side
139
+ of the canary.
140
+
123
141
  **Credentials.** The scheduler never writes a password into a unit file. It
124
142
  stores the *path* given to `--admin-pass-file` (defaulting to
125
143
  `~/.flair/admin-pass` when that exists) and the CLI reads the file at run time,
@@ -197,6 +215,7 @@ Records with `updatedAt` more than 5 minutes in the future are rejected. This pr
197
215
  | `flair federation sync disable [--remove-shim]` | Remove the scheduled sync driver |
198
216
  | `flair federation sync status` | Show whether the driver is installed and genuinely active |
199
217
  | `flair federation watch [--interval <s>]` | Run sync in a foreground loop for an interactive session (default 30s) |
218
+ | `flair federation verify [--wait <s>] [--admin-pass <pass>]` | Write a canary, push it, and check each peer. Flag/file admin credentials authenticate the admin-gated peer listing. 401/403, unreachable, and revoked are UNVERIFIABLE (warning, exit 0); a reachable peer missing the canary still FAILs (exit 1). |
200
219
  | `flair federation reachability` | Probe local instance + each paired peer (read-only) |
201
220
  | `flair federation token [--ttl <min>]` | Generate a one-time pairing token triple (hub only) |
202
221
 
@@ -155,7 +155,7 @@ flair status --target https://<cluster>.<org>.harperfabric.com
155
155
  flair fleet verify --target https://<cluster>.<org>.harperfabric.com
156
156
  ```
157
157
 
158
- `fleet verify` checks health, auth, and version across the origin node plus every Flair federation peer on file. Exit codes: 0 = all verified, 1 = origin failed, 2 = peer version skew, 3 = peer unverifiable.
158
+ `fleet verify` checks health, auth, and version across the origin node plus every Flair federation peer on file. Exit codes: 0 = all probed nodes verified (unverifiable peers warn, do not fail), 1 = origin failed, 2 = reachable peer diverged, 3 = reachable peer unreachable/auth-failed.
159
159
 
160
160
  > **A credential mismatch renders as an empty section.** `flair status` reads `/HealthDetail` with `FLAIR_ADMIN_PASS` / `HDB_ADMIN_PASSWORD` / a pinned agent key — **not** the `FABRIC_*` credentials. On failure it renders blank.
161
161
 
@@ -241,9 +241,9 @@ Fabric gives you N regional nodes running one component — **not** N Flair inst
241
241
 
242
242
  `flair status` reports usage for two directories: no free space, no total, no quota. An instance can hit its quota with nothing saying so. The one indirect signal is a migration halting for space.
243
243
 
244
- ### Unbounded npm cache
244
+ ### npm cache is ephemeral per deploy
245
245
 
246
- Every deploy runs a server-side `npm install` using the node's default cache. npm never evicts it, so it grows until it fills the quota. There is no cache flag, alternate location, or cleanup option. [flair#886](https://github.com/tpsdev-ai/flair/issues/886).
246
+ `flair deploy` / `flair upgrade --target` run the node's `npm install` against a temporary cache and delete it afterwards, so hub quota no longer grows with every install ([flair#886](https://github.com/tpsdev-ai/flair/issues/886)).
247
247
 
248
248
  ---
249
249
 
@@ -202,7 +202,8 @@ Soul entries and relevant memories, in one block sized to a token budget. Paste
202
202
  flair stop # stop the service, keep data
203
203
  flair restart # restart
204
204
  flair uninstall # remove the service, keep data and keys
205
- flair uninstall --purge # remove everything, including data and keys
205
+ flair uninstall --purge # remove data, keys, secrets, schedulers, and client wiring
206
+ # (the npm package is left; `npm uninstall -g @tpsdev-ai/flair`)
206
207
  ```
207
208
 
208
209
  All reversible. Your memories aren't locked in.
package/docs/releasing.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # Releasing Flair
2
2
 
3
- Flair publishes eight workspace packages to npm under `@tpsdev-ai/*`. Releases are
3
+ Flair publishes nine workspace packages to npm under `@tpsdev-ai/*`. Releases are
4
4
  **tokenless** and **staged**: CI authenticates to npm with a short-lived OIDC token
5
5
  (no `NPM_TOKEN` lives anywhere) and submits each package to npm's **staging** area.
6
6
  A maintainer then approves the staged tarballs on npmjs.com with 2FA to make them live.
7
7
 
8
8
  > `flair-bench` is version-bumped and tagged in lockstep with the other 7, and stages in
9
9
  > its own step in CI for [historical reasons](#flair-bench-bootstrap-one-time-done). That
10
- > step is no longer allowed to fail: all eight packages must stage for a release to pass.
10
+ > step is no longer allowed to fail: every already-published package must stage
11
+ > for a release to pass.
11
12
 
12
13
  ```
13
14
  merge release PR ──▶ push tag v0.11.0 ──▶ CI stages all packages ──▶ npm staging
@@ -145,11 +146,15 @@ Leave `npm publish` **unchecked** under allowed actions. This structurally preve
145
146
  CI/OIDC identity from publishing anything live directly — the only path to live is the
146
147
  human 2FA approval of a staged package.
147
148
 
148
- Packages: `flair-client`, `flair-mcp`, `flair`, `openclaw-flair`, `pi-flair`,
149
- `n8n-nodes-flair`, `langgraph-flair`, `flair-bench`.
149
+ Packages: `flair-client`, `flair-mcp`, `flair`,
150
+ `openclaw-flair`, `pi-flair`, `n8n-nodes-flair`, `langgraph-flair`, `flair-bench`.
150
151
 
151
- > A package must already exist on npm before a trusted publisher can be added — all
152
- > eight already do. This account-level config can only be done by an npm org owner.
152
+ > `flair-tool-descriptors` is private and never published. Since flair#1683 it is a
153
+ > **build-time source**: `scripts/vendor-tool-descriptors.mjs` copies it into each
154
+ > consumer's own tree at prebuild (`resources/tool-descriptors/` for `flair`,
155
+ > `packages/flair-mcp/src/tool-descriptors/` for `flair-mcp`) and the consumers
156
+ > import it by relative path. Nothing declares or bundles it as a dependency —
157
+ > 0.54.1's `bundleDependencies` broke fresh global installs (flair#1681 → #1683).
153
158
 
154
159
  ### `flair-bench` bootstrap (one-time, done)
155
160
 
@@ -188,6 +193,15 @@ review. Because the release is triggered by a tag push, its deployment policy mu
188
193
  **`v*` tags** (Settings → Environments → `release` → Deployment branches and tags →
189
194
  Selected branches and tags → add tag rule `v*`).
190
195
 
196
+ ### Required status checks (ruleset)
197
+
198
+ Add **`First-publish preflight`** to the main branch ruleset's required status
199
+ checks (repo settings → Rules → main). This is a repo-settings act, not a code
200
+ change: until it is listed, the release-PR job is **advisory only** and a red
201
+ first-publish preflight can be merged past. The tag-triggered
202
+ `release-publish.yml` also runs the same check before staging, so a hazard is
203
+ still stopped on the normal release path either way.
204
+
191
205
  ### Approver 2FA
192
206
 
193
207
  The maintainer who approves staged packages must have 2FA enabled on their npm account.
@@ -138,8 +138,8 @@ The `--admin-pass` is required so the CLI can write the hub as a local `Peer` re
138
138
 
139
139
  1. The spoke POSTs a signed pairing request to the hub's `/FederationPair` endpoint.
140
140
  2. The bootstrap user authenticates at the Harper platform layer (works on standalone and Fabric deployments alike).
141
- 3. The hub validates the one-time token, verifies the Ed25519 signature, and creates a `Peer` record.
142
- 4. The spoke writes a `Peer` record pointing to the hub so sync knows where to push.
141
+ 3. The hub validates the one-time token, verifies the Ed25519 signature, and creates a `Peer` record. The response includes the hub's `instance {id, publicKey}` when the hub has a FederationInstance row.
142
+ 4. The spoke writes a `Peer` record pointing to the hub so sync knows where to push. A missing hub `publicKey` is an error, never an empty string. That does not create the hub's Instance row.
143
143
 
144
144
  ---
145
145
 
@@ -156,13 +156,18 @@ Syncing to hub: <hub-instance-id>...
156
156
  ✅ Synced 0 records (0 skipped) in 45ms
157
157
  ```
158
158
 
159
- Then verify reachability across the federation:
159
+ Then verify the pairing. `federation verify` pushes the canary itself — you
160
+ do not need the systemd timer from §7 first:
160
161
 
161
162
  ```bash
163
+ flair federation verify --admin-pass "$FLAIR_ADMIN_PASS"
162
164
  flair federation reachability
163
165
  ```
164
166
 
165
- Both local and hub peer should report `OK`.
167
+ `verify` writes a tagged memory, syncs it, and checks each peer. HTTP 401/403,
168
+ an unreachable peer, or a revoked leftover row is UNVERIFIABLE (a warning),
169
+ not FAIL. A reachable peer that is missing the canary still fails.
170
+ `reachability` should report `OK` for local and the hub.
166
171
 
167
172
  ---
168
173
 
@@ -290,7 +295,7 @@ flair federation pair https://fabric-node.example.com:19926/<instance> \
290
295
  | Hub: mint token | `flair federation token --admin-pass <pass> > triple.json` |
291
296
  | Spoke: pair | `flair federation pair <hub-url> --token-from ./triple.json --admin-pass <pass>` |
292
297
  | Spoke: sync | `flair federation sync --admin-pass <pass>` |
293
- | Spoke: verify | `flair federation reachability` |
298
+ | Spoke: verify | `flair federation verify --admin-pass <pass>` then `flair federation reachability` |
294
299
  | Watch loop | `flair federation watch --interval 30` |
295
300
 
296
301
  ---
@@ -57,7 +57,8 @@ flair status # check everything is working
57
57
  flair stop # stop the service (keeps data)
58
58
  flair restart # restart the service
59
59
  flair uninstall # remove the service (keeps data + keys)
60
- flair uninstall --purge # remove everything including data and keys
60
+ flair uninstall --purge # remove data, keys, secrets, schedulers, and client wiring
61
+ # (the npm package is left; `npm uninstall -g @tpsdev-ai/flair`)
61
62
  ```
62
63
 
63
64
  On macOS the service is a launchd plist at `~/Library/LaunchAgents/ai.tpsdev.flair.plist`. On Linux it is a systemd unit at `~/.config/systemd/user/flair.service`. Both auto-start on login/boot and restart on crash.
@@ -108,6 +109,7 @@ logging:
108
109
  | `FLAIR_KEY_PASSPHRASE` | Passphrase for AES-256-GCM encryption of federation private-key seeds. | Set explicitly for production federation deployments. |
109
110
  | `FLAIR_URL` | Override the Flair base URL for CLI commands (points to a remote instance). | When connecting from a different machine. |
110
111
  | `FLAIR_EMBED_THREADS` | CPU threads for in-process embedding. Default is `max(1, availableParallelism() − 1)` — host-aware, one core left for Harper. | Pin a positive integer on a dedicated ingest host, or when the default leaves cores idle / oversubscribed. See [deployment.md](deployment.md#performance-related-environment-variables). |
112
+ | `FLAIR_EMBED_GPU_LAYERS` | GPU layers to offload. Unset leaves HFE's default of 0 (CPU only) — this is a pin, not a new default. | Only to measure Metal offload (`99`) or pin CPU (`0`). See [deployment.md](deployment.md#performance-related-environment-variables) and #1437. |
111
113
  | `FLAIR_HYBRID_RETRIEVAL` | Hybrid BM25 + vector retrieval (default on). | Set `false` to revert to HNSW-only. |
112
114
  | `FLAIR_MODELS_DIR` | Directory the embedding GGUF is loaded from. | When the model lives outside `<data-dir>/models`. |
113
115