@tpsdev-ai/flair 0.44.8 → 0.44.10
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/cli.js +292 -28
- package/dist/doctor-client.js +55 -1
- package/dist/install/clients.js +50 -2
- package/dist/rem/runner.js +167 -23
- package/dist/resources/MemoryBootstrap.js +267 -65
- package/dist/resources/MemoryReflect.js +15 -13
- package/dist/resources/mcp-tools.js +243 -20
- package/dist/resources/memory-bootstrap-lib.js +58 -0
- package/dist/resources/memory-reflect-lib.js +70 -0
- package/dist/resources/token-estimate.js +25 -0
- package/dist/resources/trust-block.js +16 -7
- package/docs/integrations.md +3 -0
- package/docs/rem.md +6 -0
- package/package.json +1 -1
- package/schemas/memory.graphql +9 -0
package/dist/cli.js
CHANGED
|
@@ -19,11 +19,11 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
|
|
|
19
19
|
import { probeInstance } from "./probe.js";
|
|
20
20
|
import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
|
|
21
21
|
import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
|
|
22
|
-
import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
|
|
23
|
-
import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
|
|
22
|
+
import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
|
|
23
|
+
import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
|
|
24
24
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
25
25
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
26
|
-
import { readClientMcpBlock, checkClaudeMdBootstrap, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
|
|
26
|
+
import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
|
|
27
27
|
import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
28
28
|
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
29
29
|
import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
|
|
@@ -2394,14 +2394,58 @@ export function upgradeStatusSuffix(name, status) {
|
|
|
2394
2394
|
if (status === "current")
|
|
2395
2395
|
return " (current)";
|
|
2396
2396
|
if (status === "missing") {
|
|
2397
|
-
return name ===
|
|
2397
|
+
return name === FLAIR_MCP_PACKAGE
|
|
2398
2398
|
? " (zero-install via npx — run: flair doctor --fix)"
|
|
2399
2399
|
: " (run: npm install -g)";
|
|
2400
2400
|
}
|
|
2401
2401
|
if (status === "optional")
|
|
2402
2402
|
return " (install via: openclaw plugins install @tpsdev-ai/openclaw-flair)";
|
|
2403
|
+
// flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
|
|
2404
|
+
// the post-upgrade pin refresh), never `npm install -g` — a global bin does
|
|
2405
|
+
// nothing for an `npx -y -p @tpsdev-ai/flair-mcp` invocation (flair#1208).
|
|
2406
|
+
if (status === "outdated" && name === FLAIR_MCP_PACKAGE) {
|
|
2407
|
+
return " (npx-wired — run: flair doctor --fix to re-pin)";
|
|
2408
|
+
}
|
|
2403
2409
|
return "";
|
|
2404
2410
|
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Resolve the `flair upgrade` finding for flair-mcp from its ACTUAL wiring,
|
|
2413
|
+
* not a global-install probe (flair#1208).
|
|
2414
|
+
*
|
|
2415
|
+
* flair-mcp is zero-install via npx (#1168): a correctly-wired machine never
|
|
2416
|
+
* installs it globally, so the global bin/lib probe returning null is the
|
|
2417
|
+
* NORMAL state, not "missing". Its real installed version is the pin its wiring
|
|
2418
|
+
* carries (a client MCP config's args, refreshed by `flair doctor --fix`).
|
|
2419
|
+
*
|
|
2420
|
+
* Resolution order:
|
|
2421
|
+
* 1. Legacy global install — the bin/lib probe found a version. Honor it.
|
|
2422
|
+
* 2. Not wired anywhere — genuinely missing; the remedy (upgradeStatusSuffix)
|
|
2423
|
+
* is `flair doctor --fix`, never `npm install -g`.
|
|
2424
|
+
* 3. Wired with a concrete pin — that pin IS the installed version
|
|
2425
|
+
* (current when it equals latest, else outdated → re-pin via doctor).
|
|
2426
|
+
* 4. Wired but unpinned (a bare npx spec / the SessionStart hook) — `npx -y`
|
|
2427
|
+
* re-resolves latest every session, so the effective version IS latest →
|
|
2428
|
+
* current.
|
|
2429
|
+
*/
|
|
2430
|
+
export function resolveFlairMcpFinding(globalProbe, latest, wiring) {
|
|
2431
|
+
// 1. Legacy global install.
|
|
2432
|
+
if (globalProbe !== null) {
|
|
2433
|
+
return { installed: globalProbe, status: globalProbe === latest ? "current" : "outdated" };
|
|
2434
|
+
}
|
|
2435
|
+
// 2. Not wired anywhere.
|
|
2436
|
+
if (!wiring.wired) {
|
|
2437
|
+
return { installed: null, status: "missing" };
|
|
2438
|
+
}
|
|
2439
|
+
// 3. Wired with a pin.
|
|
2440
|
+
if (wiring.pinnedVersion) {
|
|
2441
|
+
return {
|
|
2442
|
+
installed: wiring.pinnedVersion,
|
|
2443
|
+
status: wiring.pinnedVersion === latest ? "current" : "outdated",
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
// 4. Wired but unpinned — npx resolves latest on every session.
|
|
2447
|
+
return { installed: latest, status: "current" };
|
|
2448
|
+
}
|
|
2405
2449
|
/**
|
|
2406
2450
|
* Pure flag resolution for `flair upgrade`'s restart/verify defaults
|
|
2407
2451
|
* (flair#635 decision: restart is now the default; `--no-restart` opts
|
|
@@ -2714,7 +2758,7 @@ program
|
|
|
2714
2758
|
.option("--data-dir <dir>", "Harper data directory")
|
|
2715
2759
|
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
2716
2760
|
.option("--skip-soul", "Skip interactive personality setup")
|
|
2717
|
-
.option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
|
|
2761
|
+
.option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, antigravity, all, or none")
|
|
2718
2762
|
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
2719
2763
|
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
2720
2764
|
.option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
|
|
@@ -2933,9 +2977,9 @@ program
|
|
|
2933
2977
|
const noMcp = opts.mcp === false;
|
|
2934
2978
|
const selectedClients = [];
|
|
2935
2979
|
if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
|
|
2936
|
-
const valid = ["claude-code", "codex", "gemini", "cursor"];
|
|
2980
|
+
const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
|
|
2937
2981
|
if (!valid.includes(clientOpt)) {
|
|
2938
|
-
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
|
|
2982
|
+
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, all, none`);
|
|
2939
2983
|
process.exit(1);
|
|
2940
2984
|
}
|
|
2941
2985
|
selectedClients.push(clientOpt);
|
|
@@ -3536,6 +3580,9 @@ program
|
|
|
3536
3580
|
case "cursor":
|
|
3537
3581
|
result = wireCursor({ ...mcpEnv, FLAIR_CLIENT: "cursor" });
|
|
3538
3582
|
break;
|
|
3583
|
+
case "antigravity":
|
|
3584
|
+
result = wireAntigravity({ ...mcpEnv, FLAIR_CLIENT: "antigravity" });
|
|
3585
|
+
break;
|
|
3539
3586
|
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
3540
3587
|
}
|
|
3541
3588
|
wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
|
|
@@ -7408,6 +7455,133 @@ export function decideCandidateAction(candidate, action) {
|
|
|
7408
7455
|
}
|
|
7409
7456
|
return { ok: true };
|
|
7410
7457
|
}
|
|
7458
|
+
// ─── ADK tag-lineage on promote (#1205 slice 1205a — Sherlock security req) ───
|
|
7459
|
+
// ADK session records are written by adk-flair (memory_service.py) under a
|
|
7460
|
+
// SHARED-namespace agentId, with per-user separation carried ENTIRELY by a
|
|
7461
|
+
// compound scope tag `adk:<app>:<user>`. That tag is the access-control
|
|
7462
|
+
// boundary. A candidate distilled from those records therefore MUST carry the
|
|
7463
|
+
// scope tag when promoted, or the promoted claim lands in the shared agentId
|
|
7464
|
+
// memory retrievable by every other user of the app — a cross-user leak.
|
|
7465
|
+
//
|
|
7466
|
+
// `rem promote` historically hard-coded `["nightly-rem-promoted", from:<id>]`
|
|
7467
|
+
// and DROPPED the source tag. We now propagate the source scope tag for
|
|
7468
|
+
// ADK-sourced candidates, and FAIL CLOSED (refuse) when a candidate is
|
|
7469
|
+
// ADK-sourced but its scope tag can't be uniquely+completely determined.
|
|
7470
|
+
//
|
|
7471
|
+
// SCOPING (deliberate, per spec): fail-closed applies ONLY to ADK-sourced
|
|
7472
|
+
// candidates. Non-ADK candidates carry no `adk:` tag and promote byte-for-byte
|
|
7473
|
+
// as before — a transient/deleted source on a non-ADK candidate must NOT block
|
|
7474
|
+
// its promotion.
|
|
7475
|
+
//
|
|
7476
|
+
// SEAM (foundation only; the distillation engine is slice #1205b): ADK-sourcing
|
|
7477
|
+
// is detected here by re-reading the candidate's source memories and inspecting
|
|
7478
|
+
// their tags. That leaves ONE residual fail-open: an ADK-sourced candidate all
|
|
7479
|
+
// of whose source memories are unreadable (deleted/transient) yields no `adk:`
|
|
7480
|
+
// evidence and is treated as non-ADK. Closing that corner without regressing
|
|
7481
|
+
// non-ADK promotion requires the ENGINE to stamp the authoritative scope tag
|
|
7482
|
+
// onto the MemoryCandidate row at distillation time (it distills per single
|
|
7483
|
+
// scope:tagged tag, so it knows it authoritatively). `derivePromotedTags` is
|
|
7484
|
+
// written so that override can be threaded in later without touching callers.
|
|
7485
|
+
export const ADK_SCOPE_TAG_PREFIX = "adk:";
|
|
7486
|
+
/**
|
|
7487
|
+
* Decide the tag set for a promoted Memory given the candidate id and the
|
|
7488
|
+
* result of fetching each of its source memories. Pure — no I/O; the action
|
|
7489
|
+
* callback does the fetching and threads the results here so this is unit-
|
|
7490
|
+
* testable and the fail-closed logic is exercised directly.
|
|
7491
|
+
*
|
|
7492
|
+
* `stampedScopeTag` (#1205b-1 — the engine slice the #1205a SEAM note below
|
|
7493
|
+
* anticipated): the authoritative scope:"tagged" tag the distillation engine
|
|
7494
|
+
* stamped onto the MemoryCandidate row (resources/MemoryReflect.ts →
|
|
7495
|
+
* buildStagedCandidateRow). When present it is AUTHORITATIVE and short-circuits
|
|
7496
|
+
* the source re-read entirely — the engine distilled under exactly this one
|
|
7497
|
+
* tag, so it knows the per-user scope tag independent of whether the source
|
|
7498
|
+
* memories are still readable. This closes the residual fail-open the SEAM
|
|
7499
|
+
* note describes: a candidate all of whose sources are unreadable yields no
|
|
7500
|
+
* `adk:` evidence and would otherwise be mis-classified NON-ADK and promoted
|
|
7501
|
+
* tagless into the shared agentId namespace (a cross-user leak). Threading it
|
|
7502
|
+
* in as an optional trailing arg keeps every pre-#1205b caller (and every
|
|
7503
|
+
* candidate that never carried a stamp) on the unchanged source-re-read path.
|
|
7504
|
+
*
|
|
7505
|
+
* With NO stamp (undefined/empty) the source-re-read classification runs
|
|
7506
|
+
* exactly as in #1205a:
|
|
7507
|
+
* - No `adk:` scope tag across readable sources → NON-ADK candidate; return
|
|
7508
|
+
* the provenance tags only (unchanged behavior).
|
|
7509
|
+
* - Exactly one `adk:` scope tag AND every source readable → ADK-sourced;
|
|
7510
|
+
* return [scopeTag, ...provenance].
|
|
7511
|
+
* - `adk:` evidence present but the scope tag is ambiguous (>1 distinct tag)
|
|
7512
|
+
* OR incomplete (some source unreadable) → REFUSE (fail-closed): a
|
|
7513
|
+
* tagless/mis-tagged claim in a shared ADK namespace is a cross-user leak,
|
|
7514
|
+
* not a benign miss.
|
|
7515
|
+
*/
|
|
7516
|
+
export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
|
|
7517
|
+
const provenance = ["nightly-rem-promoted", `from:${candidateId}`];
|
|
7518
|
+
// #1205b-1: a stamped scope tag is AUTHORITATIVE — consume it directly, never
|
|
7519
|
+
// re-read sources. This is the seam closure: correctness no longer depends on
|
|
7520
|
+
// source readability. `adkSourced` (which gates the Soul-promotion refusal in
|
|
7521
|
+
// the promote action) tracks whether the stamped tag is an ADK scope tag.
|
|
7522
|
+
if (typeof stampedScopeTag === "string" && stampedScopeTag.length > 0) {
|
|
7523
|
+
return {
|
|
7524
|
+
ok: true,
|
|
7525
|
+
tags: [stampedScopeTag, ...provenance],
|
|
7526
|
+
adkSourced: stampedScopeTag.startsWith(ADK_SCOPE_TAG_PREFIX),
|
|
7527
|
+
};
|
|
7528
|
+
}
|
|
7529
|
+
const adkTags = new Set();
|
|
7530
|
+
let anySourceUnreadable = false;
|
|
7531
|
+
for (const s of sources) {
|
|
7532
|
+
if (!s.ok) {
|
|
7533
|
+
anySourceUnreadable = true;
|
|
7534
|
+
continue;
|
|
7535
|
+
}
|
|
7536
|
+
for (const t of s.tags) {
|
|
7537
|
+
if (typeof t === "string" && t.startsWith(ADK_SCOPE_TAG_PREFIX))
|
|
7538
|
+
adkTags.add(t);
|
|
7539
|
+
}
|
|
7540
|
+
}
|
|
7541
|
+
// No positive ADK evidence → non-ADK. An unreadable source with zero ADK
|
|
7542
|
+
// evidence does NOT fail closed here (that would regress non-ADK promotion);
|
|
7543
|
+
// see the SEAM note above.
|
|
7544
|
+
if (adkTags.size === 0) {
|
|
7545
|
+
return { ok: true, tags: provenance, adkSourced: false };
|
|
7546
|
+
}
|
|
7547
|
+
if (adkTags.size > 1) {
|
|
7548
|
+
return {
|
|
7549
|
+
ok: false,
|
|
7550
|
+
reason: `ADK-sourced candidate spans multiple scope tags (${[...adkTags].sort().join(", ")}); refusing to promote — a merged cross-user claim would leak across users`,
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
if (anySourceUnreadable) {
|
|
7554
|
+
return {
|
|
7555
|
+
ok: false,
|
|
7556
|
+
reason: `ADK-sourced candidate has unreadable source memories; the per-user scope tag cannot be confirmed — refusing to promote (fail-closed)`,
|
|
7557
|
+
};
|
|
7558
|
+
}
|
|
7559
|
+
const scopeTag = [...adkTags][0];
|
|
7560
|
+
return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
|
|
7561
|
+
}
|
|
7562
|
+
// ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
|
|
7563
|
+
// A promotion records a reviewerId that feeds audit/attribution
|
|
7564
|
+
// (schemas/memory.graphql:209). An automated (machine-driven) promotion path
|
|
7565
|
+
// must record a reviewerId that can NEVER be mistaken for a human/agent
|
|
7566
|
+
// reviewer, so attribution isn't laundered. Reserve the `machine:` namespace
|
|
7567
|
+
// for that, and forbid the human `--reviewer` path from claiming it.
|
|
7568
|
+
export const MACHINE_REVIEWER_PREFIX = "machine:";
|
|
7569
|
+
/** Canonical machine reviewerId for the ADK auto-promote consumer (#1205b). */
|
|
7570
|
+
export const MACHINE_REVIEWER_ADK_AUTO_PROMOTE = "machine:adk-auto-promote";
|
|
7571
|
+
/** True iff `id` is in the reserved machine-reviewer namespace — i.e. it
|
|
7572
|
+
* denotes an automated path, not a human or agent reviewer. */
|
|
7573
|
+
export function isMachineReviewerId(id) {
|
|
7574
|
+
return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
|
|
7575
|
+
}
|
|
7576
|
+
/** The human `flair rem promote` path must not record a reviewerId in the
|
|
7577
|
+
* reserved machine namespace — that would launder automated attribution onto
|
|
7578
|
+
* a human-operated promotion. Returns an error string, or null if allowed. */
|
|
7579
|
+
export function validateHumanReviewerId(reviewerId) {
|
|
7580
|
+
if (isMachineReviewerId(reviewerId)) {
|
|
7581
|
+
return `--reviewer '${reviewerId}' uses the reserved '${MACHINE_REVIEWER_PREFIX}' namespace (reserved for automated promotion); use a human/agent reviewer id`;
|
|
7582
|
+
}
|
|
7583
|
+
return null;
|
|
7584
|
+
}
|
|
7411
7585
|
// ─── flair rem promote ───────────────────────────────────────────────────────
|
|
7412
7586
|
// Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
|
|
7413
7587
|
// or persistent Memory. Both --rationale and --to are required (spec § 5: no
|
|
@@ -7435,6 +7609,13 @@ rem
|
|
|
7435
7609
|
process.exit(1);
|
|
7436
7610
|
}
|
|
7437
7611
|
const reviewerId = opts.reviewer || process.env.FLAIR_AGENT_ID || "admin";
|
|
7612
|
+
// The human promote path must not record a reserved machine reviewerId
|
|
7613
|
+
// (Sherlock #4): that would launder automated attribution.
|
|
7614
|
+
const reviewerErr = validateHumanReviewerId(reviewerId);
|
|
7615
|
+
if (reviewerErr) {
|
|
7616
|
+
console.error(`Error: ${reviewerErr}`);
|
|
7617
|
+
process.exit(1);
|
|
7618
|
+
}
|
|
7438
7619
|
try {
|
|
7439
7620
|
// Fetch the candidate
|
|
7440
7621
|
const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
|
|
@@ -7445,6 +7626,51 @@ rem
|
|
|
7445
7626
|
console.error(`Error: candidate ${candidateId} ${msg}`);
|
|
7446
7627
|
process.exit(1);
|
|
7447
7628
|
}
|
|
7629
|
+
// ADK tag-lineage: derive the promoted-claim tag set.
|
|
7630
|
+
//
|
|
7631
|
+
// #1205b-1: if the engine stamped an authoritative `scopeTag` on the
|
|
7632
|
+
// candidate (scope:"tagged" distillation), consume it DIRECTLY and skip
|
|
7633
|
+
// the source re-read — correctness no longer depends on the source
|
|
7634
|
+
// memories still being readable (the #1205a seam closure). We only fall
|
|
7635
|
+
// back to re-reading sources when there is NO stamp (a pre-#1205b
|
|
7636
|
+
// candidate, or a non-tagged distillation).
|
|
7637
|
+
const stampedScopeTag = typeof candidate.scopeTag === "string" && candidate.scopeTag.length > 0 ? candidate.scopeTag : undefined;
|
|
7638
|
+
const sourceFetches = [];
|
|
7639
|
+
if (!stampedScopeTag) {
|
|
7640
|
+
// No authoritative stamp — re-read sources to classify. Fail-closed for
|
|
7641
|
+
// ADK-sourced candidates whose per-user scope tag can't be confirmed;
|
|
7642
|
+
// unchanged for non-ADK candidates. See derivePromotedTags for rules.
|
|
7643
|
+
const sourceIds = Array.isArray(candidate.sourceMemoryIds) ? candidate.sourceMemoryIds : [];
|
|
7644
|
+
for (const sid of sourceIds) {
|
|
7645
|
+
try {
|
|
7646
|
+
const mem = await api("GET", `/Memory/${encodeURIComponent(String(sid))}`);
|
|
7647
|
+
if (mem && !mem.error) {
|
|
7648
|
+
sourceFetches.push({ ok: true, tags: Array.isArray(mem.tags) ? mem.tags : [] });
|
|
7649
|
+
}
|
|
7650
|
+
else {
|
|
7651
|
+
sourceFetches.push({ ok: false });
|
|
7652
|
+
}
|
|
7653
|
+
}
|
|
7654
|
+
catch {
|
|
7655
|
+
sourceFetches.push({ ok: false });
|
|
7656
|
+
}
|
|
7657
|
+
}
|
|
7658
|
+
}
|
|
7659
|
+
const tagDecision = derivePromotedTags(candidateId, sourceFetches, stampedScopeTag);
|
|
7660
|
+
if (!tagDecision.ok) {
|
|
7661
|
+
console.error(`Error: candidate ${candidateId} — ${tagDecision.reason}`);
|
|
7662
|
+
process.exit(1);
|
|
7663
|
+
}
|
|
7664
|
+
// Soul entries are agentId-scoped and cannot carry a per-user scope tag,
|
|
7665
|
+
// so an ADK-sourced candidate promoted to Soul is a cross-user leak by
|
|
7666
|
+
// construction — fail closed. (Server-side trust-tier enforcement that
|
|
7667
|
+
// hard-locks the target is the engine slice #1205b; this is the CLI-side
|
|
7668
|
+
// foundation.)
|
|
7669
|
+
if (opts.to === "soul" && tagDecision.adkSourced) {
|
|
7670
|
+
console.error(`Error: candidate ${candidateId} is ADK-sourced (scope tag ${tagDecision.tags[0]}); Soul is agentId-scoped and cannot carry a per-user scope tag — refusing to promote to Soul (would leak across users). Promote ADK-sourced candidates to memory.`);
|
|
7671
|
+
process.exit(1);
|
|
7672
|
+
}
|
|
7673
|
+
const promotedTags = tagDecision.tags;
|
|
7448
7674
|
const decidedAt = new Date().toISOString();
|
|
7449
7675
|
// Write the resulting Soul or Memory entry
|
|
7450
7676
|
if (opts.to === "memory") {
|
|
@@ -7454,7 +7680,7 @@ rem
|
|
|
7454
7680
|
agentId: candidate.agentId,
|
|
7455
7681
|
content: candidate.claim,
|
|
7456
7682
|
durability: "persistent",
|
|
7457
|
-
tags:
|
|
7683
|
+
tags: promotedTags,
|
|
7458
7684
|
derivedFrom: candidate.sourceMemoryIds ?? [],
|
|
7459
7685
|
promotionStatus: "approved",
|
|
7460
7686
|
promotedAt: decidedAt,
|
|
@@ -9517,18 +9743,30 @@ program
|
|
|
9517
9743
|
}
|
|
9518
9744
|
catch { /* best-effort */ }
|
|
9519
9745
|
}
|
|
9520
|
-
const
|
|
9746
|
+
const globalProbe = probe();
|
|
9747
|
+
let installed;
|
|
9521
9748
|
let status;
|
|
9522
|
-
if (
|
|
9523
|
-
//
|
|
9524
|
-
//
|
|
9525
|
-
|
|
9526
|
-
|
|
9527
|
-
|
|
9528
|
-
status =
|
|
9749
|
+
if (name === FLAIR_MCP_PACKAGE) {
|
|
9750
|
+
// flair-mcp is zero-install via npx (#1168) — a null global probe is
|
|
9751
|
+
// the NORMAL state, not "missing". Resolve it from its actual wiring
|
|
9752
|
+
// (the pin in a client MCP config / the SessionStart hook) so the
|
|
9753
|
+
// listing is truthful and the remedy actually works (flair#1208).
|
|
9754
|
+
const home = process.env.HOME ?? homedir();
|
|
9755
|
+
({ installed, status } = resolveFlairMcpFinding(globalProbe, latest, detectWiredFlairMcp(home)));
|
|
9529
9756
|
}
|
|
9530
9757
|
else {
|
|
9531
|
-
|
|
9758
|
+
installed = globalProbe;
|
|
9759
|
+
if (installed === null) {
|
|
9760
|
+
// openclaw-plugin packages are optional — if openclaw isn't
|
|
9761
|
+
// installed, don't surface a misleading "install with npm" advice.
|
|
9762
|
+
status = kind === "openclaw-plugin" ? "optional" : "missing";
|
|
9763
|
+
}
|
|
9764
|
+
else if (installed === latest) {
|
|
9765
|
+
status = "current";
|
|
9766
|
+
}
|
|
9767
|
+
else {
|
|
9768
|
+
status = "outdated";
|
|
9769
|
+
}
|
|
9532
9770
|
}
|
|
9533
9771
|
findings.push({ name, installed, latest, status, kind });
|
|
9534
9772
|
// Suppress the line for openclaw plugins that are optional-because-
|
|
@@ -9553,12 +9791,19 @@ program
|
|
|
9553
9791
|
console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
|
|
9554
9792
|
const outdated = findings.filter((f) => f.status === "outdated");
|
|
9555
9793
|
const missing = findings.filter((f) => f.status === "missing");
|
|
9794
|
+
// flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
|
|
9795
|
+
// the post-upgrade pin refresh below), NEVER `npm install -g` — a global
|
|
9796
|
+
// bin does nothing for an `npx -y -p @tpsdev-ai/flair-mcp` invocation
|
|
9797
|
+
// (#1168/#1208). So a stale-pinned flair-mcp drives a remedy line, not the
|
|
9798
|
+
// npm-install + restart transaction. It is kept out of npmUpgrades here and
|
|
9799
|
+
// surfaced separately below.
|
|
9800
|
+
const flairMcpOutdated = outdated.find((f) => f.name === FLAIR_MCP_PACKAGE) ?? null;
|
|
9556
9801
|
// openclaw plugins upgrade through `openclaw plugins install`, not `npm
|
|
9557
9802
|
// install -g` (npm-installed wouldn't connect to OpenClaw's gateway slot).
|
|
9558
9803
|
// Split outdated into npm-upgradeable vs openclaw-plugin so we can use
|
|
9559
9804
|
// the right command for each.
|
|
9560
9805
|
const npmUpgrades = outdated
|
|
9561
|
-
.filter((f) => f.kind !== "openclaw-plugin")
|
|
9806
|
+
.filter((f) => f.kind !== "openclaw-plugin" && f.name !== FLAIR_MCP_PACKAGE)
|
|
9562
9807
|
.map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
|
|
9563
9808
|
const openclawUpgrades = outdated
|
|
9564
9809
|
.filter((f) => f.kind === "openclaw-plugin")
|
|
@@ -9568,15 +9813,26 @@ program
|
|
|
9568
9813
|
console.log("\n✅ Everything is up to date.");
|
|
9569
9814
|
return;
|
|
9570
9815
|
}
|
|
9571
|
-
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
|
|
9576
|
-
|
|
9816
|
+
// Nothing to install via npm/openclaw. What is left is advisory: packages
|
|
9817
|
+
// not detected (missing) and/or a flair-mcp whose wired pin is behind latest
|
|
9818
|
+
// — both fixed by re-wiring (`flair doctor --fix`), never by the
|
|
9819
|
+
// npm-install + restart transaction below (#1168/#1208). Print the remedies
|
|
9820
|
+
// and stop.
|
|
9821
|
+
if (totalUpgrades === 0) {
|
|
9822
|
+
if (missing.length > 0) {
|
|
9823
|
+
const npmMissing = missing.filter((f) => f.name !== FLAIR_MCP_PACKAGE);
|
|
9824
|
+
const mcpMissing = missing.some((f) => f.name === FLAIR_MCP_PACKAGE);
|
|
9825
|
+
console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
|
|
9826
|
+
if (npmMissing.length > 0) {
|
|
9827
|
+
console.log(` Install missing: npm install -g ${npmMissing.map((f) => f.name).join(" ")}`);
|
|
9828
|
+
}
|
|
9829
|
+
if (mcpMissing) {
|
|
9830
|
+
console.log(` flair-mcp is zero-install via npx — run: flair doctor --fix to wire the hook`);
|
|
9831
|
+
}
|
|
9577
9832
|
}
|
|
9578
|
-
if (
|
|
9579
|
-
console.log(
|
|
9833
|
+
if (flairMcpOutdated) {
|
|
9834
|
+
console.log(`\n⬆️ flair-mcp is wired via npx (pinned ${flairMcpOutdated.installed} → latest ${flairMcpOutdated.latest}).`);
|
|
9835
|
+
console.log(` Re-pin it: flair doctor --fix`);
|
|
9580
9836
|
}
|
|
9581
9837
|
return;
|
|
9582
9838
|
}
|
|
@@ -12154,7 +12410,8 @@ program
|
|
|
12154
12410
|
const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
|
|
12155
12411
|
client.id === "codex" ? wireCodex(wireEnv) :
|
|
12156
12412
|
client.id === "gemini" ? wireGemini(wireEnv) :
|
|
12157
|
-
|
|
12413
|
+
client.id === "antigravity" ? wireAntigravity(wireEnv) :
|
|
12414
|
+
wireCursor(wireEnv);
|
|
12158
12415
|
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
12159
12416
|
if (wireResult.ok)
|
|
12160
12417
|
fixed++;
|
|
@@ -12680,7 +12937,14 @@ program
|
|
|
12680
12937
|
// a real user query, so a high score means "recall is functioning", not
|
|
12681
12938
|
// "recall is optimal". Its job is to catch recall CRATERING (embeddings
|
|
12682
12939
|
// down, index busted) — the score collapsing toward 0 is the signal, not
|
|
12683
|
-
// fine-grained precision grading.
|
|
12940
|
+
// fine-grained precision grading. NOTE (#1216): this cue-from-the-memory
|
|
12941
|
+
// design is self-polluting as a recall-QUALITY metric — relevance is
|
|
12942
|
+
// query/corpus overlap by construction, so near-duplicate density reads as a
|
|
12943
|
+
// recall collapse (flair#967 / #857 / #996). It is deliberately NOT the
|
|
12944
|
+
// recall-quality number; that authority is the deterministic, fixed-label,
|
|
12945
|
+
// CI-gated eval at test/bench/recall-eval (recall@k / nDCG@10 / MRR). This
|
|
12946
|
+
// probe stays scoped to live-health cratering only. Requires an actual
|
|
12947
|
+
// agent identity to
|
|
12684
12948
|
// query AS (semantic search is agent-scoped) — no identity, fewer than the
|
|
12685
12949
|
// sample-size memories to sample, or a search error all degrade to `null` +
|
|
12686
12950
|
// a `gaps` entry, same graceful-degradation contract as every metric here —
|
package/dist/doctor-client.js
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
import { spawnSync } from "node:child_process";
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
|
-
import { clientConfigPath } from "./install/clients.js";
|
|
25
|
+
import { ALL_CLIENTS, clientConfigPath } from "./install/clients.js";
|
|
26
|
+
import { FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
|
|
26
27
|
// The exact substring `flair init` writes into CLAUDE.md (src/cli.ts, the
|
|
27
28
|
// `init` action) and that the doctor check + fix both key off of.
|
|
28
29
|
export const CLAUDE_MD_BOOTSTRAP_MARKER = "mcp__flair__bootstrap";
|
|
@@ -365,6 +366,59 @@ export function checkSessionStartHook(homeDir) {
|
|
|
365
366
|
return { present: false, path };
|
|
366
367
|
}
|
|
367
368
|
}
|
|
369
|
+
// ── flair-mcp presence by WIRING, not global install (flair#1208) ───────────
|
|
370
|
+
//
|
|
371
|
+
// flair-mcp is zero-install via npx by design (#1168): a correctly-wired
|
|
372
|
+
// machine invokes it as `npx -y -p @tpsdev-ai/flair-mcp` and NEVER installs it
|
|
373
|
+
// globally, so `flair upgrade`'s global bin/lib probe finds nothing and
|
|
374
|
+
// mis-reports it "not detected." Its real "installed version" is the pin its
|
|
375
|
+
// wiring carries — the mcpServerSpec() written into a client's MCP config
|
|
376
|
+
// (pinned since #1135). Detect it there instead.
|
|
377
|
+
/**
|
|
378
|
+
* Extract a pinned `@tpsdev-ai/flair-mcp` version from any wiring string — a
|
|
379
|
+
* client MCP `args` array, a Codex TOML args line, or a SessionStart hook
|
|
380
|
+
* command. Returns the version when the spec is written
|
|
381
|
+
* `@tpsdev-ai/flair-mcp@<ver>`; null for a bare/unpinned spec.
|
|
382
|
+
*
|
|
383
|
+
* The SessionStart hook is deliberately unpinned (`npx -y -p
|
|
384
|
+
* @tpsdev-ai/flair-mcp`, buildSessionStartHookCommand above), so a hook
|
|
385
|
+
* establishes that flair-mcp is wired but never carries a version — the pin
|
|
386
|
+
* comes from the client MCP config.
|
|
387
|
+
*/
|
|
388
|
+
export function extractFlairMcpPin(text) {
|
|
389
|
+
if (typeof text !== "string")
|
|
390
|
+
return null;
|
|
391
|
+
// `@tpsdev-ai/flair-mcp@<version>`; the version token runs until the first
|
|
392
|
+
// character that can't appear in a spec embedded in JSON args / TOML.
|
|
393
|
+
const m = text.match(/@tpsdev-ai\/flair-mcp@([0-9A-Za-z][^\s"'\],]*)/);
|
|
394
|
+
return m ? m[1] : null;
|
|
395
|
+
}
|
|
396
|
+
export function detectWiredFlairMcp(homeDir) {
|
|
397
|
+
let wired = false;
|
|
398
|
+
let pinnedVersion = null;
|
|
399
|
+
// The package name only ever appears in a Flair MCP wiring block, so its
|
|
400
|
+
// presence in a config's text is a reliable "flair-mcp is wired here" signal.
|
|
401
|
+
const note = (text) => {
|
|
402
|
+
if (!text || !text.includes(FLAIR_MCP_PACKAGE))
|
|
403
|
+
return;
|
|
404
|
+
wired = true;
|
|
405
|
+
if (!pinnedVersion) {
|
|
406
|
+
const pin = extractFlairMcpPin(text);
|
|
407
|
+
if (pin)
|
|
408
|
+
pinnedVersion = pin;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
// 1. The SessionStart hook (claude-code). Establishes wiring; unpinned by design.
|
|
412
|
+
const hook = checkSessionStartHook(homeDir);
|
|
413
|
+
if (hook.present && isFlairHookCommand(hook.command ?? ""))
|
|
414
|
+
note(hook.command);
|
|
415
|
+
// 2. Every known client's MCP config — a wired flair block carries the spec.
|
|
416
|
+
for (const client of ALL_CLIENTS) {
|
|
417
|
+
const configPath = withHome(homeDir, () => clientConfigPath(client.id));
|
|
418
|
+
note(readTextFile(configPath));
|
|
419
|
+
}
|
|
420
|
+
return { wired, pinnedVersion };
|
|
421
|
+
}
|
|
368
422
|
/**
|
|
369
423
|
* Merge-safe insert of a Flair SessionStart hook group into
|
|
370
424
|
* ~/.claude/settings.json — creates the file/array if absent, preserves any
|
package/dist/install/clients.js
CHANGED
|
@@ -204,9 +204,16 @@ function replaceCodexFlairBlock(raw, env) {
|
|
|
204
204
|
* Creates the file (and parent dir) if absent; preserves existing servers and
|
|
205
205
|
* any other top-level keys. Returns ok:true only when the file was written.
|
|
206
206
|
*/
|
|
207
|
-
function wireJsonMcp(configPath, label, env
|
|
207
|
+
function wireJsonMcp(configPath, label, env,
|
|
208
|
+
// The parenthetical appended to a successful wire/refresh message. Defaults to
|
|
209
|
+
// the confident "restart <label> to pick it up". A client whose end-to-end
|
|
210
|
+
// pickup Flair has NOT verified (Antigravity — flair#1209) passes an honest
|
|
211
|
+
// note instead, so the message claims only what it did (wrote the config), not
|
|
212
|
+
// that the client will read it.
|
|
213
|
+
pickupNote) {
|
|
208
214
|
const home = resolveHome();
|
|
209
215
|
const display = configPath.startsWith(home) ? "~" + configPath.slice(home.length) : configPath;
|
|
216
|
+
const note = pickupNote ?? `restart ${label} to pick it up`;
|
|
210
217
|
try {
|
|
211
218
|
let config = {};
|
|
212
219
|
if (existsSync(configPath)) {
|
|
@@ -229,7 +236,7 @@ function wireJsonMcp(configPath, label, env) {
|
|
|
229
236
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
230
237
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
231
238
|
const action = urlAgentMatch ? "refreshed pin in" : "wired";
|
|
232
|
-
return { ok: true, message: `${label}: ${action} ${display} (
|
|
239
|
+
return { ok: true, message: `${label}: ${action} ${display} (${note})` };
|
|
233
240
|
}
|
|
234
241
|
catch (err) {
|
|
235
242
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -256,6 +263,23 @@ function geminiConfigPath() {
|
|
|
256
263
|
function codexConfigPath() {
|
|
257
264
|
return join(resolveHome(), ".codex", "config.toml");
|
|
258
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Antigravity CLI (`agy`) + Antigravity 2.0 IDE + SDK: they share ONE central
|
|
268
|
+
* MCP config at ~/.gemini/config/mcp_config.json on every OS (flair#1209).
|
|
269
|
+
*
|
|
270
|
+
* This is a SIBLING of, and distinct from, Gemini CLI's ~/.gemini/settings.json
|
|
271
|
+
* (geminiConfigPath above) — both tools live under ~/.gemini but read different
|
|
272
|
+
* files, so wiring one never touches the other. Same standard `mcpServers`
|
|
273
|
+
* stdio schema (command/args/env) the JSON clients above use.
|
|
274
|
+
*
|
|
275
|
+
* Path per Antigravity's own docs (antigravity.google/docs/mcp) and a Google
|
|
276
|
+
* Developer Advocate write-up (atamel.dev "Where does Antigravity look for MCP
|
|
277
|
+
* Servers?"). NOTE: the end-to-end wiring has NOT been verified against a real
|
|
278
|
+
* `agy` install — see the PR body.
|
|
279
|
+
*/
|
|
280
|
+
function antigravityConfigPath() {
|
|
281
|
+
return join(resolveHome(), ".gemini", "config", "mcp_config.json");
|
|
282
|
+
}
|
|
259
283
|
/**
|
|
260
284
|
* Single dispatcher for "where does this client's MCP config live" — used by
|
|
261
285
|
* `flair doctor`'s client-integration checks (flair#588) to read the config
|
|
@@ -272,6 +296,8 @@ export function clientConfigPath(id) {
|
|
|
272
296
|
return geminiConfigPath();
|
|
273
297
|
case "cursor":
|
|
274
298
|
return cursorConfigPath();
|
|
299
|
+
case "antigravity":
|
|
300
|
+
return antigravityConfigPath();
|
|
275
301
|
}
|
|
276
302
|
}
|
|
277
303
|
// ---- Internal wiring functions --------------------------------------------------
|
|
@@ -330,6 +356,18 @@ function _wireGemini(env) {
|
|
|
330
356
|
function _wireCursor(env) {
|
|
331
357
|
return wireJsonMcp(cursorConfigPath(), "Cursor", env);
|
|
332
358
|
}
|
|
359
|
+
// Antigravity uses the same standard JSON `mcpServers` stdio schema as Gemini/
|
|
360
|
+
// Cursor (command/args/env), so wireJsonMcp merges into it byte-identically —
|
|
361
|
+
// only the config PATH differs (flair#1209).
|
|
362
|
+
//
|
|
363
|
+
// The success message deliberately does NOT claim "restart Antigravity to pick
|
|
364
|
+
// it up": Flair writes the config to the documented path, but has not verified
|
|
365
|
+
// end-to-end that a live `agy` reads it. So the message claims only the write,
|
|
366
|
+
// and asks the user to confirm pickup (flair#1209 review — honesty on an
|
|
367
|
+
// unverified integration).
|
|
368
|
+
function _wireAntigravity(env) {
|
|
369
|
+
return wireJsonMcp(antigravityConfigPath(), "Antigravity", env, "wiring unverified against a real agy — restart Antigravity and confirm the flair tools appear");
|
|
370
|
+
}
|
|
333
371
|
// ---- Exported detection & wiring array ------------------------------------------
|
|
334
372
|
export const ALL_CLIENTS = [
|
|
335
373
|
{
|
|
@@ -356,6 +394,13 @@ export const ALL_CLIENTS = [
|
|
|
356
394
|
bin: "cursor",
|
|
357
395
|
wire: _wireCursor,
|
|
358
396
|
},
|
|
397
|
+
{
|
|
398
|
+
id: "antigravity",
|
|
399
|
+
label: "Antigravity",
|
|
400
|
+
// Google's Antigravity CLI — the executable is `agy` (flair#1209).
|
|
401
|
+
bin: "agy",
|
|
402
|
+
wire: _wireAntigravity,
|
|
403
|
+
},
|
|
359
404
|
];
|
|
360
405
|
/**
|
|
361
406
|
* The summary `flair init` prints LAST.
|
|
@@ -432,3 +477,6 @@ export function wireGemini(env) {
|
|
|
432
477
|
export function wireCursor(env) {
|
|
433
478
|
return _wireCursor(env);
|
|
434
479
|
}
|
|
480
|
+
export function wireAntigravity(env) {
|
|
481
|
+
return _wireAntigravity(env);
|
|
482
|
+
}
|