@tpsdev-ai/flair 0.7.0 → 0.8.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
@@ -2,12 +2,12 @@
2
2
  import { Command } from "commander";
3
3
  import nacl from "tweetnacl";
4
4
  import { load as parseYaml } from "js-yaml";
5
- import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, } from "node:fs";
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
6
6
  import { homedir, hostname, tmpdir } from "node:os";
7
7
  import { join, resolve, sep } from "node:path";
8
8
  import { spawn } from "node:child_process";
9
9
  import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
10
- import { create as tarCreate } from "tar";
10
+ import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
11
11
  import { keystore } from "./keystore.js";
12
12
  import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
13
13
  import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
@@ -681,6 +681,82 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
681
681
  }
682
682
  }
683
683
  }
684
+ // ─── flair_pair_initiator role ──────────────────────────────────────────────
685
+ //
686
+ // Hub instances need a `flair_pair_initiator` role so that bootstrap credentials
687
+ // (created in PR-2) can pass platform auth on Harper Fabric before reaching the
688
+ // FederationPair resource handler. The role carries no table permissions itself
689
+ // — the resource's own allowCreate bypass handles route-level access once the
690
+ // request gets through the auth gate.
691
+ /** Canonical permission spec for flair_pair_initiator. */
692
+ const PAIR_INITIATOR_PERMISSION = {
693
+ super_user: false,
694
+ cluster_user: false,
695
+ structure_user: false,
696
+ flair: {
697
+ tables: {
698
+ Memory: { read: false, insert: false, update: false, delete: false },
699
+ Soul: { read: false, insert: false, update: false, delete: false },
700
+ Agent: { read: false, insert: false, update: false, delete: false },
701
+ Workspace: { read: false, insert: false, update: false, delete: false },
702
+ Event: { read: false, insert: false, update: false, delete: false },
703
+ OAuth: { read: false, insert: false, update: false, delete: false },
704
+ Instance: { read: false, insert: false, update: false, delete: false },
705
+ Peer: { read: false, insert: false, update: false, delete: false },
706
+ PairingToken: { read: false, insert: false, update: false, delete: false },
707
+ SyncLog: { read: false, insert: false, update: false, delete: false },
708
+ },
709
+ },
710
+ };
711
+ /**
712
+ * Idempotently ensures the `flair_pair_initiator` role exists on the Harper
713
+ * instance at `opsUrl` with the canonical permission spec.
714
+ *
715
+ * - If the role is absent → `add_role`
716
+ * - If it exists with different permissions → `alter_role` to bring it into spec
717
+ * - If it already matches → no-op
718
+ */
719
+ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass) {
720
+ const ROLE_NAME = "flair_pair_initiator";
721
+ // 1. Check for existing role
722
+ let roles = [];
723
+ try {
724
+ const result = await callOpsApi(opsUrl, { operation: "list_roles" }, adminUser, adminPass);
725
+ roles = Array.isArray(result) ? result : [];
726
+ }
727
+ catch (err) {
728
+ const msg = err instanceof Error ? err.message : String(err);
729
+ throw new Error(`ensureFlairPairInitiatorRole: list_roles failed: ${msg}`);
730
+ }
731
+ const existing = roles.find((r) => r.role === ROLE_NAME || r.name === ROLE_NAME);
732
+ if (!existing) {
733
+ // 2a. Role absent → create it
734
+ console.log(`Creating role '${ROLE_NAME}'...`);
735
+ await callOpsApi(opsUrl, {
736
+ operation: "add_role",
737
+ role: ROLE_NAME,
738
+ permission: PAIR_INITIATOR_PERMISSION,
739
+ }, adminUser, adminPass);
740
+ console.log(`Role '${ROLE_NAME}' created ✓`);
741
+ return;
742
+ }
743
+ // 2b. Role exists — check if permissions match the canonical spec
744
+ const existingPerm = existing.permission ?? existing.role?.permission;
745
+ const canonicalStr = JSON.stringify(PAIR_INITIATOR_PERMISSION);
746
+ const existingStr = JSON.stringify(existingPerm);
747
+ if (existingStr === canonicalStr) {
748
+ console.log(`Role '${ROLE_NAME}' already exists with correct permissions — skipping`);
749
+ return;
750
+ }
751
+ // 2c. Permissions differ → bring into spec via alter_role
752
+ console.log(`Role '${ROLE_NAME}' exists but permissions differ — updating...`);
753
+ await callOpsApi(opsUrl, {
754
+ operation: "alter_role",
755
+ role: ROLE_NAME,
756
+ permission: PAIR_INITIATOR_PERMISSION,
757
+ }, adminUser, adminPass);
758
+ console.log(`Role '${ROLE_NAME}' updated ✓`);
759
+ }
684
760
  // ─── Upgrade presence probes ──────────────────────────────────────────────────
685
761
  //
686
762
  // `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
@@ -726,6 +802,35 @@ export function probeLibVersion(pkgName) {
726
802
  return null;
727
803
  }
728
804
  }
