@tpsdev-ai/flair 0.15.0 → 0.16.1

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
@@ -1047,6 +1158,17 @@ export function probeOpenclawPluginVersion(extensionName) {
1047
1158
  return null;
1048
1159
  }
1049
1160
  }
1161
+ /**
1162
+ * Whether a package's status line should be printed in the default `flair
1163
+ * upgrade` listing. Suppresses optional-because-openclaw-is-absent lines
1164
+ * (ops-p42n) — pure noise on machines without openclaw — unless `--all`
1165
+ * (showAll) is set. All other statuses always print.
1166
+ */
1167
+ export function shouldPrintUpgradeLine(status, showAll) {
1168
+ if (status === "optional" && !showAll)
1169
+ return false;
1170
+ return true;
1171
+ }
1050
1172
  /**
1051
1173
  * Order a soul key→count map for display: highest count first, ties broken
1052
1174
  * alphabetically for stable output. Soul entries are keyed identity facts
@@ -1231,8 +1353,9 @@ program.name("flair").version(__pkgVersion, "-v, --version");
1231
1353
  // ─── flair init ──────────────────────────────────────────────────────────────
1232
1354
  program
1233
1355
  .command("init")
1234
- .description("Bootstrap a Flair (Harper) instance for an agent")
1356
+ .description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
1235
1357
  .option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
1358
+ .option("--agent <id>", "Alias for --agent-id")
1236
1359
  .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1237
1360
  .option("--ops-port <port>", "Harper operations API port")
1238
1361
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
@@ -1241,6 +1364,9 @@ program
1241
1364
  .option("--data-dir <dir>", "Harper data directory")
1242
1365
  .option("--skip-start", "Skip Harper startup (assume already running)")
1243
1366
  .option("--skip-soul", "Skip interactive personality setup")
1367
+ .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
1368
+ .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
1369
+ .option("--skip-smoke", "Skip the MCP smoke test")
1244
1370
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
1245
1371
  .option("--remote", "When used with --target, init as hub for remote federation")
1246
1372
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
@@ -1249,7 +1375,7 @@ program
1249
1375
  .option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
1250
1376
  .option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
1251
1377
  .action(async (opts) => {
1252
- const agentId = opts.agentId;
1378
+ const agentId = opts.agentId ?? opts.agent;
1253
1379
  const target = resolveTarget(opts);
1254
1380
  const opsTarget = resolveOpsTarget(opts);
1255
1381
  // ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
@@ -1426,11 +1552,26 @@ program
1426
1552
  }
1427
1553
  return;
1428
1554
  }
1429
- // ── Local init (original behavior) ──
1555
+ // ── Local init (full one-command setup) ──
1430
1556
  const httpPort = resolveHttpPort(opts);
1431
1557
  const opsPort = resolveOpsPort(opts);
1432
1558
  const keysDir = opts.keysDir ?? defaultKeysDir();
1433
1559
  const dataDir = opts.dataDir ?? defaultDataDir();
1560
+ // Resolve MCP client selection (union of init's auto-wire + the multi-client
1561
+ // detection/wiring that the front-door command provides). `--no-mcp` sets
1562
+ // opts.mcp === false (commander negates the flag). Validate an explicit
1563
+ // --client up front so a typo fails before Harper is touched.
1564
+ const clientOpt = opts.client;
1565
+ const noMcp = opts.mcp === false;
1566
+ const selectedClients = [];
1567
+ if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
1568
+ const valid = ["claude-code", "codex", "gemini", "cursor"];
1569
+ if (!valid.includes(clientOpt)) {
1570
+ console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
1571
+ process.exit(1);
1572
+ }
1573
+ selectedClients.push(clientOpt);
1574
+ }
1434
1575
  // Admin password: determine from opts, env, or generate
1435
1576
  // Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
1436
1577
  let adminPass;
@@ -1688,6 +1829,26 @@ program
1688
1829
  if (!verifyRes.ok)
1689
1830
  throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
1690
1831
  console.log("Ed25519 auth verified ✓");
1832
+ // Verify semantic search ACTUALLY works (real embed→paraphrase-search
1833
+ // round-trip). A clean-VM dogfood found semantic search dead out of the box
1834
+ // (sudo/root-owned install can't write the embeddings models symlink →
1835
+ // EACCES) while init reported success. Never report a clean init when
1836
+ // recall-by-meaning is broken. Skipped paths (no key yet) are non-fatal.
1837
+ console.log("Verifying semantic search...");
1838
+ const embedCheck = await verifySemanticSearch(httpUrl, agentId, keysDir);
1839
+ if (embedCheck.state === "ok") {
1840
+ console.log(`Semantic search operational ✓ ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${embedCheck.score.toFixed(2)})`)}`);
1841
+ }
1842
+ else if (embedCheck.state === "degraded") {
1843
+ // LOUD — embeddings not loaded. Same message class as `flair doctor`.
1844
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Semantic search DEGRADED")} — embeddings not loaded; recall-by-meaning will NOT work.`);
1845
+ console.log(` ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
1846
+ console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
1847
+ console.log(` ${render.wrap(render.c.dim, "Fix: install without sudo (see README Quick Start), then:")} flair restart && flair doctor`);
1848
+ }
1849
+ else {
1850
+ console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
1851
+ }
1691
1852
  // Output — admin password printed once, never written to disk
1692
1853
  console.log("\n✅ Flair initialized successfully");
1693
1854
  console.log(` Agent ID: ${agentId}`);
@@ -1747,38 +1908,164 @@ program
1747
1908
  }
1748
1909
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
1749
1910
  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");
1911
+ // ── MCP client wiring ────────────────────────────────────────────────
1912
+ // The full one-command front door: detect installed MCP clients and wire
1913
+ // each to the zero-install `npx -y @tpsdev-ai/flair-mcp` server. Claude
1914
+ // Code is auto-wired into ~/.claude.json (the only client the CLI can
1915
+ // safely modify); other clients get copy-paste snippets. `--no-mcp`
1916
+ // skips wiring entirely; `--client <name>` targets one client; the
1917
+ // default (no flag) wires every detected client.
1752
1918
  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 ✓`);
1919
+ const wiringResults = [];
1920
+ if (!noMcp && clientOpt !== "none") {
1921
+ // Determine which clients to wire.
1922
+ let clients = detectClients();
1923
+ if (selectedClients.length > 0) {
1924
+ clients = clients.filter(c => selectedClients.includes(c.id));
1925
+ }
1926
+ const detected = clients.filter(c => c.detected);
1927
+ if (!clientOpt) {
1928
+ if (detected.length === 0) {
1929
+ console.log("\n No MCP clients detected. Run with --client <name> to wire a specific client.");
1765
1930
  }
1766
1931
  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.`);
1932
+ console.log(`\n Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
1772
1933
  }
1773
1934
  }
1774
- else {
1775
- console.log(`\n MCP config (add to ~/.claude.json):`);
1776
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1935
+ const toWire = clientOpt === "all"
1936
+ ? clients.filter(c => c.detected).map(c => c.id)
1937
+ : selectedClients.length > 0
1938
+ ? selectedClients
1939
+ : clients.filter(c => c.detected).map(c => c.id);
1940
+ for (const clientId of toWire) {
1941
+ if (clientId === "claude-code") {
1942
+ // Claude Code gets real auto-wiring into ~/.claude.json (zero-install
1943
+ // npx form; matches the snippets everywhere else). Other clients only
1944
+ // get printed instructions — the CLI can't safely edit their configs.
1945
+ const claudeJsonPath = join(homedir(), ".claude.json");
1946
+ const flairMcpConfig = {
1947
+ type: "stdio",
1948
+ command: "npx",
1949
+ args: ["-y", "@tpsdev-ai/flair-mcp"],
1950
+ env: mcpEnv,
1951
+ };
1952
+ try {
1953
+ if (existsSync(claudeJsonPath)) {
1954
+ const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1955
+ const existing = claudeJson.mcpServers?.flair;
1956
+ if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
1957
+ console.log(` ✓ Claude Code already wired in ~/.claude.json`);
1958
+ wiringResults.push({ client: "claude-code", message: "already wired" });
1959
+ }
1960
+ else {
1961
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
1962
+ claudeJson.mcpServers.flair = flairMcpConfig;
1963
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
1964
+ console.log(` ✓ Claude Code wired in ~/.claude.json (restart Claude Code to pick it up)`);
1965
+ wiringResults.push({ client: "claude-code", message: "wired ~/.claude.json" });
1966
+ }
1967
+ }
1968
+ else {
1969
+ console.log(` MCP config (add to ~/.claude.json):`);
1970
+ console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1971
+ wiringResults.push({ client: "claude-code", message: "snippet printed (no ~/.claude.json)" });
1972
+ }
1973
+ }
1974
+ catch {
1975
+ console.log(` MCP config (add manually to ~/.claude.json):`);
1976
+ console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1977
+ wiringResults.push({ client: "claude-code", message: "snippet printed" });
1978
+ }
1979
+ }
1980
+ else {
1981
+ let result;
1982
+ switch (clientId) {
1983
+ case "codex":
1984
+ result = wireCodex(mcpEnv);
1985
+ break;
1986
+ case "gemini":
1987
+ result = wireGemini(mcpEnv);
1988
+ break;
1989
+ case "cursor":
1990
+ result = wireCursor(mcpEnv);
1991
+ break;
1992
+ default: result = { ok: false, message: `Unknown client: ${clientId}` };
1993
+ }
1994
+ wiringResults.push({ client: clientId, message: result.message });
1995
+ console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
1996
+ }
1777
1997
  }
1778
1998
  }
1779
- catch {
1780
- console.log(`\n MCP config (add manually to ~/.claude.json):`);
1781
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1999
+ // ── Smoke-test the MCP server ────────────────────────────────────────
2000
+ // Launch flair-mcp and confirm it answers a JSON-RPC initialize over
2001
+ // stdio. Best-effort: failures warn but never fail the command. Skipped
2002
+ // with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
2003
+ if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
2004
+ console.log("\n Smoke-testing MCP server...");
2005
+ try {
2006
+ const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
2007
+ env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
2008
+ stdio: ["pipe", "pipe", "pipe"],
2009
+ });
2010
+ const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-init", version: "1.0.0" } } });
2011
+ mcpProc.stdin.write(initMsg + "\n");
2012
+ mcpProc.stdin.end();
2013
+ let stdout = "";
2014
+ mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
2015
+ // A single timer drives the timeout AND cleanup. It MUST be cleared on
2016
+ // settle — an un-cleared setTimeout is a live handle that keeps Node's
2017
+ // event loop alive (the ~60s phantom hang after `flair init` printed
2018
+ // success: the smoke timer + the lingering npx child both pinned the
2019
+ // loop). We clear it on every exit path below.
2020
+ let smokeTimer = null;
2021
+ await new Promise((resolve, reject) => {
2022
+ const settle = (fn) => {
2023
+ if (smokeTimer) {
2024
+ clearTimeout(smokeTimer);
2025
+ smokeTimer = null;
2026
+ }
2027
+ fn();
2028
+ };
2029
+ mcpProc.on("exit", (code) => {
2030
+ settle(() => {
2031
+ if (code === 0 && stdout.length > 0)
2032
+ resolve();
2033
+ else
2034
+ reject(new Error(`MCP server exited with code ${code}`));
2035
+ });
2036
+ });
2037
+ mcpProc.on("error", (err) => settle(() => reject(err)));
2038
+ smokeTimer = setTimeout(() => settle(() => { mcpProc.kill("SIGKILL"); reject(new Error("MCP smoke test timed out")); }), 15_000);
2039
+ });
2040
+ try {
2041
+ const lines = stdout.split("\n").filter(l => l.trim());
2042
+ for (const line of lines) {
2043
+ const parsed = JSON.parse(line);
2044
+ if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
2045
+ console.log(" ✓ MCP server responded");
2046
+ break;
2047
+ }
2048
+ }
2049
+ }
2050
+ catch {
2051
+ console.log(" ⚠ MCP server responded but response could not be parsed");
2052
+ }
2053
+ finally {
2054
+ // Reap the child even on the resolve path: the MCP server exits on
2055
+ // stdin close, but the `npx` wrapper can linger holding the loop.
2056
+ // SIGKILL is safe — we already have the response we need.
2057
+ try {
2058
+ if (mcpProc.exitCode === null)
2059
+ mcpProc.kill("SIGKILL");
2060
+ }
2061
+ catch { /* already gone */ }
2062
+ }
2063
+ }
2064
+ catch (err) {
2065
+ const message = err instanceof Error ? err.message : String(err);
2066
+ console.log(` ⚠ MCP smoke test failed: ${message}`);
2067
+ console.log(" Use --skip-smoke to bypass.");
2068
+ }
1782
2069
  }
