@tpsdev-ai/flair 0.8.3 → 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,9 +2,10 @@
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
- import { join, resolve, sep } from "node:path";
8
+ import { join, resolve, sep, dirname } from "node:path";
8
9
  import { spawn } from "node:child_process";
9
10
  import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
10
11
  import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
@@ -83,6 +84,34 @@ const DEFAULT_OPS_PORT = 19925;
83
84
  const DEFAULT_ADMIN_USER = "admin";
84
85
  const STARTUP_TIMEOUT_MS = 60_000;
85
86
  const HEALTH_POLL_INTERVAL_MS = 500;
87
+ /**
88
+ * Read an admin password from a file, refusing if the file is world/group readable.
89
+ *
90
+ * Admin pass files (default ~/.flair/admin-pass) are short-lived secrets generated
91
+ * by `openssl rand` — mode 0600 keeps them out of reach of other local users + most
92
+ * backup tooling. A 0644 file silently leaks the secret to anyone with read access
93
+ * to the user's home (multi-user hosts, NFS, time-machine snapshots, etc.).
94
+ *
95
+ * Throws with an actionable error if the file isn't owner-only, otherwise returns
96
+ * the trimmed file content (admin-pass files generated by `openssl rand -base64`
97
+ * conventionally end in a newline).
98
+ */
99
+ export function readAdminPassFileSecure(path) {
100
+ if (!existsSync(path)) {
101
+ throw new Error(`--admin-pass-file path does not exist: ${path}`);
102
+ }
103
+ const st = statSync(path);
104
+ if (st.mode & 0o077) {
105
+ const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
106
+ throw new Error(`Refusing to read --admin-pass-file at ${path}: permissions ${modeOctal} are too open. ` +
107
+ `Run \`chmod 600 ${path}\` to restrict to owner-only.`);
108
+ }
109
+ const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
110
+ if (!content) {
111
+ throw new Error(`admin password file is empty or contains only whitespace: ${path}`);
112
+ }
113
+ return content;
114
+ }
86
115
  function defaultKeysDir() {
87
116
  return join(homedir(), ".flair", "keys");
88
117
  }
@@ -126,6 +155,25 @@ function resolveHttpPort(opts) {
126
155
  }
127
156
  return readPortFromConfig() ?? DEFAULT_PORT;
128
157
  }
158
+ // Unified base URL resolution. Precedence:
159
+ // --target > --url > FLAIR_TARGET env > FLAIR_URL env > http://127.0.0.1:<resolveHttpPort>
160
+ //
161
+ // Every user-facing command that talks to a Flair instance should call this
162
+ // instead of hand-rolling the precedence. Keeps remote-target switching
163
+ // (e.g. CI hitting Fabric) consistent across `flair status`, `flair search`,
164
+ // `flair bootstrap`, etc.
165
+ function resolveBaseUrl(opts) {
166
+ return (opts.target
167
+ || opts.url
168
+ || process.env.FLAIR_TARGET
169
+ || process.env.FLAIR_URL
170
+ || `http://127.0.0.1:${resolveHttpPort(opts)}`);
171
+ }
172
+ // Resolve agent id from --agent flag or FLAIR_AGENT_ID env.
173
+ // Returns null if neither is set; caller decides whether that's fatal.
174
+ function resolveAgentIdOrEnv(opts) {
175
+ return opts.agent || process.env.FLAIR_AGENT_ID || null;
176
+ }
129
177
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
130
178
  function resolveOpsPort(opts) {
131
179
  if (opts.opsPort !== undefined && opts.opsPort !== null) {
@@ -831,6 +879,16 @@ export function probeOpenclawPluginVersion(extensionName) {
831
879
  return null;
832
880
  }
833
881
  }
882
+ /**
883
+ * Order a soul key→count map for display: highest count first, ties broken
884
+ * alphabetically for stable output. Soul entries are keyed identity facts
885
+ * (role / project / standards / …) — this is the honest breakdown dimension.
886
+ * (Replaced a priority breakdown that was dead telemetry — see flair#453:
887
+ * nothing ever writes Soul.priority to anything but "standard".)
888
+ */
889
+ export function sortSoulKeyEntries(byKey) {
890
+ return Object.entries(byKey ?? {}).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
891
+ }
834
892
  export function templateSoul(choice) {
835
893
  const templates = {
836
894
  "1": [
@@ -1206,14 +1264,11 @@ program
1206
1264
  }
1207
1265
  // Read from file if provided
1208
1266
  if (opts.adminPassFile) {
1209
- if (!existsSync(opts.adminPassFile)) {
1210
- console.error(`Error: --admin-pass-file path does not exist: ${opts.adminPassFile}`);
1211
- process.exit(1);
1267
+ try {
1268
+ adminPass = readAdminPassFileSecure(opts.adminPassFile);
1212
1269
  }
1213
- const fileContent = readFileSync(opts.adminPassFile, "utf-8");
1214
- adminPass = fileContent.trim();
1215
- if (!adminPass) {
1216
- console.error(`Error: admin password file is empty or contains only whitespace: ${opts.adminPassFile}`);
1270
+ catch (err) {
1271
+ console.error(`Error: ${err.message}`);
1217
1272
  process.exit(1);
1218
1273
  }
1219
1274
  passwordSource = "file";
@@ -1956,6 +2011,7 @@ agent
1956
2011
  .description("List all agents")
1957
2012
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
1958
2013
  .option("--port <port>", "Harper HTTP port")
2014
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
1959
2015
  .action(async (opts) => {
1960
2016
  const port = resolveHttpPort(opts);
1961
2017
  // fromEnv is true ONLY when the resolved value came from env (no inline override).
@@ -1965,8 +2021,9 @@ agent
1965
2021
  "to keep secrets out of shell history.");
1966
2022
  }
1967
2023
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
2024
+ const mode = render.resolveOutputMode(opts);
2025
+ let agents;
1968
2026
  if (adminPass) {
1969
- // Use admin basic auth against ops API to list agents directly
1970
2027
  const opsPort = resolveOpsPort(opts);
1971
2028
  const auth = Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64");
1972
2029
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
@@ -1976,39 +2033,79 @@ agent
1976
2033
  });
1977
2034
  if (!res.ok) {
1978
2035
  const text = await res.text().catch(() => "");
1979
- console.error(`Error: ${res.status} ${text}`);
2036
+ console.error(`${render.icons.error} ${res.status} ${text}`);
1980
2037
  process.exit(1);
1981
2038
  }
1982
- const agents = await res.json();
1983
- agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
1984
- console.log(JSON.stringify(agents, null, 2));
2039
+ agents = await res.json();
1985
2040
  }
1986
2041
  else {
1987
- // Localhost operator path: allow IDs-only enumeration without per-agent auth
1988
- // This treats localhost as a trusted boundary for read-only public metadata
1989
2042
  const baseUrl = `http://127.0.0.1:${port}`;
1990
2043
  const res = await fetch(`${baseUrl}/Agent`, {
1991
2044
  headers: { "Content-Type": "application/json" },
1992
2045
  });
1993
2046
  if (!res.ok) {
1994
2047
  const text = await res.text().catch(() => "");
1995
- console.error(`Error: ${res.status} ${text}`);
2048
+ console.error(`${render.icons.error} ${res.status} ${text}`);
1996
2049
  process.exit(1);
1997
2050
  }
1998
2051
  const data = await res.json();
1999
- // Filter to IDs-only to respect the localhost trust boundary
2000
- if (Array.isArray(data)) {
2001
- console.log(JSON.stringify(data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })), null, 2));
2002
- }
2003
- else {
2004
- console.log(JSON.stringify(data, null, 2));
2005
- }
2052
+ agents = Array.isArray(data) ? data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })) : [];
2053
+ }
2054
+ agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
2055
+ if (mode === "json") {
2056
+ console.log(render.asJSON(agents));
2057
+ return;
2006
2058
  }
2059
+ if (agents.length === 0) {
2060
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no agents")}`);
2061
+ return;
2062
+ }
2063
+ console.log(`${render.wrap(render.c.bold, String(agents.length))} agents\n`);
2064
+ const cols = [
2065
+ { label: "id", key: "id", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
2066
+ { label: "name", key: "name", format: (v) => String(v ?? "—") },
2067
+ { label: "created", key: "createdAt", format: (v) => render.wrap(render.c.dim, v ? String(v).slice(0, 10) : "—") },
2068
+ ];
2069
+ console.log(render.table(cols, agents));
2007
2070
  });
2008
2071
  agent
2009
2072
  .command("show <id>")
2010
2073
  .description("Show agent details")
2011
- .action(async (id) => console.log(JSON.stringify(await api("GET", `/Agent/${id}`), null, 2)));
2074
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
2075
+ .action(async (id, opts) => {
2076
+ const out = await api("GET", `/Agent/${id}`);
2077
+ const mode = render.resolveOutputMode(opts);
2078
+ if (mode === "json") {
2079
+ console.log(render.asJSON(out));
2080
+ return;
2081
+ }
2082
+ if (!out || (typeof out === "object" && !out.id)) {
2083
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no agent ${id}`)}`);
2084
+ return;
2085
+ }
2086
+ console.log(render.wrap(render.c.bold, String(out.id)));
2087
+ if (out.name)
2088
+ console.log(render.kv("name", String(out.name)));
2089
+ if (out.kind)
2090
+ console.log(render.kv("kind", render.wrap(render.c.cyan, String(out.kind))));
2091
+ if (out.status) {
2092
+ const statusColor = out.status === "active" ? render.c.green : out.status === "disabled" ? render.c.red : render.c.yellow;
2093
+ console.log(render.kv("status", render.wrap(statusColor, String(out.status))));
2094
+ }
2095
+ if (out.defaultTrustTier)
2096
+ console.log(render.kv("trust tier", String(out.defaultTrustTier)));
2097
+ if (out.admin)
2098
+ console.log(render.kv("admin", render.wrap(render.c.magenta, "yes")));
2099
+ if (out.runtime)
2100
+ console.log(render.kv("runtime", String(out.runtime)));
2101
+ if (out.publicKey)
2102
+ console.log(render.kv("publicKey", render.wrap(render.c.dim, String(out.publicKey))));
2103
+ if (out.createdAt)
2104
+ console.log(render.kv("created", `${render.relativeTime(out.createdAt)} ${render.wrap(render.c.dim, `(${out.createdAt})`)}`));
2105
+ if (out.updatedAt && out.updatedAt !== out.createdAt) {
2106
+ console.log(render.kv("updated", `${render.relativeTime(out.updatedAt)} ${render.wrap(render.c.dim, `(${out.updatedAt})`)}`));
2107
+ }
2108
+ });
2012
2109
  agent
