@tpsdev-ai/flair 0.48.0 → 0.50.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 +2 -0
- package/dist/bridges/runtime/roundtrip.js +91 -2
- package/dist/build-info.json +3 -3
- package/dist/cli.js +903 -226
- package/dist/component-env.js +52 -4
- package/dist/deploy.js +20 -3
- package/dist/doctor-client.js +105 -32
- package/dist/federation/scheduler.js +24 -3
- package/dist/hook-install.js +96 -16
- package/dist/install/clients.js +318 -9
- package/dist/lib/auth-resolve.js +34 -3
- package/dist/lib/mcp-enable.js +134 -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/AgentSeed.js +2 -0
- package/dist/resources/Memory.js +24 -5
- package/dist/resources/MemoryBootstrap.js +8 -4
- package/dist/resources/MemoryFeed.js +3 -0
- package/dist/resources/MemoryMaintenance.js +11 -2
- package/dist/resources/bm25-index-service.js +257 -0
- package/dist/resources/bm25-index.js +631 -0
- package/dist/resources/bm25.js +31 -1
- package/dist/resources/embeddings-boot.js +45 -3
- package/dist/resources/health.js +52 -7
- package/dist/resources/mcp-tools.js +1 -0
- package/dist/resources/memory-read-scope.js +2 -0
- package/dist/resources/search-readiness.js +100 -0
- package/dist/resources/semantic-retrieval-core.js +102 -23
- package/dist/resources/sort-comparators.js +45 -0
- package/dist/src/lib/scheduler-platform.js +132 -10
- package/dist/src/rem/scheduler.js +23 -5
- package/dist/version-check.js +59 -13
- package/docs/auth.md +5 -0
- package/docs/claude-code.md +10 -3
- package/docs/deepseek-harness.md +1 -1
- package/docs/deployment.md +11 -1
- package/docs/hosted-on-fabric.md +2 -0
- package/docs/integrations.md +78 -5
- package/docs/mcp-clients.md +85 -15
- package/docs/notes/mcp-oauth-model2.md +31 -13
- package/docs/quickstart-fabric.md +1 -1
- package/docs/quickstart.md +9 -9
- package/docs/standalone-local.md +3 -0
- package/docs/troubleshooting.md +25 -0
- package/package.json +4 -3
package/dist/cli.js
CHANGED
|
@@ -20,14 +20,14 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
|
|
|
20
20
|
import { probeInstance } from "./probe.js";
|
|
21
21
|
import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
|
|
22
22
|
import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
|
|
23
|
-
import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
|
|
23
|
+
import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, wirePi, piFlairSpec, PI_FLAIR_PACKAGE, PI_FLAIR_DEFAULT_URL, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
|
|
24
24
|
import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
|
|
25
25
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
26
26
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
27
|
-
import { readClientMcpBlock, effectiveFlairUrl, 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";
|
|
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, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
30
|
-
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.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
|
+
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";
|
|
@@ -141,7 +141,8 @@ function shouldShowInlineSecretWarning(optValue, fromEnv, secretFlagNames, flagN
|
|
|
141
141
|
const DEFAULT_PORT = 19926;
|
|
142
142
|
const DEFAULT_OPS_PORT = 19925;
|
|
143
143
|
const FABRIC_OPS_PORT = 9925;
|
|
144
|
-
|
|
144
|
+
// DEFAULT_ADMIN_USER + resolveAdminUser (flag > FLAIR_ADMIN_USER env > "admin")
|
|
145
|
+
// live in src/lib/auth-resolve.ts — imported above (flair#1345).
|
|
145
146
|
const STARTUP_TIMEOUT_MS = 60_000;
|
|
146
147
|
const HEALTH_POLL_INTERVAL_MS = 500;
|
|
147
148
|
// flair#670 — single-host default for the Harper ops API bind address.
|
|
@@ -684,6 +685,48 @@ function resolveSigningIdentityFor(opts, command) {
|
|
|
684
685
|
function resolveSigningAgentId(opts, command) {
|
|
685
686
|
return resolveSigningIdentityFor(opts, command).agentId;
|
|
686
687
|
}
|
|
688
|
+
// ── Shared credential/identity flag surface (flair#1106) ─────────────────────
|
|
689
|
+
// Sibling commands (memory add, backup, federation sync) used to drift on
|
|
690
|
+
// the same concepts: `--admin-pass-file` existed on backup/sync but was an
|
|
691
|
+
// unknown option on `memory add`, and `memory add --agent` was a commander
|
|
692
|
+
// requiredOption so FLAIR_AGENT_ID could never satisfy it. One helper owns
|
|
693
|
+
// the credential flag names/shapes; identity (`--agent`) stays optional so
|
|
694
|
+
// the env fallback can actually apply. This does not invent a new auth
|
|
695
|
+
// model — it only declares the flags authedRequest already resolves.
|
|
696
|
+
/** Flag strings the sibling commands must share (name + argument shape). */
|
|
697
|
+
export const SHARED_CREDENTIAL_FLAGS = {
|
|
698
|
+
adminPass: "--admin-pass <pass>",
|
|
699
|
+
adminPassFile: "--admin-pass-file <path>",
|
|
700
|
+
adminUser: "--admin-user <name>",
|
|
701
|
+
};
|
|
702
|
+
export const SHARED_IDENTITY_FLAGS = {
|
|
703
|
+
agent: "--agent <id>",
|
|
704
|
+
};
|
|
705
|
+
function addSharedCredentialOptions(cmd) {
|
|
706
|
+
return cmd
|
|
707
|
+
.option(SHARED_CREDENTIAL_FLAGS.adminPass, "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
|
|
708
|
+
.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.")
|
|
709
|
+
.option(SHARED_CREDENTIAL_FLAGS.adminUser, "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)");
|
|
710
|
+
}
|
|
711
|
+
function addSharedIdentityOption(cmd) {
|
|
712
|
+
return cmd.option(SHARED_IDENTITY_FLAGS.agent, "Agent ID (or set FLAIR_AGENT_ID env)");
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Resolve `--admin-pass-file` into the same `adminPass` slot the inline flag
|
|
716
|
+
* uses. Shared so sibling commands cannot drift on how the file is read
|
|
717
|
+
* (mode 0600 via readAdminPassFileSecure).
|
|
718
|
+
*/
|
|
719
|
+
function applyAdminPassFile(opts) {
|
|
720
|
+
if (!opts.adminPass && opts.adminPassFile) {
|
|
721
|
+
try {
|
|
722
|
+
opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
726
|
+
process.exit(1);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
687
730
|
// Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
|
|
688
731
|
//
|
|
689
732
|
// Deliberately NOT routed through Harper's per-instance config the way
|
|
@@ -1428,7 +1471,13 @@ async function api(method, path, body, options) {
|
|
|
1428
1471
|
agentId = decodeURIComponent(match[1]);
|
|
1429
1472
|
}
|
|
1430
1473
|
}
|
|
1431
|
-
return authedRequest(method, path, body, {
|
|
1474
|
+
return authedRequest(method, path, body, {
|
|
1475
|
+
baseUrl: base,
|
|
1476
|
+
agentId,
|
|
1477
|
+
keysDir: options?.keysDir,
|
|
1478
|
+
explicitAdminPass: options?.explicitAdminPass,
|
|
1479
|
+
adminUser: options?.adminUser,
|
|
1480
|
+
});
|
|
1432
1481
|
}
|
|
1433
1482
|
/**
|
|
1434
1483
|
* The authedGet `flair upgrade` verification (flair#635/#741) hands to
|
|
@@ -1905,6 +1954,27 @@ function readHarperPid(dataDir) {
|
|
|
1905
1954
|
return null;
|
|
1906
1955
|
}
|
|
1907
1956
|
}
|
|
1957
|
+
/**
|
|
1958
|
+
* flair#1345 — Harper returns the SAME 401 `{"error":"Login failed"}` for a
|
|
1959
|
+
* wrong password and for a nonexistent username, and the CLI's errors used
|
|
1960
|
+
* to hint only at the password. On an instance whose superuser is not named
|
|
1961
|
+
* `admin` (now reachable in practice: the #604/#610 `authorizeLocal: false`
|
|
1962
|
+
* hardening removed the credential-less loopback path, so these calls MUST
|
|
1963
|
+
* send real Basic auth) that sent operators down the wrong trail entirely.
|
|
1964
|
+
* Name both causes, each with the knob that fixes it.
|
|
1965
|
+
*/
|
|
1966
|
+
function opsAuth401Hint(adminUser) {
|
|
1967
|
+
if (adminUser === undefined) {
|
|
1968
|
+
// No credentials were sent at all (local caller riding authorizeLocal) —
|
|
1969
|
+
// "wrong password or username" would be asserting a cause that isn't
|
|
1970
|
+
// established. The remedy is to send credentials.
|
|
1971
|
+
return ("\n No admin credentials were sent and the instance rejected the request." +
|
|
1972
|
+
"\n Pass --admin-pass <pass> or --admin-pass-file <path> (and --admin-user <name> if the superuser is not 'admin').");
|
|
1973
|
+
}
|
|
1974
|
+
return (`\n The operations API rejected the admin credentials (tried username '${adminUser}'). Two possible causes:` +
|
|
1975
|
+
"\n - wrong password — check --admin-pass / --admin-pass-file / FLAIR_ADMIN_PASS" +
|
|
1976
|
+
`\n - wrong username — this instance's superuser may not be '${adminUser}'; pass --admin-user <name> or set FLAIR_ADMIN_USER`);
|
|
1977
|
+
}
|
|
1908
1978
|
/**
|
|
1909
1979
|
* Seed an agent record via the Harper operations API.
|
|
1910
1980
|
* Accepts either a port number (localhost) or a full URL string (--target).
|
|
@@ -1958,6 +2028,9 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
|
|
|
1958
2028
|
const text = await res.text().catch(() => "");
|
|
1959
2029
|
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
1960
2030
|
return;
|
|
2031
|
+
if (res.status === 401) {
|
|
2032
|
+
throw new Error(`Operations API insert failed (401): ${text}${opsAuth401Hint(auth === undefined ? undefined : adminUser)}`);
|
|
2033
|
+
}
|
|
1961
2034
|
throw new Error(`Operations API insert failed (${res.status}): ${text}`);
|
|
1962
2035
|
}
|
|
1963
2036
|
}
|
|
@@ -2009,6 +2082,9 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
|
|
|
2009
2082
|
const text = await res.text().catch(() => "");
|
|
2010
2083
|
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
2011
2084
|
return;
|
|
2085
|
+
if (res.status === 401) {
|
|
2086
|
+
throw new Error(`Federation Instance insert via ops API failed (401): ${text}${opsAuth401Hint(auth === undefined ? undefined : adminUser)}`);
|
|
2087
|
+
}
|
|
2012
2088
|
throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
|
|
2013
2089
|
}
|
|
2014
2090
|
}
|
|
@@ -2586,9 +2662,9 @@ export function upgradeStatusSuffix(name, status) {
|
|
|
2586
2662
|
* is `flair doctor --fix`, never `npm install -g`.
|
|
2587
2663
|
* 3. Wired with a concrete pin — that pin IS the installed version
|
|
2588
2664
|
* (current when it equals latest, else outdated → re-pin via doctor).
|
|
2589
|
-
* 4. Wired but unpinned (a bare npx spec /
|
|
2590
|
-
* re-resolves latest every session, so the effective version IS
|
|
2591
|
-
* current.
|
|
2665
|
+
* 4. Wired but unpinned (a bare npx spec / a pre-#1143 SessionStart hook) —
|
|
2666
|
+
* `npx -y` re-resolves latest every session, so the effective version IS
|
|
2667
|
+
* latest → current.
|
|
2592
2668
|
*/
|
|
2593
2669
|
export function resolveFlairMcpFinding(globalProbe, latest, wiring) {
|
|
2594
2670
|
// 1. Legacy global install.
|
|
@@ -2917,11 +2993,12 @@ program
|
|
|
2917
2993
|
.option("--ops-bind <addr>", "Harper ops API bind address (env: FLAIR_OPS_BIND; default: 127.0.0.1 loopback-only for single-host — pass e.g. 0.0.0.0 for multi-host/Fabric remote admin)")
|
|
2918
2994
|
.option("--admin-pass <pass>", "Admin password (generated if omitted)")
|
|
2919
2995
|
.option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
|
|
2996
|
+
.option("--admin-user <name>", "Admin username when authenticating to an already-running instance via --target/--ops-target (env: FLAIR_ADMIN_USER; default: admin — local bootstrap and Fabric provisioning always create 'admin')")
|
|
2920
2997
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
2921
2998
|
.option("--data-dir <dir>", "Harper data directory")
|
|
2922
2999
|
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
2923
3000
|
.option("--skip-soul", "Skip interactive personality setup")
|
|
2924
|
-
.option("--client <client>", "
|
|
3001
|
+
.option("--client <client>", "Client(s) to wire: claude-code, codex, gemini, cursor, antigravity, pi (native extension), all, or none")
|
|
2925
3002
|
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
2926
3003
|
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
2927
3004
|
.option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
|
|
@@ -3005,7 +3082,10 @@ program
|
|
|
3005
3082
|
}
|
|
3006
3083
|
flairAdminPass = opts.adminPass;
|
|
3007
3084
|
}
|
|
3008
|
-
|
|
3085
|
+
// flair#1345: only the already-running-instance leg honors --admin-user /
|
|
3086
|
+
// FLAIR_ADMIN_USER — the provisioning leg just CREATED the superuser as
|
|
3087
|
+
// DEFAULT_ADMIN_USER via provisionFabric, so that name is ground truth.
|
|
3088
|
+
const adminUser = didProvision ? DEFAULT_ADMIN_USER : resolveAdminUser(opts.adminUser);
|
|
3009
3089
|
const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
|
|
3010
3090
|
const role = opts.remote ? "hub" : undefined;
|
|
3011
3091
|
// Generate or reuse keypair (only if --agent-id provided, or --remote needs
|
|
@@ -3140,9 +3220,9 @@ program
|
|
|
3140
3220
|
const noMcp = opts.mcp === false;
|
|
3141
3221
|
const selectedClients = [];
|
|
3142
3222
|
if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
|
|
3143
|
-
const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
|
|
3223
|
+
const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity", "pi"];
|
|
3144
3224
|
if (!valid.includes(clientOpt)) {
|
|
3145
|
-
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, all, none`);
|
|
3225
|
+
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, pi, all, none`);
|
|
3146
3226
|
process.exit(1);
|
|
3147
3227
|
}
|
|
3148
3228
|
selectedClients.push(clientOpt);
|
|
@@ -3773,6 +3853,13 @@ program
|
|
|
3773
3853
|
case "antigravity":
|
|
3774
3854
|
result = wireAntigravity({ ...mcpEnv, FLAIR_CLIENT: "antigravity" });
|
|
3775
3855
|
break;
|
|
3856
|
+
// pi is a NATIVE EXTENSION, not an MCP client (flair#1342):
|
|
3857
|
+
// wirePi edits ~/.pi/agent/settings.json `packages`, and pi
|
|
3858
|
+
// settings carry no env block — no FLAIR_CLIENT to stamp; the
|
|
3859
|
+
// wire message tells the user what to export at pi launch.
|
|
3860
|
+
case "pi":
|
|
3861
|
+
result = wirePi(mcpEnv);
|
|
3862
|
+
break;
|
|
3776
3863
|
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
3777
3864
|
}
|
|
3778
3865
|
wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
|
|
@@ -3784,7 +3871,12 @@ program
|
|
|
3784
3871
|
// Launch flair-mcp and confirm it answers a JSON-RPC initialize over
|
|
3785
3872
|
// stdio. Best-effort: failures warn but never fail the command. Skipped
|
|
3786
3873
|
// with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
|
|
3787
|
-
|
|
3874
|
+
// pi doesn't run flair-mcp (native extension, flair#1342), so a pi-only
|
|
3875
|
+
// wiring has nothing this smoke test exercises — spawning it anyway
|
|
3876
|
+
// would render a green "MCP server responded" for a setup that never
|
|
3877
|
+
// starts an MCP server.
|
|
3878
|
+
const wiredAnyMcpClient = wiringResults.some((r) => r.client !== "pi");
|
|
3879
|
+
if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0 && wiredAnyMcpClient) {
|
|
3788
3880
|
console.log("\n Smoke-testing MCP server...");
|
|
3789
3881
|
try {
|
|
3790
3882
|
// Same spec that gets WIRED above — the smoke test must exercise the
|
|
@@ -3934,6 +4026,7 @@ agent
|
|
|
3934
4026
|
.option("--port <port>", "Harper HTTP port")
|
|
3935
4027
|
.option("--admin-pass <pass>", "Admin password for registration")
|
|
3936
4028
|
.option("--admin-pass-file <path>", "Read the admin password from a file (chmod 600 enforced). Preferred over inline --admin-pass — keeps the secret out of ps and shell history; works for remote targets too (an explicit flag is operator intent).")
|
|
4029
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
3937
4030
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
3938
4031
|
.option("--ops-port <port>", "Harper operations API port")
|
|
3939
4032
|
.option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
|
|
@@ -3942,7 +4035,7 @@ agent
|
|
|
3942
4035
|
const httpPort = resolveHttpPort(opts);
|
|
3943
4036
|
const opsPort = resolveOpsPort(opts);
|
|
3944
4037
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
3945
|
-
const adminUser =
|
|
4038
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
3946
4039
|
const name = opts.name ?? id;
|
|
3947
4040
|
// Where to seed the Agent record. Default is localhost (opsPort). When
|
|
3948
4041
|
// --ops-target or --target is given, seed on the remote instead of localhost
|
|
@@ -4026,6 +4119,7 @@ agent
|
|
|
4026
4119
|
.command("list")
|
|
4027
4120
|
.description("List all agents")
|
|
4028
4121
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
4122
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
4029
4123
|
.option("--agent <id>", "Agent ID to authenticate as via Ed25519 (or FLAIR_AGENT_ID env) when no admin pass")
|
|
4030
4124
|
.option("--keys-dir <dir>", "Directory holding the agent's Ed25519 key")
|
|
4031
4125
|
.option("--port <port>", "Harper HTTP port")
|
|
@@ -4043,7 +4137,7 @@ agent
|
|
|
4043
4137
|
let agents;
|
|
4044
4138
|
if (adminPass) {
|
|
4045
4139
|
const opsPort = resolveOpsPort(opts);
|
|
4046
|
-
const auth = Buffer.from(`${
|
|
4140
|
+
const auth = Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64");
|
|
4047
4141
|
// List every Agent without null-scanning the primary key. A
|
|
4048
4142
|
// `starts_with ""` on `id` makes Harper search the index for nulls, which
|
|
4049
4143
|
// the bundled Harper (5.0.21) rejects with "id is not indexed for nulls".
|
|
@@ -4163,6 +4257,7 @@ agent
|
|
|
4163
4257
|
.option("--port <port>", "Harper HTTP port")
|
|
4164
4258
|
.option("--ops-port <port>", "Harper operations API port")
|
|
4165
4259
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
4260
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
4166
4261
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
4167
4262
|
.action(async (id, opts) => {
|
|
4168
4263
|
const httpPort = resolveHttpPort(opts);
|
|
@@ -4174,7 +4269,7 @@ agent
|
|
|
4174
4269
|
"to keep secrets out of shell history.");
|
|
4175
4270
|
}
|
|
4176
4271
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
4177
|
-
const adminUser =
|
|
4272
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
4178
4273
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
4179
4274
|
if (!adminPass) {
|
|
4180
4275
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for key rotation");
|
|
@@ -4247,12 +4342,13 @@ agent
|
|
|
4247
4342
|
.option("--port <port>", "Harper HTTP port")
|
|
4248
4343
|
.option("--ops-port <port>", "Harper operations API port")
|
|
4249
4344
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
4345
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
4250
4346
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
4251
4347
|
.option("--force", "Skip interactive confirmation (required when stdin is not a TTY)")
|
|
4252
4348
|
.action(async (id, opts) => {
|
|
4253
4349
|
const opsPort = resolveOpsPort(opts);
|
|
4254
4350
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
4255
|
-
const adminUser =
|
|
4351
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
4256
4352
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
4257
4353
|
if (!adminPass) {
|
|
4258
4354
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for agent remove");
|
|
@@ -4525,18 +4621,12 @@ keys
|
|
|
4525
4621
|
// dry-run delta, symmetric removal) lives in src/hook-install.ts — this
|
|
4526
4622
|
// section is pure CLI plumbing: option parsing, default resolution, and
|
|
4527
4623
|
// rendering the pure functions' results.
|
|
4528
|
-
function
|
|
4529
|
-
return (opts.agent ||
|
|
4530
|
-
opts.agentId ||
|
|
4531
|
-
process.env.FLAIR_AGENT_ID ||
|
|
4532
|
-
readClientMcpBlock("claude-code", homeDir).agentId ||
|
|
4533
|
-
undefined);
|
|
4534
|
-
}
|
|
4535
|
-
function resolveHookFlairUrl(opts, homeDir) {
|
|
4624
|
+
function resolveHookFlairUrl(opts, homeDir, harness) {
|
|
4536
4625
|
return (opts.url ||
|
|
4537
4626
|
process.env.FLAIR_TARGET ||
|
|
4538
4627
|
process.env.FLAIR_URL ||
|
|
4539
|
-
readClientMcpBlock(
|
|
4628
|
+
readClientMcpBlock(harness, homeDir).flairUrl ||
|
|
4629
|
+
(harness !== "claude-code" ? readClientMcpBlock("claude-code", homeDir).flairUrl : undefined) ||
|
|
4540
4630
|
resolveBaseUrl({}));
|
|
4541
4631
|
}
|
|
4542
4632
|
function requireSupportedHarness(raw) {
|
|
@@ -4553,19 +4643,19 @@ hook
|
|
|
4553
4643
|
.description("Wire the Flair SessionStart hook into the harness config so memory loads automatically at session start")
|
|
4554
4644
|
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
4555
4645
|
.option("--dry-run", "Print the exact JSON delta without writing")
|
|
4556
|
-
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for
|
|
4646
|
+
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for this harness's MCP client)")
|
|
4557
4647
|
.option("--agent-id <id>", "Alias for --agent")
|
|
4558
|
-
.option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else
|
|
4648
|
+
.option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else this harness's MCP wiring, else the local default)")
|
|
4559
4649
|
.option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
|
|
4560
4650
|
.action((opts) => {
|
|
4561
4651
|
const harness = requireSupportedHarness(opts.harness);
|
|
4562
4652
|
const home = homedir();
|
|
4563
|
-
const agentId = resolveHookAgentId(opts, home);
|
|
4653
|
+
const agentId = resolveHookAgentId(opts, home, harness);
|
|
4564
4654
|
if (!agentId) {
|
|
4565
4655
|
console.error("No agent id known — pass --agent <id>, set FLAIR_AGENT_ID, or run `flair init` / `flair agent add` first.");
|
|
4566
4656
|
process.exit(1);
|
|
4567
4657
|
}
|
|
4568
|
-
const flairUrl = resolveHookFlairUrl(opts, home);
|
|
4658
|
+
const flairUrl = resolveHookFlairUrl(opts, home, harness);
|
|
4569
4659
|
const dryRun = !!opts.dryRun;
|
|
4570
4660
|
if (opts.continuity) {
|
|
4571
4661
|
const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
@@ -4582,6 +4672,11 @@ hook
|
|
|
4582
4672
|
const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
4583
4673
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4584
4674
|
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
4675
|
+
const pinWarning = unpinnedSpecWarning();
|
|
4676
|
+
if (pinWarning && result.ok) {
|
|
4677
|
+
for (const line of pinWarning.split("\n"))
|
|
4678
|
+
console.error(` ⚠ ${line}`);
|
|
4679
|
+
}
|
|
4585
4680
|
if (result.backupPath) {
|
|
4586
4681
|
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
4587
4682
|
}
|
|
@@ -4641,16 +4736,20 @@ hook
|
|
|
4641
4736
|
// status in every branch below. "absent" is NOT a failure: installing the
|
|
4642
4737
|
// pair is the opt-in, so absence renders as "not enabled".
|
|
4643
4738
|
const renderContinuity = () => {
|
|
4739
|
+
// Continuity is Claude Code only. Do not tip `--continuity --harness
|
|
4740
|
+
// <other>` — that writes Claude tool matchers into the wrong file.
|
|
4741
|
+
if (!harnessSupportsContinuity(harness))
|
|
4742
|
+
return;
|
|
4644
4743
|
const cont = continuityHookStatus(home, harness);
|
|
4645
4744
|
if (cont.state === "installed") {
|
|
4646
4745
|
console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
|
|
4647
4746
|
}
|
|
4648
4747
|
else if (cont.state === "absent") {
|
|
4649
|
-
console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim,
|
|
4748
|
+
console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, `(opt-in: ${hookInstallHint(harness, "--continuity")})`)}`);
|
|
4650
4749
|
}
|
|
4651
4750
|
else {
|
|
4652
4751
|
const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
|
|
4653
|
-
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim,
|
|
4752
|
+
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, `— re-run: ${hookInstallHint(harness, "--continuity")}`)}`);
|
|
4654
4753
|
}
|
|
4655
4754
|
};
|
|
4656
4755
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
|
|
@@ -4663,21 +4762,28 @@ hook
|
|
|
4663
4762
|
}
|
|
4664
4763
|
if (!status.wired) {
|
|
4665
4764
|
console.log(` ${render.icons.error} not wired`);
|
|
4666
|
-
console.log(` ${render.wrap(render.c.dim, "Fix:")}
|
|
4765
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${hookInstallHint(status.harness)}`);
|
|
4667
4766
|
renderContinuity();
|
|
4668
4767
|
console.log("");
|
|
4669
4768
|
process.exit(1);
|
|
4670
4769
|
}
|
|
4671
4770
|
console.log(` ${status.correctShape ? render.icons.ok : render.icons.warn} wired${status.correctShape ? "" : " (unexpected shape — was it hand-edited?)"}`);
|
|
4672
|
-
|
|
4673
|
-
|
|
4771
|
+
// flair#1325 — skip the URL line only when agentId was recovered
|
|
4772
|
+
// (the installer form that omits FLAIR_URL). A wired correct-shape
|
|
4773
|
+
// command with no env assignments still prints unknown, not a
|
|
4774
|
+
// silent all-clear.
|
|
4775
|
+
for (const line of hookStatusIdentityLines(status)) {
|
|
4776
|
+
const label = line.label === "Agent" ? "Agent: " : "Flair URL:";
|
|
4777
|
+
const value = line.value === HOOK_STATUS_UNPARSED ? render.wrap(render.c.dim, line.value) : line.value;
|
|
4778
|
+
console.log(` ${render.wrap(render.c.dim, label)} ${value}`);
|
|
4779
|
+
}
|
|
4674
4780
|
// flair#1007 — whether a command that stopped resolving would fail quietly
|
|
4675
4781
|
// or print an error on every session start.
|
|
4676
4782
|
if (status.silenced) {
|
|
4677
4783
|
console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
|
|
4678
4784
|
}
|
|
4679
4785
|
else {
|
|
4680
|
-
console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`
|
|
4786
|
+
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`);
|
|
4681
4787
|
}
|
|
4682
4788
|
renderContinuity();
|
|
4683
4789
|
console.log("");
|
|
@@ -5026,6 +5132,7 @@ mcp
|
|
|
5026
5132
|
.option("--keys-dir <dir>", "Directory to write the new key pair into (else FLAIR_KEY_DIR, ~/.flair/keys)")
|
|
5027
5133
|
.option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
|
|
5028
5134
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
5135
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5029
5136
|
.option("--port <port>", "Harper HTTP port")
|
|
5030
5137
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5031
5138
|
.option("--json", "Print machine-readable JSON instead of a human summary")
|
|
@@ -5061,7 +5168,7 @@ mcp
|
|
|
5061
5168
|
manifestPath,
|
|
5062
5169
|
issuer,
|
|
5063
5170
|
opsPortOrUrl: opsPort,
|
|
5064
|
-
adminUser:
|
|
5171
|
+
adminUser: resolveAdminUser(opts.adminUser),
|
|
5065
5172
|
adminPass,
|
|
5066
5173
|
});
|
|
5067
5174
|
if (opts.json) {
|
|
@@ -5086,6 +5193,7 @@ mcp
|
|
|
5086
5193
|
.description("Server-side revoke a granted machine client (deletes its backing Agent record), then clean up locally.")
|
|
5087
5194
|
.option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
|
|
5088
5195
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
5196
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5089
5197
|
.option("--issuer <url>", "Public origin of the /mcp OAuth surface — used only for the enable-gate probe (defaults to FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
|
|
5090
5198
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5091
5199
|
.option("--port <port>", "Harper HTTP port")
|
|
@@ -5114,7 +5222,7 @@ mcp
|
|
|
5114
5222
|
name,
|
|
5115
5223
|
manifestPath,
|
|
5116
5224
|
opsPortOrUrl: opsPort,
|
|
5117
|
-
adminUser:
|
|
5225
|
+
adminUser: resolveAdminUser(opts.adminUser),
|
|
5118
5226
|
adminPass,
|
|
5119
5227
|
keepKeys: !!opts.keepKeys,
|
|
5120
5228
|
});
|
|
@@ -5205,6 +5313,7 @@ mcp
|
|
|
5205
5313
|
.option("--cimd-allowed-hosts <hosts>", "Comma-separated clientIdMetadataDocuments.allowedHosts override (else claude.ai,claude.com)")
|
|
5206
5314
|
.option("--signing-key-file <path>", "RS256 signing key PEM file (else ~/.flair/mcp-signing-key.pem)")
|
|
5207
5315
|
.option("--admin-pass <pass>", "Admin password for the TARGET instance. Required explicitly for a remote target — FLAIR_ADMIN_PASS and ~/.flair/admin-pass are this machine's local credentials and are never sent to a remote instance")
|
|
5316
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5208
5317
|
.option("--confirm-secrets-applied", "Confirm the staged secrets are already live on the target instance's environment (skips the interactive confirm)")
|
|
5209
5318
|
.option("--dry-run", "Generate keys/tokens/config and validate inputs; skip every remote call")
|
|
5210
5319
|
.option("--json", "Print machine-readable JSON instead of a human summary")
|
|
@@ -5264,7 +5373,7 @@ mcp
|
|
|
5264
5373
|
idpSubject,
|
|
5265
5374
|
principal: opts.principal,
|
|
5266
5375
|
principalKind: opts.principalKind,
|
|
5267
|
-
adminUser:
|
|
5376
|
+
adminUser: resolveAdminUser(opts.adminUser),
|
|
5268
5377
|
adminPass,
|
|
5269
5378
|
signingKeyFilePath: opts.signingKeyFile,
|
|
5270
5379
|
secretsMechanism,
|
|
@@ -5313,6 +5422,7 @@ mcp
|
|
|
5313
5422
|
.description("Flag off + restart = byte-identical boot (Model-2 contract) — removes the /mcp OAuth surface.")
|
|
5314
5423
|
.option("--instance <url>", "Remote flair instance to disable against (else FLAIR_URL)")
|
|
5315
5424
|
.option("--admin-pass <pass>", "Admin password for the target instance (or FLAIR_ADMIN_PASS)")
|
|
5425
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5316
5426
|
.option("--confirm-flag-off", "Confirm FLAIR_MCP_OAUTH is already unset on the target instance's environment (skips the interactive confirm)")
|
|
5317
5427
|
.option("--json", "Print machine-readable JSON instead of a human summary")
|
|
5318
5428
|
.action(async (opts) => {
|
|
@@ -5328,7 +5438,7 @@ mcp
|
|
|
5328
5438
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required.");
|
|
5329
5439
|
process.exit(1);
|
|
5330
5440
|
}
|
|
5331
|
-
const result = await disableMcp({ instance, adminUser:
|
|
5441
|
+
const result = await disableMcp({ instance, adminUser: resolveAdminUser(opts.adminUser), adminPass, confirmFlagOff: Boolean(opts.confirmFlagOff) }, { confirmPrompt: confirmYesNo });
|
|
5332
5442
|
if (opts.json) {
|
|
5333
5443
|
console.log(render.asJSON(result));
|
|
5334
5444
|
if (!result.ok)
|
|
@@ -5399,12 +5509,13 @@ principal
|
|
|
5399
5509
|
.option("--runtime <runtime>", "Runtime: openclaw, claude-code, headless, external")
|
|
5400
5510
|
.option("--port <port>", "Harper HTTP port")
|
|
5401
5511
|
.option("--admin-pass <pass>", "Admin password for registration")
|
|
5512
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5402
5513
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
5403
5514
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5404
5515
|
.action(async (id, opts) => {
|
|
5405
5516
|
const opsPort = resolveOpsPort(opts);
|
|
5406
5517
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
5407
|
-
const adminUser =
|
|
5518
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
5408
5519
|
const kind = opts.kind ?? "agent";
|
|
5409
5520
|
const name = opts.name ?? id;
|
|
5410
5521
|
const isAdmin = opts.admin ?? false;
|
|
@@ -5491,6 +5602,7 @@ principal
|
|
|
5491
5602
|
.description("List all principals")
|
|
5492
5603
|
.option("--kind <kind>", "Filter by kind: human or agent")
|
|
5493
5604
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
5605
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5494
5606
|
.option("--port <port>", "Harper HTTP port")
|
|
5495
5607
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5496
5608
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
@@ -5501,7 +5613,7 @@ principal
|
|
|
5501
5613
|
console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
|
|
5502
5614
|
process.exit(1);
|
|
5503
5615
|
}
|
|
5504
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5616
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5505
5617
|
const conditions = opts.kind
|
|
5506
5618
|
? [{ search_attribute: "kind", search_type: "equals", search_value: opts.kind }]
|
|
5507
5619
|
: [{ search_attribute: "id", search_type: "starts_with", search_value: "" }];
|
|
@@ -5623,6 +5735,7 @@ principal
|
|
|
5623
5735
|
.command("disable <id>")
|
|
5624
5736
|
.description("Deactivate a principal (revokes access, preserves data)")
|
|
5625
5737
|
.option("--admin-pass <pass>", "Admin password")
|
|
5738
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5626
5739
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5627
5740
|
.action(async (id, opts) => {
|
|
5628
5741
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -5631,7 +5744,7 @@ principal
|
|
|
5631
5744
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
5632
5745
|
process.exit(1);
|
|
5633
5746
|
}
|
|
5634
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5747
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5635
5748
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5636
5749
|
method: "POST",
|
|
5637
5750
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -5653,6 +5766,7 @@ principal
|
|
|
5653
5766
|
.command("promote <id> <tier>")
|
|
5654
5767
|
.description("Change a principal's trust tier (endorsed, corroborated, unverified)")
|
|
5655
5768
|
.option("--admin-pass <pass>", "Admin password")
|
|
5769
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5656
5770
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5657
5771
|
.action(async (id, tier, opts) => {
|
|
5658
5772
|
const validTiers = ["endorsed", "corroborated", "unverified"];
|
|
@@ -5666,7 +5780,7 @@ principal
|
|
|
5666
5780
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
5667
5781
|
process.exit(1);
|
|
5668
5782
|
}
|
|
5669
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5783
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5670
5784
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5671
5785
|
method: "POST",
|
|
5672
5786
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -5698,6 +5812,7 @@ idp
|
|
|
5698
5812
|
.option("--no-jit-provision", "Disable auto-creation of principals for new IdP users")
|
|
5699
5813
|
.option("--default-trust <tier>", "Trust tier for JIT principals", "unverified")
|
|
5700
5814
|
.option("--admin-pass <pass>", "Admin password")
|
|
5815
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5701
5816
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5702
5817
|
.action(async (opts) => {
|
|
5703
5818
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -5707,7 +5822,7 @@ idp
|
|
|
5707
5822
|
process.exit(1);
|
|
5708
5823
|
}
|
|
5709
5824
|
const id = `idp_${randomUUID().slice(0, 8)}`;
|
|
5710
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5825
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5711
5826
|
const now = new Date().toISOString();
|
|
5712
5827
|
const record = {
|
|
5713
5828
|
id,
|
|
@@ -5744,6 +5859,7 @@ idp
|
|
|
5744
5859
|
.command("list")
|
|
5745
5860
|
.description("List configured IdPs")
|
|
5746
5861
|
.option("--admin-pass <pass>", "Admin password")
|
|
5862
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5747
5863
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5748
5864
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
5749
5865
|
.action(async (opts) => {
|
|
@@ -5753,7 +5869,7 @@ idp
|
|
|
5753
5869
|
console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
|
|
5754
5870
|
process.exit(1);
|
|
5755
5871
|
}
|
|
5756
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5872
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5757
5873
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5758
5874
|
method: "POST",
|
|
5759
5875
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -5798,6 +5914,7 @@ idp
|
|
|
5798
5914
|
.command("remove <id>")
|
|
5799
5915
|
.description("Remove an IdP configuration")
|
|
5800
5916
|
.option("--admin-pass <pass>", "Admin password")
|
|
5917
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5801
5918
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5802
5919
|
.action(async (id, opts) => {
|
|
5803
5920
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -5806,7 +5923,7 @@ idp
|
|
|
5806
5923
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
5807
5924
|
process.exit(1);
|
|
5808
5925
|
}
|
|
5809
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5926
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5810
5927
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5811
5928
|
method: "POST",
|
|
5812
5929
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -5865,12 +5982,13 @@ program
|
|
|
5865
5982
|
.option("--port <port>", "Harper HTTP port")
|
|
5866
5983
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5867
5984
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
5985
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5868
5986
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys (for from-agent Ed25519 auth)")
|
|
5869
5987
|
.action(async (fromAgent, toAgent, opts) => {
|
|
5870
5988
|
const httpPort = resolveHttpPort(opts);
|
|
5871
5989
|
const opsPort = resolveOpsPort(opts);
|
|
5872
5990
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
5873
|
-
const adminUser =
|
|
5991
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
5874
5992
|
const scope = opts.scope ?? "read";
|
|
5875
5993
|
if (!adminPass) {
|
|
5876
5994
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for grant");
|
|
@@ -5914,11 +6032,12 @@ program
|
|
|
5914
6032
|
.option("--port <port>", "Harper HTTP port")
|
|
5915
6033
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5916
6034
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
6035
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5917
6036
|
.action(async (fromAgent, toAgent, opts) => {
|
|
5918
6037
|
const httpPort = resolveHttpPort(opts);
|
|
5919
6038
|
const opsPort = resolveOpsPort(opts);
|
|
5920
6039
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
5921
|
-
const adminUser =
|
|
6040
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
5922
6041
|
if (!adminPass) {
|
|
5923
6042
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for revoke");
|
|
5924
6043
|
process.exit(1);
|
|
@@ -5962,7 +6081,7 @@ async function loadInstanceSecretKey(instanceId, opts) {
|
|
|
5962
6081
|
// Fallback: check DB for legacy _keySeed
|
|
5963
6082
|
const opsPort = resolveOpsPort(opts);
|
|
5964
6083
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
5965
|
-
const auth = `Basic ${Buffer.from(`${
|
|
6084
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5966
6085
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5967
6086
|
method: "POST",
|
|
5968
6087
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -6048,6 +6167,95 @@ function driverCheckAppliesTo(opts) {
|
|
|
6048
6167
|
const target = resolveTarget(opts);
|
|
6049
6168
|
return !target || isLocalBase(target.replace(/\/$/, ""));
|
|
6050
6169
|
}
|
|
6170
|
+
/**
|
|
6171
|
+
* flair#1108: a bare undici/Node "fetch failed" names neither the URL
|
|
6172
|
+
* that was probed nor the knob that would change it. These helpers are
|
|
6173
|
+
* the operator-facing sentence and the setting that produced (or would
|
|
6174
|
+
* change) that URL. Pure so the contract can be unit-tested without
|
|
6175
|
+
* driving process.exit.
|
|
6176
|
+
*/
|
|
6177
|
+
export function federationStatusUrlSetting(opts) {
|
|
6178
|
+
if (opts.target)
|
|
6179
|
+
return "--target";
|
|
6180
|
+
if (process.env.FLAIR_TARGET)
|
|
6181
|
+
return "FLAIR_TARGET";
|
|
6182
|
+
if (process.env.FLAIR_URL)
|
|
6183
|
+
return "FLAIR_URL";
|
|
6184
|
+
if (opts.port !== undefined && opts.port !== null && String(opts.port) !== "")
|
|
6185
|
+
return "--port";
|
|
6186
|
+
return "FLAIR_URL or --port";
|
|
6187
|
+
}
|
|
6188
|
+
export function describeFederationStatusFetchFailed(url, setting) {
|
|
6189
|
+
return `fetch failed against ${url} (set ${setting})`;
|
|
6190
|
+
}
|
|
6191
|
+
/** True for a connect-level failure (no HTTP status): Node's undici
|
|
6192
|
+
* `TypeError: fetch failed`, Bun's `Unable to connect…`, or a cause
|
|
6193
|
+
* carrying a connect/DNS errno. Auth and HTTP errors stay out. */
|
|
6194
|
+
export function isFederationStatusConnectFailure(err) {
|
|
6195
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6196
|
+
if (/\bfetch failed\b/i.test(msg))
|
|
6197
|
+
return true;
|
|
6198
|
+
if (/unable to connect/i.test(msg))
|
|
6199
|
+
return true;
|
|
6200
|
+
const cause = err instanceof Error ? err.cause : undefined;
|
|
6201
|
+
const code = cause && typeof cause === "object" && cause && "code" in cause
|
|
6202
|
+
? String(cause.code)
|
|
6203
|
+
: "";
|
|
6204
|
+
return /^(ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH)$/.test(code);
|
|
6205
|
+
}
|
|
6206
|
+
export function rewriteFederationStatusFetchFailed(err, url, setting) {
|
|
6207
|
+
if (!isFederationStatusConnectFailure(err))
|
|
6208
|
+
return err;
|
|
6209
|
+
const next = new Error(describeFederationStatusFetchFailed(url, setting));
|
|
6210
|
+
if (err && typeof err === "object" && "status" in err) {
|
|
6211
|
+
next.status = err.status;
|
|
6212
|
+
}
|
|
6213
|
+
return next;
|
|
6214
|
+
}
|
|
6215
|
+
/**
|
|
6216
|
+
* Auth-shaped vs connect-level for `federation status`. A rewritten
|
|
6217
|
+
* fetch-failed sentence embeds the probed URL; that URL can contain a
|
|
6218
|
+
* whole-token `401` (e.g. `--port 401`). The old `message.includes("401")`
|
|
6219
|
+
* check then printed the credential remedy and hid the URL+setting this
|
|
6220
|
+
* change exists to surface (Bugbot on flair#1108).
|
|
6221
|
+
*/
|
|
6222
|
+
export function isFederationStatusAuthFailure(err) {
|
|
6223
|
+
if (!err)
|
|
6224
|
+
return false;
|
|
6225
|
+
if (isFederationStatusConnectFailure(err))
|
|
6226
|
+
return false;
|
|
6227
|
+
if (typeof err === "object" && "status" in err) {
|
|
6228
|
+
const status = err.status;
|
|
6229
|
+
if (status === 401 || status === 403)
|
|
6230
|
+
return true;
|
|
6231
|
+
}
|
|
6232
|
+
const m = err instanceof Error
|
|
6233
|
+
? err.message
|
|
6234
|
+
: String(typeof err === "object" && err && "message" in err
|
|
6235
|
+
? err.message ?? err
|
|
6236
|
+
: err);
|
|
6237
|
+
return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
|
|
6238
|
+
}
|
|
6239
|
+
/**
|
|
6240
|
+
* Whether to print the "set one of: FLAIR_AGENT_ID / FLAIR_ADMIN_PASS /
|
|
6241
|
+
* FLAIR_TOKEN" block. Narrower than `isFederationStatusAuthFailure`: a
|
|
6242
|
+
* 403 with credentials already sent (wrong password) is fatal, but the
|
|
6243
|
+
* server's own body is the honest message — the credential-list remedy
|
|
6244
|
+
* is for missing/invalid auth (401), not a rejected password (flair#634).
|
|
6245
|
+
*/
|
|
6246
|
+
export function isFederationStatusAuthRemedy(err) {
|
|
6247
|
+
if (!err || isFederationStatusConnectFailure(err))
|
|
6248
|
+
return false;
|
|
6249
|
+
if (typeof err === "object" && "status" in err && err.status === 401) {
|
|
6250
|
+
return true;
|
|
6251
|
+
}
|
|
6252
|
+
const m = err instanceof Error
|
|
6253
|
+
? err.message
|
|
6254
|
+
: String(typeof err === "object" && err && "message" in err
|
|
6255
|
+
? err.message ?? err
|
|
6256
|
+
: err);
|
|
6257
|
+
return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
|
|
6258
|
+
}
|
|
6051
6259
|
federation
|
|
6052
6260
|
.command("status")
|
|
6053
6261
|
.description("Show federation status and peer connections")
|
|
@@ -6056,8 +6264,11 @@ federation
|
|
|
6056
6264
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
6057
6265
|
.option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
|
|
6058
6266
|
.action(async (opts) => {
|
|
6059
|
-
|
|
6060
|
-
|
|
6267
|
+
// Same URL api() would have derived, including --port (the command
|
|
6268
|
+
// advertised --port but previously dropped it on the floor). Naming
|
|
6269
|
+
// that URL on fetch failure is only honest if it is the URL we probe.
|
|
6270
|
+
const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
|
|
6271
|
+
const urlSetting = federationStatusUrlSetting(opts);
|
|
6061
6272
|
const mode = render.resolveOutputMode(opts);
|
|
6062
6273
|
// flair#1233: fetch instance and peers INDEPENDENTLY. One read failing
|
|
6063
6274
|
// must never take down the whole render — the principle latestPeerContact
|
|
@@ -6067,41 +6278,33 @@ federation
|
|
|
6067
6278
|
let instance = null;
|
|
6068
6279
|
let instanceErr = null;
|
|
6069
6280
|
try {
|
|
6070
|
-
instance = await api("GET", "/FederationInstance", undefined,
|
|
6281
|
+
instance = await api("GET", "/FederationInstance", undefined, { baseUrl });
|
|
6071
6282
|
}
|
|
6072
6283
|
catch (err) {
|
|
6073
|
-
instanceErr = err;
|
|
6284
|
+
instanceErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
|
|
6074
6285
|
}
|
|
6075
6286
|
// peers: null = unverifiable (the read failed), [] = verified empty.
|
|
6076
6287
|
let peers = null;
|
|
6077
6288
|
let peersErr = null;
|
|
6078
6289
|
try {
|
|
6079
|
-
const r = await api("GET", "/FederationPeers", undefined,
|
|
6290
|
+
const r = await api("GET", "/FederationPeers", undefined, { baseUrl });
|
|
6080
6291
|
peers = r.peers ?? [];
|
|
6081
6292
|
}
|
|
6082
6293
|
catch (err) {
|
|
6083
|
-
peersErr = err;
|
|
6294
|
+
peersErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
|
|
6084
6295
|
}
|
|
6085
6296
|
// Auth-shaped failures stay FATAL even when the other read succeeded:
|
|
6086
6297
|
// both endpoints sit behind the same allowAdmin gate, so a 401/403 is a
|
|
6087
6298
|
// property of the session's credentials, not of one endpoint — and
|
|
6088
6299
|
// degrading it to "unverifiable" would swallow the actionable remedy
|
|
6089
6300
|
// (flair#634's UX, kept). Only non-auth failures degrade independently.
|
|
6090
|
-
const authShaped = (err) => {
|
|
6091
|
-
if (!err)
|
|
6092
|
-
return false;
|
|
6093
|
-
if (err.status === 401 || err.status === 403)
|
|
6094
|
-
return true;
|
|
6095
|
-
const m = String(err.message ?? err);
|
|
6096
|
-
return m.includes("missing_or_invalid_authorization") || m.includes("401");
|
|
6097
|
-
};
|
|
6098
6301
|
// Both reads failed → nothing to render at all. Either way keep the
|
|
6099
6302
|
// classic failure UX (auth remedy when it's an auth problem), exit
|
|
6100
6303
|
// non-zero.
|
|
6101
|
-
if ((instanceErr && peersErr) ||
|
|
6304
|
+
if ((instanceErr && peersErr) || isFederationStatusAuthFailure(instanceErr) || isFederationStatusAuthFailure(peersErr)) {
|
|
6102
6305
|
const primaryErr = instanceErr ?? peersErr;
|
|
6103
6306
|
const msg = String(primaryErr.message ?? primaryErr);
|
|
6104
|
-
if (
|
|
6307
|
+
if (isFederationStatusAuthRemedy(primaryErr)) {
|
|
6105
6308
|
console.error(`${render.icons.error} federation status requires auth.`);
|
|
6106
6309
|
console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
|
|
6107
6310
|
console.error(` ${render.wrap(render.c.cyan, "FLAIR_AGENT_ID=<your-agent-id>")} ${render.wrap(render.c.dim, "(Ed25519 — uses ~/.flair/keys/<id>.key)")}`);
|
|
@@ -6459,6 +6662,7 @@ federation
|
|
|
6459
6662
|
.description("Pair this spoke with a hub instance")
|
|
6460
6663
|
.option("--port <port>", "Harper HTTP port")
|
|
6461
6664
|
.option("--admin-pass <pass>", "Admin password")
|
|
6665
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
6462
6666
|
.option("--ops-port <port>", "Harper operations API port")
|
|
6463
6667
|
.option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN) [deprecated: use --token-from]")
|
|
6464
6668
|
.option("--token-from <file>", "Read bootstrap triple from JSON file (use '-' for stdin)")
|
|
@@ -6536,7 +6740,7 @@ federation
|
|
|
6536
6740
|
"Without it, 'flair federation sync' will report 'No hub peer configured'.");
|
|
6537
6741
|
process.exit(1);
|
|
6538
6742
|
}
|
|
6539
|
-
const auth = `Basic ${Buffer.from(`${
|
|
6743
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
6540
6744
|
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
6541
6745
|
const peerRes = await fetch(`${opsEndpoint}/`, {
|
|
6542
6746
|
method: "POST",
|
|
@@ -6573,6 +6777,7 @@ federation
|
|
|
6573
6777
|
.description("Generate a one-time pairing token (run on the hub)")
|
|
6574
6778
|
.option("--port <port>", "Harper HTTP port")
|
|
6575
6779
|
.option("--admin-pass <pass>", "Admin password")
|
|
6780
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
6576
6781
|
.option("--ops-port <port>", "Harper operations API port")
|
|
6577
6782
|
.option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
|
|
6578
6783
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
@@ -6587,7 +6792,7 @@ federation
|
|
|
6587
6792
|
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
6588
6793
|
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
6589
6794
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
6590
|
-
const auth = `Basic ${Buffer.from(`${
|
|
6795
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
6591
6796
|
// 1. Persist the PairingToken record
|
|
6592
6797
|
const opsRes = await fetch(`${opsEndpoint}/`, {
|
|
6593
6798
|
method: "POST",
|
|
@@ -6692,7 +6897,7 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6692
6897
|
const syncStartedAt = new Date().toISOString();
|
|
6693
6898
|
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
6694
6899
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
6695
|
-
const auth = `Basic ${Buffer.from(`${
|
|
6900
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
6696
6901
|
const tables = ["Memory", "Soul", "Agent", "Relationship"];
|
|
6697
6902
|
const instance = await api("GET", "/FederationInstance", undefined, apiOpts);
|
|
6698
6903
|
const hubUrl = hub.endpoint ?? hub.id;
|
|
@@ -6934,28 +7139,16 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6934
7139
|
return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
|
|
6935
7140
|
}
|
|
6936
7141
|
}
|
|
6937
|
-
const federationSync = federation
|
|
7142
|
+
const federationSync = addSharedCredentialOptions(federation
|
|
6938
7143
|
.command("sync")
|
|
6939
7144
|
.description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
|
|
6940
7145
|
.option("--port <port>", "Harper HTTP port")
|
|
6941
|
-
.option("--admin-pass <pass>", "Admin password")
|
|
6942
|
-
.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.")
|
|
6943
7146
|
.option("--ops-port <port>", "Harper operations API port")
|
|
6944
7147
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
6945
|
-
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
6946
|
-
.action(async (opts) => {
|
|
7148
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")).action(async (opts) => {
|
|
6947
7149
|
// --admin-pass-file resolves into the same `adminPass` slot the inline
|
|
6948
7150
|
// flag uses, so the scheduler never has to embed a secret in a unit file.
|
|
6949
|
-
|
|
6950
|
-
if (!opts.adminPass && opts.adminPassFile) {
|
|
6951
|
-
try {
|
|
6952
|
-
opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
6953
|
-
}
|
|
6954
|
-
catch (err) {
|
|
6955
|
-
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
6956
|
-
process.exit(1);
|
|
6957
|
-
}
|
|
6958
|
-
}
|
|
7151
|
+
applyAdminPassFile(opts);
|
|
6959
7152
|
const r = await runFederationSyncOnce(opts);
|
|
6960
7153
|
if (r.error) {
|
|
6961
7154
|
console.error(`Error: ${r.error.message}`);
|
|
@@ -7140,6 +7333,7 @@ federation
|
|
|
7140
7333
|
.option("--interval <seconds>", "Seconds between syncs", "30")
|
|
7141
7334
|
.option("--port <port>", "Harper HTTP port")
|
|
7142
7335
|
.option("--admin-pass <pass>", "Admin password")
|
|
7336
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
7143
7337
|
.option("--ops-port <port>", "Harper operations API port")
|
|
7144
7338
|
.option("--target <url>", "Remote Flair URL")
|
|
7145
7339
|
.option("--ops-target <url>", "Explicit ops API URL")
|
|
@@ -8666,7 +8860,7 @@ async function fetchHealthDetail(opts, signingAgentIdOverride) {
|
|
|
8666
8860
|
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
8667
8861
|
if (adminPass) {
|
|
8668
8862
|
res = await fetch(`${baseUrl}/Health`, {
|
|
8669
|
-
headers: { Authorization: `Basic ${Buffer.from(
|
|
8863
|
+
headers: { Authorization: `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}` },
|
|
8670
8864
|
signal: AbortSignal.timeout(5000),
|
|
8671
8865
|
});
|
|
8672
8866
|
}
|
|
@@ -8718,8 +8912,10 @@ const statusCmd = program
|
|
|
8718
8912
|
discoveredPort = await discoverLocalFlairPort(baseUrl);
|
|
8719
8913
|
}
|
|
8720
8914
|
// Version-behind check (flair#587) — offline-tolerant + cached, so this
|
|
8721
|
-
// never
|
|
8722
|
-
//
|
|
8915
|
+
// never fails `status` when the registry is unreachable, and costs no
|
|
8916
|
+
// network round trip on the common up-to-date path. When a cached answer
|
|
8917
|
+
// would print a nudge it spends one short-timeout refetch so the printed
|
|
8918
|
+
// fact is current (flair#1341). Independent of Harper health; runs either way.
|
|
8723
8919
|
const versionCheckResult = await checkVersion(__pkgVersion);
|
|
8724
8920
|
const versionNudge = formatVersionNudge(versionCheckResult);
|
|
8725
8921
|
if (opts.json) {
|
|
@@ -9211,7 +9407,7 @@ statusCmd
|
|
|
9211
9407
|
}
|
|
9212
9408
|
}
|
|
9213
9409
|
else {
|
|
9214
|
-
const auth = `Basic ${Buffer.from(`${
|
|
9410
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
|
|
9215
9411
|
const maxTokens = Number.parseInt(String(opts.maxTokens ?? "4000"), 10);
|
|
9216
9412
|
for (const agentId of agentList) {
|
|
9217
9413
|
try {
|
|
@@ -11754,7 +11950,7 @@ program
|
|
|
11754
11950
|
// embedding with a freshly-computed one. Without this path, `flair
|
|
11755
11951
|
// reembed` could not recover from the very condition it exists to fix.
|
|
11756
11952
|
const opsPort = resolveOpsPort(opts);
|
|
11757
|
-
const opsAuth = `Basic ${Buffer.from(
|
|
11953
|
+
const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
|
|
11758
11954
|
// Harper rejects empty-value conditions ("not indexed for nulls"). Use
|
|
11759
11955
|
// `createdAt > 1970-01-01` as the "select all" pattern: every Memory row
|
|
11760
11956
|
// has a createdAt, the index is built, and the comparison is total.
|
|
@@ -11852,7 +12048,7 @@ program
|
|
|
11852
12048
|
let allMemories = [];
|
|
11853
12049
|
if (adminPassSingle) {
|
|
11854
12050
|
const opsPort = resolveOpsPort(opts);
|
|
11855
|
-
const opsAuth = `Basic ${Buffer.from(
|
|
12051
|
+
const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPassSingle}`).toString("base64")}`;
|
|
11856
12052
|
const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
11857
12053
|
method: "POST",
|
|
11858
12054
|
headers: { "Content-Type": "application/json", Authorization: opsAuth },
|
|
@@ -11940,7 +12136,10 @@ program
|
|
|
11940
12136
|
console.error(`${render.icons.error} ${render.wrap(render.c.red, "set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS")}`);
|
|
11941
12137
|
process.exit(1);
|
|
11942
12138
|
}
|
|
11943
|
-
|
|
12139
|
+
// Single source of truth (flair#1351): banner prints the URL the test's
|
|
12140
|
+
// own client uses. resolveBaseUrl is the existing CLI resolver; pass the
|
|
12141
|
+
// same value through to api() so the two cannot diverge.
|
|
12142
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
11944
12143
|
console.log(`\n${render.wrap(render.c.bold, "Flair test")} ${render.wrap(render.c.dim, `(url: ${baseUrl})`)}\n`);
|
|
11945
12144
|
let passed = 0;
|
|
11946
12145
|
let failed = 0;
|
|
@@ -11975,7 +12174,7 @@ program
|
|
|
11975
12174
|
};
|
|
11976
12175
|
if (agentId)
|
|
11977
12176
|
body.agentId = agentId;
|
|
11978
|
-
await api("PUT", `/Memory/${id}`, body);
|
|
12177
|
+
await api("PUT", `/Memory/${id}`, body, { baseUrl });
|
|
11979
12178
|
memoryId = id;
|
|
11980
12179
|
return true;
|
|
11981
12180
|
});
|
|
@@ -11985,7 +12184,7 @@ program
|
|
|
11985
12184
|
const body = { q: "flair test", limit: 5 };
|
|
11986
12185
|
if (agentId)
|
|
11987
12186
|
body.agentId = agentId;
|
|
11988
|
-
const result = await api("POST", "/SemanticSearch", body);
|
|
12187
|
+
const result = await api("POST", "/SemanticSearch", body, { baseUrl });
|
|
11989
12188
|
return (result?.results?.length ?? 0) > 0;
|
|
11990
12189
|
});
|
|
11991
12190
|
// 3. Delete the test memory via DELETE /Memory/<id>
|
|
@@ -11995,7 +12194,7 @@ program
|
|
|
11995
12194
|
console.log(` (skipped — no id returned from write step)`);
|
|
11996
12195
|
return true;
|
|
11997
12196
|
}
|
|
11998
|
-
await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
|
|
12197
|
+
await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined, { baseUrl });
|
|
11999
12198
|
return true;
|
|
12000
12199
|
});
|
|
12001
12200
|
const passColor = passed > 0 ? render.c.green : render.c.dim;
|
|
@@ -12666,6 +12865,9 @@ program
|
|
|
12666
12865
|
catch { /* unreachable/unparseable → null → the finding is skipped, not passed */ }
|
|
12667
12866
|
// The component directory for a local install is the flair package itself:
|
|
12668
12867
|
// `flair start` spawns `harper run .` with cwd = flairPackageDir().
|
|
12868
|
+
// That path is often inside node_modules on an npm install-g; doctor still
|
|
12869
|
+
// READs it for drift detection, but describePublicUrlFinding never names
|
|
12870
|
+
// it as the fix (flair#1313 — wiped on every upgrade).
|
|
12669
12871
|
const componentEnvPath = join(flairPackageDir(), COMPONENT_ENV_FILENAME);
|
|
12670
12872
|
let componentEnvValue = null;
|
|
12671
12873
|
try {
|
|
@@ -12755,7 +12957,7 @@ program
|
|
|
12755
12957
|
}
|
|
12756
12958
|
const auditStatus = auditCredIssue
|
|
12757
12959
|
? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
|
|
12758
|
-
: await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`,
|
|
12960
|
+
: await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, resolveAdminUser(undefined), auditAdminPass);
|
|
12759
12961
|
switch (auditStatus.state) {
|
|
12760
12962
|
case "ok":
|
|
12761
12963
|
// Present-tense claim ONLY (see AuditVerifyResult): the probe
|
|
@@ -12834,11 +13036,13 @@ program
|
|
|
12834
13036
|
}
|
|
12835
13037
|
// 7. Client integration (flair#588) — the first 6 checks diagnose the
|
|
12836
13038
|
// SERVER side. This diagnoses whether Flair is actually wired to a real
|
|
12837
|
-
// MCP
|
|
12838
|
-
// + reachable + the configured agent
|
|
12839
|
-
//
|
|
12840
|
-
//
|
|
12841
|
-
//
|
|
13039
|
+
// client: for MCP clients (Claude Code, Codex, Gemini, Cursor,
|
|
13040
|
+
// Antigravity) the MCP block present + reachable + the configured agent
|
|
13041
|
+
// genuinely registered; for pi (a NATIVE EXTENSION host — flair#1342) the
|
|
13042
|
+
// pi-flair reference in pi's own settings, including the flair#1346
|
|
13043
|
+
// npm:-under-"extensions" trap; plus CLAUDE.md (Claude Code) and the
|
|
13044
|
+
// SessionStart hook (Claude Code + Codex — flair#1148). Reuses
|
|
13045
|
+
// detectClients() rather than reimplementing client detection.
|
|
12842
13046
|
console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
|
|
12843
13047
|
// Prompt y/N before a content-editing fix, but only when interactive —
|
|
12844
13048
|
// in a non-TTY context (CI, scripts) --fix itself is the consent signal,
|
|
@@ -12858,6 +13062,7 @@ program
|
|
|
12858
13062
|
}
|
|
12859
13063
|
else {
|
|
12860
13064
|
let claudeCodeAgentId;
|
|
13065
|
+
let codexAgentId;
|
|
12861
13066
|
let anyKnownAgentId;
|
|
12862
13067
|
// `doctor --fix` writes client configs through the same wire functions
|
|
12863
13068
|
// init does, so it owes the user the same warning when the spec it would
|
|
@@ -12870,9 +13075,175 @@ program
|
|
|
12870
13075
|
}
|
|
12871
13076
|
}
|
|
12872
13077
|
for (const client of detectedClients) {
|
|
13078
|
+
// ── pi (flair#1342): NATIVE EXTENSION, not an MCP client ───────────
|
|
13079
|
+
// There is no mcpServers block to read — pi loads @tpsdev-ai/pi-flair
|
|
13080
|
+
// through its own settings.json (`packages`). Every check below is a
|
|
13081
|
+
// filesystem fact except agent registration, which is only checkable
|
|
13082
|
+
// when this shell exposes the env pi would launch with — and the
|
|
13083
|
+
// output says which of the two it verified.
|
|
13084
|
+
if (client.kind === "native-extension") {
|
|
13085
|
+
let pi = checkPiFlairWiring(homedir(), process.cwd());
|
|
13086
|
+
// --fix for pi needs no agent id (pi settings carry no env block);
|
|
13087
|
+
// a resolvable id only improves the export hint in the message.
|
|
13088
|
+
const wirePiFix = async (prompt) => {
|
|
13089
|
+
if (dryRun) {
|
|
13090
|
+
console.log(` ${render.wrap(render.c.dim, "Would update")} ${pi.settingsPath}`);
|
|
13091
|
+
return;
|
|
13092
|
+
}
|
|
13093
|
+
const proceed = await confirmFix(prompt);
|
|
13094
|
+
if (!proceed) {
|
|
13095
|
+
console.log(` Skipped.`);
|
|
13096
|
+
return;
|
|
13097
|
+
}
|
|
13098
|
+
const hintAgentId = resolveFixAgentId({
|
|
13099
|
+
optsAgent: opts.agent,
|
|
13100
|
+
envAgentId: process.env.FLAIR_AGENT_ID,
|
|
13101
|
+
anyKnownAgentId,
|
|
13102
|
+
keyAgentIds,
|
|
13103
|
+
keysDir: defaultKeysDir(),
|
|
13104
|
+
}) ?? "<your-agent-id>";
|
|
13105
|
+
const wireResult = wirePi({ FLAIR_AGENT_ID: hintAgentId, FLAIR_URL: baseUrl });
|
|
13106
|
+
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
13107
|
+
if (wireResult.ok)
|
|
13108
|
+
fixed++;
|
|
13109
|
+
};
|
|
13110
|
+
// (a) The flair#1346 trap FIRST, and by NAME: an npm: spec under
|
|
13111
|
+
// "extensions" is silently ignored by pi — the user believes they
|
|
13112
|
+
// are wired while pi registers zero tools. This is the documented
|
|
13113
|
+
// field failure mode and must never fold into a generic "not
|
|
13114
|
+
// wired": the fix is a MOVE to "packages", not an add.
|
|
13115
|
+
const userTraps = pi.misconfigured.filter((m) => m.path === pi.settingsPath);
|
|
13116
|
+
const projectTraps = pi.misconfigured.filter((m) => m.path !== pi.settingsPath);
|
|
13117
|
+
for (const bad of pi.misconfigured) {
|
|
13118
|
+
console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} is listed under "extensions" as an npm: spec (${bad.entry}) in ${render.wrap(render.c.dim, bad.path)}`);
|
|
13119
|
+
console.log(` pi silently ignores npm: specs under "extensions", so the Flair tools never register (flair#1346). Package sources belong under "packages".`);
|
|
13120
|
+
issues++;
|
|
13121
|
+
}
|
|
13122
|
+
if (userTraps.length > 0) {
|
|
13123
|
+
if (autoFix) {
|
|
13124
|
+
await wirePiFix(` Move the npm: spec to "packages" in ${pi.settingsPath} now? [y/N] `);
|
|
13125
|
+
// Re-derive the wiring from disk so the sections below reason
|
|
13126
|
+
// about the POST-fix state — otherwise a move that just
|
|
13127
|
+
// succeeded would still read as "not wired" and prompt again.
|
|
13128
|
+
pi = checkPiFlairWiring(homedir(), process.cwd());
|
|
13129
|
+
}
|
|
13130
|
+
else {
|
|
13131
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(moves it to "packages")`)}`);
|
|
13132
|
+
}
|
|
13133
|
+
}
|
|
13134
|
+
if (projectTraps.length > 0) {
|
|
13135
|
+
// wirePi edits the USER-scope settings only — a project-scope
|
|
13136
|
+
// trap gets the exact manual fix, never a --fix that claims a
|
|
13137
|
+
// file it does not touch.
|
|
13138
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} move the entry from "extensions" to "packages" in ${projectTraps[0].path}`);
|
|
13139
|
+
}
|
|
13140
|
+
if (!pi.wired) {
|
|
13141
|
+
console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} not wired in ${render.wrap(render.c.dim, pi.settingsPath)}`);
|
|
13142
|
+
if (autoFix) {
|
|
13143
|
+
await wirePiFix(` Wire pi now (adds ${piFlairSpec()} to "packages" in ${pi.settingsPath})? [y/N] `);
|
|
13144
|
+
}
|
|
13145
|
+
else {
|
|
13146
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(adds ${piFlairSpec()} to "packages")`)} — or: pi install npm:${PI_FLAIR_PACKAGE}`);
|
|
13147
|
+
}
|
|
13148
|
+
issues++;
|
|
13149
|
+
continue;
|
|
13150
|
+
}
|
|
13151
|
+
if (pi.wiredVia === "packages") {
|
|
13152
|
+
console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via "packages" (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
|
|
13153
|
+
if (!pi.pinnedVersion) {
|
|
13154
|
+
console.log(` ${render.icons.info} unpinned — pi re-resolves latest on (re)install; pin with ${piFlairSpec()}`);
|
|
13155
|
+
}
|
|
13156
|
+
}
|
|
13157
|
+
else {
|
|
13158
|
+
// extension-path: the documented pre-0.49 workaround (a local
|
|
13159
|
+
// path to the installed dist/index.js). Works, but the canonical
|
|
13160
|
+
// form is a "packages" entry — and a DANGLING path is a broken
|
|
13161
|
+
// wiring pi skips silently, so check the one thing checkable.
|
|
13162
|
+
if (pi.extensionPathExists) {
|
|
13163
|
+
console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via a file-path "extensions" entry (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
|
|
13164
|
+
console.log(` ${render.wrap(render.c.dim, `pre-0.49 workaround — the canonical form is a "packages" entry: ${piFlairSpec()}`)}`);
|
|
13165
|
+
}
|
|
13166
|
+
else {
|
|
13167
|
+
console.log(` ${render.icons.error} pi: the "extensions" entry ${pi.spec} in ${render.wrap(render.c.dim, pi.wiredIn)} points at a file that does not exist — pi silently skips missing extension paths`);
|
|
13168
|
+
if (autoFix) {
|
|
13169
|
+
await wirePiFix(` Wire pi via "packages" instead (adds ${piFlairSpec()})? [y/N] `);
|
|
13170
|
+
}
|
|
13171
|
+
else {
|
|
13172
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(adds ${piFlairSpec()} to "packages"; remove the dangling entry yourself)`)}`);
|
|
13173
|
+
}
|
|
13174
|
+
issues++;
|
|
13175
|
+
continue;
|
|
13176
|
+
}
|
|
13177
|
+
}
|
|
13178
|
+
// Env sanity (flair#1342 scope 3). pi settings carry no env block:
|
|
13179
|
+
// pi-flair reads FLAIR_* from the environment of whatever shell/IDE
|
|
13180
|
+
// launches pi. Doctor can only see ITS OWN environment — these
|
|
13181
|
+
// lines verify this shell, and say so, rather than pretending to
|
|
13182
|
+
// verify every pi launch. None of them counts as an issue: a clean
|
|
13183
|
+
// pi launched elsewhere can be fine while this shell is bare, and
|
|
13184
|
+
// vice versa.
|
|
13185
|
+
console.log(` ${render.wrap(render.c.dim, "pi-flair reads FLAIR_AGENT_ID / FLAIR_URL / FLAIR_KEY_PATH from the shell that launches pi — doctor sees only its own environment (this shell):")}`);
|
|
13186
|
+
const piEnvAgent = process.env.FLAIR_AGENT_ID;
|
|
13187
|
+
const piEnvUrl = process.env.FLAIR_URL;
|
|
13188
|
+
const piEnvKey = process.env.FLAIR_KEY_PATH;
|
|
13189
|
+
if (piEnvAgent) {
|
|
13190
|
+
console.log(` ${render.icons.ok} FLAIR_AGENT_ID set ('${piEnvAgent}')`);
|
|
13191
|
+
}
|
|
13192
|
+
else {
|
|
13193
|
+
console.log(` ${render.icons.warn} FLAIR_AGENT_ID not set in this shell — pi-flair falls back to the cwd directory name as its agent id (identity varies by project); export FLAIR_AGENT_ID=<id> where pi is launched`);
|
|
13194
|
+
}
|
|
13195
|
+
if (piEnvUrl) {
|
|
13196
|
+
console.log(` ${render.icons.ok} FLAIR_URL set (${piEnvUrl})`);
|
|
13197
|
+
}
|
|
13198
|
+
else {
|
|
13199
|
+
console.log(` ${render.icons.info} FLAIR_URL not set — pi-flair defaults to ${render.wrap(render.c.dim, PI_FLAIR_DEFAULT_URL)}`);
|
|
13200
|
+
}
|
|
13201
|
+
if (piEnvKey) {
|
|
13202
|
+
if (existsSync(piEnvKey)) {
|
|
13203
|
+
console.log(` ${render.icons.ok} FLAIR_KEY_PATH set (${piEnvKey})`);
|
|
13204
|
+
}
|
|
13205
|
+
else {
|
|
13206
|
+
console.log(` ${render.icons.warn} FLAIR_KEY_PATH points at a missing file (${piEnvKey})`);
|
|
13207
|
+
}
|
|
13208
|
+
}
|
|
13209
|
+
else {
|
|
13210
|
+
console.log(` ${render.icons.info} FLAIR_KEY_PATH not set — auto-resolved from ~/.flair/keys`);
|
|
13211
|
+
}
|
|
13212
|
+
// Agent registration — checkable only when this shell exposes an
|
|
13213
|
+
// agent id at all; otherwise say what was NOT verified instead of
|
|
13214
|
+
// skipping silently.
|
|
13215
|
+
if (piEnvAgent) {
|
|
13216
|
+
const piUrl = piEnvUrl || PI_FLAIR_DEFAULT_URL;
|
|
13217
|
+
const piReachable = await probeFlairReachable(piUrl);
|
|
13218
|
+
if (!piReachable) {
|
|
13219
|
+
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, piUrl)} not reachable — cannot verify agent registration`);
|
|
13220
|
+
}
|
|
13221
|
+
else {
|
|
13222
|
+
const piReg = await checkAgentRegistered(piUrl, piEnvAgent, defaultKeysDir());
|
|
13223
|
+
if (piReg.state === "registered") {
|
|
13224
|
+
console.log(` ${render.icons.ok} agent '${piEnvAgent}' registered`);
|
|
13225
|
+
}
|
|
13226
|
+
else if (piReg.state === "not-registered") {
|
|
13227
|
+
console.log(` ${render.icons.error} agent '${piEnvAgent}' is NOT registered on this Flair instance`);
|
|
13228
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${piEnvAgent}`);
|
|
13229
|
+
issues++;
|
|
13230
|
+
}
|
|
13231
|
+
else {
|
|
13232
|
+
const piFinding = describeAgentGateFinding(piEnvAgent, piReg.state, piReg.detail, { instanceReachable: piReachable });
|
|
13233
|
+
console.log(` ${render.icons.warn} ${piFinding?.message ?? `could not verify agent registration (${piReg.detail})`}`);
|
|
13234
|
+
}
|
|
13235
|
+
}
|
|
13236
|
+
}
|
|
13237
|
+
else {
|
|
13238
|
+
console.log(` ${render.wrap(render.c.dim, "agent registration not verified — no FLAIR_AGENT_ID visible to doctor")}`);
|
|
13239
|
+
}
|
|
13240
|
+
continue;
|
|
13241
|
+
}
|
|
12873
13242
|
const block = readClientMcpBlock(client.id, homedir());
|
|
12874
13243
|
if (client.id === "claude-code" && block.agentId)
|
|
12875
13244
|
claudeCodeAgentId = block.agentId;
|
|
13245
|
+
if (client.id === "codex" && block.agentId)
|
|
13246
|
+
codexAgentId = block.agentId;
|
|
12876
13247
|
if (block.agentId)
|
|
12877
13248
|
anyKnownAgentId = anyKnownAgentId ?? block.agentId;
|
|
12878
13249
|
if (!block.present) {
|
|
@@ -12919,8 +13290,14 @@ program
|
|
|
12919
13290
|
client.id === "antigravity" ? wireAntigravity(wireEnv) :
|
|
12920
13291
|
wireCursor(wireEnv);
|
|
12921
13292
|
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
12922
|
-
if (wireResult.ok)
|
|
13293
|
+
if (wireResult.ok) {
|
|
12923
13294
|
fixed++;
|
|
13295
|
+
if (client.id === "claude-code")
|
|
13296
|
+
claudeCodeAgentId = fixAgentId;
|
|
13297
|
+
if (client.id === "codex")
|
|
13298
|
+
codexAgentId = fixAgentId;
|
|
13299
|
+
anyKnownAgentId = anyKnownAgentId ?? fixAgentId;
|
|
13300
|
+
}
|
|
12924
13301
|
}
|
|
12925
13302
|
}
|
|
12926
13303
|
}
|
|
@@ -12970,8 +13347,9 @@ program
|
|
|
12970
13347
|
console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
|
|
12971
13348
|
}
|
|
12972
13349
|
}
|
|
12973
|
-
// Claude-Code-specific: CLAUDE.md + SessionStart hook
|
|
12974
|
-
// has
|
|
13350
|
+
// Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
|
|
13351
|
+
// Codex has a SessionStart hook too (checked below); CLAUDE.md and
|
|
13352
|
+
// continuity stay Claude Code only.
|
|
12975
13353
|
if (detectedClients.some((c) => c.id === "claude-code")) {
|
|
12976
13354
|
const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
|
|
12977
13355
|
if (claudeMd.present) {
|
|
@@ -13161,6 +13539,92 @@ program
|
|
|
13161
13539
|
issues++;
|
|
13162
13540
|
}
|
|
13163
13541
|
}
|
|
13542
|
+
// Codex SessionStart hook (flair#1148) — same flair-session-start
|
|
13543
|
+
// command Claude Code uses, written to ~/.codex/hooks.json. Continuity
|
|
13544
|
+
// and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
|
|
13545
|
+
// is the hook file.
|
|
13546
|
+
if (detectedClients.some((c) => c.id === "codex")) {
|
|
13547
|
+
const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
|
|
13548
|
+
if (hook.present) {
|
|
13549
|
+
if (hook.execution === "broken") {
|
|
13550
|
+
if (hook.silenced) {
|
|
13551
|
+
console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
|
|
13552
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
13553
|
+
console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
|
|
13554
|
+
console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Codex session will warm the npx cache.")}`);
|
|
13555
|
+
console.log(` ${render.wrap(render.c.dim, "Codex requires /hooks to trust a newly written command before it runs.")}`);
|
|
13556
|
+
}
|
|
13557
|
+
else {
|
|
13558
|
+
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`);
|
|
13559
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
13560
|
+
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)")}`);
|
|
13561
|
+
}
|
|
13562
|
+
}
|
|
13563
|
+
else if (hook.execution === "unknown") {
|
|
13564
|
+
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"})`)}`);
|
|
13565
|
+
}
|
|
13566
|
+
else if (!hook.ours) {
|
|
13567
|
+
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)")}`);
|
|
13568
|
+
}
|
|
13569
|
+
else {
|
|
13570
|
+
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")}`);
|
|
13571
|
+
}
|
|
13572
|
+
if (!hook.silenced && hook.ours) {
|
|
13573
|
+
console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
|
|
13574
|
+
if (hook.upgradable) {
|
|
13575
|
+
if (autoFix) {
|
|
13576
|
+
if (dryRun) {
|
|
13577
|
+
console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
|
|
13578
|
+
}
|
|
13579
|
+
else {
|
|
13580
|
+
const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
|
|
13581
|
+
if (!proceed) {
|
|
13582
|
+
console.log(` Skipped.`);
|
|
13583
|
+
}
|
|
13584
|
+
else {
|
|
13585
|
+
const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
|
|
13586
|
+
console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
|
|
13587
|
+
if (upgrade.ok && upgrade.changed)
|
|
13588
|
+
fixed++;
|
|
13589
|
+
}
|
|
13590
|
+
}
|
|
13591
|
+
}
|
|
13592
|
+
else {
|
|
13593
|
+
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)")}`);
|
|
13594
|
+
}
|
|
13595
|
+
}
|
|
13596
|
+
else {
|
|
13597
|
+
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`);
|
|
13598
|
+
}
|
|
13599
|
+
issues++;
|
|
13600
|
+
}
|
|
13601
|
+
}
|
|
13602
|
+
else {
|
|
13603
|
+
console.log(` ${render.icons.error} SessionStart hook (codex): not found in ${render.wrap(render.c.dim, hook.path)}`);
|
|
13604
|
+
if (autoFix) {
|
|
13605
|
+
if (dryRun) {
|
|
13606
|
+
console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
|
|
13607
|
+
}
|
|
13608
|
+
else {
|
|
13609
|
+
const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
|
|
13610
|
+
if (!proceed) {
|
|
13611
|
+
console.log(` Skipped.`);
|
|
13612
|
+
}
|
|
13613
|
+
else {
|
|
13614
|
+
const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
|
|
13615
|
+
const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
|
|
13616
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13617
|
+
if (fixRes.ok)
|
|
13618
|
+
fixed++;
|
|
13619
|
+
}
|
|
13620
|
+
}
|
|
13621
|
+
}
|
|
13622
|
+
else {
|
|
13623
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
|
|
13624
|
+
}
|
|
13625
|
+
issues++;
|
|
13626
|
+
}
|
|
13627
|
+
}
|
|
13164
13628
|
}
|
|
13165
13629
|
// 7a. Resolve which agent identities the two verified-read sections below
|
|
13166
13630
|
// (Fleet presence, Migrations) iterate (flair#722). Previously both
|
|
@@ -13560,7 +14024,8 @@ program
|
|
|
13560
14024
|
// from /HealthDetail at all — it's the one metric in this file that requires
|
|
13561
14025
|
// live QUERIES, because it's checking whether querying itself still works.
|
|
13562
14026
|
// For a sample of the querying agent's OWN memories (fetchRecallSpotCheckData
|
|
13563
|
-
// below, GET /Memory
|
|
14027
|
+
// below, a projected+bounded GET /Memory — flair#1360: never the unfiltered
|
|
14028
|
+
// collection with embeddings inline), a CUE is derived from each memory
|
|
13564
14029
|
// (deriveRecallCue — its `subject` if present, else the leading ~8 words /
|
|
13565
14030
|
// first sentence of `content`; a PARTIAL cue, never the full content) and
|
|
13566
14031
|
// searched for through the EXACT SAME authenticated read path `flair memory
|
|
@@ -13573,23 +14038,42 @@ program
|
|
|
13573
14038
|
// own id appears in its search's top-k; MRR = mean reciprocal rank (0 if
|
|
13574
14039
|
// not found within k).
|
|
13575
14040
|
//
|
|
13576
|
-
// Framing — this is a HEALTH SPOT-CHECK, not a benchmark
|
|
13577
|
-
// judgment
|
|
13578
|
-
// a
|
|
13579
|
-
//
|
|
13580
|
-
//
|
|
13581
|
-
//
|
|
13582
|
-
//
|
|
13583
|
-
//
|
|
13584
|
-
//
|
|
13585
|
-
// recall-
|
|
13586
|
-
//
|
|
13587
|
-
//
|
|
13588
|
-
//
|
|
13589
|
-
//
|
|
13590
|
-
//
|
|
13591
|
-
//
|
|
13592
|
-
//
|
|
14041
|
+
// Framing — this is a REPORT-ONLY HEALTH SPOT-CHECK, not a benchmark, not a
|
|
14042
|
+
// trust judgment, and (since flair#967) not an alerting signal either.
|
|
14043
|
+
// Querying by a cue derived FROM the target memory is easier than a real
|
|
14044
|
+
// user query, so a high score means "recall is functioning", not "recall is
|
|
14045
|
+
// optimal". NOTE (#1216): this cue-from-the-memory design is self-polluting
|
|
14046
|
+
// as a recall-QUALITY metric — relevance is query/corpus overlap by
|
|
14047
|
+
// construction, so near-duplicate density reads as a recall collapse
|
|
14048
|
+
// (flair#967 / #857 / #996). It is deliberately NOT the recall-quality
|
|
14049
|
+
// number; that authority is the deterministic, fixed-label, CI-gated eval at
|
|
14050
|
+
// test/bench/recall-eval, wired as a gate in
|
|
14051
|
+
// test/integration-heavy/recall-eval-gate.test.ts.
|
|
14052
|
+
//
|
|
14053
|
+
// flair#967 — WHY THIS METRIC NO LONGER EMITS AN EVENT. Measured on rockit
|
|
14054
|
+
// production over 32 nightly runs: population σ = 0.291, mean absolute
|
|
14055
|
+
// run-to-run delta = 0.223, against a QUALITY_EVENT_RECALL_DROP_THRESHOLD of
|
|
14056
|
+
// 0.2. The alarm sat at 0.69σ — BELOW the metric's own noise floor, so the
|
|
14057
|
+
// median night-to-night wobble already exceeded the delta that declared a
|
|
14058
|
+
// regression. Replaying diffQualitySnapshots over the stored snapshot series
|
|
14059
|
+
// predicts the sweep's 6 findings-mails in 34 runs exactly, 6 for 6, and all
|
|
14060
|
+
// six were oscillation: lifetime precision 0. So the emission is gone. The
|
|
14061
|
+
// score is still computed, still printed, still snapshotted (history and the
|
|
14062
|
+
// cratering signal are both preserved) — it just no longer has the authority
|
|
14063
|
+
// to page anyone, because it never once earned it. That authority stays with
|
|
14064
|
+
// the deterministic CI gate above, which is fixed-label, hermetic and
|
|
14065
|
+
// actually detects ranking regressions. Re-arming this probe is a data
|
|
14066
|
+
// question, not a taste question: it needs a measured precision on the FIXED
|
|
14067
|
+
// cue derivation first, and a threshold DERIVED from that run-to-run variance
|
|
14068
|
+
// (≥2σ on the sample design), not another literal.
|
|
14069
|
+
//
|
|
14070
|
+
// Requires an actual agent identity to query AS (semantic search is
|
|
14071
|
+
// agent-scoped) — no identity, fewer than the sample-size memories to sample,
|
|
14072
|
+
// an UNHEALTHY sample (planRecallSpotCheck below: duplicate or empty cues, so
|
|
14073
|
+
// the window cannot be scored fairly) or a search error all degrade to `null`
|
|
14074
|
+
// + a `gaps` entry, same graceful-degradation contract as every metric here —
|
|
14075
|
+
// NEVER a false 0.0 masquerading as a real (broken) score, and never a number
|
|
14076
|
+
// quietly computed over a window that could not produce one.
|
|
13593
14077
|
/** First-pass default, same "documented heuristic, not derived from data we
|
|
13594
14078
|
* don't have" spirit as health.ts's own 10%-hash-fallback threshold below.
|
|
13595
14079
|
* Tunable later if a real fleet shows this is too loud/quiet. */
|
|
@@ -13604,17 +14088,108 @@ export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
|
|
|
13604
14088
|
* "first-pass default, tunable later" spirit as the thresholds above. */
|
|
13605
14089
|
export const QUALITY_RECALL_SAMPLE_SIZE = 10;
|
|
13606
14090
|
export const QUALITY_RECALL_K = 5;
|
|
14091
|
+
/**
|
|
14092
|
+
* Fields the recall spot-check and the quality-snapshot lookup actually
|
|
14093
|
+
* read. Harper REST `select(...)` (same syntax adk-flair-js's listMemories
|
|
14094
|
+
* already uses) projects these server-side so the nightly sweep never
|
|
14095
|
+
* pulls embedding vectors inline — the defect in flair#1360 was an
|
|
14096
|
+
* unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
|
|
14097
|
+
* vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
|
|
14098
|
+
* 10 memories. `type` is intentionally omitted: it is not a declared
|
|
14099
|
+
* Memory column (see schemas/memory.graphql); snapshot exclusion keys
|
|
14100
|
+
* off `subject` (`quality-snapshot/…`).
|
|
14101
|
+
*/
|
|
14102
|
+
export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
|
|
14103
|
+
/**
|
|
14104
|
+
* Extra most-recent rows fetched beyond `sampleSize` so
|
|
14105
|
+
* `planRecallSpotCheck` can drop the sweep's own quality-snapshot
|
|
14106
|
+
* bookkeeping and still fill a 10-row window — without scanning the
|
|
14107
|
+
* table. Nightly `--emit` writes one snapshot per run; 16 is a buffer
|
|
14108
|
+
* for a few extra `--emit`s in the same recency window, not a second
|
|
14109
|
+
* full-table read.
|
|
14110
|
+
*/
|
|
14111
|
+
export const QUALITY_RECALL_SNAPSHOT_OVERFETCH = 16;
|
|
14112
|
+
/**
|
|
14113
|
+
* Harper REST collection path for the recall spot-check's sample fetch:
|
|
14114
|
+
* agent-scoped, projected (never `embedding`), recency-sorted, bounded.
|
|
14115
|
+
* `limit(start,end)` is Harper's offset window — same as
|
|
14116
|
+
* packages/adk-flair-js/src/memory_service.ts.
|
|
14117
|
+
*/
|
|
14118
|
+
export function qualityRecallSamplePath(agentId, sampleSize = QUALITY_RECALL_SAMPLE_SIZE) {
|
|
14119
|
+
const select = QUALITY_MEMORY_LIST_SELECT.join(",");
|
|
14120
|
+
const end = sampleSize + QUALITY_RECALL_SNAPSHOT_OVERFETCH;
|
|
14121
|
+
return `/Memory?agentId=${encodeURIComponent(agentId)}&select(${select})&sort(-createdAt)&limit(0,${end})`;
|
|
14122
|
+
}
|
|
14123
|
+
/**
|
|
14124
|
+
* Harper REST collection path for the previous quality-snapshot lookup:
|
|
14125
|
+
* same projection as the sample fetch (never `embedding`). Subject is
|
|
14126
|
+
* passed as a query equals (indexed) plus a client-side re-filter —
|
|
14127
|
+
* Memory.search() historically did not turn bare query params into
|
|
14128
|
+
* conditions beyond the signed agent scope, so the client-side filter
|
|
14129
|
+
* in fetchPreviousQualitySnapshot stays as defense in depth. No `limit`:
|
|
14130
|
+
* a bounded window could miss yesterday's snapshot after a busy day of
|
|
14131
|
+
* writes, and without a reliable server-side subject pushdown that
|
|
14132
|
+
* would silently look like a first run.
|
|
14133
|
+
*/
|
|
14134
|
+
export function qualitySnapshotLookupPath(agentId, subject) {
|
|
14135
|
+
const select = QUALITY_MEMORY_LIST_SELECT.join(",");
|
|
14136
|
+
return `/Memory?agentId=${encodeURIComponent(agentId)}&subject=${encodeURIComponent(subject)}&select(${select})&sort(-createdAt)`;
|
|
14137
|
+
}
|
|
14138
|
+
/** Leading-word cap on the content-derived cue. 25, matching the arm of the
|
|
14139
|
+
* flair#967 A/B that was actually measured (same 10 memories, same instance,
|
|
14140
|
+
* same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
|
|
14141
|
+
* content cue → 1.00 / 0.78). Still a PARTIAL cue by construction — capped,
|
|
14142
|
+
* never the whole memory for anything longer than the cap. */
|
|
14143
|
+
const RECALL_CUE_CONTENT_WORD_LIMIT = 25;
|
|
14144
|
+
/**
|
|
14145
|
+
* Is `subject` DISCRIMINATIVE enough to be handed to semantic search as a
|
|
14146
|
+
* query in its own right? (flair#967.)
|
|
14147
|
+
*
|
|
14148
|
+
* The old bar was `length >= 3`, which is a check on whether the subject
|
|
14149
|
+
* EXISTS, not on whether it is a query. Measured consequence: slug-shaped
|
|
14150
|
+
* subjects — `pr-1359`, `kern-2026-08-23`, the spot-check's own
|
|
14151
|
+
* `quality-snapshot/127.0.0.1:9926` — carry almost no semantic signal, so
|
|
14152
|
+
* searching one is a query for nothing in particular (searching `pr-1359` on
|
|
14153
|
+
* rockit production returned, as top-1, a review note about PR #1275 from five
|
|
14154
|
+
* days earlier). Worse, every memory sharing such a subject issues the
|
|
14155
|
+
* IDENTICAL query and gets the IDENTICAL result list, so siblings must
|
|
14156
|
+
* mutually displace each other and all but one are scored as misses no matter
|
|
14157
|
+
* how healthy retrieval is.
|
|
14158
|
+
*
|
|
14159
|
+
* The rule, stated plainly — a subject is used as the cue only when it is:
|
|
14160
|
+
* 1. at least 3 characters (the original bar, kept), AND
|
|
14161
|
+
* 2. NOT opaque-identifier-shaped: an unspaced token carrying a digit or an
|
|
14162
|
+
* identifier separator (`/ : _ . # @ \`) is a slug, not a phrase.
|
|
14163
|
+
* Whitespace is the primary discriminator — `Harper 5.2 upgrade` is
|
|
14164
|
+
* prose and stays a cue; `kern-2026-08-23` is not. A bare hyphen does
|
|
14165
|
+
* NOT make a slug, so ordinary compounds (`two-gate`) survive, AND
|
|
14166
|
+
* 3. carrying at least one alphabetic run of 3+ characters — a subject with
|
|
14167
|
+
* no word in it (`---`, `42`) is not a query either.
|
|
14168
|
+
*
|
|
14169
|
+
* Fails CLOSED: anything that isn't clearly a phrase falls back to content,
|
|
14170
|
+
* which the A/B measured as the strictly better cue. Pure — no I/O.
|
|
14171
|
+
*/
|
|
14172
|
+
export function isDiscriminativeSubject(subject) {
|
|
14173
|
+
const s = (subject ?? "").trim();
|
|
14174
|
+
if (s.length < 3)
|
|
14175
|
+
return false;
|
|
14176
|
+
if (!/\s/.test(s) && /[0-9/:_.#@\\]/.test(s))
|
|
14177
|
+
return false;
|
|
14178
|
+
if (!/[A-Za-z]{3}/.test(s))
|
|
14179
|
+
return false;
|
|
14180
|
+
return true;
|
|
14181
|
+
}
|
|
13607
14182
|
/**
|
|
13608
14183
|
* Derive a PARTIAL search cue from a memory — used by the recall spot-check
|
|
13609
14184
|
* (Slice 1d) to query for a memory without handing back its full content.
|
|
13610
|
-
* Prefers `subject` when
|
|
13611
|
-
*
|
|
13612
|
-
*
|
|
14185
|
+
* Prefers `subject` ONLY when it is discriminative (isDiscriminativeSubject
|
|
14186
|
+
* above — flair#967); otherwise falls back to the first sentence of
|
|
14187
|
+
* `content`, capped to the leading ~25 words so the cue stays a genuine
|
|
13613
14188
|
* partial cue rather than the whole memory. Pure — no I/O.
|
|
13614
14189
|
*/
|
|
13615
14190
|
export function deriveRecallCue(memory) {
|
|
13616
14191
|
const subject = (memory.subject ?? "").trim();
|
|
13617
|
-
if (subject
|
|
14192
|
+
if (isDiscriminativeSubject(subject))
|
|
13618
14193
|
return subject;
|
|
13619
14194
|
const content = (memory.content ?? "").trim();
|
|
13620
14195
|
if (!content)
|
|
@@ -13622,7 +14197,7 @@ export function deriveRecallCue(memory) {
|
|
|
13622
14197
|
const sentenceMatch = content.match(/^[^.!?\n]+[.!?]?/);
|
|
13623
14198
|
const firstSentence = (sentenceMatch ? sentenceMatch[0] : content).trim();
|
|
13624
14199
|
const words = firstSentence.split(/\s+/).filter(Boolean);
|
|
13625
|
-
const cueWordLimit =
|
|
14200
|
+
const cueWordLimit = RECALL_CUE_CONTENT_WORD_LIMIT;
|
|
13626
14201
|
return words.length <= cueWordLimit ? firstSentence : words.slice(0, cueWordLimit).join(" ");
|
|
13627
14202
|
}
|
|
13628
14203
|
/**
|
|
@@ -13659,6 +14234,71 @@ export function computeRecallSpotCheck(sampledIds, perQueryResultIds, k) {
|
|
|
13659
14234
|
k,
|
|
13660
14235
|
};
|
|
13661
14236
|
}
|
|
14237
|
+
/** Rows the spot-check writes itself, and therefore must never grade itself
|
|
14238
|
+
* on — see RecallSpotCheckPlan['excludedSnapshotRows']. */
|
|
14239
|
+
function isQualitySnapshotRow(m) {
|
|
14240
|
+
return m?.type === "quality-snapshot" || (m?.subject ?? "").startsWith("quality-snapshot/");
|
|
14241
|
+
}
|
|
14242
|
+
/**
|
|
14243
|
+
* Pure planner for the recall spot-check: raw memory rows → the window to
|
|
14244
|
+
* query (id + cue) plus that window's health. Extracted from
|
|
14245
|
+
* fetchRecallSpotCheckData so the sampling, cue-derivation and
|
|
14246
|
+
* fail-closed health rules are testable without any I/O (flair#967).
|
|
14247
|
+
*
|
|
14248
|
+
* Order of operations, and why:
|
|
14249
|
+
* 1. drop the tool's own quality-snapshot rows (never grade your own
|
|
14250
|
+
* bookkeeping);
|
|
14251
|
+
* 2. take the `sampleSize` most-recently-written remaining rows (unchanged —
|
|
14252
|
+
* recency is still the sampling frame; see the issue's direction 3 for the
|
|
14253
|
+
* stratified-sampling follow-up this deliberately does NOT take on);
|
|
14254
|
+
* 3. derive each cue via deriveRecallCue;
|
|
14255
|
+
* 4. judge the window: any duplicate cue, or any empty cue, makes it
|
|
14256
|
+
* UNSCORABLE — reported as unhealthy, never silently scored.
|
|
14257
|
+
*/
|
|
14258
|
+
export function planRecallSpotCheck(memories, opts = {}) {
|
|
14259
|
+
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
14260
|
+
const rows = Array.isArray(memories) ? memories : [];
|
|
14261
|
+
const scorable = rows.filter((m) => !isQualitySnapshotRow(m ?? {}));
|
|
14262
|
+
const excludedSnapshotRows = rows.length - scorable.length;
|
|
14263
|
+
const sorted = scorable.slice().sort((a, b) => {
|
|
14264
|
+
const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
14265
|
+
const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
14266
|
+
return tb - ta;
|
|
14267
|
+
});
|
|
14268
|
+
const sampled = sorted.slice(0, sampleSize).map((m) => ({ id: String(m?.id), cue: deriveRecallCue(m ?? {}) }));
|
|
14269
|
+
const counts = new Map();
|
|
14270
|
+
let emptyCueCount = 0;
|
|
14271
|
+
for (const s of sampled) {
|
|
14272
|
+
if (!s.cue) {
|
|
14273
|
+
emptyCueCount += 1;
|
|
14274
|
+
continue;
|
|
14275
|
+
}
|
|
14276
|
+
counts.set(s.cue, (counts.get(s.cue) ?? 0) + 1);
|
|
14277
|
+
}
|
|
14278
|
+
const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
|
|
14279
|
+
if (duplicateCues.length === 0 && emptyCueCount === 0) {
|
|
14280
|
+
return { sampled, health: { healthy: true }, excludedSnapshotRows };
|
|
14281
|
+
}
|
|
14282
|
+
const parts = [];
|
|
14283
|
+
if (duplicateCues.length > 0) {
|
|
14284
|
+
const shown = duplicateCues.slice(0, 3).map((c) => `"${c.length > 60 ? `${c.slice(0, 57)}...` : c}"`).join(", ");
|
|
14285
|
+
const dupMemberCount = duplicateCues.reduce((n, c) => n + (counts.get(c) ?? 0), 0);
|
|
14286
|
+
parts.push(`${dupMemberCount} of the ${sampled.length} sampled memories derive the same cue as another (${shown}${duplicateCues.length > 3 ? `, +${duplicateCues.length - 3} more` : ""}) — identical cues are one query with one result list, so those memories must displace each other and cannot all be found`);
|
|
14287
|
+
}
|
|
14288
|
+
if (emptyCueCount > 0) {
|
|
14289
|
+
parts.push(`${emptyCueCount} of the ${sampled.length} sampled memories have no derivable cue (no subject and no content)`);
|
|
14290
|
+
}
|
|
14291
|
+
return {
|
|
14292
|
+
sampled,
|
|
14293
|
+
health: {
|
|
14294
|
+
healthy: false,
|
|
14295
|
+
reason: `sample unhealthy — ${parts.join("; ")}. No score recorded for this run (flair#967: fail closed rather than publish an unscorable number).`,
|
|
14296
|
+
duplicateCues,
|
|
14297
|
+
emptyCueCount,
|
|
14298
|
+
},
|
|
14299
|
+
excludedSnapshotRows,
|
|
14300
|
+
};
|
|
14301
|
+
}
|
|
13662
14302
|
/**
|
|
13663
14303
|
* Pure computation: /HealthDetail response (+ reachability) → quality report.
|
|
13664
14304
|
* Never throws — every missing data source degrades to a null section + a
|
|
@@ -13856,50 +14496,55 @@ export function computeQualityReport(healthy, healthData, opts = {}) {
|
|
|
13856
14496
|
* `agentId`'s own memories and, for each, search for a cue derived from it.
|
|
13857
14497
|
* Reuses the EXACT read path `flair memory search` / `flair memory list`
|
|
13858
14498
|
* use — `api()` (→ authedRequest's 5-tier resolver) for both the
|
|
13859
|
-
* `GET /Memory
|
|
13860
|
-
*
|
|
13861
|
-
*
|
|
13862
|
-
*
|
|
13863
|
-
*
|
|
13864
|
-
*
|
|
14499
|
+
* projected, bounded `GET /Memory?…&select(…)&limit(…)` sample fetch
|
|
14500
|
+
* (flair#1360 — never the unfiltered collection with embeddings inline)
|
|
14501
|
+
* and the `POST /SemanticSearch` queries — so this has zero new endpoint
|
|
14502
|
+
* and zero new auth mechanism; it is scoped to `agentId`'s own memories
|
|
14503
|
+
* exactly as those commands already are. Never throws: every failure mode
|
|
14504
|
+
* (no agentId, fewer than `sampleSize` memories, a fetch/search error)
|
|
14505
|
+
* returns `{ ok: false, skipReason }` for computeQualityReport to turn
|
|
14506
|
+
* into a `gaps` entry.
|
|
13865
14507
|
*/
|
|
13866
|
-
async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
14508
|
+
export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
13867
14509
|
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
13868
14510
|
const k = opts.k ?? QUALITY_RECALL_K;
|
|
14511
|
+
const request = opts.request ?? api;
|
|
13869
14512
|
if (!agentId) {
|
|
13870
14513
|
return { ok: false, skipReason: "no agent identity to query as — pass --agent or set FLAIR_AGENT_ID" };
|
|
13871
14514
|
}
|
|
13872
14515
|
let all;
|
|
13873
14516
|
try {
|
|
13874
|
-
const
|
|
13875
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
|
|
14517
|
+
const raw = await request("GET", qualityRecallSamplePath(agentId, sampleSize), undefined, { baseUrl, agentId });
|
|
13876
14518
|
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
13877
14519
|
}
|
|
13878
14520
|
catch (err) {
|
|
13879
14521
|
return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
|
|
13880
14522
|
}
|
|
13881
|
-
|
|
14523
|
+
// Deterministic sample + cue derivation + fail-closed health judgment, all
|
|
14524
|
+
// pure (planRecallSpotCheck above). Snapshot rows are excluded there, so the
|
|
14525
|
+
// "enough memories" check has to run on the PLANNED window, not on the raw
|
|
14526
|
+
// row count — an instance whose recent writes are mostly the sweep's own
|
|
14527
|
+
// bookkeeping should skip with a reason, not score a short window.
|
|
14528
|
+
const plan = planRecallSpotCheck(all, { sampleSize });
|
|
14529
|
+
if (plan.sampled.length < sampleSize) {
|
|
14530
|
+
const excluded = plan.excludedSnapshotRows > 0 ? ` (${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping)` : "";
|
|
13882
14531
|
return {
|
|
13883
14532
|
ok: false,
|
|
13884
14533
|
agentId,
|
|
13885
|
-
skipReason: `agent '${agentId}' has ${
|
|
14534
|
+
skipReason: `agent '${agentId}' has ${plan.sampled.length} scorable memories, fewer than the ${sampleSize} needed to sample${excluded}`,
|
|
13886
14535
|
};
|
|
13887
14536
|
}
|
|
13888
|
-
//
|
|
13889
|
-
|
|
13890
|
-
|
|
13891
|
-
|
|
13892
|
-
|
|
13893
|
-
});
|
|
13894
|
-
const sampled = sorted.slice(0, sampleSize);
|
|
14537
|
+
// flair#967: a window whose cues collide cannot be scored fairly — report
|
|
14538
|
+
// that fact instead of a number, and don't spend the searches either.
|
|
14539
|
+
if (!plan.health.healthy) {
|
|
14540
|
+
return { ok: false, agentId, skipReason: plan.health.reason, sampleHealth: plan.health };
|
|
14541
|
+
}
|
|
13895
14542
|
const sampledIds = [];
|
|
13896
14543
|
const perQueryResultIds = [];
|
|
13897
14544
|
try {
|
|
13898
|
-
for (const
|
|
13899
|
-
const id = String(m.id);
|
|
13900
|
-
const cue = deriveRecallCue(m);
|
|
14545
|
+
for (const { id, cue } of plan.sampled) {
|
|
13901
14546
|
const body = { agentId, q: cue, limit: k };
|
|
13902
|
-
const res = await
|
|
14547
|
+
const res = await request("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
13903
14548
|
const results = Array.isArray(res) ? res : (res?.results ?? []);
|
|
13904
14549
|
sampledIds.push(id);
|
|
13905
14550
|
perQueryResultIds.push(results.map((r) => String(r.id)));
|
|
@@ -13908,7 +14553,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
13908
14553
|
catch (err) {
|
|
13909
14554
|
return { ok: false, agentId, skipReason: `recall spot-check search failed: ${err?.message ?? String(err)}` };
|
|
13910
14555
|
}
|
|
13911
|
-
return { ok: true, agentId, sampledIds, perQueryResultIds, k };
|
|
14556
|
+
return { ok: true, agentId, sampledIds, perQueryResultIds, k, sampleHealth: plan.health };
|
|
13912
14557
|
}
|
|
13913
14558
|
// ─── flair quality --emit (Slice 2 of the memory-quality-observability arc:
|
|
13914
14559
|
// quality OrgEvents) ─────────────────────────────────────────────────────────
|
|
@@ -13958,6 +14603,22 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
13958
14603
|
export const QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT = 90;
|
|
13959
14604
|
export const QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT = 5;
|
|
13960
14605
|
export const QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT = 10;
|
|
14606
|
+
/**
|
|
14607
|
+
* RETAINED AT ITS ORIGINAL VALUE AND DELIBERATELY UNWIRED (flair#967).
|
|
14608
|
+
*
|
|
14609
|
+
* Nothing in diffQualitySnapshots reads this any more — the recall spot-check
|
|
14610
|
+
* is report-only and emits no event at any delta (see the Slice 1d framing in
|
|
14611
|
+
* the module doc for the 32-run σ = 0.291 / precision-0 measurement behind
|
|
14612
|
+
* that). The constant stays, unchanged at 0.2, as the standing evidence that
|
|
14613
|
+
* the fix was "remove alerting authority from a metric that never earned it",
|
|
14614
|
+
* NOT "widen the gate until it stops talking" — a silenced check and a
|
|
14615
|
+
* de-authorised one look identical in a changelog and are opposites in
|
|
14616
|
+
* practice, and 0.2 sitting here at 0.69σ is the arithmetic that makes the
|
|
14617
|
+
* difference legible. If this probe is ever re-armed, the replacement
|
|
14618
|
+
* threshold must be DERIVED from the measured run-to-run variance of the
|
|
14619
|
+
* FIXED cue derivation, not typed in — do not just re-reference this literal.
|
|
14620
|
+
* Asserted unchanged by test/unit/quality-recall-spotcheck-967.test.ts.
|
|
14621
|
+
*/
|
|
13961
14622
|
export const QUALITY_EVENT_RECALL_DROP_THRESHOLD = 0.2;
|
|
13962
14623
|
export const QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD = 0.5; // >50%
|
|
13963
14624
|
export const QUALITY_EVENT_DEDUP_GROWTH_ABS_THRESHOLD = 5; // AND by >= 5 clusters
|
|
@@ -14024,29 +14685,30 @@ export function diffQualitySnapshots(current, previous) {
|
|
|
14024
14685
|
});
|
|
14025
14686
|
}
|
|
14026
14687
|
}
|
|
14027
|
-
// ── recall spot-check:
|
|
14028
|
-
|
|
14029
|
-
|
|
14030
|
-
|
|
14031
|
-
|
|
14032
|
-
|
|
14033
|
-
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
14037
|
-
|
|
14038
|
-
|
|
14039
|
-
|
|
14040
|
-
|
|
14041
|
-
|
|
14042
|
-
|
|
14043
|
-
|
|
14044
|
-
|
|
14045
|
-
|
|
14046
|
-
|
|
14047
|
-
|
|
14048
|
-
|
|
14049
|
-
|
|
14688
|
+
// ── recall spot-check: REPORT-ONLY, no branch here on purpose (flair#967) ──
|
|
14689
|
+
//
|
|
14690
|
+
// This metric used to emit two quality.regression events (recall@k and MRR,
|
|
14691
|
+
// both at QUALITY_EVENT_RECALL_DROP_THRESHOLD). It no longer emits anything,
|
|
14692
|
+
// at any delta. Measured, on rockit production:
|
|
14693
|
+
//
|
|
14694
|
+
// 32 nightly runs · population σ 0.291 · mean |run-to-run delta| 0.223
|
|
14695
|
+
// threshold 0.2 → 0.69σ, i.e. BELOW the metric's own noise floor
|
|
14696
|
+
// 6 findings-mails in 34 runs, replay-predicted 6/6 from these branches,
|
|
14697
|
+
// all 6 oscillation → lifetime precision 0
|
|
14698
|
+
//
|
|
14699
|
+
// Removing an emission is not the same move as raising a threshold, and the
|
|
14700
|
+
// distinction is the whole point: raising 0.2 would leave a check that still
|
|
14701
|
+
// claims to detect recall regressions while detecting none, whereas this
|
|
14702
|
+
// hands that job to the instrument that can actually do it — the
|
|
14703
|
+
// deterministic, fixed-label, CI-gated eval in
|
|
14704
|
+
// test/integration-heavy/recall-eval-gate.test.ts (test/bench/recall-eval),
|
|
14705
|
+
// whose floors sit ≥2 whole queries below the measured value against a
|
|
14706
|
+
// 0.000 noise band. QUALITY_EVENT_RECALL_DROP_THRESHOLD is left at 0.2,
|
|
14707
|
+
// unwired, so that stays checkable rather than asserted.
|
|
14708
|
+
//
|
|
14709
|
+
// current.recallSpotCheck / previous.recallSpotCheck are still SNAPSHOTTED
|
|
14710
|
+
// (buildQualitySnapshot above) — the history that made this diagnosis
|
|
14711
|
+
// possible keeps accumulating, and `flair quality` still prints the number.
|
|
14050
14712
|
// ── quiet agents: per-agent, NEWLY quiet only (was false last snapshot,
|
|
14051
14713
|
// true now) — never re-fires for an agent that was already quiet last
|
|
14052
14714
|
// snapshot, and never fires for an agent absent from the previous snapshot
|
|
@@ -14100,21 +14762,23 @@ export function qualitySnapshotSubject(baseUrl) {
|
|
|
14100
14762
|
return `quality-snapshot/${host}`;
|
|
14101
14763
|
}
|
|
14102
14764
|
/** Fetch the most recent prior quality snapshot for `agentId` at `baseUrl`,
|
|
14103
|
-
* via the
|
|
14104
|
-
*
|
|
14105
|
-
*
|
|
14106
|
-
* `
|
|
14107
|
-
*
|
|
14108
|
-
*
|
|
14109
|
-
*
|
|
14110
|
-
*
|
|
14111
|
-
*
|
|
14112
|
-
*
|
|
14113
|
-
|
|
14765
|
+
* via the same signed `GET /Memory` read path fetchRecallSpotCheckData
|
|
14766
|
+
* uses (self-scoped by the signed request's own agent identity — no new
|
|
14767
|
+
* endpoint). Projects the same fields (never embeddings — flair#1360) and
|
|
14768
|
+
* asks for `subject` as a query equals; still filters client-side by
|
|
14769
|
+
* subject because Memory.search() historically did not turn bare query
|
|
14770
|
+
* params into search conditions beyond the signed agentId scope (see
|
|
14771
|
+
* resources/Memory.ts's search()), same client-side-filter pattern
|
|
14772
|
+
* `memory list --hash-fallback` already uses. Returns null on: no prior
|
|
14773
|
+
* snapshot, a fetch error, or a snapshot row whose content isn't
|
|
14774
|
+
* parseable/versioned JSON (never throws — a corrupt or foreign row
|
|
14775
|
+
* degrades to "no snapshot", same as a genuine first run, rather than
|
|
14776
|
+
* crashing `--emit`). */
|
|
14777
|
+
export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, opts = {}) {
|
|
14778
|
+
const request = opts.request ?? api;
|
|
14114
14779
|
let all;
|
|
14115
14780
|
try {
|
|
14116
|
-
const
|
|
14117
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
|
|
14781
|
+
const raw = await request("GET", qualitySnapshotLookupPath(agentId, subject), undefined, { baseUrl, agentId });
|
|
14118
14782
|
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
14119
14783
|
}
|
|
14120
14784
|
catch {
|
|
@@ -14226,6 +14890,12 @@ program
|
|
|
14226
14890
|
const mode = render.resolveOutputMode(opts);
|
|
14227
14891
|
if (mode === "json") {
|
|
14228
14892
|
const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...report };
|
|
14893
|
+
// flair#967: when a window was assembled, say whether it was scorable —
|
|
14894
|
+
// structurally, not only as prose inside a `gaps` reason. An unhealthy
|
|
14895
|
+
// sample is a FACT ABOUT THE RUN that a consumer must be able to read
|
|
14896
|
+
// without string-matching.
|
|
14897
|
+
if (recallSpotCheckData.sampleHealth)
|
|
14898
|
+
out.recallSampleHealth = recallSpotCheckData.sampleHealth;
|
|
14229
14899
|
if (emitResult) {
|
|
14230
14900
|
out.emit = { firstRun: emitResult.firstRun, snapshotId: emitResult.snapshotId, errors: emitResult.errors };
|
|
14231
14901
|
out.emittedEvents = emitResult.emittedEvents.map((e) => ({
|
|
@@ -14339,15 +15009,15 @@ program
|
|
|
14339
15009
|
console.log(render.kv("Clusters", `${render.wrap(render.c.bold, String(dc.clusterCount))} ${render.wrap(render.c.dim, `(${dc.totalMemoriesInClusters} memories, largest cluster ${dc.largestClusterSize})`)}`));
|
|
14340
15010
|
console.log(` ${render.wrap(render.c.dim, "an ops signal — near-duplicate memories piling up, not a trust judgment")}`);
|
|
14341
15011
|
}
|
|
14342
|
-
// Recall spot-check (flair-quality Slice 1d) — a
|
|
14343
|
-
// a benchmark
|
|
14344
|
-
//
|
|
14345
|
-
// doc for the full framing.
|
|
15012
|
+
// Recall spot-check (flair-quality Slice 1d) — a REPORT-ONLY health
|
|
15013
|
+
// spot-check: not a benchmark, not a trust judgment, and since flair#967
|
|
15014
|
+
// not an alerting signal either. See QualityReport['recallSpotCheck'] doc
|
|
15015
|
+
// and the Slice 1d module doc for the full framing.
|
|
14346
15016
|
if (report.recallSpotCheck) {
|
|
14347
15017
|
const rc = report.recallSpotCheck;
|
|
14348
|
-
console.log(`\n${render.wrap(render.c.bold, "Recall spot-check")} ${render.wrap(render.c.dim, `(agent ${rc.agentId ?? "—"},
|
|
15018
|
+
console.log(`\n${render.wrap(render.c.bold, "Recall spot-check")} ${render.wrap(render.c.dim, `(agent ${rc.agentId ?? "—"}, report-only — not a benchmark, not an alert)`)}`);
|
|
14349
15019
|
console.log(render.kv(`recall@${rc.k}`, `${render.wrap(render.c.bold, rc.recallAtK.toFixed(2))} ${render.wrap(render.c.dim, `(MRR ${rc.mrr.toFixed(2)}, ${rc.sampleSize} sampled)`)}`));
|
|
14350
|
-
console.log(` ${render.wrap(render.c.dim, "
|
|
15020
|
+
console.log(` ${render.wrap(render.c.dim, "observability only — recall REGRESSIONS are detected by the deterministic CI gate (test/bench/recall-eval), not by this number")}`);
|
|
14351
15021
|
}
|
|
14352
15022
|
// Gaps
|
|
14353
15023
|
if (report.gaps.length > 0) {
|
|
@@ -14576,9 +15246,8 @@ function parseEntitiesOptionOrExit(csv) {
|
|
|
14576
15246
|
}
|
|
14577
15247
|
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`)";
|
|
14578
15248
|
const memory = program.command("memory").description("Manage agent memories");
|
|
14579
|
-
memory.command("add [content]")
|
|
14580
|
-
.description("Write a new memory row for an agent (content via positional arg or --content)")
|
|
14581
|
-
.requiredOption("--agent <id>")
|
|
15249
|
+
addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]")
|
|
15250
|
+
.description("Write a new memory row for an agent (content via positional arg or --content)")))
|
|
14582
15251
|
.option("--content <text>", "memory content (alias for positional arg)")
|
|
14583
15252
|
.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>")
|
|
14584
15253
|
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
|
|
@@ -14592,10 +15261,15 @@ memory.command("add [content]")
|
|
|
14592
15261
|
console.error("error: content required (positional arg or --content)");
|
|
14593
15262
|
process.exit(1);
|
|
14594
15263
|
}
|
|
14595
|
-
|
|
14596
|
-
const
|
|
15264
|
+
applyAdminPassFile(opts);
|
|
15265
|
+
const agentId = resolveSigningAgentId(opts, "memory add");
|
|
15266
|
+
if (!agentId) {
|
|
15267
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15268
|
+
process.exit(2);
|
|
15269
|
+
}
|
|
15270
|
+
const memId = `${agentId}-${Date.now()}`;
|
|
14597
15271
|
const body = {
|
|
14598
|
-
id: memId, agentId
|
|
15272
|
+
id: memId, agentId, content, durability: opts.durability || "standard",
|
|
14599
15273
|
tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
|
|
14600
15274
|
type: "memory", createdAt: new Date().toISOString(),
|
|
14601
15275
|
};
|
|
@@ -14628,7 +15302,11 @@ memory.command("add [content]")
|
|
|
14628
15302
|
if (entities.length > 0)
|
|
14629
15303
|
body.entities = entities;
|
|
14630
15304
|
}
|
|
14631
|
-
const out = await api("PUT", `/Memory/${memId}`, body, {
|
|
15305
|
+
const out = await api("PUT", `/Memory/${memId}`, body, {
|
|
15306
|
+
agentId,
|
|
15307
|
+
explicitAdminPass: opts.adminPass,
|
|
15308
|
+
adminUser: opts.adminUser,
|
|
15309
|
+
});
|
|
14632
15310
|
console.log(JSON.stringify(out, null, 2));
|
|
14633
15311
|
});
|
|
14634
15312
|
// ─── flair memory write-task-summary ────────────────────────────────────────
|
|
@@ -14906,7 +15584,7 @@ memory.command("hygiene")
|
|
|
14906
15584
|
const enabled = new Set((opts.pattern ?? "compact-id,test-content,tiny").split(",").map((s) => s.trim()).filter(Boolean));
|
|
14907
15585
|
const tinyThreshold = Math.max(0, Number(opts.tinyThreshold) || 25);
|
|
14908
15586
|
const apply = !!opts.apply;
|
|
14909
|
-
const opsAuth = `Basic ${Buffer.from(
|
|
15587
|
+
const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
|
|
14910
15588
|
async function ops(body) {
|
|
14911
15589
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
14912
15590
|
method: "POST",
|
|
@@ -15874,7 +16552,14 @@ bridge
|
|
|
15874
16552
|
process.exit(2);
|
|
15875
16553
|
}
|
|
15876
16554
|
try {
|
|
15877
|
-
const result = await runRoundTrip({
|
|
16555
|
+
const result = await runRoundTrip({
|
|
16556
|
+
descriptor: loaded.descriptor,
|
|
16557
|
+
cwd,
|
|
16558
|
+
fixturePath: opts.fixture,
|
|
16559
|
+
// Keep the intermediate export so a failure can print a live path.
|
|
16560
|
+
// The next harness start sweeps leftovers older than a minute (flair#1032).
|
|
16561
|
+
retainTmpDir: true,
|
|
16562
|
+
});
|
|
15878
16563
|
if (opts.json) {
|
|
15879
16564
|
console.log(JSON.stringify(result, null, 2));
|
|
15880
16565
|
process.exit(result.passed ? 0 : 1);
|
|
@@ -16081,31 +16766,20 @@ function printTrustError(detail) {
|
|
|
16081
16766
|
}
|
|
16082
16767
|
}
|
|
16083
16768
|
// ─── flair backup ────────────────────────────────────────────────────────────
|
|
16084
|
-
program
|
|
16769
|
+
addSharedCredentialOptions(program
|
|
16085
16770
|
.command("backup")
|
|
16086
16771
|
.description("Export agents, memories, and souls to a JSON archive")
|
|
16087
16772
|
.option("--output <path>", "Output file path (default: ~/.flair/backups/flair-backup-<timestamp>.json)")
|
|
16088
16773
|
.option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
|
|
16089
16774
|
.option("--port <port>", "Harper HTTP port")
|
|
16090
|
-
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16091
|
-
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
|
|
16092
|
-
.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.")
|
|
16093
|
-
.action(async (opts) => {
|
|
16775
|
+
.option("--url <url>", "Flair base URL (overrides --port)")).action(async (opts) => {
|
|
16094
16776
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
16095
|
-
|
|
16096
|
-
|
|
16097
|
-
|
|
16098
|
-
|
|
16099
|
-
|
|
16100
|
-
|
|
16101
|
-
adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
16102
|
-
}
|
|
16103
|
-
catch (err) {
|
|
16104
|
-
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
16105
|
-
process.exit(1);
|
|
16106
|
-
}
|
|
16107
|
-
}
|
|
16108
|
-
const adminUser = DEFAULT_ADMIN_USER;
|
|
16777
|
+
applyAdminPassFile(opts);
|
|
16778
|
+
// Env is a second-class fallback after the explicit flags (same order
|
|
16779
|
+
// backup used before the shared helper). FLAIR_ADMIN_PASS is still
|
|
16780
|
+
// accepted so existing scripts keep working.
|
|
16781
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
16782
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
16109
16783
|
if (!adminPass) {
|
|
16110
16784
|
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
|
|
16111
16785
|
process.exit(1);
|
|
@@ -16194,11 +16868,12 @@ program
|
|
|
16194
16868
|
.option("--port <port>", "Harper HTTP port")
|
|
16195
16869
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16196
16870
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
16871
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16197
16872
|
.option("--dry-run", "Show what would be imported without making changes")
|
|
16198
16873
|
.action(async (backupPath, opts) => {
|
|
16199
16874
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
16200
16875
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
16201
|
-
const adminUser =
|
|
16876
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
16202
16877
|
const dryRun = Boolean(opts.dryRun);
|
|
16203
16878
|
const mode = opts.replace ? "replace" : "merge";
|
|
16204
16879
|
if (!adminPass) {
|
|
@@ -16311,6 +16986,7 @@ program
|
|
|
16311
16986
|
.option("--port <port>", "Harper HTTP port")
|
|
16312
16987
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16313
16988
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
16989
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16314
16990
|
.option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
|
|
16315
16991
|
.action(async (agentId, opts) => {
|
|
16316
16992
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
@@ -16319,7 +16995,7 @@ program
|
|
|
16319
16995
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
16320
16996
|
process.exit(1);
|
|
16321
16997
|
}
|
|
16322
|
-
const auth = `Basic ${Buffer.from(`${
|
|
16998
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
16323
16999
|
async function adminGet(path) {
|
|
16324
17000
|
const res = await fetch(`${baseUrl}${path}`, { headers: { Authorization: auth }, signal: AbortSignal.timeout(10_000) });
|
|
16325
17001
|
if (!res.ok)
|
|
@@ -16402,6 +17078,7 @@ program
|
|
|
16402
17078
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16403
17079
|
.option("--ops-target <url>", "Explicit ops API URL for the Agent seed (env: FLAIR_OPS_TARGET; bypasses port derivation). Use when --url is remote and the ops port isn't HTTP-1.")
|
|
16404
17080
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
17081
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16405
17082
|
.option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
|
|
16406
17083
|
.action(async (importPath, opts) => {
|
|
16407
17084
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
@@ -16462,12 +17139,12 @@ program
|
|
|
16462
17139
|
: nacl.sign.keyPair.fromSeed(new Uint8Array(decodedSeed.subarray(0, 32))).publicKey;
|
|
16463
17140
|
const pubKeyB64url = b64url(pubKey);
|
|
16464
17141
|
// Register agent via ops API (remote when --url/--ops-target points off-box)
|
|
16465
|
-
await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url,
|
|
17142
|
+
await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, resolveAdminUser(opts.adminUser), adminPass);
|
|
16466
17143
|
console.log(typeof seedOpsTarget === "string"
|
|
16467
17144
|
? ` Agent registered (ops: ${seedOpsTarget})`
|
|
16468
17145
|
: ` Agent registered`);
|
|
16469
17146
|
// Restore memories
|
|
16470
|
-
const auth = `Basic ${Buffer.from(`${
|
|
17147
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
16471
17148
|
let memCount = 0;
|
|
16472
17149
|
for (const mem of data.memories ?? []) {
|
|
16473
17150
|
try {
|
|
@@ -16748,7 +17425,7 @@ program
|
|
|
16748
17425
|
createdAt: new Date().toISOString(),
|
|
16749
17426
|
};
|
|
16750
17427
|
const memoryPath = `/Memory/${memoryId}`;
|
|
16751
|
-
const auth = `Basic ${Buffer.from(
|
|
17428
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
|
|
16752
17429
|
const res = await fetch(`${httpUrl}${memoryPath}`, {
|
|
16753
17430
|
method: "PUT",
|
|
16754
17431
|
headers: {
|
|
@@ -17222,7 +17899,7 @@ if (import.meta.main) {
|
|
|
17222
17899
|
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
17223
17900
|
export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
|
|
17224
17901
|
// Harper's own config — the per-instance port record (flair#914)
|
|
17225
|
-
harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, FABRIC_OPS_PORT, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
|
|
17902
|
+
harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, FABRIC_OPS_PORT, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure, DEFAULT_ADMIN_USER, resolveAdminUser,
|
|
17226
17903
|
// launchd label (flair#693)
|
|
17227
17904
|
LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
|
|
17228
17905
|
// launchd management observation (flair#1022)
|