@tpsdev-ai/flair 0.5.6 → 0.6.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
@@ -7,6 +7,7 @@ import { join, resolve as resolvePath } from "node:path";
7
7
  import { spawn } from "node:child_process";
8
8
  import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
9
9
  import { keystore } from "./keystore.js";
10
+ import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
10
11
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
11
12
  // src/ into resources/, which don't survive npm packaging (see also
12
13
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -323,6 +324,163 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
323
324
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
324
325
  }
325
326
  }
327
+ export function templateSoul(choice) {
328
+ const templates = {
329
+ "1": [
330
+ ["role", "Pair programmer on this machine. Concise, direct, proactive about flagging risks before I hit them."],
331
+ ["project", "(fill in: the main project or repo I'm helping with — shapes what bootstrap prioritizes)"],
332
+ ["standards", "Match existing codebase style. Prefer editing over rewriting. Surface tradeoffs on ambiguous decisions instead of making unilateral calls."],
333
+ ],
334
+ "2": [
335
+ ["role", "Team agent — operates in a shared repo and coordinates with other agents. Communicate through structured channels (PRs, issues, mail), not free-form chat."],
336
+ ["project", "(fill in: the repo or ops flow this agent runs in)"],
337
+ ["standards", "Keep changes minimal and reviewable. Always open PRs, never push to main. Document decisions in the issue tracker, not in agent memory."],
338
+ ],
339
+ "3": [
340
+ ["role", "Research assistant. Survey sources, extract findings, write structured notes. Flag uncertainty explicitly; separate evidence from inference."],
341
+ ["project", "(fill in: the research area or question being tracked)"],
342
+ ["standards", "Cite sources inline. When sources disagree, surface the disagreement rather than picking a side silently. Prefer primary sources."],
343
+ ],
344
+ };
345
+ return templates[choice] ?? [];
346
+ }
347
+ async function customSoulPrompts(ask) {
348
+ const entries = [];
349
+ console.log("\n Three fields. Press Enter on any to skip it.\n");
350
+ console.log(" role — how the agent identifies itself and acts");
351
+ console.log(" \"Senior dev, concise and direct\"");
352
+ console.log(" \"Data-engineering sidekick, SQL-first\"");
353
+ console.log(" \"PM assistant — asks clarifying questions before writing specs\"");
354
+ const role = await ask(" > ");
355
+ if (role.trim())
356
+ entries.push(["role", role.trim()]);
357
+ console.log("\n project — what the agent is currently focused on");
358
+ console.log(" \"LifestyleLab — building Flair and TPS\"");
359
+ console.log(" \"Legal discovery review, Q2 contracts\"");
360
+ console.log(" \"Personal automation scripts in Bash + Python\"");
361
+ const project = await ask(" > ");
362
+ if (project.trim())
363
+ entries.push(["project", project.trim()]);
364
+ console.log("\n standards — communication or coding preferences that should persist");
365
+ console.log(" \"No emojis. Match existing style. Ask before risky ops.\"");
366
+ console.log(" \"Always cite sources. Flag uncertainty explicitly.\"");
367
+ console.log(" \"Typescript strict mode. Prefer composition over inheritance.\"");
368
+ const standards = await ask(" > ");
369
+ if (standards.trim())
370
+ entries.push(["standards", standards.trim()]);
371
+ return entries;
372
+ }
373
+ async function editEntries(ask, entries) {
374
+ console.log("\n Press Enter to keep each default, or type a replacement:");
375
+ const result = [];
376
+ for (const [key, def] of entries) {
377
+ const preview = def.length > 60 ? def.slice(0, 57) + "..." : def;
378
+ console.log(`\n ${key} [keep: ${preview}]`);
379
+ const input = (await ask(" > ")).trim();
380
+ result.push([key, input || def]);
381
+ }
382
+ return result;
383
+ }
384
+ export function parseSoulJson(raw) {
385
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
386
+ if (!jsonMatch)
387
+ throw new Error("no JSON object found in input");
388
+ const parsed = JSON.parse(jsonMatch[0]);
389
+ const entries = [];
390
+ if (parsed.role)
391
+ entries.push(["role", String(parsed.role).trim()]);
392
+ if (parsed.project)
393
+ entries.push(["project", String(parsed.project).trim()]);
394
+ if (parsed.standards)
395
+ entries.push(["standards", String(parsed.standards).trim()]);
396
+ if (entries.length === 0)
397
+ throw new Error("JSON had no role/project/standards keys");
398
+ return entries;
399
+ }
400
+ async function runSoulWizard(agentId) {
401
+ const { createInterface } = await import("node:readline");
402
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
403
+ // Buffered ask: collects rapid input (pasted text) into one answer.
404
+ // Waits 200ms after last line before resolving, so pasted multiline
405
+ // blocks are captured as a single answer instead of spilling across prompts.
406
+ const ask = (q) => new Promise(resolve => {
407
+ let buffer = "";
408
+ let timer = null;
409
+ let done = false;
410
+ const finish = () => {
411
+ if (done)
412
+ return;
413
+ done = true;
414
+ rl.removeListener("line", onLine);
415
+ resolve(buffer.trim());
416
+ };
417
+ const onLine = (line) => {
418
+ buffer += (buffer ? "\n" : "") + line;
419
+ if (timer)
420
+ clearTimeout(timer);
421
+ timer = setTimeout(finish, 200);
422
+ };
423
+ process.stdout.write(q);
424
+ rl.on("line", onLine);
425
+ });
426
+ console.log("\n🎭 Agent personality setup");
427
+ console.log(" Soul entries shape what every future session starts with.\n");
428
+ console.log(" What best describes this agent?");
429
+ console.log(" (1) Solo developer — helps you with code on this machine");
430
+ console.log(" (2) Team agent — runs in a shared repo / ops flow");
431
+ console.log(" (3) Research assistant — surveys sources, writes notes");
432
+ console.log(" (4) Draft from Claude — paste a Claude-generated JSON draft");
433
+ console.log(" (5) Custom — I'll prompt for each field with examples");
434
+ console.log(" (s) Skip — set up later; `flair doctor` will nudge\n");
435
+ const choice = (await ask(" Choice [1-5/s]: ")).trim().toLowerCase();
436
+ let entries = [];
437
+ if (choice === "s" || choice === "skip") {
438
+ rl.close();
439
+ return [];
440
+ }
441
+ else if (choice === "1" || choice === "2" || choice === "3") {
442
+ entries = templateSoul(choice);
443
+ console.log("\n Template draft:");
444
+ for (const [k, v] of entries)
445
+ console.log(` ${k}: ${v}`);
446
+ const edit = (await ask("\n Edit before saving? [y/N]: ")).trim().toLowerCase();
447
+ if (edit === "y" || edit === "yes") {
448
+ entries = await editEntries(ask, entries);
449
+ }
450
+ }
451
+ else if (choice === "4") {
452
+ console.log("\n Paste this prompt into your Claude session:");
453
+ console.log(" ─────────────────────────────────────────────────────────────");
454
+ console.log(` Generate a JSON object with keys "role", "project", and`);
455
+ console.log(` "standards" suitable as Flair soul entries for an agent with`);
456
+ console.log(` id "${agentId}" operating in my current context. Each value`);
457
+ console.log(` should be 1-2 specific sentences that shape behavior. Output`);
458
+ console.log(` only the JSON object, no prose.`);
459
+ console.log(" ─────────────────────────────────────────────────────────────\n");
460
+ console.log(" Paste the resulting JSON below:");
461
+ const raw = await ask(" > ");
462
+ try {
463
+ entries = parseSoulJson(raw);
464
+ console.log("\n Parsed draft:");
465
+ for (const [k, v] of entries)
466
+ console.log(` ${k}: ${v}`);
467
+ const edit = (await ask("\n Edit before saving? [y/N]: ")).trim().toLowerCase();
468
+ if (edit === "y" || edit === "yes") {
469
+ entries = await editEntries(ask, entries);
470
+ }
471
+ }
472
+ catch (err) {
473
+ console.log(`\n Couldn't parse JSON (${err.message}). Falling back to custom prompts.`);
474
+ entries = await customSoulPrompts(ask);
475
+ }
476
+ }
477
+ else {
478
+ // Custom (5) or unrecognized input — route to custom prompts
479
+ entries = await customSoulPrompts(ask);
480
+ }
481
+ rl.close();
482
+ return entries.filter(([, v]) => v.trim().length > 0);
483
+ }
326
484
  // ─── Program ─────────────────────────────────────────────────────────────────
