@tpsdev-ai/flair 0.9.0 → 0.10.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
@@ -2,6 +2,7 @@
2
2
  import { Command } from "commander";
3
3
  import nacl from "tweetnacl";
4
4
  import { load as parseYaml } from "js-yaml";
5
+ import * as render from "./render.js";
5
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
6
7
  import { homedir, hostname, tmpdir } from "node:os";
7
8
  import { join, resolve, sep, dirname } from "node:path";
@@ -83,6 +84,34 @@ const DEFAULT_OPS_PORT = 19925;
83
84
  const DEFAULT_ADMIN_USER = "admin";
84
85
  const STARTUP_TIMEOUT_MS = 60_000;
85
86
  const HEALTH_POLL_INTERVAL_MS = 500;
87
+ /**
88
+ * Read an admin password from a file, refusing if the file is world/group readable.
89
+ *
90
+ * Admin pass files (default ~/.flair/admin-pass) are short-lived secrets generated
91
+ * by `openssl rand` — mode 0600 keeps them out of reach of other local users + most
92
+ * backup tooling. A 0644 file silently leaks the secret to anyone with read access
93
+ * to the user's home (multi-user hosts, NFS, time-machine snapshots, etc.).
94
+ *
95
+ * Throws with an actionable error if the file isn't owner-only, otherwise returns
96
+ * the trimmed file content (admin-pass files generated by `openssl rand -base64`
97
+ * conventionally end in a newline).
98
+ */
99
+ export function readAdminPassFileSecure(path) {
100
+ if (!existsSync(path)) {
101
+ throw new Error(`--admin-pass-file path does not exist: ${path}`);
102
+ }
103
+ const st = statSync(path);
104
+ if (st.mode & 0o077) {
105
+ const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
106
+ throw new Error(`Refusing to read --admin-pass-file at ${path}: permissions ${modeOctal} are too open. ` +
107
+ `Run \`chmod 600 ${path}\` to restrict to owner-only.`);
108
+ }
109
+ const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
110
+ if (!content) {
111
+ throw new Error(`admin password file is empty or contains only whitespace: ${path}`);
112
+ }
113
+ return content;
114
+ }
86
115
  function defaultKeysDir() {
87
116
  return join(homedir(), ".flair", "keys");
88
117
  }
@@ -126,6 +155,25 @@ function resolveHttpPort(opts) {
126
155
  }
127
156
  return readPortFromConfig() ?? DEFAULT_PORT;
128
157
  }
158
+ // Unified base URL resolution. Precedence:
159
+ // --target > --url > FLAIR_TARGET env > FLAIR_URL env > http://127.0.0.1:<resolveHttpPort>
160
+ //
161
+ // Every user-facing command that talks to a Flair instance should call this
162
+ // instead of hand-rolling the precedence. Keeps remote-target switching
163
+ // (e.g. CI hitting Fabric) consistent across `flair status`, `flair search`,
164
+ // `flair bootstrap`, etc.
165
+ function resolveBaseUrl(opts) {
166
+ return (opts.target
167
+ || opts.url
168
+ || process.env.FLAIR_TARGET
169
+ || process.env.FLAIR_URL
170
+ || `http://127.0.0.1:${resolveHttpPort(opts)}`);
171
+ }
172
+ // Resolve agent id from --agent flag or FLAIR_AGENT_ID env.
173
+ // Returns null if neither is set; caller decides whether that's fatal.
174
+ function resolveAgentIdOrEnv(opts) {
175
+ return opts.agent || process.env.FLAIR_AGENT_ID || null;
176
+ }
129
177
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
130
178
  function resolveOpsPort(opts) {
131
179
  if (opts.opsPort !== undefined && opts.opsPort !== null) {
@@ -831,6 +879,16 @@ export function probeOpenclawPluginVersion(extensionName) {
831
879
  return null;
832
880
  }
833
881
  }
882
+ /**
883
+ * Order a soul key→count map for display: highest count first, ties broken
884
+ * alphabetically for stable output. Soul entries are keyed identity facts
885
+ * (role / project / standards / …) — this is the honest breakdown dimension.
886
+ * (Replaced a priority breakdown that was dead telemetry — see flair#453:
887
+ * nothing ever writes Soul.priority to anything but "standard".)
888
+ */
889
+ export function sortSoulKeyEntries(byKey) {
890
+ return Object.entries(byKey ?? {}).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
891
+ }
834
892
  export function templateSoul(choice) {
835
893
  const templates = {
836
894
  "1": [
@@ -1206,14 +1264,11 @@ program
1206
1264
  }
1207
1265
  // Read from file if provided
1208
1266
  if (opts.adminPassFile) {
1209
- if (!existsSync(opts.adminPassFile)) {
1210
- console.error(`Error: --admin-pass-file path does not exist: ${opts.adminPassFile}`);
1211
- process.exit(1);
1267
+ try {
1268
+ adminPass = readAdminPassFileSecure(opts.adminPassFile);
1212
1269
  }
1213
- const fileContent = readFileSync(opts.adminPassFile, "utf-8");
1214
- adminPass = fileContent.trim();
1215
- if (!adminPass) {
1216
- console.error(`Error: admin password file is empty or contains only whitespace: ${opts.adminPassFile}`);
1270
+ catch (err) {
1271
+ console.error(`Error: ${err.message}`);
1217
1272
  process.exit(1);
1218
1273
  }
1219
1274
  passwordSource = "file";
@@ -1956,6 +2011,7 @@ agent
1956
2011
  .description("List all agents")
1957
2012
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
1958
2013
  .option("--port <port>", "Harper HTTP port")
2014
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
1959
2015
  .action(async (opts) => {
1960
2016
  const port = resolveHttpPort(opts);
1961
2017
  // fromEnv is true ONLY when the resolved value came from env (no inline override).
@@ -1965,8 +2021,9 @@ agent
1965
2021
  "to keep secrets out of shell history.");
1966
2022
  }
1967
2023
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
2024
+ const mode = render.resolveOutputMode(opts);
2025
+ let agents;
1968
2026
  if (adminPass) {
1969
- // Use admin basic auth against ops API to list agents directly
1970
2027
  const opsPort = resolveOpsPort(opts);
1971
2028
  const auth = Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64");
1972
2029
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
@@ -1976,39 +2033,79 @@ agent
1976
2033
  });
1977
2034
  if (!res.ok) {
1978
2035
  const text = await res.text().catch(() => "");
1979
- console.error(`Error: ${res.status} ${text}`);
2036
+ console.error(`${render.icons.error} ${res.status} ${text}`);
1980
2037
  process.exit(1);
1981
2038
  }
1982
- const agents = await res.json();
1983
- agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
1984
- console.log(JSON.stringify(agents, null, 2));
2039
+ agents = await res.json();
1985
2040
  }
1986
2041
  else {
1987
- // Localhost operator path: allow IDs-only enumeration without per-agent auth
1988
- // This treats localhost as a trusted boundary for read-only public metadata
1989
2042
  const baseUrl = `http://127.0.0.1:${port}`;
1990
2043
  const res = await fetch(`${baseUrl}/Agent`, {
1991
2044
  headers: { "Content-Type": "application/json" },
1992
2045
  });
1993
2046
  if (!res.ok) {
1994
2047
  const text = await res.text().catch(() => "");
1995
- console.error(`Error: ${res.status} ${text}`);
2048
+ console.error(`${render.icons.error} ${res.status} ${text}`);
1996
2049
  process.exit(1);
1997
2050
  }
1998
2051
  const data = await res.json();
1999
- // Filter to IDs-only to respect the localhost trust boundary
2000
- if (Array.isArray(data)) {
2001
- console.log(JSON.stringify(data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })), null, 2));
2002
- }
2003
- else {
2004
- console.log(JSON.stringify(data, null, 2));
2005
- }
2052
+ agents = Array.isArray(data) ? data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })) : [];
2053
+ }
2054
+ agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
2055
+ if (mode === "json") {
2056
+ console.log(render.asJSON(agents));
2057
+ return;
2006
2058
  }
2059
+ if (agents.length === 0) {
2060
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no agents")}`);
2061
+ return;
2062
+ }
2063
+ console.log(`${render.wrap(render.c.bold, String(agents.length))} agents\n`);
2064
+ const cols = [
2065
+ { label: "id", key: "id", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
2066
+ { label: "name", key: "name", format: (v) => String(v ?? "—") },
2067
+ { label: "created", key: "createdAt", format: (v) => render.wrap(render.c.dim, v ? String(v).slice(0, 10) : "—") },
2068
+ ];
2069
+ console.log(render.table(cols, agents));
2007
2070
  });
2008
2071
  agent
2009
2072
  .command("show <id>")
2010
2073
  .description("Show agent details")
2011
- .action(async (id) => console.log(JSON.stringify(await api("GET", `/Agent/${id}`), null, 2)));
2074
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
2075
+ .action(async (id, opts) => {
2076
+ const out = await api("GET", `/Agent/${id}`);
2077
+ const mode = render.resolveOutputMode(opts);
2078
+ if (mode === "json") {
2079
+ console.log(render.asJSON(out));
2080
+ return;
2081
+ }
2082
+ if (!out || (typeof out === "object" && !out.id)) {
2083
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no agent ${id}`)}`);
2084
+ return;
2085
+ }
2086
+ console.log(render.wrap(render.c.bold, String(out.id)));
2087
+ if (out.name)
2088
+ console.log(render.kv("name", String(out.name)));
2089
+ if (out.kind)
2090
+ console.log(render.kv("kind", render.wrap(render.c.cyan, String(out.kind))));
2091
+ if (out.status) {
2092
+ const statusColor = out.status === "active" ? render.c.green : out.status === "disabled" ? render.c.red : render.c.yellow;
2093
+ console.log(render.kv("status", render.wrap(statusColor, String(out.status))));
2094
+ }
2095
+ if (out.defaultTrustTier)
2096
+ console.log(render.kv("trust tier", String(out.defaultTrustTier)));
2097
+ if (out.admin)
2098
+ console.log(render.kv("admin", render.wrap(render.c.magenta, "yes")));
2099
+ if (out.runtime)
2100
+ console.log(render.kv("runtime", String(out.runtime)));
2101
+ if (out.publicKey)
2102
+ console.log(render.kv("publicKey", render.wrap(render.c.dim, String(out.publicKey))));
2103
+ if (out.createdAt)
2104
+ console.log(render.kv("created", `${render.relativeTime(out.createdAt)} ${render.wrap(render.c.dim, `(${out.createdAt})`)}`));
2105
+ if (out.updatedAt && out.updatedAt !== out.createdAt) {
2106
+ console.log(render.kv("updated", `${render.relativeTime(out.updatedAt)} ${render.wrap(render.c.dim, `(${out.updatedAt})`)}`));
2107
+ }
2108
+ });
2012
2109
  agent
2013
2110
  .command("rotate-key <id>")
2014
2111
  .description("Rotate an agent's Ed25519 keypair")
@@ -2295,11 +2392,12 @@ principal
2295
2392
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
2296
2393
  .option("--port <port>", "Harper HTTP port")
2297
2394
  .option("--ops-port <port>", "Harper operations API port")
2395
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
2298
2396
  .action(async (opts) => {
2299
2397
  const opsPort = resolveOpsPort(opts);
2300
2398
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
2301
2399
  if (!adminPass) {
2302
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
2400
+ console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
2303
2401
  process.exit(1);
2304
2402
  }
2305
2403
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
@@ -2320,34 +2418,90 @@ principal
2320
2418
  });
2321
2419
  if (!res.ok) {
2322
2420
  const text = await res.text().catch(() => "");
2323
- console.error(`Error: ${res.status} ${text}`);
2421
+ console.error(`${render.icons.error} ${res.status} ${text}`);
2324
2422
  process.exit(1);
2325
2423
  }
2326
2424
  const records = await res.json();
2327
2425
  records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
2328
- if (records.length === 0) {
2329
- console.log("No principals found.");
2426
+ const mode = render.resolveOutputMode(opts);
2427
+ if (mode === "json") {
2428
+ console.log(render.asJSON(records));
2330
2429
  return;
2331
2430
  }
2332
- // Table format
2333
- console.log(`${"ID".padEnd(20)} ${"Kind".padEnd(7)} ${"Trust".padEnd(14)} ${"Admin".padEnd(6)} ${"Status".padEnd(12)} ${"Runtime".padEnd(12)} Created`);
2334
- console.log("─".repeat(95));
2335
- for (const r of records) {
2336
- const kind = r.kind ?? "agent";
2337
- const trust = r.defaultTrustTier ?? "—";
2338
- const admin = r.admin ? "yes" : "no";
2339
- const status = r.status ?? "active";
2340
- const runtime = r.runtime ?? "—";
2341
- const created = r.createdAt?.slice(0, 10) ?? "—";
2342
- console.log(`${String(r.id).padEnd(20)} ${kind.padEnd(7)} ${trust.padEnd(14)} ${admin.padEnd(6)} ${status.padEnd(12)} ${runtime.padEnd(12)} ${created}`);
2431
+ if (records.length === 0) {
2432
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no principals")}`);
2433
+ return;
2343
2434
  }
2435
+ console.log(`${render.wrap(render.c.bold, String(records.length))} principals${opts.kind ? ` ${render.wrap(render.c.dim, `(kind=${opts.kind})`)}` : ""}\n`);
2436
+ const cols = [
2437
+ { label: "id", key: "id", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
2438
+ {
2439
+ label: "kind",
2440
+ key: "kind",
2441
+ format: (v) => {
2442
+ const k = String(v ?? "agent");
2443
+ return render.wrap(k === "human" ? render.c.cyan : render.c.magenta, k);
2444
+ },
2445
+ },
2446
+ { label: "trust", key: "defaultTrustTier", format: (v) => String(v ?? "—") },
2447
+ {
2448
+ label: "admin",
2449
+ key: "admin",
2450
+ format: (v) => (v ? render.wrap(render.c.red, "yes") : render.wrap(render.c.dim, "no")),
2451
+ },
2452
+ {
2453
+ label: "status",
2454
+ key: "status",
2455
+ format: (v) => {
2456
+ const s = String(v ?? "active");
2457
+ const color = s === "active" ? render.c.green : s === "disabled" ? render.c.red : render.c.yellow;
2458
+ return render.wrap(color, s);
2459
+ },
2460
+ },
2461
+ { label: "runtime", key: "runtime", format: (v) => String(v ?? "—") },
2462
+ { label: "created", key: "createdAt", format: (v) => render.wrap(render.c.dim, v ? String(v).slice(0, 10) : "—") },
2463
+ ];
2464
+ console.log(render.table(cols, records));
2344
2465
  });
2345
2466
  principal
2346
2467
  .command("show <id>")
2347
2468
  .description("Show principal details")
2348
- .action(async (id) => {
2469
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
2470
+ .action(async (id, opts) => {
2349
2471
  const result = await api("GET", `/Agent/${id}`);
2350
- console.log(JSON.stringify(result, null, 2));
2472
+ const mode = render.resolveOutputMode(opts);
2473
+ if (mode === "json") {
2474
+ console.log(render.asJSON(result));
2475
+ return;
2476
+ }
2477
+ if (!result || (typeof result === "object" && !result.id)) {
2478
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no principal ${id}`)}`);
2479
+ return;
2480
+ }
2481
+ console.log(render.wrap(render.c.bold, String(result.id)));
2482
+ if (result.name)
2483
+ console.log(render.kv("name", String(result.name)));
2484
+ if (result.kind)
2485
+ console.log(render.kv("kind", render.wrap(result.kind === "human" ? render.c.cyan : render.c.magenta, String(result.kind))));
2486
+ if (result.status) {
2487
+ const statusColor = result.status === "active" ? render.c.green : result.status === "disabled" ? render.c.red : render.c.yellow;
2488
+ console.log(render.kv("status", render.wrap(statusColor, String(result.status))));
2489
+ }
2490
+ if (result.defaultTrustTier)
2491
+ console.log(render.kv("trust tier", String(result.defaultTrustTier)));
2492
+ if (result.admin)
2493
+ console.log(render.kv("admin", render.wrap(render.c.red, "yes")));
2494
+ if (result.runtime)
2495
+ console.log(render.kv("runtime", String(result.runtime)));
2496
+ if (result.email)
2497
+ console.log(render.kv("email", String(result.email)));
2498
+ if (result.publicKey)
2499
+ console.log(render.kv("publicKey", render.wrap(render.c.dim, String(result.publicKey))));
2500
+ if (result.createdAt)
2501
+ console.log(render.kv("created", `${render.relativeTime(result.createdAt)} ${render.wrap(render.c.dim, `(${result.createdAt})`)}`));
2502
+ if (result.updatedAt && result.updatedAt !== result.createdAt) {
2503
+ console.log(render.kv("updated", `${render.relativeTime(result.updatedAt)} ${render.wrap(render.c.dim, `(${result.updatedAt})`)}`));
2504
+ }
2351
2505
  });
2352
2506
  principal
2353
2507
  .command("disable <id>")
@@ -2475,11 +2629,12 @@ idp
2475
2629
  .description("List configured IdPs")
2476
2630
  .option("--admin-pass <pass>", "Admin password")
2477
2631
  .option("--ops-port <port>", "Harper operations API port")
2632
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
2478
2633
  .action(async (opts) => {
2479
2634
  const opsPort = resolveOpsPort(opts);
2480
2635
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
2481
2636
  if (!adminPass) {
2482
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
2637
+ console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
2483
2638
  process.exit(1);
2484
2639
  }
2485
2640
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
@@ -2498,22 +2653,28 @@ idp
2498
2653
  });
2499
2654
  if (!res.ok) {
2500
2655
  const text = await res.text().catch(() => "");
2501
- console.error(`Error: ${res.status} ${text}`);
2656
+ console.error(`${render.icons.error} ${res.status} ${text}`);
2502
2657
  process.exit(1);
2503
2658
  }
2504
2659
  const records = await res.json();
2505
2660
  records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
2661
+ const mode = render.resolveOutputMode(opts);
2662
+ if (mode === "json") {
2663
+ console.log(render.asJSON(records));
2664
+ return;
2665
+ }
2506
2666
  if (records.length === 0) {
2507
- console.log("No IdPs configured.");
2667
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no IdPs configured")}`);
2508
2668
  return;
2509
2669
  }
2670
+ console.log(`${render.wrap(render.c.bold, String(records.length))} IdP${records.length === 1 ? "" : "s"}\n`);
2510
2671
  for (const r of records) {
2511
- const status = r.enabled ? "enabled" : "disabled";
2512
- console.log(`${r.name} (${r.id}) — ${status}`);
2513
- console.log(` Issuer: ${r.issuer}`);
2672
+ const enabled = r.enabled ? render.wrap(render.c.green, "enabled") : render.wrap(render.c.dim, "disabled");
2673
+ console.log(`${render.wrap(render.c.bold, r.name ?? "?")} ${render.wrap(render.c.dim, `(${r.id})`)} ${render.wrap(render.c.dim, "")} ${enabled}`);
2674
+ console.log(render.kv("issuer", String(r.issuer ?? "—")));
2514
2675
  if (r.requiredDomain)
2515
- console.log(` Domain: ${r.requiredDomain}`);
2516
- console.log(` JIT: ${r.jitProvision ?? true}`);
2676
+ console.log(render.kv("domain", String(r.requiredDomain)));
2677
+ console.log(render.kv("JIT", String(r.jitProvision ?? true)));
2517
2678
  console.log();
2518
2679
  }
2519
2680
  });
