@tpsdev-ai/flair 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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 })) : [];
2006
2053
  }
2054
+ agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
2055
+ if (mode === "json") {
2056
+ console.log(render.asJSON(agents));
2057
+ return;
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
  });
@@ -3146,6 +3359,12 @@ export async function runFederationSyncOnce(opts) {
3146
3359
  }
3147
3360
  console.log(`Syncing to hub: ${hub.id}...`);
3148
3361
  const since = hub.lastSyncAt ?? new Date(0).toISOString();
3362
+ // Capture sync start time BEFORE we query records. We advance the local
3363
+ // hub peer's lastSyncAt to this value after success so the next poll's
3364
+ // `since` cursor moves forward — fixes task #146 (federation peer
3365
+ // .lastSyncAt update bug). Records updated DURING this sync will have
3366
+ // updatedAt > syncStartedAt and be picked up next cycle, not missed.
3367
+ const syncStartedAt = new Date().toISOString();
3149
3368
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
3150
3369
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
3151
3370
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
@@ -3224,6 +3443,35 @@ export async function runFederationSyncOnce(opts) {
3224
3443
  totalBatches++;
3225
3444
  }
3226
3445
  }
3446
+ // Advance the local hub peer's lastSyncAt cursor. The hub-side
3447
+ // FederationSync handler updates ITS view of the spoke peer, but the
3448
+ // spoke never updated its own view of the hub — so `since` stayed at
3449
+ // whatever value was on the peer record at pair time (often near-epoch),
3450
+ // and every poll re-queried `updatedAt > since` and re-sent every
3451
+ // memory ever written. The receiver-side contentHash gate in
3452
+ // Federation.ts prevents the actual blob re-write, but advancing the
3453
+ // cursor here stops the redundant network traffic + Lambda compute
3454
+ // entirely. Task #146. Even no-change runs should advance.
3455
+ try {
3456
+ const advanceRes = await fetch(`${opsEndpoint}/`, {
3457
+ method: "POST",
3458
+ headers: { "Content-Type": "application/json", Authorization: auth },
3459
+ body: JSON.stringify({
3460
+ operation: "update",
3461
+ database: "flair",
3462
+ table: "Peer",
3463
+ records: [{ id: hub.id, lastSyncAt: syncStartedAt }],
3464
+ }),
3465
+ signal: AbortSignal.timeout(10_000),
3466
+ });
3467
+ if (!advanceRes.ok) {
3468
+ const txt = await advanceRes.text().catch(() => "");
3469
+ console.warn(`⚠️ Local hub.lastSyncAt advance failed (${advanceRes.status}): ${txt.slice(0, 200)}. Next poll will re-send memories.`);
3470
+ }
3471
+ }
3472
+ catch (advErr) {
3473
+ console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
3474
+ }
3227
3475
  if (totalBatches === 0) {
3228
3476
  console.log("No changes since last sync.");
3229
3477
  return { pushed: 0, skipped: 0 };
@@ -3784,7 +4032,7 @@ rem
3784
4032
  process.exit(1);
3785
4033
  }
3786
4034
  if (!agentId) {
3787
- console.error("Error: --agent is required (or set FLAIR_AGENT_ID)");
4035
+ console.error(`${render.icons.error} --agent is required (or set FLAIR_AGENT_ID)`);
3788
4036
  process.exit(1);
3789
4037
  }
3790
4038
  try {
@@ -3796,42 +4044,49 @@ rem
3796
4044
  ],
3797
4045
  get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
3798
4046
  });
3799
- // search_by_conditions returns either an array or { error }; tolerate both shapes
3800
4047
  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));
4048
+ const mode = render.resolveOutputMode(opts);
4049
+ if (mode === "json") {
4050
+ console.log(render.asJSON({ agentId, status, count: candidates.length, candidates }));
3803
4051
  return;
3804
4052
  }
3805
- console.log(`\n-- rem candidates (agent=${agentId}, status=${status}) --\n`);
4053
+ const statusColor = status === "promoted" ? render.c.green : status === "rejected" ? render.c.red : render.c.yellow;
4054
+ 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
4055
  if (candidates.length === 0) {
3807
- console.log(`No ${status} candidates.`);
4056
+ console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, `No ${status} candidates.`)}`);
3808
4057
  if (status === "pending") {
3809
- console.log("\n(Run `flair rem nightly enable` to start the nightly distillation cycle that populates this table.)");
4058
+ 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
4059
  }
3811
4060
  return;
3812
4061
  }
3813
- // Sort newest-first by generatedAt
3814
4062
  candidates.sort((a, b) => String(b.generatedAt ?? "").localeCompare(String(a.generatedAt ?? "")));
4063
+ console.log();
3815
4064
  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}`);
4065
+ let tag;
4066
+ if (c.status === "promoted") {
4067
+ 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)}`)}`;
4068
+ }
4069
+ else if (c.status === "rejected") {
4070
+ tag = `${render.wrap(render.c.red, "✗ rejected")} ${render.wrap(render.c.dim, `by ${c.reviewerId ?? "?"} ${render.relativeTime(c.decidedAt)}`)}`;
4071
+ }
4072
+ else {
4073
+ tag = `${render.wrap(render.c.yellow, "○ pending")} ${render.wrap(render.c.dim, `— ${c.generatedBy ?? "?"} ${render.relativeTime(c.generatedAt)}`)}`;
4074
+ }
4075
+ console.log(` ${render.wrap(render.c.dim, c.id)} ${tag}`);
3822
4076
  console.log(` ${c.claim}`);
3823
- if (c.supersedes)
3824
- console.log(` (supersedes ${c.supersedes} — recurring proposal)`);
4077
+ if (c.supersedes) {
4078
+ console.log(` ${render.wrap(render.c.dim, `(supersedes ${c.supersedes} — recurring proposal)`)}`);
4079
+ }
3825
4080
  console.log("");
3826
4081
  }
3827
- console.log(`${candidates.length} candidate${candidates.length > 1 ? "s" : ""}.`);
4082
+ console.log(`${render.wrap(render.c.bold, String(candidates.length))} candidate${candidates.length > 1 ? "s" : ""}.`);
3828
4083
  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>"`);
4084
+ console.log(`${render.wrap(render.c.dim, "Promote:")} flair rem promote <id> --rationale "<why>" --to (soul|memory)`);
4085
+ console.log(`${render.wrap(render.c.dim, "Reject: ")} flair rem reject <id> --reason "<why>"`);
3831
4086
  }
3832
4087
  }
3833
4088
  catch (err) {
3834
- console.error(`Error: ${err.message}`);
4089
+ console.error(`${render.icons.error} ${err.message}`);
3835
4090
  process.exit(1);
3836
4091
  }
3837
4092
  });
@@ -4626,139 +4881,176 @@ const statusCmd = program
4626
4881
  })
4627
4882
  : warnings;
4628
4883
  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}`);
4884
+ const headerIcon = hasWarn ? render.icons.warn : render.icons.ok;
4885
+ const versionStr = render.wrap(render.c.bold, `Flair v${__pkgVersion}`);
4886
+ const runStatus = `${headerIcon} ${render.wrap(render.c.green, "running")}`;
4887
+ const pidPart = pid ? render.wrap(render.c.dim, `PID ${pid}`) : "";
4888
+ const uptimePart = uptimeStr ? render.wrap(render.c.dim, `uptime ${uptimeStr}`) : "";
4889
+ const metaParts = [pidPart, uptimePart].filter(Boolean).join(render.wrap(render.c.dim, " · "));
4890
+ console.log(`${versionStr} ${render.wrap(render.c.dim, "—")} ${runStatus}${metaParts ? ` ${metaParts}` : ""}`);
4891
+ console.log(render.kv("URL", baseUrl));
4632
4892
  if (scopedWarnings.length > 0) {
4633
- console.log(`\n Warnings: ${scopedWarnings.length}`);
4634
- for (const w of scopedWarnings)
4635
- console.log(` • ${w.level} ${w.message}`);
4893
+ console.log(`\n${render.wrap(render.c.bold, "Warnings")} ${render.wrap(render.c.dim, `(${scopedWarnings.length})`)}`);
4894
+ for (const w of scopedWarnings) {
4895
+ const icon = w.level === "warn" ? render.icons.warn : render.icons.info;
4896
+ console.log(` ${icon} ${w.message}`);
4897
+ }
4636
4898
  }
4637
4899
  if (memories) {
4638
- console.log("\nMemory:");
4900
+ console.log(`\n${render.wrap(render.c.bold, "Memory")}`);
4639
4901
  const embStr = memories.withEmbeddings > 0 ? `${memories.withEmbeddings} embedded` : "";
4640
4902
  const hashStr = memories.hashFallback > 0 ? `${memories.hashFallback} hash` : "";
4641
4903
  const detail = [embStr, hashStr].filter(Boolean).join(", ");
4642
- console.log(` Total: ${memories.total}${detail ? ` (${detail})` : ""}`);
4904
+ console.log(render.kv("Total", `${render.wrap(render.c.bold, String(memories.total))}${detail ? ` ${render.wrap(render.c.dim, `(${detail})`)}` : ""}`));
4643
4905
  if (memories.modelCounts && typeof memories.modelCounts === "object") {
4644
4906
  const entries = Object.entries(memories.modelCounts)
4645
4907
  .filter(([, n]) => n > 0)
4646
4908
  .sort((a, b) => b[1] - a[1]);
4647
4909
  if (entries.length > 0) {
4648
- const formatted = entries.map(([k, n]) => `${k}: ${n}`).join(", ");
4649
- console.log(` Embeddings: ${formatted}`);
4910
+ const formatted = entries.map(([k, n]) => `${render.wrap(render.c.cyan, k)}:${n}`).join(render.wrap(render.c.dim, ", "));
4911
+ console.log(render.kv("Embeddings", formatted));
4650
4912
  }
4651
4913
  }
4652
4914
  if (memories.byDurability) {
4653
4915
  const d = memories.byDurability;
4654
- console.log(` Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
4916
+ const parts = [
4917
+ `${render.wrap(render.c.magenta, "permanent")}:${d.permanent ?? 0}`,
4918
+ `${render.wrap(render.c.blue, "persistent")}:${d.persistent ?? 0}`,
4919
+ `${render.wrap(render.c.cyan, "standard")}:${d.standard ?? 0}`,
4920
+ `${render.wrap(render.c.gray, "ephemeral")}:${d.ephemeral ?? 0}`,
4921
+ ];
4922
+ console.log(render.kv("Durability", parts.join(render.wrap(render.c.dim, " · "))));
4655
4923
  }
4656
4924
  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}`);
4925
+ console.log(render.kv("Archived", String(memories.archived)));
4926
+ if (typeof memories.expired === "number" && memories.expired > 0) {
4927
+ console.log(render.kv("Expired", `${render.wrap(render.c.yellow, String(memories.expired))}`));
4928
+ }
4660
4929
  if (healthData?.lastWrite)
4661
- console.log(` Last write: ${relativeTime(healthData.lastWrite)}`);
4930
+ console.log(render.kv("Last write", render.relativeTime(healthData.lastWrite)));
4662
4931
  }
4663
4932
  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}`);
4933
+ console.log(`\n${render.wrap(render.c.bold, "Agents")}`);
4934
+ const nameStr = agents.names?.length > 0 ? ` ${render.wrap(render.c.dim, "")} ${agents.names.join(render.wrap(render.c.dim, ", "))}` : "";
4935
+ console.log(render.kv("Total", `${render.wrap(render.c.bold, String(agents.count))}${nameStr}`));
4667
4936
  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
4937
  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