2013
2110
  .command("rotate-key <id>")
2014
2111
  .description("Rotate an agent's Ed25519 keypair")
@@ -2295,11 +2392,12 @@ principal
2295
2392
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
2296
2393
  .option("--port <port>", "Harper HTTP port")
2297
2394
  .option("--ops-port <port>", "Harper operations API port")
2395
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
2298
2396
  .action(async (opts) => {
2299
2397
  const opsPort = resolveOpsPort(opts);
2300
2398
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
2301
2399
  if (!adminPass) {
2302
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
2400
+ console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
2303
2401
  process.exit(1);
2304
2402
  }
2305
2403
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
@@ -2320,34 +2418,90 @@ principal
2320
2418
  });
2321
2419
  if (!res.ok) {
2322
2420
  const text = await res.text().catch(() => "");
2323
- console.error(`Error: ${res.status} ${text}`);
2421
+ console.error(`${render.icons.error} ${res.status} ${text}`);
2324
2422
  process.exit(1);
2325
2423
  }
2326
2424
  const records = await res.json();
2327
2425
  records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
2328
- if (records.length === 0) {
2329
- console.log("No principals found.");
2426
+ const mode = render.resolveOutputMode(opts);
2427
+ if (mode === "json") {
2428
+ console.log(render.asJSON(records));
2330
2429
  return;
2331
2430
  }
2332
- // Table format
2333
- console.log(`${"ID".padEnd(20)} ${"Kind".padEnd(7)} ${"Trust".padEnd(14)} ${"Admin".padEnd(6)} ${"Status".padEnd(12)} ${"Runtime".padEnd(12)} Created`);
2334
- console.log("─".repeat(95));
2335
- for (const r of records) {
2336
- const kind = r.kind ?? "agent";
2337
- const trust = r.defaultTrustTier ?? "—";
2338
- const admin = r.admin ? "yes" : "no";
2339
- const status = r.status ?? "active";
2340
- const runtime = r.runtime ?? "—";
2341
- const created = r.createdAt?.slice(0, 10) ?? "—";
2342
- console.log(`${String(r.id).padEnd(20)} ${kind.padEnd(7)} ${trust.padEnd(14)} ${admin.padEnd(6)} ${status.padEnd(12)} ${runtime.padEnd(12)} ${created}`);
2431
+ if (records.length === 0) {
2432
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no principals")}`);
2433
+ return;
2343
2434
  }
2435
+ console.log(`${render.wrap(render.c.bold, String(records.length))} principals${opts.kind ? ` ${render.wrap(render.c.dim, `(kind=${opts.kind})`)}` : ""}\n`);
2436
+ const cols = [
2437
+ { label: "id", key: "id", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
2438
+ {
2439
+ label: "kind",
2440
+ key: "kind",
2441
+ format: (v) => {
2442
+ const k = String(v ?? "agent");
2443
+ return render.wrap(k === "human" ? render.c.cyan : render.c.magenta, k);
2444
+ },
2445
+ },
2446
+ { label: "trust", key: "defaultTrustTier", format: (v) => String(v ?? "—") },
2447
+ {
2448
+ label: "admin",
2449
+ key: "admin",
2450
+ format: (v) => (v ? render.wrap(render.c.red, "yes") : render.wrap(render.c.dim, "no")),
2451
+ },
2452
+ {
2453
+ label: "status",
2454
+ key: "status",
2455
+ format: (v) => {
2456
+ const s = String(v ?? "active");
2457
+ const color = s === "active" ? render.c.green : s === "disabled" ? render.c.red : render.c.yellow;
2458
+ return render.wrap(color, s);
2459
+ },
2460
+ },
2461
+ { label: "runtime", key: "runtime", format: (v) => String(v ?? "—") },
2462
+ { label: "created", key: "createdAt", format: (v) => render.wrap(render.c.dim, v ? String(v).slice(0, 10) : "—") },
2463
+ ];
2464
+ console.log(render.table(cols, records));
2344
2465
  });
2345
2466
  principal
2346
2467
  .command("show <id>")
2347
2468
  .description("Show principal details")
2348
- .action(async (id) => {
2469
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
2470
+ .action(async (id, opts) => {
2349
2471
  const result = await api("GET", `/Agent/${id}`);
2350
- console.log(JSON.stringify(result, null, 2));
2472
+ const mode = render.resolveOutputMode(opts);
2473
+ if (mode === "json") {
2474
+ console.log(render.asJSON(result));
2475
+ return;
2476
+ }
2477
+ if (!result || (typeof result === "object" && !result.id)) {
2478
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no principal ${id}`)}`);
2479
+ return;
2480
+ }
2481
+ console.log(render.wrap(render.c.bold, String(result.id)));
2482
+ if (result.name)
2483
+ console.log(render.kv("name", String(result.name)));
2484
+ if (result.kind)
2485
+ console.log(render.kv("kind", render.wrap(result.kind === "human" ? render.c.cyan : render.c.magenta, String(result.kind))));
2486
+ if (result.status) {
2487
+ const statusColor = result.status === "active" ? render.c.green : result.status === "disabled" ? render.c.red : render.c.yellow;
2488
+ console.log(render.kv("status", render.wrap(statusColor, String(result.status))));
2489
+ }
2490
+ if (result.defaultTrustTier)
2491
+ console.log(render.kv("trust tier", String(result.defaultTrustTier)));
2492
+ if (result.admin)
2493
+ console.log(render.kv("admin", render.wrap(render.c.red, "yes")));
2494
+ if (result.runtime)
2495
+ console.log(render.kv("runtime", String(result.runtime)));
2496
+ if (result.email)
2497
+ console.log(render.kv("email", String(result.email)));
2498
+ if (result.publicKey)
2499
+ console.log(render.kv("publicKey", render.wrap(render.c.dim, String(result.publicKey))));
2500
+ if (result.createdAt)
2501
+ console.log(render.kv("created", `${render.relativeTime(result.createdAt)} ${render.wrap(render.c.dim, `(${result.createdAt})`)}`));
2502
+ if (result.updatedAt && result.updatedAt !== result.createdAt) {
2503
+ console.log(render.kv("updated", `${render.relativeTime(result.updatedAt)} ${render.wrap(render.c.dim, `(${result.updatedAt})`)}`));
2504
+ }
2351
2505
  });
2352
2506
  principal
2353
2507
  .command("disable <id>")
@@ -2475,11 +2629,12 @@ idp
2475
2629
  .description("List configured IdPs")
2476
2630
  .option("--admin-pass <pass>", "Admin password")
2477
2631
  .option("--ops-port <port>", "Harper operations API port")
2632
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
2478
2633
  .action(async (opts) => {
2479
2634
  const opsPort = resolveOpsPort(opts);
2480
2635
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
2481
2636
  if (!adminPass) {
2482
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
2637
+ console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
2483
2638
  process.exit(1);
2484
2639
  }
2485
2640
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
@@ -2498,22 +2653,28 @@ idp
2498
2653
  });
2499
2654
  if (!res.ok) {
2500
2655
  const text = await res.text().catch(() => "");
2501
- console.error(`Error: ${res.status} ${text}`);
2656
+ console.error(`${render.icons.error} ${res.status} ${text}`);
2502
2657
  process.exit(1);
2503
2658
  }
2504
2659
  const records = await res.json();
2505
2660
  records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
2661
+ const mode = render.resolveOutputMode(opts);
2662
+ if (mode === "json") {
2663
+ console.log(render.asJSON(records));
2664
+ return;
2665
+ }
2506
2666
  if (records.length === 0) {
2507
- console.log("No IdPs configured.");
2667
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no IdPs configured")}`);
2508
2668
  return;
2509
2669
  }
2670
+ console.log(`${render.wrap(render.c.bold, String(records.length))} IdP${records.length === 1 ? "" : "s"}\n`);
2510
2671
  for (const r of records) {
2511
- const status = r.enabled ? "enabled" : "disabled";
2512
- console.log(`${r.name} (${r.id}) — ${status}`);
2513
- console.log(` Issuer: ${r.issuer}`);
2672
+ const enabled = r.enabled ? render.wrap(render.c.green, "enabled") : render.wrap(render.c.dim, "disabled");
2673
+ console.log(`${render.wrap(render.c.bold, r.name ?? "?")} ${render.wrap(render.c.dim, `(${r.id})`)} ${render.wrap(render.c.dim, "")} ${enabled}`);
2674
+ console.log(render.kv("issuer", String(r.issuer ?? "—")));
2514
2675
  if (r.requiredDomain)
2515
- console.log(` Domain: ${r.requiredDomain}`);
2516
- console.log(` JIT: ${r.jitProvision ?? true}`);
2676
+ console.log(render.kv("domain", String(r.requiredDomain)));
2677
+ console.log(render.kv("JIT", String(r.jitProvision ?? true)));
2517
2678
  console.log();
2518
2679
  }
2519
2680
  });
@@ -2727,30 +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
- console.log(`${"Peer".padEnd(20)} ${"Role".padEnd(8)} ${"Status".padEnd(14)} ${"Last Sync".padEnd(22)} Relay`);
2745
- console.log("─".repeat(80));
2746
- for (const p of peers) {
2747
- const lastSync = p.lastSyncAt?.slice(0, 19) ?? "never";
2748
- console.log(`${p.id.padEnd(20)} ${(p.role ?? "—").padEnd(8)} ${(p.status ?? "—").padEnd(14)} ${lastSync.padEnd(22)} ${p.relayOnly ? "yes" : "no"}`);
2749
- }
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.")}`);
2750
2989
  }
2751
2990
  }
2752
2991
  catch (err) {
2753
- console.error(`Error: ${err.message}`);
2992
+ const msg = String(err.message ?? err);
2993
+ if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
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)")}`);
2999
+ process.exit(1);
3000
+ }
3001
+ console.error(`${render.icons.error} ${msg}`);
2754
3002
  process.exit(1);