@@ -2727,65 +2888,117 @@ federation
2727
2888
  .option("--port <port>", "Harper HTTP port")
2728
2889
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2729
2890
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
2891
+ .option("--json", "Emit JSON {instance, peers} (also: pipe + FLAIR_OUTPUT=json)")
2730
2892
  .action(async (opts) => {
2731
2893
  const target = resolveTarget(opts);
2732
2894
  const baseUrl = target ? target.replace(/\/$/, "") : undefined;
2895
+ const mode = render.resolveOutputMode(opts);
2733
2896
  try {
2734
2897
  const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
2735
- console.log(`Instance: ${instance.id} (${instance.role})`);
2736
- console.log(`Public key: ${instance.publicKey}`);
2737
- console.log(`Status: ${instance.status}`);
2738
- console.log();
2739
2898
  const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
2899
+ if (mode === "json") {
2900
+ console.log(render.asJSON({ instance, peers }));
2901
+ return;
2902
+ }
2903
+ const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
2904
+ console.log(render.wrap(render.c.bold, "Federation"));
2905
+ console.log(render.kv("Instance", `${instance.id} ${render.wrap(render.c.dim, `(${instance.role})`)}`));
2906
+ console.log(render.kv("Public key", render.wrap(render.c.dim, instance.publicKey)));
2907
+ console.log(render.kv("Status", render.wrap(statusColor, instance.status)));
2740
2908
  if (peers.length === 0) {
2741
- console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
2909
+ console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
2910
+ return;
2742
2911
  }
2743
- else {
2744
- // Compute lastSync staleness so an operator can tell at a glance whether sync is current.
2745
- const now = Date.now();
2746
- const stale = (iso) => {
2747
- if (!iso)
2748
- return "never";
2749
- const t = Date.parse(iso);
2750
- if (!Number.isFinite(t))
2751
- return "never";
2752
- const ageMs = now - t;
2753
- if (ageMs < 60_000)
2754
- return "<1m ago";
2755
- if (ageMs < 3_600_000)
2756
- return `${Math.floor(ageMs / 60_000)}m ago`;
2757
- if (ageMs < 86_400_000)
2758
- return `${Math.floor(ageMs / 3_600_000)}h ago`;
2759
- return `${Math.floor(ageMs / 86_400_000)}d ago`;
2760
- };
2761
- console.log(`${"Peer".padEnd(20)} ${"Role".padEnd(8)} ${"Status".padEnd(14)} ${"Last Sync".padEnd(14)} Relay`);
2762
- console.log("─".repeat(80));
2763
- for (const p of peers) {
2764
- console.log(`${p.id.padEnd(20)} ${(p.role ?? "—").padEnd(8)} ${(p.status ?? "—").padEnd(14)} ${stale(p.lastSyncAt).padEnd(14)} ${p.relayOnly ? "yes" : "no"}`);
2765
- }
2766
- const haveStale = peers.some((p) => {
2767
- if (!p.lastSyncAt)
2768
- return true;
2769
- return now - Date.parse(p.lastSyncAt) > 86_400_000;
2770
- });
2771
- if (haveStale) {
2772
- console.log();
2773
- console.log("⚠ One or more peers haven't synced in >24h. Run 'flair federation sync' or check the launchd watchdog.");
2774
- }
2912
+ const now = Date.now();
2913
+ const formatPeerAge = (iso, refNow, staleAfterMs) => {
2914
+ if (!iso)
2915
+ return render.wrap(render.c.red, "never");
2916
+ const t = Date.parse(iso);
2917
+ if (!Number.isFinite(t))
2918
+ return render.wrap(render.c.red, "never");
2919
+ const ageMs = refNow - t;
2920
+ const ageStr = ageMs < 60_000 ? "<1m ago"
2921
+ : ageMs < 3_600_000 ? `${Math.floor(ageMs / 60_000)}m ago`
2922
+ : ageMs < 86_400_000 ? `${Math.floor(ageMs / 3_600_000)}h ago`
2923
+ : `${Math.floor(ageMs / 86_400_000)}d ago`;
2924
+ const stale = ageMs > staleAfterMs;
2925
+ return render.wrap(stale ? render.c.yellow : render.c.dim, ageStr);
2926
+ };
2927
+ console.log();
2928
+ const cols = [
2929
+ { label: "peer", key: "id" },
2930
+ { label: "role", key: "role", format: (v) => String(v ?? "—") },
2931
+ {
2932
+ label: "status",
2933
+ key: "status",
2934
+ format: (v) => {
2935
+ const s = String(v ?? "—");
2936
+ const color = s === "paired" || s === "connected" || s === "active" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
2937
+ return render.wrap(color, s);
2938
+ },
2939
+ },
2940
+ {
2941
+ // Liveness: "did we hear from this peer recently?" Updates on every
2942
+ // contact, even when 100% of records were skipped. See flair#444.
2943
+ label: "last_sync",
2944
+ key: "lastSyncAt",
2945
+ format: (v) => formatPeerAge(v, now, 86_400_000),
2946
+ },
2947
+ {
2948
+ // Progress: "did data actually flow in?" Updates only when merged>0.
2949
+ // Diverging from last_sync means contact-yes but data-no — investigate.
2950
+ label: "last_merge",
2951
+ key: "lastMergeAt",
2952
+ format: (v) => formatPeerAge(v, now, 86_400_000),
2953
+ },
2954
+ {
2955
+ label: "relay",
2956
+ key: "relayOnly",
2957
+ format: (v) => (v ? render.wrap(render.c.cyan, "yes") : render.wrap(render.c.dim, "no")),
2958
+ },
2959
+ ];
2960
+ console.log(render.table(cols, peers));
2961
+ // Stale warning is gated on lastMergeAt (real progress), not lastSyncAt.
2962
+ // A peer that "syncs" every 5min but hasn't merged a record in 24h is
2963
+ // exactly the failure mode we want surfaced.
2964
+ const haveStale = peers.some((p) => {
2965
+ const cursor = p.lastMergeAt ?? p.lastSyncAt;
2966
+ if (!cursor)
2967
+ return true;
2968
+ const t = Date.parse(cursor);
2969
+ return !Number.isFinite(t) || (now - t) > 86_400_000;
2970
+ });
2971
+ if (haveStale) {
2972
+ console.log();
2973
+ console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, "Check skippedReasons in SyncLog or run 'flair federation sync'.")}`);
2974
+ }
2975
+ const haveContactButNoMerge = peers.some((p) => {
2976
+ if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
2977
+ return false;
2978
+ if ((now - Date.parse(p.lastSyncAt)) > 3_600_000)
2979
+ return false; // only recent contact
2980
+ // Contact within the last hour, but no merge ever (or stale by >1h)
2981
+ if (!p.lastMergeAt)
2982
+ return true;
2983
+ const tm = Date.parse(p.lastMergeAt);
2984
+ return !Number.isFinite(tm) || (now - tm) > 3_600_000;
2985
+ });
2986
+ if (haveContactButNoMerge && !haveStale) {
2987
+ console.log();
2988
+ console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Peer contact is fresh but no records merged in the last hour.")} ${render.wrap(render.c.dim, "Possible silent-skip scenario — check SyncLog.skippedReasons.")}`);
2775
2989
  }
2776
2990
  }
2777
2991
  catch (err) {
2778
- // Better UX on the common auth failure: tell the user what to set.
2779
2992
  const msg = String(err.message ?? err);
2780
2993
  if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
2781
- console.error("Error: federation status requires auth.");
2782
- console.error(" Set one of:");
2783
- console.error(" FLAIR_AGENT_ID=<your-agent-id> (Ed25519 — uses ~/.flair/keys/<id>.key)");
2784
- console.error(" FLAIR_ADMIN_PASS=<admin-password> (admin Basic auth, remote targets)");
2785
- console.error(" FLAIR_TOKEN=<bearer> (legacy)");
2994
+ console.error(`${render.icons.error} federation status requires auth.`);
2995
+ console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
2996
+ console.error(` ${render.wrap(render.c.cyan, "FLAIR_AGENT_ID=<your-agent-id>")} ${render.wrap(render.c.dim, "(Ed25519 — uses ~/.flair/keys/<id>.key)")}`);
2997
+ console.error(` ${render.wrap(render.c.cyan, "FLAIR_ADMIN_PASS=<admin-password>")} ${render.wrap(render.c.dim, "(admin Basic auth, remote targets)")}`);
2998
+ console.error(` ${render.wrap(render.c.cyan, "FLAIR_TOKEN=<bearer>")} ${render.wrap(render.c.dim, "(legacy)")}`);
2786
2999
  process.exit(1);
2787
3000
  }
2788
- console.error(`Error: ${msg}`);
3001
+ console.error(`${render.icons.error} ${msg}`);
2789
3002
  process.exit(1);
2790
3003
  }
2791
3004
  });
@@ -3002,28 +3215,45 @@ federation
3002
3215
  }
3003
3216
  const result = await res.json();
3004
3217
  console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
3005
- // Record the hub as our peer locally or remotely depending on --target
3006
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
3007
- if (adminPass) {
3008
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
3009
- const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
3010
- await fetch(`${opsEndpoint}/`, {
3011
- method: "POST",
3012
- headers: { "Content-Type": "application/json", Authorization: auth },
3013
- body: JSON.stringify({
3014
- operation: "upsert", database: "flair", table: "Peer",
3015
- records: [{
3016
- id: result.instance?.id ?? "hub",
3017
- publicKey: result.instance?.publicKey ?? "",
3018
- role: "hub", endpoint: hubUrl, status: "paired",
3019
- pairedAt: new Date().toISOString(),
3020
- createdAt: new Date().toISOString(),
3021
- updatedAt: new Date().toISOString(),
3022
- }],
3023
- }),
3024
- signal: AbortSignal.timeout(10_000),
3025
- });
3218
+ // Record the hub as our local peer. This is REQUIRED, not optional:
3219
+ // `flair federation sync` reads the Peer table to find the hub, so
3220
+ // without this record sync reports "No hub peer configured" and silently
3221
+ // never runs. Previously this was gated on `if (adminPass)` and the write
3222
+ // result was never checked — pairing with only an agent key (or a failed
3223
+ // upsert) left no peer behind a misleadingly green "✅ Paired".
3224
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
3225
+ if (!adminPass) {
3226
+ console.error("Error: paired on the hub, but the local hub-peer record needs admin auth to write — " +
3227
+ "pass --admin-pass, or set FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD, then re-run pair. " +
3228
+ "Without it, 'flair federation sync' will report 'No hub peer configured'.");
3229
+ process.exit(1);
3230
+ }
3231
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
3232
+ const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
3233
+ const peerRes = await fetch(`${opsEndpoint}/`, {
3234
+ method: "POST",
3235
+ headers: { "Content-Type": "application/json", Authorization: auth },
3236
+ body: JSON.stringify({
3237
+ operation: "upsert", database: "flair", table: "Peer",
3238
+ records: [{
3239
+ id: result.instance?.id ?? "hub",
3240
+ publicKey: result.instance?.publicKey ?? "",
3241
+ role: "hub", endpoint: hubUrl, status: "paired",
3242
+ pairedAt: new Date().toISOString(),
3243
+ createdAt: new Date().toISOString(),
3244
+ updatedAt: new Date().toISOString(),
3245
+ }],
3246
+ }),
3247
+ signal: AbortSignal.timeout(10_000),
3248
+ });
3249
+ if (!peerRes.ok) {
3250
+ const text = await peerRes.text().catch(() => "");
3251
+ console.error(`Error: paired with the hub but failed to write the local hub-peer record ` +
3252
+ `(${peerRes.status} ${text.slice(0, 200)}). Ops endpoint: ${opsEndpoint}. ` +
3253
+ `'flair federation sync' will not find the hub until this succeeds — check --admin-pass and the ops port.`);
3254
+ process.exit(1);
3026
3255
  }
3256
+ console.log(`✅ Recorded hub as local peer: ${result.instance?.id ?? "hub"} → ${hubUrl}`);
3027
3257
  }
3028
3258
  catch (err) {
3029
3259
  console.error(`Error: ${err.message}`);
@@ -3146,6 +3376,12 @@ export async function runFederationSyncOnce(opts) {
3146
3376
  }
3147
3377
  console.log(`Syncing to hub: ${hub.id}...`);
3148
3378
  const since = hub.lastSyncAt ?? new Date(0).toISOString();
3379
+ // Capture sync start time BEFORE we query records. We advance the local
3380
+ // hub peer's lastSyncAt to this value after success so the next poll's
3381
+ // `since` cursor moves forward — fixes task #146 (federation peer
3382
+ // .lastSyncAt update bug). Records updated DURING this sync will have
3383
+ // updatedAt > syncStartedAt and be picked up next cycle, not missed.
3384
+ const syncStartedAt = new Date().toISOString();
3149
3385
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
3150
3386
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
3151
3387
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
@@ -3154,25 +3390,63 @@ export async function runFederationSyncOnce(opts) {
3154
3390
  const hubUrl = hub.endpoint ?? hub.id;
3155
3391
  // ── Batching constants ──────────────────────────────────────────────
3156
3392
  // 2MB JSON budget (server cap is 10MB; 2MB leaves headroom for headers
3157
- // and signature metadata) + 200 records max per batch.
3393
+ // and signature metadata) + 50 records max per batch. The hub merge itself
3394
+ // is fast (~1.7s/50 records, per its SyncLog), but the Fabric ingress was
3395
+ // observed to intermittently stall on larger POSTs — a 50-record batch hung
3396
+ // ~2 min while the same records split into 2×25 went through immediately.
3397
+ // 50 keeps batches in the reliable range, and sendBatch's adaptive split
3398
+ // recovers if a stretch still stalls.
3158
3399
  const BUDGET_BYTES = 2_000_000;
3159
- const BUDGET_RECORDS = 200;
3400
+ const BUDGET_RECORDS = 50;
3160
3401
  // ── sendBatch helper ────────────────────────────────────────────────
3161
3402
  // Secret key is lazy-loaded: only needed when there are records to send.
3162
3403
  // Loading earlier would cause a spurious error when SQL queries fail
3163
3404
  // (e.g. 401) before we know we have records.
3164
3405
  let secretKey;
3406
+ // Statuses the Fabric ingress returns when a batch POST didn't complete in
3407
+ // time (408) or was too large (413), plus the transient gateway 5xx family.
3408
+ // Splitting the batch and retrying smaller chunks lets the sync converge
3409
+ // instead of aborting the whole run.
3410
+ const TIMEOUT_STATUSES = new Set([408, 413, 502, 503, 504]);
3411
+ // Per-batch wall-clock cap. Without it a stalled connection to the Fabric
3412
+ // ingress hangs the whole sync until the *gateway's* timeout fires (~2 min
3413
+ // observed), which is what stranded the re-pair. A 45s cap is generous —
3414
+ // a healthy 50-record batch merges in <2s — so a trip means a real stall,
3415
+ // and we split-and-retry rather than wait it out.
3416
+ const BATCH_TIMEOUT_MS = 45_000;
3165
3417
  async function sendBatch(batch) {
3166
3418
  if (!secretKey)
3167
3419
  secretKey = await loadInstanceSecretKey(instance.id, opts);
3168
3420
  const syncBody = { instanceId: instance.id, records: batch, lamportClock: Date.now() };
3169
3421
  const signedSyncBody = signBodyFresh(syncBody, secretKey);
3170
- const syncRes = await fetch(`${hubUrl}/FederationSync`, {
3171
- method: "POST",
3172
- headers: { "Content-Type": "application/json" },
3173
- body: JSON.stringify(signedSyncBody),
3174
- });
3422
+ // Halve and retry down to a single record. Covers both an explicit
3423
+ // timeout status AND a client-side abort (stalled socket). The hub
3424
+ // merges idempotently (put-by-id), so retried records are safe.
3425
+ const splittable = (status) => batch.length > 1 && (status === null || TIMEOUT_STATUSES.has(status));
3426
+ const split = async () => {
3427
+ const mid = Math.floor(batch.length / 2);
3428
+ const left = await sendBatch(batch.slice(0, mid));
3429
+ const right = await sendBatch(batch.slice(mid));
3430
+ return { merged: left.merged + right.merged, skipped: left.skipped + right.skipped };
3431
+ };
3432
+ let syncRes;
3433
+ try {
3434
+ syncRes = await fetch(`${hubUrl}/FederationSync`, {
3435
+ method: "POST",
3436
+ headers: { "Content-Type": "application/json" },
3437
+ body: JSON.stringify(signedSyncBody),
3438
+ signal: AbortSignal.timeout(BATCH_TIMEOUT_MS),
3439
+ });
3440
+ }
3441
+ catch (err) {
3442
+ // Timeout/abort or network drop — no status. Split if we can.
3443
+ if (splittable(null))
3444
+ return await split();
3445
+ throw new Error(`Sync batch (${batch.length} record${batch.length === 1 ? "" : "s"}) failed: ${err?.message ?? err}`);
3446
+ }
3175
3447
  if (!syncRes.ok) {
3448
+ if (splittable(syncRes.status))
3449
+ return await split();
3176
3450
  const text = await syncRes.text().catch(() => "");
3177
3451
  throw new Error(`Sync batch failed: ${syncRes.status} ${text}`);
3178
3452
  }
@@ -3224,6 +3498,35 @@ export async function runFederationSyncOnce(opts) {
3224
3498
  totalBatches++;
3225
3499
  }
3226
3500
  }
3501
+ // Advance the local hub peer's lastSyncAt cursor. The hub-side
3502
+ // FederationSync handler updates ITS view of the spoke peer, but the
3503
+ // spoke never updated its own view of the hub — so `since` stayed at
3504
+ // whatever value was on the peer record at pair time (often near-epoch),
3505
+ // and every poll re-queried `updatedAt > since` and re-sent every
3506
+ // memory ever written. The receiver-side contentHash gate in
3507
+ // Federation.ts prevents the actual blob re-write, but advancing the
3508
+ // cursor here stops the redundant network traffic + Lambda compute
3509
+ // entirely. Task #146. Even no-change runs should advance.
3510
+ try {
3511
+ const advanceRes = await fetch(`${opsEndpoint}/`, {
3512
+ method: "POST",
3513
+ headers: { "Content-Type": "application/json", Authorization: auth },
3514
+ body: JSON.stringify({
3515
+ operation: "update",
3516
+ database: "flair",
3517
+ table: "Peer",
3518
+ records: [{ id: hub.id, lastSyncAt: syncStartedAt }],
3519
+ }),
3520
+ signal: AbortSignal.timeout(10_000),
3521
+ });
3522
+ if (!advanceRes.ok) {
3523
+ const txt = await advanceRes.text().catch(() => "");
3524
+ console.warn(`⚠️ Local hub.lastSyncAt advance failed (${advanceRes.status}): ${txt.slice(0, 200)}. Next poll will re-send memories.`);
3525
+ }
3526
+ }
3527
+ catch (advErr) {
3528
+ console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
3529
+ }
3227
3530
  if (totalBatches === 0) {
3228
3531
  console.log("No changes since last sync.");
3229
3532
  return { pushed: 0, skipped: 0 };
@@ -3784,7 +4087,7 @@ rem
3784
4087
  process.exit(1);
3785
4088
  }
3786
4089
  if (!agentId) {
3787
- console.error("Error: --agent is required (or set FLAIR_AGENT_ID)");
4090
+ console.error(`${render.icons.error} --agent is required (or set FLAIR_AGENT_ID)`);
3788
4091
  process.exit(1);
3789
4092
  }
3790
4093
  try {
@@ -3796,42 +4099,49 @@ rem
3796
4099
  ],
3797
4100
  get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
3798
4101
  });