- }
4938
+ const cols = hasDeep
4939
+ ? [
4940
+ { label: "id", key: "id" },
4941
+ { label: "memories", key: "memoryCount", align: "right" },
4942
+ { label: "hash_fb", key: "hashFallback", align: "right", format: (v) => (typeof v === "number" ? String(v) : "—") },
4943
+ { label: "24h", key: "writes24h", align: "right", format: (v) => (typeof v === "number" ? String(v) : "—") },
4944
+ { label: "last_write", key: "lastWriteAt", format: (v) => render.relativeTime(v) },
4945
+ ]
4946
+ : [
4947
+ { label: "id", key: "id" },
4948
+ { label: "memories", key: "memoryCount", align: "right" },
4949
+ { label: "last_write", key: "lastWriteAt", format: (v) => render.relativeTime(v) },
4950
+ ];
4951
+ console.log(render.table(cols, agents.perAgent));
4686
4952
  }
4687
4953
  }
4688
4954
  if (healthData?.relationships) {
4689
4955
  const r = healthData.relationships;
4690
- console.log("\nRelationships:");
4691
- console.log(` ${r.total} total (${r.active} active)`);
4956
+ console.log(`\n${render.wrap(render.c.bold, "Relationships")}`);
4957
+ console.log(render.kv("Total", `${r.total} ${render.wrap(render.c.dim, `(${r.active} active)`)}`));
4692
4958
  }
4693
4959
  if (healthData?.soul && healthData.soul.total > 0) {
4694
4960
  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`);
4961
+ console.log(`\n${render.wrap(render.c.bold, "Soul")}`);
4962
+ const entries = sortSoulKeyEntries(s.byKey ?? {});
4963
+ const parts = entries.map(([k, n]) => `${render.wrap(render.c.cyan, k)}:${n}`);
4964
+ const suffix = parts.length > 0
4965
+ ? ` ${render.wrap(render.c.dim, "—")} ${parts.join(render.wrap(render.c.dim, " · "))}`
4966
+ : "";
4967
+ console.log(render.kv("Entries", `${render.wrap(render.c.bold, String(s.total))}${suffix}`));
4698
4968
  }
4699
4969
  else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
4700
- console.log("\nSoul:");
4701
- console.log(` ${healthData.soulEntries} entries`);
4970
+ console.log(`\n${render.wrap(render.c.bold, "Soul")}`);
4971
+ console.log(render.kv("Entries", String(healthData.soulEntries)));
4702
4972
  }
4703
4973
  if (healthData?.rem) {
4704
4974
  const r = healthData.rem;
4705
- console.log("\nREM:");
4975
+ console.log(`\n${render.wrap(render.c.bold, "REM")}`);
4706
4976
  if (r.lastLightAt)
4707
- console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
4977
+ console.log(render.kv("Last light", render.relativeTime(r.lastLightAt)));
4708
4978
  if (r.lastRapidAt)
4709
- console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
4979
+ console.log(render.kv("Last rapid", render.relativeTime(r.lastRapidAt)));
4710
4980
  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}`);
4981
+ console.log(render.kv("Last restorative", render.relativeTime(r.lastRestorativeAt)));
4982
+ const nightlyTxt = r.nightlyEnabled === true
4983
+ ? render.wrap(render.c.green, "enabled")
4984
+ : r.nightlyEnabled === false
4985
+ ? render.wrap(render.c.dim, "disabled")
4986
+ : render.wrap(render.c.dim, "unknown");
4987
+ console.log(render.kv("Nightly", nightlyTxt));
4714
4988
  if (r.nightlyEnabled && r.lastNightlyAt)
4715
- console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)}`);
4989
+ console.log(render.kv("Last nightly", render.relativeTime(r.lastNightlyAt)));
4716
4990
  if (typeof r.pendingCandidates === "number" && r.pendingCandidates > 0) {
4717
- console.log(` Pending candidates: ${r.pendingCandidates}`);
4991
+ console.log(render.kv("Pending candidates", render.wrap(render.c.yellow, String(r.pendingCandidates))));
4718
4992
  }
4719
4993
  }
4720
4994
  if (healthData?.federation) {
4721
4995
  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)`);
4996
+ console.log(`\n${render.wrap(render.c.bold, "Federation")}`);
4997
+ if (f.instance) {
4998
+ const statusColor = f.instance.status === "active" ? render.c.green : render.c.yellow;
4999
+ 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, ")")}`));
5000
+ }
5001
+ if (f.peers) {
5002
+ const connColor = f.peers.connected > 0 ? render.c.green : render.c.dim;
5003
+ const downColor = f.peers.disconnected > 0 ? render.c.yellow : render.c.dim;
5004
+ const revColor = f.peers.revoked > 0 ? render.c.red : render.c.dim;
5005
+ const parts = [
5006
+ `${render.wrap(connColor, `${f.peers.connected} connected`)}`,
5007
+ `${render.wrap(downColor, `${f.peers.disconnected} down`)}`,
5008
+ `${render.wrap(revColor, `${f.peers.revoked} revoked`)}`,
5009
+ ];
5010
+ 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, " · "))}`));
5011
+ }
4727
5012
  if (f.pendingTokens > 0)
4728
- console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
5013
+ console.log(render.kv("Pairing", `${render.wrap(render.c.yellow, String(f.pendingTokens))} unconsumed token(s)`));
4729
5014
  }
4730
5015
  else {
4731
- console.log("\nFederation: not configured");
5016
+ console.log(`\n${render.wrap(render.c.bold, "Federation")} ${render.wrap(render.c.dim, "not configured")}`);
4732
5017
  }
4733
5018
  if (healthData?.oauth) {
4734
5019
  const lines = oauthSummaryLines(healthData.oauth);
4735
- for (const line of lines)
4736
- console.log(line);
5020
+ // Tweak the "OAuth:" header to bold; downstream lines are aligned k/v which already look fine
5021
+ for (const line of lines) {
5022
+ if (line.trim() === "OAuth:")
5023
+ console.log(`\n${render.wrap(render.c.bold, "OAuth")}`);
5024
+ else
5025
+ console.log(line);
5026
+ }
4737
5027
  }
4738
5028
  if (healthData?.bridges) {
4739
5029
  const b = healthData.bridges;
4740
- console.log("\nBridges:");
5030
+ console.log(`\n${render.wrap(render.c.bold, "Bridges")}`);
4741
5031
  if (Array.isArray(b.installed) && b.installed.length > 0)
4742
- console.log(` Installed: ${b.installed.join(", ")}`);
5032
+ console.log(render.kv("Installed", b.installed.join(render.wrap(render.c.dim, ", "))));
4743
5033
  if (b.lastImport)
4744
- console.log(` Last import: ${relativeTime(b.lastImport)}`);
5034
+ console.log(render.kv("Last import", render.relativeTime(b.lastImport)));
4745
5035
  if (b.lastExport)
4746
- console.log(` Last export: ${relativeTime(b.lastExport)}`);
5036
+ console.log(render.kv("Last export", render.relativeTime(b.lastExport)));
4747
5037
  }
4748
5038
  else {
4749
- console.log("\nBridges: none installed");
5039
+ console.log(`\n${render.wrap(render.c.bold, "Bridges")} ${render.wrap(render.c.dim, "none installed")}`);
4750
5040
  }
4751
5041
  if (healthData?.disk) {
4752
5042
  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)}`);
5043
+ console.log(`\n${render.wrap(render.c.bold, "Disk")}`);
5044
+ 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))}`));
5045
+ 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
5046
  }
4757
5047
  console.log("");
4758
- if (scopedWarnings.length > 0)
4759
- console.log(` Health: ⚠ ${scopedWarnings.length} warning(s)`);
4760
- else
4761
- console.log(` Health: ✅ all checks passing`);
5048
+ if (scopedWarnings.length > 0) {
5049
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `${scopedWarnings.length} warning${scopedWarnings.length === 1 ? "" : "s"}`)}`);
5050
+ }
5051
+ else {
5052
+ console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "all checks passing")}`);
5053
+ }
4762
5054
  });
4763
5055
  statusCmd
4764
5056
  .command("rem")
@@ -4766,33 +5058,42 @@ statusCmd
4766
5058
  .action(async function () {
4767
5059
  const opts = this.optsWithGlobals();
4768
5060
  const { healthy, healthData } = await fetchHealthDetail(opts);
4769
- if (opts.json) {
4770
- console.log(JSON.stringify({ healthy, rem: healthData?.rem ?? null }, null, 2));
5061
+ const mode = render.resolveOutputMode(opts);
5062
+ if (mode === "json") {
5063
+ console.log(render.asJSON({ healthy, rem: healthData?.rem ?? null }));
4771
5064
  if (!healthy)
4772
5065
  process.exit(1);
4773
5066
  return;
4774
5067
  }
4775
5068
  if (!healthy) {
4776
- console.log("🔴 unreachable");
5069
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4777
5070
  process.exit(1);
4778
5071
  }
4779
5072
  const r = healthData?.rem;
4780
5073
  if (!r) {
4781
- console.log("REM: not configured (no log entries or platform timers found)");
5074
+ console.log(`${render.wrap(render.c.bold, "REM")} ${render.wrap(render.c.dim, "not configured (no log entries or platform timers found)")}`);
4782
5075
  return;
4783
5076
  }
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)`);
5077
+ console.log(render.wrap(render.c.bold, "REM"));
5078
+ console.log(render.kv("Last light", render.relativeTime(r.lastLightAt), 18));
5079
+ console.log(render.kv("Last rapid", render.relativeTime(r.lastRapidAt), 18));
5080
+ console.log(render.kv("Last restorative", render.relativeTime(r.lastRestorativeAt), 18));
5081
+ const nightlyTxt = r.nightlyEnabled === true
5082
+ ? render.wrap(render.c.green, "enabled")
5083
+ : r.nightlyEnabled === false
5084
+ ? render.wrap(render.c.dim, "disabled")
5085
+ : render.wrap(render.c.dim, "unknown");
5086
+ console.log(render.kv("Nightly", nightlyTxt, 18));
5087
+ if (r.lastNightlyAt) {
5088
+ console.log(render.kv("Last nightly", `${render.relativeTime(r.lastNightlyAt)} ${render.wrap(render.c.dim, `(${r.lastNightlyAt})`)}`, 18));
5089
+ }
5090
+ if (typeof r.pendingCandidates === "number") {
5091
+ const pendingColor = r.pendingCandidates > 0 ? render.c.yellow : render.c.dim;
5092
+ console.log(render.kv("Pending candidates", render.wrap(pendingColor, String(r.pendingCandidates)), 18));
5093
+ }
5094
+ else {
5095
+ console.log(render.kv("Pending candidates", render.wrap(render.c.dim, "— (schema not available)"), 18));
5096
+ }
4796
5097
  });
4797
5098
  statusCmd
4798
5099
  .command("federation")
@@ -4800,36 +5101,70 @@ statusCmd
4800
5101
  .action(async function () {
4801
5102
  const opts = this.optsWithGlobals();
4802
5103
  const { healthy, healthData } = await fetchHealthDetail(opts);
4803
- if (opts.json) {
4804
- console.log(JSON.stringify({ healthy, federation: healthData?.federation ?? null }, null, 2));
5104
+ const mode = render.resolveOutputMode(opts);
5105
+ if (mode === "json") {
5106
+ console.log(render.asJSON({ healthy, federation: healthData?.federation ?? null }));
4805
5107
  if (!healthy)
4806
5108
  process.exit(1);
4807
5109
  return;
4808
5110
  }
4809
5111
  if (!healthy) {
4810
- console.log("🔴 unreachable");
5112
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4811
5113
  process.exit(1);
4812
5114
  }
4813
5115
  const f = healthData?.federation;
4814
5116
  if (!f) {
4815
- console.log("Federation: not configured");
5117
+ console.log(`${render.wrap(render.c.bold, "Federation")} ${render.wrap(render.c.dim, "not configured")}`);
4816
5118
  return;
4817
5119
  }
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)`);
5120
+ console.log(render.wrap(render.c.bold, "Federation"));
5121
+ if (f.instance) {
5122
+ const statusColor = f.instance.status === "active" ? render.c.green : render.c.yellow;
5123
+ 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, ")")}`));
5124
+ }
5125
+ else {
5126
+ console.log(render.kv("Instance", render.wrap(render.c.dim, "—")));
5127
+ }
5128
+ if (f.peers) {
5129
+ const connColor = f.peers.connected > 0 ? render.c.green : render.c.dim;
5130
+ const downColor = f.peers.disconnected > 0 ? render.c.yellow : render.c.dim;
5131
+ const revColor = f.peers.revoked > 0 ? render.c.red : render.c.dim;
5132
+ const parts = [
5133
+ render.wrap(connColor, `${f.peers.connected} connected`),
5134
+ render.wrap(downColor, `${f.peers.disconnected} down`),
5135
+ render.wrap(revColor, `${f.peers.revoked} revoked`),
5136
+ ];
5137
+ 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, " · "))}`));
5138
+ }
5139
+ if (typeof f.pendingTokens === "number" && f.pendingTokens > 0) {
5140
+ console.log(render.kv("Pairing", `${render.wrap(render.c.yellow, String(f.pendingTokens))} unconsumed token(s)`));
5141
+ }
4827
5142
  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
