@tpsdev-ai/flair 0.48.0 → 0.50.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 (46) hide show
  1. package/README.md +2 -0
  2. package/dist/bridges/runtime/roundtrip.js +91 -2
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli.js +903 -226
  5. package/dist/component-env.js +52 -4
  6. package/dist/deploy.js +20 -3
  7. package/dist/doctor-client.js +105 -32
  8. package/dist/federation/scheduler.js +24 -3
  9. package/dist/hook-install.js +96 -16
  10. package/dist/install/clients.js +318 -9
  11. package/dist/lib/auth-resolve.js +34 -3
  12. package/dist/lib/mcp-enable.js +134 -26
  13. package/dist/lib/scheduler-platform.js +132 -10
  14. package/dist/lib/scratch-owner.js +49 -0
  15. package/dist/rem/scheduler.js +23 -5
  16. package/dist/resources/AgentSeed.js +2 -0
  17. package/dist/resources/Memory.js +24 -5
  18. package/dist/resources/MemoryBootstrap.js +8 -4
  19. package/dist/resources/MemoryFeed.js +3 -0
  20. package/dist/resources/MemoryMaintenance.js +11 -2
  21. package/dist/resources/bm25-index-service.js +257 -0
  22. package/dist/resources/bm25-index.js +631 -0
  23. package/dist/resources/bm25.js +31 -1
  24. package/dist/resources/embeddings-boot.js +45 -3
  25. package/dist/resources/health.js +52 -7
  26. package/dist/resources/mcp-tools.js +1 -0
  27. package/dist/resources/memory-read-scope.js +2 -0
  28. package/dist/resources/search-readiness.js +100 -0
  29. package/dist/resources/semantic-retrieval-core.js +102 -23
  30. package/dist/resources/sort-comparators.js +45 -0
  31. package/dist/src/lib/scheduler-platform.js +132 -10
  32. package/dist/src/rem/scheduler.js +23 -5
  33. package/dist/version-check.js +59 -13
  34. package/docs/auth.md +5 -0
  35. package/docs/claude-code.md +10 -3
  36. package/docs/deepseek-harness.md +1 -1
  37. package/docs/deployment.md +11 -1
  38. package/docs/hosted-on-fabric.md +2 -0
  39. package/docs/integrations.md +78 -5
  40. package/docs/mcp-clients.md +85 -15
  41. package/docs/notes/mcp-oauth-model2.md +31 -13
  42. package/docs/quickstart-fabric.md +1 -1
  43. package/docs/quickstart.md +9 -9
  44. package/docs/standalone-local.md +3 -0
  45. package/docs/troubleshooting.md +25 -0
  46. package/package.json +4 -3
@@ -20,9 +20,9 @@
20
20
  * in `verifyFirstRun()`: success may not be claimed until the thing the
21
21
  * operator asked for has been observed to happen once.
22
22
  */
23
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
24
- import { resolve, dirname, isAbsolute } from "node:path";
25
- import { platform } from "node:os";
23
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
24
+ import { resolve, dirname, isAbsolute, basename } from "node:path";
25
+ import { platform, userInfo } from "node:os";
26
26
  import { spawnSync } from "node:child_process";
27
27
  /**
28
28
  * 30s ceiling on launchctl/systemctl invocations so a hung service manager
@@ -90,20 +90,71 @@ export function interpretActiveResult(plat, code, stdout, stderr) {
90
90
  return null; // spawn itself failed — inconclusive
91
91
  return false; // covers the no-bus case: empty stdout, nonzero/failed exit
92
92
  }
93
+ /** True when this session already has the env `systemctl --user` needs. */
94
+ export function sessionHasUserBusEnv(env = process.env) {
95
+ return Boolean(env.XDG_RUNTIME_DIR?.trim() && env.DBUS_SESSION_BUS_ADDRESS?.trim());
96
+ }
97
+ /**
98
+ * Reads whether lingering is already enabled for the current user.
99
+ * `loginctl show-user … Linger=yes` is the official answer; the stamp file
100
+ * `loginctl enable-linger` creates is the fallback when loginctl is missing
101
+ * or inconclusive. A failed probe is `null`, never linger-off — inventing
102
+ * linger-off would repeat the linger remedy after it already ran (#1107).
103
+ */
104
+ export function probeUserLingerEnabled(opts = {}) {
105
+ const run = opts.run ?? spawnReport;
106
+ const lingerStampExists = opts.lingerStampExists ?? ((u) => existsSync(`/var/lib/systemd/linger/${u}`));
107
+ let user = "";
108
+ try {
109
+ user = userInfo().username;
110
+ }
111
+ catch {
112
+ user = process.env.USER || process.env.LOGNAME || "";
113
+ }
114
+ if (!user)
115
+ return null;
116
+ const r = run(["loginctl", "show-user", user, "--property=Linger"], STATUS_CHECK_TIMEOUT_MS);
117
+ const m = /^Linger=(yes|no)\s*$/m.exec(r.stdout ?? "");
118
+ if (m)
119
+ return m[1] === "yes";
120
+ if (lingerStampExists(user))
121
+ return true;
122
+ return null;
123
+ }
93
124
  /**
94
- * Human remedy text for a failed scheduler-load attempt (flair#850). Covers
95
- * the one root cause traced so far: a missing systemd user session bus,
96
- * which blocks `systemctl --user` entirely in ssh-without-lingering,
97
- * container, and CI contexts. Returns null when the failure doesn't match a
98
- * known patternthe caller already prints the raw stderr, so the operator
99
- * still has something to go on.
125
+ * Human remedy text for a failed scheduler-load attempt (flair#850, #1107).
126
+ * Covers the traced "no systemd user session bus" failure, which blocks
127
+ * `systemctl --user` entirely in ssh-without-lingering, container, and CI
128
+ * contexts. Two cases that used to share one remedy:
129
+ * (a) lingering genuinely off print `loginctl enable-linger`
130
+ * (b) linger already on, this session has no user-bus env — print the
131
+ * `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` export lines
132
+ * Repeating (a) after the operator has applied it is the #1107 lie.
133
+ * Returns null when the failure doesn't match a known pattern — the caller
134
+ * already prints the raw stderr, so the operator still has something to go on.
100
135
  *
101
136
  * `enableCommand` is the caller's own enable invocation, named in the remedy
102
137
  * so the operator is told to re-run the command they actually ran.
103
138
  */