2755
3003
  }
2756
3004
  });
@@ -3111,6 +3359,12 @@ export async function runFederationSyncOnce(opts) {
3111
3359
  }
3112
3360
  console.log(`Syncing to hub: ${hub.id}...`);
3113
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();
3114
3368
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
3115
3369
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
3116
3370
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
@@ -3189,6 +3443,35 @@ export async function runFederationSyncOnce(opts) {
3189
3443
  totalBatches++;
3190
3444
  }
3191
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
+ }
3192
3475
  if (totalBatches === 0) {
3193
3476
  console.log("No changes since last sync.");
3194
3477
  return { pushed: 0, skipped: 0 };
@@ -3749,7 +4032,7 @@ rem
3749
4032
  process.exit(1);
3750
4033
  }
3751
4034
  if (!agentId) {
3752
- 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)`);
3753
4036
  process.exit(1);
3754
4037
  }
3755
4038
  try {
@@ -3761,42 +4044,49 @@ rem
3761
4044
  ],
3762
4045
  get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
3763
4046
  });
3764
- // search_by_conditions returns either an array or { error }; tolerate both shapes
3765
4047
  const candidates = Array.isArray(result) ? result : (result?.results ?? []);
3766
- if (opts.json) {
3767
- 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 }));
3768
4051
  return;
3769
4052
  }
3770
- 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)}`);
3771
4055
  if (candidates.length === 0) {
3772
- console.log(`No ${status} candidates.`);
4056
+ console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, `No ${status} candidates.`)}`);
3773
4057
  if (status === "pending") {
3774
- 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.")}`);
3775
4059
  }
3776
4060
  return;
3777
4061
  }
3778
- // Sort newest-first by generatedAt
3779
4062
  candidates.sort((a, b) => String(b.generatedAt ?? "").localeCompare(String(a.generatedAt ?? "")));
4063
+ console.log();
3780
4064
  for (const c of candidates) {
3781
- const tag = c.status === "promoted"
3782
- ? `[promoted → ${c.target ?? "?"} by ${c.reviewerId ?? "?"} @ ${relativeTime(c.decidedAt)}]`
3783
- : c.status === "rejected"
3784
- ? `[rejected by ${c.reviewerId ?? "?"} @ ${relativeTime(c.decidedAt)}]`
3785
- : `[pending — ${c.generatedBy ?? "?"} @ ${relativeTime(c.generatedAt)}]`;
3786
- 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}`);
3787
4076
  console.log(` ${c.claim}`);
3788
- if (c.supersedes)
3789
- 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
+ }
3790
4080
  console.log("");
3791
4081
  }
3792
- 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" : ""}.`);
3793
4083
  if (status === "pending") {
3794
- console.log(`Promote: flair rem promote <id> --rationale "<why>" --to (soul|memory)`);
3795
- 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>"`);
3796
4086
  }
3797
4087
  }
3798
4088
  catch (err) {
3799
- console.error(`Error: ${err.message}`);
4089
+ console.error(`${render.icons.error} ${err.message}`);
3800
4090
  process.exit(1);
3801
4091
  }
3802
4092
  });
@@ -3991,6 +4281,346 @@ rem
3991
4281
  process.exit(1);
3992
4282
  }
3993
4283
  });