- }
5143
+ console.log();
5144
+ const cols = [
5145
+ { label: "peer", key: "id" },
5146
+ { label: "role", key: "role", format: (v) => String(v ?? "—") },
5147
+ {
5148
+ label: "status",
5149
+ key: "status",
5150
+ format: (v) => {
5151
+ const s = String(v ?? "—");
5152
+ const color = s === "paired" || s === "connected" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
5153
+ return render.wrap(color, s);
5154
+ },
5155
+ },
5156
+ {
5157
+ label: "last_sync",
5158
+ key: "lastSyncAt",
5159
+ format: (v) => {
5160
+ const iso = v;
5161
+ if (!iso)
5162
+ return render.wrap(render.c.dim, "never");
5163
+ return `${render.relativeTime(iso)} ${render.wrap(render.c.dim, `(${iso})`)}`;
5164
+ },
5165
+ },
5166
+ ];
5167
+ console.log(render.table(cols, f.peerList));
4833
5168
  }
4834
5169
  });
4835
5170
  statusCmd
@@ -4838,24 +5173,46 @@ statusCmd
4838
5173
  .action(async function () {
4839
5174
  const opts = this.optsWithGlobals();
4840
5175
  const { healthy, healthData } = await fetchHealthDetail(opts);
4841
- if (opts.json) {
4842
- console.log(JSON.stringify({ healthy, oauth: healthData?.oauth ?? null }, null, 2));
5176
+ const mode = render.resolveOutputMode(opts);
5177
+ if (mode === "json") {
5178
+ console.log(render.asJSON({ healthy, oauth: healthData?.oauth ?? null }));
4843
5179
  if (!healthy)
4844
5180
  process.exit(1);
4845
5181
  return;
4846
5182
  }
4847
5183
  if (!healthy) {
4848
- console.log("🔴 unreachable");
5184
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4849
5185
  process.exit(1);
4850
5186
  }
4851
5187
  const o = healthData?.oauth;
4852
5188
  if (!o) {
4853
- console.log("OAuth: not configured");
5189
+ console.log(`${render.wrap(render.c.bold, "OAuth")} ${render.wrap(render.c.dim, "not configured")}`);
4854
5190
  return;
4855
5191
  }
4856
- const lines = oauthDetailLines(o);
4857
- for (const line of lines)
4858
- console.log(line);
5192
+ console.log(render.wrap(render.c.bold, "OAuth"));
5193
+ console.log(render.kv("Clients", render.wrap(render.c.bold, String(Number(o?.clients ?? 0)))));
5194
+ console.log(render.kv("IdP configs", String(Number(o?.idpConfigs ?? 0))));
5195
+ const tokenColor = Number(o?.activeTokens ?? 0) > 0 ? render.c.green : render.c.dim;
5196
+ console.log(render.kv("Active tokens", render.wrap(tokenColor, String(Number(o?.activeTokens ?? 0)))));
5197
+ if (Array.isArray(o?.clientList) && o.clientList.length > 0) {
5198
+ console.log(`\n ${render.wrap(render.c.dim, "Clients")}`);
5199
+ const cols = [
5200
+ { label: "id", key: "id" },
5201
+ { label: "name", key: "name", format: (v) => String(v ?? "—") },
5202
+ { label: "registered_by", key: "registeredBy", format: (v) => String(v ?? "—") },
5203
+ { label: "created_at", key: "createdAt", format: (v) => String(v ?? "—") },
5204
+ ];
5205
+ console.log(render.table(cols, o.clientList));
5206
+ }
5207
+ if (Array.isArray(o?.idpList) && o.idpList.length > 0) {
5208
+ console.log(`\n ${render.wrap(render.c.dim, "IdPs")}`);
5209
+ const cols = [
5210
+ { label: "id", key: "id" },
5211
+ { label: "name", key: "name", format: (v) => String(v ?? "—") },
5212
+ { label: "issuer", key: "issuer", format: (v) => String(v ?? "—") },
5213
+ ];
5214
+ console.log(render.table(cols, o.idpList));
5215
+ }
4859
5216
  });
4860
5217
  statusCmd
4861
5218
  .command("bridges")
@@ -4863,28 +5220,283 @@ statusCmd
4863
5220
  .action(async function () {
4864
5221
  const opts = this.optsWithGlobals();
4865
5222
  const { healthy, healthData } = await fetchHealthDetail(opts);
4866
- if (opts.json) {
4867
- console.log(JSON.stringify({ healthy, bridges: healthData?.bridges ?? null }, null, 2));
5223
+ const mode = render.resolveOutputMode(opts);
5224
+ if (mode === "json") {
5225
+ console.log(render.asJSON({ healthy, bridges: healthData?.bridges ?? null }));
4868
5226
  if (!healthy)
4869
5227
  process.exit(1);
4870
5228
  return;
4871
5229
  }
4872
5230
  if (!healthy) {
4873
- console.log("🔴 unreachable");
5231
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4874
5232
  process.exit(1);
4875
5233
  }
4876
5234
  const b = healthData?.bridges;
4877
5235
  if (!b) {
4878
- console.log("Bridges: none installed (no flair-bridge-* packages found)");
5236
+ console.log(`${render.wrap(render.c.bold, "Bridges")} ${render.wrap(render.c.dim, "none installed (no flair-bridge-* packages found)")}`);
4879
5237
  return;
4880
5238
  }
4881
- console.log("Bridges:");
4882
- if (Array.isArray(b.installed) && b.installed.length > 0)
4883
- console.log(` Installed: ${b.installed.join(", ")}`);
5239
+ console.log(render.wrap(render.c.bold, "Bridges"));
5240
+ if (Array.isArray(b.installed) && b.installed.length > 0) {
5241
+ console.log(render.kv("Installed", b.installed.join(render.wrap(render.c.dim, ", "))));
5242
+ }
4884
5243
  if (b.lastImport)
4885
- console.log(` Last import: ${relativeTime(b.lastImport)}`);
5244
+ console.log(render.kv("Last import", render.relativeTime(b.lastImport)));
4886
5245
  if (b.lastExport)
4887
- console.log(` Last export: ${relativeTime(b.lastExport)}`);
5246
+ console.log(render.kv("Last export", render.relativeTime(b.lastExport)));
5247
+ });
5248
+ // ─── flair status --deep ──────────────────────────────────────────────────────
5249
+ //
5250
+ // Deeper observability than the default `flair status` summary — full
5251
+ // per-section detail with no condensing. Optional --bootstrap measures
5252
+ // cold-start context bytes per agent (slow; calls /MemoryBootstrap once per
5253
+ // agent, admin-auth required).
5254
+ //
5255
+ // Addresses ops-yph: Nathan's 2026-04-22 ask for "how much storage does my
5256
+ // memory take, how much context are bootstraps pulling, what's the real usage
5257
+ // pattern" — questions the default `flair status` summarizes but doesn't
5258
+ // surface in full. Agents should be able to self-audit via this too.
5259
+ statusCmd
5260
+ .command("deep")
5261
+ .description("Verbose status + optional bootstrap context size per agent (ops-yph)")
5262
+ .option("--bootstrap", "Also measure bootstrap context bytes per agent (slow; admin auth required)")
5263
+ .option("--max-tokens <n>", "Bootstrap maxTokens cap when --bootstrap is set", "4000")
5264
+ .action(async function () {
5265
+ const opts = this.optsWithGlobals();
5266
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
5267
+ if (!healthy) {
5268
+ if (opts.json) {
5269
+ console.log(JSON.stringify({ healthy: false, url: baseUrl, error: "unreachable" }, null, 2));
5270
+ }
5271
+ else {
5272
+ console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
5273
+ console.log(` URL: ${baseUrl}`);
5274
+ console.log(`\n Run: flair start or flair doctor`);
5275
+ }
5276
+ process.exit(1);
5277
+ }
5278
+ // Optional: per-agent bootstrap context bytes. Calls /MemoryBootstrap with
5279
+ // admin auth (so we can measure on behalf of any agent without holding
5280
+ // their keys). 15s timeout per call — bootstrap can be expensive on cold
5281
+ // caches or large memory sets.
5282
+ const agentList = (Array.isArray(healthData?.agents?.names) && healthData.agents.names.length > 0
5283
+ ? healthData.agents.names
5284
+ : Array.isArray(healthData?.agents?.perAgent)
5285
+ ? healthData.agents.perAgent.map((r) => r.id).filter(Boolean)
5286
+ : []);
5287
+ const bootstrapBytes = {};
5288
+ if (opts.bootstrap && agentList.length > 0) {
5289
+ const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
5290
+ if (!adminPass) {
5291
+ if (!opts.json) {
5292
+ console.log("⚠ --bootstrap requires HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS env var");
5293
+ }
5294
+ }
5295
+ else {
5296
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5297
+ const maxTokens = Number.parseInt(String(opts.maxTokens ?? "4000"), 10);
5298
+ for (const agentId of agentList) {
5299
+ try {
5300
+ const res = await fetch(`${baseUrl}/BootstrapMemories`, {
5301
+ method: "POST",
5302
+ headers: { "Content-Type": "application/json", Authorization: auth },
5303
+ body: JSON.stringify({ agentId, maxTokens }),
5304
+ signal: AbortSignal.timeout(15000),
5305
+ });
5306
+ const text = await res.text();
5307
+ const bytes = Buffer.byteLength(text, "utf8");
5308
+ let tokenEstimate;
5309
+ let memoriesIncluded;
5310
+ try {
5311
+ const json = JSON.parse(text);
5312
+ tokenEstimate = typeof json.tokenEstimate === "number" ? json.tokenEstimate : undefined;
5313
+ memoriesIncluded = typeof json.memoriesIncluded === "number" ? json.memoriesIncluded : undefined;
5314
+ }
5315
+ catch { /* response wasn't JSON; bytes still valid */ }
5316
+ if (!res.ok) {
5317
+ bootstrapBytes[agentId] = { bytes, error: `HTTP ${res.status}` };
5318
+ }
5319
+ else {
5320
+ bootstrapBytes[agentId] = { bytes, tokenEstimate, memoriesIncluded };
5321
+ }
5322
+ }
5323
+ catch (e) {
5324
+ bootstrapBytes[agentId] = { bytes: 0, error: e?.message ?? String(e) };
5325
+ }
5326
+ }
5327
+ }
5328
+ }
5329
+ if (opts.json) {
5330
+ const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
5331
+ if (opts.bootstrap)
5332
+ out.bootstrapBytes = bootstrapBytes;
5333
+ console.log(JSON.stringify(out, null, 2));
5334
+ return;
5335
+ }
5336
+ // Human-readable verbose render.
5337
+ const uptimeSec = healthData?.uptimeSeconds;
5338
+ let uptimeStr = "";
5339
+ if (uptimeSec != null) {
5340
+ const d = Math.floor(uptimeSec / 86400);
5341
+ const h = Math.floor((uptimeSec % 86400) / 3600);
5342
+ const m = Math.floor((uptimeSec % 3600) / 60);
5343
+ uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
5344
+ }
5345
+ const pid = healthData?.pid ?? "?";
5346
+ console.log(`Flair v${__pkgVersion} — running (PID ${pid}${uptimeStr ? `, uptime ${uptimeStr}` : ""})`);
5347
+ console.log(`URL: ${baseUrl}`);
5348
+ const memories = healthData?.memories;
5349
+ if (memories) {
5350
+ console.log("\n═══ Memory ═══════════════════════════════════════");
5351
+ console.log(`Total: ${memories.total}`);
5352
+ console.log(`Breakdown: ${memories.withEmbeddings ?? 0} embedded, ${memories.hashFallback ?? 0} hash-fallback`);
5353
+ if (memories.modelCounts && memories.total > 0) {
5354
+ const entries = Object.entries(memories.modelCounts)
5355
+ .filter(([, n]) => n > 0)
5356
+ .sort((a, b) => b[1] - a[1]);
5357
+ for (const [k, n] of entries) {
5358
+ const pct = ((n / memories.total) * 100).toFixed(1);
5359
+ console.log(` ${k.padEnd(28)} ${String(n).padStart(6)} (${pct}%)`);
5360
+ }
5361
+ }
5362
+ if (memories.byDurability) {
5363
+ const d = memories.byDurability;
5364
+ console.log(`Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
5365
+ }
5366
+ console.log(`Archived: ${memories.archived ?? 0}`);
5367
+ console.log(`Expired: ${memories.expired ?? 0}`);
5368
+ if (healthData?.lastWrite)
5369
+ console.log(`Last write: ${relativeTime(healthData.lastWrite)} (${healthData.lastWrite})`);
5370
+ }
5371
+ const agents = healthData?.agents;
5372
+ if (agents && agents.count > 0) {
5373
+ console.log("\n═══ Agents ═══════════════════════════════════════");
5374
+ console.log(`Total: ${agents.count}`);
5375
+ if (Array.isArray(agents.names) && agents.names.length > 0) {
5376
+ console.log(`Names: ${agents.names.join(", ")}`);
5377
+ }
5378
+ if (Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
5379
+ const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
5380
+ console.log(`\n ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
5381
+ for (const r of agents.perAgent) {
5382
+ const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "—";
5383
+ const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
5384
+ console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
5385
+ }
5386
+ }
5387
+ }
5388
+ // Bootstrap context section — always printed so users see how to opt in.
5389
+ console.log("\n═══ Bootstrap context ═══════════════════════════");
5390
+ if (!opts.bootstrap) {
5391
+ console.log(" (not measured — pass --bootstrap to fetch per-agent context bytes)");
5392
+ }
5393
+ else if (Object.keys(bootstrapBytes).length === 0) {
5394
+ console.log(" (no agents found, or admin pass missing — see HDB_ADMIN_PASSWORD)");
5395
+ }
5396
+ else {
5397
+ const idW = Math.max(5, ...Object.keys(bootstrapBytes).map((id) => id.length));
5398
+ console.log(` ${"agent".padEnd(idW)} ${"bytes".padStart(9)} ${"~tokens".padStart(7)} ${"mems".padStart(4)} status`);
5399
+ // Sort by bytes desc so heaviest bootstraps surface first.
5400
+ const sortedEntries = Object.entries(bootstrapBytes).sort((a, b) => (b[1].bytes ?? 0) - (a[1].bytes ?? 0));
5401
+ for (const [agentId, info] of sortedEntries) {
5402
+ const bytesStr = info.error ? "error" : humanBytes(info.bytes);
5403
+ const tok = info.tokenEstimate != null ? String(info.tokenEstimate) : "—";
5404
+ const mems = info.memoriesIncluded != null ? String(info.memoriesIncluded) : "—";
5405
+ const status = info.error ? info.error.slice(0, 40) : "ok";
5406
+ console.log(` ${agentId.padEnd(idW)} ${bytesStr.padStart(9)} ${tok.padStart(7)} ${mems.padStart(4)} ${status}`);
5407
+ }
5408
+ }
5409
+ if (healthData?.relationships) {
5410
+ const r = healthData.relationships;
5411
+ console.log("\n═══ Relationships ═══════════════════════════════");
5412
+ console.log(`Total: ${r.total} (${r.active} active)`);
5413
+ }
5414
+ if (healthData?.soul && healthData.soul.total > 0) {
5415
+ const s = healthData.soul;
5416
+ console.log("\n═══ Soul ═════════════════════════════════════════");
5417
+ console.log(`Total: ${s.total} entries`);
5418
+ const entries = sortSoulKeyEntries(s.byKey ?? {});
5419
+ if (entries.length > 0) {
5420
+ console.log(`Keys: ${entries.map(([k, n]) => `${n} ${k}`).join(" / ")}`);
5421
+ }
5422
+ }
5423
+ else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
5424
+ console.log("\n═══ Soul ═════════════════════════════════════════");
5425
+ console.log(`Total: ${healthData.soulEntries} entries`);
5426
+ }
5427
+ if (healthData?.rem) {
5428
+ const r = healthData.rem;
5429
+ console.log("\n═══ REM ══════════════════════════════════════════");
5430
+ console.log(`Last light: ${relativeTime(r.lastLightAt)}`);
5431
+ console.log(`Last rapid: ${relativeTime(r.lastRapidAt)}`);
5432
+ console.log(`Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
5433
+ const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
5434
+ console.log(`Nightly: ${nightly}`);
5435
+ if (r.lastNightlyAt)
5436
+ console.log(`Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
5437
+ if (typeof r.pendingCandidates === "number")
5438
+ console.log(`Pending candidates: ${r.pendingCandidates}`);
5439
+ }
5440
+ if (healthData?.federation) {
5441
+ const f = healthData.federation;
5442
+ console.log("\n═══ Federation ═══════════════════════════════════");
5443
+ if (f.instance)
5444
+ console.log(`Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
5445
+ if (f.peers)
5446
+ console.log(`Peers: ${f.peers.total} total (${f.peers.connected} connected, ${f.peers.disconnected} down, ${f.peers.revoked} revoked)`);
5447
+ if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
5448
+ console.log(`Pairing: ${f.pendingTokens} unconsumed token(s)`);
5449
+ if (Array.isArray(f.peerList) && f.peerList.length > 0) {
5450
+ const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
5451
+ console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
5452
+ for (const p of f.peerList) {
5453
+ console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "—").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
5454
+ }
5455
+ }
5456
+ }
5457
+ else {
5458
+ console.log("\n═══ Federation ═══════════════════════════════════");
5459
+ console.log(" not configured");
5460
+ }
5461
+ if (healthData?.oauth) {
5462
+ const o = healthData.oauth;
5463
+ console.log("\n═══ OAuth / IdP ══════════════════════════════════");
5464
+ console.log(`Clients: ${o.clients}`);
5465
+ console.log(`IdP configs: ${o.idpConfigs}`);
5466
+ console.log(`Active tokens: ${o.activeTokens}`);
5467
+ if (Array.isArray(o.clientList) && o.clientList.length > 0) {
5468
+ console.log(`\n Clients:`);
5469
+ for (const c of o.clientList) {
5470
+ console.log(` ${c.id} ${c.name ?? "—"} ${c.registeredBy ?? "—"} ${c.createdAt ?? "—"}`);
5471
+ }
5472
+ }
5473
+ }
5474
+ if (healthData?.bridges) {
5475
+ const b = healthData.bridges;
5476
+ console.log("\n═══ Bridges ══════════════════════════════════════");
5477
+ if (Array.isArray(b.installed) && b.installed.length > 0)
5478
+ console.log(`Installed: ${b.installed.join(", ")}`);
5479
+ if (b.lastImport)
5480
+ console.log(`Last import: ${relativeTime(b.lastImport)}`);
5481
+ if (b.lastExport)
5482
+ console.log(`Last export: ${relativeTime(b.lastExport)}`);
5483
+ }
5484
+ if (healthData?.disk) {
5485
+ const d = healthData.disk;
5486
+ console.log("\n═══ Disk ═════════════════════════════════════════");
5487
+ console.log(`Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
5488
+ console.log(`Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
5489
+ console.log(`Total: ${humanBytes((d.dataBytes ?? 0) + (d.snapshotBytes ?? 0))}`);
5490
+ }
5491
+ const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
5492
+ if (warnings.length > 0) {
5493
+ console.log("\n═══ Warnings ═════════════════════════════════════");
5494
+ for (const w of warnings)
5495
+ console.log(` ${w.level === "warn" ? "⚠" : "ℹ"} ${w.message}`);
5496
+ }
5497
+ else {
5498
+ console.log("\n✅ no warnings");
5499
+ }
4888
5500
  });
4889
5501
  // ─── flair upgrade ────────────────────────────────────────────────────────────
4890
5502
  program
@@ -5565,15 +6177,13 @@ program
5565
6177
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
5566
6178
  .option("--port <port>", "Harper HTTP port")
5567
6179
  .action(async (opts) => {
5568
- const green = (s) => `\x1b[32m${s}\x1b[0m`;
5569
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
5570
6180
  const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
5571
6181
  if (!agentId && !process.env.FLAIR_ADMIN_PASS) {
5572
- console.error(red("Error: set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS"));
6182
+ console.error(`${render.icons.error} ${render.wrap(render.c.red, "set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS")}`);
5573
6183
  process.exit(1);
5574
6184
  }
5575
6185
  const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
5576
- console.log(`\nFlair test (url: ${baseUrl})\n`);
6186
+ console.log(`\n${render.wrap(render.c.bold, "Flair test")} ${render.wrap(render.c.dim, `(url: ${baseUrl})`)}\n`);
5577
6187
  let passed = 0;
5578
6188
  let failed = 0;
5579
6189
  let memoryId = null;
@@ -5581,17 +6191,17 @@ program
5581
6191
  try {
5582
6192
  const ok = await fn();
5583
6193
  if (ok) {
5584
- console.log(` ${green("PASS")} ${name}`);
6194
+ console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "PASS")} ${name}`);
5585
6195
  passed++;
5586
6196
  }
5587
6197
  else {
5588
- console.log(` ${red("FAIL")} ${name}`);
6198
+ console.log(` ${render.icons.error} ${render.wrap(render.c.red, "FAIL")} ${name}`);
5589
6199
  failed++;
5590
6200
  }
5591
6201
  }
5592
6202
  catch (e) {
5593
6203
  const message = e instanceof Error ? e.message : String(e);
5594
- console.log(` ${red("FAIL")} ${name}: ${message?.slice(0, 120)}`);
6204
+ console.log(` ${render.icons.error} ${render.wrap(render.c.red, "FAIL")} ${name}: ${render.wrap(render.c.dim, message?.slice(0, 120))}`);
5595
6205
  failed++;
5596
6206
  }
5597
6207
  };
@@ -5630,7 +6240,9 @@ program
5630
6240
  await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
5631
6241
  return true;
5632
6242
  });
5633
- console.log(`\n${passed} passed, ${failed} failed`);
6243
+ const passColor = passed > 0 ? render.c.green : render.c.dim;
6244
+ const failColor = failed > 0 ? render.c.red : render.c.dim;
6245
+ console.log(`\n ${render.wrap(passColor, `${passed} passed`)} ${render.wrap(render.c.dim, "·")} ${render.wrap(failColor, `${failed} failed`)}`);
5634
6246
  if (failed > 0)
5635
6247
  process.exit(1);
5636
6248
  });
@@ -5733,7 +6345,7 @@ program
5733
6345
  let baseUrl = `http://127.0.0.1:${port}`;
5734
6346
  let issues = 0;
5735
6347
  let harperResponding = false;
5736
- console.log("\n🩺 Flair Doctor\n");
6348
+ console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
5737
6349
  // Helper: try to reach Harper on a given port
5738
6350
  async function probePort(p) {
5739
6351
  try {
@@ -5777,11 +6389,11 @@ program
5777
6389
  catch { /* dead */ }
5778
6390
  }
5779
6391
  else {
5780
- console.log(` ⚠️ PID file contains non-numeric value: ${pidFile0} — skipping`);
6392
+ console.log(` ${render.icons.warn} PID file contains non-numeric value: ${render.wrap(render.c.dim, pidFile0)} — skipping`);
5781
6393
  }
5782
6394
  }
5783
6395
  if (await probePort(port)) {
5784
- console.log(` Harper responding on port ${port}`);
6396
+ console.log(` ${render.icons.ok} Harper responding on port ${render.wrap(render.c.bold, String(port))}`);
5785
6397
  harperResponding = true;
5786
6398
  }
5787
6399
  else {
@@ -5790,19 +6402,19 @@ program
5790
6402
  if (pidAlive) {
5791
6403
  discoveredPort = await discoverPortFromPid(pidValue);
5792
6404
  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}`);
6405
+ 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})`)}`);
6406
+ console.log(` ${render.wrap(render.c.dim, `Your config says port ${port} but Harper is actually running on ${discoveredPort}`)}`);
5795
6407
  if (autoFix) {
5796
6408
  if (dryRun) {
5797
- console.log(` Would update config to port ${discoveredPort}`);
6409
+ console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
5798
6410
  }
5799
6411
  else {
5800
6412
  writeConfig(discoveredPort);
5801
- console.log(` Updated config to port ${discoveredPort}`);
6413
+ console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
5802
6414
  }
5803
6415
  }
5804
6416
  else {
5805
- console.log(` Fix: flair doctor --fix (updates config to match running port)`);
6417
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(updates config to match running port)")}`);
5806
6418
  }