327
485
  // Read version from package.json at the package root
328
486
  const __pkgDir = join(import.meta.dirname ?? __dirname, "..");
@@ -566,43 +724,15 @@ program
566
724
  }
567
725
  console.log(`\n Export: FLAIR_URL=${httpUrl}`);
568
726
  // ── First-run soul setup ──────────────────────────────────────────────
569
- // Interactive prompts to set initial personality. Skipped with --skip-soul
570
- // or when stdin is not a TTY (CI, scripts, piped input).
727
+ // Interactive wizard to set initial personality (see runSoulWizard).
728
+ // Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
729
+ //
730
+ // Non-TTY / --skip-soul used to seed placeholder text like
731
+ // "AI assistant [default]" — it leaked into bootstrap output and
732
+ // confused users. Now those paths leave the soul empty and nudge the
733
+ // user toward `flair soul set` / `flair doctor` instead.
571
734
  if (!opts.skipSoul && process.stdin.isTTY) {
572
- console.log("\n🎭 Set up agent personality (press Enter to skip any):\n");
573
- const { createInterface } = await import("node:readline");
574
- const rl = createInterface({ input: process.stdin, output: process.stdout });
575
- // Buffered ask: collects rapid input (pasted text) into one answer.
576
- // Waits 200ms after last line before resolving, so pasted multiline
577
- // blocks are captured as a single answer instead of spilling across prompts.
578
- const ask = (q) => new Promise(resolve => {
579
- let buffer = "";
580
- let timer = null;
581
- const finish = () => {
582
- rl.removeListener("line", onLine);
583
- resolve(buffer.trim());
584
- };
585
- const onLine = (line) => {
586
- buffer += (buffer ? "\n" : "") + line;
587
- if (timer)
588
- clearTimeout(timer);
589
- timer = setTimeout(finish, 200);
590
- };
591
- process.stdout.write(q);
592
- rl.on("line", onLine);
593
- });
594
- const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
595
- const project = await ask(" What project is it working on?\n > ");
596
- const standards = await ask(" Any coding standards or preferences?\n > ");
597
- rl.close();
598
- // Write non-empty answers as soul entries
599
- const soulEntries = [];
600
- if (role.trim())
601
- soulEntries.push(["role", role.trim()]);
602
- if (project.trim())
603
- soulEntries.push(["project", project.trim()]);
604
- if (standards.trim())
605
- soulEntries.push(["standards", standards.trim()]);
735
+ const soulEntries = await runSoulWizard(agentId);
606
736
  if (soulEntries.length > 0) {
607
737
  console.log("");
608
738
  for (const [key, value] of soulEntries) {
@@ -614,30 +744,19 @@ program
614
744
  console.warn(` ⚠ soul:${key} failed: ${err.message}`);
615
745
  }
616
746
  }
617
- console.log(`\n ${soulEntries.length} soul entries saved. Bootstrap will include them.`);
747
+ console.log(`\n ${soulEntries.length} soul entries saved.`);
748
+ console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
618
749
  }
619
750
  else {
620
- console.log("\n No soul entries you can add them later with: flair soul set --agent " + agentId + " --key role --value \"...\"");
751
+ console.log(`\n No soul entries saved. Add later with:`);
752
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
753
+ console.log(` Or run \`flair doctor\` anytime for a nudge.`);
621
754
  }
622
755
  }
623
756
  else {
624
- // --skip-soul or non-interactive: seed sensible defaults so bootstrap returns useful context
625
- const defaultSoulEntries = [
626
- ["role", "AI assistant [default customize with 'flair soul set']"],
627
- ["personality", "Helpful, precise, and proactive [default — customize with 'flair soul set']"],
628
- ["constraints", "Respect user privacy. Be concise. [default — customize with 'flair soul set']"],
629
- ];
630
- console.log("\nSeeding default soul entries...");
631
- for (const [key, value] of defaultSoulEntries) {
632
- try {
633
- await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
634
- console.log(` ✓ soul:${key} set (default)`);
635
- }
636
- catch (err) {
637
- console.warn(` ⚠ soul:${key} failed: ${err.message}`);
638
- }
639
- }
640
- console.log(` Customize with: flair soul set --agent ${agentId} --key role --value "..."`);
757
+ const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
758
+ console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
759
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
641
760
  }
642
761
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
643
762
  console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
@@ -1824,23 +1943,81 @@ rem
1824
1943
  }
1825
1944
  });
1826
1945
  // ─── flair status ─────────────────────────────────────────────────────────────
1827
- program
1828
- .command("status")
1829
- .description("Show Flair instance status, memory stats, and agent info")
1830
- .option("--port <port>", "Harper HTTP port")
1831
- .option("--url <url>", "Flair base URL (overrides --port)")
1832
- .option("--json", "Output as JSON")
1833
- .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
1834
- .action(async (opts) => {
1946
+ function humanBytes(n) {
1947
+ if (!Number.isFinite(n) || n < 0)
1948
+ return "—";
1949
+ if (n < 1024)
1950
+ return `${n} B`;
1951
+ if (n < 1024 * 1024)
1952
+ return `${(n / 1024).toFixed(1)} KB`;
1953
+ if (n < 1024 * 1024 * 1024)
1954
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
1955
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
1956
+ }
1957
+ function relativeTime(iso) {
1958
+ if (!iso)
1959
+ return "—";
1960
+ const ago = Date.now() - new Date(iso).getTime();
1961
+ if (!Number.isFinite(ago) || ago < 0)
1962
+ return "—";
1963
+ const mins = Math.floor(ago / 60000);
1964
+ const hrs = Math.floor(ago / 3600000);
1965
+ const days = Math.floor(ago / 86400000);
1966
+ return days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
1967
+ }
1968
+ // Renders OAuth status lines from non-secret metadata. /HealthDetail never
1969
+ // returns clientSecret — only counts and identifying fields (id, name,
1970
+ // registeredBy, createdAt, issuer). Inputs are coerced to scalar primitives
1971
+ // before formatting to keep the display values clearly separated from the
1972
+ // source record.
1973
+ function oauthSummaryLines(o) {
1974
+ const clients = Number(o?.clients ?? 0);
1975
+ const idps = Number(o?.idpConfigs ?? 0);
1976
+ const tokens = Number(o?.activeTokens ?? 0);
1977
+ return [
1978
+ "\nOAuth:",
1979
+ ` Clients: ${clients} IdPs: ${idps} Active tokens: ${tokens}`,
1980
+ ];
1981
+ }
1982
+ function oauthDetailLines(o) {
1983
+ const clients = Number(o?.clients ?? 0);
1984
+ const idps = Number(o?.idpConfigs ?? 0);
1985
+ const tokens = Number(o?.activeTokens ?? 0);
1986
+ const out = [
1987
+ "OAuth:",
1988
+ ` Clients: ${clients}`,
1989
+ ` IdP configs: ${idps}`,
1990
+ ` Active tokens: ${tokens}`,
1991
+ ];
1992
+ if (Array.isArray(o?.clientList) && o.clientList.length > 0) {
1993
+ out.push("", " Clients:");
1994
+ for (const c of o.clientList) {
1995
+ const id = String(c?.id ?? "");
1996
+ const name = String(c?.name ?? "—");
1997
+ const registeredBy = String(c?.registeredBy ?? "—");
1998
+ const createdAt = String(c?.createdAt ?? "—");
1999
+ out.push(` ${id} ${name} ${registeredBy} ${createdAt}`);
2000
+ }
2001
+ }
2002
+ if (Array.isArray(o?.idpList) && o.idpList.length > 0) {
2003
+ out.push("", " IdPs:");
2004
+ for (const i of o.idpList) {
2005
+ const id = String(i?.id ?? "");
2006
+ const name = String(i?.name ?? "—");
2007
+ const issuer = String(i?.issuer ?? "—");
2008
+ out.push(` ${id} ${name} ${issuer}`);
2009
+ }
2010
+ }
2011
+ return out;
2012
+ }
2013
+ async function fetchHealthDetail(opts) {
1835
2014
  const port = resolveHttpPort(opts);
1836
2015
  const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
1837
2016
  let healthy = false;
1838
2017
  let healthData = null;
1839
- // 1. Basic health check — try unauthenticated first, then with admin auth
1840
2018
  try {
1841
2019
  let res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1842
2020
  if (!res.ok && res.status === 401) {
1843
- // Harper requires auth (authorizeLocal: true) — retry with admin credentials
1844
2021
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
1845
2022
  if (adminPass) {
1846
2023
  res = await fetch(`${baseUrl}/Health`, {
@@ -1852,7 +2029,6 @@ program
1852
2029
  healthy = res.ok;
1853
2030
  }
1854
2031
  catch { /* unreachable */ }
1855
- // 2. Try authenticated /HealthDetail for rich stats
1856
2032
  if (healthy) {
1857
2033
  const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
1858
2034
  if (agentId) {
@@ -1864,14 +2040,12 @@ program
1864
2040
  headers: { Authorization: authHeader },
1865
2041
  signal: AbortSignal.timeout(5000),
1866
2042
  });
1867
- if (res.ok) {
2043
+ if (res.ok)
1868
2044
  healthData = await res.json().catch(() => null);
1869
- }
1870
2045
  }
1871
- catch { /* fall through to basic output */ }
2046
+ catch { /* fall through */ }
1872
2047
  }
1873
2048
  }
1874
- // Fallback: try admin basic auth if available
1875
2049
  if (!healthData) {
1876
2050
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
1877
2051
  if (adminPass) {
@@ -1881,14 +2055,24 @@ program
1881
2055
  headers: { Authorization: auth },
1882
2056
  signal: AbortSignal.timeout(5000),
1883
2057
  });
1884
- if (res.ok) {
2058
+ if (res.ok)
1885
2059
  healthData = await res.json().catch(() => null);
1886
- }
1887
2060
  }
1888
- catch { /* fall through to basic output */ }
2061
+ catch { /* fall through */ }
1889
2062
  }
1890
2063
  }
1891
2064
  }