1783
2070
  }
1784
2071
  else {
@@ -1802,354 +2089,16 @@ program
1802
2089
  }
1803
2090
  console.log(`\n Export: FLAIR_URL=${httpUrl}`);
1804
2091
  }
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?"`);
2092
+ // All init work is genuinely done at this point: Harper is installed +
2093
+ // running (detached, unref'd — survives this process exiting), the agent is
2094
+ // registered, semantic search is verified, MCP clients are wired, and the
2095
+ // smoke test ran. The MCP smoke subprocess can leave a lingering npx handle
2096
+ // that pins Node's event loop for ~60s after success ("rc=0 but doesn't
2097
+ // return"). We've cleared/unref'd the known timers above; exit explicitly so
2098
+ // the prompt returns in a couple seconds regardless of any stray handle. The
2099
+ // running Harper instance is unaffected.
2100
+ await new Promise((r) => process.stdout.write("", () => r()));
2101
+ process.exit(0);
2153
2102
  });
2154
2103
  // ─── flair agent ─────────────────────────────────────────────────────────────
2155
2104
  const agent = program.command("agent").description("Manage Flair agents");
@@ -2209,6 +2158,8 @@ agent
2209
2158
  .command("list")
2210
2159
  .description("List all agents")
2211
2160
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
2161
+ .option("--agent <id>", "Agent ID to authenticate as via Ed25519 (or FLAIR_AGENT_ID env) when no admin pass")
2162
+ .option("--keys-dir <dir>", "Directory holding the agent's Ed25519 key")
2212
2163
  .option("--port <port>", "Harper HTTP port")