3799
- // search_by_conditions returns either an array or { error }; tolerate both shapes
3800
4102
  const candidates = Array.isArray(result) ? result : (result?.results ?? []);
3801
- if (opts.json) {
3802
- console.log(JSON.stringify({ agentId, status, count: candidates.length, candidates }, null, 2));
4103
+ const mode = render.resolveOutputMode(opts);
4104
+ if (mode === "json") {
4105
+ console.log(render.asJSON({ agentId, status, count: candidates.length, candidates }));
3803
4106
  return;
3804
4107
  }
3805
- console.log(`\n-- rem candidates (agent=${agentId}, status=${status}) --\n`);
4108
+ const statusColor = status === "promoted" ? render.c.green : status === "rejected" ? render.c.red : render.c.yellow;
4109
+ console.log(`${render.wrap(render.c.bold, "REM candidates")} ${render.wrap(render.c.dim, "—")} agent ${render.wrap(render.c.bold, agentId)} ${render.wrap(render.c.dim, "·")} ${render.wrap(statusColor, status)}`);
3806
4110
  if (candidates.length === 0) {
3807
- console.log(`No ${status} candidates.`);
4111
+ console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, `No ${status} candidates.`)}`);
3808
4112
  if (status === "pending") {
3809
- console.log("\n(Run `flair rem nightly enable` to start the nightly distillation cycle that populates this table.)");
4113
+ console.log(`${render.wrap(render.c.dim, " Run")} flair rem nightly enable ${render.wrap(render.c.dim, "to start the nightly distillation cycle that populates this table.")}`);
3810
4114
  }
3811
4115
  return;
3812
4116
  }
3813
- // Sort newest-first by generatedAt
3814
4117
  candidates.sort((a, b) => String(b.generatedAt ?? "").localeCompare(String(a.generatedAt ?? "")));
4118
+ console.log();
3815
4119
  for (const c of candidates) {
3816
- const tag = c.status === "promoted"
3817
- ? `[promoted → ${c.target ?? "?"} by ${c.reviewerId ?? "?"} @ ${relativeTime(c.decidedAt)}]`
3818
- : c.status === "rejected"
3819
- ? `[rejected by ${c.reviewerId ?? "?"} @ ${relativeTime(c.decidedAt)}]`
3820
- : `[pending — ${c.generatedBy ?? "?"} @ ${relativeTime(c.generatedAt)}]`;
3821
- console.log(` ${c.id} ${tag}`);
4120
+ let tag;
4121
+ if (c.status === "promoted") {
4122
+ tag = `${render.wrap(render.c.green, "✓ promoted")} ${render.wrap(render.c.dim, "→")} ${render.wrap(render.c.bold, c.target ?? "?")} ${render.wrap(render.c.dim, `by ${c.reviewerId ?? "?"} ${render.relativeTime(c.decidedAt)}`)}`;
4123
+ }
4124
+ else if (c.status === "rejected") {
4125
+ tag = `${render.wrap(render.c.red, "✗ rejected")} ${render.wrap(render.c.dim, `by ${c.reviewerId ?? "?"} ${render.relativeTime(c.decidedAt)}`)}`;
4126
+ }
4127
+ else {
4128
+ tag = `${render.wrap(render.c.yellow, "○ pending")} ${render.wrap(render.c.dim, `— ${c.generatedBy ?? "?"} ${render.relativeTime(c.generatedAt)}`)}`;
4129
+ }
4130
+ console.log(` ${render.wrap(render.c.dim, c.id)} ${tag}`);
3822
4131
  console.log(` ${c.claim}`);
3823
- if (c.supersedes)
3824
- console.log(` (supersedes ${c.supersedes} — recurring proposal)`);
4132
+ if (c.supersedes) {
4133
+ console.log(` ${render.wrap(render.c.dim, `(supersedes ${c.supersedes} — recurring proposal)`)}`);
4134
+ }
3825
4135
  console.log("");
3826
4136
  }
3827
- console.log(`${candidates.length} candidate${candidates.length > 1 ? "s" : ""}.`);
4137
+ console.log(`${render.wrap(render.c.bold, String(candidates.length))} candidate${candidates.length > 1 ? "s" : ""}.`);
3828
4138
  if (status === "pending") {
3829
- console.log(`Promote: flair rem promote <id> --rationale "<why>" --to (soul|memory)`);
3830
- console.log(`Reject: flair rem reject <id> --reason "<why>"`);
4139
+ console.log(`${render.wrap(render.c.dim, "Promote:")} flair rem promote <id> --rationale "<why>" --to (soul|memory)`);
4140
+ console.log(`${render.wrap(render.c.dim, "Reject: ")} flair rem reject <id> --reason "<why>"`);
3831
4141
  }
3832
4142
  }
3833
4143
  catch (err) {
3834
- console.error(`Error: ${err.message}`);
4144
+ console.error(`${render.icons.error} ${err.message}`);
3835
4145
  process.exit(1);
3836
4146
  }
3837
4147
  });
@@ -4438,7 +4748,7 @@ function oauthDetailLines(o) {
4438
4748
  // discoverLocalFlairPort when the configured URL is unreachable, to detect
4439
4749
  // config-vs-daemon port drift (ops-mbdi). Order is ad-hoc — first hit wins.
4440
4750
  //
4441
- // 9926: original default (long-running rockit installs predate the bump)
4751
+ // 9926: original default (long-running early installs predate the bump)
4442
4752
  // 19926: current default (DEFAULT_PORT)
4443
4753
  // 19925: ops-anvil VM secondary
4444
4754
  const LOCAL_FLAIR_PROBE_PORTS = [9926, 19926, 19925];
@@ -4626,139 +4936,176 @@ const statusCmd = program
4626
4936
  })
4627
4937
  : warnings;
4628
4938
  const hasWarn = scopedWarnings.some((w) => w.level === "warn");
4629
- const headerIcon = hasWarn ? "🟡" : "🟢";
4630
- console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
4631
- console.log(` URL: ${baseUrl}`);
4939
+ const headerIcon = hasWarn ? render.icons.warn : render.icons.ok;
4940
+ const versionStr = render.wrap(render.c.bold, `Flair v${__pkgVersion}`);
4941
+ const runStatus = `${headerIcon} ${render.wrap(render.c.green, "running")}`;
4942
+ const pidPart = pid ? render.wrap(render.c.dim, `PID ${pid}`) : "";
4943
+ const uptimePart = uptimeStr ? render.wrap(render.c.dim, `uptime ${uptimeStr}`) : "";
4944
+ const metaParts = [pidPart, uptimePart].filter(Boolean).join(render.wrap(render.c.dim, " · "));
4945
+ console.log(`${versionStr} ${render.wrap(render.c.dim, "—")} ${runStatus}${metaParts ? ` ${metaParts}` : ""}`);
4946
+ console.log(render.kv("URL", baseUrl));
4632
4947
  if (scopedWarnings.length > 0) {
4633
- console.log(`\n Warnings: ${scopedWarnings.length}`);
4634
- for (const w of scopedWarnings)
4635
- console.log(` • ${w.level} ${w.message}`);
4948
+ console.log(`\n${render.wrap(render.c.bold, "Warnings")} ${render.wrap(render.c.dim, `(${scopedWarnings.length})`)}`);
4949
+ for (const w of scopedWarnings) {
4950
+ const icon = w.level === "warn" ? render.icons.warn : render.icons.info;
4951
+ console.log(` ${icon} ${w.message}`);
4952
+ }
4636
4953
  }
4637
4954
  if (memories) {
4638
- console.log("\nMemory:");
4955
+ console.log(`\n${render.wrap(render.c.bold, "Memory")}`);
4639
4956
  const embStr = memories.withEmbeddings > 0 ? `${memories.withEmbeddings} embedded` : "";
4640
4957
  const hashStr = memories.hashFallback > 0 ? `${memories.hashFallback} hash` : "";
4641
4958
  const detail = [embStr, hashStr].filter(Boolean).join(", ");
4642
- console.log(` Total: ${memories.total}${detail ? ` (${detail})` : ""}`);
4959
+ console.log(render.kv("Total", `${render.wrap(render.c.bold, String(memories.total))}${detail ? ` ${render.wrap(render.c.dim, `(${detail})`)}` : ""}`));
4643
4960
  if (memories.modelCounts && typeof memories.modelCounts === "object") {
4644
4961
  const entries = Object.entries(memories.modelCounts)
4645
4962
  .filter(([, n]) => n > 0)
4646
4963
  .sort((a, b) => b[1] - a[1]);
4647
4964
  if (entries.length > 0) {
4648
- const formatted = entries.map(([k, n]) => `${k}: ${n}`).join(", ");
4649
- console.log(` Embeddings: ${formatted}`);
4965
+ const formatted = entries.map(([k, n]) => `${render.wrap(render.c.cyan, k)}:${n}`).join(render.wrap(render.c.dim, ", "));
4966
+ console.log(render.kv("Embeddings", formatted));
4650
4967
  }
4651
4968
  }
4652
4969
  if (memories.byDurability) {
4653
4970
  const d = memories.byDurability;
4654
- console.log(` Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
4971
+ const parts = [
4972
+ `${render.wrap(render.c.magenta, "permanent")}:${d.permanent ?? 0}`,
4973
+ `${render.wrap(render.c.blue, "persistent")}:${d.persistent ?? 0}`,
4974
+ `${render.wrap(render.c.cyan, "standard")}:${d.standard ?? 0}`,
4975
+ `${render.wrap(render.c.gray, "ephemeral")}:${d.ephemeral ?? 0}`,
4976
+ ];
4977
+ console.log(render.kv("Durability", parts.join(render.wrap(render.c.dim, " · "))));
4655
4978
  }
4656
4979
  if (typeof memories.archived === "number")
4657
- console.log(` Archived: ${memories.archived}`);
4658
- if (typeof memories.expired === "number" && memories.expired > 0)
4659
- console.log(` Expired: ${memories.expired}`);
4980
+ console.log(render.kv("Archived", String(memories.archived)));
4981
+ if (typeof memories.expired === "number" && memories.expired > 0) {
4982
+ console.log(render.kv("Expired", `${render.wrap(render.c.yellow, String(memories.expired))}`));
4983
+ }
4660
4984
  if (healthData?.lastWrite)
4661
- console.log(` Last write: ${relativeTime(healthData.lastWrite)}`);
4985
+ console.log(render.kv("Last write", render.relativeTime(healthData.lastWrite)));
4662
4986
  }
4663
4987
  if (agents && agents.count > 0) {
4664
- console.log("\nAgents:");
4665
- const nameStr = agents.names?.length > 0 ? ` — ${agents.names.join(", ")}` : "";
4666
- console.log(` ${agents.count} total${nameStr}`);
4988
+ console.log(`\n${render.wrap(render.c.bold, "Agents")}`);
4989
+ const nameStr = agents.names?.length > 0 ? ` ${render.wrap(render.c.dim, "")} ${agents.names.join(render.wrap(render.c.dim, ", "))}` : "";
4990
+ console.log(render.kv("Total", `${render.wrap(render.c.bold, String(agents.count))}${nameStr}`));
4667
4991
  if (agents.count > 1 && Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
4668
- const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
4669
- // Older HealthDetail responses only carry id / memoryCount / lastWriteAt.
4670
- // Only print the richer columns if at least one row supplies them.
4671
4992
  const hasDeep = agents.perAgent.some((r) => typeof r.hashFallback === "number" || typeof r.writes24h === "number");
4672
- if (hasDeep) {
4673
- console.log(` ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
4674
- for (const r of agents.perAgent) {
4675
- const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "";
4676
- const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
4677
- console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
4678
- }
4679
- }
4680
- else {
4681
- console.log(` ${"id".padEnd(idW)} memories last_write`);
4682
- for (const r of agents.perAgent) {
4683
- console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${relativeTime(r.lastWriteAt)}`);
4684
- }
4685
- }
4993
+ const cols = hasDeep
4994
+ ? [
4995
+ { label: "id", key: "id" },
4996
+ { label: "memories", key: "memoryCount", align: "right" },
4997
+ { label: "hash_fb", key: "hashFallback", align: "right", format: (v) => (typeof v === "number" ? String(v) : "—") },
4998
+ { label: "24h", key: "writes24h", align: "right", format: (v) => (typeof v === "number" ? String(v) : "—") },
4999
+ { label: "last_write", key: "lastWriteAt", format: (v) => render.relativeTime(v) },
5000
+ ]
5001
+ : [
5002
+ { label: "id", key: "id" },
5003
+ { label: "memories", key: "memoryCount", align: "right" },
5004
+ { label: "last_write", key: "lastWriteAt", format: (v) => render.relativeTime(v) },
5005
+ ];
5006
+ console.log(render.table(cols, agents.perAgent));
4686
5007
  }
4687
5008
  }
4688
5009
  if (healthData?.relationships) {
4689
5010
  const r = healthData.relationships;
4690
- console.log("\nRelationships:");
4691
- console.log(` ${r.total} total (${r.active} active)`);
5011
+ console.log(`\n${render.wrap(render.c.bold, "Relationships")}`);
5012
+ console.log(render.kv("Total", `${r.total} ${render.wrap(render.c.dim, `(${r.active} active)`)}`));
4692
5013
  }
4693
5014
  if (healthData?.soul && healthData.soul.total > 0) {
4694
5015
  const s = healthData.soul;
4695
- const bp = s.byPriority ?? {};
4696
- console.log("\nSoul:");
4697
- console.log(` ${s.total} entries ${bp.critical ?? 0} critical / ${bp.high ?? 0} high / ${bp.standard ?? 0} standard / ${bp.low ?? 0} low`);
5016
+ console.log(`\n${render.wrap(render.c.bold, "Soul")}`);
5017
+ const entries = sortSoulKeyEntries(s.byKey ?? {});
5018
+ const parts = entries.map(([k, n]) => `${render.wrap(render.c.cyan, k)}:${n}`);
5019
+ const suffix = parts.length > 0
5020
+ ? ` ${render.wrap(render.c.dim, "—")} ${parts.join(render.wrap(render.c.dim, " · "))}`
5021
+ : "";
5022
+ console.log(render.kv("Entries", `${render.wrap(render.c.bold, String(s.total))}${suffix}`));
4698
5023
  }
4699
5024
  else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
4700
- console.log("\nSoul:");
4701
- console.log(` ${healthData.soulEntries} entries`);
5025
+ console.log(`\n${render.wrap(render.c.bold, "Soul")}`);
5026
+ console.log(render.kv("Entries", String(healthData.soulEntries)));
4702
5027
  }
