@tpsdev-ai/flair 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import nacl from "tweetnacl";
4
4
  import { load as parseYaml } from "js-yaml";
5
5
  import * as render from "./render.js";
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
7
- import { homedir, hostname, tmpdir } from "node:os";
7
+ import { homedir, tmpdir } from "node:os";
8
8
  import { join, resolve, sep, dirname } from "node:path";
9
9
  import { spawn } from "node:child_process";
10
10
  import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
@@ -12,7 +12,7 @@ import { create as tarCreate, extract as tarExtract, list as tarList } from "tar
12
12
  import { keystore } from "./keystore.js";
13
13
  import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
14
14
  import { fabricUpgrade } from "./fabric-upgrade.js";
15
- import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
15
+ import { detectClients, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
16
16
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
17
17
  // src/ into resources/, which don't survive npm packaging (see also
18
18
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -447,6 +447,117 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
447
447
  }
448
448
  throw new Error(`Harper at port ${httpPort} did not respond within ${timeoutMs}ms (${attempt} attempts)`);
449
449
  }
450
+ /**
451
+ * Verify that semantic search ACTUALLY works by storing a memory with a
452
+ * distinctive phrase and searching for a PARAPHRASE (different words, same
453
+ * meaning). If embeddings are loaded, the paraphrase recovers the memory by
454
+ * meaning with a genuine semantic score. If embeddings are NOT loaded,
455
+ * SemanticSearch falls back to keyword-only scan: the paraphrase shares no
456
+ * keywords with the stored content, so the memory is NOT recovered (or only via
457
+ * the `_warning` keyword-fallback marker) → "degraded".
458
+ *
459
+ * The probe is authenticated as a real agent (Ed25519) because SemanticSearch
460
+ * rejects anonymous callers (401) and per-agent scoping requires it. We pick the
461
+ * given agentId, else FLAIR_AGENT_ID, else the first `.key` in keysDir.
462
+ *
463
+ * Exported so the init smoke test and unit tests can reuse the exact same gate.
464
+ */
465
+ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
466
+ // Resolve an agent + key to sign with.
467
+ let agentId = agentIdOpt || process.env.FLAIR_AGENT_ID || undefined;
468
+ if (!agentId) {
469
+ try {
470
+ const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
471
+ if (keyFiles.length > 0)
472
+ agentId = keyFiles[0].replace(/\.key$/, "");
473
+ }
474
+ catch { /* keysDir missing */ }
475
+ }
476
+ if (!agentId) {
477
+ return { state: "skipped", detail: "no agent id or key found" };
478
+ }
479
+ // Find the signing key. Prefer the standard locations (resolveKeyPath), but
480
+ // fall back to the keysDir we were handed — `flair init` keys live there and
481
+ // it may not be a standard location (e.g. --keys-dir, tests).
482
+ let keyPath = resolveKeyPath(agentId);
483
+ if (!keyPath) {
484
+ const candidate = join(keysDir, `${agentId}.key`);
485
+ if (existsSync(candidate))
486
+ keyPath = candidate;
487
+ }
488
+ if (!keyPath) {
489
+ return { state: "skipped", detail: `no private key for agent '${agentId}'` };
490
+ }
491
+ // Distinctive content vs. a PARAPHRASE query with deliberately ZERO shared
492
+ // content words. If the search recovers the memory it can ONLY be by meaning.
493
+ // content: "The feline predator silently stalked its unsuspecting rodent quarry at dusk."
494
+ // query: "a cat hunting a mouse in the evening"
495
+ // No word in the query appears in the content (cat≠feline, mouse≠rodent,
496
+ // hunting≠stalked, evening≠dusk), so a keyword scan returns nothing.
497
+ const marker = `flair-doctor-embed-check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
498
+ const id = marker;
499
+ const content = `The feline predator silently stalked its unsuspecting rodent quarry at dusk. [${marker}]`;
500
+ const paraphrase = "a cat hunting a mouse in the evening";
501
+ let stored = false;
502
+ try {
503
+ // Write the test memory (ephemeral so it's never durable). PUT /Memory/<id>.
504
+ const writeRes = await authFetch(baseUrl, agentId, keyPath, "PUT", `/Memory/${id}`, {
505
+ id, agentId, content, durability: "ephemeral", createdAt: new Date().toISOString(),
506
+ });
507
+ if (!writeRes.ok && writeRes.status !== 204) {
508
+ const text = await writeRes.text().catch(() => "");
509
+ return { state: "skipped", detail: `could not write probe memory: HTTP ${writeRes.status} ${text.slice(0, 80)}` };
510
+ }
511
+ stored = true;
512
+ // Allow the HNSW index to catch up before searching.
513
+ await new Promise((r) => setTimeout(r, 1500));
514
+ // Search by PARAPHRASE. scoring: "raw" so we read the unweighted semantic
515
+ // similarity (_rawScore) directly, without recency/durability composites
516
+ // muddying the keyword-vs-semantic distinction.
517
+ const searchRes = await authFetch(baseUrl, agentId, keyPath, "POST", "/SemanticSearch", {
518
+ agentId, q: paraphrase, limit: 10, scoring: "raw",
519
+ });
520
+ if (!searchRes.ok) {
521
+ const text = await searchRes.text().catch(() => "");
522
+ return { state: "skipped", detail: `SemanticSearch failed: HTTP ${searchRes.status} ${text.slice(0, 80)}` };
523
+ }
524
+ const data = await searchRes.json();
525
+ // The server sets _warning ONLY when getMode() === "none" — i.e. the
526
+ // embedding engine failed to init and the search ran keyword-only. That is
527
+ // the unambiguous "embeddings not loaded" signal.
528
+ if (data._warning) {
529
+ return { state: "degraded", detail: data._warning };
530
+ }
531
+ const results = data.results ?? [];
532
+ const hit = results.find((r) => r.id === id);
533
+ if (!hit) {
534
+ // The paraphrase shares no keywords with the content, so a keyword-only
535
+ // fallback can't find it. Missing the memory == recall-by-meaning is dead.
536
+ return { state: "degraded", detail: "paraphrase did not recall the probe memory (keyword-only fallback active)" };
537
+ }
538
+ // A genuine semantic hit has a positive similarity score. The keyword bonus
539
+ // is +0.05; since there is no keyword overlap here, any score above that
540
+ // floor can only come from vector similarity. Require a real semantic score.
541
+ const score = typeof hit._rawScore === "number" ? hit._rawScore : (hit._score ?? 0);
542
+ if (score <= 0.05) {
543
+ return { state: "degraded", detail: `probe recalled with non-semantic score ${score} (keyword/zero)` };
544
+ }
545
+ return { state: "ok", score };
546
+ }
547
+ catch (err) {
548
+ const message = err instanceof Error ? err.message : String(err);
549
+ return { state: "skipped", detail: `probe error: ${message.slice(0, 100)}` };
550
+ }
551
+ finally {
552
+ // Best-effort cleanup of the ephemeral probe memory.
553
+ if (stored) {
554
+ try {
555
+ await authFetch(baseUrl, agentId, keyPath, "DELETE", `/Memory/${id}`);
556
+ }
557
+ catch { /* leave the ephemeral row; it'll age out */ }
558
+ }
559
+ }
560
+ }
450
561
  // Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
451
562
  // Used during restart to confirm the old Harper process actually exited before
452
563
  // we start polling /Health — otherwise the still-shutting-down old process can
@@ -1231,8 +1342,9 @@ program.name("flair").version(__pkgVersion, "-v, --version");
1231
1342
  // ─── flair init ──────────────────────────────────────────────────────────────
1232
1343
  program
1233
1344
  .command("init")
1234
- .description("Bootstrap a Flair (Harper) instance for an agent")
1345
+ .description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
1235
1346
  .option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
1347
+ .option("--agent <id>", "Alias for --agent-id")
1236
1348
  .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1237
1349
  .option("--ops-port <port>", "Harper operations API port")
1238
1350
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
@@ -1241,6 +1353,9 @@ program
1241
1353
  .option("--data-dir <dir>", "Harper data directory")
1242
1354
  .option("--skip-start", "Skip Harper startup (assume already running)")
1243
1355
  .option("--skip-soul", "Skip interactive personality setup")
1356
+ .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
1357
+ .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
1358
+ .option("--skip-smoke", "Skip the MCP smoke test")
1244
1359
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
1245
1360
  .option("--remote", "When used with --target, init as hub for remote federation")
1246
1361
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
@@ -1249,7 +1364,7 @@ program
1249
1364
  .option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
1250
1365
  .option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
1251
1366
  .action(async (opts) => {
1252
- const agentId = opts.agentId;
1367
+ const agentId = opts.agentId ?? opts.agent;
1253
1368
  const target = resolveTarget(opts);
1254
1369
  const opsTarget = resolveOpsTarget(opts);
1255
1370
  // ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
@@ -1426,11 +1541,26 @@ program
1426
1541
  }
1427
1542
  return;
1428
1543
  }
1429
- // ── Local init (original behavior) ──
1544
+ // ── Local init (full one-command setup) ──
1430
1545
  const httpPort = resolveHttpPort(opts);
1431
1546
  const opsPort = resolveOpsPort(opts);
1432
1547
  const keysDir = opts.keysDir ?? defaultKeysDir();
1433
1548
  const dataDir = opts.dataDir ?? defaultDataDir();
1549
+ // Resolve MCP client selection (union of init's auto-wire + the multi-client
1550
+ // detection/wiring that the front-door command provides). `--no-mcp` sets
1551
+ // opts.mcp === false (commander negates the flag). Validate an explicit
1552
+ // --client up front so a typo fails before Harper is touched.
1553
+ const clientOpt = opts.client;
1554
+ const noMcp = opts.mcp === false;
1555
+ const selectedClients = [];
1556
+ if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
1557
+ const valid = ["claude-code", "codex", "gemini", "cursor"];
1558
+ if (!valid.includes(clientOpt)) {
1559
+ console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
1560
+ process.exit(1);
1561
+ }
1562
+ selectedClients.push(clientOpt);
1563
+ }
1434
1564
  // Admin password: determine from opts, env, or generate
1435
1565
  // Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
1436
1566
  let adminPass;
@@ -1688,6 +1818,26 @@ program
1688
1818
  if (!verifyRes.ok)
1689
1819
  throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
1690
1820
  console.log("Ed25519 auth verified ✓");
1821
+ // Verify semantic search ACTUALLY works (real embed→paraphrase-search
1822
+ // round-trip). A clean-VM dogfood found semantic search dead out of the box
1823
+ // (sudo/root-owned install can't write the embeddings models symlink →
1824
+ // EACCES) while init reported success. Never report a clean init when
1825
+ // recall-by-meaning is broken. Skipped paths (no key yet) are non-fatal.
1826
+ console.log("Verifying semantic search...");
1827
+ const embedCheck = await verifySemanticSearch(httpUrl, agentId, keysDir);
1828
+ if (embedCheck.state === "ok") {
1829
+ console.log(`Semantic search operational ✓ ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${embedCheck.score.toFixed(2)})`)}`);
1830
+ }
1831
+ else if (embedCheck.state === "degraded") {
1832
+ // LOUD — embeddings not loaded. Same message class as `flair doctor`.
1833
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Semantic search DEGRADED")} — embeddings not loaded; recall-by-meaning will NOT work.`);
1834
+ console.log(` ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
1835
+ console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
1836
+ console.log(` ${render.wrap(render.c.dim, "Fix: install without sudo (see README Quick Start), then:")} flair restart && flair doctor`);
1837
+ }
1838
+ else {
1839
+ console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
1840
+ }
1691
1841
  // Output — admin password printed once, never written to disk
1692
1842
  console.log("\n✅ Flair initialized successfully");
1693
1843
  console.log(` Agent ID: ${agentId}`);
@@ -1747,38 +1897,164 @@ program
1747
1897
  }
1748
1898
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
1749
1899
  console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
1750
- // Auto-wire MCP config into ~/.claude.json if Claude Code is installed
1751
- const claudeJsonPath = join(homedir(), ".claude.json");
1900
+ // ── MCP client wiring ────────────────────────────────────────────────
1901
+ // The full one-command front door: detect installed MCP clients and wire
1902
+ // each to the zero-install `npx -y @tpsdev-ai/flair-mcp` server. Claude
1903
+ // Code is auto-wired into ~/.claude.json (the only client the CLI can
1904
+ // safely modify); other clients get copy-paste snippets. `--no-mcp`
1905
+ // skips wiring entirely; `--client <name>` targets one client; the
1906
+ // default (no flag) wires every detected client.
1752
1907
  const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
1753
- const flairMcpConfig = {
1754
- type: "stdio",
1755
- command: "flair-mcp",
1756
- args: [],
1757
- env: mcpEnv,
1758
- };
1759
- try {
1760
- if (existsSync(claudeJsonPath)) {
1761
- const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1762
- const existing = claudeJson.mcpServers?.flair;
1763
- if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
1764
- console.log(`\n MCP config already set in ~/.claude.json ✓`);
1908
+ const wiringResults = [];
1909
+ if (!noMcp && clientOpt !== "none") {
1910
+ // Determine which clients to wire.
1911
+ let clients = detectClients();
1912
+ if (selectedClients.length > 0) {
1913
+ clients = clients.filter(c => selectedClients.includes(c.id));
1914
+ }
1915
+ const detected = clients.filter(c => c.detected);
1916
+ if (!clientOpt) {
1917
+ if (detected.length === 0) {
1918
+ console.log("\n No MCP clients detected. Run with --client <name> to wire a specific client.");
1765
1919
  }
1766
1920
  else {
1767
- claudeJson.mcpServers = claudeJson.mcpServers || {};
1768
- claudeJson.mcpServers.flair = flairMcpConfig;
1769
- writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
1770
- console.log(`\n MCP config written to ~/.claude.json ✓`);
1771
- console.log(` Restart Claude Code to pick up the new config.`);
1921
+ console.log(`\n Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
1772
1922
  }
1773
1923
  }