5807
6419
  effectivePort = discoveredPort;
5808
6420
  baseUrl = `http://127.0.0.1:${discoveredPort}`;
@@ -5810,8 +6422,8 @@ program
5810
6422
  issues++;
5811
6423
  }
5812
6424
  else {
5813
- console.log(` Harper process alive (PID ${pidValue}) but not responding on any detected port`);
5814
- console.log(` Fix: flair restart`);
6425
+ console.log(` ${render.icons.error} Harper process alive (PID ${pidValue}) but not responding on any detected port`);
6426
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
5815
6427
  issues++;
5816
6428
  }
5817
6429
  }
@@ -5822,34 +6434,34 @@ program
5822
6434
  const { execSync } = await import("node:child_process");
5823
6435
  const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
5824
6436
  if (lsof) {
5825
- console.log(` Nothing responding on port ${port} (port occupied by PID ${lsof})`);
5826
- console.log(` Fix: kill ${lsof} && flair restart`);
6437
+ console.log(` ${render.icons.error} Nothing responding on port ${port} ${render.wrap(render.c.dim, `(port occupied by PID ${lsof})`)}`);
6438
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} kill ${lsof} && flair restart`);
5827
6439
  }
5828
6440
  else {
5829
- console.log(` Harper is not running`);
5830
- console.log(` Fix: flair restart`);
6441
+ console.log(` ${render.icons.error} Harper is not running`);
6442
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
5831
6443
  }
5832
6444
  }
5833
6445
  catch {
5834
- console.log(` Harper is not running`);
6446
+ console.log(` ${render.icons.error} Harper is not running`);
5835
6447
  if (autoFix) {
5836
6448
  if (dryRun) {
5837
- console.log(` Would run: flair restart`);
6449
+ console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
5838
6450
  }
5839
6451
  else {
5840
- console.log(` Attempting restart...`);
6452
+ console.log(` ${render.wrap(render.c.dim, "Attempting restart...")}`);
5841
6453
  try {
5842
6454
  const { execSync } = await import("node:child_process");
5843
6455
  execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${port}`, { stdio: "inherit" });