4703
5028
  if (healthData?.rem) {
4704
5029
  const r = healthData.rem;
4705
- console.log("\nREM:");
5030
+ console.log(`\n${render.wrap(render.c.bold, "REM")}`);
4706
5031
  if (r.lastLightAt)
4707
- console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
5032
+ console.log(render.kv("Last light", render.relativeTime(r.lastLightAt)));
4708
5033
  if (r.lastRapidAt)
4709
- console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
5034
+ console.log(render.kv("Last rapid", render.relativeTime(r.lastRapidAt)));
4710
5035
  if (r.lastRestorativeAt)
4711
- console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
4712
- const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
4713
- console.log(` Nightly: ${nightly}`);
5036
+ console.log(render.kv("Last restorative", render.relativeTime(r.lastRestorativeAt)));
5037
+ const nightlyTxt = r.nightlyEnabled === true
5038
+ ? render.wrap(render.c.green, "enabled")
5039
+ : r.nightlyEnabled === false
5040
+ ? render.wrap(render.c.dim, "disabled")
5041
+ : render.wrap(render.c.dim, "unknown");
5042
+ console.log(render.kv("Nightly", nightlyTxt));
4714
5043
  if (r.nightlyEnabled && r.lastNightlyAt)
4715
- console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)}`);
5044
+ console.log(render.kv("Last nightly", render.relativeTime(r.lastNightlyAt)));
4716
5045
  if (typeof r.pendingCandidates === "number" && r.pendingCandidates > 0) {
4717
- console.log(` Pending candidates: ${r.pendingCandidates}`);
5046
+ console.log(render.kv("Pending candidates", render.wrap(render.c.yellow, String(r.pendingCandidates))));
4718
5047
  }
4719
5048
  }
4720
5049
  if (healthData?.federation) {
4721
5050
  const f = healthData.federation;
4722
- console.log("\nFederation:");
4723
- if (f.instance)
4724
- console.log(` Instance: ${f.instance.id} (${f.instance.role ?? ""}, ${f.instance.status ?? "—"})`);
4725
- if (f.peers)
4726
- console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
5051
+ console.log(`\n${render.wrap(render.c.bold, "Federation")}`);
5052
+ if (f.instance) {
5053
+ const statusColor = f.instance.status === "active" ? render.c.green : render.c.yellow;
5054
+ console.log(render.kv("Instance", `${f.instance.id} ${render.wrap(render.c.dim, "(")}${f.instance.role ?? "—"}${render.wrap(render.c.dim, ", ")}${render.wrap(statusColor, f.instance.status ?? "—")}${render.wrap(render.c.dim, ")")}`));
5055
+ }
5056
+ if (f.peers) {
5057
+ const connColor = f.peers.connected > 0 ? render.c.green : render.c.dim;
5058
+ const downColor = f.peers.disconnected > 0 ? render.c.yellow : render.c.dim;
5059
+ const revColor = f.peers.revoked > 0 ? render.c.red : render.c.dim;
5060
+ const parts = [
5061
+ `${render.wrap(connColor, `${f.peers.connected} connected`)}`,
5062
+ `${render.wrap(downColor, `${f.peers.disconnected} down`)}`,
5063
+ `${render.wrap(revColor, `${f.peers.revoked} revoked`)}`,
5064
+ ];
5065
+ console.log(render.kv("Peers", `${render.wrap(render.c.bold, String(f.peers.total))} ${render.wrap(render.c.dim, "—")} ${parts.join(render.wrap(render.c.dim, " · "))}`));
5066
+ }
4727
5067
  if (f.pendingTokens > 0)
4728
- console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
5068
+ console.log(render.kv("Pairing", `${render.wrap(render.c.yellow, String(f.pendingTokens))} unconsumed token(s)`));
4729
5069
  }
4730
5070
  else {
4731
- console.log("\nFederation: not configured");
5071
+ console.log(`\n${render.wrap(render.c.bold, "Federation")} ${render.wrap(render.c.dim, "not configured")}`);
4732
5072
  }
4733
5073
  if (healthData?.oauth) {
4734
5074
  const lines = oauthSummaryLines(healthData.oauth);
4735
- for (const line of lines)
4736
- console.log(line);
5075
+ // Tweak the "OAuth:" header to bold; downstream lines are aligned k/v which already look fine
5076
+ for (const line of lines) {
5077
+ if (line.trim() === "OAuth:")
5078
+ console.log(`\n${render.wrap(render.c.bold, "OAuth")}`);
5079
+ else
5080
+ console.log(line);
5081
+ }
4737
5082
  }
4738
5083
  if (healthData?.bridges) {
4739
5084
  const b = healthData.bridges;
4740
- console.log("\nBridges:");
5085
+ console.log(`\n${render.wrap(render.c.bold, "Bridges")}`);
4741
5086
  if (Array.isArray(b.installed) && b.installed.length > 0)
4742
- console.log(` Installed: ${b.installed.join(", ")}`);
5087
+ console.log(render.kv("Installed", b.installed.join(render.wrap(render.c.dim, ", "))));
4743
5088
  if (b.lastImport)
4744
- console.log(` Last import: ${relativeTime(b.lastImport)}`);
5089
+ console.log(render.kv("Last import", render.relativeTime(b.lastImport)));
4745
5090
  if (b.lastExport)
4746
- console.log(` Last export: ${relativeTime(b.lastExport)}`);
5091
+ console.log(render.kv("Last export", render.relativeTime(b.lastExport)));
4747
5092
  }
4748
5093
  else {
4749
- console.log("\nBridges: none installed");
5094
+ console.log(`\n${render.wrap(render.c.bold, "Bridges")} ${render.wrap(render.c.dim, "none installed")}`);
4750
5095
  }
4751
5096
  if (healthData?.disk) {
4752
5097
  const d = healthData.disk;
4753
- console.log("\nDisk:");
4754
- console.log(` Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
4755
- console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
5098
+ console.log(`\n${render.wrap(render.c.bold, "Disk")}`);
5099
+ console.log(render.kv("Data", `${render.wrap(render.c.dim, d.dataDir)} ${render.wrap(render.c.dim, "")} ${render.wrap(render.c.bold, render.humanBytes(d.dataBytes ?? 0))}`));
5100
+ console.log(render.kv("Snapshots", `${render.wrap(render.c.dim, d.snapshotDir)} ${render.wrap(render.c.dim, "")} ${render.wrap(render.c.bold, render.humanBytes(d.snapshotBytes ?? 0))}`));
4756
5101
  }
4757
5102
  console.log("");
4758
- if (scopedWarnings.length > 0)
4759
- console.log(` Health: ⚠ ${scopedWarnings.length} warning(s)`);
4760
- else
4761
- console.log(` Health: ✅ all checks passing`);
5103
+ if (scopedWarnings.length > 0) {
5104
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `${scopedWarnings.length} warning${scopedWarnings.length === 1 ? "" : "s"}`)}`);
5105
+ }
5106
+ else {
5107
+ console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "all checks passing")}`);
5108
+ }
4762
5109
  });
4763
5110
  statusCmd
4764
5111
  .command("rem")