805
+ /**
806
+ * Read the version of an OpenClaw plugin from `~/.openclaw/extensions/<name>/package.json`.
807
+ *
808
+ * `flair upgrade` uses this to surface the installed `@tpsdev-ai/openclaw-flair`
809
+ * version even though it isn't a globally-installed bin or a flair lib dep.
810
+ * Returns null if openclaw isn't installed, the extension isn't installed, or
811
+ * the package.json can't be parsed.
812
+ *
813
+ * @param extensionName — the directory name under `~/.openclaw/extensions/`
814
+ * (typically the plugin name without scope, e.g. `openclaw-flair`)
815
+ */
816
+ export function probeOpenclawPluginVersion(extensionName) {
817
+ try {
818
+ const { existsSync, readFileSync } = require("node:fs");
819
+ const { homedir } = require("node:os");
820
+ const { resolve } = require("node:path");
821
+ // process.env.HOME first so tests can override; homedir() as fallback —
822
+ // homedir() doesn't honor runtime HOME changes (caches at module load).
823
+ const home = process.env.HOME ?? homedir();
824
+ const pkgJsonPath = resolve(home, ".openclaw", "extensions", extensionName, "package.json");
825
+ if (!existsSync(pkgJsonPath))
826
+ return null;
827
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
828
+ return typeof pkg.version === "string" ? pkg.version : null;
829
+ }
830
+ catch {
831
+ return null;
832
+ }
833
+ }
729
834
  export function templateSoul(choice) {
730
835
  const templates = {
731
836
  "1": [
@@ -957,6 +1062,12 @@ program
957
1062
  // Atomic provisioning: deploy + wait + provision user
958
1063
  await provisionFabric(baseUrl, opsUrl, clusterAdminUser, clusterAdminPass, flairAdminPass);
959
1064
  didProvision = true;
1065
+ // Hub instances (--remote) receive federation pair requests and need
1066
+ // the flair_pair_initiator role so bootstrap credentials can pass
1067
+ // platform auth before reaching the FederationPair resource handler.
1068
+ if (opts.remote) {
1069
+ await ensureFlairPairInitiatorRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
1070
+ }
960
1071
  }
961
1072
  else {
962
1073
  // ── Existing behavior: --admin-pass required for already-running Flair ──
@@ -1643,8 +1754,8 @@ program
1643
1754
  // all/none handled below
1644
1755
  }
1645
1756
  else {
1646
- // Filter to only the selected client
1647
- clients = [{ id: selectedClients[0], label: selectedClients[0], detected: true }];
1757
+ // Filter to only the selected client (preserving wire function from detectClients)
1758
+ clients = clients.filter(c => selectedClients.includes(c.id));
1648
1759
  }
1649
1760
  }
1650
1761
  if (!clientOpt && !noMcp) {
@@ -1717,7 +1828,7 @@ program
1717
1828
  let result;
1718
1829
  switch (clientId) {
1719
1830
  case "claude-code":
1720
- result = wireClaudeCode(mcpEnv, httpUrl);
1831
+ result = wireClaudeCode(mcpEnv);
1721
1832
  break;
1722
1833
  case "codex":
1723
1834
  result = wireCodex(mcpEnv);
@@ -2595,9 +2706,19 @@ async function loadInstanceSecretKey(instanceId, opts) {
2595
2706
  * Sign a request body and return a new body with the signature field added.
2596
2707
  */
2597
2708
  function signRequestBody(body, secretKey) {
2598
- const sig = signBody(body, secretKey);
2599
- return { ...body, signature: sig };
2709
+ // Fresh signing with anti-replay: embeds _ts and _nonce before signing.
2710
+ // Equivalent to federation-crypto.ts signBodyFresh — duplicated here because
2711
+ // the CLI module has its own local signBody for dependency isolation.
2712
+ const freshBody = {
2713
+ ...body,
2714
+ _ts: Date.now(),
2715
+ _nonce: Buffer.from(nacl.randomBytes(16)).toString("base64url"),
2716
+ };
2717
+ const sig = signBody(freshBody, secretKey);
2718
+ return { ...freshBody, signature: sig };
2600
2719
  }
2720
+ // Alias: signBodyFresh for clarity at call sites
2721
+ const signBodyFresh = signRequestBody;
2601
2722
  // ─── flair federation ────────────────────────────────────────────────────────
2602
2723
  const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
2603
2724
  federation
@@ -2633,13 +2754,158 @@ federation
2633
2754
  process.exit(1);
2634
2755
  }
2635
2756
  });
2757
+ // `flair federation reachability` — probe local instance + all paired peers.
2758
+ // Productizes ~/ops/scripts/flair-boot-probe.sh: a single command that tells
2759
+ // you whether memories CAN flow across the federation right now. Read-only;
2760
+ // no mutations, no side effects beyond a single tagged status read per peer.
2761
+ federation
2762
+ .command("reachability")
2763
+ .description("Probe local Flair + each paired peer for reachability (read-only)")
2764
+ .option("--port <port>", "Harper HTTP port")
2765
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2766
+ .option("--quiet", "Suppress output on full success")
2767
+ .option("--json", "Emit machine-readable JSON instead of text")
2768
+ .option("--peer-timeout <seconds>", "HTTP timeout per peer probe (default 5)", "5")
2769
+ .action(async (opts) => {
2770
+ const target = resolveTarget(opts);
2771
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
2772
+ const timeoutMs = (Number(opts.peerTimeout) || 5) * 1000;
2773
+ const results = [];
2774
+ // 1. Local probe.
2775
+ try {
2776
+ const inst = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
2777
+ results.push({ host: "local", port: null, status: "ok", detail: `instance ${inst.id} (${inst.role}, ${inst.status})` });
2778
+ }
2779
+ catch (e) {
2780
+ results.push({ host: "local", port: null, status: "fail", detail: e.message });
2781
+ }
2782
+ // 2. Per-peer probes. For each peer with an `endpoint` (URL), probe it.
2783
+ // Peers without an endpoint are reverse-tunnel-paired (the spoke can't
2784
+ // reach the hub directly without the tunnel) and we skip.
2785
+ let peers = [];
2786
+ try {
2787
+ const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
2788
+ peers = r.peers ?? [];
2789
+ }
2790
+ catch (e) {
2791
+ results.push({ host: "/FederationPeers", port: null, status: "fail", detail: e.message });
2792
+ }
2793
+ for (const p of peers) {
2794
+ const endpoint = p.endpoint;
2795
+ if (!endpoint) {
2796
+ results.push({ host: p.id, port: null, status: "skip", detail: `${p.role ?? "—"} (no endpoint — needs tunnel)` });
2797
+ continue;
2798
+ }
2799
+ // Any HTTP response (including 401) means the peer is reachable + responding.
2800
+ // We're checking the network path, not auth; 401 is expected for unauth probes.
2801
+ // Use new URL() to avoid path-swallowing when endpoint includes a query
2802
+ // (Sherlock review on #314).
2803
+ let probeUrl;
2804
+ try {
2805
+ probeUrl = new URL("/Health", endpoint);
2806
+ }
2807
+ catch {
2808
+ results.push({ host: p.id, port: null, status: "fail", detail: `${p.role ?? "—"} invalid endpoint URL` });
2809
+ continue;
2810
+ }
2811
+ if (probeUrl.protocol !== "http:" && probeUrl.protocol !== "https:") {
2812
+ results.push({ host: p.id, port: null, status: "fail", detail: `${p.role ?? "—"} unsupported protocol ${probeUrl.protocol}` });
2813
+ continue;
2814
+ }
2815
+ try {
2816
+ const ctrl = new AbortController();
2817
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
2818
+ const res = await fetch(probeUrl, { signal: ctrl.signal });
2819
+ clearTimeout(t);
2820
+ results.push({ host: p.id, port: null, status: "ok", detail: `${p.role ?? "—"} HTTP ${res.status}` });
2821
+ }
2822
+ catch (e) {
2823
+ const msg = e.name === "AbortError" ? `timeout after ${opts.peerTimeout}s` : e.message;
2824
+ results.push({ host: p.id, port: null, status: "fail", detail: `${p.role ?? "—"} ${msg}` });
2825
+ }
2826
+ }
2827
+ const failures = results.filter(r => r.status === "fail").length;
2828
+ if (opts.json) {
2829
+ console.log(JSON.stringify({ ts: new Date().toISOString(), failures, results }, null, 2));
2830
+ }
2831
+ else if (!(opts.quiet && failures === 0)) {
2832
+ console.log(`── Flair reachability — ${new Date().toISOString()} ──`);
2833
+ for (const r of results) {
2834
+ const tag = r.status === "ok" ? "OK " : r.status === "skip" ? "SKIP" : "FAIL";
2835
+ console.log(`${tag} ${r.host.padEnd(40)} ${r.detail}`);
2836
+ }
2837
+ if (failures > 0) {
2838
+ console.log(`── ${failures} path(s) FAILED ──`);
2839
+ }
2840
+ else {
2841
+ console.log("── all reachable ──");
2842
+ }
2843
+ }
2844
+ if (failures > 0)
2845
+ process.exit(1);
2846
+ });
2847
+ /** Parse a JSON triple file for --token-from.
2848
+ * Expected shape: { "token": "...", "user": "pair-bootstrap-<id>", "password": "...", "expiresAt": "<ISO>" }
2849
+ * Returns the triple on success. Validation failures exit(1).
2850
+ */
2851
+ function parseTokenFromFile(filePath) {
2852
+ let raw;
2853
+ if (filePath === "-") {
2854
+ raw = readFileSync("/dev/stdin", "utf-8");
2855
+ }
2856
+ else {
2857
+ if (!existsSync(filePath)) {
2858
+ console.error(`Error: --token-from file not found: ${filePath}`);
2859
+ process.exit(1);
2860
+ }
2861
+ raw = readFileSync(filePath, "utf-8");
2862
+ }
2863
+ let parsed;
2864
+ try {
2865
+ parsed = JSON.parse(raw);
2866
+ }
2867
+ catch {
2868
+ console.error(`Error: --token-from file is not valid JSON: ${filePath}`);
2869
+ process.exit(1);
2870
+ }
2871
+ // Validate all four fields present and non-empty
2872
+ const required = ["token", "user", "password", "expiresAt"];
2873
+ for (const field of required) {
2874
+ if (!parsed[field] || typeof parsed[field] !== "string" || parsed[field].trim() === "") {
2875
+ console.error(`Error: --token-from JSON is missing or has empty required field "${field}"`);
2876
+ process.exit(1);
2877
+ }
2878
+ }
2879
+ // Validate expiresAt is a parseable date and is in the future
2880
+ const expiry = new Date(parsed.expiresAt);
2881
+ if (isNaN(expiry.getTime())) {
2882
+ console.error(`Error: --token-from JSON has invalid expiresAt date: "${parsed.expiresAt}"`);
2883
+ process.exit(1);
2884
+ }
2885
+ const now = new Date();
2886
+ if (expiry <= now) {
2887
+ console.error(`Error: --token-from JSON has expired token (expiresAt: ${parsed.expiresAt})`);
2888
+ process.exit(1);
2889
+ }
2890
+ const fiveMin = 5 * 60 * 1000;
2891
+ if (expiry.getTime() - now.getTime() < fiveMin) {
2892
+ console.error(`warning: pairing token expires in less than 5 minutes (expiresAt: ${parsed.expiresAt})`);
2893
+ }
2894
+ return {
2895
+ token: parsed.token.trim(),
2896
+ user: parsed.user.trim(),
2897
+ password: parsed.password.trim(),
2898
+ expiresAt: parsed.expiresAt.trim(),
2899
+ };
2900
+ }
2636
2901
  federation
2637
2902
  .command("pair <hub-url>")
2638
2903
  .description("Pair this spoke with a hub instance")
2639
2904
  .option("--port <port>", "Harper HTTP port")
2640
2905
  .option("--admin-pass <pass>", "Admin password")
2641
2906
  .option("--ops-port <port>", "Harper operations API port")
2642
- .option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN)")
2907
+ .option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN) [deprecated: use --token-from]")
2908
+ .option("--token-from <file>", "Read bootstrap triple from JSON file (use '-' for stdin)")
2643
2909
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2644
2910
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
2645
2911
  .action(async (hubUrl, opts) => {
@@ -2648,34 +2914,50 @@ federation
2648
2914
  try {
2649
2915
  const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
2650
2916
  console.log(`${target ? "Remote" : "Local"} instance: ${instance.id} (${instance.role})`);
2651
- if (!opts.token) {
2652
- console.error("Error: --token is required. Ask the hub admin to run 'flair federation token' and provide the token.");
2917
+ // Determine token source: --token-from wins if both specified
2918
+ if (opts.tokenFrom && opts.token) {
2919
+ console.error("warning: --token-from takes precedence over --token. The --token flag is deprecated; use --token-from <file> instead.");
2920
+ }
2921
+ let pairingToken;
2922
+ let authHeader;
2923
+ if (opts.tokenFrom) {
2924
+ // ── Bootstrap triple path (--token-from) ──
2925
+ const triple = parseTokenFromFile(opts.tokenFrom);
2926
+ pairingToken = triple.token;
2927
+ authHeader = `Basic ${Buffer.from(`${triple.user}:${triple.password}`).toString("base64")}`;
2928
+ console.log(`Using bootstrap user: ${triple.user}`);
2929
+ }
2930
+ else if (opts.token) {
2931
+ // ── Bare token path (--token) — deprecated ──
2932
+ pairingToken = opts.token || process.env.FLAIR_PAIRING_TOKEN;
2933
+ console.error("warning: --token is deprecated. Use --token-from <file> to keep credentials out of shell history.");
2934
+ // Warning: inline token may leak to shell history.
2935
+ const tokenFromEnv = !opts.token && !!process.env.FLAIR_PAIRING_TOKEN;
2936
+ if (shouldShowInlineSecretWarning(opts.token, tokenFromEnv, new Set(["--token"]), "--token")) {
2937
+ console.error("warning: --token passed inline. Consider --token-from <file> or FLAIR_PAIRING_TOKEN env " +
2938
+ "to keep secrets out of shell history.");
2939
+ }
2940
+ }
2941
+ else {
2942
+ console.error("Error: --token or --token-from is required. Ask the hub admin to run 'flair federation token' and provide the token.");
2653
2943
  process.exit(1);
2654
2944
  }
2655
- // Warning: inline token may leak to shell history.
2656
- // fromEnv is true ONLY when the resolved value came from env (no inline override).
2657
- const tokenFromEnv = !opts.token && !!process.env.FLAIR_PAIRING_TOKEN;
2658
- if (shouldShowInlineSecretWarning(opts.token, tokenFromEnv, new Set(["--token"]), "--token")) {
2659
- console.error("warning: --token passed inline. Consider --token-from <file> or FLAIR_PAIRING_TOKEN env " +
2660
- "to keep secrets out of shell history.");
2661
- }
2662
- // Load secret key and sign the pairing request. The pairing token is
2663
- // included in the signed body (not in an Authorization header) because
2664
- // Harper's auth layer claims any "Bearer X" Authorization header for
2665
- // itself and 401s before our resource ever runs.
2945
+ // Load secret key and sign the pairing request.
2666
2946
  const secretKey = await loadInstanceSecretKey(instance.id, opts);
2667
- // Env var fallback for --token: FLAIR_PAIRING_TOKEN
2668
- const pairingToken = opts.token || process.env.FLAIR_PAIRING_TOKEN;
2669
2947
  const pairBody = {
2670
2948
  instanceId: instance.id,
2671
2949
  publicKey: instance.publicKey,
2672
2950
  role: "spoke",
2673
2951
  pairingToken,
2674
2952
  };
2675
- const signedBody = signRequestBody(pairBody, secretKey);
2953
+ const signedBody = signBodyFresh(pairBody, secretKey);
2954
+ const fetchHeaders = { "Content-Type": "application/json" };
2955
+ if (authHeader) {
2956
+ fetchHeaders.Authorization = authHeader;
2957
+ }
2676
2958
  const res = await fetch(`${hubUrl}/FederationPair`, {
2677
2959
  method: "POST",
2678
- headers: { "Content-Type": "application/json" },
2960
+ headers: fetchHeaders,
2679
2961
  body: JSON.stringify(signedBody),
2680
2962
  });
2681
2963
  if (!res.ok) {
@@ -2722,17 +3004,18 @@ federation
2722
3004
  .option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
2723
3005
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2724
3006
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
3007
+ .option("--format <format>", "Output format: json (default) or text (bare token, deprecated)", "json")
2725
3008
  .action(async (opts) => {
2726
3009
  const target = resolveTarget(opts);
2727
3010
  const baseUrl = target ? target.replace(/\/$/, "") : undefined;
2728
3011
  try {
2729
- const { randomBytes } = await import("node:crypto");
2730
3012
  const token = randomBytes(24).toString("base64url");
2731
3013
  const ttlMinutes = parseInt(opts.ttl, 10) || 60;
2732
3014
  const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
2733
3015
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
2734
3016
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
2735
3017
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
3018
+ // 1. Persist the PairingToken record
2736
3019
  const opsRes = await fetch(`${opsEndpoint}/`, {
2737
3020
  method: "POST",
2738
3021
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -2750,10 +3033,64 @@ federation
2750
3033
  const detail = await opsRes.text().catch(() => "");
2751
3034
  throw new Error(`Failed to persist pairing token (${opsRes.status}): ${detail || "no body"}`);
2752
3035
  }
2753
- console.log(`Pairing token (expires in ${ttlMinutes}m):`);
2754
- console.log(` ${token}`);
2755
- console.log(`\nGive this to the spoke admin to run:`);
2756
- console.log(` flair federation pair <this-hub-url> --token ${token}`);
3036
+ // 2. Create bootstrap user for this token
3037
+ const bootstrapPassword = randomBytes(32).toString("base64url");
3038
+ const bootstrapUsername = `pair-bootstrap-${token.slice(0, 8)}`;
3039
+ let addUserRes;
3040
+ try {
3041
+ addUserRes = await fetch(`${opsEndpoint}/`, {
3042
+ method: "POST",
3043
+ headers: { "Content-Type": "application/json", Authorization: auth },
3044
+ body: JSON.stringify({
3045
+ operation: "add_user",
3046
+ username: bootstrapUsername,
3047
+ password: bootstrapPassword,
3048
+ role: "flair_pair_initiator",
3049
+ active: true,
3050
+ }),
3051
+ signal: AbortSignal.timeout(10_000),
3052
+ });
3053
+ }
3054
+ catch (err) {
3055
+ // Network failure creating bootstrap user — roll back PairingToken
3056
+ await fetch(`${opsEndpoint}/`, {
3057
+ method: "POST",
3058
+ headers: { "Content-Type": "application/json", Authorization: auth },
3059
+ body: JSON.stringify({
3060
+ operation: "delete",
3061
+ database: "flair",
3062
+ table: "PairingToken",
3063
+ hash_value: token,
3064
+ }),
3065
+ signal: AbortSignal.timeout(10_000),
3066
+ }).catch(() => { });
3067
+ throw new Error(`Failed to create bootstrap user (network): ${err.message}`);
3068
+ }
3069
+ if (!addUserRes.ok) {
3070
+ // add_user failed — roll back PairingToken so the two stay in sync
3071
+ await fetch(`${opsEndpoint}/`, {
3072
+ method: "POST",
3073
+ headers: { "Content-Type": "application/json", Authorization: auth },
3074
+ body: JSON.stringify({
3075
+ operation: "delete",
3076
+ database: "flair",
3077
+ table: "PairingToken",
3078
+ hash_value: token,
3079
+ }),
3080
+ signal: AbortSignal.timeout(10_000),
3081
+ }).catch(() => { });
3082
+ const detail = await addUserRes.text().catch(() => "");
3083
+ throw new Error(`Failed to create bootstrap user (${addUserRes.status}): ${detail || "no body"}`);
3084
+ }
3085
+ // 3. Output
3086
+ const format = (opts.format ?? "json").toLowerCase();
3087
+ if (format === "text") {
3088
+ process.stderr.write(`[DEPRECATION] --format text is deprecated. Default output is now JSON.\n`);
3089
+ console.log(token);
3090
+ }
3091
+ else {
3092
+ console.log(JSON.stringify({ token, user: bootstrapUsername, password: bootstrapPassword, expiresAt }, null, 2));
3093
+ }
2757
3094
  }
2758
3095
  catch (err) {
2759
3096
  console.error(`Error: ${err.message}`);
@@ -2764,6 +3101,8 @@ export async function runFederationSyncOnce(opts) {
2764
3101
  const target = resolveTarget(opts);
2765
3102
  const baseUrl = target ? target.replace(/\/$/, "") : undefined;
2766
3103
  const apiOpts = baseUrl ? { baseUrl } : undefined;
3104
+ let totalMerged = 0;
3105
+ let totalSkipped = 0;
2767
3106
  try {
2768
3107
  const { peers } = await api("GET", "/FederationPeers", undefined, apiOpts);
2769
3108
  const hub = peers.find((p) => p.role === "hub" && p.status !== "revoked");
@@ -2776,8 +3115,35 @@ export async function runFederationSyncOnce(opts) {
2776
3115
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
2777
3116
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
2778
3117
  const tables = ["Memory", "Soul", "Agent", "Relationship"];
2779
- const records = [];
2780
3118
  const instance = await api("GET", "/FederationInstance", undefined, apiOpts);
3119
+ const hubUrl = hub.endpoint ?? hub.id;
3120
+ // ── Batching constants ──────────────────────────────────────────────
3121
+ // 2MB JSON budget (server cap is 10MB; 2MB leaves headroom for headers
3122
+ // and signature metadata) + 200 records max per batch.
3123
+ const BUDGET_BYTES = 2_000_000;
3124
+ const BUDGET_RECORDS = 200;
3125
+ // ── sendBatch helper ────────────────────────────────────────────────
3126
+ // Secret key is lazy-loaded: only needed when there are records to send.
3127
+ // Loading earlier would cause a spurious error when SQL queries fail
3128
+ // (e.g. 401) before we know we have records.
3129
+ let secretKey;
3130
+ async function sendBatch(batch) {
3131
+ if (!secretKey)
3132
+ secretKey = await loadInstanceSecretKey(instance.id, opts);
3133
+ const syncBody = { instanceId: instance.id, records: batch, lamportClock: Date.now() };
3134
+ const signedSyncBody = signBodyFresh(syncBody, secretKey);
3135
+ const syncRes = await fetch(`${hubUrl}/FederationSync`, {
3136
+ method: "POST",
3137
+ headers: { "Content-Type": "application/json" },
3138
+ body: JSON.stringify(signedSyncBody),
3139
+ });
3140
+ if (!syncRes.ok) {
3141
+ const text = await syncRes.text().catch(() => "");
3142
+ throw new Error(`Sync batch failed: ${syncRes.status} ${text}`);
3143
+ }
3144
+ return await syncRes.json();
3145
+ }
3146
+ let totalBatches = 0;
2781
3147
  for (const table of tables) {
2782
3148
  let res;
2783
3149
  try {
@@ -2789,39 +3155,49 @@ export async function runFederationSyncOnce(opts) {
2789
3155
  });
2790
3156
  }
2791
3157
  catch (err) {
2792
- return { pushed: 0, skipped: 0, error: err instanceof Error ? err : new Error(String(err)) };
3158
+ return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
2793
3159
  }
2794
3160
  if (!res.ok) {
2795
3161
  const text = await res.text().catch(() => "");
2796
- return { pushed: 0, skipped: 0, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3162
+ return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3163
+ }
3164
+ // Stream-collect records into batches
3165
+ const rows = await res.json();
3166
+ if (rows.length === 0)
3167
+ continue;
3168
+ let batch = [];
3169
+ let batchBytes = 0;
3170
+ for (const row of rows) {
3171
+ const sr = { table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id };
3172
+ const srBytes = JSON.stringify(sr).length;
3173
+ if (batch.length >= BUDGET_RECORDS || (batch.length > 0 && batchBytes + srBytes > BUDGET_BYTES)) {
3174
+ const result = await sendBatch(batch);
3175
+ totalMerged += result.merged;
3176
+ totalSkipped += result.skipped;
3177
+ totalBatches++;
3178
+ batch = [];
3179
+ batchBytes = 0;
3180
+ }
3181
+ batch.push(sr);
3182
+ batchBytes += srBytes;
2797
3183
  }
2798
- for (const row of await res.json()) {
2799
- records.push({ table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id });
3184
+ // Send final partial batch for this table
3185
+ if (batch.length > 0) {
3186
+ const result = await sendBatch(batch);
3187
+ totalMerged += result.merged;
3188
+ totalSkipped += result.skipped;
3189
+ totalBatches++;
2800
3190
  }
2801
3191
  }
2802
- if (records.length === 0) {
3192
+ if (totalBatches === 0) {
2803
3193
  console.log("No changes since last sync.");
2804
3194
  return { pushed: 0, skipped: 0 };
2805
3195
  }
2806
- // Sign the sync request with our instance key
2807
- const secretKey = await loadInstanceSecretKey(instance.id, opts);
2808
- const syncBody = { instanceId: instance.id, records, lamportClock: Date.now() };
2809
- const signedSyncBody = signRequestBody(syncBody, secretKey);
2810
- const syncRes = await fetch(`${hub.endpoint ?? hub.id}/FederationSync`, {
2811
- method: "POST",
2812
- headers: { "Content-Type": "application/json" },
2813
- body: JSON.stringify(signedSyncBody),
2814
- });
2815
- if (!syncRes.ok) {
2816
- const text = await syncRes.text().catch(() => "");
2817
- return { pushed: 0, skipped: 0, error: new Error(`Sync failed: ${syncRes.status} ${text}`) };
2818
- }
2819
- const result = await syncRes.json();
2820
- console.log(`✅ Synced ${result.merged} records (${result.skipped} skipped) in ${result.durationMs}ms`);
2821
- return { pushed: result.merged ?? 0, skipped: result.skipped ?? 0 };
3196
+ console.log(`✅ Synced ${totalMerged} records (${totalSkipped} skipped) across ${totalBatches} batches`);
3197
+ return { pushed: totalMerged, skipped: totalSkipped };
2822
3198
  }
2823
3199
  catch (err) {
2824
- return { pushed: 0, skipped: 0, error: err instanceof Error ? err : new Error(String(err)) };
3200
+ return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
2825
3201
  }
2826
3202
  }
2827
3203
  federation
@@ -2885,6 +3261,281 @@ federation
2885
3261
  .action(async (opts) => {
2886
3262
  await runFederationWatch(opts);
2887
3263
  });
3264
+ // `flair federation prune` — remove stale spoke peers (never the hub).
3265
+ // Productizes ~/ops/scripts/cleanup-stale-fed-peers.sh into a real CLI
3266
+ // subcommand with safety: dry-run is the default, --apply required to delete.
3267
+ function parseDuration(spec) {
3268
+ // Accept forms like "30d", "12h", "90m". Returns milliseconds.
3269
+ // Rejects zero and sub-1-minute durations: a 0-ms cutoff would equal Date.now()
3270
+ // and prune every non-hub peer (Sherlock review on #314).
3271
+ const m = spec.match(/^(\d+)\s*([smhd])$/i);
3272
+ if (!m)
3273
+ return null;
3274
+ const n = Number(m[1]);
3275
+ const unit = m[2].toLowerCase();
3276
+ const mul = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 }[unit] ?? null;
3277
+ if (mul == null)
3278
+ return null;
3279
+ const ms = n * mul;
3280
+ const ONE_MINUTE = 60 * 1000;
3281
+ if (ms < ONE_MINUTE)
3282
+ return null;
3283
+ return ms;
3284
+ }
3285
+ federation
3286
+ .command("prune")
3287
+ .description("Remove stale spoke peers (older than --older-than). Hub is never pruned. Default dry-run.")
3288
+ .option("--older-than <duration>", "Duration spec (e.g. 30d, 12h, 90m)", "30d")
3289
+ .option("--apply", "Actually delete (default is dry-run)")
3290
+ .option("--include <pattern>", "Only consider peer IDs starting with this prefix")
3291
+ .option("--port <port>", "Harper HTTP port")
3292
+ .option("--ops-port <port>", "Harper operations API port")
3293
+ .option("--target <url>", "Remote Flair URL")
3294
+ .option("--ops-target <url>", "Explicit ops API URL")
3295
+ .action(async (opts) => {
3296
+ const target = resolveTarget(opts);
3297
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
3298
+ const olderThanMs = parseDuration(opts.olderThan);
3299
+ if (olderThanMs == null) {
3300
+ console.error(`Error: invalid or unsafe --older-than '${opts.olderThan}'. Use forms like 30d, 12h, 90m. Minimum 1 minute.`);
3301
+ process.exit(2);
3302
+ }
3303
+ const cutoff = Date.now() - olderThanMs;
3304
+ let peers = [];
3305
+ try {
3306
+ const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
3307
+ peers = r.peers ?? [];
3308
+ }
3309
+ catch (e) {
3310
+ console.error(`Error fetching peers: ${e.message}`);
3311
+ process.exit(1);
3312
+ }
3313
+ const candidates = peers.filter(p => {
3314
+ // Hub-protection: never prune. Case-insensitive; null/undefined role is
3315
+ // treated as "unknown — refuse to prune to be safe" (Sherlock review on #314).
3316
+ const role = (p.role ?? "").toString().toLowerCase();
3317
+ if (role === "hub" || role === "")
3318
+ return false;
3319
+ // Include filter.
3320
+ if (opts.include && !String(p.id ?? "").startsWith(opts.include))
3321
+ return false;
3322
+ // Stale threshold: a peer with NO lastSyncAt is treated as having been
3323
+ // born and immediately abandoned — qualifies if it's older than the
3324
+ // threshold based on pairedAt instead.
3325
+ const ts = p.lastSyncAt ?? p.pairedAt;
3326
+ if (!ts)
3327
+ return true; // truly orphaned record — prune candidate.
3328
+ return new Date(ts).getTime() < cutoff;
3329
+ });
3330
+ if (candidates.length === 0) {
3331
+ console.log(`flair federation prune: no peers older than ${opts.olderThan} (and not hub) — nothing to do.`);
3332
+ return;
3333
+ }
3334
+ if (!opts.apply) {
3335
+ console.log(`── flair federation prune — dry-run (use --apply to delete) ──`);
3336
+ console.log(`Would delete ${candidates.length} peer(s) older than ${opts.olderThan}:`);
3337
+ for (const p of candidates) {
3338
+ const ts = p.lastSyncAt ?? p.pairedAt ?? "never";
3339
+ const age = ts === "never" ? "(never synced/paired)" : `${Math.floor((Date.now() - new Date(ts).getTime()) / (24 * 60 * 60 * 1000))}d ago`;
3340
+ console.log(` ${p.id} ${(p.role ?? "—").padEnd(8)} lastSyncAt ${ts} (${age})`);
3341
+ }
3342
+ console.log(`Run with --apply to actually delete.`);
3343
+ return;
3344
+ }
3345
+ // Apply path. Delete each peer via the Harper ops API. We use the
3346
+ // domain-socket form when local; otherwise we fall back to the resource
3347
+ // DELETE which requires admin auth.
3348
+ let deleted = 0;
3349
+ let errors = 0;
3350
+ for (const p of candidates) {
3351
+ try {
3352
+ const res = await api("DELETE", `/FederationPeers/${encodeURIComponent(p.id)}`, undefined, baseUrl ? { baseUrl } : undefined);
3353
+ const ok = res?.ok ?? res?.deleted ?? true;
3354
+ if (ok) {
3355
+ deleted++;
3356
+ const ts = p.lastSyncAt ?? p.pairedAt ?? "never";
3357
+ console.log(`Deleted ${p.id} (last seen ${ts}).`);
3358
+ }
3359
+ else {
3360
+ errors++;
3361
+ console.log(`Failed to delete ${p.id}: ${JSON.stringify(res)}`);
3362
+ }
3363
+ }
3364
+ catch (e) {
3365
+ errors++;
3366
+ console.log(`Failed to delete ${p.id}: ${e.message}`);
3367
+ }
3368
+ }
3369
+ console.log(`${deleted} peer(s) deleted; ${errors} error(s).`);
3370
+ if (errors > 0)
3371
+ process.exit(1);
3372
+ });
3373
+ // `flair federation verify` — end-to-end roundtrip: write a tagged memory
3374
+ // locally, wait for federation push, probe peers for the tag. Productizes
3375
+ // ~/ops/scripts/verify-fed-sync.sh. Cleans up the test memory at the end.
3376
+ federation
3377
+ .command("verify")
3378
+ .description("End-to-end check: write a tagged memory locally and verify it shows up on each peer")
3379
+ .option("--peer <id>", "Verify only against this peer ID (default: all hubs + spokes)")
3380
+ .option("--wait <seconds>", "How long to wait for federation push (default 60)", "60")
3381
+ .option("--tag <prefix>", "Memory tag prefix (default: fed-verify)", "fed-verify")
3382
+ .option("--port <port>", "Harper HTTP port")
3383
+ .option("--target <url>", "Remote Flair URL")
3384
+ .action(async (opts) => {
3385
+ const target = resolveTarget(opts);
3386
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
3387
+ const waitMs = (Number(opts.wait) || 60) * 1000;
3388
+ const tag = `${opts.tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3389
+ console.log(`── flair federation verify — ${new Date().toISOString()} ──`);
3390
+ console.log(`Tag: ${tag}, wait window: ${opts.wait}s`);
3391
+ // Resolve agent ID for the local write.
3392
+ const agentId = process.env.FLAIR_AGENT_ID;
3393
+ if (!agentId) {
3394
+ console.error("Error: FLAIR_AGENT_ID not set. Set it or use 'flair agent default <id>'.");
3395
+ process.exit(1);
3396
+ }
3397
+ // 1. Write tagged ephemeral memory locally (mirror `flair memory add`).
3398
+ // The whole post-write block is wrapped in try/finally so cleanup runs on
3399
+ // every exit path (Sherlock review on #314: previous early-exits leaked
3400
+ // the probe memory).
3401
+ const memId = `${agentId}-${Date.now()}-fed-verify`;
3402
+ try {
3403
+ await api("PUT", `/Memory/${encodeURIComponent(memId)}`, {
3404
+ id: memId,
3405
+ agentId,
3406
+ content: `${tag} — federation verify probe written at ${new Date().toISOString()}`,
3407
+ type: "memory",
3408
+ durability: "ephemeral",
3409
+ tags: ["federation-verify", tag],
3410
+ createdAt: new Date().toISOString(),
3411
+ }, baseUrl ? { baseUrl } : undefined);
3412
+ console.log(`1. Wrote local memory: ${memId}`);
3413
+ }
3414
+ catch (e) {
3415
+ console.error(`1. Local write FAILED: ${e.message}`);
3416
+ // No memory was written — nothing to clean up. Direct exit is safe.
3417
+ process.exit(1);
3418
+ }
3419
+ // From here, memId is committed and MUST be cleaned up regardless of how
3420
+ // we leave this block.
3421
+ let exitCode = 0;
3422
+ try {
3423
+ // 2. List peers to probe.
3424
+ let peers = [];
3425
+ try {
3426
+ const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
3427
+ peers = r.peers ?? [];
3428
+ if (opts.peer)
3429
+ peers = peers.filter(p => p.id === opts.peer);
3430
+ }
3431
+ catch (e) {
3432
+ console.error(`Failed to list peers: ${e.message}`);
3433
+ }
3434
+ if (peers.length === 0) {
3435
+ console.log("(no peers to probe)");
3436
+ // Still falls through to finally for cleanup.
3437
+ }
3438
+ else {
3439
+ console.log(`2. Probing ${peers.length} peer(s) over ${opts.wait}s window…`);
3440
+ // 3. Poll each peer until found OR window elapses.
3441
+ const started = Date.now();
3442
+ const found = new Set();
3443
+ const failed = new Set();
3444
+ while (Date.now() - started < waitMs && (found.size + failed.size) < peers.length) {
3445
+ for (const p of peers) {
3446
+ if (found.has(p.id) || failed.has(p.id))
3447
+ continue;
3448
+ const endpoint = p.endpoint;
3449
+ if (!endpoint) {
3450
+ // Tunnel-paired — no direct endpoint to probe. Mark as skipped.
3451
+ failed.add(p.id);
3452
+ console.log(` ${p.id} SKIP (no endpoint — tunnel-paired)`);
3453
+ continue;
3454
+ }
3455
+ // Reject non-http(s) endpoints to keep the probe surface small.
3456
+ // (Sherlock review on #314 — protocol allowlist.)
3457
+ let probeUrl;
3458
+ try {
3459
+ probeUrl = new URL("/SemanticSearch", endpoint);
3460
+ }
3461
+ catch {
3462
+ failed.add(p.id);
3463
+ console.log(` ${p.id} FAIL (invalid endpoint URL: ${endpoint})`);
3464
+ continue;
3465
+ }
3466
+ if (probeUrl.protocol !== "http:" && probeUrl.protocol !== "https:") {
3467
+ failed.add(p.id);
3468
+ console.log(` ${p.id} FAIL (unsupported endpoint protocol: ${probeUrl.protocol})`);
3469
+ continue;
3470
+ }
3471
+ try {
3472
+ const res = await fetch(probeUrl, {
3473
+ method: "POST",
3474
+ headers: { "Content-Type": "application/json" },
3475
+ body: JSON.stringify({ q: tag, limit: 5 }),
3476
+ signal: AbortSignal.timeout(5000),
3477
+ });
3478
+ if (res.status === 401) {
3479
+ // Auth-gated — can't verify without admin creds for the peer.
3480
+ // Fail the probe with diagnostic.
3481
+ failed.add(p.id);
3482
+ console.log(` ${p.id} FAIL (HTTP 401 — peer auth-gated; needs cross-instance admin auth)`);
3483
+ continue;
3484
+ }
3485
+ if (!res.ok) {
3486
+ failed.add(p.id);
3487
+ console.log(` ${p.id} FAIL (HTTP ${res.status})`);
3488
+ continue;
3489
+ }
3490
+ const data = await res.json().catch(() => ({}));
3491
+ const results = data.results ?? [];
3492
+ if (results.some((r) => (r.content ?? "").includes(tag))) {
3493
+ const elapsed = Math.floor((Date.now() - started) / 1000);
3494
+ console.log(` ${p.id} OK (memory found after ${elapsed}s)`);
3495
+ found.add(p.id);
3496
+ }
3497
+ }
3498
+ catch {
3499
+ // Don't mark failed yet — peer might just be slow. Retry next iteration.
3500
+ }
3501
+ }
3502
+ await new Promise(res => setTimeout(res, 5000));
3503
+ }
3504
+ // Anything still pending is a timeout failure.
3505
+ for (const p of peers) {
3506
+ if (!found.has(p.id) && !failed.has(p.id)) {
3507
+ failed.add(p.id);
3508
+ console.log(` ${p.id} FAIL (timeout — memory did not propagate within ${opts.wait}s)`);
3509
+ }
3510
+ }
3511
+ // 5. Summary + diagnostics on failure.
3512
+ if (failed.size > 0) {
3513
+ console.log(`── FAIL: ${failed.size}/${peers.length} peer(s) did not see the memory ──`);
3514
+ console.log(`Diagnostics to run next:`);
3515
+ console.log(` flair federation status # confirm peers are paired + lastSyncAt is recent`);
3516
+ console.log(` flair federation reachability # confirm peers are HTTP-reachable`);
3517
+ console.log(` launchctl list | grep fed-sync # confirm federation-sync daemon is running (macOS)`);
3518
+ console.log(` curl <peer-endpoint>/Health # raw probe`);
3519
+ exitCode = 1;
3520
+ }
3521
+ else {
3522
+ console.log(`── PASS: memory propagated to all ${peers.length} peer(s) ──`);
3523
+ }
3524
+ }
3525
+ }
3526
+ finally {
3527
+ // 4. Cleanup: delete the local probe memory. Runs on EVERY exit path.
3528
+ try {
3529
+ await api("DELETE", `/Memory/${encodeURIComponent(memId)}`, undefined, baseUrl ? { baseUrl } : undefined);
3530
+ console.log(`4. Cleanup: deleted local memory ${memId}`);
3531
+ }
3532
+ catch {
3533
+ console.log(`4. Cleanup: could NOT delete local memory ${memId} (manual cleanup needed)`);
3534
+ }
3535
+ }
3536
+ if (exitCode !== 0)
3537
+ process.exit(exitCode);
3538
+ });
2888
3539
  // ─── flair rem ───────────────────────────────────────────────────────────────
2889
3540
  // Memory hygiene and reflection: light (NREM), rapid (REM), restorative (deep).
2890
3541
  const rem = program.command("rem").description("Memory hygiene and reflection");
@@ -3077,6 +3728,269 @@ rem
3077
3728
  process.exit(1);
3078
3729
  }
3079
3730
  });
3731
+ // ─── flair rem candidates ─────────────────────────────────────────────────────
3732
+ // Slice 1 of FLAIR-NIGHTLY-REM (ops-2qq). Lists staged distillations from the
3733
+ // MemoryCandidate table. Empty until the nightly cycle (later slice) starts
3734
+ // populating. Per spec § 5: candidates are NEVER auto-promoted; this command
3735
+ // is the operator's review surface.
3736
+ rem
3737
+ .command("candidates")
3738
+ .description("List staged memory candidates from the FLAIR-NIGHTLY-REM cycle (pending review)")
3739
+ .option("--port <port>", "Harper HTTP port")
3740
+ .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
3741
+ .option("--status <s>", "Filter by status: pending | promoted | rejected (default: pending)")
3742
+ .option("--json", "Output as JSON for scripting")
3743
+ .action(async (opts) => {
3744
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
3745
+ const status = opts.status ?? "pending";
3746
+ const validStatuses = new Set(["pending", "promoted", "rejected"]);
3747
+ if (!validStatuses.has(status)) {
3748
+ console.error(`Error: --status must be one of: pending, promoted, rejected (got: ${status})`);
3749
+ process.exit(1);
3750
+ }
3751
+ if (!agentId) {
3752
+ console.error("Error: --agent is required (or set FLAIR_AGENT_ID)");
3753
+ process.exit(1);
3754
+ }
3755
+ try {
3756
+ const result = await api("POST", "/MemoryCandidate/search_by_conditions", {
3757
+ operator: "and",
3758
+ conditions: [
3759
+ { search_attribute: "agentId", search_type: "equals", search_value: agentId },
3760
+ { search_attribute: "status", search_type: "equals", search_value: status },
3761
+ ],
3762
+ get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
3763
+ });
3764
+ // search_by_conditions returns either an array or { error }; tolerate both shapes
3765
+ const candidates = Array.isArray(result) ? result : (result?.results ?? []);
3766
+ if (opts.json) {
3767
+ console.log(JSON.stringify({ agentId, status, count: candidates.length, candidates }, null, 2));
3768
+ return;
3769
+ }
3770
+ console.log(`\n-- rem candidates (agent=${agentId}, status=${status}) --\n`);
3771
+ if (candidates.length === 0) {
3772
+ console.log(`No ${status} candidates.`);
3773
+ if (status === "pending") {
3774
+ console.log("\n(Run `flair rem nightly enable` to start the nightly distillation cycle that populates this table.)");
3775
+ }
3776
+ return;
3777
+ }
3778
+ // Sort newest-first by generatedAt
3779
+ candidates.sort((a, b) => String(b.generatedAt ?? "").localeCompare(String(a.generatedAt ?? "")));
3780
+ for (const c of candidates) {
3781
+ const tag = c.status === "promoted"
3782
+ ? `[promoted → ${c.target ?? "?"} by ${c.reviewerId ?? "?"} @ ${relativeTime(c.decidedAt)}]`
3783
+ : c.status === "rejected"
3784
+ ? `[rejected by ${c.reviewerId ?? "?"} @ ${relativeTime(c.decidedAt)}]`
3785
+ : `[pending — ${c.generatedBy ?? "?"} @ ${relativeTime(c.generatedAt)}]`;
3786
+ console.log(` ${c.id} ${tag}`);
3787
+ console.log(` ${c.claim}`);
3788
+ if (c.supersedes)
3789
+ console.log(` (supersedes ${c.supersedes} — recurring proposal)`);
3790
+ console.log("");
3791
+ }
3792
+ console.log(`${candidates.length} candidate${candidates.length > 1 ? "s" : ""}.`);
3793
+ if (status === "pending") {
3794
+ console.log(`Promote: flair rem promote <id> --rationale "<why>" --to (soul|memory)`);
3795
+ console.log(`Reject: flair rem reject <id> --reason "<why>"`);
3796
+ }
3797
+ }
3798
+ catch (err) {
3799
+ console.error(`Error: ${err.message}`);
3800
+ process.exit(1);
3801
+ }
3802
+ });
3803
+ // ─── flair rem promote / reject helpers ──────────────────────────────────────
3804
+ // Pure validators extracted for testability. The action callbacks below thread
3805
+ // these through process.exit on failure; the helpers themselves are
3806
+ // side-effect-free.
3807
+ export function validatePromoteOpts(opts) {
3808
+ if (!opts.rationale || !opts.rationale.trim()) {
3809
+ return "--rationale is required (per spec § 5: no rubber-stamp)";
3810
+ }
3811
+ if (!opts.to || (opts.to !== "soul" && opts.to !== "memory")) {
3812
+ return "--to must be 'soul' or 'memory'";
3813
+ }
3814
+ if (opts.to === "soul" && (!opts.key || !opts.key.trim())) {
3815
+ return "--key is required when --to=soul (gives the Soul entry a meaningful identifier)";
3816
+ }
3817
+ return null;
3818
+ }
3819
+ export function validateRejectOpts(opts) {
3820
+ if (!opts.reason || !opts.reason.trim()) {
3821
+ return "--reason is required";
3822
+ }
3823
+ return null;
3824
+ }
3825
+ /**
3826
+ * Decide whether a promote/reject action can proceed against a candidate's
3827
+ * current state, and what message to surface to the operator. Pure function;
3828
+ * action side effects happen in the CLI body after this returns ok.
3829
+ */
3830
+ export function decideCandidateAction(candidate, action) {
3831
+ if (!candidate)
3832
+ return { ok: false, severity: "error", message: "candidate not found" };
3833
+ const status = candidate.status;
3834
+ if (status === "promoted") {
3835
+ return action === "promote"
3836
+ ? { ok: false, severity: "error", message: `already promoted (target=${candidate.target}, reviewer=${candidate.reviewerId})` }
3837
+ : { ok: false, severity: "error", message: `already promoted; cannot reject after promotion` };
3838
+ }
3839
+ if (status === "rejected") {
3840
+ return action === "reject"
3841
+ ? { ok: false, severity: "info", message: `already rejected on ${candidate.decidedAt} by ${candidate.reviewerId}` }
3842
+ : { ok: false, severity: "error", message: `already rejected; use a fresh candidate or reset status manually` };
3843
+ }
3844
+ return { ok: true };
3845
+ }
3846
+ // ─── flair rem promote ───────────────────────────────────────────────────────
3847
+ // Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
3848
+ // or persistent Memory. Both --rationale and --to are required (spec § 5: no
3849
+ // rubber-stamp). When --to=soul, --key is also required so the resulting
3850
+ // Soul row has a meaningful identifier.
3851
+ //
3852
+ // Trust-tier policy is enforced by the caller's authentication today (1.0):
3853
+ // admin pass → any promote; agent key → can write to own Memory/Soul. Server-
3854
+ // side trust-tier enforcement (endorsed agents → memory only, never soul) is
3855
+ // scoped for slice 2b when agent-routed promotion lands. For now, the
3856
+ // human-operator workflow is the supported path.
3857
+ rem
3858
+ .command("promote")
3859
+ .description("Promote a memory candidate to Soul or persistent Memory (rationale required)")
3860
+ .argument("<candidate-id>", "MemoryCandidate id to promote")
3861
+ .option("--port <port>", "Harper HTTP port")
3862
+ .option("--rationale <text>", "Why this candidate is being promoted (required, no rubber-stamp)")
3863
+ .option("--to <target>", "Promotion target: 'soul' or 'memory'")
3864
+ .option("--key <key>", "Soul key (required when --to=soul; e.g. 'lessons', 'preference-X')")
3865
+ .option("--reviewer <id>", "Reviewer agent id (default: FLAIR_AGENT_ID or 'admin')")
3866
+ .action(async (candidateId, opts) => {
3867
+ const validationErr = validatePromoteOpts(opts);
3868
+ if (validationErr) {
3869
+ console.error(`Error: ${validationErr}`);
3870
+ process.exit(1);
3871
+ }
3872
+ const reviewerId = opts.reviewer || process.env.FLAIR_AGENT_ID || "admin";
3873
+ try {
3874
+ // Fetch the candidate
3875
+ const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
3876
+ const candidateData = (candidate && !candidate.error) ? candidate : null;
3877
+ const decision = decideCandidateAction(candidateData, "promote");
3878
+ if (!decision.ok) {
3879
+ const msg = decision.message;
3880
+ console.error(`Error: candidate ${candidateId} ${msg}`);
3881
+ process.exit(1);
3882
+ }
3883
+ const decidedAt = new Date().toISOString();
3884
+ // Write the resulting Soul or Memory entry
3885
+ if (opts.to === "memory") {
3886
+ const memId = `${candidate.agentId}-promoted-${Date.now()}`;
3887
+ const memWrite = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, {
3888
+ id: memId,
3889
+ agentId: candidate.agentId,
3890
+ content: candidate.claim,
3891
+ durability: "persistent",
3892
+ tags: ["nightly-rem-promoted", `from:${candidateId}`],
3893
+ derivedFrom: candidate.sourceMemoryIds ?? [],
3894
+ promotionStatus: "approved",
3895
+ promotedAt: decidedAt,
3896
+ promotedBy: reviewerId,
3897
+ createdAt: decidedAt,
3898
+ });
3899
+ if (memWrite?.error) {
3900
+ console.error(`Error writing Memory: ${memWrite.error}`);
3901
+ process.exit(1);
3902
+ }
3903
+ console.log(`✅ Wrote Memory ${memId} (durability=persistent)`);
3904
+ }
3905
+ else {
3906
+ // soul
3907
+ const soulId = `${candidate.agentId}-${opts.key}`;
3908
+ const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
3909
+ id: soulId,
3910
+ agentId: candidate.agentId,
3911
+ key: opts.key,
3912
+ value: candidate.claim,
3913
+ priority: "standard",
3914
+ durability: "persistent",
3915
+ createdAt: decidedAt,
3916
+ updatedAt: decidedAt,
3917
+ });
3918
+ if (soulWrite?.error) {
3919
+ console.error(`Error writing Soul: ${soulWrite.error}`);
3920
+ process.exit(1);
3921
+ }
3922
+ console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
3923
+ }
3924
+ // Update the candidate row
3925
+ const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
3926
+ ...candidate,
3927
+ status: "promoted",
3928
+ target: opts.to,
3929
+ reviewerId,
3930
+ reviewRationale: opts.rationale,
3931
+ decidedAt,
3932
+ });
3933
+ if (upd?.error) {
3934
+ console.error(`Warning: candidate row update returned: ${upd.error}`);
3935
+ }
3936
+ console.log(`✅ Candidate ${candidateId} marked promoted → ${opts.to}, reviewer=${reviewerId}`);
3937
+ }
3938
+ catch (err) {
3939
+ console.error(`Error: ${err.message}`);
3940
+ process.exit(1);
3941
+ }
3942
+ });
3943
+ // ─── flair rem reject ────────────────────────────────────────────────────────
3944
+ // Reject a candidate with a required --reason. Per spec § 5, rejected
3945
+ // candidates retain full decision history so recurring proposals are visible
3946
+ // via the supersedes chain.
3947
+ rem
3948
+ .command("reject")
3949
+ .description("Reject a memory candidate with a required reason")
3950
+ .argument("<candidate-id>", "MemoryCandidate id to reject")
3951
+ .option("--port <port>", "Harper HTTP port")
3952
+ .option("--reason <text>", "Why this candidate is being rejected (required)")
3953
+ .option("--reviewer <id>", "Reviewer agent id (default: FLAIR_AGENT_ID or 'admin')")
3954
+ .action(async (candidateId, opts) => {
3955
+ const validationErr = validateRejectOpts(opts);
3956
+ if (validationErr) {
3957
+ console.error(`Error: ${validationErr}`);
3958
+ process.exit(1);
3959
+ }
3960
+ const reviewerId = opts.reviewer || process.env.FLAIR_AGENT_ID || "admin";
3961
+ try {
3962
+ const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
3963
+ const candidateData = (candidate && !candidate.error) ? candidate : null;
3964
+ const decision = decideCandidateAction(candidateData, "reject");
3965
+ if (!decision.ok) {
3966
+ const _d = decision;
3967
+ if (_d.severity === "info") {
3968
+ console.log(`(candidate ${candidateId} ${_d.message})`);
3969
+ return;
3970
+ }
3971
+ console.error(`Error: candidate ${candidateId} ${_d.message}`);
3972
+ process.exit(1);
3973
+ }
3974
+ const decidedAt = new Date().toISOString();
3975
+ const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
3976
+ ...candidate,
3977
+ status: "rejected",
3978
+ reviewerId,
3979
+ reviewRationale: opts.reason,
3980
+ decidedAt,
3981
+ });
3982
+ if (upd?.error) {
3983
+ console.error(`Error: candidate row update failed: ${upd.error}`);
3984
+ process.exit(1);
3985
+ }
3986
+ console.log(`✅ Candidate ${candidateId} rejected by ${reviewerId}`);
3987
+ console.log(` Reason: ${opts.reason}`);
3988
+ }
3989
+ catch (err) {
3990
+ console.error(`Error: ${err.message}`);
3991
+ process.exit(1);
3992
+ }
3993
+ });
3080
3994
  // ─── flair status ─────────────────────────────────────────────────────────────
