@tpsdev-ai/flair 0.3.19 → 0.4.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/dist/cli.js CHANGED
@@ -7,8 +7,8 @@ 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
9
  // ─── Defaults ────────────────────────────────────────────────────────────────
10
- const DEFAULT_PORT = 9926;
11
- const DEFAULT_OPS_PORT = 9925;
10
+ const DEFAULT_PORT = 19926;
11
+ const DEFAULT_OPS_PORT = 19925;
12
12
  const DEFAULT_ADMIN_USER = "admin";
13
13
  const STARTUP_TIMEOUT_MS = 60_000;
14
14
  const HEALTH_POLL_INTERVAL_MS = 500;
@@ -18,6 +18,27 @@ function defaultKeysDir() {
18
18
  function defaultDataDir() {
19
19
  return join(homedir(), ".flair", "data");
20
20
  }
21
+ function configPath() {
22
+ return join(homedir(), ".flair", "config.yaml");
23
+ }
24
+ function readPortFromConfig() {
25
+ try {
26
+ const p = configPath();
27
+ if (existsSync(p)) {
28
+ const yaml = readFileSync(p, "utf-8");
29
+ const m = yaml.match(/port:\s*(\d+)/);
30
+ if (m)
31
+ return Number(m[1]);
32
+ }
33
+ }
34
+ catch { /* ignore */ }
35
+ return null;
36
+ }
37
+ function writeConfig(port) {
38
+ const p = configPath();
39
+ mkdirSync(join(homedir(), ".flair"), { recursive: true });
40
+ writeFileSync(p, `# Flair configuration\nport: ${port}\n`);
41
+ }
21
42
  function privKeyPath(agentId, keysDir) {
22
43
  return join(keysDir, `${agentId}.key`);
23
44
  }
@@ -48,17 +69,8 @@ function b64url(bytes) {
48
69
  }
49
70
  async function api(method, path, body) {
50
71
  // Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
51
- let defaultUrl = "http://127.0.0.1:9926";
52
- try {
53
- const configPath = join(homedir(), ".flair", "config.yaml");
54
- if (existsSync(configPath)) {
55
- const yaml = readFileSync(configPath, "utf-8");
56
- const portMatch = yaml.match(/port:\s*(\d+)/);
57
- if (portMatch)
58
- defaultUrl = `http://127.0.0.1:${portMatch[1]}`;
59
- }
60
- }
61
- catch { /* ignore config read errors */ }
72
+ const savedPort = readPortFromConfig();
73
+ const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
62
74
  const base = process.env.FLAIR_URL || defaultUrl;
63
75
  // Auth resolution order:
64
76
  // 1. FLAIR_TOKEN env → Bearer token (backward compat)
@@ -261,9 +273,19 @@ program
261
273
  if (!bin)
262
274
  throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
263
275
  mkdirSync(dataDir, { recursive: true });
276
+ const opsSocket = join(dataDir, "operations-server");
277
+ const harperSetConfig = JSON.stringify({
278
+ rootPath: dataDir,
279
+ http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
280
+ operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
281
+ mqtt: { network: { port: null }, webSocket: false },
282
+ localStudio: { enabled: false },
283
+ authentication: { authorizeLocal: true, enableSessions: true },
284
+ });
264
285
  const env = {
265
286
  ...process.env,
266
287
  ROOTPATH: dataDir,
288
+ HARPER_SET_CONFIG: harperSetConfig,
267
289
  DEFAULTS_MODE: "dev",
268
290
  HDB_ADMIN_USERNAME: adminUser,
269
291
  HDB_ADMIN_PASSWORD: adminPass,
@@ -273,7 +295,10 @@ program
273
295
  OPERATIONSAPI_NETWORK_PORT: String(opsPort),
274
296
  LOCAL_STUDIO: "false",
275
297
  };
276
- // Install
298
+ // Install Harper (creates system database, admin user, config file).
299
+ // IMPORTANT: Do NOT pre-create harper-config.yaml — Harper's install checks
300
+ // for its existence to detect existing installations. If found, it skips
301
+ // install and tries to read the (empty) database, causing a crash.
277
302
  console.log("Installing Harper...");
278
303
  await new Promise((resolve, reject) => {
279
304
  let output = "";
@@ -282,17 +307,21 @@ program
282
307
  install.stderr?.on("data", (d) => { output += d.toString(); });
283
308
  install.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`)));
284
309
  install.on("error", reject);
285
- setTimeout(() => { install.kill(); reject(new Error(`Harper install timed out: ${output}`)); }, 20_000);
310
+ setTimeout(() => { install.kill(); reject(new Error(`Harper install timed out: ${output}`)); }, 60_000);
286
311
  });
287
- // Start (detached)
312
+ // Start Harper in dev mode (detached). Dev mode sets authorizeLocal=true
313
+ // which allows our Ed25519 middleware to handle auth while internal
314
+ // cross-resource calls (e.g. SemanticSearch → Memory) pass through.
288
315
  console.log(`Starting Harper on port ${httpPort}...`);
289
- const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
316
+ const proc = spawn(process.execPath, [bin, "dev", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
290
317
  proc.unref();
291
318
  }
292
319
  console.log("Waiting for Harper health check...");
293
320
  await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
294
321
  console.log("Harper is healthy ✓");
295
322
  }
323
+ // Persist port to config so other commands can find this instance
324
+ writeConfig(httpPort);
296
325
  // Generate or reuse keypair
297
326
  mkdirSync(keysDir, { recursive: true });
298
327
  const privPath = privKeyPath(agentId, keysDir);
@@ -375,8 +404,10 @@ program
375
404
  }
376
405
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
377
406
  console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
407
+ const mcpEnv = { FLAIR_AGENT_ID: agentId };
408
+ mcpEnv.FLAIR_URL = httpUrl;
378
409
  console.log(`\n MCP config (.mcp.json):`);
379
- console.log(` { "mcpServers": { "flair": { "command": "npx", "args": ["@tpsdev-ai/flair-mcp"], "env": { "FLAIR_AGENT_ID": "${agentId}" } } } }`);
410
+ console.log(` { "mcpServers": { "flair": { "command": "npx", "args": ["@tpsdev-ai/flair-mcp"], "env": ${JSON.stringify(mcpEnv)} } } }`);
380
411
  });
381
412
  // ─── flair agent ─────────────────────────────────────────────────────────────
382
413
  const agent = program.command("agent").description("Manage Flair agents");
@@ -384,7 +415,7 @@ agent
384
415
  .command("add <id>")
385
416
  .description("Register a new agent in a running Flair instance")
386
417
  .option("--name <name>", "Display name (defaults to id)")
387
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
418
+ .option("--port <port>", "Harper HTTP port")
388
419
  .option("--admin-pass <pass>", "Admin password for registration")
389
420
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
390
421
  .option("--ops-port <port>", "Harper operations API port")
@@ -434,7 +465,7 @@ agent
434
465
  agent
435
466
  .command("rotate-key <id>")
436
467
  .description("Rotate an agent's Ed25519 keypair")
437
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
468
+ .option("--port <port>", "Harper HTTP port")
438
469
  .option("--ops-port <port>", "Harper operations API port")
439
470
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
440
471
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
@@ -512,7 +543,7 @@ agent
512
543
  .command("remove <id>")
513
544
  .description("Remove an agent and all its data from Flair")
514
545
  .option("--keep-keys", "Do not delete key files from disk")
515
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
546
+ .option("--port <port>", "Harper HTTP port")
516
547
  .option("--ops-port <port>", "Harper operations API port")
517
548
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
518
549
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
@@ -624,7 +655,7 @@ program
624
655
  .command("grant <from-agent> <to-agent>")
625
656
  .description("Grant an agent read access to another agent's memories")
626
657
  .option("--scope <scope>", "Grant scope: read or search", "read")
627
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
658
+ .option("--port <port>", "Harper HTTP port")
628
659
  .option("--ops-port <port>", "Harper operations API port")
629
660
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
630
661
  .option("--keys-dir <dir>", "Directory for Ed25519 keys (for from-agent Ed25519 auth)")
@@ -673,7 +704,7 @@ program
673
704
  program
674
705
  .command("revoke <from-agent> <to-agent>")
675
706
  .description("Revoke a memory grant between two agents")
676
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
707
+ .option("--port <port>", "Harper HTTP port")
677
708
  .option("--ops-port <port>", "Harper operations API port")
678
709
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
679
710
  .action(async (fromAgent, toAgent, opts) => {
@@ -714,10 +745,11 @@ program
714
745
  program
715
746
  .command("status")
716
747
  .description("Check Flair (Harper) instance health and agent count")
717
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
748
+ .option("--port <port>", "Harper HTTP port")
718
749
  .option("--url <url>", "Flair base URL (overrides --port)")
719
750
  .action(async (opts) => {
720
- const baseUrl = opts.url ?? `http://127.0.0.1:${opts.port}`;
751
+ const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
752
+ const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
721
753
  let healthy = false;
722
754
  let agentCount = null;
723
755
  let version = null;
@@ -788,36 +820,511 @@ program
788
820
  }
789
821
  console.log("\nTo upgrade: npm install -g @tpsdev-ai/flair@latest");
790
822
  });
791
- // ─── Legacy identity/memory/soul commands (preserved) ────────────────────────
792
- const identity = program.command("identity").description("Legacy identity commands");
793
- identity.command("register")
794
- .requiredOption("--id <id>")
795
- .requiredOption("--name <name>")
796
- .option("--role <role>")
823
+ // ─── flair stop ───────────────────────────────────────────────────────────────
824
+ program
825
+ .command("stop")
826
+ .description("Stop the running Flair (Harper) instance")
827
+ .option("--port <port>", "Harper HTTP port")
797
828
  .action(async (opts) => {
798
- const kp = nacl.sign.keyPair();
799
- const now = new Date().toISOString();
800
- const agentRecord = await api("POST", "/Agent", {
801
- id: opts.id, name: opts.name, role: opts.role,
802
- publicKey: b64(kp.publicKey), createdAt: now, updatedAt: now,
829
+ const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
830
+ const platform = process.platform;
831
+ if (platform === "darwin") {
832
+ // macOS: try launchd first
833
+ const label = "ai.tpsdev.flair";
834
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
835
+ if (existsSync(plistPath)) {
836
+ try {
837
+ const { execSync } = await import("node:child_process");
838
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
839
+ console.log("✅ Flair stopped (launchd service unloaded)");
840
+ return;
841
+ }
842
+ catch {
843
+ // launchd unload failed, try PID fallback
844
+ }
845
+ }
846
+ }
847
+ // Fallback: find process by port
848
+ try {
849
+ const { execSync } = await import("node:child_process");
850
+ const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
851
+ if (lsof) {
852
+ const pids = lsof.split("\n").map(p => p.trim()).filter(Boolean);
853
+ for (const pid of pids) {
854
+ process.kill(Number(pid), "SIGTERM");
855
+ }
856
+ console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
857
+ }
858
+ else {
859
+ console.log("Flair is not running.");
860
+ }
861
+ }
862
+ catch {
863
+ console.log("Flair is not running (nothing found on port " + port + ").");
864
+ }
865
+ });
866
+ // ─── flair restart ────────────────────────────────────────────────────────────
867
+ program
868
+ .command("restart")
869
+ .description("Restart the Flair (Harper) instance")
870
+ .option("--port <port>", "Harper HTTP port")
871
+ .action(async (opts) => {
872
+ const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
873
+ const platform = process.platform;
874
+ if (platform === "darwin") {
875
+ const label = "ai.tpsdev.flair";
876
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
877
+ if (existsSync(plistPath)) {
878
+ try {
879
+ const { execSync } = await import("node:child_process");
880
+ const uid = process.getuid?.() ?? 501;
881
+ execSync(`launchctl kickstart -k user/${uid}/${label}`, { stdio: "pipe" });
882
+ console.log("✅ Flair restarted (launchd kickstart)");
883
+ return;
884
+ }
885
+ catch (err) {
886
+ console.error(`launchd restart failed: ${err.message}`);
887
+ }
888
+ }
889
+ else {
890
+ console.error("❌ No launchd service found. Run 'flair init' first.");
891
+ process.exit(1);
892
+ }
893
+ }
894
+ else {
895
+ // Linux: stop + start via init
896
+ console.log("Stopping...");
897
+ try {
898
+ const { execSync } = await import("node:child_process");
899
+ const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
900
+ if (lsof) {
901
+ for (const pid of lsof.split("\n")) {
902
+ try {
903
+ process.kill(Number(pid.trim()), "SIGTERM");
904
+ }
905
+ catch { }
906
+ }
907
+ // Wait briefly for shutdown
908
+ await new Promise(r => setTimeout(r, 2000));
909
+ }
910
+ }
911
+ catch { /* not running */ }
912
+ console.log("Starting...");
913
+ const bin = harperBin();
914
+ if (!bin) {
915
+ console.error("❌ Harper binary not found. Run 'flair init' first.");
916
+ process.exit(1);
917
+ }
918
+ const dataDir = defaultDataDir();
919
+ const adminPass = process.env.HDB_ADMIN_PASSWORD ?? "";
920
+ const env = {
921
+ ...process.env,
922
+ ROOTPATH: dataDir,
923
+ DEFAULTS_MODE: "dev",
924
+ HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
925
+ HDB_ADMIN_PASSWORD: adminPass,
926
+ HTTP_PORT: String(port),
927
+ LOCAL_STUDIO: "false",
928
+ };
929
+ const proc = spawn(process.execPath, [bin, "run", "."], {
930
+ cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
931
+ });
932
+ proc.unref();
933
+ try {
934
+ await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
935
+ console.log("✅ Flair restarted");
936
+ }
937
+ catch {
938
+ console.error("❌ Flair failed to restart within timeout");
939
+ process.exit(1);
940
+ }
941
+ }
942
+ });
943
+ // ─── flair uninstall ──────────────────────────────────────────────────────────
944
+ program
945
+ .command("uninstall")
946
+ .description("Stop Flair and remove the launchd/systemd service")
947
+ .option("--purge", "Also remove data and keys (destructive)")
948
+ .action(async (opts) => {
949
+ const platform = process.platform;
950
+ const port = readPortFromConfig() ?? DEFAULT_PORT;
951
+ // Stop first
952
+ if (platform === "darwin") {
953
+ const label = "ai.tpsdev.flair";
954
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
955
+ if (existsSync(plistPath)) {
956
+ try {
957
+ const { execSync } = await import("node:child_process");
958
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
959
+ }
960
+ catch { /* best effort */ }
961
+ const { unlinkSync } = await import("node:fs");
962
+ unlinkSync(plistPath);
963
+ console.log("✅ Launchd service removed");
964
+ }
965
+ else {
966
+ console.log("No launchd service found — skipping");
967
+ }
968
+ }
969
+ else {
970
+ // Linux: kill by port
971
+ try {
972
+ const { execSync } = await import("node:child_process");
973
+ const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
974
+ if (lsof) {
975
+ for (const pid of lsof.split("\n")) {
976
+ try {
977
+ process.kill(Number(pid.trim()), "SIGTERM");
978
+ }
979
+ catch { }
980
+ }
981
+ }
982
+ }
983
+ catch { /* not running */ }
984
+ console.log("✅ Flair process stopped");
985
+ }
986
+ // Remove config
987
+ const cfgPath = configPath();
988
+ if (existsSync(cfgPath)) {
989
+ const { unlinkSync } = await import("node:fs");
990
+ unlinkSync(cfgPath);
991
+ console.log("✅ Config removed");
992
+ }
993
+ if (opts.purge) {
994
+ const { rmSync } = await import("node:fs");
995
+ const dataDir = defaultDataDir();
996
+ const keysDir = defaultKeysDir();
997
+ const flairDir = join(homedir(), ".flair");
998
+ if (existsSync(dataDir)) {
999
+ rmSync(dataDir, { recursive: true, force: true });
1000
+ console.log("✅ Data removed: " + dataDir);
1001
+ }
1002
+ if (existsSync(keysDir)) {
1003
+ rmSync(keysDir, { recursive: true, force: true });
1004
+ console.log("✅ Keys removed: " + keysDir);
1005
+ }
1006
+ // Remove .flair dir if empty
1007
+ try {
1008
+ const { readdirSync, rmdirSync } = await import("node:fs");
1009
+ if (existsSync(flairDir) && readdirSync(flairDir).length === 0) {
1010
+ rmdirSync(flairDir);
1011
+ }
1012
+ }
1013
+ catch { /* non-empty, that's fine */ }
1014
+ console.log("\n🗑️ Flair fully purged");
1015
+ }
1016
+ else {
1017
+ console.log("\nData and keys preserved at ~/.flair/");
1018
+ console.log("To remove everything: flair uninstall --purge");
1019
+ }
1020
+ });
1021
+ // ─── flair reembed ────────────────────────────────────────────────────────────
1022
+ program
1023
+ .command("reembed")
1024
+ .description("Re-generate embeddings for memories with stale or missing model tags")
1025
+ .requiredOption("--agent <id>", "Agent ID to re-embed memories for")
1026
+ .option("--stale-only", "Only re-embed memories with mismatched model tag")
1027
+ .option("--dry-run", "Show count without modifying")
1028
+ .option("--port <port>", "Harper HTTP port")
1029
+ .option("--batch-size <n>", "Records per batch", "50")
1030
+ .option("--delay-ms <ms>", "Delay between batches (ms)", "100")
1031
+ .action(async (opts) => {
1032
+ const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
1033
+ const baseUrl = `http://127.0.0.1:${port}`;
1034
+ const agentId = opts.agent;
1035
+ const staleOnly = opts.staleOnly ?? false;
1036
+ const dryRun = opts.dryRun ?? false;
1037
+ const batchSize = Number(opts.batchSize);
1038
+ const delayMs = Number(opts.delayMs);
1039
+ const currentModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
1040
+ console.log(`Re-embedding memories for agent: ${agentId}`);
1041
+ console.log(`Current model: ${currentModel}`);
1042
+ if (staleOnly)
1043
+ console.log("Mode: stale-only (skipping up-to-date memories)");
1044
+ if (dryRun)
1045
+ console.log("Mode: dry-run (no modifications)");
1046
+ console.log("");
1047
+ const keysDir = defaultKeysDir();
1048
+ const privPath = privKeyPath(agentId, keysDir);
1049
+ if (!existsSync(privPath)) {
1050
+ console.error(`❌ Key not found: ${privPath}`);
1051
+ process.exit(1);
1052
+ }
1053
+ const searchRes = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
1054
+ agentId, limit: 10000,
1055
+ });
1056
+ if (!searchRes.ok) {
1057
+ console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
1058
+ process.exit(1);
1059
+ }
1060
+ const data = await searchRes.json();
1061
+ const allMemories = data.results ?? [];
1062
+ const candidates = allMemories.filter((m) => {
1063
+ if (!m.content)
1064
+ return false;
1065
+ if (staleOnly)
1066
+ return !m.embeddingModel || m.embeddingModel !== currentModel;
1067
+ return true;
803
1068
  });
804
- console.log(JSON.stringify({ agent: agentRecord, privateKey: b64(kp.secretKey) }, null, 2));
1069
+ const total = candidates.length;
1070
+ const skipped = allMemories.length - total;
1071
+ console.log(`Total memories: ${allMemories.length}`);
1072
+ console.log(`Candidates for re-embedding: ${total}`);
1073
+ if (skipped > 0)
1074
+ console.log(`Skipped (up-to-date): ${skipped}`);
1075
+ if (dryRun || total === 0) {
1076
+ if (total === 0)
1077
+ console.log("\n✅ All memories are up-to-date!");
1078
+ return;
1079
+ }
1080
+ console.log("");
1081
+ let processed = 0;
1082
+ let errors = 0;
1083
+ for (let i = 0; i < candidates.length; i += batchSize) {
1084
+ const batch = candidates.slice(i, i + batchSize);
1085
+ for (const memory of batch) {
1086
+ try {
1087
+ const updateRes = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${memory.id}`, {
1088
+ id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined,
1089
+ });
1090
+ if (updateRes.ok)
1091
+ processed++;
1092
+ else
1093
+ errors++;
1094
+ }
1095
+ catch {
1096
+ errors++;
1097
+ }
1098
+ }
1099
+ const pct = Math.round(((i + batch.length) / total) * 100);
1100
+ process.stdout.write(`\rRe-embedded ${processed}/${total} (${pct}%)${errors > 0 ? ` [${errors} errors]` : ""}`);
1101
+ if (i + batchSize < candidates.length)
1102
+ await new Promise(r => setTimeout(r, delayMs));
1103
+ }
1104
+ console.log(`\n\n✅ Re-embedding complete: ${processed} updated, ${errors} errors`);
805
1105
  });
806
- identity.command("show").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Agent/${id}`), null, 2)));
807
- identity.command("list").action(async () => console.log(JSON.stringify(await api("GET", "/Agent"), null, 2)));
808
- identity.command("add-integration")
809
- .requiredOption("--agent <agentId>")
810
- .requiredOption("--platform <platform>")
811
- .requiredOption("--encrypted-credential <ciphertext>")
1106
+ // ─── flair test ───────────────────────────────────────────────────────────────
1107
+ program
1108
+ .command("test")
1109
+ .description("Verify Flair is working: store, search, bootstrap, cleanup")
1110
+ .requiredOption("--agent <id>", "Agent ID to test with")
1111
+ .option("--port <port>", "Harper HTTP port")
812
1112
  .action(async (opts) => {
813
- const now = new Date().toISOString();
814
- const out = await api("POST", "/Integration", {
815
- id: `${opts.agent}:${opts.platform}`, agentId: opts.agent,
816
- platform: opts.platform, encryptedCredential: opts.encryptedCredential,
817
- createdAt: now, updatedAt: now,
1113
+ const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
1114
+ const baseUrl = `http://127.0.0.1:${port}`;
1115
+ const agentId = opts.agent;
1116
+ const keysDir = defaultKeysDir();
1117
+ const privPath = privKeyPath(agentId, keysDir);
1118
+ if (!existsSync(privPath)) {
1119
+ console.error(`❌ Key not found: ${privPath}`);
1120
+ console.error(` Run: flair init --agent-id ${agentId}`);
1121
+ process.exit(1);
1122
+ }
1123
+ const testId = `test-${agentId}-${Date.now()}`;
1124
+ const testContent = `Flair test memory (${new Date().toISOString()})`;
1125
+ let passed = 0;
1126
+ let failed = 0;
1127
+ const check = async (name, fn) => {
1128
+ try {
1129
+ const ok = await fn();
1130
+ if (ok) {
1131
+ console.log(` ✅ ${name}`);
1132
+ passed++;
1133
+ }
1134
+ else {
1135
+ console.log(` ❌ ${name}`);
1136
+ failed++;
1137
+ }
1138
+ }
1139
+ catch (e) {
1140
+ console.log(` ❌ ${name}: ${e.message?.slice(0, 100)}`);
1141
+ failed++;
1142
+ }
1143
+ };
1144
+ console.log(`\nFlair test (agent: ${agentId}, url: ${baseUrl})\n`);
1145
+ // 1. Health
1146
+ await check("Health check", async () => {
1147
+ const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1148
+ return res.status > 0;
818
1149
  });
819
- console.log(JSON.stringify(out, null, 2));
1150
+ // 2. Store
1151
+ await check("Memory store", async () => {
1152
+ const res = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${testId}`, {
1153
+ id: testId, agentId, content: testContent, durability: "ephemeral",
1154
+ createdAt: new Date().toISOString(), archived: false,
1155
+ });
1156
+ return res.ok;
1157
+ });
1158
+ // 3. Search
1159
+ await check("Semantic search", async () => {
1160
+ await new Promise(r => setTimeout(r, 2000)); // wait for indexing
1161
+ const res = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
1162
+ agentId, q: "flair test memory", limit: 5,
1163
+ });
1164
+ if (!res.ok)
1165
+ return false;
1166
+ const data = await res.json();
1167
+ return (data.results?.length ?? 0) > 0;
1168
+ });
1169
+ // 4. Bootstrap
1170
+ await check("Bootstrap context", async () => {
1171
+ const res = await authFetch(baseUrl, agentId, privPath, "POST", "/BootstrapMemories", {
1172
+ agentId, maxTokens: 1000,
1173
+ });
1174
+ if (!res.ok)
1175
+ return false;
1176
+ const data = await res.json();
1177
+ return (data.context?.length ?? 0) > 0;
1178
+ });
1179
+ // 5. Cleanup
1180
+ await check("Memory delete", async () => {
1181
+ const res = await authFetch(baseUrl, agentId, privPath, "DELETE", `/Memory/${testId}`);
1182
+ return res.ok || res.status === 204;
1183
+ });
1184
+ console.log(`\n${passed} passed, ${failed} failed`);
1185
+ if (failed > 0)
1186
+ process.exit(1);
1187
+ });
1188
+ // ─── flair doctor ─────────────────────────────────────────────────────────────
1189
+ program
1190
+ .command("doctor")
1191
+ .description("Diagnose common Flair problems and suggest fixes")
1192
+ .option("--port <port>", "Harper HTTP port")
1193
+ .action(async (opts) => {
1194
+ const port = opts.port ? Number(opts.port) : (readPortFromConfig() ?? DEFAULT_PORT);
1195
+ const baseUrl = `http://127.0.0.1:${port}`;
1196
+ let issues = 0;
1197
+ console.log("\n🩺 Flair Doctor\n");
1198
+ // 1. Port check — is something listening?
1199
+ try {
1200
+ const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(3000) });
1201
+ if (res.status > 0) {
1202
+ console.log(` ✅ Harper responding on port ${port}`);
1203
+ }
1204
+ }
1205
+ catch {
1206
+ console.log(` ❌ Nothing responding on port ${port}`);
1207
+ // Check if port is in use by something else
1208
+ try {
1209
+ const { execSync } = await import("node:child_process");
1210
+ const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
1211
+ if (lsof) {
1212
+ console.log(` Port ${port} is in use by PID ${lsof} — might be a stale process`);
1213
+ console.log(` Fix: kill ${lsof} && flair restart`);
1214
+ }
1215
+ else {
1216
+ console.log(` Harper is not running`);
1217
+ console.log(` Fix: flair init --agent-id <your-agent>`);
1218
+ }
1219
+ }
1220
+ catch {
1221
+ console.log(` Harper is not running`);
1222
+ console.log(` Fix: flair init --agent-id <your-agent>`);
1223
+ }
1224
+ issues++;
1225
+ }
1226
+ // 2. Keys directory
1227
+ const keysDir = defaultKeysDir();
1228
+ if (existsSync(keysDir)) {
1229
+ const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
1230
+ if (keyFiles.length > 0) {
1231
+ console.log(` ✅ Keys found: ${keyFiles.length} agent(s) in ${keysDir}`);
1232
+ }
1233
+ else {
1234
+ console.log(` ❌ Keys directory exists but no .key files found`);
1235
+ console.log(` Fix: flair init --agent-id <your-agent>`);
1236
+ issues++;
1237
+ }
1238
+ }
1239
+ else {
1240
+ console.log(` ❌ Keys directory missing: ${keysDir}`);
1241
+ console.log(` Fix: flair init --agent-id <your-agent>`);
1242
+ issues++;
1243
+ }
1244
+ // 3. Config file
1245
+ const cfgPath = join(homedir(), ".flair", "config.yaml");
1246
+ if (existsSync(cfgPath)) {
1247
+ const savedPort = readPortFromConfig();
1248
+ console.log(` ✅ Config: ${cfgPath} (port: ${savedPort ?? "default"})`);
1249
+ }
1250
+ else {
1251
+ console.log(` ⚠️ No config file at ${cfgPath} — using defaults`);
1252
+ }
1253
+ // 4. Embeddings check (only if Harper is running)
1254
+ try {
1255
+ const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(3000) });
1256
+ if (res.status > 0) {
1257
+ // Try a test embedding via SemanticSearch with a dummy query
1258
+ // If embedding mode is "none", search will include _warning
1259
+ const testRes = await fetch(`${baseUrl}/SemanticSearch`, {
1260
+ method: "POST",
1261
+ headers: { "Content-Type": "application/json" },
1262
+ body: JSON.stringify({ q: "test", limit: 1 }),
1263
+ signal: AbortSignal.timeout(10000),
1264
+ });
1265
+ if (testRes.ok) {
1266
+ const data = await testRes.json();
1267
+ if (data._warning) {
1268
+ console.log(` ⚠️ Embeddings: keyword-only (${data._warning})`);
1269
+ console.log(` Semantic search quality is degraded`);
1270
+ console.log(` Check: ls ~/.npm-global/lib/node_modules/@tpsdev-ai/flair/models/`);
1271
+ issues++;
1272
+ }
1273
+ else {
1274
+ console.log(` ✅ Embeddings: semantic search operational`);
1275
+ }
1276
+ }
1277
+ else if (testRes.status === 401) {
1278
+ // Auth required — can't test embeddings without an agent key
1279
+ console.log(` ⚠️ Embeddings: cannot verify (auth required for SemanticSearch)`);
1280
+ }
1281
+ }
1282
+ }
1283
+ catch { /* Harper not running, already flagged above */ }
1284
+ // 5. Stale PID file
1285
+ const dataDir = defaultDataDir();
1286
+ const pidFile = join(dataDir, "hdb.pid");
1287
+ if (existsSync(pidFile)) {
1288
+ const pidContent = (await import("node:fs")).readFileSync(pidFile, "utf-8").trim();
1289
+ try {
1290
+ process.kill(Number(pidContent), 0); // check if process exists
1291
+ console.log(` ✅ PID file: ${pidFile} (process ${pidContent} is alive)`);
1292
+ }
1293
+ catch {
1294
+ console.log(` ❌ Stale PID file: ${pidFile} (process ${pidContent} is dead)`);
1295
+ console.log(` Fix: rm ${pidFile} && flair restart`);
1296
+ issues++;
1297
+ }
1298
+ }
1299
+ // 6. Data directory
1300
+ if (existsSync(dataDir)) {
1301
+ console.log(` ✅ Data directory: ${dataDir}`);
1302
+ }
1303
+ else {
1304
+ // Check ~/harper/ (common alternative)
1305
+ const altDir = join(homedir(), "harper");
1306
+ if (existsSync(altDir)) {
1307
+ console.log(` ⚠️ Data at ~/harper/ (not ~/.flair/data) — old install location`);
1308
+ }
1309
+ else {
1310
+ console.log(` ❌ No data directory found`);
1311
+ console.log(` Fix: flair init --agent-id <your-agent>`);
1312
+ issues++;
1313
+ }
1314
+ }
1315
+ // Summary
1316
+ console.log("");
1317
+ if (issues === 0) {
1318
+ console.log(" 🟢 No issues found");
1319
+ }
1320
+ else {
1321
+ console.log(` 🔴 ${issues} issue${issues > 1 ? "s" : ""} found — see fixes above`);
1322
+ }
1323
+ console.log("");
1324
+ if (issues > 0)
1325
+ process.exit(1);
820
1326
  });
1327
+ // ─── Memory and Soul commands ────────────────────────────────────────────────
821
1328
  const memory = program.command("memory").description("Manage agent memories");
822
1329
  memory.command("add").requiredOption("--agent <id>").requiredOption("--content <text>")
823
1330
  .option("--durability <d>", "standard").option("--tags <csv>")
@@ -843,7 +1350,7 @@ program
843
1350
  .description("Search memories by meaning (shortcut for memory search)")
844
1351
  .requiredOption("--agent <id>", "Agent ID")
845
1352
  .option("--limit <n>", "Max results", "5")
846
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1353
+ .option("--port <port>", "Harper HTTP port")
847
1354
  .option("--url <url>", "Flair base URL (overrides --port)")
848
1355
  .option("--key <path>", "Ed25519 private key path")
849
1356
  .action(async (query, opts) => {
@@ -888,7 +1395,7 @@ program
888
1395
  .description("Cold-start context: get soul + recent memories as formatted text")
889
1396
  .requiredOption("--agent <id>", "Agent ID")
890
1397
  .option("--max-tokens <n>", "Maximum tokens in output", "4000")
891
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1398
+ .option("--port <port>", "Harper HTTP port")
892
1399
  .option("--url <url>", "Flair base URL (overrides --port)")
893
1400
  .option("--key <path>", "Ed25519 private key path")
894
1401
  .action(async (opts) => {
@@ -938,7 +1445,7 @@ program
938
1445
  .description("Export agents, memories, and souls to a JSON archive")
939
1446
  .option("--output <path>", "Output file path (default: ~/.flair/backups/flair-backup-<timestamp>.json)")
940
1447
  .option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
941
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1448
+ .option("--port <port>", "Harper HTTP port")
942
1449
  .option("--url <url>", "Flair base URL (overrides --port)")
943
1450
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
944
1451
  .action(async (opts) => {
@@ -1017,7 +1524,7 @@ program
1017
1524
  .description("Import a Flair backup archive")
1018
1525
  .option("--merge", "Add/update records without deleting existing (default)")
1019
1526
  .option("--replace", "Delete all existing data for backed-up agents first, then import")
1020
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1527
+ .option("--port <port>", "Harper HTTP port")
1021
1528
  .option("--url <url>", "Flair base URL (overrides --port)")
1022
1529
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
1023
1530
  .option("--dry-run", "Show what would be imported without making changes")
@@ -1134,7 +1641,7 @@ program
1134
1641
  .description("Export a single agent's identity (soul + memories) to a portable file")
1135
1642
  .option("--output <path>", "Output file path")
1136
1643
  .option("--include-key", "Include private key in export (UNENCRYPTED — keep the output file secure)")
1137
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1644
+ .option("--port <port>", "Harper HTTP port")
1138
1645
  .option("--url <url>", "Flair base URL (overrides --port)")
1139
1646
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
1140
1647
  .option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
@@ -1220,7 +1727,7 @@ program
1220
1727
  program
1221
1728
  .command("import <path>")
1222
1729
  .description("Import an agent from an export file into this Flair instance")
1223
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1730
+ .option("--port <port>", "Harper HTTP port")
1224
1731
  .option("--ops-port <port>", "Harper operations API port")
1225
1732
  .option("--url <url>", "Flair base URL (overrides --port)")
1226
1733
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
@@ -1344,11 +1851,11 @@ program
1344
1851
  // ─── flair migrate-keys ───────────────────────────────────────────────────────
1345
1852
  program
1346
1853
  .command("migrate-keys")
1347
- .description("Migrate agent keys from legacy path (~/.tps/secrets/flair/) to ~/.flair/keys/")
1348
- .option("--from <dir>", "Legacy keys directory", join(homedir(), ".tps", "secrets", "flair"))
1854
+ .description("Migrate agent keys from old path (~/.tps/secrets/flair/) to ~/.flair/keys/")
1855
+ .option("--from <dir>", "Old keys directory", join(homedir(), ".tps", "secrets", "flair"))
1349
1856
  .option("--to <dir>", "New keys directory", defaultKeysDir())
1350
1857
  .option("--dry-run", "Show what would be migrated without copying")
1351
- .option("--port <port>", "Harper HTTP port (for agent list)", String(DEFAULT_PORT))
1858
+ .option("--port <port>", "Harper HTTP port")
1352
1859
  .option("--ops-port <port>", "Harper operations API port")
1353
1860
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
1354
1861
  .action(async (opts) => {
@@ -1358,7 +1865,7 @@ program
1358
1865
  const opsPort = opts.opsPort ? Number(opts.opsPort) : Number(opts.port) + 1;
1359
1866
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1360
1867
  if (!existsSync(fromDir)) {
1361
- console.log(`Legacy keys directory not found: ${fromDir}`);
1868
+ console.log(`Old keys directory not found: ${fromDir}`);
1362
1869
  console.log("Nothing to migrate.");
1363
1870
  process.exit(0);
1364
1871
  }
@@ -1400,7 +1907,7 @@ program
1400
1907
  else {
1401
1908
  console.log(`\n✅ Migration complete: ${migrated} migrated, ${skipped} skipped`);
1402
1909
  if (migrated > 0) {
1403
- console.log(`\nLegacy keys preserved at ${fromDir} (delete manually when confirmed working).`);
1910
+ console.log(`\nOld keys preserved at ${fromDir} (delete manually when confirmed working).`);
1404
1911
  // Optionally verify keys match Flair records
1405
1912
  if (adminPass) {
1406
1913
  console.log("\nVerifying migrated keys against Flair...");