1774
- else {
1775
- console.log(`\n MCP config (add to ~/.claude.json):`);
1776
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1924
+ const toWire = clientOpt === "all"
1925
+ ? clients.filter(c => c.detected).map(c => c.id)
1926
+ : selectedClients.length > 0
1927
+ ? selectedClients
1928
+ : clients.filter(c => c.detected).map(c => c.id);
1929
+ for (const clientId of toWire) {
1930
+ if (clientId === "claude-code") {
1931
+ // Claude Code gets real auto-wiring into ~/.claude.json (zero-install
1932
+ // npx form; matches the snippets everywhere else). Other clients only
1933
+ // get printed instructions — the CLI can't safely edit their configs.
1934
+ const claudeJsonPath = join(homedir(), ".claude.json");
1935
+ const flairMcpConfig = {
1936
+ type: "stdio",
1937
+ command: "npx",
1938
+ args: ["-y", "@tpsdev-ai/flair-mcp"],
1939
+ env: mcpEnv,
1940
+ };
1941
+ try {
1942
+ if (existsSync(claudeJsonPath)) {
1943
+ const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1944
+ const existing = claudeJson.mcpServers?.flair;
1945
+ if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
1946
+ console.log(` ✓ Claude Code already wired in ~/.claude.json`);
1947
+ wiringResults.push({ client: "claude-code", message: "already wired" });
1948
+ }
1949
+ else {
1950
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
1951
+ claudeJson.mcpServers.flair = flairMcpConfig;
1952
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
1953
+ console.log(` ✓ Claude Code wired in ~/.claude.json (restart Claude Code to pick it up)`);
1954
+ wiringResults.push({ client: "claude-code", message: "wired ~/.claude.json" });
1955
+ }
1956
+ }
1957
+ else {
1958
+ console.log(` MCP config (add to ~/.claude.json):`);
1959
+ console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1960
+ wiringResults.push({ client: "claude-code", message: "snippet printed (no ~/.claude.json)" });
1961
+ }
1962
+ }
1963
+ catch {
1964
+ console.log(` MCP config (add manually to ~/.claude.json):`);
1965
+ console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1966
+ wiringResults.push({ client: "claude-code", message: "snippet printed" });
1967
+ }
1968
+ }
1969
+ else {
1970
+ let result;
1971
+ switch (clientId) {
1972
+ case "codex":
1973
+ result = wireCodex(mcpEnv);
1974
+ break;
1975
+ case "gemini":
1976
+ result = wireGemini(mcpEnv);
1977
+ break;
1978
+ case "cursor":
1979
+ result = wireCursor(mcpEnv);
1980
+ break;
1981
+ default: result = { ok: false, message: `Unknown client: ${clientId}` };
1982
+ }
1983
+ wiringResults.push({ client: clientId, message: result.message });
1984
+ console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
1985
+ }
1777
1986
  }
