@tpsdev-ai/flair 0.6.2 → 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 +19 -6
- 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 +3113 -298
- package/dist/install/clients.js +225 -0
- package/dist/resources/Federation.js +87 -24
- package/dist/resources/Memory.js +17 -24
- package/dist/resources/MemoryBootstrap.js +11 -2
- package/dist/resources/auth-middleware.js +66 -11
- 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/dist/resources/health.js +1 -1
- package/package.json +8 -6
- package/schemas/memory.graphql +28 -0
- package/ui/observation-center.html +356 -62
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { load as parseYaml } from "js-yaml";
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
|
|
6
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
7
|
+
import { join, resolve, sep } from "node:path";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
|
-
import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
|
|
9
|
+
import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
|
|
10
|
+
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
9
11
|
import { keystore } from "./keystore.js";
|
|
10
12
|
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
|
|
13
|
+
import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
11
14
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
12
15
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
13
16
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -30,6 +33,50 @@ function signBody(body, secretKey) {
|
|
|
30
33
|
const sig = nacl.sign.detached(message, secretKey);
|
|
31
34
|
return Buffer.from(sig).toString("base64url");
|
|
32
35
|
}
|
|
36
|
+
// ─── Secret detection helpers ────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Check if a value looks like a real secret/password/token.
|
|
39
|
+
* Triggers warning when:
|
|
40
|
+
* - length >= 8
|
|
41
|
+
* - contains only alphanumerics and URL-safe punctuation (._-)
|
|
42
|
+
* - NOT a URL (doesn't contain ://)
|
|
43
|
+
*/
|
|
44
|
+
function isLikelyRealSecret(value) {
|
|
45
|
+
if (!value || value.length < 8)
|
|
46
|
+
return false;
|
|
47
|
+
if (value.includes("://"))
|
|
48
|
+
return false; // exclude URLs
|
|
49
|
+
// Match typical password/token format: alphanumerics + URL-safe punct
|
|
50
|
+
const pattern = /^[A-Za-z0-9._-]+$/;
|
|
51
|
+
return pattern.test(value);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Determine if we should show an inline-secret warning.
|
|
55
|
+
*
|
|
56
|
+
* @param optValue - The value from the command line option
|
|
57
|
+
* @param fromEnv - Whether the value came from an environment variable (true = no warning)
|
|
58
|
+
* @param secretFlagNames - Set of flag names that carry secrets
|
|
59
|
+
* @param flagName - The flag being checked
|
|
60
|
+
* @returns true if warning should be shown
|
|
61
|
+
*/
|
|
62
|
+
function shouldShowInlineSecretWarning(optValue, fromEnv, secretFlagNames, flagName) {
|
|
63
|
+
// Skip if no value provided
|
|
64
|
+
if (!optValue || optValue === "")
|
|
65
|
+
return false;
|
|
66
|
+
// Skip URLs (not secrets)
|
|
67
|
+
if (flagName === "--target" || flagName === "--url")
|
|
68
|
+
return false;
|
|
69
|
+
// Only warn for secret-bearing flags
|
|
70
|
+
if (!secretFlagNames.has(flagName))
|
|
71
|
+
return false;
|
|
72
|
+
// Skip if value came from env (not argv)
|
|
73
|
+
if (fromEnv)
|
|
74
|
+
return false;
|
|
75
|
+
// Check if value looks like a real secret
|
|
76
|
+
if (!isLikelyRealSecret(optValue))
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
33
80
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
34
81
|
const DEFAULT_PORT = 19926;
|
|
35
82
|
const DEFAULT_OPS_PORT = 19925;
|
|
@@ -103,6 +150,64 @@ function resolveOpsPort(opts) {
|
|
|
103
150
|
// Default: httpPort - 1
|
|
104
151
|
return resolveHttpPort(opts) - 1;
|
|
105
152
|
}
|
|
153
|
+
// ─── Target resolution (remote Flair instance) ─────────────────────────────────
|
|
154
|
+
// --target <url> (or FLAIR_TARGET env) points all CLI operations at a remote
|
|
155
|
+
// Flair instance instead of localhost. This enables bootstrapping and
|
|
156
|
+
// managing Fabric-deployed Flair instances.
|
|
157
|
+
function resolveTarget(opts) {
|
|
158
|
+
return opts.target || process.env.FLAIR_TARGET || undefined;
|
|
159
|
+
}
|
|
160
|
+
/** Resolve the ops API target URL from --ops-target flag or FLAIR_OPS_TARGET env.
|
|
161
|
+
* Returns undefined if neither is set (caller should fall back to derivation or localhost).
|
|
162
|
+
*/
|
|
163
|
+
function resolveOpsTarget(opts) {
|
|
164
|
+
return opts.opsTarget || process.env.FLAIR_OPS_TARGET || undefined;
|
|
165
|
+
}
|
|
166
|
+
/** Derive the ops API URL from a Flair base URL.
|
|
167
|
+
* Convention: ops port = HTTP port - 1.
|
|
168
|
+
* If target has an explicit port, use port-1 (validated: must be 1-65535).
|
|
169
|
+
* If no explicit port: https → 442 (443-1), http → 19925 (19926-1), bare host → https://<host>:19925.
|
|
170
|
+
* Throws on unparseable URLs.
|
|
171
|
+
*/
|
|
172
|
+
/** Compute the effective ops API URL for remote commands.
|
|
173
|
+
* - If --ops-target is set, use it directly (no derivation).
|
|
174
|
+
* - Else if --target is set, derive ops URL via resolveOpsUrlFromTarget.
|
|
175
|
+
* - Else return undefined (fall back to localhost resolution).
|
|
176
|
+
*/
|
|
177
|
+
function resolveEffectiveOpsUrl(opts) {
|
|
178
|
+
const opsTarget = resolveOpsTarget(opts);
|
|
179
|
+
if (opsTarget)
|
|
180
|
+
return opsTarget.replace(/\/$/, "");
|
|
181
|
+
const target = resolveTarget(opts);
|
|
182
|
+
if (target)
|
|
183
|
+
return resolveOpsUrlFromTarget(target);
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
function resolveOpsUrlFromTarget(targetUrl) {
|
|
187
|
+
// Normalise bare hosts: add https:// prefix so URL parser can handle them.
|
|
188
|
+
const normalised = targetUrl.includes("://") ? targetUrl : `https://${targetUrl}`;
|
|
189
|
+
const url = new URL(normalised);
|
|
190
|
+
const port = parseInt(url.port, 10);
|
|
191
|
+
if (!isNaN(port) && port > 0 && port <= 65535) {
|
|
192
|
+
const opsPort = port - 1;
|
|
193
|
+
if (opsPort < 1)
|
|
194
|
+
throw new Error(`Derived ops port ${opsPort} is out of range; target port must be > 1`);
|
|
195
|
+
url.port = String(opsPort);
|
|
196
|
+
return url.toString().replace(/\/$/, "");
|
|
197
|
+
}
|
|
198
|
+
// No valid explicit port — reject port 0 or out-of-range
|
|
199
|
+
if (url.port !== "" && url.port !== undefined) {
|
|
200
|
+
throw new Error(`Invalid target port: ${url.port} (must be 1-65535)`);
|
|
201
|
+
}
|
|
202
|
+
// No explicit port — infer from scheme
|
|
203
|
+
if (url.protocol === "https:") {
|
|
204
|
+
url.port = "442";
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
url.port = String(DEFAULT_OPS_PORT);
|
|
208
|
+
}
|
|
209
|
+
return url.toString().replace(/\/$/, "");
|
|
210
|
+
}
|
|
106
211
|
function writeConfig(port) {
|
|
107
212
|
const p = configPath();
|
|
108
213
|
mkdirSync(join(homedir(), ".flair"), { recursive: true });
|
|
@@ -136,22 +241,40 @@ function b64(bytes) {
|
|
|
136
241
|
function b64url(bytes) {
|
|
137
242
|
return Buffer.from(bytes).toString("base64url");
|
|
138
243
|
}
|
|
139
|
-
|
|
244
|
+
function isLocalBase(base) {
|
|
245
|
+
try {
|
|
246
|
+
const url = new URL(base);
|
|
247
|
+
return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return !base;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function api(method, path, body, options) {
|
|
140
254
|
// Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
|
|
255
|
+
// When baseUrl is provided (--target), use it directly.
|
|
141
256
|
const savedPort = readPortFromConfig();
|
|
142
257
|
const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
143
|
-
const base = process.env.FLAIR_URL || defaultUrl;
|
|
258
|
+
const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
|
|
259
|
+
const isLocal = isLocalBase(base);
|
|
144
260
|
// Auth resolution order:
|
|
145
261
|
// 1. FLAIR_TOKEN env → Bearer token (backward compat)
|
|
146
|
-
// 2.
|
|
147
|
-
//
|
|
148
|
-
//
|
|
262
|
+
// 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth (remote targets only).
|
|
263
|
+
// For local targets with authorizeLocal=true, skip Basic auth and let Harper handle it.
|
|
264
|
+
// 3. FLAIR_AGENT_ID env + key file → Ed25519 signature (standard)
|
|
265
|
+
// 4. No auth (Harper authorizeLocal handles local; remote will 401)
|
|
266
|
+
//
|
|
267
|
+
// NOTE: this function is for the Harper HTTP/REST API only. The Harper
|
|
268
|
+
// operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
|
|
269
|
+
// does NOT honor authorizeLocal — it always requires Basic admin auth, and
|
|
270
|
+
// those helpers send it unconditionally. authorizeLocal=true affects this
|
|
271
|
+
// path; it does not affect ops-API calls.
|
|
149
272
|
let authHeader;
|
|
150
273
|
const token = process.env.FLAIR_TOKEN;
|
|
151
274
|
if (token) {
|
|
152
275
|
authHeader = `Bearer ${token}`;
|
|
153
276
|
}
|
|
154
|
-
else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
|
|
277
|
+
else if (!isLocal && (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD)) {
|
|
155
278
|
// Admin Basic auth — used by federation, backup, and other admin CLI commands
|
|
156
279
|
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
157
280
|
authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
@@ -174,7 +297,8 @@ async function api(method, path, body) {
|
|
|
174
297
|
}
|
|
175
298
|
catch (err) {
|
|
176
299
|
// Key exists but auth build failed — warn and continue without auth
|
|
177
|
-
|
|
300
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
301
|
+
console.error(`Warning: Ed25519 auth failed for agent '${agentId}': ${message}`);
|
|
178
302
|
}
|
|
179
303
|
}
|
|
180
304
|
}
|
|
@@ -303,9 +427,22 @@ function readHarperPid(dataDir) {
|
|
|
303
427
|
return null;
|
|
304
428
|
}
|
|
305
429
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Seed an agent record via the Harper operations API.
|
|
432
|
+
* Accepts either a port number (localhost) or a full URL string (--target).
|
|
433
|
+
*
|
|
434
|
+
* `adminPass` is typed as optional but in practice the Harper operations API
|
|
435
|
+
* always requires Basic admin auth — every existing call site passes one.
|
|
436
|
+
* The optional signature leaves headroom for a future Harper that honors
|
|
437
|
+
* `authorizeLocal` on its ops endpoint; until then, callers must pass it.
|
|
438
|
+
*/
|
|
439
|
+
export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, adminUser, adminPass) {
|
|
440
|
+
const url = typeof opsPortOrUrl === "number"
|
|
441
|
+
? `http://127.0.0.1:${opsPortOrUrl}/`
|
|
442
|
+
: `${opsPortOrUrl.replace(/\/$/, "")}/`;
|
|
443
|
+
// Send Basic auth whenever the caller passed an adminPass. The caller decides
|
|
444
|
+
// when to omit it (e.g., local target with authorizeLocal=true).
|
|
445
|
+
const auth = adminPass !== undefined ? Buffer.from(`${adminUser}:${adminPass}`).toString("base64") : undefined;
|
|
309
446
|
const body = {
|
|
310
447
|
operation: "insert",
|
|
311
448
|
database: "flair",
|
|
@@ -314,8 +451,9 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
|
|
|
314
451
|
};
|
|
315
452
|
const res = await fetch(url, {
|
|
316
453
|
method: "POST",
|
|
317
|
-
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
|
454
|
+
headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
|
|
318
455
|
body: JSON.stringify(body),
|
|
456
|
+
signal: AbortSignal.timeout(10_000),
|
|
319
457
|
});
|
|
320
458
|
if (!res.ok) {
|
|
321
459
|
const text = await res.text().catch(() => "");
|
|
@@ -324,6 +462,301 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
|
|
|
324
462
|
throw new Error(`Operations API insert failed (${res.status}): ${text}`);
|
|
325
463
|
}
|
|
326
464
|
}
|
|
465
|
+
// Seed an agent record via the Harper REST API.
|
|
466
|
+
// Uses localhost and the given HTTP port.
|
|
467
|
+
export async function seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass) {
|
|
468
|
+
const baseUrl = `http://127.0.0.1:${httpPort}`;
|
|
469
|
+
const body = {
|
|
470
|
+
operation: "insert",
|
|
471
|
+
database: "flair",
|
|
472
|
+
table: "Agent",
|
|
473
|
+
records: [{ id: agentId, name: agentId, publicKey: pubKeyB64url, createdAt: new Date().toISOString() }],
|
|
474
|
+
};
|
|
475
|
+
// Only send Authorization header if adminPass is provided.
|
|
476
|
+
// Matches the auth pattern in api() which respects authorizeLocal=true.
|
|
477
|
+
const headers = { "Content-Type": "application/json" };
|
|
478
|
+
if (adminPass) {
|
|
479
|
+
headers.Authorization = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
480
|
+
}
|
|
481
|
+
const res = await fetch(`${baseUrl}/Agent`, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
headers,
|
|
484
|
+
body: JSON.stringify(body),
|
|
485
|
+
signal: AbortSignal.timeout(10_000),
|
|
486
|
+
});
|
|
487
|
+
if (!res.ok) {
|
|
488
|
+
const text = await res.text().catch(() => "");
|
|
489
|
+
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
490
|
+
return;
|
|
491
|
+
throw new Error(`REST API insert failed (${res.status}): ${text}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// ─── FederationInstance seed via ops API ──────────────────────────────────────
|
|
495
|
+
//
|
|
496
|
+
// Remote init writes FederationInstance through the ops API (Basic auth with
|
|
497
|
+
// admin:admin-pass), not the REST API (which needs server-side HDB_ADMIN_PASSWORD
|
|
498
|
+
// — unavailable on Fabric). Same pattern as seedAgentViaOpsApi above.
|
|
499
|
+
//
|
|
500
|
+
// `adminPass` is optional in the signature for symmetry with seedAgentViaOpsApi
|
|
501
|
+
// and to keep the door open for a future Harper that honors authorizeLocal on
|
|
502
|
+
// its ops endpoint. Today the Harper operations API always requires Basic admin
|
|
503
|
+
// auth; every current caller passes it.
|
|
504
|
+
export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId, publicKey, role, adminUser, adminPass) {
|
|
505
|
+
const url = typeof opsPortOrUrl === "number"
|
|
506
|
+
? `http://127.0.0.1:${opsPortOrUrl}/`
|
|
507
|
+
: `${opsPortOrUrl.replace(/\/$/, "")}/`;
|
|
508
|
+
// Send Basic auth whenever the caller passed an adminPass. The caller decides
|
|
509
|
+
// when to omit it (e.g., local target with authorizeLocal=true).
|
|
510
|
+
const auth = adminPass !== undefined ? Buffer.from(`${adminUser}:${adminPass}`).toString("base64") : undefined;
|
|
511
|
+
const now = new Date().toISOString();
|
|
512
|
+
const body = {
|
|
513
|
+
operation: "insert",
|
|
514
|
+
database: "flair",
|
|
515
|
+
table: "Instance",
|
|
516
|
+
records: [{
|
|
517
|
+
id: instanceId,
|
|
518
|
+
publicKey,
|
|
519
|
+
role,
|
|
520
|
+
status: "active",
|
|
521
|
+
createdAt: now,
|
|
522
|
+
updatedAt: now,
|
|
523
|
+
}],
|
|
524
|
+
};
|
|
525
|
+
const res = await fetch(url, {
|
|
526
|
+
method: "POST",
|
|
527
|
+
headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
|
|
528
|
+
body: JSON.stringify(body),
|
|
529
|
+
signal: AbortSignal.timeout(10_000),
|
|
530
|
+
});
|
|
531
|
+
if (!res.ok) {
|
|
532
|
+
const text = await res.text().catch(() => "");
|
|
533
|
+
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
534
|
+
return;
|
|
535
|
+
throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// ─── Provision Flair on Harper Fabric (ops-2kyi) ───────────────────────────
|
|
539
|
+
//
|
|
540
|
+
// Atomic provisioning for a fresh Harper Fabric cluster: builds a deploy
|
|
541
|
+
// tarball with .env baked in, deploys via ops API, waits for restart, and
|
|
542
|
+
// creates the super_user admin account.
|
|
543
|
+
export async function callOpsApi(opsUrl, body, user, pass) {
|
|
544
|
+
const url = `${opsUrl.replace(/\/$/, "")}/`;
|
|
545
|
+
const auth = Buffer.from(`${user}:${pass}`).toString("base64");
|
|
546
|
+
const res = await fetch(url, {
|
|
547
|
+
method: "POST",
|
|
548
|
+
headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
|
|
549
|
+
body: JSON.stringify(body),
|
|
550
|
+
signal: AbortSignal.timeout(30_000),
|
|
551
|
+
});
|
|
552
|
+
if (!res.ok) {
|
|
553
|
+
const text = await res.text().catch(() => "");
|
|
554
|
+
throw new Error(`Ops API call failed (${res.status}): ${text}`);
|
|
555
|
+
}
|
|
556
|
+
return res.json();
|
|
557
|
+
}
|
|
558
|
+
export async function buildDeployTarball(projectRoot, flairAdminPass) {
|
|
559
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "flair-deploy-"));
|
|
560
|
+
try {
|
|
561
|
+
// Copy deployment files into temp directory
|
|
562
|
+
const entries = ["dist", "schemas", "config.yaml", "package.json", "LICENSE", "README.md", "SECURITY.md"];
|
|
563
|
+
if (existsSync(join(projectRoot, "ui")))
|
|
564
|
+
entries.push("ui");
|
|
565
|
+
for (const entry of entries) {
|
|
566
|
+
const src = join(projectRoot, entry);
|
|
567
|
+
const dst = join(tmpDir, entry);
|
|
568
|
+
if (existsSync(src)) {
|
|
569
|
+
cpSync(src, dst, { recursive: true });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Write .env with 600 permissions
|
|
573
|
+
const envContent = [
|
|
574
|
+
`HDB_ADMIN_PASSWORD=${flairAdminPass}`,
|
|
575
|
+
`FLAIR_ADMIN_PASSWORD=${flairAdminPass}`,
|
|
576
|
+
"",
|
|
577
|
+
].join("\n");
|
|
578
|
+
writeFileSync(join(tmpDir, ".env"), envContent, { mode: 0o600 });
|
|
579
|
+
// Build compressed tarball
|
|
580
|
+
const tarballPath = join(tmpDir, "deploy.tar.gz");
|
|
581
|
+
await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, entries);
|
|
582
|
+
const buf = readFileSync(tarballPath);
|
|
583
|
+
return { tarballB64: buf.toString("base64") };
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
export async function waitForFlairRestart(targetUrl, maxWaitMs = 30_000) {
|
|
590
|
+
const url = `${targetUrl.replace(/\/$/, "")}/FederationPair`;
|
|
591
|
+
const intervalMs = 1_000;
|
|
592
|
+
const deadline = Date.now() + maxWaitMs;
|
|
593
|
+
while (Date.now() < deadline) {
|
|
594
|
+
try {
|
|
595
|
+
const res = await fetch(url, {
|
|
596
|
+
method: "POST",
|
|
597
|
+
headers: { "Content-Type": "application/json" },
|
|
598
|
+
body: JSON.stringify({}),
|
|
599
|
+
signal: AbortSignal.timeout(5_000),
|
|
600
|
+
});
|
|
601
|
+
const text = await res.text().catch(() => "");
|
|
602
|
+
// The resource handler responds with "instanceId and publicKey required"
|
|
603
|
+
// when the deployment is live and Flair is serving requests.
|
|
604
|
+
if (text.includes("instanceId and publicKey required"))
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
// Not ready yet — keep polling
|
|
609
|
+
}
|
|
610
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
611
|
+
}
|
|
612
|
+
throw new Error(`Flair did not respond within ${maxWaitMs / 1000}s`);
|
|
613
|
+
}
|
|
614
|
+
export async function provisionFabric(target, opsTarget, clusterAdminUser, clusterAdminPass, flairAdminPass) {
|
|
615
|
+
const projectRoot = process.cwd();
|
|
616
|
+
// 1. Build and deploy component tarball
|
|
617
|
+
console.log("Building deploy tarball...");
|
|
618
|
+
const { tarballB64 } = await buildDeployTarball(projectRoot, flairAdminPass);
|
|
619
|
+
console.log("Deploying via ops API...");
|
|
620
|
+
await callOpsApi(opsTarget, {
|
|
621
|
+
operation: "deploy_component",
|
|
622
|
+
project: "flair",
|
|
623
|
+
payload: tarballB64,
|
|
624
|
+
restart: "rolling",
|
|
625
|
+
}, clusterAdminUser, clusterAdminPass);
|
|
626
|
+
// 2. Wait for restart
|
|
627
|
+
console.log("Waiting for Flair to restart...");
|
|
628
|
+
await waitForFlairRestart(target);
|
|
629
|
+
console.log("Flair is running ✓");
|
|
630
|
+
// 3. Provision Harper super_user
|
|
631
|
+
// Since ops-lzmg is merged, the username doesn't have to be "admin".
|
|
632
|
+
// We can use the cluster-admin user directly if it's already a super_user.
|
|
633
|
+
// Check if cluster admin is already a super_user first:
|
|
634
|
+
let clusterAdminIsSuperUser = false;
|
|
635
|
+
try {
|
|
636
|
+
const userInfo = await callOpsApi(opsTarget, {
|
|
637
|
+
operation: "list_users",
|
|
638
|
+
}, clusterAdminUser, clusterAdminPass);
|
|
639
|
+
// list_users returns an array of user objects with role/permission info
|
|
640
|
+
const users = Array.isArray(userInfo) ? userInfo : [];
|
|
641
|
+
const adminRecord = users.find((u) => u.username === clusterAdminUser || u.user?.username === clusterAdminUser);
|
|
642
|
+
clusterAdminIsSuperUser = !!(adminRecord?.role?.permission?.super_user ??
|
|
643
|
+
adminRecord?.permission?.super_user ??
|
|
644
|
+
false);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// If we can't check, assume not and proceed with add_user
|
|
648
|
+
}
|
|
649
|
+
if (clusterAdminIsSuperUser) {
|
|
650
|
+
console.log(`Cluster admin '${clusterAdminUser}' is already a super_user — skipping user provisioning`);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
console.log(`Provisioning Harper user 'admin' as super_user...`);
|
|
654
|
+
try {
|
|
655
|
+
await callOpsApi(opsTarget, {
|
|
656
|
+
operation: "add_user",
|
|
657
|
+
username: "admin",
|
|
658
|
+
password: flairAdminPass,
|
|
659
|
+
role: "super_user",
|
|
660
|
+
active: true,
|
|
661
|
+
}, clusterAdminUser, clusterAdminPass);
|
|
662
|
+
console.log("User 'admin' created ✓");
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
666
|
+
if (msg.includes("already exists") || msg.includes("duplicate")) {
|
|
667
|
+
// Idempotent: fall back to alter_user
|
|
668
|
+
console.log("User 'admin' already exists — updating password...");
|
|
669
|
+
await callOpsApi(opsTarget, {
|
|
670
|
+
operation: "alter_user",
|
|
671
|
+
username: "admin",
|
|
672
|
+
password: flairAdminPass,
|
|
673
|
+
role: "super_user",
|
|
674
|
+
active: true,
|
|
675
|
+
}, clusterAdminUser, clusterAdminPass);
|
|
676
|
+
console.log("User 'admin' updated ✓");
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
throw err;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
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
|
+
}
|
|
327
760
|
// ─── Upgrade presence probes ──────────────────────────────────────────────────
|
|
328
761
|
//
|
|
329
762
|
// `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
|
|
@@ -369,6 +802,35 @@ export function probeLibVersion(pkgName) {
|
|
|
369
802
|
return null;
|
|
370
803
|
}
|
|
371
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
|
+
}
|
|
372
834
|
export function templateSoul(choice) {
|
|
373
835
|
const templates = {
|
|
374
836
|
"1": [
|
|
@@ -515,7 +977,8 @@ async function runSoulWizard(agentId) {
|
|
|
515
977
|
}
|
|
516
978
|
}
|
|
517
979
|
catch (err) {
|
|
518
|
-
|
|
980
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
981
|
+
console.log(`\n Couldn't parse JSON (${message}). Falling back to custom prompts.`);
|
|
519
982
|
entries = await customSoulPrompts(ask);
|
|
520
983
|
}
|
|
521
984
|
}
|
|
@@ -542,24 +1005,263 @@ program.name("flair").version(__pkgVersion, "-v, --version");
|
|
|
542
1005
|
// ─── flair init ──────────────────────────────────────────────────────────────
|
|
543
1006
|
program
|
|
544
1007
|
.command("init")
|
|
545
|
-
.description("Bootstrap a
|
|
546
|
-
.option("--agent-id <id>", "Agent ID to register
|
|
1008
|
+
.description("Bootstrap a Flair (Harper) instance for an agent")
|
|
1009
|
+
.option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
|
|
547
1010
|
.option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
|
|
548
1011
|
.option("--ops-port <port>", "Harper operations API port")
|
|
549
1012
|
.option("--admin-pass <pass>", "Admin password (generated if omitted)")
|
|
1013
|
+
.option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
|
|
550
1014
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
551
1015
|
.option("--data-dir <dir>", "Harper data directory")
|
|
552
1016
|
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
553
1017
|
.option("--skip-soul", "Skip interactive personality setup")
|
|
1018
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
1019
|
+
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
1020
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
1021
|
+
.option("--force", "Skip confirmation prompt for remote writes (required with --target)")
|
|
1022
|
+
.option("--cluster-admin-user <user>", "Harper cluster admin username (env: FLAIR_CLUSTER_ADMIN_USER)")
|
|
1023
|
+
.option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
|
|
1024
|
+
.option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
|
|
554
1025
|
.action(async (opts) => {
|
|
555
1026
|
const agentId = opts.agentId;
|
|
1027
|
+
const target = resolveTarget(opts);
|
|
1028
|
+
const opsTarget = resolveOpsTarget(opts);
|
|
1029
|
+
// ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
|
|
1030
|
+
if (target || opsTarget) {
|
|
1031
|
+
// When -only- --ops-target is provided, attempt to derive REST URL
|
|
1032
|
+
if (!target && opsTarget) {
|
|
1033
|
+
console.error("Error: --ops-target requires --target as well. Pass --target <rest-url> for the REST API surface.");
|
|
1034
|
+
console.error(" Currently only explicit --ops-target + --target combination is supported.");
|
|
1035
|
+
process.exit(1);
|
|
1036
|
+
}
|
|
1037
|
+
const baseUrl = target.replace(/\/$/, "");
|
|
1038
|
+
// --ops-target overrides derivation; otherwise derive from --target
|
|
1039
|
+
const opsUrl = opsTarget ? opsTarget.replace(/\/$/, "") : resolveOpsUrlFromTarget(baseUrl);
|
|
1040
|
+
// Check for cluster-admin provisioning (new atomic flow)
|
|
1041
|
+
const clusterAdminUser = opts.clusterAdminUser || process.env.FLAIR_CLUSTER_ADMIN_USER;
|
|
1042
|
+
const clusterAdminPass = opts.clusterAdminPass || process.env.FLAIR_CLUSTER_ADMIN_PASS;
|
|
1043
|
+
let flairAdminPass = opts.flairAdminPass || process.env.FLAIR_ADMIN_PASS;
|
|
1044
|
+
let didProvision = false;
|
|
1045
|
+
if (clusterAdminUser && clusterAdminPass) {
|
|
1046
|
+
// ── New provisioning path: deploy Flair to Fabric, wait, provision super_user ──
|
|
1047
|
+
if (!opts.force) {
|
|
1048
|
+
console.error("Error: --force is required with --target/--ops-target (remote init provisions a live Fabric instance)");
|
|
1049
|
+
console.error(" Pass --force to confirm this is intended.");
|
|
1050
|
+
process.exit(1);
|
|
1051
|
+
}
|
|
1052
|
+
// Generate flair admin pass if not provided
|
|
1053
|
+
if (!flairAdminPass) {
|
|
1054
|
+
flairAdminPass = randomBytes(24).toString("base64url");
|
|
1055
|
+
}
|
|
1056
|
+
// Write the flair admin pass to secrets directory
|
|
1057
|
+
const secretsDir = join(homedir(), ".tps", "secrets");
|
|
1058
|
+
mkdirSync(secretsDir, { recursive: true });
|
|
1059
|
+
const secretPath = join(secretsDir, "flair-fabric-hdb");
|
|
1060
|
+
writeFileSync(secretPath, flairAdminPass + "\n", { mode: 0o600 });
|
|
1061
|
+
console.log(`Admin password written to ${secretPath}`);
|
|
1062
|
+
// Atomic provisioning: deploy + wait + provision user
|
|
1063
|
+
await provisionFabric(baseUrl, opsUrl, clusterAdminUser, clusterAdminPass, flairAdminPass);
|
|
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
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
// ── Existing behavior: --admin-pass required for already-running Flair ──
|
|
1074
|
+
if (!opts.adminPass) {
|
|
1075
|
+
console.error("Error: --admin-pass is required with --target/--ops-target (remote init without --cluster-admin-user/--cluster-admin-pass)");
|
|
1076
|
+
console.error(" Use --cluster-admin-user and --cluster-admin-pass for automated Fabric provisioning.");
|
|
1077
|
+
process.exit(1);
|
|
1078
|
+
}
|
|
1079
|
+
if (!opts.force) {
|
|
1080
|
+
const displayTarget = target || opsTarget;
|
|
1081
|
+
console.error(`Error: --force is required with --target/--ops-target. Remote init writes to a live Flair instance at ${displayTarget}.`);
|
|
1082
|
+
console.error(" Pass --force to confirm this is intended.");
|
|
1083
|
+
process.exit(1);
|
|
1084
|
+
}
|
|
1085
|
+
flairAdminPass = opts.adminPass;
|
|
1086
|
+
}
|
|
1087
|
+
const adminUser = DEFAULT_ADMIN_USER;
|
|
1088
|
+
const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
|
|
1089
|
+
const role = opts.remote ? "hub" : undefined;
|
|
1090
|
+
// Generate or reuse keypair (only if --agent-id provided, or --remote needs
|
|
1091
|
+
// a public key for the FederationInstance row)
|
|
1092
|
+
let pubKeyB64url;
|
|
1093
|
+
let privPath;
|
|
1094
|
+
let instanceId;
|
|
1095
|
+
if (agentId || role) {
|
|
1096
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
1097
|
+
mkdirSync(keysDir, { recursive: true });
|
|
1098
|
+
if (agentId) {
|
|
1099
|
+
privPath = privKeyPath(agentId, keysDir);
|
|
1100
|
+
const pubPath = pubKeyPath(agentId, keysDir);
|
|
1101
|
+
if (existsSync(privPath)) {
|
|
1102
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
1103
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
1104
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
1105
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1106
|
+
}
|
|
1107
|
+
else {
|
|
1108
|
+
console.log("Generating Ed25519 keypair...");
|
|
1109
|
+
const kp = nacl.sign.keyPair();
|
|
1110
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
1111
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
1112
|
+
chmodSync(privPath, 0o600);
|
|
1113
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
1114
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1115
|
+
console.log(`Keypair written: ${privPath} ✓`);
|
|
1116
|
+
}
|
|
1117
|
+
// Seed agent via remote ops API
|
|
1118
|
+
console.log(`Seeding agent '${agentId}' on ${baseUrl}...`);
|
|
1119
|
+
await seedAgentViaOpsApi(opsUrl, agentId, pubKeyB64url, adminUser, flairAdminPass);
|
|
1120
|
+
console.log(`Agent '${agentId}' registered on remote instance ✓`);
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
// No agentId -- generate throwaway keypair for FederationInstance row
|
|
1124
|
+
console.log("Generating federation instance keypair...");
|
|
1125
|
+
const kp = nacl.sign.keyPair();
|
|
1126
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
console.log("No --agent-id provided -- skipping agent registration");
|
|
1131
|
+
}
|
|
1132
|
+
// Write FederationInstance row if --remote (hub role)
|
|
1133
|
+
if (role) {
|
|
1134
|
+
if (!pubKeyB64url) {
|
|
1135
|
+
const kp = nacl.sign.keyPair();
|
|
1136
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1137
|
+
}
|
|
1138
|
+
instanceId = randomUUID();
|
|
1139
|
+
console.log(`Writing federation Instance (role=${role}) via ops API...`);
|
|
1140
|
+
await seedFederationInstanceViaOpsApi(opsUrl, instanceId, pubKeyB64url, role, adminUser, flairAdminPass);
|
|
1141
|
+
console.log(`Federation Instance created: ${instanceId} (${role}) ✓`);
|
|
1142
|
+
}
|
|
1143
|
+
// Verify connectivity
|
|
1144
|
+
if (didProvision) {
|
|
1145
|
+
// Use /FederationInstance with Basic auth (not /Health which false-401s on Fabric)
|
|
1146
|
+
console.log("Verifying remote connectivity...");
|
|
1147
|
+
const verifyRes = await fetch(`${baseUrl}/FederationInstance`, {
|
|
1148
|
+
headers: { Authorization: auth },
|
|
1149
|
+
signal: AbortSignal.timeout(5000),
|
|
1150
|
+
});
|
|
1151
|
+
if (!verifyRes.ok) {
|
|
1152
|
+
const body = await verifyRes.text().catch(() => "");
|
|
1153
|
+
console.error(`Remote verification failed (${verifyRes.status}): ${body}`);
|
|
1154
|
+
process.exit(1);
|
|
1155
|
+
}
|
|
1156
|
+
console.log("✓ Hub ready at " + baseUrl);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
// Existing behavior: /Health check (already-running Flair)
|
|
1160
|
+
console.log("Verifying remote connectivity...");
|
|
1161
|
+
const verifyRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
|
|
1162
|
+
if (!verifyRes.ok) {
|
|
1163
|
+
console.error(`Remote health check failed: ${verifyRes.status}`);
|
|
1164
|
+
process.exit(1);
|
|
1165
|
+
}
|
|
1166
|
+
console.log("Remote Flair instance healthy ✓");
|
|
1167
|
+
}
|
|
1168
|
+
// Print summary
|
|
1169
|
+
if (didProvision) {
|
|
1170
|
+
const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8"));
|
|
1171
|
+
const flavor = pkg.version || "(unknown)";
|
|
1172
|
+
const displayTarget = target || opsTarget;
|
|
1173
|
+
console.log(`\n✓ Flair hub deployed to ${displayTarget}`);
|
|
1174
|
+
console.log(` Component: flair@${flavor}`);
|
|
1175
|
+
console.log(` Admin user: ${adminUser} (pass written to ${join(homedir(), ".tps", "secrets", "flair-fabric-hdb")})`);
|
|
1176
|
+
if (instanceId)
|
|
1177
|
+
console.log(` Instance: ${instanceId} (role=${role})`);
|
|
1178
|
+
console.log(` Federation: ready — run \`flair federation token\` to mint a pairing token`);
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
console.log(`\n✅ Remote Flair initialized`);
|
|
1182
|
+
if (agentId)
|
|
1183
|
+
console.log(` Agent ID: ${agentId}`);
|
|
1184
|
+
console.log(` Target: ${baseUrl}`);
|
|
1185
|
+
if (agentId)
|
|
1186
|
+
console.log(` Private key: ${privPath}`);
|
|
1187
|
+
if (role)
|
|
1188
|
+
console.log(` Role: ${role}`);
|
|
1189
|
+
console.log(`\n Export: FLAIR_URL=${baseUrl}`);
|
|
1190
|
+
}
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
// ── Local init (original behavior) ──
|
|
556
1194
|
const httpPort = resolveHttpPort(opts);
|
|
557
1195
|
const opsPort = resolveOpsPort(opts);
|
|
558
1196
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
559
1197
|
const dataDir = opts.dataDir ?? defaultDataDir();
|
|
560
|
-
// Admin password:
|
|
561
|
-
|
|
1198
|
+
// Admin password: determine from opts, env, or generate
|
|
1199
|
+
// Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
|
|
1200
|
+
let adminPass;
|
|
1201
|
+
let passwordSource = "generated";
|
|
1202
|
+
// Warn if --admin-pass is passed inline (not from env)
|
|
1203
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, false, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
1204
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-file <path> or FLAIR_ADMIN_PASS env " +
|
|
1205
|
+
"to keep secrets out of shell history.");
|
|
1206
|
+
}
|
|
1207
|
+
// Read from file if provided
|
|
1208
|
+
if (opts.adminPassFile) {
|
|
1209
|
+
if (!existsSync(opts.adminPassFile)) {
|
|
1210
|
+
console.error(`Error: --admin-pass-file path does not exist: ${opts.adminPassFile}`);
|
|
1211
|
+
process.exit(1);
|
|
1212
|
+
}
|
|
1213
|
+
const fileContent = readFileSync(opts.adminPassFile, "utf-8");
|
|
1214
|
+
adminPass = fileContent.trim();
|
|
1215
|
+
if (!adminPass) {
|
|
1216
|
+
console.error(`Error: admin password file is empty or contains only whitespace: ${opts.adminPassFile}`);
|
|
1217
|
+
process.exit(1);
|
|
1218
|
+
}
|
|
1219
|
+
passwordSource = "file";
|
|
1220
|
+
}
|
|
1221
|
+
else if (process.env.FLAIR_ADMIN_PASS) {
|
|
1222
|
+
adminPass = process.env.FLAIR_ADMIN_PASS;
|
|
1223
|
+
passwordSource = "env";
|
|
1224
|
+
}
|
|
1225
|
+
else if (process.env.HDB_ADMIN_PASSWORD) {
|
|
1226
|
+
adminPass = process.env.HDB_ADMIN_PASSWORD;
|
|
1227
|
+
passwordSource = "env";
|
|
1228
|
+
}
|
|
1229
|
+
else if (opts.adminPass) {
|
|
1230
|
+
// Inline admin pass (deprecated)
|
|
1231
|
+
adminPass = opts.adminPass;
|
|
1232
|
+
// Don't generate - don't write to file
|
|
1233
|
+
passwordSource = "env"; // Treat same as env for display purposes
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
// Generate new password and write to file atomically
|
|
1237
|
+
adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
|
|
1238
|
+
passwordSource = "generated";
|
|
1239
|
+
// Atomic write: create temp file in same dir, then rename
|
|
1240
|
+
const flairDir = join(homedir(), ".flair");
|
|
1241
|
+
mkdirSync(flairDir, { recursive: true });
|
|
1242
|
+
const adminPassPath = join(flairDir, "admin-pass");
|
|
1243
|
+
const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
|
|
1244
|
+
const finalTempPath = join(tempPath, "admin-pass");
|
|
1245
|
+
try {
|
|
1246
|
+
writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
|
|
1247
|
+
renameSync(finalTempPath, adminPassPath);
|
|
1248
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
1249
|
+
}
|
|
1250
|
+
catch (err) {
|
|
1251
|
+
// Clean up temp dir on failure
|
|
1252
|
+
try {
|
|
1253
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
1254
|
+
}
|
|
1255
|
+
catch { }
|
|
1256
|
+
throw err;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
562
1259
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
1260
|
+
// If we generated the password, report where it was saved
|
|
1261
|
+
if (passwordSource === "generated") {
|
|
1262
|
+
const adminPassPath = join(homedir(), ".flair", "admin-pass");
|
|
1263
|
+
console.log(`Admin password saved to: ${adminPassPath}`);
|
|
1264
|
+
}
|
|
563
1265
|
// Check Node.js version
|
|
564
1266
|
const major = parseInt(process.version.slice(1), 10);
|
|
565
1267
|
if (major < 18)
|
|
@@ -719,150 +1421,515 @@ program
|
|
|
719
1421
|
}
|
|
720
1422
|
// Persist port to config so other commands can find this instance
|
|
721
1423
|
writeConfig(httpPort);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1424
|
+
if (agentId) {
|
|
1425
|
+
// Generate or reuse keypair
|
|
1426
|
+
mkdirSync(keysDir, { recursive: true });
|
|
1427
|
+
const privPath = privKeyPath(agentId, keysDir);
|
|
1428
|
+
const pubPath = pubKeyPath(agentId, keysDir);
|
|
1429
|
+
let pubKeyB64url;
|
|
1430
|
+
if (existsSync(privPath)) {
|
|
1431
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
1432
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
1433
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
1434
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1435
|
+
}
|
|
1436
|
+
else {
|
|
1437
|
+
console.log("Generating Ed25519 keypair...");
|
|
1438
|
+
const kp = nacl.sign.keyPair();
|
|
1439
|
+
// Store only the 32-byte seed (first 32 bytes of secretKey)
|
|
1440
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
1441
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
1442
|
+
chmodSync(privPath, 0o600);
|
|
1443
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
1444
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1445
|
+
console.log(`Keypair written: ${privPath} ✓`);
|
|
1446
|
+
}
|
|
1447
|
+
// Seed agent via operations API
|
|
1448
|
+
console.log(`Seeding agent '${agentId}' via operations API...`);
|
|
1449
|
+
await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass);
|
|
1450
|
+
console.log(`Agent '${agentId}' registered ✓`);
|
|
1451
|
+
// Verify Ed25519 auth
|
|
1452
|
+
console.log("Verifying Ed25519 auth...");
|
|
1453
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1454
|
+
const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
|
|
1455
|
+
if (!verifyRes.ok)
|
|
1456
|
+
throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
|
|
1457
|
+
console.log("Ed25519 auth verified ✓");
|
|
1458
|
+
// Output — admin password printed once, never written to disk
|
|
1459
|
+
console.log("\n✅ Flair initialized successfully");
|
|
1460
|
+
console.log(` Agent ID: ${agentId}`);
|
|
1461
|
+
console.log(` Flair URL: ${httpUrl}`);
|
|
1462
|
+
console.log(` Private key: ${privPath}`);
|
|
1463
|
+
// Display admin credentials when password was generated or from a file
|
|
1464
|
+
// Do NOT display when from env (to avoid showing the env var value)
|
|
1465
|
+
if (passwordSource !== "env" && !alreadyRunning) {
|
|
1466
|
+
const passDisplay = passwordSource === "file"
|
|
1467
|
+
? opts.adminPassFile ?? "(file path)"
|
|
1468
|
+
: "~/.flair/admin-pass";
|
|
1469
|
+
console.log(`\n ┌─────────────────────────────────────────────────┐`);
|
|
1470
|
+
console.log(` │ Harper admin credentials (save these now): │`);
|
|
1471
|
+
console.log(` │ │`);
|
|
1472
|
+
console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
|
|
1473
|
+
console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
|
|
1474
|
+
console.log(` │ │`);
|
|
1475
|
+
console.log(` │ ⚠️ The password won't be shown again. │`);
|
|
1476
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
1477
|
+
}
|
|
1478
|
+
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
1479
|
+
// ── First-run soul setup ──────────────────────────────────────────────
|
|
1480
|
+
// Interactive wizard to set initial personality (see runSoulWizard).
|
|
1481
|
+
// Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
|
|
1482
|
+
//
|
|
1483
|
+
// Non-TTY / --skip-soul used to seed placeholder text like
|
|
1484
|
+
// "AI assistant [default]" — it leaked into bootstrap output and
|
|
1485
|
+
// confused users. Now those paths leave the soul empty and nudge the
|
|
1486
|
+
// user toward `flair soul set` / `flair doctor` instead.
|
|
1487
|
+
if (!opts.skipSoul && process.stdin.isTTY) {
|
|
1488
|
+
const soulEntries = await runSoulWizard(agentId);
|
|
1489
|
+
if (soulEntries.length > 0) {
|
|
1490
|
+
console.log("");
|
|
1491
|
+
for (const [key, value] of soulEntries) {
|
|
1492
|
+
try {
|
|
1493
|
+
await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
|
|
1494
|
+
console.log(` ✓ soul:${key} set`);
|
|
1495
|
+
}
|
|
1496
|
+
catch (err) {
|
|
1497
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1498
|
+
console.warn(` ⚠ soul:${key} failed: ${message}`);
|
|
1499
|
+
}
|
|
790
1500
|
}
|
|
1501
|
+
console.log(`\n ${soulEntries.length} soul entries saved.`);
|
|
1502
|
+
console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
|
|
1503
|
+
}
|
|
1504
|
+
else {
|
|
1505
|
+
console.log(`\n No soul entries saved. Add later with:`);
|
|
1506
|
+
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
1507
|
+
console.log(` Or run \`flair doctor\` anytime for a nudge.`);
|
|
791
1508
|
}
|
|
792
|
-
console.log(`\n ${soulEntries.length} soul entries saved.`);
|
|
793
|
-
console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
|
|
794
1509
|
}
|
|
795
1510
|
else {
|
|
796
|
-
|
|
1511
|
+
const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
|
|
1512
|
+
console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
|
|
797
1513
|
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
798
|
-
console.log(` Or run \`flair doctor\` anytime for a nudge.`);
|
|
799
1514
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1515
|
+
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
1516
|
+
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
1517
|
+
// Auto-wire MCP config into ~/.claude.json if Claude Code is installed
|
|
1518
|
+
const claudeJsonPath = join(homedir(), ".claude.json");
|
|
1519
|
+
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
1520
|
+
const flairMcpConfig = {
|
|
1521
|
+
type: "stdio",
|
|
1522
|
+
command: "flair-mcp",
|
|
1523
|
+
args: [],
|
|
1524
|
+
env: mcpEnv,
|
|
1525
|
+
};
|
|
1526
|
+
try {
|
|
1527
|
+
if (existsSync(claudeJsonPath)) {
|
|
1528
|
+
const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
|
|
1529
|
+
const existing = claudeJson.mcpServers?.flair;
|
|
1530
|
+
if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
|
|
1531
|
+
console.log(`\n MCP config already set in ~/.claude.json ✓`);
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
claudeJson.mcpServers = claudeJson.mcpServers || {};
|
|
1535
|
+
claudeJson.mcpServers.flair = flairMcpConfig;
|
|
1536
|
+
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
1537
|
+
console.log(`\n MCP config written to ~/.claude.json ✓`);
|
|
1538
|
+
console.log(` Restart Claude Code to pick up the new config.`);
|
|
1539
|
+
}
|
|
823
1540
|
}
|
|
824
1541
|
else {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
828
|
-
console.log(`\n MCP config written to ~/.claude.json ✓`);
|
|
829
|
-
console.log(` Restart Claude Code to pick up the new config.`);
|
|
1542
|
+
console.log(`\n MCP config (add to ~/.claude.json):`);
|
|
1543
|
+
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
830
1544
|
}
|
|
831
1545
|
}
|
|
832
|
-
|
|
833
|
-
console.log(`\n MCP config (add to ~/.claude.json):`);
|
|
1546
|
+
catch {
|
|
1547
|
+
console.log(`\n MCP config (add manually to ~/.claude.json):`);
|
|
834
1548
|
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
835
1549
|
}
|
|
836
1550
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
console.log(
|
|
1551
|
+
else {
|
|
1552
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1553
|
+
console.log("\n✅ Flair initialized (no agent registered)");
|
|
1554
|
+
console.log(` Flair URL: ${httpUrl}`);
|
|
1555
|
+
// Display admin credentials when password was generated or from a file
|
|
1556
|
+
// Do NOT display when from env (to avoid showing the env var value)
|
|
1557
|
+
if (passwordSource !== "env" && !alreadyRunning) {
|
|
1558
|
+
const passDisplay = passwordSource === "file"
|
|
1559
|
+
? opts.adminPassFile ?? "(file path)"
|
|
1560
|
+
: "~/.flair/admin-pass";
|
|
1561
|
+
console.log(`\n ┌─────────────────────────────────────────────────┐`);
|
|
1562
|
+
console.log(` │ Harper admin credentials (save these now): │`);
|
|
1563
|
+
console.log(` │ │`);
|
|
1564
|
+
console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
|
|
1565
|
+
console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
|
|
1566
|
+
console.log(` │ │`);
|
|
1567
|
+
console.log(` │ ⚠️ The password won't be shown again. │`);
|
|
1568
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
1569
|
+
}
|
|
1570
|
+
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
840
1571
|
}
|
|
841
1572
|
});
|
|
842
|
-
// ─── flair
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
.
|
|
846
|
-
.
|
|
847
|
-
.option("--
|
|
1573
|
+
// ─── flair install ───────────────────────────────────────────────────────────
|
|
1574
|
+
program
|
|
1575
|
+
.command("install")
|
|
1576
|
+
.description("One-command Flair setup — init, agent, and MCP client wiring")
|
|
1577
|
+
.option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
|
|
1578
|
+
.option("--agent <id>", "Agent ID (defaults to hostname short-form)")
|
|
1579
|
+
.option("--no-mcp", "Skip MCP wiring (init + agent only)")
|
|
848
1580
|
.option("--port <port>", "Harper HTTP port")
|
|
849
|
-
.option("--admin-pass <pass>", "Admin password for registration")
|
|
850
|
-
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
851
1581
|
.option("--ops-port <port>", "Harper operations API port")
|
|
852
|
-
.
|
|
1582
|
+
.option("--data-dir <dir>", "Harper data directory")
|
|
1583
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
1584
|
+
.option("--skip-smoke", "Skip MCP smoke test")
|
|
1585
|
+
.action(async (opts) => {
|
|
853
1586
|
const httpPort = resolveHttpPort(opts);
|
|
854
1587
|
const opsPort = resolveOpsPort(opts);
|
|
855
1588
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1589
|
+
const dataDir = opts.dataDir ?? defaultDataDir();
|
|
1590
|
+
// Resolve client selection
|
|
1591
|
+
const clientOpt = opts.client;
|
|
1592
|
+
const noMcp = opts.noMcp === true;
|
|
1593
|
+
const selectedClients = [];
|
|
1594
|
+
if (clientOpt === "none" || noMcp) {
|
|
1595
|
+
// Skip MCP entirely — just init + agent
|
|
1596
|
+
}
|
|
1597
|
+
else if (clientOpt === "all") {
|
|
1598
|
+
// Wire all detected clients
|
|
1599
|
+
}
|
|
1600
|
+
else if (clientOpt) {
|
|
1601
|
+
// Wire a specific client
|
|
1602
|
+
const valid = ["claude-code", "codex", "gemini", "cursor"];
|
|
1603
|
+
if (!valid.includes(clientOpt)) {
|
|
1604
|
+
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
|
|
1605
|
+
process.exit(1);
|
|
1606
|
+
}
|
|
1607
|
+
selectedClients.push(clientOpt);
|
|
862
1608
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1609
|
+
// If no --client flag: interactive detection later
|
|
1610
|
+
// ── Step 1: Ensure Flair is initialized ──
|
|
1611
|
+
let alreadyInitialized = false;
|
|
1612
|
+
let alreadyRunning = false;
|
|
1613
|
+
let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1614
|
+
const adminUser = DEFAULT_ADMIN_USER;
|
|
1615
|
+
// Check if Flair is already initialized (config with port exists)
|
|
1616
|
+
try {
|
|
1617
|
+
const cp = configPath();
|
|
1618
|
+
if (existsSync(cp)) {
|
|
1619
|
+
const yaml = readFileSync(cp, "utf-8");
|
|
1620
|
+
if (yaml.match(/port:\s*\d+/)) {
|
|
1621
|
+
alreadyInitialized = true;
|
|
1622
|
+
console.log("Flair already initialized — skipping init.");
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
catch { /* not initialized */ }
|
|
1627
|
+
if (!alreadyInitialized) {
|
|
1628
|
+
const major = parseInt(process.version.slice(1), 10);
|
|
1629
|
+
if (major < 18)
|
|
1630
|
+
throw new Error(`Node.js >= 18 required (found ${process.version})`);
|
|
1631
|
+
// Only generate a password if none provided via env (fresh install)
|
|
1632
|
+
// If we generate, write it atomically to ~/.flair/admin-pass
|
|
1633
|
+
let adminPassGenerated = false;
|
|
1634
|
+
if (!adminPass) {
|
|
1635
|
+
adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
|
|
1636
|
+
adminPassGenerated = true;
|
|
1637
|
+
// Atomic write: create temp file in same dir, then rename
|
|
1638
|
+
const flairDir = join(homedir(), ".flair");
|
|
1639
|
+
mkdirSync(flairDir, { recursive: true });
|
|
1640
|
+
const adminPassPath = join(flairDir, "admin-pass");
|
|
1641
|
+
const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
|
|
1642
|
+
const finalTempPath = join(tempPath, "admin-pass");
|
|
1643
|
+
try {
|
|
1644
|
+
writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
|
|
1645
|
+
renameSync(finalTempPath, adminPassPath);
|
|
1646
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
1647
|
+
}
|
|
1648
|
+
catch (err) {
|
|
1649
|
+
// Clean up temp dir on failure
|
|
1650
|
+
try {
|
|
1651
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
1652
|
+
}
|
|
1653
|
+
catch { }
|
|
1654
|
+
throw err;
|
|
1655
|
+
}
|
|
1656
|
+
console.log(`Admin password saved to: ${adminPassPath}`);
|
|
1657
|
+
}
|
|
1658
|
+
// Check if Harper is already running on this port
|
|
1659
|
+
try {
|
|
1660
|
+
const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
|
|
1661
|
+
if (res.status > 0) {
|
|
1662
|
+
alreadyRunning = true;
|
|
1663
|
+
console.log(`Harper already running on port ${httpPort} — skipping start`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
catch { /* not running */ }
|
|
1667
|
+
if (!alreadyRunning) {
|
|
1668
|
+
const bin = harperBin();
|
|
1669
|
+
if (!bin)
|
|
1670
|
+
throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
|
|
1671
|
+
mkdirSync(dataDir, { recursive: true });
|
|
1672
|
+
const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
|
|
1673
|
+
const harperSetConfig = JSON.stringify({
|
|
1674
|
+
rootPath: dataDir,
|
|
1675
|
+
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
1676
|
+
operationsApi: { network: { port: opsPort, cors: true }, domainSocket: join(dataDir, "operations-server") },
|
|
1677
|
+
mqtt: { network: { port: null }, webSocket: false },
|
|
1678
|
+
localStudio: { enabled: false },
|
|
1679
|
+
authentication: { authorizeLocal: true, enableSessions: true },
|
|
1680
|
+
});
|
|
1681
|
+
const env = {
|
|
1682
|
+
...process.env,
|
|
1683
|
+
ROOTPATH: dataDir,
|
|
1684
|
+
HARPER_SET_CONFIG: harperSetConfig,
|
|
1685
|
+
DEFAULTS_MODE: "dev",
|
|
1686
|
+
HDB_ADMIN_USERNAME: adminUser,
|
|
1687
|
+
HDB_ADMIN_PASSWORD: adminPass,
|
|
1688
|
+
THREADS_COUNT: "1",
|
|
1689
|
+
NODE_HOSTNAME: "localhost",
|
|
1690
|
+
HTTP_PORT: String(httpPort),
|
|
1691
|
+
OPERATIONSAPI_NETWORK_PORT: String(opsPort),
|
|
1692
|
+
LOCAL_STUDIO: "false",
|
|
1693
|
+
};
|
|
1694
|
+
if (alreadyInstalled) {
|
|
1695
|
+
console.log("Existing Harper installation found — skipping install.");
|
|
1696
|
+
}
|
|
1697
|
+
else {
|
|
1698
|
+
const installEnv = { ...env, HOME: join(dataDir, "..") };
|
|
1699
|
+
console.log("Installing Harper...");
|
|
1700
|
+
console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
|
|
1701
|
+
await new Promise((resolve, reject) => {
|
|
1702
|
+
let output = "";
|
|
1703
|
+
let dotTimer = null;
|
|
1704
|
+
const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
|
|
1705
|
+
dotTimer = setInterval(() => process.stdout.write("."), 3000);
|
|
1706
|
+
install.stdout?.on("data", (d) => { output += d.toString(); });
|
|
1707
|
+
install.stderr?.on("data", (d) => { output += d.toString(); });
|
|
1708
|
+
install.on("exit", (code) => {
|
|
1709
|
+
if (dotTimer) {
|
|
1710
|
+
clearInterval(dotTimer);
|
|
1711
|
+
process.stdout.write("\n");
|
|
1712
|
+
}
|
|
1713
|
+
code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
|
|
1714
|
+
});
|
|
1715
|
+
install.on("error", (err) => {
|
|
1716
|
+
if (dotTimer) {
|
|
1717
|
+
clearInterval(dotTimer);
|
|
1718
|
+
process.stdout.write("\n");
|
|
1719
|
+
}
|
|
1720
|
+
reject(err);
|
|
1721
|
+
});
|
|
1722
|
+
setTimeout(() => {
|
|
1723
|
+
install.kill();
|
|
1724
|
+
if (dotTimer) {
|
|
1725
|
+
clearInterval(dotTimer);
|
|
1726
|
+
process.stdout.write("\n");
|
|
1727
|
+
}
|
|
1728
|
+
reject(new Error(`Harper install timed out: ${output}`));
|
|
1729
|
+
}, 60_000);
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
// Start Harper
|
|
1733
|
+
console.log(`Starting Harper on port ${httpPort}...`);
|
|
1734
|
+
const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
|
|
1735
|
+
proc.unref();
|
|
1736
|
+
}
|
|
1737
|
+
// Wait for health
|
|
1738
|
+
console.log("Waiting for Harper health check...");
|
|
1739
|
+
await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
|
|
1740
|
+
console.log("Harper is healthy ✓");
|
|
1741
|
+
// Write config so other commands can find this instance
|
|
1742
|
+
writeConfig(httpPort);
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
// Flair already initialized — resolve admin pass from env or running instance
|
|
1746
|
+
adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1747
|
+
}
|
|
1748
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1749
|
+
// ── Step 2: Detect MCP clients ──
|
|
1750
|
+
let clients = detectClients();
|
|
1751
|
+
if (selectedClients.length > 0) {
|
|
1752
|
+
// Explicit --client flag — override detection
|
|
1753
|
+
if (clientOpt === "all" || clientOpt === "none" || noMcp) {
|
|
1754
|
+
// all/none handled below
|
|
1755
|
+
}
|
|
1756
|
+
else {
|
|
1757
|
+
// Filter to only the selected client (preserving wire function from detectClients)
|
|
1758
|
+
clients = clients.filter(c => selectedClients.includes(c.id));
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (!clientOpt && !noMcp) {
|
|
1762
|
+
// No --client flag — print detected clients
|
|
1763
|
+
const detected = clients.filter(c => c.detected);
|
|
1764
|
+
if (detected.length === 0) {
|
|
1765
|
+
console.log("No MCP clients detected. Run with --client <name> to wire a specific client.");
|
|
1766
|
+
}
|
|
1767
|
+
else {
|
|
1768
|
+
console.log(`Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
// ── Step 3: Resolve agent identity ──
|
|
1772
|
+
const agentId = opts.agent ?? (() => {
|
|
1773
|
+
try {
|
|
1774
|
+
const hn = hostname();
|
|
1775
|
+
return hn.split(".")[0];
|
|
1776
|
+
}
|
|
1777
|
+
catch {
|
|
1778
|
+
return "flair-agent";
|
|
1779
|
+
}
|
|
1780
|
+
})();
|
|
1781
|
+
// Validate agentId to prevent path traversal
|
|
1782
|
+
const VALID_AGENT_ID = /^[a-zA-Z0-9_-]+$/;
|
|
1783
|
+
if (!VALID_AGENT_ID.test(agentId)) {
|
|
1784
|
+
throw new Error(`Invalid agent ID: ${agentId}. Agent ID must contain only letters, numbers, underscores, and hyphens.`);
|
|
1785
|
+
}
|
|
1786
|
+
let agentExists = false;
|
|
1787
|
+
// Check if agent already exists locally
|
|
1788
|
+
const privPath = privKeyPath(agentId, keysDir);
|
|
1789
|
+
if (existsSync(privPath)) {
|
|
1790
|
+
agentExists = true;
|
|
1791
|
+
console.log(`Agent '${agentId}' already exists ✓`);
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
// Create agent
|
|
1795
|
+
mkdirSync(keysDir, { recursive: true });
|
|
1796
|
+
const pubPath = pubKeyPath(agentId, keysDir);
|
|
1797
|
+
console.log("Generating Ed25519 keypair...");
|
|
1798
|
+
const kp = nacl.sign.keyPair();
|
|
1799
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
1800
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
1801
|
+
chmodSync(privPath, 0o600);
|
|
1802
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
1803
|
+
const pubKeyB64url = b64url(kp.publicKey);
|
|
1804
|
+
console.log(`Keypair written: ${privPath} ✓`);
|
|
1805
|
+
// Seed agent - use REST API for local Harper, Ops API only when --ops-target is specified
|
|
1806
|
+
if (opts.opsTarget) {
|
|
1807
|
+
// Remote Harper via explicit ops target
|
|
1808
|
+
console.log(`Seeding agent '${agentId}' via operations API (--ops-target)...`);
|
|
1809
|
+
await seedAgentViaOpsApi(opts.opsTarget, agentId, pubKeyB64url, adminUser, adminPass);
|
|
1810
|
+
}
|
|
1811
|
+
else {
|
|
1812
|
+
// Local Harper - use REST API
|
|
1813
|
+
console.log(`Seeding agent '${agentId}' via REST API...`);
|
|
1814
|
+
await seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass);
|
|
1815
|
+
}
|
|
1816
|
+
console.log(`Agent '${agentId}' registered ✓`);
|
|
1817
|
+
}
|
|
1818
|
+
// ── Step 4: Wire MCP clients ──
|
|
1819
|
+
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
1820
|
+
const wiringResults = [];
|
|
1821
|
+
if (!noMcp && clientOpt !== "none") {
|
|
1822
|
+
const toWire = clientOpt === "all"
|
|
1823
|
+
? clients.filter(c => c.detected).map(c => c.id)
|
|
1824
|
+
: selectedClients.length > 0
|
|
1825
|
+
? selectedClients
|
|
1826
|
+
: clients.filter(c => c.detected).map(c => c.id);
|
|
1827
|
+
for (const clientId of toWire) {
|
|
1828
|
+
let result;
|
|
1829
|
+
switch (clientId) {
|
|
1830
|
+
case "claude-code":
|
|
1831
|
+
result = wireClaudeCode(mcpEnv);
|
|
1832
|
+
break;
|
|
1833
|
+
case "codex":
|
|
1834
|
+
result = wireCodex(mcpEnv);
|
|
1835
|
+
break;
|
|
1836
|
+
case "gemini":
|
|
1837
|
+
result = wireGemini(mcpEnv);
|
|
1838
|
+
break;
|
|
1839
|
+
case "cursor":
|
|
1840
|
+
result = wireCursor(mcpEnv);
|
|
1841
|
+
break;
|
|
1842
|
+
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
1843
|
+
}
|
|
1844
|
+
wiringResults.push({ client: clientId, message: result.message });
|
|
1845
|
+
console.log(` ${result.ok ? "✓" : "✗"} ${result.message}`);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
// ── Step 5: Smoke test the MCP server ──
|
|
1849
|
+
if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
|
|
1850
|
+
console.log("Smoke-testing MCP server...");
|
|
1851
|
+
try {
|
|
1852
|
+
// Launch flair-mcp and send initialize request over stdio
|
|
1853
|
+
const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
|
|
1854
|
+
env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
|
|
1855
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1856
|
+
timeout: 15_000,
|
|
1857
|
+
});
|
|
1858
|
+
// Send initialize request
|
|
1859
|
+
const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-install", version: "1.0.0" } } });
|
|
1860
|
+
mcpProc.stdin.write(initMsg + "\n");
|
|
1861
|
+
mcpProc.stdin.end();
|
|
1862
|
+
let stdout = "";
|
|
1863
|
+
mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
1864
|
+
await new Promise((resolve, reject) => {
|
|
1865
|
+
mcpProc.on("exit", (code) => {
|
|
1866
|
+
if (code === 0 && stdout.length > 0) {
|
|
1867
|
+
resolve();
|
|
1868
|
+
}
|
|
1869
|
+
else {
|
|
1870
|
+
reject(new Error(`MCP server exited with code ${code}`));
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
mcpProc.on("error", reject);
|
|
1874
|
+
setTimeout(() => { mcpProc.kill(); reject(new Error("MCP smoke test timed out")); }, 15_000);
|
|
1875
|
+
});
|
|
1876
|
+
// Check for a valid JSON-RPC response
|
|
1877
|
+
try {
|
|
1878
|
+
const lines = stdout.split("\n").filter(l => l.trim());
|
|
1879
|
+
for (const line of lines) {
|
|
1880
|
+
const parsed = JSON.parse(line);
|
|
1881
|
+
if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
|
|
1882
|
+
console.log(" ✓ MCP server responded");
|
|
1883
|
+
break;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
catch {
|
|
1888
|
+
console.log(" ⚠ MCP server responded but response could not be parsed");
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
catch (err) {
|
|
1892
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1893
|
+
console.log(` ⚠ MCP smoke test failed: ${message}`);
|
|
1894
|
+
console.log(" Use --skip-smoke to bypass.");
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
// ── Step 6: Summary ──
|
|
1898
|
+
console.log("");
|
|
1899
|
+
console.log("✓ Flair installed.");
|
|
1900
|
+
console.log(` Agent: ${agentId}`);
|
|
1901
|
+
if (existsSync(privKeyPath(agentId, keysDir))) {
|
|
1902
|
+
console.log(` Private key: ${privKeyPath(agentId, keysDir)}`);
|
|
1903
|
+
}
|
|
1904
|
+
console.log(` Local: ${httpUrl}`);
|
|
1905
|
+
console.log(` MCP: ${wiringResults.length > 0 ? wiringResults.map(r => r.client).join(", ") + " ✓ wired" : "none wired"}`);
|
|
1906
|
+
console.log("");
|
|
1907
|
+
console.log(` Try it: in Claude Code, ask the agent "what do you remember about me?"`);
|
|
1908
|
+
});
|
|
1909
|
+
// ─── flair agent ─────────────────────────────────────────────────────────────
|
|
1910
|
+
const agent = program.command("agent").description("Manage Flair agents");
|
|
1911
|
+
agent
|
|
1912
|
+
.command("add <id>")
|
|
1913
|
+
.description("Register a new agent in a running Flair instance")
|
|
1914
|
+
.option("--name <name>", "Display name (defaults to id)")
|
|
1915
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1916
|
+
.option("--admin-pass <pass>", "Admin password for registration")
|
|
1917
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
1918
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1919
|
+
.action(async (id, opts) => {
|
|
1920
|
+
const httpPort = resolveHttpPort(opts);
|
|
1921
|
+
const opsPort = resolveOpsPort(opts);
|
|
1922
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
1923
|
+
const adminPass = opts.adminPass;
|
|
1924
|
+
const adminUser = DEFAULT_ADMIN_USER;
|
|
1925
|
+
const name = opts.name ?? id;
|
|
1926
|
+
if (!adminPass) {
|
|
1927
|
+
console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table)");
|
|
1928
|
+
process.exit(1);
|
|
1929
|
+
}
|
|
1930
|
+
mkdirSync(keysDir, { recursive: true });
|
|
1931
|
+
const privPath = privKeyPath(id, keysDir);
|
|
1932
|
+
const pubPath = pubKeyPath(id, keysDir);
|
|
866
1933
|
let pubKeyB64url;
|
|
867
1934
|
if (existsSync(privPath)) {
|
|
868
1935
|
console.log(`Reusing existing key: ${privPath}`);
|
|
@@ -891,7 +1958,13 @@ agent
|
|
|
891
1958
|
.option("--port <port>", "Harper HTTP port")
|
|
892
1959
|
.action(async (opts) => {
|
|
893
1960
|
const port = resolveHttpPort(opts);
|
|
894
|
-
|
|
1961
|
+
// fromEnv is true ONLY when the resolved value came from env (no inline override).
|
|
1962
|
+
const adminPassFromEnv = !opts.adminPass && (!!process.env.FLAIR_ADMIN_PASS || !!process.env.HDB_ADMIN_PASSWORD);
|
|
1963
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
1964
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
|
|
1965
|
+
"to keep secrets out of shell history.");
|
|
1966
|
+
}
|
|
1967
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
895
1968
|
if (adminPass) {
|
|
896
1969
|
// Use admin basic auth against ops API to list agents directly
|
|
897
1970
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -899,18 +1972,37 @@ agent
|
|
|
899
1972
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
900
1973
|
method: "POST",
|
|
901
1974
|
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
|
902
|
-
body: JSON.stringify({ operation: "
|
|
1975
|
+
body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Agent", search_attribute: "id", search_type: "starts_with", search_value: "", get_attributes: ["id", "name", "createdAt"] }),
|
|
903
1976
|
});
|
|
904
1977
|
if (!res.ok) {
|
|
905
1978
|
const text = await res.text().catch(() => "");
|
|
906
1979
|
console.error(`Error: ${res.status} ${text}`);
|
|
907
1980
|
process.exit(1);
|
|
908
1981
|
}
|
|
909
|
-
|
|
1982
|
+
const agents = await res.json();
|
|
1983
|
+
agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
1984
|
+
console.log(JSON.stringify(agents, null, 2));
|
|
910
1985
|
}
|
|
911
1986
|
else {
|
|
912
|
-
//
|
|
913
|
-
|
|
1987
|
+
// Localhost operator path: allow IDs-only enumeration without per-agent auth
|
|
1988
|
+
// This treats localhost as a trusted boundary for read-only public metadata
|
|
1989
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
1990
|
+
const res = await fetch(`${baseUrl}/Agent`, {
|
|
1991
|
+
headers: { "Content-Type": "application/json" },
|
|
1992
|
+
});
|
|
1993
|
+
if (!res.ok) {
|
|
1994
|
+
const text = await res.text().catch(() => "");
|
|
1995
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1996
|
+
process.exit(1);
|
|
1997
|
+
}
|
|
1998
|
+
const data = await res.json();
|
|
1999
|
+
// Filter to IDs-only to respect the localhost trust boundary
|
|
2000
|
+
if (Array.isArray(data)) {
|
|
2001
|
+
console.log(JSON.stringify(data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })), null, 2));
|
|
2002
|
+
}
|
|
2003
|
+
else {
|
|
2004
|
+
console.log(JSON.stringify(data, null, 2));
|
|
2005
|
+
}
|
|
914
2006
|
}
|
|
915
2007
|
});
|
|
916
2008
|
agent
|
|
@@ -927,6 +2019,12 @@ agent
|
|
|
927
2019
|
.action(async (id, opts) => {
|
|
928
2020
|
const httpPort = resolveHttpPort(opts);
|
|
929
2021
|
const opsPort = resolveOpsPort(opts);
|
|
2022
|
+
// fromEnv is true ONLY when the resolved value came from env (no inline override).
|
|
2023
|
+
const adminPassFromEnv = !opts.adminPass && !!process.env.FLAIR_ADMIN_PASS;
|
|
2024
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
2025
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
|
|
2026
|
+
"to keep secrets out of shell history.");
|
|
2027
|
+
}
|
|
930
2028
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
931
2029
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
932
2030
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
@@ -1204,14 +2302,20 @@ principal
|
|
|
1204
2302
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1205
2303
|
process.exit(1);
|
|
1206
2304
|
}
|
|
1207
|
-
const kindFilter = opts.kind ? ` WHERE kind = '${opts.kind}'` : "";
|
|
1208
2305
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
2306
|
+
const conditions = opts.kind
|
|
2307
|
+
? [{ search_attribute: "kind", search_type: "equals", search_value: opts.kind }]
|
|
2308
|
+
: [{ search_attribute: "id", search_type: "starts_with", search_value: "" }];
|
|
1209
2309
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1210
2310
|
method: "POST",
|
|
1211
2311
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1212
2312
|
body: JSON.stringify({
|
|
1213
|
-
operation: "
|
|
1214
|
-
|
|
2313
|
+
operation: "search_by_conditions",
|
|
2314
|
+
schema: "flair",
|
|
2315
|
+
table: "Agent",
|
|
2316
|
+
operator: "and",
|
|
2317
|
+
conditions,
|
|
2318
|
+
get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "admin", "runtime", "createdAt"],
|
|
1215
2319
|
}),
|
|
1216
2320
|
});
|
|
1217
2321
|
if (!res.ok) {
|
|
@@ -1220,6 +2324,7 @@ principal
|
|
|
1220
2324
|
process.exit(1);
|
|
1221
2325
|
}
|
|
1222
2326
|
const records = await res.json();
|
|
2327
|
+
records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
1223
2328
|
if (records.length === 0) {
|
|
1224
2329
|
console.log("No principals found.");
|
|
1225
2330
|
return;
|
|
@@ -1382,8 +2487,13 @@ idp
|
|
|
1382
2487
|
method: "POST",
|
|
1383
2488
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1384
2489
|
body: JSON.stringify({
|
|
1385
|
-
operation: "
|
|
1386
|
-
|
|
2490
|
+
operation: "search_by_value",
|
|
2491
|
+
schema: "flair",
|
|
2492
|
+
table: "IdpConfig",
|
|
2493
|
+
search_attribute: "id",
|
|
2494
|
+
search_type: "starts_with",
|
|
2495
|
+
search_value: "",
|
|
2496
|
+
get_attributes: ["id", "name", "issuer", "requiredDomain", "jitProvision", "enabled", "createdAt"],
|
|
1387
2497
|
}),
|
|
1388
2498
|
});
|
|
1389
2499
|
if (!res.ok) {
|
|
@@ -1392,6 +2502,7 @@ idp
|
|
|
1392
2502
|
process.exit(1);
|
|
1393
2503
|
}
|
|
1394
2504
|
const records = await res.json();
|
|
2505
|
+
records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
1395
2506
|
if (records.length === 0) {
|
|
1396
2507
|
console.log("No IdPs configured.");
|
|
1397
2508
|
return;
|
|
@@ -1443,18 +2554,14 @@ idp
|
|
|
1443
2554
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1444
2555
|
process.exit(1);
|
|
1445
2556
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
});
|
|
1452
|
-
const records = await res.json();
|
|
1453
|
-
if (records.length === 0) {
|
|
2557
|
+
let cfg;
|
|
2558
|
+
try {
|
|
2559
|
+
cfg = await api("GET", `/IdpConfig/${id}`);
|
|
2560
|
+
}
|
|
2561
|
+
catch {
|
|
1454
2562
|
console.error(`IdP '${id}' not found`);
|
|
1455
2563
|
process.exit(1);
|
|
1456
2564
|
}
|
|
1457
|
-
const cfg = records[0];
|
|
1458
2565
|
console.log(`Testing IdP: ${cfg.name} (${cfg.issuer})`);
|
|
1459
2566
|
console.log(` JWKS endpoint: ${cfg.jwksUri}`);
|
|
1460
2567
|
try {
|
|
@@ -1468,7 +2575,8 @@ idp
|
|
|
1468
2575
|
console.log(` ✅ JWKS reachable — ${keyCount} key(s) found`);
|
|
1469
2576
|
}
|
|
1470
2577
|
catch (err) {
|
|
1471
|
-
|
|
2578
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2579
|
+
console.error(` ❌ JWKS fetch error: ${message}`);
|
|
1472
2580
|
process.exit(1);
|
|
1473
2581
|
}
|
|
1474
2582
|
});
|
|
@@ -1581,7 +2689,7 @@ async function loadInstanceSecretKey(instanceId, opts) {
|
|
|
1581
2689
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1582
2690
|
method: "POST",
|
|
1583
2691
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1584
|
-
body: JSON.stringify({ operation: "
|
|
2692
|
+
body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Instance", search_attribute: "id", search_type: "equals", search_value: instanceId, get_attributes: ["*"] }),
|
|
1585
2693
|
});
|
|
1586
2694
|
if (res.ok) {
|
|
1587
2695
|
const rows = await res.json();
|
|
@@ -1598,23 +2706,37 @@ async function loadInstanceSecretKey(instanceId, opts) {
|
|
|
1598
2706
|
* Sign a request body and return a new body with the signature field added.
|
|
1599
2707
|
*/
|
|
1600
2708
|
function signRequestBody(body, secretKey) {
|
|
1601
|
-
|
|
1602
|
-
|
|
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 };
|
|
1603
2719
|
}
|
|
2720
|
+
// Alias: signBodyFresh for clarity at call sites
|
|
2721
|
+
const signBodyFresh = signRequestBody;
|
|
1604
2722
|
// ─── flair federation ────────────────────────────────────────────────────────
|
|
1605
2723
|
const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
|
|
1606
2724
|
federation
|
|
1607
2725
|
.command("status")
|
|
1608
2726
|
.description("Show federation status and peer connections")
|
|
1609
2727
|
.option("--port <port>", "Harper HTTP port")
|
|
2728
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
2729
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
1610
2730
|
.action(async (opts) => {
|
|
2731
|
+
const target = resolveTarget(opts);
|
|
2732
|
+
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
1611
2733
|
try {
|
|
1612
|
-
const instance = await api("GET", "/FederationInstance");
|
|
2734
|
+
const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
|
|
1613
2735
|
console.log(`Instance: ${instance.id} (${instance.role})`);
|
|
1614
2736
|
console.log(`Public key: ${instance.publicKey}`);
|
|
1615
2737
|
console.log(`Status: ${instance.status}`);
|
|
1616
2738
|
console.log();
|
|
1617
|
-
const { peers } = await api("GET", "/FederationPeers");
|
|
2739
|
+
const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
|
|
1618
2740
|
if (peers.length === 0) {
|
|
1619
2741
|
console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
|
|
1620
2742
|
}
|
|
@@ -1632,33 +2754,210 @@ federation
|
|
|
1632
2754
|
process.exit(1);
|
|
1633
2755
|
}
|
|
1634
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
|
+
}
|
|
1635
2901
|
federation
|
|
1636
2902
|
.command("pair <hub-url>")
|
|
1637
2903
|
.description("Pair this spoke with a hub instance")
|
|
1638
2904
|
.option("--port <port>", "Harper HTTP port")
|
|
1639
2905
|
.option("--admin-pass <pass>", "Admin password")
|
|
1640
2906
|
.option("--ops-port <port>", "Harper operations API port")
|
|
1641
|
-
.option("--token <token>", "One-time pairing token from hub admin")
|
|
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)")
|
|
2909
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
2910
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
1642
2911
|
.action(async (hubUrl, opts) => {
|
|
2912
|
+
const target = resolveTarget(opts);
|
|
2913
|
+
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
1643
2914
|
try {
|
|
1644
|
-
const instance = await api("GET", "/FederationInstance");
|
|
1645
|
-
console.log(
|
|
1646
|
-
|
|
1647
|
-
|
|
2915
|
+
const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
|
|
2916
|
+
console.log(`${target ? "Remote" : "Local"} instance: ${instance.id} (${instance.role})`);
|
|
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.");
|
|
1648
2943
|
process.exit(1);
|
|
1649
2944
|
}
|
|
1650
|
-
// Load secret key and sign the pairing request
|
|
2945
|
+
// Load secret key and sign the pairing request.
|
|
1651
2946
|
const secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
1652
2947
|
const pairBody = {
|
|
1653
2948
|
instanceId: instance.id,
|
|
1654
2949
|
publicKey: instance.publicKey,
|
|
1655
2950
|
role: "spoke",
|
|
1656
|
-
pairingToken
|
|
2951
|
+
pairingToken,
|
|
1657
2952
|
};
|
|
1658
|
-
const signedBody =
|
|
2953
|
+
const signedBody = signBodyFresh(pairBody, secretKey);
|
|
2954
|
+
const fetchHeaders = { "Content-Type": "application/json" };
|
|
2955
|
+
if (authHeader) {
|
|
2956
|
+
fetchHeaders.Authorization = authHeader;
|
|
2957
|
+
}
|
|
1659
2958
|
const res = await fetch(`${hubUrl}/FederationPair`, {
|
|
1660
2959
|
method: "POST",
|
|
1661
|
-
headers:
|
|
2960
|
+
headers: fetchHeaders,
|
|
1662
2961
|
body: JSON.stringify(signedBody),
|
|
1663
2962
|
});
|
|
1664
2963
|
if (!res.ok) {
|
|
@@ -1668,12 +2967,12 @@ federation
|
|
|
1668
2967
|
}
|
|
1669
2968
|
const result = await res.json();
|
|
1670
2969
|
console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
|
|
1671
|
-
// Record the hub as our peer locally
|
|
1672
|
-
const opsPort = resolveOpsPort(opts);
|
|
2970
|
+
// Record the hub as our peer — locally or remotely depending on --target
|
|
1673
2971
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1674
2972
|
if (adminPass) {
|
|
1675
2973
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1676
|
-
|
|
2974
|
+
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
2975
|
+
await fetch(`${opsEndpoint}/`, {
|
|
1677
2976
|
method: "POST",
|
|
1678
2977
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1679
2978
|
body: JSON.stringify({
|
|
@@ -1687,6 +2986,7 @@ federation
|
|
|
1687
2986
|
updatedAt: new Date().toISOString(),
|
|
1688
2987
|
}],
|
|
1689
2988
|
}),
|
|
2989
|
+
signal: AbortSignal.timeout(10_000),
|
|
1690
2990
|
});
|
|
1691
2991
|
}
|
|
1692
2992
|
}
|
|
@@ -1702,16 +3002,21 @@ federation
|
|
|
1702
3002
|
.option("--admin-pass <pass>", "Admin password")
|
|
1703
3003
|
.option("--ops-port <port>", "Harper operations API port")
|
|
1704
3004
|
.option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
|
|
3005
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
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")
|
|
1705
3008
|
.action(async (opts) => {
|
|
3009
|
+
const target = resolveTarget(opts);
|
|
3010
|
+
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
1706
3011
|
try {
|
|
1707
|
-
const { randomBytes } = await import("node:crypto");
|
|
1708
3012
|
const token = randomBytes(24).toString("base64url");
|
|
1709
3013
|
const ttlMinutes = parseInt(opts.ttl, 10) || 60;
|
|
1710
3014
|
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
1711
|
-
const
|
|
3015
|
+
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
1712
3016
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1713
3017
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1714
|
-
|
|
3018
|
+
// 1. Persist the PairingToken record
|
|
3019
|
+
const opsRes = await fetch(`${opsEndpoint}/`, {
|
|
1715
3020
|
method: "POST",
|
|
1716
3021
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1717
3022
|
body: JSON.stringify({
|
|
@@ -1722,78 +3027,514 @@ federation
|
|
|
1722
3027
|
expiresAt,
|
|
1723
3028
|
}],
|
|
1724
3029
|
}),
|
|
3030
|
+
signal: AbortSignal.timeout(10_000),
|
|
1725
3031
|
});
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
3032
|
+
if (!opsRes.ok) {
|
|
3033
|
+
const detail = await opsRes.text().catch(() => "");
|
|
3034
|
+
throw new Error(`Failed to persist pairing token (${opsRes.status}): ${detail || "no body"}`);
|
|
3035
|
+
}
|
|
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
|
+
}
|
|
1730
3094
|
}
|
|
1731
3095
|
catch (err) {
|
|
1732
3096
|
console.error(`Error: ${err.message}`);
|
|
1733
3097
|
process.exit(1);
|
|
1734
3098
|
}
|
|
1735
3099
|
});
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
.action(async (opts) => {
|
|
3100
|
+
export async function runFederationSyncOnce(opts) {
|
|
3101
|
+
const target = resolveTarget(opts);
|
|
3102
|
+
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
3103
|
+
const apiOpts = baseUrl ? { baseUrl } : undefined;
|
|
3104
|
+
let totalMerged = 0;
|
|
3105
|
+
let totalSkipped = 0;
|
|
1743
3106
|
try {
|
|
1744
|
-
const { peers } = await api("GET", "/FederationPeers");
|
|
3107
|
+
const { peers } = await api("GET", "/FederationPeers", undefined, apiOpts);
|
|
1745
3108
|
const hub = peers.find((p) => p.role === "hub" && p.status !== "revoked");
|
|
1746
3109
|
if (!hub) {
|
|
1747
|
-
|
|
1748
|
-
process.exit(1);
|
|
3110
|
+
return { pushed: 0, skipped: 0, error: new Error("No hub peer configured. Use 'flair federation pair' first.") };
|
|
1749
3111
|
}
|
|
1750
3112
|
console.log(`Syncing to hub: ${hub.id}...`);
|
|
1751
3113
|
const since = hub.lastSyncAt ?? new Date(0).toISOString();
|
|
1752
|
-
const
|
|
3114
|
+
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
1753
3115
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1754
3116
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1755
3117
|
const tables = ["Memory", "Soul", "Agent", "Relationship"];
|
|
1756
|
-
const
|
|
1757
|
-
const
|
|
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;
|
|
1758
3147
|
for (const table of tables) {
|
|
3148
|
+
let res;
|
|
1759
3149
|
try {
|
|
1760
|
-
|
|
3150
|
+
res = await fetch(`${opsEndpoint}/`, {
|
|
1761
3151
|
method: "POST",
|
|
1762
3152
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1763
|
-
body: JSON.stringify({ operation: "
|
|
3153
|
+
body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [{ search_attribute: "updatedAt", search_type: "greater_than", search_value: since }], get_attributes: ["*"] }),
|
|
3154
|
+
signal: AbortSignal.timeout(15_000),
|
|
1764
3155
|
});
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
3156
|
+
}
|
|
3157
|
+
catch (err) {
|
|
3158
|
+
return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
|
|
3159
|
+
}
|
|
3160
|
+
if (!res.ok) {
|
|
3161
|
+
const text = await res.text().catch(() => "");
|
|
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;
|
|
1769
3180
|
}
|
|
3181
|
+
batch.push(sr);
|
|
3182
|
+
batchBytes += srBytes;
|
|
3183
|
+
}
|
|
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++;
|
|
1770
3190
|
}
|
|
1771
|
-
catch { }
|
|
1772
3191
|
}
|
|
1773
|
-
if (
|
|
3192
|
+
if (totalBatches === 0) {
|
|
1774
3193
|
console.log("No changes since last sync.");
|
|
1775
|
-
return;
|
|
1776
|
-
}
|
|
1777
|
-
// Sign the sync request with our instance key
|
|
1778
|
-
const secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
1779
|
-
const syncBody = { instanceId: instance.id, records, lamportClock: Date.now() };
|
|
1780
|
-
const signedSyncBody = signRequestBody(syncBody, secretKey);
|
|
1781
|
-
const syncRes = await fetch(`${hub.endpoint ?? hub.id}/FederationSync`, {
|
|
1782
|
-
method: "POST",
|
|
1783
|
-
headers: { "Content-Type": "application/json" },
|
|
1784
|
-
body: JSON.stringify(signedSyncBody),
|
|
1785
|
-
});
|
|
1786
|
-
if (!syncRes.ok) {
|
|
1787
|
-
console.error(`Sync failed: ${syncRes.status} ${await syncRes.text().catch(() => "")}`);
|
|
1788
|
-
process.exit(1);
|
|
3194
|
+
return { pushed: 0, skipped: 0 };
|
|
1789
3195
|
}
|
|
1790
|
-
|
|
1791
|
-
|
|
3196
|
+
console.log(`✅ Synced ${totalMerged} records (${totalSkipped} skipped) across ${totalBatches} batches`);
|
|
3197
|
+
return { pushed: totalMerged, skipped: totalSkipped };
|
|
1792
3198
|
}
|
|
1793
3199
|
catch (err) {
|
|
1794
|
-
|
|
3200
|
+
return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
federation
|
|
3204
|
+
.command("sync")
|
|
3205
|
+
.description("Push local changes to the hub")
|
|
3206
|
+
.option("--port <port>", "Harper HTTP port")
|
|
3207
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
3208
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
3209
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
3210
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
3211
|
+
.action(async (opts) => {
|
|
3212
|
+
const r = await runFederationSyncOnce(opts);
|
|
3213
|
+
if (r.error) {
|
|
3214
|
+
console.error(`Error: ${r.error.message}`);
|
|
3215
|
+
process.exit(1);
|
|
3216
|
+
}
|
|
3217
|
+
});
|
|
3218
|
+
export async function runFederationWatch(opts) {
|
|
3219
|
+
const intervalMs = Math.max(5, parseFloat(opts.interval) || 30) * 1000;
|
|
3220
|
+
let stopped = false;
|
|
3221
|
+
const stop = () => { stopped = true; };
|
|
3222
|
+
process.on("SIGINT", stop);
|
|
3223
|
+
process.on("SIGTERM", stop);
|
|
3224
|
+
console.log(`flair federation watch — interval ${intervalMs / 1000}s. Ctrl-C to stop.`);
|
|
3225
|
+
try {
|
|
3226
|
+
while (!stopped) {
|
|
3227
|
+
try {
|
|
3228
|
+
const r = await runFederationSyncOnce(opts);
|
|
3229
|
+
const ts = new Date().toISOString();
|
|
3230
|
+
if (r.error)
|
|
3231
|
+
console.error(`[${ts}] sync error: ${r.error.message}`);
|
|
3232
|
+
else
|
|
3233
|
+
console.log(`[${ts}] sync ok — pushed ${r.pushed}, skipped ${r.skipped}`);
|
|
3234
|
+
}
|
|
3235
|
+
catch (err) {
|
|
3236
|
+
console.error(`[${new Date().toISOString()}] watch loop error: ${err.message}`);
|
|
3237
|
+
}
|
|
3238
|
+
// Sleep but exit early on signal
|
|
3239
|
+
const t = Date.now();
|
|
3240
|
+
while (!stopped && Date.now() - t < intervalMs) {
|
|
3241
|
+
const remaining = intervalMs - (Date.now() - t);
|
|
3242
|
+
await new Promise((r) => setTimeout(r, Math.min(250, remaining)));
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
finally {
|
|
3247
|
+
process.removeListener("SIGINT", stop);
|
|
3248
|
+
process.removeListener("SIGTERM", stop);
|
|
3249
|
+
}
|
|
3250
|
+
console.log("flair federation watch — stopped.");
|
|
3251
|
+
}
|
|
3252
|
+
federation
|
|
3253
|
+
.command("watch")
|
|
3254
|
+
.description("Run federation sync in a loop (foreground daemon)")
|
|
3255
|
+
.option("--interval <seconds>", "Seconds between syncs", "30")
|
|
3256
|
+
.option("--port <port>", "Harper HTTP port")
|
|
3257
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
3258
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
3259
|
+
.option("--target <url>", "Remote Flair URL")
|
|
3260
|
+
.option("--ops-target <url>", "Explicit ops API URL")
|
|
3261
|
+
.action(async (opts) => {
|
|
3262
|
+
await runFederationWatch(opts);
|
|
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>'.");
|
|
1795
3395
|
process.exit(1);
|
|
1796
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);
|
|
1797
3538
|
});
|
|
1798
3539
|
// ─── flair rem ───────────────────────────────────────────────────────────────
|
|
1799
3540
|
// Memory hygiene and reflection: light (NREM), rapid (REM), restorative (deep).
|
|
@@ -1956,31 +3697,294 @@ rem
|
|
|
1956
3697
|
}
|
|
1957
3698
|
}
|
|
1958
3699
|
else {
|
|
1959
|
-
console.log("\n[2/3] Consolidation skipped — no agent ID");
|
|
1960
|
-
}
|
|
1961
|
-
// Step 3: Reflection
|
|
1962
|
-
if (agentId) {
|
|
1963
|
-
console.log("\n[3/3] Reflection (scope=all)...");
|
|
1964
|
-
const reflect = await api("POST", "/ReflectMemories", {
|
|
1965
|
-
agentId,
|
|
1966
|
-
scope: "all",
|
|
3700
|
+
console.log("\n[2/3] Consolidation skipped — no agent ID");
|
|
3701
|
+
}
|
|
3702
|
+
// Step 3: Reflection
|
|
3703
|
+
if (agentId) {
|
|
3704
|
+
console.log("\n[3/3] Reflection (scope=all)...");
|
|
3705
|
+
const reflect = await api("POST", "/ReflectMemories", {
|
|
3706
|
+
agentId,
|
|
3707
|
+
scope: "all",
|
|
3708
|
+
});
|
|
3709
|
+
if (reflect.error) {
|
|
3710
|
+
console.error(`Reflection error: ${reflect.error}`);
|
|
3711
|
+
process.exit(1);
|
|
3712
|
+
}
|
|
3713
|
+
console.log(` Source memories: ${reflect.count ?? 0}`);
|
|
3714
|
+
if (reflect.suggestedTags?.length) {
|
|
3715
|
+
console.log(` Tags: ${reflect.suggestedTags.join(", ")}`);
|
|
3716
|
+
}
|
|
3717
|
+
console.log("\n--- Reflection Prompt ---");
|
|
3718
|
+
console.log(reflect.prompt ?? "(no prompt returned)");
|
|
3719
|
+
console.log("--- End Prompt ---");
|
|
3720
|
+
}
|
|
3721
|
+
else {
|
|
3722
|
+
console.log("\n[3/3] Reflection skipped — no agent ID");
|
|
3723
|
+
}
|
|
3724
|
+
console.log(`\nRestorative cycle complete.${dryRun ? " No changes applied (dry run)." : ""}`);
|
|
3725
|
+
}
|
|
3726
|
+
catch (err) {
|
|
3727
|
+
console.error(`Error: ${err.message}`);
|
|
3728
|
+
process.exit(1);
|
|
3729
|
+
}
|
|
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,
|
|
1967
3917
|
});
|
|
1968
|
-
if (
|
|
1969
|
-
console.error(`
|
|
3918
|
+
if (soulWrite?.error) {
|
|
3919
|
+
console.error(`Error writing Soul: ${soulWrite.error}`);
|
|
1970
3920
|
process.exit(1);
|
|
1971
3921
|
}
|
|
1972
|
-
console.log(
|
|
1973
|
-
|
|
1974
|
-
|
|
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;
|
|
1975
3970
|
}
|
|
1976
|
-
console.
|
|
1977
|
-
|
|
1978
|
-
console.log("--- End Prompt ---");
|
|
3971
|
+
console.error(`Error: candidate ${candidateId} ${_d.message}`);
|
|
3972
|
+
process.exit(1);
|
|
1979
3973
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
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);
|
|
1982
3985
|
}
|
|
1983
|
-
console.log(
|
|
3986
|
+
console.log(`✅ Candidate ${candidateId} rejected by ${reviewerId}`);
|
|
3987
|
+
console.log(` Reason: ${opts.reason}`);
|
|
1984
3988
|
}
|
|
1985
3989
|
catch (err) {
|
|
1986
3990
|
console.error(`Error: ${err.message}`);
|
|
@@ -2055,9 +4059,63 @@ function oauthDetailLines(o) {
|
|
|
2055
4059
|
}
|
|
2056
4060
|
return out;
|
|
2057
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
|
+
}
|
|
2058
4115
|
async function fetchHealthDetail(opts) {
|
|
2059
4116
|
const port = resolveHttpPort(opts);
|
|
2060
|
-
|
|
4117
|
+
// --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
|
|
4118
|
+
const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
|
|
2061
4119
|
let healthy = false;
|
|
2062
4120
|
let healthData = null;
|
|
2063
4121
|
try {
|
|
@@ -2114,12 +4172,23 @@ const statusCmd = program
|
|
|
2114
4172
|
.description("Show Flair instance status, memory stats, and agent info")
|
|
2115
4173
|
.option("--port <port>", "Harper HTTP port")
|
|
2116
4174
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
4175
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
2117
4176
|
.option("--json", "Output as JSON")
|
|
2118
4177
|
.option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
|
|
2119
4178
|
.action(async (opts) => {
|
|
2120
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
|
+
}
|
|
2121
4187
|
if (opts.json) {
|
|
2122
|
-
|
|
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));
|
|
2123
4192
|
if (!healthy)
|
|
2124
4193
|
process.exit(1);
|
|
2125
4194
|
return;
|
|
@@ -2127,7 +4196,17 @@ const statusCmd = program
|
|
|
2127
4196
|
if (!healthy) {
|
|
2128
4197
|
console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
|
|
2129
4198
|
console.log(` URL: ${baseUrl}`);
|
|
2130
|
-
|
|
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
|
+
}
|
|
2131
4210
|
process.exit(1);
|
|
2132
4211
|
}
|
|
2133
4212
|
const uptimeSec = healthData?.uptimeSeconds;
|
|
@@ -2142,17 +4221,42 @@ const statusCmd = program
|
|
|
2142
4221
|
const agents = healthData?.agents;
|
|
2143
4222
|
const memories = healthData?.memories;
|
|
2144
4223
|
const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
4224
|
+
// Scope warnings to the filtered agent if --agent is set
|
|
4225
|
+
const scopedWarnings = opts.agent && healthData?.agents?.perAgent
|
|
4226
|
+
? warnings.filter((w) => {
|
|
4227
|
+
// Hash-fallback warnings contain agent-specific counts
|
|
4228
|
+
if (w.message.includes("hash-fallback")) {
|
|
4229
|
+
const match = w.message.match(/\b(\d+)\/(\d+) \((\d+)%\)/);
|
|
4230
|
+
if (match) {
|
|
4231
|
+
const hashCount = parseInt(match[1]);
|
|
4232
|
+
const totalCount = parseInt(match[2]);
|
|
4233
|
+
const agentRow = healthData.agents.perAgent.find((r) => r.id === opts.agent);
|
|
4234
|
+
if (agentRow && agentRow.hashFallback === hashCount && agentRow.memoryCount === totalCount) {
|
|
4235
|
+
return true;
|
|
4236
|
+
}
|
|
4237
|
+
return false;
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
// Mixed-model warnings are fleet-wide; keep them
|
|
4241
|
+
if (w.message.includes("multiple embedding models"))
|
|
4242
|
+
return true;
|
|
4243
|
+
// Federation warnings are fleet-wide; keep them
|
|
4244
|
+
if (w.message.includes("federation"))
|
|
4245
|
+
return true;
|
|
4246
|
+
// REM warnings are fleet-wide; keep them
|
|
4247
|
+
if (w.message.includes("REM") || w.message.includes("nightly"))
|
|
4248
|
+
return true;
|
|
4249
|
+
// Default: keep fleet-wide warnings
|
|
4250
|
+
return !w.message.includes(opts.agent);
|
|
4251
|
+
})
|
|
4252
|
+
: warnings;
|
|
4253
|
+
const hasWarn = scopedWarnings.some((w) => w.level === "warn");
|
|
2150
4254
|
const headerIcon = hasWarn ? "🟡" : "🟢";
|
|
2151
4255
|
console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
|
|
2152
4256
|
console.log(` URL: ${baseUrl}`);
|
|
2153
|
-
if (
|
|
2154
|
-
console.log(`\n⚠ Warnings: ${
|
|
2155
|
-
for (const w of
|
|
4257
|
+
if (scopedWarnings.length > 0) {
|
|
4258
|
+
console.log(`\n⚠ Warnings: ${scopedWarnings.length}`);
|
|
4259
|
+
for (const w of scopedWarnings)
|
|
2156
4260
|
console.log(` • ${w.level} ${w.message}`);
|
|
2157
4261
|
}
|
|
2158
4262
|
if (memories) {
|
|
@@ -2248,6 +4352,9 @@ const statusCmd = program
|
|
|
2248
4352
|
if (f.pendingTokens > 0)
|
|
2249
4353
|
console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
|
|
2250
4354
|
}
|
|
4355
|
+
else {
|
|
4356
|
+
console.log("\nFederation: not configured");
|
|
4357
|
+
}
|
|
2251
4358
|
if (healthData?.oauth) {
|
|
2252
4359
|
const lines = oauthSummaryLines(healthData.oauth);
|
|
2253
4360
|
for (const line of lines)
|
|
@@ -2263,6 +4370,9 @@ const statusCmd = program
|
|
|
2263
4370
|
if (b.lastExport)
|
|
2264
4371
|
console.log(` Last export: ${relativeTime(b.lastExport)}`);
|
|
2265
4372
|
}
|
|
4373
|
+
else {
|
|
4374
|
+
console.log("\nBridges: none installed");
|
|
4375
|
+
}
|
|
2266
4376
|
if (healthData?.disk) {
|
|
2267
4377
|
const d = healthData.disk;
|
|
2268
4378
|
console.log("\nDisk:");
|
|
@@ -2270,8 +4380,8 @@ const statusCmd = program
|
|
|
2270
4380
|
console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
|
|
2271
4381
|
}
|
|
2272
4382
|
console.log("");
|
|
2273
|
-
if (
|
|
2274
|
-
console.log(` Health: ⚠ ${
|
|
4383
|
+
if (scopedWarnings.length > 0)
|
|
4384
|
+
console.log(` Health: ⚠ ${scopedWarnings.length} warning(s)`);
|
|
2275
4385
|
else
|
|
2276
4386
|
console.log(` Health: ✅ all checks passing`);
|
|
2277
4387
|
});
|
|
@@ -2407,37 +4517,39 @@ program
|
|
|
2407
4517
|
.description("Upgrade Flair and related packages to latest versions")
|
|
2408
4518
|
.option("--check", "Only check for updates, don't install")
|
|
2409
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")
|
|
2410
4521
|
.action(async (opts) => {
|
|
2411
4522
|
const { execSync, execFileSync } = await import("node:child_process");
|
|
2412
4523
|
const checkOnly = opts.check ?? false;
|
|
4524
|
+
const showAll = opts.all ?? false;
|
|
2413
4525
|
console.log("Checking for updates...\n");
|
|
2414
|
-
// Per-package install probes. `npm list -g` assumed the default global
|
|
2415
|
-
// prefix and silently mis-reported "not installed" for anyone using
|
|
2416
|
-
// mise / fnm / nvm / volta / non-default-prefix npm — including the
|
|
2417
|
-
// running flair binary itself, which was obviously installed. Each
|
|
2418
|
-
// entry now has a locator that works regardless of install path:
|
|
2419
|
-
//
|
|
2420
|
-
// - For packages with a bin: shell out to the bin with --version
|
|
2421
|
-
// (same PATH lookup that got them invokable in the first place).
|
|
2422
|
-
// - For library packages: require.resolve the package.json from the
|
|
2423
|
-
// running flair's module graph (works whether it's a sibling
|
|
2424
|
-
// global install or a bundled dep).
|
|
2425
4526
|
const packages = [
|
|
2426
4527
|
{
|
|
2427
4528
|
name: "@tpsdev-ai/flair",
|
|
4529
|
+
kind: "bin",
|
|
2428
4530
|
probe: () => probeBinVersion(execFileSync, "flair"),
|
|
2429
4531
|
},
|
|
2430
|
-
{
|
|
2431
|
-
name: "@tpsdev-ai/flair-client",
|
|
2432
|
-
probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
|
|
2433
|
-
},
|
|
2434
4532
|
{
|
|
2435
4533
|
name: "@tpsdev-ai/flair-mcp",
|
|
4534
|
+
kind: "bin",
|
|
2436
4535
|
probe: () => probeBinVersion(execFileSync, "flair-mcp"),
|
|
2437
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
|
+
},
|
|
2438
4548
|
];
|
|
2439
4549
|
const findings = [];
|
|
2440
|
-
for (const { name, probe } of packages) {
|
|
4550
|
+
for (const { name, probe, kind, transitive } of packages) {
|
|
4551
|
+
if (transitive && !showAll)
|
|
4552
|
+
continue;
|
|
2441
4553
|
try {
|
|
2442
4554
|
const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
|
|
2443
4555
|
if (!res.ok)
|
|
@@ -2445,18 +4557,45 @@ program
|
|
|
2445
4557
|
const data = await res.json();
|
|
2446
4558
|
const latest = data.version ?? "unknown";
|
|
2447
4559
|
const installed = probe();
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
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
|
+
: "";
|
|
2453
4582
|
console.log(` ${icon} ${name}: ${installedLabel} → ${latest}${suffix}`);
|
|
2454
4583
|
}
|
|
2455
4584
|
catch { /* skip unavailable packages */ }
|
|
2456
4585
|
}
|
|
2457
4586
|
const outdated = findings.filter((f) => f.status === "outdated");
|
|
2458
4587
|
const missing = findings.filter((f) => f.status === "missing");
|
|
2459
|
-
|
|
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;
|
|
2460
4599
|
if (outdated.length === 0 && missing.length === 0) {
|
|
2461
4600
|
console.log("\n✅ Everything is up to date.");
|
|
2462
4601
|
return;
|
|
@@ -2476,9 +4615,9 @@ program
|
|
|
2476
4615
|
// Perform upgrade. `latest` comes from the npm registry's HTTP
|
|
2477
4616
|
// response, so CodeQL (correctly) treats it as untrusted input.
|
|
2478
4617
|
// Use execFileSync with argv — the spec `<name>@<version>` becomes a
|
|
2479
|
-
// single argument to
|
|
2480
|
-
console.log(`\nUpgrading ${
|
|
2481
|
-
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) {
|
|
2482
4621
|
try {
|
|
2483
4622
|
console.log(` Installing ${pkg}@${latest}...`);
|
|
2484
4623
|
execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
|
|
@@ -2488,6 +4627,26 @@ program
|
|
|
2488
4627
|
console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
|
|
2489
4628
|
}
|
|
2490
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
|
+
}
|
|
2491
4650
|
if (opts.restart) {
|
|
2492
4651
|
console.log("\nRestarting Flair...");
|
|
2493
4652
|
try {
|
|
@@ -2806,7 +4965,7 @@ program
|
|
|
2806
4965
|
program
|
|
2807
4966
|
.command("reembed")
|
|
2808
4967
|
.description("Re-generate embeddings for memories with stale or missing model tags")
|
|
2809
|
-
.
|
|
4968
|
+
.option("--agent <id>", "Agent ID to re-embed memories for (defaults to all agents with stale rows)")
|
|
2810
4969
|
.option("--stale-only", "Only re-embed memories with mismatched model tag")
|
|
2811
4970
|
.option("--dry-run", "Show count without modifying")
|
|
2812
4971
|
.option("--port <port>", "Harper HTTP port")
|
|
@@ -2821,28 +4980,165 @@ program
|
|
|
2821
4980
|
const batchSize = Number(opts.batchSize);
|
|
2822
4981
|
const delayMs = Number(opts.delayMs);
|
|
2823
4982
|
const currentModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
|
|
2824
|
-
|
|
4983
|
+
if (agentId) {
|
|
4984
|
+
console.log(`Re-embedding memories for agent: ${agentId}`);
|
|
4985
|
+
}
|
|
4986
|
+
else {
|
|
4987
|
+
console.log("Re-embedding memories for all agents with stale rows");
|
|
4988
|
+
}
|
|
2825
4989
|
console.log(`Current model: ${currentModel}`);
|
|
2826
4990
|
if (staleOnly)
|
|
2827
4991
|
console.log("Mode: stale-only (skipping up-to-date memories)");
|
|
2828
4992
|
if (dryRun)
|
|
2829
4993
|
console.log("Mode: dry-run (no modifications)");
|
|
2830
4994
|
console.log("");
|
|
4995
|
+
// When no agent specified, use admin auth to fetch all memories
|
|
4996
|
+
if (!agentId) {
|
|
4997
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
4998
|
+
if (!adminPass) {
|
|
4999
|
+
console.error("❌ Admin password required when --agent is not specified (set FLAIR_ADMIN_PASS or HDB_ADMIN_PASSWORD)");
|
|
5000
|
+
process.exit(1);
|
|
5001
|
+
}
|
|
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}/`, {
|
|
5017
|
+
method: "POST",
|
|
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),
|
|
5029
|
+
});
|
|
5030
|
+
if (!searchRes.ok) {
|
|
5031
|
+
console.error(`❌ Failed to fetch memories via ops API: ${searchRes.status}`);
|
|
5032
|
+
process.exit(1);
|
|
5033
|
+
}
|
|
5034
|
+
const raw = await searchRes.json();
|
|
5035
|
+
const allMemories = Array.isArray(raw) ? raw : (raw?.results ?? []);
|
|
5036
|
+
// Group by agentId
|
|
5037
|
+
const byAgent = new Map();
|
|
5038
|
+
for (const m of allMemories) {
|
|
5039
|
+
if (!m.content)
|
|
5040
|
+
continue;
|
|
5041
|
+
if (staleOnly && m.embeddingModel === currentModel)
|
|
5042
|
+
continue;
|
|
5043
|
+
const agent = m.agentId || "unknown";
|
|
5044
|
+
if (!byAgent.has(agent))
|
|
5045
|
+
byAgent.set(agent, []);
|
|
5046
|
+
byAgent.get(agent).push(m);
|
|
5047
|
+
}
|
|
5048
|
+
// Process each agent
|
|
5049
|
+
let totalProcessed = 0;
|
|
5050
|
+
let totalErrors = 0;
|
|
5051
|
+
const agentCount = byAgent.size;
|
|
5052
|
+
let agentIndex = 0;
|
|
5053
|
+
for (const [agent, memories] of byAgent) {
|
|
5054
|
+
agentIndex++;
|
|
5055
|
+
console.log(`\nAgent ${agentIndex}/${agentCount}: ${agent}`);
|
|
5056
|
+
console.log(` Memories to re-embed: ${memories.length}`);
|
|
5057
|
+
const keysDir = defaultKeysDir();
|
|
5058
|
+
const privPath = privKeyPath(agent, keysDir);
|
|
5059
|
+
if (!existsSync(privPath)) {
|
|
5060
|
+
console.error(` ❌ Key not found: ${privPath} — skipping`);
|
|
5061
|
+
continue;
|
|
5062
|
+
}
|
|
5063
|
+
if (dryRun)
|
|
5064
|
+
continue;
|
|
5065
|
+
let processed = 0;
|
|
5066
|
+
let errors = 0;
|
|
5067
|
+
for (let i = 0; i < memories.length; i += batchSize) {
|
|
5068
|
+
const batch = memories.slice(i, i + batchSize);
|
|
5069
|
+
for (const memory of batch) {
|
|
5070
|
+
try {
|
|
5071
|
+
const updateRes = await authFetch(baseUrl, agent, privPath, "PUT", `/Memory/${memory.id}`, {
|
|
5072
|
+
id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || agent,
|
|
5073
|
+
});
|
|
5074
|
+
if (updateRes.ok)
|
|
5075
|
+
processed++;
|
|
5076
|
+
else
|
|
5077
|
+
errors++;
|
|
5078
|
+
}
|
|
5079
|
+
catch {
|
|
5080
|
+
errors++;
|
|
5081
|
+
}
|
|
5082
|
+
}
|
|
5083
|
+
const pct = Math.round(((i + batch.length) / memories.length) * 100);
|
|
5084
|
+
process.stdout.write(` \r Re-embedded ${processed}/${memories.length} (${pct}%)${errors > 0 ? ` [${errors} errors]` : ""}`);
|
|
5085
|
+
if (i + batchSize < memories.length)
|
|
5086
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
5087
|
+
}
|
|
5088
|
+
console.log(`\n ✅ Agent ${agent}: ${processed} updated, ${errors} errors`);
|
|
5089
|
+
totalProcessed += processed;
|
|
5090
|
+
totalErrors += errors;
|
|
5091
|
+
}
|
|
5092
|
+
console.log(`\n\n✅ Re-embedding complete: ${totalProcessed} updated, ${totalErrors} errors`);
|
|
5093
|
+
return;
|
|
5094
|
+
}
|
|
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.
|
|
2831
5100
|
const keysDir = defaultKeysDir();
|
|
2832
5101
|
const privPath = privKeyPath(agentId, keysDir);
|
|
2833
5102
|
if (!existsSync(privPath)) {
|
|
2834
5103
|
console.error(`❌ Key not found: ${privPath}`);
|
|
2835
5104
|
process.exit(1);
|
|
2836
5105
|
}
|
|
2837
|
-
const
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
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 ?? [];
|
|
2843
5141
|
}
|
|
2844
|
-
const data = await searchRes.json();
|
|
2845
|
-
const allMemories = data.results ?? [];
|
|
2846
5142
|
const candidates = allMemories.filter((m) => {
|
|
2847
5143
|
if (!m.content)
|
|
2848
5144
|
return false;
|
|
@@ -2869,7 +5165,7 @@ program
|
|
|
2869
5165
|
for (const memory of batch) {
|
|
2870
5166
|
try {
|
|
2871
5167
|
const updateRes = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${memory.id}`, {
|
|
2872
|
-
id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined,
|
|
5168
|
+
id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || opts.agent,
|
|
2873
5169
|
});
|
|
2874
5170
|
if (updateRes.ok)
|
|
2875
5171
|
processed++;
|
|
@@ -2919,7 +5215,8 @@ program
|
|
|
2919
5215
|
}
|
|
2920
5216
|
}
|
|
2921
5217
|
catch (e) {
|
|
2922
|
-
|
|
5218
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
5219
|
+
console.log(` ${red("FAIL")} ${name}: ${message?.slice(0, 120)}`);
|
|
2923
5220
|
failed++;
|
|
2924
5221
|
}
|
|
2925
5222
|
};
|
|
@@ -2963,6 +5260,13 @@ program
|
|
|
2963
5260
|
process.exit(1);
|
|
2964
5261
|
});
|
|
2965
5262
|
// ─── flair deploy ─────────────────────────────────────────────────────────────
|
|
5263
|
+
// NOTE on env-var naming for `flair deploy`: the FABRIC_* env vars below intentionally
|
|
5264
|
+
// do NOT carry the FLAIR_ prefix that the rest of the CLI uses (FLAIR_ADMIN_PASS,
|
|
5265
|
+
// FLAIR_TARGET, FLAIR_PAIRING_TOKEN, etc.). FABRIC_* credentials are shared with
|
|
5266
|
+
// the broader TPS tooling stack — multiple tools deploy to the same Harper Fabric
|
|
5267
|
+
// org/cluster with the same auth, and demanding a tool-specific prefix would force
|
|
5268
|
+
// operators to maintain duplicated env vars. Per Kern review on PR #306: the
|
|
5269
|
+
// inconsistency is deliberate, document it here so the next agent doesn't "fix" it.
|
|
2966
5270
|
program
|
|
2967
5271
|
.command("deploy")
|
|
2968
5272
|
.description("Deploy Flair as a component to a remote Harper Fabric cluster")
|
|
@@ -3287,11 +5591,164 @@ program
|
|
|
3287
5591
|
if (issues > 0)
|
|
3288
5592
|
process.exit(1);
|
|
3289
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
|
+
});
|
|
3290
5745
|
// ─── Memory and Soul commands ────────────────────────────────────────────────
|
|
3291
5746
|
const memory = program.command("memory").description("Manage agent memories");
|
|
3292
5747
|
memory.command("add [content]").requiredOption("--agent <id>")
|
|
3293
5748
|
.option("--content <text>", "memory content (alias for positional arg)")
|
|
3294
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")
|
|
3295
5752
|
.action(async (contentArg, opts) => {
|
|
3296
5753
|
const content = contentArg ?? opts.content;
|
|
3297
5754
|
if (!content) {
|
|
@@ -3299,13 +5756,95 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
3299
5756
|
process.exit(1);
|
|
3300
5757
|
}
|
|
3301
5758
|
const memId = `${opts.agent}-${Date.now()}`;
|
|
3302
|
-
const
|
|
5759
|
+
const body = {
|
|
3303
5760
|
id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
|
|
3304
5761
|
tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
|
|
3305
5762
|
type: "memory", createdAt: new Date().toISOString(),
|
|
3306
|
-
}
|
|
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);
|
|
3307
5769
|
console.log(JSON.stringify(out, null, 2));
|
|
3308
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
|
+
});
|
|
3309
5848
|
memory.command("search [query]").requiredOption("--agent <id>")
|
|
3310
5849
|
.option("--q <query>", "search query (alias for positional arg)")
|
|
3311
5850
|
.option("--limit <n>", "Max results", "5")
|
|
@@ -3411,6 +5950,20 @@ program
|
|
|
3411
5950
|
}
|
|
3412
5951
|
});
|
|
3413
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.
|
|
3414
5967
|
program
|
|
3415
5968
|
.command("bootstrap")
|
|
3416
5969
|
.description("Cold-start context: get soul + recent memories as formatted text")
|
|
@@ -3444,6 +5997,12 @@ program
|
|
|
3444
5997
|
console.error("No context available.");
|
|
3445
5998
|
process.exit(1);
|
|
3446
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]`);
|
|
3447
6006
|
}
|
|
3448
6007
|
catch (err) {
|
|
3449
6008
|
console.error(`Bootstrap failed: ${err.message}`);
|
|
@@ -3539,6 +6098,7 @@ bridge
|
|
|
3539
6098
|
.option("--port <port>", "Harper HTTP port")
|
|
3540
6099
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
3541
6100
|
.option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
|
|
6101
|
+
.option("--source <path>", "Source directory (for directory-based imports like markdown)")
|
|
3542
6102
|
.action(async (name, srcArg, opts) => {
|
|
3543
6103
|
const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
|
|
3544
6104
|
const cwd = opts.cwd ?? srcArg ?? process.cwd();
|
|
@@ -4302,7 +6862,7 @@ program
|
|
|
4302
6862
|
};
|
|
4303
6863
|
const rawOutputPath = opts.output ?? join(homedir(), ".flair", "exports", `${agentId}-${Date.now()}.json`);
|
|
4304
6864
|
// Canonicalize to prevent path traversal (e.g. ../../etc/passwd)
|
|
4305
|
-
const outputPath =
|
|
6865
|
+
const outputPath = resolve(rawOutputPath);
|
|
4306
6866
|
mkdirSync(join(outputPath, ".."), { recursive: true });
|
|
4307
6867
|
const fileMode = privateKey ? 0o600 : 0o644;
|
|
4308
6868
|
writeFileSync(outputPath, JSON.stringify(exportData, null, 2), { mode: fileMode });
|
|
@@ -4441,9 +7001,264 @@ program
|
|
|
4441
7001
|
console.log(`Souls: ${(data.souls ?? []).length}`);
|
|
4442
7002
|
}
|
|
4443
7003
|
});
|
|
7004
|
+
// ─── flair migrate-harness-memory ───────────────────────────────────────────
|
|
7005
|
+
/** Resolve the memory directory path for a target harness. */
|
|
7006
|
+
function resolveMemoryDir(target, agentId) {
|
|
7007
|
+
switch (target) {
|
|
7008
|
+
case "claude-code": {
|
|
7009
|
+
const cwd = process.cwd();
|
|
7010
|
+
const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
|
|
7011
|
+
const memoryDir = join(homedir(), ".claude", "projects", encodedCwd, "memory");
|
|
7012
|
+
const resolved = resolve(memoryDir);
|
|
7013
|
+
const expectedRoot = join(homedir(), ".claude", "projects");
|
|
7014
|
+
if (!resolved.startsWith(expectedRoot + sep)) {
|
|
7015
|
+
throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
|
|
7016
|
+
}
|
|
7017
|
+
return resolved;
|
|
7018
|
+
}
|
|
7019
|
+
case "openclaw": {
|
|
7020
|
+
const cwd = process.cwd();
|
|
7021
|
+
const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
|
|
7022
|
+
const memoryDir = join(homedir(), ".openclaw", "projects", encodedCwd, "memory");
|
|
7023
|
+
const resolved = resolve(memoryDir);
|
|
7024
|
+
const expectedRoot = join(homedir(), ".openclaw", "projects");
|
|
7025
|
+
if (!resolved.startsWith(expectedRoot + sep)) {
|
|
7026
|
+
throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
|
|
7027
|
+
}
|
|
7028
|
+
return resolved;
|
|
7029
|
+
}
|
|
7030
|
+
case "pi": {
|
|
7031
|
+
const cwd = process.cwd();
|
|
7032
|
+
const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
|
|
7033
|
+
const memoryDir = join(homedir(), ".pi", "projects", encodedCwd, "memory");
|
|
7034
|
+
const resolved = resolve(memoryDir);
|
|
7035
|
+
const expectedRoot = join(homedir(), ".pi", "projects");
|
|
7036
|
+
if (!resolved.startsWith(expectedRoot + sep)) {
|
|
7037
|
+
throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
|
|
7038
|
+
}
|
|
7039
|
+
return resolved;
|
|
7040
|
+
}
|
|
7041
|
+
default:
|
|
7042
|
+
throw new Error(`Unknown target: ${target}. Valid: claude-code, openclaw, pi`);
|
|
7043
|
+
}
|
|
7044
|
+
}
|
|
7045
|
+
/** Parse a memory file with YAML frontmatter. */
|
|
7046
|
+
function parseMemoryFile(filePath) {
|
|
7047
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
7048
|
+
const lines = raw.split("\n");
|
|
7049
|
+
if (!lines[0].startsWith("---")) {
|
|
7050
|
+
return { meta: {}, body: raw };
|
|
7051
|
+
}
|
|
7052
|
+
let endIdx = 1;
|
|
7053
|
+
while (endIdx < lines.length && !lines[endIdx].startsWith("---")) {
|
|
7054
|
+
endIdx++;
|
|
7055
|
+
}
|
|
7056
|
+
const frontmatterLines = lines.slice(1, endIdx);
|
|
7057
|
+
const bodyLines = lines.slice(endIdx + 1);
|
|
7058
|
+
const yamlContent = frontmatterLines.join("\n");
|
|
7059
|
+
const meta = parseYaml(yamlContent) || {};
|
|
7060
|
+
return {
|
|
7061
|
+
meta: {
|
|
7062
|
+
name: meta.name,
|
|
7063
|
+
description: meta.description,
|
|
7064
|
+
type: meta.type,
|
|
7065
|
+
tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []),
|
|
7066
|
+
},
|
|
7067
|
+
body: bodyLines.join("\n").trim(),
|
|
7068
|
+
};
|
|
7069
|
+
}
|
|
7070
|
+
/** Map memory type to durability. */
|
|
7071
|
+
function mapDurability(type) {
|
|
7072
|
+
switch (type) {
|
|
7073
|
+
case "feedback":
|
|
7074
|
+
case "reference":
|
|
7075
|
+
return "permanent";
|
|
7076
|
+
case "project":
|
|
7077
|
+
case "user":
|
|
7078
|
+
return "persistent";
|
|
7079
|
+
default:
|
|
7080
|
+
return "standard";
|
|
7081
|
+
}
|
|
7082
|
+
}
|
|
7083
|
+
/** Extract keywords from filename for tags. */
|
|
7084
|
+
function extractKeywordsFromFilename(filename) {
|
|
7085
|
+
const base = filename.replace(/\.md$/, "");
|
|
7086
|
+
const withoutType = base.replace(/^(feedback|project|reference|user)_/, "");
|
|
7087
|
+
const parts = withoutType.split(/[_-]+/).map(p => p.toLowerCase());
|
|
7088
|
+
const stopwords = new Set(["the", "and", "for", "with", "about", "on", "in", "to", "of", "a", "an"]);
|
|
7089
|
+
return parts.filter(p => p.length > 2 && !stopwords.has(p));
|
|
7090
|
+
}
|
|
7091
|
+
program
|
|
7092
|
+
.command("migrate-harness-memory")
|
|
7093
|
+
.description("Migrate harness-local memories to Flair")
|
|
7094
|
+
.requiredOption("--target <target>", "Target harness: claude-code, openclaw, pi")
|
|
7095
|
+
.requiredOption("--agent <id>", "Agent ID to write memories under")
|
|
7096
|
+
.option("--dry-run", "Show what would be migrated without writing")
|
|
7097
|
+
.action(async (opts) => {
|
|
7098
|
+
const target = opts.target;
|
|
7099
|
+
const agentId = opts.agent;
|
|
7100
|
+
const dryRun = !!opts.dryRun;
|
|
7101
|
+
let memoryDir;
|
|
7102
|
+
try {
|
|
7103
|
+
memoryDir = resolveMemoryDir(target, agentId);
|
|
7104
|
+
}
|
|
7105
|
+
catch (e) {
|
|
7106
|
+
console.error(`Error resolving memory directory: ${e.message}`);
|
|
7107
|
+
process.exit(1);
|
|
7108
|
+
}
|
|
7109
|
+
if (!existsSync(memoryDir)) {
|
|
7110
|
+
console.error(`Error: Memory directory not found: ${memoryDir}`);
|
|
7111
|
+
process.exit(1);
|
|
7112
|
+
}
|
|
7113
|
+
const migratedDir = join(memoryDir, ".migrated");
|
|
7114
|
+
if (!dryRun) {
|
|
7115
|
+
mkdirSync(migratedDir, { recursive: true, mode: 0o700 });
|
|
7116
|
+
}
|
|
7117
|
+
console.log(`Migrating memories from ${memoryDir} to Flair (agentId=${agentId})`);
|
|
7118
|
+
if (dryRun)
|
|
7119
|
+
console.log(" (DRY RUN - no files will be modified)");
|
|
7120
|
+
const files = readdirSync(memoryDir, { withFileTypes: true })
|
|
7121
|
+
.filter(d => d.isFile() && d.name.endsWith(".md") && d.name !== "MEMORY.md")
|
|
7122
|
+
.map(d => d.name)
|
|
7123
|
+
.sort();
|
|
7124
|
+
if (files.length === 0) {
|
|
7125
|
+
console.log("No memory files found.");
|
|
7126
|
+
return;
|
|
7127
|
+
}
|
|
7128
|
+
console.log(`Found ${files.length} memory file(s) to migrate.`);
|
|
7129
|
+
let successCount = 0;
|
|
7130
|
+
let skipCount = 0;
|
|
7131
|
+
let failCount = 0;
|
|
7132
|
+
for (const filename of files) {
|
|
7133
|
+
const sourcePath = join(memoryDir, filename);
|
|
7134
|
+
const migratedPath = join(migratedDir, filename);
|
|
7135
|
+
if (existsSync(migratedPath)) {
|
|
7136
|
+
console.log(` ${filename}: already migrated, skipping`);
|
|
7137
|
+
skipCount++;
|
|
7138
|
+
continue;
|
|
7139
|
+
}
|
|
7140
|
+
console.log(` Processing: ${filename}...`);
|
|
7141
|
+
let parsed;
|
|
7142
|
+
try {
|
|
7143
|
+
parsed = parseMemoryFile(sourcePath);
|
|
7144
|
+
}
|
|
7145
|
+
catch (e) {
|
|
7146
|
+
console.error(` Failed to parse: ${e.message}`);
|
|
7147
|
+
failCount++;
|
|
7148
|
+
continue;
|
|
7149
|
+
}
|
|
7150
|
+
let type = parsed.meta.type;
|
|
7151
|
+
if (!type) {
|
|
7152
|
+
if (filename.startsWith("feedback_"))
|
|
7153
|
+
type = "feedback";
|
|
7154
|
+
else if (filename.startsWith("project_"))
|
|
7155
|
+
type = "project";
|
|
7156
|
+
else if (filename.startsWith("reference_"))
|
|
7157
|
+
type = "reference";
|
|
7158
|
+
else
|
|
7159
|
+
type = "session";
|
|
7160
|
+
}
|
|
7161
|
+
const durability = mapDurability(type);
|
|
7162
|
+
const tags = [...(parsed.meta.tags || [])];
|
|
7163
|
+
const filenameTags = extractKeywordsFromFilename(filename);
|
|
7164
|
+
for (const t of filenameTags) {
|
|
7165
|
+
if (!tags.includes(t))
|
|
7166
|
+
tags.push(t);
|
|
7167
|
+
}
|
|
7168
|
+
tags.push(type);
|
|
7169
|
+
const content = parsed.body;
|
|
7170
|
+
console.log(` Type: ${type}, Durability: ${durability}`);
|
|
7171
|
+
console.log(` Tags: ${tags.join(", ")}`);
|
|
7172
|
+
console.log(` Content preview: ${content.slice(0, 100)}${content.length > 100 ? "..." : ""}`);
|
|
7173
|
+
if (!dryRun) {
|
|
7174
|
+
console.log(` Writing to Flair...`);
|
|
7175
|
+
try {
|
|
7176
|
+
const DEFAULT_PORT = 19926;
|
|
7177
|
+
const httpUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
7178
|
+
const agentKeyId = `${agentId}.key`;
|
|
7179
|
+
const keysDir = join(homedir(), ".flair", "keys");
|
|
7180
|
+
const keyPath = join(keysDir, agentKeyId);
|
|
7181
|
+
let authSuccess = false;
|
|
7182
|
+
if (existsSync(keyPath)) {
|
|
7183
|
+
const memoryId = `${agentId}-${randomUUID()}`;
|
|
7184
|
+
const body = {
|
|
7185
|
+
id: memoryId,
|
|
7186
|
+
agentId: agentId,
|
|
7187
|
+
content: content,
|
|
7188
|
+
type: type,
|
|
7189
|
+
durability: durability,
|
|
7190
|
+
tags: tags,
|
|
7191
|
+
createdAt: new Date().toISOString(),
|
|
7192
|
+
};
|
|
7193
|
+
const memoryPath = `/Memory/${memoryId}`;
|
|
7194
|
+
const res = await authFetch(httpUrl, agentId, keyPath, "PUT", memoryPath, body);
|
|
7195
|
+
if (res.ok) {
|
|
7196
|
+
authSuccess = true;
|
|
7197
|
+
console.log(` Write to Flair successful (Ed25519 auth)`);
|
|
7198
|
+
}
|
|
7199
|
+
}
|
|
7200
|
+
if (!authSuccess) {
|
|
7201
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD || "";
|
|
7202
|
+
if (adminPass) {
|
|
7203
|
+
const memoryId = `${agentId}-${randomUUID()}`;
|
|
7204
|
+
const body = {
|
|
7205
|
+
id: memoryId,
|
|
7206
|
+
agentId: agentId,
|
|
7207
|
+
content: content,
|
|
7208
|
+
type: type,
|
|
7209
|
+
durability: durability,
|
|
7210
|
+
tags: tags,
|
|
7211
|
+
createdAt: new Date().toISOString(),
|
|
7212
|
+
};
|
|
7213
|
+
const memoryPath = `/Memory/${memoryId}`;
|
|
7214
|
+
const auth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
7215
|
+
const res = await fetch(`${httpUrl}${memoryPath}`, {
|
|
7216
|
+
method: "PUT",
|
|
7217
|
+
headers: {
|
|
7218
|
+
Authorization: auth,
|
|
7219
|
+
"Content-Type": "application/json",
|
|
7220
|
+
},
|
|
7221
|
+
body: JSON.stringify(body),
|
|
7222
|
+
signal: AbortSignal.timeout(10000),
|
|
7223
|
+
});
|
|
7224
|
+
if (res.ok) {
|
|
7225
|
+
console.log(` Write to Flair successful (Basic auth)`);
|
|
7226
|
+
}
|
|
7227
|
+
else {
|
|
7228
|
+
const text = await res.text();
|
|
7229
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
7230
|
+
}
|
|
7231
|
+
}
|
|
7232
|
+
else {
|
|
7233
|
+
throw new Error("No authentication method available");
|
|
7234
|
+
}
|
|
7235
|
+
}
|
|
7236
|
+
renameSync(sourcePath, migratedPath);
|
|
7237
|
+
console.log(` Moved to .migrated/${filename}`);
|
|
7238
|
+
}
|
|
7239
|
+
catch (e) {
|
|
7240
|
+
console.error(` Failed: ${e.message}`);
|
|
7241
|
+
failCount++;
|
|
7242
|
+
continue;
|
|
7243
|
+
}
|
|
7244
|
+
}
|
|
7245
|
+
else {
|
|
7246
|
+
console.log(` [dry-run] Would write to Flair and move to .migrated/${filename}`);
|
|
7247
|
+
}
|
|
7248
|
+
successCount++;
|
|
7249
|
+
}
|
|
7250
|
+
console.log(`\nMigration complete:`);
|
|
7251
|
+
console.log(` Processed: ${files.length}`);
|
|
7252
|
+
console.log(` Successful: ${successCount}`);
|
|
7253
|
+
console.log(` Skipped: ${skipCount}`);
|
|
7254
|
+
console.log(` Failed: ${failCount}`);
|
|
7255
|
+
if (failCount > 0) {
|
|
7256
|
+
process.exit(1);
|
|
7257
|
+
}
|
|
7258
|
+
});
|
|
4444
7259
|
// Run CLI only when this is the entry point (not when imported for testing)
|
|
4445
7260
|
if (import.meta.main) {
|
|
4446
7261
|
await program.parseAsync();
|
|
4447
7262
|
}
|
|
4448
7263
|
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
4449
|
-
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, signRequestBody, b64, b64url, program, };
|
|
7264
|
+
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
|