104
- export function describeLoadFailure(plat, loadResult, enableCommand) {
139
+ export function describeLoadFailure(plat, loadResult, enableCommand, session) {
105
140
  const stderr = loadResult.stderr || "";
106
141
  if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
142
+ if (session?.lingerEnabled === true) {
143
+ const env = session.env ?? process.env;
144
+ if (!sessionHasUserBusEnv(env)) {
145
+ return ("No systemd user session bus is available in this session. Lingering is already enabled — " +
146
+ "do not re-run `loginctl enable-linger`. The remaining gap is this session's user-bus environment. " +
147
+ "Export:\n" +
148
+ " export XDG_RUNTIME_DIR=/run/user/$(id -u)\n" +
149
+ " export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus\n" +
150
+ ` then re-run \`${enableCommand}\`.`);
151
+ }
152
+ return ("No systemd user session bus is available in this session. Lingering is already enabled and " +
153
+ "this session already has XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS — " +
154
+ "do not re-run `loginctl enable-linger` or re-export those variables. " +
155
+ "Check that `$XDG_RUNTIME_DIR/bus` exists (the systemd --user instance may not be running), " +
156
+ `then re-run \`${enableCommand}\`.`);
157
+ }
107
158
  return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
108
159
  "in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
109
160
  `— then re-run \`${enableCommand}\`.`);
@@ -172,6 +223,77 @@ export function resolveNodeBin(explicit) {
172
223
  "enable time — refusing to install a shim that would resolve `node` from the service manager's PATH " +
173
224
  "at run time. Install node (or put it on PATH for this shell) and re-run enable.");
174
225
  }
226
+ /**
227
+ * Resolves the path enable will bake as FLAIR_BIN, and whether that path is
228
+ * the stable public `flair` entry (flair#1279).
229
+ *
230
+ * Resolution order:
231
+ * 1. `explicit` — caller/test override. Relatives are resolved against cwd.
232
+ * 2. `hooks.argv1` / `process.argv[1]` — whatever launched enable.
233
+ * 3. The public `flair` on PATH, only when (1) and (2) are empty.
234
+ * Nothing absolute resolvable ⇒ throw. A bare `"flair"` is not an exec
235
+ * target under #1231's `exec <node> <script>` form (`node flair` looks in
236
+ * cwd, not PATH).
237
+ */
238
+ export function resolveFlairBin(explicit, hooks) {
239
+ const publicBin = hooks && "publicBin" in hooks ? (hooks.publicBin ?? null) : lookupPublicFlairBin();
240
+ const captured = explicit ?? hooks?.argv1 ?? process.argv[1];
241
+ let path;
242
+ if (typeof captured === "string" && captured.length > 0) {
243
+ path = isAbsolute(captured) ? captured : resolve(captured);
244
+ }
245
+ else if (publicBin) {
246
+ path = publicBin;
247
+ }
248
+ else {
249
+ throw new Error("unable to resolve an absolute path to the flair CLI (process.argv[1] was empty and `command -v flair` " +
250
+ "found nothing). The scheduler shim bakes this path in at enable time — refusing to install a shim " +
251
+ "whose exec target is unknown. Re-run enable via the `flair` command.");
252
+ }
253
+ return { path, publicBin, canonical: isCanonicalFlairBin(path, publicBin) };
254
+ }
255
+ /** True when `baked` is the public `flair` entry, not a working-tree capture. */
256
+ export function isCanonicalFlairBin(baked, publicBin) {
257
+ if (basename(baked) === "flair")
258
+ return true;
259
+ if (publicBin && pathsReferToSameFile(baked, publicBin))
260
+ return true;
261
+ return false;
262
+ }
263
+ /**
264
+ * The enable-report lines for a non-canonical FLAIR_BIN. Empty when the
265
+ * baked path is the public entry — callers should not print a warning then.
266
+ */
267
+ export function formatFlairBinWarning(baked, publicBin, enableCommand) {
268
+ if (isCanonicalFlairBin(baked, publicBin))
269
+ return [];
270
+ const lines = [
271
+ `⚠️ FLAIR_BIN is ${baked} — that is the process that ran enable, not a stable public entry.`,
272
+ ` A later blue/green directory swap, or deleting this working tree, will strand the scheduler unit.`,
273
+ ];
274
+ if (publicBin) {
275
+ lines.push(` Public \`flair\` on PATH: ${publicBin}. Re-run \`${enableCommand}\` as the \`flair\` command to bake that path instead.`);
276
+ }
277
+ else {
278
+ lines.push(` No \`flair\` on PATH. Re-run \`${enableCommand}\` via the installed \`flair\` command (or a stable symlink) so the baked path survives a tree swap.`);
279
+ }
280
+ return lines;
281
+ }
282
+ function lookupPublicFlairBin() {
283
+ const r = spawnReport(["/bin/sh", "-c", "command -v flair"], STATUS_CHECK_TIMEOUT_MS);
284
+ const found = r.stdout.trim().split("\n")[0]?.trim() ?? "";
285
+ if (r.code === 0 && found && isAbsolute(found) && existsSync(found))
286
+ return found;
287
+ return null;
288
+ }
289
+ function pathsReferToSameFile(a, b) {
290
+ try {
291
+ return realpathSync(a) === realpathSync(b);
292
+ }
293
+ catch {
294
+ return resolve(a) === resolve(b);
295
+ }
296
+ }
175
297
  // ─── first-run verification (flair#1231) ────────────────────────────────────