1778
1987
  }
1779
- catch {
1780
- console.log(`\n MCP config (add manually to ~/.claude.json):`);
1781
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1988
+ // ── Smoke-test the MCP server ────────────────────────────────────────
1989
+ // Launch flair-mcp and confirm it answers a JSON-RPC initialize over
1990
+ // stdio. Best-effort: failures warn but never fail the command. Skipped
1991
+ // with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
1992
+ if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
1993
+ console.log("\n Smoke-testing MCP server...");
1994
+ try {
1995
+ const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
1996
+ env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
1997
+ stdio: ["pipe", "pipe", "pipe"],
1998
+ });
1999
+ const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-init", version: "1.0.0" } } });
2000
+ mcpProc.stdin.write(initMsg + "\n");
2001
+ mcpProc.stdin.end();
2002
+ let stdout = "";
2003
+ mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
2004
+ // A single timer drives the timeout AND cleanup. It MUST be cleared on
2005
+ // settle — an un-cleared setTimeout is a live handle that keeps Node's
2006
+ // event loop alive (the ~60s phantom hang after `flair init` printed
2007
+ // success: the smoke timer + the lingering npx child both pinned the
2008
+ // loop). We clear it on every exit path below.
2009
+ let smokeTimer = null;
2010
+ await new Promise((resolve, reject) => {
2011
+ const settle = (fn) => {
2012
+ if (smokeTimer) {
2013
+ clearTimeout(smokeTimer);
2014
+ smokeTimer = null;
2015
+ }
2016
+ fn();
2017
+ };
2018
+ mcpProc.on("exit", (code) => {
2019
+ settle(() => {
2020
+ if (code === 0 && stdout.length > 0)
2021
+ resolve();
2022
+ else
2023
+ reject(new Error(`MCP server exited with code ${code}`));
2024
+ });
2025
+ });
2026
+ mcpProc.on("error", (err) => settle(() => reject(err)));
2027
+ smokeTimer = setTimeout(() => settle(() => { mcpProc.kill("SIGKILL"); reject(new Error("MCP smoke test timed out")); }), 15_000);
2028
+ });
2029
+ try {
2030
+ const lines = stdout.split("\n").filter(l => l.trim());
2031
+ for (const line of lines) {
2032
+ const parsed = JSON.parse(line);
2033
+ if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
2034
+ console.log(" ✓ MCP server responded");
2035
+ break;
2036
+ }
2037
+ }
2038
+ }
2039
+ catch {
2040
+ console.log(" ⚠ MCP server responded but response could not be parsed");
2041
+ }
2042
+ finally {
2043
+ // Reap the child even on the resolve path: the MCP server exits on
2044
+ // stdin close, but the `npx` wrapper can linger holding the loop.
2045
+ // SIGKILL is safe — we already have the response we need.
2046
+ try {
2047
+ if (mcpProc.exitCode === null)
2048
+ mcpProc.kill("SIGKILL");
2049
+ }
2050
+ catch { /* already gone */ }
2051
+ }
2052
+ }
2053
+ catch (err) {
2054
+ const message = err instanceof Error ? err.message : String(err);
2055
+ console.log(` ⚠ MCP smoke test failed: ${message}`);
2056
+ console.log(" Use --skip-smoke to bypass.");
2057
+ }
1782
2058
  }