2065
+ return { healthy, baseUrl, healthData };
2066
+ }
2067
+ const statusCmd = program
2068
+ .command("status")
2069
+ .description("Show Flair instance status, memory stats, and agent info")
2070
+ .option("--port <port>", "Harper HTTP port")
2071
+ .option("--url <url>", "Flair base URL (overrides --port)")
2072
+ .option("--json", "Output as JSON")
2073
+ .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
2074
+ .action(async (opts) => {
2075
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
1892
2076
  if (opts.json) {
1893
2077
  console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
1894
2078
  if (!healthy)
@@ -1901,7 +2085,6 @@ program
1901
2085
  console.log(`\n Run: flair start or flair doctor`);
1902
2086
  process.exit(1);
1903
2087
  }
1904
- // Format uptime
1905
2088
  const uptimeSec = healthData?.uptimeSeconds;
1906
2089
  let uptimeStr = "";
1907
2090
  if (uptimeSec != null) {
@@ -1910,41 +2093,268 @@ program
1910
2093
  const m = Math.floor((uptimeSec % 3600) / 60);
1911
2094
  uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
1912
2095
  }
1913
- // Format last write as relative time
1914
- let lastWriteStr = "";
1915
- if (healthData?.lastWrite) {
1916
- const ago = Date.now() - new Date(healthData.lastWrite).getTime();
1917
- const mins = Math.floor(ago / 60000);
1918
- const hrs = Math.floor(ago / 3600000);
1919
- const days = Math.floor(ago / 86400000);
1920
- lastWriteStr = days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
1921
- }
1922
2096
  const pid = healthData?.pid ?? "";
1923
2097
  const agents = healthData?.agents;
1924
2098
  const memories = healthData?.memories;
1925
- console.log(`Flair v${__pkgVersion} 🟢 running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
2099
+ const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
2100
+ const hasWarn = warnings.some((w) => w.level === "warn");
2101
+ // Header state-word stays "running" whenever the process is alive; the
2102
+ // icon conveys health (🟢 clean / 🟡 warnings). 🔴 unreachable is already
2103
+ // handled above by the `!healthy` early-exit. Decoupling state from
2104
+ // health keeps the smoke-test `grep -q "running"` stable across tiers.
2105
+ const headerIcon = hasWarn ? "🟡" : "🟢";
2106
+ console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
1926
2107
  console.log(` URL: ${baseUrl}`);
2108
+ if (warnings.length > 0) {
2109
+ console.log(`\n⚠ Warnings: ${warnings.length}`);
2110
+ for (const w of warnings)
2111
+ console.log(` • ${w.level} ${w.message}`);
2112
+ }
1927
2113
  if (memories) {
1928
- const embStr = memories.withEmbeddings > 0
1929
- ? `${memories.withEmbeddings} with embeddings`
1930
- : "";
1931
- const hashStr = memories.hashFallback > 0
1932
- ? `${memories.hashFallback} hash-fallback`
1933
- : "";
2114
+ console.log("\nMemory:");
2115
+ const embStr = memories.withEmbeddings > 0 ? `${memories.withEmbeddings} embedded` : "";
2116
+ const hashStr = memories.hashFallback > 0 ? `${memories.hashFallback} hash` : "";
1934
2117
  const detail = [embStr, hashStr].filter(Boolean).join(", ");
1935
- console.log(` Memories: ${memories.total}${detail ? ` (${detail})` : ""}`);
2118
+ console.log(` Total: ${memories.total}${detail ? ` (${detail})` : ""}`);
2119
+ if (memories.modelCounts && typeof memories.modelCounts === "object") {
2120
+ const entries = Object.entries(memories.modelCounts)
2121
+ .filter(([, n]) => n > 0)
2122
+ .sort((a, b) => b[1] - a[1]);
2123
+ if (entries.length > 0) {
2124
+ const formatted = entries.map(([k, n]) => `${k}: ${n}`).join(", ");
2125
+ console.log(` Embeddings: ${formatted}`);
2126
+ }
2127
+ }
2128
+ if (memories.byDurability) {
2129
+ const d = memories.byDurability;
2130
+ console.log(` Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
2131
+ }
2132
+ if (typeof memories.archived === "number")
2133
+ console.log(` Archived: ${memories.archived}`);
2134
+ if (typeof memories.expired === "number" && memories.expired > 0)
2135
+ console.log(` Expired: ${memories.expired}`);
2136
+ if (healthData?.lastWrite)
2137
+ console.log(` Last write: ${relativeTime(healthData.lastWrite)}`);
2138
+ }
2139
+ if (agents && agents.count > 0) {
2140
+ console.log("\nAgents:");
2141
+ const nameStr = agents.names?.length > 0 ? ` — ${agents.names.join(", ")}` : "";
2142
+ console.log(` ${agents.count} total${nameStr}`);
2143
+ if (agents.count > 1 && Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
2144
+ const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
2145
+ // Older HealthDetail responses only carry id / memoryCount / lastWriteAt.
2146
+ // Only print the richer columns if at least one row supplies them.
2147
+ const hasDeep = agents.perAgent.some((r) => typeof r.hashFallback === "number" || typeof r.writes24h === "number");
2148
+ if (hasDeep) {
2149
+ console.log(` ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
2150
+ for (const r of agents.perAgent) {
2151
+ const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "—";
2152
+ const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
2153
+ console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
2154
+ }
2155
+ }
2156
+ else {
2157
+ console.log(` ${"id".padEnd(idW)} memories last_write`);
2158
+ for (const r of agents.perAgent) {
2159
+ console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${relativeTime(r.lastWriteAt)}`);
2160
+ }
2161
+ }
2162
+ }
1936
2163
  }
