@tpsdev-ai/flair 0.49.0 → 0.51.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 +15 -4
- package/dist/bridges/runtime/roundtrip.js +91 -2
- package/dist/build-info.json +3 -3
- package/dist/cli.js +565 -171
- package/dist/deploy.js +20 -3
- package/dist/doctor-client.js +62 -34
- package/dist/federation/scheduler.js +24 -3
- package/dist/hook-install.js +45 -13
- package/dist/install/clients.js +28 -17
- package/dist/lib/doctor-run.js +481 -0
- package/dist/lib/launchd-management.js +7 -26
- package/dist/lib/scheduler-platform.js +132 -10
- package/dist/lib/scratch-owner.js +49 -0
- package/dist/rem/scheduler.js +23 -5
- package/dist/resources/Federation.js +42 -20
- package/dist/resources/MemoryBootstrap.js +8 -4
- package/dist/resources/RecordUsage.js +13 -6
- package/dist/resources/SemanticSearch.js +8 -1
- package/dist/resources/federation-classify.js +90 -0
- package/dist/resources/health.js +51 -7
- package/dist/resources/mcp-tools.js +10 -6
- package/dist/resources/search-readiness.js +123 -0
- package/dist/resources/semantic-retrieval-core.js +48 -21
- package/dist/resources/sort-comparators.js +45 -0
- package/dist/resources/usage-ids.js +63 -0
- package/dist/src/lib/scheduler-platform.js +132 -10
- package/dist/src/rem/scheduler.js +23 -5
- package/docs/auth.md +5 -0
- package/docs/deepseek-harness.md +1 -1
- package/docs/federation.md +11 -0
- package/docs/hosted-on-fabric.md +2 -0
- package/docs/integrations.md +53 -1
- package/docs/mcp-clients.md +67 -15
- package/docs/quickstart-fabric.md +1 -1
- package/docs/supply-chain-policy.md +1 -1
- package/docs/troubleshooting.md +25 -0
- package/docs/upgrade.md +17 -1
- package/package.json +3 -3
- package/schemas/federation.graphql +1 -1
package/dist/cli.js
CHANGED
|
@@ -26,13 +26,14 @@ import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion
|
|
|
26
26
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
27
27
|
import { readClientMcpBlock, effectiveFlairUrl, checkPiFlairWiring, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
|
|
28
28
|
import { checkGlobalBinOnPath, cliBootPathWarning, resolveNpmGlobalPrefix, } from "./install/global-bin-path.js";
|
|
29
|
-
import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
29
|
+
import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookSettingsPath, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "./hook-install.js";
|
|
30
30
|
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, DEFAULT_ADMIN_USER, resolveAdminUser, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
31
31
|
import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
|
|
32
32
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
33
33
|
import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
|
|
34
34
|
import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
|
|
35
|
-
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning,
|
|
35
|
+
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
|
|
36
|
+
import { applyUpgradeHookConsent, catalogIssueDelta, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
|
|
36
37
|
// Value-only static import so `--interval`'s advertised default cannot drift
|
|
37
38
|
// from the one the scheduler actually validates against. The module itself is
|
|
38
39
|
// still loaded lazily at call time (the `await import()`s below) for the
|
|
@@ -60,14 +61,18 @@ function signBody(body, secretKey) {
|
|
|
60
61
|
const sig = nacl.sign.detached(message, secretKey);
|
|
61
62
|
return Buffer.from(sig).toString("base64url");
|
|
62
63
|
}
|
|
63
|
-
// Per-record principalId (federation-edge-hardening slice 3a
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
64
|
+
// Per-record principalId (federation-edge-hardening slice 3a / flair#1416).
|
|
65
|
+
// Sourced from the write-time provenance stamp (memory-provenance slice 1,
|
|
66
|
+
// Memory.ts's buildProvenance) when present. `provenance` is persisted as
|
|
67
|
+
// a JSON STRING (not an object), so it must be parsed — a raw
|
|
68
|
+
// `row.provenance?.verified?.agentId` would silently always be undefined.
|
|
69
|
+
// Soul/Agent/Relationship rows never carry a provenance stamp today, so
|
|
70
|
+
// this is a no-op for them (those tables are not principal-owning).
|
|
71
|
+
//
|
|
72
|
+
// As of v:2 this value is IN the signed payload. The receiver validates
|
|
73
|
+
// it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
|
|
74
|
+
// no longer informational-only. Credential.principalId is an unrelated
|
|
75
|
+
// owner field — do not grep that path when changing this one.
|
|
71
76
|
function principalIdFromRow(row) {
|
|
72
77
|
if (typeof row?.provenance !== "string" || row.provenance.length === 0)
|
|
73
78
|
return undefined;
|
|
@@ -685,6 +690,48 @@ function resolveSigningIdentityFor(opts, command) {
|
|
|
685
690
|
function resolveSigningAgentId(opts, command) {
|
|
686
691
|
return resolveSigningIdentityFor(opts, command).agentId;
|
|
687
692
|
}
|
|
693
|
+
// ── Shared credential/identity flag surface (flair#1106) ─────────────────────
|
|
694
|
+
// Sibling commands (memory add, backup, federation sync) used to drift on
|
|
695
|
+
// the same concepts: `--admin-pass-file` existed on backup/sync but was an
|
|
696
|
+
// unknown option on `memory add`, and `memory add --agent` was a commander
|
|
697
|
+
// requiredOption so FLAIR_AGENT_ID could never satisfy it. One helper owns
|
|
698
|
+
// the credential flag names/shapes; identity (`--agent`) stays optional so
|
|
699
|
+
// the env fallback can actually apply. This does not invent a new auth
|
|
700
|
+
// model — it only declares the flags authedRequest already resolves.
|
|
701
|
+
/** Flag strings the sibling commands must share (name + argument shape). */
|
|
702
|
+
export const SHARED_CREDENTIAL_FLAGS = {
|
|
703
|
+
adminPass: "--admin-pass <pass>",
|
|
704
|
+
adminPassFile: "--admin-pass-file <path>",
|
|
705
|
+
adminUser: "--admin-user <name>",
|
|
706
|
+
};
|
|
707
|
+
export const SHARED_IDENTITY_FLAGS = {
|
|
708
|
+
agent: "--agent <id>",
|
|
709
|
+
};
|
|
710
|
+
function addSharedCredentialOptions(cmd) {
|
|
711
|
+
return cmd
|
|
712
|
+
.option(SHARED_CREDENTIAL_FLAGS.adminPass, "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
|
|
713
|
+
.option(SHARED_CREDENTIAL_FLAGS.adminPassFile, "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
|
|
714
|
+
.option(SHARED_CREDENTIAL_FLAGS.adminUser, "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)");
|
|
715
|
+
}
|
|
716
|
+
function addSharedIdentityOption(cmd) {
|
|
717
|
+
return cmd.option(SHARED_IDENTITY_FLAGS.agent, "Agent ID (or set FLAIR_AGENT_ID env)");
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Resolve `--admin-pass-file` into the same `adminPass` slot the inline flag
|
|
721
|
+
* uses. Shared so sibling commands cannot drift on how the file is read
|
|
722
|
+
* (mode 0600 via readAdminPassFileSecure).
|
|
723
|
+
*/
|
|
724
|
+
function applyAdminPassFile(opts) {
|
|
725
|
+
if (!opts.adminPass && opts.adminPassFile) {
|
|
726
|
+
try {
|
|
727
|
+
opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
688
735
|
// Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
|
|
689
736
|
//
|
|
690
737
|
// Deliberately NOT routed through Harper's per-instance config the way
|
|
@@ -1429,7 +1476,13 @@ async function api(method, path, body, options) {
|
|
|
1429
1476
|
agentId = decodeURIComponent(match[1]);
|
|
1430
1477
|
}
|
|
1431
1478
|
}
|
|
1432
|
-
return authedRequest(method, path, body, {
|
|
1479
|
+
return authedRequest(method, path, body, {
|
|
1480
|
+
baseUrl: base,
|
|
1481
|
+
agentId,
|
|
1482
|
+
keysDir: options?.keysDir,
|
|
1483
|
+
explicitAdminPass: options?.explicitAdminPass,
|
|
1484
|
+
adminUser: options?.adminUser,
|
|
1485
|
+
});
|
|
1433
1486
|
}
|
|
1434
1487
|
/**
|
|
1435
1488
|
* The authedGet `flair upgrade` verification (flair#635/#741) hands to
|
|
@@ -2614,9 +2667,9 @@ export function upgradeStatusSuffix(name, status) {
|
|
|
2614
2667
|
* is `flair doctor --fix`, never `npm install -g`.
|
|
2615
2668
|
* 3. Wired with a concrete pin — that pin IS the installed version
|
|
2616
2669
|
* (current when it equals latest, else outdated → re-pin via doctor).
|
|
2617
|
-
* 4. Wired but unpinned (a bare npx spec /
|
|
2618
|
-
* re-resolves latest every session, so the effective version IS
|
|
2619
|
-
* current.
|
|
2670
|
+
* 4. Wired but unpinned (a bare npx spec / a pre-#1143 SessionStart hook) —
|
|
2671
|
+
* `npx -y` re-resolves latest every session, so the effective version IS
|
|
2672
|
+
* latest → current.
|
|
2620
2673
|
*/
|
|
2621
2674
|
export function resolveFlairMcpFinding(globalProbe, latest, wiring) {
|
|
2622
2675
|
// 1. Legacy global install.
|
|
@@ -2954,7 +3007,7 @@ program
|
|
|
2954
3007
|
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
2955
3008
|
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
2956
3009
|
.option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
|
|
2957
|
-
.option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code
|
|
3010
|
+
.option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code and Codex)")
|
|
2958
3011
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
2959
3012
|
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
2960
3013
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
@@ -3816,6 +3869,18 @@ program
|
|
|
3816
3869
|
}
|
|
3817
3870
|
wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
|
|
3818
3871
|
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
3872
|
+
// Codex SessionStart hook (flair#1148 / #1439) — the hook is not
|
|
3873
|
+
// optional on Codex (no CLAUDE.md alternative). Init is the
|
|
3874
|
+
// consent to set up the client, same as the Claude Code hook
|
|
3875
|
+
// applied above. --skip-hook opts out and prints the JSON.
|
|
3876
|
+
if (clientId === "codex" && result.ok) {
|
|
3877
|
+
const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook, hookSettingsPath(homedir(), "codex"));
|
|
3878
|
+
console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
|
|
3879
|
+
if (hookResult.hint) {
|
|
3880
|
+
for (const line of hookResult.hint.split("\n"))
|
|
3881
|
+
console.log(` ${line}`);
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3819
3884
|
}
|
|
3820
3885
|
}
|
|
3821
3886
|
}
|
|
@@ -4573,18 +4638,12 @@ keys
|
|
|
4573
4638
|
// dry-run delta, symmetric removal) lives in src/hook-install.ts — this
|
|
4574
4639
|
// section is pure CLI plumbing: option parsing, default resolution, and
|
|
4575
4640
|
// rendering the pure functions' results.
|
|
4576
|
-
function
|
|
4577
|
-
return (opts.agent ||
|
|
4578
|
-
opts.agentId ||
|
|
4579
|
-
process.env.FLAIR_AGENT_ID ||
|
|
4580
|
-
readClientMcpBlock("claude-code", homeDir).agentId ||
|
|
4581
|
-
undefined);
|
|
4582
|
-
}
|
|
4583
|
-
function resolveHookFlairUrl(opts, homeDir) {
|
|
4641
|
+
function resolveHookFlairUrl(opts, homeDir, harness) {
|
|
4584
4642
|
return (opts.url ||
|
|
4585
4643
|
process.env.FLAIR_TARGET ||
|
|
4586
4644
|
process.env.FLAIR_URL ||
|
|
4587
|
-
readClientMcpBlock(
|
|
4645
|
+
readClientMcpBlock(harness, homeDir).flairUrl ||
|
|
4646
|
+
(harness !== "claude-code" ? readClientMcpBlock("claude-code", homeDir).flairUrl : undefined) ||
|
|
4588
4647
|
resolveBaseUrl({}));
|
|
4589
4648
|
}
|
|
4590
4649
|
function requireSupportedHarness(raw) {
|
|
@@ -4601,19 +4660,19 @@ hook
|
|
|
4601
4660
|
.description("Wire the Flair SessionStart hook into the harness config so memory loads automatically at session start")
|
|
4602
4661
|
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
4603
4662
|
.option("--dry-run", "Print the exact JSON delta without writing")
|
|
4604
|
-
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for
|
|
4663
|
+
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for this harness's MCP client)")
|
|
4605
4664
|
.option("--agent-id <id>", "Alias for --agent")
|
|
4606
|
-
.option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else
|
|
4665
|
+
.option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else this harness's MCP wiring, else the local default)")
|
|
4607
4666
|
.option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
|
|
4608
4667
|
.action((opts) => {
|
|
4609
4668
|
const harness = requireSupportedHarness(opts.harness);
|
|
4610
4669
|
const home = homedir();
|
|
4611
|
-
const agentId = resolveHookAgentId(opts, home);
|
|
4670
|
+
const agentId = resolveHookAgentId(opts, home, harness);
|
|
4612
4671
|
if (!agentId) {
|
|
4613
4672
|
console.error("No agent id known — pass --agent <id>, set FLAIR_AGENT_ID, or run `flair init` / `flair agent add` first.");
|
|
4614
4673
|
process.exit(1);
|
|
4615
4674
|
}
|
|
4616
|
-
const flairUrl = resolveHookFlairUrl(opts, home);
|
|
4675
|
+
const flairUrl = resolveHookFlairUrl(opts, home, harness);
|
|
4617
4676
|
const dryRun = !!opts.dryRun;
|
|
4618
4677
|
if (opts.continuity) {
|
|
4619
4678
|
const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
@@ -4630,6 +4689,11 @@ hook
|
|
|
4630
4689
|
const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
4631
4690
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4632
4691
|
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
4692
|
+
const pinWarning = unpinnedSpecWarning();
|
|
4693
|
+
if (pinWarning && result.ok) {
|
|
4694
|
+
for (const line of pinWarning.split("\n"))
|
|
4695
|
+
console.error(` ⚠ ${line}`);
|
|
4696
|
+
}
|
|
4633
4697
|
if (result.backupPath) {
|
|
4634
4698
|
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
4635
4699
|
}
|
|
@@ -4689,16 +4753,20 @@ hook
|
|
|
4689
4753
|
// status in every branch below. "absent" is NOT a failure: installing the
|
|
4690
4754
|
// pair is the opt-in, so absence renders as "not enabled".
|
|
4691
4755
|
const renderContinuity = () => {
|
|
4756
|
+
// Continuity is Claude Code only. Do not tip `--continuity --harness
|
|
4757
|
+
// <other>` — that writes Claude tool matchers into the wrong file.
|
|
4758
|
+
if (!harnessSupportsContinuity(harness))
|
|
4759
|
+
return;
|
|
4692
4760
|
const cont = continuityHookStatus(home, harness);
|
|
4693
4761
|
if (cont.state === "installed") {
|
|
4694
4762
|
console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
|
|
4695
4763
|
}
|
|
4696
4764
|
else if (cont.state === "absent") {
|
|
4697
|
-
console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim,
|
|
4765
|
+
console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, `(opt-in: ${hookInstallHint(harness, "--continuity")})`)}`);
|
|
4698
4766
|
}
|
|
4699
4767
|
else {
|
|
4700
4768
|
const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
|
|
4701
|
-
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim,
|
|
4769
|
+
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, `— re-run: ${hookInstallHint(harness, "--continuity")}`)}`);
|
|
4702
4770
|
}
|
|
4703
4771
|
};
|
|
4704
4772
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
|
|
@@ -4711,7 +4779,7 @@ hook
|
|
|
4711
4779
|
}
|
|
4712
4780
|
if (!status.wired) {
|
|
4713
4781
|
console.log(` ${render.icons.error} not wired`);
|
|
4714
|
-
console.log(` ${render.wrap(render.c.dim, "Fix:")}
|
|
4782
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${hookInstallHint(status.harness)}`);
|
|
4715
4783
|
renderContinuity();
|
|
4716
4784
|
console.log("");
|
|
4717
4785
|
process.exit(1);
|
|
@@ -4732,7 +4800,7 @@ hook
|
|
|
4732
4800
|
console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
|
|
4733
4801
|
}
|
|
4734
4802
|
else {
|
|
4735
|
-
console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`
|
|
4803
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`${hookInstallHint(status.harness)}\` to adopt the silent form`);
|
|
4736
4804
|
}
|
|
4737
4805
|
renderContinuity();
|
|
4738
4806
|
console.log("");
|
|
@@ -6116,6 +6184,95 @@ function driverCheckAppliesTo(opts) {
|
|
|
6116
6184
|
const target = resolveTarget(opts);
|
|
6117
6185
|
return !target || isLocalBase(target.replace(/\/$/, ""));
|
|
6118
6186
|
}
|
|
6187
|
+
/**
|
|
6188
|
+
* flair#1108: a bare undici/Node "fetch failed" names neither the URL
|
|
6189
|
+
* that was probed nor the knob that would change it. These helpers are
|
|
6190
|
+
* the operator-facing sentence and the setting that produced (or would
|
|
6191
|
+
* change) that URL. Pure so the contract can be unit-tested without
|
|
6192
|
+
* driving process.exit.
|
|
6193
|
+
*/
|
|
6194
|
+
export function federationStatusUrlSetting(opts) {
|
|
6195
|
+
if (opts.target)
|
|
6196
|
+
return "--target";
|
|
6197
|
+
if (process.env.FLAIR_TARGET)
|
|
6198
|
+
return "FLAIR_TARGET";
|
|
6199
|
+
if (process.env.FLAIR_URL)
|
|
6200
|
+
return "FLAIR_URL";
|
|
6201
|
+
if (opts.port !== undefined && opts.port !== null && String(opts.port) !== "")
|
|
6202
|
+
return "--port";
|
|
6203
|
+
return "FLAIR_URL or --port";
|
|
6204
|
+
}
|
|
6205
|
+
export function describeFederationStatusFetchFailed(url, setting) {
|
|
6206
|
+
return `fetch failed against ${url} (set ${setting})`;
|
|
6207
|
+
}
|
|
6208
|
+
/** True for a connect-level failure (no HTTP status): Node's undici
|
|
6209
|
+
* `TypeError: fetch failed`, Bun's `Unable to connect…`, or a cause
|
|
6210
|
+
* carrying a connect/DNS errno. Auth and HTTP errors stay out. */
|
|
6211
|
+
export function isFederationStatusConnectFailure(err) {
|
|
6212
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6213
|
+
if (/\bfetch failed\b/i.test(msg))
|
|
6214
|
+
return true;
|
|
6215
|
+
if (/unable to connect/i.test(msg))
|
|
6216
|
+
return true;
|
|
6217
|
+
const cause = err instanceof Error ? err.cause : undefined;
|
|
6218
|
+
const code = cause && typeof cause === "object" && cause && "code" in cause
|
|
6219
|
+
? String(cause.code)
|
|
6220
|
+
: "";
|
|
6221
|
+
return /^(ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH)$/.test(code);
|
|
6222
|
+
}
|
|
6223
|
+
export function rewriteFederationStatusFetchFailed(err, url, setting) {
|
|
6224
|
+
if (!isFederationStatusConnectFailure(err))
|
|
6225
|
+
return err;
|
|
6226
|
+
const next = new Error(describeFederationStatusFetchFailed(url, setting));
|
|
6227
|
+
if (err && typeof err === "object" && "status" in err) {
|
|
6228
|
+
next.status = err.status;
|
|
6229
|
+
}
|
|
6230
|
+
return next;
|
|
6231
|
+
}
|
|
6232
|
+
/**
|
|
6233
|
+
* Auth-shaped vs connect-level for `federation status`. A rewritten
|
|
6234
|
+
* fetch-failed sentence embeds the probed URL; that URL can contain a
|
|
6235
|
+
* whole-token `401` (e.g. `--port 401`). The old `message.includes("401")`
|
|
6236
|
+
* check then printed the credential remedy and hid the URL+setting this
|
|
6237
|
+
* change exists to surface (Bugbot on flair#1108).
|
|
6238
|
+
*/
|
|
6239
|
+
export function isFederationStatusAuthFailure(err) {
|
|
6240
|
+
if (!err)
|
|
6241
|
+
return false;
|
|
6242
|
+
if (isFederationStatusConnectFailure(err))
|
|
6243
|
+
return false;
|
|
6244
|
+
if (typeof err === "object" && "status" in err) {
|
|
6245
|
+
const status = err.status;
|
|
6246
|
+
if (status === 401 || status === 403)
|
|
6247
|
+
return true;
|
|
6248
|
+
}
|
|
6249
|
+
const m = err instanceof Error
|
|
6250
|
+
? err.message
|
|
6251
|
+
: String(typeof err === "object" && err && "message" in err
|
|
6252
|
+
? err.message ?? err
|
|
6253
|
+
: err);
|
|
6254
|
+
return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
|
|
6255
|
+
}
|
|
6256
|
+
/**
|
|
6257
|
+
* Whether to print the "set one of: FLAIR_AGENT_ID / FLAIR_ADMIN_PASS /
|
|
6258
|
+
* FLAIR_TOKEN" block. Narrower than `isFederationStatusAuthFailure`: a
|
|
6259
|
+
* 403 with credentials already sent (wrong password) is fatal, but the
|
|
6260
|
+
* server's own body is the honest message — the credential-list remedy
|
|
6261
|
+
* is for missing/invalid auth (401), not a rejected password (flair#634).
|
|
6262
|
+
*/
|
|
6263
|
+
export function isFederationStatusAuthRemedy(err) {
|
|
6264
|
+
if (!err || isFederationStatusConnectFailure(err))
|
|
6265
|
+
return false;
|
|
6266
|
+
if (typeof err === "object" && "status" in err && err.status === 401) {
|
|
6267
|
+
return true;
|
|
6268
|
+
}
|
|
6269
|
+
const m = err instanceof Error
|
|
6270
|
+
? err.message
|
|
6271
|
+
: String(typeof err === "object" && err && "message" in err
|
|
6272
|
+
? err.message ?? err
|
|
6273
|
+
: err);
|
|
6274
|
+
return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
|
|
6275
|
+
}
|
|
6119
6276
|
federation
|
|
6120
6277
|
.command("status")
|
|
6121
6278
|
.description("Show federation status and peer connections")
|
|
@@ -6124,8 +6281,11 @@ federation
|
|
|
6124
6281
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
6125
6282
|
.option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
|
|
6126
6283
|
.action(async (opts) => {
|
|
6127
|
-
|
|
6128
|
-
|
|
6284
|
+
// Same URL api() would have derived, including --port (the command
|
|
6285
|
+
// advertised --port but previously dropped it on the floor). Naming
|
|
6286
|
+
// that URL on fetch failure is only honest if it is the URL we probe.
|
|
6287
|
+
const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
|
|
6288
|
+
const urlSetting = federationStatusUrlSetting(opts);
|
|
6129
6289
|
const mode = render.resolveOutputMode(opts);
|
|
6130
6290
|
// flair#1233: fetch instance and peers INDEPENDENTLY. One read failing
|
|
6131
6291
|
// must never take down the whole render — the principle latestPeerContact
|
|
@@ -6135,41 +6295,33 @@ federation
|
|
|
6135
6295
|
let instance = null;
|
|
6136
6296
|
let instanceErr = null;
|
|
6137
6297
|
try {
|
|
6138
|
-
instance = await api("GET", "/FederationInstance", undefined,
|
|
6298
|
+
instance = await api("GET", "/FederationInstance", undefined, { baseUrl });
|
|
6139
6299
|
}
|
|
6140
6300
|
catch (err) {
|
|
6141
|
-
instanceErr = err;
|
|
6301
|
+
instanceErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
|
|
6142
6302
|
}
|
|
6143
6303
|
// peers: null = unverifiable (the read failed), [] = verified empty.
|
|
6144
6304
|
let peers = null;
|
|
6145
6305
|
let peersErr = null;
|
|
6146
6306
|
try {
|
|
6147
|
-
const r = await api("GET", "/FederationPeers", undefined,
|
|
6307
|
+
const r = await api("GET", "/FederationPeers", undefined, { baseUrl });
|
|
6148
6308
|
peers = r.peers ?? [];
|
|
6149
6309
|
}
|
|
6150
6310
|
catch (err) {
|
|
6151
|
-
peersErr = err;
|
|
6311
|
+
peersErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
|
|
6152
6312
|
}
|
|
6153
6313
|
// Auth-shaped failures stay FATAL even when the other read succeeded:
|
|
6154
6314
|
// both endpoints sit behind the same allowAdmin gate, so a 401/403 is a
|
|
6155
6315
|
// property of the session's credentials, not of one endpoint — and
|
|
6156
6316
|
// degrading it to "unverifiable" would swallow the actionable remedy
|
|
6157
6317
|
// (flair#634's UX, kept). Only non-auth failures degrade independently.
|
|
6158
|
-
const authShaped = (err) => {
|
|
6159
|
-
if (!err)
|
|
6160
|
-
return false;
|
|
6161
|
-
if (err.status === 401 || err.status === 403)
|
|
6162
|
-
return true;
|
|
6163
|
-
const m = String(err.message ?? err);
|
|
6164
|
-
return m.includes("missing_or_invalid_authorization") || m.includes("401");
|
|
6165
|
-
};
|
|
6166
6318
|
// Both reads failed → nothing to render at all. Either way keep the
|
|
6167
6319
|
// classic failure UX (auth remedy when it's an auth problem), exit
|
|
6168
6320
|
// non-zero.
|
|
6169
|
-
if ((instanceErr && peersErr) ||
|
|
6321
|
+
if ((instanceErr && peersErr) || isFederationStatusAuthFailure(instanceErr) || isFederationStatusAuthFailure(peersErr)) {
|
|
6170
6322
|
const primaryErr = instanceErr ?? peersErr;
|
|
6171
6323
|
const msg = String(primaryErr.message ?? primaryErr);
|
|
6172
|
-
if (
|
|
6324
|
+
if (isFederationStatusAuthRemedy(primaryErr)) {
|
|
6173
6325
|
console.error(`${render.icons.error} federation status requires auth.`);
|
|
6174
6326
|
console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
|
|
6175
6327
|
console.error(` ${render.wrap(render.c.cyan, "FLAIR_AGENT_ID=<your-agent-id>")} ${render.wrap(render.c.dim, "(Ed25519 — uses ~/.flair/keys/<id>.key)")}`);
|
|
@@ -6895,22 +7047,40 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6895
7047
|
// batch. Closes the hub-relay forgery hole — see
|
|
6896
7048
|
// resources/Federation.ts FederationSync.post's verification gate.
|
|
6897
7049
|
//
|
|
6898
|
-
// CONTRACT — must match
|
|
6899
|
-
// byte-for-byte
|
|
6900
|
-
//
|
|
6901
|
-
//
|
|
6902
|
-
//
|
|
6903
|
-
// field set ever changes, so an old signature fails closed instead of
|
|
6904
|
-
// silently mis-verifying under a new form.
|
|
7050
|
+
// CONTRACT — must match reconstructRecordVerifyBody
|
|
7051
|
+
// (resources/federation-classify.ts) byte-for-byte. canonicalize()
|
|
7052
|
+
// sorts keys, so field ORDER doesn't matter, but the field SET and
|
|
7053
|
+
// values do. `v` versions the canonical form itself: a v:1
|
|
7054
|
+
// signature cannot verify as v:2 (principalId in the field set).
|
|
6905
7055
|
//
|
|
6906
|
-
//
|
|
6907
|
-
// `
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
//
|
|
6911
|
-
// receiver
|
|
6912
|
-
//
|
|
7056
|
+
// v: 2 puts principalId in the signed payload when the row carries
|
|
7057
|
+
// a provenance stamp, and puts `v` on the wire so Phase 1
|
|
7058
|
+
// receivers (`const v = record.v ?? 1`) don't default these
|
|
7059
|
+
// records back to 1. Soul/Agent/Relationship have no stamp and
|
|
7060
|
+
// omit principalId; Memory without a stamp also omits it (the
|
|
7061
|
+
// receiver then skips Memory as principal_mismatch — absent is
|
|
7062
|
+
// not an accept).
|
|
6913
7063
|
const principalId = principalIdFromRow(row);
|
|
7064
|
+
const signedPayload = {
|
|
7065
|
+
v: 2,
|
|
7066
|
+
table,
|
|
7067
|
+
id: row.id,
|
|
7068
|
+
data: row,
|
|
7069
|
+
updatedAt,
|
|
7070
|
+
originatorInstanceId,
|
|
7071
|
+
};
|
|
7072
|
+
if (principalId)
|
|
7073
|
+
signedPayload.principalId = principalId;
|
|
7074
|
+
const signature = signBody(signedPayload, secretKey);
|
|
7075
|
+
const sr = {
|
|
7076
|
+
v: 2,
|
|
7077
|
+
table,
|
|
7078
|
+
id: row.id,
|
|
7079
|
+
data: row,
|
|
7080
|
+
updatedAt,
|
|
7081
|
+
originatorInstanceId,
|
|
7082
|
+
signature,
|
|
7083
|
+
};
|
|
6914
7084
|
if (principalId)
|
|
6915
7085
|
sr.principalId = principalId;
|
|
6916
7086
|
const srBytes = JSON.stringify(sr).length;
|
|
@@ -7004,29 +7174,16 @@ export async function runFederationSyncOnce(opts) {
|
|
|
7004
7174
|
return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
|
|
7005
7175
|
}
|
|
7006
7176
|
}
|
|
7007
|
-
const federationSync = federation
|
|
7177
|
+
const federationSync = addSharedCredentialOptions(federation
|
|
7008
7178
|
.command("sync")
|
|
7009
7179
|
.description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
|
|
7010
7180
|
.option("--port <port>", "Harper HTTP port")
|
|
7011
|
-
.option("--admin-pass <pass>", "Admin password")
|
|
7012
|
-
.option("--admin-pass-file <path>", "Read the admin password from a file (e.g. ~/.flair/admin-pass). Preferred for launchd/cron — keeps the secret out of ps and shell history.")
|
|
7013
|
-
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
7014
7181
|
.option("--ops-port <port>", "Harper operations API port")
|
|
7015
7182
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
7016
|
-
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
7017
|
-
.action(async (opts) => {
|
|
7183
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")).action(async (opts) => {
|
|
7018
7184
|
// --admin-pass-file resolves into the same `adminPass` slot the inline
|
|
7019
7185
|
// flag uses, so the scheduler never has to embed a secret in a unit file.
|
|
7020
|
-
|
|
7021
|
-
if (!opts.adminPass && opts.adminPassFile) {
|
|
7022
|
-
try {
|
|
7023
|
-
opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
7024
|
-
}
|
|
7025
|
-
catch (err) {
|
|
7026
|
-
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
7027
|
-
process.exit(1);
|
|
7028
|
-
}
|
|
7029
|
-
}
|
|
7186
|
+
applyAdminPassFile(opts);
|
|
7030
7187
|
const r = await runFederationSyncOnce(opts);
|
|
7031
7188
|
if (r.error) {
|
|
7032
7189
|
console.error(`Error: ${r.error.message}`);
|
|
@@ -10154,6 +10311,7 @@ program
|
|
|
10154
10311
|
.option("--project <name>", "Fabric component name for --target", "flair")
|
|
10155
10312
|
.option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
|
|
10156
10313
|
.option("--yes", "Skip the confirmation prompt for --target")
|
|
10314
|
+
.option("--install-hooks", "Consent to installing missing SessionStart hooks (claude-code / Codex) during upgrade. The hook executes at every session start — upgrade will not write it unprompted. Interactive runs prompt; non-interactive runs state the gap and withhold ✅ unless this flag is passed.")
|
|
10157
10315
|
.option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
|
|
10158
10316
|
// ── flair#878 ─────────────────────────────────────────────────────────────
|
|
10159
10317
|
// These existed on `flair deploy` but stopped at the upgrade boundary, so
|
|
@@ -10819,20 +10977,16 @@ program
|
|
|
10819
10977
|
});
|
|
10820
10978
|
const verdict = decideAfterVerify(verify, previousFlairVersion);
|
|
10821
10979
|
if (verdict.kind === "ok") {
|
|
10822
|
-
// flair#
|
|
10823
|
-
//
|
|
10824
|
-
//
|
|
10825
|
-
//
|
|
10826
|
-
|
|
10827
|
-
|
|
10828
|
-
|
|
10829
|
-
|
|
10830
|
-
|
|
10831
|
-
|
|
10832
|
-
console.error(line);
|
|
10833
|
-
else
|
|
10834
|
-
console.log(line);
|
|
10835
|
-
}
|
|
10980
|
+
// flair#1439: the success marker is the doctor runner's verdict, not
|
|
10981
|
+
// a second, narrower notion of "verified". Launchd detach is one
|
|
10982
|
+
// catalog member; the Codex SessionStart hook is another. Adding a
|
|
10983
|
+
// doctor check widens this claim automatically.
|
|
10984
|
+
const run = await doctorRunAfterUpgrade({
|
|
10985
|
+
management,
|
|
10986
|
+
port,
|
|
10987
|
+
installHooksFlag: !!opts.installHooks,
|
|
10988
|
+
});
|
|
10989
|
+
printVerifiedSummary(renderVerifiedSummary(verify.version, run));
|
|
10836
10990
|
return;
|
|
10837
10991
|
}
|
|
10838
10992
|
// flair#741 follow-through: a healthy instance the verifier just couldn't
|
|
@@ -10844,21 +10998,23 @@ program
|
|
|
10844
10998
|
// "print an honest note but roll back anyway" branch that used to sit below
|
|
10845
10999
|
// is gone — that credentials case can no longer reach the rollback path.)
|
|
10846
11000
|
if (verdict.kind === "healthy-unverified") {
|
|
10847
|
-
//
|
|
10848
|
-
//
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
|
|
10852
|
-
|
|
10853
|
-
|
|
11001
|
+
// Same doctor runner as the "ok" branch — an unverified version must
|
|
11002
|
+
// not restore the unqualified ✅ while a catalog member is failing.
|
|
11003
|
+
const run = await doctorRunAfterUpgrade({
|
|
11004
|
+
management,
|
|
11005
|
+
port,
|
|
11006
|
+
installHooksFlag: !!opts.installHooks,
|
|
11007
|
+
});
|
|
11008
|
+
const versionNote = expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : "";
|
|
11009
|
+
if (run.healthy) {
|
|
11010
|
+
console.log(`✅ upgrade complete: the instance is up and healthy${versionNote}.`);
|
|
11011
|
+
}
|
|
11012
|
+
else {
|
|
11013
|
+
printVerifiedSummary(renderVerifiedSummary(verify.version, run, { authenticated: false }));
|
|
11014
|
+
}
|
|
10854
11015
|
console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
|
|
10855
11016
|
console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
|
|
10856
11017
|
console.log(" To enable full post-upgrade verification: set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key.");
|
|
10857
|
-
if (detached) {
|
|
10858
|
-
for (const line of renderDetachedWarning(management, "The instance is NOT running under launchd.")) {
|
|
10859
|
-
console.error(line);
|
|
10860
|
-
}
|
|
10861
|
-
}
|
|
10862
11018
|
return;
|
|
10863
11019
|
}
|
|
10864
11020
|
console.error(`❌ post-restart verification failed: ${verdict.reason}`);
|
|
@@ -11177,6 +11333,83 @@ function resolveInstanceServingPid(dataDir, port) {
|
|
|
11177
11333
|
listeningPids,
|
|
11178
11334
|
});
|
|
11179
11335
|
}
|
|
11336
|
+
/** Agent signing-key ids under `keysDir` — node-scoped federation keys excluded. */
|
|
11337
|
+
function collectKeyAgentIds(keysDir) {
|
|
11338
|
+
if (!existsSync(keysDir))
|
|
11339
|
+
return [];
|
|
11340
|
+
try {
|
|
11341
|
+
const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
|
|
11342
|
+
const { agentKeyIds } = partitionKeyIds(keyFiles.map((f) => f.replace(/\.key$/, "")), keysDir);
|
|
11343
|
+
return agentKeyIds;
|
|
11344
|
+
}
|
|
11345
|
+
catch {
|
|
11346
|
+
return [];
|
|
11347
|
+
}
|
|
11348
|
+
}
|
|
11349
|
+
async function confirmYes(question) {
|
|
11350
|
+
if (!process.stdin.isTTY)
|
|
11351
|
+
return false;
|
|
11352
|
+
const { createInterface } = await import("node:readline");
|
|
11353
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
11354
|
+
const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
|
|
11355
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
11356
|
+
}
|
|
11357
|
+
/**
|
|
11358
|
+
* flair#1439 — run the enumerable doctor catalog after upgrade's instance
|
|
11359
|
+
* probe, and offer a consented SessionStart-hook install when that check
|
|
11360
|
+
* fails. Silent writes are refused: `--install-hooks` or an interactive
|
|
11361
|
+
* yes is the only consent. The consent→write composition lives in
|
|
11362
|
+
* applyUpgradeHookConsent so tests can drive the path that actually
|
|
11363
|
+
* writes (or does not write) the hook file.
|
|
11364
|
+
*/
|
|
11365
|
+
async function doctorRunAfterUpgrade(args) {
|
|
11366
|
+
const homeDir = homedir();
|
|
11367
|
+
const keysDir = defaultKeysDir();
|
|
11368
|
+
const ctx = {
|
|
11369
|
+
homeDir,
|
|
11370
|
+
cwd: process.cwd(),
|
|
11371
|
+
detectedClientIds: detectClients().filter((c) => c.detected).map((c) => c.id),
|
|
11372
|
+
launchd: args.management,
|
|
11373
|
+
keysDir,
|
|
11374
|
+
keyAgentIds: collectKeyAgentIds(keysDir),
|
|
11375
|
+
};
|
|
11376
|
+
const run = runDoctorChecks(ctx);
|
|
11377
|
+
const apply = (promptAccepted) => applyUpgradeHookConsent({
|
|
11378
|
+
homeDir,
|
|
11379
|
+
ctx,
|
|
11380
|
+
run,
|
|
11381
|
+
installHooksFlag: args.installHooksFlag,
|
|
11382
|
+
interactive: !!process.stdin.isTTY,
|
|
11383
|
+
promptAccepted,
|
|
11384
|
+
port: args.port,
|
|
11385
|
+
});
|
|
11386
|
+
let outcome = apply();
|
|
11387
|
+
if (outcome.consent === "prompt" && outcome.prompt) {
|
|
11388
|
+
console.log("");
|
|
11389
|
+
for (const line of outcome.prompt.preamble)
|
|
11390
|
+
console.log(` ${line}`);
|
|
11391
|
+
outcome = apply(await confirmYes(outcome.prompt.question));
|
|
11392
|
+
}
|
|
11393
|
+
if (outcome.consent === "install") {
|
|
11394
|
+
for (const w of outcome.writes) {
|
|
11395
|
+
console.log(` ${w.ok ? "✓" : "•"} ${w.message}`);
|
|
11396
|
+
}
|
|
11397
|
+
}
|
|
11398
|
+
if (outcome.consent === "skip-noninteractive") {
|
|
11399
|
+
for (const line of outcome.messages) {
|
|
11400
|
+
console.error(` ${line}`);
|
|
11401
|
+
}
|
|
11402
|
+
}
|
|
11403
|
+
return outcome.run;
|
|
11404
|
+
}
|
|
11405
|
+
function printVerifiedSummary(summary) {
|
|
11406
|
+
for (const line of summary.lines) {
|
|
11407
|
+
if (summary.degraded)
|
|
11408
|
+
console.error(line);
|
|
11409
|
+
else
|
|
11410
|
+
console.log(line);
|
|
11411
|
+
}
|
|
11412
|
+
}
|
|
11180
11413
|
/**
|
|
11181
11414
|
* Observe whether `dataDir`'s instance is running under launchd right now.
|
|
11182
11415
|
*
|
|
@@ -12918,8 +13151,8 @@ program
|
|
|
12918
13151
|
// Antigravity) the MCP block present + reachable + the configured agent
|
|
12919
13152
|
// genuinely registered; for pi (a NATIVE EXTENSION host — flair#1342) the
|
|
12920
13153
|
// pi-flair reference in pi's own settings, including the flair#1346
|
|
12921
|
-
// npm:-under-"extensions" trap; plus CLAUDE.md
|
|
12922
|
-
// (Claude Code
|
|
13154
|
+
// npm:-under-"extensions" trap; plus CLAUDE.md (Claude Code) and the
|
|
13155
|
+
// SessionStart hook (Claude Code + Codex — flair#1148). Reuses
|
|
12923
13156
|
// detectClients() rather than reimplementing client detection.
|
|
12924
13157
|
console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
|
|
12925
13158
|
// Prompt y/N before a content-editing fix, but only when interactive —
|
|
@@ -12935,11 +13168,27 @@ program
|
|
|
12935
13168
|
return /^y(es)?$/i.test(answer.trim());
|
|
12936
13169
|
}
|
|
12937
13170
|
const detectedClients = detectClients().filter((c) => c.detected);
|
|
13171
|
+
// flair#1439 — install-health (MCP, FLAIR_URL, CLAUDE.md, SessionStart
|
|
13172
|
+
// hook, verified-read plan, keys classification, launchd) is the same
|
|
13173
|
+
// catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
|
|
13174
|
+
// both. Extra doctor UX (pi, --fix, execution probe, continuity,
|
|
13175
|
+
// agent registration) stays below and does not redefine those checks.
|
|
13176
|
+
const doctorCtx = {
|
|
13177
|
+
homeDir: homedir(),
|
|
13178
|
+
cwd: process.cwd(),
|
|
13179
|
+
detectedClientIds: detectedClients.map((c) => c.id),
|
|
13180
|
+
launchd: observeLaunchdManagement(defaultDataDir(), effectivePort),
|
|
13181
|
+
keysDir,
|
|
13182
|
+
keyAgentIds,
|
|
13183
|
+
agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
|
|
13184
|
+
};
|
|
13185
|
+
const catalogBefore = runDoctorChecks(doctorCtx);
|
|
12938
13186
|
if (detectedClients.length === 0) {
|
|
12939
13187
|
console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
|
|
12940
13188
|
}
|
|
12941
13189
|
else {
|
|
12942
13190
|
let claudeCodeAgentId;
|
|
13191
|
+
let codexAgentId;
|
|
12943
13192
|
let anyKnownAgentId;
|
|
12944
13193
|
// `doctor --fix` writes client configs through the same wire functions
|
|
12945
13194
|
// init does, so it owes the user the same warning when the spec it would
|
|
@@ -13119,6 +13368,8 @@ program
|
|
|
13119
13368
|
const block = readClientMcpBlock(client.id, homedir());
|
|
13120
13369
|
if (client.id === "claude-code" && block.agentId)
|
|
13121
13370
|
claudeCodeAgentId = block.agentId;
|
|
13371
|
+
if (client.id === "codex" && block.agentId)
|
|
13372
|
+
codexAgentId = block.agentId;
|
|
13122
13373
|
if (block.agentId)
|
|
13123
13374
|
anyKnownAgentId = anyKnownAgentId ?? block.agentId;
|
|
13124
13375
|
if (!block.present) {
|
|
@@ -13165,8 +13416,13 @@ program
|
|
|
13165
13416
|
client.id === "antigravity" ? wireAntigravity(wireEnv) :
|
|
13166
13417
|
wireCursor(wireEnv);
|
|
13167
13418
|
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
13168
|
-
if (wireResult.ok)
|
|
13169
|
-
|
|
13419
|
+
if (wireResult.ok) {
|
|
13420
|
+
if (client.id === "claude-code")
|
|
13421
|
+
claudeCodeAgentId = fixAgentId;
|
|
13422
|
+
if (client.id === "codex")
|
|
13423
|
+
codexAgentId = fixAgentId;
|
|
13424
|
+
anyKnownAgentId = anyKnownAgentId ?? fixAgentId;
|
|
13425
|
+
}
|
|
13170
13426
|
}
|
|
13171
13427
|
}
|
|
13172
13428
|
}
|
|
@@ -13180,7 +13436,6 @@ program
|
|
|
13180
13436
|
const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
|
|
13181
13437
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
|
|
13182
13438
|
}
|
|
13183
|
-
issues++;
|
|
13184
13439
|
continue;
|
|
13185
13440
|
}
|
|
13186
13441
|
console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
|
|
@@ -13216,8 +13471,9 @@ program
|
|
|
13216
13471
|
console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
|
|
13217
13472
|
}
|
|
13218
13473
|
}
|
|
13219
|
-
// Claude-Code-specific: CLAUDE.md + SessionStart hook
|
|
13220
|
-
// has
|
|
13474
|
+
// Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
|
|
13475
|
+
// Codex has a SessionStart hook too (checked below); CLAUDE.md and
|
|
13476
|
+
// continuity stay Claude Code only.
|
|
13221
13477
|
if (detectedClients.some((c) => c.id === "claude-code")) {
|
|
13222
13478
|
const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
|
|
13223
13479
|
if (claudeMd.present) {
|
|
@@ -13237,15 +13493,12 @@ program
|
|
|
13237
13493
|
else {
|
|
13238
13494
|
const fixRes = fixClaudeMdBootstrap(process.cwd());
|
|
13239
13495
|
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13240
|
-
if (fixRes.ok)
|
|
13241
|
-
fixed++;
|
|
13242
13496
|
}
|
|
13243
13497
|
}
|
|
13244
13498
|
}
|
|
13245
13499
|
else {
|
|
13246
13500
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the mcp__flair__bootstrap line to ./CLAUDE.md)")}`);
|
|
13247
13501
|
}
|
|
13248
|
-
issues++;
|
|
13249
13502
|
}
|
|
13250
13503
|
// flair#1007: presence was never the problem — the failing entry was
|
|
13251
13504
|
// perfectly well-formed. inspectSessionStartHook() additionally RUNS
|
|
@@ -13314,8 +13567,6 @@ program
|
|
|
13314
13567
|
else {
|
|
13315
13568
|
const upgrade = upgradeSessionStartHookCommand(homedir());
|
|
13316
13569
|
console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
|
|
13317
|
-
if (upgrade.ok && upgrade.changed)
|
|
13318
|
-
fixed++;
|
|
13319
13570
|
}
|
|
13320
13571
|
}
|
|
13321
13572
|
}
|
|
@@ -13326,7 +13577,6 @@ program
|
|
|
13326
13577
|
else {
|
|
13327
13578
|
console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install`);
|
|
13328
13579
|
}
|
|
13329
|
-
issues++;
|
|
13330
13580
|
}
|
|
13331
13581
|
}
|
|
13332
13582
|
else {
|
|
@@ -13344,15 +13594,12 @@ program
|
|
|
13344
13594
|
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
13345
13595
|
const fixRes = fixSessionStartHook(homedir(), fixAgentId);
|
|
13346
13596
|
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13347
|
-
if (fixRes.ok)
|
|
13348
|
-
fixed++;
|
|
13349
13597
|
}
|
|
13350
13598
|
}
|
|
13351
13599
|
}
|
|
13352
13600
|
else {
|
|
13353
13601
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
|
|
13354
13602
|
}
|
|
13355
|
-
issues++;
|
|
13356
13603
|
}
|
|
13357
13604
|
// flair#1257 slice 2 — continuity capture pair (the check-5 twin of
|
|
13358
13605
|
// the SessionStart check above: installed / absent / stale-form).
|
|
@@ -13407,6 +13654,98 @@ program
|
|
|
13407
13654
|
issues++;
|
|
13408
13655
|
}
|
|
13409
13656
|
}
|
|
13657
|
+
// Codex SessionStart hook (flair#1148) — same flair-session-start
|
|
13658
|
+
// command Claude Code uses, written to ~/.codex/hooks.json. Continuity
|
|
13659
|
+
// and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
|
|
13660
|
+
// is the hook file.
|
|
13661
|
+
if (detectedClients.some((c) => c.id === "codex")) {
|
|
13662
|
+
const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
|
|
13663
|
+
if (hook.present) {
|
|
13664
|
+
if (hook.execution === "broken") {
|
|
13665
|
+
if (hook.silenced) {
|
|
13666
|
+
console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
|
|
13667
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
13668
|
+
console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
|
|
13669
|
+
console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Codex session will warm the npx cache.")}`);
|
|
13670
|
+
console.log(` ${render.wrap(render.c.dim, "Codex requires /hooks to trust a newly written command before it runs.")}`);
|
|
13671
|
+
}
|
|
13672
|
+
else {
|
|
13673
|
+
console.log(` ${render.icons.warn} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
|
|
13674
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
13675
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(rewrites the hook to the current silent-failure form)")}`);
|
|
13676
|
+
}
|
|
13677
|
+
}
|
|
13678
|
+
else if (hook.execution === "unknown") {
|
|
13679
|
+
console.log(` ${render.icons.warn} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
|
|
13680
|
+
}
|
|
13681
|
+
else if (!hook.ours) {
|
|
13682
|
+
console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "(custom command — not verified, not modified)")}`);
|
|
13683
|
+
}
|
|
13684
|
+
else {
|
|
13685
|
+
console.log(` ${render.icons.ok} SessionStart hook (codex): flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
|
|
13686
|
+
}
|
|
13687
|
+
if (!hook.silenced && hook.ours) {
|
|
13688
|
+
console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
|
|
13689
|
+
if (hook.upgradable) {
|
|
13690
|
+
if (autoFix) {
|
|
13691
|
+
if (dryRun) {
|
|
13692
|
+
console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
|
|
13693
|
+
}
|
|
13694
|
+
else {
|
|
13695
|
+
const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
|
|
13696
|
+
if (!proceed) {
|
|
13697
|
+
console.log(` Skipped.`);
|
|
13698
|
+
}
|
|
13699
|
+
else {
|
|
13700
|
+
const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
|
|
13701
|
+
console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
|
|
13702
|
+
}
|
|
13703
|
+
}
|
|
13704
|
+
}
|
|
13705
|
+
else {
|
|
13706
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(rewrites the hook command in place — same agent, same instance)")}`);
|
|
13707
|
+
}
|
|
13708
|
+
}
|
|
13709
|
+
else {
|
|
13710
|
+
console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install --harness codex`);
|
|
13711
|
+
}
|
|
13712
|
+
}
|
|
13713
|
+
}
|
|
13714
|
+
else {
|
|
13715
|
+
console.log(` ${render.icons.error} SessionStart hook (codex): not found in ${render.wrap(render.c.dim, hook.path)}`);
|
|
13716
|
+
if (autoFix) {
|
|
13717
|
+
if (dryRun) {
|
|
13718
|
+
console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
|
|
13719
|
+
}
|
|
13720
|
+
else {
|
|
13721
|
+
const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
|
|
13722
|
+
if (!proceed) {
|
|
13723
|
+
console.log(` Skipped.`);
|
|
13724
|
+
}
|
|
13725
|
+
else {
|
|
13726
|
+
const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
|
|
13727
|
+
const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
|
|
13728
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13729
|
+
}
|
|
13730
|
+
}
|
|
13731
|
+
}
|
|
13732
|
+
else {
|
|
13733
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
|
|
13734
|
+
}
|
|
13735
|
+
}
|
|
13736
|
+
}
|
|
13737
|
+
}
|
|
13738
|
+
// Catalog is the install-health verdict — count fail/unrun here, not
|
|
13739
|
+
// via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
|
|
13740
|
+
// --fix that cleared a catalog member shows up in the found→fixed delta.
|
|
13741
|
+
const catalogAfter = autoFix ? runDoctorChecks(doctorCtx) : catalogBefore;
|
|
13742
|
+
const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
|
|
13743
|
+
issues += catalogDelta.found;
|
|
13744
|
+
if (autoFix)
|
|
13745
|
+
fixed += catalogDelta.fixed;
|
|
13746
|
+
console.log(`\n ${render.wrap(render.c.bold, "Install health")}`);
|
|
13747
|
+
for (const row of renderCatalogDoctorLines(catalogAfter)) {
|
|
13748
|
+
console.log(` ${render.icons[row.icon]} ${row.line}`);
|
|
13410
13749
|
}
|
|
13411
13750
|
// 7a. Resolve which agent identities the two verified-read sections below
|
|
13412
13751
|
// (Fleet presence, Migrations) iterate (flair#722). Previously both
|
|
@@ -13806,7 +14145,8 @@ program
|
|
|
13806
14145
|
// from /HealthDetail at all — it's the one metric in this file that requires
|
|
13807
14146
|
// live QUERIES, because it's checking whether querying itself still works.
|
|
13808
14147
|
// For a sample of the querying agent's OWN memories (fetchRecallSpotCheckData
|
|
13809
|
-
// below, GET /Memory
|
|
14148
|
+
// below, a projected+bounded GET /Memory — flair#1360: never the unfiltered
|
|
14149
|
+
// collection with embeddings inline), a CUE is derived from each memory
|
|
13810
14150
|
// (deriveRecallCue — its `subject` if present, else the leading ~8 words /
|
|
13811
14151
|
// first sentence of `content`; a PARTIAL cue, never the full content) and
|
|
13812
14152
|
// searched for through the EXACT SAME authenticated read path `flair memory
|
|
@@ -13869,6 +14209,53 @@ export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
|
|
|
13869
14209
|
* "first-pass default, tunable later" spirit as the thresholds above. */
|
|
13870
14210
|
export const QUALITY_RECALL_SAMPLE_SIZE = 10;
|
|
13871
14211
|
export const QUALITY_RECALL_K = 5;
|
|
14212
|
+
/**
|
|
14213
|
+
* Fields the recall spot-check and the quality-snapshot lookup actually
|
|
14214
|
+
* read. Harper REST `select(...)` (same syntax adk-flair-js's listMemories
|
|
14215
|
+
* already uses) projects these server-side so the nightly sweep never
|
|
14216
|
+
* pulls embedding vectors inline — the defect in flair#1360 was an
|
|
14217
|
+
* unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
|
|
14218
|
+
* vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
|
|
14219
|
+
* 10 memories. `type` is intentionally omitted: it is not a declared
|
|
14220
|
+
* Memory column (see schemas/memory.graphql); snapshot exclusion keys
|
|
14221
|
+
* off `subject` (`quality-snapshot/…`).
|
|
14222
|
+
*/
|
|
14223
|
+
export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
|
|
14224
|
+
/**
|
|
14225
|
+
* Extra most-recent rows fetched beyond `sampleSize` so
|
|
14226
|
+
* `planRecallSpotCheck` can drop the sweep's own quality-snapshot
|
|
14227
|
+
* bookkeeping and still fill a 10-row window — without scanning the
|
|
14228
|
+
* table. Nightly `--emit` writes one snapshot per run; 16 is a buffer
|
|
14229
|
+
* for a few extra `--emit`s in the same recency window, not a second
|
|
14230
|
+
* full-table read.
|
|
14231
|
+
*/
|
|
14232
|
+
export const QUALITY_RECALL_SNAPSHOT_OVERFETCH = 16;
|
|
14233
|
+
/**
|
|
14234
|
+
* Harper REST collection path for the recall spot-check's sample fetch:
|
|
14235
|
+
* agent-scoped, projected (never `embedding`), recency-sorted, bounded.
|
|
14236
|
+
* `limit(start,end)` is Harper's offset window — same as
|
|
14237
|
+
* packages/adk-flair-js/src/memory_service.ts.
|
|
14238
|
+
*/
|
|
14239
|
+
export function qualityRecallSamplePath(agentId, sampleSize = QUALITY_RECALL_SAMPLE_SIZE) {
|
|
14240
|
+
const select = QUALITY_MEMORY_LIST_SELECT.join(",");
|
|
14241
|
+
const end = sampleSize + QUALITY_RECALL_SNAPSHOT_OVERFETCH;
|
|
14242
|
+
return `/Memory?agentId=${encodeURIComponent(agentId)}&select(${select})&sort(-createdAt)&limit(0,${end})`;
|
|
14243
|
+
}
|
|
14244
|
+
/**
|
|
14245
|
+
* Harper REST collection path for the previous quality-snapshot lookup:
|
|
14246
|
+
* same projection as the sample fetch (never `embedding`). Subject is
|
|
14247
|
+
* passed as a query equals (indexed) plus a client-side re-filter —
|
|
14248
|
+
* Memory.search() historically did not turn bare query params into
|
|
14249
|
+
* conditions beyond the signed agent scope, so the client-side filter
|
|
14250
|
+
* in fetchPreviousQualitySnapshot stays as defense in depth. No `limit`:
|
|
14251
|
+
* a bounded window could miss yesterday's snapshot after a busy day of
|
|
14252
|
+
* writes, and without a reliable server-side subject pushdown that
|
|
14253
|
+
* would silently look like a first run.
|
|
14254
|
+
*/
|
|
14255
|
+
export function qualitySnapshotLookupPath(agentId, subject) {
|
|
14256
|
+
const select = QUALITY_MEMORY_LIST_SELECT.join(",");
|
|
14257
|
+
return `/Memory?agentId=${encodeURIComponent(agentId)}&subject=${encodeURIComponent(subject)}&select(${select})&sort(-createdAt)`;
|
|
14258
|
+
}
|
|
13872
14259
|
/** Leading-word cap on the content-derived cue. 25, matching the arm of the
|
|
13873
14260
|
* flair#967 A/B that was actually measured (same 10 memories, same instance,
|
|
13874
14261
|
* same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
|
|
@@ -14230,23 +14617,25 @@ export function computeQualityReport(healthy, healthData, opts = {}) {
|
|
|
14230
14617
|
* `agentId`'s own memories and, for each, search for a cue derived from it.
|
|
14231
14618
|
* Reuses the EXACT read path `flair memory search` / `flair memory list`
|
|
14232
14619
|
* use — `api()` (→ authedRequest's 5-tier resolver) for both the
|
|
14233
|
-
* `GET /Memory
|
|
14234
|
-
*
|
|
14235
|
-
*
|
|
14236
|
-
*
|
|
14237
|
-
*
|
|
14238
|
-
*
|
|
14620
|
+
* projected, bounded `GET /Memory?…&select(…)&limit(…)` sample fetch
|
|
14621
|
+
* (flair#1360 — never the unfiltered collection with embeddings inline)
|
|
14622
|
+
* and the `POST /SemanticSearch` queries — so this has zero new endpoint
|
|
14623
|
+
* and zero new auth mechanism; it is scoped to `agentId`'s own memories
|
|
14624
|
+
* exactly as those commands already are. Never throws: every failure mode
|
|
14625
|
+
* (no agentId, fewer than `sampleSize` memories, a fetch/search error)
|
|
14626
|
+
* returns `{ ok: false, skipReason }` for computeQualityReport to turn
|
|
14627
|
+
* into a `gaps` entry.
|
|
14239
14628
|
*/
|
|
14240
|
-
async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
14629
|
+
export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
14241
14630
|
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
14242
14631
|
const k = opts.k ?? QUALITY_RECALL_K;
|
|
14632
|
+
const request = opts.request ?? api;
|
|
14243
14633
|
if (!agentId) {
|
|
14244
14634
|
return { ok: false, skipReason: "no agent identity to query as — pass --agent or set FLAIR_AGENT_ID" };
|
|
14245
14635
|
}
|
|
14246
14636
|
let all;
|
|
14247
14637
|
try {
|
|
14248
|
-
const
|
|
14249
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
|
|
14638
|
+
const raw = await request("GET", qualityRecallSamplePath(agentId, sampleSize), undefined, { baseUrl, agentId });
|
|
14250
14639
|
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
14251
14640
|
}
|
|
14252
14641
|
catch (err) {
|
|
@@ -14276,7 +14665,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
14276
14665
|
try {
|
|
14277
14666
|
for (const { id, cue } of plan.sampled) {
|
|
14278
14667
|
const body = { agentId, q: cue, limit: k };
|
|
14279
|
-
const res = await
|
|
14668
|
+
const res = await request("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
14280
14669
|
const results = Array.isArray(res) ? res : (res?.results ?? []);
|
|
14281
14670
|
sampledIds.push(id);
|
|
14282
14671
|
perQueryResultIds.push(results.map((r) => String(r.id)));
|
|
@@ -14494,21 +14883,23 @@ export function qualitySnapshotSubject(baseUrl) {
|
|
|
14494
14883
|
return `quality-snapshot/${host}`;
|
|
14495
14884
|
}
|
|
14496
14885
|
/** Fetch the most recent prior quality snapshot for `agentId` at `baseUrl`,
|
|
14497
|
-
* via the
|
|
14498
|
-
*
|
|
14499
|
-
*
|
|
14500
|
-
* `
|
|
14501
|
-
*
|
|
14502
|
-
*
|
|
14503
|
-
*
|
|
14504
|
-
*
|
|
14505
|
-
*
|
|
14506
|
-
*
|
|
14507
|
-
|
|
14886
|
+
* via the same signed `GET /Memory` read path fetchRecallSpotCheckData
|
|
14887
|
+
* uses (self-scoped by the signed request's own agent identity — no new
|
|
14888
|
+
* endpoint). Projects the same fields (never embeddings — flair#1360) and
|
|
14889
|
+
* asks for `subject` as a query equals; still filters client-side by
|
|
14890
|
+
* subject because Memory.search() historically did not turn bare query
|
|
14891
|
+
* params into search conditions beyond the signed agentId scope (see
|
|
14892
|
+
* resources/Memory.ts's search()), same client-side-filter pattern
|
|
14893
|
+
* `memory list --hash-fallback` already uses. Returns null on: no prior
|
|
14894
|
+
* snapshot, a fetch error, or a snapshot row whose content isn't
|
|
14895
|
+
* parseable/versioned JSON (never throws — a corrupt or foreign row
|
|
14896
|
+
* degrades to "no snapshot", same as a genuine first run, rather than
|
|
14897
|
+
* crashing `--emit`). */
|
|
14898
|
+
export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, opts = {}) {
|
|
14899
|
+
const request = opts.request ?? api;
|
|
14508
14900
|
let all;
|
|
14509
14901
|
try {
|
|
14510
|
-
const
|
|
14511
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
|
|
14902
|
+
const raw = await request("GET", qualitySnapshotLookupPath(agentId, subject), undefined, { baseUrl, agentId });
|
|
14512
14903
|
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
14513
14904
|
}
|
|
14514
14905
|
catch {
|
|
@@ -14976,9 +15367,8 @@ function parseEntitiesOptionOrExit(csv) {
|
|
|
14976
15367
|
}
|
|
14977
15368
|
const ENTITIES_OPTION_DESCRIPTION = "Comma-separated entity vocabulary strings this record touches (type:value from the closed type set, e.g. repo:tpsdev-ai/flair — see docs/entity-vocabulary.md; feeds `flair attention`)";
|
|
14978
15369
|
const memory = program.command("memory").description("Manage agent memories");
|
|
14979
|
-
memory.command("add [content]")
|
|
14980
|
-
.description("Write a new memory row for an agent (content via positional arg or --content)")
|
|
14981
|
-
.requiredOption("--agent <id>")
|
|
15370
|
+
addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]")
|
|
15371
|
+
.description("Write a new memory row for an agent (content via positional arg or --content)")))
|
|
14982
15372
|
.option("--content <text>", "memory content (alias for positional arg)")
|
|
14983
15373
|
.option("--durability <d>", "permanent|persistent|standard|ephemeral (default standard). Also decides the default visibility when --visibility is omitted: permanent/persistent -> shared, standard/ephemeral -> private").option("--tags <csv>")
|
|
14984
15374
|
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
|
|
@@ -14992,10 +15382,15 @@ memory.command("add [content]")
|
|
|
14992
15382
|
console.error("error: content required (positional arg or --content)");
|
|
14993
15383
|
process.exit(1);
|
|
14994
15384
|
}
|
|
14995
|
-
|
|
14996
|
-
const
|
|
15385
|
+
applyAdminPassFile(opts);
|
|
15386
|
+
const agentId = resolveSigningAgentId(opts, "memory add");
|
|
15387
|
+
if (!agentId) {
|
|
15388
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15389
|
+
process.exit(2);
|
|
15390
|
+
}
|
|
15391
|
+
const memId = `${agentId}-${Date.now()}`;
|
|
14997
15392
|
const body = {
|
|
14998
|
-
id: memId, agentId
|
|
15393
|
+
id: memId, agentId, content, durability: opts.durability || "standard",
|
|
14999
15394
|
tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
|
|
15000
15395
|
type: "memory", createdAt: new Date().toISOString(),
|
|
15001
15396
|
};
|
|
@@ -15028,7 +15423,11 @@ memory.command("add [content]")
|
|
|
15028
15423
|
if (entities.length > 0)
|
|
15029
15424
|
body.entities = entities;
|
|
15030
15425
|
}
|
|
15031
|
-
const out = await api("PUT", `/Memory/${memId}`, body, {
|
|
15426
|
+
const out = await api("PUT", `/Memory/${memId}`, body, {
|
|
15427
|
+
agentId,
|
|
15428
|
+
explicitAdminPass: opts.adminPass,
|
|
15429
|
+
adminUser: opts.adminUser,
|
|
15430
|
+
});
|
|
15032
15431
|
console.log(JSON.stringify(out, null, 2));
|
|
15033
15432
|
});
|
|
15034
15433
|
// ─── flair memory write-task-summary ────────────────────────────────────────
|
|
@@ -16274,7 +16673,14 @@ bridge
|
|
|
16274
16673
|
process.exit(2);
|
|
16275
16674
|
}
|
|
16276
16675
|
try {
|
|
16277
|
-
const result = await runRoundTrip({
|
|
16676
|
+
const result = await runRoundTrip({
|
|
16677
|
+
descriptor: loaded.descriptor,
|
|
16678
|
+
cwd,
|
|
16679
|
+
fixturePath: opts.fixture,
|
|
16680
|
+
// Keep the intermediate export so a failure can print a live path.
|
|
16681
|
+
// The next harness start sweeps leftovers older than a minute (flair#1032).
|
|
16682
|
+
retainTmpDir: true,
|
|
16683
|
+
});
|
|
16278
16684
|
if (opts.json) {
|
|
16279
16685
|
console.log(JSON.stringify(result, null, 2));
|
|
16280
16686
|
process.exit(result.passed ? 0 : 1);
|
|
@@ -16481,31 +16887,19 @@ function printTrustError(detail) {
|
|
|
16481
16887
|
}
|
|
16482
16888
|
}
|
|
16483
16889
|
// ─── flair backup ────────────────────────────────────────────────────────────
|
|
16484
|
-
program
|
|
16890
|
+
addSharedCredentialOptions(program
|
|
16485
16891
|
.command("backup")
|
|
16486
16892
|
.description("Export agents, memories, and souls to a JSON archive")
|
|
16487
16893
|
.option("--output <path>", "Output file path (default: ~/.flair/backups/flair-backup-<timestamp>.json)")
|
|
16488
16894
|
.option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
|
|
16489
16895
|
.option("--port <port>", "Harper HTTP port")
|
|
16490
|
-
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16491
|
-
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
|
|
16492
|
-
.option("--admin-pass-file <path>", "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
|
|
16493
|
-
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16494
|
-
.action(async (opts) => {
|
|
16896
|
+
.option("--url <url>", "Flair base URL (overrides --port)")).action(async (opts) => {
|
|
16495
16897
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
16496
|
-
|
|
16497
|
-
|
|
16498
|
-
|
|
16499
|
-
|
|
16500
|
-
|
|
16501
|
-
try {
|
|
16502
|
-
adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
16503
|
-
}
|
|
16504
|
-
catch (err) {
|
|
16505
|
-
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
16506
|
-
process.exit(1);
|
|
16507
|
-
}
|
|
16508
|
-
}
|
|
16898
|
+
applyAdminPassFile(opts);
|
|
16899
|
+
// Env is a second-class fallback after the explicit flags (same order
|
|
16900
|
+
// backup used before the shared helper). FLAIR_ADMIN_PASS is still
|
|
16901
|
+
// accepted so existing scripts keep working.
|
|
16902
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
16509
16903
|
const adminUser = resolveAdminUser(opts.adminUser);
|
|
16510
16904
|
if (!adminPass) {
|
|
16511
16905
|
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
|