5844
- console.log(` Restart attempted`);
6456
+ console.log(` ${render.icons.ok} Restart attempted`);
5845
6457
  }
5846
6458
  catch {
5847
- console.log(` Restart failed — try: flair init --agent-id <your-agent>`);
6459
+ console.log(` ${render.icons.error} Restart failed — try: flair init --agent-id <your-agent>`);
5848
6460
  }
5849
6461
  }
5850
6462
  }
5851
6463
  else {
5852
- console.log(` Fix: flair restart`);
6464
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
5853
6465
  }
5854
6466
  }
5855
6467
  issues++;
@@ -5860,27 +6472,27 @@ program
5860
6472
  if (existsSync(keysDir)) {
5861
6473
  const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
5862
6474
  if (keyFiles.length > 0) {
5863
- console.log(` Keys found: ${keyFiles.length} agent(s) in ${keysDir}`);
6475
+ 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
6476
  }
5865
6477
  else {
5866
- console.log(` Keys directory exists but no .key files found`);
5867
- console.log(` Fix: flair init --agent-id <your-agent>`);
6478
+ console.log(` ${render.icons.error} Keys directory exists but no .key files found`);
6479
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
5868
6480
  issues++;
5869
6481
  }
5870
6482
  }
5871
6483
  else {
5872
- console.log(` Keys directory missing: ${keysDir}`);
5873
- console.log(` Fix: flair init --agent-id <your-agent>`);
6484
+ console.log(` ${render.icons.error} Keys directory missing: ${render.wrap(render.c.dim, keysDir)}`);
6485
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
5874
6486
  issues++;
5875
6487
  }
5876
6488
  // 3. Config file
5877
6489
  const cfgPath = configPath();
5878
6490
  if (existsSync(cfgPath)) {
5879
6491
  const savedPort = readPortFromConfig();
5880
- console.log(` Config: ${cfgPath} (port: ${savedPort ?? "default"})`);
6492
+ console.log(` ${render.icons.ok} Config: ${render.wrap(render.c.dim, cfgPath)} ${render.wrap(render.c.dim, `(port: ${savedPort ?? "default"})`)}`);
5881
6493
  }
5882
6494
  else {
5883
- console.log(` ⚠️ No config file at ${cfgPath} — using defaults`);
6495
+ console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
5884
6496
  }
5885
6497
  // 4. Embeddings check (only if Harper is responding)
5886
6498
  if (harperResponding) {
@@ -5894,17 +6506,17 @@ program
5894
6506
  if (testRes.ok) {
5895
6507
  const data = await testRes.json();
5896
6508
  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/`);
6509
+ console.log(` ${render.icons.warn} Embeddings: keyword-only ${render.wrap(render.c.dim, `(${data._warning})`)}`);
6510
+ console.log(` ${render.wrap(render.c.dim, "Semantic search quality is degraded")}`);
6511
+ console.log(` ${render.wrap(render.c.dim, "Check:")} ls ~/.npm-global/lib/node_modules/@tpsdev-ai/flair/models/`);
5900
6512
  issues++;
5901
6513
  }
5902
6514
  else {
5903
- console.log(` Embeddings: semantic search operational`);
6515
+ console.log(` ${render.icons.ok} Embeddings: semantic search operational`);
5904
6516
  }
5905
6517
  }
5906
6518
  else if (testRes.status === 401) {
5907
- console.log(` ⚠️ Embeddings: cannot verify (auth required for SemanticSearch)`);
6519
+ console.log(` ${render.icons.warn} Embeddings: cannot verify ${render.wrap(render.c.dim, "(auth required for SemanticSearch)")}`);
5908
6520
  }
5909
6521
  }
5910
6522
  catch { /* fetch error, already flagged */ }
@@ -5917,50 +6529,50 @@ program
5917
6529
  try {
5918
6530
  process.kill(Number(pidContent), 0);
5919
6531
  if (harperResponding) {
5920
- console.log(` PID file: ${pidFile} (process ${pidContent} is alive)`);
6532
+ console.log(` ${render.icons.ok} PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is alive)`)}`);
5921
6533
  }
5922
6534
  // If not responding, we already reported the issue in step 1
5923
6535
  }
5924
6536
  catch {
5925
- console.log(` Stale PID file: ${pidFile} (process ${pidContent} is dead)`);
6537
+ console.log(` ${render.icons.error} Stale PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is dead)`)}`);
5926
6538
  if (autoFix) {
5927
6539
  if (dryRun) {
5928
- console.log(` Would remove: ${pidFile}`);
6540
+ console.log(` ${render.wrap(render.c.dim, "Would remove:")} ${pidFile}`);
5929
6541
  }
5930
6542
  else {
5931
6543
  (await import("node:fs")).unlinkSync(pidFile);
5932
- console.log(` Removed stale PID file`);
6544
+ console.log(` ${render.icons.ok} Removed stale PID file`);
5933
6545
  }
5934
6546
  }
5935
6547
  else {
5936
- console.log(` Fix: rm ${pidFile} && flair restart`);
6548
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} rm ${pidFile} && flair restart`);
5937
6549
  }
5938
6550
  issues++;
5939
6551
  }
5940
6552
  }
5941
6553
  // 6. Data directory
5942
6554
  if (existsSync(dataDir)) {
5943
- console.log(` Data directory: ${dataDir}`);
6555
+ console.log(` ${render.icons.ok} Data directory: ${render.wrap(render.c.dim, dataDir)}`);
5944
6556
  }
5945
6557
  else {
5946
6558
  // Check ~/harper/ (common alternative)
5947
6559
  const altDir = join(homedir(), "harper");
5948
6560
  if (existsSync(altDir)) {
5949
- console.log(` ⚠️ Data at ~/harper/ (not ~/.flair/data) — old install location`);
6561
+ 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
6562
  }
5951
6563
  else {
5952
- console.log(` No data directory found`);
5953
- console.log(` Fix: flair init --agent-id <your-agent>`);
6564
+ console.log(` ${render.icons.error} No data directory found`);
6565
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
5954
6566
  issues++;
5955
6567
  }
5956
6568
  }
5957
6569
  // Summary
5958
6570
  console.log("");