1783
2059
  }
1784
2060
  else {
@@ -1802,354 +2078,16 @@ program
1802
2078
  }
1803
2079
  console.log(`\n Export: FLAIR_URL=${httpUrl}`);
1804
2080
  }
1805
- });
1806
- // ─── flair install ───────────────────────────────────────────────────────────
1807
- program
1808
- .command("install")
1809
- .description("One-command Flair setup init, agent, and MCP client wiring")
1810
- .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
1811
- .option("--agent <id>", "Agent ID (defaults to hostname short-form)")
1812
- .option("--no-mcp", "Skip MCP wiring (init + agent only)")
1813
- .option("--port <port>", "Harper HTTP port")
1814
- .option("--ops-port <port>", "Harper operations API port")
1815
- .option("--data-dir <dir>", "Harper data directory")
1816
- .option("--keys-dir <dir>", "Directory for Ed25519 keys")
1817
- .option("--skip-smoke", "Skip MCP smoke test")
1818
- .action(async (opts) => {
1819
- const httpPort = resolveHttpPort(opts);
1820
- const opsPort = resolveOpsPort(opts);
1821
- const keysDir = opts.keysDir ?? defaultKeysDir();
1822
- const dataDir = opts.dataDir ?? defaultDataDir();
1823
- // Resolve client selection
1824
- const clientOpt = opts.client;
1825
- const noMcp = opts.noMcp === true;
1826
- const selectedClients = [];
1827
- if (clientOpt === "none" || noMcp) {
1828
- // Skip MCP entirely — just init + agent
1829
- }
1830
- else if (clientOpt === "all") {
1831
- // Wire all detected clients
1832
- }
1833
- else if (clientOpt) {
1834
- // Wire a specific client
1835
- const valid = ["claude-code", "codex", "gemini", "cursor"];
1836
- if (!valid.includes(clientOpt)) {
1837
- console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
1838
- process.exit(1);
1839
- }
1840
- selectedClients.push(clientOpt);
1841
- }
1842
- // If no --client flag: interactive detection later
1843
- // ── Step 1: Ensure Flair is initialized ──
1844
- let alreadyInitialized = false;
1845
- let alreadyRunning = false;
1846
- let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
1847
- const adminUser = DEFAULT_ADMIN_USER;
1848
- // Check if Flair is already initialized (config with port exists)
1849
- try {
1850
- const cp = configPath();
1851
- if (existsSync(cp)) {
1852
- const yaml = readFileSync(cp, "utf-8");
1853
- if (yaml.match(/port:\s*\d+/)) {
1854
- alreadyInitialized = true;
1855
- console.log("Flair already initialized — skipping init.");
1856
- }
1857
- }
1858
- }
1859
- catch { /* not initialized */ }
1860
- if (!alreadyInitialized) {
1861
- const major = parseInt(process.version.slice(1), 10);
1862
- if (major < 18)
1863
- throw new Error(`Node.js >= 18 required (found ${process.version})`);
1864
- // Only generate a password if none provided via env (fresh install)
1865
- // If we generate, write it atomically to ~/.flair/admin-pass
1866
- let adminPassGenerated = false;
1867
- if (!adminPass) {
1868
- adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
1869
- adminPassGenerated = true;
1870
- // Atomic write: create temp file in same dir, then rename
1871
- const flairDir = join(homedir(), ".flair");
1872
- mkdirSync(flairDir, { recursive: true });
1873
- const adminPassPath = join(flairDir, "admin-pass");
1874
- const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
1875
- const finalTempPath = join(tempPath, "admin-pass");
1876
- try {
1877
- writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
1878
- renameSync(finalTempPath, adminPassPath);
1879
- rmSync(tempPath, { recursive: true, force: true });
1880
- }
1881
- catch (err) {
1882
- // Clean up temp dir on failure
1883
- try {
1884
- rmSync(tempPath, { recursive: true, force: true });
1885
- }
1886
- catch { }
1887
- throw err;
1888
- }
1889
- console.log(`Admin password saved to: ${adminPassPath}`);
1890
- }
1891
- // Check if Harper is already running on this port
1892
- try {
1893
- const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
1894
- if (res.status > 0) {
1895
- alreadyRunning = true;
1896
- console.log(`Harper already running on port ${httpPort} — skipping start`);
1897
- }
1898
- }
1899
- catch { /* not running */ }
1900
- if (!alreadyRunning) {
1901
- const bin = harperBin();
1902
- if (!bin)
1903
- throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
1904
- mkdirSync(dataDir, { recursive: true });
1905
- const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
1906
- const harperSetConfig = JSON.stringify({
1907
- rootPath: dataDir,
1908
- http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
1909
- operationsApi: { network: { port: opsPort, cors: true }, domainSocket: join(dataDir, "operations-server") },
1910
- mqtt: { network: { port: null }, webSocket: false },
1911
- localStudio: { enabled: false },
1912
- authentication: { authorizeLocal: true, enableSessions: true },
1913
- });
1914
- const env = {
1915
- ...process.env,
1916
- ROOTPATH: dataDir,
1917
- HARPER_SET_CONFIG: harperSetConfig,
1918
- DEFAULTS_MODE: "dev",
1919
- HDB_ADMIN_USERNAME: adminUser,
1920
- HDB_ADMIN_PASSWORD: adminPass,
1921
- THREADS_COUNT: "1",
1922
- NODE_HOSTNAME: "localhost",
1923
- HTTP_PORT: String(httpPort),
1924
- OPERATIONSAPI_NETWORK_PORT: String(opsPort),
1925
- LOCAL_STUDIO: "false",
1926
- };
1927
- if (alreadyInstalled) {
1928
- console.log("Existing Harper installation found — skipping install.");
1929
- }
1930
- else {
1931
- const installEnv = { ...env, HOME: join(dataDir, "..") };
1932
- console.log("Installing Harper...");
1933
- console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
1934
- await new Promise((resolve, reject) => {
1935
- let output = "";
1936
- let dotTimer = null;
1937
- const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
1938
- dotTimer = setInterval(() => process.stdout.write("."), 3000);
1939
- install.stdout?.on("data", (d) => { output += d.toString(); });
1940
- install.stderr?.on("data", (d) => { output += d.toString(); });
1941
- install.on("exit", (code) => {
1942
- if (dotTimer) {
1943
- clearInterval(dotTimer);
1944
- process.stdout.write("\n");
1945
- }
1946
- code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
1947
- });
1948
- install.on("error", (err) => {
1949
- if (dotTimer) {
1950
- clearInterval(dotTimer);
1951
- process.stdout.write("\n");
1952
- }
1953
- reject(err);
1954
- });
1955
- setTimeout(() => {
1956
- install.kill();
1957
- if (dotTimer) {
1958
- clearInterval(dotTimer);
1959
- process.stdout.write("\n");
1960
- }
1961
- reject(new Error(`Harper install timed out: ${output}`));
1962
- }, 60_000);
1963
- });
1964
- }
1965
- // Start Harper
1966
- console.log(`Starting Harper on port ${httpPort}...`);
1967
- const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
1968
- proc.unref();
1969
- }
1970
- // Wait for health
1971
- console.log("Waiting for Harper health check...");
1972
- await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
1973
- console.log("Harper is healthy ✓");
1974
- // Write config so other commands can find this instance
1975
- writeConfig(httpPort);
1976
- }
1977
- else {
1978
- // Flair already initialized — resolve admin pass from env, falling back to
1979
- // the persisted ~/.flair/admin-pass written at first install. Agent
1980
- // seeding now goes through the ops API (#499), which always requires Basic
1981
- // admin auth (it does NOT honor authorizeLocal), so a re-run of
1982
- // `flair install --agent <new-id>` against an already-initialized local
1983
- // Flair needs a real pass even when no env var is set.
1984
- adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
1985
- if (!adminPass) {
1986
- try {
1987
- const persisted = join(homedir(), ".flair", "admin-pass");
1988
- if (existsSync(persisted))
1989
- adminPass = readFileSync(persisted, "utf-8").trim();
1990
- }
1991
- catch { /* fall through with empty pass */ }
1992
- }
1993
- }
1994
- const httpUrl = `http://127.0.0.1:${httpPort}`;
1995
- // ── Step 2: Detect MCP clients ──
1996
- let clients = detectClients();
1997
- if (selectedClients.length > 0) {
1998
- // Explicit --client flag — override detection
1999
- if (clientOpt === "all" || clientOpt === "none" || noMcp) {
2000
- // all/none handled below
2001
- }
2002
- else {
2003
- // Filter to only the selected client (preserving wire function from detectClients)
2004
- clients = clients.filter(c => selectedClients.includes(c.id));
2005
- }
2006
- }
2007
- if (!clientOpt && !noMcp) {
2008
- // No --client flag — print detected clients
2009
- const detected = clients.filter(c => c.detected);
2010
- if (detected.length === 0) {
2011
- console.log("No MCP clients detected. Run with --client <name> to wire a specific client.");
2012
- }
2013
- else {
2014
- console.log(`Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
2015
- }
2016
- }
2017
- // ── Step 3: Resolve agent identity ──
2018
- const agentId = opts.agent ?? (() => {
2019
- try {
2020
- const hn = hostname();
2021
- return hn.split(".")[0];
2022
- }
2023
- catch {
2024
- return "flair-agent";
2025
- }
2026
- })();
2027
- // Validate agentId to prevent path traversal
2028
- const VALID_AGENT_ID = /^[a-zA-Z0-9_-]+$/;
2029
- if (!VALID_AGENT_ID.test(agentId)) {
2030
- throw new Error(`Invalid agent ID: ${agentId}. Agent ID must contain only letters, numbers, underscores, and hyphens.`);
2031
- }
2032
- let agentExists = false;
2033
- // Check if agent already exists locally
2034
- const privPath = privKeyPath(agentId, keysDir);
2035
- if (existsSync(privPath)) {
2036
- agentExists = true;
2037
- console.log(`Agent '${agentId}' already exists ✓`);
2038
- }
2039
- else {
2040
- // Create agent
2041
- mkdirSync(keysDir, { recursive: true });
2042
- const pubPath = pubKeyPath(agentId, keysDir);
2043
- console.log("Generating Ed25519 keypair...");
2044
- const kp = nacl.sign.keyPair();
2045
- const seed = kp.secretKey.slice(0, 32);
2046
- writeFileSync(privPath, Buffer.from(seed));
2047
- chmodSync(privPath, 0o600);
2048
- writeFileSync(pubPath, Buffer.from(kp.publicKey));
2049
- const pubKeyB64url = b64url(kp.publicKey);
2050
- console.log(`Keypair written: ${privPath} ✓`);
2051
- // Seed agent via the Harper operations API. The Agent table is a REST
2052
- // resource without a POST handler, so the insert body must go to the ops
2053
- // API (POST <opsUrl>/ with {operation:"insert",...}), NOT the REST root
2054
- // (which 405s the body as a collection POST to /Agent). This is the same
2055
- // path `flair agent add` uses. (#499)
2056
- const seedOpsTarget = opts.opsTarget ?? opsPort;
2057
- console.log(opts.opsTarget
2058
- ? `Seeding agent '${agentId}' via operations API (--ops-target)...`
2059
- : `Seeding agent '${agentId}' via operations API...`);
2060
- await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, adminUser, adminPass);
2061
- console.log(`Agent '${agentId}' registered ✓`);
2062
- }
2063
- // ── Step 4: Wire MCP clients ──
2064
- const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
2065
- const wiringResults = [];
2066
- if (!noMcp && clientOpt !== "none") {
2067
- const toWire = clientOpt === "all"
2068
- ? clients.filter(c => c.detected).map(c => c.id)
2069
- : selectedClients.length > 0
2070
- ? selectedClients
2071
- : clients.filter(c => c.detected).map(c => c.id);
2072
- for (const clientId of toWire) {
2073
- let result;
2074
- switch (clientId) {
2075
- case "claude-code":
2076
- result = wireClaudeCode(mcpEnv);
2077
- break;
2078
- case "codex":
2079
- result = wireCodex(mcpEnv);
2080
- break;
2081
- case "gemini":
2082
- result = wireGemini(mcpEnv);
2083
- break;
2084
- case "cursor":
2085
- result = wireCursor(mcpEnv);
2086
- break;
2087
- default: result = { ok: false, message: `Unknown client: ${clientId}` };
2088
- }
2089
- wiringResults.push({ client: clientId, message: result.message });
2090
- console.log(` ${result.ok ? "✓" : "✗"} ${result.message}`);
2091
- }
2092
- }
2093
- // ── Step 5: Smoke test the MCP server ──
2094
- if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
2095
- console.log("Smoke-testing MCP server...");
2096
- try {
2097
- // Launch flair-mcp and send initialize request over stdio
2098
- const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
2099
- env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
2100
- stdio: ["pipe", "pipe", "pipe"],
2101
- timeout: 15_000,
2102
- });
2103
- // Send initialize request
2104
- const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-install", version: "1.0.0" } } });
2105
- mcpProc.stdin.write(initMsg + "\n");
2106
- mcpProc.stdin.end();
2107
- let stdout = "";
2108
- mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
2109
- await new Promise((resolve, reject) => {
2110
- mcpProc.on("exit", (code) => {
2111
- if (code === 0 && stdout.length > 0) {
2112
- resolve();
2113
- }
2114
- else {
2115
- reject(new Error(`MCP server exited with code ${code}`));
2116
- }
2117
- });
2118
- mcpProc.on("error", reject);
2119
- setTimeout(() => { mcpProc.kill(); reject(new Error("MCP smoke test timed out")); }, 15_000);
2120
- });
2121
- // Check for a valid JSON-RPC response
2122
- try {
2123
- const lines = stdout.split("\n").filter(l => l.trim());
2124
- for (const line of lines) {
2125
- const parsed = JSON.parse(line);
2126
- if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
2127
- console.log(" ✓ MCP server responded");
2128
- break;
2129
- }
2130
- }
2131
- }
2132
- catch {
2133
- console.log(" ⚠ MCP server responded but response could not be parsed");
2134
- }
2135
- }
2136
- catch (err) {
2137
- const message = err instanceof Error ? err.message : String(err);
2138
- console.log(` ⚠ MCP smoke test failed: ${message}`);
2139
- console.log(" Use --skip-smoke to bypass.");
2140
- }
2141
- }
2142
- // ── Step 6: Summary ──
2143
- console.log("");
2144
- console.log("✓ Flair installed.");
2145
- console.log(` Agent: ${agentId}`);
2146
- if (existsSync(privKeyPath(agentId, keysDir))) {
2147
- console.log(` Private key: ${privKeyPath(agentId, keysDir)}`);
2148
- }
2149
- console.log(` Local: ${httpUrl}`);
2150
- console.log(` MCP: ${wiringResults.length > 0 ? wiringResults.map(r => r.client).join(", ") + " ✓ wired" : "none wired"}`);
2151
- console.log("");
2152
- console.log(` Try it: in Claude Code, ask the agent "what do you remember about me?"`);
2081
+ // All init work is genuinely done at this point: Harper is installed +
2082
+ // running (detached, unref'd — survives this process exiting), the agent is
2083
+ // registered, semantic search is verified, MCP clients are wired, and the
2084
+ // smoke test ran. The MCP smoke subprocess can leave a lingering npx handle
2085
+ // that pins Node's event loop for ~60s after success ("rc=0 but doesn't
2086
+ // return"). We've cleared/unref'd the known timers above; exit explicitly so
2087
+ // the prompt returns in a couple seconds regardless of any stray handle. The
2088
+ // running Harper instance is unaffected.
2089
+ await new Promise((r) => process.stdout.write("", () => r()));
2090
+ process.exit(0);
2153
2091
  });
2154
2092
  // ─── flair agent ─────────────────────────────────────────────────────────────
2155
2093
  const agent = program.command("agent").description("Manage Flair agents");
@@ -2209,6 +2147,8 @@ agent
2209
2147
  .command("list")
2210
2148
  .description("List all agents")
2211
2149
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
2150
+ .option("--agent <id>", "Agent ID to authenticate as via Ed25519 (or FLAIR_AGENT_ID env) when no admin pass")
2151
+ .option("--keys-dir <dir>", "Directory holding the agent's Ed25519 key")
2212
2152
  .option("--port <port>", "Harper HTTP port")
2213
2153
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
2214
2154
  .action(async (opts) => {
@@ -2244,13 +2184,39 @@ agent
2244
2184
  agents = await res.json();
2245
2185
  }
2246
2186
  else {
2187
+ // No admin pass → authenticate as the AGENT via Ed25519. The Agent table's
2188
+ // allowRead() is allowVerified — a bare unauthenticated GET /Agent returns
2189
+ // 403 AccessViolation (the dogfood symptom: the natural "did my agent
2190
+ // register?" check errored on a healthy install). A verified agent reads
2191
+ // the principal table for discovery, so sign the request with its key.
2247
2192
  const baseUrl = `http://127.0.0.1:${port}`;
2248
- const res = await fetch(`${baseUrl}/Agent`, {
2249
- headers: { "Content-Type": "application/json" },
2250
- });
2193
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
2194
+ const keysDir = opts.keysDir ?? process.env.FLAIR_KEY_DIR ?? defaultKeysDir();
2195
+ let res;
2196
+ if (agentId) {
2197
+ const keyPath = resolveKeyPath(agentId) ?? join(keysDir, `${agentId}.key`);
2198
+ if (!existsSync(keyPath)) {
2199
+ console.error(`${render.icons.error} no key for agent '${agentId}' (looked in ${keysDir}). Pass --admin-pass, --keys-dir, or a registered --agent.`);
2200
+ process.exit(1);
2201
+ }
2202
+ res = await authFetch(baseUrl, agentId, keyPath, "GET", "/Agent");
2203
+ }
2204
+ else {
2205
+ // No agent identity available either. Try anonymously, but if it 403s
2206
+ // (the common case), tell the user exactly how to authenticate rather
2207
+ // than dumping a raw AccessViolation.
2208
+ res = await fetch(`${baseUrl}/Agent`, { headers: { "Content-Type": "application/json" } });
2209
+ }
2251
2210
  if (!res.ok) {
2252
2211
  const text = await res.text().catch(() => "");
2253
- console.error(`${render.icons.error} ${res.status} ${text}`);
2212
+ if (res.status === 403 && !agentId) {
2213
+ console.error(`${render.icons.error} 403 — listing agents requires authentication.`);
2214
+ console.error(` Use ${render.wrap(render.c.cyan, "--agent <id>")} (or set FLAIR_AGENT_ID) to authenticate as a registered agent,`);
2215
+ console.error(` or ${render.wrap(render.c.cyan, "--admin-pass")} / FLAIR_ADMIN_PASS for the admin view.`);
2216
+ }
2217
+ else {
2218
+ console.error(`${render.icons.error} ${res.status} ${text}`);
2219
+ }
2254
2220
  process.exit(1);
2255
2221
  }
2256
2222
  const data = await res.json();
@@ -6718,6 +6684,7 @@ program
6718
6684
  .command("doctor")
6719
6685
  .description("Diagnose common Flair problems and suggest fixes")
6720
6686
  .option("--port <port>", "Harper HTTP port")
6687
+ .option("--agent <id>", "Agent ID to use for the semantic-search round-trip (or FLAIR_AGENT_ID env)")
6721
6688
  .option("--fix", "Automatically fix issues where possible")
6722
6689
  .option("--dry-run", "Show what --fix would do without making changes")
6723
6690
  .action(async (opts) => {
@@ -6880,32 +6847,38 @@ program
6880
6847
  else {
6881
6848
  console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
6882
6849
  }
6883
- // 4. Embeddings check (only if Harper is responding)
6850
+ // 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
6851
+ //
6852
+ // The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
6853
+ // not loaded: SemanticSearch falls back to keyword-only scan, and an
6854
+ // unauthenticated probe 401s → "cannot verify" → no issue counted. A clean-VM
6855
+ // dogfood found semantic search DEAD out of the box (sudo/root-owned install
6856
+ // can't write the models symlink → EACCES) while `flair doctor` reported
6857
+ // "no issues found". This now stores a memory with a distinctive phrase and
6858
+ // searches for a PARAPHRASE (no shared keywords). If the top result isn't
6859
+ // recovered by MEANING, recall-by-meaning is broken and doctor FAILS LOUDLY.
6884
6860
  if (harperResponding) {
6885
- try {
6886
- const testRes = await fetch(`${baseUrl}/SemanticSearch`, {
6887
- method: "POST",
6888
- headers: { "Content-Type": "application/json" },
6889
- body: JSON.stringify({ q: "test", limit: 1 }),
6890
- signal: AbortSignal.timeout(10000),
6891
- });
6892
- if (testRes.ok) {
6893
- const data = await testRes.json();
6894
- if (data._warning) {
6895
- console.log(` ${render.icons.warn} Embeddings: keyword-only ${render.wrap(render.c.dim, `(${data._warning})`)}`);
6896
- console.log(` ${render.wrap(render.c.dim, "Semantic search quality is degraded")}`);
6897
- console.log(` ${render.wrap(render.c.dim, "Check:")} ls ~/.npm-global/lib/node_modules/@tpsdev-ai/flair/models/`);
6898
- issues++;
6899
- }
6900
- else {
6901
- console.log(` ${render.icons.ok} Embeddings: semantic search operational`);
6902
- }
6903
- }
6904
- else if (testRes.status === 401) {
6905
- console.log(` ${render.icons.warn} Embeddings: cannot verify ${render.wrap(render.c.dim, "(auth required for SemanticSearch)")}`);
6906
- }
6861
+ const semanticStatus = await verifySemanticSearch(baseUrl, opts.agent, defaultKeysDir());
6862
+ switch (semanticStatus.state) {
6863
+ case "ok":
6864
+ console.log(` ${render.icons.ok} Embeddings: semantic search operational ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${semanticStatus.score.toFixed(2)})`)}`);
6865
+ break;
6866
+ case "degraded":
6867
+ // LOUD failure — never report all-clear when recall-by-meaning is dead.
6868
+ console.log(` ${render.icons.error} Semantic search DEGRADED ${render.wrap(render.c.dim, `— ${semanticStatus.detail}`)}`);
6869
+ console.log(` ${render.wrap(render.c.red, "Embeddings are not loaded; recall-by-meaning will NOT work.")}`);
6870
+ console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
6871
+ console.log(` ${render.wrap(render.c.dim, "See:")} docs/troubleshooting.md ${render.wrap(render.c.dim, "→ \"Semantic search DEGRADED\"")}`);
6872
+ issues++;
6873
+ break;
6874
+ case "skipped":
6875
+ // Could not run the round-trip (no agent / no key). Don't claim
6876
+ // all-clear — surface that the check was skipped, but don't count it
6877
+ // as a hard issue since the user may simply not have an agent yet.
6878
+ console.log(` ${render.icons.warn} Embeddings: not verified ${render.wrap(render.c.dim, `(${semanticStatus.detail})`)}`);
6879
+ console.log(` ${render.wrap(render.c.dim, "Pass --agent <id> (or set FLAIR_AGENT_ID) so doctor can run a real semantic round-trip.")}`);
6880
+ break;
6907
6881
  }
6908
- catch { /* fetch error, already flagged */ }
6909
6882
  }
6910
6883
  // 5. Stale PID file (skip if already reported in port check)
6911
6884
  const dataDir = defaultDataDir();