@tpsdev-ai/flair 0.5.6 → 0.6.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 +3 -0
- package/dist/bridges/builtins/agentic-stack.js +58 -0
- package/dist/bridges/builtins/index.js +36 -0
- package/dist/bridges/discover.js +195 -0
- package/dist/bridges/runtime/context.js +59 -0
- package/dist/bridges/runtime/execute.js +92 -0
- package/dist/bridges/runtime/export-runner.js +105 -0
- package/dist/bridges/runtime/formats.js +136 -0
- package/dist/bridges/runtime/import-runner.js +107 -0
- package/dist/bridges/runtime/load-descriptor.js +46 -0
- package/dist/bridges/runtime/mapper.js +139 -0
- package/dist/bridges/runtime/predicate.js +122 -0
- package/dist/bridges/runtime/writers.js +130 -0
- package/dist/bridges/runtime/yaml-loader.js +148 -0
- package/dist/bridges/scaffold.js +224 -0
- package/dist/bridges/types.js +30 -0
- package/dist/cli.js +970 -216
- package/dist/deploy.js +161 -0
- package/dist/resources/ObservationCenter.js +19 -0
- package/dist/resources/health.js +443 -14
- package/package.json +6 -6
- package/ui/observation-center.html +91 -0
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
|
|
9
9
|
import { keystore } from "./keystore.js";
|
|
10
|
+
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
|
|
10
11
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
11
12
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
12
13
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -323,6 +324,163 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
|
|
|
323
324
|
throw new Error(`Operations API insert failed (${res.status}): ${text}`);
|
|
324
325
|
}
|
|
325
326
|
}
|
|
327
|
+
export function templateSoul(choice) {
|
|
328
|
+
const templates = {
|
|
329
|
+
"1": [
|
|
330
|
+
["role", "Pair programmer on this machine. Concise, direct, proactive about flagging risks before I hit them."],
|
|
331
|
+
["project", "(fill in: the main project or repo I'm helping with — shapes what bootstrap prioritizes)"],
|
|
332
|
+
["standards", "Match existing codebase style. Prefer editing over rewriting. Surface tradeoffs on ambiguous decisions instead of making unilateral calls."],
|
|
333
|
+
],
|
|
334
|
+
"2": [
|
|
335
|
+
["role", "Team agent — operates in a shared repo and coordinates with other agents. Communicate through structured channels (PRs, issues, mail), not free-form chat."],
|
|
336
|
+
["project", "(fill in: the repo or ops flow this agent runs in)"],
|
|
337
|
+
["standards", "Keep changes minimal and reviewable. Always open PRs, never push to main. Document decisions in the issue tracker, not in agent memory."],
|
|
338
|
+
],
|
|
339
|
+
"3": [
|
|
340
|
+
["role", "Research assistant. Survey sources, extract findings, write structured notes. Flag uncertainty explicitly; separate evidence from inference."],
|
|
341
|
+
["project", "(fill in: the research area or question being tracked)"],
|
|
342
|
+
["standards", "Cite sources inline. When sources disagree, surface the disagreement rather than picking a side silently. Prefer primary sources."],
|
|
343
|
+
],
|
|
344
|
+
};
|
|
345
|
+
return templates[choice] ?? [];
|
|
346
|
+
}
|
|
347
|
+
async function customSoulPrompts(ask) {
|
|
348
|
+
const entries = [];
|
|
349
|
+
console.log("\n Three fields. Press Enter on any to skip it.\n");
|
|
350
|
+
console.log(" role — how the agent identifies itself and acts");
|
|
351
|
+
console.log(" \"Senior dev, concise and direct\"");
|
|
352
|
+
console.log(" \"Data-engineering sidekick, SQL-first\"");
|
|
353
|
+
console.log(" \"PM assistant — asks clarifying questions before writing specs\"");
|
|
354
|
+
const role = await ask(" > ");
|
|
355
|
+
if (role.trim())
|
|
356
|
+
entries.push(["role", role.trim()]);
|
|
357
|
+
console.log("\n project — what the agent is currently focused on");
|
|
358
|
+
console.log(" \"LifestyleLab — building Flair and TPS\"");
|
|
359
|
+
console.log(" \"Legal discovery review, Q2 contracts\"");
|
|
360
|
+
console.log(" \"Personal automation scripts in Bash + Python\"");
|
|
361
|
+
const project = await ask(" > ");
|
|
362
|
+
if (project.trim())
|
|
363
|
+
entries.push(["project", project.trim()]);
|
|
364
|
+
console.log("\n standards — communication or coding preferences that should persist");
|
|
365
|
+
console.log(" \"No emojis. Match existing style. Ask before risky ops.\"");
|
|
366
|
+
console.log(" \"Always cite sources. Flag uncertainty explicitly.\"");
|
|
367
|
+
console.log(" \"Typescript strict mode. Prefer composition over inheritance.\"");
|
|
368
|
+
const standards = await ask(" > ");
|
|
369
|
+
if (standards.trim())
|
|
370
|
+
entries.push(["standards", standards.trim()]);
|
|
371
|
+
return entries;
|
|
372
|
+
}
|
|
373
|
+
async function editEntries(ask, entries) {
|
|
374
|
+
console.log("\n Press Enter to keep each default, or type a replacement:");
|
|
375
|
+
const result = [];
|
|
376
|
+
for (const [key, def] of entries) {
|
|
377
|
+
const preview = def.length > 60 ? def.slice(0, 57) + "..." : def;
|
|
378
|
+
console.log(`\n ${key} [keep: ${preview}]`);
|
|
379
|
+
const input = (await ask(" > ")).trim();
|
|
380
|
+
result.push([key, input || def]);
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
export function parseSoulJson(raw) {
|
|
385
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
386
|
+
if (!jsonMatch)
|
|
387
|
+
throw new Error("no JSON object found in input");
|
|
388
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
389
|
+
const entries = [];
|
|
390
|
+
if (parsed.role)
|
|
391
|
+
entries.push(["role", String(parsed.role).trim()]);
|
|
392
|
+
if (parsed.project)
|
|
393
|
+
entries.push(["project", String(parsed.project).trim()]);
|
|
394
|
+
if (parsed.standards)
|
|
395
|
+
entries.push(["standards", String(parsed.standards).trim()]);
|
|
396
|
+
if (entries.length === 0)
|
|
397
|
+
throw new Error("JSON had no role/project/standards keys");
|
|
398
|
+
return entries;
|
|
399
|
+
}
|
|
400
|
+
async function runSoulWizard(agentId) {
|
|
401
|
+
const { createInterface } = await import("node:readline");
|
|
402
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
403
|
+
// Buffered ask: collects rapid input (pasted text) into one answer.
|
|
404
|
+
// Waits 200ms after last line before resolving, so pasted multiline
|
|
405
|
+
// blocks are captured as a single answer instead of spilling across prompts.
|
|
406
|
+
const ask = (q) => new Promise(resolve => {
|
|
407
|
+
let buffer = "";
|
|
408
|
+
let timer = null;
|
|
409
|
+
let done = false;
|
|
410
|
+
const finish = () => {
|
|
411
|
+
if (done)
|
|
412
|
+
return;
|
|
413
|
+
done = true;
|
|
414
|
+
rl.removeListener("line", onLine);
|
|
415
|
+
resolve(buffer.trim());
|
|
416
|
+
};
|
|
417
|
+
const onLine = (line) => {
|
|
418
|
+
buffer += (buffer ? "\n" : "") + line;
|
|
419
|
+
if (timer)
|
|
420
|
+
clearTimeout(timer);
|
|
421
|
+
timer = setTimeout(finish, 200);
|
|
422
|
+
};
|
|
423
|
+
process.stdout.write(q);
|
|
424
|
+
rl.on("line", onLine);
|
|
425
|
+
});
|
|
426
|
+
console.log("\n🎭 Agent personality setup");
|
|
427
|
+
console.log(" Soul entries shape what every future session starts with.\n");
|
|
428
|
+
console.log(" What best describes this agent?");
|
|
429
|
+
console.log(" (1) Solo developer — helps you with code on this machine");
|
|
430
|
+
console.log(" (2) Team agent — runs in a shared repo / ops flow");
|
|
431
|
+
console.log(" (3) Research assistant — surveys sources, writes notes");
|
|
432
|
+
console.log(" (4) Draft from Claude — paste a Claude-generated JSON draft");
|
|
433
|
+
console.log(" (5) Custom — I'll prompt for each field with examples");
|
|
434
|
+
console.log(" (s) Skip — set up later; `flair doctor` will nudge\n");
|
|
435
|
+
const choice = (await ask(" Choice [1-5/s]: ")).trim().toLowerCase();
|
|
436
|
+
let entries = [];
|
|
437
|
+
if (choice === "s" || choice === "skip") {
|
|
438
|
+
rl.close();
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
else if (choice === "1" || choice === "2" || choice === "3") {
|
|
442
|
+
entries = templateSoul(choice);
|
|
443
|
+
console.log("\n Template draft:");
|
|
444
|
+
for (const [k, v] of entries)
|
|
445
|
+
console.log(` ${k}: ${v}`);
|
|
446
|
+
const edit = (await ask("\n Edit before saving? [y/N]: ")).trim().toLowerCase();
|
|
447
|
+
if (edit === "y" || edit === "yes") {
|
|
448
|
+
entries = await editEntries(ask, entries);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
else if (choice === "4") {
|
|
452
|
+
console.log("\n Paste this prompt into your Claude session:");
|
|
453
|
+
console.log(" ─────────────────────────────────────────────────────────────");
|
|
454
|
+
console.log(` Generate a JSON object with keys "role", "project", and`);
|
|
455
|
+
console.log(` "standards" suitable as Flair soul entries for an agent with`);
|
|
456
|
+
console.log(` id "${agentId}" operating in my current context. Each value`);
|
|
457
|
+
console.log(` should be 1-2 specific sentences that shape behavior. Output`);
|
|
458
|
+
console.log(` only the JSON object, no prose.`);
|
|
459
|
+
console.log(" ─────────────────────────────────────────────────────────────\n");
|
|
460
|
+
console.log(" Paste the resulting JSON below:");
|
|
461
|
+
const raw = await ask(" > ");
|
|
462
|
+
try {
|
|
463
|
+
entries = parseSoulJson(raw);
|
|
464
|
+
console.log("\n Parsed draft:");
|
|
465
|
+
for (const [k, v] of entries)
|
|
466
|
+
console.log(` ${k}: ${v}`);
|
|
467
|
+
const edit = (await ask("\n Edit before saving? [y/N]: ")).trim().toLowerCase();
|
|
468
|
+
if (edit === "y" || edit === "yes") {
|
|
469
|
+
entries = await editEntries(ask, entries);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
console.log(`\n Couldn't parse JSON (${err.message}). Falling back to custom prompts.`);
|
|
474
|
+
entries = await customSoulPrompts(ask);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
// Custom (5) or unrecognized input — route to custom prompts
|
|
479
|
+
entries = await customSoulPrompts(ask);
|
|
480
|
+
}
|
|
481
|
+
rl.close();
|
|
482
|
+
return entries.filter(([, v]) => v.trim().length > 0);
|
|
483
|
+
}
|
|
326
484
|
// ─── Program ─────────────────────────────────────────────────────────────────
|
|
327
485
|
// Read version from package.json at the package root
|
|
328
486
|
const __pkgDir = join(import.meta.dirname ?? __dirname, "..");
|
|
@@ -566,43 +724,15 @@ program
|
|
|
566
724
|
}
|
|
567
725
|
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
568
726
|
// ── First-run soul setup ──────────────────────────────────────────────
|
|
569
|
-
// Interactive
|
|
570
|
-
// or when stdin is not a TTY (CI, scripts,
|
|
727
|
+
// Interactive wizard to set initial personality (see runSoulWizard).
|
|
728
|
+
// Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
|
|
729
|
+
//
|
|
730
|
+
// Non-TTY / --skip-soul used to seed placeholder text like
|
|
731
|
+
// "AI assistant [default]" — it leaked into bootstrap output and
|
|
732
|
+
// confused users. Now those paths leave the soul empty and nudge the
|
|
733
|
+
// user toward `flair soul set` / `flair doctor` instead.
|
|
571
734
|
if (!opts.skipSoul && process.stdin.isTTY) {
|
|
572
|
-
|
|
573
|
-
const { createInterface } = await import("node:readline");
|
|
574
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
575
|
-
// Buffered ask: collects rapid input (pasted text) into one answer.
|
|
576
|
-
// Waits 200ms after last line before resolving, so pasted multiline
|
|
577
|
-
// blocks are captured as a single answer instead of spilling across prompts.
|
|
578
|
-
const ask = (q) => new Promise(resolve => {
|
|
579
|
-
let buffer = "";
|
|
580
|
-
let timer = null;
|
|
581
|
-
const finish = () => {
|
|
582
|
-
rl.removeListener("line", onLine);
|
|
583
|
-
resolve(buffer.trim());
|
|
584
|
-
};
|
|
585
|
-
const onLine = (line) => {
|
|
586
|
-
buffer += (buffer ? "\n" : "") + line;
|
|
587
|
-
if (timer)
|
|
588
|
-
clearTimeout(timer);
|
|
589
|
-
timer = setTimeout(finish, 200);
|
|
590
|
-
};
|
|
591
|
-
process.stdout.write(q);
|
|
592
|
-
rl.on("line", onLine);
|
|
593
|
-
});
|
|
594
|
-
const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
|
|
595
|
-
const project = await ask(" What project is it working on?\n > ");
|
|
596
|
-
const standards = await ask(" Any coding standards or preferences?\n > ");
|
|
597
|
-
rl.close();
|
|
598
|
-
// Write non-empty answers as soul entries
|
|
599
|
-
const soulEntries = [];
|
|
600
|
-
if (role.trim())
|
|
601
|
-
soulEntries.push(["role", role.trim()]);
|
|
602
|
-
if (project.trim())
|
|
603
|
-
soulEntries.push(["project", project.trim()]);
|
|
604
|
-
if (standards.trim())
|
|
605
|
-
soulEntries.push(["standards", standards.trim()]);
|
|
735
|
+
const soulEntries = await runSoulWizard(agentId);
|
|
606
736
|
if (soulEntries.length > 0) {
|
|
607
737
|
console.log("");
|
|
608
738
|
for (const [key, value] of soulEntries) {
|
|
@@ -614,30 +744,19 @@ program
|
|
|
614
744
|
console.warn(` ⚠ soul:${key} failed: ${err.message}`);
|
|
615
745
|
}
|
|
616
746
|
}
|
|
617
|
-
console.log(`\n ${soulEntries.length} soul entries saved
|
|
747
|
+
console.log(`\n ${soulEntries.length} soul entries saved.`);
|
|
748
|
+
console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
|
|
618
749
|
}
|
|
619
750
|
else {
|
|
620
|
-
console.log(
|
|
751
|
+
console.log(`\n No soul entries saved. Add later with:`);
|
|
752
|
+
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
753
|
+
console.log(` Or run \`flair doctor\` anytime for a nudge.`);
|
|
621
754
|
}
|
|
622
755
|
}
|
|
623
756
|
else {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
["personality", "Helpful, precise, and proactive [default — customize with 'flair soul set']"],
|
|
628
|
-
["constraints", "Respect user privacy. Be concise. [default — customize with 'flair soul set']"],
|
|
629
|
-
];
|
|
630
|
-
console.log("\nSeeding default soul entries...");
|
|
631
|
-
for (const [key, value] of defaultSoulEntries) {
|
|
632
|
-
try {
|
|
633
|
-
await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
|
|
634
|
-
console.log(` ✓ soul:${key} set (default)`);
|
|
635
|
-
}
|
|
636
|
-
catch (err) {
|
|
637
|
-
console.warn(` ⚠ soul:${key} failed: ${err.message}`);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
console.log(` Customize with: flair soul set --agent ${agentId} --key role --value "..."`);
|
|
757
|
+
const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
|
|
758
|
+
console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
|
|
759
|
+
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
641
760
|
}
|
|
642
761
|
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
643
762
|
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
@@ -1824,23 +1943,81 @@ rem
|
|
|
1824
1943
|
}
|
|
1825
1944
|
});
|
|
1826
1945
|
// ─── flair status ─────────────────────────────────────────────────────────────
|
|
1827
|
-
|
|
1828
|
-
.
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1946
|
+
function humanBytes(n) {
|
|
1947
|
+
if (!Number.isFinite(n) || n < 0)
|
|
1948
|
+
return "—";
|
|
1949
|
+
if (n < 1024)
|
|
1950
|
+
return `${n} B`;
|
|
1951
|
+
if (n < 1024 * 1024)
|
|
1952
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
1953
|
+
if (n < 1024 * 1024 * 1024)
|
|
1954
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
1955
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
1956
|
+
}
|
|
1957
|
+
function relativeTime(iso) {
|
|
1958
|
+
if (!iso)
|
|
1959
|
+
return "—";
|
|
1960
|
+
const ago = Date.now() - new Date(iso).getTime();
|
|
1961
|
+
if (!Number.isFinite(ago) || ago < 0)
|
|
1962
|
+
return "—";
|
|
1963
|
+
const mins = Math.floor(ago / 60000);
|
|
1964
|
+
const hrs = Math.floor(ago / 3600000);
|
|
1965
|
+
const days = Math.floor(ago / 86400000);
|
|
1966
|
+
return days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
|
|
1967
|
+
}
|
|
1968
|
+
// Renders OAuth status lines from non-secret metadata. /HealthDetail never
|
|
1969
|
+
// returns clientSecret — only counts and identifying fields (id, name,
|
|
1970
|
+
// registeredBy, createdAt, issuer). Inputs are coerced to scalar primitives
|
|
1971
|
+
// before formatting to keep the display values clearly separated from the
|
|
1972
|
+
// source record.
|
|
1973
|
+
function oauthSummaryLines(o) {
|
|
1974
|
+
const clients = Number(o?.clients ?? 0);
|
|
1975
|
+
const idps = Number(o?.idpConfigs ?? 0);
|
|
1976
|
+
const tokens = Number(o?.activeTokens ?? 0);
|
|
1977
|
+
return [
|
|
1978
|
+
"\nOAuth:",
|
|
1979
|
+
` Clients: ${clients} IdPs: ${idps} Active tokens: ${tokens}`,
|
|
1980
|
+
];
|
|
1981
|
+
}
|
|
1982
|
+
function oauthDetailLines(o) {
|
|
1983
|
+
const clients = Number(o?.clients ?? 0);
|
|
1984
|
+
const idps = Number(o?.idpConfigs ?? 0);
|
|
1985
|
+
const tokens = Number(o?.activeTokens ?? 0);
|
|
1986
|
+
const out = [
|
|
1987
|
+
"OAuth:",
|
|
1988
|
+
` Clients: ${clients}`,
|
|
1989
|
+
` IdP configs: ${idps}`,
|
|
1990
|
+
` Active tokens: ${tokens}`,
|
|
1991
|
+
];
|
|
1992
|
+
if (Array.isArray(o?.clientList) && o.clientList.length > 0) {
|
|
1993
|
+
out.push("", " Clients:");
|
|
1994
|
+
for (const c of o.clientList) {
|
|
1995
|
+
const id = String(c?.id ?? "");
|
|
1996
|
+
const name = String(c?.name ?? "—");
|
|
1997
|
+
const registeredBy = String(c?.registeredBy ?? "—");
|
|
1998
|
+
const createdAt = String(c?.createdAt ?? "—");
|
|
1999
|
+
out.push(` ${id} ${name} ${registeredBy} ${createdAt}`);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
if (Array.isArray(o?.idpList) && o.idpList.length > 0) {
|
|
2003
|
+
out.push("", " IdPs:");
|
|
2004
|
+
for (const i of o.idpList) {
|
|
2005
|
+
const id = String(i?.id ?? "");
|
|
2006
|
+
const name = String(i?.name ?? "—");
|
|
2007
|
+
const issuer = String(i?.issuer ?? "—");
|
|
2008
|
+
out.push(` ${id} ${name} ${issuer}`);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return out;
|
|
2012
|
+
}
|
|
2013
|
+
async function fetchHealthDetail(opts) {
|
|
1835
2014
|
const port = resolveHttpPort(opts);
|
|
1836
2015
|
const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
|
|
1837
2016
|
let healthy = false;
|
|
1838
2017
|
let healthData = null;
|
|
1839
|
-
// 1. Basic health check — try unauthenticated first, then with admin auth
|
|
1840
2018
|
try {
|
|
1841
2019
|
let res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
|
|
1842
2020
|
if (!res.ok && res.status === 401) {
|
|
1843
|
-
// Harper requires auth (authorizeLocal: true) — retry with admin credentials
|
|
1844
2021
|
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
1845
2022
|
if (adminPass) {
|
|
1846
2023
|
res = await fetch(`${baseUrl}/Health`, {
|
|
@@ -1852,7 +2029,6 @@ program
|
|
|
1852
2029
|
healthy = res.ok;
|
|
1853
2030
|
}
|
|
1854
2031
|
catch { /* unreachable */ }
|
|
1855
|
-
// 2. Try authenticated /HealthDetail for rich stats
|
|
1856
2032
|
if (healthy) {
|
|
1857
2033
|
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1858
2034
|
if (agentId) {
|
|
@@ -1864,14 +2040,12 @@ program
|
|
|
1864
2040
|
headers: { Authorization: authHeader },
|
|
1865
2041
|
signal: AbortSignal.timeout(5000),
|
|
1866
2042
|
});
|
|
1867
|
-
if (res.ok)
|
|
2043
|
+
if (res.ok)
|
|
1868
2044
|
healthData = await res.json().catch(() => null);
|
|
1869
|
-
}
|
|
1870
2045
|
}
|
|
1871
|
-
catch { /* fall through
|
|
2046
|
+
catch { /* fall through */ }
|
|
1872
2047
|
}
|
|
1873
2048
|
}
|
|
1874
|
-
// Fallback: try admin basic auth if available
|
|
1875
2049
|
if (!healthData) {
|
|
1876
2050
|
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
|
|
1877
2051
|
if (adminPass) {
|
|
@@ -1881,14 +2055,24 @@ program
|
|
|
1881
2055
|
headers: { Authorization: auth },
|
|
1882
2056
|
signal: AbortSignal.timeout(5000),
|
|
1883
2057
|
});
|
|
1884
|
-
if (res.ok)
|
|
2058
|
+
if (res.ok)
|
|
1885
2059
|
healthData = await res.json().catch(() => null);
|
|
1886
|
-
}
|
|
1887
2060
|
}
|
|
1888
|
-
catch { /* fall through
|
|
2061
|
+
catch { /* fall through */ }
|
|
1889
2062
|
}
|
|
1890
2063
|
}
|
|
1891
2064
|
}
|
|
2065
|
+
return { healthy, baseUrl, healthData };
|
|
2066
|
+
}
|
|
2067
|
+
const statusCmd = program
|
|
2068
|
+
.command("status")
|
|
2069
|
+
.description("Show Flair instance status, memory stats, and agent info")
|
|
2070
|
+
.option("--port <port>", "Harper HTTP port")
|
|
2071
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
2072
|
+
.option("--json", "Output as JSON")
|
|
2073
|
+
.option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
|
|
2074
|
+
.action(async (opts) => {
|
|
2075
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
|
|
1892
2076
|
if (opts.json) {
|
|
1893
2077
|
console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
|
|
1894
2078
|
if (!healthy)
|
|
@@ -1901,7 +2085,6 @@ program
|
|
|
1901
2085
|
console.log(`\n Run: flair start or flair doctor`);
|
|
1902
2086
|
process.exit(1);
|
|
1903
2087
|
}
|
|
1904
|
-
// Format uptime
|
|
1905
2088
|
const uptimeSec = healthData?.uptimeSeconds;
|
|
1906
2089
|
let uptimeStr = "";
|
|
1907
2090
|
if (uptimeSec != null) {
|
|
@@ -1910,41 +2093,268 @@ program
|
|
|
1910
2093
|
const m = Math.floor((uptimeSec % 3600) / 60);
|
|
1911
2094
|
uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
1912
2095
|
}
|
|
1913
|
-
// Format last write as relative time
|
|
1914
|
-
let lastWriteStr = "";
|
|
1915
|
-
if (healthData?.lastWrite) {
|
|
1916
|
-
const ago = Date.now() - new Date(healthData.lastWrite).getTime();
|
|
1917
|
-
const mins = Math.floor(ago / 60000);
|
|
1918
|
-
const hrs = Math.floor(ago / 3600000);
|
|
1919
|
-
const days = Math.floor(ago / 86400000);
|
|
1920
|
-
lastWriteStr = days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
|
|
1921
|
-
}
|
|
1922
2096
|
const pid = healthData?.pid ?? "";
|
|
1923
2097
|
const agents = healthData?.agents;
|
|
1924
2098
|
const memories = healthData?.memories;
|
|
1925
|
-
|
|
2099
|
+
const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
|
|
2100
|
+
const hasWarn = warnings.some((w) => w.level === "warn");
|
|
2101
|
+
// Header state-word stays "running" whenever the process is alive; the
|
|
2102
|
+
// icon conveys health (🟢 clean / 🟡 warnings). 🔴 unreachable is already
|
|
2103
|
+
// handled above by the `!healthy` early-exit. Decoupling state from
|
|
2104
|
+
// health keeps the smoke-test `grep -q "running"` stable across tiers.
|
|
2105
|
+
const headerIcon = hasWarn ? "🟡" : "🟢";
|
|
2106
|
+
console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
|
|
1926
2107
|
console.log(` URL: ${baseUrl}`);
|
|
2108
|
+
if (warnings.length > 0) {
|
|
2109
|
+
console.log(`\n⚠ Warnings: ${warnings.length}`);
|
|
2110
|
+
for (const w of warnings)
|
|
2111
|
+
console.log(` • ${w.level} ${w.message}`);
|
|
2112
|
+
}
|
|
1927
2113
|
if (memories) {
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
const hashStr = memories.hashFallback > 0
|
|
1932
|
-
? `${memories.hashFallback} hash-fallback`
|
|
1933
|
-
: "";
|
|
2114
|
+
console.log("\nMemory:");
|
|
2115
|
+
const embStr = memories.withEmbeddings > 0 ? `${memories.withEmbeddings} embedded` : "";
|
|
2116
|
+
const hashStr = memories.hashFallback > 0 ? `${memories.hashFallback} hash` : "";
|
|
1934
2117
|
const detail = [embStr, hashStr].filter(Boolean).join(", ");
|
|
1935
|
-
console.log(`
|
|
2118
|
+
console.log(` Total: ${memories.total}${detail ? ` (${detail})` : ""}`);
|
|
2119
|
+
if (memories.modelCounts && typeof memories.modelCounts === "object") {
|
|
2120
|
+
const entries = Object.entries(memories.modelCounts)
|
|
2121
|
+
.filter(([, n]) => n > 0)
|
|
2122
|
+
.sort((a, b) => b[1] - a[1]);
|
|
2123
|
+
if (entries.length > 0) {
|
|
2124
|
+
const formatted = entries.map(([k, n]) => `${k}: ${n}`).join(", ");
|
|
2125
|
+
console.log(` Embeddings: ${formatted}`);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
if (memories.byDurability) {
|
|
2129
|
+
const d = memories.byDurability;
|
|
2130
|
+
console.log(` Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
|
|
2131
|
+
}
|
|
2132
|
+
if (typeof memories.archived === "number")
|
|
2133
|
+
console.log(` Archived: ${memories.archived}`);
|
|
2134
|
+
if (typeof memories.expired === "number" && memories.expired > 0)
|
|
2135
|
+
console.log(` Expired: ${memories.expired}`);
|
|
2136
|
+
if (healthData?.lastWrite)
|
|
2137
|
+
console.log(` Last write: ${relativeTime(healthData.lastWrite)}`);
|
|
2138
|
+
}
|
|
2139
|
+
if (agents && agents.count > 0) {
|
|
2140
|
+
console.log("\nAgents:");
|
|
2141
|
+
const nameStr = agents.names?.length > 0 ? ` — ${agents.names.join(", ")}` : "";
|
|
2142
|
+
console.log(` ${agents.count} total${nameStr}`);
|
|
2143
|
+
if (agents.count > 1 && Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
|
|
2144
|
+
const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
|
|
2145
|
+
// Older HealthDetail responses only carry id / memoryCount / lastWriteAt.
|
|
2146
|
+
// Only print the richer columns if at least one row supplies them.
|
|
2147
|
+
const hasDeep = agents.perAgent.some((r) => typeof r.hashFallback === "number" || typeof r.writes24h === "number");
|
|
2148
|
+
if (hasDeep) {
|
|
2149
|
+
console.log(` ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
|
|
2150
|
+
for (const r of agents.perAgent) {
|
|
2151
|
+
const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "—";
|
|
2152
|
+
const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
|
|
2153
|
+
console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
else {
|
|
2157
|
+
console.log(` ${"id".padEnd(idW)} memories last_write`);
|
|
2158
|
+
for (const r of agents.perAgent) {
|
|
2159
|
+
console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${relativeTime(r.lastWriteAt)}`);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
1936
2163
|
}
|
|
1937
|
-
if (
|
|
1938
|
-
const
|
|
1939
|
-
console.log(
|
|
2164
|
+
if (healthData?.relationships) {
|
|
2165
|
+
const r = healthData.relationships;
|
|
2166
|
+
console.log("\nRelationships:");
|
|
2167
|
+
console.log(` ${r.total} total (${r.active} active)`);
|
|
2168
|
+
}
|
|
2169
|
+
if (healthData?.soul && healthData.soul.total > 0) {
|
|
2170
|
+
const s = healthData.soul;
|
|
2171
|
+
const bp = s.byPriority ?? {};
|
|
2172
|
+
console.log("\nSoul:");
|
|
2173
|
+
console.log(` ${s.total} entries — ${bp.critical ?? 0} critical / ${bp.high ?? 0} high / ${bp.standard ?? 0} standard / ${bp.low ?? 0} low`);
|
|
2174
|
+
}
|
|
2175
|
+
else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
|
|
2176
|
+
console.log("\nSoul:");
|
|
2177
|
+
console.log(` ${healthData.soulEntries} entries`);
|
|
2178
|
+
}
|
|
2179
|
+
if (healthData?.rem) {
|
|
2180
|
+
const r = healthData.rem;
|
|
2181
|
+
console.log("\nREM:");
|
|
2182
|
+
if (r.lastLightAt)
|
|
2183
|
+
console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
|
|
2184
|
+
if (r.lastRapidAt)
|
|
2185
|
+
console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
|
|
2186
|
+
if (r.lastRestorativeAt)
|
|
2187
|
+
console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
|
|
2188
|
+
const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
|
|
2189
|
+
console.log(` Nightly: ${nightly}`);
|
|
2190
|
+
if (r.nightlyEnabled && r.lastNightlyAt)
|
|
2191
|
+
console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)}`);
|
|
2192
|
+
if (typeof r.pendingCandidates === "number" && r.pendingCandidates > 0) {
|
|
2193
|
+
console.log(` Pending candidates: ${r.pendingCandidates}`);
|
|
2194
|
+
}
|
|
1940
2195
|
}
|
|
1941
|
-
if (healthData?.
|
|
1942
|
-
|
|
2196
|
+
if (healthData?.federation) {
|
|
2197
|
+
const f = healthData.federation;
|
|
2198
|
+
console.log("\nFederation:");
|
|
2199
|
+
if (f.instance)
|
|
2200
|
+
console.log(` Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
|
|
2201
|
+
if (f.peers)
|
|
2202
|
+
console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
|
|
2203
|
+
if (f.pendingTokens > 0)
|
|
2204
|
+
console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
|
|
2205
|
+
}
|
|
2206
|
+
if (healthData?.oauth) {
|
|
2207
|
+
const lines = oauthSummaryLines(healthData.oauth);
|
|
2208
|
+
for (const line of lines)
|
|
2209
|
+
console.log(line);
|
|
2210
|
+
}
|
|
2211
|
+
if (healthData?.bridges) {
|
|
2212
|
+
const b = healthData.bridges;
|
|
2213
|
+
console.log("\nBridges:");
|
|
2214
|
+
if (Array.isArray(b.installed) && b.installed.length > 0)
|
|
2215
|
+
console.log(` Installed: ${b.installed.join(", ")}`);
|
|
2216
|
+
if (b.lastImport)
|
|
2217
|
+
console.log(` Last import: ${relativeTime(b.lastImport)}`);
|
|
2218
|
+
if (b.lastExport)
|
|
2219
|
+
console.log(` Last export: ${relativeTime(b.lastExport)}`);
|
|
2220
|
+
}
|
|
2221
|
+
if (healthData?.disk) {
|
|
2222
|
+
const d = healthData.disk;
|
|
2223
|
+
console.log("\nDisk:");
|
|
2224
|
+
console.log(` Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
|
|
2225
|
+
console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
|
|
1943
2226
|
}
|
|
1944
|
-
|
|
1945
|
-
|
|
2227
|
+
console.log("");
|
|
2228
|
+
if (warnings.length > 0)
|
|
2229
|
+
console.log(` Health: ⚠ ${warnings.length} warning(s)`);
|
|
2230
|
+
else
|
|
2231
|
+
console.log(` Health: ✅ all checks passing`);
|
|
2232
|
+
});
|
|
2233
|
+
statusCmd
|
|
2234
|
+
.command("rem")
|
|
2235
|
+
.description("Show REM (memory hygiene) subsystem status")
|
|
2236
|
+
.action(async function () {
|
|
2237
|
+
const opts = this.optsWithGlobals();
|
|
2238
|
+
const { healthy, healthData } = await fetchHealthDetail(opts);
|
|
2239
|
+
if (opts.json) {
|
|
2240
|
+
console.log(JSON.stringify({ healthy, rem: healthData?.rem ?? null }, null, 2));
|
|
2241
|
+
if (!healthy)
|
|
2242
|
+
process.exit(1);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (!healthy) {
|
|
2246
|
+
console.log("🔴 unreachable");
|
|
2247
|
+
process.exit(1);
|
|
2248
|
+
}
|
|
2249
|
+
const r = healthData?.rem;
|
|
2250
|
+
if (!r) {
|
|
2251
|
+
console.log("REM: not configured (no log entries or platform timers found)");
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
console.log("REM:");
|
|
2255
|
+
console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
|
|
2256
|
+
console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
|
|
2257
|
+
console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
|
|
2258
|
+
const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
|
|
2259
|
+
console.log(` Nightly: ${nightly}`);
|
|
2260
|
+
if (r.lastNightlyAt)
|
|
2261
|
+
console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
|
|
2262
|
+
if (typeof r.pendingCandidates === "number")
|
|
2263
|
+
console.log(` Pending candidates: ${r.pendingCandidates}`);
|
|
2264
|
+
else
|
|
2265
|
+
console.log(` Pending candidates: — (schema not available)`);
|
|
2266
|
+
});
|
|
2267
|
+
statusCmd
|
|
2268
|
+
.command("federation")
|
|
2269
|
+
.description("Show federation subsystem status")
|
|
2270
|
+
.action(async function () {
|
|
2271
|
+
const opts = this.optsWithGlobals();
|
|
2272
|
+
const { healthy, healthData } = await fetchHealthDetail(opts);
|
|
2273
|
+
if (opts.json) {
|
|
2274
|
+
console.log(JSON.stringify({ healthy, federation: healthData?.federation ?? null }, null, 2));
|
|
2275
|
+
if (!healthy)
|
|
2276
|
+
process.exit(1);
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
if (!healthy) {
|
|
2280
|
+
console.log("🔴 unreachable");
|
|
2281
|
+
process.exit(1);
|
|
2282
|
+
}
|
|
2283
|
+
const f = healthData?.federation;
|
|
2284
|
+
if (!f) {
|
|
2285
|
+
console.log("Federation: not configured");
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
console.log("Federation:");
|
|
2289
|
+
if (f.instance)
|
|
2290
|
+
console.log(` Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
|
|
2291
|
+
else
|
|
2292
|
+
console.log(" Instance: —");
|
|
2293
|
+
if (f.peers)
|
|
2294
|
+
console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
|
|
2295
|
+
if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
|
|
2296
|
+
console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
|
|
2297
|
+
if (Array.isArray(f.peerList) && f.peerList.length > 0) {
|
|
2298
|
+
const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
|
|
2299
|
+
console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
|
|
2300
|
+
for (const p of f.peerList) {
|
|
2301
|
+
console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "—").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
});
|
|
2305
|
+
statusCmd
|
|
2306
|
+
.command("auth")
|
|
2307
|
+
.description("Show OAuth / IdP subsystem status")
|
|
2308
|
+
.action(async function () {
|
|
2309
|
+
const opts = this.optsWithGlobals();
|
|
2310
|
+
const { healthy, healthData } = await fetchHealthDetail(opts);
|
|
2311
|
+
if (opts.json) {
|
|
2312
|
+
console.log(JSON.stringify({ healthy, oauth: healthData?.oauth ?? null }, null, 2));
|
|
2313
|
+
if (!healthy)
|
|
2314
|
+
process.exit(1);
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
if (!healthy) {
|
|
2318
|
+
console.log("🔴 unreachable");
|
|
2319
|
+
process.exit(1);
|
|
2320
|
+
}
|
|
2321
|
+
const o = healthData?.oauth;
|
|
2322
|
+
if (!o) {
|
|
2323
|
+
console.log("OAuth: not configured");
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
const lines = oauthDetailLines(o);
|
|
2327
|
+
for (const line of lines)
|
|
2328
|
+
console.log(line);
|
|
2329
|
+
});
|
|
2330
|
+
statusCmd
|
|
2331
|
+
.command("bridges")
|
|
2332
|
+
.description("Show memory bridges subsystem status")
|
|
2333
|
+
.action(async function () {
|
|
2334
|
+
const opts = this.optsWithGlobals();
|
|
2335
|
+
const { healthy, healthData } = await fetchHealthDetail(opts);
|
|
2336
|
+
if (opts.json) {
|
|
2337
|
+
console.log(JSON.stringify({ healthy, bridges: healthData?.bridges ?? null }, null, 2));
|
|
2338
|
+
if (!healthy)
|
|
2339
|
+
process.exit(1);
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
if (!healthy) {
|
|
2343
|
+
console.log("🔴 unreachable");
|
|
2344
|
+
process.exit(1);
|
|
2345
|
+
}
|
|
2346
|
+
const b = healthData?.bridges;
|
|
2347
|
+
if (!b) {
|
|
2348
|
+
console.log("Bridges: none installed (no flair-bridge-* packages found)");
|
|
2349
|
+
return;
|
|
1946
2350
|
}
|
|
1947
|
-
console.log(
|
|
2351
|
+
console.log("Bridges:");
|
|
2352
|
+
if (Array.isArray(b.installed) && b.installed.length > 0)
|
|
2353
|
+
console.log(` Installed: ${b.installed.join(", ")}`);
|
|
2354
|
+
if (b.lastImport)
|
|
2355
|
+
console.log(` Last import: ${relativeTime(b.lastImport)}`);
|
|
2356
|
+
if (b.lastExport)
|
|
2357
|
+
console.log(` Last export: ${relativeTime(b.lastExport)}`);
|
|
1948
2358
|
});
|
|
1949
2359
|
// ─── flair upgrade ────────────────────────────────────────────────────────────
|
|
1950
2360
|
program
|
|
@@ -2440,19 +2850,21 @@ program
|
|
|
2440
2850
|
failed++;
|
|
2441
2851
|
}
|
|
2442
2852
|
};
|
|
2443
|
-
// 1. Write a test memory via
|
|
2444
|
-
|
|
2853
|
+
// 1. Write a test memory via PUT /Memory/<id>.
|
|
2854
|
+
// Schema only exposes PUT — POST returns 'Memory does not have a post method implemented'.
|
|
2855
|
+
await check("Write test memory (PUT /Memory/<id>)", async () => {
|
|
2856
|
+
const id = `flair-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2445
2857
|
const body = {
|
|
2858
|
+
id,
|
|
2446
2859
|
content: "flair test \u2014 this will be deleted",
|
|
2447
2860
|
durability: "ephemeral",
|
|
2448
2861
|
createdAt: new Date().toISOString(),
|
|
2449
2862
|
};
|
|
2450
2863
|
if (agentId)
|
|
2451
2864
|
body.agentId = agentId;
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
return !!memoryId || result?.ok === true;
|
|
2865
|
+
await api("PUT", `/Memory/${id}`, body);
|
|
2866
|
+
memoryId = id;
|
|
2867
|
+
return true;
|
|
2456
2868
|
});
|
|
2457
2869
|
// 2. Search for the test memory via POST /SemanticSearch
|
|
2458
2870
|
await check("Search for test memory (POST /SemanticSearch)", async () => {
|
|
@@ -2477,6 +2889,80 @@ program
|
|
|
2477
2889
|
if (failed > 0)
|
|
2478
2890
|
process.exit(1);
|
|
2479
2891
|
});
|
|
2892
|
+
// ─── flair deploy ─────────────────────────────────────────────────────────────
|
|
2893
|
+
program
|
|
2894
|
+
.command("deploy")
|
|
2895
|
+
.description("Deploy Flair as a component to a remote Harper Fabric cluster")
|
|
2896
|
+
.option("--fabric-org <org>", "Fabric org (env: FABRIC_ORG)")
|
|
2897
|
+
.option("--fabric-cluster <cluster>", "Fabric cluster within the org (env: FABRIC_CLUSTER)")
|
|
2898
|
+
.option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER)")
|
|
2899
|
+
.option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD)")
|
|
2900
|
+
.option("--fabric-token <token>", "OAuth bearer token (env: FABRIC_TOKEN) — reserved for future Fabric bearer support")
|
|
2901
|
+
.option("--target <url>", "Override the Fabric URL template (https://<cluster>.<org>.harperfabric.com)")
|
|
2902
|
+
.option("--project <name>", "Component name in Fabric", "flair")
|
|
2903
|
+
.option("--pkg-version <semver>", "Override version label (default: installed package version)")
|
|
2904
|
+
.option("--no-replicated", "Disable cluster-wide replication (default: replicated=true)")
|
|
2905
|
+
.option("--no-restart", "Do not restart the component after deploy (default: restart=true)")
|
|
2906
|
+
.option("--dry-run", "Resolve package, validate args, skip the deploy call")
|
|
2907
|
+
.option("--package-root <dir>", "Override package root (mainly for testing)")
|
|
2908
|
+
.action(async (opts) => {
|
|
2909
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
2910
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
2911
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
2912
|
+
const deployOpts = {
|
|
2913
|
+
fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
|
|
2914
|
+
fabricCluster: opts.fabricCluster ?? process.env.FABRIC_CLUSTER,
|
|
2915
|
+
fabricUser: opts.fabricUser ?? process.env.FABRIC_USER,
|
|
2916
|
+
fabricPassword: opts.fabricPassword ?? process.env.FABRIC_PASSWORD,
|
|
2917
|
+
fabricToken: opts.fabricToken ?? process.env.FABRIC_TOKEN,
|
|
2918
|
+
target: opts.target,
|
|
2919
|
+
project: opts.project,
|
|
2920
|
+
version: opts.pkgVersion,
|
|
2921
|
+
replicated: opts.replicated !== false,
|
|
2922
|
+
restart: opts.restart !== false,
|
|
2923
|
+
dryRun: opts.dryRun ?? false,
|
|
2924
|
+
packageRoot: opts.packageRoot,
|
|
2925
|
+
};
|
|
2926
|
+
const errors = validateDeployOptions(deployOpts);
|
|
2927
|
+
if (errors.length) {
|
|
2928
|
+
console.error(red("flair deploy: missing required options"));
|
|
2929
|
+
for (const e of errors)
|
|
2930
|
+
console.error(` - ${e}`);
|
|
2931
|
+
process.exit(1);
|
|
2932
|
+
}
|
|
2933
|
+
// Warn on password-via-flag (leaks to shell history). Env is preferred.
|
|
2934
|
+
if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
|
|
2935
|
+
console.error(dim("warning: --fabric-password leaks to shell history. " +
|
|
2936
|
+
"Prefer FABRIC_PASSWORD env."));
|
|
2937
|
+
}
|
|
2938
|
+
const url = buildDeployUrl(deployOpts);
|
|
2939
|
+
console.log(`${green("→")} Deploying ${deployOpts.project} to ${url}`);
|
|
2940
|
+
if (deployOpts.dryRun)
|
|
2941
|
+
console.log(dim(" (dry-run: skipping API call)"));
|
|
2942
|
+
try {
|
|
2943
|
+
const result = await deployToFabric(deployOpts);
|
|
2944
|
+
if (result.dryRun) {
|
|
2945
|
+
console.log(`${green("✓")} dry-run OK: ${result.project} ${result.version} ready to deploy to ${result.url}`);
|
|
2946
|
+
console.log(dim(` package root: ${result.packageRoot}`));
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
console.log(`\n${green("✓")} Flair ${result.version} deployed`);
|
|
2950
|
+
console.log(`\n URL: ${result.url}`);
|
|
2951
|
+
console.log(` Project: ${result.project}`);
|
|
2952
|
+
console.log(`\nNext steps:`);
|
|
2953
|
+
console.log(dim(` 1. Set an admin password in Fabric Studio (Cluster Settings → Admin)`));
|
|
2954
|
+
console.log(dim(` 2. Seed your first agent:`));
|
|
2955
|
+
console.log(` flair agent add --remote ${result.url} --name my-agent`);
|
|
2956
|
+
}
|
|
2957
|
+
catch (err) {
|
|
2958
|
+
console.error(red(`\n✗ deploy failed: ${err.message}`));
|
|
2959
|
+
const hint = err.message?.toLowerCase();
|
|
2960
|
+
if (hint?.includes("401") || hint?.includes("unauthoriz")) {
|
|
2961
|
+
console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
|
|
2962
|
+
}
|
|
2963
|
+
process.exit(1);
|
|
2964
|
+
}
|
|
2965
|
+
});
|
|
2480
2966
|
// ─── flair doctor ─────────────────────────────────────────────────────────────
|
|
2481
2967
|
program
|
|
2482
2968
|
.command("doctor")
|
|
@@ -2730,23 +3216,81 @@ program
|
|
|
2730
3216
|
});
|
|
2731
3217
|
// ─── Memory and Soul commands ────────────────────────────────────────────────
|
|
2732
3218
|
const memory = program.command("memory").description("Manage agent memories");
|
|
2733
|
-
memory.command("add").requiredOption("--agent <id>")
|
|
3219
|
+
memory.command("add [content]").requiredOption("--agent <id>")
|
|
3220
|
+
.option("--content <text>", "memory content (alias for positional arg)")
|
|
2734
3221
|
.option("--durability <d>", "standard").option("--tags <csv>")
|
|
2735
|
-
.action(async (opts) => {
|
|
3222
|
+
.action(async (contentArg, opts) => {
|
|
3223
|
+
const content = contentArg ?? opts.content;
|
|
3224
|
+
if (!content) {
|
|
3225
|
+
console.error("error: content required (positional arg or --content)");
|
|
3226
|
+
process.exit(1);
|
|
3227
|
+
}
|
|
2736
3228
|
const memId = `${opts.agent}-${Date.now()}`;
|
|
2737
3229
|
const out = await api("PUT", `/Memory/${memId}`, {
|
|
2738
|
-
id: memId, agentId: opts.agent, content
|
|
3230
|
+
id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
|
|
2739
3231
|
tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
|
|
2740
3232
|
type: "memory", createdAt: new Date().toISOString(),
|
|
2741
3233
|
});
|
|
2742
3234
|
console.log(JSON.stringify(out, null, 2));
|
|
2743
3235
|
});
|
|
2744
|
-
memory.command("search").requiredOption("--agent <id>")
|
|
2745
|
-
.
|
|
2746
|
-
|
|
3236
|
+
memory.command("search [query]").requiredOption("--agent <id>")
|
|
3237
|
+
.option("--q <query>", "search query (alias for positional arg)")
|
|
3238
|
+
.option("--limit <n>", "Max results", "5")
|
|
3239
|
+
.option("--tag <tag>")
|
|
3240
|
+
.action(async (queryArg, opts) => {
|
|
3241
|
+
const q = queryArg ?? opts.q;
|
|
3242
|
+
if (!q) {
|
|
3243
|
+
console.error("error: query required (positional arg or --q)");
|
|
3244
|
+
process.exit(1);
|
|
3245
|
+
}
|
|
3246
|
+
const body = { agentId: opts.agent, q, limit: parseInt(opts.limit, 10) || 5 };
|
|
3247
|
+
if (opts.tag)
|
|
3248
|
+
body.tag = opts.tag;
|
|
3249
|
+
console.log(JSON.stringify(await api("POST", "/SemanticSearch", body), null, 2));
|
|
3250
|
+
});
|
|
3251
|
+
memory.command("list")
|
|
3252
|
+
.requiredOption("--agent <id>")
|
|
3253
|
+
.option("--tag <tag>")
|
|
3254
|
+
.option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
|
|
3255
|
+
.option("--limit <n>", "Max rows when using --hash-fallback", "50")
|
|
2747
3256
|
.action(async (opts) => {
|
|
2748
3257
|
const q = new URLSearchParams({ agentId: opts.agent, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
|
|
2749
|
-
|
|
3258
|
+
const raw = await api("GET", `/Memory?${q}`);
|
|
3259
|
+
if (!opts.hashFallback) {
|
|
3260
|
+
console.log(JSON.stringify(raw, null, 2));
|
|
3261
|
+
return;
|
|
3262
|
+
}
|
|
3263
|
+
// --hash-fallback: filter to entries without a real embedding and print as a table.
|
|
3264
|
+
// Same predicate HealthDetail uses: missing model or the "hash-512d" marker.
|
|
3265
|
+
const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
3266
|
+
const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
|
|
3267
|
+
if (fallback.length === 0) {
|
|
3268
|
+
console.log(`No hash-fallback memories for agent ${opts.agent}. All embedded.`);
|
|
3269
|
+
return;
|
|
3270
|
+
}
|
|
3271
|
+
const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
|
|
3272
|
+
const rows = fallback
|
|
3273
|
+
.slice()
|
|
3274
|
+
.sort((a, b) => {
|
|
3275
|
+
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
3276
|
+
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
3277
|
+
return tb - ta;
|
|
3278
|
+
})
|
|
3279
|
+
.slice(0, limit);
|
|
3280
|
+
const idW = Math.max(2, ...rows.map((r) => String(r.id ?? "").length));
|
|
3281
|
+
console.log(`${fallback.length} hash-fallback memories for agent ${opts.agent} (showing ${rows.length}):\n`);
|
|
3282
|
+
console.log(` ${"id".padEnd(idW)} created_at preview`);
|
|
3283
|
+
for (const r of rows) {
|
|
3284
|
+
const created = r.createdAt ? String(r.createdAt).slice(0, 19).replace("T", " ") : "—".padEnd(19);
|
|
3285
|
+
const preview = String(r.content ?? "").replace(/\s+/g, " ").slice(0, 80);
|
|
3286
|
+
console.log(` ${String(r.id ?? "").padEnd(idW)} ${created} ${preview}`);
|
|
3287
|
+
}
|
|
3288
|
+
if (fallback.length > rows.length) {
|
|
3289
|
+
console.log(`\n... ${fallback.length - rows.length} more (raise with --limit). To backfill: flair reembed --agent ${opts.agent} --stale-only`);
|
|
3290
|
+
}
|
|
3291
|
+
else {
|
|
3292
|
+
console.log(`\nTo backfill: flair reembed --agent ${opts.agent} --stale-only`);
|
|
3293
|
+
}
|
|
2750
3294
|
});
|
|
2751
3295
|
// ─── flair search (top-level shortcut) ───────────────────────────────────────
|
|
2752
3296
|
program
|
|
@@ -2843,6 +3387,320 @@ soul.command("set").requiredOption("--agent <id>").requiredOption("--key <key>")
|
|
|
2843
3387
|
soul.command("get").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Soul/${id}`), null, 2)));
|
|
2844
3388
|
soul.command("list").requiredOption("--agent <id>")
|
|
2845
3389
|
.action(async (opts) => console.log(JSON.stringify(await api("GET", `/Soul?agentId=${encodeURIComponent(opts.agent)}`), null, 2)));
|
|
3390
|
+
// ─── flair bridge ────────────────────────────────────────────────────────────
|
|
3391
|
+
// Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
|
|
3392
|
+
// + agentic-stack reference adapter as a built-in.
|
|
3393
|
+
// `test` and `export` are still stubbed; Shape B (npm code plugins) too.
|
|
3394
|
+
// See specs/FLAIR-BRIDGES.md.
|
|
3395
|
+
const bridge = program.command("bridge").description("Manage memory bridges (import/export between Flair and foreign systems)");
|
|
3396
|
+
bridge
|
|
3397
|
+
.command("list")
|
|
3398
|
+
.description("List installed bridges across project YAML, user YAML, npm packages, and built-ins")
|
|
3399
|
+
.option("--json", "Output as JSON")
|
|
3400
|
+
.action(async (opts) => {
|
|
3401
|
+
const { discover } = await import("./bridges/discover.js");
|
|
3402
|
+
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3403
|
+
const found = await discover({ builtins: builtinDiscoveryRecords() });
|
|
3404
|
+
if (opts.json) {
|
|
3405
|
+
console.log(JSON.stringify(found, null, 2));
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
if (found.length === 0) {
|
|
3409
|
+
console.log("No bridges installed.");
|
|
3410
|
+
console.log("Add one with: flair bridge scaffold <name> --file");
|
|
3411
|
+
console.log("Or install from npm: npm install flair-bridge-<name>");
|
|
3412
|
+
return;
|
|
3413
|
+
}
|
|
3414
|
+
const nameW = Math.max(4, ...found.map((b) => b.name.length));
|
|
3415
|
+
const kindW = Math.max(4, ...found.map((b) => b.kind.length));
|
|
3416
|
+
const srcW = Math.max(6, ...found.map((b) => b.source.length));
|
|
3417
|
+
console.log(` ${"name".padEnd(nameW)} ${"kind".padEnd(kindW)} ${"source".padEnd(srcW)} description`);
|
|
3418
|
+
for (const b of found) {
|
|
3419
|
+
const desc = b.description ?? "";
|
|
3420
|
+
console.log(` ${b.name.padEnd(nameW)} ${b.kind.padEnd(kindW)} ${b.source.padEnd(srcW)} ${desc}`);
|
|
3421
|
+
}
|
|
3422
|
+
});
|
|
3423
|
+
bridge
|
|
3424
|
+
.command("scaffold <name>")
|
|
3425
|
+
.description("Emit starter files for a new bridge. Choose --file (YAML, declarative) or --api (TS code plugin)")
|
|
3426
|
+
.option("--file", "YAML file-format bridge (shape A)")
|
|
3427
|
+
.option("--api", "TypeScript API bridge (shape B)")
|
|
3428
|
+
.option("--force", "Overwrite existing files")
|
|
3429
|
+
.action(async (name, opts) => {
|
|
3430
|
+
if (opts.file && opts.api) {
|
|
3431
|
+
console.error("Pick one: --file or --api.");
|
|
3432
|
+
process.exit(1);
|
|
3433
|
+
}
|
|
3434
|
+
const { BUILTIN_BY_NAME } = await import("./bridges/builtins/index.js");
|
|
3435
|
+
if (BUILTIN_BY_NAME.has(name)) {
|
|
3436
|
+
console.error(`"${name}" is a built-in bridge name and can't be scaffolded — pick a different name.`);
|
|
3437
|
+
process.exit(1);
|
|
3438
|
+
}
|
|
3439
|
+
const kind = opts.api ? "api" : "file"; // --file is default
|
|
3440
|
+
const { scaffold } = await import("./bridges/scaffold.js");
|
|
3441
|
+
try {
|
|
3442
|
+
const result = await scaffold({ name, kind, force: !!opts.force });
|
|
3443
|
+
if (result.createdFiles.length > 0) {
|
|
3444
|
+
console.log(`Created ${result.createdFiles.length} file(s):`);
|
|
3445
|
+
for (const p of result.createdFiles)
|
|
3446
|
+
console.log(` + ${p}`);
|
|
3447
|
+
}
|
|
3448
|
+
if (result.skippedFiles.length > 0) {
|
|
3449
|
+
console.log(`Skipped ${result.skippedFiles.length} existing file(s) (pass --force to overwrite):`);
|
|
3450
|
+
for (const p of result.skippedFiles)
|
|
3451
|
+
console.log(` · ${p}`);
|
|
3452
|
+
}
|
|
3453
|
+
console.log(`\n${result.summary}`);
|
|
3454
|
+
}
|
|
3455
|
+
catch (err) {
|
|
3456
|
+
console.error(`Scaffold failed: ${err.message}`);
|
|
3457
|
+
process.exit(1);
|
|
3458
|
+
}
|
|
3459
|
+
});
|
|
3460
|
+
bridge
|
|
3461
|
+
.command("import <name> [src]")
|
|
3462
|
+
.description("Import memories from a foreign system into Flair via a bridge (Shape A YAML / built-in)")
|
|
3463
|
+
.option("--agent <id>", "Default agent ID for memories that don't carry one (or set FLAIR_AGENT_ID)")
|
|
3464
|
+
.option("--cwd <dir>", "Filesystem root the descriptor's relative paths resolve against (default: cwd)")
|
|
3465
|
+
.option("--dry-run", "Validate + count, don't write to Flair")
|
|
3466
|
+
.option("--port <port>", "Harper HTTP port")
|
|
3467
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
3468
|
+
.option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
|
|
3469
|
+
.action(async (name, srcArg, opts) => {
|
|
3470
|
+
const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
|
|
3471
|
+
const cwd = opts.cwd ?? srcArg ?? process.cwd();
|
|
3472
|
+
const { discover } = await import("./bridges/discover.js");
|
|
3473
|
+
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3474
|
+
const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
|
|
3475
|
+
const { runImport } = await import("./bridges/runtime/import-runner.js");
|
|
3476
|
+
const { makeContext } = await import("./bridges/runtime/context.js");
|
|
3477
|
+
const { BridgeRuntimeError } = await import("./bridges/types.js");
|
|
3478
|
+
const found = await discover({ builtins: builtinDiscoveryRecords() });
|
|
3479
|
+
const target = found.find((b) => b.name === name);
|
|
3480
|
+
if (!target) {
|
|
3481
|
+
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
3482
|
+
process.exit(1);
|
|
3483
|
+
}
|
|
3484
|
+
let descriptor;
|
|
3485
|
+
try {
|
|
3486
|
+
descriptor = await loadDescriptor(target);
|
|
3487
|
+
}
|
|
3488
|
+
catch (err) {
|
|
3489
|
+
printBridgeError(err);
|
|
3490
|
+
process.exit(1);
|
|
3491
|
+
}
|
|
3492
|
+
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
3493
|
+
const ctx = makeContext({ bridge: name });
|
|
3494
|
+
// Memory POST: Ed25519-signed when an agent key is available, fall back
|
|
3495
|
+
// to the shared `api()` helper otherwise. Mirrors how `flair memory add`
|
|
3496
|
+
// works (see the `memory.command("add")` handler above).
|
|
3497
|
+
const putMemory = async (body) => {
|
|
3498
|
+
const headers = { "content-type": "application/json" };
|
|
3499
|
+
const keyPath = opts.key ?? resolveKeyPath(body.agentId);
|
|
3500
|
+
if (keyPath) {
|
|
3501
|
+
headers["authorization"] = buildEd25519Auth(body.agentId, "PUT", `/Memory/${body.id}`, keyPath);
|
|
3502
|
+
}
|
|
3503
|
+
const res = await fetch(`${baseUrl}/Memory/${encodeURIComponent(body.id)}`, {
|
|
3504
|
+
method: "PUT",
|
|
3505
|
+
headers,
|
|
3506
|
+
body: JSON.stringify(body),
|
|
3507
|
+
});
|
|
3508
|
+
if (!res.ok) {
|
|
3509
|
+
const text = await res.text().catch(() => "");
|
|
3510
|
+
throw new Error(`PUT /Memory/${body.id} → ${res.status}: ${text || res.statusText}`);
|
|
3511
|
+
}
|
|
3512
|
+
};
|
|
3513
|
+
let lastReportedAt = Date.now();
|
|
3514
|
+
let lastReportedOrdinal = 0;
|
|
3515
|
+
const onProgress = (ev) => {
|
|
3516
|
+
// Throttle in-progress chatter to at most one line every 2s + the
|
|
3517
|
+
// final summary. Avoids flooding stdout for big imports.
|
|
3518
|
+
if (ev.type === "done") {
|
|
3519
|
+
const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
|
|
3520
|
+
if (opts.dryRun) {
|
|
3521
|
+
console.log(`\n${descriptor.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
|
|
3522
|
+
}
|
|
3523
|
+
else {
|
|
3524
|
+
console.log(`\n${descriptor.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
|
|
3525
|
+
}
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
const now = Date.now();
|
|
3529
|
+
if (now - lastReportedAt < 2000 && ev.ordinal - lastReportedOrdinal < 25)
|
|
3530
|
+
return;
|
|
3531
|
+
lastReportedAt = now;
|
|
3532
|
+
lastReportedOrdinal = ev.ordinal;
|
|
3533
|
+
if (ev.type === "memory-imported") {
|
|
3534
|
+
process.stdout.write(`\r ${ev.ordinal} imported (${ev.foreignId ?? ev.flairId})`.padEnd(80));
|
|
3535
|
+
}
|
|
3536
|
+
else if (ev.type === "memory-skipped") {
|
|
3537
|
+
process.stdout.write(`\r ${ev.ordinal} skipped (${ev.reason})`.padEnd(80));
|
|
3538
|
+
}
|
|
3539
|
+
};
|
|
3540
|
+
try {
|
|
3541
|
+
await runImport({
|
|
3542
|
+
descriptor,
|
|
3543
|
+
cwd,
|
|
3544
|
+
agentId,
|
|
3545
|
+
dryRun: !!opts.dryRun,
|
|
3546
|
+
putMemory,
|
|
3547
|
+
onProgress,
|
|
3548
|
+
ctx,
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
3551
|
+
catch (err) {
|
|
3552
|
+
if (err instanceof BridgeRuntimeError) {
|
|
3553
|
+
printBridgeError(err);
|
|
3554
|
+
process.exit(1);
|
|
3555
|
+
}
|
|
3556
|
+
console.error(`Bridge import failed: ${err?.message ?? err}`);
|
|
3557
|
+
process.exit(1);
|
|
3558
|
+
}
|
|
3559
|
+
});
|
|
3560
|
+
bridge
|
|
3561
|
+
.command("export <name> <dst>")
|
|
3562
|
+
.description("Export memories from Flair to a foreign system via a bridge (Shape A YAML / built-in)")
|
|
3563
|
+
.requiredOption("--agent <id>", "Agent ID to export memories for (or set FLAIR_AGENT_ID)")
|
|
3564
|
+
.option("--source <tag>", "Filter to memories with a matching `source:` tag (typical for round-tripping a single bridge's data)")
|
|
3565
|
+
.option("--subject <subj>", "Filter to memories with a matching `subject:` tag")
|
|
3566
|
+
.option("--since <iso>", "Only memories with createdAt >= this ISO-8601 timestamp")
|
|
3567
|
+
.option("--cwd <dir>", "Filesystem root the descriptor's relative target paths resolve against (default: cwd)")
|
|
3568
|
+
.option("--dry-run", "Validate + count + apply maps, don't write to the target")
|
|
3569
|
+
.option("--port <port>", "Harper HTTP port")
|
|
3570
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
3571
|
+
.option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
|
|
3572
|
+
.action(async (name, dst, opts) => {
|
|
3573
|
+
const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
|
|
3574
|
+
if (!agentId) {
|
|
3575
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
3576
|
+
process.exit(1);
|
|
3577
|
+
}
|
|
3578
|
+
const cwd = opts.cwd ?? dst;
|
|
3579
|
+
const { discover } = await import("./bridges/discover.js");
|
|
3580
|
+
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3581
|
+
const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
|
|
3582
|
+
const { runExport } = await import("./bridges/runtime/export-runner.js");
|
|
3583
|
+
const { makeContext } = await import("./bridges/runtime/context.js");
|
|
3584
|
+
const { BridgeRuntimeError } = await import("./bridges/types.js");
|
|
3585
|
+
const found = await discover({ builtins: builtinDiscoveryRecords() });
|
|
3586
|
+
const target = found.find((b) => b.name === name);
|
|
3587
|
+
if (!target) {
|
|
3588
|
+
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
3589
|
+
process.exit(1);
|
|
3590
|
+
}
|
|
3591
|
+
let descriptor;
|
|
3592
|
+
try {
|
|
3593
|
+
descriptor = await loadDescriptor(target);
|
|
3594
|
+
}
|
|
3595
|
+
catch (err) {
|
|
3596
|
+
printBridgeError(err);
|
|
3597
|
+
process.exit(1);
|
|
3598
|
+
}
|
|
3599
|
+
if (!descriptor.export) {
|
|
3600
|
+
console.error(`Bridge "${name}" has no export block — cannot export through it.`);
|
|
3601
|
+
process.exit(1);
|
|
3602
|
+
}
|
|
3603
|
+
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
3604
|
+
const ctx = makeContext({ bridge: name });
|
|
3605
|
+
// Memory fetcher — paginates GET /Memory?agentId=... applying any
|
|
3606
|
+
// descriptor + caller-side filters in memory. Slice 3a does the
|
|
3607
|
+
// simplest thing: one round trip, no streaming. Slice 3b can move
|
|
3608
|
+
// to cursor-paginated streaming if real corpora warrant it.
|
|
3609
|
+
const fetchMemories = async function* (filters) {
|
|
3610
|
+
const params = new URLSearchParams({ agentId });
|
|
3611
|
+
if (opts.subject)
|
|
3612
|
+
params.set("subject", opts.subject);
|
|
3613
|
+
const headers = { "content-type": "application/json" };
|
|
3614
|
+
const keyPath = opts.key ?? resolveKeyPath(agentId);
|
|
3615
|
+
const path = `/Memory?${params.toString()}`;
|
|
3616
|
+
if (keyPath)
|
|
3617
|
+
headers["authorization"] = buildEd25519Auth(agentId, "GET", path, keyPath);
|
|
3618
|
+
const res = await fetch(`${baseUrl}${path}`, { headers });
|
|
3619
|
+
if (!res.ok) {
|
|
3620
|
+
const text = await res.text().catch(() => "");
|
|
3621
|
+
throw new Error(`GET /Memory → ${res.status}: ${text || res.statusText}`);
|
|
3622
|
+
}
|
|
3623
|
+
const raw = await res.json();
|
|
3624
|
+
const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
3625
|
+
const sourceFilter = opts.source;
|
|
3626
|
+
const sinceMs = opts.since ? new Date(opts.since).getTime() : null;
|
|
3627
|
+
for (const m of all) {
|
|
3628
|
+
if (sourceFilter && m.source !== sourceFilter)
|
|
3629
|
+
continue;
|
|
3630
|
+
if (sinceMs !== null && m.createdAt && new Date(m.createdAt).getTime() < sinceMs)
|
|
3631
|
+
continue;
|
|
3632
|
+
yield m;
|
|
3633
|
+
}
|
|
3634
|
+
void filters;
|
|
3635
|
+
};
|
|
3636
|
+
let lastReportedAt = Date.now();
|
|
3637
|
+
const onProgress = (ev) => {
|
|
3638
|
+
if (ev.type === "done") {
|
|
3639
|
+
if (opts.dryRun) {
|
|
3640
|
+
console.log(`\n${descriptor.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
|
|
3641
|
+
}
|
|
3642
|
+
else {
|
|
3643
|
+
console.log(`\n${descriptor.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
|
|
3644
|
+
}
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
if (ev.type === "target-write") {
|
|
3648
|
+
console.log(` ✓ ${ev.path} (${ev.written} record${ev.written === 1 ? "" : "s"})`);
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
if (ev.type === "target-skipped") {
|
|
3652
|
+
console.log(` · ${ev.path} skipped (${ev.reason})`);
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3655
|
+
// memory-skipped events throttled to one line every 2s
|
|
3656
|
+
const now = Date.now();
|
|
3657
|
+
if (now - lastReportedAt < 2000)
|
|
3658
|
+
return;
|
|
3659
|
+
lastReportedAt = now;
|
|
3660
|
+
process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
|
|
3661
|
+
};
|
|
3662
|
+
try {
|
|
3663
|
+
await runExport({
|
|
3664
|
+
descriptor,
|
|
3665
|
+
cwd,
|
|
3666
|
+
fetchMemories,
|
|
3667
|
+
filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
|
|
3668
|
+
dryRun: !!opts.dryRun,
|
|
3669
|
+
ctx,
|
|
3670
|
+
onProgress,
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
catch (err) {
|
|
3674
|
+
if (err instanceof BridgeRuntimeError) {
|
|
3675
|
+
printBridgeError(err);
|
|
3676
|
+
process.exit(1);
|
|
3677
|
+
}
|
|
3678
|
+
console.error(`Bridge export failed: ${err?.message ?? err}`);
|
|
3679
|
+
process.exit(1);
|
|
3680
|
+
}
|
|
3681
|
+
});
|
|
3682
|
+
// `test` still stubbed — round-trip harness lands in slice 3b.
|
|
3683
|
+
bridge
|
|
3684
|
+
.command("test <name> [args...]")
|
|
3685
|
+
.description("test a bridge — not yet implemented (slice 3b of FLAIR-BRIDGES)")
|
|
3686
|
+
.allowUnknownOption()
|
|
3687
|
+
.action(() => {
|
|
3688
|
+
console.error(`\`flair bridge test\` is not yet implemented — landing in slice 3b of FLAIR-BRIDGES.`);
|
|
3689
|
+
console.error(`Slice 3a ships export (Shape A YAML); the round-trip test harness pairs with it.`);
|
|
3690
|
+
process.exit(2);
|
|
3691
|
+
});
|
|
3692
|
+
function printBridgeError(err) {
|
|
3693
|
+
// Pretty-print BridgeRuntimeError as the structured shape from §10 of the
|
|
3694
|
+
// spec, plus a one-line human summary so the operator gets both.
|
|
3695
|
+
const detail = err?.detail;
|
|
3696
|
+
if (detail && typeof detail === "object") {
|
|
3697
|
+
console.error(`Bridge error: ${detail.hint ?? err.message}`);
|
|
3698
|
+
console.error(JSON.stringify(detail, null, 2));
|
|
3699
|
+
}
|
|
3700
|
+
else {
|
|
3701
|
+
console.error(`Bridge error: ${err.message ?? String(err)}`);
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
2846
3704
|
// ─── flair backup ────────────────────────────────────────────────────────────
|
|
2847
3705
|
program
|
|
2848
3706
|
.command("backup")
|
|
@@ -3252,110 +4110,6 @@ program
|
|
|
3252
4110
|
console.log(`Souls: ${(data.souls ?? []).length}`);
|
|
3253
4111
|
}
|
|
3254
4112
|
});
|
|
3255
|
-
// ─── flair migrate-keys ───────────────────────────────────────────────────────
|
|
3256
|
-
program
|
|
3257
|
-
.command("migrate-keys")
|
|
3258
|
-
.description("Migrate agent keys from old path (~/.tps/secrets/flair/) to ~/.flair/keys/")
|
|
3259
|
-
.option("--from <dir>", "Old keys directory", join(homedir(), ".tps", "secrets", "flair"))
|
|
3260
|
-
.option("--to <dir>", "New keys directory", defaultKeysDir())
|
|
3261
|
-
.option("--dry-run", "Show what would be migrated without copying")
|
|
3262
|
-
.option("--port <port>", "Harper HTTP port")
|
|
3263
|
-
.option("--ops-port <port>", "Harper operations API port")
|
|
3264
|
-
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
3265
|
-
.action(async (opts) => {
|
|
3266
|
-
const fromDir = opts.from;
|
|
3267
|
-
const toDir = opts.to;
|
|
3268
|
-
const dryRun = opts.dryRun ?? false;
|
|
3269
|
-
const opsPort = resolveOpsPort(opts);
|
|
3270
|
-
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
3271
|
-
if (!existsSync(fromDir)) {
|
|
3272
|
-
console.log(`Old keys directory not found: ${fromDir}`);
|
|
3273
|
-
console.log("Nothing to migrate.");
|
|
3274
|
-
process.exit(0);
|
|
3275
|
-
}
|
|
3276
|
-
// Discover legacy keys: <id>-priv.key pattern
|
|
3277
|
-
const { readdirSync, unlinkSync } = await import("node:fs");
|
|
3278
|
-
const files = readdirSync(fromDir);
|
|
3279
|
-
const keyFiles = files.filter((f) => f.endsWith("-priv.key"));
|
|
3280
|
-
if (keyFiles.length === 0) {
|
|
3281
|
-
console.log(`No legacy key files found in ${fromDir}`);
|
|
3282
|
-
process.exit(0);
|
|
3283
|
-
}
|
|
3284
|
-
console.log(`Found ${keyFiles.length} legacy key file(s) in ${fromDir}:`);
|
|
3285
|
-
let migrated = 0;
|
|
3286
|
-
let skipped = 0;
|
|
3287
|
-
for (const file of keyFiles) {
|
|
3288
|
-
const agentId = file.replace("-priv.key", "");
|
|
3289
|
-
const srcPath = join(fromDir, file);
|
|
3290
|
-
const destPath = join(toDir, `${agentId}.key`);
|
|
3291
|
-
if (existsSync(destPath)) {
|
|
3292
|
-
console.log(` skip: ${agentId} — already exists at ${destPath}`);
|
|
3293
|
-
skipped++;
|
|
3294
|
-
continue;
|
|
3295
|
-
}
|
|
3296
|
-
if (dryRun) {
|
|
3297
|
-
console.log(` would migrate: ${srcPath} → ${destPath}`);
|
|
3298
|
-
migrated++;
|
|
3299
|
-
continue;
|
|
3300
|
-
}
|
|
3301
|
-
mkdirSync(toDir, { recursive: true });
|
|
3302
|
-
const keyData = readFileSync(srcPath);
|
|
3303
|
-
writeFileSync(destPath, keyData);
|
|
3304
|
-
chmodSync(destPath, 0o600);
|
|
3305
|
-
console.log(` migrated: ${agentId} → ${destPath}`);
|
|
3306
|
-
migrated++;
|
|
3307
|
-
}
|
|
3308
|
-
if (dryRun) {
|
|
3309
|
-
console.log(`\n🔍 Dry run: ${migrated} key(s) would be migrated, ${skipped} skipped`);
|
|
3310
|
-
}
|
|
3311
|
-
else {
|
|
3312
|
-
console.log(`\n✅ Migration complete: ${migrated} migrated, ${skipped} skipped`);
|
|
3313
|
-
if (migrated > 0) {
|
|
3314
|
-
console.log(`\nOld keys preserved at ${fromDir} (delete manually when confirmed working).`);
|
|
3315
|
-
// Optionally verify keys match Flair records
|
|
3316
|
-
if (adminPass) {
|
|
3317
|
-
console.log("\nVerifying migrated keys against Flair...");
|
|
3318
|
-
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
3319
|
-
for (const file of keyFiles) {
|
|
3320
|
-
const agentId = file.replace("-priv.key", "");
|
|
3321
|
-
const destPath = join(toDir, `${agentId}.key`);
|
|
3322
|
-
if (!existsSync(destPath))
|
|
3323
|
-
continue;
|
|
3324
|
-
try {
|
|
3325
|
-
const keyB64 = readFileSync(destPath, "utf-8").trim();
|
|
3326
|
-
const seed = Buffer.from(keyB64, "base64");
|
|
3327
|
-
const kp = nacl.sign.keyPair.fromSeed(new Uint8Array(seed));
|
|
3328
|
-
const localPub = b64url(kp.publicKey);
|
|
3329
|
-
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
3330
|
-
method: "POST",
|
|
3331
|
-
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
3332
|
-
body: JSON.stringify({ operation: "search_by_value", database: "flair", table: "Agent", search_attribute: "id", search_value: agentId, get_attributes: ["id", "publicKey"] }),
|
|
3333
|
-
signal: AbortSignal.timeout(10_000),
|
|
3334
|
-
});
|
|
3335
|
-
if (res.ok) {
|
|
3336
|
-
const agents = await res.json();
|
|
3337
|
-
if (Array.isArray(agents) && agents.length > 0) {
|
|
3338
|
-
const remotePub = agents[0].publicKey;
|
|
3339
|
-
if (remotePub === localPub) {
|
|
3340
|
-
console.log(` ✅ ${agentId}: key matches Flair`);
|
|
3341
|
-
}
|
|
3342
|
-
else {
|
|
3343
|
-
console.log(` ⚠️ ${agentId}: key MISMATCH — local key doesn't match Flair record`);
|
|
3344
|
-
}
|
|
3345
|
-
}
|
|
3346
|
-
else {
|
|
3347
|
-
console.log(` ⚠️ ${agentId}: not found in Flair`);
|
|
3348
|
-
}
|
|
3349
|
-
}
|
|
3350
|
-
}
|
|
3351
|
-
catch (err) {
|
|
3352
|
-
console.log(` ⚠️ ${agentId}: verification failed — ${err.message}`);
|
|
3353
|
-
}
|
|
3354
|
-
}
|
|
3355
|
-
}
|
|
3356
|
-
}
|
|
3357
|
-
}
|
|
3358
|
-
});
|
|
3359
4113
|
// Run CLI only when this is the entry point (not when imported for testing)
|
|
3360
4114
|
if (import.meta.main) {
|
|
3361
4115
|
await program.parseAsync();
|