@@ -4766,33 +5113,42 @@ statusCmd
4766
5113
  .action(async function () {
4767
5114
  const opts = this.optsWithGlobals();
4768
5115
  const { healthy, healthData } = await fetchHealthDetail(opts);
4769
- if (opts.json) {
4770
- console.log(JSON.stringify({ healthy, rem: healthData?.rem ?? null }, null, 2));
5116
+ const mode = render.resolveOutputMode(opts);
5117
+ if (mode === "json") {
5118
+ console.log(render.asJSON({ healthy, rem: healthData?.rem ?? null }));
4771
5119
  if (!healthy)
4772
5120
  process.exit(1);
4773
5121
  return;
4774
5122
  }
4775
5123
  if (!healthy) {
4776
- console.log("🔴 unreachable");
5124
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4777
5125
  process.exit(1);
4778
5126
  }
4779
5127
  const r = healthData?.rem;
4780
5128
  if (!r) {
4781
- console.log("REM: not configured (no log entries or platform timers found)");
5129
+ console.log(`${render.wrap(render.c.bold, "REM")} ${render.wrap(render.c.dim, "not configured (no log entries or platform timers found)")}`);
4782
5130
  return;
4783
5131
  }
4784
- console.log("REM:");
4785
- console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
4786
- console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
4787
- console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
4788
- const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
4789
- console.log(` Nightly: ${nightly}`);
4790
- if (r.lastNightlyAt)
4791
- console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
4792
- if (typeof r.pendingCandidates === "number")
4793
- console.log(` Pending candidates: ${r.pendingCandidates}`);
4794
- else
4795
- console.log(` Pending candidates: (schema not available)`);
5132
+ console.log(render.wrap(render.c.bold, "REM"));
5133
+ console.log(render.kv("Last light", render.relativeTime(r.lastLightAt), 18));
5134
+ console.log(render.kv("Last rapid", render.relativeTime(r.lastRapidAt), 18));
5135
+ console.log(render.kv("Last restorative", render.relativeTime(r.lastRestorativeAt), 18));
5136
+ const nightlyTxt = r.nightlyEnabled === true
5137
+ ? render.wrap(render.c.green, "enabled")
5138
+ : r.nightlyEnabled === false
5139
+ ? render.wrap(render.c.dim, "disabled")
5140
+ : render.wrap(render.c.dim, "unknown");
5141
+ console.log(render.kv("Nightly", nightlyTxt, 18));
5142
+ if (r.lastNightlyAt) {
5143
+ console.log(render.kv("Last nightly", `${render.relativeTime(r.lastNightlyAt)} ${render.wrap(render.c.dim, `(${r.lastNightlyAt})`)}`, 18));
5144
+ }
5145
+ if (typeof r.pendingCandidates === "number") {
5146
+ const pendingColor = r.pendingCandidates > 0 ? render.c.yellow : render.c.dim;
5147
+ console.log(render.kv("Pending candidates", render.wrap(pendingColor, String(r.pendingCandidates)), 18));
5148
+ }
5149
+ else {
5150
+ console.log(render.kv("Pending candidates", render.wrap(render.c.dim, "— (schema not available)"), 18));
5151
+ }
4796
5152
  });
4797
5153
  statusCmd
4798
5154
  .command("federation")
@@ -4800,36 +5156,70 @@ statusCmd
4800
5156
  .action(async function () {
4801
5157
  const opts = this.optsWithGlobals();
4802
5158
  const { healthy, healthData } = await fetchHealthDetail(opts);
4803
- if (opts.json) {
4804
- console.log(JSON.stringify({ healthy, federation: healthData?.federation ?? null }, null, 2));
5159
+ const mode = render.resolveOutputMode(opts);
5160
+ if (mode === "json") {
5161
+ console.log(render.asJSON({ healthy, federation: healthData?.federation ?? null }));
4805
5162
  if (!healthy)
4806
5163
  process.exit(1);
4807
5164
  return;
4808
5165
  }
4809
5166
  if (!healthy) {
4810
- console.log("🔴 unreachable");
5167
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4811
5168
  process.exit(1);
4812
5169
  }
4813
5170
  const f = healthData?.federation;
4814
5171
  if (!f) {
4815
- console.log("Federation: not configured");
5172
+ console.log(`${render.wrap(render.c.bold, "Federation")} ${render.wrap(render.c.dim, "not configured")}`);
4816
5173
  return;
4817
5174
  }
4818
- console.log("Federation:");
4819
- if (f.instance)
4820
- console.log(` Instance: ${f.instance.id} (${f.instance.role ?? ""}, ${f.instance.status ?? "—"})`);
4821
- else
4822
- console.log(" Instance: —");
4823
- if (f.peers)
4824
- console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
4825
- if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
4826
- console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
5175
+ console.log(render.wrap(render.c.bold, "Federation"));
5176
+ if (f.instance) {
5177
+ const statusColor = f.instance.status === "active" ? render.c.green : render.c.yellow;
5178
+ console.log(render.kv("Instance", `${f.instance.id} ${render.wrap(render.c.dim, "(")}${f.instance.role ?? "—"}${render.wrap(render.c.dim, ", ")}${render.wrap(statusColor, f.instance.status ?? "—")}${render.wrap(render.c.dim, ")")}`));
5179
+ }
5180
+ else {
5181
+ console.log(render.kv("Instance", render.wrap(render.c.dim, "—")));
5182
+ }
5183
+ if (f.peers) {
5184
+ const connColor = f.peers.connected > 0 ? render.c.green : render.c.dim;
5185
+ const downColor = f.peers.disconnected > 0 ? render.c.yellow : render.c.dim;
5186
+ const revColor = f.peers.revoked > 0 ? render.c.red : render.c.dim;
5187
+ const parts = [
5188
+ render.wrap(connColor, `${f.peers.connected} connected`),
5189
+ render.wrap(downColor, `${f.peers.disconnected} down`),
5190
+ render.wrap(revColor, `${f.peers.revoked} revoked`),
5191
+ ];
5192
+ console.log(render.kv("Peers", `${render.wrap(render.c.bold, String(f.peers.total))} ${render.wrap(render.c.dim, "—")} ${parts.join(render.wrap(render.c.dim, " · "))}`));
5193
+ }
5194
+ if (typeof f.pendingTokens === "number" && f.pendingTokens > 0) {
5195
+ console.log(render.kv("Pairing", `${render.wrap(render.c.yellow, String(f.pendingTokens))} unconsumed token(s)`));
5196
+ }
4827
5197
  if (Array.isArray(f.peerList) && f.peerList.length > 0) {
4828
- const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
4829
- console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
4830
- for (const p of f.peerList) {
4831
- console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
4832
- }
5198
+ console.log();
5199
+ const cols = [
5200
+ { label: "peer", key: "id" },
5201
+ { label: "role", key: "role", format: (v) => String(v ?? "—") },
5202
+ {
5203
+ label: "status",
5204
+ key: "status",
5205
+ format: (v) => {
5206
+ const s = String(v ?? "—");
5207
+ const color = s === "paired" || s === "connected" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
5208
+ return render.wrap(color, s);
5209
+ },
5210
+ },
5211
+ {
5212
+ label: "last_sync",
5213
+ key: "lastSyncAt",
5214
+ format: (v) => {
5215
+ const iso = v;
5216
+ if (!iso)
5217
+ return render.wrap(render.c.dim, "never");
5218
+ return `${render.relativeTime(iso)} ${render.wrap(render.c.dim, `(${iso})`)}`;
5219
+ },
5220
+ },
5221
+ ];
5222
+ console.log(render.table(cols, f.peerList));
4833
5223
  }
4834
5224
  });
4835
5225
  statusCmd
@@ -4838,24 +5228,46 @@ statusCmd
4838
5228
  .action(async function () {
4839
5229
  const opts = this.optsWithGlobals();
4840
5230
  const { healthy, healthData } = await fetchHealthDetail(opts);
4841
- if (opts.json) {
4842
- console.log(JSON.stringify({ healthy, oauth: healthData?.oauth ?? null }, null, 2));
5231
+ const mode = render.resolveOutputMode(opts);
5232
+ if (mode === "json") {
5233
+ console.log(render.asJSON({ healthy, oauth: healthData?.oauth ?? null }));
4843
5234
  if (!healthy)
4844
5235
  process.exit(1);
4845
5236
  return;
4846
5237
  }
4847
5238
  if (!healthy) {
4848
- console.log("🔴 unreachable");
5239
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4849
5240
  process.exit(1);
4850
5241
  }
4851
5242
  const o = healthData?.oauth;
4852
5243
  if (!o) {
4853
- console.log("OAuth: not configured");
5244
+ console.log(`${render.wrap(render.c.bold, "OAuth")} ${render.wrap(render.c.dim, "not configured")}`);
4854
5245
  return;
4855
5246
  }
4856
- const lines = oauthDetailLines(o);
4857
- for (const line of lines)
4858
- console.log(line);
5247
+ console.log(render.wrap(render.c.bold, "OAuth"));
5248
+ console.log(render.kv("Clients", render.wrap(render.c.bold, String(Number(o?.clients ?? 0)))));
5249
+ console.log(render.kv("IdP configs", String(Number(o?.idpConfigs ?? 0))));
5250
+ const tokenColor = Number(o?.activeTokens ?? 0) > 0 ? render.c.green : render.c.dim;
5251
+ console.log(render.kv("Active tokens", render.wrap(tokenColor, String(Number(o?.activeTokens ?? 0)))));
5252
+ if (Array.isArray(o?.clientList) && o.clientList.length > 0) {
5253
+ console.log(`\n ${render.wrap(render.c.dim, "Clients")}`);
5254
+ const cols = [
5255
+ { label: "id", key: "id" },
5256
+ { label: "name", key: "name", format: (v) => String(v ?? "—") },
5257
+ { label: "registered_by", key: "registeredBy", format: (v) => String(v ?? "—") },
5258
+ { label: "created_at", key: "createdAt", format: (v) => String(v ?? "—") },
5259
+ ];
5260
+ console.log(render.table(cols, o.clientList));
5261
+ }
5262
+ if (Array.isArray(o?.idpList) && o.idpList.length > 0) {
5263
+ console.log(`\n ${render.wrap(render.c.dim, "IdPs")}`);
5264
+ const cols = [
5265
+ { label: "id", key: "id" },
5266
+ { label: "name", key: "name", format: (v) => String(v ?? "—") },
5267
+ { label: "issuer", key: "issuer", format: (v) => String(v ?? "—") },
5268
+ ];
5269
+ console.log(render.table(cols, o.idpList));
5270
+ }
4859
5271
  });
4860
5272
  statusCmd
4861
5273
  .command("bridges")
@@ -4863,28 +5275,283 @@ statusCmd
4863
5275
  .action(async function () {
4864
5276
  const opts = this.optsWithGlobals();
4865
5277
  const { healthy, healthData } = await fetchHealthDetail(opts);
4866
- if (opts.json) {
4867
- console.log(JSON.stringify({ healthy, bridges: healthData?.bridges ?? null }, null, 2));
5278
+ const mode = render.resolveOutputMode(opts);
5279
+ if (mode === "json") {
5280
+ console.log(render.asJSON({ healthy, bridges: healthData?.bridges ?? null }));
4868
5281
  if (!healthy)
4869
5282
  process.exit(1);
4870
5283
  return;
4871
5284
  }
4872
5285
  if (!healthy) {
4873
- console.log("🔴 unreachable");
5286
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4874
5287
  process.exit(1);
4875
5288
  }
4876
5289
  const b = healthData?.bridges;
4877
5290
  if (!b) {
4878
- console.log("Bridges: none installed (no flair-bridge-* packages found)");
5291
+ console.log(`${render.wrap(render.c.bold, "Bridges")} ${render.wrap(render.c.dim, "none installed (no flair-bridge-* packages found)")}`);
4879
5292
  return;
4880
5293
  }
4881
- console.log("Bridges:");
4882
- if (Array.isArray(b.installed) && b.installed.length > 0)
4883
- console.log(` Installed: ${b.installed.join(", ")}`);
5294
+ console.log(render.wrap(render.c.bold, "Bridges"));
5295
+ if (Array.isArray(b.installed) && b.installed.length > 0) {
5296
+ console.log(render.kv("Installed", b.installed.join(render.wrap(render.c.dim, ", "))));
5297
+ }
4884
5298
  if (b.lastImport)
4885
- console.log(` Last import: ${relativeTime(b.lastImport)}`);
5299
+ console.log(render.kv("Last import", render.relativeTime(b.lastImport)));
4886
5300
  if (b.lastExport)
4887
- console.log(` Last export: ${relativeTime(b.lastExport)}`);
5301
+ console.log(render.kv("Last export", render.relativeTime(b.lastExport)));
5302
+ });
5303
+ // ─── flair status --deep ──────────────────────────────────────────────────────
5304
+ //
5305
+ // Deeper observability than the default `flair status` summary — full
5306
+ // per-section detail with no condensing. Optional --bootstrap measures
5307
+ // cold-start context bytes per agent (slow; calls /MemoryBootstrap once per
5308
+ // agent, admin-auth required).
5309
+ //
5310
+ // Addresses ops-yph: Nathan's 2026-04-22 ask for "how much storage does my
5311
+ // memory take, how much context are bootstraps pulling, what's the real usage
5312
+ // pattern" — questions the default `flair status` summarizes but doesn't
5313
+ // surface in full. Agents should be able to self-audit via this too.
5314
+ statusCmd
5315
+ .command("deep")
5316
+ .description("Verbose status + optional bootstrap context size per agent (ops-yph)")
5317
+ .option("--bootstrap", "Also measure bootstrap context bytes per agent (slow; admin auth required)")
5318
+ .option("--max-tokens <n>", "Bootstrap maxTokens cap when --bootstrap is set", "4000")
5319
+ .action(async function () {
5320
+ const opts = this.optsWithGlobals();
5321
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
5322
+ if (!healthy) {
5323
+ if (opts.json) {
5324
+ console.log(JSON.stringify({ healthy: false, url: baseUrl, error: "unreachable" }, null, 2));
5325
+ }
5326
+ else {
5327
+ console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
5328
+ console.log(` URL: ${baseUrl}`);
5329
+ console.log(`\n Run: flair start or flair doctor`);
5330
+ }
5331
+ process.exit(1);
5332
+ }
5333
+ // Optional: per-agent bootstrap context bytes. Calls /MemoryBootstrap with
5334
+ // admin auth (so we can measure on behalf of any agent without holding
5335
+ // their keys). 15s timeout per call — bootstrap can be expensive on cold
5336
+ // caches or large memory sets.
5337
+ const agentList = (Array.isArray(healthData?.agents?.names) && healthData.agents.names.length > 0
5338
+ ? healthData.agents.names
5339
+ : Array.isArray(healthData?.agents?.perAgent)
5340
+ ? healthData.agents.perAgent.map((r) => r.id).filter(Boolean)
5341
+ : []);
5342
+ const bootstrapBytes = {};
5343
+ if (opts.bootstrap && agentList.length > 0) {
5344
+ const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
5345
+ if (!adminPass) {
5346
+ if (!opts.json) {
5347
+ console.log("⚠ --bootstrap requires HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS env var");
5348
+ }
5349
+ }
5350
+ else {
5351
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5352
+ const maxTokens = Number.parseInt(String(opts.maxTokens ?? "4000"), 10);
5353
+ for (const agentId of agentList) {
5354
+ try {
5355
+ const res = await fetch(`${baseUrl}/BootstrapMemories`, {
5356
+ method: "POST",
5357
+ headers: { "Content-Type": "application/json", Authorization: auth },
5358
+ body: JSON.stringify({ agentId, maxTokens }),
5359
+ signal: AbortSignal.timeout(15000),
5360
+ });
5361
+ const text = await res.text();
5362
+ const bytes = Buffer.byteLength(text, "utf8");
5363
+ let tokenEstimate;
5364
+ let memoriesIncluded;
5365
+ try {
5366
+ const json = JSON.parse(text);
5367
+ tokenEstimate = typeof json.tokenEstimate === "number" ? json.tokenEstimate : undefined;
5368
+ memoriesIncluded = typeof json.memoriesIncluded === "number" ? json.memoriesIncluded : undefined;
5369
+ }
5370
+ catch { /* response wasn't JSON; bytes still valid */ }
5371
+ if (!res.ok) {
5372
+ bootstrapBytes[agentId] = { bytes, error: `HTTP ${res.status}` };
5373
+ }
5374
+ else {
5375
+ bootstrapBytes[agentId] = { bytes, tokenEstimate, memoriesIncluded };
5376
+ }
5377
+ }
5378
+ catch (e) {
5379
+ bootstrapBytes[agentId] = { bytes: 0, error: e?.message ?? String(e) };
5380
+ }
5381
+ }
5382
+ }
5383
+ }
5384
+ if (opts.json) {
5385
+ const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
5386
+ if (opts.bootstrap)
5387
+ out.bootstrapBytes = bootstrapBytes;
5388
+ console.log(JSON.stringify(out, null, 2));
5389
+ return;
5390
+ }
5391
+ // Human-readable verbose render.
5392
+ const uptimeSec = healthData?.uptimeSeconds;
5393
+ let uptimeStr = "";
5394
+ if (uptimeSec != null) {
5395
+ const d = Math.floor(uptimeSec / 86400);
5396
+ const h = Math.floor((uptimeSec % 86400) / 3600);
5397
+ const m = Math.floor((uptimeSec % 3600) / 60);
5398
+ uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
5399
+ }
5400
+ const pid = healthData?.pid ?? "?";
5401
+ console.log(`Flair v${__pkgVersion} — running (PID ${pid}${uptimeStr ? `, uptime ${uptimeStr}` : ""})`);
5402
+ console.log(`URL: ${baseUrl}`);
5403
+ const memories = healthData?.memories;
5404
+ if (memories) {
5405
+ console.log("\n═══ Memory ═══════════════════════════════════════");
5406
+ console.log(`Total: ${memories.total}`);
5407
+ console.log(`Breakdown: ${memories.withEmbeddings ?? 0} embedded, ${memories.hashFallback ?? 0} hash-fallback`);
5408
+ if (memories.modelCounts && memories.total > 0) {
5409
+ const entries = Object.entries(memories.modelCounts)
5410
+ .filter(([, n]) => n > 0)
5411
+ .sort((a, b) => b[1] - a[1]);
5412
+ for (const [k, n] of entries) {
5413
+ const pct = ((n / memories.total) * 100).toFixed(1);
5414
+ console.log(` ${k.padEnd(28)} ${String(n).padStart(6)} (${pct}%)`);
5415
+ }
5416
+ }
5417
+ if (memories.byDurability) {
5418
+ const d = memories.byDurability;
5419
+ console.log(`Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
5420
+ }
5421
+ console.log(`Archived: ${memories.archived ?? 0}`);
5422
+ console.log(`Expired: ${memories.expired ?? 0}`);
5423
+ if (healthData?.lastWrite)
5424
+ console.log(`Last write: ${relativeTime(healthData.lastWrite)} (${healthData.lastWrite})`);
5425
+ }
5426
+ const agents = healthData?.agents;
5427
+ if (agents && agents.count > 0) {
5428
+ console.log("\n═══ Agents ═══════════════════════════════════════");
5429
+ console.log(`Total: ${agents.count}`);
5430
+ if (Array.isArray(agents.names) && agents.names.length > 0) {
5431
+ console.log(`Names: ${agents.names.join(", ")}`);
5432
+ }
5433
+ if (Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
5434
+ const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
5435
+ console.log(`\n ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
5436
+ for (const r of agents.perAgent) {
5437
+ const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "—";
5438
+ const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
5439
+ console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
5440
+ }
5441
+ }
5442
+ }
5443
+ // Bootstrap context section — always printed so users see how to opt in.
5444
+ console.log("\n═══ Bootstrap context ═══════════════════════════");
5445
+ if (!opts.bootstrap) {
5446
+ console.log(" (not measured — pass --bootstrap to fetch per-agent context bytes)");
5447
+ }
5448
+ else if (Object.keys(bootstrapBytes).length === 0) {
5449
+ console.log(" (no agents found, or admin pass missing — see HDB_ADMIN_PASSWORD)");
5450
+ }
5451
+ else {
5452
+ const idW = Math.max(5, ...Object.keys(bootstrapBytes).map((id) => id.length));
5453
+ console.log(` ${"agent".padEnd(idW)} ${"bytes".padStart(9)} ${"~tokens".padStart(7)} ${"mems".padStart(4)} status`);
5454
+ // Sort by bytes desc so heaviest bootstraps surface first.
5455
+ const sortedEntries = Object.entries(bootstrapBytes).sort((a, b) => (b[1].bytes ?? 0) - (a[1].bytes ?? 0));
5456
+ for (const [agentId, info] of sortedEntries) {
5457
+ const bytesStr = info.error ? "error" : humanBytes(info.bytes);
5458
+ const tok = info.tokenEstimate != null ? String(info.tokenEstimate) : "—";
5459
+ const mems = info.memoriesIncluded != null ? String(info.memoriesIncluded) : "—";
5460
+ const status = info.error ? info.error.slice(0, 40) : "ok";
5461
+ console.log(` ${agentId.padEnd(idW)} ${bytesStr.padStart(9)} ${tok.padStart(7)} ${mems.padStart(4)} ${status}`);
5462
+ }
5463
+ }
5464
+ if (healthData?.relationships) {
5465
+ const r = healthData.relationships;
5466
+ console.log("\n═══ Relationships ═══════════════════════════════");
5467
+ console.log(`Total: ${r.total} (${r.active} active)`);
5468
+ }
5469
+ if (healthData?.soul && healthData.soul.total > 0) {
5470
+ const s = healthData.soul;
5471
+ console.log("\n═══ Soul ═════════════════════════════════════════");
5472
+ console.log(`Total: ${s.total} entries`);
5473
+ const entries = sortSoulKeyEntries(s.byKey ?? {});
5474
+ if (entries.length > 0) {
5475
+ console.log(`Keys: ${entries.map(([k, n]) => `${n} ${k}`).join(" / ")}`);
5476
+ }
5477
+ }
5478
+ else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
5479
+ console.log("\n═══ Soul ═════════════════════════════════════════");
5480
+ console.log(`Total: ${healthData.soulEntries} entries`);
5481
+ }
5482
+ if (healthData?.rem) {
5483
+ const r = healthData.rem;
5484
+ console.log("\n═══ REM ══════════════════════════════════════════");
5485
+ console.log(`Last light: ${relativeTime(r.lastLightAt)}`);
5486
+ console.log(`Last rapid: ${relativeTime(r.lastRapidAt)}`);
5487
+ console.log(`Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
5488
+ const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
5489
+ console.log(`Nightly: ${nightly}`);
5490
+ if (r.lastNightlyAt)
5491
+ console.log(`Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
5492
+ if (typeof r.pendingCandidates === "number")
5493
+ console.log(`Pending candidates: ${r.pendingCandidates}`);
5494
+ }
5495
+ if (healthData?.federation) {
5496
+ const f = healthData.federation;
5497
+ console.log("\n═══ Federation ═══════════════════════════════════");
5498
+ if (f.instance)
5499
+ console.log(`Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
5500
+ if (f.peers)
5501
+ console.log(`Peers: ${f.peers.total} total (${f.peers.connected} connected, ${f.peers.disconnected} down, ${f.peers.revoked} revoked)`);
5502
+ if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
5503
+ console.log(`Pairing: ${f.pendingTokens} unconsumed token(s)`);
5504
+ if (Array.isArray(f.peerList) && f.peerList.length > 0) {
5505
+ const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
5506
+ console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
5507
+ for (const p of f.peerList) {
5508
+ console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "—").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
5509
+ }
5510
+ }
5511
+ }
5512
+ else {
5513
+ console.log("\n═══ Federation ═══════════════════════════════════");
5514
+ console.log(" not configured");
5515
+ }
5516
+ if (healthData?.oauth) {
5517
+ const o = healthData.oauth;
5518
+ console.log("\n═══ OAuth / IdP ══════════════════════════════════");
5519
+ console.log(`Clients: ${o.clients}`);
5520
+ console.log(`IdP configs: ${o.idpConfigs}`);
5521
+ console.log(`Active tokens: ${o.activeTokens}`);
5522
+ if (Array.isArray(o.clientList) && o.clientList.length > 0) {
5523
+ console.log(`\n Clients:`);
5524
+ for (const c of o.clientList) {
5525
+ console.log(` ${c.id} ${c.name ?? "—"} ${c.registeredBy ?? "—"} ${c.createdAt ?? "—"}`);
5526
+ }
5527
+ }
5528
+ }
5529
+ if (healthData?.bridges) {
5530
+ const b = healthData.bridges;
5531
+ console.log("\n═══ Bridges ══════════════════════════════════════");
5532
+ if (Array.isArray(b.installed) && b.installed.length > 0)
5533
+ console.log(`Installed: ${b.installed.join(", ")}`);
5534
+ if (b.lastImport)
5535
+ console.log(`Last import: ${relativeTime(b.lastImport)}`);
5536
+ if (b.lastExport)
5537
+ console.log(`Last export: ${relativeTime(b.lastExport)}`);
5538
+ }
5539
+ if (healthData?.disk) {
5540
+ const d = healthData.disk;
5541
+ console.log("\n═══ Disk ═════════════════════════════════════════");
5542
+ console.log(`Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
5543
+ console.log(`Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
5544
+ console.log(`Total: ${humanBytes((d.dataBytes ?? 0) + (d.snapshotBytes ?? 0))}`);
5545
+ }
5546
+ const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
5547
+ if (warnings.length > 0) {
5548
+ console.log("\n═══ Warnings ═════════════════════════════════════");
5549
+ for (const w of warnings)
5550
+ console.log(` ${w.level === "warn" ? "⚠" : "ℹ"} ${w.message}`);
5551
+ }
5552
+ else {
5553
+ console.log("\n✅ no warnings");
5554
+ }
4888
5555
  });
4889
5556
  // ─── flair upgrade ────────────────────────────────────────────────────────────
4890
5557
  program
@@ -5565,15 +6232,13 @@ program
5565
6232
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
5566
6233
  .option("--port <port>", "Harper HTTP port")
5567
6234
  .action(async (opts) => {
5568
- const green = (s) => `\x1b[32m${s}\x1b[0m`;
5569
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
5570
6235
  const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
5571
6236
  if (!agentId && !process.env.FLAIR_ADMIN_PASS) {
5572
- console.error(red("Error: set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS"));
6237
+ console.error(`${render.icons.error} ${render.wrap(render.c.red, "set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS")}`);
5573
6238
  process.exit(1);
5574
6239
  }
5575
6240
  const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
5576
- console.log(`\nFlair test (url: ${baseUrl})\n`);
6241
+ console.log(`\n${render.wrap(render.c.bold, "Flair test")} ${render.wrap(render.c.dim, `(url: ${baseUrl})`)}\n`);
5577
6242
  let passed = 0;
5578
6243
  let failed = 0;
5579
6244
  let memoryId = null;
@@ -5581,17 +6246,17 @@ program
5581
6246
  try {
5582
6247
  const ok = await fn();
5583
6248
  if (ok) {
5584
- console.log(` ${green("PASS")} ${name}`);
6249
+ console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "PASS")} ${name}`);
5585
6250
  passed++;
5586
6251
  }
5587
6252
  else {
5588
- console.log(` ${red("FAIL")} ${name}`);
6253
+ console.log(` ${render.icons.error} ${render.wrap(render.c.red, "FAIL")} ${name}`);
5589
6254
  failed++;
5590
6255
  }
5591
6256
  }
5592
6257
  catch (e) {
5593
6258
  const message = e instanceof Error ? e.message : String(e);
5594
- console.log(` ${red("FAIL")} ${name}: ${message?.slice(0, 120)}`);
6259
+ console.log(` ${render.icons.error} ${render.wrap(render.c.red, "FAIL")} ${name}: ${render.wrap(render.c.dim, message?.slice(0, 120))}`);
5595
6260
  failed++;
5596
6261
  }
5597
6262
  };
@@ -5630,7 +6295,9 @@ program
5630
6295
  await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
5631
6296
  return true;
5632
6297
  });
5633
- console.log(`\n${passed} passed, ${failed} failed`);
6298
+ const passColor = passed > 0 ? render.c.green : render.c.dim;
6299
+ const failColor = failed > 0 ? render.c.red : render.c.dim;
6300
+ console.log(`\n ${render.wrap(passColor, `${passed} passed`)} ${render.wrap(render.c.dim, "·")} ${render.wrap(failColor, `${failed} failed`)}`);
5634
6301
  if (failed > 0)
5635
6302
  process.exit(1);
5636
6303
  });
@@ -5733,7 +6400,7 @@ program
5733
6400
  let baseUrl = `http://127.0.0.1:${port}`;
5734
6401
  let issues = 0;
5735
6402
  let harperResponding = false;
5736
- console.log("\n🩺 Flair Doctor\n");
6403
+ console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
5737
6404
  // Helper: try to reach Harper on a given port
5738
6405
  async function probePort(p) {
5739
6406
  try {
@@ -5777,11 +6444,11 @@ program
5777
6444
  catch { /* dead */ }
5778
6445
  }
5779
6446
  else {
5780
- console.log(` ⚠️ PID file contains non-numeric value: ${pidFile0} — skipping`);
6447
+ console.log(` ${render.icons.warn} PID file contains non-numeric value: ${render.wrap(render.c.dim, pidFile0)} — skipping`);
5781
6448
  }
5782
6449
  }
5783
6450
  if (await probePort(port)) {
5784
- console.log(` Harper responding on port ${port}`);
6451
+ console.log(` ${render.icons.ok} Harper responding on port ${render.wrap(render.c.bold, String(port))}`);
5785
6452
  harperResponding = true;
5786
6453
  }
5787
6454
  else {
@@ -5790,19 +6457,19 @@ program
5790
6457
  if (pidAlive) {
5791
6458
  discoveredPort = await discoverPortFromPid(pidValue);
5792
6459
  if (discoveredPort && discoveredPort !== port && await probePort(discoveredPort)) {
5793
- console.log(` ⚠️ Harper not on expected port ${port}, but responding on port ${discoveredPort} (PID ${pidValue})`);
5794
- console.log(` Your config says port ${port} but Harper is actually running on ${discoveredPort}`);
6460
+ console.log(` ${render.icons.warn} Harper not on expected port ${port}, but responding on port ${render.wrap(render.c.bold, String(discoveredPort))} ${render.wrap(render.c.dim, `(PID ${pidValue})`)}`);
6461
+ console.log(` ${render.wrap(render.c.dim, `Your config says port ${port} but Harper is actually running on ${discoveredPort}`)}`);
5795
6462
  if (autoFix) {
5796
6463
  if (dryRun) {
5797
- console.log(` Would update config to port ${discoveredPort}`);
6464
+ console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
5798
6465
  }
5799
6466
  else {
5800
6467
  writeConfig(discoveredPort);
5801
- console.log(` Updated config to port ${discoveredPort}`);
6468
+ console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
5802
6469
  }
5803
6470
  }
5804
6471
  else {
5805
- console.log(` Fix: flair doctor --fix (updates config to match running port)`);
6472
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(updates config to match running port)")}`);
5806
6473
  }
5807
6474
  effectivePort = discoveredPort;
5808
6475
  baseUrl = `http://127.0.0.1:${discoveredPort}`;
@@ -5810,8 +6477,8 @@ program
5810
6477
  issues++;
5811
6478
  }
5812
6479
  else {
5813
- console.log(` Harper process alive (PID ${pidValue}) but not responding on any detected port`);
5814
- console.log(` Fix: flair restart`);
6480
+ console.log(` ${render.icons.error} Harper process alive (PID ${pidValue}) but not responding on any detected port`);
6481
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
5815
6482
  issues++;
5816
6483
  }
5817
6484
  }
@@ -5822,34 +6489,34 @@ program
5822
6489
  const { execSync } = await import("node:child_process");
5823
6490
  const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
5824
6491
  if (lsof) {
5825
- console.log(` Nothing responding on port ${port} (port occupied by PID ${lsof})`);
5826
- console.log(` Fix: kill ${lsof} && flair restart`);
6492
+ console.log(` ${render.icons.error} Nothing responding on port ${port} ${render.wrap(render.c.dim, `(port occupied by PID ${lsof})`)}`);
6493
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} kill ${lsof} && flair restart`);
5827
6494
  }
5828
6495
  else {
5829
- console.log(` Harper is not running`);
5830
- console.log(` Fix: flair restart`);
6496
+ console.log(` ${render.icons.error} Harper is not running`);
6497
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
5831
6498
  }
5832
6499
  }
5833
6500
  catch {
5834
- console.log(` Harper is not running`);
6501
+ console.log(` ${render.icons.error} Harper is not running`);
5835
6502
  if (autoFix) {
5836
6503
  if (dryRun) {
5837
- console.log(` Would run: flair restart`);
6504
+ console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
5838
6505
  }
5839
6506
  else {
5840
- console.log(` Attempting restart...`);
6507
+ console.log(` ${render.wrap(render.c.dim, "Attempting restart...")}`);
5841
6508
  try {
5842
6509
  const { execSync } = await import("node:child_process");
5843
6510
  execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${port}`, { stdio: "inherit" });
5844
- console.log(` Restart attempted`);
6511
+ console.log(` ${render.icons.ok} Restart attempted`);
5845
6512
  }
5846
6513
  catch {
5847
- console.log(` Restart failed — try: flair init --agent-id <your-agent>`);
6514
+ console.log(` ${render.icons.error} Restart failed — try: flair init --agent-id <your-agent>`);
5848
6515
  }
5849
6516
  }
5850
6517
  }
5851
6518
  else {
5852
- console.log(` Fix: flair restart`);
6519
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
5853
6520
  }
5854
6521
  }