1937
- if (agents) {
1938
- const nameStr = agents.names?.length > 0 ? ` (${agents.names.join(", ")})` : "";
1939
- console.log(` Agents: ${agents.count}${nameStr}`);
2164
+ if (healthData?.relationships) {
2165
+ const r = healthData.relationships;
2166
+ console.log("\nRelationships:");
2167
+ console.log(` ${r.total} total (${r.active} active)`);
2168
+ }
2169
+ if (healthData?.soul && healthData.soul.total > 0) {
2170
+ const s = healthData.soul;
2171
+ const bp = s.byPriority ?? {};
2172
+ console.log("\nSoul:");
2173
+ console.log(` ${s.total} entries — ${bp.critical ?? 0} critical / ${bp.high ?? 0} high / ${bp.standard ?? 0} standard / ${bp.low ?? 0} low`);
2174
+ }
2175
+ else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
2176
+ console.log("\nSoul:");
2177
+ console.log(` ${healthData.soulEntries} entries`);
2178
+ }
2179
+ if (healthData?.rem) {
2180
+ const r = healthData.rem;
2181
+ console.log("\nREM:");
2182
+ if (r.lastLightAt)
2183
+ console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
2184
+ if (r.lastRapidAt)
2185
+ console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
2186
+ if (r.lastRestorativeAt)
2187
+ console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
2188
+ const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
2189
+ console.log(` Nightly: ${nightly}`);
2190
+ if (r.nightlyEnabled && r.lastNightlyAt)
2191
+ console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)}`);
2192
+ if (typeof r.pendingCandidates === "number" && r.pendingCandidates > 0) {
2193
+ console.log(` Pending candidates: ${r.pendingCandidates}`);
2194
+ }
1940
2195
  }
1941
- if (healthData?.soulEntries != null) {
1942
- console.log(` Soul: ${healthData.soulEntries} entries`);
2196
+ if (healthData?.federation) {
2197
+ const f = healthData.federation;
2198
+ console.log("\nFederation:");
2199
+ if (f.instance)
2200
+ console.log(` Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
2201
+ if (f.peers)
2202
+ console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
2203
+ if (f.pendingTokens > 0)
2204
+ console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
2205
+ }
2206
+ if (healthData?.oauth) {
2207
+ const lines = oauthSummaryLines(healthData.oauth);
2208
+ for (const line of lines)
2209
+ console.log(line);
2210
+ }
2211
+ if (healthData?.bridges) {
2212
+ const b = healthData.bridges;
2213
+ console.log("\nBridges:");
2214
+ if (Array.isArray(b.installed) && b.installed.length > 0)
2215
+ console.log(` Installed: ${b.installed.join(", ")}`);
2216
+ if (b.lastImport)
2217
+ console.log(` Last import: ${relativeTime(b.lastImport)}`);
2218
+ if (b.lastExport)
2219
+ console.log(` Last export: ${relativeTime(b.lastExport)}`);
2220
+ }
2221
+ if (healthData?.disk) {
2222
+ const d = healthData.disk;
2223
+ console.log("\nDisk:");
2224
+ console.log(` Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
2225
+ console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
1943
2226
  }
1944
- if (lastWriteStr) {
1945
- console.log(` Last write: ${lastWriteStr}`);
2227
+ console.log("");
2228
+ if (warnings.length > 0)
2229
+ console.log(` Health: ⚠ ${warnings.length} warning(s)`);
2230
+ else
2231
+ console.log(` Health: ✅ all checks passing`);
2232
+ });
2233
+ statusCmd
2234
+ .command("rem")
2235
+ .description("Show REM (memory hygiene) subsystem status")
2236
+ .action(async function () {
2237
+ const opts = this.optsWithGlobals();
2238
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2239
+ if (opts.json) {
2240
+ console.log(JSON.stringify({ healthy, rem: healthData?.rem ?? null }, null, 2));
2241
+ if (!healthy)
2242
+ process.exit(1);
2243
+ return;
2244
+ }
2245
+ if (!healthy) {
2246
+ console.log("🔴 unreachable");
2247
+ process.exit(1);
2248
+ }
2249
+ const r = healthData?.rem;
2250
+ if (!r) {
2251
+ console.log("REM: not configured (no log entries or platform timers found)");
2252
+ return;
2253
+ }
2254
+ console.log("REM:");
2255
+ console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
2256
+ console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
2257
+ console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
2258
+ const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
2259
+ console.log(` Nightly: ${nightly}`);
2260
+ if (r.lastNightlyAt)
2261
+ console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
2262
+ if (typeof r.pendingCandidates === "number")
2263
+ console.log(` Pending candidates: ${r.pendingCandidates}`);
2264
+ else
2265
+ console.log(` Pending candidates: — (schema not available)`);
2266
+ });
2267
+ statusCmd
2268
+ .command("federation")
2269
+ .description("Show federation subsystem status")
2270
+ .action(async function () {
2271
+ const opts = this.optsWithGlobals();
2272
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2273
+ if (opts.json) {
2274
+ console.log(JSON.stringify({ healthy, federation: healthData?.federation ?? null }, null, 2));
2275
+ if (!healthy)
2276
+ process.exit(1);
2277
+ return;
2278
+ }
2279
+ if (!healthy) {
2280
+ console.log("🔴 unreachable");
2281
+ process.exit(1);
2282
+ }
2283
+ const f = healthData?.federation;
2284
+ if (!f) {
2285
+ console.log("Federation: not configured");
2286
+ return;
2287
+ }
2288
+ console.log("Federation:");
2289
+ if (f.instance)
2290
+ console.log(` Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
2291
+ else
2292
+ console.log(" Instance: —");
2293
+ if (f.peers)
2294
+ console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
2295
+ if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
2296
+ console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
2297
+ if (Array.isArray(f.peerList) && f.peerList.length > 0) {
2298
+ const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
2299
+ console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
2300
+ for (const p of f.peerList) {
2301
+ console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "—").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
2302
+ }
2303
+ }
2304
+ });
2305
+ statusCmd
2306
+ .command("auth")
2307
+ .description("Show OAuth / IdP subsystem status")
2308
+ .action(async function () {
2309
+ const opts = this.optsWithGlobals();
2310
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2311
+ if (opts.json) {
2312
+ console.log(JSON.stringify({ healthy, oauth: healthData?.oauth ?? null }, null, 2));
2313
+ if (!healthy)
2314
+ process.exit(1);
2315
+ return;
2316
+ }
2317
+ if (!healthy) {
2318
+ console.log("🔴 unreachable");
2319
+ process.exit(1);
2320
+ }
2321
+ const o = healthData?.oauth;
2322
+ if (!o) {
2323
+ console.log("OAuth: not configured");
2324
+ return;
2325
+ }
2326
+ const lines = oauthDetailLines(o);
2327
+ for (const line of lines)
2328
+ console.log(line);
2329
+ });
2330
+ statusCmd
2331
+ .command("bridges")
2332
+ .description("Show memory bridges subsystem status")
2333
+ .action(async function () {
2334
+ const opts = this.optsWithGlobals();
2335
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2336
+ if (opts.json) {
2337
+ console.log(JSON.stringify({ healthy, bridges: healthData?.bridges ?? null }, null, 2));
2338
+ if (!healthy)
2339
+ process.exit(1);
2340
+ return;
2341
+ }
2342
+ if (!healthy) {
2343
+ console.log("🔴 unreachable");
2344
+ process.exit(1);
2345
+ }
2346
+ const b = healthData?.bridges;
2347
+ if (!b) {
2348
+ console.log("Bridges: none installed (no flair-bridge-* packages found)");
2349
+ return;
1946
2350
  }
1947
- console.log(` Health: ✅ all checks passing`);
2351
+ console.log("Bridges:");
2352
+ if (Array.isArray(b.installed) && b.installed.length > 0)
2353
+ console.log(` Installed: ${b.installed.join(", ")}`);
2354
+ if (b.lastImport)
2355
+ console.log(` Last import: ${relativeTime(b.lastImport)}`);
2356
+ if (b.lastExport)
2357
+ console.log(` Last export: ${relativeTime(b.lastExport)}`);
1948
2358
  });