5959
6571
  if (issues === 0) {
5960
- console.log(" 🟢 No issues found");
6572
+ console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`);
5961
6573
  }
5962
6574
  else {
5963
- console.log(` 🔴 ${issues} issue${issues > 1 ? "s" : ""} found — see fixes above`);
6575
+ 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
6576
  }
5965
6577
  console.log("");
5966
6578
  if (issues > 0)
@@ -6220,64 +6832,140 @@ memory.command("write-task-summary")
6220
6832
  // without parsing a JSON blob.
6221
6833
  console.log(memId);
6222
6834
  });
6223
- memory.command("search [query]").requiredOption("--agent <id>")
6835
+ memory.command("search [query]")
6836
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6224
6837
  .option("--q <query>", "search query (alias for positional arg)")
6225
6838
  .option("--limit <n>", "Max results", "5")
6226
6839
  .option("--tag <tag>")
6840
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
6841
+ .option("--url <url>", "Flair base URL (overrides --port)")
6842
+ .option("--port <port>", "Harper HTTP port")
6227
6843
  .action(async (queryArg, opts) => {
6844
+ const agentId = resolveAgentIdOrEnv(opts);
6845
+ if (!agentId) {
6846
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
6847
+ process.exit(2);
6848
+ }
6228
6849
  const q = queryArg ?? opts.q;
6229
6850
  if (!q) {
6230
6851
  console.error("error: query required (positional arg or --q)");
6231
6852
  process.exit(1);
6232
6853
  }
6233
- const body = { agentId: opts.agent, q, limit: parseInt(opts.limit, 10) || 5 };
6854
+ const body = { agentId, q, limit: parseInt(opts.limit, 10) || 5 };
6234
6855
  if (opts.tag)
6235
6856
  body.tag = opts.tag;
6236
- console.log(JSON.stringify(await api("POST", "/SemanticSearch", body), null, 2));
6857
+ const baseUrl = resolveBaseUrl(opts);
6858
+ const res = await api("POST", "/SemanticSearch", body, { baseUrl });
6859
+ console.log(JSON.stringify(res, null, 2));
6237
6860
  });
6238
6861
  memory.command("list")
6239
- .requiredOption("--agent <id>")
6862
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6240
6863
  .option("--tag <tag>")
6241
6864
  .option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
6242
6865
  .option("--limit <n>", "Max rows when using --hash-fallback", "50")
6866
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
6243
6867
  .action(async (opts) => {
6244
- const q = new URLSearchParams({ agentId: opts.agent, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
6868
+ const agentId = resolveAgentIdOrEnv(opts);
6869
+ if (!agentId) {
6870
+ console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
6871
+ process.exit(2);
6872
+ }
6873
+ const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
6245
6874
  const raw = await api("GET", `/Memory?${q}`);
6246
- if (!opts.hashFallback) {
6247
- console.log(JSON.stringify(raw, null, 2));
6875
+ const mode = render.resolveOutputMode(opts);
6876
+ // hashFallback flag changes the lens: instead of all memories, show
6877
+ // only those that need re-embedding. Keep that surface separate.
6878
+ if (opts.hashFallback) {
6879
+ const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
6880
+ const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
6881
+ if (mode === "json") {
6882
+ console.log(render.asJSON(fallback));
6883
+ return;
6884
+ }
6885
+ if (fallback.length === 0) {
6886
+ console.log(`${render.icons.ok} ${render.wrap(render.c.green, "All memories embedded")} ${render.wrap(render.c.dim, `(agent ${agentId})`)}`);
6887
+ return;
6888
+ }
6889
+ const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
6890
+ const rows = fallback
6891
+ .slice()
6892
+ .sort((a, b) => {
6893
+ const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
6894
+ const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
6895
+ return tb - ta;
6896
+ })
6897
+ .slice(0, limit);
6898
+ 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`);
6899
+ const cols = [
6900
+ { label: "id", key: "id" },
6901
+ {
6902
+ label: "created_at",
6903
+ key: "createdAt",
6904
+ format: (v) => (v ? String(v).slice(0, 19).replace("T", " ") : "—"),
6905
+ },
6906
+ {
6907
+ label: "preview",
6908
+ key: "content",
6909
+ format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
6910
+ },
6911
+ ];
6912
+ console.log(render.table(cols, rows));
6913
+ if (fallback.length > rows.length) {
6914
+ console.log(`\n${render.wrap(render.c.dim, `... ${fallback.length - rows.length} more (raise with --limit). To backfill:`)} flair reembed --agent ${agentId} --stale-only`);
6915
+ }
6916
+ else {
6917
+ console.log(`\n${render.wrap(render.c.dim, "To backfill:")} flair reembed --agent ${agentId} --stale-only`);
6918
+ }
6248
6919
  return;
6249
6920
  }
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.
6921
+ // Default lens: all memories for the agent.
6252
6922
  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.`);
6923
+ if (mode === "json") {
6924
+ console.log(render.asJSON(all));
6925
+ return;
6926
+ }
6927
+ if (all.length === 0) {
6928
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `No memories for agent ${agentId}`)}`);
6256
6929
  return;
6257
6930
  }
6258
- const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
6259
- const rows = fallback
6931
+ 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`);
6932
+ const sorted = all
6260
6933
  .slice()
6261
6934
  .sort((a, b) => {
6262
6935
  const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
6263
6936
  const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
6264
6937
  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
- }
6938
+ });
6939
+ const durabilityColor = (d) => {
6940
+ if (d === "permanent")
6941
+ return render.c.magenta;
6942
+ if (d === "persistent")
6943
+ return render.c.blue;
6944
+ if (d === "ephemeral")
6945
+ return render.c.gray;
6946
+ return render.c.cyan;
6947
+ };
6948
+ const cols = [
6949
+ {
6950
+ label: "created_at",
6951
+ key: "createdAt",
6952
+ format: (v) => (v ? render.wrap(render.c.dim, String(v).slice(0, 10)) : render.wrap(render.c.dim, "—")),
6953
+ },
6954
+ {
6955
+ label: "durability",
6956
+ key: "durability",
6957
+ format: (v) => {
6958
+ const d = String(v ?? "standard");
6959
+ return render.wrap(durabilityColor(d), d);
6960
+ },
6961
+ },
6962
+ {
6963
+ label: "preview",
6964
+ key: "content",
6965
+ format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
6966
+ },
6967
+ ];
6968
+ console.log(render.table(cols, sorted));
6281
6969
  });
6282
6970
  // ─── flair memory hygiene ────────────────────────────────────────────────────
6283
6971
  // Detect + remove junk memory rows that accumulate over time. Surfaced from
@@ -6421,47 +7109,184 @@ memory.command("hygiene")
6421
7109
  console.log("hygiene --apply` on each peer to fan out.");
6422
7110
  });
6423
7111
  // ─── flair search (top-level shortcut) ───────────────────────────────────────
7112
+ // Parse --since / --as-of: accept ISO 8601 OR relative expressions
7113
+ // ("1h", "7d", "30m"). Returns ISO 8601 string or null if input is empty.
7114
+ // Returns the original string unchanged if it looks like a date (caller
7115
+ // passes through to server, which validates).
7116
+ function parseRelativeOrIso(input) {
7117
+ if (!input)
7118
+ return null;
7119
+ const m = input.match(/^(\d+)([smhdw])$/);
7120
+ if (!m)
7121
+ return input;
7122
+ const n = Number.parseInt(m[1], 10);
7123
+ const unit = m[2];
7124
+ const multMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
7125
+ return new Date(Date.now() - n * (multMs[unit] ?? 0)).toISOString();
7126
+ }
6424
7127
  program
6425
7128
  .command("search <query>")
6426
- .description("Search memories by meaning (shortcut for memory search)")
6427
- .requiredOption("--agent <id>", "Agent ID")
7129
+ .description("Search memories by meaning (shortcut for memory search) — filterable, with --explain ranking")
7130
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6428
7131
  .option("--limit <n>", "Max results", "5")
6429
7132
  .option("--port <port>", "Harper HTTP port")
6430
7133
  .option("--url <url>", "Flair base URL (overrides --port)")
7134
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
6431
7135
  .option("--key <path>", "Ed25519 private key path")
7136
+ // Server-side filters (forwarded to /SemanticSearch payload)
7137
+ .option("--tag <tag>", "Filter to memories carrying this tag")
7138
+ .option("--subject <subject>", "Filter to memories carrying this subject (case-insensitive)")
7139
+ .option("--subjects <list>", "Comma-separated list of subjects to OR-filter (case-insensitive)")
7140
+ .option("--since <iso-or-relative>", "Only memories created after this point (ISO 8601 or '7d'/'24h'/'30m')")
7141
+ .option("--as-of <iso>", "Temporal validity: only memories valid at this point (ISO 8601)")
7142
+ .option("--include-superseded", "Include memories that have been superseded")
7143
+ .option("--scoring <mode>", "Scoring mode: composite (default) re-ranks by durability/recency/retrieval; raw uses cosine similarity only", "composite")
7144
+ .option("--min-score <n>", "Drop results below this score (0..1)", "0")
7145
+ // Client-side filters (applied after server response)
7146
+ .option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
7147
+ .option("--source <name>", "Filter by source/agentId (client-side)")
7148
+ // Output modes
7149
+ .option("--explain", "Show score breakdown (composite, raw, durability, age, retrieval) per hit")
7150
+ .option("--json", "Output raw JSON array")
6432
7151
  .action(async (query, opts) => {
6433
7152
  try {
6434
- const baseUrl = opts.url || `http://127.0.0.1:${resolveHttpPort(opts)}`;
7153
+ const agentId = resolveAgentIdOrEnv(opts);
7154
+ if (!agentId) {
7155
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
7156
+ process.exit(2);
7157
+ }
7158
+ const baseUrl = resolveBaseUrl(opts);
6435
7159
  const headers = { "content-type": "application/json" };
6436
- const keyPath = opts.key || resolveKeyPath(opts.agent);
7160
+ const keyPath = opts.key || resolveKeyPath(agentId);
6437
7161
  if (keyPath) {
6438
- headers["authorization"] = buildEd25519Auth(opts.agent, "POST", "/SemanticSearch", keyPath);
7162
+ headers["authorization"] = buildEd25519Auth(agentId, "POST", "/SemanticSearch", keyPath);
6439
7163
  }
7164
+ // Build payload from CLI options. Server validates types.
7165
+ const payload = {
7166
+ agentId,
7167
+ q: query,
7168
+ limit: Number.parseInt(opts.limit, 10) || 5,
7169
+ scoring: opts.scoring === "raw" ? "raw" : "composite",
7170
+ };
7171
+ if (opts.tag)
7172
+ payload.tag = opts.tag;
7173
+ if (opts.subject)
7174
+ payload.subject = opts.subject;
7175
+ if (opts.subjects)
7176
+ payload.subjects = String(opts.subjects).split(",").map((s) => s.trim()).filter(Boolean);
7177
+ const since = parseRelativeOrIso(opts.since);
7178
+ if (since)
7179
+ payload.since = since;
7180
+ if (opts.asOf)
7181
+ payload.asOf = opts.asOf;
7182
+ if (opts.includeSuperseded)
7183
+ payload.includeSuperseded = true;
7184
+ const minScore = Number.parseFloat(opts.minScore ?? "0");
7185
+ if (Number.isFinite(minScore) && minScore > 0)
7186
+ payload.minScore = minScore;
6440
7187
  const res = await fetch(`${baseUrl}/SemanticSearch`, {
6441
7188
  method: "POST",
6442
7189
  headers,
6443
- body: JSON.stringify({ agentId: opts.agent, q: query, limit: parseInt(opts.limit, 10) }),
7190
+ body: JSON.stringify(payload),
6444
7191
  });
6445
7192
  if (!res.ok)
6446
7193
  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.");
7194
+ const result = (await res.json());
7195
+ let results = result.results || result || [];
7196
+ if (!Array.isArray(results))
7197
+ results = [];
7198
+ // Client-side filters: durability + source. Server doesn't expose these
7199
+ // as conditions, so we filter after fetch.
7200
+ if (opts.durability) {
7201
+ const allowed = new Set(String(opts.durability).split(",").map((d) => d.trim()));
7202
+ results = results.filter((r) => allowed.has(r.durability ?? "standard"));
7203
+ }
7204
+ if (opts.source) {
7205
+ const allowed = new Set(String(opts.source).split(",").map((s) => s.trim()));
7206
+ results = results.filter((r) => allowed.has(r._source ?? r.agentId ?? ""));
7207
+ }
7208
+ const mode = render.resolveOutputMode(opts);
7209
+ if (mode === "json") {
7210
+ console.log(render.asJSON(results));
6451
7211
  return;
6452
7212
  }
7213
+ if (results.length === 0) {
7214
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No results found.")}`);
7215
+ const filters = [];
7216
+ if (opts.tag)
7217
+ filters.push(`tag=${opts.tag}`);
7218
+ if (opts.subject)
7219
+ filters.push(`subject=${opts.subject}`);
7220
+ if (opts.subjects)
7221
+ filters.push(`subjects=${opts.subjects}`);
7222
+ if (opts.since)
7223
+ filters.push(`since=${opts.since}`);
7224
+ if (opts.durability)
7225
+ filters.push(`durability=${opts.durability}`);
7226
+ if (opts.source)
7227
+ filters.push(`source=${opts.source}`);
7228
+ if (filters.length > 0) {
7229
+ console.log(` ${render.wrap(render.c.dim, "Filters:")} ${filters.join(render.wrap(render.c.dim, " · "))}`);
7230
+ console.log(` ${render.icons.arrow} ${render.wrap(render.c.dim, "Try removing a filter or:")} flair search "${query}" --agent ${agentId}`);
7231
+ }
7232
+ return;
7233
+ }
7234
+ const durabilityColor = (d) => {
7235
+ if (d === "permanent")
7236
+ return render.c.magenta;
7237
+ if (d === "persistent")
7238
+ return render.c.blue;
7239
+ if (d === "ephemeral")
7240
+ return render.c.gray;
7241
+ return render.c.cyan;
7242
+ };
6453
7243
  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(" · ");
7244
+ const date = r.createdAt ? String(r.createdAt).slice(0, 10) : "";
7245
+ const scoreVal = typeof r._score === "number" ? r._score : 0;
7246
+ const scorePct = typeof r._score === "number" ? `${(scoreVal * 100).toFixed(0)}%` : "";
7247
+ const scoreColor = scoreVal >= 0.7 ? render.c.green : scoreVal >= 0.4 ? render.c.yellow : render.c.dim;
7248
+ const durability = r.durability ?? "standard";
7249
+ const metaParts = [];
7250
+ if (date)
7251
+ metaParts.push(render.wrap(render.c.dim, date));
7252
+ metaParts.push(render.wrap(durabilityColor(durability), durability));
7253
+ if (scorePct)
7254
+ metaParts.push(render.wrap(scoreColor, scorePct));
7255
+ if (r._source)
7256
+ metaParts.push(render.wrap(render.c.cyan, `from:${r._source}`));
7257
+ const meta = metaParts.join(render.wrap(render.c.dim, " · "));
6457
7258
  console.log(` ${r.content}`);
6458
7259
  if (meta)
6459
- console.log(` (${meta})`);
7260
+ console.log(` ${render.wrap(render.c.dim, "(")} ${meta} ${render.wrap(render.c.dim, ")")}`);
7261
+ if (opts.explain) {
7262
+ const parts = [];
7263
+ if (typeof r._rawScore === "number")
7264
+ parts.push(`raw=${r._rawScore.toFixed(3)}`);
7265
+ if (typeof r._score === "number")
7266
+ parts.push(`composite=${r._score.toFixed(3)}`);
7267
+ if (typeof r.retrievalCount === "number" && r.retrievalCount > 0)
7268
+ parts.push(`retrievals=${r.retrievalCount}`);
7269
+ if (r.tags && Array.isArray(r.tags) && r.tags.length > 0)
7270
+ parts.push(`tags=[${r.tags.join(",")}]`);
7271
+ if (r.subject)
7272
+ parts.push(`subject=${r.subject}`);
7273
+ if (r.supersedes)
7274
+ parts.push(`supersedes=${r.supersedes}`);
7275
+ if (parts.length > 0) {
7276
+ console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, parts.join(" · "))}`);
7277
+ }
7278
+ }
6460
7279
  console.log();
6461
7280
  }
7281
+ if (opts.explain) {
7282
+ const formula = payload.scoring === "composite"
7283
+ ? "semantic × durability-weight × recency-decay × retrieval-boost"
7284
+ : "cosine similarity only";
7285
+ console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, payload.scoring)} ${render.wrap(render.c.dim, `(${formula})`)}`);
7286
+ }
6462
7287
  }
