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