5855
6522
  issues++;
@@ -5860,27 +6527,27 @@ program
5860
6527
  if (existsSync(keysDir)) {
5861
6528
  const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
5862
6529
  if (keyFiles.length > 0) {
5863
- console.log(` Keys found: ${keyFiles.length} agent(s) in ${keysDir}`);
6530
+ console.log(` ${render.icons.ok} Keys found: ${render.wrap(render.c.bold, String(keyFiles.length))} agent(s) in ${render.wrap(render.c.dim, keysDir)}`);
5864
6531
  }
5865
6532
  else {
5866
- console.log(` Keys directory exists but no .key files found`);
5867
- console.log(` Fix: flair init --agent-id <your-agent>`);
6533
+ console.log(` ${render.icons.error} Keys directory exists but no .key files found`);
6534
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
5868
6535
  issues++;
5869
6536
  }
5870
6537
  }
5871
6538
  else {
5872
- console.log(` Keys directory missing: ${keysDir}`);
5873
- console.log(` Fix: flair init --agent-id <your-agent>`);
6539
+ console.log(` ${render.icons.error} Keys directory missing: ${render.wrap(render.c.dim, keysDir)}`);
6540
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
5874
6541
  issues++;
5875
6542
  }
5876
6543
  // 3. Config file
5877
6544
  const cfgPath = configPath();
5878
6545
  if (existsSync(cfgPath)) {
5879
6546
  const savedPort = readPortFromConfig();
5880
- console.log(` Config: ${cfgPath} (port: ${savedPort ?? "default"})`);
6547
+ console.log(` ${render.icons.ok} Config: ${render.wrap(render.c.dim, cfgPath)} ${render.wrap(render.c.dim, `(port: ${savedPort ?? "default"})`)}`);
5881
6548
  }
5882
6549
  else {
5883
- console.log(` ⚠️ No config file at ${cfgPath} — using defaults`);
6550
+ console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
5884
6551
  }
5885
6552
  // 4. Embeddings check (only if Harper is responding)
5886
6553
  if (harperResponding) {
@@ -5894,17 +6561,17 @@ program
5894
6561
  if (testRes.ok) {
5895
6562
  const data = await testRes.json();
5896
6563
  if (data._warning) {
5897
- console.log(` ⚠️ Embeddings: keyword-only (${data._warning})`);
5898
- console.log(` Semantic search quality is degraded`);
5899
- console.log(` Check: ls ~/.npm-global/lib/node_modules/@tpsdev-ai/flair/models/`);
6564
+ console.log(` ${render.icons.warn} Embeddings: keyword-only ${render.wrap(render.c.dim, `(${data._warning})`)}`);
6565
+ console.log(` ${render.wrap(render.c.dim, "Semantic search quality is degraded")}`);
6566
+ console.log(` ${render.wrap(render.c.dim, "Check:")} ls ~/.npm-global/lib/node_modules/@tpsdev-ai/flair/models/`);
5900
6567
  issues++;
5901
6568
  }
5902
6569
  else {
5903
- console.log(` Embeddings: semantic search operational`);
6570
+ console.log(` ${render.icons.ok} Embeddings: semantic search operational`);
5904
6571
  }
5905
6572
  }
5906
6573
  else if (testRes.status === 401) {
5907
- console.log(` ⚠️ Embeddings: cannot verify (auth required for SemanticSearch)`);
6574
+ console.log(` ${render.icons.warn} Embeddings: cannot verify ${render.wrap(render.c.dim, "(auth required for SemanticSearch)")}`);
5908
6575
  }
5909
6576
  }
5910
6577
  catch { /* fetch error, already flagged */ }
@@ -5917,50 +6584,50 @@ program
5917
6584
  try {
5918
6585
  process.kill(Number(pidContent), 0);
5919
6586
  if (harperResponding) {
5920
- console.log(` PID file: ${pidFile} (process ${pidContent} is alive)`);
6587
+ console.log(` ${render.icons.ok} PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is alive)`)}`);
5921
6588
  }
5922
6589
  // If not responding, we already reported the issue in step 1
5923
6590
  }
5924
6591
  catch {
5925
- console.log(` Stale PID file: ${pidFile} (process ${pidContent} is dead)`);
6592
+ console.log(` ${render.icons.error} Stale PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is dead)`)}`);
5926
6593
  if (autoFix) {
5927
6594
  if (dryRun) {
5928
- console.log(` Would remove: ${pidFile}`);
6595
+ console.log(` ${render.wrap(render.c.dim, "Would remove:")} ${pidFile}`);
5929
6596
  }
5930
6597
  else {
5931
6598
  (await import("node:fs")).unlinkSync(pidFile);
5932
- console.log(` Removed stale PID file`);
6599
+ console.log(` ${render.icons.ok} Removed stale PID file`);
5933
6600
  }
5934
6601
  }
5935
6602
  else {
5936
- console.log(` Fix: rm ${pidFile} && flair restart`);
6603
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} rm ${pidFile} && flair restart`);
5937
6604
  }
5938
6605
  issues++;
5939
6606
  }
5940
6607
  }
5941
6608
  // 6. Data directory
5942
6609
  if (existsSync(dataDir)) {
5943
- console.log(` Data directory: ${dataDir}`);
6610
+ console.log(` ${render.icons.ok} Data directory: ${render.wrap(render.c.dim, dataDir)}`);
5944
6611
  }
5945
6612
  else {
5946
6613
  // Check ~/harper/ (common alternative)
5947
6614
  const altDir = join(homedir(), "harper");
5948
6615
  if (existsSync(altDir)) {
5949
- console.log(` ⚠️ Data at ~/harper/ (not ~/.flair/data) — old install location`);
6616
+ console.log(` ${render.icons.warn} Data at ${render.wrap(render.c.dim, "~/harper/")} (not ${render.wrap(render.c.dim, "~/.flair/data")}) — old install location`);
5950
6617
  }
5951
6618
  else {
5952
- console.log(` No data directory found`);
5953
- console.log(` Fix: flair init --agent-id <your-agent>`);
6619
+ console.log(` ${render.icons.error} No data directory found`);
6620
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
5954
6621
  issues++;
5955
6622
  }
5956
6623
  }
5957
6624
  // Summary
5958
6625
  console.log("");
5959
6626
  if (issues === 0) {
5960
- console.log(" 🟢 No issues found");
6627
+ console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`);
5961
6628
  }
5962
6629
  else {
5963
- console.log(` 🔴 ${issues} issue${issues > 1 ? "s" : ""} found — see fixes above`);
6630
+ console.log(` ${render.icons.error} ${render.wrap(render.c.red, `${issues} issue${issues > 1 ? "s" : ""} found`)} ${render.wrap(render.c.dim, "— see fixes above")}`);
5964
6631
  }
5965
6632
  console.log("");
5966
6633
  if (issues > 0)
@@ -6220,68 +6887,144 @@ memory.command("write-task-summary")
6220
6887
  // without parsing a JSON blob.
6221
6888
  console.log(memId);
6222
6889
  });
6223
- memory.command("search [query]").requiredOption("--agent <id>")
6890
+ memory.command("search [query]")
6891
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6224
6892
  .option("--q <query>", "search query (alias for positional arg)")
6225
6893
  .option("--limit <n>", "Max results", "5")
6226
6894
  .option("--tag <tag>")
6895
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
6896
+ .option("--url <url>", "Flair base URL (overrides --port)")
6897
+ .option("--port <port>", "Harper HTTP port")
6227
6898
  .action(async (queryArg, opts) => {
6899
+ const agentId = resolveAgentIdOrEnv(opts);
6900
+ if (!agentId) {
6901
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
6902
+ process.exit(2);
6903
+ }
6228
6904
  const q = queryArg ?? opts.q;
6229
6905
  if (!q) {
6230
6906
  console.error("error: query required (positional arg or --q)");
6231
6907
  process.exit(1);
6232
6908
  }
6233
- const body = { agentId: opts.agent, q, limit: parseInt(opts.limit, 10) || 5 };
6909
+ const body = { agentId, q, limit: parseInt(opts.limit, 10) || 5 };
6234
6910
  if (opts.tag)
6235
6911
  body.tag = opts.tag;
6236
- console.log(JSON.stringify(await api("POST", "/SemanticSearch", body), null, 2));
6912
+ const baseUrl = resolveBaseUrl(opts);
6913
+ const res = await api("POST", "/SemanticSearch", body, { baseUrl });
6914
+ console.log(JSON.stringify(res, null, 2));
6237
6915
  });
6238
6916
  memory.command("list")
6239
- .requiredOption("--agent <id>")
6917
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6240
6918
  .option("--tag <tag>")
6241
6919
  .option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
6242
6920
  .option("--limit <n>", "Max rows when using --hash-fallback", "50")
6921
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
6243
6922
  .action(async (opts) => {
6244
- const q = new URLSearchParams({ agentId: opts.agent, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
6923
+ const agentId = resolveAgentIdOrEnv(opts);
6924
+ if (!agentId) {
6925
+ console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
6926
+ process.exit(2);
6927
+ }
6928
+ const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
6245
6929
  const raw = await api("GET", `/Memory?${q}`);
6246
- if (!opts.hashFallback) {
6247
- console.log(JSON.stringify(raw, null, 2));
6930
+ const mode = render.resolveOutputMode(opts);
6931
+ // hashFallback flag changes the lens: instead of all memories, show
6932
+ // only those that need re-embedding. Keep that surface separate.
6933
+ if (opts.hashFallback) {
6934
+ const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
6935
+ const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
6936
+ if (mode === "json") {
6937
+ console.log(render.asJSON(fallback));
6938
+ return;
6939
+ }
6940
+ if (fallback.length === 0) {
6941
+ console.log(`${render.icons.ok} ${render.wrap(render.c.green, "All memories embedded")} ${render.wrap(render.c.dim, `(agent ${agentId})`)}`);
6942
+ return;
6943
+ }
6944
+ const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
6945
+ const rows = fallback
6946
+ .slice()
6947
+ .sort((a, b) => {
6948
+ const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
6949
+ const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
6950
+ return tb - ta;
6951
+ })
6952
+ .slice(0, limit);
6953
+ console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, String(fallback.length))} hash-fallback memories for agent ${render.wrap(render.c.bold, agentId)} ${render.wrap(render.c.dim, `(showing ${rows.length})`)}\n`);
6954
+ const cols = [
6955
+ { label: "id", key: "id" },
6956
+ {
6957
+ label: "created_at",
6958
+ key: "createdAt",
6959
+ format: (v) => (v ? String(v).slice(0, 19).replace("T", " ") : "—"),
6960
+ },
6961
+ {
6962
+ label: "preview",
6963
+ key: "content",
6964
+ format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
6965
+ },
6966
+ ];
6967
+ console.log(render.table(cols, rows));
6968
+ if (fallback.length > rows.length) {
6969
+ console.log(`\n${render.wrap(render.c.dim, `... ${fallback.length - rows.length} more (raise with --limit). To backfill:`)} flair reembed --agent ${agentId} --stale-only`);
6970
+ }
6971
+ else {
6972
+ console.log(`\n${render.wrap(render.c.dim, "To backfill:")} flair reembed --agent ${agentId} --stale-only`);
6973
+ }
6248
6974
  return;
6249
6975
  }
6250
- // --hash-fallback: filter to entries without a real embedding and print as a table.
6251
- // Same predicate HealthDetail uses: missing model or the "hash-512d" marker.
6976
+ // Default lens: all memories for the agent.
6252
6977
  const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
6253
- const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
6254
- if (fallback.length === 0) {
6255
- console.log(`No hash-fallback memories for agent ${opts.agent}. All embedded.`);
6978
+ if (mode === "json") {
6979
+ console.log(render.asJSON(all));
6980
+ return;
6981
+ }
6982
+ if (all.length === 0) {
6983
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `No memories for agent ${agentId}`)}`);
6256
6984
  return;
6257
6985
  }
6258
- const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
6259
- const rows = fallback
6986
+ console.log(`${render.wrap(render.c.bold, String(all.length))} memories for agent ${render.wrap(render.c.bold, agentId)}${opts.tag ? ` ${render.wrap(render.c.dim, `(tag=${opts.tag})`)}` : ""}\n`);
6987
+ const sorted = all
6260
6988
  .slice()
6261
6989
  .sort((a, b) => {
6262
6990
  const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
6263
6991
  const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
6264
6992
  return tb - ta;
6265
- })
6266
- .slice(0, limit);
6267
- const idW = Math.max(2, ...rows.map((r) => String(r.id ?? "").length));
6268
- console.log(`${fallback.length} hash-fallback memories for agent ${opts.agent} (showing ${rows.length}):\n`);
6269
- console.log(` ${"id".padEnd(idW)} created_at preview`);
6270
- for (const r of rows) {
6271
- const created = r.createdAt ? String(r.createdAt).slice(0, 19).replace("T", " ") : "—".padEnd(19);
6272
- const preview = String(r.content ?? "").replace(/\s+/g, " ").slice(0, 80);
6273
- console.log(` ${String(r.id ?? "").padEnd(idW)} ${created} ${preview}`);
6274
- }
6275
- if (fallback.length > rows.length) {
6276
- console.log(`\n... ${fallback.length - rows.length} more (raise with --limit). To backfill: flair reembed --agent ${opts.agent} --stale-only`);
6277
- }
6278
- else {
6279
- console.log(`\nTo backfill: flair reembed --agent ${opts.agent} --stale-only`);
6280
- }
6993
+ });
6994
+ const durabilityColor = (d) => {
6995
+ if (d === "permanent")
6996
+ return render.c.magenta;
6997
+ if (d === "persistent")
6998
+ return render.c.blue;
6999
+ if (d === "ephemeral")
7000
+ return render.c.gray;
7001
+ return render.c.cyan;
7002
+ };
7003
+ const cols = [
7004
+ {
7005
+ label: "created_at",
7006
+ key: "createdAt",
7007
+ format: (v) => (v ? render.wrap(render.c.dim, String(v).slice(0, 10)) : render.wrap(render.c.dim, "—")),
7008
+ },
7009
+ {
7010
+ label: "durability",
7011
+ key: "durability",
7012
+ format: (v) => {
7013
+ const d = String(v ?? "standard");
7014
+ return render.wrap(durabilityColor(d), d);
7015
+ },
7016
+ },
7017
+ {
7018
+ label: "preview",
7019
+ key: "content",
7020
+ format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
7021
+ },
7022
+ ];
7023
+ console.log(render.table(cols, sorted));
6281
7024
  });
6282
7025
  // ─── flair memory hygiene ────────────────────────────────────────────────────
