@tpsdev-ai/flair 0.8.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -11
- package/dist/cli.js +1740 -377
- package/dist/rem/restore.js +265 -0
- package/dist/rem/runner.js +221 -0
- package/dist/rem/scheduler.js +240 -0
- package/dist/rem/snapshot.js +139 -0
- package/dist/render.js +168 -0
- package/dist/resources/A2AAdapter.js +35 -0
- package/dist/resources/Admin.js +16 -0
- package/dist/resources/AdminInstance.js +86 -4
- package/dist/resources/AdminMemory.js +7 -1
- package/dist/resources/Federation.js +64 -33
- package/dist/resources/MemoryMaintenance.js +50 -22
- package/dist/resources/OAuth.js +22 -0
- package/dist/resources/ObservationCenter.js +4 -0
- package/dist/resources/SkillScan.js +32 -89
- package/dist/resources/admin-layout.js +25 -1
- package/dist/resources/auth-middleware.js +23 -3
- package/dist/resources/federation-classify.js +29 -0
- package/dist/resources/health.js +10 -5
- package/dist/resources/scan/skill-scanner.js +166 -0
- package/package.json +2 -1
- package/schemas/federation.graphql +6 -3
- package/templates/bin/flair-rem-nightly.sh.tmpl +9 -0
- package/templates/launchd/dev.flair.rem.nightly.plist.tmpl +46 -0
- package/templates/systemd/flair-rem-nightly.service.tmpl +14 -0
- package/templates/systemd/flair-rem-nightly.timer.tmpl +10 -0
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
4
|
import { load as parseYaml } from "js-yaml";
|
|
5
|
+
import * as render from "./render.js";
|
|
5
6
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
|
|
6
7
|
import { homedir, hostname, tmpdir } from "node:os";
|
|
7
|
-
import { join, resolve, sep } from "node:path";
|
|
8
|
+
import { join, resolve, sep, dirname } from "node:path";
|
|
8
9
|
import { spawn } from "node:child_process";
|
|
9
10
|
import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
|
|
10
11
|
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
@@ -83,6 +84,34 @@ const DEFAULT_OPS_PORT = 19925;
|
|
|
83
84
|
const DEFAULT_ADMIN_USER = "admin";
|
|
84
85
|
const STARTUP_TIMEOUT_MS = 60_000;
|
|
85
86
|
const HEALTH_POLL_INTERVAL_MS = 500;
|
|
87
|
+
/**
|
|
88
|
+
* Read an admin password from a file, refusing if the file is world/group readable.
|
|
89
|
+
*
|
|
90
|
+
* Admin pass files (default ~/.flair/admin-pass) are short-lived secrets generated
|
|
91
|
+
* by `openssl rand` — mode 0600 keeps them out of reach of other local users + most
|
|
92
|
+
* backup tooling. A 0644 file silently leaks the secret to anyone with read access
|
|
93
|
+
* to the user's home (multi-user hosts, NFS, time-machine snapshots, etc.).
|
|
94
|
+
*
|
|
95
|
+
* Throws with an actionable error if the file isn't owner-only, otherwise returns
|
|
96
|
+
* the trimmed file content (admin-pass files generated by `openssl rand -base64`
|
|
97
|
+
* conventionally end in a newline).
|
|
98
|
+
*/
|
|
99
|
+
export function readAdminPassFileSecure(path) {
|
|
100
|
+
if (!existsSync(path)) {
|
|
101
|
+
throw new Error(`--admin-pass-file path does not exist: ${path}`);
|
|
102
|
+
}
|
|
103
|
+
const st = statSync(path);
|
|
104
|
+
if (st.mode & 0o077) {
|
|
105
|
+
const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
|
|
106
|
+
throw new Error(`Refusing to read --admin-pass-file at ${path}: permissions ${modeOctal} are too open. ` +
|
|
107
|
+
`Run \`chmod 600 ${path}\` to restrict to owner-only.`);
|
|
108
|
+
}
|
|
109
|
+
const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
|
|
110
|
+
if (!content) {
|
|
111
|
+
throw new Error(`admin password file is empty or contains only whitespace: ${path}`);
|
|
112
|
+
}
|
|
113
|
+
return content;
|
|
114
|
+
}
|
|
86
115
|
function defaultKeysDir() {
|
|
87
116
|
return join(homedir(), ".flair", "keys");
|
|
88
117
|
}
|
|
@@ -126,6 +155,25 @@ function resolveHttpPort(opts) {
|
|
|
126
155
|
}
|
|
127
156
|
return readPortFromConfig() ?? DEFAULT_PORT;
|
|
128
157
|
}
|
|
158
|
+
// Unified base URL resolution. Precedence:
|
|
159
|
+
// --target > --url > FLAIR_TARGET env > FLAIR_URL env > http://127.0.0.1:<resolveHttpPort>
|
|
160
|
+
//
|
|
161
|
+
// Every user-facing command that talks to a Flair instance should call this
|
|
162
|
+
// instead of hand-rolling the precedence. Keeps remote-target switching
|
|
163
|
+
// (e.g. CI hitting Fabric) consistent across `flair status`, `flair search`,
|
|
164
|
+
// `flair bootstrap`, etc.
|
|
165
|
+
function resolveBaseUrl(opts) {
|
|
166
|
+
return (opts.target
|
|
167
|
+
|| opts.url
|
|
168
|
+
|| process.env.FLAIR_TARGET
|
|
169
|
+
|| process.env.FLAIR_URL
|
|
170
|
+
|| `http://127.0.0.1:${resolveHttpPort(opts)}`);
|
|
171
|
+
}
|
|
172
|
+
// Resolve agent id from --agent flag or FLAIR_AGENT_ID env.
|
|
173
|
+
// Returns null if neither is set; caller decides whether that's fatal.
|
|
174
|
+
function resolveAgentIdOrEnv(opts) {
|
|
175
|
+
return opts.agent || process.env.FLAIR_AGENT_ID || null;
|
|
176
|
+
}
|
|
129
177
|
// Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
|
|
130
178
|
function resolveOpsPort(opts) {
|
|
131
179
|
if (opts.opsPort !== undefined && opts.opsPort !== null) {
|
|
@@ -831,6 +879,16 @@ export function probeOpenclawPluginVersion(extensionName) {
|
|
|
831
879
|
return null;
|
|
832
880
|
}
|
|
833
881
|
}
|
|
882
|
+
/**
|
|
883
|
+
* Order a soul key→count map for display: highest count first, ties broken
|
|
884
|
+
* alphabetically for stable output. Soul entries are keyed identity facts
|
|
885
|
+
* (role / project / standards / …) — this is the honest breakdown dimension.
|
|
886
|
+
* (Replaced a priority breakdown that was dead telemetry — see flair#453:
|
|
887
|
+
* nothing ever writes Soul.priority to anything but "standard".)
|
|
888
|
+
*/
|
|
889
|
+
export function sortSoulKeyEntries(byKey) {
|
|
890
|
+
return Object.entries(byKey ?? {}).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
891
|
+
}
|
|
834
892
|
export function templateSoul(choice) {
|
|
835
893
|
const templates = {
|
|
836
894
|
"1": [
|
|
@@ -1206,14 +1264,11 @@ program
|
|
|
1206
1264
|
}
|
|
1207
1265
|
// Read from file if provided
|
|
1208
1266
|
if (opts.adminPassFile) {
|
|
1209
|
-
|
|
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,30 +2888,117 @@ federation
|
|
|
2727
2888
|
.option("--port <port>", "Harper HTTP port")
|
|
2728
2889
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
2729
2890
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
2891
|
+
.option("--json", "Emit JSON {instance, peers} (also: pipe + FLAIR_OUTPUT=json)")
|
|
2730
2892
|
.action(async (opts) => {
|
|
2731
2893
|
const target = resolveTarget(opts);
|
|
2732
2894
|
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
2895
|
+
const mode = render.resolveOutputMode(opts);
|
|
2733
2896
|
try {
|
|
2734
2897
|
const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
|
|
2735
|
-
console.log(`Instance: ${instance.id} (${instance.role})`);
|
|
2736
|
-
console.log(`Public key: ${instance.publicKey}`);
|
|
2737
|
-
console.log(`Status: ${instance.status}`);
|
|
2738
|
-
console.log();
|
|
2739
2898
|
const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
|
|
2899
|
+
if (mode === "json") {
|
|
2900
|
+
console.log(render.asJSON({ instance, peers }));
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
|
|
2904
|
+
console.log(render.wrap(render.c.bold, "Federation"));
|
|
2905
|
+
console.log(render.kv("Instance", `${instance.id} ${render.wrap(render.c.dim, `(${instance.role})`)}`));
|
|
2906
|
+
console.log(render.kv("Public key", render.wrap(render.c.dim, instance.publicKey)));
|
|
2907
|
+
console.log(render.kv("Status", render.wrap(statusColor, instance.status)));
|
|
2740
2908
|
if (peers.length === 0) {
|
|
2741
|
-
console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
|
|
2909
|
+
console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
|
|
2910
|
+
return;
|
|
2742
2911
|
}
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2912
|
+
const now = Date.now();
|
|
2913
|
+
const formatPeerAge = (iso, refNow, staleAfterMs) => {
|
|
2914
|
+
if (!iso)
|
|
2915
|
+
return render.wrap(render.c.red, "never");
|
|
2916
|
+
const t = Date.parse(iso);
|
|
2917
|
+
if (!Number.isFinite(t))
|
|
2918
|
+
return render.wrap(render.c.red, "never");
|
|
2919
|
+
const ageMs = refNow - t;
|
|
2920
|
+
const ageStr = ageMs < 60_000 ? "<1m ago"
|
|
2921
|
+
: ageMs < 3_600_000 ? `${Math.floor(ageMs / 60_000)}m ago`
|
|
2922
|
+
: ageMs < 86_400_000 ? `${Math.floor(ageMs / 3_600_000)}h ago`
|
|
2923
|
+
: `${Math.floor(ageMs / 86_400_000)}d ago`;
|
|
2924
|
+
const stale = ageMs > staleAfterMs;
|
|
2925
|
+
return render.wrap(stale ? render.c.yellow : render.c.dim, ageStr);
|
|
2926
|
+
};
|
|
2927
|
+
console.log();
|
|
2928
|
+
const cols = [
|
|
2929
|
+
{ label: "peer", key: "id" },
|
|
2930
|
+
{ label: "role", key: "role", format: (v) => String(v ?? "—") },
|
|
2931
|
+
{
|
|
2932
|
+
label: "status",
|
|
2933
|
+
key: "status",
|
|
2934
|
+
format: (v) => {
|
|
2935
|
+
const s = String(v ?? "—");
|
|
2936
|
+
const color = s === "paired" || s === "connected" || s === "active" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
|
|
2937
|
+
return render.wrap(color, s);
|
|
2938
|
+
},
|
|
2939
|
+
},
|
|
2940
|
+
{
|
|
2941
|
+
// Liveness: "did we hear from this peer recently?" Updates on every
|
|
2942
|
+
// contact, even when 100% of records were skipped. See flair#444.
|
|
2943
|
+
label: "last_sync",
|
|
2944
|
+
key: "lastSyncAt",
|
|
2945
|
+
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
2946
|
+
},
|
|
2947
|
+
{
|
|
2948
|
+
// Progress: "did data actually flow in?" Updates only when merged>0.
|
|
2949
|
+
// Diverging from last_sync means contact-yes but data-no — investigate.
|
|
2950
|
+
label: "last_merge",
|
|
2951
|
+
key: "lastMergeAt",
|
|
2952
|
+
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
2953
|
+
},
|
|
2954
|
+
{
|
|
2955
|
+
label: "relay",
|
|
2956
|
+
key: "relayOnly",
|
|
2957
|
+
format: (v) => (v ? render.wrap(render.c.cyan, "yes") : render.wrap(render.c.dim, "no")),
|
|
2958
|
+
},
|
|
2959
|
+
];
|
|
2960
|
+
console.log(render.table(cols, peers));
|
|
2961
|
+
// Stale warning is gated on lastMergeAt (real progress), not lastSyncAt.
|
|
2962
|
+
// A peer that "syncs" every 5min but hasn't merged a record in 24h is
|
|
2963
|
+
// exactly the failure mode we want surfaced.
|
|
2964
|
+
const haveStale = peers.some((p) => {
|
|
2965
|
+
const cursor = p.lastMergeAt ?? p.lastSyncAt;
|
|
2966
|
+
if (!cursor)
|
|
2967
|
+
return true;
|
|
2968
|
+
const t = Date.parse(cursor);
|
|
2969
|
+
return !Number.isFinite(t) || (now - t) > 86_400_000;
|
|
2970
|
+
});
|
|
2971
|
+
if (haveStale) {
|
|
2972
|
+
console.log();
|
|
2973
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, "Check skippedReasons in SyncLog or run 'flair federation sync'.")}`);
|
|
2974
|
+
}
|
|
2975
|
+
const haveContactButNoMerge = peers.some((p) => {
|
|
2976
|
+
if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
|
|
2977
|
+
return false;
|
|
2978
|
+
if ((now - Date.parse(p.lastSyncAt)) > 3_600_000)
|
|
2979
|
+
return false; // only recent contact
|
|
2980
|
+
// Contact within the last hour, but no merge ever (or stale by >1h)
|
|
2981
|
+
if (!p.lastMergeAt)
|
|
2982
|
+
return true;
|
|
2983
|
+
const tm = Date.parse(p.lastMergeAt);
|
|
2984
|
+
return !Number.isFinite(tm) || (now - tm) > 3_600_000;
|
|
2985
|
+
});
|
|
2986
|
+
if (haveContactButNoMerge && !haveStale) {
|
|
2987
|
+
console.log();
|
|
2988
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Peer contact is fresh but no records merged in the last hour.")} ${render.wrap(render.c.dim, "Possible silent-skip scenario — check SyncLog.skippedReasons.")}`);
|
|
2750
2989
|
}
|
|
2751
2990
|
}
|
|
2752
2991
|
catch (err) {
|
|
2753
|
-
|
|
2992
|
+
const msg = String(err.message ?? err);
|
|
2993
|
+
if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
|
|
2994
|
+
console.error(`${render.icons.error} federation status requires auth.`);
|
|
2995
|
+
console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
|
|
2996
|
+
console.error(` ${render.wrap(render.c.cyan, "FLAIR_AGENT_ID=<your-agent-id>")} ${render.wrap(render.c.dim, "(Ed25519 — uses ~/.flair/keys/<id>.key)")}`);
|
|
2997
|
+
console.error(` ${render.wrap(render.c.cyan, "FLAIR_ADMIN_PASS=<admin-password>")} ${render.wrap(render.c.dim, "(admin Basic auth, remote targets)")}`);
|
|
2998
|
+
console.error(` ${render.wrap(render.c.cyan, "FLAIR_TOKEN=<bearer>")} ${render.wrap(render.c.dim, "(legacy)")}`);
|
|
2999
|
+
process.exit(1);
|
|
3000
|
+
}
|
|
3001
|
+
console.error(`${render.icons.error} ${msg}`);
|
|
2754
3002
|
process.exit(1);
|
|
2755
3003
|
}
|
|
2756
3004
|
});
|
|
@@ -3111,6 +3359,12 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3111
3359
|
}
|
|
3112
3360
|
console.log(`Syncing to hub: ${hub.id}...`);
|
|
3113
3361
|
const since = hub.lastSyncAt ?? new Date(0).toISOString();
|
|
3362
|
+
// Capture sync start time BEFORE we query records. We advance the local
|
|
3363
|
+
// hub peer's lastSyncAt to this value after success so the next poll's
|
|
3364
|
+
// `since` cursor moves forward — fixes task #146 (federation peer
|
|
3365
|
+
// .lastSyncAt update bug). Records updated DURING this sync will have
|
|
3366
|
+
// updatedAt > syncStartedAt and be picked up next cycle, not missed.
|
|
3367
|
+
const syncStartedAt = new Date().toISOString();
|
|
3114
3368
|
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
3115
3369
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
3116
3370
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
@@ -3189,6 +3443,35 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3189
3443
|
totalBatches++;
|
|
3190
3444
|
}
|
|
3191
3445
|
}
|
|
3446
|
+
// Advance the local hub peer's lastSyncAt cursor. The hub-side
|
|
3447
|
+
// FederationSync handler updates ITS view of the spoke peer, but the
|
|
3448
|
+
// spoke never updated its own view of the hub — so `since` stayed at
|
|
3449
|
+
// whatever value was on the peer record at pair time (often near-epoch),
|
|
3450
|
+
// and every poll re-queried `updatedAt > since` and re-sent every
|
|
3451
|
+
// memory ever written. The receiver-side contentHash gate in
|
|
3452
|
+
// Federation.ts prevents the actual blob re-write, but advancing the
|
|
3453
|
+
// cursor here stops the redundant network traffic + Lambda compute
|
|
3454
|
+
// entirely. Task #146. Even no-change runs should advance.
|
|
3455
|
+
try {
|
|
3456
|
+
const advanceRes = await fetch(`${opsEndpoint}/`, {
|
|
3457
|
+
method: "POST",
|
|
3458
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
3459
|
+
body: JSON.stringify({
|
|
3460
|
+
operation: "update",
|
|
3461
|
+
database: "flair",
|
|
3462
|
+
table: "Peer",
|
|
3463
|
+
records: [{ id: hub.id, lastSyncAt: syncStartedAt }],
|
|
3464
|
+
}),
|
|
3465
|
+
signal: AbortSignal.timeout(10_000),
|
|
3466
|
+
});
|
|
3467
|
+
if (!advanceRes.ok) {
|
|
3468
|
+
const txt = await advanceRes.text().catch(() => "");
|
|
3469
|
+
console.warn(`⚠️ Local hub.lastSyncAt advance failed (${advanceRes.status}): ${txt.slice(0, 200)}. Next poll will re-send memories.`);
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
catch (advErr) {
|
|
3473
|
+
console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
|
|
3474
|
+
}
|
|
3192
3475
|
if (totalBatches === 0) {
|
|
3193
3476
|
console.log("No changes since last sync.");
|
|
3194
3477
|
return { pushed: 0, skipped: 0 };
|
|
@@ -3749,7 +4032,7 @@ rem
|
|
|
3749
4032
|
process.exit(1);
|
|
3750
4033
|
}
|
|
3751
4034
|
if (!agentId) {
|
|
3752
|
-
console.error(
|
|
4035
|
+
console.error(`${render.icons.error} --agent is required (or set FLAIR_AGENT_ID)`);
|
|
3753
4036
|
process.exit(1);
|
|
3754
4037
|
}
|
|
3755
4038
|
try {
|
|
@@ -3761,42 +4044,49 @@ rem
|
|
|
3761
4044
|
],
|
|
3762
4045
|
get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
|
|
3763
4046
|
});
|
|
3764
|
-
// search_by_conditions returns either an array or { error }; tolerate both shapes
|
|
3765
4047
|
const candidates = Array.isArray(result) ? result : (result?.results ?? []);
|
|
3766
|
-
|
|
3767
|
-
|
|
4048
|
+
const mode = render.resolveOutputMode(opts);
|
|
4049
|
+
if (mode === "json") {
|
|
4050
|
+
console.log(render.asJSON({ agentId, status, count: candidates.length, candidates }));
|
|
3768
4051
|
return;
|
|
3769
4052
|
}
|
|
3770
|
-
|
|
4053
|
+
const statusColor = status === "promoted" ? render.c.green : status === "rejected" ? render.c.red : render.c.yellow;
|
|
4054
|
+
console.log(`${render.wrap(render.c.bold, "REM candidates")} ${render.wrap(render.c.dim, "—")} agent ${render.wrap(render.c.bold, agentId)} ${render.wrap(render.c.dim, "·")} ${render.wrap(statusColor, status)}`);
|
|
3771
4055
|
if (candidates.length === 0) {
|
|
3772
|
-
console.log(`No ${status} candidates.`);
|
|
4056
|
+
console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, `No ${status} candidates.`)}`);
|
|
3773
4057
|
if (status === "pending") {
|
|
3774
|
-
console.log("
|
|
4058
|
+
console.log(`${render.wrap(render.c.dim, " Run")} flair rem nightly enable ${render.wrap(render.c.dim, "to start the nightly distillation cycle that populates this table.")}`);
|
|
3775
4059
|
}
|
|
3776
4060
|
return;
|
|
3777
4061
|
}
|
|
3778
|
-
// Sort newest-first by generatedAt
|
|
3779
4062
|
candidates.sort((a, b) => String(b.generatedAt ?? "").localeCompare(String(a.generatedAt ?? "")));
|
|
4063
|
+
console.log();
|
|
3780
4064
|
for (const c of candidates) {
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
4065
|
+
let tag;
|
|
4066
|
+
if (c.status === "promoted") {
|
|
4067
|
+
tag = `${render.wrap(render.c.green, "✓ promoted")} ${render.wrap(render.c.dim, "→")} ${render.wrap(render.c.bold, c.target ?? "?")} ${render.wrap(render.c.dim, `by ${c.reviewerId ?? "?"} ${render.relativeTime(c.decidedAt)}`)}`;
|
|
4068
|
+
}
|
|
4069
|
+
else if (c.status === "rejected") {
|
|
4070
|
+
tag = `${render.wrap(render.c.red, "✗ rejected")} ${render.wrap(render.c.dim, `by ${c.reviewerId ?? "?"} ${render.relativeTime(c.decidedAt)}`)}`;
|
|
4071
|
+
}
|
|
4072
|
+
else {
|
|
4073
|
+
tag = `${render.wrap(render.c.yellow, "○ pending")} ${render.wrap(render.c.dim, `— ${c.generatedBy ?? "?"} ${render.relativeTime(c.generatedAt)}`)}`;
|
|
4074
|
+
}
|
|
4075
|
+
console.log(` ${render.wrap(render.c.dim, c.id)} ${tag}`);
|
|
3787
4076
|
console.log(` ${c.claim}`);
|
|
3788
|
-
if (c.supersedes)
|
|
3789
|
-
console.log(` (supersedes ${c.supersedes} — recurring proposal)`);
|
|
4077
|
+
if (c.supersedes) {
|
|
4078
|
+
console.log(` ${render.wrap(render.c.dim, `(supersedes ${c.supersedes} — recurring proposal)`)}`);
|
|
4079
|
+
}
|
|
3790
4080
|
console.log("");
|
|
3791
4081
|
}
|
|
3792
|
-
console.log(`${candidates.length} candidate${candidates.length > 1 ? "s" : ""}.`);
|
|
4082
|
+
console.log(`${render.wrap(render.c.bold, String(candidates.length))} candidate${candidates.length > 1 ? "s" : ""}.`);
|
|
3793
4083
|
if (status === "pending") {
|
|
3794
|
-
console.log(
|
|
3795
|
-
console.log(
|
|
4084
|
+
console.log(`${render.wrap(render.c.dim, "Promote:")} flair rem promote <id> --rationale "<why>" --to (soul|memory)`);
|
|
4085
|
+
console.log(`${render.wrap(render.c.dim, "Reject: ")} flair rem reject <id> --reason "<why>"`);
|
|
3796
4086
|
}
|
|
3797
4087
|
}
|
|
3798
4088
|
catch (err) {
|
|
3799
|
-
console.error(
|
|
4089
|
+
console.error(`${render.icons.error} ${err.message}`);
|
|
3800
4090
|
process.exit(1);
|
|
3801
4091
|
}
|
|
3802
4092
|
});
|
|
@@ -3991,6 +4281,346 @@ rem
|
|
|
3991
4281
|
process.exit(1);
|
|
3992
4282
|
}
|
|
3993
4283
|
});
|
|
4284
|
+
// ─── flair rem nightly run-once ──────────────────────────────────────────────
|
|
4285
|
+
// Slice 1 of FLAIR-NIGHTLY-REM § 3. Manually invokes the nightly cycle code
|
|
4286
|
+
// path — same module the scheduler will call in PR-2. Useful for:
|
|
4287
|
+
// - First-time operators verifying the cycle works before turning on the
|
|
4288
|
+
// scheduled timer.
|
|
4289
|
+
// - The dry-run-first-run guard (spec § 10) when the scheduler isn't yet
|
|
4290
|
+
// installed.
|
|
4291
|
+
// - Debugging a stale snapshot or audit row.
|
|
4292
|
+
//
|
|
4293
|
+
// `nightly enable` / `disable` / `status` land in PR-2 (scheduler templates).
|
|
4294
|
+
const remNightly = rem.command("nightly").description("Scheduled REM nightly cycle (manual trigger + scheduler management)");
|
|
4295
|
+
// `enable` / `disable` / `status` — scheduler install/uninstall (slice-1 PR-2).
|
|
4296
|
+
// macOS: writes ~/Library/LaunchAgents/dev.flair.rem.nightly.plist and bootstraps it.
|
|
4297
|
+
// Linux: writes ~/.config/systemd/user/flair-rem-nightly.{timer,service} and enables the timer.
|
|
4298
|
+
// Snapshot data and the audit log are preserved through enable/disable cycles.
|
|
4299
|
+
remNightly
|
|
4300
|
+
.command("enable")
|
|
4301
|
+
.description("Install the nightly scheduler (launchd on macOS, systemd timer on Linux)")
|
|
4302
|
+
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
4303
|
+
.option("--at <HH:MM>", "Local time to run nightly (default 03:00)", "03:00")
|
|
4304
|
+
.option("--flair-url <url>", "Flair HTTP URL the runner will hit (default http://127.0.0.1:<port>)")
|
|
4305
|
+
.action(async (opts) => {
|
|
4306
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
4307
|
+
if (!agentId) {
|
|
4308
|
+
console.error("Error: --agent or FLAIR_AGENT_ID env required");
|
|
4309
|
+
process.exit(1);
|
|
4310
|
+
}
|
|
4311
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(opts.at);
|
|
4312
|
+
if (!match) {
|
|
4313
|
+
console.error(`Error: --at must be HH:MM (got: ${opts.at})`);
|
|
4314
|
+
process.exit(1);
|
|
4315
|
+
}
|
|
4316
|
+
const hour = parseInt(match[1], 10);
|
|
4317
|
+
const minute = parseInt(match[2], 10);
|
|
4318
|
+
const port = readPortFromConfig() ?? DEFAULT_PORT;
|
|
4319
|
+
const flairUrl = opts.flairUrl || process.env.FLAIR_URL || `http://127.0.0.1:${port}`;
|
|
4320
|
+
const { enableScheduler } = await import("./rem/scheduler.js");
|
|
4321
|
+
try {
|
|
4322
|
+
const r = enableScheduler({ agentId, flairUrl, hour, minute });
|
|
4323
|
+
console.log(`✅ REM nightly scheduler enabled (${r.platform})`);
|
|
4324
|
+
console.log(` Schedule: ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} local time`);
|
|
4325
|
+
console.log(` Scheduler: ${r.schedulerPath}`);
|
|
4326
|
+
console.log(` Shim: ${r.shimPath}`);
|
|
4327
|
+
console.log(` Agent: ${agentId}`);
|
|
4328
|
+
console.log(` Flair URL: ${flairUrl}`);
|
|
4329
|
+
if (r.loadResult) {
|
|
4330
|
+
if (r.loadResult.code === 0) {
|
|
4331
|
+
console.log(` Load: ${r.loadCommand.join(" ")} → ok`);
|
|
4332
|
+
}
|
|
4333
|
+
else {
|
|
4334
|
+
console.log(` Load: ${r.loadCommand.join(" ")} → code ${r.loadResult.code}`);
|
|
4335
|
+
if (r.loadResult.stderr)
|
|
4336
|
+
console.log(` stderr: ${r.loadResult.stderr.trim()}`);
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
console.log(`\nTip: run \`flair rem nightly run-once --dry-run\` to verify the cycle works`);
|
|
4340
|
+
console.log(` before the first scheduled fire. Disable with \`flair rem nightly disable\`.`);
|
|
4341
|
+
}
|
|
4342
|
+
catch (err) {
|
|
4343
|
+
console.error(`Error: ${err.message}`);
|
|
4344
|
+
process.exit(1);
|
|
4345
|
+
}
|
|
4346
|
+
});
|
|
4347
|
+
remNightly
|
|
4348
|
+
.command("disable")
|
|
4349
|
+
.description("Remove the nightly scheduler (keeps snapshots + audit log)")
|
|
4350
|
+
.option("--remove-shim", "Also delete the ~/.flair/bin/flair-rem-nightly shim")
|
|
4351
|
+
.action(async (opts) => {
|
|
4352
|
+
const { disableScheduler } = await import("./rem/scheduler.js");
|
|
4353
|
+
try {
|
|
4354
|
+
const r = disableScheduler({ removeShim: !!opts.removeShim });
|
|
4355
|
+
if (r.removed.length === 0) {
|
|
4356
|
+
console.log(`(REM nightly scheduler was not installed on ${r.platform})`);
|
|
4357
|
+
return;
|
|
4358
|
+
}
|
|
4359
|
+
console.log(`✅ REM nightly scheduler disabled (${r.platform})`);
|
|
4360
|
+
console.log(` Removed:`);
|
|
4361
|
+
for (const p of r.removed)
|
|
4362
|
+
console.log(` ${p}`);
|
|
4363
|
+
if (r.unloadResult && r.unloadResult.code !== 0) {
|
|
4364
|
+
console.log(` Unload: ${r.unloadCommand.join(" ")} → code ${r.unloadResult.code}`);
|
|
4365
|
+
if (r.unloadResult.stderr)
|
|
4366
|
+
console.log(` stderr: ${r.unloadResult.stderr.trim()}`);
|
|
4367
|
+
}
|
|
4368
|
+
console.log(`\nSnapshots at ~/.flair/snapshots/ and the audit log at`);
|
|
4369
|
+
console.log(`~/.flair/logs/rem-nightly.jsonl are preserved.`);
|
|
4370
|
+
}
|
|
4371
|
+
catch (err) {
|
|
4372
|
+
console.error(`Error: ${err.message}`);
|
|
4373
|
+
process.exit(1);
|
|
4374
|
+
}
|
|
4375
|
+
});
|
|
4376
|
+
remNightly
|
|
4377
|
+
.command("status")
|
|
4378
|
+
.description("Show whether the nightly scheduler is installed")
|
|
4379
|
+
.action(async () => {
|
|
4380
|
+
const { schedulerStatus } = await import("./rem/scheduler.js");
|
|
4381
|
+
try {
|
|
4382
|
+
const s = schedulerStatus();
|
|
4383
|
+
console.log(`REM nightly scheduler (${s.platform}):`);
|
|
4384
|
+
console.log(` Installed: ${s.installed ? "yes" : "no"}`);
|
|
4385
|
+
console.log(` Scheduler: ${s.schedulerPath}`);
|
|
4386
|
+
console.log(` Shim: ${s.shimPath}${s.shimExists ? "" : " (missing)"}`);
|
|
4387
|
+
if (!s.installed) {
|
|
4388
|
+
console.log(`\nEnable with: flair rem nightly enable --agent <id> [--at HH:MM]`);
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
catch (err) {
|
|
4392
|
+
console.error(`Error: ${err.message}`);
|
|
4393
|
+
process.exit(1);
|
|
4394
|
+
}
|
|
4395
|
+
});
|
|
4396
|
+
remNightly
|
|
4397
|
+
.command("run-once")
|
|
4398
|
+
.description("Run one nightly cycle now (snapshot + log). Same code path the scheduler will use.")
|
|
4399
|
+
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
4400
|
+
.option("--dry-run", "Log the row but skip the snapshot write")
|
|
4401
|
+
.action(async (opts) => {
|
|
4402
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
4403
|
+
if (!agentId) {
|
|
4404
|
+
console.error("Error: --agent or FLAIR_AGENT_ID env required");
|
|
4405
|
+
process.exit(1);
|
|
4406
|
+
}
|
|
4407
|
+
const { runNightlyCycle } = await import("./rem/runner.js");
|
|
4408
|
+
try {
|
|
4409
|
+
const result = await runNightlyCycle({
|
|
4410
|
+
agentId,
|
|
4411
|
+
flairVersion: __pkgVersion,
|
|
4412
|
+
apiCall: api,
|
|
4413
|
+
dryRun: !!opts.dryRun,
|
|
4414
|
+
});
|
|
4415
|
+
const row = result.logRow;
|
|
4416
|
+
console.log(`-- rem nightly run-once${opts.dryRun ? " (dry-run)" : ""} --`);
|
|
4417
|
+
console.log(`Agent: ${agentId}`);
|
|
4418
|
+
console.log(`Status: ${result.status}`);
|
|
4419
|
+
if (result.snapshotPath) {
|
|
4420
|
+
console.log(`Snapshot: ${result.snapshotPath}`);
|
|
4421
|
+
}
|
|
4422
|
+
console.log(`Memories: ${row.memoryCount ?? "—"}`);
|
|
4423
|
+
console.log(`Souls: ${row.soulCount ?? "—"}`);
|
|
4424
|
+
console.log(`Pending: ${row.pendingCandidates ?? "—"}`);
|
|
4425
|
+
if (typeof row.archived === "number" || typeof row.expired === "number") {
|
|
4426
|
+
console.log(`Archived: ${row.archived ?? "—"}`);
|
|
4427
|
+
console.log(`Expired: ${row.expired ?? "—"}`);
|
|
4428
|
+
}
|
|
4429
|
+
console.log(`Duration: ${row.durationMs}ms`);
|
|
4430
|
+
if (row.errors.length > 0) {
|
|
4431
|
+
console.log(`Errors:`);
|
|
4432
|
+
for (const e of row.errors)
|
|
4433
|
+
console.log(` - ${e}`);
|
|
4434
|
+
process.exit(1);
|
|
4435
|
+
}
|
|
4436
|
+
if (result.status === "paused") {
|
|
4437
|
+
console.log(`\nNote: REM is paused (sentinel ~/.flair/rem.paused or FLAIR_REM_PAUSE env).`);
|
|
4438
|
+
console.log(`Resume with: flair rem resume`);
|
|
4439
|
+
}
|
|
4440
|
+
}
|
|
4441
|
+
catch (err) {
|
|
4442
|
+
console.error(`Error: ${err.message}`);
|
|
4443
|
+
process.exit(1);
|
|
4444
|
+
}
|
|
4445
|
+
});
|
|
4446
|
+
// ─── flair rem snapshot list ─────────────────────────────────────────────────
|
|
4447
|
+
// Slice 1 of FLAIR-NIGHTLY-REM (ops-2qq). Lists snapshot tarballs under
|
|
4448
|
+
// ~/.flair/snapshots/<agent>/. Snapshot creation lives inside the nightly
|
|
4449
|
+
// runner (and exposed via `flair rem nightly run-once`) — there is no
|
|
4450
|
+
// user-facing `rem snapshot create` because that would invite operators to
|
|
4451
|
+
// create snapshots out of sync with the audit log. The list is the surface.
|
|
4452
|
+
const remSnapshot = rem.command("snapshot").description("REM nightly snapshots (tar.gz archives of agent memory + soul)");
|
|
4453
|
+
remSnapshot
|
|
4454
|
+
.command("list")
|
|
4455
|
+
.description("List REM snapshots for an agent (or all agents)")
|
|
4456
|
+
.option("--agent <id>", "Filter to a single agent")
|
|
4457
|
+
.option("--json", "Output as JSON")
|
|
4458
|
+
.action(async (opts) => {
|
|
4459
|
+
const { listSnapshots } = await import("./rem/snapshot.js");
|
|
4460
|
+
const rows = listSnapshots(opts.agent);
|
|
4461
|
+
if (opts.json) {
|
|
4462
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
if (rows.length === 0) {
|
|
4466
|
+
console.log("(no REM snapshots — ~/.flair/snapshots/ is empty or absent)");
|
|
4467
|
+
console.log("\nSnapshots are produced by the nightly cycle. Run `flair rem nightly run-once`");
|
|
4468
|
+
console.log("to generate one manually (slice 1).");
|
|
4469
|
+
return;
|
|
4470
|
+
}
|
|
4471
|
+
const agentW = Math.max(5, ...rows.map((r) => r.agent.length));
|
|
4472
|
+
const fileW = Math.max(20, ...rows.map((r) => r.file.length));
|
|
4473
|
+
console.log(` ${"agent".padEnd(agentW)} ${"file".padEnd(fileW)} size age`);
|
|
4474
|
+
for (const r of rows) {
|
|
4475
|
+
console.log(` ${r.agent.padEnd(agentW)} ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
|
|
4476
|
+
}
|
|
4477
|
+
console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
|
|
4478
|
+
});
|
|
4479
|
+
// ─── flair rem restore <date> ────────────────────────────────────────────────
|
|
4480
|
+
// Slice 1 + 2 of FLAIR-NIGHTLY-REM § 9.
|
|
4481
|
+
//
|
|
4482
|
+
// Default (no --apply): filesystem-only extract for inspection. Writes
|
|
4483
|
+
// memories.jsonl / soul.json / metadata.json to a target directory.
|
|
4484
|
+
// Harper state is unchanged.
|
|
4485
|
+
//
|
|
4486
|
+
// --apply: live replay. Reads the snapshot contents, takes a pre-restore
|
|
4487
|
+
// snapshot of the agent's CURRENT state (so this restore is itself
|
|
4488
|
+
// reversible), then DELETEs current memories/souls for the agent and PUTs
|
|
4489
|
+
// the snapshot's rows back. Per-row failures are captured per-row; the
|
|
4490
|
+
// pre-restore snapshot's path is reported so operator can roll back if
|
|
4491
|
+
// something goes wrong mid-flight.
|
|
4492
|
+
//
|
|
4493
|
+
// The <date> argument is an ISO-timestamp prefix or date-only prefix; the
|
|
4494
|
+
// command picks the latest snapshot matching that prefix.
|
|
4495
|
+
rem
|
|
4496
|
+
.command("restore <date>")
|
|
4497
|
+
.description("Restore from a REM snapshot (inspect by default; --apply rewinds Harper state)")
|
|
4498
|
+
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
4499
|
+
.option("--target <dir>", "Directory to extract into (default: <snapshot>.restored, only used without --apply)")
|
|
4500
|
+
.option("--dry-run", "Plan-only — list contents or planned counts without writing")
|
|
4501
|
+
.option("--apply", "Live replay: rewind Harper state to the snapshot (irreversible without the pre-restore snapshot)")
|
|
4502
|
+
.action(async (date, opts) => {
|
|
4503
|
+
const { listSnapshots, extractSnapshot } = await import("./rem/snapshot.js");
|
|
4504
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
4505
|
+
if (!agentId) {
|
|
4506
|
+
console.error("Error: --agent or FLAIR_AGENT_ID env required");
|
|
4507
|
+
process.exit(1);
|
|
4508
|
+
}
|
|
4509
|
+
let rows;
|
|
4510
|
+
try {
|
|
4511
|
+
rows = listSnapshots(agentId);
|
|
4512
|
+
}
|
|
4513
|
+
catch (err) {
|
|
4514
|
+
console.error(`Error: ${err.message}`);
|
|
4515
|
+
process.exit(1);
|
|
4516
|
+
}
|
|
4517
|
+
const matches = rows.filter((r) => r.file.startsWith(date));
|
|
4518
|
+
if (matches.length === 0) {
|
|
4519
|
+
console.error(`Error: no snapshot found for agent '${agentId}' matching date '${date}'`);
|
|
4520
|
+
if (rows.length > 0) {
|
|
4521
|
+
console.error(` Available: ${rows.slice(0, 5).map((r) => r.file.replace(/\.tar\.gz$/, "")).join(", ")}`);
|
|
4522
|
+
}
|
|
4523
|
+
else {
|
|
4524
|
+
console.error(` No snapshots exist for ${agentId}. Run \`flair rem nightly run-once\` to create one.`);
|
|
4525
|
+
}
|
|
4526
|
+
process.exit(1);
|
|
4527
|
+
}
|
|
4528
|
+
// listSnapshots returns descending by mtime, so matches[0] is the newest
|
|
4529
|
+
// snapshot for the date prefix.
|
|
4530
|
+
const match = matches[0];
|
|
4531
|
+
// --apply path: live replay via src/rem/restore.ts
|
|
4532
|
+
if (opts.apply) {
|
|
4533
|
+
const { applySnapshot } = await import("./rem/restore.js");
|
|
4534
|
+
try {
|
|
4535
|
+
const result = await applySnapshot({
|
|
4536
|
+
agentId,
|
|
4537
|
+
snapshotPath: match.path,
|
|
4538
|
+
flairVersion: __pkgVersion,
|
|
4539
|
+
apiCall: api,
|
|
4540
|
+
dryRun: !!opts.dryRun,
|
|
4541
|
+
});
|
|
4542
|
+
const verb = opts.dryRun ? "(dry-run) would" : "";
|
|
4543
|
+
console.log(`${opts.dryRun ? "(dry-run) " : ""}flair rem restore --apply${opts.dryRun ? "" : ""}`);
|
|
4544
|
+
console.log(` Status: ${result.status}`);
|
|
4545
|
+
console.log(` Snapshot: ${match.path}`);
|
|
4546
|
+
if (result.preRestoreSnapshotPath) {
|
|
4547
|
+
console.log(` Pre-restore: ${result.preRestoreSnapshotPath}`);
|
|
4548
|
+
console.log(` (rollback: flair rem restore <pre-restore-date> --agent ${agentId} --apply)`);
|
|
4549
|
+
}
|
|
4550
|
+
console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls`);
|
|
4551
|
+
console.log(` Restored: ${result.restored.memories} memories, ${result.restored.souls} souls`);
|
|
4552
|
+
if (result.errors.length > 0) {
|
|
4553
|
+
console.log(` Errors:`);
|
|
4554
|
+
for (const e of result.errors)
|
|
4555
|
+
console.log(` - ${e}`);
|
|
4556
|
+
}
|
|
4557
|
+
if (result.status === "failed")
|
|
4558
|
+
process.exit(1);
|
|
4559
|
+
}
|
|
4560
|
+
catch (err) {
|
|
4561
|
+
console.error(`Error: ${err.message}`);
|
|
4562
|
+
process.exit(1);
|
|
4563
|
+
}
|
|
4564
|
+
return;
|
|
4565
|
+
}
|
|
4566
|
+
// Default: filesystem extract.
|
|
4567
|
+
try {
|
|
4568
|
+
const result = await extractSnapshot({
|
|
4569
|
+
snapshotPath: match.path,
|
|
4570
|
+
targetDir: opts.target,
|
|
4571
|
+
dryRun: !!opts.dryRun,
|
|
4572
|
+
});
|
|
4573
|
+
if (opts.dryRun) {
|
|
4574
|
+
console.log(`(dry-run) snapshot: ${match.path}`);
|
|
4575
|
+
for (const e of result.entries) {
|
|
4576
|
+
console.log(` ${e.path} (${humanBytes(e.size)})`);
|
|
4577
|
+
}
|
|
4578
|
+
return;
|
|
4579
|
+
}
|
|
4580
|
+
console.log(`✅ Extracted: ${match.path}`);
|
|
4581
|
+
console.log(` To: ${result.targetDir}`);
|
|
4582
|
+
for (const e of result.entries) {
|
|
4583
|
+
console.log(` ${e.path} (${humanBytes(e.size)})`);
|
|
4584
|
+
}
|
|
4585
|
+
console.log(`\nNote: this is a filesystem extract — Harper state is unchanged.`);
|
|
4586
|
+
console.log(`To actually rewind state, re-run with --apply.`);
|
|
4587
|
+
}
|
|
4588
|
+
catch (err) {
|
|
4589
|
+
console.error(`Error: ${err.message}`);
|
|
4590
|
+
process.exit(1);
|
|
4591
|
+
}
|
|
4592
|
+
});
|
|
4593
|
+
// ─── flair rem pause / resume ────────────────────────────────────────────────
|
|
4594
|
+
// Slice 1 of FLAIR-NIGHTLY-REM § 9. The pause sentinel is checked by the
|
|
4595
|
+
// nightly runner before any side effects. Env-var FLAIR_REM_PAUSE=1 is also
|
|
4596
|
+
// honored — lets ops pause fleet-wide without writing a file.
|
|
4597
|
+
const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
|
|
4598
|
+
rem
|
|
4599
|
+
.command("pause")
|
|
4600
|
+
.description("Pause nightly REM runs — writes ~/.flair/rem.paused sentinel")
|
|
4601
|
+
.action(() => {
|
|
4602
|
+
const dir = dirname(REM_PAUSE_FLAG);
|
|
4603
|
+
if (!existsSync(dir))
|
|
4604
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4605
|
+
writeFileSync(REM_PAUSE_FLAG, new Date().toISOString() + "\n", { mode: 0o600 });
|
|
4606
|
+
console.log(`✅ REM nightly runs paused (sentinel: ${REM_PAUSE_FLAG})`);
|
|
4607
|
+
console.log(` Resume with: flair rem resume`);
|
|
4608
|
+
});
|
|
4609
|
+
rem
|
|
4610
|
+
.command("resume")
|
|
4611
|
+
.description("Resume nightly REM runs — removes the pause sentinel")
|
|
4612
|
+
.action(() => {
|
|
4613
|
+
if (existsSync(REM_PAUSE_FLAG)) {
|
|
4614
|
+
rmSync(REM_PAUSE_FLAG);
|
|
4615
|
+
console.log(`✅ REM nightly runs resumed (removed ${REM_PAUSE_FLAG})`);
|
|
4616
|
+
}
|
|
4617
|
+
else {
|
|
4618
|
+
console.log(`(REM was not paused — no sentinel at ${REM_PAUSE_FLAG})`);
|
|
4619
|
+
}
|
|
4620
|
+
if (process.env.FLAIR_REM_PAUSE === "1") {
|
|
4621
|
+
console.log(`\n⚠ FLAIR_REM_PAUSE=1 env var is also set; unset it to fully resume.`);
|
|
4622
|
+
}
|
|
4623
|
+
});
|
|
3994
4624
|
// ─── flair status ─────────────────────────────────────────────────────────────
|
|
3995
4625
|
function humanBytes(n) {
|
|
3996
4626
|
if (!Number.isFinite(n) || n < 0)
|
|
@@ -4251,139 +4881,176 @@ const statusCmd = program
|
|
|
4251
4881
|
})
|
|
4252
4882
|
: warnings;
|
|
4253
4883
|
const hasWarn = scopedWarnings.some((w) => w.level === "warn");
|
|
4254
|
-
const headerIcon = hasWarn ?
|
|
4255
|
-
|
|
4256
|
-
|
|
4884
|
+
const headerIcon = hasWarn ? render.icons.warn : render.icons.ok;
|
|
4885
|
+
const versionStr = render.wrap(render.c.bold, `Flair v${__pkgVersion}`);
|
|
4886
|
+
const runStatus = `${headerIcon} ${render.wrap(render.c.green, "running")}`;
|
|
4887
|
+
const pidPart = pid ? render.wrap(render.c.dim, `PID ${pid}`) : "";
|
|
4888
|
+
const uptimePart = uptimeStr ? render.wrap(render.c.dim, `uptime ${uptimeStr}`) : "";
|
|
4889
|
+
const metaParts = [pidPart, uptimePart].filter(Boolean).join(render.wrap(render.c.dim, " · "));
|
|
4890
|
+
console.log(`${versionStr} ${render.wrap(render.c.dim, "—")} ${runStatus}${metaParts ? ` ${metaParts}` : ""}`);
|
|
4891
|
+
console.log(render.kv("URL", baseUrl));
|
|
4257
4892
|
if (scopedWarnings.length > 0) {
|
|
4258
|
-
console.log(`\n
|
|
4259
|
-
for (const w of scopedWarnings)
|
|
4260
|
-
|
|
4893
|
+
console.log(`\n${render.wrap(render.c.bold, "Warnings")} ${render.wrap(render.c.dim, `(${scopedWarnings.length})`)}`);
|
|
4894
|
+
for (const w of scopedWarnings) {
|
|
4895
|
+
const icon = w.level === "warn" ? render.icons.warn : render.icons.info;
|
|
4896
|
+
console.log(` ${icon} ${w.message}`);
|
|
4897
|
+
}
|
|
4261
4898
|
}
|
|
4262
4899
|
if (memories) {
|
|
4263
|
-
console.log("
|
|
4900
|
+
console.log(`\n${render.wrap(render.c.bold, "Memory")}`);
|
|
4264
4901
|
const embStr = memories.withEmbeddings > 0 ? `${memories.withEmbeddings} embedded` : "";
|
|
4265
4902
|
const hashStr = memories.hashFallback > 0 ? `${memories.hashFallback} hash` : "";
|
|
4266
4903
|
const detail = [embStr, hashStr].filter(Boolean).join(", ");
|
|
4267
|
-
console.log(
|
|
4904
|
+
console.log(render.kv("Total", `${render.wrap(render.c.bold, String(memories.total))}${detail ? ` ${render.wrap(render.c.dim, `(${detail})`)}` : ""}`));
|
|
4268
4905
|
if (memories.modelCounts && typeof memories.modelCounts === "object") {
|
|
4269
4906
|
const entries = Object.entries(memories.modelCounts)
|
|
4270
4907
|
.filter(([, n]) => n > 0)
|
|
4271
4908
|
.sort((a, b) => b[1] - a[1]);
|
|
4272
4909
|
if (entries.length > 0) {
|
|
4273
|
-
const formatted = entries.map(([k, n]) => `${k}
|
|
4274
|
-
console.log(
|
|
4910
|
+
const formatted = entries.map(([k, n]) => `${render.wrap(render.c.cyan, k)}:${n}`).join(render.wrap(render.c.dim, ", "));
|
|
4911
|
+
console.log(render.kv("Embeddings", formatted));
|
|
4275
4912
|
}
|
|
4276
4913
|
}
|
|
4277
4914
|
if (memories.byDurability) {
|
|
4278
4915
|
const d = memories.byDurability;
|
|
4279
|
-
|
|
4916
|
+
const parts = [
|
|
4917
|
+
`${render.wrap(render.c.magenta, "permanent")}:${d.permanent ?? 0}`,
|
|
4918
|
+
`${render.wrap(render.c.blue, "persistent")}:${d.persistent ?? 0}`,
|
|
4919
|
+
`${render.wrap(render.c.cyan, "standard")}:${d.standard ?? 0}`,
|
|
4920
|
+
`${render.wrap(render.c.gray, "ephemeral")}:${d.ephemeral ?? 0}`,
|
|
4921
|
+
];
|
|
4922
|
+
console.log(render.kv("Durability", parts.join(render.wrap(render.c.dim, " · "))));
|
|
4280
4923
|
}
|
|
4281
4924
|
if (typeof memories.archived === "number")
|
|
4282
|
-
console.log(
|
|
4283
|
-
if (typeof memories.expired === "number" && memories.expired > 0)
|
|
4284
|
-
console.log(
|
|
4925
|
+
console.log(render.kv("Archived", String(memories.archived)));
|
|
4926
|
+
if (typeof memories.expired === "number" && memories.expired > 0) {
|
|
4927
|
+
console.log(render.kv("Expired", `${render.wrap(render.c.yellow, String(memories.expired))}`));
|
|
4928
|
+
}
|
|
4285
4929
|
if (healthData?.lastWrite)
|
|
4286
|
-
console.log(
|
|
4930
|
+
console.log(render.kv("Last write", render.relativeTime(healthData.lastWrite)));
|
|
4287
4931
|
}
|
|
4288
4932
|
if (agents && agents.count > 0) {
|
|
4289
|
-
console.log("
|
|
4290
|
-
const nameStr = agents.names?.length > 0 ? ` — ${agents.names.join(", ")}` : "";
|
|
4291
|
-
console.log(
|
|
4933
|
+
console.log(`\n${render.wrap(render.c.bold, "Agents")}`);
|
|
4934
|
+
const nameStr = agents.names?.length > 0 ? ` ${render.wrap(render.c.dim, "—")} ${agents.names.join(render.wrap(render.c.dim, ", "))}` : "";
|
|
4935
|
+
console.log(render.kv("Total", `${render.wrap(render.c.bold, String(agents.count))}${nameStr}`));
|
|
4292
4936
|
if (agents.count > 1 && Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
|
|
4293
|
-
const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
|
|
4294
|
-
// Older HealthDetail responses only carry id / memoryCount / lastWriteAt.
|
|
4295
|
-
// Only print the richer columns if at least one row supplies them.
|
|
4296
4937
|
const hasDeep = agents.perAgent.some((r) => typeof r.hashFallback === "number" || typeof r.writes24h === "number");
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4938
|
+
const cols = hasDeep
|
|
4939
|
+
? [
|
|
4940
|
+
{ label: "id", key: "id" },
|
|
4941
|
+
{ label: "memories", key: "memoryCount", align: "right" },
|
|
4942
|
+
{ label: "hash_fb", key: "hashFallback", align: "right", format: (v) => (typeof v === "number" ? String(v) : "—") },
|
|
4943
|
+
{ label: "24h", key: "writes24h", align: "right", format: (v) => (typeof v === "number" ? String(v) : "—") },
|
|
4944
|
+
{ label: "last_write", key: "lastWriteAt", format: (v) => render.relativeTime(v) },
|
|
4945
|
+
]
|
|
4946
|
+
: [
|
|
4947
|
+
{ label: "id", key: "id" },
|
|
4948
|
+
{ label: "memories", key: "memoryCount", align: "right" },
|
|
4949
|
+
{ label: "last_write", key: "lastWriteAt", format: (v) => render.relativeTime(v) },
|
|
4950
|
+
];
|
|
4951
|
+
console.log(render.table(cols, agents.perAgent));
|
|
4311
4952
|
}
|
|
4312
4953
|
}
|
|
4313
4954
|
if (healthData?.relationships) {
|
|
4314
4955
|
const r = healthData.relationships;
|
|
4315
|
-
console.log("
|
|
4316
|
-
console.log(
|
|
4956
|
+
console.log(`\n${render.wrap(render.c.bold, "Relationships")}`);
|
|
4957
|
+
console.log(render.kv("Total", `${r.total} ${render.wrap(render.c.dim, `(${r.active} active)`)}`));
|
|
4317
4958
|
}
|
|
4318
4959
|
if (healthData?.soul && healthData.soul.total > 0) {
|
|
4319
4960
|
const s = healthData.soul;
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4961
|
+
console.log(`\n${render.wrap(render.c.bold, "Soul")}`);
|
|
4962
|
+
const entries = sortSoulKeyEntries(s.byKey ?? {});
|
|
4963
|
+
const parts = entries.map(([k, n]) => `${render.wrap(render.c.cyan, k)}:${n}`);
|
|
4964
|
+
const suffix = parts.length > 0
|
|
4965
|
+
? ` ${render.wrap(render.c.dim, "—")} ${parts.join(render.wrap(render.c.dim, " · "))}`
|
|
4966
|
+
: "";
|
|
4967
|
+
console.log(render.kv("Entries", `${render.wrap(render.c.bold, String(s.total))}${suffix}`));
|
|
4323
4968
|
}
|
|
4324
4969
|
else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
|
|
4325
|
-
console.log("
|
|
4326
|
-
console.log(
|
|
4970
|
+
console.log(`\n${render.wrap(render.c.bold, "Soul")}`);
|
|
4971
|
+
console.log(render.kv("Entries", String(healthData.soulEntries)));
|
|
4327
4972
|
}
|
|
4328
4973
|
if (healthData?.rem) {
|
|
4329
4974
|
const r = healthData.rem;
|
|
4330
|
-
console.log("
|
|
4975
|
+
console.log(`\n${render.wrap(render.c.bold, "REM")}`);
|
|
4331
4976
|
if (r.lastLightAt)
|
|
4332
|
-
console.log(
|
|
4977
|
+
console.log(render.kv("Last light", render.relativeTime(r.lastLightAt)));
|
|
4333
4978
|
if (r.lastRapidAt)
|
|
4334
|
-
console.log(
|
|
4979
|
+
console.log(render.kv("Last rapid", render.relativeTime(r.lastRapidAt)));
|
|
4335
4980
|
if (r.lastRestorativeAt)
|
|
4336
|
-
console.log(
|
|
4337
|
-
const
|
|
4338
|
-
|
|
4981
|
+
console.log(render.kv("Last restorative", render.relativeTime(r.lastRestorativeAt)));
|
|
4982
|
+
const nightlyTxt = r.nightlyEnabled === true
|
|
4983
|
+
? render.wrap(render.c.green, "enabled")
|
|
4984
|
+
: r.nightlyEnabled === false
|
|
4985
|
+
? render.wrap(render.c.dim, "disabled")
|
|
4986
|
+
: render.wrap(render.c.dim, "unknown");
|
|
4987
|
+
console.log(render.kv("Nightly", nightlyTxt));
|
|
4339
4988
|
if (r.nightlyEnabled && r.lastNightlyAt)
|
|
4340
|
-
console.log(
|
|
4989
|
+
console.log(render.kv("Last nightly", render.relativeTime(r.lastNightlyAt)));
|
|
4341
4990
|
if (typeof r.pendingCandidates === "number" && r.pendingCandidates > 0) {
|
|
4342
|
-
console.log(
|
|
4991
|
+
console.log(render.kv("Pending candidates", render.wrap(render.c.yellow, String(r.pendingCandidates))));
|
|
4343
4992
|
}
|
|
4344
4993
|
}
|
|
4345
4994
|
if (healthData?.federation) {
|
|
4346
4995
|
const f = healthData.federation;
|
|
4347
|
-
console.log("
|
|
4348
|
-
if (f.instance)
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4996
|
+
console.log(`\n${render.wrap(render.c.bold, "Federation")}`);
|
|
4997
|
+
if (f.instance) {
|
|
4998
|
+
const statusColor = f.instance.status === "active" ? render.c.green : render.c.yellow;
|
|
4999
|
+
console.log(render.kv("Instance", `${f.instance.id} ${render.wrap(render.c.dim, "(")}${f.instance.role ?? "—"}${render.wrap(render.c.dim, ", ")}${render.wrap(statusColor, f.instance.status ?? "—")}${render.wrap(render.c.dim, ")")}`));
|
|
5000
|
+
}
|
|
5001
|
+
if (f.peers) {
|
|
5002
|
+
const connColor = f.peers.connected > 0 ? render.c.green : render.c.dim;
|
|
5003
|
+
const downColor = f.peers.disconnected > 0 ? render.c.yellow : render.c.dim;
|
|
5004
|
+
const revColor = f.peers.revoked > 0 ? render.c.red : render.c.dim;
|
|
5005
|
+
const parts = [
|
|
5006
|
+
`${render.wrap(connColor, `${f.peers.connected} connected`)}`,
|
|
5007
|
+
`${render.wrap(downColor, `${f.peers.disconnected} down`)}`,
|
|
5008
|
+
`${render.wrap(revColor, `${f.peers.revoked} revoked`)}`,
|
|
5009
|
+
];
|
|
5010
|
+
console.log(render.kv("Peers", `${render.wrap(render.c.bold, String(f.peers.total))} ${render.wrap(render.c.dim, "—")} ${parts.join(render.wrap(render.c.dim, " · "))}`));
|
|
5011
|
+
}
|
|
4352
5012
|
if (f.pendingTokens > 0)
|
|
4353
|
-
console.log(
|
|
5013
|
+
console.log(render.kv("Pairing", `${render.wrap(render.c.yellow, String(f.pendingTokens))} unconsumed token(s)`));
|
|
4354
5014
|
}
|
|
4355
5015
|
else {
|
|
4356
|
-
console.log("
|
|
5016
|
+
console.log(`\n${render.wrap(render.c.bold, "Federation")} ${render.wrap(render.c.dim, "not configured")}`);
|
|
4357
5017
|
}
|
|
4358
5018
|
if (healthData?.oauth) {
|
|
4359
5019
|
const lines = oauthSummaryLines(healthData.oauth);
|
|
4360
|
-
|
|
4361
|
-
|
|
5020
|
+
// Tweak the "OAuth:" header to bold; downstream lines are aligned k/v which already look fine
|
|
5021
|
+
for (const line of lines) {
|
|
5022
|
+
if (line.trim() === "OAuth:")
|
|
5023
|
+
console.log(`\n${render.wrap(render.c.bold, "OAuth")}`);
|
|
5024
|
+
else
|
|
5025
|
+
console.log(line);
|
|
5026
|
+
}
|
|
4362
5027
|
}
|
|
4363
5028
|
if (healthData?.bridges) {
|
|
4364
5029
|
const b = healthData.bridges;
|
|
4365
|
-
console.log("
|
|
5030
|
+
console.log(`\n${render.wrap(render.c.bold, "Bridges")}`);
|
|
4366
5031
|
if (Array.isArray(b.installed) && b.installed.length > 0)
|
|
4367
|
-
console.log(
|
|
5032
|
+
console.log(render.kv("Installed", b.installed.join(render.wrap(render.c.dim, ", "))));
|
|
4368
5033
|
if (b.lastImport)
|
|
4369
|
-
console.log(
|
|
5034
|
+
console.log(render.kv("Last import", render.relativeTime(b.lastImport)));
|
|
4370
5035
|
if (b.lastExport)
|
|
4371
|
-
console.log(
|
|
5036
|
+
console.log(render.kv("Last export", render.relativeTime(b.lastExport)));
|
|
4372
5037
|
}
|
|
4373
5038
|
else {
|
|
4374
|
-
console.log("
|
|
5039
|
+
console.log(`\n${render.wrap(render.c.bold, "Bridges")} ${render.wrap(render.c.dim, "none installed")}`);
|
|
4375
5040
|
}
|
|
4376
5041
|
if (healthData?.disk) {
|
|
4377
5042
|
const d = healthData.disk;
|
|
4378
|
-
console.log("
|
|
4379
|
-
console.log(
|
|
4380
|
-
console.log(
|
|
5043
|
+
console.log(`\n${render.wrap(render.c.bold, "Disk")}`);
|
|
5044
|
+
console.log(render.kv("Data", `${render.wrap(render.c.dim, d.dataDir)} ${render.wrap(render.c.dim, "—")} ${render.wrap(render.c.bold, render.humanBytes(d.dataBytes ?? 0))}`));
|
|
5045
|
+
console.log(render.kv("Snapshots", `${render.wrap(render.c.dim, d.snapshotDir)} ${render.wrap(render.c.dim, "—")} ${render.wrap(render.c.bold, render.humanBytes(d.snapshotBytes ?? 0))}`));
|
|
4381
5046
|
}
|
|
4382
5047
|
console.log("");
|
|
4383
|
-
if (scopedWarnings.length > 0)
|
|
4384
|
-
console.log(`
|
|
4385
|
-
|
|
4386
|
-
|
|
5048
|
+
if (scopedWarnings.length > 0) {
|
|
5049
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `${scopedWarnings.length} warning${scopedWarnings.length === 1 ? "" : "s"}`)}`);
|
|
5050
|
+
}
|
|
5051
|
+
else {
|
|
5052
|
+
console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "all checks passing")}`);
|
|
5053
|
+
}
|
|
4387
5054
|
});
|
|
4388
5055
|
statusCmd
|
|
4389
5056
|
.command("rem")
|
|
@@ -4391,33 +5058,42 @@ statusCmd
|
|
|
4391
5058
|
.action(async function () {
|
|
4392
5059
|
const opts = this.optsWithGlobals();
|
|
4393
5060
|
const { healthy, healthData } = await fetchHealthDetail(opts);
|
|
4394
|
-
|
|
4395
|
-
|
|
5061
|
+
const mode = render.resolveOutputMode(opts);
|
|
5062
|
+
if (mode === "json") {
|
|
5063
|
+
console.log(render.asJSON({ healthy, rem: healthData?.rem ?? null }));
|
|
4396
5064
|
if (!healthy)
|
|
4397
5065
|
process.exit(1);
|
|
4398
5066
|
return;
|
|
4399
5067
|
}
|
|
4400
5068
|
if (!healthy) {
|
|
4401
|
-
console.log("
|
|
5069
|
+
console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
|
|
4402
5070
|
process.exit(1);
|
|
4403
5071
|
}
|
|
4404
5072
|
const r = healthData?.rem;
|
|
4405
5073
|
if (!r) {
|
|
4406
|
-
console.log("REM
|
|
5074
|
+
console.log(`${render.wrap(render.c.bold, "REM")} ${render.wrap(render.c.dim, "not configured (no log entries or platform timers found)")}`);
|
|
4407
5075
|
return;
|
|
4408
5076
|
}
|
|
4409
|
-
console.log("REM
|
|
4410
|
-
console.log(
|
|
4411
|
-
console.log(
|
|
4412
|
-
console.log(
|
|
4413
|
-
const
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
console.log(
|
|
5077
|
+
console.log(render.wrap(render.c.bold, "REM"));
|
|
5078
|
+
console.log(render.kv("Last light", render.relativeTime(r.lastLightAt), 18));
|
|
5079
|
+
console.log(render.kv("Last rapid", render.relativeTime(r.lastRapidAt), 18));
|
|
5080
|
+
console.log(render.kv("Last restorative", render.relativeTime(r.lastRestorativeAt), 18));
|
|
5081
|
+
const nightlyTxt = r.nightlyEnabled === true
|
|
5082
|
+
? render.wrap(render.c.green, "enabled")
|
|
5083
|
+
: r.nightlyEnabled === false
|
|
5084
|
+
? render.wrap(render.c.dim, "disabled")
|
|
5085
|
+
: render.wrap(render.c.dim, "unknown");
|
|
5086
|
+
console.log(render.kv("Nightly", nightlyTxt, 18));
|
|
5087
|
+
if (r.lastNightlyAt) {
|
|
5088
|
+
console.log(render.kv("Last nightly", `${render.relativeTime(r.lastNightlyAt)} ${render.wrap(render.c.dim, `(${r.lastNightlyAt})`)}`, 18));
|
|
5089
|
+
}
|
|
5090
|
+
if (typeof r.pendingCandidates === "number") {
|
|
5091
|
+
const pendingColor = r.pendingCandidates > 0 ? render.c.yellow : render.c.dim;
|
|
5092
|
+
console.log(render.kv("Pending candidates", render.wrap(pendingColor, String(r.pendingCandidates)), 18));
|
|
5093
|
+
}
|
|
5094
|
+
else {
|
|
5095
|
+
console.log(render.kv("Pending candidates", render.wrap(render.c.dim, "— (schema not available)"), 18));
|
|
5096
|
+
}
|
|
4421
5097
|
});
|
|
4422
5098
|
statusCmd
|
|
4423
5099
|
.command("federation")
|
|
@@ -4425,36 +5101,70 @@ statusCmd
|
|
|
4425
5101
|
.action(async function () {
|
|
4426
5102
|
const opts = this.optsWithGlobals();
|
|
4427
5103
|
const { healthy, healthData } = await fetchHealthDetail(opts);
|
|
4428
|
-
|
|
4429
|
-
|
|
5104
|
+
const mode = render.resolveOutputMode(opts);
|
|
5105
|
+
if (mode === "json") {
|
|
5106
|
+
console.log(render.asJSON({ healthy, federation: healthData?.federation ?? null }));
|
|
4430
5107
|
if (!healthy)
|
|
4431
5108
|
process.exit(1);
|
|
4432
5109
|
return;
|
|
4433
5110
|
}
|
|
4434
5111
|
if (!healthy) {
|
|
4435
|
-
console.log("
|
|
5112
|
+
console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
|
|
4436
5113
|
process.exit(1);
|
|
4437
5114
|
}
|
|
4438
5115
|
const f = healthData?.federation;
|
|
4439
5116
|
if (!f) {
|
|
4440
|
-
console.log("Federation
|
|
5117
|
+
console.log(`${render.wrap(render.c.bold, "Federation")} ${render.wrap(render.c.dim, "not configured")}`);
|
|
4441
5118
|
return;
|
|
4442
5119
|
}
|
|
4443
|
-
console.log("Federation
|
|
4444
|
-
if (f.instance)
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
console.log(
|
|
4450
|
-
|
|
4451
|
-
|
|
5120
|
+
console.log(render.wrap(render.c.bold, "Federation"));
|
|
5121
|
+
if (f.instance) {
|
|
5122
|
+
const statusColor = f.instance.status === "active" ? render.c.green : render.c.yellow;
|
|
5123
|
+
console.log(render.kv("Instance", `${f.instance.id} ${render.wrap(render.c.dim, "(")}${f.instance.role ?? "—"}${render.wrap(render.c.dim, ", ")}${render.wrap(statusColor, f.instance.status ?? "—")}${render.wrap(render.c.dim, ")")}`));
|
|
5124
|
+
}
|
|
5125
|
+
else {
|
|
5126
|
+
console.log(render.kv("Instance", render.wrap(render.c.dim, "—")));
|
|
5127
|
+
}
|
|
5128
|
+
if (f.peers) {
|
|
5129
|
+
const connColor = f.peers.connected > 0 ? render.c.green : render.c.dim;
|
|
5130
|
+
const downColor = f.peers.disconnected > 0 ? render.c.yellow : render.c.dim;
|
|
5131
|
+
const revColor = f.peers.revoked > 0 ? render.c.red : render.c.dim;
|
|
5132
|
+
const parts = [
|
|
5133
|
+
render.wrap(connColor, `${f.peers.connected} connected`),
|
|
5134
|
+
render.wrap(downColor, `${f.peers.disconnected} down`),
|
|
5135
|
+
render.wrap(revColor, `${f.peers.revoked} revoked`),
|
|
5136
|
+
];
|
|
5137
|
+
console.log(render.kv("Peers", `${render.wrap(render.c.bold, String(f.peers.total))} ${render.wrap(render.c.dim, "—")} ${parts.join(render.wrap(render.c.dim, " · "))}`));
|
|
5138
|
+
}
|
|
5139
|
+
if (typeof f.pendingTokens === "number" && f.pendingTokens > 0) {
|
|
5140
|
+
console.log(render.kv("Pairing", `${render.wrap(render.c.yellow, String(f.pendingTokens))} unconsumed token(s)`));
|
|
5141
|
+
}
|
|
4452
5142
|
if (Array.isArray(f.peerList) && f.peerList.length > 0) {
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
5143
|
+
console.log();
|
|
5144
|
+
const cols = [
|
|
5145
|
+
{ label: "peer", key: "id" },
|
|
5146
|
+
{ label: "role", key: "role", format: (v) => String(v ?? "—") },
|
|
5147
|
+
{
|
|
5148
|
+
label: "status",
|
|
5149
|
+
key: "status",
|
|
5150
|
+
format: (v) => {
|
|
5151
|
+
const s = String(v ?? "—");
|
|
5152
|
+
const color = s === "paired" || s === "connected" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
|
|
5153
|
+
return render.wrap(color, s);
|
|
5154
|
+
},
|
|
5155
|
+
},
|
|
5156
|
+
{
|
|
5157
|
+
label: "last_sync",
|
|
5158
|
+
key: "lastSyncAt",
|
|
5159
|
+
format: (v) => {
|
|
5160
|
+
const iso = v;
|
|
5161
|
+
if (!iso)
|
|
5162
|
+
return render.wrap(render.c.dim, "never");
|
|
5163
|
+
return `${render.relativeTime(iso)} ${render.wrap(render.c.dim, `(${iso})`)}`;
|
|
5164
|
+
},
|
|
5165
|
+
},
|
|
5166
|
+
];
|
|
5167
|
+
console.log(render.table(cols, f.peerList));
|
|
4458
5168
|
}
|
|
4459
5169
|
});
|
|
4460
5170
|
statusCmd
|
|
@@ -4463,24 +5173,46 @@ statusCmd
|
|
|
4463
5173
|
.action(async function () {
|
|
4464
5174
|
const opts = this.optsWithGlobals();
|
|
4465
5175
|
const { healthy, healthData } = await fetchHealthDetail(opts);
|
|
4466
|
-
|
|
4467
|
-
|
|
5176
|
+
const mode = render.resolveOutputMode(opts);
|
|
5177
|
+
if (mode === "json") {
|
|
5178
|
+
console.log(render.asJSON({ healthy, oauth: healthData?.oauth ?? null }));
|
|
4468
5179
|
if (!healthy)
|
|
4469
5180
|
process.exit(1);
|
|
4470
5181
|
return;
|
|
4471
5182
|
}
|
|
4472
5183
|
if (!healthy) {
|
|
4473
|
-
console.log("
|
|
5184
|
+
console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
|
|
4474
5185
|
process.exit(1);
|
|
4475
5186
|
}
|
|
4476
5187
|
const o = healthData?.oauth;
|
|
4477
5188
|
if (!o) {
|
|
4478
|
-
console.log("OAuth
|
|
5189
|
+
console.log(`${render.wrap(render.c.bold, "OAuth")} ${render.wrap(render.c.dim, "not configured")}`);
|
|
4479
5190
|
return;
|
|
4480
5191
|
}
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
5192
|
+
console.log(render.wrap(render.c.bold, "OAuth"));
|
|
5193
|
+
console.log(render.kv("Clients", render.wrap(render.c.bold, String(Number(o?.clients ?? 0)))));
|
|
5194
|
+
console.log(render.kv("IdP configs", String(Number(o?.idpConfigs ?? 0))));
|
|
5195
|
+
const tokenColor = Number(o?.activeTokens ?? 0) > 0 ? render.c.green : render.c.dim;
|
|
5196
|
+
console.log(render.kv("Active tokens", render.wrap(tokenColor, String(Number(o?.activeTokens ?? 0)))));
|
|
5197
|
+
if (Array.isArray(o?.clientList) && o.clientList.length > 0) {
|
|
5198
|
+
console.log(`\n ${render.wrap(render.c.dim, "Clients")}`);
|
|
5199
|
+
const cols = [
|
|
5200
|
+
{ label: "id", key: "id" },
|
|
5201
|
+
{ label: "name", key: "name", format: (v) => String(v ?? "—") },
|
|
5202
|
+
{ label: "registered_by", key: "registeredBy", format: (v) => String(v ?? "—") },
|
|
5203
|
+
{ label: "created_at", key: "createdAt", format: (v) => String(v ?? "—") },
|
|
5204
|
+
];
|
|
5205
|
+
console.log(render.table(cols, o.clientList));
|
|
5206
|
+
}
|
|
5207
|
+
if (Array.isArray(o?.idpList) && o.idpList.length > 0) {
|
|
5208
|
+
console.log(`\n ${render.wrap(render.c.dim, "IdPs")}`);
|
|
5209
|
+
const cols = [
|
|
5210
|
+
{ label: "id", key: "id" },
|
|
5211
|
+
{ label: "name", key: "name", format: (v) => String(v ?? "—") },
|
|
5212
|
+
{ label: "issuer", key: "issuer", format: (v) => String(v ?? "—") },
|
|
5213
|
+
];
|
|
5214
|
+
console.log(render.table(cols, o.idpList));
|
|
5215
|
+
}
|
|
4484
5216
|
});
|
|
4485
5217
|
statusCmd
|
|
4486
5218
|
.command("bridges")
|
|
@@ -4488,28 +5220,283 @@ statusCmd
|
|
|
4488
5220
|
.action(async function () {
|
|
4489
5221
|
const opts = this.optsWithGlobals();
|
|
4490
5222
|
const { healthy, healthData } = await fetchHealthDetail(opts);
|
|
4491
|
-
|
|
4492
|
-
|
|
5223
|
+
const mode = render.resolveOutputMode(opts);
|
|
5224
|
+
if (mode === "json") {
|
|
5225
|
+
console.log(render.asJSON({ healthy, bridges: healthData?.bridges ?? null }));
|
|
4493
5226
|
if (!healthy)
|
|
4494
5227
|
process.exit(1);
|
|
4495
5228
|
return;
|
|
4496
5229
|
}
|
|
4497
5230
|
if (!healthy) {
|
|
4498
|
-
console.log("
|
|
5231
|
+
console.log(`${render.icons.error} ${render.wrap(render.c.red, "unreachable")}`);
|
|
4499
5232
|
process.exit(1);
|
|
4500
5233
|
}
|
|
4501
5234
|
const b = healthData?.bridges;
|
|
4502
5235
|
if (!b) {
|
|
4503
|
-
console.log("Bridges
|
|
5236
|
+
console.log(`${render.wrap(render.c.bold, "Bridges")} ${render.wrap(render.c.dim, "none installed (no flair-bridge-* packages found)")}`);
|
|
4504
5237
|
return;
|
|
4505
5238
|
}
|
|
4506
|
-
console.log("Bridges
|
|
4507
|
-
if (Array.isArray(b.installed) && b.installed.length > 0)
|
|
4508
|
-
console.log(
|
|
5239
|
+
console.log(render.wrap(render.c.bold, "Bridges"));
|
|
5240
|
+
if (Array.isArray(b.installed) && b.installed.length > 0) {
|
|
5241
|
+
console.log(render.kv("Installed", b.installed.join(render.wrap(render.c.dim, ", "))));
|
|
5242
|
+
}
|
|
4509
5243
|
if (b.lastImport)
|
|
4510
|
-
console.log(
|
|
5244
|
+
console.log(render.kv("Last import", render.relativeTime(b.lastImport)));
|
|
4511
5245
|
if (b.lastExport)
|
|
4512
|
-
console.log(
|
|
5246
|
+
console.log(render.kv("Last export", render.relativeTime(b.lastExport)));
|
|
5247
|
+
});
|
|
5248
|
+
// ─── flair status --deep ──────────────────────────────────────────────────────
|
|
5249
|
+
//
|
|
5250
|
+
// Deeper observability than the default `flair status` summary — full
|
|
5251
|
+
// per-section detail with no condensing. Optional --bootstrap measures
|
|
5252
|
+
// cold-start context bytes per agent (slow; calls /MemoryBootstrap once per
|
|
5253
|
+
// agent, admin-auth required).
|
|
5254
|
+
//
|
|
5255
|
+
// Addresses ops-yph: Nathan's 2026-04-22 ask for "how much storage does my
|
|
5256
|
+
// memory take, how much context are bootstraps pulling, what's the real usage
|
|
5257
|
+
// pattern" — questions the default `flair status` summarizes but doesn't
|
|
5258
|
+
// surface in full. Agents should be able to self-audit via this too.
|
|
5259
|
+
statusCmd
|
|
5260
|
+
.command("deep")
|
|
5261
|
+
.description("Verbose status + optional bootstrap context size per agent (ops-yph)")
|
|
5262
|
+
.option("--bootstrap", "Also measure bootstrap context bytes per agent (slow; admin auth required)")
|
|
5263
|
+
.option("--max-tokens <n>", "Bootstrap maxTokens cap when --bootstrap is set", "4000")
|
|
5264
|
+
.action(async function () {
|
|
5265
|
+
const opts = this.optsWithGlobals();
|
|
5266
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
|
|
5267
|
+
if (!healthy) {
|
|
5268
|
+
if (opts.json) {
|
|
5269
|
+
console.log(JSON.stringify({ healthy: false, url: baseUrl, error: "unreachable" }, null, 2));
|
|
5270
|
+
}
|
|
5271
|
+
else {
|
|
5272
|
+
console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
|
|
5273
|
+
console.log(` URL: ${baseUrl}`);
|
|
5274
|
+
console.log(`\n Run: flair start or flair doctor`);
|
|
5275
|
+
}
|
|
5276
|
+
process.exit(1);
|
|
5277
|
+
}
|
|
5278
|
+
// Optional: per-agent bootstrap context bytes. Calls /MemoryBootstrap with
|
|
5279
|
+
// admin auth (so we can measure on behalf of any agent without holding
|
|
5280
|
+
// their keys). 15s timeout per call — bootstrap can be expensive on cold
|
|
5281
|
+
// caches or large memory sets.
|
|
5282
|
+
const agentList = (Array.isArray(healthData?.agents?.names) && healthData.agents.names.length > 0
|
|
5283
|
+
? healthData.agents.names
|
|
5284
|
+
: Array.isArray(healthData?.agents?.perAgent)
|
|
5285
|
+
? healthData.agents.perAgent.map((r) => r.id).filter(Boolean)
|
|
5286
|
+
: []);
|
|
5287
|
+
const bootstrapBytes = {};
|
|
5288
|
+
if (opts.bootstrap && agentList.length > 0) {
|
|
5289
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
|
|
5290
|
+
if (!adminPass) {
|
|
5291
|
+
if (!opts.json) {
|
|
5292
|
+
console.log("⚠ --bootstrap requires HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS env var");
|
|
5293
|
+
}
|
|
5294
|
+
}
|
|
5295
|
+
else {
|
|
5296
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
5297
|
+
const maxTokens = Number.parseInt(String(opts.maxTokens ?? "4000"), 10);
|
|
5298
|
+
for (const agentId of agentList) {
|
|
5299
|
+
try {
|
|
5300
|
+
const res = await fetch(`${baseUrl}/BootstrapMemories`, {
|
|
5301
|
+
method: "POST",
|
|
5302
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
5303
|
+
body: JSON.stringify({ agentId, maxTokens }),
|
|
5304
|
+
signal: AbortSignal.timeout(15000),
|
|
5305
|
+
});
|
|
5306
|
+
const text = await res.text();
|
|
5307
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
5308
|
+
let tokenEstimate;
|
|
5309
|
+
let memoriesIncluded;
|
|
5310
|
+
try {
|
|
5311
|
+
const json = JSON.parse(text);
|
|
5312
|
+
tokenEstimate = typeof json.tokenEstimate === "number" ? json.tokenEstimate : undefined;
|
|
5313
|
+
memoriesIncluded = typeof json.memoriesIncluded === "number" ? json.memoriesIncluded : undefined;
|
|
5314
|
+
}
|
|
5315
|
+
catch { /* response wasn't JSON; bytes still valid */ }
|
|
5316
|
+
if (!res.ok) {
|
|
5317
|
+
bootstrapBytes[agentId] = { bytes, error: `HTTP ${res.status}` };
|
|
5318
|
+
}
|
|
5319
|
+
else {
|
|
5320
|
+
bootstrapBytes[agentId] = { bytes, tokenEstimate, memoriesIncluded };
|
|
5321
|
+
}
|
|
5322
|
+
}
|
|
5323
|
+
catch (e) {
|
|
5324
|
+
bootstrapBytes[agentId] = { bytes: 0, error: e?.message ?? String(e) };
|
|
5325
|
+
}
|
|
5326
|
+
}
|
|
5327
|
+
}
|
|
5328
|
+
}
|
|
5329
|
+
if (opts.json) {
|
|
5330
|
+
const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
|
|
5331
|
+
if (opts.bootstrap)
|
|
5332
|
+
out.bootstrapBytes = bootstrapBytes;
|
|
5333
|
+
console.log(JSON.stringify(out, null, 2));
|
|
5334
|
+
return;
|
|
5335
|
+
}
|
|
5336
|
+
// Human-readable verbose render.
|
|
5337
|
+
const uptimeSec = healthData?.uptimeSeconds;
|
|
5338
|
+
let uptimeStr = "";
|
|
5339
|
+
if (uptimeSec != null) {
|
|
5340
|
+
const d = Math.floor(uptimeSec / 86400);
|
|
5341
|
+
const h = Math.floor((uptimeSec % 86400) / 3600);
|
|
5342
|
+
const m = Math.floor((uptimeSec % 3600) / 60);
|
|
5343
|
+
uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
5344
|
+
}
|
|
5345
|
+
const pid = healthData?.pid ?? "?";
|
|
5346
|
+
console.log(`Flair v${__pkgVersion} — running (PID ${pid}${uptimeStr ? `, uptime ${uptimeStr}` : ""})`);
|
|
5347
|
+
console.log(`URL: ${baseUrl}`);
|
|
5348
|
+
const memories = healthData?.memories;
|
|
5349
|
+
if (memories) {
|
|
5350
|
+
console.log("\n═══ Memory ═══════════════════════════════════════");
|
|
5351
|
+
console.log(`Total: ${memories.total}`);
|
|
5352
|
+
console.log(`Breakdown: ${memories.withEmbeddings ?? 0} embedded, ${memories.hashFallback ?? 0} hash-fallback`);
|
|
5353
|
+
if (memories.modelCounts && memories.total > 0) {
|
|
5354
|
+
const entries = Object.entries(memories.modelCounts)
|
|
5355
|
+
.filter(([, n]) => n > 0)
|
|
5356
|
+
.sort((a, b) => b[1] - a[1]);
|
|
5357
|
+
for (const [k, n] of entries) {
|
|
5358
|
+
const pct = ((n / memories.total) * 100).toFixed(1);
|
|
5359
|
+
console.log(` ${k.padEnd(28)} ${String(n).padStart(6)} (${pct}%)`);
|
|
5360
|
+
}
|
|
5361
|
+
}
|
|
5362
|
+
if (memories.byDurability) {
|
|
5363
|
+
const d = memories.byDurability;
|
|
5364
|
+
console.log(`Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
|
|
5365
|
+
}
|
|
5366
|
+
console.log(`Archived: ${memories.archived ?? 0}`);
|
|
5367
|
+
console.log(`Expired: ${memories.expired ?? 0}`);
|
|
5368
|
+
if (healthData?.lastWrite)
|
|
5369
|
+
console.log(`Last write: ${relativeTime(healthData.lastWrite)} (${healthData.lastWrite})`);
|
|
5370
|
+
}
|
|
5371
|
+
const agents = healthData?.agents;
|
|
5372
|
+
if (agents && agents.count > 0) {
|
|
5373
|
+
console.log("\n═══ Agents ═══════════════════════════════════════");
|
|
5374
|
+
console.log(`Total: ${agents.count}`);
|
|
5375
|
+
if (Array.isArray(agents.names) && agents.names.length > 0) {
|
|
5376
|
+
console.log(`Names: ${agents.names.join(", ")}`);
|
|
5377
|
+
}
|
|
5378
|
+
if (Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
|
|
5379
|
+
const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
|
|
5380
|
+
console.log(`\n ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
|
|
5381
|
+
for (const r of agents.perAgent) {
|
|
5382
|
+
const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "—";
|
|
5383
|
+
const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
|
|
5384
|
+
console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
|
|
5385
|
+
}
|
|
5386
|
+
}
|
|
5387
|
+
}
|
|
5388
|
+
// Bootstrap context section — always printed so users see how to opt in.
|
|
5389
|
+
console.log("\n═══ Bootstrap context ═══════════════════════════");
|
|
5390
|
+
if (!opts.bootstrap) {
|
|
5391
|
+
console.log(" (not measured — pass --bootstrap to fetch per-agent context bytes)");
|
|
5392
|
+
}
|
|
5393
|
+
else if (Object.keys(bootstrapBytes).length === 0) {
|
|
5394
|
+
console.log(" (no agents found, or admin pass missing — see HDB_ADMIN_PASSWORD)");
|
|
5395
|
+
}
|
|
5396
|
+
else {
|
|
5397
|
+
const idW = Math.max(5, ...Object.keys(bootstrapBytes).map((id) => id.length));
|
|
5398
|
+
console.log(` ${"agent".padEnd(idW)} ${"bytes".padStart(9)} ${"~tokens".padStart(7)} ${"mems".padStart(4)} status`);
|
|
5399
|
+
// Sort by bytes desc so heaviest bootstraps surface first.
|
|
5400
|
+
const sortedEntries = Object.entries(bootstrapBytes).sort((a, b) => (b[1].bytes ?? 0) - (a[1].bytes ?? 0));
|
|
5401
|
+
for (const [agentId, info] of sortedEntries) {
|
|
5402
|
+
const bytesStr = info.error ? "error" : humanBytes(info.bytes);
|
|
5403
|
+
const tok = info.tokenEstimate != null ? String(info.tokenEstimate) : "—";
|
|
5404
|
+
const mems = info.memoriesIncluded != null ? String(info.memoriesIncluded) : "—";
|
|
5405
|
+
const status = info.error ? info.error.slice(0, 40) : "ok";
|
|
5406
|
+
console.log(` ${agentId.padEnd(idW)} ${bytesStr.padStart(9)} ${tok.padStart(7)} ${mems.padStart(4)} ${status}`);
|
|
5407
|
+
}
|
|
5408
|
+
}
|
|
5409
|
+
if (healthData?.relationships) {
|
|
5410
|
+
const r = healthData.relationships;
|
|
5411
|
+
console.log("\n═══ Relationships ═══════════════════════════════");
|
|
5412
|
+
console.log(`Total: ${r.total} (${r.active} active)`);
|
|
5413
|
+
}
|
|
5414
|
+
if (healthData?.soul && healthData.soul.total > 0) {
|
|
5415
|
+
const s = healthData.soul;
|
|
5416
|
+
console.log("\n═══ Soul ═════════════════════════════════════════");
|
|
5417
|
+
console.log(`Total: ${s.total} entries`);
|
|
5418
|
+
const entries = sortSoulKeyEntries(s.byKey ?? {});
|
|
5419
|
+
if (entries.length > 0) {
|
|
5420
|
+
console.log(`Keys: ${entries.map(([k, n]) => `${n} ${k}`).join(" / ")}`);
|
|
5421
|
+
}
|
|
5422
|
+
}
|
|
5423
|
+
else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
|
|
5424
|
+
console.log("\n═══ Soul ═════════════════════════════════════════");
|
|
5425
|
+
console.log(`Total: ${healthData.soulEntries} entries`);
|
|
5426
|
+
}
|
|
5427
|
+
if (healthData?.rem) {
|
|
5428
|
+
const r = healthData.rem;
|
|
5429
|
+
console.log("\n═══ REM ══════════════════════════════════════════");
|
|
5430
|
+
console.log(`Last light: ${relativeTime(r.lastLightAt)}`);
|
|
5431
|
+
console.log(`Last rapid: ${relativeTime(r.lastRapidAt)}`);
|
|
5432
|
+
console.log(`Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
|
|
5433
|
+
const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
|
|
5434
|
+
console.log(`Nightly: ${nightly}`);
|
|
5435
|
+
if (r.lastNightlyAt)
|
|
5436
|
+
console.log(`Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
|
|
5437
|
+
if (typeof r.pendingCandidates === "number")
|
|
5438
|
+
console.log(`Pending candidates: ${r.pendingCandidates}`);
|
|
5439
|
+
}
|
|
5440
|
+
if (healthData?.federation) {
|
|
5441
|
+
const f = healthData.federation;
|
|
5442
|
+
console.log("\n═══ Federation ═══════════════════════════════════");
|
|
5443
|
+
if (f.instance)
|
|
5444
|
+
console.log(`Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
|
|
5445
|
+
if (f.peers)
|
|
5446
|
+
console.log(`Peers: ${f.peers.total} total (${f.peers.connected} connected, ${f.peers.disconnected} down, ${f.peers.revoked} revoked)`);
|
|
5447
|
+
if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
|
|
5448
|
+
console.log(`Pairing: ${f.pendingTokens} unconsumed token(s)`);
|
|
5449
|
+
if (Array.isArray(f.peerList) && f.peerList.length > 0) {
|
|
5450
|
+
const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
|
|
5451
|
+
console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
|
|
5452
|
+
for (const p of f.peerList) {
|
|
5453
|
+
console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "—").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
else {
|
|
5458
|
+
console.log("\n═══ Federation ═══════════════════════════════════");
|
|
5459
|
+
console.log(" not configured");
|
|
5460
|
+
}
|
|
5461
|
+
if (healthData?.oauth) {
|
|
5462
|
+
const o = healthData.oauth;
|
|
5463
|
+
console.log("\n═══ OAuth / IdP ══════════════════════════════════");
|
|
5464
|
+
console.log(`Clients: ${o.clients}`);
|
|
5465
|
+
console.log(`IdP configs: ${o.idpConfigs}`);
|
|
5466
|
+
console.log(`Active tokens: ${o.activeTokens}`);
|
|
5467
|
+
if (Array.isArray(o.clientList) && o.clientList.length > 0) {
|
|
5468
|
+
console.log(`\n Clients:`);
|
|
5469
|
+
for (const c of o.clientList) {
|
|
5470
|
+
console.log(` ${c.id} ${c.name ?? "—"} ${c.registeredBy ?? "—"} ${c.createdAt ?? "—"}`);
|
|
5471
|
+
}
|
|
5472
|
+
}
|
|
5473
|
+
}
|
|
5474
|
+
if (healthData?.bridges) {
|
|
5475
|
+
const b = healthData.bridges;
|
|
5476
|
+
console.log("\n═══ Bridges ══════════════════════════════════════");
|
|
5477
|
+
if (Array.isArray(b.installed) && b.installed.length > 0)
|
|
5478
|
+
console.log(`Installed: ${b.installed.join(", ")}`);
|
|
5479
|
+
if (b.lastImport)
|
|
5480
|
+
console.log(`Last import: ${relativeTime(b.lastImport)}`);
|
|
5481
|
+
if (b.lastExport)
|
|
5482
|
+
console.log(`Last export: ${relativeTime(b.lastExport)}`);
|
|
5483
|
+
}
|
|
5484
|
+
if (healthData?.disk) {
|
|
5485
|
+
const d = healthData.disk;
|
|
5486
|
+
console.log("\n═══ Disk ═════════════════════════════════════════");
|
|
5487
|
+
console.log(`Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
|
|
5488
|
+
console.log(`Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
|
|
5489
|
+
console.log(`Total: ${humanBytes((d.dataBytes ?? 0) + (d.snapshotBytes ?? 0))}`);
|
|
5490
|
+
}
|
|
5491
|
+
const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
|
|
5492
|
+
if (warnings.length > 0) {
|
|
5493
|
+
console.log("\n═══ Warnings ═════════════════════════════════════");
|
|
5494
|
+
for (const w of warnings)
|
|
5495
|
+
console.log(` ${w.level === "warn" ? "⚠" : "ℹ"} ${w.message}`);
|
|
5496
|
+
}
|
|
5497
|
+
else {
|
|
5498
|
+
console.log("\n✅ no warnings");
|
|
5499
|
+
}
|
|
4513
5500
|
});
|
|
4514
5501
|
// ─── flair upgrade ────────────────────────────────────────────────────────────
|
|
4515
5502
|
program
|
|
@@ -5190,15 +6177,13 @@ program
|
|
|
5190
6177
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
5191
6178
|
.option("--port <port>", "Harper HTTP port")
|
|
5192
6179
|
.action(async (opts) => {
|
|
5193
|
-
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
5194
|
-
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
5195
6180
|
const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
|
|
5196
6181
|
if (!agentId && !process.env.FLAIR_ADMIN_PASS) {
|
|
5197
|
-
console.error(red
|
|
6182
|
+
console.error(`${render.icons.error} ${render.wrap(render.c.red, "set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS")}`);
|
|
5198
6183
|
process.exit(1);
|
|
5199
6184
|
}
|
|
5200
6185
|
const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
5201
|
-
console.log(`\
|
|
6186
|
+
console.log(`\n${render.wrap(render.c.bold, "Flair test")} ${render.wrap(render.c.dim, `(url: ${baseUrl})`)}\n`);
|
|
5202
6187
|
let passed = 0;
|
|
5203
6188
|
let failed = 0;
|
|
5204
6189
|
let memoryId = null;
|
|
@@ -5206,17 +6191,17 @@ program
|
|
|
5206
6191
|
try {
|
|
5207
6192
|
const ok = await fn();
|
|
5208
6193
|
if (ok) {
|
|
5209
|
-
console.log(` ${green
|
|
6194
|
+
console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "PASS")} ${name}`);
|
|
5210
6195
|
passed++;
|
|
5211
6196
|
}
|
|
5212
6197
|
else {
|
|
5213
|
-
console.log(` ${red
|
|
6198
|
+
console.log(` ${render.icons.error} ${render.wrap(render.c.red, "FAIL")} ${name}`);
|
|
5214
6199
|
failed++;
|
|
5215
6200
|
}
|
|
5216
6201
|
}
|
|
5217
6202
|
catch (e) {
|
|
5218
6203
|
const message = e instanceof Error ? e.message : String(e);
|
|
5219
|
-
console.log(` ${red
|
|
6204
|
+
console.log(` ${render.icons.error} ${render.wrap(render.c.red, "FAIL")} ${name}: ${render.wrap(render.c.dim, message?.slice(0, 120))}`);
|
|
5220
6205
|
failed++;
|
|
5221
6206
|
}
|
|
5222
6207
|
};
|
|
@@ -5255,7 +6240,9 @@ program
|
|
|
5255
6240
|
await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
|
|
5256
6241
|
return true;
|
|
5257
6242
|
});
|
|
5258
|
-
|
|
6243
|
+
const passColor = passed > 0 ? render.c.green : render.c.dim;
|
|
6244
|
+
const failColor = failed > 0 ? render.c.red : render.c.dim;
|
|
6245
|
+
console.log(`\n ${render.wrap(passColor, `${passed} passed`)} ${render.wrap(render.c.dim, "·")} ${render.wrap(failColor, `${failed} failed`)}`);
|
|
5259
6246
|
if (failed > 0)
|
|
5260
6247
|
process.exit(1);
|
|
5261
6248
|
});
|
|
@@ -5358,7 +6345,7 @@ program
|
|
|
5358
6345
|
let baseUrl = `http://127.0.0.1:${port}`;
|
|
5359
6346
|
let issues = 0;
|
|
5360
6347
|
let harperResponding = false;
|
|
5361
|
-
console.log("
|
|
6348
|
+
console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
|
|
5362
6349
|
// Helper: try to reach Harper on a given port
|
|
5363
6350
|
async function probePort(p) {
|
|
5364
6351
|
try {
|
|
@@ -5402,11 +6389,11 @@ program
|
|
|
5402
6389
|
catch { /* dead */ }
|
|
5403
6390
|
}
|
|
5404
6391
|
else {
|
|
5405
|
-
console.log(`
|
|
6392
|
+
console.log(` ${render.icons.warn} PID file contains non-numeric value: ${render.wrap(render.c.dim, pidFile0)} — skipping`);
|
|
5406
6393
|
}
|
|
5407
6394
|
}
|
|
5408
6395
|
if (await probePort(port)) {
|
|
5409
|
-
console.log(`
|
|
6396
|
+
console.log(` ${render.icons.ok} Harper responding on port ${render.wrap(render.c.bold, String(port))}`);
|
|
5410
6397
|
harperResponding = true;
|
|
5411
6398
|
}
|
|
5412
6399
|
else {
|
|
@@ -5415,19 +6402,19 @@ program
|
|
|
5415
6402
|
if (pidAlive) {
|
|
5416
6403
|
discoveredPort = await discoverPortFromPid(pidValue);
|
|
5417
6404
|
if (discoveredPort && discoveredPort !== port && await probePort(discoveredPort)) {
|
|
5418
|
-
console.log(`
|
|
5419
|
-
console.log(` Your config says port ${port} but Harper is actually running on ${discoveredPort}`);
|
|
6405
|
+
console.log(` ${render.icons.warn} Harper not on expected port ${port}, but responding on port ${render.wrap(render.c.bold, String(discoveredPort))} ${render.wrap(render.c.dim, `(PID ${pidValue})`)}`);
|
|
6406
|
+
console.log(` ${render.wrap(render.c.dim, `Your config says port ${port} but Harper is actually running on ${discoveredPort}`)}`);
|
|
5420
6407
|
if (autoFix) {
|
|
5421
6408
|
if (dryRun) {
|
|
5422
|
-
console.log(` Would update config to port ${discoveredPort}`);
|
|
6409
|
+
console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
|
|
5423
6410
|
}
|
|
5424
6411
|
else {
|
|
5425
6412
|
writeConfig(discoveredPort);
|
|
5426
|
-
console.log(`
|
|
6413
|
+
console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
|
|
5427
6414
|
}
|
|
5428
6415
|
}
|
|
5429
6416
|
else {
|
|
5430
|
-
console.log(` Fix: flair doctor --fix (updates config to match running port)`);
|
|
6417
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(updates config to match running port)")}`);
|
|
5431
6418
|
}
|
|
5432
6419
|
effectivePort = discoveredPort;
|
|
5433
6420
|
baseUrl = `http://127.0.0.1:${discoveredPort}`;
|
|
@@ -5435,8 +6422,8 @@ program
|
|
|
5435
6422
|
issues++;
|
|
5436
6423
|
}
|
|
5437
6424
|
else {
|
|
5438
|
-
console.log(`
|
|
5439
|
-
console.log(` Fix: flair restart`);
|
|
6425
|
+
console.log(` ${render.icons.error} Harper process alive (PID ${pidValue}) but not responding on any detected port`);
|
|
6426
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
|
|
5440
6427
|
issues++;
|
|
5441
6428
|
}
|
|
5442
6429
|
}
|
|
@@ -5447,34 +6434,34 @@ program
|
|
|
5447
6434
|
const { execSync } = await import("node:child_process");
|
|
5448
6435
|
const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
|
|
5449
6436
|
if (lsof) {
|
|
5450
|
-
console.log(`
|
|
5451
|
-
console.log(` Fix: kill ${lsof} && flair restart`);
|
|
6437
|
+
console.log(` ${render.icons.error} Nothing responding on port ${port} ${render.wrap(render.c.dim, `(port occupied by PID ${lsof})`)}`);
|
|
6438
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} kill ${lsof} && flair restart`);
|
|
5452
6439
|
}
|
|
5453
6440
|
else {
|
|
5454
|
-
console.log(`
|
|
5455
|
-
console.log(` Fix: flair restart`);
|
|
6441
|
+
console.log(` ${render.icons.error} Harper is not running`);
|
|
6442
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
|
|
5456
6443
|
}
|
|
5457
6444
|
}
|
|
5458
6445
|
catch {
|
|
5459
|
-
console.log(`
|
|
6446
|
+
console.log(` ${render.icons.error} Harper is not running`);
|
|
5460
6447
|
if (autoFix) {
|
|
5461
6448
|
if (dryRun) {
|
|
5462
|
-
console.log(` Would run: flair restart`);
|
|
6449
|
+
console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
|
|
5463
6450
|
}
|
|
5464
6451
|
else {
|
|
5465
|
-
console.log(` Attempting restart
|
|
6452
|
+
console.log(` ${render.wrap(render.c.dim, "Attempting restart...")}`);
|
|
5466
6453
|
try {
|
|
5467
6454
|
const { execSync } = await import("node:child_process");
|
|
5468
6455
|
execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${port}`, { stdio: "inherit" });
|
|
5469
|
-
console.log(`
|
|
6456
|
+
console.log(` ${render.icons.ok} Restart attempted`);
|
|
5470
6457
|
}
|
|
5471
6458
|
catch {
|
|
5472
|
-
console.log(`
|
|
6459
|
+
console.log(` ${render.icons.error} Restart failed — try: flair init --agent-id <your-agent>`);
|
|
5473
6460
|
}
|
|
5474
6461
|
}
|
|
5475
6462
|
}
|
|
5476
6463
|
else {
|
|
5477
|
-
console.log(` Fix: flair restart`);
|
|
6464
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
|
|
5478
6465
|
}
|
|
5479
6466
|
}
|
|
5480
6467
|
issues++;
|
|
@@ -5485,27 +6472,27 @@ program
|
|
|
5485
6472
|
if (existsSync(keysDir)) {
|
|
5486
6473
|
const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
|
|
5487
6474
|
if (keyFiles.length > 0) {
|
|
5488
|
-
console.log(`
|
|
6475
|
+
console.log(` ${render.icons.ok} Keys found: ${render.wrap(render.c.bold, String(keyFiles.length))} agent(s) in ${render.wrap(render.c.dim, keysDir)}`);
|
|
5489
6476
|
}
|
|
5490
6477
|
else {
|
|
5491
|
-
console.log(`
|
|
5492
|
-
console.log(` Fix: flair init --agent-id <your-agent>`);
|
|
6478
|
+
console.log(` ${render.icons.error} Keys directory exists but no .key files found`);
|
|
6479
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
|
|
5493
6480
|
issues++;
|
|
5494
6481
|
}
|
|
5495
6482
|
}
|
|
5496
6483
|
else {
|
|
5497
|
-
console.log(`
|
|
5498
|
-
console.log(` Fix: flair init --agent-id <your-agent>`);
|
|
6484
|
+
console.log(` ${render.icons.error} Keys directory missing: ${render.wrap(render.c.dim, keysDir)}`);
|
|
6485
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
|
|
5499
6486
|
issues++;
|
|
5500
6487
|
}
|
|
5501
6488
|
// 3. Config file
|
|
5502
6489
|
const cfgPath = configPath();
|
|
5503
6490
|
if (existsSync(cfgPath)) {
|
|
5504
6491
|
const savedPort = readPortFromConfig();
|
|
5505
|
-
console.log(`
|
|
6492
|
+
console.log(` ${render.icons.ok} Config: ${render.wrap(render.c.dim, cfgPath)} ${render.wrap(render.c.dim, `(port: ${savedPort ?? "default"})`)}`);
|
|
5506
6493
|
}
|
|
5507
6494
|
else {
|
|
5508
|
-
console.log(`
|
|
6495
|
+
console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
|
|
5509
6496
|
}
|
|
5510
6497
|
// 4. Embeddings check (only if Harper is responding)
|
|
5511
6498
|
if (harperResponding) {
|
|
@@ -5519,17 +6506,17 @@ program
|
|
|
5519
6506
|
if (testRes.ok) {
|
|
5520
6507
|
const data = await testRes.json();
|
|
5521
6508
|
if (data._warning) {
|
|
5522
|
-
console.log(`
|
|
5523
|
-
console.log(` Semantic search quality is degraded`);
|
|
5524
|
-
console.log(` Check: ls ~/.npm-global/lib/node_modules/@tpsdev-ai/flair/models/`);
|
|
6509
|
+
console.log(` ${render.icons.warn} Embeddings: keyword-only ${render.wrap(render.c.dim, `(${data._warning})`)}`);
|
|
6510
|
+
console.log(` ${render.wrap(render.c.dim, "Semantic search quality is degraded")}`);
|
|
6511
|
+
console.log(` ${render.wrap(render.c.dim, "Check:")} ls ~/.npm-global/lib/node_modules/@tpsdev-ai/flair/models/`);
|
|
5525
6512
|
issues++;
|
|
5526
6513
|
}
|
|
5527
6514
|
else {
|
|
5528
|
-
console.log(`
|
|
6515
|
+
console.log(` ${render.icons.ok} Embeddings: semantic search operational`);
|
|
5529
6516
|
}
|
|
5530
6517
|
}
|
|
5531
6518
|
else if (testRes.status === 401) {
|
|
5532
|
-
console.log(`
|
|
6519
|
+
console.log(` ${render.icons.warn} Embeddings: cannot verify ${render.wrap(render.c.dim, "(auth required for SemanticSearch)")}`);
|
|
5533
6520
|
}
|
|
5534
6521
|
}
|
|
5535
6522
|
catch { /* fetch error, already flagged */ }
|
|
@@ -5542,50 +6529,50 @@ program
|
|
|
5542
6529
|
try {
|
|
5543
6530
|
process.kill(Number(pidContent), 0);
|
|
5544
6531
|
if (harperResponding) {
|
|
5545
|
-
console.log(`
|
|
6532
|
+
console.log(` ${render.icons.ok} PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is alive)`)}`);
|
|
5546
6533
|
}
|
|
5547
6534
|
// If not responding, we already reported the issue in step 1
|
|
5548
6535
|
}
|
|
5549
6536
|
catch {
|
|
5550
|
-
console.log(`
|
|
6537
|
+
console.log(` ${render.icons.error} Stale PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is dead)`)}`);
|
|
5551
6538
|
if (autoFix) {
|
|
5552
6539
|
if (dryRun) {
|
|
5553
|
-
console.log(` Would remove: ${pidFile}`);
|
|
6540
|
+
console.log(` ${render.wrap(render.c.dim, "Would remove:")} ${pidFile}`);
|
|
5554
6541
|
}
|
|
5555
6542
|
else {
|
|
5556
6543
|
(await import("node:fs")).unlinkSync(pidFile);
|
|
5557
|
-
console.log(`
|
|
6544
|
+
console.log(` ${render.icons.ok} Removed stale PID file`);
|
|
5558
6545
|
}
|
|
5559
6546
|
}
|
|
5560
6547
|
else {
|
|
5561
|
-
console.log(` Fix: rm ${pidFile} && flair restart`);
|
|
6548
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} rm ${pidFile} && flair restart`);
|
|
5562
6549
|
}
|
|
5563
6550
|
issues++;
|
|
5564
6551
|
}
|
|
5565
6552
|
}
|
|
5566
6553
|
// 6. Data directory
|
|
5567
6554
|
if (existsSync(dataDir)) {
|
|
5568
|
-
console.log(`
|
|
6555
|
+
console.log(` ${render.icons.ok} Data directory: ${render.wrap(render.c.dim, dataDir)}`);
|
|
5569
6556
|
}
|
|
5570
6557
|
else {
|
|
5571
6558
|
// Check ~/harper/ (common alternative)
|
|
5572
6559
|
const altDir = join(homedir(), "harper");
|
|
5573
6560
|
if (existsSync(altDir)) {
|
|
5574
|
-
console.log(`
|
|
6561
|
+
console.log(` ${render.icons.warn} Data at ${render.wrap(render.c.dim, "~/harper/")} (not ${render.wrap(render.c.dim, "~/.flair/data")}) — old install location`);
|
|
5575
6562
|
}
|
|
5576
6563
|
else {
|
|
5577
|
-
console.log(`
|
|
5578
|
-
console.log(` Fix: flair init --agent-id <your-agent>`);
|
|
6564
|
+
console.log(` ${render.icons.error} No data directory found`);
|
|
6565
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
|
|
5579
6566
|
issues++;
|
|
5580
6567
|
}
|
|
5581
6568
|
}
|
|
5582
6569
|
// Summary
|
|
5583
6570
|
console.log("");
|
|
5584
6571
|
if (issues === 0) {
|
|
5585
|
-
console.log(
|
|
6572
|
+
console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`);
|
|
5586
6573
|
}
|
|
5587
6574
|
else {
|
|
5588
|
-
console.log(`
|
|
6575
|
+
console.log(` ${render.icons.error} ${render.wrap(render.c.red, `${issues} issue${issues > 1 ? "s" : ""} found`)} ${render.wrap(render.c.dim, "— see fixes above")}`);
|
|
5589
6576
|
}
|
|
5590
6577
|
console.log("");
|
|
5591
6578
|
if (issues > 0)
|
|
@@ -5845,64 +6832,140 @@ memory.command("write-task-summary")
|
|
|
5845
6832
|
// without parsing a JSON blob.
|
|
5846
6833
|
console.log(memId);
|
|
5847
6834
|
});
|
|
5848
|
-
memory.command("search [query]")
|
|
6835
|
+
memory.command("search [query]")
|
|
6836
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
5849
6837
|
.option("--q <query>", "search query (alias for positional arg)")
|
|
5850
6838
|
.option("--limit <n>", "Max results", "5")
|
|
5851
6839
|
.option("--tag <tag>")
|
|
6840
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
6841
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
6842
|
+
.option("--port <port>", "Harper HTTP port")
|
|
5852
6843
|
.action(async (queryArg, opts) => {
|
|
6844
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
6845
|
+
if (!agentId) {
|
|
6846
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
6847
|
+
process.exit(2);
|
|
6848
|
+
}
|
|
5853
6849
|
const q = queryArg ?? opts.q;
|
|
5854
6850
|
if (!q) {
|
|
5855
6851
|
console.error("error: query required (positional arg or --q)");
|
|
5856
6852
|
process.exit(1);
|
|
5857
6853
|
}
|
|
5858
|
-
const body = { agentId
|
|
6854
|
+
const body = { agentId, q, limit: parseInt(opts.limit, 10) || 5 };
|
|
5859
6855
|
if (opts.tag)
|
|
5860
6856
|
body.tag = opts.tag;
|
|
5861
|
-
|
|
6857
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
6858
|
+
const res = await api("POST", "/SemanticSearch", body, { baseUrl });
|
|
6859
|
+
console.log(JSON.stringify(res, null, 2));
|
|
5862
6860
|
});
|
|
5863
6861
|
memory.command("list")
|
|
5864
|
-
.
|
|
6862
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
5865
6863
|
.option("--tag <tag>")
|
|
5866
6864
|
.option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
|
|
5867
6865
|
.option("--limit <n>", "Max rows when using --hash-fallback", "50")
|
|
6866
|
+
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
5868
6867
|
.action(async (opts) => {
|
|
5869
|
-
const
|
|
6868
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
6869
|
+
if (!agentId) {
|
|
6870
|
+
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
6871
|
+
process.exit(2);
|
|
6872
|
+
}
|
|
6873
|
+
const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
|
|
5870
6874
|
const raw = await api("GET", `/Memory?${q}`);
|
|
5871
|
-
|
|
5872
|
-
|
|
6875
|
+
const mode = render.resolveOutputMode(opts);
|
|
6876
|
+
// hashFallback flag changes the lens: instead of all memories, show
|
|
6877
|
+
// only those that need re-embedding. Keep that surface separate.
|
|
6878
|
+
if (opts.hashFallback) {
|
|
6879
|
+
const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
6880
|
+
const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
|
|
6881
|
+
if (mode === "json") {
|
|
6882
|
+
console.log(render.asJSON(fallback));
|
|
6883
|
+
return;
|
|
6884
|
+
}
|
|
6885
|
+
if (fallback.length === 0) {
|
|
6886
|
+
console.log(`${render.icons.ok} ${render.wrap(render.c.green, "All memories embedded")} ${render.wrap(render.c.dim, `(agent ${agentId})`)}`);
|
|
6887
|
+
return;
|
|
6888
|
+
}
|
|
6889
|
+
const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
|
|
6890
|
+
const rows = fallback
|
|
6891
|
+
.slice()
|
|
6892
|
+
.sort((a, b) => {
|
|
6893
|
+
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
6894
|
+
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
6895
|
+
return tb - ta;
|
|
6896
|
+
})
|
|
6897
|
+
.slice(0, limit);
|
|
6898
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, String(fallback.length))} hash-fallback memories for agent ${render.wrap(render.c.bold, agentId)} ${render.wrap(render.c.dim, `(showing ${rows.length})`)}\n`);
|
|
6899
|
+
const cols = [
|
|
6900
|
+
{ label: "id", key: "id" },
|
|
6901
|
+
{
|
|
6902
|
+
label: "created_at",
|
|
6903
|
+
key: "createdAt",
|
|
6904
|
+
format: (v) => (v ? String(v).slice(0, 19).replace("T", " ") : "—"),
|
|
6905
|
+
},
|
|
6906
|
+
{
|
|
6907
|
+
label: "preview",
|
|
6908
|
+
key: "content",
|
|
6909
|
+
format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
|
|
6910
|
+
},
|
|
6911
|
+
];
|
|
6912
|
+
console.log(render.table(cols, rows));
|
|
6913
|
+
if (fallback.length > rows.length) {
|
|
6914
|
+
console.log(`\n${render.wrap(render.c.dim, `... ${fallback.length - rows.length} more (raise with --limit). To backfill:`)} flair reembed --agent ${agentId} --stale-only`);
|
|
6915
|
+
}
|
|
6916
|
+
else {
|
|
6917
|
+
console.log(`\n${render.wrap(render.c.dim, "To backfill:")} flair reembed --agent ${agentId} --stale-only`);
|
|
6918
|
+
}
|
|
5873
6919
|
return;
|
|
5874
6920
|
}
|
|
5875
|
-
//
|
|
5876
|
-
// Same predicate HealthDetail uses: missing model or the "hash-512d" marker.
|
|
6921
|
+
// Default lens: all memories for the agent.
|
|
5877
6922
|
const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
console.log(`No hash-fallback memories for agent ${opts.agent}. All embedded.`);
|
|
6923
|
+
if (mode === "json") {
|
|
6924
|
+
console.log(render.asJSON(all));
|
|
5881
6925
|
return;
|
|
5882
6926
|
}
|
|
5883
|
-
|
|
5884
|
-
|
|
6927
|
+
if (all.length === 0) {
|
|
6928
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, `No memories for agent ${agentId}`)}`);
|
|
6929
|
+
return;
|
|
6930
|
+
}
|
|
6931
|
+
console.log(`${render.wrap(render.c.bold, String(all.length))} memories for agent ${render.wrap(render.c.bold, agentId)}${opts.tag ? ` ${render.wrap(render.c.dim, `(tag=${opts.tag})`)}` : ""}\n`);
|
|
6932
|
+
const sorted = all
|
|
5885
6933
|
.slice()
|
|
5886
6934
|
.sort((a, b) => {
|
|
5887
6935
|
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
5888
6936
|
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
5889
6937
|
return tb - ta;
|
|
5890
|
-
})
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
}
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
6938
|
+
});
|
|
6939
|
+
const durabilityColor = (d) => {
|
|
6940
|
+
if (d === "permanent")
|
|
6941
|
+
return render.c.magenta;
|
|
6942
|
+
if (d === "persistent")
|
|
6943
|
+
return render.c.blue;
|
|
6944
|
+
if (d === "ephemeral")
|
|
6945
|
+
return render.c.gray;
|
|
6946
|
+
return render.c.cyan;
|
|
6947
|
+
};
|
|
6948
|
+
const cols = [
|
|
6949
|
+
{
|
|
6950
|
+
label: "created_at",
|
|
6951
|
+
key: "createdAt",
|
|
6952
|
+
format: (v) => (v ? render.wrap(render.c.dim, String(v).slice(0, 10)) : render.wrap(render.c.dim, "—")),
|
|
6953
|
+
},
|
|
6954
|
+
{
|
|
6955
|
+
label: "durability",
|
|
6956
|
+
key: "durability",
|
|
6957
|
+
format: (v) => {
|
|
6958
|
+
const d = String(v ?? "standard");
|
|
6959
|
+
return render.wrap(durabilityColor(d), d);
|
|
6960
|
+
},
|
|
6961
|
+
},
|
|
6962
|
+
{
|
|
6963
|
+
label: "preview",
|
|
6964
|
+
key: "content",
|
|
6965
|
+
format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
|
|
6966
|
+
},
|
|
6967
|
+
];
|
|
6968
|
+
console.log(render.table(cols, sorted));
|
|
5906
6969
|
});
|
|
5907
6970
|
// ─── flair memory hygiene ────────────────────────────────────────────────────
|
|
5908
6971
|
// Detect + remove junk memory rows that accumulate over time. Surfaced from
|
|
@@ -6046,47 +7109,184 @@ memory.command("hygiene")
|
|
|
6046
7109
|
console.log("hygiene --apply` on each peer to fan out.");
|
|
6047
7110
|
});
|
|
6048
7111
|
// ─── flair search (top-level shortcut) ───────────────────────────────────────
|
|
7112
|
+
// Parse --since / --as-of: accept ISO 8601 OR relative expressions
|
|
7113
|
+
// ("1h", "7d", "30m"). Returns ISO 8601 string or null if input is empty.
|
|
7114
|
+
// Returns the original string unchanged if it looks like a date (caller
|
|
7115
|
+
// passes through to server, which validates).
|
|
7116
|
+
function parseRelativeOrIso(input) {
|
|
7117
|
+
if (!input)
|
|
7118
|
+
return null;
|
|
7119
|
+
const m = input.match(/^(\d+)([smhdw])$/);
|
|
7120
|
+
if (!m)
|
|
7121
|
+
return input;
|
|
7122
|
+
const n = Number.parseInt(m[1], 10);
|
|
7123
|
+
const unit = m[2];
|
|
7124
|
+
const multMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
7125
|
+
return new Date(Date.now() - n * (multMs[unit] ?? 0)).toISOString();
|
|
7126
|
+
}
|
|
6049
7127
|
program
|
|
6050
7128
|
.command("search <query>")
|
|
6051
|
-
.description("Search memories by meaning (shortcut for memory search)")
|
|
6052
|
-
.
|
|
7129
|
+
.description("Search memories by meaning (shortcut for memory search) — filterable, with --explain ranking")
|
|
7130
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
6053
7131
|
.option("--limit <n>", "Max results", "5")
|
|
6054
7132
|
.option("--port <port>", "Harper HTTP port")
|
|
6055
7133
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
7134
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
6056
7135
|
.option("--key <path>", "Ed25519 private key path")
|
|
7136
|
+
// Server-side filters (forwarded to /SemanticSearch payload)
|
|
7137
|
+
.option("--tag <tag>", "Filter to memories carrying this tag")
|
|
7138
|
+
.option("--subject <subject>", "Filter to memories carrying this subject (case-insensitive)")
|
|
7139
|
+
.option("--subjects <list>", "Comma-separated list of subjects to OR-filter (case-insensitive)")
|
|
7140
|
+
.option("--since <iso-or-relative>", "Only memories created after this point (ISO 8601 or '7d'/'24h'/'30m')")
|
|
7141
|
+
.option("--as-of <iso>", "Temporal validity: only memories valid at this point (ISO 8601)")
|
|
7142
|
+
.option("--include-superseded", "Include memories that have been superseded")
|
|
7143
|
+
.option("--scoring <mode>", "Scoring mode: composite (default) re-ranks by durability/recency/retrieval; raw uses cosine similarity only", "composite")
|
|
7144
|
+
.option("--min-score <n>", "Drop results below this score (0..1)", "0")
|
|
7145
|
+
// Client-side filters (applied after server response)
|
|
7146
|
+
.option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
|
|
7147
|
+
.option("--source <name>", "Filter by source/agentId (client-side)")
|
|
7148
|
+
// Output modes
|
|
7149
|
+
.option("--explain", "Show score breakdown (composite, raw, durability, age, retrieval) per hit")
|
|
7150
|
+
.option("--json", "Output raw JSON array")
|
|
6057
7151
|
.action(async (query, opts) => {
|
|
6058
7152
|
try {
|
|
6059
|
-
const
|
|
7153
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
7154
|
+
if (!agentId) {
|
|
7155
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
7156
|
+
process.exit(2);
|
|
7157
|
+
}
|
|
7158
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
6060
7159
|
const headers = { "content-type": "application/json" };
|
|
6061
|
-
const keyPath = opts.key || resolveKeyPath(
|
|
7160
|
+
const keyPath = opts.key || resolveKeyPath(agentId);
|
|
6062
7161
|
if (keyPath) {
|
|
6063
|
-
headers["authorization"] = buildEd25519Auth(
|
|
7162
|
+
headers["authorization"] = buildEd25519Auth(agentId, "POST", "/SemanticSearch", keyPath);
|
|
6064
7163
|
}
|
|
7164
|
+
// Build payload from CLI options. Server validates types.
|
|
7165
|
+
const payload = {
|
|
7166
|
+
agentId,
|
|
7167
|
+
q: query,
|
|
7168
|
+
limit: Number.parseInt(opts.limit, 10) || 5,
|
|
7169
|
+
scoring: opts.scoring === "raw" ? "raw" : "composite",
|
|
7170
|
+
};
|
|
7171
|
+
if (opts.tag)
|
|
7172
|
+
payload.tag = opts.tag;
|
|
7173
|
+
if (opts.subject)
|
|
7174
|
+
payload.subject = opts.subject;
|
|
7175
|
+
if (opts.subjects)
|
|
7176
|
+
payload.subjects = String(opts.subjects).split(",").map((s) => s.trim()).filter(Boolean);
|
|
7177
|
+
const since = parseRelativeOrIso(opts.since);
|
|
7178
|
+
if (since)
|
|
7179
|
+
payload.since = since;
|
|
7180
|
+
if (opts.asOf)
|
|
7181
|
+
payload.asOf = opts.asOf;
|
|
7182
|
+
if (opts.includeSuperseded)
|
|
7183
|
+
payload.includeSuperseded = true;
|
|
7184
|
+
const minScore = Number.parseFloat(opts.minScore ?? "0");
|
|
7185
|
+
if (Number.isFinite(minScore) && minScore > 0)
|
|
7186
|
+
payload.minScore = minScore;
|
|
6065
7187
|
const res = await fetch(`${baseUrl}/SemanticSearch`, {
|
|
6066
7188
|
method: "POST",
|
|
6067
7189
|
headers,
|
|
6068
|
-
body: JSON.stringify(
|
|
7190
|
+
body: JSON.stringify(payload),
|
|
6069
7191
|
});
|
|
6070
7192
|
if (!res.ok)
|
|
6071
7193
|
throw new Error(await res.text());
|
|
6072
|
-
const result = await res.json();
|
|
6073
|
-
|
|
6074
|
-
if (!Array.isArray(results)
|
|
6075
|
-
|
|
7194
|
+
const result = (await res.json());
|
|
7195
|
+
let results = result.results || result || [];
|
|
7196
|
+
if (!Array.isArray(results))
|
|
7197
|
+
results = [];
|
|
7198
|
+
// Client-side filters: durability + source. Server doesn't expose these
|
|
7199
|
+
// as conditions, so we filter after fetch.
|
|
7200
|
+
if (opts.durability) {
|
|
7201
|
+
const allowed = new Set(String(opts.durability).split(",").map((d) => d.trim()));
|
|
7202
|
+
results = results.filter((r) => allowed.has(r.durability ?? "standard"));
|
|
7203
|
+
}
|
|
7204
|
+
if (opts.source) {
|
|
7205
|
+
const allowed = new Set(String(opts.source).split(",").map((s) => s.trim()));
|
|
7206
|
+
results = results.filter((r) => allowed.has(r._source ?? r.agentId ?? ""));
|
|
7207
|
+
}
|
|
7208
|
+
const mode = render.resolveOutputMode(opts);
|
|
7209
|
+
if (mode === "json") {
|
|
7210
|
+
console.log(render.asJSON(results));
|
|
7211
|
+
return;
|
|
7212
|
+
}
|
|
7213
|
+
if (results.length === 0) {
|
|
7214
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No results found.")}`);
|
|
7215
|
+
const filters = [];
|
|
7216
|
+
if (opts.tag)
|
|
7217
|
+
filters.push(`tag=${opts.tag}`);
|
|
7218
|
+
if (opts.subject)
|
|
7219
|
+
filters.push(`subject=${opts.subject}`);
|
|
7220
|
+
if (opts.subjects)
|
|
7221
|
+
filters.push(`subjects=${opts.subjects}`);
|
|
7222
|
+
if (opts.since)
|
|
7223
|
+
filters.push(`since=${opts.since}`);
|
|
7224
|
+
if (opts.durability)
|
|
7225
|
+
filters.push(`durability=${opts.durability}`);
|
|
7226
|
+
if (opts.source)
|
|
7227
|
+
filters.push(`source=${opts.source}`);
|
|
7228
|
+
if (filters.length > 0) {
|
|
7229
|
+
console.log(` ${render.wrap(render.c.dim, "Filters:")} ${filters.join(render.wrap(render.c.dim, " · "))}`);
|
|
7230
|
+
console.log(` ${render.icons.arrow} ${render.wrap(render.c.dim, "Try removing a filter or:")} flair search "${query}" --agent ${agentId}`);
|
|
7231
|
+
}
|
|
6076
7232
|
return;
|
|
6077
7233
|
}
|
|
7234
|
+
const durabilityColor = (d) => {
|
|
7235
|
+
if (d === "permanent")
|
|
7236
|
+
return render.c.magenta;
|
|
7237
|
+
if (d === "persistent")
|
|
7238
|
+
return render.c.blue;
|
|
7239
|
+
if (d === "ephemeral")
|
|
7240
|
+
return render.c.gray;
|
|
7241
|
+
return render.c.cyan;
|
|
7242
|
+
};
|
|
6078
7243
|
for (const r of results) {
|
|
6079
|
-
const date = r.createdAt ? r.createdAt.slice(0, 10) : "";
|
|
6080
|
-
const
|
|
6081
|
-
const
|
|
7244
|
+
const date = r.createdAt ? String(r.createdAt).slice(0, 10) : "";
|
|
7245
|
+
const scoreVal = typeof r._score === "number" ? r._score : 0;
|
|
7246
|
+
const scorePct = typeof r._score === "number" ? `${(scoreVal * 100).toFixed(0)}%` : "";
|
|
7247
|
+
const scoreColor = scoreVal >= 0.7 ? render.c.green : scoreVal >= 0.4 ? render.c.yellow : render.c.dim;
|
|
7248
|
+
const durability = r.durability ?? "standard";
|
|
7249
|
+
const metaParts = [];
|
|
7250
|
+
if (date)
|
|
7251
|
+
metaParts.push(render.wrap(render.c.dim, date));
|
|
7252
|
+
metaParts.push(render.wrap(durabilityColor(durability), durability));
|
|
7253
|
+
if (scorePct)
|
|
7254
|
+
metaParts.push(render.wrap(scoreColor, scorePct));
|
|
7255
|
+
if (r._source)
|
|
7256
|
+
metaParts.push(render.wrap(render.c.cyan, `from:${r._source}`));
|
|
7257
|
+
const meta = metaParts.join(render.wrap(render.c.dim, " · "));
|
|
6082
7258
|
console.log(` ${r.content}`);
|
|
6083
7259
|
if (meta)
|
|
6084
|
-
console.log(` (${meta})`);
|
|
7260
|
+
console.log(` ${render.wrap(render.c.dim, "(")} ${meta} ${render.wrap(render.c.dim, ")")}`);
|
|
7261
|
+
if (opts.explain) {
|
|
7262
|
+
const parts = [];
|
|
7263
|
+
if (typeof r._rawScore === "number")
|
|
7264
|
+
parts.push(`raw=${r._rawScore.toFixed(3)}`);
|
|
7265
|
+
if (typeof r._score === "number")
|
|
7266
|
+
parts.push(`composite=${r._score.toFixed(3)}`);
|
|
7267
|
+
if (typeof r.retrievalCount === "number" && r.retrievalCount > 0)
|
|
7268
|
+
parts.push(`retrievals=${r.retrievalCount}`);
|
|
7269
|
+
if (r.tags && Array.isArray(r.tags) && r.tags.length > 0)
|
|
7270
|
+
parts.push(`tags=[${r.tags.join(",")}]`);
|
|
7271
|
+
if (r.subject)
|
|
7272
|
+
parts.push(`subject=${r.subject}`);
|
|
7273
|
+
if (r.supersedes)
|
|
7274
|
+
parts.push(`supersedes=${r.supersedes}`);
|
|
7275
|
+
if (parts.length > 0) {
|
|
7276
|
+
console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, parts.join(" · "))}`);
|
|
7277
|
+
}
|
|
7278
|
+
}
|
|
6085
7279
|
console.log();
|
|
6086
7280
|
}
|
|
7281
|
+
if (opts.explain) {
|
|
7282
|
+
const formula = payload.scoring === "composite"
|
|
7283
|
+
? "semantic × durability-weight × recency-decay × retrieval-boost"
|
|
7284
|
+
: "cosine similarity only";
|
|
7285
|
+
console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, payload.scoring)} ${render.wrap(render.c.dim, `(${formula})`)}`);
|
|
7286
|
+
}
|
|
6087
7287
|
}
|
|
6088
7288
|
catch (err) {
|
|
6089
|
-
console.error(
|
|
7289
|
+
console.error(`${render.icons.error} Search failed: ${err.message}`);
|
|
6090
7290
|
process.exit(1);
|
|
6091
7291
|
}
|
|
6092
7292
|
});
|
|
@@ -6108,58 +7308,179 @@ program
|
|
|
6108
7308
|
program
|
|
6109
7309
|
.command("bootstrap")
|
|
6110
7310
|
.description("Cold-start context: get soul + recent memories as formatted text")
|
|
6111
|
-
.
|
|
7311
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
6112
7312
|
.option("--max-tokens <n>", "Maximum tokens in output", "4000")
|
|
6113
7313
|
.option("--port <port>", "Harper HTTP port")
|
|
6114
7314
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
7315
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
6115
7316
|
.option("--key <path>", "Ed25519 private key path")
|
|
7317
|
+
.option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
|
|
6116
7318
|
.action(async (opts) => {
|
|
6117
|
-
const
|
|
7319
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
7320
|
+
if (!agentId) {
|
|
7321
|
+
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
7322
|
+
process.exit(2);
|
|
7323
|
+
}
|
|
7324
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
7325
|
+
const mode = render.resolveOutputMode(opts);
|
|
6118
7326
|
try {
|
|
6119
7327
|
const headers = { "content-type": "application/json" };
|
|
6120
|
-
const keyPath = opts.key || resolveKeyPath(
|
|
7328
|
+
const keyPath = opts.key || resolveKeyPath(agentId);
|
|
6121
7329
|
if (keyPath) {
|
|
6122
|
-
headers["authorization"] = buildEd25519Auth(
|
|
7330
|
+
headers["authorization"] = buildEd25519Auth(agentId, "POST", "/BootstrapMemories", keyPath);
|
|
6123
7331
|
}
|
|
6124
7332
|
const res = await fetch(`${baseUrl}/BootstrapMemories`, {
|
|
6125
7333
|
method: "POST",
|
|
6126
7334
|
headers,
|
|
6127
|
-
body: JSON.stringify({ agentId
|
|
7335
|
+
body: JSON.stringify({ agentId, maxTokens: parseInt(opts.maxTokens, 10) }),
|
|
6128
7336
|
});
|
|
6129
7337
|
if (!res.ok) {
|
|
6130
7338
|
const body = await res.text();
|
|
6131
7339
|
throw new Error(`${res.status}: ${body}`);
|
|
6132
7340
|
}
|
|
6133
|
-
const result = await res.json();
|
|
7341
|
+
const result = (await res.json());
|
|
7342
|
+
if (mode === "json") {
|
|
7343
|
+
// Agent-first: emit the full server response, augmented with the cap
|
|
7344
|
+
// that was requested. Includes context, sections, tokenEstimate, etc.
|
|
7345
|
+
console.log(render.asJSON({ ...result, maxTokens: parseInt(opts.maxTokens, 10) }));
|
|
7346
|
+
return;
|
|
7347
|
+
}
|
|
7348
|
+
// Human mode: print context to stdout, budget footer to stderr (parseable).
|
|
6134
7349
|
if (result.context) {
|
|
6135
7350
|
console.log(result.context);
|
|
6136
7351
|
}
|
|
6137
7352
|
else {
|
|
6138
|
-
console.error(
|
|
7353
|
+
console.error(`${render.icons.error} No context available.`);
|
|
6139
7354
|
process.exit(1);
|
|
6140
7355
|
}
|
|
6141
|
-
// Print budget footer to stderr (parseable, won't interfere with context output)
|
|
6142
7356
|
const tokensUsed = result.tokenEstimate ?? 0;
|
|
6143
7357
|
const maxTokens = parseInt(opts.maxTokens, 10);
|
|
6144
7358
|
const included = result.memoriesIncluded ?? 0;
|
|
6145
7359
|
const truncated = result.memoriesTruncated ?? 0;
|
|
6146
|
-
|
|
7360
|
+
const tokenPct = maxTokens > 0 ? (tokensUsed / maxTokens) * 100 : 0;
|
|
7361
|
+
const tokenIcon = tokenPct >= 90 ? render.icons.warn : tokenPct >= 70 ? render.icons.info : render.icons.ok;
|
|
7362
|
+
const truncIcon = truncated > 0 ? render.icons.warn : render.icons.ok;
|
|
7363
|
+
console.error(`${tokenIcon} budget ${tokensUsed}/${maxTokens} tokens (${tokenPct.toFixed(0)}%) ${render.icons.bullet} ${render.icons.ok} ${included} included ${render.icons.bullet} ${truncIcon} ${truncated} truncated`);
|
|
6147
7364
|
}
|
|
6148
7365
|
catch (err) {
|
|
6149
|
-
console.error(
|
|
7366
|
+
console.error(`${render.icons.error} Bootstrap failed: ${err.message}`);
|
|
6150
7367
|
process.exit(1);
|
|
6151
7368
|
}
|
|
6152
7369
|
});
|
|
6153
7370
|
const soul = program.command("soul").description("Manage agent soul entries");
|
|
6154
|
-
soul.command("set")
|
|
7371
|
+
soul.command("set")
|
|
7372
|
+
.requiredOption("--agent <id>")
|
|
7373
|
+
.requiredOption("--key <key>")
|
|
7374
|
+
.requiredOption("--value <value>")
|
|
6155
7375
|
.option("--durability <d>", "permanent")
|
|
7376
|
+
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
6156
7377
|
.action(async (opts) => {
|
|
6157
|
-
const out = await api("POST", "/Soul", {
|
|
6158
|
-
|
|
7378
|
+
const out = await api("POST", "/Soul", {
|
|
7379
|
+
id: `${opts.agent}:${opts.key}`,
|
|
7380
|
+
agentId: opts.agent,
|
|
7381
|
+
key: opts.key,
|
|
7382
|
+
value: opts.value,
|
|
7383
|
+
durability: opts.durability,
|
|
7384
|
+
});
|
|
7385
|
+
const mode = render.resolveOutputMode(opts);
|
|
7386
|
+
if (mode === "json") {
|
|
7387
|
+
console.log(render.asJSON(out));
|
|
7388
|
+
return;
|
|
7389
|
+
}
|
|
7390
|
+
console.log(`${render.icons.ok} ${render.wrap(render.c.green, "soul entry set")}`);
|
|
7391
|
+
console.log(render.kv("agent", opts.agent));
|
|
7392
|
+
console.log(render.kv("key", render.wrap(render.c.bold, opts.key)));
|
|
7393
|
+
console.log(render.kv("value", String(opts.value)));
|
|
7394
|
+
if (opts.durability)
|
|
7395
|
+
console.log(render.kv("durability", render.wrap(render.c.magenta, opts.durability)));
|
|
7396
|
+
});
|
|
7397
|
+
soul.command("get")
|
|
7398
|
+
.argument("<id>")
|
|
7399
|
+
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
7400
|
+
.action(async (id, opts) => {
|
|
7401
|
+
const out = await api("GET", `/Soul/${id}`);
|
|
7402
|
+
const mode = render.resolveOutputMode(opts);
|
|
7403
|
+
if (mode === "json") {
|
|
7404
|
+
console.log(render.asJSON(out));
|
|
7405
|
+
return;
|
|
7406
|
+
}
|
|
7407
|
+
if (!out || (typeof out === "object" && !out.id)) {
|
|
7408
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no entry")}`);
|
|
7409
|
+
return;
|
|
7410
|
+
}
|
|
7411
|
+
console.log(render.wrap(render.c.bold, out.id ?? id));
|
|
7412
|
+
if (out.agentId)
|
|
7413
|
+
console.log(render.kv("agent", out.agentId));
|
|
7414
|
+
if (out.key)
|
|
7415
|
+
console.log(render.kv("key", out.key));
|
|
7416
|
+
if (out.value !== undefined)
|
|
7417
|
+
console.log(render.kv("value", String(out.value)));
|
|
7418
|
+
if (out.durability)
|
|
7419
|
+
console.log(render.kv("durability", render.wrap(render.c.magenta, String(out.durability))));
|
|
7420
|
+
if (out.priority)
|
|
7421
|
+
console.log(render.kv("priority", String(out.priority)));
|
|
7422
|
+
if (out.createdAt)
|
|
7423
|
+
console.log(render.kv("created", `${render.relativeTime(out.createdAt)} ${render.wrap(render.c.dim, `(${out.createdAt})`)}`));
|
|
7424
|
+
if (out.updatedAt && out.updatedAt !== out.createdAt) {
|
|
7425
|
+
console.log(render.kv("updated", `${render.relativeTime(out.updatedAt)} ${render.wrap(render.c.dim, `(${out.updatedAt})`)}`));
|
|
7426
|
+
}
|
|
7427
|
+
});
|
|
7428
|
+
soul.command("list")
|
|
7429
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
7430
|
+
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
7431
|
+
.action(async (opts) => {
|
|
7432
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
7433
|
+
if (!agentId) {
|
|
7434
|
+
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
7435
|
+
process.exit(2);
|
|
7436
|
+
}
|
|
7437
|
+
const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`);
|
|
7438
|
+
const mode = render.resolveOutputMode(opts);
|
|
7439
|
+
if (mode === "json") {
|
|
7440
|
+
console.log(render.asJSON(out));
|
|
7441
|
+
return;
|
|
7442
|
+
}
|
|
7443
|
+
const all = Array.isArray(out) ? out : (out?.results ?? out?.items ?? []);
|
|
7444
|
+
if (all.length === 0) {
|
|
7445
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no soul entries for agent ${agentId}`)}`);
|
|
7446
|
+
return;
|
|
7447
|
+
}
|
|
7448
|
+
console.log(`${render.wrap(render.c.bold, String(all.length))} soul entries for agent ${render.wrap(render.c.bold, agentId)}\n`);
|
|
7449
|
+
const priorityColor = (p) => {
|
|
7450
|
+
if (p === "critical")
|
|
7451
|
+
return render.c.red;
|
|
7452
|
+
if (p === "high")
|
|
7453
|
+
return render.c.yellow;
|
|
7454
|
+
if (p === "low")
|
|
7455
|
+
return render.c.gray;
|
|
7456
|
+
return render.c.cyan;
|
|
7457
|
+
};
|
|
7458
|
+
const cols = [
|
|
7459
|
+
{ label: "key", key: "key", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
|
|
7460
|
+
{
|
|
7461
|
+
label: "priority",
|
|
7462
|
+
key: "priority",
|
|
7463
|
+
format: (v) => {
|
|
7464
|
+
const p = String(v ?? "standard");
|
|
7465
|
+
return render.wrap(priorityColor(p), p);
|
|
7466
|
+
},
|
|
7467
|
+
},
|
|
7468
|
+
{
|
|
7469
|
+
label: "durability",
|
|
7470
|
+
key: "durability",
|
|
7471
|
+
format: (v) => {
|
|
7472
|
+
const d = String(v ?? "—");
|
|
7473
|
+
return d === "permanent" ? render.wrap(render.c.magenta, d) : render.wrap(render.c.dim, d);
|
|
7474
|
+
},
|
|
7475
|
+
},
|
|
7476
|
+
{
|
|
7477
|
+
label: "value",
|
|
7478
|
+
key: "value",
|
|
7479
|
+
format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
|
|
7480
|
+
},
|
|
7481
|
+
];
|
|
7482
|
+
console.log(render.table(cols, all));
|
|
6159
7483
|
});
|
|
6160
|
-
soul.command("get").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Soul/${id}`), null, 2)));
|
|
6161
|
-
soul.command("list").requiredOption("--agent <id>")
|
|
6162
|
-
.action(async (opts) => console.log(JSON.stringify(await api("GET", `/Soul?agentId=${encodeURIComponent(opts.agent)}`), null, 2)));
|
|
6163
7484
|
// ─── flair bridge ────────────────────────────────────────────────────────────
|
|
6164
7485
|
// Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
|
|
6165
7486
|
// + agentic-stack reference adapter as a built-in.
|
|
@@ -6174,24 +7495,39 @@ bridge
|
|
|
6174
7495
|
const { discover } = await import("./bridges/discover.js");
|
|
6175
7496
|
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
6176
7497
|
const found = await discover({ builtins: builtinDiscoveryRecords() });
|
|
6177
|
-
|
|
6178
|
-
|
|
7498
|
+
const mode = render.resolveOutputMode(opts);
|
|
7499
|
+
if (mode === "json") {
|
|
7500
|
+
console.log(render.asJSON(found));
|
|
6179
7501
|
return;
|
|
6180
7502
|
}
|
|
6181
7503
|
if (found.length === 0) {
|
|
6182
|
-
console.log("No bridges installed.");
|
|
6183
|
-
console.log("Add one with:
|
|
6184
|
-
console.log("Or install from npm:
|
|
7504
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No bridges installed.")}`);
|
|
7505
|
+
console.log(`${render.wrap(render.c.dim, " Add one with:")} flair bridge scaffold <name> --file`);
|
|
7506
|
+
console.log(`${render.wrap(render.c.dim, " Or install from npm:")} npm install flair-bridge-<name>`);
|
|
6185
7507
|
return;
|
|
6186
7508
|
}
|
|
6187
|
-
|
|
6188
|
-
const
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
7509
|
+
console.log(`${render.wrap(render.c.bold, String(found.length))} bridge${found.length === 1 ? "" : "s"}\n`);
|
|
7510
|
+
const cols = [
|
|
7511
|
+
{ label: "name", key: "name", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
|
|
7512
|
+
{
|
|
7513
|
+
label: "kind",
|
|
7514
|
+
key: "kind",
|
|
7515
|
+
format: (v) => {
|
|
7516
|
+
const k = String(v ?? "—");
|
|
7517
|
+
return render.wrap(k === "yaml" ? render.c.cyan : k === "api" ? render.c.magenta : render.c.dim, k);
|
|
7518
|
+
},
|
|
7519
|
+
},
|
|
7520
|
+
{
|
|
7521
|
+
label: "source",
|
|
7522
|
+
key: "source",
|
|
7523
|
+
format: (v) => {
|
|
7524
|
+
const s = String(v ?? "—");
|
|
7525
|
+
return render.wrap(s === "builtin" ? render.c.green : render.c.dim, s);
|
|
7526
|
+
},
|
|
7527
|
+
},
|
|
7528
|
+
{ label: "description", key: "description", format: (v) => String(v ?? "") },
|
|
7529
|
+
];
|
|
7530
|
+
console.log(render.table(cols, found));
|
|
6195
7531
|
});
|
|
6196
7532
|
bridge
|
|
6197
7533
|
.command("scaffold <name>")
|
|
@@ -6629,21 +7965,22 @@ bridge
|
|
|
6629
7965
|
.action(async (opts) => {
|
|
6630
7966
|
const { list: listAllowed } = await import("./bridges/runtime/allow-list.js");
|
|
6631
7967
|
const entries = await listAllowed();
|
|
6632
|
-
|
|
6633
|
-
|
|
7968
|
+
const mode = render.resolveOutputMode(opts);
|
|
7969
|
+
if (mode === "json") {
|
|
7970
|
+
console.log(render.asJSON(entries));
|
|
6634
7971
|
return;
|
|
6635
7972
|
}
|
|
6636
7973
|
if (entries.length === 0) {
|
|
6637
|
-
console.log("No code-plugin bridges are allow-listed yet.");
|
|
6638
|
-
console.log("Allow one with: flair bridge allow <name
|
|
7974
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, "No code-plugin bridges are allow-listed yet.")}`);
|
|
7975
|
+
console.log(`${render.wrap(render.c.dim, " Allow one with:")} flair bridge allow <name>`);
|
|
6639
7976
|
return;
|
|
6640
7977
|
}
|
|
6641
|
-
|
|
6642
|
-
const verW = Math.max(7, ...entries.map((e) => (e.version ?? "—").length));
|
|
6643
|
-
console.log(` ${"name".padEnd(nameW)} ${"version".padEnd(verW)} allowed-at location / digest`);
|
|
7978
|
+
console.log(`${render.wrap(render.c.bold, String(entries.length))} allow-listed code-plugin bridge${entries.length === 1 ? "" : "s"}\n`);
|
|
6644
7979
|
for (const e of entries) {
|
|
6645
|
-
console.log(
|
|
6646
|
-
console.log(
|
|
7980
|
+
console.log(`${render.wrap(render.c.bold, e.name)} ${render.wrap(render.c.dim, `(${e.version ?? "—"})`)} ${render.wrap(render.c.green, "✓ allowed")} ${render.wrap(render.c.dim, e.allowedAt)}`);
|
|
7981
|
+
console.log(render.kv("location", render.wrap(render.c.dim, e.packageDir)));
|
|
7982
|
+
console.log(render.kv("digest", render.wrap(render.c.dim, `sha256:${e.packageJsonSha256.slice(0, 16)}…`)));
|
|
7983
|
+
console.log();
|
|
6647
7984
|
}
|
|
6648
7985
|
});
|
|
6649
7986
|
function printBridgeError(err) {
|
|
@@ -6741,13 +8078,26 @@ program
|
|
|
6741
8078
|
.option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
|
|
6742
8079
|
.option("--port <port>", "Harper HTTP port")
|
|
6743
8080
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
6744
|
-
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
8081
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
|
|
8082
|
+
.option("--admin-pass-file <path>", "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
|
|
6745
8083
|
.action(async (opts) => {
|
|
6746
8084
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
6747
|
-
|
|
8085
|
+
let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
8086
|
+
if (!adminPass && opts.adminPassFile) {
|
|
8087
|
+
// readAdminPassFileSecure refuses world/group readable files (mode 0600
|
|
8088
|
+
// recommended). Common gotcha: files generated via
|
|
8089
|
+
// `openssl rand -base64 24 > admin-pass` end in a newline; helper trims it.
|
|
8090
|
+
try {
|
|
8091
|
+
adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
8092
|
+
}
|
|
8093
|
+
catch (err) {
|
|
8094
|
+
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
8095
|
+
process.exit(1);
|
|
8096
|
+
}
|
|
8097
|
+
}
|
|
6748
8098
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
6749
8099
|
if (!adminPass) {
|
|
6750
|
-
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for backup");
|
|
8100
|
+
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
|
|
6751
8101
|
process.exit(1);
|
|
6752
8102
|
}
|
|
6753
8103
|
const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
@@ -6806,11 +8156,11 @@ program
|
|
|
6806
8156
|
const tmp = outputPath + ".tmp";
|
|
6807
8157
|
writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
|
|
6808
8158
|
renameSync(tmp, outputPath);
|
|
6809
|
-
console.log(`\n
|
|
6810
|
-
console.log(
|
|
6811
|
-
console.log(
|
|
6812
|
-
console.log(
|
|
6813
|
-
console.log(
|
|
8159
|
+
console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
|
|
8160
|
+
console.log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
|
|
8161
|
+
console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
|
|
8162
|
+
console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
|
|
8163
|
+
console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
|
|
6814
8164
|
});
|
|
6815
8165
|
// ─── flair restore ────────────────────────────────────────────────────────────
|
|
6816
8166
|
program
|
|
@@ -6924,10 +8274,10 @@ program
|
|
|
6924
8274
|
console.warn(` warn: soul ${soul.id}: ${err.message}`);
|
|
6925
8275
|
}
|
|
6926
8276
|
}
|
|
6927
|
-
console.log(`\n
|
|
6928
|
-
console.log(
|
|
6929
|
-
console.log(
|
|
6930
|
-
console.log(
|
|
8277
|
+
console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Restore complete")}`);
|
|
8278
|
+
console.log(render.kv("Agents restored", `${render.wrap(render.c.bold, String(agentCount))}${render.wrap(render.c.dim, `/${agents.length}`)}`));
|
|
8279
|
+
console.log(render.kv("Memories restored", `${render.wrap(render.c.bold, String(memoryCount))}${render.wrap(render.c.dim, `/${memories.length}`)}`));
|
|
8280
|
+
console.log(render.kv("Souls restored", `${render.wrap(render.c.bold, String(soulCount))}${render.wrap(render.c.dim, `/${souls.length}`)}`));
|
|
6931
8281
|
});
|
|
6932
8282
|
// ─── flair export ────────────────────────────────────────────────────────────
|
|
6933
8283
|
program
|
|
@@ -7009,13 +8359,16 @@ program
|
|
|
7009
8359
|
writeFileSync(outputPath, JSON.stringify(exportData, null, 2), { mode: fileMode });
|
|
7010
8360
|
if (privateKey)
|
|
7011
8361
|
chmodSync(outputPath, 0o600); // enforce even if umask is permissive
|
|
7012
|
-
console.log(`\n
|
|
7013
|
-
console.log(
|
|
7014
|
-
console.log(
|
|
7015
|
-
console.log(
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
8362
|
+
console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, `Agent '${agentId}' exported`)}`);
|
|
8363
|
+
console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
|
|
8364
|
+
console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
|
|
8365
|
+
console.log(render.kv("Grants", render.wrap(render.c.bold, String(grants.length))));
|
|
8366
|
+
const keyText = privateKey
|
|
8367
|
+
? `${render.wrap(render.c.magenta, "included")} ${render.wrap(render.c.red, "(UNENCRYPTED — protect this file)")}`
|
|
8368
|
+
: render.wrap(render.c.dim, "not included");
|
|
8369
|
+
console.log(render.kv("Key", keyText));
|
|
8370
|
+
console.log(render.kv("Mode", `${fileMode.toString(8)} ${render.wrap(render.c.dim, `(${privateKey ? "owner-only" : "standard"})`)}`));
|
|
8371
|
+
console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
|
|
7019
8372
|
});
|
|
7020
8373
|
// ─── flair import ────────────────────────────────────────────────────────────
|
|
7021
8374
|
program
|
|
@@ -7106,40 +8459,50 @@ program
|
|
|
7106
8459
|
}
|
|
7107
8460
|
catch { /* skip failures */ }
|
|
7108
8461
|
}
|
|
7109
|
-
console.log(`\n
|
|
7110
|
-
console.log(
|
|
7111
|
-
console.log(
|
|
7112
|
-
console.log(
|
|
8462
|
+
console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, `Agent '${agentId}' imported`)}`);
|
|
8463
|
+
console.log(render.kv("Memories", `${render.wrap(render.c.bold, String(memCount))}${render.wrap(render.c.dim, `/${(data.memories ?? []).length}`)}`));
|
|
8464
|
+
console.log(render.kv("Souls", `${render.wrap(render.c.bold, String(soulCount))}${render.wrap(render.c.dim, `/${(data.souls ?? []).length}`)}`));
|
|
8465
|
+
console.log(render.kv("Key", render.wrap(render.c.dim, privPath)));
|
|
7113
8466
|
});
|
|
7114
8467
|
// ─── flair backup inspect ────────────────────────────────────────────────────
|
|
7115
8468
|
program
|
|
7116
8469
|
.command("inspect <path>")
|
|
7117
8470
|
.description("Show contents of a backup or export file")
|
|
7118
|
-
.
|
|
8471
|
+
.option("--json", "Emit raw JSON of the file (also: pipe + FLAIR_OUTPUT=json)")
|
|
8472
|
+
.action(async (filePath, opts) => {
|
|
7119
8473
|
if (!existsSync(filePath)) {
|
|
7120
|
-
console.error(
|
|
8474
|
+
console.error(`${render.icons.error} File not found: ${render.wrap(render.c.dim, filePath)}`);
|
|
7121
8475
|
process.exit(1);
|
|
7122
8476
|
}
|
|
7123
8477
|
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
8478
|
+
const mode = render.resolveOutputMode(opts);
|
|
8479
|
+
if (mode === "json") {
|
|
8480
|
+
console.log(render.asJSON(data));
|
|
8481
|
+
return;
|
|
8482
|
+
}
|
|
8483
|
+
console.log(`${render.wrap(render.c.bold, "File:")} ${render.wrap(render.c.dim, filePath)}`);
|
|
8484
|
+
const type = data.type ?? "full-backup";
|
|
8485
|
+
const typeColor = type === "agent-export" ? render.c.cyan : render.c.magenta;
|
|
8486
|
+
console.log(render.kv("Type", render.wrap(typeColor, type)));
|
|
8487
|
+
console.log(render.kv("Created", String(data.createdAt ?? data.exportedAt ?? render.wrap(render.c.dim, "unknown"))));
|
|
8488
|
+
console.log(render.kv("Source", String(data.source ?? render.wrap(render.c.dim, "unknown"))));
|
|
7128
8489
|
if (data.type === "agent-export") {
|
|
7129
|
-
console.log(`\
|
|
7130
|
-
console.log(
|
|
7131
|
-
console.log(
|
|
7132
|
-
console.log(
|
|
7133
|
-
console.log(
|
|
7134
|
-
|
|
8490
|
+
console.log(`\n${render.wrap(render.c.bold, "Agent")}: ${render.wrap(render.c.bold, data.agent?.id ?? "unknown")}`);
|
|
8491
|
+
console.log(render.kv("Name", String(data.agent?.name ?? data.agent?.id ?? "—")));
|
|
8492
|
+
console.log(render.kv("Memories", render.wrap(render.c.bold, String((data.memories ?? []).length))));
|
|
8493
|
+
console.log(render.kv("Souls", render.wrap(render.c.bold, String((data.souls ?? []).length))));
|
|
8494
|
+
console.log(render.kv("Grants", render.wrap(render.c.bold, String((data.grants ?? []).length))));
|
|
8495
|
+
const keyText = data.privateKey ? render.wrap(render.c.magenta, "yes") : render.wrap(render.c.dim, "no");
|
|
8496
|
+
console.log(render.kv("Key included", keyText));
|
|
7135
8497
|
}
|
|
7136
8498
|
else {
|
|
7137
8499
|
const agents = data.agents ?? [];
|
|
7138
|
-
console.log(`\
|
|
7139
|
-
for (const a of agents)
|
|
7140
|
-
console.log(`
|
|
7141
|
-
|
|
7142
|
-
console.log(
|
|
8500
|
+
console.log(`\n${render.wrap(render.c.bold, "Agents")}: ${render.wrap(render.c.bold, String(agents.length))}`);
|
|
8501
|
+
for (const a of agents) {
|
|
8502
|
+
console.log(` ${render.wrap(render.c.dim, "·")} ${render.wrap(render.c.bold, a.id)} ${render.wrap(render.c.dim, `(${a.name ?? a.id})`)}`);
|
|
8503
|
+
}
|
|
8504
|
+
console.log(render.kv("Memories", render.wrap(render.c.bold, String((data.memories ?? []).length))));
|
|
8505
|
+
console.log(render.kv("Souls", render.wrap(render.c.bold, String((data.souls ?? []).length))));
|
|
7143
8506
|
}
|
|
7144
8507
|
});
|
|
7145
8508
|
// ─── flair migrate-harness-memory ───────────────────────────────────────────
|