@tpsdev-ai/flair 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/dist/bridges/builtins/index.js +28 -11
- package/dist/bridges/builtins/markdown.js +66 -0
- package/dist/bridges/runtime/context.js +2 -1
- package/dist/bridges/runtime/formats.js +97 -9
- package/dist/bridges/runtime/load-bridge.js +15 -5
- package/dist/cli.js +1445 -103
- package/dist/resources/Federation.js +67 -10
- package/dist/resources/Memory.js +17 -24
- package/dist/resources/MemoryBootstrap.js +11 -2
- package/dist/resources/auth-middleware.js +23 -1
- package/dist/resources/content-safety.js +21 -0
- package/dist/resources/federation-cleanup.js +161 -0
- package/dist/resources/federation-crypto.js +100 -1
- package/package.json +6 -6
- package/schemas/memory.graphql +28 -0
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 =
|
|
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
|
|
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
|
-
|
|
2599
|
-
|
|
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
|
-
|
|
2652
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
2799
|
-
|
|
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 (
|
|
3192
|
+
if (totalBatches === 0) {
|
|
2803
3193
|
console.log("No changes since last sync.");
|
|
2804
3194
|
return { pushed: 0, skipped: 0 };
|
|
2805
3195
|
}
|
|
2806
|
-
|
|
2807
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
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
|
-
|
|
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
|
|
3603
|
-
console.log(`\nUpgrading ${
|
|
3604
|
-
for (const { pkg, latest } of
|
|
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
|
|
3967
|
-
|
|
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
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
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
|
|
3980
|
-
const allMemories =
|
|
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
|
-
//
|
|
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
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
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
|
|
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 > 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 =
|
|
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, };
|