6463
7288
  catch (err) {
6464
- console.error(`Search failed: ${err.message}`);
7289
+ console.error(`${render.icons.error} Search failed: ${err.message}`);
6465
7290
  process.exit(1);
6466
7291
  }
6467
7292
  });
@@ -6483,58 +7308,179 @@ program
6483
7308
  program
6484
7309
  .command("bootstrap")
6485
7310
  .description("Cold-start context: get soul + recent memories as formatted text")
6486
- .requiredOption("--agent <id>", "Agent ID")
7311
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6487
7312
  .option("--max-tokens <n>", "Maximum tokens in output", "4000")
6488
7313
  .option("--port <port>", "Harper HTTP port")
6489
7314
  .option("--url <url>", "Flair base URL (overrides --port)")
7315
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
6490
7316
  .option("--key <path>", "Ed25519 private key path")
7317
+ .option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
6491
7318
  .action(async (opts) => {
6492
- const baseUrl = opts.url || `http://127.0.0.1:${resolveHttpPort(opts)}`;
7319
+ const agentId = resolveAgentIdOrEnv(opts);
7320
+ if (!agentId) {
7321
+ console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
7322
+ process.exit(2);
7323
+ }
7324
+ const baseUrl = resolveBaseUrl(opts);
7325
+ const mode = render.resolveOutputMode(opts);
6493
7326
  try {
6494
7327
  const headers = { "content-type": "application/json" };
6495
- const keyPath = opts.key || resolveKeyPath(opts.agent);
7328
+ const keyPath = opts.key || resolveKeyPath(agentId);
6496
7329
  if (keyPath) {
6497
- headers["authorization"] = buildEd25519Auth(opts.agent, "POST", "/BootstrapMemories", keyPath);
7330
+ headers["authorization"] = buildEd25519Auth(agentId, "POST", "/BootstrapMemories", keyPath);
6498
7331
  }
6499
7332
  const res = await fetch(`${baseUrl}/BootstrapMemories`, {
6500
7333
  method: "POST",
6501
7334
  headers,
6502
- body: JSON.stringify({ agentId: opts.agent, maxTokens: parseInt(opts.maxTokens, 10) }),
7335
+ body: JSON.stringify({ agentId, maxTokens: parseInt(opts.maxTokens, 10) }),
6503
7336
  });
6504
7337
  if (!res.ok) {
6505
7338
  const body = await res.text();
6506
7339
  throw new Error(`${res.status}: ${body}`);
6507
7340
  }
6508
- const result = await res.json();
7341
+ const result = (await res.json());
7342
+ if (mode === "json") {
7343
+ // Agent-first: emit the full server response, augmented with the cap
7344
+ // that was requested. Includes context, sections, tokenEstimate, etc.
7345
+ console.log(render.asJSON({ ...result, maxTokens: parseInt(opts.maxTokens, 10) }));
7346
+ return;
7347
+ }
7348
+ // Human mode: print context to stdout, budget footer to stderr (parseable).
6509
7349
  if (result.context) {
6510
7350
  console.log(result.context);
6511
7351
  }
6512
7352
  else {
6513
- console.error("No context available.");
7353
+ console.error(`${render.icons.error} No context available.`);
6514
7354
  process.exit(1);
6515
7355
  }
6516
- // Print budget footer to stderr (parseable, won't interfere with context output)
6517
7356
  const tokensUsed = result.tokenEstimate ?? 0;
6518
7357
  const maxTokens = parseInt(opts.maxTokens, 10);
6519
7358
  const included = result.memoriesIncluded ?? 0;
6520
7359
  const truncated = result.memoriesTruncated ?? 0;
6521
- console.error(`[budget: ${tokensUsed}/${maxTokens} tokens, ${included} included, ${truncated} truncated]`);
7360
+ const tokenPct = maxTokens > 0 ? (tokensUsed / maxTokens) * 100 : 0;
7361
+ const tokenIcon = tokenPct >= 90 ? render.icons.warn : tokenPct >= 70 ? render.icons.info : render.icons.ok;
7362
+ const truncIcon = truncated > 0 ? render.icons.warn : render.icons.ok;
7363
+ 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
7364
  }
6523
7365
  catch (err) {
6524
- console.error(`Bootstrap failed: ${err.message}`);
7366
+ console.error(`${render.icons.error} Bootstrap failed: ${err.message}`);
6525
7367
  process.exit(1);
6526
7368
  }
6527
7369
  });
6528
7370
  const soul = program.command("soul").description("Manage agent soul entries");
6529
- soul.command("set").requiredOption("--agent <id>").requiredOption("--key <key>").requiredOption("--value <value>")
7371
+ soul.command("set")
7372
+ .requiredOption("--agent <id>")
7373
+ .requiredOption("--key <key>")
7374
+ .requiredOption("--value <value>")
6530
7375
  .option("--durability <d>", "permanent")
7376
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
6531
7377
  .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));
7378
+ const out = await api("POST", "/Soul", {
7379
+ id: `${opts.agent}:${opts.key}`,
7380
+ agentId: opts.agent,
7381
+ key: opts.key,
7382
+ value: opts.value,
7383
+ durability: opts.durability,
7384
+ });
7385
+ const mode = render.resolveOutputMode(opts);
7386
+ if (mode === "json") {
7387
+ console.log(render.asJSON(out));
7388
+ return;
7389
+ }
7390
+ console.log(`${render.icons.ok} ${render.wrap(render.c.green, "soul entry set")}`);
7391
+ console.log(render.kv("agent", opts.agent));
7392
+ console.log(render.kv("key", render.wrap(render.c.bold, opts.key)));
7393
+ console.log(render.kv("value", String(opts.value)));
7394
+ if (opts.durability)
7395
+ console.log(render.kv("durability", render.wrap(render.c.magenta, opts.durability)));
7396
+ });
7397
+ soul.command("get")
7398
+ .argument("<id>")
7399
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
7400
+ .action(async (id, opts) => {
7401
+ const out = await api("GET", `/Soul/${id}`);
7402
+ const mode = render.resolveOutputMode(opts);
7403
+ if (mode === "json") {
7404
+ console.log(render.asJSON(out));
7405
+ return;
7406
+ }
7407
+ if (!out || (typeof out === "object" && !out.id)) {
7408
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no entry")}`);
7409
+ return;
7410
+ }
7411
+ console.log(render.wrap(render.c.bold, out.id ?? id));
7412
+ if (out.agentId)
7413
+ console.log(render.kv("agent", out.agentId));
7414
+ if (out.key)
7415
+ console.log(render.kv("key", out.key));
7416
+ if (out.value !== undefined)
7417
+ console.log(render.kv("value", String(out.value)));
7418
+ if (out.durability)
7419
+ console.log(render.kv("durability", render.wrap(render.c.magenta, String(out.durability))));
7420
+ if (out.priority)
7421
+ console.log(render.kv("priority", String(out.priority)));
7422
+ if (out.createdAt)
7423
+ console.log(render.kv("created", `${render.relativeTime(out.createdAt)} ${render.wrap(render.c.dim, `(${out.createdAt})`)}`));
7424
+ if (out.updatedAt && out.updatedAt !== out.createdAt) {
7425
+ console.log(render.kv("updated", `${render.relativeTime(out.updatedAt)} ${render.wrap(render.c.dim, `(${out.updatedAt})`)}`));
7426
+ }
7427
+ });
7428
+ soul.command("list")
7429
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
7430
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
7431
+ .action(async (opts) => {
7432
+ const agentId = resolveAgentIdOrEnv(opts);
7433
+ if (!agentId) {
7434
+ console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
7435
+ process.exit(2);
7436
+ }
7437
+ const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`);
7438
+ const mode = render.resolveOutputMode(opts);
7439
+ if (mode === "json") {
7440
+ console.log(render.asJSON(out));
7441
+ return;
7442
+ }
7443
+ const all = Array.isArray(out) ? out : (out?.results ?? out?.items ?? []);
7444
+ if (all.length === 0) {
7445
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no soul entries for agent ${agentId}`)}`);
7446
+ return;
7447
+ }
7448
+ console.log(`${render.wrap(render.c.bold, String(all.length))} soul entries for agent ${render.wrap(render.c.bold, agentId)}\n`);
7449
+ const priorityColor = (p) => {
7450
+ if (p === "critical")
7451
+ return render.c.red;
7452
+ if (p === "high")
7453
+ return render.c.yellow;
7454
+ if (p === "low")
7455
+ return render.c.gray;
7456
+ return render.c.cyan;
7457
+ };
7458
+ const cols = [
7459
+ { label: "key", key: "key", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
7460
+ {
7461
+ label: "priority",
7462
+ key: "priority",
7463
+ format: (v) => {
7464
+ const p = String(v ?? "standard");
7465
+ return render.wrap(priorityColor(p), p);
7466
+ },
7467
+ },
7468
+ {
7469
+ label: "durability",
7470
+ key: "durability",
7471
+ format: (v) => {
7472
+ const d = String(v ?? "—");
7473
+ return d === "permanent" ? render.wrap(render.c.magenta, d) : render.wrap(render.c.dim, d);
7474
+ },
7475
+ },
7476
+ {
7477
+ label: "value",
7478
+ key: "value",
7479
+ format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
7480
+ },
7481
+ ];
7482
+ console.log(render.table(cols, all));
6534
7483
  });
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
7484
  // ─── flair bridge ────────────────────────────────────────────────────────────
6539
7485
  // Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
6540
7486
  // + agentic-stack reference adapter as a built-in.
@@ -6549,24 +7495,39 @@ bridge
6549
7495
  const { discover } = await import("./bridges/discover.js");
6550
7496
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
6551
7497
  const found = await discover({ builtins: builtinDiscoveryRecords() });
6552
- if (opts.json) {
6553
- console.log(JSON.stringify(found, null, 2));
7498
+ const mode = render.resolveOutputMode(opts);
7499
+ if (mode === "json") {
7500
+ console.log(render.asJSON(found));
6554
7501
  return;
6555
7502
  }
6556
7503
  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>");
7504
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No bridges installed.")}`);
7505
+ console.log(`${render.wrap(render.c.dim, " Add one with:")} flair bridge scaffold <name> --file`);
7506
+ console.log(`${render.wrap(render.c.dim, " Or install from npm:")} npm install flair-bridge-<name>`);
6560
7507
  return;