2213
2164
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
2214
2165
  .action(async (opts) => {
@@ -2244,13 +2195,39 @@ agent
2244
2195
  agents = await res.json();
2245
2196
  }
2246
2197
  else {
2198
+ // No admin pass → authenticate as the AGENT via Ed25519. The Agent table's
2199
+ // allowRead() is allowVerified — a bare unauthenticated GET /Agent returns
2200
+ // 403 AccessViolation (the dogfood symptom: the natural "did my agent
2201
+ // register?" check errored on a healthy install). A verified agent reads
2202
+ // the principal table for discovery, so sign the request with its key.
2247
2203
  const baseUrl = `http://127.0.0.1:${port}`;
2248
- const res = await fetch(`${baseUrl}/Agent`, {
2249
- headers: { "Content-Type": "application/json" },
2250
- });
2204
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
2205
+ const keysDir = opts.keysDir ?? process.env.FLAIR_KEY_DIR ?? defaultKeysDir();
2206
+ let res;
2207
+ if (agentId) {
2208
+ const keyPath = resolveKeyPath(agentId) ?? join(keysDir, `${agentId}.key`);
2209
+ if (!existsSync(keyPath)) {
2210
+ console.error(`${render.icons.error} no key for agent '${agentId}' (looked in ${keysDir}). Pass --admin-pass, --keys-dir, or a registered --agent.`);
2211
+ process.exit(1);
2212
+ }
2213
+ res = await authFetch(baseUrl, agentId, keyPath, "GET", "/Agent");
2214
+ }
2215
+ else {
2216
+ // No agent identity available either. Try anonymously, but if it 403s
2217
+ // (the common case), tell the user exactly how to authenticate rather
2218
+ // than dumping a raw AccessViolation.
2219
+ res = await fetch(`${baseUrl}/Agent`, { headers: { "Content-Type": "application/json" } });
2220
+ }
2251
2221
  if (!res.ok) {
2252
2222
  const text = await res.text().catch(() => "");
2253
- console.error(`${render.icons.error} ${res.status} ${text}`);
2223
+ if (res.status === 403 && !agentId) {
2224
+ console.error(`${render.icons.error} 403 — listing agents requires authentication.`);
2225
+ console.error(` Use ${render.wrap(render.c.cyan, "--agent <id>")} (or set FLAIR_AGENT_ID) to authenticate as a registered agent,`);
2226
+ console.error(` or ${render.wrap(render.c.cyan, "--admin-pass")} / FLAIR_ADMIN_PASS for the admin view.`);
2227
+ }
2228
+ else {
2229
+ console.error(`${render.icons.error} ${res.status} ${text}`);
2230
+ }
2254
2231
  process.exit(1);
2255
2232
  }
2256
2233
  const data = await res.json();
@@ -5905,7 +5882,13 @@ program
5905
5882
  {
5906
5883
  name: "@tpsdev-ai/flair-mcp",
5907
5884
  kind: "bin",
5908
- probe: () => probeBinVersion(execFileSync, "flair-mcp"),
5885
+ // Older flair-mcp installs (e.g. 0.10.0) either aren't on PATH or
5886
+ // don't support `--version`, so the bin probe returns null even when
5887
+ // the package IS globally installed (ops-p42n). Fall back to the lib
5888
+ // probe, which require.resolves the package.json from a sibling global
5889
+ // install regardless of PATH or --version support. kind stays "bin" so
5890
+ // it remains npm-upgradeable (npm install -g), not the openclaw path.
5891
+ probe: () => probeBinVersion(execFileSync, "flair-mcp") ?? probeLibVersion("@tpsdev-ai/flair-mcp"),
5909
5892
  },
5910
5893
  {
5911
5894
  name: "@tpsdev-ai/openclaw-flair",
@@ -5943,6 +5926,13 @@ program
5943
5926
  status = "outdated";
5944
5927
  }
5945
5928
  findings.push({ name, installed, latest, status, kind });
5929
+ // Suppress the line for openclaw plugins that are optional-because-
5930
+ // openclaw-is-absent (ops-p42n): on machines without openclaw the
5931
+ // "○ … not installed (openclaw not detected) → … (install via …)"
5932
+ // line is pure noise. Still print it when openclaw IS installed
5933
+ // (current/outdated) or under --all.
5934
+ if (!shouldPrintUpgradeLine(status, showAll))
5935
+ continue;
5946
5936
  const icon = status === "current" ? "✅"
5947
5937
  : status === "outdated" ? "⬆️"
5948
5938
  : status === "optional" ? "○"
@@ -5956,6 +5946,9 @@ program
5956
5946
  }
5957
5947
  catch { /* skip unavailable packages */ }
5958
5948
  }