6283
7026
  // Detect + remove junk memory rows that accumulate over time. Surfaced from
6284
- // the 2026-05-07 manual cleanup (ops-ucyy): rockit had 627 records, ~250 of
7027
+ // the 2026-05-07 manual cleanup (ops-ucyy): an instance had 627 records, ~250 of
6285
7028
  // them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
6286
7029
  // test content ("the quick brown fox..." / "Flair 251 test ..."), and
6287
7030
  // near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
@@ -6421,47 +7164,184 @@ memory.command("hygiene")
6421
7164
  console.log("hygiene --apply` on each peer to fan out.");
6422
7165
  });
6423
7166
  // ─── flair search (top-level shortcut) ───────────────────────────────────────
7167
+ // Parse --since / --as-of: accept ISO 8601 OR relative expressions
7168
+ // ("1h", "7d", "30m"). Returns ISO 8601 string or null if input is empty.
7169
+ // Returns the original string unchanged if it looks like a date (caller
7170
+ // passes through to server, which validates).
7171
+ function parseRelativeOrIso(input) {
7172
+ if (!input)
7173
+ return null;
7174
+ const m = input.match(/^(\d+)([smhdw])$/);
7175
+ if (!m)
7176
+ return input;
7177
+ const n = Number.parseInt(m[1], 10);
7178
+ const unit = m[2];
7179
+ const multMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
7180
+ return new Date(Date.now() - n * (multMs[unit] ?? 0)).toISOString();
7181
+ }
6424
7182
  program
6425
7183
  .command("search <query>")
6426
- .description("Search memories by meaning (shortcut for memory search)")
6427
- .requiredOption("--agent <id>", "Agent ID")
7184
+ .description("Search memories by meaning (shortcut for memory search) — filterable, with --explain ranking")
7185
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6428
7186
  .option("--limit <n>", "Max results", "5")
6429
7187
  .option("--port <port>", "Harper HTTP port")
6430
7188
  .option("--url <url>", "Flair base URL (overrides --port)")
7189
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
6431
7190
  .option("--key <path>", "Ed25519 private key path")
7191
+ // Server-side filters (forwarded to /SemanticSearch payload)
7192
+ .option("--tag <tag>", "Filter to memories carrying this tag")
7193
+ .option("--subject <subject>", "Filter to memories carrying this subject (case-insensitive)")
7194
+ .option("--subjects <list>", "Comma-separated list of subjects to OR-filter (case-insensitive)")
7195
+ .option("--since <iso-or-relative>", "Only memories created after this point (ISO 8601 or '7d'/'24h'/'30m')")
7196
+ .option("--as-of <iso>", "Temporal validity: only memories valid at this point (ISO 8601)")
7197
+ .option("--include-superseded", "Include memories that have been superseded")
7198
+ .option("--scoring <mode>", "Scoring mode: composite (default) re-ranks by durability/recency/retrieval; raw uses cosine similarity only", "composite")
7199
+ .option("--min-score <n>", "Drop results below this score (0..1)", "0")
7200
+ // Client-side filters (applied after server response)
7201
+ .option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
7202
+ .option("--source <name>", "Filter by source/agentId (client-side)")
7203
+ // Output modes
7204
+ .option("--explain", "Show score breakdown (composite, raw, durability, age, retrieval) per hit")
7205
+ .option("--json", "Output raw JSON array")
6432
7206
  .action(async (query, opts) => {
6433
7207
  try {
6434
- const baseUrl = opts.url || `http://127.0.0.1:${resolveHttpPort(opts)}`;
7208
+ const agentId = resolveAgentIdOrEnv(opts);
7209
+ if (!agentId) {
7210
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
7211
+ process.exit(2);
7212
+ }
7213
+ const baseUrl = resolveBaseUrl(opts);
6435
7214
  const headers = { "content-type": "application/json" };
6436
- const keyPath = opts.key || resolveKeyPath(opts.agent);
7215
+ const keyPath = opts.key || resolveKeyPath(agentId);
6437
7216
  if (keyPath) {
6438
- headers["authorization"] = buildEd25519Auth(opts.agent, "POST", "/SemanticSearch", keyPath);
7217
+ headers["authorization"] = buildEd25519Auth(agentId, "POST", "/SemanticSearch", keyPath);
6439
7218
  }
7219
+ // Build payload from CLI options. Server validates types.
7220
+ const payload = {
7221
+ agentId,
7222
+ q: query,
7223
+ limit: Number.parseInt(opts.limit, 10) || 5,
7224
+ scoring: opts.scoring === "raw" ? "raw" : "composite",
7225
+ };
7226
+ if (opts.tag)
7227
+ payload.tag = opts.tag;
7228
+ if (opts.subject)
7229
+ payload.subject = opts.subject;
7230
+ if (opts.subjects)
7231
+ payload.subjects = String(opts.subjects).split(",").map((s) => s.trim()).filter(Boolean);
7232
+ const since = parseRelativeOrIso(opts.since);
7233
+ if (since)
7234
+ payload.since = since;
7235
+ if (opts.asOf)
7236
+ payload.asOf = opts.asOf;
7237
+ if (opts.includeSuperseded)
7238
+ payload.includeSuperseded = true;
7239
+ const minScore = Number.parseFloat(opts.minScore ?? "0");
7240
+ if (Number.isFinite(minScore) && minScore > 0)
7241
+ payload.minScore = minScore;
6440
7242
  const res = await fetch(`${baseUrl}/SemanticSearch`, {
6441
7243
  method: "POST",
6442
7244
  headers,
6443
- body: JSON.stringify({ agentId: opts.agent, q: query, limit: parseInt(opts.limit, 10) }),
7245
+ body: JSON.stringify(payload),
6444
7246
  });
6445
7247
  if (!res.ok)
6446
7248
  throw new Error(await res.text());
6447
- const result = await res.json();
6448
- const results = result.results || result || [];
6449
- if (!Array.isArray(results) || results.length === 0) {
6450
- console.log("No results found.");
7249
+ const result = (await res.json());
7250
+ let results = result.results || result || [];
7251
+ if (!Array.isArray(results))
7252
+ results = [];
7253
+ // Client-side filters: durability + source. Server doesn't expose these
7254
+ // as conditions, so we filter after fetch.
7255
+ if (opts.durability) {
7256
+ const allowed = new Set(String(opts.durability).split(",").map((d) => d.trim()));
7257
+ results = results.filter((r) => allowed.has(r.durability ?? "standard"));
7258
+ }
7259
+ if (opts.source) {
7260
+ const allowed = new Set(String(opts.source).split(",").map((s) => s.trim()));
7261
+ results = results.filter((r) => allowed.has(r._source ?? r.agentId ?? ""));
7262
+ }
7263
+ const mode = render.resolveOutputMode(opts);
7264
+ if (mode === "json") {
7265
+ console.log(render.asJSON(results));
7266
+ return;
7267
+ }
7268
+ if (results.length === 0) {
7269
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No results found.")}`);
7270
+ const filters = [];
7271
+ if (opts.tag)
7272
+ filters.push(`tag=${opts.tag}`);
7273
+ if (opts.subject)
7274
+ filters.push(`subject=${opts.subject}`);
7275
+ if (opts.subjects)
7276
+ filters.push(`subjects=${opts.subjects}`);
7277
+ if (opts.since)
7278
+ filters.push(`since=${opts.since}`);
7279
+ if (opts.durability)
7280
+ filters.push(`durability=${opts.durability}`);
7281
+ if (opts.source)
7282
+ filters.push(`source=${opts.source}`);
7283
+ if (filters.length > 0) {
7284
+ console.log(` ${render.wrap(render.c.dim, "Filters:")} ${filters.join(render.wrap(render.c.dim, " · "))}`);
7285
+ console.log(` ${render.icons.arrow} ${render.wrap(render.c.dim, "Try removing a filter or:")} flair search "${query}" --agent ${agentId}`);
7286
+ }
6451
7287
  return;
6452
7288
  }
7289
+ const durabilityColor = (d) => {
7290
+ if (d === "permanent")
7291
+ return render.c.magenta;
7292
+ if (d === "persistent")
7293
+ return render.c.blue;
7294
+ if (d === "ephemeral")
7295
+ return render.c.gray;
7296
+ return render.c.cyan;
7297
+ };
6453
7298
  for (const r of results) {
6454
- const date = r.createdAt ? r.createdAt.slice(0, 10) : "";
6455
- const score = r._score ? `${(r._score * 100).toFixed(0)}%` : "";
6456
- const meta = [date, r.type, score].filter(Boolean).join(" · ");
7299
+ const date = r.createdAt ? String(r.createdAt).slice(0, 10) : "";
7300
+ const scoreVal = typeof r._score === "number" ? r._score : 0;
7301
+ const scorePct = typeof r._score === "number" ? `${(scoreVal * 100).toFixed(0)}%` : "";
7302
+ const scoreColor = scoreVal >= 0.7 ? render.c.green : scoreVal >= 0.4 ? render.c.yellow : render.c.dim;
7303
+ const durability = r.durability ?? "standard";
7304
+ const metaParts = [];
7305
+ if (date)
7306
+ metaParts.push(render.wrap(render.c.dim, date));
7307
+ metaParts.push(render.wrap(durabilityColor(durability), durability));
7308
+ if (scorePct)
7309
+ metaParts.push(render.wrap(scoreColor, scorePct));
7310
+ if (r._source)
7311
+ metaParts.push(render.wrap(render.c.cyan, `from:${r._source}`));
7312
+ const meta = metaParts.join(render.wrap(render.c.dim, " · "));
6457
7313
  console.log(` ${r.content}`);
6458
7314
  if (meta)
6459
- console.log(` (${meta})`);
7315
+ console.log(` ${render.wrap(render.c.dim, "(")} ${meta} ${render.wrap(render.c.dim, ")")}`);
7316
+ if (opts.explain) {
7317
+ const parts = [];
7318
+ if (typeof r._rawScore === "number")
7319
+ parts.push(`raw=${r._rawScore.toFixed(3)}`);
7320
+ if (typeof r._score === "number")
7321
+ parts.push(`composite=${r._score.toFixed(3)}`);
7322
+ if (typeof r.retrievalCount === "number" && r.retrievalCount > 0)
7323
+ parts.push(`retrievals=${r.retrievalCount}`);
7324
+ if (r.tags && Array.isArray(r.tags) && r.tags.length > 0)
7325
+ parts.push(`tags=[${r.tags.join(",")}]`);
7326
+ if (r.subject)
7327
+ parts.push(`subject=${r.subject}`);
7328
+ if (r.supersedes)
7329
+ parts.push(`supersedes=${r.supersedes}`);
7330
+ if (parts.length > 0) {
7331
+ console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, parts.join(" · "))}`);
7332
+ }
7333
+ }
6460
7334
  console.log();
6461
7335
  }
7336
+ if (opts.explain) {
7337
+ const formula = payload.scoring === "composite"
7338
+ ? "semantic × durability-weight × recency-decay × retrieval-boost"
7339
+ : "cosine similarity only";
7340
+ console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, payload.scoring)} ${render.wrap(render.c.dim, `(${formula})`)}`);
7341
+ }
6462
7342
  }
6463
7343
  catch (err) {
6464
- console.error(`Search failed: ${err.message}`);
7344
+ console.error(`${render.icons.error} Search failed: ${err.message}`);
6465
7345
  process.exit(1);
6466
7346
  }
6467
7347
  });
@@ -6483,58 +7363,179 @@ program
6483
7363
  program
6484
7364
  .command("bootstrap")
6485
7365
  .description("Cold-start context: get soul + recent memories as formatted text")
6486
- .requiredOption("--agent <id>", "Agent ID")
7366
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6487
7367
  .option("--max-tokens <n>", "Maximum tokens in output", "4000")
6488
7368
  .option("--port <port>", "Harper HTTP port")
6489
7369
  .option("--url <url>", "Flair base URL (overrides --port)")
7370
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
6490
7371
  .option("--key <path>", "Ed25519 private key path")
7372
+ .option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
6491
7373
  .action(async (opts) => {
6492
- const baseUrl = opts.url || `http://127.0.0.1:${resolveHttpPort(opts)}`;
7374
+ const agentId = resolveAgentIdOrEnv(opts);
7375
+ if (!agentId) {
7376
+ console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
7377
+ process.exit(2);
7378
+ }
7379
+ const baseUrl = resolveBaseUrl(opts);
7380
+ const mode = render.resolveOutputMode(opts);
6493
7381
  try {
6494
7382
  const headers = { "content-type": "application/json" };
6495
- const keyPath = opts.key || resolveKeyPath(opts.agent);
7383
+ const keyPath = opts.key || resolveKeyPath(agentId);
6496
7384
  if (keyPath) {
6497
- headers["authorization"] = buildEd25519Auth(opts.agent, "POST", "/BootstrapMemories", keyPath);
7385
+ headers["authorization"] = buildEd25519Auth(agentId, "POST", "/BootstrapMemories", keyPath);
6498
7386
  }
6499
7387
  const res = await fetch(`${baseUrl}/BootstrapMemories`, {
6500
7388
  method: "POST",
6501
7389
  headers,
6502
- body: JSON.stringify({ agentId: opts.agent, maxTokens: parseInt(opts.maxTokens, 10) }),
7390
+ body: JSON.stringify({ agentId, maxTokens: parseInt(opts.maxTokens, 10) }),
6503
7391
  });
6504
7392
  if (!res.ok) {
6505
7393
  const body = await res.text();
6506
7394
  throw new Error(`${res.status}: ${body}`);
6507
7395
  }
6508
- const result = await res.json();
7396
+ const result = (await res.json());
7397
+ if (mode === "json") {
7398
+ // Agent-first: emit the full server response, augmented with the cap
7399
+ // that was requested. Includes context, sections, tokenEstimate, etc.
7400
+ console.log(render.asJSON({ ...result, maxTokens: parseInt(opts.maxTokens, 10) }));
7401
+ return;
7402
+ }
7403
+ // Human mode: print context to stdout, budget footer to stderr (parseable).
6509
7404
  if (result.context) {
6510
7405
  console.log(result.context);
6511
7406
  }
6512
7407
  else {
6513
- console.error("No context available.");
7408
+ console.error(`${render.icons.error} No context available.`);
6514
7409
  process.exit(1);
6515
7410
  }
6516
- // Print budget footer to stderr (parseable, won't interfere with context output)
6517
7411
  const tokensUsed = result.tokenEstimate ?? 0;
6518
7412
  const maxTokens = parseInt(opts.maxTokens, 10);
6519
7413
  const included = result.memoriesIncluded ?? 0;
6520
7414
  const truncated = result.memoriesTruncated ?? 0;
6521
- console.error(`[budget: ${tokensUsed}/${maxTokens} tokens, ${included} included, ${truncated} truncated]`);
7415
+ const tokenPct = maxTokens > 0 ? (tokensUsed / maxTokens) * 100 : 0;
7416
+ const tokenIcon = tokenPct >= 90 ? render.icons.warn : tokenPct >= 70 ? render.icons.info : render.icons.ok;
7417
+ const truncIcon = truncated > 0 ? render.icons.warn : render.icons.ok;
7418
+ console.error(`${tokenIcon} budget ${tokensUsed}/${maxTokens} tokens (${tokenPct.toFixed(0)}%) ${render.icons.bullet} ${render.icons.ok} ${included} included ${render.icons.bullet} ${truncIcon} ${truncated} truncated`);
6522
7419
  }
6523
7420
  catch (err) {
6524
- console.error(`Bootstrap failed: ${err.message}`);
7421
+ console.error(`${render.icons.error} Bootstrap failed: ${err.message}`);
6525
7422
  process.exit(1);
6526
7423
  }
6527
7424
  });
6528
7425
  const soul = program.command("soul").description("Manage agent soul entries");
6529
- soul.command("set").requiredOption("--agent <id>").requiredOption("--key <key>").requiredOption("--value <value>")
7426
+ soul.command("set")
7427
+ .requiredOption("--agent <id>")
7428
+ .requiredOption("--key <key>")
7429
+ .requiredOption("--value <value>")
6530
7430
  .option("--durability <d>", "permanent")