3081
3995
  function humanBytes(n) {
3082
3996
  if (!Number.isFinite(n) || n < 0)
@@ -3145,6 +4059,59 @@ function oauthDetailLines(o) {
3145
4059
  }
3146
4060
  return out;
3147
4061
  }
4062
+ // Common localhost ports a running Flair daemon might be on. Used by
4063
+ // discoverLocalFlairPort when the configured URL is unreachable, to detect
4064
+ // config-vs-daemon port drift (ops-mbdi). Order is ad-hoc — first hit wins.
4065
+ //
4066
+ // 9926: original default (long-running rockit installs predate the bump)
4067
+ // 19926: current default (DEFAULT_PORT)
4068
+ // 19925: ops-anvil VM secondary
4069
+ const LOCAL_FLAIR_PROBE_PORTS = [9926, 19926, 19925];
4070
+ export function isLocalhostUrl(url) {
4071
+ try {
4072
+ const u = new URL(url);
4073
+ // URL.hostname keeps brackets around IPv6 (e.g. "[::1]") so match both forms.
4074
+ return (u.hostname === "127.0.0.1" ||
4075
+ u.hostname === "localhost" ||
4076
+ u.hostname === "::1" ||
4077
+ u.hostname === "[::1]");
4078
+ }
4079
+ catch {
4080
+ return false;
4081
+ }
4082
+ }
4083
+ /**
4084
+ * When a configured-localhost URL is unreachable, probe a small candidate-port
4085
+ * set to detect a daemon listening on a different port (config drift). Returns
4086
+ * the first responsive port, or null if none. Excludes the original port from
4087
+ * the probe set so we don't repeat the failed call.
4088
+ *
4089
+ * Runs sequentially with a 500ms timeout per probe — typical 3-port sweep
4090
+ * completes in <1.5s on a healthy box, faster on an unhealthy one.
4091
+ */
4092
+ export async function discoverLocalFlairPort(originalUrl) {
4093
+ if (!isLocalhostUrl(originalUrl))
4094
+ return null;
4095
+ let originalPort = null;
4096
+ try {
4097
+ originalPort = Number(new URL(originalUrl).port) || null;
4098
+ }
4099
+ catch { /* ignore */ }
4100
+ for (const port of LOCAL_FLAIR_PROBE_PORTS) {
4101
+ if (port === originalPort)
4102
+ continue;
4103
+ try {
4104
+ const res = await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(500) });
4105
+ // Treat 401 (auth required) the same as 200 — the daemon is alive,
4106
+ // we just can't see /Health without admin auth. The point is to detect
4107
+ // "something is listening" not "we have full access".
4108
+ if (res.ok || res.status === 401)
4109
+ return port;
4110
+ }
4111
+ catch { /* port not listening, try next */ }
4112
+ }
4113
+ return null;
4114
+ }
3148
4115
  async function fetchHealthDetail(opts) {
3149
4116
  const port = resolveHttpPort(opts);
3150
4117
  // --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
@@ -3210,8 +4177,18 @@ const statusCmd = program
3210
4177
  .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
3211
4178
  .action(async (opts) => {
3212
4179
  const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
4180
+ // When unreachable on a localhost URL, probe candidate ports to detect
4181
+ // config-vs-daemon port drift (ops-mbdi). Surface the actually-listening
4182
+ // port with a fix recipe — better UX than just "unreachable."
4183
+ let discoveredPort = null;
4184
+ if (!healthy && isLocalhostUrl(baseUrl)) {
4185
+ discoveredPort = await discoverLocalFlairPort(baseUrl);
4186
+ }
3213
4187
  if (opts.json) {
3214
- console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
4188
+ const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
4189
+ if (discoveredPort != null)
4190
+ out.discoveredPort = discoveredPort;
4191
+ console.log(JSON.stringify(out, null, 2));
3215
4192
  if (!healthy)
3216
4193
  process.exit(1);
3217
4194
  return;
@@ -3219,7 +4196,17 @@ const statusCmd = program
3219
4196
  if (!healthy) {
3220
4197
  console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
3221
4198
  console.log(` URL: ${baseUrl}`);
3222
- console.log(`\n Run: flair start or flair doctor`);
4199
+ if (discoveredPort != null) {
4200
+ const altUrl = `http://127.0.0.1:${discoveredPort}`;
4201
+ console.log(`\n ⚠ Found a Flair daemon listening on port ${discoveredPort} (URL: ${altUrl}).`);
4202
+ console.log(` Your config points at ${baseUrl} — drift detected.`);
4203
+ console.log(`\n Quick fix: FLAIR_URL=${altUrl} flair status`);
4204
+ console.log(` Permanent fix: edit ~/.flair/config.yaml to set port: ${discoveredPort}`);
4205
+ console.log(` Or: flair doctor (when port-drift detection lands there)`);
4206
+ }
4207
+ else {
4208
+ console.log(`\n Run: flair start or flair doctor`);
4209
+ }
3223
4210
  process.exit(1);
3224
4211
  }
3225
4212
  const uptimeSec = healthData?.uptimeSeconds;
@@ -3530,37 +4517,39 @@ program
3530
4517
  .description("Upgrade Flair and related packages to latest versions")
3531
4518
  .option("--check", "Only check for updates, don't install")
3532
4519
  .option("--restart", "Restart Flair after upgrade")
4520
+ .option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
3533
4521
  .action(async (opts) => {
3534
4522
  const { execSync, execFileSync } = await import("node:child_process");
3535
4523
  const checkOnly = opts.check ?? false;
4524
+ const showAll = opts.all ?? false;
3536
4525
  console.log("Checking for updates...\n");
3537
- // Per-package install probes. `npm list -g` assumed the default global
3538
- // prefix and silently mis-reported "not installed" for anyone using
3539
- // mise / fnm / nvm / volta / non-default-prefix npm — including the
3540
- // running flair binary itself, which was obviously installed. Each
3541
- // entry now has a locator that works regardless of install path:
3542
- //
3543
- // - For packages with a bin: shell out to the bin with --version
3544
- // (same PATH lookup that got them invokable in the first place).
3545
- // - For library packages: require.resolve the package.json from the
3546
- // running flair's module graph (works whether it's a sibling
3547
- // global install or a bundled dep).
3548
4526
  const packages = [
3549
4527
  {
3550
4528
  name: "@tpsdev-ai/flair",
4529
+ kind: "bin",
3551
4530
  probe: () => probeBinVersion(execFileSync, "flair"),
3552
4531
  },
3553
- {
3554
- name: "@tpsdev-ai/flair-client",
3555
- probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
3556
- },
3557
4532
  {
3558
4533
  name: "@tpsdev-ai/flair-mcp",
4534
+ kind: "bin",
3559
4535
  probe: () => probeBinVersion(execFileSync, "flair-mcp"),
3560
4536
  },
4537
+ {
4538
+ name: "@tpsdev-ai/openclaw-flair",
4539
+ kind: "openclaw-plugin",
4540
+ probe: () => probeOpenclawPluginVersion("openclaw-flair"),
4541
+ },
4542
+ {
4543
+ name: "@tpsdev-ai/flair-client",
4544
+ kind: "lib",
4545
+ probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
4546
+ transitive: true,
4547
+ },
3561
4548
  ];
3562
4549
  const findings = [];
3563
- for (const { name, probe } of packages) {
4550
+ for (const { name, probe, kind, transitive } of packages) {
4551
+ if (transitive && !showAll)
4552
+ continue;
3564
4553
  try {
3565
4554
  const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
3566
4555
  if (!res.ok)
@@ -3568,18 +4557,45 @@ program
3568
4557
  const data = await res.json();
3569
4558
  const latest = data.version ?? "unknown";
3570
4559
  const installed = probe();
3571
- const status = installed === null ? "missing" : installed === latest ? "current" : "outdated";
3572
- findings.push({ name, installed, latest, status });
3573
- const icon = status === "current" ? "✅" : status === "outdated" ? "⬆️" : "❔";
3574
- const installedLabel = installed ?? "not detected";
3575
- const suffix = status === "current" ? " (current)" : status === "missing" ? " (run: npm install -g)" : "";
4560
+ let status;
4561
+ if (installed === null) {
4562
+ // openclaw-plugin packages are optional if openclaw isn't
4563
+ // installed, don't surface a misleading "install with npm" advice.
4564
+ status = kind === "openclaw-plugin" ? "optional" : "missing";
4565
+ }
4566
+ else if (installed === latest) {
4567
+ status = "current";
4568
+ }
4569
+ else {
4570
+ status = "outdated";
4571
+ }
4572
+ findings.push({ name, installed, latest, status, kind });
4573
+ const icon = status === "current" ? "✅"
4574
+ : status === "outdated" ? "⬆️"
4575
+ : status === "optional" ? "○"
4576
+ : "❔";
4577
+ const installedLabel = installed ?? (status === "optional" ? "not installed (openclaw not detected)" : "not detected");
4578
+ const suffix = status === "current" ? " (current)"
4579
+ : status === "missing" ? " (run: npm install -g)"
4580
+ : status === "optional" ? " (install via: openclaw plugins install @tpsdev-ai/openclaw-flair)"
4581
+ : "";
3576
4582
  console.log(` ${icon} ${name}: ${installedLabel} → ${latest}${suffix}`);
3577
4583
  }
3578
4584
  catch { /* skip unavailable packages */ }
3579
4585
  }
3580
4586
  const outdated = findings.filter((f) => f.status === "outdated");
3581
4587
  const missing = findings.filter((f) => f.status === "missing");
3582
- const upgrades = outdated.map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
4588
+ // openclaw plugins upgrade through `openclaw plugins install`, not `npm
4589
+ // install -g` (npm-installed wouldn't connect to OpenClaw's gateway slot).
4590
+ // Split outdated into npm-upgradeable vs openclaw-plugin so we can use
4591
+ // the right command for each.
4592
+ const npmUpgrades = outdated
4593
+ .filter((f) => f.kind !== "openclaw-plugin")
4594
+ .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
4595
+ const openclawUpgrades = outdated
4596
+ .filter((f) => f.kind === "openclaw-plugin")
4597
+ .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
4598
+ const totalUpgrades = npmUpgrades.length + openclawUpgrades.length;
3583
4599
  if (outdated.length === 0 && missing.length === 0) {
3584
4600
  console.log("\n✅ Everything is up to date.");
3585
4601
  return;
@@ -3599,9 +4615,9 @@ program
3599
4615
  // Perform upgrade. `latest` comes from the npm registry's HTTP
3600
4616
  // response, so CodeQL (correctly) treats it as untrusted input.
3601
4617
  // Use execFileSync with argv — the spec `<name>@<version>` becomes a
3602
- // single argument to npm, no shell to inject into.
3603
- console.log(`\nUpgrading ${upgrades.length} package${upgrades.length > 1 ? "s" : ""}...\n`);
3604
- for (const { pkg, latest } of upgrades) {
4618
+ // single argument to the upgrade command, no shell to inject into.
4619
+ console.log(`\nUpgrading ${totalUpgrades} package${totalUpgrades > 1 ? "s" : ""}...\n`);
4620
+ for (const { pkg, latest } of npmUpgrades) {
3605
4621
  try {
3606
4622
  console.log(` Installing ${pkg}@${latest}...`);
3607
4623
  execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
@@ -3611,6 +4627,26 @@ program
3611
4627
  console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
3612
4628
  }
3613
4629
  }
4630
+ for (const { pkg, latest } of openclawUpgrades) {
4631
+ // OpenClaw plugins upgrade via `openclaw plugins install --force --pin`.
4632
+ // Requires openclaw on PATH; if not, surface the manual recipe instead
4633
+ // of a confusing failure.
4634
+ try {
4635
+ execFileSync("openclaw", ["--version"], { stdio: "pipe", timeout: 2000 });
4636
+ }
4637
+ catch {
4638
+ console.error(` ❌ ${pkg} upgrade skipped: openclaw not on PATH. Install manually: openclaw plugins install ${pkg}@${latest} --force --pin`);
4639
+ continue;
4640
+ }
4641
+ try {
4642
+ console.log(` Installing ${pkg}@${latest} via openclaw...`);
4643
+ execFileSync("openclaw", ["plugins", "install", `${pkg}@${latest}`, "--force", "--pin"], { stdio: "pipe" });
4644
+ console.log(` ✅ ${pkg}@${latest} installed`);
4645
+ }
4646
+ catch (err) {
4647
+ console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
4648
+ }
4649
+ }
3614
4650
  if (opts.restart) {
3615
4651
  console.log("\nRestarting Flair...");
3616
4652
  try {
@@ -3963,21 +4999,40 @@ program
3963
4999
  console.error("❌ Admin password required when --agent is not specified (set FLAIR_ADMIN_PASS or HDB_ADMIN_PASSWORD)");
3964
5000
  process.exit(1);
3965
5001
  }
3966
- // Fetch all memories with admin auth
3967
- const searchRes = await fetch(`${baseUrl}/SemanticSearch`, {
5002
+ // Fetch every memory via the Harper ops API (search_by_conditions on the
5003
+ // Memory table) rather than POST /SemanticSearch. SemanticSearch goes
5004
+ // through the HNSW cosine index, which throws "Cosine distance comparison
5005
+ // requires an array" against rows whose stored embedding shape is
5006
+ // incompatible with the running Harper version (e.g. data written under
5007
+ // @harperfast/harper@5.0.1 read under 5.0.9). The ops API bypasses the
5008
+ // vector index — exactly what we need when the goal is to replace every
5009
+ // embedding with a freshly-computed one. Without this path, `flair
5010
+ // reembed` could not recover from the very condition it exists to fix.
5011
+ const opsPort = resolveOpsPort(opts);
5012
+ const opsAuth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
5013
+ // Harper rejects empty-value conditions ("not indexed for nulls"). Use
5014
+ // `createdAt > 1970-01-01` as the "select all" pattern: every Memory row
5015
+ // has a createdAt, the index is built, and the comparison is total.
5016
+ const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
3968
5017
  method: "POST",
3969
- headers: {
3970
- "Content-Type": "application/json",
3971
- Authorization: `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`,
3972
- },
3973
- body: JSON.stringify({ limit: 10000 }),
5018
+ headers: { "Content-Type": "application/json", Authorization: opsAuth },
5019
+ body: JSON.stringify({
5020
+ operation: "search_by_conditions",
5021
+ database: "flair",
5022
+ table: "Memory",
5023
+ operator: "and",
5024
+ conditions: [{ search_attribute: "createdAt", search_type: "greater_than", search_value: "1970-01-01" }],
5025
+ get_attributes: ["*"],
5026
+ limit: 100000,
5027
+ }),
5028
+ signal: AbortSignal.timeout(60_000),
3974
5029
  });
3975
5030
  if (!searchRes.ok) {
3976
- console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
5031
+ console.error(`❌ Failed to fetch memories via ops API: ${searchRes.status}`);
3977
5032
  process.exit(1);
3978
5033
  }
3979
- const data = await searchRes.json();
3980
- const allMemories = data.results ?? [];
5034
+ const raw = await searchRes.json();
5035
+ const allMemories = Array.isArray(raw) ? raw : (raw?.results ?? []);
3981
5036
  // Group by agentId
3982
5037
  const byAgent = new Map();
3983
5038
  for (const m of allMemories) {
@@ -4037,22 +5092,53 @@ program
4037
5092
  console.log(`\n\n✅ Re-embedding complete: ${totalProcessed} updated, ${totalErrors} errors`);
4038
5093
  return;
4039
5094
  }
4040
- // Original single-agent path
5095
+ // Single-agent path. Same rationale as above: fetch via the ops API
5096
+ // (search_by_value on agentId) so the vector index isn't in the read path.
5097
+ // This requires admin pass — fall back to the old SemanticSearch fetch only
5098
+ // if no admin pass is available, since that path still works on
5099
+ // version-matched data and requires only the agent's own key.
4041
5100
  const keysDir = defaultKeysDir();
4042
5101
  const privPath = privKeyPath(agentId, keysDir);
4043
5102
  if (!existsSync(privPath)) {
4044
5103
  console.error(`❌ Key not found: ${privPath}`);
4045
5104
  process.exit(1);
4046
5105
  }
4047
- const searchRes = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
4048
- agentId, limit: 10000,
4049
- });
4050
- if (!searchRes.ok) {
4051
- console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
4052
- process.exit(1);
5106
+ const adminPassSingle = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
5107
+ let allMemories = [];
5108
+ if (adminPassSingle) {
5109
+ const opsPort = resolveOpsPort(opts);
5110
+ const opsAuth = `Basic ${Buffer.from(`admin:${adminPassSingle}`).toString("base64")}`;
5111
+ const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
5112
+ method: "POST",
5113
+ headers: { "Content-Type": "application/json", Authorization: opsAuth },
5114
+ body: JSON.stringify({
5115
+ operation: "search_by_value",
5116
+ database: "flair",
5117
+ table: "Memory",
5118
+ search_attribute: "agentId",
5119
+ search_value: agentId,
5120
+ get_attributes: ["*"],
5121
+ }),
5122
+ signal: AbortSignal.timeout(60_000),
5123
+ });
5124
+ if (!searchRes.ok) {
5125
+ console.error(`❌ Failed to fetch memories via ops API: ${searchRes.status}`);
5126
+ process.exit(1);
5127
+ }
5128
+ const raw = await searchRes.json();
5129
+ allMemories = Array.isArray(raw) ? raw : (raw?.results ?? []);
5130
+ }
5131
+ else {
5132
+ const searchRes = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
5133
+ agentId, limit: 10000,
5134
+ });
5135
+ if (!searchRes.ok) {
5136
+ console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
5137
+ process.exit(1);
5138
+ }
5139
+ const data = await searchRes.json();
5140
+ allMemories = data.results ?? [];
4053
5141
  }
4054
- const data = await searchRes.json();
4055
- const allMemories = data.results ?? [];
4056
5142
  const candidates = allMemories.filter((m) => {
4057
5143
  if (!m.content)
4058
5144
  return false;
@@ -4505,11 +5591,164 @@ program
4505
5591
  if (issues > 0)
4506
5592
  process.exit(1);
4507
5593
  });
5594
+ // ─── flair session snapshot ──────────────────────────────────────────────────
5595
+ // Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B (ops-9wji-B / ops-ojht). Snapshot a
5596
+ // session jsonl + label metadata into a tar.gz under ~/.flair/snapshots/<agent>/sessions/.
5597
+ //
5598
+ // Three subcommands: create | list | restore. Mirrors FLAIR-NIGHTLY-REM's
5599
+ // snapshot pattern (tar.gz, 600 perms, 30-day retention enforced separately).
5600
+ //
5601
+ // Standalone-callable today; harness slices 3+4 will wire it into the
5602
+ // session-reset pipeline.
5603
+ const SNAPSHOT_ROOT = resolve(homedir(), ".flair", "snapshots");
5604
+ function sessionSnapshotDir(agent) {
5605
+ if (!/^[a-zA-Z0-9_-]+$/.test(agent))
5606
+ throw new Error(`invalid agent id: ${agent}`);
5607
+ return resolve(SNAPSHOT_ROOT, agent, "sessions");
5608
+ }
5609
+ const session = program.command("session").description("Agent session lifecycle (snapshot/restore for FLAIR-AGENT-CONTEXT-TIERS-B)");
5610
+ const sessionSnapshot = session.command("snapshot").description("Manage session snapshots (tar.gz of session jsonl + metadata)");
5611
+ sessionSnapshot
5612
+ .command("create")
5613
+ .description("Create a session snapshot tar.gz")
5614
+ .requiredOption("--agent <id>", "Agent the session belongs to")
5615
+ .requiredOption("--session-file <path>", "Path to the session jsonl to snapshot (e.g. /tmp/openclaw/openclaw-2026-05-03.log)")
5616
+ .option("--label <text>", "Label for the snapshot file (e.g. ops-ID); default: ISO timestamp")
5617
+ .action(async (opts) => {
5618
+ const sessionFile = resolve(opts.sessionFile);
5619
+ if (!existsSync(sessionFile)) {
5620
+ console.error(`Error: --session-file does not exist: ${sessionFile}`);
5621
+ process.exit(1);
5622
+ }
5623
+ const dir = sessionSnapshotDir(opts.agent);
5624
+ if (!existsSync(dir))
5625
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
5626
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
5627
+ const safeLabel = opts.label ? String(opts.label).replace(/[^a-zA-Z0-9._-]/g, "_") : ts;
5628
+ const tarballName = opts.label ? `${safeLabel}-${ts}.tar.gz` : `${ts}.tar.gz`;
5629
+ const tarballPath = resolve(dir, tarballName);
5630
+ // Write a metadata.json into a tmp dir alongside the session file for the
5631
+ // tarball, so the snapshot is self-describing.
5632
+ const meta = {
5633
+ agent: opts.agent,
5634
+ label: opts.label ?? null,
5635
+ sessionFile,
5636
+ sessionFileSize: statSync(sessionFile).size,
5637
+ createdAt: new Date().toISOString(),
5638
+ flairVersion: __pkgVersion,
5639
+ };
5640
+ const tmpDir = resolve(dir, `.tmp-${process.pid}-${Date.now()}`);
5641
+ mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
5642
+ try {
5643
+ const sessionBaseName = sessionFile.split("/").pop() ?? "session.jsonl";
5644
+ writeFileSync(resolve(tmpDir, sessionBaseName), readFileSync(sessionFile));
5645
+ writeFileSync(resolve(tmpDir, "metadata.json"), JSON.stringify(meta, null, 2) + "\n");
5646
+ await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, [sessionBaseName, "metadata.json"]);
5647
+ // Tarball perms: 600 (owner-only) — matches FLAIR-NIGHTLY-REM
5648
+ chmodSync(tarballPath, 0o600);
5649
+ }
5650
+ finally {
5651
+ rmSync(tmpDir, { recursive: true, force: true });
5652
+ }
5653
+ const size = statSync(tarballPath).size;
5654
+ console.log(tarballPath);
5655
+ console.error(` agent: ${opts.agent}`);
5656
+ console.error(` label: ${opts.label ?? "(none)"}`);
5657
+ console.error(` size: ${humanBytes(size)}`);
5658
+ });
5659
+ sessionSnapshot
5660
+ .command("list")
5661
+ .description("List session snapshots for an agent (or all agents)")
5662
+ .option("--agent <id>", "Filter to a single agent")
5663
+ .option("--json", "Output as JSON")
5664
+ .action(async (opts) => {
5665
+ if (!existsSync(SNAPSHOT_ROOT)) {
5666
+ if (opts.json) {
5667
+ console.log("[]");
5668
+ return;
5669
+ }
5670
+ console.log("(no snapshots — ~/.flair/snapshots/ does not exist yet)");
5671
+ return;
5672
+ }
5673
+ const { readdirSync } = require("node:fs");
5674
+ const rows = [];
5675
+ const agents = opts.agent ? [opts.agent] : readdirSync(SNAPSHOT_ROOT).filter((d) => {
5676
+ try {
5677
+ return statSync(resolve(SNAPSHOT_ROOT, d)).isDirectory();
5678
+ }
5679
+ catch {
5680
+ return false;
5681
+ }
5682
+ });
5683
+ for (const a of agents) {
5684
+ const dir = resolve(SNAPSHOT_ROOT, a, "sessions");
5685
+ if (!existsSync(dir))
5686
+ continue;
5687
+ for (const f of readdirSync(dir)) {
5688
+ if (!f.endsWith(".tar.gz"))
5689
+ continue;
5690
+ const p = resolve(dir, f);
5691
+ const s = statSync(p);
5692
+ rows.push({ agent: a, file: f, path: p, size: s.size, mtime: s.mtime.toISOString() });
5693
+ }
5694
+ }
5695
+ rows.sort((a, b) => b.mtime.localeCompare(a.mtime));
5696
+ if (opts.json) {
5697
+ console.log(JSON.stringify(rows, null, 2));
5698
+ return;
5699
+ }
5700
+ if (rows.length === 0) {
5701
+ console.log("(no snapshots)");
5702
+ return;
5703
+ }
5704
+ const agentW = Math.max(5, ...rows.map((r) => r.agent.length));
5705
+ const fileW = Math.max(20, ...rows.map((r) => r.file.length));
5706
+ console.log(` ${"agent".padEnd(agentW)} ${"file".padEnd(fileW)} size age`);
5707
+ for (const r of rows) {
5708
+ console.log(` ${r.agent.padEnd(agentW)} ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
5709
+ }
5710
+ console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
5711
+ });
5712
+ sessionSnapshot
5713
+ .command("restore")
5714
+ .description("Extract a session snapshot to a target directory")
5715
+ .requiredOption("--snapshot <path>", "Path to the .tar.gz snapshot")
5716
+ .option("--target <dir>", "Directory to extract into (default: <snapshot>.restored next to the snapshot)")
5717
+ .option("--dry-run", "List the snapshot's contents without extracting")
5718
+ .action(async (opts) => {
5719
+ const snapshotPath = resolve(opts.snapshot);
5720
+ if (!existsSync(snapshotPath)) {
5721
+ console.error(`Error: snapshot does not exist: ${snapshotPath}`);
5722
+ process.exit(1);
5723
+ }
5724
+ if (opts.dryRun) {
5725
+ console.log("(dry-run) snapshot contents:");
5726
+ const entries = [];
5727
+ await tarList({ file: snapshotPath, onReadEntry: (entry) => entries.push(` ${entry.path} (${humanBytes(entry.size ?? 0)})`) });
5728
+ for (const e of entries)
5729
+ console.log(e);
5730
+ return;
5731
+ }
5732
+ const targetDir = opts.target
5733
+ ? resolve(opts.target)
5734
+ : `${snapshotPath}.restored`;
5735
+ if (existsSync(targetDir)) {
5736
+ console.error(`Error: target directory already exists: ${targetDir}`);
5737
+ console.error(` Pass --target <new-path> or remove the existing dir.`);
5738
+ process.exit(1);
5739
+ }
5740
+ mkdirSync(targetDir, { recursive: true, mode: 0o700 });
5741
+ await tarExtract({ file: snapshotPath, cwd: targetDir });
5742
+ console.log(targetDir);
5743
+ console.error(` extracted to: ${targetDir}`);
5744
+ });
4508
5745
  // ─── Memory and Soul commands ────────────────────────────────────────────────
4509
5746
  const memory = program.command("memory").description("Manage agent memories");
4510
5747
  memory.command("add [content]").requiredOption("--agent <id>")
4511
5748
  .option("--content <text>", "memory content (alias for positional arg)")
4512
5749
  .option("--durability <d>", "standard").option("--tags <csv>")
5750
+ .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
5751
+ .option("--subject <text>", "one-line title / entity this memory is about")
4513
5752
  .action(async (contentArg, opts) => {
4514
5753
  const content = contentArg ?? opts.content;
4515
5754
  if (!content) {
@@ -4517,13 +5756,95 @@ memory.command("add [content]").requiredOption("--agent <id>")
4517
5756
  process.exit(1);
4518
5757
  }
4519
5758
  const memId = `${opts.agent}-${Date.now()}`;
4520
- const out = await api("PUT", `/Memory/${memId}`, {
5759
+ const body = {
4521
5760
  id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
4522
5761
  tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
4523
5762
  type: "memory", createdAt: new Date().toISOString(),
4524
- });
5763
+ };
5764
+ if (opts.summary)
5765
+ body.summary = opts.summary;
5766
+ if (opts.subject)
5767
+ body.subject = opts.subject;
5768
+ const out = await api("PUT", `/Memory/${memId}`, body);
4525
5769
  console.log(JSON.stringify(out, null, 2));
4526
5770
  });
5771
+ // ─── flair memory write-task-summary ────────────────────────────────────────
5772
+ // Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B (ops-9wji-B / ops-3xyd). Standalone
5773
+ // helper that any agent harness (or a manual operator) can invoke at task
5774
+ // close to capture a structured task summary as a persistent Memory row
5775
+ // before resetting the session.
5776
+ //
5777
+ // The shape of this row matters: tags=['task-summary','auto-on-reset'] +
5778
+ // subject='task:<beads-id>' + summary populated. Slice 3+4 (harness
5779
+ // integrations) will call this as part of the reset pipeline; slice 5+6
5780
+ // (operator surfaces) will surface promote/restore controls. Today, this
5781
+ // command is independently useful — operator can capture a manual summary
5782
+ // at any time.
5783
+ //
5784
+ // Returns the memory id on stdout (single line, parseable) so the harness
5785
+ // can plumb it into the next-dispatch system message.
5786
+ memory.command("write-task-summary")
5787
+ .description("Capture a structured task summary as a persistent Memory row (used by session-reset harness; standalone-callable by operators)")
5788
+ .requiredOption("--agent <id>", "Agent the summary belongs to")
5789
+ .requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about (e.g. ops-3xyd)")
5790
+ .requiredOption("--outcome <s>", "Outcome of the task: merged | rejected | abandoned")
5791
+ .option("--summary <text>", "Multi-sentence dense compression (populates Memory.summary; will be the agent's read-time view)")
5792
+ .option("--files-touched <csv>", "Comma-separated list of files touched during the task (becomes part of content)")
5793
+ .option("--lessons <text>", "Lessons learned during the task (becomes part of content)")
5794
+ .option("--derived-from <csv>", "Comma-separated list of source Memory IDs this summary was distilled from")
5795
+ .action(async (opts) => {
5796
+ const validOutcomes = new Set(["merged", "rejected", "abandoned"]);
5797
+ if (!validOutcomes.has(opts.outcome)) {
5798
+ console.error(`Error: --outcome must be one of: merged, rejected, abandoned (got: ${opts.outcome})`);
5799
+ process.exit(1);
5800
+ }
5801
+ if (!opts.summary && !opts.lessons && !opts.filesTouched) {
5802
+ console.error("Error: at least one of --summary, --lessons, --files-touched is required (otherwise the summary has no content)");
5803
+ process.exit(1);
5804
+ }
5805
+ // Build the structured content block. Format chosen to be parseable + readable
5806
+ // — the agent reads it back on bootstrap of the next session.
5807
+ const lines = [];
5808
+ lines.push(`task: ${opts.beads}`);
5809
+ lines.push(`outcome: ${opts.outcome}`);
5810
+ if (opts.filesTouched)
5811
+ lines.push(`files: ${opts.filesTouched}`);
5812
+ if (opts.lessons) {
5813
+ lines.push("");
5814
+ lines.push("lessons:");
5815
+ lines.push(opts.lessons);
5816
+ }
5817
+ if (opts.summary) {
5818
+ lines.push("");
5819
+ lines.push("summary:");
5820
+ lines.push(opts.summary);
5821
+ }
5822
+ const content = lines.join("\n");
5823
+ const memId = `${opts.agent}-task-${opts.beads}-${Date.now()}`;
5824
+ const body = {
5825
+ id: memId,
5826
+ agentId: opts.agent,
5827
+ content,
5828
+ durability: "persistent",
5829
+ tags: ["task-summary", "auto-on-reset"],
5830
+ subject: `task:${opts.beads}`,
5831
+ type: "task-summary",
5832
+ createdAt: new Date().toISOString(),
5833
+ };
5834
+ if (opts.summary)
5835
+ body.summary = opts.summary;
5836
+ if (opts.derivedFrom) {
5837
+ body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
5838
+ }
5839
+ const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body);
5840
+ if (out?.error) {
5841
+ console.error(`Error writing task summary: ${out.error}`);
5842
+ process.exit(1);
5843
+ }
5844
+ // Print just the memory id on stdout so the harness can capture it
5845
+ // without parsing a JSON blob.
5846
+ console.log(memId);
5847
+ });
4527
5848
  memory.command("search [query]").requiredOption("--agent <id>")
4528
5849
  .option("--q <query>", "search query (alias for positional arg)")
4529
5850
  .option("--limit <n>", "Max results", "5")
@@ -4629,6 +5950,20 @@ program
4629
5950
  }
4630
5951
  });
4631
5952
  // ─── flair bootstrap ─────────────────────────────────────────────────────────
5953
+ //
5954
+ // `flair bootstrap` prints agent context (soul, memories) and a structured budget
5955
+ // footer summarizing token usage and memory inclusion/truncation. The footer is
5956
+ // parseable for downstream agents to react to budget pressure.
5957
+ //
5958
+ // Budget footer format (printed to stderr):
5959
+ // [budget: <used>/<max> tokens, <included> included, <truncated> truncated]
5960
+ //
5961
+ // Fields:
5962
+ // - tokens: estimated tokens used / max budget
5963
+ // - included: number of memories/soul entries included in context
5964
+ // - truncated: number of memories excluded due to token budget
5965
+ //
5966
+ // When truncated &gt; 0, the agent should consider asking for more context or reducing scope.
4632
5967
  program
4633
5968
  .command("bootstrap")
4634
5969
  .description("Cold-start context: get soul + recent memories as formatted text")
@@ -4662,6 +5997,12 @@ program
4662
5997
  console.error("No context available.");
4663
5998
  process.exit(1);
4664
5999
  }
6000
+ // Print budget footer to stderr (parseable, won't interfere with context output)
6001
+ const tokensUsed = result.tokenEstimate ?? 0;
6002
+ const maxTokens = parseInt(opts.maxTokens, 10);
6003
+ const included = result.memoriesIncluded ?? 0;
6004
+ const truncated = result.memoriesTruncated ?? 0;
6005
+ console.error(`[budget: ${tokensUsed}/${maxTokens} tokens, ${included} included, ${truncated} truncated]`);
4665
6006
  }
4666
6007
  catch (err) {
4667
6008
  console.error(`Bootstrap failed: ${err.message}`);
@@ -4757,6 +6098,7 @@ bridge
4757
6098
  .option("--port <port>", "Harper HTTP port")
4758
6099
  .option("--url <url>", "Flair base URL (overrides --port)")
4759
6100
  .option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
6101
+ .option("--source <path>", "Source directory (for directory-based imports like markdown)")
4760
6102
  .action(async (name, srcArg, opts) => {
4761
6103
  const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
4762
6104
  const cwd = opts.cwd ?? srcArg ?? process.cwd();
@@ -5520,7 +6862,7 @@ program
5520
6862
  };
5521
6863
  const rawOutputPath = opts.output ?? join(homedir(), ".flair", "exports", `${agentId}-${Date.now()}.json`);
5522
6864
  // Canonicalize to prevent path traversal (e.g. ../../etc/passwd)
5523
- const outputPath = resolvePath(rawOutputPath);
6865
+ const outputPath = resolve(rawOutputPath);
5524
6866
  mkdirSync(join(outputPath, ".."), { recursive: true });
5525
6867
  const fileMode = privateKey ? 0o600 : 0o644;
5526
6868
  writeFileSync(outputPath, JSON.stringify(exportData, null, 2), { mode: fileMode });
@@ -5919,4 +7261,4 @@ if (import.meta.main) {
5919
7261
  await program.parseAsync();
5920
7262
  }
5921
7263
  // ─── Exported for testing ─────────────────────────────────────────────────────
5922
- export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, };
7264
+ export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };