@tpsdev-ai/flair 0.4.16 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/cli.js +1149 -89
- package/dist/keystore.js +116 -0
- package/dist/resources/AdminConnectors.js +54 -0
- package/dist/resources/AdminDashboard.js +68 -0
- package/dist/resources/AdminIdp.js +57 -0
- package/dist/resources/AdminInstance.js +64 -0
- package/dist/resources/AdminMemory.js +92 -0
- package/dist/resources/AdminPrincipals.js +71 -0
- package/dist/resources/Agent.js +49 -2
- package/dist/resources/Credential.js +105 -0
- package/dist/resources/Federation.js +315 -0
- package/dist/resources/Memory.js +11 -0
- package/dist/resources/MemoryBootstrap.js +91 -4
- package/dist/resources/OAuth.js +381 -0
- package/dist/resources/Relationship.js +96 -0
- package/dist/resources/SemanticSearch.js +20 -4
- package/dist/resources/XAA.js +264 -0
- package/dist/resources/admin-layout.js +94 -0
- package/dist/resources/auth-middleware.js +39 -21
- package/dist/resources/embeddings-provider.js +33 -30
- package/dist/resources/federation-crypto.js +52 -0
- package/dist/resources/health.js +67 -1
- package/dist/src/keystore.js +116 -0
- package/package.json +7 -3
- package/schemas/agent.graphql +51 -2
- package/schemas/federation.graphql +51 -0
- package/schemas/memory.graphql +20 -0
- package/schemas/oauth.graphql +72 -0
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,8 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { join, resolve as resolvePath } from "node:path";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
|
|
9
|
+
import { keystore } from "./keystore.js";
|
|
10
|
+
import { signBody } from "../resources/federation-crypto.js";
|
|
9
11
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
10
12
|
const DEFAULT_PORT = 19926;
|
|
11
13
|
const DEFAULT_OPS_PORT = 19925;
|
|
@@ -127,6 +129,11 @@ async function api(method, path, body) {
|
|
|
127
129
|
if (token) {
|
|
128
130
|
authHeader = `Bearer ${token}`;
|
|
129
131
|
}
|
|
132
|
+
else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
|
|
133
|
+
// Admin Basic auth — used by federation, backup, and other admin CLI commands
|
|
134
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
135
|
+
authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
136
|
+
}
|
|
130
137
|
else {
|
|
131
138
|
// Extract agentId from body (POST/PUT) or URL query params (GET)
|
|
132
139
|
let agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
|
|
@@ -357,14 +364,37 @@ program
|
|
|
357
364
|
// Only applied to install — run needs real HOME for npm/node resolution.
|
|
358
365
|
const installEnv = { ...env, HOME: join(dataDir, "..") };
|
|
359
366
|
console.log("Installing Harper...");
|
|
367
|
+
console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
|
|
360
368
|
await new Promise((resolve, reject) => {
|
|
361
369
|
let output = "";
|
|
370
|
+
let dotTimer = null;
|
|
362
371
|
const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
|
|
372
|
+
// Print progress dots so the terminal doesn't appear frozen during model download
|
|
373
|
+
dotTimer = setInterval(() => process.stdout.write("."), 3000);
|
|
363
374
|
install.stdout?.on("data", (d) => { output += d.toString(); });
|
|
364
375
|
install.stderr?.on("data", (d) => { output += d.toString(); });
|
|
365
|
-
install.on("exit", (code) =>
|
|
366
|
-
|
|
367
|
-
|
|
376
|
+
install.on("exit", (code) => {
|
|
377
|
+
if (dotTimer) {
|
|
378
|
+
clearInterval(dotTimer);
|
|
379
|
+
process.stdout.write("\n");
|
|
380
|
+
}
|
|
381
|
+
code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
|
|
382
|
+
});
|
|
383
|
+
install.on("error", (err) => {
|
|
384
|
+
if (dotTimer) {
|
|
385
|
+
clearInterval(dotTimer);
|
|
386
|
+
process.stdout.write("\n");
|
|
387
|
+
}
|
|
388
|
+
reject(err);
|
|
389
|
+
});
|
|
390
|
+
setTimeout(() => {
|
|
391
|
+
install.kill();
|
|
392
|
+
if (dotTimer) {
|
|
393
|
+
clearInterval(dotTimer);
|
|
394
|
+
process.stdout.write("\n");
|
|
395
|
+
}
|
|
396
|
+
reject(new Error(`Harper install timed out: ${output}`));
|
|
397
|
+
}, 60_000);
|
|
368
398
|
});
|
|
369
399
|
}
|
|
370
400
|
// Start Harper with flair loaded as a component (the "." arg).
|
|
@@ -490,7 +520,25 @@ program
|
|
|
490
520
|
console.log("\n🎭 Set up agent personality (press Enter to skip any):\n");
|
|
491
521
|
const { createInterface } = await import("node:readline");
|
|
492
522
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
493
|
-
|
|
523
|
+
// Buffered ask: collects rapid input (pasted text) into one answer.
|
|
524
|
+
// Waits 200ms after last line before resolving, so pasted multiline
|
|
525
|
+
// blocks are captured as a single answer instead of spilling across prompts.
|
|
526
|
+
const ask = (q) => new Promise(resolve => {
|
|
527
|
+
let buffer = "";
|
|
528
|
+
let timer = null;
|
|
529
|
+
const finish = () => {
|
|
530
|
+
rl.removeListener("line", onLine);
|
|
531
|
+
resolve(buffer.trim());
|
|
532
|
+
};
|
|
533
|
+
const onLine = (line) => {
|
|
534
|
+
buffer += (buffer ? "\n" : "") + line;
|
|
535
|
+
if (timer)
|
|
536
|
+
clearTimeout(timer);
|
|
537
|
+
timer = setTimeout(finish, 200);
|
|
538
|
+
};
|
|
539
|
+
process.stdout.write(q);
|
|
540
|
+
rl.on("line", onLine);
|
|
541
|
+
});
|
|
494
542
|
const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
|
|
495
543
|
const project = await ask(" What project is it working on?\n > ");
|
|
496
544
|
const standards = await ask(" Any coding standards or preferences?\n > ");
|
|
@@ -520,6 +568,25 @@ program
|
|
|
520
568
|
console.log("\n No soul entries — you can add them later with: flair soul set --agent " + agentId + " --key role --value \"...\"");
|
|
521
569
|
}
|
|
522
570
|
}
|
|
571
|
+
else {
|
|
572
|
+
// --skip-soul or non-interactive: seed sensible defaults so bootstrap returns useful context
|
|
573
|
+
const defaultSoulEntries = [
|
|
574
|
+
["role", "AI assistant [default — customize with 'flair soul set']"],
|
|
575
|
+
["personality", "Helpful, precise, and proactive [default — customize with 'flair soul set']"],
|
|
576
|
+
["constraints", "Respect user privacy. Be concise. [default — customize with 'flair soul set']"],
|
|
577
|
+
];
|
|
578
|
+
console.log("\nSeeding default soul entries...");
|
|
579
|
+
for (const [key, value] of defaultSoulEntries) {
|
|
580
|
+
try {
|
|
581
|
+
await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
|
|
582
|
+
console.log(` ✓ soul:${key} set (default)`);
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
console.warn(` ⚠ soul:${key} failed: ${err.message}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
console.log(` Customize with: flair soul set --agent ${agentId} --key role --value "..."`);
|
|
589
|
+
}
|
|
523
590
|
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
524
591
|
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
525
592
|
// Auto-wire MCP config into ~/.claude.json if Claude Code is installed
|
|
@@ -822,6 +889,373 @@ agent
|
|
|
822
889
|
}
|
|
823
890
|
console.log(`\n✅ Agent '${id}' removed successfully`);
|
|
824
891
|
});
|
|
892
|
+
// ─── flair principal ─────────────────────────────────────────────────────────
|
|
893
|
+
// 1.0 identity management. The Principal model extends Agent — this is the
|
|
894
|
+
// preferred CLI surface for managing identities going forward.
|
|
895
|
+
const principal = program.command("principal").description("Manage principals (humans and agents)");
|
|
896
|
+
principal
|
|
897
|
+
.command("add <id>")
|
|
898
|
+
.description("Create a new principal")
|
|
899
|
+
.option("--kind <kind>", "Principal kind: human or agent", "agent")
|
|
900
|
+
.option("--name <name>", "Display name (defaults to id)")
|
|
901
|
+
.option("--admin", "Grant admin privileges")
|
|
902
|
+
.option("--trust <tier>", "Default trust tier: endorsed, corroborated, or unverified")
|
|
903
|
+
.option("--runtime <runtime>", "Runtime: openclaw, claude-code, headless, external")
|
|
904
|
+
.option("--port <port>", "Harper HTTP port")
|
|
905
|
+
.option("--admin-pass <pass>", "Admin password for registration")
|
|
906
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
907
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
908
|
+
.action(async (id, opts) => {
|
|
909
|
+
const opsPort = resolveOpsPort(opts);
|
|
910
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
911
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS;
|
|
912
|
+
const adminUser = DEFAULT_ADMIN_USER;
|
|
913
|
+
const kind = opts.kind ?? "agent";
|
|
914
|
+
const name = opts.name ?? id;
|
|
915
|
+
const isAdmin = opts.admin ?? false;
|
|
916
|
+
const trustTier = opts.trust ?? (isAdmin ? "endorsed" : "unverified");
|
|
917
|
+
const runtime = opts.runtime;
|
|
918
|
+
if (!adminPass) {
|
|
919
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
// Generate Ed25519 keypair (agents always get one; humans get one for instance-attestation)
|
|
923
|
+
mkdirSync(keysDir, { recursive: true });
|
|
924
|
+
const privPath = privKeyPath(id, keysDir);
|
|
925
|
+
const pubPath = pubKeyPath(id, keysDir);
|
|
926
|
+
let pubKeyB64url;
|
|
927
|
+
if (existsSync(privPath)) {
|
|
928
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
929
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
930
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
931
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
const kp = nacl.sign.keyPair();
|
|
935
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
936
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
937
|
+
chmodSync(privPath, 0o600);
|
|
938
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
939
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
940
|
+
console.log(`Keypair written: ${privPath}`);
|
|
941
|
+
}
|
|
942
|
+
// Insert via operations API with Principal fields
|
|
943
|
+
const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
944
|
+
const record = {
|
|
945
|
+
id,
|
|
946
|
+
name,
|
|
947
|
+
displayName: name,
|
|
948
|
+
kind,
|
|
949
|
+
type: kind === "human" ? "human" : "agent",
|
|
950
|
+
status: "active",
|
|
951
|
+
publicKey: pubKeyB64url,
|
|
952
|
+
defaultTrustTier: trustTier,
|
|
953
|
+
admin: isAdmin,
|
|
954
|
+
runtime: runtime ?? null,
|
|
955
|
+
createdAt: new Date().toISOString(),
|
|
956
|
+
updatedAt: new Date().toISOString(),
|
|
957
|
+
};
|
|
958
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
961
|
+
body: JSON.stringify({ operation: "upsert", database: "flair", table: "Agent", records: [record] }),
|
|
962
|
+
});
|
|
963
|
+
if (!res.ok) {
|
|
964
|
+
const text = await res.text().catch(() => "");
|
|
965
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
console.log(`✅ Principal '${id}' created`);
|
|
969
|
+
console.log(` Kind: ${kind}`);
|
|
970
|
+
console.log(` Trust: ${trustTier}`);
|
|
971
|
+
console.log(` Admin: ${isAdmin}`);
|
|
972
|
+
if (runtime)
|
|
973
|
+
console.log(` Runtime: ${runtime}`);
|
|
974
|
+
console.log(` Public key: ${pubKeyB64url}`);
|
|
975
|
+
console.log(` Private key: ${privPath}`);
|
|
976
|
+
});
|
|
977
|
+
principal
|
|
978
|
+
.command("list")
|
|
979
|
+
.description("List all principals")
|
|
980
|
+
.option("--kind <kind>", "Filter by kind: human or agent")
|
|
981
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
982
|
+
.option("--port <port>", "Harper HTTP port")
|
|
983
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
984
|
+
.action(async (opts) => {
|
|
985
|
+
const opsPort = resolveOpsPort(opts);
|
|
986
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
987
|
+
if (!adminPass) {
|
|
988
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
989
|
+
process.exit(1);
|
|
990
|
+
}
|
|
991
|
+
const kindFilter = opts.kind ? ` WHERE kind = '${opts.kind}'` : "";
|
|
992
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
993
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
994
|
+
method: "POST",
|
|
995
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
996
|
+
body: JSON.stringify({
|
|
997
|
+
operation: "sql",
|
|
998
|
+
sql: `SELECT id, name, kind, status, defaultTrustTier, admin, runtime, createdAt FROM flair.Agent${kindFilter} ORDER BY createdAt`,
|
|
999
|
+
}),
|
|
1000
|
+
});
|
|
1001
|
+
if (!res.ok) {
|
|
1002
|
+
const text = await res.text().catch(() => "");
|
|
1003
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1004
|
+
process.exit(1);
|
|
1005
|
+
}
|
|
1006
|
+
const records = await res.json();
|
|
1007
|
+
if (records.length === 0) {
|
|
1008
|
+
console.log("No principals found.");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
// Table format
|
|
1012
|
+
console.log(`${"ID".padEnd(20)} ${"Kind".padEnd(7)} ${"Trust".padEnd(14)} ${"Admin".padEnd(6)} ${"Status".padEnd(12)} ${"Runtime".padEnd(12)} Created`);
|
|
1013
|
+
console.log("─".repeat(95));
|
|
1014
|
+
for (const r of records) {
|
|
1015
|
+
const kind = r.kind ?? "agent";
|
|
1016
|
+
const trust = r.defaultTrustTier ?? "—";
|
|
1017
|
+
const admin = r.admin ? "yes" : "no";
|
|
1018
|
+
const status = r.status ?? "active";
|
|
1019
|
+
const runtime = r.runtime ?? "—";
|
|
1020
|
+
const created = r.createdAt?.slice(0, 10) ?? "—";
|
|
1021
|
+
console.log(`${String(r.id).padEnd(20)} ${kind.padEnd(7)} ${trust.padEnd(14)} ${admin.padEnd(6)} ${status.padEnd(12)} ${runtime.padEnd(12)} ${created}`);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
principal
|
|
1025
|
+
.command("show <id>")
|
|
1026
|
+
.description("Show principal details")
|
|
1027
|
+
.action(async (id) => {
|
|
1028
|
+
const result = await api("GET", `/Agent/${id}`);
|
|
1029
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1030
|
+
});
|
|
1031
|
+
principal
|
|
1032
|
+
.command("disable <id>")
|
|
1033
|
+
.description("Deactivate a principal (revokes access, preserves data)")
|
|
1034
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1035
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1036
|
+
.action(async (id, opts) => {
|
|
1037
|
+
const opsPort = resolveOpsPort(opts);
|
|
1038
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1039
|
+
if (!adminPass) {
|
|
1040
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1041
|
+
process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1044
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1045
|
+
method: "POST",
|
|
1046
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1047
|
+
body: JSON.stringify({
|
|
1048
|
+
operation: "update",
|
|
1049
|
+
database: "flair",
|
|
1050
|
+
table: "Agent",
|
|
1051
|
+
records: [{ id, status: "deactivated", updatedAt: new Date().toISOString() }],
|
|
1052
|
+
}),
|
|
1053
|
+
});
|
|
1054
|
+
if (!res.ok) {
|
|
1055
|
+
const text = await res.text().catch(() => "");
|
|
1056
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
}
|
|
1059
|
+
console.log(`✅ Principal '${id}' deactivated`);
|
|
1060
|
+
});
|
|
1061
|
+
principal
|
|
1062
|
+
.command("promote <id> <tier>")
|
|
1063
|
+
.description("Change a principal's trust tier (endorsed, corroborated, unverified)")
|
|
1064
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1065
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1066
|
+
.action(async (id, tier, opts) => {
|
|
1067
|
+
const validTiers = ["endorsed", "corroborated", "unverified"];
|
|
1068
|
+
if (!validTiers.includes(tier)) {
|
|
1069
|
+
console.error(`Error: tier must be one of: ${validTiers.join(", ")}`);
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
const opsPort = resolveOpsPort(opts);
|
|
1073
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1074
|
+
if (!adminPass) {
|
|
1075
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1076
|
+
process.exit(1);
|
|
1077
|
+
}
|
|
1078
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1079
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1080
|
+
method: "POST",
|
|
1081
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1082
|
+
body: JSON.stringify({
|
|
1083
|
+
operation: "update",
|
|
1084
|
+
database: "flair",
|
|
1085
|
+
table: "Agent",
|
|
1086
|
+
records: [{ id, defaultTrustTier: tier, updatedAt: new Date().toISOString() }],
|
|
1087
|
+
}),
|
|
1088
|
+
});
|
|
1089
|
+
if (!res.ok) {
|
|
1090
|
+
const text = await res.text().catch(() => "");
|
|
1091
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1092
|
+
process.exit(1);
|
|
1093
|
+
}
|
|
1094
|
+
console.log(`✅ Principal '${id}' trust tier set to '${tier}'`);
|
|
1095
|
+
});
|
|
1096
|
+
// ─── flair idp ───────────────────────────────────────────────────────────────
|
|
1097
|
+
// XAA Enterprise IdP configuration (per FLAIR-XAA spec § 4).
|
|
1098
|
+
const idp = program.command("idp").description("Manage enterprise IdP configurations (XAA)");
|
|
1099
|
+
idp
|
|
1100
|
+
.command("add")
|
|
1101
|
+
.description("Register a trusted enterprise IdP")
|
|
1102
|
+
.requiredOption("--name <name>", "Display name (e.g., 'Harper Corporate')")
|
|
1103
|
+
.requiredOption("--issuer <url>", "IdP issuer URL (e.g., https://accounts.google.com)")
|
|
1104
|
+
.requiredOption("--jwks-uri <url>", "JWKS endpoint URL")
|
|
1105
|
+
.requiredOption("--client-id <id>", "Flair's client_id at this IdP")
|
|
1106
|
+
.option("--required-domain <domain>", "Reject tokens without this domain (hd/tid claim)")
|
|
1107
|
+
.option("--no-jit-provision", "Disable auto-creation of principals for new IdP users")
|
|
1108
|
+
.option("--default-trust <tier>", "Trust tier for JIT principals", "unverified")
|
|
1109
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1110
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1111
|
+
.action(async (opts) => {
|
|
1112
|
+
const opsPort = resolveOpsPort(opts);
|
|
1113
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1114
|
+
if (!adminPass) {
|
|
1115
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1116
|
+
process.exit(1);
|
|
1117
|
+
}
|
|
1118
|
+
const id = `idp_${randomUUID().slice(0, 8)}`;
|
|
1119
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1120
|
+
const now = new Date().toISOString();
|
|
1121
|
+
const record = {
|
|
1122
|
+
id,
|
|
1123
|
+
name: opts.name,
|
|
1124
|
+
issuer: opts.issuer,
|
|
1125
|
+
jwksUri: opts.jwksUri,
|
|
1126
|
+
clientId: opts.clientId,
|
|
1127
|
+
requiredDomain: opts.requiredDomain ?? null,
|
|
1128
|
+
jitProvision: opts.jitProvision !== false,
|
|
1129
|
+
defaultTrustTier: opts.defaultTrust ?? "unverified",
|
|
1130
|
+
enabled: true,
|
|
1131
|
+
createdAt: now,
|
|
1132
|
+
updatedAt: now,
|
|
1133
|
+
};
|
|
1134
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1135
|
+
method: "POST",
|
|
1136
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1137
|
+
body: JSON.stringify({ operation: "upsert", database: "flair", table: "IdpConfig", records: [record] }),
|
|
1138
|
+
});
|
|
1139
|
+
if (!res.ok) {
|
|
1140
|
+
const text = await res.text().catch(() => "");
|
|
1141
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1142
|
+
process.exit(1);
|
|
1143
|
+
}
|
|
1144
|
+
console.log(`✅ IdP '${opts.name}' registered (id: ${id})`);
|
|
1145
|
+
console.log(` Issuer: ${opts.issuer}`);
|
|
1146
|
+
console.log(` JWKS: ${opts.jwksUri}`);
|
|
1147
|
+
console.log(` Client: ${opts.clientId}`);
|
|
1148
|
+
if (opts.requiredDomain)
|
|
1149
|
+
console.log(` Domain: ${opts.requiredDomain}`);
|
|
1150
|
+
console.log(` JIT: ${opts.jitProvision !== false}`);
|
|
1151
|
+
});
|
|
1152
|
+
idp
|
|
1153
|
+
.command("list")
|
|
1154
|
+
.description("List configured IdPs")
|
|
1155
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1156
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1157
|
+
.action(async (opts) => {
|
|
1158
|
+
const opsPort = resolveOpsPort(opts);
|
|
1159
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1160
|
+
if (!adminPass) {
|
|
1161
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1162
|
+
process.exit(1);
|
|
1163
|
+
}
|
|
1164
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1165
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1166
|
+
method: "POST",
|
|
1167
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1168
|
+
body: JSON.stringify({
|
|
1169
|
+
operation: "sql",
|
|
1170
|
+
sql: "SELECT id, name, issuer, requiredDomain, jitProvision, enabled, createdAt FROM flair.IdpConfig ORDER BY createdAt",
|
|
1171
|
+
}),
|
|
1172
|
+
});
|
|
1173
|
+
if (!res.ok) {
|
|
1174
|
+
const text = await res.text().catch(() => "");
|
|
1175
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1176
|
+
process.exit(1);
|
|
1177
|
+
}
|
|
1178
|
+
const records = await res.json();
|
|
1179
|
+
if (records.length === 0) {
|
|
1180
|
+
console.log("No IdPs configured.");
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
for (const r of records) {
|
|
1184
|
+
const status = r.enabled ? "enabled" : "disabled";
|
|
1185
|
+
console.log(`${r.name} (${r.id}) — ${status}`);
|
|
1186
|
+
console.log(` Issuer: ${r.issuer}`);
|
|
1187
|
+
if (r.requiredDomain)
|
|
1188
|
+
console.log(` Domain: ${r.requiredDomain}`);
|
|
1189
|
+
console.log(` JIT: ${r.jitProvision ?? true}`);
|
|
1190
|
+
console.log();
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
idp
|
|
1194
|
+
.command("remove <id>")
|
|
1195
|
+
.description("Remove an IdP configuration")
|
|
1196
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1197
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1198
|
+
.action(async (id, opts) => {
|
|
1199
|
+
const opsPort = resolveOpsPort(opts);
|
|
1200
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1201
|
+
if (!adminPass) {
|
|
1202
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1203
|
+
process.exit(1);
|
|
1204
|
+
}
|
|
1205
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1206
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1207
|
+
method: "POST",
|
|
1208
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1209
|
+
body: JSON.stringify({ operation: "delete", database: "flair", table: "IdpConfig", hash_values: [id] }),
|
|
1210
|
+
});
|
|
1211
|
+
if (!res.ok) {
|
|
1212
|
+
const text = await res.text().catch(() => "");
|
|
1213
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1214
|
+
process.exit(1);
|
|
1215
|
+
}
|
|
1216
|
+
console.log(`✅ IdP '${id}' removed`);
|
|
1217
|
+
});
|
|
1218
|
+
idp
|
|
1219
|
+
.command("test <id>")
|
|
1220
|
+
.description("Test IdP connectivity (fetches JWKS)")
|
|
1221
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1222
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1223
|
+
.action(async (id, opts) => {
|
|
1224
|
+
const opsPort = resolveOpsPort(opts);
|
|
1225
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1226
|
+
if (!adminPass) {
|
|
1227
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1228
|
+
process.exit(1);
|
|
1229
|
+
}
|
|
1230
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1231
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1232
|
+
method: "POST",
|
|
1233
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1234
|
+
body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.IdpConfig WHERE id = '${id}'` }),
|
|
1235
|
+
});
|
|
1236
|
+
const records = await res.json();
|
|
1237
|
+
if (records.length === 0) {
|
|
1238
|
+
console.error(`IdP '${id}' not found`);
|
|
1239
|
+
process.exit(1);
|
|
1240
|
+
}
|
|
1241
|
+
const cfg = records[0];
|
|
1242
|
+
console.log(`Testing IdP: ${cfg.name} (${cfg.issuer})`);
|
|
1243
|
+
console.log(` JWKS endpoint: ${cfg.jwksUri}`);
|
|
1244
|
+
try {
|
|
1245
|
+
const jwksRes = await fetch(cfg.jwksUri, { signal: AbortSignal.timeout(10_000) });
|
|
1246
|
+
if (!jwksRes.ok) {
|
|
1247
|
+
console.error(` ❌ JWKS fetch failed: HTTP ${jwksRes.status}`);
|
|
1248
|
+
process.exit(1);
|
|
1249
|
+
}
|
|
1250
|
+
const jwks = await jwksRes.json();
|
|
1251
|
+
const keyCount = jwks.keys?.length ?? 0;
|
|
1252
|
+
console.log(` ✅ JWKS reachable — ${keyCount} key(s) found`);
|
|
1253
|
+
}
|
|
1254
|
+
catch (err) {
|
|
1255
|
+
console.error(` ❌ JWKS fetch error: ${err.message}`);
|
|
1256
|
+
process.exit(1);
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
825
1259
|
// ─── flair grant / revoke ─────────────────────────────────────────────────────
|
|
826
1260
|
program
|
|
827
1261
|
.command("grant <from-agent> <to-agent>")
|
|
@@ -913,61 +1347,569 @@ program
|
|
|
913
1347
|
console.log(`✅ Grant revoked: '${toAgent}' can no longer read '${fromAgent}'s memories`);
|
|
914
1348
|
console.log(` Removed grant ID: ${grantId}`);
|
|
915
1349
|
});
|
|
1350
|
+
// ─── Federation signing helpers ──────────────────────────────────────────────
|
|
1351
|
+
/**
|
|
1352
|
+
* Load the Ed25519 secret key for the local federation instance.
|
|
1353
|
+
* Tries keystore first, then falls back to DB-stored seed (migration path).
|
|
1354
|
+
*/
|
|
1355
|
+
async function loadInstanceSecretKey(instanceId, opts) {
|
|
1356
|
+
// Try keystore first
|
|
1357
|
+
const seed = keystore.getPrivateKeySeed(instanceId);
|
|
1358
|
+
if (seed) {
|
|
1359
|
+
return nacl.sign.keyPair.fromSeed(seed).secretKey;
|
|
1360
|
+
}
|
|
1361
|
+
// Fallback: check DB for legacy _keySeed
|
|
1362
|
+
const opsPort = resolveOpsPort(opts);
|
|
1363
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1364
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1365
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1366
|
+
method: "POST",
|
|
1367
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1368
|
+
body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.Instance WHERE id = '${instanceId}'` }),
|
|
1369
|
+
});
|
|
1370
|
+
if (res.ok) {
|
|
1371
|
+
const rows = await res.json();
|
|
1372
|
+
if (rows[0]?._keySeed) {
|
|
1373
|
+
const seedFromDb = Buffer.from(rows[0]._keySeed, "base64url");
|
|
1374
|
+
// Migrate to keystore
|
|
1375
|
+
keystore.setPrivateKeySeed(instanceId, new Uint8Array(seedFromDb));
|
|
1376
|
+
return nacl.sign.keyPair.fromSeed(new Uint8Array(seedFromDb)).secretKey;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
throw new Error(`No private key found for instance ${instanceId}. Re-run 'flair federation status' to regenerate.`);
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Sign a request body and return a new body with the signature field added.
|
|
1383
|
+
*/
|
|
1384
|
+
function signRequestBody(body, secretKey) {
|
|
1385
|
+
const sig = signBody(body, secretKey);
|
|
1386
|
+
return { ...body, signature: sig };
|
|
1387
|
+
}
|
|
1388
|
+
// ─── flair federation ────────────────────────────────────────────────────────
|
|
1389
|
+
const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
|
|
1390
|
+
federation
|
|
1391
|
+
.command("status")
|
|
1392
|
+
.description("Show federation status and peer connections")
|
|
1393
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1394
|
+
.action(async (opts) => {
|
|
1395
|
+
try {
|
|
1396
|
+
const instance = await api("GET", "/FederationInstance");
|
|
1397
|
+
console.log(`Instance: ${instance.id} (${instance.role})`);
|
|
1398
|
+
console.log(`Public key: ${instance.publicKey}`);
|
|
1399
|
+
console.log(`Status: ${instance.status}`);
|
|
1400
|
+
console.log();
|
|
1401
|
+
const { peers } = await api("GET", "/FederationPeers");
|
|
1402
|
+
if (peers.length === 0) {
|
|
1403
|
+
console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
|
|
1404
|
+
}
|
|
1405
|
+
else {
|
|
1406
|
+
console.log(`${"Peer".padEnd(20)} ${"Role".padEnd(8)} ${"Status".padEnd(14)} ${"Last Sync".padEnd(22)} Relay`);
|
|
1407
|
+
console.log("─".repeat(80));
|
|
1408
|
+
for (const p of peers) {
|
|
1409
|
+
const lastSync = p.lastSyncAt?.slice(0, 19) ?? "never";
|
|
1410
|
+
console.log(`${p.id.padEnd(20)} ${(p.role ?? "—").padEnd(8)} ${(p.status ?? "—").padEnd(14)} ${lastSync.padEnd(22)} ${p.relayOnly ? "yes" : "no"}`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
catch (err) {
|
|
1415
|
+
console.error(`Error: ${err.message}`);
|
|
1416
|
+
process.exit(1);
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
federation
|
|
1420
|
+
.command("pair <hub-url>")
|
|
1421
|
+
.description("Pair this spoke with a hub instance")
|
|
1422
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1423
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1424
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1425
|
+
.option("--token <token>", "One-time pairing token from hub admin")
|
|
1426
|
+
.action(async (hubUrl, opts) => {
|
|
1427
|
+
try {
|
|
1428
|
+
const instance = await api("GET", "/FederationInstance");
|
|
1429
|
+
console.log(`Local instance: ${instance.id} (${instance.role})`);
|
|
1430
|
+
if (!opts.token) {
|
|
1431
|
+
console.error("Error: --token is required. Ask the hub admin to run 'flair federation token' and provide the token.");
|
|
1432
|
+
process.exit(1);
|
|
1433
|
+
}
|
|
1434
|
+
// Load secret key and sign the pairing request
|
|
1435
|
+
const secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
1436
|
+
const pairBody = {
|
|
1437
|
+
instanceId: instance.id,
|
|
1438
|
+
publicKey: instance.publicKey,
|
|
1439
|
+
role: "spoke",
|
|
1440
|
+
pairingToken: opts.token,
|
|
1441
|
+
};
|
|
1442
|
+
const signedBody = signRequestBody(pairBody, secretKey);
|
|
1443
|
+
const res = await fetch(`${hubUrl}/FederationPair`, {
|
|
1444
|
+
method: "POST",
|
|
1445
|
+
headers: { "Content-Type": "application/json" },
|
|
1446
|
+
body: JSON.stringify(signedBody),
|
|
1447
|
+
});
|
|
1448
|
+
if (!res.ok) {
|
|
1449
|
+
const text = await res.text().catch(() => "");
|
|
1450
|
+
console.error(`Pairing failed: ${res.status} ${text}`);
|
|
1451
|
+
process.exit(1);
|
|
1452
|
+
}
|
|
1453
|
+
const result = await res.json();
|
|
1454
|
+
console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
|
|
1455
|
+
// Record the hub as our peer locally
|
|
1456
|
+
const opsPort = resolveOpsPort(opts);
|
|
1457
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1458
|
+
if (adminPass) {
|
|
1459
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1460
|
+
await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1461
|
+
method: "POST",
|
|
1462
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1463
|
+
body: JSON.stringify({
|
|
1464
|
+
operation: "upsert", database: "flair", table: "Peer",
|
|
1465
|
+
records: [{
|
|
1466
|
+
id: result.instance?.id ?? "hub",
|
|
1467
|
+
publicKey: result.instance?.publicKey ?? "",
|
|
1468
|
+
role: "hub", endpoint: hubUrl, status: "paired",
|
|
1469
|
+
pairedAt: new Date().toISOString(),
|
|
1470
|
+
createdAt: new Date().toISOString(),
|
|
1471
|
+
updatedAt: new Date().toISOString(),
|
|
1472
|
+
}],
|
|
1473
|
+
}),
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
catch (err) {
|
|
1478
|
+
console.error(`Error: ${err.message}`);
|
|
1479
|
+
process.exit(1);
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
federation
|
|
1483
|
+
.command("token")
|
|
1484
|
+
.description("Generate a one-time pairing token (run on the hub)")
|
|
1485
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1486
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1487
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1488
|
+
.option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
|
|
1489
|
+
.action(async (opts) => {
|
|
1490
|
+
try {
|
|
1491
|
+
const { randomBytes } = await import("node:crypto");
|
|
1492
|
+
const token = randomBytes(24).toString("base64url");
|
|
1493
|
+
const ttlMinutes = parseInt(opts.ttl, 10) || 60;
|
|
1494
|
+
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
1495
|
+
const opsPort = resolveOpsPort(opts);
|
|
1496
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1497
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1498
|
+
await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1499
|
+
method: "POST",
|
|
1500
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1501
|
+
body: JSON.stringify({
|
|
1502
|
+
operation: "upsert", database: "flair", table: "PairingToken",
|
|
1503
|
+
records: [{
|
|
1504
|
+
id: token,
|
|
1505
|
+
createdAt: new Date().toISOString(),
|
|
1506
|
+
expiresAt,
|
|
1507
|
+
}],
|
|
1508
|
+
}),
|
|
1509
|
+
});
|
|
1510
|
+
console.log(`Pairing token (expires in ${ttlMinutes}m):`);
|
|
1511
|
+
console.log(` ${token}`);
|
|
1512
|
+
console.log(`\nGive this to the spoke admin to run:`);
|
|
1513
|
+
console.log(` flair federation pair <this-hub-url> --token ${token}`);
|
|
1514
|
+
}
|
|
1515
|
+
catch (err) {
|
|
1516
|
+
console.error(`Error: ${err.message}`);
|
|
1517
|
+
process.exit(1);
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
federation
|
|
1521
|
+
.command("sync")
|
|
1522
|
+
.description("Push local changes to the hub")
|
|
1523
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1524
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1525
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1526
|
+
.action(async (opts) => {
|
|
1527
|
+
try {
|
|
1528
|
+
const { peers } = await api("GET", "/FederationPeers");
|
|
1529
|
+
const hub = peers.find((p) => p.role === "hub" && p.status !== "revoked");
|
|
1530
|
+
if (!hub) {
|
|
1531
|
+
console.error("No hub peer configured. Use 'flair federation pair' first.");
|
|
1532
|
+
process.exit(1);
|
|
1533
|
+
}
|
|
1534
|
+
console.log(`Syncing to hub: ${hub.id}...`);
|
|
1535
|
+
const since = hub.lastSyncAt ?? new Date(0).toISOString();
|
|
1536
|
+
const opsPort = resolveOpsPort(opts);
|
|
1537
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1538
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1539
|
+
const tables = ["Memory", "Soul", "Agent", "Relationship"];
|
|
1540
|
+
const records = [];
|
|
1541
|
+
const instance = await api("GET", "/FederationInstance");
|
|
1542
|
+
for (const table of tables) {
|
|
1543
|
+
try {
|
|
1544
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1545
|
+
method: "POST",
|
|
1546
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1547
|
+
body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.${table} WHERE updatedAt > '${since}'` }),
|
|
1548
|
+
});
|
|
1549
|
+
if (res.ok) {
|
|
1550
|
+
for (const row of await res.json()) {
|
|
1551
|
+
records.push({ table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id });
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
catch { }
|
|
1556
|
+
}
|
|
1557
|
+
if (records.length === 0) {
|
|
1558
|
+
console.log("No changes since last sync.");
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
// Sign the sync request with our instance key
|
|
1562
|
+
const secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
1563
|
+
const syncBody = { instanceId: instance.id, records, lamportClock: Date.now() };
|
|
1564
|
+
const signedSyncBody = signRequestBody(syncBody, secretKey);
|
|
1565
|
+
const syncRes = await fetch(`${hub.endpoint ?? hub.id}/FederationSync`, {
|
|
1566
|
+
method: "POST",
|
|
1567
|
+
headers: { "Content-Type": "application/json" },
|
|
1568
|
+
body: JSON.stringify(signedSyncBody),
|
|
1569
|
+
});
|
|
1570
|
+
if (!syncRes.ok) {
|
|
1571
|
+
console.error(`Sync failed: ${syncRes.status} ${await syncRes.text().catch(() => "")}`);
|
|
1572
|
+
process.exit(1);
|
|
1573
|
+
}
|
|
1574
|
+
const result = await syncRes.json();
|
|
1575
|
+
console.log(`✅ Synced ${result.merged} records (${result.skipped} skipped) in ${result.durationMs}ms`);
|
|
1576
|
+
}
|
|
1577
|
+
catch (err) {
|
|
1578
|
+
console.error(`Error: ${err.message}`);
|
|
1579
|
+
process.exit(1);
|
|
1580
|
+
}
|
|
1581
|
+
});
|
|
1582
|
+
// ─── flair rem ───────────────────────────────────────────────────────────────
|
|
1583
|
+
// Memory hygiene and reflection: light (NREM), rapid (REM), restorative (deep).
|
|
1584
|
+
const rem = program.command("rem").description("Memory hygiene and reflection");
|
|
1585
|
+
rem
|
|
1586
|
+
.command("light")
|
|
1587
|
+
.description("NREM — quick cleanup: delete expired, archive old, consolidate candidates")
|
|
1588
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1589
|
+
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
1590
|
+
.option("--dry-run", "Preview changes without applying them")
|
|
1591
|
+
.action(async (opts) => {
|
|
1592
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1593
|
+
const dryRun = !!opts.dryRun;
|
|
1594
|
+
console.log(`\n-- rem light${dryRun ? " (dry run)" : ""} --`);
|
|
1595
|
+
if (agentId)
|
|
1596
|
+
console.log(`Agent: ${agentId}`);
|
|
1597
|
+
try {
|
|
1598
|
+
// Step 1: Maintenance — expire + archive
|
|
1599
|
+
const maint = await api("POST", "/MemoryMaintenance", {
|
|
1600
|
+
...(agentId ? { agentId } : {}),
|
|
1601
|
+
dryRun,
|
|
1602
|
+
});
|
|
1603
|
+
if (maint.error) {
|
|
1604
|
+
console.error(`Maintenance error: ${maint.error}`);
|
|
1605
|
+
process.exit(1);
|
|
1606
|
+
}
|
|
1607
|
+
const s = maint.stats ?? {};
|
|
1608
|
+
console.log("\nCleanup");
|
|
1609
|
+
console.log(` Expired (deleted): ${s.expired ?? 0}`);
|
|
1610
|
+
console.log(` Archived (soft): ${s.archived ?? 0}`);
|
|
1611
|
+
console.log(` Total scanned: ${s.total ?? 0}`);
|
|
1612
|
+
if (s.errors)
|
|
1613
|
+
console.log(` Errors: ${s.errors}`);
|
|
1614
|
+
// Step 2: Consolidation candidates
|
|
1615
|
+
if (!agentId) {
|
|
1616
|
+
console.log("\nConsolidation skipped — no agent ID (pass --agent or set FLAIR_AGENT_ID)");
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const consol = await api("POST", "/ConsolidateMemories", {
|
|
1620
|
+
agentId,
|
|
1621
|
+
scope: "all",
|
|
1622
|
+
});
|
|
1623
|
+
if (consol.error) {
|
|
1624
|
+
console.error(`Consolidation error: ${consol.error}`);
|
|
1625
|
+
process.exit(1);
|
|
1626
|
+
}
|
|
1627
|
+
const candidates = consol.candidates ?? [];
|
|
1628
|
+
const promote = candidates.filter((c) => c.suggestion === "promote");
|
|
1629
|
+
const archive = candidates.filter((c) => c.suggestion === "archive");
|
|
1630
|
+
console.log("\nConsolidation candidates");
|
|
1631
|
+
console.log(` Promote: ${promote.length}`);
|
|
1632
|
+
console.log(` Archive: ${archive.length}`);
|
|
1633
|
+
if (promote.length > 0) {
|
|
1634
|
+
console.log("\n Promote:");
|
|
1635
|
+
for (const c of promote) {
|
|
1636
|
+
console.log(` [${c.memory?.id ?? "?"}] ${c.reason}`);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
if (archive.length > 0) {
|
|
1640
|
+
console.log("\n Archive:");
|
|
1641
|
+
for (const c of archive) {
|
|
1642
|
+
console.log(` [${c.memory?.id ?? "?"}] ${c.reason}`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
console.log(`\nDone.${dryRun ? " No changes applied (dry run)." : ""}`);
|
|
1646
|
+
}
|
|
1647
|
+
catch (err) {
|
|
1648
|
+
console.error(`Error: ${err.message}`);
|
|
1649
|
+
process.exit(1);
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
rem
|
|
1653
|
+
.command("rapid")
|
|
1654
|
+
.description("REM — reflection/learning: generate a structured LLM reflection prompt")
|
|
1655
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1656
|
+
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
1657
|
+
.option("--focus <type>", "lessons_learned | patterns | decisions | errors", "lessons_learned")
|
|
1658
|
+
.option("--since <date>", "ISO timestamp lower bound (default: 24h ago)")
|
|
1659
|
+
.action(async (opts) => {
|
|
1660
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1661
|
+
if (!agentId) {
|
|
1662
|
+
console.error("Error: --agent <id> or FLAIR_AGENT_ID env required");
|
|
1663
|
+
process.exit(1);
|
|
1664
|
+
}
|
|
1665
|
+
console.log(`\n-- rem rapid --`);
|
|
1666
|
+
console.log(`Agent: ${agentId} Focus: ${opts.focus}`);
|
|
1667
|
+
try {
|
|
1668
|
+
const body = {
|
|
1669
|
+
agentId,
|
|
1670
|
+
focus: opts.focus,
|
|
1671
|
+
};
|
|
1672
|
+
if (opts.since)
|
|
1673
|
+
body.since = opts.since;
|
|
1674
|
+
const result = await api("POST", "/ReflectMemories", body);
|
|
1675
|
+
if (result.error) {
|
|
1676
|
+
console.error(`Reflection error: ${result.error}`);
|
|
1677
|
+
process.exit(1);
|
|
1678
|
+
}
|
|
1679
|
+
console.log(`\nSource memories: ${result.count ?? 0}`);
|
|
1680
|
+
if (result.suggestedTags?.length) {
|
|
1681
|
+
console.log(`Tags: ${result.suggestedTags.join(", ")}`);
|
|
1682
|
+
}
|
|
1683
|
+
console.log("\n--- Reflection Prompt ---");
|
|
1684
|
+
console.log(result.prompt ?? "(no prompt returned)");
|
|
1685
|
+
console.log("--- End Prompt ---\n");
|
|
1686
|
+
console.log("Feed the prompt above to your LLM, then write insights back with:");
|
|
1687
|
+
console.log(" flair memory add --agent <id> --content <insight> --durability persistent");
|
|
1688
|
+
}
|
|
1689
|
+
catch (err) {
|
|
1690
|
+
console.error(`Error: ${err.message}`);
|
|
1691
|
+
process.exit(1);
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
rem
|
|
1695
|
+
.command("restorative")
|
|
1696
|
+
.description("Deep audit: full maintenance + consolidation (olderThan=7d) + reflection")
|
|
1697
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1698
|
+
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
1699
|
+
.option("--dry-run", "Preview maintenance changes without applying them")
|
|
1700
|
+
.action(async (opts) => {
|
|
1701
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1702
|
+
const dryRun = !!opts.dryRun;
|
|
1703
|
+
console.log(`\n== rem restorative${dryRun ? " (dry run)" : ""} ==`);
|
|
1704
|
+
if (agentId)
|
|
1705
|
+
console.log(`Agent: ${agentId}`);
|
|
1706
|
+
try {
|
|
1707
|
+
// Step 1: Maintenance
|
|
1708
|
+
console.log("\n[1/3] Maintenance...");
|
|
1709
|
+
const maint = await api("POST", "/MemoryMaintenance", {
|
|
1710
|
+
...(agentId ? { agentId } : {}),
|
|
1711
|
+
dryRun,
|
|
1712
|
+
});
|
|
1713
|
+
if (maint.error) {
|
|
1714
|
+
console.error(`Maintenance error: ${maint.error}`);
|
|
1715
|
+
process.exit(1);
|
|
1716
|
+
}
|
|
1717
|
+
const s = maint.stats ?? {};
|
|
1718
|
+
console.log(` Expired: ${s.expired ?? 0} Archived: ${s.archived ?? 0} Scanned: ${s.total ?? 0}${s.errors ? ` Errors: ${s.errors}` : ""}`);
|
|
1719
|
+
// Step 2: Consolidation (skip if no agentId)
|
|
1720
|
+
if (agentId) {
|
|
1721
|
+
console.log("\n[2/3] Consolidation (scope=all, olderThan=7d)...");
|
|
1722
|
+
const consol = await api("POST", "/ConsolidateMemories", {
|
|
1723
|
+
agentId,
|
|
1724
|
+
scope: "all",
|
|
1725
|
+
olderThan: "7d",
|
|
1726
|
+
});
|
|
1727
|
+
if (consol.error) {
|
|
1728
|
+
console.error(`Consolidation error: ${consol.error}`);
|
|
1729
|
+
process.exit(1);
|
|
1730
|
+
}
|
|
1731
|
+
const candidates = consol.candidates ?? [];
|
|
1732
|
+
const promote = candidates.filter((c) => c.suggestion === "promote");
|
|
1733
|
+
const archive = candidates.filter((c) => c.suggestion === "archive");
|
|
1734
|
+
console.log(` Promote candidates: ${promote.length} Archive candidates: ${archive.length}`);
|
|
1735
|
+
for (const c of promote) {
|
|
1736
|
+
console.log(` promote [${c.memory?.id ?? "?"}] ${c.reason}`);
|
|
1737
|
+
}
|
|
1738
|
+
for (const c of archive) {
|
|
1739
|
+
console.log(` archive [${c.memory?.id ?? "?"}] ${c.reason}`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
else {
|
|
1743
|
+
console.log("\n[2/3] Consolidation skipped — no agent ID");
|
|
1744
|
+
}
|
|
1745
|
+
// Step 3: Reflection
|
|
1746
|
+
if (agentId) {
|
|
1747
|
+
console.log("\n[3/3] Reflection (scope=all)...");
|
|
1748
|
+
const reflect = await api("POST", "/ReflectMemories", {
|
|
1749
|
+
agentId,
|
|
1750
|
+
scope: "all",
|
|
1751
|
+
});
|
|
1752
|
+
if (reflect.error) {
|
|
1753
|
+
console.error(`Reflection error: ${reflect.error}`);
|
|
1754
|
+
process.exit(1);
|
|
1755
|
+
}
|
|
1756
|
+
console.log(` Source memories: ${reflect.count ?? 0}`);
|
|
1757
|
+
if (reflect.suggestedTags?.length) {
|
|
1758
|
+
console.log(` Tags: ${reflect.suggestedTags.join(", ")}`);
|
|
1759
|
+
}
|
|
1760
|
+
console.log("\n--- Reflection Prompt ---");
|
|
1761
|
+
console.log(reflect.prompt ?? "(no prompt returned)");
|
|
1762
|
+
console.log("--- End Prompt ---");
|
|
1763
|
+
}
|
|
1764
|
+
else {
|
|
1765
|
+
console.log("\n[3/3] Reflection skipped — no agent ID");
|
|
1766
|
+
}
|
|
1767
|
+
console.log(`\nRestorative cycle complete.${dryRun ? " No changes applied (dry run)." : ""}`);
|
|
1768
|
+
}
|
|
1769
|
+
catch (err) {
|
|
1770
|
+
console.error(`Error: ${err.message}`);
|
|
1771
|
+
process.exit(1);
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
916
1774
|
// ─── flair status ─────────────────────────────────────────────────────────────
|
|
917
1775
|
program
|
|
918
1776
|
.command("status")
|
|
919
|
-
.description("
|
|
1777
|
+
.description("Show Flair instance status, memory stats, and agent info")
|
|
920
1778
|
.option("--port <port>", "Harper HTTP port")
|
|
921
1779
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
1780
|
+
.option("--json", "Output as JSON")
|
|
1781
|
+
.option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
|
|
922
1782
|
.action(async (opts) => {
|
|
923
1783
|
const port = resolveHttpPort(opts);
|
|
924
1784
|
const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
|
|
925
1785
|
let healthy = false;
|
|
926
|
-
let
|
|
927
|
-
|
|
1786
|
+
let healthData = null;
|
|
1787
|
+
// 1. Basic health check — try unauthenticated first, then with admin auth
|
|
928
1788
|
try {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const
|
|
933
|
-
if (
|
|
934
|
-
|
|
1789
|
+
let res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
|
|
1790
|
+
if (!res.ok && res.status === 401) {
|
|
1791
|
+
// Harper requires auth (authorizeLocal: true) — retry with admin credentials
|
|
1792
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
1793
|
+
if (adminPass) {
|
|
1794
|
+
res = await fetch(`${baseUrl}/Health`, {
|
|
1795
|
+
headers: { Authorization: `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}` },
|
|
1796
|
+
signal: AbortSignal.timeout(5000),
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
935
1799
|
}
|
|
1800
|
+
healthy = res.ok;
|
|
936
1801
|
}
|
|
937
1802
|
catch { /* unreachable */ }
|
|
1803
|
+
// 2. Try authenticated /HealthDetail for rich stats
|
|
938
1804
|
if (healthy) {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1805
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1806
|
+
if (agentId) {
|
|
1807
|
+
const keyPath = resolveKeyPath(agentId);
|
|
1808
|
+
if (keyPath) {
|
|
1809
|
+
try {
|
|
1810
|
+
const authHeader = buildEd25519Auth(agentId, "GET", "/HealthDetail", keyPath);
|
|
1811
|
+
const res = await fetch(`${baseUrl}/HealthDetail`, {
|
|
1812
|
+
headers: { Authorization: authHeader },
|
|
1813
|
+
signal: AbortSignal.timeout(5000),
|
|
1814
|
+
});
|
|
1815
|
+
if (res.ok) {
|
|
1816
|
+
healthData = await res.json().catch(() => null);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
catch { /* fall through to basic output */ }
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
// Fallback: try admin basic auth if available
|
|
1823
|
+
if (!healthData) {
|
|
1824
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
|
|
1825
|
+
if (adminPass) {
|
|
1826
|
+
try {
|
|
1827
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1828
|
+
const res = await fetch(`${baseUrl}/HealthDetail`, {
|
|
1829
|
+
headers: { Authorization: auth },
|
|
1830
|
+
signal: AbortSignal.timeout(5000),
|
|
1831
|
+
});
|
|
1832
|
+
if (res.ok) {
|
|
1833
|
+
healthData = await res.json().catch(() => null);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
catch { /* fall through to basic output */ }
|
|
945
1837
|
}
|
|
946
1838
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
console.log(`
|
|
957
|
-
|
|
1839
|
+
}
|
|
1840
|
+
if (opts.json) {
|
|
1841
|
+
console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
|
|
1842
|
+
if (!healthy)
|
|
1843
|
+
process.exit(1);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
if (!healthy) {
|
|
1847
|
+
console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
|
|
1848
|
+
console.log(` URL: ${baseUrl}`);
|
|
1849
|
+
console.log(`\n Run: flair start or flair doctor`);
|
|
958
1850
|
process.exit(1);
|
|
1851
|
+
}
|
|
1852
|
+
// Format uptime
|
|
1853
|
+
const uptimeSec = healthData?.uptimeSeconds;
|
|
1854
|
+
let uptimeStr = "";
|
|
1855
|
+
if (uptimeSec != null) {
|
|
1856
|
+
const d = Math.floor(uptimeSec / 86400);
|
|
1857
|
+
const h = Math.floor((uptimeSec % 86400) / 3600);
|
|
1858
|
+
const m = Math.floor((uptimeSec % 3600) / 60);
|
|
1859
|
+
uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
1860
|
+
}
|
|
1861
|
+
// Format last write as relative time
|
|
1862
|
+
let lastWriteStr = "";
|
|
1863
|
+
if (healthData?.lastWrite) {
|
|
1864
|
+
const ago = Date.now() - new Date(healthData.lastWrite).getTime();
|
|
1865
|
+
const mins = Math.floor(ago / 60000);
|
|
1866
|
+
const hrs = Math.floor(ago / 3600000);
|
|
1867
|
+
const days = Math.floor(ago / 86400000);
|
|
1868
|
+
lastWriteStr = days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
|
|
1869
|
+
}
|
|
1870
|
+
const pid = healthData?.pid ?? "";
|
|
1871
|
+
const agents = healthData?.agents;
|
|
1872
|
+
const memories = healthData?.memories;
|
|
1873
|
+
console.log(`Flair v${__pkgVersion} — 🟢 running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
|
|
1874
|
+
console.log(` URL: ${baseUrl}`);
|
|
1875
|
+
if (memories) {
|
|
1876
|
+
const embStr = memories.withEmbeddings > 0
|
|
1877
|
+
? `${memories.withEmbeddings} with embeddings`
|
|
1878
|
+
: "";
|
|
1879
|
+
const hashStr = memories.hashFallback > 0
|
|
1880
|
+
? `${memories.hashFallback} hash-fallback`
|
|
1881
|
+
: "";
|
|
1882
|
+
const detail = [embStr, hashStr].filter(Boolean).join(", ");
|
|
1883
|
+
console.log(` Memories: ${memories.total}${detail ? ` (${detail})` : ""}`);
|
|
1884
|
+
}
|
|
1885
|
+
if (agents) {
|
|
1886
|
+
const nameStr = agents.names?.length > 0 ? ` (${agents.names.join(", ")})` : "";
|
|
1887
|
+
console.log(` Agents: ${agents.count}${nameStr}`);
|
|
1888
|
+
}
|
|
1889
|
+
if (healthData?.soulEntries != null) {
|
|
1890
|
+
console.log(` Soul: ${healthData.soulEntries} entries`);
|
|
1891
|
+
}
|
|
1892
|
+
if (lastWriteStr) {
|
|
1893
|
+
console.log(` Last write: ${lastWriteStr}`);
|
|
1894
|
+
}
|
|
1895
|
+
console.log(` Health: ✅ all checks passing`);
|
|
959
1896
|
});
|
|
960
1897
|
// ─── flair upgrade ────────────────────────────────────────────────────────────
|
|
961
1898
|
program
|
|
962
1899
|
.command("upgrade")
|
|
963
1900
|
.description("Upgrade Flair and related packages to latest versions")
|
|
964
|
-
.
|
|
1901
|
+
.option("--check", "Only check for updates, don't install")
|
|
1902
|
+
.option("--restart", "Restart Flair after upgrade")
|
|
1903
|
+
.action(async (opts) => {
|
|
1904
|
+
const { execSync } = await import("node:child_process");
|
|
1905
|
+
const checkOnly = opts.check ?? false;
|
|
965
1906
|
console.log("Checking for updates...\n");
|
|
966
1907
|
const packages = [
|
|
967
1908
|
"@tpsdev-ai/flair",
|
|
968
1909
|
"@tpsdev-ai/flair-client",
|
|
969
1910
|
"@tpsdev-ai/flair-mcp",
|
|
970
1911
|
];
|
|
1912
|
+
const upgrades = [];
|
|
971
1913
|
for (const pkg of packages) {
|
|
972
1914
|
try {
|
|
973
1915
|
const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, { signal: AbortSignal.timeout(5000) });
|
|
@@ -975,22 +1917,67 @@ program
|
|
|
975
1917
|
continue;
|
|
976
1918
|
const data = await res.json();
|
|
977
1919
|
const latest = data.version ?? "unknown";
|
|
978
|
-
// Check installed version
|
|
979
1920
|
let installed = "not installed";
|
|
980
1921
|
try {
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
const match = installed.match(/@(\d+\.\d+\.\d+)/);
|
|
1922
|
+
const out = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null || true`, { encoding: "utf-8" }).trim();
|
|
1923
|
+
const match = out.match(/@(\d+\.\d+[\d.a-z-]*)/);
|
|
984
1924
|
installed = match ? match[1] : "not installed";
|
|
985
1925
|
}
|
|
986
1926
|
catch { /* best effort */ }
|
|
987
1927
|
const upToDate = installed === latest;
|
|
988
1928
|
const icon = upToDate ? "✅" : "⬆️";
|
|
989
1929
|
console.log(` ${icon} ${pkg}: ${installed} → ${latest}${upToDate ? " (current)" : ""}`);
|
|
1930
|
+
if (!upToDate && installed !== "not installed") {
|
|
1931
|
+
upgrades.push({ pkg, installed, latest });
|
|
1932
|
+
}
|
|
990
1933
|
}
|
|
991
1934
|
catch { /* skip unavailable packages */ }
|
|
992
1935
|
}
|
|
993
|
-
|
|
1936
|
+
if (upgrades.length === 0) {
|
|
1937
|
+
console.log("\n✅ Everything is up to date.");
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (checkOnly) {
|
|
1941
|
+
console.log(`\n${upgrades.length} update${upgrades.length > 1 ? "s" : ""} available. Run: flair upgrade`);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
// Perform upgrade
|
|
1945
|
+
console.log(`\nUpgrading ${upgrades.length} package${upgrades.length > 1 ? "s" : ""}...\n`);
|
|
1946
|
+
for (const { pkg, latest } of upgrades) {
|
|
1947
|
+
try {
|
|
1948
|
+
console.log(` Installing ${pkg}@${latest}...`);
|
|
1949
|
+
execSync(`npm install -g ${pkg}@${latest}`, { stdio: "pipe" });
|
|
1950
|
+
console.log(` ✅ ${pkg}@${latest} installed`);
|
|
1951
|
+
}
|
|
1952
|
+
catch (err) {
|
|
1953
|
+
console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
if (opts.restart) {
|
|
1957
|
+
console.log("\nRestarting Flair...");
|
|
1958
|
+
try {
|
|
1959
|
+
const port = resolveHttpPort({});
|
|
1960
|
+
const label = "ai.tpsdev.flair";
|
|
1961
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
1962
|
+
if (process.platform === "darwin" && existsSync(plistPath)) {
|
|
1963
|
+
try {
|
|
1964
|
+
execSync(`launchctl stop ${label}`, { stdio: "pipe" });
|
|
1965
|
+
}
|
|
1966
|
+
catch { }
|
|
1967
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
1968
|
+
console.log("✅ Flair restarted with new version");
|
|
1969
|
+
}
|
|
1970
|
+
else {
|
|
1971
|
+
console.log("Run: flair restart");
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
console.log("Run: flair restart");
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
else {
|
|
1979
|
+
console.log("\nRun: flair restart to use the new version");
|
|
1980
|
+
}
|
|
994
1981
|
});
|
|
995
1982
|
// ─── flair stop ───────────────────────────────────────────────────────────────
|
|
996
1983
|
program
|
|
@@ -1035,6 +2022,83 @@ program
|
|
|
1035
2022
|
console.log("Flair is not running (nothing found on port " + port + ").");
|
|
1036
2023
|
}
|
|
1037
2024
|
});
|
|
2025
|
+
// ─── flair start ──────────────────────────────────────────────────────────────
|
|
2026
|
+
program
|
|
2027
|
+
.command("start")
|
|
2028
|
+
.description("Start Flair (Harper) — requires a prior 'flair init'")
|
|
2029
|
+
.option("--port <port>", "Harper HTTP port")
|
|
2030
|
+
.action(async (opts) => {
|
|
2031
|
+
const port = resolveHttpPort(opts);
|
|
2032
|
+
// Check if already running
|
|
2033
|
+
try {
|
|
2034
|
+
const res = await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
|
|
2035
|
+
if (res.status > 0) {
|
|
2036
|
+
console.log(`Flair is already running on port ${port}.`);
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
catch { /* not running — good */ }
|
|
2041
|
+
const dataDir = defaultDataDir();
|
|
2042
|
+
if (!existsSync(dataDir)) {
|
|
2043
|
+
console.error("❌ No Flair data directory found. Run 'flair init' first.");
|
|
2044
|
+
process.exit(1);
|
|
2045
|
+
}
|
|
2046
|
+
const platform = process.platform;
|
|
2047
|
+
if (platform === "darwin") {
|
|
2048
|
+
const label = "ai.tpsdev.flair";
|
|
2049
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
2050
|
+
if (existsSync(plistPath)) {
|
|
2051
|
+
try {
|
|
2052
|
+
const { execSync } = await import("node:child_process");
|
|
2053
|
+
try {
|
|
2054
|
+
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
2055
|
+
}
|
|
2056
|
+
catch { }
|
|
2057
|
+
execSync(`launchctl start ${label}`, { stdio: "pipe" });
|
|
2058
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
2059
|
+
console.log("✅ Flair started (launchd)");
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
catch (err) {
|
|
2063
|
+
console.error(`launchd start failed, falling back to direct start: ${err.message}`);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
// Direct start (Linux, or macOS fallback when no launchd plist)
|
|
2068
|
+
const bin = harperBin();
|
|
2069
|
+
if (!bin) {
|
|
2070
|
+
console.error("❌ Harper binary not found. Run 'flair init' first.");
|
|
2071
|
+
process.exit(1);
|
|
2072
|
+
}
|
|
2073
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
|
|
2074
|
+
const opsPort = resolveOpsPort(opts);
|
|
2075
|
+
const env = {
|
|
2076
|
+
...process.env,
|
|
2077
|
+
ROOTPATH: dataDir,
|
|
2078
|
+
DEFAULTS_MODE: "dev",
|
|
2079
|
+
HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
|
|
2080
|
+
HTTP_PORT: String(port),
|
|
2081
|
+
OPERATIONSAPI_NETWORK_PORT: String(opsPort),
|
|
2082
|
+
LOCAL_STUDIO: "false",
|
|
2083
|
+
};
|
|
2084
|
+
// Only set HDB_ADMIN_PASSWORD if we have a real value — empty string
|
|
2085
|
+
// would strip Harper's auth on an existing install
|
|
2086
|
+
if (adminPass) {
|
|
2087
|
+
env.HDB_ADMIN_PASSWORD = adminPass;
|
|
2088
|
+
}
|
|
2089
|
+
const proc = spawn(process.execPath, [bin, "run", "."], {
|
|
2090
|
+
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
2091
|
+
});
|
|
2092
|
+
proc.unref();
|
|
2093
|
+
try {
|
|
2094
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
2095
|
+
console.log(`✅ Flair started on port ${port}`);
|
|
2096
|
+
}
|
|
2097
|
+
catch {
|
|
2098
|
+
console.error("❌ Flair failed to start within timeout. Check logs in " + join(dataDir, "harper.log"));
|
|
2099
|
+
process.exit(1);
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
1038
2102
|
// ─── flair restart ────────────────────────────────────────────────────────────
|
|
1039
2103
|
program
|
|
1040
2104
|
.command("restart")
|
|
@@ -1280,80 +2344,71 @@ program
|
|
|
1280
2344
|
// ─── flair test ───────────────────────────────────────────────────────────────
|
|
1281
2345
|
program
|
|
1282
2346
|
.command("test")
|
|
1283
|
-
.description("Verify Flair
|
|
1284
|
-
.
|
|
2347
|
+
.description("Verify the full Flair stack: write, search, and delete a test memory")
|
|
2348
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
1285
2349
|
.option("--port <port>", "Harper HTTP port")
|
|
1286
2350
|
.action(async (opts) => {
|
|
1287
|
-
const
|
|
1288
|
-
const
|
|
1289
|
-
const agentId = opts.agent;
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
if (!existsSync(privPath)) {
|
|
1293
|
-
console.error(`❌ Key not found: ${privPath}`);
|
|
1294
|
-
console.error(` Run: flair init --agent-id ${agentId}`);
|
|
2351
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
2352
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
2353
|
+
const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
|
|
2354
|
+
if (!agentId && !process.env.FLAIR_ADMIN_PASS) {
|
|
2355
|
+
console.error(red("Error: set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS"));
|
|
1295
2356
|
process.exit(1);
|
|
1296
2357
|
}
|
|
1297
|
-
const
|
|
1298
|
-
|
|
2358
|
+
const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
2359
|
+
console.log(`\nFlair test (url: ${baseUrl})\n`);
|
|
1299
2360
|
let passed = 0;
|
|
1300
2361
|
let failed = 0;
|
|
2362
|
+
let memoryId = null;
|
|
1301
2363
|
const check = async (name, fn) => {
|
|
1302
2364
|
try {
|
|
1303
2365
|
const ok = await fn();
|
|
1304
2366
|
if (ok) {
|
|
1305
|
-
console.log(`
|
|
2367
|
+
console.log(` ${green("PASS")} ${name}`);
|
|
1306
2368
|
passed++;
|
|
1307
2369
|
}
|
|
1308
2370
|
else {
|
|
1309
|
-
console.log(`
|
|
2371
|
+
console.log(` ${red("FAIL")} ${name}`);
|
|
1310
2372
|
failed++;
|
|
1311
2373
|
}
|
|
1312
2374
|
}
|
|
1313
2375
|
catch (e) {
|
|
1314
|
-
console.log(`
|
|
2376
|
+
console.log(` ${red("FAIL")} ${name}: ${e.message?.slice(0, 120)}`);
|
|
1315
2377
|
failed++;
|
|
1316
2378
|
}
|
|
1317
2379
|
};
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
return
|
|
1331
|
-
});
|
|
1332
|
-
// 3. Search
|
|
1333
|
-
await check("Semantic search", async () => {
|
|
1334
|
-
await new Promise(r => setTimeout(r, 2000)); // wait for indexing
|
|
1335
|
-
const res = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
|
|
1336
|
-
agentId, q: "flair test memory", limit: 5,
|
|
1337
|
-
});
|
|
1338
|
-
if (!res.ok)
|
|
1339
|
-
return false;
|
|
1340
|
-
const data = await res.json();
|
|
1341
|
-
return (data.results?.length ?? 0) > 0;
|
|
2380
|
+
// 1. Write a test memory via POST /Memory
|
|
2381
|
+
await check("Write test memory (POST /Memory)", async () => {
|
|
2382
|
+
const body = {
|
|
2383
|
+
content: "flair test \u2014 this will be deleted",
|
|
2384
|
+
durability: "ephemeral",
|
|
2385
|
+
createdAt: new Date().toISOString(),
|
|
2386
|
+
};
|
|
2387
|
+
if (agentId)
|
|
2388
|
+
body.agentId = agentId;
|
|
2389
|
+
const result = await api("POST", "/Memory", body);
|
|
2390
|
+
// id may be returned directly or nested
|
|
2391
|
+
memoryId = result?.id ?? result?.[0]?.id ?? null;
|
|
2392
|
+
return !!memoryId || result?.ok === true;
|
|
1342
2393
|
});
|
|
1343
|
-
//
|
|
1344
|
-
await check("
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
return (data.context?.length ?? 0) > 0;
|
|
2394
|
+
// 2. Search for the test memory via POST /SemanticSearch
|
|
2395
|
+
await check("Search for test memory (POST /SemanticSearch)", async () => {
|
|
2396
|
+
await new Promise(r => setTimeout(r, 1500)); // allow indexing
|
|
2397
|
+
const body = { q: "flair test", limit: 5 };
|
|
2398
|
+
if (agentId)
|
|
2399
|
+
body.agentId = agentId;
|
|
2400
|
+
const result = await api("POST", "/SemanticSearch", body);
|
|
2401
|
+
return (result?.results?.length ?? 0) > 0;
|
|
1352
2402
|
});
|
|
1353
|
-
//
|
|
1354
|
-
await check("Memory
|
|
1355
|
-
|
|
1356
|
-
|
|
2403
|
+
// 3. Delete the test memory via DELETE /Memory/<id>
|
|
2404
|
+
await check("Delete test memory (DELETE /Memory/<id>)", async () => {
|
|
2405
|
+
if (!memoryId) {
|
|
2406
|
+
// If write returned ok without an id, skip deletion cleanly
|
|
2407
|
+
console.log(` (skipped — no id returned from write step)`);
|
|
2408
|
+
return true;
|
|
2409
|
+
}
|
|
2410
|
+
await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
|
|
2411
|
+
return true;
|
|
1357
2412
|
});
|
|
1358
2413
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
1359
2414
|
if (failed > 0)
|
|
@@ -2238,4 +3293,9 @@ program
|
|
|
2238
3293
|
}
|
|
2239
3294
|
}
|
|
2240
3295
|
});
|
|
2241
|
-
|
|
3296
|
+
// Run CLI only when this is the entry point (not when imported for testing)
|
|
3297
|
+
if (import.meta.main) {
|
|
3298
|
+
await program.parseAsync();
|
|
3299
|
+
}
|
|
3300
|
+
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
3301
|
+
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, signRequestBody, b64, b64url, program, };
|