4284
+ // ─── flair rem nightly run-once ──────────────────────────────────────────────
4285
+ // Slice 1 of FLAIR-NIGHTLY-REM § 3. Manually invokes the nightly cycle code
4286
+ // path — same module the scheduler will call in PR-2. Useful for:
4287
+ // - First-time operators verifying the cycle works before turning on the
4288
+ // scheduled timer.
4289
+ // - The dry-run-first-run guard (spec § 10) when the scheduler isn't yet
4290
+ // installed.
4291
+ // - Debugging a stale snapshot or audit row.
4292
+ //
4293
+ // `nightly enable` / `disable` / `status` land in PR-2 (scheduler templates).
4294
+ const remNightly = rem.command("nightly").description("Scheduled REM nightly cycle (manual trigger + scheduler management)");
4295
+ // `enable` / `disable` / `status` — scheduler install/uninstall (slice-1 PR-2).
4296
+ // macOS: writes ~/Library/LaunchAgents/dev.flair.rem.nightly.plist and bootstraps it.
4297
+ // Linux: writes ~/.config/systemd/user/flair-rem-nightly.{timer,service} and enables the timer.
4298
+ // Snapshot data and the audit log are preserved through enable/disable cycles.
4299
+ remNightly
4300
+ .command("enable")
4301
+ .description("Install the nightly scheduler (launchd on macOS, systemd timer on Linux)")
4302
+ .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
4303
+ .option("--at <HH:MM>", "Local time to run nightly (default 03:00)", "03:00")
4304
+ .option("--flair-url <url>", "Flair HTTP URL the runner will hit (default http://127.0.0.1:<port>)")
4305
+ .action(async (opts) => {
4306
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
4307
+ if (!agentId) {
4308
+ console.error("Error: --agent or FLAIR_AGENT_ID env required");
4309
+ process.exit(1);
4310
+ }
4311
+ const match = /^(\d{1,2}):(\d{2})$/.exec(opts.at);
4312
+ if (!match) {
4313
+ console.error(`Error: --at must be HH:MM (got: ${opts.at})`);
4314
+ process.exit(1);
4315
+ }
4316
+ const hour = parseInt(match[1], 10);
4317
+ const minute = parseInt(match[2], 10);
4318
+ const port = readPortFromConfig() ?? DEFAULT_PORT;
4319
+ const flairUrl = opts.flairUrl || process.env.FLAIR_URL || `http://127.0.0.1:${port}`;
4320
+ const { enableScheduler } = await import("./rem/scheduler.js");
4321
+ try {
4322
+ const r = enableScheduler({ agentId, flairUrl, hour, minute });
4323
+ console.log(`✅ REM nightly scheduler enabled (${r.platform})`);
4324
+ console.log(` Schedule: ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} local time`);
4325
+ console.log(` Scheduler: ${r.schedulerPath}`);
4326
+ console.log(` Shim: ${r.shimPath}`);
4327
+ console.log(` Agent: ${agentId}`);
4328
+ console.log(` Flair URL: ${flairUrl}`);
4329
+ if (r.loadResult) {
4330
+ if (r.loadResult.code === 0) {
4331
+ console.log(` Load: ${r.loadCommand.join(" ")} → ok`);
4332
+ }
4333
+ else {
4334
+ console.log(` Load: ${r.loadCommand.join(" ")} → code ${r.loadResult.code}`);
4335
+ if (r.loadResult.stderr)
4336
+ console.log(` stderr: ${r.loadResult.stderr.trim()}`);
4337
+ }
4338
+ }
4339
+ console.log(`\nTip: run \`flair rem nightly run-once --dry-run\` to verify the cycle works`);
4340
+ console.log(` before the first scheduled fire. Disable with \`flair rem nightly disable\`.`);
4341
+ }
4342
+ catch (err) {
4343
+ console.error(`Error: ${err.message}`);
4344
+ process.exit(1);
4345
+ }
4346
+ });
4347
+ remNightly
4348
+ .command("disable")
4349
+ .description("Remove the nightly scheduler (keeps snapshots + audit log)")
4350
+ .option("--remove-shim", "Also delete the ~/.flair/bin/flair-rem-nightly shim")
4351
+ .action(async (opts) => {
4352
+ const { disableScheduler } = await import("./rem/scheduler.js");
4353
+ try {
4354
+ const r = disableScheduler({ removeShim: !!opts.removeShim });
4355
+ if (r.removed.length === 0) {
4356
+ console.log(`(REM nightly scheduler was not installed on ${r.platform})`);
4357
+ return;
4358
+ }
4359
+ console.log(`✅ REM nightly scheduler disabled (${r.platform})`);
4360
+ console.log(` Removed:`);
4361
+ for (const p of r.removed)
4362
+ console.log(` ${p}`);
4363
+ if (r.unloadResult && r.unloadResult.code !== 0) {
4364
+ console.log(` Unload: ${r.unloadCommand.join(" ")} → code ${r.unloadResult.code}`);
4365
+ if (r.unloadResult.stderr)
4366
+ console.log(` stderr: ${r.unloadResult.stderr.trim()}`);
4367
+ }
4368
+ console.log(`\nSnapshots at ~/.flair/snapshots/ and the audit log at`);
4369
+ console.log(`~/.flair/logs/rem-nightly.jsonl are preserved.`);
4370
+ }
4371
+ catch (err) {
4372
+ console.error(`Error: ${err.message}`);
4373
+ process.exit(1);
4374
+ }
4375
+ });
4376
+ remNightly
4377
+ .command("status")
4378
+ .description("Show whether the nightly scheduler is installed")
4379
+ .action(async () => {
4380
+ const { schedulerStatus } = await import("./rem/scheduler.js");
4381
+ try {
4382
+ const s = schedulerStatus();
4383
+ console.log(`REM nightly scheduler (${s.platform}):`);
4384
+ console.log(` Installed: ${s.installed ? "yes" : "no"}`);
4385
+ console.log(` Scheduler: ${s.schedulerPath}`);
4386
+ console.log(` Shim: ${s.shimPath}${s.shimExists ? "" : " (missing)"}`);
4387
+ if (!s.installed) {
4388
+ console.log(`\nEnable with: flair rem nightly enable --agent <id> [--at HH:MM]`);
4389
+ }
4390
+ }
4391
+ catch (err) {
4392
+ console.error(`Error: ${err.message}`);
4393
+ process.exit(1);
4394
+ }
4395
+ });
4396
+ remNightly
4397
+ .command("run-once")
4398
+ .description("Run one nightly cycle now (snapshot + log). Same code path the scheduler will use.")
4399
+ .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
4400
+ .option("--dry-run", "Log the row but skip the snapshot write")
4401
+ .action(async (opts) => {
4402
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
4403
+ if (!agentId) {
4404
+ console.error("Error: --agent or FLAIR_AGENT_ID env required");
4405
+ process.exit(1);
4406
+ }
4407
+ const { runNightlyCycle } = await import("./rem/runner.js");
4408
+ try {
4409
+ const result = await runNightlyCycle({
4410
+ agentId,
4411
+ flairVersion: __pkgVersion,
4412
+ apiCall: api,
4413
+ dryRun: !!opts.dryRun,
4414
+ });
4415
+ const row = result.logRow;
4416
+ console.log(`-- rem nightly run-once${opts.dryRun ? " (dry-run)" : ""} --`);
4417
+ console.log(`Agent: ${agentId}`);
4418
+ console.log(`Status: ${result.status}`);
4419
+ if (result.snapshotPath) {
4420
+ console.log(`Snapshot: ${result.snapshotPath}`);
4421
+ }
4422
+ console.log(`Memories: ${row.memoryCount ?? "—"}`);
4423
+ console.log(`Souls: ${row.soulCount ?? "—"}`);
4424
+ console.log(`Pending: ${row.pendingCandidates ?? "—"}`);
4425
+ if (typeof row.archived === "number" || typeof row.expired === "number") {
4426
+ console.log(`Archived: ${row.archived ?? "—"}`);
4427
+ console.log(`Expired: ${row.expired ?? "—"}`);
4428
+ }
4429
+ console.log(`Duration: ${row.durationMs}ms`);
4430
+ if (row.errors.length > 0) {
4431
+ console.log(`Errors:`);
4432
+ for (const e of row.errors)
4433
+ console.log(` - ${e}`);
4434
+ process.exit(1);
4435
+ }
4436
+ if (result.status === "paused") {
4437
+ console.log(`\nNote: REM is paused (sentinel ~/.flair/rem.paused or FLAIR_REM_PAUSE env).`);
4438
+ console.log(`Resume with: flair rem resume`);
4439
+ }
4440
+ }
4441
+ catch (err) {
4442
+ console.error(`Error: ${err.message}`);
4443
+ process.exit(1);
4444
+ }
4445
+ });
4446
+ // ─── flair rem snapshot list ─────────────────────────────────────────────────
4447
+ // Slice 1 of FLAIR-NIGHTLY-REM (ops-2qq). Lists snapshot tarballs under
4448
+ // ~/.flair/snapshots/<agent>/. Snapshot creation lives inside the nightly
4449
+ // runner (and exposed via `flair rem nightly run-once`) — there is no
4450
+ // user-facing `rem snapshot create` because that would invite operators to
4451
+ // create snapshots out of sync with the audit log. The list is the surface.
4452
+ const remSnapshot = rem.command("snapshot").description("REM nightly snapshots (tar.gz archives of agent memory + soul)");
4453
+ remSnapshot
4454
+ .command("list")
4455
+ .description("List REM snapshots for an agent (or all agents)")
4456
+ .option("--agent <id>", "Filter to a single agent")
4457
+ .option("--json", "Output as JSON")
4458
+ .action(async (opts) => {
4459
+ const { listSnapshots } = await import("./rem/snapshot.js");
4460
+ const rows = listSnapshots(opts.agent);
4461
+ if (opts.json) {
4462
+ console.log(JSON.stringify(rows, null, 2));
4463
+ return;
4464
+ }
4465
+ if (rows.length === 0) {
4466
+ console.log("(no REM snapshots — ~/.flair/snapshots/ is empty or absent)");
4467
+ console.log("\nSnapshots are produced by the nightly cycle. Run `flair rem nightly run-once`");
4468
+ console.log("to generate one manually (slice 1).");
4469
+ return;
4470
+ }
4471
+ const agentW = Math.max(5, ...rows.map((r) => r.agent.length));
4472
+ const fileW = Math.max(20, ...rows.map((r) => r.file.length));
4473
+ console.log(` ${"agent".padEnd(agentW)} ${"file".padEnd(fileW)} size age`);
4474
+ for (const r of rows) {
4475
+ console.log(` ${r.agent.padEnd(agentW)} ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
4476
+ }
4477
+ console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
4478
+ });
4479
+ // ─── flair rem restore <date> ────────────────────────────────────────────────
4480
+ // Slice 1 + 2 of FLAIR-NIGHTLY-REM § 9.
4481
+ //
4482
+ // Default (no --apply): filesystem-only extract for inspection. Writes
4483
+ // memories.jsonl / soul.json / metadata.json to a target directory.
4484
+ // Harper state is unchanged.
4485
+ //
4486
+ // --apply: live replay. Reads the snapshot contents, takes a pre-restore
4487
+ // snapshot of the agent's CURRENT state (so this restore is itself
4488
+ // reversible), then DELETEs current memories/souls for the agent and PUTs
4489
+ // the snapshot's rows back. Per-row failures are captured per-row; the
4490
+ // pre-restore snapshot's path is reported so operator can roll back if
4491
+ // something goes wrong mid-flight.
4492
+ //
4493
+ // The <date> argument is an ISO-timestamp prefix or date-only prefix; the
4494
+ // command picks the latest snapshot matching that prefix.
4495
+ rem
4496
+ .command("restore <date>")
4497
+ .description("Restore from a REM snapshot (inspect by default; --apply rewinds Harper state)")
4498
+ .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
4499
+ .option("--target <dir>", "Directory to extract into (default: <snapshot>.restored, only used without --apply)")
4500
+ .option("--dry-run", "Plan-only — list contents or planned counts without writing")
4501
+ .option("--apply", "Live replay: rewind Harper state to the snapshot (irreversible without the pre-restore snapshot)")
4502
+ .action(async (date, opts) => {
4503
+ const { listSnapshots, extractSnapshot } = await import("./rem/snapshot.js");
4504
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
4505
+ if (!agentId) {
4506
+ console.error("Error: --agent or FLAIR_AGENT_ID env required");
4507
+ process.exit(1);
4508
+ }
4509
+ let rows;
4510
+ try {
4511
+ rows = listSnapshots(agentId);
4512
+ }
4513
+ catch (err) {
4514
+ console.error(`Error: ${err.message}`);
4515
+ process.exit(1);
4516
+ }
4517
+ const matches = rows.filter((r) => r.file.startsWith(date));
4518
+ if (matches.length === 0) {
4519
+ console.error(`Error: no snapshot found for agent '${agentId}' matching date '${date}'`);
4520
+ if (rows.length > 0) {
4521
+ console.error(` Available: ${rows.slice(0, 5).map((r) => r.file.replace(/\.tar\.gz$/, "")).join(", ")}`);
4522
+ }
4523
+ else {
4524
+ console.error(` No snapshots exist for ${agentId}. Run \`flair rem nightly run-once\` to create one.`);
4525
+ }
4526
+ process.exit(1);
4527
+ }
4528
+ // listSnapshots returns descending by mtime, so matches[0] is the newest
4529
+ // snapshot for the date prefix.
4530
+ const match = matches[0];
4531
+ // --apply path: live replay via src/rem/restore.ts
4532
+ if (opts.apply) {
4533
+ const { applySnapshot } = await import("./rem/restore.js");
4534
+ try {
4535
+ const result = await applySnapshot({
4536
+ agentId,
4537
+ snapshotPath: match.path,
4538
+ flairVersion: __pkgVersion,
4539
+ apiCall: api,
4540
+ dryRun: !!opts.dryRun,
4541
+ });
4542
+ const verb = opts.dryRun ? "(dry-run) would" : "";
4543
+ console.log(`${opts.dryRun ? "(dry-run) " : ""}flair rem restore --apply${opts.dryRun ? "" : ""}`);
4544
+ console.log(` Status: ${result.status}`);
4545
+ console.log(` Snapshot: ${match.path}`);
4546
+ if (result.preRestoreSnapshotPath) {
4547
+ console.log(` Pre-restore: ${result.preRestoreSnapshotPath}`);
4548
+ console.log(` (rollback: flair rem restore <pre-restore-date> --agent ${agentId} --apply)`);
4549
+ }
4550
+ console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls`);
4551
+ console.log(` Restored: ${result.restored.memories} memories, ${result.restored.souls} souls`);
4552
+ if (result.errors.length > 0) {
4553
+ console.log(` Errors:`);
4554
+ for (const e of result.errors)
4555
+ console.log(` - ${e}`);
4556
+ }
4557
+ if (result.status === "failed")
4558
+ process.exit(1);
4559
+ }
4560
+ catch (err) {
4561
+ console.error(`Error: ${err.message}`);
4562
+ process.exit(1);
4563
+ }
4564
+ return;
4565
+ }
4566
+ // Default: filesystem extract.
4567
+ try {
4568
+ const result = await extractSnapshot({
4569
+ snapshotPath: match.path,
4570
+ targetDir: opts.target,
4571
+ dryRun: !!opts.dryRun,
4572
+ });
4573
+ if (opts.dryRun) {
4574
+ console.log(`(dry-run) snapshot: ${match.path}`);
4575
+ for (const e of result.entries) {
4576
+ console.log(` ${e.path} (${humanBytes(e.size)})`);
4577
+ }
4578
+ return;
4579
+ }
4580
+ console.log(`✅ Extracted: ${match.path}`);
4581
+ console.log(` To: ${result.targetDir}`);
4582
+ for (const e of result.entries) {
4583
+ console.log(` ${e.path} (${humanBytes(e.size)})`);
4584
+ }
4585
+ console.log(`\nNote: this is a filesystem extract — Harper state is unchanged.`);
4586
+ console.log(`To actually rewind state, re-run with --apply.`);
4587
+ }
4588
+ catch (err) {
4589
+ console.error(`Error: ${err.message}`);
4590
+ process.exit(1);
4591
+ }
4592
+ });
4593
+ // ─── flair rem pause / resume ────────────────────────────────────────────────
4594
+ // Slice 1 of FLAIR-NIGHTLY-REM § 9. The pause sentinel is checked by the
4595
+ // nightly runner before any side effects. Env-var FLAIR_REM_PAUSE=1 is also
4596
+ // honored — lets ops pause fleet-wide without writing a file.
4597
+ const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
4598
+ rem
4599
+ .command("pause")
4600
+ .description("Pause nightly REM runs — writes ~/.flair/rem.paused sentinel")
4601
+ .action(() => {
4602
+ const dir = dirname(REM_PAUSE_FLAG);
4603
+ if (!existsSync(dir))
4604
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
4605
+ writeFileSync(REM_PAUSE_FLAG, new Date().toISOString() + "\n", { mode: 0o600 });
4606
+ console.log(`✅ REM nightly runs paused (sentinel: ${REM_PAUSE_FLAG})`);
4607
+ console.log(` Resume with: flair rem resume`);
4608
+ });
4609
+ rem
4610
+ .command("resume")
4611
+ .description("Resume nightly REM runs — removes the pause sentinel")
4612
+ .action(() => {
4613
+ if (existsSync(REM_PAUSE_FLAG)) {
4614
+ rmSync(REM_PAUSE_FLAG);
4615
+ console.log(`✅ REM nightly runs resumed (removed ${REM_PAUSE_FLAG})`);
4616
+ }
4617
+ else {
4618
+ console.log(`(REM was not paused — no sentinel at ${REM_PAUSE_FLAG})`);
4619
+ }
4620
+ if (process.env.FLAIR_REM_PAUSE === "1") {
4621
+ console.log(`\n⚠ FLAIR_REM_PAUSE=1 env var is also set; unset it to fully resume.`);
4622
+ }
4623
+ });
3994
4624
  // ─── flair status ─────────────────────────────────────────────────────────────
3995
4625
  function humanBytes(n) {
3996
4626
  if (!Number.isFinite(n) || n < 0)
@@ -4251,139 +4881,176 @@ const statusCmd = program
4251
4881
  })
4252
4882
  : warnings;
4253
4883
  const hasWarn = scopedWarnings.some((w) => w.level === "warn");
4254
- const headerIcon = hasWarn ? "🟡" : "🟢";
4255
- console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
4256
- 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));
4257
4892
  if (scopedWarnings.length > 0) {
4258
- console.log(`\n Warnings: ${scopedWarnings.length}`);
4259
- for (const w of scopedWarnings)
4260
- 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
+ }
4261
4898
  }
4262
4899
  if (memories) {
4263
- console.log("\nMemory:");
4900
+ console.log(`\n${render.wrap(render.c.bold, "Memory")}`);
4264
4901
  const embStr = memories.withEmbeddings > 0 ? `${memories.withEmbeddings} embedded` : "";
4265
4902
  const hashStr = memories.hashFallback > 0 ? `${memories.hashFallback} hash` : "";
4266
4903
  const detail = [embStr, hashStr].filter(Boolean).join(", ");
4267
- 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})`)}` : ""}`));
4268
4905
  if (memories.modelCounts && typeof memories.modelCounts === "object") {
4269
4906
  const entries = Object.entries(memories.modelCounts)
4270
4907
  .filter(([, n]) => n > 0)
4271
4908
  .sort((a, b) => b[1] - a[1]);
4272
4909
  if (entries.length > 0) {
4273
- const formatted = entries.map(([k, n]) => `${k}: ${n}`).join(", ");
4274
- 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));
4275
4912
  }
4276
4913
  }
4277
4914
  if (memories.byDurability) {
4278
4915
  const d = memories.byDurability;
4279
- 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, " · "))));
4280
4923
  }
4281
4924
  if (typeof memories.archived === "number")
4282
- console.log(` Archived: ${memories.archived}`);
4283
- if (typeof memories.expired === "number" && memories.expired > 0)
4284
- 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
+ }
4285
4929
  if (healthData?.lastWrite)
4286
- console.log(` Last write: ${relativeTime(healthData.lastWrite)}`);
4930
+ console.log(render.kv("Last write", render.relativeTime(healthData.lastWrite)));
4287
4931
  }
4288
4932
  if (agents && agents.count > 0) {
4289
- console.log("\nAgents:");
4290
- const nameStr = agents.names?.length > 0 ? ` — ${agents.names.join(", ")}` : "";
4291
- 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}`));
4292
4936
  if (agents.count > 1 && Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
4293
- const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
4294
- // Older HealthDetail responses only carry id / memoryCount / lastWriteAt.
4295
- // Only print the richer columns if at least one row supplies them.
4296
4937
  const hasDeep = agents.perAgent.some((r) => typeof r.hashFallback === "number" || typeof r.writes24h === "number");
4297
- if (hasDeep) {
4298
- console.log(` ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
4299
- for (const r of agents.perAgent) {
4300
- const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "";
4301
- const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
4302
- console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
4303
- }
4304
- }
4305
- else {
4306
- console.log(` ${"id".padEnd(idW)} memories last_write`);
4307
- for (const r of agents.perAgent) {
4308
- console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${relativeTime(r.lastWriteAt)}`);
4309
- }
4310
- }
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));
4311
4952
  }
4312
4953
  }
4313
4954
  if (healthData?.relationships) {
4314
4955
  const r = healthData.relationships;
4315
- console.log("\nRelationships:");
4316
- 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)`)}`));
4317
4958
  }
4318
4959
  if (healthData?.soul && healthData.soul.total > 0) {
4319
4960
  const s = healthData.soul;
4320
- const bp = s.byPriority ?? {};
4321
- console.log("\nSoul:");
4322
- 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}`));
4323
4968
  }
4324
4969
  else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
4325
- console.log("\nSoul:");
4326
- console.log(` ${healthData.soulEntries} entries`);
4970
+ console.log(`\n${render.wrap(render.c.bold, "Soul")}`);
4971
+ console.log(render.kv("Entries", String(healthData.soulEntries)));
4327
4972
  }
4328
4973
  if (healthData?.rem) {
4329
4974
  const r = healthData.rem;
4330
- console.log("\nREM:");
4975
+ console.log(`\n${render.wrap(render.c.bold, "REM")}`);
4331
4976
  if (r.lastLightAt)
4332
- console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
4977
+ console.log(render.kv("Last light", render.relativeTime(r.lastLightAt)));
4333
4978
  if (r.lastRapidAt)
4334
- console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
4979
+ console.log(render.kv("Last rapid", render.relativeTime(r.lastRapidAt)));
4335
4980
  if (r.lastRestorativeAt)
4336
- console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
4337
- const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
4338
- 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));
4339
4988
  if (r.nightlyEnabled && r.lastNightlyAt)
4340
- console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)}`);
4989
+ console.log(render.kv("Last nightly", render.relativeTime(r.lastNightlyAt)));
4341
4990
  if (typeof r.pendingCandidates === "number" && r.pendingCandidates > 0) {
4342
- console.log(` Pending candidates: ${r.pendingCandidates}`);
4991
+ console.log(render.kv("Pending candidates", render.wrap(render.c.yellow, String(r.pendingCandidates))));
4343
4992
  }
4344
4993
  }
4345
4994
  if (healthData?.federation) {
4346
4995
  const f = healthData.federation;
4347
- console.log("\nFederation:");
4348
- if (f.instance)
4349
- console.log(` Instance: ${f.instance.id} (${f.instance.role ?? ""}, ${f.instance.status ?? "—"})`);
4350
- if (f.peers)
4351
- 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
+ }
4352
5012
  if (f.pendingTokens > 0)
4353
- 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)`));
4354
5014
  }
4355
5015
  else {
4356
- console.log("\nFederation: not configured");
5016
+ console.log(`\n${render.wrap(render.c.bold, "Federation")} ${render.wrap(render.c.dim, "not configured")}`);
4357
5017
  }
4358
5018
  if (healthData?.oauth) {
4359
5019
  const lines = oauthSummaryLines(healthData.oauth);
4360
- for (const line of lines)
4361
- 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
+ }
4362
5027
  }
4363
5028
  if (healthData?.bridges) {
4364
5029
  const b = healthData.bridges;
4365
- console.log("\nBridges:");
5030
+ console.log(`\n${render.wrap(render.c.bold, "Bridges")}`);
4366
5031
  if (Array.isArray(b.installed) && b.installed.length > 0)
4367
- console.log(` Installed: ${b.installed.join(", ")}`);
5032
+ console.log(render.kv("Installed", b.installed.join(render.wrap(render.c.dim, ", "))));
4368
5033
  if (b.lastImport)
4369
- console.log(` Last import: ${relativeTime(b.lastImport)}`);
5034
+ console.log(render.kv("Last import", render.relativeTime(b.lastImport)));
4370
5035
  if (b.lastExport)
4371
- console.log(` Last export: ${relativeTime(b.lastExport)}`);
5036
+ console.log(render.kv("Last export", render.relativeTime(b.lastExport)));
4372
5037
  }
4373
5038
  else {
4374
- console.log("\nBridges: none installed");
5039
+ console.log(`\n${render.wrap(render.c.bold, "Bridges")} ${render.wrap(render.c.dim, "none installed")}`);
4375
5040
  }
4376
5041
  if (healthData?.disk) {
4377
5042
  const d = healthData.disk;
4378
- console.log("\nDisk:");
4379
- console.log(` Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
4380
- 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))}`));
4381
5046
  }
4382
5047
  console.log("");
4383
- if (scopedWarnings.length > 0)
4384
- console.log(` Health: ⚠ ${scopedWarnings.length} warning(s)`);
4385
- else
4386
- 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
+ }
4387
5054
  });
4388
5055
  statusCmd
4389
5056
  .command("rem")
@@ -4391,33 +5058,42 @@ statusCmd
4391
5058
  .action(async function () {
4392
5059
  const opts = this.optsWithGlobals();
4393
5060
  const { healthy, healthData } = await fetchHealthDetail(opts);
4394
- if (opts.json) {
4395
- 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 }));
4396
5064
  if (!healthy)
4397
5065
  process.exit(1);
4398
5066
  return;
4399
5067
  }
4400
5068
  if (!healthy) {
4401
- console.log("🔴 unreachable");
5069
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4402
5070
  process.exit(1);
4403
5071
  }
4404
5072
  const r = healthData?.rem;
4405
5073
  if (!r) {
4406
- 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)")}`);
4407
5075
  return;
4408
5076
  }
4409
- console.log("REM:");
4410
- console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
4411
- console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
4412
- console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
4413
- const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
4414
- console.log(` Nightly: ${nightly}`);
4415
- if (r.lastNightlyAt)
4416
- console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
4417
- if (typeof r.pendingCandidates === "number")
4418
- console.log(` Pending candidates: ${r.pendingCandidates}`);
4419
- else
4420
- 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
+ }
4421
5097
  });
4422
5098
  statusCmd
4423
5099
  .command("federation")
@@ -4425,36 +5101,70 @@ statusCmd
4425
5101
  .action(async function () {
4426
5102
  const opts = this.optsWithGlobals();
4427
5103
  const { healthy, healthData } = await fetchHealthDetail(opts);
4428
- if (opts.json) {
4429
- 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 }));
4430
5107
  if (!healthy)
4431
5108
  process.exit(1);
4432
5109
  return;
4433
5110
  }
4434
5111
  if (!healthy) {
4435
- console.log("🔴 unreachable");
5112
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4436
5113
  process.exit(1);
4437
5114
  }
4438
5115
  const f = healthData?.federation;
4439
5116
  if (!f) {
4440
- console.log("Federation: not configured");
5117
+ console.log(`${render.wrap(render.c.bold, "Federation")} ${render.wrap(render.c.dim, "not configured")}`);
4441
5118
  return;
4442
5119
  }
4443
- console.log("Federation:");
4444
- if (f.instance)
4445
- console.log(` Instance: ${f.instance.id} (${f.instance.role ?? ""}, ${f.instance.status ?? "—"})`);
4446
- else
4447
- console.log(" Instance: —");
4448
- if (f.peers)
4449
- console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
4450
- if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
4451
- 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
+ }
4452
5142
  if (Array.isArray(f.peerList) && f.peerList.length > 0) {
4453
- const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
4454
- console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
4455
- for (const p of f.peerList) {
4456
- console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
4457
- }
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));
4458
5168
  }
4459
5169
  });
4460
5170
  statusCmd
@@ -4463,24 +5173,46 @@ statusCmd
4463
5173
  .action(async function () {
4464
5174
  const opts = this.optsWithGlobals();
4465
5175
  const { healthy, healthData } = await fetchHealthDetail(opts);
4466
- if (opts.json) {
4467
- 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 }));
4468
5179
  if (!healthy)
4469
5180
  process.exit(1);
4470
5181
  return;
4471
5182
  }
4472
5183
  if (!healthy) {
4473
- console.log("🔴 unreachable");
5184
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4474
5185
  process.exit(1);
4475
5186
  }
4476
5187
  const o = healthData?.oauth;
4477
5188
  if (!o) {
4478
- console.log("OAuth: not configured");
5189
+ console.log(`${render.wrap(render.c.bold, "OAuth")} ${render.wrap(render.c.dim, "not configured")}`);
4479
5190
  return;
4480
5191
  }
4481
- const lines = oauthDetailLines(o);
4482
- for (const line of lines)
4483
- 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
+ }
4484
5216
  });
4485
5217
  statusCmd
4486
5218
  .command("bridges")
@@ -4488,28 +5220,283 @@ statusCmd
4488
5220
  .action(async function () {
4489
5221
  const opts = this.optsWithGlobals();
4490
5222
  const { healthy, healthData } = await fetchHealthDetail(opts);
4491
- if (opts.json) {
4492
- 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 }));
4493
5226
  if (!healthy)
4494
5227
  process.exit(1);
4495
5228
  return;
4496
5229
  }
4497
5230
  if (!healthy) {
4498
- console.log("🔴 unreachable");
5231
+ console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
4499
5232
  process.exit(1);
4500
5233
  }
4501
5234
  const b = healthData?.bridges;
4502
5235
  if (!b) {
4503
- 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)")}`);
4504
5237
  return;
4505
5238
  }
4506
- console.log("Bridges:");
4507
- if (Array.isArray(b.installed) && b.installed.length > 0)
4508
- 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
+ }
4509
5243
  if (b.lastImport)
4510
- console.log(` Last import: ${relativeTime(b.lastImport)}`);
5244
+ console.log(render.kv("Last import", render.relativeTime(b.lastImport)));
4511
5245
  if (b.lastExport)
4512
- 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
+ }
4513
5500
  });
4514
5501
  // ─── flair upgrade ────────────────────────────────────────────────────────────
4515
5502
  program
@@ -5190,15 +6177,13 @@ program
5190
6177
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
5191
6178
  .option("--port <port>", "Harper HTTP port")
5192
6179
  .action(async (opts) => {
5193
- const green = (s) => `\x1b[32m${s}\x1b[0m`;
5194
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
5195
6180
  const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
5196
6181
  if (!agentId && !process.env.FLAIR_ADMIN_PASS) {
5197
- 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")}`);
5198
6183
  process.exit(1);
5199
6184
  }
5200
6185
  const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
5201
- 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`);
5202
6187
  let passed = 0;
5203
6188
  let failed = 0;
5204
6189
  let memoryId = null;
@@ -5206,17 +6191,17 @@ program
5206
6191
  try {
5207
6192
  const ok = await fn();
5208
6193
  if (ok) {
5209
- console.log(` ${green("PASS")} ${name}`);
6194
+ console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "PASS")} ${name}`);
5210
6195
  passed++;
5211
6196
  }
5212
6197
  else {
5213
- console.log(` ${red("FAIL")} ${name}`);
6198
+ console.log(` ${render.icons.error} ${render.wrap(render.c.red, "FAIL")} ${name}`);
5214
6199
  failed++;
5215
6200
  }
5216
6201
  }
5217
6202
  catch (e) {
5218
6203
  const message = e instanceof Error ? e.message : String(e);
5219
- 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))}`);
5220
6205
  failed++;
5221
6206
  }
5222
6207
  };
@@ -5255,7 +6240,9 @@ program
5255
6240
  await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
5256
6241
  return true;
5257
6242
  });
5258
- 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`)}`);
5259
6246
  if (failed > 0)
5260
6247
  process.exit(1);
5261
6248
  });
@@ -5358,7 +6345,7 @@ program
5358
6345
  let baseUrl = `http://127.0.0.1:${port}`;
5359
6346
  let issues = 0;
5360
6347
  let harperResponding = false;
5361
- console.log("\n🩺 Flair Doctor\n");
6348
+ console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
5362
6349
  // Helper: try to reach Harper on a given port
5363
6350
  async function probePort(p) {
5364
6351
  try {
@@ -5402,11 +6389,11 @@ program
5402
6389
  catch { /* dead */ }
5403
6390
  }
5404
6391
  else {
5405
- 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`);
5406
6393
  }
5407
6394
  }
5408
6395
  if (await probePort(port)) {
5409
- console.log(` Harper responding on port ${port}`);
6396
+ console.log(` ${render.icons.ok} Harper responding on port ${render.wrap(render.c.bold, String(port))}`);
5410
6397
  harperResponding = true;
5411
6398
  }
5412
6399
  else {
@@ -5415,19 +6402,19 @@ program
5415
6402
  if (pidAlive) {
5416
6403
  discoveredPort = await discoverPortFromPid(pidValue);
5417
6404
  if (discoveredPort && discoveredPort !== port && await probePort(discoveredPort)) {
5418
- console.log(` ⚠️ Harper not on expected port ${port}, but responding on port ${discoveredPort} (PID ${pidValue})`);
5419
- 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}`)}`);
5420
6407
  if (autoFix) {
5421
6408
  if (dryRun) {
5422
- console.log(` Would update config to port ${discoveredPort}`);
6409
+ console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
5423
6410
  }
5424
6411
  else {
5425
6412
  writeConfig(discoveredPort);
5426
- console.log(` Updated config to port ${discoveredPort}`);
6413
+ console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
5427
6414
  }
5428
6415
  }
5429
6416
  else {
5430
- 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)")}`);
5431
6418
  }
5432
6419
  effectivePort = discoveredPort;
5433
6420
  baseUrl = `http://127.0.0.1:${discoveredPort}`;
@@ -5435,8 +6422,8 @@ program
5435
6422
  issues++;
5436
6423
  }
5437
6424
  else {
5438
- console.log(` Harper process alive (PID ${pidValue}) but not responding on any detected port`);
5439
- 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`);
5440
6427
  issues++;
5441
6428
  }
5442
6429
  }
@@ -5447,34 +6434,34 @@ program
5447
6434
  const { execSync } = await import("node:child_process");
5448
6435
  const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
5449
6436
  if (lsof) {
5450
- console.log(` Nothing responding on port ${port} (port occupied by PID ${lsof})`);
5451
- 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`);
5452
6439
  }
5453
6440
  else {
5454
- console.log(` Harper is not running`);
5455
- 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`);
5456
6443
  }
5457
6444
  }
5458
6445
  catch {
5459
- console.log(` Harper is not running`);
6446
+ console.log(` ${render.icons.error} Harper is not running`);
5460
6447
  if (autoFix) {
5461
6448
  if (dryRun) {
5462
- console.log(` Would run: flair restart`);
6449
+ console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
5463
6450
  }
5464
6451
  else {
5465
- console.log(` Attempting restart...`);
6452
+ console.log(` ${render.wrap(render.c.dim, "Attempting restart...")}`);
5466
6453
  try {
5467
6454
  const { execSync } = await import("node:child_process");
5468
6455
  execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${port}`, { stdio: "inherit" });
5469
- console.log(` Restart attempted`);
6456
+ console.log(` ${render.icons.ok} Restart attempted`);
5470
6457
  }
5471
6458
  catch {
5472
- 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>`);
5473
6460
  }
5474
6461
  }
5475
6462
  }
5476
6463
  else {
5477
- console.log(` Fix: flair restart`);
6464
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
5478
6465
  }
5479
6466
  }
5480
6467
  issues++;
@@ -5485,27 +6472,27 @@ program
5485
6472
  if (existsSync(keysDir)) {
5486
6473
  const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
5487
6474
  if (keyFiles.length > 0) {
5488
- 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)}`);
5489
6476
  }
5490
6477
  else {
5491
- console.log(` Keys directory exists but no .key files found`);
5492
- 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>`);
5493
6480
  issues++;
5494
6481
  }
5495
6482
  }
5496
6483
  else {
5497
- console.log(` Keys directory missing: ${keysDir}`);
5498
- 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>`);
5499
6486
  issues++;
5500
6487
  }
5501
6488
  // 3. Config file
5502
6489
  const cfgPath = configPath();
5503
6490
  if (existsSync(cfgPath)) {
5504
6491
  const savedPort = readPortFromConfig();
5505
- 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"})`)}`);
5506
6493
  }
5507
6494
  else {
5508
- 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`);
5509
6496
  }
5510
6497
  // 4. Embeddings check (only if Harper is responding)
5511
6498
  if (harperResponding) {
@@ -5519,17 +6506,17 @@ program
5519
6506
  if (testRes.ok) {
5520
6507
  const data = await testRes.json();
5521
6508
  if (data._warning) {
5522
- console.log(` ⚠️ Embeddings: keyword-only (${data._warning})`);
5523
- console.log(` Semantic search quality is degraded`);
5524
- 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/`);
5525
6512
  issues++;