5949
+ // Scope footer (ops-p42n): make explicit what `flair upgrade` does and
5950
+ // doesn't cover, so "were the others checked?" has a one-line answer.
5951
+ console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
5959
5952
  const outdated = findings.filter((f) => f.status === "outdated");
5960
5953
  const missing = findings.filter((f) => f.status === "missing");
5961
5954
  // openclaw plugins upgrade through `openclaw plugins install`, not `npm
@@ -6043,7 +6036,7 @@ program
6043
6036
  }
6044
6037
  }
6045
6038
  else {
6046
- console.log("\nRun: flair restart to use the new version");
6039
+ console.log("\nRun: flair restart to use the new version");
6047
6040
  }
6048
6041
  });
6049
6042
  // ─── flair stop ───────────────────────────────────────────────────────────────
@@ -6718,6 +6711,7 @@ program
6718
6711
  .command("doctor")
6719
6712
  .description("Diagnose common Flair problems and suggest fixes")
6720
6713
  .option("--port <port>", "Harper HTTP port")
6714
+ .option("--agent <id>", "Agent ID to use for the semantic-search round-trip (or FLAIR_AGENT_ID env)")
6721
6715
  .option("--fix", "Automatically fix issues where possible")
6722
6716
  .option("--dry-run", "Show what --fix would do without making changes")
