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