1949
2359
  // ─── flair upgrade ────────────────────────────────────────────────────────────
1950
2360
  program
@@ -2440,19 +2850,21 @@ program
2440
2850
  failed++;
2441
2851
  }
2442
2852
  };
2443
- // 1. Write a test memory via POST /Memory
2444
- await check("Write test memory (POST /Memory)", async () => {
2853
+ // 1. Write a test memory via PUT /Memory/<id>.
2854
+ // Schema only exposes PUT — POST returns 'Memory does not have a post method implemented'.
2855
+ await check("Write test memory (PUT /Memory/<id>)", async () => {
2856
+ const id = `flair-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2445
2857
  const body = {
2858
+ id,
2446
2859
  content: "flair test \u2014 this will be deleted",
2447
2860
  durability: "ephemeral",
2448
2861
  createdAt: new Date().toISOString(),
2449
2862
  };
2450
2863
  if (agentId)
2451
2864
  body.agentId = agentId;
2452
- const result = await api("POST", "/Memory", body);
2453
- // id may be returned directly or nested
2454
- memoryId = result?.id ?? result?.[0]?.id ?? null;
2455
- return !!memoryId || result?.ok === true;
2865
+ await api("PUT", `/Memory/${id}`, body);
2866
+ memoryId = id;
2867
+ return true;
2456
2868
  });
2457
2869
  // 2. Search for the test memory via POST /SemanticSearch
2458
2870
  await check("Search for test memory (POST /SemanticSearch)", async () => {
@@ -2477,6 +2889,80 @@ program
2477
2889
  if (failed > 0)
2478
2890
  process.exit(1);
2479
2891
  });
2892
+ // ─── flair deploy ─────────────────────────────────────────────────────────────
2893
+ program
2894
+ .command("deploy")
2895
+ .description("Deploy Flair as a component to a remote Harper Fabric cluster")
2896
+ .option("--fabric-org <org>", "Fabric org (env: FABRIC_ORG)")
2897
+ .option("--fabric-cluster <cluster>", "Fabric cluster within the org (env: FABRIC_CLUSTER)")
2898
+ .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER)")
2899
+ .option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD)")
2900
+ .option("--fabric-token <token>", "OAuth bearer token (env: FABRIC_TOKEN) — reserved for future Fabric bearer support")
2901
+ .option("--target <url>", "Override the Fabric URL template (https://<cluster>.<org>.harperfabric.com)")
2902
+ .option("--project <name>", "Component name in Fabric", "flair")
2903
+ .option("--pkg-version <semver>", "Override version label (default: installed package version)")
2904
+ .option("--no-replicated", "Disable cluster-wide replication (default: replicated=true)")
2905
+ .option("--no-restart", "Do not restart the component after deploy (default: restart=true)")
2906
+ .option("--dry-run", "Resolve package, validate args, skip the deploy call")
2907
+ .option("--package-root <dir>", "Override package root (mainly for testing)")
2908
+ .action(async (opts) => {
2909
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
2910
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
2911
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
2912
+ const deployOpts = {
2913
+ fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
2914
+ fabricCluster: opts.fabricCluster ?? process.env.FABRIC_CLUSTER,
2915
+ fabricUser: opts.fabricUser ?? process.env.FABRIC_USER,
2916
+ fabricPassword: opts.fabricPassword ?? process.env.FABRIC_PASSWORD,
2917
+ fabricToken: opts.fabricToken ?? process.env.FABRIC_TOKEN,
2918
+ target: opts.target,
2919
+ project: opts.project,
2920
+ version: opts.pkgVersion,
2921
+ replicated: opts.replicated !== false,
2922
+ restart: opts.restart !== false,
2923
+ dryRun: opts.dryRun ?? false,
2924
+ packageRoot: opts.packageRoot,
2925
+ };
2926
+ const errors = validateDeployOptions(deployOpts);
2927
+ if (errors.length) {
2928
+ console.error(red("flair deploy: missing required options"));
2929
+ for (const e of errors)
2930
+ console.error(` - ${e}`);
2931
+ process.exit(1);
2932
+ }
2933
+ // Warn on password-via-flag (leaks to shell history). Env is preferred.
2934
+ if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
2935
+ console.error(dim("warning: --fabric-password leaks to shell history. " +
2936
+ "Prefer FABRIC_PASSWORD env."));
2937
+ }
2938
+ const url = buildDeployUrl(deployOpts);
2939
+ console.log(`${green("→")} Deploying ${deployOpts.project} to ${url}`);
2940
+ if (deployOpts.dryRun)
2941
+ console.log(dim(" (dry-run: skipping API call)"));
2942
+ try {
2943
+ const result = await deployToFabric(deployOpts);
2944
+ if (result.dryRun) {
2945
+ console.log(`${green("✓")} dry-run OK: ${result.project} ${result.version} ready to deploy to ${result.url}`);
2946
+ console.log(dim(` package root: ${result.packageRoot}`));
2947
+ return;
2948
+ }
2949
+ console.log(`\n${green("✓")} Flair ${result.version} deployed`);
2950
+ console.log(`\n URL: ${result.url}`);
2951
+ console.log(` Project: ${result.project}`);
2952
+ console.log(`\nNext steps:`);
2953
+ console.log(dim(` 1. Set an admin password in Fabric Studio (Cluster Settings → Admin)`));
2954
+ console.log(dim(` 2. Seed your first agent:`));
2955
+ console.log(` flair agent add --remote ${result.url} --name my-agent`);
2956
+ }
2957
+ catch (err) {
2958
+ console.error(red(`\n✗ deploy failed: ${err.message}`));
2959
+ const hint = err.message?.toLowerCase();
2960
+ if (hint?.includes("401") || hint?.includes("unauthoriz")) {
2961
+ console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
2962
+ }
2963
+ process.exit(1);
2964
+ }
2965
+ });
2480
2966
  // ─── flair doctor ─────────────────────────────────────────────────────────────
2481
2967
  program
2482
2968
  .command("doctor")
@@ -2730,23 +3216,81 @@ program
2730
3216
  });
2731
3217
  // ─── Memory and Soul commands ────────────────────────────────────────────────
2732
3218
  const memory = program.command("memory").description("Manage agent memories");
2733
- memory.command("add").requiredOption("--agent <id>").requiredOption("--content <text>")
3219
+ memory.command("add [content]").requiredOption("--agent <id>")
3220
+ .option("--content <text>", "memory content (alias for positional arg)")
2734
3221
  .option("--durability <d>", "standard").option("--tags <csv>")
2735
- .action(async (opts) => {
3222
+ .action(async (contentArg, opts) => {
3223
+ const content = contentArg ?? opts.content;
3224
+ if (!content) {
3225
+ console.error("error: content required (positional arg or --content)");
3226
+ process.exit(1);
3227
+ }
2736
3228
  const memId = `${opts.agent}-${Date.now()}`;
2737
3229
  const out = await api("PUT", `/Memory/${memId}`, {
2738
- id: memId, agentId: opts.agent, content: opts.content, durability: opts.durability || "standard",
3230
+ id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
2739
3231
  tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
2740
3232
  type: "memory", createdAt: new Date().toISOString(),
2741
3233
  });
2742
3234
  console.log(JSON.stringify(out, null, 2));
2743
3235
  });
2744
- memory.command("search").requiredOption("--agent <id>").requiredOption("--q <query>").option("--tag <tag>")
2745
- .action(async (opts) => console.log(JSON.stringify(await api("POST", "/SemanticSearch", { agentId: opts.agent, q: opts.q, tag: opts.tag }), null, 2)));
2746
- memory.command("list").requiredOption("--agent <id>").option("--tag <tag>")
3236
+ memory.command("search [query]").requiredOption("--agent <id>")
3237
+ .option("--q <query>", "search query (alias for positional arg)")
3238
+ .option("--limit <n>", "Max results", "5")
3239
+ .option("--tag <tag>")
3240
+ .action(async (queryArg, opts) => {
3241
+ const q = queryArg ?? opts.q;
3242
+ if (!q) {
3243
+ console.error("error: query required (positional arg or --q)");
3244
+ process.exit(1);
3245
+ }
3246
+ const body = { agentId: opts.agent, q, limit: parseInt(opts.limit, 10) || 5 };
3247
+ if (opts.tag)
3248
+ body.tag = opts.tag;
3249
+ console.log(JSON.stringify(await api("POST", "/SemanticSearch", body), null, 2));
3250
+ });
3251
+ memory.command("list")
3252
+ .requiredOption("--agent <id>")
3253
+ .option("--tag <tag>")
3254
+ .option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
3255
+ .option("--limit <n>", "Max rows when using --hash-fallback", "50")
2747
3256
  .action(async (opts) => {
2748
3257
  const q = new URLSearchParams({ agentId: opts.agent, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
2749
- console.log(JSON.stringify(await api("GET", `/Memory?${q}`), null, 2));
3258
+ const raw = await api("GET", `/Memory?${q}`);
3259
+ if (!opts.hashFallback) {
3260
+ console.log(JSON.stringify(raw, null, 2));
3261
+ return;
3262
+ }
3263
+ // --hash-fallback: filter to entries without a real embedding and print as a table.
3264
+ // Same predicate HealthDetail uses: missing model or the "hash-512d" marker.
3265
+ const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
3266
+ const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
3267
+ if (fallback.length === 0) {
3268
+ console.log(`No hash-fallback memories for agent ${opts.agent}. All embedded.`);
3269
+ return;
3270
+ }
3271
+ const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
3272
+ const rows = fallback
3273
+ .slice()
3274
+ .sort((a, b) => {
3275
+ const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
3276
+ const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
3277
+ return tb - ta;
3278
+ })
3279
+ .slice(0, limit);
3280
+ const idW = Math.max(2, ...rows.map((r) => String(r.id ?? "").length));
3281
+ console.log(`${fallback.length} hash-fallback memories for agent ${opts.agent} (showing ${rows.length}):\n`);
3282
+ console.log(` ${"id".padEnd(idW)} created_at preview`);
3283
+ for (const r of rows) {
3284
+ const created = r.createdAt ? String(r.createdAt).slice(0, 19).replace("T", " ") : "—".padEnd(19);
3285
+ const preview = String(r.content ?? "").replace(/\s+/g, " ").slice(0, 80);
3286
+ console.log(` ${String(r.id ?? "").padEnd(idW)} ${created} ${preview}`);
3287
+ }
3288
+ if (fallback.length > rows.length) {
3289
+ console.log(`\n... ${fallback.length - rows.length} more (raise with --limit). To backfill: flair reembed --agent ${opts.agent} --stale-only`);
3290
+ }
3291
+ else {
3292
+ console.log(`\nTo backfill: flair reembed --agent ${opts.agent} --stale-only`);
3293
+ }
2750
3294
  });
2751
3295
  // ─── flair search (top-level shortcut) ───────────────────────────────────────
2752
3296
  program
@@ -2843,6 +3387,320 @@ soul.command("set").requiredOption("--agent <id>").requiredOption("--key <key>")
2843
3387
  soul.command("get").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Soul/${id}`), null, 2)));