6723
6717
  .action(async (opts) => {
@@ -6880,32 +6874,38 @@ program
6880
6874
  else {
6881
6875
  console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
6882
6876
  }
6883
- // 4. Embeddings check (only if Harper is responding)
6877
+ // 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
6878
+ //
6879
+ // The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
6880
+ // not loaded: SemanticSearch falls back to keyword-only scan, and an
6881
+ // unauthenticated probe 401s → "cannot verify" → no issue counted. A clean-VM
6882
+ // dogfood found semantic search DEAD out of the box (sudo/root-owned install
6883
+ // can't write the models symlink → EACCES) while `flair doctor` reported
6884
+ // "no issues found". This now stores a memory with a distinctive phrase and
6885
+ // searches for a PARAPHRASE (no shared keywords). If the top result isn't
6886
+ // recovered by MEANING, recall-by-meaning is broken and doctor FAILS LOUDLY.
6884
6887
  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
- }
6888
+ const semanticStatus = await verifySemanticSearch(baseUrl, opts.agent, defaultKeysDir());
6889
+ switch (semanticStatus.state) {
6890
+ case "ok":
6891
+ console.log(` ${render.icons.ok} Embeddings: semantic search operational ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${semanticStatus.score.toFixed(2)})`)}`);
6892
+ break;
6893
+ case "degraded":
6894
+ // LOUD failure — never report all-clear when recall-by-meaning is dead.
6895
+ console.log(` ${render.icons.error} Semantic search DEGRADED ${render.wrap(render.c.dim, `— ${semanticStatus.detail}`)}`);
6896
+ console.log(` ${render.wrap(render.c.red, "Embeddings are not loaded; recall-by-meaning will NOT work.")}`);
6897
+ console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
6898
+ console.log(` ${render.wrap(render.c.dim, "See:")} docs/troubleshooting.md ${render.wrap(render.c.dim, "→ \"Semantic search DEGRADED\"")}`);
6899
+ issues++;
6900
+ break;
6901
+ case "skipped":
6902
+ // Could not run the round-trip (no agent / no key). Don't claim
6903
+ // all-clear — surface that the check was skipped, but don't count it
6904
+ // as a hard issue since the user may simply not have an agent yet.
6905
+ console.log(` ${render.icons.warn} Embeddings: not verified ${render.wrap(render.c.dim, `(${semanticStatus.detail})`)}`);
6906
+ console.log(` ${render.wrap(render.c.dim, "Pass --agent <id> (or set FLAIR_AGENT_ID) so doctor can run a real semantic round-trip.")}`);
6907
+ break;
6907
6908
  }
6908
- catch { /* fetch error, already flagged */ }
6909
6909
  }
6910
6910
  // 5. Stale PID file (skip if already reported in port check)
6911
6911
  const dataDir = defaultDataDir();