@tpsdev-ai/flair 0.49.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/dist/bridges/runtime/roundtrip.js +91 -2
- package/dist/build-info.json +3 -3
- package/dist/cli.js +383 -110
- package/dist/deploy.js +20 -3
- package/dist/doctor-client.js +59 -31
- package/dist/federation/scheduler.js +24 -3
- package/dist/hook-install.js +45 -13
- 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/MemoryBootstrap.js +8 -4
- package/dist/resources/health.js +52 -7
- package/dist/resources/mcp-tools.js +1 -0
- package/dist/resources/search-readiness.js +100 -0
- package/dist/resources/semantic-retrieval-core.js +9 -1
- 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/docs/auth.md +5 -0
- package/docs/deepseek-harness.md +1 -1
- package/docs/hosted-on-fabric.md +2 -0
- package/docs/integrations.md +53 -1
- package/docs/mcp-clients.md +67 -15
- package/docs/quickstart-fabric.md +1 -1
- package/docs/troubleshooting.md +25 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -26,7 +26,7 @@ import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion
|
|
|
26
26
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
27
27
|
import { readClientMcpBlock, effectiveFlairUrl, checkPiFlairWiring, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
|
|
28
28
|
import { checkGlobalBinOnPath, cliBootPathWarning, resolveNpmGlobalPrefix, } from "./install/global-bin-path.js";
|
|
29
|
-
import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
29
|
+
import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookSettingsPath, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "./hook-install.js";
|
|
30
30
|
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, DEFAULT_ADMIN_USER, resolveAdminUser, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
31
31
|
import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
|
|
32
32
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
@@ -685,6 +685,48 @@ function resolveSigningIdentityFor(opts, command) {
|
|
|
685
685
|
function resolveSigningAgentId(opts, command) {
|
|
686
686
|
return resolveSigningIdentityFor(opts, command).agentId;
|
|
687
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
|
+
}
|
|
688
730
|
// Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
|
|
689
731
|
//
|
|
690
732
|
// Deliberately NOT routed through Harper's per-instance config the way
|
|
@@ -1429,7 +1471,13 @@ async function api(method, path, body, options) {
|
|
|
1429
1471
|
agentId = decodeURIComponent(match[1]);
|
|
1430
1472
|
}
|
|
1431
1473
|
}
|
|
1432
|
-
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
|
+
});
|
|
1433
1481
|
}
|
|
1434
1482
|
/**
|
|
1435
1483
|
* The authedGet `flair upgrade` verification (flair#635/#741) hands to
|
|
@@ -2614,9 +2662,9 @@ export function upgradeStatusSuffix(name, status) {
|
|
|
2614
2662
|
* is `flair doctor --fix`, never `npm install -g`.
|
|
2615
2663
|
* 3. Wired with a concrete pin — that pin IS the installed version
|
|
2616
2664
|
* (current when it equals latest, else outdated → re-pin via doctor).
|
|
2617
|
-
* 4. Wired but unpinned (a bare npx spec /
|
|
2618
|
-
* re-resolves latest every session, so the effective version IS
|
|
2619
|
-
* current.
|
|
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.
|
|
2620
2668
|
*/
|
|
2621
2669
|
export function resolveFlairMcpFinding(globalProbe, latest, wiring) {
|
|
2622
2670
|
// 1. Legacy global install.
|
|
@@ -4573,18 +4621,12 @@ keys
|
|
|
4573
4621
|
// dry-run delta, symmetric removal) lives in src/hook-install.ts — this
|
|
4574
4622
|
// section is pure CLI plumbing: option parsing, default resolution, and
|
|
4575
4623
|
// rendering the pure functions' results.
|
|
4576
|
-
function
|
|
4577
|
-
return (opts.agent ||
|
|
4578
|
-
opts.agentId ||
|
|
4579
|
-
process.env.FLAIR_AGENT_ID ||
|
|
4580
|
-
readClientMcpBlock("claude-code", homeDir).agentId ||
|
|
4581
|
-
undefined);
|
|
4582
|
-
}
|
|
4583
|
-
function resolveHookFlairUrl(opts, homeDir) {
|
|
4624
|
+
function resolveHookFlairUrl(opts, homeDir, harness) {
|
|
4584
4625
|
return (opts.url ||
|
|
4585
4626
|
process.env.FLAIR_TARGET ||
|
|
4586
4627
|
process.env.FLAIR_URL ||
|
|
4587
|
-
readClientMcpBlock(
|
|
4628
|
+
readClientMcpBlock(harness, homeDir).flairUrl ||
|
|
4629
|
+
(harness !== "claude-code" ? readClientMcpBlock("claude-code", homeDir).flairUrl : undefined) ||
|
|
4588
4630
|
resolveBaseUrl({}));
|
|
4589
4631
|
}
|
|
4590
4632
|
function requireSupportedHarness(raw) {
|
|
@@ -4601,19 +4643,19 @@ hook
|
|
|
4601
4643
|
.description("Wire the Flair SessionStart hook into the harness config so memory loads automatically at session start")
|
|
4602
4644
|
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
4603
4645
|
.option("--dry-run", "Print the exact JSON delta without writing")
|
|
4604
|
-
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for
|
|
4646
|
+
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for this harness's MCP client)")
|
|
4605
4647
|
.option("--agent-id <id>", "Alias for --agent")
|
|
4606
|
-
.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)")
|
|
4607
4649
|
.option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
|
|
4608
4650
|
.action((opts) => {
|
|
4609
4651
|
const harness = requireSupportedHarness(opts.harness);
|
|
4610
4652
|
const home = homedir();
|
|
4611
|
-
const agentId = resolveHookAgentId(opts, home);
|
|
4653
|
+
const agentId = resolveHookAgentId(opts, home, harness);
|
|
4612
4654
|
if (!agentId) {
|
|
4613
4655
|
console.error("No agent id known — pass --agent <id>, set FLAIR_AGENT_ID, or run `flair init` / `flair agent add` first.");
|
|
4614
4656
|
process.exit(1);
|
|
4615
4657
|
}
|
|
4616
|
-
const flairUrl = resolveHookFlairUrl(opts, home);
|
|
4658
|
+
const flairUrl = resolveHookFlairUrl(opts, home, harness);
|
|
4617
4659
|
const dryRun = !!opts.dryRun;
|
|
4618
4660
|
if (opts.continuity) {
|
|
4619
4661
|
const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
@@ -4630,6 +4672,11 @@ hook
|
|
|
4630
4672
|
const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
4631
4673
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4632
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
|
+
}
|
|
4633
4680
|
if (result.backupPath) {
|
|
4634
4681
|
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
4635
4682
|
}
|
|
@@ -4689,16 +4736,20 @@ hook
|
|
|
4689
4736
|
// status in every branch below. "absent" is NOT a failure: installing the
|
|
4690
4737
|
// pair is the opt-in, so absence renders as "not enabled".
|
|
4691
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;
|
|
4692
4743
|
const cont = continuityHookStatus(home, harness);
|
|
4693
4744
|
if (cont.state === "installed") {
|
|
4694
4745
|
console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
|
|
4695
4746
|
}
|
|
4696
4747
|
else if (cont.state === "absent") {
|
|
4697
|
-
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")})`)}`);
|
|
4698
4749
|
}
|
|
4699
4750
|
else {
|
|
4700
4751
|
const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
|
|
4701
|
-
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim,
|
|
4752
|
+
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, `— re-run: ${hookInstallHint(harness, "--continuity")}`)}`);
|
|
4702
4753
|
}
|
|
4703
4754
|
};
|
|
4704
4755
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
|
|
@@ -4711,7 +4762,7 @@ hook
|
|
|
4711
4762
|
}
|
|
4712
4763
|
if (!status.wired) {
|
|
4713
4764
|
console.log(` ${render.icons.error} not wired`);
|
|
4714
|
-
console.log(` ${render.wrap(render.c.dim, "Fix:")}
|
|
4765
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${hookInstallHint(status.harness)}`);
|
|
4715
4766
|
renderContinuity();
|
|
4716
4767
|
console.log("");
|
|
4717
4768
|
process.exit(1);
|
|
@@ -4732,7 +4783,7 @@ hook
|
|
|
4732
4783
|
console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
|
|
4733
4784
|
}
|
|
4734
4785
|
else {
|
|
4735
|
-
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`);
|
|
4736
4787
|
}
|
|
4737
4788
|
renderContinuity();
|
|
4738
4789
|
console.log("");
|
|
@@ -6116,6 +6167,95 @@ function driverCheckAppliesTo(opts) {
|
|
|
6116
6167
|
const target = resolveTarget(opts);
|
|
6117
6168
|
return !target || isLocalBase(target.replace(/\/$/, ""));
|
|
6118
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
|
+
}
|
|
6119
6259
|
federation
|
|
6120
6260
|
.command("status")
|
|
6121
6261
|
.description("Show federation status and peer connections")
|
|
@@ -6124,8 +6264,11 @@ federation
|
|
|
6124
6264
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
6125
6265
|
.option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
|
|
6126
6266
|
.action(async (opts) => {
|
|
6127
|
-
|
|
6128
|
-
|
|
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);
|
|
6129
6272
|
const mode = render.resolveOutputMode(opts);
|
|
6130
6273
|
// flair#1233: fetch instance and peers INDEPENDENTLY. One read failing
|
|
6131
6274
|
// must never take down the whole render — the principle latestPeerContact
|
|
@@ -6135,41 +6278,33 @@ federation
|
|
|
6135
6278
|
let instance = null;
|
|
6136
6279
|
let instanceErr = null;
|
|
6137
6280
|
try {
|
|
6138
|
-
instance = await api("GET", "/FederationInstance", undefined,
|
|
6281
|
+
instance = await api("GET", "/FederationInstance", undefined, { baseUrl });
|
|
6139
6282
|
}
|
|
6140
6283
|
catch (err) {
|
|
6141
|
-
instanceErr = err;
|
|
6284
|
+
instanceErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
|
|
6142
6285
|
}
|
|
6143
6286
|
// peers: null = unverifiable (the read failed), [] = verified empty.
|
|
6144
6287
|
let peers = null;
|
|
6145
6288
|
let peersErr = null;
|
|
6146
6289
|
try {
|
|
6147
|
-
const r = await api("GET", "/FederationPeers", undefined,
|
|
6290
|
+
const r = await api("GET", "/FederationPeers", undefined, { baseUrl });
|
|
6148
6291
|
peers = r.peers ?? [];
|
|
6149
6292
|
}
|
|
6150
6293
|
catch (err) {
|
|
6151
|
-
peersErr = err;
|
|
6294
|
+
peersErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
|
|
6152
6295
|
}
|
|
6153
6296
|
// Auth-shaped failures stay FATAL even when the other read succeeded:
|
|
6154
6297
|
// both endpoints sit behind the same allowAdmin gate, so a 401/403 is a
|
|
6155
6298
|
// property of the session's credentials, not of one endpoint — and
|
|
6156
6299
|
// degrading it to "unverifiable" would swallow the actionable remedy
|
|
6157
6300
|
// (flair#634's UX, kept). Only non-auth failures degrade independently.
|
|
6158
|
-
const authShaped = (err) => {
|
|
6159
|
-
if (!err)
|
|
6160
|
-
return false;
|
|
6161
|
-
if (err.status === 401 || err.status === 403)
|
|
6162
|
-
return true;
|
|
6163
|
-
const m = String(err.message ?? err);
|
|
6164
|
-
return m.includes("missing_or_invalid_authorization") || m.includes("401");
|
|
6165
|
-
};
|
|
6166
6301
|
// Both reads failed → nothing to render at all. Either way keep the
|
|
6167
6302
|
// classic failure UX (auth remedy when it's an auth problem), exit
|
|
6168
6303
|
// non-zero.
|
|
6169
|
-
if ((instanceErr && peersErr) ||
|
|
6304
|
+
if ((instanceErr && peersErr) || isFederationStatusAuthFailure(instanceErr) || isFederationStatusAuthFailure(peersErr)) {
|
|
6170
6305
|
const primaryErr = instanceErr ?? peersErr;
|
|
6171
6306
|
const msg = String(primaryErr.message ?? primaryErr);
|
|
6172
|
-
if (
|
|
6307
|
+
if (isFederationStatusAuthRemedy(primaryErr)) {
|
|
6173
6308
|
console.error(`${render.icons.error} federation status requires auth.`);
|
|
6174
6309
|
console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
|
|
6175
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)")}`);
|
|
@@ -7004,29 +7139,16 @@ export async function runFederationSyncOnce(opts) {
|
|
|
7004
7139
|
return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
|
|
7005
7140
|
}
|
|
7006
7141
|
}
|
|
7007
|
-
const federationSync = federation
|
|
7142
|
+
const federationSync = addSharedCredentialOptions(federation
|
|
7008
7143
|
.command("sync")
|
|
7009
7144
|
.description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
|
|
7010
7145
|
.option("--port <port>", "Harper HTTP port")
|
|
7011
|
-
.option("--admin-pass <pass>", "Admin password")
|
|
7012
|
-
.option("--admin-pass-file <path>", "Read the admin password from a file (e.g. ~/.flair/admin-pass). Preferred for launchd/cron — keeps the secret out of ps and shell history.")
|
|
7013
|
-
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
7014
7146
|
.option("--ops-port <port>", "Harper operations API port")
|
|
7015
7147
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
7016
|
-
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
7017
|
-
.action(async (opts) => {
|
|
7148
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")).action(async (opts) => {
|
|
7018
7149
|
// --admin-pass-file resolves into the same `adminPass` slot the inline
|
|
7019
7150
|
// flag uses, so the scheduler never has to embed a secret in a unit file.
|
|
7020
|
-
|
|
7021
|
-
if (!opts.adminPass && opts.adminPassFile) {
|
|
7022
|
-
try {
|
|
7023
|
-
opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
7024
|
-
}
|
|
7025
|
-
catch (err) {
|
|
7026
|
-
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
7027
|
-
process.exit(1);
|
|
7028
|
-
}
|
|
7029
|
-
}
|
|
7151
|
+
applyAdminPassFile(opts);
|
|
7030
7152
|
const r = await runFederationSyncOnce(opts);
|
|
7031
7153
|
if (r.error) {
|
|
7032
7154
|
console.error(`Error: ${r.error.message}`);
|
|
@@ -12918,8 +13040,8 @@ program
|
|
|
12918
13040
|
// Antigravity) the MCP block present + reachable + the configured agent
|
|
12919
13041
|
// genuinely registered; for pi (a NATIVE EXTENSION host — flair#1342) the
|
|
12920
13042
|
// pi-flair reference in pi's own settings, including the flair#1346
|
|
12921
|
-
// npm:-under-"extensions" trap; plus CLAUDE.md
|
|
12922
|
-
// (Claude Code
|
|
13043
|
+
// npm:-under-"extensions" trap; plus CLAUDE.md (Claude Code) and the
|
|
13044
|
+
// SessionStart hook (Claude Code + Codex — flair#1148). Reuses
|
|
12923
13045
|
// detectClients() rather than reimplementing client detection.
|
|
12924
13046
|
console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
|
|
12925
13047
|
// Prompt y/N before a content-editing fix, but only when interactive —
|
|
@@ -12940,6 +13062,7 @@ program
|
|
|
12940
13062
|
}
|
|
12941
13063
|
else {
|
|
12942
13064
|
let claudeCodeAgentId;
|
|
13065
|
+
let codexAgentId;
|
|
12943
13066
|
let anyKnownAgentId;
|
|
12944
13067
|
// `doctor --fix` writes client configs through the same wire functions
|
|
12945
13068
|
// init does, so it owes the user the same warning when the spec it would
|
|
@@ -13119,6 +13242,8 @@ program
|
|
|
13119
13242
|
const block = readClientMcpBlock(client.id, homedir());
|
|
13120
13243
|
if (client.id === "claude-code" && block.agentId)
|
|
13121
13244
|
claudeCodeAgentId = block.agentId;
|
|
13245
|
+
if (client.id === "codex" && block.agentId)
|
|
13246
|
+
codexAgentId = block.agentId;
|
|
13122
13247
|
if (block.agentId)
|
|
13123
13248
|
anyKnownAgentId = anyKnownAgentId ?? block.agentId;
|
|
13124
13249
|
if (!block.present) {
|
|
@@ -13165,8 +13290,14 @@ program
|
|
|
13165
13290
|
client.id === "antigravity" ? wireAntigravity(wireEnv) :
|
|
13166
13291
|
wireCursor(wireEnv);
|
|
13167
13292
|
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
13168
|
-
if (wireResult.ok)
|
|
13293
|
+
if (wireResult.ok) {
|
|
13169
13294
|
fixed++;
|
|
13295
|
+
if (client.id === "claude-code")
|
|
13296
|
+
claudeCodeAgentId = fixAgentId;
|
|
13297
|
+
if (client.id === "codex")
|
|
13298
|
+
codexAgentId = fixAgentId;
|
|
13299
|
+
anyKnownAgentId = anyKnownAgentId ?? fixAgentId;
|
|
13300
|
+
}
|
|
13170
13301
|
}
|
|
13171
13302
|
}
|
|
13172
13303
|
}
|
|
@@ -13216,8 +13347,9 @@ program
|
|
|
13216
13347
|
console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
|
|
13217
13348
|
}
|
|
13218
13349
|
}
|
|
13219
|
-
// Claude-Code-specific: CLAUDE.md + SessionStart hook
|
|
13220
|
-
// 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.
|
|
13221
13353
|
if (detectedClients.some((c) => c.id === "claude-code")) {
|
|
13222
13354
|
const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
|
|
13223
13355
|
if (claudeMd.present) {
|
|
@@ -13407,6 +13539,92 @@ program
|
|
|
13407
13539
|
issues++;
|
|
13408
13540
|
}
|
|
13409
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
|
+
}
|
|
13410
13628
|
}
|
|
13411
13629
|
// 7a. Resolve which agent identities the two verified-read sections below
|
|
13412
13630
|
// (Fleet presence, Migrations) iterate (flair#722). Previously both
|
|
@@ -13806,7 +14024,8 @@ program
|
|
|
13806
14024
|
// from /HealthDetail at all — it's the one metric in this file that requires
|
|
13807
14025
|
// live QUERIES, because it's checking whether querying itself still works.
|
|
13808
14026
|
// For a sample of the querying agent's OWN memories (fetchRecallSpotCheckData
|
|
13809
|
-
// 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
|
|
13810
14029
|
// (deriveRecallCue — its `subject` if present, else the leading ~8 words /
|
|
13811
14030
|
// first sentence of `content`; a PARTIAL cue, never the full content) and
|
|
13812
14031
|
// searched for through the EXACT SAME authenticated read path `flair memory
|
|
@@ -13869,6 +14088,53 @@ export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
|
|
|
13869
14088
|
* "first-pass default, tunable later" spirit as the thresholds above. */
|
|
13870
14089
|
export const QUALITY_RECALL_SAMPLE_SIZE = 10;
|
|
13871
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
|
+
}
|
|
13872
14138
|
/** Leading-word cap on the content-derived cue. 25, matching the arm of the
|
|
13873
14139
|
* flair#967 A/B that was actually measured (same 10 memories, same instance,
|
|
13874
14140
|
* same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
|
|
@@ -14230,23 +14496,25 @@ export function computeQualityReport(healthy, healthData, opts = {}) {
|
|
|
14230
14496
|
* `agentId`'s own memories and, for each, search for a cue derived from it.
|
|
14231
14497
|
* Reuses the EXACT read path `flair memory search` / `flair memory list`
|
|
14232
14498
|
* use — `api()` (→ authedRequest's 5-tier resolver) for both the
|
|
14233
|
-
* `GET /Memory
|
|
14234
|
-
*
|
|
14235
|
-
*
|
|
14236
|
-
*
|
|
14237
|
-
*
|
|
14238
|
-
*
|
|
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.
|
|
14239
14507
|
*/
|
|
14240
|
-
async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
14508
|
+
export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
14241
14509
|
const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
|
|
14242
14510
|
const k = opts.k ?? QUALITY_RECALL_K;
|
|
14511
|
+
const request = opts.request ?? api;
|
|
14243
14512
|
if (!agentId) {
|
|
14244
14513
|
return { ok: false, skipReason: "no agent identity to query as — pass --agent or set FLAIR_AGENT_ID" };
|
|
14245
14514
|
}
|
|
14246
14515
|
let all;
|
|
14247
14516
|
try {
|
|
14248
|
-
const
|
|
14249
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
|
|
14517
|
+
const raw = await request("GET", qualityRecallSamplePath(agentId, sampleSize), undefined, { baseUrl, agentId });
|
|
14250
14518
|
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
14251
14519
|
}
|
|
14252
14520
|
catch (err) {
|
|
@@ -14276,7 +14544,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
14276
14544
|
try {
|
|
14277
14545
|
for (const { id, cue } of plan.sampled) {
|
|
14278
14546
|
const body = { agentId, q: cue, limit: k };
|
|
14279
|
-
const res = await
|
|
14547
|
+
const res = await request("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
14280
14548
|
const results = Array.isArray(res) ? res : (res?.results ?? []);
|
|
14281
14549
|
sampledIds.push(id);
|
|
14282
14550
|
perQueryResultIds.push(results.map((r) => String(r.id)));
|
|
@@ -14494,21 +14762,23 @@ export function qualitySnapshotSubject(baseUrl) {
|
|
|
14494
14762
|
return `quality-snapshot/${host}`;
|
|
14495
14763
|
}
|
|
14496
14764
|
/** Fetch the most recent prior quality snapshot for `agentId` at `baseUrl`,
|
|
14497
|
-
* via the
|
|
14498
|
-
*
|
|
14499
|
-
*
|
|
14500
|
-
* `
|
|
14501
|
-
*
|
|
14502
|
-
*
|
|
14503
|
-
*
|
|
14504
|
-
*
|
|
14505
|
-
*
|
|
14506
|
-
*
|
|
14507
|
-
|
|
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;
|
|
14508
14779
|
let all;
|
|
14509
14780
|
try {
|
|
14510
|
-
const
|
|
14511
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
|
|
14781
|
+
const raw = await request("GET", qualitySnapshotLookupPath(agentId, subject), undefined, { baseUrl, agentId });
|
|
14512
14782
|
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
14513
14783
|
}
|
|
14514
14784
|
catch {
|
|
@@ -14976,9 +15246,8 @@ function parseEntitiesOptionOrExit(csv) {
|
|
|
14976
15246
|
}
|
|
14977
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`)";
|
|
14978
15248
|
const memory = program.command("memory").description("Manage agent memories");
|
|
14979
|
-
memory.command("add [content]")
|
|
14980
|
-
.description("Write a new memory row for an agent (content via positional arg or --content)")
|
|
14981
|
-
.requiredOption("--agent <id>")
|
|
15249
|
+
addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]")
|
|
15250
|
+
.description("Write a new memory row for an agent (content via positional arg or --content)")))
|
|
14982
15251
|
.option("--content <text>", "memory content (alias for positional arg)")
|
|
14983
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>")
|
|
14984
15253
|
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
|
|
@@ -14992,10 +15261,15 @@ memory.command("add [content]")
|
|
|
14992
15261
|
console.error("error: content required (positional arg or --content)");
|
|
14993
15262
|
process.exit(1);
|
|
14994
15263
|
}
|
|
14995
|
-
|
|
14996
|
-
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()}`;
|
|
14997
15271
|
const body = {
|
|
14998
|
-
id: memId, agentId
|
|
15272
|
+
id: memId, agentId, content, durability: opts.durability || "standard",
|
|
14999
15273
|
tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
|
|
15000
15274
|
type: "memory", createdAt: new Date().toISOString(),
|
|
15001
15275
|
};
|
|
@@ -15028,7 +15302,11 @@ memory.command("add [content]")
|
|
|
15028
15302
|
if (entities.length > 0)
|
|
15029
15303
|
body.entities = entities;
|
|
15030
15304
|
}
|
|
15031
|
-
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
|
+
});
|
|
15032
15310
|
console.log(JSON.stringify(out, null, 2));
|
|
15033
15311
|
});
|
|
15034
15312
|
// ─── flair memory write-task-summary ────────────────────────────────────────
|
|
@@ -16274,7 +16552,14 @@ bridge
|
|
|
16274
16552
|
process.exit(2);
|
|
16275
16553
|
}
|
|
16276
16554
|
try {
|
|
16277
|
-
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
|
+
});
|
|
16278
16563
|
if (opts.json) {
|
|
16279
16564
|
console.log(JSON.stringify(result, null, 2));
|
|
16280
16565
|
process.exit(result.passed ? 0 : 1);
|
|
@@ -16481,31 +16766,19 @@ function printTrustError(detail) {
|
|
|
16481
16766
|
}
|
|
16482
16767
|
}
|
|
16483
16768
|
// ─── flair backup ────────────────────────────────────────────────────────────
|
|
16484
|
-
program
|
|
16769
|
+
addSharedCredentialOptions(program
|
|
16485
16770
|
.command("backup")
|
|
16486
16771
|
.description("Export agents, memories, and souls to a JSON archive")
|
|
16487
16772
|
.option("--output <path>", "Output file path (default: ~/.flair/backups/flair-backup-<timestamp>.json)")
|
|
16488
16773
|
.option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
|
|
16489
16774
|
.option("--port <port>", "Harper HTTP port")
|
|
16490
|
-
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
16491
|
-
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
|
|
16492
|
-
.option("--admin-pass-file <path>", "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
|
|
16493
|
-
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
16494
|
-
.action(async (opts) => {
|
|
16775
|
+
.option("--url <url>", "Flair base URL (overrides --port)")).action(async (opts) => {
|
|
16495
16776
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
16496
|
-
|
|
16497
|
-
|
|
16498
|
-
|
|
16499
|
-
|
|
16500
|
-
|
|
16501
|
-
try {
|
|
16502
|
-
adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
16503
|
-
}
|
|
16504
|
-
catch (err) {
|
|
16505
|
-
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
16506
|
-
process.exit(1);
|
|
16507
|
-
}
|
|
16508
|
-
}
|
|
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 ?? "";
|
|
16509
16782
|
const adminUser = resolveAdminUser(opts.adminUser);
|
|
16510
16783
|
if (!adminPass) {
|
|
16511
16784
|
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
|