6561
7508
  }
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
- }
7509
+ console.log(`${render.wrap(render.c.bold, String(found.length))} bridge${found.length === 1 ? "" : "s"}\n`);
7510
+ const cols = [
7511
+ { label: "name", key: "name", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
7512
+ {
7513
+ label: "kind",
7514
+ key: "kind",
7515
+ format: (v) => {
7516
+ const k = String(v ?? "—");
7517
+ return render.wrap(k === "yaml" ? render.c.cyan : k === "api" ? render.c.magenta : render.c.dim, k);
7518
+ },
7519
+ },
7520
+ {
7521
+ label: "source",
7522
+ key: "source",
7523
+ format: (v) => {
7524
+ const s = String(v ?? "—");
7525
+ return render.wrap(s === "builtin" ? render.c.green : render.c.dim, s);
7526
+ },
7527
+ },
7528
+ { label: "description", key: "description", format: (v) => String(v ?? "") },
7529
+ ];
7530
+ console.log(render.table(cols, found));
6570
7531
  });
6571
7532
  bridge
6572
7533
  .command("scaffold <name>")
@@ -7004,21 +7965,22 @@ bridge
7004
7965
  .action(async (opts) => {
7005
7966
  const { list: listAllowed } = await import("./bridges/runtime/allow-list.js");
7006
7967
  const entries = await listAllowed();
7007
- if (opts.json) {
7008
- console.log(JSON.stringify(entries, null, 2));
7968
+ const mode = render.resolveOutputMode(opts);
7969
+ if (mode === "json") {
7970
+ console.log(render.asJSON(entries));
7009
7971
  return;
7010
7972
  }
7011
7973
  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>");
7974
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No code-plugin bridges are allow-listed yet.")}`);
7975
+ console.log(`${render.wrap(render.c.dim, " Allow one with:")} flair bridge allow <name>`);
7014
7976
  return;
7015
7977
  }
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`);
7978
+ console.log(`${render.wrap(render.c.bold, String(entries.length))} allow-listed code-plugin bridge${entries.length === 1 ? "" : "s"}\n`);
7019
7979
  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)}…`);
7980
+ 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)}`);
7981
+ console.log(render.kv("location", render.wrap(render.c.dim, e.packageDir)));
7982
+ console.log(render.kv("digest", render.wrap(render.c.dim, `sha256:${e.packageJsonSha256.slice(0, 16)}…`)));
7983
+ console.log();
7022
7984
  }
7023
7985
  });
7024
7986
  function printBridgeError(err) {
@@ -7116,13 +8078,26 @@ program
7116
8078
  .option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
7117
8079
  .option("--port <port>", "Harper HTTP port")
7118
8080
  .option("--url <url>", "Flair base URL (overrides --port)")
7119
- .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
8081
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
8082
+ .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
8083
  .action(async (opts) => {
7121
8084
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
7122
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8085
+ let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8086
+ if (!adminPass && opts.adminPassFile) {
8087
+ // readAdminPassFileSecure refuses world/group readable files (mode 0600
8088
+ // recommended). Common gotcha: files generated via
8089
+ // `openssl rand -base64 24 > admin-pass` end in a newline; helper trims it.
8090
+ try {
8091
+ adminPass = readAdminPassFileSecure(opts.adminPassFile);
8092
+ }
8093
+ catch (err) {
8094
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
8095
+ process.exit(1);
8096
+ }
8097
+ }
7123
8098
  const adminUser = DEFAULT_ADMIN_USER;
7124
8099
  if (!adminPass) {
7125
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for backup");
8100
+ console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
7126
8101
  process.exit(1);
7127
8102
  }
7128
8103
  const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
@@ -7181,11 +8156,11 @@ program
7181
8156
  const tmp = outputPath + ".tmp";
7182
8157
  writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
7183
8158
  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}`);
8159
+ console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
8160
+ console.log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
8161
+ console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
8162
+ console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
8163
+ console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
7189
8164
  });
7190
8165
  // ─── flair restore ────────────────────────────────────────────────────────────
7191
8166
  program
@@ -7299,10 +8274,10 @@ program
7299
8274
  console.warn(` warn: soul ${soul.id}: ${err.message}`);
7300
8275
  }
7301
8276
  }
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}`);
8277
+ console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Restore complete")}`);
8278
+ console.log(render.kv("Agents restored", `${render.wrap(render.c.bold, String(agentCount))}${render.wrap(render.c.dim, `/${agents.length}`)}`));
8279
+ console.log(render.kv("Memories restored", `${render.wrap(render.c.bold, String(memoryCount))}${render.wrap(render.c.dim, `/${memories.length}`)}`));
8280
+ console.log(render.kv("Souls restored", `${render.wrap(render.c.bold, String(soulCount))}${render.wrap(render.c.dim, `/${souls.length}`)}`));
7306
8281
  });
7307
8282
  // ─── flair export ────────────────────────────────────────────────────────────
7308
8283
  program
@@ -7384,13 +8359,16 @@ program
7384
8359
  writeFileSync(outputPath, JSON.stringify(exportData, null, 2), { mode: fileMode });
7385
8360
  if (privateKey)
7386
8361
  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}`);
8362
+ console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, `Agent '${agentId}' exported`)}`);
8363
+ console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
8364
+ console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
8365
+ console.log(render.kv("Grants", render.wrap(render.c.bold, String(grants.length))));
8366
+ const keyText = privateKey
8367
+ ? `${render.wrap(render.c.magenta, "included")} ${render.wrap(render.c.red, "(UNENCRYPTED protect this file)")}`
8368
+ : render.wrap(render.c.dim, "not included");
8369
+ console.log(render.kv("Key", keyText));
8370
+ console.log(render.kv("Mode", `${fileMode.toString(8)} ${render.wrap(render.c.dim, `(${privateKey ? "owner-only" : "standard"})`)}`));
8371
+ console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
7394
8372
  });
7395
8373
  // ─── flair import ────────────────────────────────────────────────────────────
7396
8374
  program
@@ -7481,40 +8459,50 @@ program
7481
8459
  }
7482
8460
  catch { /* skip failures */ }
7483
8461
  }
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}`);
8462
+ console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, `Agent '${agentId}' imported`)}`);
8463
+ console.log(render.kv("Memories", `${render.wrap(render.c.bold, String(memCount))}${render.wrap(render.c.dim, `/${(data.memories ?? []).length}`)}`));
8464
+ console.log(render.kv("Souls", `${render.wrap(render.c.bold, String(soulCount))}${render.wrap(render.c.dim, `/${(data.souls ?? []).length}`)}`));
8465
+ console.log(render.kv("Key", render.wrap(render.c.dim, privPath)));
7488
8466
  });
7489
8467
  // ─── flair backup inspect ────────────────────────────────────────────────────
7490
8468
  program
7491
8469
  .command("inspect <path>")
7492
8470
  .description("Show contents of a backup or export file")
7493
- .action(async (filePath) => {
8471
+ .option("--json", "Emit raw JSON of the file (also: pipe + FLAIR_OUTPUT=json)")
8472
+ .action(async (filePath, opts) => {
7494
8473
  if (!existsSync(filePath)) {
7495
- console.error(`File not found: ${filePath}`);
8474
+ console.error(`${render.icons.error} File not found: ${render.wrap(render.c.dim, filePath)}`);
7496
8475
  process.exit(1);
7497
8476
  }
7498
8477
  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"}`);
8478
+ const mode = render.resolveOutputMode(opts);
8479
+ if (mode === "json") {
8480
+ console.log(render.asJSON(data));
8481
+ return;
8482
+ }
8483
+ console.log(`${render.wrap(render.c.bold, "File:")} ${render.wrap(render.c.dim, filePath)}`);
8484
+ const type = data.type ?? "full-backup";
8485
+ const typeColor = type === "agent-export" ? render.c.cyan : render.c.magenta;
8486
+ console.log(render.kv("Type", render.wrap(typeColor, type)));
8487
+ console.log(render.kv("Created", String(data.createdAt ?? data.exportedAt ?? render.wrap(render.c.dim, "unknown"))));
8488
+ console.log(render.kv("Source", String(data.source ?? render.wrap(render.c.dim, "unknown"))));
7503
8489
  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"}`);
8490
+ console.log(`\n${render.wrap(render.c.bold, "Agent")}: ${render.wrap(render.c.bold, data.agent?.id ?? "unknown")}`);
8491
+ console.log(render.kv("Name", String(data.agent?.name ?? data.agent?.id ?? "—")));
8492
+ console.log(render.kv("Memories", render.wrap(render.c.bold, String((data.memories ?? []).length))));
8493
+ console.log(render.kv("Souls", render.wrap(render.c.bold, String((data.souls ?? []).length))));
8494
+ console.log(render.kv("Grants", render.wrap(render.c.bold, String((data.grants ?? []).length))));
8495
+ const keyText = data.privateKey ? render.wrap(render.c.magenta, "yes") : render.wrap(render.c.dim, "no");
8496
+ console.log(render.kv("Key included", keyText));
7510
8497
  }
7511
8498
  else {
7512
8499
  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}`);
8500
+ console.log(`\n${render.wrap(render.c.bold, "Agents")}: ${render.wrap(render.c.bold, String(agents.length))}`);
8501
+ for (const a of agents) {
8502
+ console.log(` ${render.wrap(render.c.dim, "·")} ${render.wrap(render.c.bold, a.id)} ${render.wrap(render.c.dim, `(${a.name ?? a.id})`)}`);
8503
+ }
8504
+ console.log(render.kv("Memories", render.wrap(render.c.bold, String((data.memories ?? []).length))));
8505
+ console.log(render.kv("Souls", render.wrap(render.c.bold, String((data.souls ?? []).length))));
7518
8506
  }
7519
8507
  });
7520
8508
  // ─── flair migrate-harness-memory ───────────────────────────────────────────