7431
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
6531
7432
  .action(async (opts) => {
6532
- const out = await api("POST", "/Soul", { id: `${opts.agent}:${opts.key}`, agentId: opts.agent, key: opts.key, value: opts.value, durability: opts.durability });
6533
- console.log(JSON.stringify(out, null, 2));
7433
+ const out = await api("POST", "/Soul", {
7434
+ id: `${opts.agent}:${opts.key}`,
7435
+ agentId: opts.agent,
7436
+ key: opts.key,
7437
+ value: opts.value,
7438
+ durability: opts.durability,
7439
+ });
7440
+ const mode = render.resolveOutputMode(opts);
7441
+ if (mode === "json") {
7442
+ console.log(render.asJSON(out));
7443
+ return;
7444
+ }
7445
+ console.log(`${render.icons.ok} ${render.wrap(render.c.green, "soul entry set")}`);
7446
+ console.log(render.kv("agent", opts.agent));
7447
+ console.log(render.kv("key", render.wrap(render.c.bold, opts.key)));
7448
+ console.log(render.kv("value", String(opts.value)));
7449
+ if (opts.durability)
7450
+ console.log(render.kv("durability", render.wrap(render.c.magenta, opts.durability)));
7451
+ });
7452
+ soul.command("get")
7453
+ .argument("<id>")
7454
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
7455
+ .action(async (id, opts) => {
7456
+ const out = await api("GET", `/Soul/${id}`);
7457
+ const mode = render.resolveOutputMode(opts);
7458
+ if (mode === "json") {
7459
+ console.log(render.asJSON(out));
7460
+ return;
7461
+ }
7462
+ if (!out || (typeof out === "object" && !out.id)) {
7463
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no entry")}`);
7464
+ return;
7465
+ }
7466
+ console.log(render.wrap(render.c.bold, out.id ?? id));
7467
+ if (out.agentId)
7468
+ console.log(render.kv("agent", out.agentId));
7469
+ if (out.key)
7470
+ console.log(render.kv("key", out.key));
7471
+ if (out.value !== undefined)
7472
+ console.log(render.kv("value", String(out.value)));
7473
+ if (out.durability)
7474
+ console.log(render.kv("durability", render.wrap(render.c.magenta, String(out.durability))));
7475
+ if (out.priority)
7476
+ console.log(render.kv("priority", String(out.priority)));
7477
+ if (out.createdAt)
7478
+ console.log(render.kv("created", `${render.relativeTime(out.createdAt)} ${render.wrap(render.c.dim, `(${out.createdAt})`)}`));
7479
+ if (out.updatedAt && out.updatedAt !== out.createdAt) {
7480
+ console.log(render.kv("updated", `${render.relativeTime(out.updatedAt)} ${render.wrap(render.c.dim, `(${out.updatedAt})`)}`));
7481
+ }
7482
+ });
7483
+ soul.command("list")
7484
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
7485
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
7486
+ .action(async (opts) => {
7487
+ const agentId = resolveAgentIdOrEnv(opts);
7488
+ if (!agentId) {
7489
+ console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
7490
+ process.exit(2);
7491
+ }
7492
+ const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`);
7493
+ const mode = render.resolveOutputMode(opts);
7494
+ if (mode === "json") {
7495
+ console.log(render.asJSON(out));
7496
+ return;
7497
+ }
7498
+ const all = Array.isArray(out) ? out : (out?.results ?? out?.items ?? []);
7499
+ if (all.length === 0) {
7500
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no soul entries for agent ${agentId}`)}`);
7501
+ return;
7502
+ }
7503
+ console.log(`${render.wrap(render.c.bold, String(all.length))} soul entries for agent ${render.wrap(render.c.bold, agentId)}\n`);
7504
+ const priorityColor = (p) => {
7505
+ if (p === "critical")
7506
+ return render.c.red;
7507
+ if (p === "high")
7508
+ return render.c.yellow;
7509
+ if (p === "low")
7510
+ return render.c.gray;
7511
+ return render.c.cyan;
7512
+ };
7513
+ const cols = [
7514
+ { label: "key", key: "key", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
7515
+ {
7516
+ label: "priority",
7517
+ key: "priority",
7518
+ format: (v) => {
7519
+ const p = String(v ?? "standard");
7520
+ return render.wrap(priorityColor(p), p);
7521
+ },
7522
+ },
7523
+ {
7524
+ label: "durability",
7525
+ key: "durability",
7526
+ format: (v) => {
7527
+ const d = String(v ?? "—");
7528
+ return d === "permanent" ? render.wrap(render.c.magenta, d) : render.wrap(render.c.dim, d);
7529
+ },
7530
+ },
7531
+ {
7532
+ label: "value",
7533
+ key: "value",
7534
+ format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
7535
+ },
7536
+ ];
7537
+ console.log(render.table(cols, all));
6534
7538
  });
6535
- soul.command("get").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Soul/${id}`), null, 2)));
6536
- soul.command("list").requiredOption("--agent <id>")
6537
- .action(async (opts) => console.log(JSON.stringify(await api("GET", `/Soul?agentId=${encodeURIComponent(opts.agent)}`), null, 2)));
6538
7539
  // ─── flair bridge ────────────────────────────────────────────────────────────
6539
7540
  // Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
6540
7541
  // + agentic-stack reference adapter as a built-in.
@@ -6549,24 +7550,39 @@ bridge
6549
7550
  const { discover } = await import("./bridges/discover.js");
6550
7551
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
6551
7552
  const found = await discover({ builtins: builtinDiscoveryRecords() });
6552
- if (opts.json) {
6553
- console.log(JSON.stringify(found, null, 2));
7553
+ const mode = render.resolveOutputMode(opts);
7554
+ if (mode === "json") {
7555
+ console.log(render.asJSON(found));
6554
7556
  return;
6555
7557
  }
6556
7558
  if (found.length === 0) {
6557
- console.log("No bridges installed.");
6558
- console.log("Add one with: flair bridge scaffold <name> --file");
6559
- console.log("Or install from npm: npm install flair-bridge-<name>");
7559
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No bridges installed.")}`);
7560
+ console.log(`${render.wrap(render.c.dim, " Add one with:")} flair bridge scaffold <name> --file`);
7561
+ console.log(`${render.wrap(render.c.dim, " Or install from npm:")} npm install flair-bridge-<name>`);
6560
7562
  return;
6561
7563
  }
6562
- const nameW = Math.max(4, ...found.map((b) => b.name.length));
6563
- const kindW = Math.max(4, ...found.map((b) => b.kind.length));
6564
- const srcW = Math.max(6, ...found.map((b) => b.source.length));
6565
- console.log(` ${"name".padEnd(nameW)} ${"kind".padEnd(kindW)} ${"source".padEnd(srcW)} description`);
6566
- for (const b of found) {
6567
- const desc = b.description ?? "";
6568
- console.log(` ${b.name.padEnd(nameW)} ${b.kind.padEnd(kindW)} ${b.source.padEnd(srcW)} ${desc}`);
6569
- }
7564
+ console.log(`${render.wrap(render.c.bold, String(found.length))} bridge${found.length === 1 ? "" : "s"}\n`);
7565
+ const cols = [
7566
+ { label: "name", key: "name", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
7567
+ {
7568
+ label: "kind",
7569
+ key: "kind",
7570
+ format: (v) => {
7571
+ const k = String(v ?? "—");
7572
+ return render.wrap(k === "yaml" ? render.c.cyan : k === "api" ? render.c.magenta : render.c.dim, k);
7573
+ },
7574
+ },
7575
+ {
7576
+ label: "source",
7577
+ key: "source",
7578
+ format: (v) => {
7579
+ const s = String(v ?? "—");
7580
+ return render.wrap(s === "builtin" ? render.c.green : render.c.dim, s);
7581
+ },
7582
+ },
7583
+ { label: "description", key: "description", format: (v) => String(v ?? "") },
7584
+ ];
7585
+ console.log(render.table(cols, found));
6570
7586
  });
6571
7587
  bridge
6572
7588
  .command("scaffold <name>")
@@ -7004,21 +8020,22 @@ bridge
7004
8020
  .action(async (opts) => {
7005
8021
  const { list: listAllowed } = await import("./bridges/runtime/allow-list.js");
7006
8022
  const entries = await listAllowed();
7007
- if (opts.json) {
7008
- console.log(JSON.stringify(entries, null, 2));
8023
+ const mode = render.resolveOutputMode(opts);
8024
+ if (mode === "json") {
8025
+ console.log(render.asJSON(entries));
7009
8026
  return;
7010
8027
  }
7011
8028
  if (entries.length === 0) {
7012
- console.log("No code-plugin bridges are allow-listed yet.");
7013
- console.log("Allow one with: flair bridge allow <name>");
8029
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No code-plugin bridges are allow-listed yet.")}`);
8030
+ console.log(`${render.wrap(render.c.dim, " Allow one with:")} flair bridge allow <name>`);
7014
8031
  return;
7015
8032
  }
7016
- const nameW = Math.max(4, ...entries.map((e) => e.name.length));
7017
- const verW = Math.max(7, ...entries.map((e) => (e.version ?? "—").length));
7018
- console.log(` ${"name".padEnd(nameW)} ${"version".padEnd(verW)} allowed-at location / digest`);
8033
+ console.log(`${render.wrap(render.c.bold, String(entries.length))} allow-listed code-plugin bridge${entries.length === 1 ? "" : "s"}\n`);
7019
8034
  for (const e of entries) {
7020
- console.log(` ${e.name.padEnd(nameW)} ${(e.version ?? "—").padEnd(verW)} ${e.allowedAt} ${e.packageDir}`);
7021
- console.log(` ${" ".repeat(nameW)} ${" ".repeat(verW)} ${" ".repeat(24)} sha256:${e.packageJsonSha256.slice(0, 16)}…`);
8035
+ console.log(`${render.wrap(render.c.bold, e.name)} ${render.wrap(render.c.dim, `(${e.version ?? "—"})`)} ${render.wrap(render.c.green, "✓ allowed")} ${render.wrap(render.c.dim, e.allowedAt)}`);
8036
+ console.log(render.kv("location", render.wrap(render.c.dim, e.packageDir)));
8037
+ console.log(render.kv("digest", render.wrap(render.c.dim, `sha256:${e.packageJsonSha256.slice(0, 16)}…`)));
8038
+ console.log();
7022
8039
  }
7023
8040
  });
7024
8041
  function printBridgeError(err) {
@@ -7116,13 +8133,26 @@ program
7116
8133
  .option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
7117
8134
  .option("--port <port>", "Harper HTTP port")
7118
8135
  .option("--url <url>", "Flair base URL (overrides --port)")
7119
- .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
8136
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
8137
+ .option("--admin-pass-file <path>", "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
7120
8138
  .action(async (opts) => {
7121
8139
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
7122
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8140
+ let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8141
+ if (!adminPass && opts.adminPassFile) {
8142
+ // readAdminPassFileSecure refuses world/group readable files (mode 0600
8143
+ // recommended). Common gotcha: files generated via
8144
+ // `openssl rand -base64 24 > admin-pass` end in a newline; helper trims it.
8145
+ try {
8146
+ adminPass = readAdminPassFileSecure(opts.adminPassFile);
8147
+ }
8148
+ catch (err) {
8149
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
8150
+ process.exit(1);
8151
+ }
8152
+ }
7123
8153
  const adminUser = DEFAULT_ADMIN_USER;
7124
8154
  if (!adminPass) {
7125
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for backup");
8155
+ console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
7126
8156
  process.exit(1);
7127
8157
  }
7128
8158
  const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
@@ -7181,11 +8211,11 @@ program
7181
8211
  const tmp = outputPath + ".tmp";
7182
8212
  writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
7183
8213
  renameSync(tmp, outputPath);
7184
- console.log(`\n Backup complete`);
7185
- console.log(` Agents: ${agents.length}`);
7186
- console.log(` Memories: ${memories.length}`);
7187
- console.log(` Souls: ${souls.length}`);
7188
- console.log(` Output: ${outputPath}`);
8214
+ console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
8215
+ console.log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
8216
+ console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
8217
+ console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
8218
+ console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
7189
8219
  });
7190
8220
  // ─── flair restore ────────────────────────────────────────────────────────────
7191
8221
  program
@@ -7299,10 +8329,10 @@ program
7299
8329
  console.warn(` warn: soul ${soul.id}: ${err.message}`);
7300
8330
  }
7301
8331
  }
7302
- console.log(`\n Restore complete`);
7303
- console.log(` Agents restored: ${agentCount}/${agents.length}`);
7304
- console.log(` Memories restored: ${memoryCount}/${memories.length}`);
7305
- console.log(` Souls restored: ${soulCount}/${souls.length}`);
8332
+ console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Restore complete")}`);
8333
+ console.log(render.kv("Agents restored", `${render.wrap(render.c.bold, String(agentCount))}${render.wrap(render.c.dim, `/${agents.length}`)}`));
8334
+ console.log(render.kv("Memories restored", `${render.wrap(render.c.bold, String(memoryCount))}${render.wrap(render.c.dim, `/${memories.length}`)}`));
8335
+ console.log(render.kv("Souls restored", `${render.wrap(render.c.bold, String(soulCount))}${render.wrap(render.c.dim, `/${souls.length}`)}`));
7306
8336
  });
7307
8337
  // ─── flair export ────────────────────────────────────────────────────────────
7308
8338
  program
@@ -7384,13 +8414,16 @@ program
7384
8414
  writeFileSync(outputPath, JSON.stringify(exportData, null, 2), { mode: fileMode });
7385
8415
  if (privateKey)
7386
8416
  chmodSync(outputPath, 0o600); // enforce even if umask is permissive
7387
- console.log(`\n Agent '${agentId}' exported`);
7388
- console.log(` Memories: ${memories.length}`);
7389
- console.log(` Souls: ${souls.length}`);
7390
- console.log(` Grants: ${grants.length}`);
7391
- console.log(` Key: ${privateKey ? "included (UNENCRYPTED — protect this file)" : "not included"}`);
7392
- console.log(` Mode: ${fileMode.toString(8)} (${privateKey ? "owner-only" : "standard"})`);
7393
- console.log(` Output: ${outputPath}`);
8417
+ console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, `Agent '${agentId}' exported`)}`);
8418
+ console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
8419
+ console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
8420
+ console.log(render.kv("Grants", render.wrap(render.c.bold, String(grants.length))));
8421
+ const keyText = privateKey
8422
+ ? `${render.wrap(render.c.magenta, "included")} ${render.wrap(render.c.red, "(UNENCRYPTED protect this file)")}`
8423
+ : render.wrap(render.c.dim, "not included");
8424
+ console.log(render.kv("Key", keyText));
8425
+ console.log(render.kv("Mode", `${fileMode.toString(8)} ${render.wrap(render.c.dim, `(${privateKey ? "owner-only" : "standard"})`)}`));
8426
+ console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
7394
8427
  });
7395
8428
  // ─── flair import ────────────────────────────────────────────────────────────
7396
8429
  program
@@ -7481,40 +8514,50 @@ program
7481
8514
  }
7482
8515
  catch { /* skip failures */ }
7483
8516
  }
7484
- console.log(`\n Agent '${agentId}' imported`);
7485
- console.log(` Memories: ${memCount}/${(data.memories ?? []).length}`);
7486
- console.log(` Souls: ${soulCount}/${(data.souls ?? []).length}`);
7487
- console.log(` Key: ${privPath}`);
8517
+ console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, `Agent '${agentId}' imported`)}`);
8518
+ console.log(render.kv("Memories", `${render.wrap(render.c.bold, String(memCount))}${render.wrap(render.c.dim, `/${(data.memories ?? []).length}`)}`));
8519
+ console.log(render.kv("Souls", `${render.wrap(render.c.bold, String(soulCount))}${render.wrap(render.c.dim, `/${(data.souls ?? []).length}`)}`));
8520
+ console.log(render.kv("Key", render.wrap(render.c.dim, privPath)));
7488
8521
  });
7489
8522
  // ─── flair backup inspect ────────────────────────────────────────────────────
7490
8523
  program
7491
8524
  .command("inspect <path>")
7492
8525
  .description("Show contents of a backup or export file")
7493
- .action(async (filePath) => {
8526
+ .option("--json", "Emit raw JSON of the file (also: pipe + FLAIR_OUTPUT=json)")
8527
+ .action(async (filePath, opts) => {
7494
8528
  if (!existsSync(filePath)) {
7495
- console.error(`File not found: ${filePath}`);
8529
+ console.error(`${render.icons.error} File not found: ${render.wrap(render.c.dim, filePath)}`);
7496
8530
  process.exit(1);
7497
8531
  }
7498
8532
  const data = JSON.parse(readFileSync(filePath, "utf-8"));
7499
- console.log(`File: ${filePath}`);
7500
- console.log(`Type: ${data.type ?? "full-backup"}`);
7501
- console.log(`Created: ${data.createdAt ?? data.exportedAt ?? "unknown"}`);
7502
- console.log(`Source: ${data.source ?? "unknown"}`);
8533
+ const mode = render.resolveOutputMode(opts);
8534
+ if (mode === "json") {
8535
+ console.log(render.asJSON(data));
8536
+ return;
8537
+ }
8538
+ console.log(`${render.wrap(render.c.bold, "File:")} ${render.wrap(render.c.dim, filePath)}`);
8539
+ const type = data.type ?? "full-backup";
8540
+ const typeColor = type === "agent-export" ? render.c.cyan : render.c.magenta;
8541
+ console.log(render.kv("Type", render.wrap(typeColor, type)));
8542
+ console.log(render.kv("Created", String(data.createdAt ?? data.exportedAt ?? render.wrap(render.c.dim, "unknown"))));
8543
+ console.log(render.kv("Source", String(data.source ?? render.wrap(render.c.dim, "unknown"))));
7503
8544
  if (data.type === "agent-export") {
7504
- console.log(`\nAgent: ${data.agent?.id ?? "unknown"}`);
7505
- console.log(` Name: ${data.agent?.name ?? data.agent?.id}`);
7506
- console.log(` Memories: ${(data.memories ?? []).length}`);
7507
- console.log(` Souls: ${(data.souls ?? []).length}`);
7508
- console.log(` Grants: ${(data.grants ?? []).length}`);
7509
- console.log(` Key included: ${data.privateKey ? "yes" : "no"}`);
8545
+ console.log(`\n${render.wrap(render.c.bold, "Agent")}: ${render.wrap(render.c.bold, data.agent?.id ?? "unknown")}`);
8546
+ console.log(render.kv("Name", String(data.agent?.name ?? data.agent?.id ?? "—")));
8547
+ console.log(render.kv("Memories", render.wrap(render.c.bold, String((data.memories ?? []).length))));
8548
+ console.log(render.kv("Souls", render.wrap(render.c.bold, String((data.souls ?? []).length))));
8549
+ console.log(render.kv("Grants", render.wrap(render.c.bold, String((data.grants ?? []).length))));
8550
+ const keyText = data.privateKey ? render.wrap(render.c.magenta, "yes") : render.wrap(render.c.dim, "no");
8551
+ console.log(render.kv("Key included", keyText));
7510
8552
  }
7511
8553
  else {
7512
8554
  const agents = data.agents ?? [];
7513
- console.log(`\nAgents: ${agents.length}`);
7514
- for (const a of agents)
7515
- console.log(` - ${a.id} (${a.name ?? a.id})`);
7516
- console.log(`Memories: ${(data.memories ?? []).length}`);
7517
- console.log(`Souls: ${(data.souls ?? []).length}`);
8555
+ console.log(`\n${render.wrap(render.c.bold, "Agents")}: ${render.wrap(render.c.bold, String(agents.length))}`);
8556
+ for (const a of agents) {
8557
+ console.log(` ${render.wrap(render.c.dim, "·")} ${render.wrap(render.c.bold, a.id)} ${render.wrap(render.c.dim, `(${a.name ?? a.id})`)}`);
8558
+ }
8559
+ console.log(render.kv("Memories", render.wrap(render.c.bold, String((data.memories ?? []).length))));
8560
+ console.log(render.kv("Souls", render.wrap(render.c.bold, String((data.souls ?? []).length))));
7518
8561
  }
7519
8562
  });
7520
8563
  // ─── flair migrate-harness-memory ───────────────────────────────────────────