176
298
  // A load/bootstrap command exiting 0 proves the service manager accepted the
177
299
  // job — not that the job can run. The only vantage that exercises the real
@@ -19,7 +19,7 @@ import { homedir } from "node:os";
19
19
  import { spawn } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { escapeXml } from "../lib/xml-escape.js";
22
- import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
22
+ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, resolveFlairBin, formatFlairBinWarning, verifyFirstRun, probeUserLingerEnabled, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
23
23
  // Re-exported so this module's public surface is unchanged by the extraction
24
24
  // into src/lib/scheduler-platform.ts (a second scheduler — `flair federation
25
25
  // sync enable` — needs the identical launchctl/systemctl interpretation, and
@@ -180,8 +180,8 @@ export async function queryActiveStateAsync(plat, timeoutMs = STATUS_CHECK_TIMEO
180
180
  * known pattern — the caller already prints the raw stderr, so the operator
181
181
  * still has something to go on.
182
182
  */
183
- export function describeLoadFailure(plat, loadResult) {
184
- return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable");
183
+ export function describeLoadFailure(plat, loadResult, session) {
184
+ return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable", session);
185
185
  }
186
186
  /**
187
187
  * Formats the `flair rem nightly enable` report from an `EnableResult`.
@@ -199,6 +199,15 @@ export function describeLoadFailure(plat, loadResult) {
199
199
  * happen once. A missing `loadResult`/`firstRun` (test-only skipLoad shape)
200
200
  * therefore withholds the headline too, instead of being treated as success.
201
201
  */
202
+ function appendFlairBinWarning(lines, r) {
203
+ if (r.flairBinCanonical !== false || !r.flairBin)
204
+ return;
205
+ const warning = formatFlairBinWarning(r.flairBin, r.flairBinPublic ?? null, "flair rem nightly enable");
206
+ if (warning.length === 0)
207
+ return;
208
+ lines.push("");
209
+ lines.push(...warning);
210
+ }
202
211
  export function formatEnableReport(r, input) {
203
212
  const { hour, minute, agentId, flairUrl } = input;
204
213
  const scheduleTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
@@ -216,11 +225,15 @@ export function formatEnableReport(r, input) {
216
225
  ];
217
226
  if (lr.stderr)
218
227
  lines.push(` stderr: ${lr.stderr.trim()}`);
219
- const remedy = describeLoadFailure(r.platform, lr);
228
+ const lingerEnabled = input.lingerEnabled !== undefined
229
+ ? input.lingerEnabled
230
+ : (r.platform === "linux" ? (input.probeLinger ?? probeUserLingerEnabled)() : undefined);
231
+ const remedy = describeLoadFailure(r.platform, lr, { lingerEnabled, env: input.env });
220
232
  lines.push("");
221
233
  lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
222
234
  lines.push("");
223
235
  lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
236
+ appendFlairBinWarning(lines, r);
224
237
  return { lines, ok: false };
225
238
  }
226
239
  if (!r.firstRunVerified) {
@@ -272,6 +285,7 @@ export function formatEnableReport(r, input) {
272
285
  }
273
286
  lines.push("");
274
287
  lines.push(` Check anytime with: flair rem nightly status`);
288
+ appendFlairBinWarning(lines, r);
275
289
  return { lines, ok: false };
276
290
  }
277
291
  const lines = [
@@ -288,6 +302,7 @@ export function formatEnableReport(r, input) {
288
302
  lines.push(` First run: completed through the service manager, exit 0`);
289
303
  lines.push("");
290
304
  lines.push(`Disable with \`flair rem nightly disable\`.`);
305
+ appendFlairBinWarning(lines, r);
291
306
  return { lines, ok: true };
292
307
  }
293
308
  /**
@@ -329,7 +344,8 @@ export function formatStatusReport(s) {
329
344
  */
330
345
  export function enableScheduler(opts) {
331
346
  const plat = detectPlatform(opts.platformOverride);
332
- const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
347
+ const resolvedFlair = resolveFlairBin(opts.flairBin);
348
+ const flairBin = resolvedFlair.path;
333
349
  const nodeBin = resolveNodeBin(opts.nodeBin);
334
350
  const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
335
351
  const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
@@ -383,6 +399,7 @@ export function enableScheduler(opts) {
383
399
  return {
384
400
  platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult,
385
401
  firstRunVerified: firstRun?.verified === true, firstRun,
402
+ flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
386
403
  };
387
404
  }
388
405
  // Linux: systemd user units.
@@ -408,6 +425,7 @@ export function enableScheduler(opts) {
408
425
  return {
409
426
  platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult,
410
427
  firstRunVerified: firstRun?.verified === true, firstRun,
428
+ flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
411
429
  };
412
430
  }
413
431
  /**
@@ -18,6 +18,15 @@
18
18
  * - No advisory data — we don't know which release fixed which CVE, so the
19
19
  * severity heuristic is purely the version GAP (major/minor count), not
20
20
  * "did this release carry a security fix". See classifyGap().
21
+ *
22
+ * Honest-numbers refinement (flair#1341): the TTL fast-path is only taken
23
+ * when the cached answer implies NOTHING will be printed. When a cached
24
+ * answer would produce a nudge, we spend one fresh fetch (same short timeout,
25
+ * same failure tolerance) so the printed fact is current whenever possible —
26
+ * nudges are rare, so the TTL still protects the common up-to-date path. If
27
+ * that fetch fails, the nudge falls back to the cached value but SAYS so
28
+ * ("latest known (checked 9h ago): …") instead of stating a stale number as
29
+ * current fact.
21
30
  */
22
31
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
32
  import { homedir } from "node:os";
@@ -85,13 +94,23 @@ export function defaultVersionCheckDeps() {
85
94
  * Resolve the latest published @tpsdev-ai/flair version, preferring a fresh
86
95
  * cache hit over a network round trip, and falling back to a stale cache (or
87
96
  * giving up quietly) when the registry is unreachable. NEVER throws.
97
+ *
98
+ * flair#1341: the cache fast-path applies only when the cached answer implies
99
+ * no nudge. A cached answer that WOULD nudge triggers one fresh fetch (same
100
+ * timeout, same failure tolerance) so the printed fact is current whenever
101
+ * the network allows; on failure it falls back to the cache, age attached.
88
102
  */
89
103
  export async function checkVersion(installed, injected = {}) {
90
104
  const deps = { ...defaultVersionCheckDeps(), ...injected };
91
105
  const nowMs = deps.now();
92
106
  const cached = deps.readCache(deps.cachePath);
93
107
  if (cached && nowMs - cached.checkedAt < deps.ttlMs) {
94
- return { installed, latest: cached.latest, source: "cache" };
108
+ if (classifyGap(installed, cached.latest).severity === "none") {
109
+ return { installed, latest: cached.latest, source: "cache", checkedAgoMs: nowMs - cached.checkedAt };
110
+ }
111
+ // The cached answer would print a nudge — fall through to one fresh
112
+ // fetch so we present a CURRENT fact when possible. The failure path
113
+ // below still falls back to this same cache (offline tolerance intact).
95
114
  }
96
115
  // Defense-in-depth: the default fetchLatest already catches everything
97
116
  // internally (network error, timeout, non-2xx, bad JSON) and resolves
@@ -109,10 +128,10 @@ export async function checkVersion(installed, injected = {}) {
109
128
  deps.writeCache(deps.cachePath, { latest: fetched, checkedAt: nowMs });
110
129
  return { installed, latest: fetched, source: "network" };
111
130
  }
112
- // Registry unreachable/timed out — fall back to a stale cache rather than
131
+ // Registry unreachable/timed out — fall back to the cache rather than
113
132
  // reporting nothing, but never block or throw trying to get a fresh one.
114
133
  if (cached) {
115
- return { installed, latest: cached.latest, source: "cache" };
134
+ return { installed, latest: cached.latest, source: "cache", checkedAgoMs: nowMs - cached.checkedAt };
116
135
  }
117
136
  return { installed, latest: null, source: "unavailable" };
118
137
  }
@@ -129,7 +148,7 @@ export function primeVersionCheckCache(latest, injected = {}) {
129
148
  const now = injected.now ?? (() => Date.now());
130
149
  writeCacheFile(cachePath, { latest, checkedAt: now() });
131
150
  }
132
- const NO_GAP = { severity: "none", majorBehind: false, releasesBehind: 0 };
151
+ const NO_GAP = { severity: "none", majorBehind: false, unit: null, versionsBehind: 0 };
133
152
  /**
134
153
  * Classify how far `installed` is behind `latest` using major.minor.patch
135
154
  * math only — we don't have advisory data, so:
@@ -146,24 +165,43 @@ export function classifyGap(installed, latest) {
146
165
  const [aMaj, aMin, aPatch] = a;
147
166
  const [bMaj, bMin, bPatch] = b;
148
167
  if (bMaj > aMaj)
149
- return { severity: "red", majorBehind: true, releasesBehind: 0 };
168
+ return { severity: "red", majorBehind: true, unit: null, versionsBehind: 0 };
150
169
  if (bMaj < aMaj)
151
170
  return NO_GAP; // installed is ahead (e.g. local/pre-release build)
152
171
  if (bMin > aMin) {
153
- const releasesBehind = bMin - aMin;
154
- return { severity: releasesBehind >= 2 ? "red" : "yellow", majorBehind: false, releasesBehind };
172
+ const versionsBehind = bMin - aMin;
173
+ return { severity: versionsBehind >= 2 ? "red" : "yellow", majorBehind: false, unit: "minor", versionsBehind };
155
174
  }
156
175
  if (bMin < aMin)
157
176
  return NO_GAP; // ahead on minor
158
- if (bPatch > aPatch)
159
- return { severity: "yellow", majorBehind: false, releasesBehind: bPatch - aPatch };
177
+ if (bPatch > aPatch) {
178
+ return { severity: "yellow", majorBehind: false, unit: "patch", versionsBehind: bPatch - aPatch };
179
+ }
160
180
  return NO_GAP; // equal, or ahead on patch
161
181
  }
182
+ /** Compact human age for "checked … ago" — coarse on purpose (a nudge, not a log). */
183
+ function formatCheckedAgo(ms) {
184
+ const minutes = Math.round(ms / 60_000);
185
+ if (minutes < 60)
186
+ return `${Math.max(1, minutes)}m`;
187
+ const hours = Math.round(ms / 3_600_000);
188
+ if (hours < 48)
189
+ return `${hours}h`;
190
+ return `${Math.round(ms / 86_400_000)}d`;
191
+ }
162
192
  /**
163
193
  * Build the human-readable nudge line for `flair status`/`flair doctor`, or
164
194
  * null when there's nothing worth printing — current, ahead (local/dev
165
195
  * build), or we couldn't determine latest at all (offline with no cache).
166
196
  * Callers own icon/color; this returns plain text plus a severity to color by.
197
+ *
198
+ * flair#1341 honest-numbers contract:
199
+ * - A cache-sourced answer is labelled as such ("latest known (checked 9h
200
+ * ago): X"), never stated as current fact.
201
+ * - The count names its unit ("N minor versions behind" / "M patch
202
+ * releases behind") — it must say what classifyGap actually counted.
203
+ * - The suggested command is our paved path, `flair upgrade` (refreshes
204
+ * MCP pins, verifies restart — see flair#1324), not a bare npm install.
167
205
  */
168
206
  export function formatVersionNudge(result) {
169
207
  if (!result.latest)
@@ -171,11 +209,19 @@ export function formatVersionNudge(result) {
171
209
  const gap = classifyGap(result.installed, result.latest);
172
210
  if (gap.severity === "none")
173
211
  return null;
212
+ const latestClaim = result.source === "cache"
213
+ ? result.checkedAgoMs != null
214
+ ? `latest known (checked ${formatCheckedAgo(result.checkedAgoMs)} ago): ${result.latest}`
215
+ : `latest known: ${result.latest}`
216
+ : `latest is ${result.latest}`;
217
+ const plural = gap.versionsBehind === 1 ? "" : "s";
174
218
  const countHint = gap.majorBehind
175
- ? "major version"
176
- : `${gap.releasesBehind} release${gap.releasesBehind === 1 ? "" : "s"}`;
177
- const message = `flair ${result.installed} is behind — latest is ${result.latest} (${countHint} behind). ` +
178
- `Upgrade: npm i -g ${FLAIR_PKG_NAME}@latest`;
219
+ ? "major version behind"
220
+ : gap.unit === "patch"
221
+ ? `${gap.versionsBehind} patch release${plural} behind`
222
+ : `${gap.versionsBehind} minor version${plural} behind`;
223
+ const message = `flair ${result.installed} is behind — ${latestClaim} (${countHint}). ` +
224
+ `Run: flair upgrade`;
179
225
  return { severity: gap.severity, message };
180
226
  }
181
227
  /**
package/docs/auth.md CHANGED
@@ -39,6 +39,11 @@ flair agent add myagent
39
39
 
40
40
  This is the default and recommended auth for single-instance deployments.
41
41
 
42
+ Adapter or hosted-Flair 404? Identity is three things (keyfile + agent id +
43
+ the `Agent` row on **that** instance). A by-id 404 is fail-closed ownership,
44
+ never an existence signal. The adapter write-up is
45
+ [integrations.md — Hosted Flair auth](integrations.md#hosted-flair-auth--your-agent-got-a-404).
46
+
42
47
  ## Deployment shapes: personal vs org
43
48
 
44
49
  Flair has no `mode`/`shape` config setting — the shape you get is emergent from *how you provision principals*, not something you declare:
@@ -43,10 +43,15 @@ Copy this into your project's `CLAUDE.md` (or `.claude/settings.md`, `AGENTS.md`
43
43
 
44
44
  Run this FIRST, before doing anything else:
45
45
 
46
- flair bootstrap --agent my-project --max-tokens 4000
46
+ mcp__flair__bootstrap
47
47
 
48
+ (`mcp__flair__bootstrap` is Claude Code's namespaced name for the server's `bootstrap` tool.)
48
49
  Read the output — that's your soul and recent memories.
49
50
 
51
+ Use the CLI variant when MCP is not wired — previewing context yourself, a script, or any agent that can run a shell command:
52
+
53
+ flair bootstrap --agent my-project --max-tokens 4000
54
+
50
55
  ### During work
51
56
 
52
57
  - Remember something: `flair memory add --agent my-project --content "what you learned"`
@@ -121,10 +126,12 @@ export FLAIR_URL=http://localhost:19926 # default, only needed if custom
121
126
  Then the CLAUDE.md simplifies to:
122
127
 
123
128
  ## Memory
124
- - Bootstrap: `flair bootstrap`
129
+ - Bootstrap: `mcp__flair__bootstrap`
125
130
  - Remember: `flair memory add --content "what you learned"`
126
131
  - Search: `flair search "your query"`
127
132
 
133
+ Use `flair bootstrap` when MCP is not wired.
134
+
128
135
  ## Soul (Personality / Context)
129
136
 
130
137
  Want Claude Code to have consistent personality or project context? Set soul entries:
@@ -143,7 +150,7 @@ flair soul set --agent my-project --key review \
143
150
  --value "Check for: error handling, edge cases, performance implications, security."
144
151
  ```
145
152
 
146
- Soul entries are included in every `flair bootstrap` — they're the persistent context that shapes how Claude Code thinks about your project.
153
+ Soul entries are included in every bootstrap — they're the persistent context that shapes how Claude Code thinks about your project.
147
154
 
148
155
  ## Remote Flair
149
156
 
@@ -4,7 +4,7 @@ Give DeepSeek Harness (DSH) sessions persistent, portable memory — no plugin c
4
4
 
5
5
  > **Verified against DSH as of 2026-08-20** (`deepseek-ai/deepseek-harness`, branch `master`). DSH is a developer preview and its own README promises compatibility-breaking changes. If wiring fails after a DSH upgrade, re-check the config field names against [their MCP client README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md) before suspecting Flair.
6
6
 
7
- The same eleven tools every other MCP client gets ([full table in mcp-clients.md](mcp-clients.md#what-the-mcp-server-exposes)) appear to the model under DSH's server-qualified names: `mcp__flair__memory_search`, `mcp__flair__memory_store`, `mcp__flair__bootstrap`, and so on — the same `mcp__<server>__<tool>` convention Claude Code uses.
7
+ The same twelve tools every other MCP client gets ([full table in mcp-clients.md](mcp-clients.md#what-the-mcp-server-exposes)) appear to the model under DSH's server-qualified names: `mcp__flair__memory_search`, `mcp__flair__memory_store`, `mcp__flair__bootstrap`, and so on — the same `mcp__<server>__<tool>` convention Claude Code uses.
8
8
 
9
9
  Two caveats up front, both structural to DSH's bridge (details below):
10
10
 
@@ -108,7 +108,7 @@ EXPOSE 19926
108
108
  CMD ["flair", "start", "--foreground"]
109
109
  ```
110
110
 
111
- Note: embeddings run on CPU in Docker (no Metal acceleration). Performance is acceptable for small-to-medium memory stores (< 10K memories).
111
+ Note: embeddings run on CPU in Docker (no Metal acceleration). Performance is acceptable for small-to-medium memory stores (< 10K memories). Thread count follows `FLAIR_EMBED_THREADS` (default `max(1, availableParallelism() − 1)`); pin it if the container CPU quota and the host you want to use disagree.
112
112
 
113
113
  ---
114
114
 
@@ -187,6 +187,16 @@ Set these in the Flair process environment (`~/Library/LaunchAgents/ai.tpsdev.fl
187
187
  | `HTTP_PORT` | Override the Harper HTTP port. Useful for sandboxes; production deployments should configure the port in `config.yaml` instead. | Rare. |
188
188
  | `FLAIR_OPS_BIND` | Bind address for the Harper **ops API**. Resolution order: `flair init --ops-bind` > this variable > the `opsBind` key `flair init` persists in `~/.flair/config.yaml` > `127.0.0.1`. Every Flair-managed Harper start re-asserts the resolved value, so the persisted key is what makes a choice survive `flair restart` / `flair upgrade`. | Only for deployments that genuinely need remote ops admin (multi-host / Fabric) — set it to `0.0.0.0`, or record it once with `flair init --ops-bind 0.0.0.0`. Single-host installs want the loopback default. |
189
189
 
190
+ ### Performance-related environment variables
191
+
192
+ These are read by the Harper process at boot (same places as the table above: launchd plist, systemd unit, component `.env` / Fabric env). They are **not** `config.yaml` keys — embedding registration is in-process and must not persist into Harper's config file.
193
+
194
+ | Variable | Default | What it does |
195
+ |----------|---------|--------------|
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_HYBRID_RETRIEVAL` | `true` | Hybrid BM25 + vector retrieval. Set `false` / `0` / `off` to revert to the legacy HNSW + keyword-bump path. |
198
+ | `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
+
190
200
  ---
191
201
 
192
202
  ## Backup & Restore
@@ -138,6 +138,8 @@ flair agent add mybot --target "$FLAIR_URL" --ops-target <ops-url>
138
138
 
139
139
  Auth is the same protocol as standalone: Ed25519 signature of `agentId:timestamp:nonce:METHOD:/path`, 30-second replay window, nonce deduplication. The difference is purely the transport — HTTPS instead of localhost HTTP.
140
140
 
141
+ An adapter that just got a 404 is almost never "Harper wants a different verb." Check the three identity pieces (keyfile, agent id, `Agent` row on **this** instance) and treat by-id 404 as fail-closed ownership, not an existence signal: [integrations.md — Hosted Flair auth](integrations.md#hosted-flair-auth--your-agent-got-a-404).
142
+
141
143
  See [secrets-and-keys.md](secrets-and-keys.md) for the full threat model.
142
144
 
143
145
  ---
@@ -22,7 +22,9 @@ Where Flair already runs. Each integration shown here is a working surface — t
22
22
  | **OpenClaw** | [`openclaw-flair`](#openclaw) | Ed25519 | Native plugin + context engine |
23
23
  | **n8n** | [`n8n-nodes-flair`](#n8n) | FlairApi credential | Three nodes (chat memory, search, store) |
24
24
  | **Hermes Agent** | [`hermes-flair`](#hermes-agent) | Ed25519 | Python `MemoryProvider` |
25
- | **Pi agent** | [`pi-flair`](#pi-agent) | Ed25519 | TS plugin |
25
+ | **Pi agent** | [`pi-flair`](#pi-agent) | Ed25519 | Native pi extension (pi has no MCP support); `flair init --client pi` wires it, `flair doctor` checks it |
26
+ | **Google ADK** (Python) | [`adk-flair`](../packages/adk-flair/README.md) | Ed25519 | `BaseMemoryService`; see [hosted auth](#hosted-flair-auth--your-agent-got-a-404) if you just got a 404 |
27
+ | **Google ADK** (JS/TS) | [`@tpsdev-ai/adk-flair`](../packages/adk-flair-js/README.md) | Ed25519 | Same identity model as the Python package |
26
28
 
27
29
  Don't see your harness? If it speaks **MCP** — Flair already works with `flair-mcp`. If it has a **custom memory protocol** like LangGraph's `BaseStore` or CrewAI's `RAGStorage`, an adapter is a ~200-line package; [open an issue](https://github.com/tpsdev-ai/flair/issues) or [send a PR](https://github.com/tpsdev-ai/flair).
28
30
 
@@ -32,6 +34,56 @@ Don't see your harness? If it speaks **MCP** — Flair already works with `flair
32
34
 
33
35
  ---
34
36
 
37
+ ## Hosted Flair auth — your agent got a 404
38
+
39
+ Written for the person whose adapter just got a 404 against a hosted Flair (Harper Fabric or any non-localhost URL). Laptop `flair init` on `127.0.0.1:19926` is a different machine from the one signing the request.
40
+
41
+ The protocol lives in [auth.md](auth.md). Key lifecycle lives in [secrets-and-keys.md](secrets-and-keys.md). Fabric registration (including the ops port trap) lives in [quickstart-fabric.md](quickstart-fabric.md). This section is only the identity check.
42
+
43
+ ### Identity is three things that must match
44
+
45
+ Ed25519 agent auth is not a password. The server accepts a request only when all three line up:
46
+
47
+ 1. **Agent id** — `FLAIR_AGENT_ID`. This is the string inside the signature.
48
+ 2. **Keyfile on the machine that signs** — `FLAIR_KEYFILE` (adk-flair / adk-flair-js) or `FLAIR_KEY_PATH` (flair-mcp, Hermes, pi). The private key never leaves that host. `flair agent add` writes `~/.flair/keys/<id>.key`.
49
+ 3. **Server-side `Agent` row on the instance at `FLAIR_URL`** whose `publicKey` matches that keyfile. Registration is per instance. A key minted against localhost is not registered on Fabric until you run `flair agent add <id> --target` at that URL.
50
+
51
+ The signed payload is `agentId:timestamp:nonce:METHOD:/path` (30-second replay window).
52
+
53
+ Do not "fix" a 401 or 404 by pasting the Harper admin password into the agent's standing environment. Admin Basic auth is for registration, once. The first signed call against an unregistered id is **401 `unknown_agent`** — that is fail-closed working as designed.
54
+
55
+ ### The three failure shapes
56
+
57
+ | Shape | What is true | What you see | What to do |
58
+ |---|---|---|---|
59
+ | **Record missing** | No `Agent` row for this id on **this** instance | `401 {"error":"unknown_agent"}` on every signed route | Register against the hosted URL: `flair agent add <id> --target "$FLAIR_URL" --ops-target <ops-url> --admin-pass-file <path>`. Fabric ops is not `data-port − 1` — see [quickstart-fabric.md](quickstart-fabric.md). |
60
+ | **Key mismatch** | The id exists; the public key on the server is not the one in your keyfile | `401 {"error":"invalid_signature"}` | Same id, wrong key — copied from another host, rotated on one side only, or the env pointing at a different agent's file. Point the env at the key that matches **this** instance, or re-seed the hosted `Agent` row from the key on this machine: `flair agent add <id> --target "$FLAIR_URL" --ops-target <ops-url> --admin-pass-file <path>` (reuses the local keyfile). `flair agent rotate-key` is localhost-only. Restart the adapter so it reloads the key. |
61
+ | **Config wrong** | Identity may be fine; you are not talking to the Flair you think | Timeouts, connection errors, or **404 from Harper's catch-all** | `FLAIR_URL` must be the origin the **signing process** can open (cloud-agent localhost is the VM, not your laptop). adk-flair also needs `FLAIR_ALLOW_REMOTE_URL=1` and a raised `FLAIR_HTTP_TIMEOUT` (defaults are localhost fail-fast). A `FLAIR_URL` with a path prefix sends every request to a route that does not exist. `/Health` can be 200 while `/Memory` is still 404 if the Flair app is not loaded yet. |
62
+
63
+ Clock skew is a fourth, rarer 401: `timestamp_out_of_window`.
64
+
65
+ `flair agent list` talks to **localhost**. It cannot tell you whether the hosted instance has your Agent row. The discriminator is the 401 body on a signed request.
66
+
67
+ ### 404 on by-id routes is not an existence signal
68
+
69
+ `GET /Memory/{id}`, `PUT /Memory/{id}`, and the adapter/MCP wrappers (`memory_get`, a by-id update) return **404** both when the id is absent **and** when the record exists but your principal may not see it (another agent's `private` memory, or outside your read scope). Same status, same body shape. That is fail-closed ownership ([flair#1264](https://github.com/tpsdev-ai/flair/issues/1264)) — a 403 would confirm the id exists and can name the owner.
70
+
71
+ **Do not treat that 404 as "the record is missing, so create it" or "Harper rejected the verb."** Creates go to `POST /Memory/` (id in the body). A by-id 404 after a write usually means you are not the owner the server thinks you are — go back to the three shapes above — not that you should switch PUT for POST.
72
+
73
+ A verified agent that is not allowed the row gets 404, never 403. Anonymous by-id reads are denied at the gate.
74
+
75
+ ### Per-adapter env
76
+
77
+ | Adapter | URL | Agent id | Keyfile | Hosted extras |
78
+ |---|---|---|---|---|
79
+ | **adk-flair** / **adk-flair-js** | `FLAIR_URL` | `FLAIR_AGENT_ID` | `FLAIR_KEYFILE` | `FLAIR_ALLOW_REMOTE_URL=1`, `FLAIR_HTTP_TIMEOUT` — [adk-flair README](../packages/adk-flair/README.md#hosted-flair) |
80
+ | **flair-mcp** / Cursor plugin | `FLAIR_URL` | `FLAIR_AGENT_ID` | `FLAIR_KEY_PATH` (optional; auto-resolved) | Key must be on the **npx host** |
81
+ | **Hermes / pi / LangGraph** | `FLAIR_URL` | `FLAIR_AGENT_ID` | `FLAIR_KEY_PATH` or client `keyPath` | Same Ed25519 model |
82
+
83
+ n8n still uses Harper admin Basic auth — it is not this path. See [n8n.md](n8n.md#security).
84
+
85
+ ---
86
+
35
87
  ## Claude Code, Cursor, Codex, Gemini CLI, Continue.dev, Goose — via `flair-mcp`
36
88
 
37
89
  [`@tpsdev-ai/flair-mcp`](https://www.npmjs.com/package/@tpsdev-ai/flair-mcp) is a [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes Flair as a memory tool to any MCP-speaking client. One server, every MCP client.
@@ -168,13 +220,34 @@ Auth: TPS-Ed25519 (the same model the rest of Flair uses) — writes are isolate
168
220
 
169
221
  ## Pi agent
170
222
 
171
- [`@tpsdev-ai/pi-flair`](https://www.npmjs.com/package/@tpsdev-ai/pi-flair) is the TS plugin for the [Pi coding agent](https://github.com/mariozechner/pi-coding-agent). Memory + identity for the Pi runtime.
223
+ [`@tpsdev-ai/pi-flair`](https://www.npmjs.com/package/@tpsdev-ai/pi-flair) is the **native pi extension** for the [Pi coding agent](https://github.com/mariozechner/pi-coding-agent) — pi has no MCP client support, so this is a first-party plugin, not an MCP bridge. Memory + identity (`memory_search`, `memory_store`, `bootstrap`) for the pi runtime.
224
+
225
+ Wire it (either form is equivalent):
226
+
227
+ ```bash
228
+ flair init --client pi # writes a pinned "packages" entry into ~/.pi/agent/settings.json
229
+ # or
230
+ pi install npm:@tpsdev-ai/pi-flair
231
+ ```
232
+
233
+ Which produces:
234
+
235
+ ```json
236
+ {
237
+ "packages": ["npm:@tpsdev-ai/pi-flair@<version>"]
238
+ }
239
+ ```
240
+
241
+ **Known trap:** the `extensions` settings key takes local file paths only — an `npm:` spec there is *silently ignored* by pi, so the tools never register ([#1346](https://github.com/tpsdev-ai/flair/issues/1346)). Package sources belong under `packages`. `flair doctor` detects pi, verifies the wiring, calls this exact misconfiguration out, and `flair doctor --fix` moves the entry.
242
+
243
+ pi settings carry no per-package env, so identity comes from the environment that launches pi:
172
244
 
173
245
  ```bash
174
- npm install @tpsdev-ai/pi-flair
246
+ export FLAIR_AGENT_ID=my-agent # per host/purpose
247
+ pi
175
248
  ```
176
249
 
177
- Pi resolves the plugin via its standard plugin config; pin `agentId` per host.
250
+ Full details (tools, env reference, auto-recall/auto-capture flags, security notes): [`packages/pi-flair/README.md`](../packages/pi-flair/README.md).
178
251
 
179
252
  ---
180
253
 
@@ -188,7 +261,6 @@ If it has a custom memory protocol, the adapter pattern is small (~200 lines). L
188
261
  - CrewAI (Python `BaseRAGStorage` protocol)
189
262
  - AG2 / AutoGen (Python)
190
263
  - Mastra (TS, denser thread model)
191
- - ADK (Google, Python + TS)
192
264
 
193
265
  [Open an issue](https://github.com/tpsdev-ai/flair/issues) describing the harness and we'll triage. PRs welcome — see [`packages/langgraph-flair`](../packages/langgraph-flair) as the smallest-shape reference.
194
266
 
@@ -198,6 +270,7 @@ If it has a custom memory protocol, the adapter pattern is small (~200 lines). L
198
270
 
199
271
  - [Quickstart](quickstart.md) — `flair init` to working memory on a laptop
200
272
  - [Fabric Quickstart](quickstart-fabric.md) — `flair deploy` to a reachable Harper Fabric URL
273
+ - [Hosted Flair auth](#hosted-flair-auth--your-agent-got-a-404) — Ed25519 identity, the three 401/404 shapes, why a by-id 404 is not an existence signal
201
274
  - [Embedding in a Harper app](embedding-in-a-harper-app.md) — run Flair as a component of your own Harper instance and call it in-process
202
275
  - [Memory bridges](bridges.md) — import/export Flair ↔ Mem0, ChatGPT, claude-project, markdown, agentic-stack (five bridges shipped)
203
276
  - [Federation](federation.md) — pair instances peer-to-peer for cross-machine sync