5526
6513
  }
5527
6514
  else {
5528
- console.log(` Embeddings: semantic search operational`);
6515
+ console.log(` ${render.icons.ok} Embeddings: semantic search operational`);
5529
6516
  }
5530
6517
  }
5531
6518
  else if (testRes.status === 401) {
5532
- 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)")}`);
5533
6520
  }
5534
6521
  }
5535
6522
  catch { /* fetch error, already flagged */ }
@@ -5542,50 +6529,50 @@ program
5542
6529
  try {
5543
6530
  process.kill(Number(pidContent), 0);
5544
6531
  if (harperResponding) {
5545
- 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)`)}`);
5546
6533
  }
5547
6534
  // If not responding, we already reported the issue in step 1
5548
6535
  }
5549
6536
  catch {
5550
- 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)`)}`);
5551
6538
  if (autoFix) {
5552
6539
  if (dryRun) {
5553
- console.log(` Would remove: ${pidFile}`);
6540
+ console.log(` ${render.wrap(render.c.dim, "Would remove:")} ${pidFile}`);
5554
6541
  }
5555
6542
  else {
5556
6543
  (await import("node:fs")).unlinkSync(pidFile);
5557
- console.log(` Removed stale PID file`);
6544
+ console.log(` ${render.icons.ok} Removed stale PID file`);
5558
6545
  }
5559
6546
  }
5560
6547
  else {
5561
- console.log(` Fix: rm ${pidFile} && flair restart`);
6548
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} rm ${pidFile} && flair restart`);
5562
6549
  }
5563
6550
  issues++;
5564
6551
  }
5565
6552
  }
5566
6553
  // 6. Data directory
5567
6554
  if (existsSync(dataDir)) {
5568
- console.log(` Data directory: ${dataDir}`);
6555
+ console.log(` ${render.icons.ok} Data directory: ${render.wrap(render.c.dim, dataDir)}`);
5569
6556
  }
5570
6557
  else {
5571
6558
  // Check ~/harper/ (common alternative)
5572
6559
  const altDir = join(homedir(), "harper");
5573
6560
  if (existsSync(altDir)) {
5574
- 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`);
5575
6562
  }
5576
6563
  else {
5577
- console.log(` No data directory found`);
5578
- 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>`);
5579
6566
  issues++;
5580
6567
  }
5581
6568
  }
5582
6569
  // Summary
5583
6570
  console.log("");
5584
6571
  if (issues === 0) {
5585
- console.log(" 🟢 No issues found");
6572
+ console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`);
5586
6573
  }
5587
6574
  else {
5588
- 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")}`);
5589
6576
  }
5590
6577
  console.log("");
5591
6578
  if (issues > 0)
@@ -5845,64 +6832,140 @@ memory.command("write-task-summary")
5845
6832
  // without parsing a JSON blob.
5846
6833
  console.log(memId);
5847
6834
  });
5848
- memory.command("search [query]").requiredOption("--agent <id>")
6835
+ memory.command("search [query]")
6836
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
5849
6837
  .option("--q <query>", "search query (alias for positional arg)")
5850
6838
  .option("--limit <n>", "Max results", "5")
5851
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")
5852
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
+ }
5853
6849
  const q = queryArg ?? opts.q;
5854
6850
  if (!q) {
5855
6851
  console.error("error: query required (positional arg or --q)");
5856
6852
  process.exit(1);
5857
6853
  }
5858
- const body = { agentId: opts.agent, q, limit: parseInt(opts.limit, 10) || 5 };
6854
+ const body = { agentId, q, limit: parseInt(opts.limit, 10) || 5 };
5859
6855
  if (opts.tag)
5860
6856
  body.tag = opts.tag;
5861
- 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));
5862
6860
  });
5863
6861
  memory.command("list")
5864
- .requiredOption("--agent <id>")
6862
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
5865
6863
  .option("--tag <tag>")
5866
6864
  .option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
5867
6865
  .option("--limit <n>", "Max rows when using --hash-fallback", "50")
6866
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
5868
6867
  .action(async (opts) => {
5869
- 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();
5870
6874
  const raw = await api("GET", `/Memory?${q}`);
5871
- if (!opts.hashFallback) {
5872
- 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
+ }
5873
6919
  return;
5874
6920
  }
5875
- // --hash-fallback: filter to entries without a real embedding and print as a table.
5876
- // Same predicate HealthDetail uses: missing model or the "hash-512d" marker.
6921
+ // Default lens: all memories for the agent.
5877
6922
  const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
5878
- const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
5879
- if (fallback.length === 0) {
5880
- console.log(`No hash-fallback memories for agent ${opts.agent}. All embedded.`);
6923
+ if (mode === "json") {
6924
+ console.log(render.asJSON(all));
5881
6925
  return;
5882
6926
  }
5883
- const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
5884
- const rows = fallback
6927
+ if (all.length === 0) {
6928
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, `No memories for agent ${agentId}`)}`);
6929
+ return;
6930
+ }
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
5885
6933
  .slice()
5886
6934
  .sort((a, b) => {
5887
6935
  const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
5888
6936
  const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
5889
6937
  return tb - ta;
5890
- })
5891
- .slice(0, limit);
5892
- const idW = Math.max(2, ...rows.map((r) => String(r.id ?? "").length));
5893
- console.log(`${fallback.length} hash-fallback memories for agent ${opts.agent} (showing ${rows.length}):\n`);
5894
- console.log(` ${"id".padEnd(idW)} created_at preview`);
5895
- for (const r of rows) {
5896
- const created = r.createdAt ? String(r.createdAt).slice(0, 19).replace("T", " ") : "—".padEnd(19);
5897
- const preview = String(r.content ?? "").replace(/\s+/g, " ").slice(0, 80);
5898
- console.log(` ${String(r.id ?? "").padEnd(idW)} ${created} ${preview}`);
5899
- }
5900
- if (fallback.length > rows.length) {
5901
- console.log(`\n... ${fallback.length - rows.length} more (raise with --limit). To backfill: flair reembed --agent ${opts.agent} --stale-only`);
5902
- }
5903
- else {
5904
- console.log(`\nTo backfill: flair reembed --agent ${opts.agent} --stale-only`);
5905
- }
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));
5906
6969
  });
5907
6970
  // ─── flair memory hygiene ────────────────────────────────────────────────────
5908
6971
  // Detect + remove junk memory rows that accumulate over time. Surfaced from
@@ -6046,47 +7109,184 @@ memory.command("hygiene")
6046
7109
  console.log("hygiene --apply` on each peer to fan out.");
6047
7110
  });
6048
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
+ }
6049
7127
  program
6050
7128
  .command("search <query>")
6051
- .description("Search memories by meaning (shortcut for memory search)")
6052
- .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)")
6053
7131
  .option("--limit <n>", "Max results", "5")
6054
7132
  .option("--port <port>", "Harper HTTP port")
6055
7133
  .option("--url <url>", "Flair base URL (overrides --port)")
7134
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
6056
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")
6057
7151
  .action(async (query, opts) => {
6058
7152
  try {
6059
- 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);
6060
7159
  const headers = { "content-type": "application/json" };
6061
- const keyPath = opts.key || resolveKeyPath(opts.agent);
7160
+ const keyPath = opts.key || resolveKeyPath(agentId);
6062
7161
  if (keyPath) {
6063
- headers["authorization"] = buildEd25519Auth(opts.agent, "POST", "/SemanticSearch", keyPath);
7162
+ headers["authorization"] = buildEd25519Auth(agentId, "POST", "/SemanticSearch", keyPath);
6064
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;
6065
7187
  const res = await fetch(`${baseUrl}/SemanticSearch`, {
6066
7188
  method: "POST",
6067
7189
  headers,
6068
- body: JSON.stringify({ agentId: opts.agent, q: query, limit: parseInt(opts.limit, 10) }),
7190
+ body: JSON.stringify(payload),
6069
7191
  });
6070
7192
  if (!res.ok)
6071
7193
  throw new Error(await res.text());
6072
- const result = await res.json();
6073
- const results = result.results || result || [];
6074
- if (!Array.isArray(results) || results.length === 0) {
6075
- 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));
7211
+ return;
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
+ }
6076
7232
  return;
6077
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
+ };
6078
7243
  for (const r of results) {
6079
- const date = r.createdAt ? r.createdAt.slice(0, 10) : "";
6080
- const score = r._score ? `${(r._score * 100).toFixed(0)}%` : "";
6081
- 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, " · "));
6082
7258
  console.log(` ${r.content}`);
6083
7259
  if (meta)
6084
- 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
+ }
6085
7279
  console.log();
6086
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
+ }
6087
7287
  }
6088
7288
  catch (err) {
6089
- console.error(`Search failed: ${err.message}`);
7289
+ console.error(`${render.icons.error} Search failed: ${err.message}`);
6090
7290
  process.exit(1);
6091
7291
  }
6092
7292
  });
@@ -6108,58 +7308,179 @@ program
6108
7308
  program
6109
7309
  .command("bootstrap")
6110
7310
  .description("Cold-start context: get soul + recent memories as formatted text")
6111
- .requiredOption("--agent <id>", "Agent ID")
7311
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
6112
7312
  .option("--max-tokens <n>", "Maximum tokens in output", "4000")
6113
7313
  .option("--port <port>", "Harper HTTP port")
6114
7314
  .option("--url <url>", "Flair base URL (overrides --port)")
7315
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
6115
7316
  .option("--key <path>", "Ed25519 private key path")
