@tpsdev-ai/flair 0.47.1 → 0.49.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/build-info.json +3 -3
- package/dist/cli.js +525 -121
- package/dist/component-env.js +52 -4
- package/dist/doctor-client.js +46 -1
- package/dist/hook-install.js +52 -4
- 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/resources/AgentSeed.js +2 -0
- package/dist/resources/Memory.js +46 -9
- package/dist/resources/MemoryFeed.js +3 -0
- package/dist/resources/MemoryMaintenance.js +11 -2
- package/dist/resources/SemanticSearch.js +15 -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/memory-read-scope.js +2 -0
- package/dist/resources/semantic-retrieval-core.js +93 -22
- package/dist/version-check.js +59 -13
- package/docs/claude-code.md +10 -3
- package/docs/deployment.md +11 -1
- package/docs/integrations.md +25 -4
- package/docs/mcp-clients.md +18 -0
- package/docs/notes/mcp-oauth-model2.md +31 -13
- package/docs/quickstart.md +9 -9
- package/docs/standalone-local.md +3 -0
- package/package.json +3 -2
- package/schemas/memory.graphql +13 -0
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, } 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.
|
|
@@ -1905,6 +1906,27 @@ function readHarperPid(dataDir) {
|
|
|
1905
1906
|
return null;
|
|
1906
1907
|
}
|
|
1907
1908
|
}
|
|
1909
|
+
/**
|
|
1910
|
+
* flair#1345 — Harper returns the SAME 401 `{"error":"Login failed"}` for a
|
|
1911
|
+
* wrong password and for a nonexistent username, and the CLI's errors used
|
|
1912
|
+
* to hint only at the password. On an instance whose superuser is not named
|
|
1913
|
+
* `admin` (now reachable in practice: the #604/#610 `authorizeLocal: false`
|
|
1914
|
+
* hardening removed the credential-less loopback path, so these calls MUST
|
|
1915
|
+
* send real Basic auth) that sent operators down the wrong trail entirely.
|
|
1916
|
+
* Name both causes, each with the knob that fixes it.
|
|
1917
|
+
*/
|
|
1918
|
+
function opsAuth401Hint(adminUser) {
|
|
1919
|
+
if (adminUser === undefined) {
|
|
1920
|
+
// No credentials were sent at all (local caller riding authorizeLocal) —
|
|
1921
|
+
// "wrong password or username" would be asserting a cause that isn't
|
|
1922
|
+
// established. The remedy is to send credentials.
|
|
1923
|
+
return ("\n No admin credentials were sent and the instance rejected the request." +
|
|
1924
|
+
"\n Pass --admin-pass <pass> or --admin-pass-file <path> (and --admin-user <name> if the superuser is not 'admin').");
|
|
1925
|
+
}
|
|
1926
|
+
return (`\n The operations API rejected the admin credentials (tried username '${adminUser}'). Two possible causes:` +
|
|
1927
|
+
"\n - wrong password — check --admin-pass / --admin-pass-file / FLAIR_ADMIN_PASS" +
|
|
1928
|
+
`\n - wrong username — this instance's superuser may not be '${adminUser}'; pass --admin-user <name> or set FLAIR_ADMIN_USER`);
|
|
1929
|
+
}
|
|
1908
1930
|
/**
|
|
1909
1931
|
* Seed an agent record via the Harper operations API.
|
|
1910
1932
|
* Accepts either a port number (localhost) or a full URL string (--target).
|
|
@@ -1958,6 +1980,9 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
|
|
|
1958
1980
|
const text = await res.text().catch(() => "");
|
|
1959
1981
|
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
1960
1982
|
return;
|
|
1983
|
+
if (res.status === 401) {
|
|
1984
|
+
throw new Error(`Operations API insert failed (401): ${text}${opsAuth401Hint(auth === undefined ? undefined : adminUser)}`);
|
|
1985
|
+
}
|
|
1961
1986
|
throw new Error(`Operations API insert failed (${res.status}): ${text}`);
|
|
1962
1987
|
}
|
|
1963
1988
|
}
|
|
@@ -2009,6 +2034,9 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
|
|
|
2009
2034
|
const text = await res.text().catch(() => "");
|
|
2010
2035
|
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
2011
2036
|
return;
|
|
2037
|
+
if (res.status === 401) {
|
|
2038
|
+
throw new Error(`Federation Instance insert via ops API failed (401): ${text}${opsAuth401Hint(auth === undefined ? undefined : adminUser)}`);
|
|
2039
|
+
}
|
|
2012
2040
|
throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
|
|
2013
2041
|
}
|
|
2014
2042
|
}
|
|
@@ -2917,11 +2945,12 @@ program
|
|
|
2917
2945
|
.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
2946
|
.option("--admin-pass <pass>", "Admin password (generated if omitted)")
|
|
2919
2947
|
.option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
|
|
2948
|
+
.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
2949
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
2921
2950
|
.option("--data-dir <dir>", "Harper data directory")
|
|
2922
2951
|
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
2923
2952
|
.option("--skip-soul", "Skip interactive personality setup")
|
|
2924
|
-
.option("--client <client>", "
|
|
2953
|
+
.option("--client <client>", "Client(s) to wire: claude-code, codex, gemini, cursor, antigravity, pi (native extension), all, or none")
|
|
2925
2954
|
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
2926
2955
|
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
2927
2956
|
.option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
|
|
@@ -3005,7 +3034,10 @@ program
|
|
|
3005
3034
|
}
|
|
3006
3035
|
flairAdminPass = opts.adminPass;
|
|
3007
3036
|
}
|
|
3008
|
-
|
|
3037
|
+
// flair#1345: only the already-running-instance leg honors --admin-user /
|
|
3038
|
+
// FLAIR_ADMIN_USER — the provisioning leg just CREATED the superuser as
|
|
3039
|
+
// DEFAULT_ADMIN_USER via provisionFabric, so that name is ground truth.
|
|
3040
|
+
const adminUser = didProvision ? DEFAULT_ADMIN_USER : resolveAdminUser(opts.adminUser);
|
|
3009
3041
|
const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
|
|
3010
3042
|
const role = opts.remote ? "hub" : undefined;
|
|
3011
3043
|
// Generate or reuse keypair (only if --agent-id provided, or --remote needs
|
|
@@ -3140,9 +3172,9 @@ program
|
|
|
3140
3172
|
const noMcp = opts.mcp === false;
|
|
3141
3173
|
const selectedClients = [];
|
|
3142
3174
|
if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
|
|
3143
|
-
const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
|
|
3175
|
+
const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity", "pi"];
|
|
3144
3176
|
if (!valid.includes(clientOpt)) {
|
|
3145
|
-
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, all, none`);
|
|
3177
|
+
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, pi, all, none`);
|
|
3146
3178
|
process.exit(1);
|
|
3147
3179
|
}
|
|
3148
3180
|
selectedClients.push(clientOpt);
|
|
@@ -3773,6 +3805,13 @@ program
|
|
|
3773
3805
|
case "antigravity":
|
|
3774
3806
|
result = wireAntigravity({ ...mcpEnv, FLAIR_CLIENT: "antigravity" });
|
|
3775
3807
|
break;
|
|
3808
|
+
// pi is a NATIVE EXTENSION, not an MCP client (flair#1342):
|
|
3809
|
+
// wirePi edits ~/.pi/agent/settings.json `packages`, and pi
|
|
3810
|
+
// settings carry no env block — no FLAIR_CLIENT to stamp; the
|
|
3811
|
+
// wire message tells the user what to export at pi launch.
|
|
3812
|
+
case "pi":
|
|
3813
|
+
result = wirePi(mcpEnv);
|
|
3814
|
+
break;
|
|
3776
3815
|
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
3777
3816
|
}
|
|
3778
3817
|
wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
|
|
@@ -3784,7 +3823,12 @@ program
|
|
|
3784
3823
|
// Launch flair-mcp and confirm it answers a JSON-RPC initialize over
|
|
3785
3824
|
// stdio. Best-effort: failures warn but never fail the command. Skipped
|
|
3786
3825
|
// with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
|
|
3787
|
-
|
|
3826
|
+
// pi doesn't run flair-mcp (native extension, flair#1342), so a pi-only
|
|
3827
|
+
// wiring has nothing this smoke test exercises — spawning it anyway
|
|
3828
|
+
// would render a green "MCP server responded" for a setup that never
|
|
3829
|
+
// starts an MCP server.
|
|
3830
|
+
const wiredAnyMcpClient = wiringResults.some((r) => r.client !== "pi");
|
|
3831
|
+
if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0 && wiredAnyMcpClient) {
|
|
3788
3832
|
console.log("\n Smoke-testing MCP server...");
|
|
3789
3833
|
try {
|
|
3790
3834
|
// Same spec that gets WIRED above — the smoke test must exercise the
|
|
@@ -3934,6 +3978,7 @@ agent
|
|
|
3934
3978
|
.option("--port <port>", "Harper HTTP port")
|
|
3935
3979
|
.option("--admin-pass <pass>", "Admin password for registration")
|
|
3936
3980
|
.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).")
|
|
3981
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
3937
3982
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
3938
3983
|
.option("--ops-port <port>", "Harper operations API port")
|
|
3939
3984
|
.option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
|
|
@@ -3942,7 +3987,7 @@ agent
|
|
|
3942
3987
|
const httpPort = resolveHttpPort(opts);
|
|
3943
3988
|
const opsPort = resolveOpsPort(opts);
|
|
3944
3989
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
3945
|
-
const adminUser =
|
|
3990
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
3946
3991
|
const name = opts.name ?? id;
|
|
3947
3992
|
// Where to seed the Agent record. Default is localhost (opsPort). When
|
|
3948
3993
|
// --ops-target or --target is given, seed on the remote instead of localhost
|
|
@@ -4026,6 +4071,7 @@ agent
|
|
|
4026
4071
|
.command("list")
|
|
4027
4072
|
.description("List all agents")
|
|
4028
4073
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
4074
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
4029
4075
|
.option("--agent <id>", "Agent ID to authenticate as via Ed25519 (or FLAIR_AGENT_ID env) when no admin pass")
|
|
4030
4076
|
.option("--keys-dir <dir>", "Directory holding the agent's Ed25519 key")
|
|
4031
4077
|
.option("--port <port>", "Harper HTTP port")
|
|
@@ -4043,7 +4089,7 @@ agent
|
|
|
4043
4089
|
let agents;
|
|
4044
4090
|
if (adminPass) {
|
|
4045
4091
|
const opsPort = resolveOpsPort(opts);
|
|
4046
|
-
const auth = Buffer.from(`${
|
|
4092
|
+
const auth = Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64");
|
|
4047
4093
|
// List every Agent without null-scanning the primary key. A
|
|
4048
4094
|
// `starts_with ""` on `id` makes Harper search the index for nulls, which
|
|
4049
4095
|
// the bundled Harper (5.0.21) rejects with "id is not indexed for nulls".
|
|
@@ -4163,6 +4209,7 @@ agent
|
|
|
4163
4209
|
.option("--port <port>", "Harper HTTP port")
|
|
4164
4210
|
.option("--ops-port <port>", "Harper operations API port")
|
|
4165
4211
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
4212
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
4166
4213
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
4167
4214
|
.action(async (id, opts) => {
|
|
4168
4215
|
const httpPort = resolveHttpPort(opts);
|
|
@@ -4174,7 +4221,7 @@ agent
|
|
|
4174
4221
|
"to keep secrets out of shell history.");
|
|
4175
4222
|
}
|
|
4176
4223
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
4177
|
-
const adminUser =
|
|
4224
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
4178
4225
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
4179
4226
|
if (!adminPass) {
|
|
4180
4227
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for key rotation");
|
|
@@ -4247,12 +4294,13 @@ agent
|
|
|
4247
4294
|
.option("--port <port>", "Harper HTTP port")
|
|
4248
4295
|
.option("--ops-port <port>", "Harper operations API port")
|
|
4249
4296
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
4297
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
4250
4298
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
4251
4299
|
.option("--force", "Skip interactive confirmation (required when stdin is not a TTY)")
|
|
4252
4300
|
.action(async (id, opts) => {
|
|
4253
4301
|
const opsPort = resolveOpsPort(opts);
|
|
4254
4302
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
4255
|
-
const adminUser =
|
|
4303
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
4256
4304
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
4257
4305
|
if (!adminPass) {
|
|
4258
4306
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for agent remove");
|
|
@@ -4669,8 +4717,15 @@ hook
|
|
|
4669
4717
|
process.exit(1);
|
|
4670
4718
|
}
|
|
4671
4719
|
console.log(` ${status.correctShape ? render.icons.ok : render.icons.warn} wired${status.correctShape ? "" : " (unexpected shape — was it hand-edited?)"}`);
|
|
4672
|
-
|
|
4673
|
-
|
|
4720
|
+
// flair#1325 — skip the URL line only when agentId was recovered
|
|
4721
|
+
// (the installer form that omits FLAIR_URL). A wired correct-shape
|
|
4722
|
+
// command with no env assignments still prints unknown, not a
|
|
4723
|
+
// silent all-clear.
|
|
4724
|
+
for (const line of hookStatusIdentityLines(status)) {
|
|
4725
|
+
const label = line.label === "Agent" ? "Agent: " : "Flair URL:";
|
|
4726
|
+
const value = line.value === HOOK_STATUS_UNPARSED ? render.wrap(render.c.dim, line.value) : line.value;
|
|
4727
|
+
console.log(` ${render.wrap(render.c.dim, label)} ${value}`);
|
|
4728
|
+
}
|
|
4674
4729
|
// flair#1007 — whether a command that stopped resolving would fail quietly
|
|
4675
4730
|
// or print an error on every session start.
|
|
4676
4731
|
if (status.silenced) {
|
|
@@ -5026,6 +5081,7 @@ mcp
|
|
|
5026
5081
|
.option("--keys-dir <dir>", "Directory to write the new key pair into (else FLAIR_KEY_DIR, ~/.flair/keys)")
|
|
5027
5082
|
.option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
|
|
5028
5083
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
5084
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5029
5085
|
.option("--port <port>", "Harper HTTP port")
|
|
5030
5086
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5031
5087
|
.option("--json", "Print machine-readable JSON instead of a human summary")
|
|
@@ -5061,7 +5117,7 @@ mcp
|
|
|
5061
5117
|
manifestPath,
|
|
5062
5118
|
issuer,
|
|
5063
5119
|
opsPortOrUrl: opsPort,
|
|
5064
|
-
adminUser:
|
|
5120
|
+
adminUser: resolveAdminUser(opts.adminUser),
|
|
5065
5121
|
adminPass,
|
|
5066
5122
|
});
|
|
5067
5123
|
if (opts.json) {
|
|
@@ -5086,6 +5142,7 @@ mcp
|
|
|
5086
5142
|
.description("Server-side revoke a granted machine client (deletes its backing Agent record), then clean up locally.")
|
|
5087
5143
|
.option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
|
|
5088
5144
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
5145
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5089
5146
|
.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
5147
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5091
5148
|
.option("--port <port>", "Harper HTTP port")
|
|
@@ -5114,7 +5171,7 @@ mcp
|
|
|
5114
5171
|
name,
|
|
5115
5172
|
manifestPath,
|
|
5116
5173
|
opsPortOrUrl: opsPort,
|
|
5117
|
-
adminUser:
|
|
5174
|
+
adminUser: resolveAdminUser(opts.adminUser),
|
|
5118
5175
|
adminPass,
|
|
5119
5176
|
keepKeys: !!opts.keepKeys,
|
|
5120
5177
|
});
|
|
@@ -5205,6 +5262,7 @@ mcp
|
|
|
5205
5262
|
.option("--cimd-allowed-hosts <hosts>", "Comma-separated clientIdMetadataDocuments.allowedHosts override (else claude.ai,claude.com)")
|
|
5206
5263
|
.option("--signing-key-file <path>", "RS256 signing key PEM file (else ~/.flair/mcp-signing-key.pem)")
|
|
5207
5264
|
.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")
|
|
5265
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5208
5266
|
.option("--confirm-secrets-applied", "Confirm the staged secrets are already live on the target instance's environment (skips the interactive confirm)")
|
|
5209
5267
|
.option("--dry-run", "Generate keys/tokens/config and validate inputs; skip every remote call")
|
|
5210
5268
|
.option("--json", "Print machine-readable JSON instead of a human summary")
|
|
@@ -5264,7 +5322,7 @@ mcp
|
|
|
5264
5322
|
idpSubject,
|
|
5265
5323
|
principal: opts.principal,
|
|
5266
5324
|
principalKind: opts.principalKind,
|
|
5267
|
-
adminUser:
|
|
5325
|
+
adminUser: resolveAdminUser(opts.adminUser),
|
|
5268
5326
|
adminPass,
|
|
5269
5327
|
signingKeyFilePath: opts.signingKeyFile,
|
|
5270
5328
|
secretsMechanism,
|
|
@@ -5313,6 +5371,7 @@ mcp
|
|
|
5313
5371
|
.description("Flag off + restart = byte-identical boot (Model-2 contract) — removes the /mcp OAuth surface.")
|
|
5314
5372
|
.option("--instance <url>", "Remote flair instance to disable against (else FLAIR_URL)")
|
|
5315
5373
|
.option("--admin-pass <pass>", "Admin password for the target instance (or FLAIR_ADMIN_PASS)")
|
|
5374
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5316
5375
|
.option("--confirm-flag-off", "Confirm FLAIR_MCP_OAUTH is already unset on the target instance's environment (skips the interactive confirm)")
|
|
5317
5376
|
.option("--json", "Print machine-readable JSON instead of a human summary")
|
|
5318
5377
|
.action(async (opts) => {
|
|
@@ -5328,7 +5387,7 @@ mcp
|
|
|
5328
5387
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required.");
|
|
5329
5388
|
process.exit(1);
|
|
5330
5389
|
}
|
|
5331
|
-
const result = await disableMcp({ instance, adminUser:
|
|
5390
|
+
const result = await disableMcp({ instance, adminUser: resolveAdminUser(opts.adminUser), adminPass, confirmFlagOff: Boolean(opts.confirmFlagOff) }, { confirmPrompt: confirmYesNo });
|
|
5332
5391
|
if (opts.json) {
|
|
5333
5392
|
console.log(render.asJSON(result));
|
|
5334
5393
|
if (!result.ok)
|
|
@@ -5399,12 +5458,13 @@ principal
|
|
|
5399
5458
|
.option("--runtime <runtime>", "Runtime: openclaw, claude-code, headless, external")
|
|
5400
5459
|
.option("--port <port>", "Harper HTTP port")
|
|
5401
5460
|
.option("--admin-pass <pass>", "Admin password for registration")
|
|
5461
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5402
5462
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
5403
5463
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5404
5464
|
.action(async (id, opts) => {
|
|
5405
5465
|
const opsPort = resolveOpsPort(opts);
|
|
5406
5466
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
5407
|
-
const adminUser =
|
|
5467
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
5408
5468
|
const kind = opts.kind ?? "agent";
|
|
5409
5469
|
const name = opts.name ?? id;
|
|
5410
5470
|
const isAdmin = opts.admin ?? false;
|
|
@@ -5491,6 +5551,7 @@ principal
|
|
|
5491
5551
|
.description("List all principals")
|
|
5492
5552
|
.option("--kind <kind>", "Filter by kind: human or agent")
|
|
5493
5553
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
5554
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5494
5555
|
.option("--port <port>", "Harper HTTP port")
|
|
5495
5556
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5496
5557
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
@@ -5501,7 +5562,7 @@ principal
|
|
|
5501
5562
|
console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
|
|
5502
5563
|
process.exit(1);
|
|
5503
5564
|
}
|
|
5504
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5565
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5505
5566
|
const conditions = opts.kind
|
|
5506
5567
|
? [{ search_attribute: "kind", search_type: "equals", search_value: opts.kind }]
|
|
5507
5568
|
: [{ search_attribute: "id", search_type: "starts_with", search_value: "" }];
|
|
@@ -5623,6 +5684,7 @@ principal
|
|
|
5623
5684
|
.command("disable <id>")
|
|
5624
5685
|
.description("Deactivate a principal (revokes access, preserves data)")
|
|
5625
5686
|
.option("--admin-pass <pass>", "Admin password")
|
|
5687
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5626
5688
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5627
5689
|
.action(async (id, opts) => {
|
|
5628
5690
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -5631,7 +5693,7 @@ principal
|
|
|
5631
5693
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
5632
5694
|
process.exit(1);
|
|
5633
5695
|
}
|
|
5634
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5696
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5635
5697
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5636
5698
|
method: "POST",
|
|
5637
5699
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -5653,6 +5715,7 @@ principal
|
|
|
5653
5715
|
.command("promote <id> <tier>")
|
|
5654
5716
|
.description("Change a principal's trust tier (endorsed, corroborated, unverified)")
|
|
5655
5717
|
.option("--admin-pass <pass>", "Admin password")
|
|
5718
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5656
5719
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5657
5720
|
.action(async (id, tier, opts) => {
|
|
5658
5721
|
const validTiers = ["endorsed", "corroborated", "unverified"];
|
|
@@ -5666,7 +5729,7 @@ principal
|
|
|
5666
5729
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
5667
5730
|
process.exit(1);
|
|
5668
5731
|
}
|
|
5669
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5732
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5670
5733
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5671
5734
|
method: "POST",
|
|
5672
5735
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -5698,6 +5761,7 @@ idp
|
|
|
5698
5761
|
.option("--no-jit-provision", "Disable auto-creation of principals for new IdP users")
|
|
5699
5762
|
.option("--default-trust <tier>", "Trust tier for JIT principals", "unverified")
|
|
5700
5763
|
.option("--admin-pass <pass>", "Admin password")
|
|
5764
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5701
5765
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5702
5766
|
.action(async (opts) => {
|
|
5703
5767
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -5707,7 +5771,7 @@ idp
|
|
|
5707
5771
|
process.exit(1);
|
|
5708
5772
|
}
|
|
5709
5773
|
const id = `idp_${randomUUID().slice(0, 8)}`;
|
|
5710
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5774
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5711
5775
|
const now = new Date().toISOString();
|
|
5712
5776
|
const record = {
|
|
5713
5777
|
id,
|
|
@@ -5744,6 +5808,7 @@ idp
|
|
|
5744
5808
|
.command("list")
|
|
5745
5809
|
.description("List configured IdPs")
|
|
5746
5810
|
.option("--admin-pass <pass>", "Admin password")
|
|
5811
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5747
5812
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5748
5813
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
5749
5814
|
.action(async (opts) => {
|
|
@@ -5753,7 +5818,7 @@ idp
|
|
|
5753
5818
|
console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
|
|
5754
5819
|
process.exit(1);
|
|
5755
5820
|
}
|
|
5756
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5821
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5757
5822
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5758
5823
|
method: "POST",
|
|
5759
5824
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -5798,6 +5863,7 @@ idp
|
|
|
5798
5863
|
.command("remove <id>")
|
|
5799
5864
|
.description("Remove an IdP configuration")
|
|
5800
5865
|
.option("--admin-pass <pass>", "Admin password")
|
|
5866
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5801
5867
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5802
5868
|
.action(async (id, opts) => {
|
|
5803
5869
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -5806,7 +5872,7 @@ idp
|
|
|
5806
5872
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
5807
5873
|
process.exit(1);
|
|
5808
5874
|
}
|
|
5809
|
-
const auth = `Basic ${Buffer.from(`${
|
|
5875
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5810
5876
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5811
5877
|
method: "POST",
|
|
5812
5878
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -5865,12 +5931,13 @@ program
|
|
|
5865
5931
|
.option("--port <port>", "Harper HTTP port")
|
|
5866
5932
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5867
5933
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
5934
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5868
5935
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys (for from-agent Ed25519 auth)")
|
|
5869
5936
|
.action(async (fromAgent, toAgent, opts) => {
|
|
5870
5937
|
const httpPort = resolveHttpPort(opts);
|
|
5871
5938
|
const opsPort = resolveOpsPort(opts);
|
|
5872
5939
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
5873
|
-
const adminUser =
|
|
5940
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
5874
5941
|
const scope = opts.scope ?? "read";
|
|
5875
5942
|
if (!adminPass) {
|
|
5876
5943
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for grant");
|
|
@@ -5914,11 +5981,12 @@ program
|
|
|
5914
5981
|
.option("--port <port>", "Harper HTTP port")
|
|
5915
5982
|
.option("--ops-port <port>", "Harper operations API port")
|
|
5916
5983
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
5984
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
5917
5985
|
.action(async (fromAgent, toAgent, opts) => {
|
|
5918
5986
|
const httpPort = resolveHttpPort(opts);
|
|
5919
5987
|
const opsPort = resolveOpsPort(opts);
|
|
5920
5988
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
5921
|
-
const adminUser =
|
|
5989
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
5922
5990
|
if (!adminPass) {
|
|
5923
5991
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for revoke");
|
|
5924
5992
|
process.exit(1);
|
|
@@ -5962,7 +6030,7 @@ async function loadInstanceSecretKey(instanceId, opts) {
|
|
|
5962
6030
|
// Fallback: check DB for legacy _keySeed
|
|
5963
6031
|
const opsPort = resolveOpsPort(opts);
|
|
5964
6032
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
5965
|
-
const auth = `Basic ${Buffer.from(`${
|
|
6033
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
5966
6034
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
5967
6035
|
method: "POST",
|
|
5968
6036
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -6459,6 +6527,7 @@ federation
|
|
|
6459
6527
|
.description("Pair this spoke with a hub instance")
|
|
6460
6528
|
.option("--port <port>", "Harper HTTP port")
|
|
6461
6529
|
.option("--admin-pass <pass>", "Admin password")
|
|
6530
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
6462
6531
|
.option("--ops-port <port>", "Harper operations API port")
|
|
6463
6532
|
.option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN) [deprecated: use --token-from]")
|
|
6464
6533
|
.option("--token-from <file>", "Read bootstrap triple from JSON file (use '-' for stdin)")
|
|
@@ -6536,7 +6605,7 @@ federation
|
|
|
6536
6605
|
"Without it, 'flair federation sync' will report 'No hub peer configured'.");
|
|
6537
6606
|
process.exit(1);
|
|
6538
6607
|
}
|
|
6539
|
-
const auth = `Basic ${Buffer.from(`${
|
|
6608
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
6540
6609
|
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
6541
6610
|
const peerRes = await fetch(`${opsEndpoint}/`, {
|
|
6542
6611
|
method: "POST",
|
|
@@ -6573,6 +6642,7 @@ federation
|
|
|
6573
6642
|
.description("Generate a one-time pairing token (run on the hub)")
|
|
6574
6643
|
.option("--port <port>", "Harper HTTP port")
|
|
6575
6644
|
.option("--admin-pass <pass>", "Admin password")
|
|
6645
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
6576
6646
|
.option("--ops-port <port>", "Harper operations API port")
|
|
6577
6647
|
.option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
|
|
6578
6648
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
@@ -6587,7 +6657,7 @@ federation
|
|
|
6587
6657
|
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
6588
6658
|
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
6589
6659
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
6590
|
-
const auth = `Basic ${Buffer.from(`${
|
|
6660
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
6591
6661
|
// 1. Persist the PairingToken record
|
|
6592
6662
|
const opsRes = await fetch(`${opsEndpoint}/`, {
|
|
6593
6663
|
method: "POST",
|
|
@@ -6692,7 +6762,7 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6692
6762
|
const syncStartedAt = new Date().toISOString();
|
|
6693
6763
|
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
6694
6764
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
6695
|
-
const auth = `Basic ${Buffer.from(`${
|
|
6765
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
6696
6766
|
const tables = ["Memory", "Soul", "Agent", "Relationship"];
|
|
6697
6767
|
const instance = await api("GET", "/FederationInstance", undefined, apiOpts);
|
|
6698
6768
|
const hubUrl = hub.endpoint ?? hub.id;
|
|
@@ -6940,6 +7010,7 @@ const federationSync = federation
|
|
|
6940
7010
|
.option("--port <port>", "Harper HTTP port")
|
|
6941
7011
|
.option("--admin-pass <pass>", "Admin password")
|
|
6942
7012
|
.option("--admin-pass-file <path>", "Read the admin password from a file (e.g. ~/.flair/admin-pass). Preferred for launchd/cron — keeps the secret out of ps and shell history.")
|
|
7013
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
6943
7014
|
.option("--ops-port <port>", "Harper operations API port")
|
|
6944
7015
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
6945
7016
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
@@ -7140,6 +7211,7 @@ federation
|
|
|
7140
7211
|
.option("--interval <seconds>", "Seconds between syncs", "30")
|
|
7141
7212
|
.option("--port <port>", "Harper HTTP port")
|
|
7142
7213
|
.option("--admin-pass <pass>", "Admin password")
|
|
7214
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
7143
7215
|
.option("--ops-port <port>", "Harper operations API port")
|
|
7144
7216
|
.option("--target <url>", "Remote Flair URL")
|
|
7145
7217
|
.option("--ops-target <url>", "Explicit ops API URL")
|
|
@@ -8666,7 +8738,7 @@ async function fetchHealthDetail(opts, signingAgentIdOverride) {
|
|
|
8666
8738
|
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
8667
8739
|
if (adminPass) {
|
|
8668
8740
|
res = await fetch(`${baseUrl}/Health`, {
|
|
8669
|
-
headers: { Authorization: `Basic ${Buffer.from(
|
|
8741
|
+
headers: { Authorization: `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}` },
|
|
8670
8742
|
signal: AbortSignal.timeout(5000),
|
|
8671
8743
|
});
|
|
8672
8744
|
}
|
|
@@ -8718,8 +8790,10 @@ const statusCmd = program
|
|
|
8718
8790
|
discoveredPort = await discoverLocalFlairPort(baseUrl);
|
|
8719
8791
|
}
|
|
8720
8792
|
// Version-behind check (flair#587) — offline-tolerant + cached, so this
|
|
8721
|
-
// never
|
|
8722
|
-
//
|
|
8793
|
+
// never fails `status` when the registry is unreachable, and costs no
|
|
8794
|
+
// network round trip on the common up-to-date path. When a cached answer
|
|
8795
|
+
// would print a nudge it spends one short-timeout refetch so the printed
|
|
8796
|
+
// fact is current (flair#1341). Independent of Harper health; runs either way.
|
|
8723
8797
|
const versionCheckResult = await checkVersion(__pkgVersion);
|
|
8724
8798
|
const versionNudge = formatVersionNudge(versionCheckResult);
|
|
8725
8799
|
if (opts.json) {
|
|
@@ -9211,7 +9285,7 @@ statusCmd
|
|
|
9211
9285
|
}
|
|
9212
9286
|
}
|
|
9213
9287
|
else {
|
|
9214
|
-
const auth = `Basic ${Buffer.from(`${
|
|
9288
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
|
|
9215
9289
|
const maxTokens = Number.parseInt(String(opts.maxTokens ?? "4000"), 10);
|
|
9216
9290
|
for (const agentId of agentList) {
|
|
9217
9291
|
try {
|
|
@@ -11754,7 +11828,7 @@ program
|
|
|
11754
11828
|
// embedding with a freshly-computed one. Without this path, `flair
|
|
11755
11829
|
// reembed` could not recover from the very condition it exists to fix.
|
|
11756
11830
|
const opsPort = resolveOpsPort(opts);
|
|
11757
|
-
const opsAuth = `Basic ${Buffer.from(
|
|
11831
|
+
const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
|
|
11758
11832
|
// Harper rejects empty-value conditions ("not indexed for nulls"). Use
|
|
11759
11833
|
// `createdAt > 1970-01-01` as the "select all" pattern: every Memory row
|
|
11760
11834
|
// has a createdAt, the index is built, and the comparison is total.
|
|
@@ -11852,7 +11926,7 @@ program
|
|
|
11852
11926
|
let allMemories = [];
|
|
11853
11927
|
if (adminPassSingle) {
|
|
11854
11928
|
const opsPort = resolveOpsPort(opts);
|
|
11855
|
-
const opsAuth = `Basic ${Buffer.from(
|
|
11929
|
+
const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPassSingle}`).toString("base64")}`;
|
|
11856
11930
|
const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
11857
11931
|
method: "POST",
|
|
11858
11932
|
headers: { "Content-Type": "application/json", Authorization: opsAuth },
|
|
@@ -11940,7 +12014,10 @@ program
|
|
|
11940
12014
|
console.error(`${render.icons.error} ${render.wrap(render.c.red, "set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS")}`);
|
|
11941
12015
|
process.exit(1);
|
|
11942
12016
|
}
|
|
11943
|
-
|
|
12017
|
+
// Single source of truth (flair#1351): banner prints the URL the test's
|
|
12018
|
+
// own client uses. resolveBaseUrl is the existing CLI resolver; pass the
|
|
12019
|
+
// same value through to api() so the two cannot diverge.
|
|
12020
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
11944
12021
|
console.log(`\n${render.wrap(render.c.bold, "Flair test")} ${render.wrap(render.c.dim, `(url: ${baseUrl})`)}\n`);
|
|
11945
12022
|
let passed = 0;
|
|
11946
12023
|
let failed = 0;
|
|
@@ -11975,7 +12052,7 @@ program
|
|
|
11975
12052
|
};
|
|
11976
12053
|
if (agentId)
|
|
11977
12054
|
body.agentId = agentId;
|
|
11978
|
-
await api("PUT", `/Memory/${id}`, body);
|
|
12055
|
+
await api("PUT", `/Memory/${id}`, body, { baseUrl });
|
|
11979
12056
|
memoryId = id;
|
|
11980
12057
|
return true;
|
|
11981
12058
|
});
|
|
@@ -11985,7 +12062,7 @@ program
|
|
|
11985
12062
|
const body = { q: "flair test", limit: 5 };
|
|
11986
12063
|
if (agentId)
|
|
11987
12064
|
body.agentId = agentId;
|
|
11988
|
-
const result = await api("POST", "/SemanticSearch", body);
|
|
12065
|
+
const result = await api("POST", "/SemanticSearch", body, { baseUrl });
|
|
11989
12066
|
return (result?.results?.length ?? 0) > 0;
|
|
11990
12067
|
});
|
|
11991
12068
|
// 3. Delete the test memory via DELETE /Memory/<id>
|
|
@@ -11995,7 +12072,7 @@ program
|
|
|
11995
12072
|
console.log(` (skipped — no id returned from write step)`);
|
|
11996
12073
|
return true;
|
|
11997
12074
|
}
|
|
11998
|
-
await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
|
|
12075
|
+
await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined, { baseUrl });
|
|
11999
12076
|
return true;
|
|
12000
12077
|
});
|
|
12001
12078
|
const passColor = passed > 0 ? render.c.green : render.c.dim;
|
|
@@ -12666,6 +12743,9 @@ program
|
|
|
12666
12743
|
catch { /* unreachable/unparseable → null → the finding is skipped, not passed */ }
|
|
12667
12744
|
// The component directory for a local install is the flair package itself:
|
|
12668
12745
|
// `flair start` spawns `harper run .` with cwd = flairPackageDir().
|
|
12746
|
+
// That path is often inside node_modules on an npm install-g; doctor still
|
|
12747
|
+
// READs it for drift detection, but describePublicUrlFinding never names
|
|
12748
|
+
// it as the fix (flair#1313 — wiped on every upgrade).
|
|
12669
12749
|
const componentEnvPath = join(flairPackageDir(), COMPONENT_ENV_FILENAME);
|
|
12670
12750
|
let componentEnvValue = null;
|
|
12671
12751
|
try {
|
|
@@ -12755,7 +12835,7 @@ program
|
|
|
12755
12835
|
}
|
|
12756
12836
|
const auditStatus = auditCredIssue
|
|
12757
12837
|
? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
|
|
12758
|
-
: await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`,
|
|
12838
|
+
: await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, resolveAdminUser(undefined), auditAdminPass);
|
|
12759
12839
|
switch (auditStatus.state) {
|
|
12760
12840
|
case "ok":
|
|
12761
12841
|
// Present-tense claim ONLY (see AuditVerifyResult): the probe
|
|
@@ -12834,11 +12914,13 @@ program
|
|
|
12834
12914
|
}
|
|
12835
12915
|
// 7. Client integration (flair#588) — the first 6 checks diagnose the
|
|
12836
12916
|
// SERVER side. This diagnoses whether Flair is actually wired to a real
|
|
12837
|
-
// MCP
|
|
12838
|
-
// + reachable + the configured agent
|
|
12839
|
-
//
|
|
12840
|
-
//
|
|
12841
|
-
//
|
|
12917
|
+
// client: for MCP clients (Claude Code, Codex, Gemini, Cursor,
|
|
12918
|
+
// Antigravity) the MCP block present + reachable + the configured agent
|
|
12919
|
+
// genuinely registered; for pi (a NATIVE EXTENSION host — flair#1342) the
|
|
12920
|
+
// pi-flair reference in pi's own settings, including the flair#1346
|
|
12921
|
+
// npm:-under-"extensions" trap; plus CLAUDE.md + the SessionStart hook
|
|
12922
|
+
// (Claude Code only, since only Claude Code has those mechanisms). Reuses
|
|
12923
|
+
// detectClients() rather than reimplementing client detection.
|
|
12842
12924
|
console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
|
|
12843
12925
|
// Prompt y/N before a content-editing fix, but only when interactive —
|
|
12844
12926
|
// in a non-TTY context (CI, scripts) --fix itself is the consent signal,
|
|
@@ -12870,6 +12952,170 @@ program
|
|
|
12870
12952
|
}
|
|
12871
12953
|
}
|
|
12872
12954
|
for (const client of detectedClients) {
|
|
12955
|
+
// ── pi (flair#1342): NATIVE EXTENSION, not an MCP client ───────────
|
|
12956
|
+
// There is no mcpServers block to read — pi loads @tpsdev-ai/pi-flair
|
|
12957
|
+
// through its own settings.json (`packages`). Every check below is a
|
|
12958
|
+
// filesystem fact except agent registration, which is only checkable
|
|
12959
|
+
// when this shell exposes the env pi would launch with — and the
|
|
12960
|
+
// output says which of the two it verified.
|
|
12961
|
+
if (client.kind === "native-extension") {
|
|
12962
|
+
let pi = checkPiFlairWiring(homedir(), process.cwd());
|
|
12963
|
+
// --fix for pi needs no agent id (pi settings carry no env block);
|
|
12964
|
+
// a resolvable id only improves the export hint in the message.
|
|
12965
|
+
const wirePiFix = async (prompt) => {
|
|
12966
|
+
if (dryRun) {
|
|
12967
|
+
console.log(` ${render.wrap(render.c.dim, "Would update")} ${pi.settingsPath}`);
|
|
12968
|
+
return;
|
|
12969
|
+
}
|
|
12970
|
+
const proceed = await confirmFix(prompt);
|
|
12971
|
+
if (!proceed) {
|
|
12972
|
+
console.log(` Skipped.`);
|
|
12973
|
+
return;
|
|
12974
|
+
}
|
|
12975
|
+
const hintAgentId = resolveFixAgentId({
|
|
12976
|
+
optsAgent: opts.agent,
|
|
12977
|
+
envAgentId: process.env.FLAIR_AGENT_ID,
|
|
12978
|
+
anyKnownAgentId,
|
|
12979
|
+
keyAgentIds,
|
|
12980
|
+
keysDir: defaultKeysDir(),
|
|
12981
|
+
}) ?? "<your-agent-id>";
|
|
12982
|
+
const wireResult = wirePi({ FLAIR_AGENT_ID: hintAgentId, FLAIR_URL: baseUrl });
|
|
12983
|
+
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
12984
|
+
if (wireResult.ok)
|
|
12985
|
+
fixed++;
|
|
12986
|
+
};
|
|
12987
|
+
// (a) The flair#1346 trap FIRST, and by NAME: an npm: spec under
|
|
12988
|
+
// "extensions" is silently ignored by pi — the user believes they
|
|
12989
|
+
// are wired while pi registers zero tools. This is the documented
|
|
12990
|
+
// field failure mode and must never fold into a generic "not
|
|
12991
|
+
// wired": the fix is a MOVE to "packages", not an add.
|
|
12992
|
+
const userTraps = pi.misconfigured.filter((m) => m.path === pi.settingsPath);
|
|
12993
|
+
const projectTraps = pi.misconfigured.filter((m) => m.path !== pi.settingsPath);
|
|
12994
|
+
for (const bad of pi.misconfigured) {
|
|
12995
|
+
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)}`);
|
|
12996
|
+
console.log(` pi silently ignores npm: specs under "extensions", so the Flair tools never register (flair#1346). Package sources belong under "packages".`);
|
|
12997
|
+
issues++;
|
|
12998
|
+
}
|
|
12999
|
+
if (userTraps.length > 0) {
|
|
13000
|
+
if (autoFix) {
|
|
13001
|
+
await wirePiFix(` Move the npm: spec to "packages" in ${pi.settingsPath} now? [y/N] `);
|
|
13002
|
+
// Re-derive the wiring from disk so the sections below reason
|
|
13003
|
+
// about the POST-fix state — otherwise a move that just
|
|
13004
|
+
// succeeded would still read as "not wired" and prompt again.
|
|
13005
|
+
pi = checkPiFlairWiring(homedir(), process.cwd());
|
|
13006
|
+
}
|
|
13007
|
+
else {
|
|
13008
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(moves it to "packages")`)}`);
|
|
13009
|
+
}
|
|
13010
|
+
}
|
|
13011
|
+
if (projectTraps.length > 0) {
|
|
13012
|
+
// wirePi edits the USER-scope settings only — a project-scope
|
|
13013
|
+
// trap gets the exact manual fix, never a --fix that claims a
|
|
13014
|
+
// file it does not touch.
|
|
13015
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} move the entry from "extensions" to "packages" in ${projectTraps[0].path}`);
|
|
13016
|
+
}
|
|
13017
|
+
if (!pi.wired) {
|
|
13018
|
+
console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} not wired in ${render.wrap(render.c.dim, pi.settingsPath)}`);
|
|
13019
|
+
if (autoFix) {
|
|
13020
|
+
await wirePiFix(` Wire pi now (adds ${piFlairSpec()} to "packages" in ${pi.settingsPath})? [y/N] `);
|
|
13021
|
+
}
|
|
13022
|
+
else {
|
|
13023
|
+
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}`);
|
|
13024
|
+
}
|
|
13025
|
+
issues++;
|
|
13026
|
+
continue;
|
|
13027
|
+
}
|
|
13028
|
+
if (pi.wiredVia === "packages") {
|
|
13029
|
+
console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via "packages" (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
|
|
13030
|
+
if (!pi.pinnedVersion) {
|
|
13031
|
+
console.log(` ${render.icons.info} unpinned — pi re-resolves latest on (re)install; pin with ${piFlairSpec()}`);
|
|
13032
|
+
}
|
|
13033
|
+
}
|
|
13034
|
+
else {
|
|
13035
|
+
// extension-path: the documented pre-0.49 workaround (a local
|
|
13036
|
+
// path to the installed dist/index.js). Works, but the canonical
|
|
13037
|
+
// form is a "packages" entry — and a DANGLING path is a broken
|
|
13038
|
+
// wiring pi skips silently, so check the one thing checkable.
|
|
13039
|
+
if (pi.extensionPathExists) {
|
|
13040
|
+
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)}`);
|
|
13041
|
+
console.log(` ${render.wrap(render.c.dim, `pre-0.49 workaround — the canonical form is a "packages" entry: ${piFlairSpec()}`)}`);
|
|
13042
|
+
}
|
|
13043
|
+
else {
|
|
13044
|
+
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`);
|
|
13045
|
+
if (autoFix) {
|
|
13046
|
+
await wirePiFix(` Wire pi via "packages" instead (adds ${piFlairSpec()})? [y/N] `);
|
|
13047
|
+
}
|
|
13048
|
+
else {
|
|
13049
|
+
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)`)}`);
|
|
13050
|
+
}
|
|
13051
|
+
issues++;
|
|
13052
|
+
continue;
|
|
13053
|
+
}
|
|
13054
|
+
}
|
|
13055
|
+
// Env sanity (flair#1342 scope 3). pi settings carry no env block:
|
|
13056
|
+
// pi-flair reads FLAIR_* from the environment of whatever shell/IDE
|
|
13057
|
+
// launches pi. Doctor can only see ITS OWN environment — these
|
|
13058
|
+
// lines verify this shell, and say so, rather than pretending to
|
|
13059
|
+
// verify every pi launch. None of them counts as an issue: a clean
|
|
13060
|
+
// pi launched elsewhere can be fine while this shell is bare, and
|
|
13061
|
+
// vice versa.
|
|
13062
|
+
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):")}`);
|
|
13063
|
+
const piEnvAgent = process.env.FLAIR_AGENT_ID;
|
|
13064
|
+
const piEnvUrl = process.env.FLAIR_URL;
|
|
13065
|
+
const piEnvKey = process.env.FLAIR_KEY_PATH;
|
|
13066
|
+
if (piEnvAgent) {
|
|
13067
|
+
console.log(` ${render.icons.ok} FLAIR_AGENT_ID set ('${piEnvAgent}')`);
|
|
13068
|
+
}
|
|
13069
|
+
else {
|
|
13070
|
+
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`);
|
|
13071
|
+
}
|
|
13072
|
+
if (piEnvUrl) {
|
|
13073
|
+
console.log(` ${render.icons.ok} FLAIR_URL set (${piEnvUrl})`);
|
|
13074
|
+
}
|
|
13075
|
+
else {
|
|
13076
|
+
console.log(` ${render.icons.info} FLAIR_URL not set — pi-flair defaults to ${render.wrap(render.c.dim, PI_FLAIR_DEFAULT_URL)}`);
|
|
13077
|
+
}
|
|
13078
|
+
if (piEnvKey) {
|
|
13079
|
+
if (existsSync(piEnvKey)) {
|
|
13080
|
+
console.log(` ${render.icons.ok} FLAIR_KEY_PATH set (${piEnvKey})`);
|
|
13081
|
+
}
|
|
13082
|
+
else {
|
|
13083
|
+
console.log(` ${render.icons.warn} FLAIR_KEY_PATH points at a missing file (${piEnvKey})`);
|
|
13084
|
+
}
|
|
13085
|
+
}
|
|
13086
|
+
else {
|
|
13087
|
+
console.log(` ${render.icons.info} FLAIR_KEY_PATH not set — auto-resolved from ~/.flair/keys`);
|
|
13088
|
+
}
|
|
13089
|
+
// Agent registration — checkable only when this shell exposes an
|
|
13090
|
+
// agent id at all; otherwise say what was NOT verified instead of
|
|
13091
|
+
// skipping silently.
|
|
13092
|
+
if (piEnvAgent) {
|
|
13093
|
+
const piUrl = piEnvUrl || PI_FLAIR_DEFAULT_URL;
|
|
13094
|
+
const piReachable = await probeFlairReachable(piUrl);
|
|
13095
|
+
if (!piReachable) {
|
|
13096
|
+
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, piUrl)} not reachable — cannot verify agent registration`);
|
|
13097
|
+
}
|
|
13098
|
+
else {
|
|
13099
|
+
const piReg = await checkAgentRegistered(piUrl, piEnvAgent, defaultKeysDir());
|
|
13100
|
+
if (piReg.state === "registered") {
|
|
13101
|
+
console.log(` ${render.icons.ok} agent '${piEnvAgent}' registered`);
|
|
13102
|
+
}
|
|
13103
|
+
else if (piReg.state === "not-registered") {
|
|
13104
|
+
console.log(` ${render.icons.error} agent '${piEnvAgent}' is NOT registered on this Flair instance`);
|
|
13105
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${piEnvAgent}`);
|
|
13106
|
+
issues++;
|
|
13107
|
+
}
|
|
13108
|
+
else {
|
|
13109
|
+
const piFinding = describeAgentGateFinding(piEnvAgent, piReg.state, piReg.detail, { instanceReachable: piReachable });
|
|
13110
|
+
console.log(` ${render.icons.warn} ${piFinding?.message ?? `could not verify agent registration (${piReg.detail})`}`);
|
|
13111
|
+
}
|
|
13112
|
+
}
|
|
13113
|
+
}
|
|
13114
|
+
else {
|
|
13115
|
+
console.log(` ${render.wrap(render.c.dim, "agent registration not verified — no FLAIR_AGENT_ID visible to doctor")}`);
|
|
13116
|
+
}
|
|
13117
|
+
continue;
|
|
13118
|
+
}
|
|
12873
13119
|
const block = readClientMcpBlock(client.id, homedir());
|
|
12874
13120
|
if (client.id === "claude-code" && block.agentId)
|
|
12875
13121
|
claudeCodeAgentId = block.agentId;
|
|
@@ -13573,23 +13819,42 @@ program
|
|
|
13573
13819
|
// own id appears in its search's top-k; MRR = mean reciprocal rank (0 if
|
|
13574
13820
|
// not found within k).
|
|
13575
13821
|
//
|
|
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
|
-
//
|
|
13822
|
+
// Framing — this is a REPORT-ONLY HEALTH SPOT-CHECK, not a benchmark, not a
|
|
13823
|
+
// trust judgment, and (since flair#967) not an alerting signal either.
|
|
13824
|
+
// Querying by a cue derived FROM the target memory is easier than a real
|
|
13825
|
+
// user query, so a high score means "recall is functioning", not "recall is
|
|
13826
|
+
// optimal". NOTE (#1216): this cue-from-the-memory design is self-polluting
|
|
13827
|
+
// as a recall-QUALITY metric — relevance is query/corpus overlap by
|
|
13828
|
+
// construction, so near-duplicate density reads as a recall collapse
|
|
13829
|
+
// (flair#967 / #857 / #996). It is deliberately NOT the recall-quality
|
|
13830
|
+
// number; that authority is the deterministic, fixed-label, CI-gated eval at
|
|
13831
|
+
// test/bench/recall-eval, wired as a gate in
|
|
13832
|
+
// test/integration-heavy/recall-eval-gate.test.ts.
|
|
13833
|
+
//
|
|
13834
|
+
// flair#967 — WHY THIS METRIC NO LONGER EMITS AN EVENT. Measured on rockit
|
|
13835
|
+
// production over 32 nightly runs: population σ = 0.291, mean absolute
|
|
13836
|
+
// run-to-run delta = 0.223, against a QUALITY_EVENT_RECALL_DROP_THRESHOLD of
|
|
13837
|
+
// 0.2. The alarm sat at 0.69σ — BELOW the metric's own noise floor, so the
|
|
13838
|
+
// median night-to-night wobble already exceeded the delta that declared a
|
|
13839
|
+
// regression. Replaying diffQualitySnapshots over the stored snapshot series
|
|
13840
|
+
// predicts the sweep's 6 findings-mails in 34 runs exactly, 6 for 6, and all
|
|
13841
|
+
// six were oscillation: lifetime precision 0. So the emission is gone. The
|
|
13842
|
+
// score is still computed, still printed, still snapshotted (history and the
|
|
13843
|
+
// cratering signal are both preserved) — it just no longer has the authority
|
|
13844
|
+
// to page anyone, because it never once earned it. That authority stays with
|
|
13845
|
+
// the deterministic CI gate above, which is fixed-label, hermetic and
|
|
13846
|
+
// actually detects ranking regressions. Re-arming this probe is a data
|
|
13847
|
+
// question, not a taste question: it needs a measured precision on the FIXED
|
|
13848
|
+
// cue derivation first, and a threshold DERIVED from that run-to-run variance
|
|
13849
|
+
// (≥2σ on the sample design), not another literal.
|
|
13850
|
+
//
|
|
13851
|
+
// Requires an actual agent identity to query AS (semantic search is
|
|
13852
|
+
// agent-scoped) — no identity, fewer than the sample-size memories to sample,
|
|
13853
|
+
// an UNHEALTHY sample (planRecallSpotCheck below: duplicate or empty cues, so
|
|
13854
|
+
// the window cannot be scored fairly) or a search error all degrade to `null`
|
|
13855
|
+
// + a `gaps` entry, same graceful-degradation contract as every metric here —
|
|
13856
|
+
// NEVER a false 0.0 masquerading as a real (broken) score, and never a number
|
|
13857
|
+
// quietly computed over a window that could not produce one.
|
|
13593
13858
|
/** First-pass default, same "documented heuristic, not derived from data we
|
|
13594
13859
|
* don't have" spirit as health.ts's own 10%-hash-fallback threshold below.
|
|
13595
13860
|
* Tunable later if a real fleet shows this is too loud/quiet. */
|
|
@@ -13604,17 +13869,61 @@ export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
|
|
|
13604
13869
|
* "first-pass default, tunable later" spirit as the thresholds above. */
|
|
13605
13870
|
export const QUALITY_RECALL_SAMPLE_SIZE = 10;
|
|
13606
13871
|
export const QUALITY_RECALL_K = 5;
|
|
13872
|
+
/** Leading-word cap on the content-derived cue. 25, matching the arm of the
|
|
13873
|
+
* flair#967 A/B that was actually measured (same 10 memories, same instance,
|
|
13874
|
+
* same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
|
|
13875
|
+
* content cue → 1.00 / 0.78). Still a PARTIAL cue by construction — capped,
|
|
13876
|
+
* never the whole memory for anything longer than the cap. */
|
|
13877
|
+
const RECALL_CUE_CONTENT_WORD_LIMIT = 25;
|
|
13878
|
+
/**
|
|
13879
|
+
* Is `subject` DISCRIMINATIVE enough to be handed to semantic search as a
|
|
13880
|
+
* query in its own right? (flair#967.)
|
|
13881
|
+
*
|
|
13882
|
+
* The old bar was `length >= 3`, which is a check on whether the subject
|
|
13883
|
+
* EXISTS, not on whether it is a query. Measured consequence: slug-shaped
|
|
13884
|
+
* subjects — `pr-1359`, `kern-2026-08-23`, the spot-check's own
|
|
13885
|
+
* `quality-snapshot/127.0.0.1:9926` — carry almost no semantic signal, so
|
|
13886
|
+
* searching one is a query for nothing in particular (searching `pr-1359` on
|
|
13887
|
+
* rockit production returned, as top-1, a review note about PR #1275 from five
|
|
13888
|
+
* days earlier). Worse, every memory sharing such a subject issues the
|
|
13889
|
+
* IDENTICAL query and gets the IDENTICAL result list, so siblings must
|
|
13890
|
+
* mutually displace each other and all but one are scored as misses no matter
|
|
13891
|
+
* how healthy retrieval is.
|
|
13892
|
+
*
|
|
13893
|
+
* The rule, stated plainly — a subject is used as the cue only when it is:
|
|
13894
|
+
* 1. at least 3 characters (the original bar, kept), AND
|
|
13895
|
+
* 2. NOT opaque-identifier-shaped: an unspaced token carrying a digit or an
|
|
13896
|
+
* identifier separator (`/ : _ . # @ \`) is a slug, not a phrase.
|
|
13897
|
+
* Whitespace is the primary discriminator — `Harper 5.2 upgrade` is
|
|
13898
|
+
* prose and stays a cue; `kern-2026-08-23` is not. A bare hyphen does
|
|
13899
|
+
* NOT make a slug, so ordinary compounds (`two-gate`) survive, AND
|
|
13900
|
+
* 3. carrying at least one alphabetic run of 3+ characters — a subject with
|
|
13901
|
+
* no word in it (`---`, `42`) is not a query either.
|
|
13902
|
+
*
|
|
13903
|
+
* Fails CLOSED: anything that isn't clearly a phrase falls back to content,
|
|
13904
|
+
* which the A/B measured as the strictly better cue. Pure — no I/O.
|
|
13905
|
+
*/
|
|
13906
|
+
export function isDiscriminativeSubject(subject) {
|
|
13907
|
+
const s = (subject ?? "").trim();
|
|
13908
|
+
if (s.length < 3)
|
|
13909
|
+
return false;
|
|
13910
|
+
if (!/\s/.test(s) && /[0-9/:_.#@\\]/.test(s))
|
|
13911
|
+
return false;
|
|
13912
|
+
if (!/[A-Za-z]{3}/.test(s))
|
|
13913
|
+
return false;
|
|
13914
|
+
return true;
|
|
13915
|
+
}
|
|
13607
13916
|
/**
|
|
13608
13917
|
* Derive a PARTIAL search cue from a memory — used by the recall spot-check
|
|
13609
13918
|
* (Slice 1d) to query for a memory without handing back its full content.
|
|
13610
|
-
* Prefers `subject` when
|
|
13611
|
-
*
|
|
13612
|
-
*
|
|
13919
|
+
* Prefers `subject` ONLY when it is discriminative (isDiscriminativeSubject
|
|
13920
|
+
* above — flair#967); otherwise falls back to the first sentence of
|
|
13921
|
+
* `content`, capped to the leading ~25 words so the cue stays a genuine
|
|
13613
13922
|
* partial cue rather than the whole memory. Pure — no I/O.
|
|
13614
13923
|
*/
|
|
13615
13924
|
export function deriveRecallCue(memory) {
|
|
13616
13925
|
const subject = (memory.subject ?? "").trim();
|
|
13617
|
-
if (subject
|
|
13926
|
+
if (isDiscriminativeSubject(subject))
|
|
13618
13927
|
return subject;
|
|
13619
13928
|
const content = (memory.content ?? "").trim();
|
|
13620
13929
|
if (!content)
|
|
@@ -13622,7 +13931,7 @@ export function deriveRecallCue(memory) {
|
|
|
13622
13931
|
const sentenceMatch = content.match(/^[^.!?\n]+[.!?]?/);
|
|
13623
13932
|
const firstSentence = (sentenceMatch ? sentenceMatch[0] : content).trim();
|
|
13624
13933
|
const words = firstSentence.split(/\s+/).filter(Boolean);
|
|
13625
|
-
const cueWordLimit =
|
|
13934
|
+
const cueWordLimit = RECALL_CUE_CONTENT_WORD_LIMIT;
|
|
13626
13935
|
return words.length <= cueWordLimit ? firstSentence : words.slice(0, cueWordLimit).join(" ");
|
|
13627
13936
|
}
|
|
13628
13937
|
/**
|
|
@@ -13659,6 +13968,71 @@ export function computeRecallSpotCheck(sampledIds, perQueryResultIds, k) {
|
|
|
13659
13968
|
k,
|
|
13660
13969
|
};
|
|
13661
13970
|
}
|
|
13971
|
+
/** Rows the spot-check writes itself, and therefore must never grade itself
|
|
13972
|
+
* on — see RecallSpotCheckPlan['excludedSnapshotRows']. */
|
|
13973
|
+
function isQualitySnapshotRow(m) {
|
|
13974
|
+
return m?.type === "quality-snapshot" || (m?.subject ?? "").startsWith("quality-snapshot/");
|
|
13975
|
+
}
|
|
13976
|
+
/**
|
|
13977
|
+
* Pure planner for the recall spot-check: raw memory rows → the window to
|
|
13978
|
+
* query (id + cue) plus that window's health. Extracted from
|
|
13979
|
+
* fetchRecallSpotCheckData so the sampling, cue-derivation and
|
|
13980
|
+
* fail-closed health rules are testable without any I/O (flair#967).
|
|
13981
|
+
*
|
|
13982
|
+
* Order of operations, and why:
|
|
13983
|
+
* 1. drop the tool's own quality-snapshot rows (never grade your own
|
|
13984
|
+
* bookkeeping);
|
|
13985
|
+
* 2. take the `sampleSize` most-recently-written remaining rows (unchanged —
|
|
13986
|
+
* recency is still the sampling frame; see the issue's direction 3 for the
|
|
13987
|
+
* stratified-sampling follow-up this deliberately does NOT take on);
|
|
13988
|
+
* 3. derive each cue via deriveRecallCue;
|
|
13989
|
+
* 4. judge the window: any duplicate cue, or any empty cue, makes it
|
|
13990
|
+
* UNSCORABLE — reported as unhealthy, never silently scored.
|
|
13991
|
+
*/
|
|
13992
|
+
export function planRecallSpotCheck(memories, opts = {}) {
|
|
13993
|
+
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
13994
|
+
const rows = Array.isArray(memories) ? memories : [];
|
|
13995
|
+
const scorable = rows.filter((m) => !isQualitySnapshotRow(m ?? {}));
|
|
13996
|
+
const excludedSnapshotRows = rows.length - scorable.length;
|
|
13997
|
+
const sorted = scorable.slice().sort((a, b) => {
|
|
13998
|
+
const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
13999
|
+
const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
14000
|
+
return tb - ta;
|
|
14001
|
+
});
|
|
14002
|
+
const sampled = sorted.slice(0, sampleSize).map((m) => ({ id: String(m?.id), cue: deriveRecallCue(m ?? {}) }));
|
|
14003
|
+
const counts = new Map();
|
|
14004
|
+
let emptyCueCount = 0;
|
|
14005
|
+
for (const s of sampled) {
|
|
14006
|
+
if (!s.cue) {
|
|
14007
|
+
emptyCueCount += 1;
|
|
14008
|
+
continue;
|
|
14009
|
+
}
|
|
14010
|
+
counts.set(s.cue, (counts.get(s.cue) ?? 0) + 1);
|
|
14011
|
+
}
|
|
14012
|
+
const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
|
|
14013
|
+
if (duplicateCues.length === 0 && emptyCueCount === 0) {
|
|
14014
|
+
return { sampled, health: { healthy: true }, excludedSnapshotRows };
|
|
14015
|
+
}
|
|
14016
|
+
const parts = [];
|
|
14017
|
+
if (duplicateCues.length > 0) {
|
|
14018
|
+
const shown = duplicateCues.slice(0, 3).map((c) => `"${c.length > 60 ? `${c.slice(0, 57)}...` : c}"`).join(", ");
|
|
14019
|
+
const dupMemberCount = duplicateCues.reduce((n, c) => n + (counts.get(c) ?? 0), 0);
|
|
14020
|
+
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`);
|
|
14021
|
+
}
|
|
14022
|
+
if (emptyCueCount > 0) {
|
|
14023
|
+
parts.push(`${emptyCueCount} of the ${sampled.length} sampled memories have no derivable cue (no subject and no content)`);
|
|
14024
|
+
}
|
|
14025
|
+
return {
|
|
14026
|
+
sampled,
|
|
14027
|
+
health: {
|
|
14028
|
+
healthy: false,
|
|
14029
|
+
reason: `sample unhealthy — ${parts.join("; ")}. No score recorded for this run (flair#967: fail closed rather than publish an unscorable number).`,
|
|
14030
|
+
duplicateCues,
|
|
14031
|
+
emptyCueCount,
|
|
14032
|
+
},
|
|
14033
|
+
excludedSnapshotRows,
|
|
14034
|
+
};
|
|
14035
|
+
}
|
|
13662
14036
|
/**
|
|
13663
14037
|
* Pure computation: /HealthDetail response (+ reachability) → quality report.
|
|
13664
14038
|
* Never throws — every missing data source degrades to a null section + a
|
|
@@ -13878,26 +14252,29 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
13878
14252
|
catch (err) {
|
|
13879
14253
|
return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
|
|
13880
14254
|
}
|
|
13881
|
-
|
|
14255
|
+
// Deterministic sample + cue derivation + fail-closed health judgment, all
|
|
14256
|
+
// pure (planRecallSpotCheck above). Snapshot rows are excluded there, so the
|
|
14257
|
+
// "enough memories" check has to run on the PLANNED window, not on the raw
|
|
14258
|
+
// row count — an instance whose recent writes are mostly the sweep's own
|
|
14259
|
+
// bookkeeping should skip with a reason, not score a short window.
|
|
14260
|
+
const plan = planRecallSpotCheck(all, { sampleSize });
|
|
14261
|
+
if (plan.sampled.length < sampleSize) {
|
|
14262
|
+
const excluded = plan.excludedSnapshotRows > 0 ? ` (${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping)` : "";
|
|
13882
14263
|
return {
|
|
13883
14264
|
ok: false,
|
|
13884
14265
|
agentId,
|
|
13885
|
-
skipReason: `agent '${agentId}' has ${
|
|
14266
|
+
skipReason: `agent '${agentId}' has ${plan.sampled.length} scorable memories, fewer than the ${sampleSize} needed to sample${excluded}`,
|
|
13886
14267
|
};
|
|
13887
14268
|
}
|
|
13888
|
-
//
|
|
13889
|
-
|
|
13890
|
-
|
|
13891
|
-
|
|
13892
|
-
|
|
13893
|
-
});
|
|
13894
|
-
const sampled = sorted.slice(0, sampleSize);
|
|
14269
|
+
// flair#967: a window whose cues collide cannot be scored fairly — report
|
|
14270
|
+
// that fact instead of a number, and don't spend the searches either.
|
|
14271
|
+
if (!plan.health.healthy) {
|
|
14272
|
+
return { ok: false, agentId, skipReason: plan.health.reason, sampleHealth: plan.health };
|
|
14273
|
+
}
|
|
13895
14274
|
const sampledIds = [];
|
|
13896
14275
|
const perQueryResultIds = [];
|
|
13897
14276
|
try {
|
|
13898
|
-
for (const
|
|
13899
|
-
const id = String(m.id);
|
|
13900
|
-
const cue = deriveRecallCue(m);
|
|
14277
|
+
for (const { id, cue } of plan.sampled) {
|
|
13901
14278
|
const body = { agentId, q: cue, limit: k };
|
|
13902
14279
|
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
13903
14280
|
const results = Array.isArray(res) ? res : (res?.results ?? []);
|
|
@@ -13908,7 +14285,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
13908
14285
|
catch (err) {
|
|
13909
14286
|
return { ok: false, agentId, skipReason: `recall spot-check search failed: ${err?.message ?? String(err)}` };
|
|
13910
14287
|
}
|
|
13911
|
-
return { ok: true, agentId, sampledIds, perQueryResultIds, k };
|
|
14288
|
+
return { ok: true, agentId, sampledIds, perQueryResultIds, k, sampleHealth: plan.health };
|
|
13912
14289
|
}
|
|
13913
14290
|
// ─── flair quality --emit (Slice 2 of the memory-quality-observability arc:
|
|
13914
14291
|
// quality OrgEvents) ─────────────────────────────────────────────────────────
|
|
@@ -13958,6 +14335,22 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
13958
14335
|
export const QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT = 90;
|
|
13959
14336
|
export const QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT = 5;
|
|
13960
14337
|
export const QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT = 10;
|
|
14338
|
+
/**
|
|
14339
|
+
* RETAINED AT ITS ORIGINAL VALUE AND DELIBERATELY UNWIRED (flair#967).
|
|
14340
|
+
*
|
|
14341
|
+
* Nothing in diffQualitySnapshots reads this any more — the recall spot-check
|
|
14342
|
+
* is report-only and emits no event at any delta (see the Slice 1d framing in
|
|
14343
|
+
* the module doc for the 32-run σ = 0.291 / precision-0 measurement behind
|
|
14344
|
+
* that). The constant stays, unchanged at 0.2, as the standing evidence that
|
|
14345
|
+
* the fix was "remove alerting authority from a metric that never earned it",
|
|
14346
|
+
* NOT "widen the gate until it stops talking" — a silenced check and a
|
|
14347
|
+
* de-authorised one look identical in a changelog and are opposites in
|
|
14348
|
+
* practice, and 0.2 sitting here at 0.69σ is the arithmetic that makes the
|
|
14349
|
+
* difference legible. If this probe is ever re-armed, the replacement
|
|
14350
|
+
* threshold must be DERIVED from the measured run-to-run variance of the
|
|
14351
|
+
* FIXED cue derivation, not typed in — do not just re-reference this literal.
|
|
14352
|
+
* Asserted unchanged by test/unit/quality-recall-spotcheck-967.test.ts.
|
|
14353
|
+
*/
|
|
13961
14354
|
export const QUALITY_EVENT_RECALL_DROP_THRESHOLD = 0.2;
|
|
13962
14355
|
export const QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD = 0.5; // >50%
|
|
13963
14356
|
export const QUALITY_EVENT_DEDUP_GROWTH_ABS_THRESHOLD = 5; // AND by >= 5 clusters
|
|
@@ -14024,29 +14417,30 @@ export function diffQualitySnapshots(current, previous) {
|
|
|
14024
14417
|
});
|
|
14025
14418
|
}
|
|
14026
14419
|
}
|
|
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
|
-
|
|
14420
|
+
// ── recall spot-check: REPORT-ONLY, no branch here on purpose (flair#967) ──
|
|
14421
|
+
//
|
|
14422
|
+
// This metric used to emit two quality.regression events (recall@k and MRR,
|
|
14423
|
+
// both at QUALITY_EVENT_RECALL_DROP_THRESHOLD). It no longer emits anything,
|
|
14424
|
+
// at any delta. Measured, on rockit production:
|
|
14425
|
+
//
|
|
14426
|
+
// 32 nightly runs · population σ 0.291 · mean |run-to-run delta| 0.223
|
|
14427
|
+
// threshold 0.2 → 0.69σ, i.e. BELOW the metric's own noise floor
|
|
14428
|
+
// 6 findings-mails in 34 runs, replay-predicted 6/6 from these branches,
|
|
14429
|
+
// all 6 oscillation → lifetime precision 0
|
|
14430
|
+
//
|
|
14431
|
+
// Removing an emission is not the same move as raising a threshold, and the
|
|
14432
|
+
// distinction is the whole point: raising 0.2 would leave a check that still
|
|
14433
|
+
// claims to detect recall regressions while detecting none, whereas this
|
|
14434
|
+
// hands that job to the instrument that can actually do it — the
|
|
14435
|
+
// deterministic, fixed-label, CI-gated eval in
|
|
14436
|
+
// test/integration-heavy/recall-eval-gate.test.ts (test/bench/recall-eval),
|
|
14437
|
+
// whose floors sit ≥2 whole queries below the measured value against a
|
|
14438
|
+
// 0.000 noise band. QUALITY_EVENT_RECALL_DROP_THRESHOLD is left at 0.2,
|
|
14439
|
+
// unwired, so that stays checkable rather than asserted.
|
|
14440
|
+
//
|
|
14441
|
+
// current.recallSpotCheck / previous.recallSpotCheck are still SNAPSHOTTED
|
|
14442
|
+
// (buildQualitySnapshot above) — the history that made this diagnosis
|
|
14443
|
+
// possible keeps accumulating, and `flair quality` still prints the number.
|
|
14050
14444
|
// ── quiet agents: per-agent, NEWLY quiet only (was false last snapshot,
|
|
14051
14445
|
// true now) — never re-fires for an agent that was already quiet last
|
|
14052
14446
|
// snapshot, and never fires for an agent absent from the previous snapshot
|
|
@@ -14226,6 +14620,12 @@ program
|
|
|
14226
14620
|
const mode = render.resolveOutputMode(opts);
|
|
14227
14621
|
if (mode === "json") {
|
|
14228
14622
|
const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...report };
|
|
14623
|
+
// flair#967: when a window was assembled, say whether it was scorable —
|
|
14624
|
+
// structurally, not only as prose inside a `gaps` reason. An unhealthy
|
|
14625
|
+
// sample is a FACT ABOUT THE RUN that a consumer must be able to read
|
|
14626
|
+
// without string-matching.
|
|
14627
|
+
if (recallSpotCheckData.sampleHealth)
|
|
14628
|
+
out.recallSampleHealth = recallSpotCheckData.sampleHealth;
|
|
14229
14629
|
if (emitResult) {
|
|
14230
14630
|
out.emit = { firstRun: emitResult.firstRun, snapshotId: emitResult.snapshotId, errors: emitResult.errors };
|
|
14231
14631
|
out.emittedEvents = emitResult.emittedEvents.map((e) => ({
|
|
@@ -14339,15 +14739,15 @@ program
|
|
|
14339
14739
|
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
14740
|
console.log(` ${render.wrap(render.c.dim, "an ops signal — near-duplicate memories piling up, not a trust judgment")}`);
|
|
14341
14741
|
}
|
|
14342
|
-
// Recall spot-check (flair-quality Slice 1d) — a
|
|
14343
|
-
// a benchmark
|
|
14344
|
-
//
|
|
14345
|
-
// doc for the full framing.
|
|
14742
|
+
// Recall spot-check (flair-quality Slice 1d) — a REPORT-ONLY health
|
|
14743
|
+
// spot-check: not a benchmark, not a trust judgment, and since flair#967
|
|
14744
|
+
// not an alerting signal either. See QualityReport['recallSpotCheck'] doc
|
|
14745
|
+
// and the Slice 1d module doc for the full framing.
|
|
14346
14746
|
if (report.recallSpotCheck) {
|
|
14347
14747
|
const rc = report.recallSpotCheck;
|
|
14348
|
-
console.log(`\n${render.wrap(render.c.bold, "Recall spot-check")} ${render.wrap(render.c.dim, `(agent ${rc.agentId ?? "—"},
|
|
14748
|
+
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
14749
|
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, "
|
|
14750
|
+
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
14751
|
}
|
|
14352
14752
|
// Gaps
|
|
14353
14753
|
if (report.gaps.length > 0) {
|
|
@@ -14906,7 +15306,7 @@ memory.command("hygiene")
|
|
|
14906
15306
|
const enabled = new Set((opts.pattern ?? "compact-id,test-content,tiny").split(",").map((s) => s.trim()).filter(Boolean));
|
|
14907
15307
|
const tinyThreshold = Math.max(0, Number(opts.tinyThreshold) || 25);
|
|
14908
15308
|
const apply = !!opts.apply;
|
|
14909
|
-
const opsAuth = `Basic ${Buffer.from(
|
|
15309
|
+
const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
|
|
14910
15310
|
async function ops(body) {
|
|
14911
15311
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
14912
15312
|
method: "POST",
|
|
@@ -16090,6 +16490,7 @@ program
|
|
|
16090
16490
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16091
16491
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
|
|
16092
16492
|
.option("--admin-pass-file <path>", "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
|
|
16493
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16093
16494
|
.action(async (opts) => {
|
|
16094
16495
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
16095
16496
|
let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
@@ -16105,7 +16506,7 @@ program
|
|
|
16105
16506
|
process.exit(1);
|
|
16106
16507
|
}
|
|
16107
16508
|
}
|
|
16108
|
-
const adminUser =
|
|
16509
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
16109
16510
|
if (!adminPass) {
|
|
16110
16511
|
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
|
|
16111
16512
|
process.exit(1);
|
|
@@ -16194,11 +16595,12 @@ program
|
|
|
16194
16595
|
.option("--port <port>", "Harper HTTP port")
|
|
16195
16596
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16196
16597
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
16598
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16197
16599
|
.option("--dry-run", "Show what would be imported without making changes")
|
|
16198
16600
|
.action(async (backupPath, opts) => {
|
|
16199
16601
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
16200
16602
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
16201
|
-
const adminUser =
|
|
16603
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
16202
16604
|
const dryRun = Boolean(opts.dryRun);
|
|
16203
16605
|
const mode = opts.replace ? "replace" : "merge";
|
|
16204
16606
|
if (!adminPass) {
|
|
@@ -16311,6 +16713,7 @@ program
|
|
|
16311
16713
|
.option("--port <port>", "Harper HTTP port")
|
|
16312
16714
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16313
16715
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
16716
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16314
16717
|
.option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
|
|
16315
16718
|
.action(async (agentId, opts) => {
|
|
16316
16719
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
@@ -16319,7 +16722,7 @@ program
|
|
|
16319
16722
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
16320
16723
|
process.exit(1);
|
|
16321
16724
|
}
|
|
16322
|
-
const auth = `Basic ${Buffer.from(`${
|
|
16725
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
16323
16726
|
async function adminGet(path) {
|
|
16324
16727
|
const res = await fetch(`${baseUrl}${path}`, { headers: { Authorization: auth }, signal: AbortSignal.timeout(10_000) });
|
|
16325
16728
|
if (!res.ok)
|
|
@@ -16402,6 +16805,7 @@ program
|
|
|
16402
16805
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16403
16806
|
.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
16807
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
16808
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16405
16809
|
.option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
|
|
16406
16810
|
.action(async (importPath, opts) => {
|
|
16407
16811
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
@@ -16462,12 +16866,12 @@ program
|
|
|
16462
16866
|
: nacl.sign.keyPair.fromSeed(new Uint8Array(decodedSeed.subarray(0, 32))).publicKey;
|
|
16463
16867
|
const pubKeyB64url = b64url(pubKey);
|
|
16464
16868
|
// Register agent via ops API (remote when --url/--ops-target points off-box)
|
|
16465
|
-
await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url,
|
|
16869
|
+
await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, resolveAdminUser(opts.adminUser), adminPass);
|
|
16466
16870
|
console.log(typeof seedOpsTarget === "string"
|
|
16467
16871
|
? ` Agent registered (ops: ${seedOpsTarget})`
|
|
16468
16872
|
: ` Agent registered`);
|
|
16469
16873
|
// Restore memories
|
|
16470
|
-
const auth = `Basic ${Buffer.from(`${
|
|
16874
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
16471
16875
|
let memCount = 0;
|
|
16472
16876
|
for (const mem of data.memories ?? []) {
|
|
16473
16877
|
try {
|
|
@@ -16748,7 +17152,7 @@ program
|
|
|
16748
17152
|
createdAt: new Date().toISOString(),
|
|
16749
17153
|
};
|
|
16750
17154
|
const memoryPath = `/Memory/${memoryId}`;
|
|
16751
|
-
const auth = `Basic ${Buffer.from(
|
|
17155
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
|
|
16752
17156
|
const res = await fetch(`${httpUrl}${memoryPath}`, {
|
|
16753
17157
|
method: "PUT",
|
|
16754
17158
|
headers: {
|
|
@@ -17222,7 +17626,7 @@ if (import.meta.main) {
|
|
|
17222
17626
|
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
17223
17627
|
export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
|
|
17224
17628
|
// 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,
|
|
17629
|
+
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
17630
|
// launchd label (flair#693)
|
|
17227
17631
|
LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
|
|
17228
17632
|
// launchd management observation (flair#1022)
|