2844
3388
  soul.command("list").requiredOption("--agent <id>")
2845
3389
  .action(async (opts) => console.log(JSON.stringify(await api("GET", `/Soul?agentId=${encodeURIComponent(opts.agent)}`), null, 2)));
3390
+ // ─── flair bridge ────────────────────────────────────────────────────────────
3391
+ // Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
3392
+ // + agentic-stack reference adapter as a built-in.
3393
+ // `test` and `export` are still stubbed; Shape B (npm code plugins) too.
3394
+ // See specs/FLAIR-BRIDGES.md.
3395
+ const bridge = program.command("bridge").description("Manage memory bridges (import/export between Flair and foreign systems)");
3396
+ bridge
3397
+ .command("list")
3398
+ .description("List installed bridges across project YAML, user YAML, npm packages, and built-ins")
3399
+ .option("--json", "Output as JSON")
3400
+ .action(async (opts) => {
3401
+ const { discover } = await import("./bridges/discover.js");
3402
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3403
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3404
+ if (opts.json) {
3405
+ console.log(JSON.stringify(found, null, 2));
3406
+ return;
3407
+ }
3408
+ if (found.length === 0) {
3409
+ console.log("No bridges installed.");
3410
+ console.log("Add one with: flair bridge scaffold <name> --file");
3411
+ console.log("Or install from npm: npm install flair-bridge-<name>");
3412
+ return;
3413
+ }
3414
+ const nameW = Math.max(4, ...found.map((b) => b.name.length));
3415
+ const kindW = Math.max(4, ...found.map((b) => b.kind.length));
3416
+ const srcW = Math.max(6, ...found.map((b) => b.source.length));
3417
+ console.log(` ${"name".padEnd(nameW)} ${"kind".padEnd(kindW)} ${"source".padEnd(srcW)} description`);
3418
+ for (const b of found) {
3419
+ const desc = b.description ?? "";
3420
+ console.log(` ${b.name.padEnd(nameW)} ${b.kind.padEnd(kindW)} ${b.source.padEnd(srcW)} ${desc}`);
3421
+ }
3422
+ });
3423
+ bridge
3424
+ .command("scaffold <name>")
3425
+ .description("Emit starter files for a new bridge. Choose --file (YAML, declarative) or --api (TS code plugin)")
3426
+ .option("--file", "YAML file-format bridge (shape A)")
3427
+ .option("--api", "TypeScript API bridge (shape B)")
3428
+ .option("--force", "Overwrite existing files")
3429
+ .action(async (name, opts) => {
3430
+ if (opts.file && opts.api) {
3431
+ console.error("Pick one: --file or --api.");
3432
+ process.exit(1);
3433
+ }
3434
+ const { BUILTIN_BY_NAME } = await import("./bridges/builtins/index.js");
3435
+ if (BUILTIN_BY_NAME.has(name)) {
3436
+ console.error(`"${name}" is a built-in bridge name and can't be scaffolded — pick a different name.`);
3437
+ process.exit(1);
3438
+ }
3439
+ const kind = opts.api ? "api" : "file"; // --file is default
3440
+ const { scaffold } = await import("./bridges/scaffold.js");
3441
+ try {
3442
+ const result = await scaffold({ name, kind, force: !!opts.force });
3443
+ if (result.createdFiles.length > 0) {
3444
+ console.log(`Created ${result.createdFiles.length} file(s):`);
3445
+ for (const p of result.createdFiles)
3446
+ console.log(` + ${p}`);
3447
+ }
3448
+ if (result.skippedFiles.length > 0) {
3449
+ console.log(`Skipped ${result.skippedFiles.length} existing file(s) (pass --force to overwrite):`);
3450
+ for (const p of result.skippedFiles)
3451
+ console.log(` · ${p}`);
3452
+ }
3453
+ console.log(`\n${result.summary}`);
3454
+ }
3455
+ catch (err) {
3456
+ console.error(`Scaffold failed: ${err.message}`);
3457
+ process.exit(1);
3458
+ }
3459
+ });
3460
+ bridge
3461
+ .command("import <name> [src]")
3462
+ .description("Import memories from a foreign system into Flair via a bridge (Shape A YAML / built-in)")
3463
+ .option("--agent <id>", "Default agent ID for memories that don't carry one (or set FLAIR_AGENT_ID)")
3464
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative paths resolve against (default: cwd)")
3465
+ .option("--dry-run", "Validate + count, don't write to Flair")
3466
+ .option("--port <port>", "Harper HTTP port")
3467
+ .option("--url <url>", "Flair base URL (overrides --port)")
3468
+ .option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
3469
+ .action(async (name, srcArg, opts) => {
3470
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
3471
+ const cwd = opts.cwd ?? srcArg ?? process.cwd();
3472
+ const { discover } = await import("./bridges/discover.js");
3473
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3474
+ const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3475
+ const { runImport } = await import("./bridges/runtime/import-runner.js");
3476
+ const { makeContext } = await import("./bridges/runtime/context.js");
3477
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3478
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3479
+ const target = found.find((b) => b.name === name);
3480
+ if (!target) {
3481
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3482
+ process.exit(1);
3483
+ }
3484
+ let descriptor;
3485
+ try {
3486
+ descriptor = await loadDescriptor(target);
3487
+ }
3488
+ catch (err) {
3489
+ printBridgeError(err);
3490
+ process.exit(1);
3491
+ }
3492
+ const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3493
+ const ctx = makeContext({ bridge: name });
3494
+ // Memory POST: Ed25519-signed when an agent key is available, fall back
3495
+ // to the shared `api()` helper otherwise. Mirrors how `flair memory add`
3496
+ // works (see the `memory.command("add")` handler above).
3497
+ const putMemory = async (body) => {
3498
+ const headers = { "content-type": "application/json" };
3499
+ const keyPath = opts.key ?? resolveKeyPath(body.agentId);
3500
+ if (keyPath) {
3501
+ headers["authorization"] = buildEd25519Auth(body.agentId, "PUT", `/Memory/${body.id}`, keyPath);
3502
+ }
3503
+ const res = await fetch(`${baseUrl}/Memory/${encodeURIComponent(body.id)}`, {
3504
+ method: "PUT",
3505
+ headers,
3506
+ body: JSON.stringify(body),
3507
+ });
3508
+ if (!res.ok) {
3509
+ const text = await res.text().catch(() => "");
3510
+ throw new Error(`PUT /Memory/${body.id} → ${res.status}: ${text || res.statusText}`);
3511
+ }
3512
+ };
3513
+ let lastReportedAt = Date.now();
3514
+ let lastReportedOrdinal = 0;
3515
+ const onProgress = (ev) => {
3516
+ // Throttle in-progress chatter to at most one line every 2s + the
3517
+ // final summary. Avoids flooding stdout for big imports.
3518
+ if (ev.type === "done") {
3519
+ const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
3520
+ if (opts.dryRun) {
3521
+ console.log(`\n${descriptor.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
3522
+ }
3523
+ else {
3524
+ console.log(`\n${descriptor.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
3525
+ }
3526
+ return;
3527
+ }
3528
+ const now = Date.now();
3529
+ if (now - lastReportedAt < 2000 && ev.ordinal - lastReportedOrdinal < 25)
3530
+ return;
3531
+ lastReportedAt = now;
3532
+ lastReportedOrdinal = ev.ordinal;
3533
+ if (ev.type === "memory-imported") {
3534
+ process.stdout.write(`\r ${ev.ordinal} imported (${ev.foreignId ?? ev.flairId})`.padEnd(80));
3535
+ }
3536
+ else if (ev.type === "memory-skipped") {
3537
+ process.stdout.write(`\r ${ev.ordinal} skipped (${ev.reason})`.padEnd(80));
3538
+ }
3539
+ };
3540
+ try {
3541
+ await runImport({
3542
+ descriptor,
3543
+ cwd,
3544
+ agentId,
3545
+ dryRun: !!opts.dryRun,
3546
+ putMemory,
3547
+ onProgress,
3548
+ ctx,
3549
+ });
3550
+ }
3551
+ catch (err) {
3552
+ if (err instanceof BridgeRuntimeError) {
3553
+ printBridgeError(err);
3554
+ process.exit(1);
3555
+ }
3556
+ console.error(`Bridge import failed: ${err?.message ?? err}`);
3557
+ process.exit(1);
3558
+ }
3559
+ });
3560
+ bridge
3561
+ .command("export <name> <dst>")
3562
+ .description("Export memories from Flair to a foreign system via a bridge (Shape A YAML / built-in)")
3563
+ .requiredOption("--agent <id>", "Agent ID to export memories for (or set FLAIR_AGENT_ID)")
3564
+ .option("--source <tag>", "Filter to memories with a matching `source:` tag (typical for round-tripping a single bridge's data)")
3565
+ .option("--subject <subj>", "Filter to memories with a matching `subject:` tag")
3566
+ .option("--since <iso>", "Only memories with createdAt >= this ISO-8601 timestamp")
3567
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative target paths resolve against (default: cwd)")
3568
+ .option("--dry-run", "Validate + count + apply maps, don't write to the target")
3569
+ .option("--port <port>", "Harper HTTP port")
3570
+ .option("--url <url>", "Flair base URL (overrides --port)")
3571
+ .option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
3572
+ .action(async (name, dst, opts) => {
3573
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
3574
+ if (!agentId) {
3575
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
3576
+ process.exit(1);
3577
+ }
3578
+ const cwd = opts.cwd ?? dst;
3579
+ const { discover } = await import("./bridges/discover.js");
3580
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3581
+ const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3582
+ const { runExport } = await import("./bridges/runtime/export-runner.js");
3583
+ const { makeContext } = await import("./bridges/runtime/context.js");
3584
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3585
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3586
+ const target = found.find((b) => b.name === name);
3587
+ if (!target) {
3588
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3589
+ process.exit(1);
3590
+ }
3591
+ let descriptor;
3592
+ try {
3593
+ descriptor = await loadDescriptor(target);
3594
+ }
3595
+ catch (err) {
3596
+ printBridgeError(err);
3597
+ process.exit(1);
3598
+ }
3599
+ if (!descriptor.export) {
3600
+ console.error(`Bridge "${name}" has no export block — cannot export through it.`);
3601
+ process.exit(1);
3602
+ }
3603
+ const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3604
+ const ctx = makeContext({ bridge: name });
3605
+ // Memory fetcher — paginates GET /Memory?agentId=... applying any
3606
+ // descriptor + caller-side filters in memory. Slice 3a does the
3607
+ // simplest thing: one round trip, no streaming. Slice 3b can move
3608
+ // to cursor-paginated streaming if real corpora warrant it.
3609
+ const fetchMemories = async function* (filters) {
3610
+ const params = new URLSearchParams({ agentId });
3611
+ if (opts.subject)
3612
+ params.set("subject", opts.subject);
3613
+ const headers = { "content-type": "application/json" };
3614
+ const keyPath = opts.key ?? resolveKeyPath(agentId);
3615
+ const path = `/Memory?${params.toString()}`;
3616
+ if (keyPath)
3617
+ headers["authorization"] = buildEd25519Auth(agentId, "GET", path, keyPath);
3618
+ const res = await fetch(`${baseUrl}${path}`, { headers });
3619
+ if (!res.ok) {
3620
+ const text = await res.text().catch(() => "");
3621
+ throw new Error(`GET /Memory → ${res.status}: ${text || res.statusText}`);
3622
+ }
3623
+ const raw = await res.json();
3624
+ const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
3625
+ const sourceFilter = opts.source;
3626
+ const sinceMs = opts.since ? new Date(opts.since).getTime() : null;
3627
+ for (const m of all) {
3628
+ if (sourceFilter && m.source !== sourceFilter)
3629
+ continue;
3630
+ if (sinceMs !== null && m.createdAt && new Date(m.createdAt).getTime() < sinceMs)
3631
+ continue;
3632
+ yield m;
3633
+ }
3634
+ void filters;
3635
+ };
3636
+ let lastReportedAt = Date.now();
3637
+ const onProgress = (ev) => {
3638
+ if (ev.type === "done") {
3639
+ if (opts.dryRun) {
3640
+ console.log(`\n${descriptor.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
3641
+ }
3642
+ else {
3643
+ console.log(`\n${descriptor.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
3644
+ }
3645
+ return;
3646
+ }
3647
+ if (ev.type === "target-write") {
3648
+ console.log(` ✓ ${ev.path} (${ev.written} record${ev.written === 1 ? "" : "s"})`);
3649
+ return;
3650
+ }
3651
+ if (ev.type === "target-skipped") {
3652
+ console.log(` · ${ev.path} skipped (${ev.reason})`);
3653
+ return;
3654
+ }
3655
+ // memory-skipped events throttled to one line every 2s
3656
+ const now = Date.now();
3657
+ if (now - lastReportedAt < 2000)
3658
+ return;
3659
+ lastReportedAt = now;
3660
+ process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
3661
+ };
3662
+ try {
3663
+ await runExport({
3664
+ descriptor,
3665
+ cwd,
3666
+ fetchMemories,
3667
+ filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
3668
+ dryRun: !!opts.dryRun,
3669
+ ctx,
3670
+ onProgress,
3671
+ });
3672
+ }
3673
+ catch (err) {
3674
+ if (err instanceof BridgeRuntimeError) {
3675
+ printBridgeError(err);
3676
+ process.exit(1);
3677
+ }
3678
+ console.error(`Bridge export failed: ${err?.message ?? err}`);
3679
+ process.exit(1);
3680
+ }
3681
+ });
3682
+ // `test` still stubbed — round-trip harness lands in slice 3b.
3683
+ bridge
3684
+ .command("test <name> [args...]")
3685
+ .description("test a bridge — not yet implemented (slice 3b of FLAIR-BRIDGES)")
3686
+ .allowUnknownOption()
3687
+ .action(() => {
3688
+ console.error(`\`flair bridge test\` is not yet implemented — landing in slice 3b of FLAIR-BRIDGES.`);
3689
+ console.error(`Slice 3a ships export (Shape A YAML); the round-trip test harness pairs with it.`);
3690
+ process.exit(2);
3691
+ });
3692
+ function printBridgeError(err) {
3693
+ // Pretty-print BridgeRuntimeError as the structured shape from §10 of the
3694
+ // spec, plus a one-line human summary so the operator gets both.
3695
+ const detail = err?.detail;
3696
+ if (detail && typeof detail === "object") {
3697
+ console.error(`Bridge error: ${detail.hint ?? err.message}`);
3698
+ console.error(JSON.stringify(detail, null, 2));
3699
+ }
3700
+ else {
3701
+ console.error(`Bridge error: ${err.message ?? String(err)}`);
3702
+ }
3703
+ }
2846
3704
  // ─── flair backup ────────────────────────────────────────────────────────────
2847
3705
  program
2848
3706
  .command("backup")
@@ -3252,110 +4110,6 @@ program
3252
4110
  console.log(`Souls: ${(data.souls ?? []).length}`);
3253
4111
  }
3254
4112
  });
3255
- // ─── flair migrate-keys ───────────────────────────────────────────────────────
3256
- program
3257
- .command("migrate-keys")
3258
- .description("Migrate agent keys from old path (~/.tps/secrets/flair/) to ~/.flair/keys/")
3259
- .option("--from <dir>", "Old keys directory", join(homedir(), ".tps", "secrets", "flair"))
3260
- .option("--to <dir>", "New keys directory", defaultKeysDir())
3261
- .option("--dry-run", "Show what would be migrated without copying")
3262
- .option("--port <port>", "Harper HTTP port")
3263
- .option("--ops-port <port>", "Harper operations API port")
3264
- .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
3265
- .action(async (opts) => {
3266
- const fromDir = opts.from;
3267
- const toDir = opts.to;
3268
- const dryRun = opts.dryRun ?? false;
3269
- const opsPort = resolveOpsPort(opts);
3270
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
3271
- if (!existsSync(fromDir)) {
3272
- console.log(`Old keys directory not found: ${fromDir}`);
3273
- console.log("Nothing to migrate.");
3274
- process.exit(0);
3275
- }
3276
- // Discover legacy keys: <id>-priv.key pattern
3277
- const { readdirSync, unlinkSync } = await import("node:fs");
3278
- const files = readdirSync(fromDir);
3279
- const keyFiles = files.filter((f) => f.endsWith("-priv.key"));
3280
- if (keyFiles.length === 0) {
3281
- console.log(`No legacy key files found in ${fromDir}`);
3282
- process.exit(0);
3283
- }
3284
- console.log(`Found ${keyFiles.length} legacy key file(s) in ${fromDir}:`);
3285
- let migrated = 0;
3286
- let skipped = 0;
3287
- for (const file of keyFiles) {
3288
- const agentId = file.replace("-priv.key", "");
3289
- const srcPath = join(fromDir, file);
3290
- const destPath = join(toDir, `${agentId}.key`);
3291
- if (existsSync(destPath)) {
3292
- console.log(` skip: ${agentId} — already exists at ${destPath}`);
3293
- skipped++;
3294
- continue;
3295
- }
3296
- if (dryRun) {
3297
- console.log(` would migrate: ${srcPath} → ${destPath}`);
3298
- migrated++;
3299
- continue;
3300
- }
3301
- mkdirSync(toDir, { recursive: true });
3302
- const keyData = readFileSync(srcPath);
3303
- writeFileSync(destPath, keyData);
3304
- chmodSync(destPath, 0o600);
3305
- console.log(` migrated: ${agentId} → ${destPath}`);
3306
- migrated++;
3307
- }
3308
- if (dryRun) {
3309
- console.log(`\n🔍 Dry run: ${migrated} key(s) would be migrated, ${skipped} skipped`);
3310
- }
3311
- else {
3312
- console.log(`\n✅ Migration complete: ${migrated} migrated, ${skipped} skipped`);
3313
- if (migrated > 0) {
3314
- console.log(`\nOld keys preserved at ${fromDir} (delete manually when confirmed working).`);
3315
- // Optionally verify keys match Flair records
3316
- if (adminPass) {
3317
- console.log("\nVerifying migrated keys against Flair...");
3318
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
3319
- for (const file of keyFiles) {
3320
- const agentId = file.replace("-priv.key", "");
3321
- const destPath = join(toDir, `${agentId}.key`);
3322
- if (!existsSync(destPath))
3323
- continue;
3324
- try {
3325
- const keyB64 = readFileSync(destPath, "utf-8").trim();
3326
- const seed = Buffer.from(keyB64, "base64");
3327
- const kp = nacl.sign.keyPair.fromSeed(new Uint8Array(seed));
3328
- const localPub = b64url(kp.publicKey);
3329
- const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
3330
- method: "POST",
3331
- headers: { "Content-Type": "application/json", Authorization: auth },
3332
- body: JSON.stringify({ operation: "search_by_value", database: "flair", table: "Agent", search_attribute: "id", search_value: agentId, get_attributes: ["id", "publicKey"] }),
3333
- signal: AbortSignal.timeout(10_000),
3334
- });
3335
- if (res.ok) {
3336
- const agents = await res.json();
3337
- if (Array.isArray(agents) && agents.length > 0) {
3338
- const remotePub = agents[0].publicKey;
3339
- if (remotePub === localPub) {
3340
- console.log(` ✅ ${agentId}: key matches Flair`);
3341
- }
3342
- else {
3343
- console.log(` ⚠️ ${agentId}: key MISMATCH — local key doesn't match Flair record`);
3344
- }
3345
- }
3346
- else {
3347
- console.log(` ⚠️ ${agentId}: not found in Flair`);
3348
- }
3349
- }
3350
- }
3351
- catch (err) {
3352
- console.log(` ⚠️ ${agentId}: verification failed — ${err.message}`);
3353
- }
3354
- }
3355
- }
3356
- }
3357
- }
3358
- });
3359
4113
  // Run CLI only when this is the entry point (not when imported for testing)
3360
4114
  if (import.meta.main) {
3361
4115
  await program.parseAsync();