7317
+ .option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
6116
7318
  .action(async (opts) => {
6117
- 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);
6118
7326
  try {
6119
7327
  const headers = { "content-type": "application/json" };
6120
- const keyPath = opts.key || resolveKeyPath(opts.agent);
7328
+ const keyPath = opts.key || resolveKeyPath(agentId);
6121
7329
  if (keyPath) {
6122
- headers["authorization"] = buildEd25519Auth(opts.agent, "POST", "/BootstrapMemories", keyPath);
7330
+ headers["authorization"] = buildEd25519Auth(agentId, "POST", "/BootstrapMemories", keyPath);
6123
7331
  }
6124
7332
  const res = await fetch(`${baseUrl}/BootstrapMemories`, {
6125
7333
  method: "POST",
6126
7334
  headers,
6127
- body: JSON.stringify({ agentId: opts.agent, maxTokens: parseInt(opts.maxTokens, 10) }),
7335
+ body: JSON.stringify({ agentId, maxTokens: parseInt(opts.maxTokens, 10) }),
6128
7336
  });
6129
7337
  if (!res.ok) {
6130
7338
  const body = await res.text();
6131
7339
  throw new Error(`${res.status}: ${body}`);
6132
7340
  }
6133
- 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).
6134
7349
  if (result.context) {
6135
7350
  console.log(result.context);
6136
7351
  }
6137
7352
  else {
6138
- console.error("No context available.");
7353
+ console.error(`${render.icons.error} No context available.`);
6139
7354
  process.exit(1);
6140
7355
  }
6141
- // Print budget footer to stderr (parseable, won't interfere with context output)
6142
7356
  const tokensUsed = result.tokenEstimate ?? 0;
6143
7357
  const maxTokens = parseInt(opts.maxTokens, 10);
6144
7358
  const included = result.memoriesIncluded ?? 0;
6145
7359
  const truncated = result.memoriesTruncated ?? 0;
6146
- 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`);
6147
7364
  }
6148
7365
  catch (err) {
6149
- console.error(`Bootstrap failed: ${err.message}`);
7366
+ console.error(`${render.icons.error} Bootstrap failed: ${err.message}`);
6150
7367
  process.exit(1);
6151
7368
  }
6152
7369
  });
6153
7370
  const soul = program.command("soul").description("Manage agent soul entries");
6154
- 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>")
6155
7375
  .option("--durability <d>", "permanent")
7376
+ .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
6156
7377
  .action(async (opts) => {
6157
- const out = await api("POST", "/Soul", { id: `${opts.agent}:${opts.key}`, agentId: opts.agent, key: opts.key, value: opts.value, durability: opts.durability });
6158
- 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));
6159
7483
  });
6160
- soul.command("get").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Soul/${id}`), null, 2)));
6161
- soul.command("list").requiredOption("--agent <id>")
6162
- .action(async (opts) => console.log(JSON.stringify(await api("GET", `/Soul?agentId=${encodeURIComponent(opts.agent)}`), null, 2)));
6163
7484
  // ─── flair bridge ────────────────────────────────────────────────────────────
6164
7485
  // Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
6165
7486
  // + agentic-stack reference adapter as a built-in.
@@ -6174,24 +7495,39 @@ bridge
6174
7495
  const { discover } = await import("./bridges/discover.js");
6175
7496
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
6176
7497
  const found = await discover({ builtins: builtinDiscoveryRecords() });
6177
- if (opts.json) {
6178
- console.log(JSON.stringify(found, null, 2));
7498
+ const mode = render.resolveOutputMode(opts);
7499
+ if (mode === "json") {
7500
+ console.log(render.asJSON(found));
6179
7501
  return;
6180
7502
  }
6181
7503
  if (found.length === 0) {
6182
- console.log("No bridges installed.");
6183
- console.log("Add one with: flair bridge scaffold <name> --file");
6184
- 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>`);
6185
7507
  return;
6186
7508
  }
6187
- const nameW = Math.max(4, ...found.map((b) => b.name.length));
6188
- const kindW = Math.max(4, ...found.map((b) => b.kind.length));
6189
- const srcW = Math.max(6, ...found.map((b) => b.source.length));
6190
- console.log(` ${"name".padEnd(nameW)} ${"kind".padEnd(kindW)} ${"source".padEnd(srcW)} description`);
6191
- for (const b of found) {
6192
- const desc = b.description ?? "";
6193
- console.log(` ${b.name.padEnd(nameW)} ${b.kind.padEnd(kindW)} ${b.source.padEnd(srcW)} ${desc}`);
6194
- }
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));
6195
7531
  });
6196
7532
  bridge
6197
7533
  .command("scaffold <name>")
@@ -6629,21 +7965,22 @@ bridge
6629
7965
  .action(async (opts) => {
6630
7966
  const { list: listAllowed } = await import("./bridges/runtime/allow-list.js");
6631
7967
  const entries = await listAllowed();
6632
- if (opts.json) {
6633
- console.log(JSON.stringify(entries, null, 2));
7968
+ const mode = render.resolveOutputMode(opts);
7969
+ if (mode === "json") {
7970
+ console.log(render.asJSON(entries));
6634
7971
  return;
6635
7972
  }
6636
7973
  if (entries.length === 0) {
6637
- console.log("No code-plugin bridges are allow-listed yet.");
6638
- 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>`);
6639
7976
  return;
6640
7977
  }
6641
- const nameW = Math.max(4, ...entries.map((e) => e.name.length));
6642
- const verW = Math.max(7, ...entries.map((e) => (e.version ?? "—").length));
6643
- 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`);
6644
7979
  for (const e of entries) {
6645
- console.log(` ${e.name.padEnd(nameW)} ${(e.version ?? "—").padEnd(verW)} ${e.allowedAt} ${e.packageDir}`);
6646
- 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();
6647
7984
  }
6648
7985
  });
6649
7986
  function printBridgeError(err) {
@@ -6741,13 +8078,26 @@ program
6741
8078
  .option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
6742
8079
  .option("--port <port>", "Harper HTTP port")
6743
8080
  .option("--url <url>", "Flair base URL (overrides --port)")
6744
- .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.")
6745
8083
  .action(async (opts) => {
6746
8084
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
6747
- 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
+ }
6748
8098
  const adminUser = DEFAULT_ADMIN_USER;
6749
8099
  if (!adminPass) {
6750
- 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");
6751
8101
  process.exit(1);
6752
8102
  }
6753
8103
  const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
@@ -6806,11 +8156,11 @@ program
6806
8156
  const tmp = outputPath + ".tmp";
6807
8157
  writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
6808
8158
  renameSync(tmp, outputPath);
6809
- console.log(`\n Backup complete`);
6810
- console.log(` Agents: ${agents.length}`);
6811
- console.log(` Memories: ${memories.length}`);
6812
- console.log(` Souls: ${souls.length}`);
6813
- 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)));
6814
8164
  });
6815
8165
  // ─── flair restore ────────────────────────────────────────────────────────────
6816
8166
  program
@@ -6924,10 +8274,10 @@ program
6924
8274
  console.warn(` warn: soul ${soul.id}: ${err.message}`);
6925
8275
  }
6926
8276
  }
6927
- console.log(`\n Restore complete`);
6928
- console.log(` Agents restored: ${agentCount}/${agents.length}`);
6929
- console.log(` Memories restored: ${memoryCount}/${memories.length}`);
6930
- 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}`)}`));
6931
8281
  });
6932
8282
  // ─── flair export ────────────────────────────────────────────────────────────
6933
8283
  program
@@ -7009,13 +8359,16 @@ program
7009
8359
  writeFileSync(outputPath, JSON.stringify(exportData, null, 2), { mode: fileMode });
7010
8360
  if (privateKey)
7011
8361
  chmodSync(outputPath, 0o600); // enforce even if umask is permissive
7012
- console.log(`\n Agent '${agentId}' exported`);
7013
- console.log(` Memories: ${memories.length}`);
7014
- console.log(` Souls: ${souls.length}`);
7015
- console.log(` Grants: ${grants.length}`);
7016
- console.log(` Key: ${privateKey ? "included (UNENCRYPTED — protect this file)" : "not included"}`);
7017
- console.log(` Mode: ${fileMode.toString(8)} (${privateKey ? "owner-only" : "standard"})`);
7018
- 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)));
7019
8372
  });
7020
8373
  // ─── flair import ────────────────────────────────────────────────────────────
7021
8374
  program
@@ -7106,40 +8459,50 @@ program
7106
8459
  }
7107
8460
  catch { /* skip failures */ }
7108
8461
  }
7109
- console.log(`\n Agent '${agentId}' imported`);
7110
- console.log(` Memories: ${memCount}/${(data.memories ?? []).length}`);
7111
- console.log(` Souls: ${soulCount}/${(data.souls ?? []).length}`);
7112
- 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)));
7113
8466
  });
7114
8467
  // ─── flair backup inspect ────────────────────────────────────────────────────
7115
8468
  program
7116
8469
  .command("inspect <path>")
7117
8470
  .description("Show contents of a backup or export file")
7118
- .action(async (filePath) => {
8471
+ .option("--json", "Emit raw JSON of the file (also: pipe + FLAIR_OUTPUT=json)")
8472
+ .action(async (filePath, opts) => {
7119
8473
  if (!existsSync(filePath)) {
7120
- console.error(`File not found: ${filePath}`);
8474
+ console.error(`${render.icons.error} File not found: ${render.wrap(render.c.dim, filePath)}`);
7121
8475
  process.exit(1);
7122
8476
  }
7123
8477
  const data = JSON.parse(readFileSync(filePath, "utf-8"));
7124
- console.log(`File: ${filePath}`);
7125
- console.log(`Type: ${data.type ?? "full-backup"}`);
7126
- console.log(`Created: ${data.createdAt ?? data.exportedAt ?? "unknown"}`);
7127
- 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"))));
7128
8489
  if (data.type === "agent-export") {
7129
- console.log(`\nAgent: ${data.agent?.id ?? "unknown"}`);
7130
- console.log(` Name: ${data.agent?.name ?? data.agent?.id}`);
7131
- console.log(` Memories: ${(data.memories ?? []).length}`);
7132
- console.log(` Souls: ${(data.souls ?? []).length}`);
7133
- console.log(` Grants: ${(data.grants ?? []).length}`);
7134
- 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));
7135
8497
  }
7136
8498
  else {
7137
8499
  const agents = data.agents ?? [];
7138
- console.log(`\nAgents: ${agents.length}`);
7139
- for (const a of agents)
7140
- console.log(` - ${a.id} (${a.name ?? a.id})`);
7141
- console.log(`Memories: ${(data.memories ?? []).length}`);
7142
- 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))));
7143
8506
  }
7144
8507
  });
7145
8508
  // ─── flair migrate-harness-memory ───────────────────────────────────────────