@tpsdev-ai/flair 0.44.8 → 0.44.9

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 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 === "@tpsdev-ai/flair-mcp"
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 });
@@ -9517,18 +9564,30 @@ program
9517
9564
  }
9518
9565
  catch { /* best-effort */ }
9519
9566
  }
9520
- const installed = probe();
9567
+ const globalProbe = probe();
9568
+ let installed;
9521
9569
  let status;
9522
- if (installed === null) {
9523
- // openclaw-plugin packages are optionalif openclaw isn't
9524
- // installed, don't surface a misleading "install with npm" advice.
9525
- status = kind === "openclaw-plugin" ? "optional" : "missing";
9526
- }
9527
- else if (installed === latest) {
9528
- status = "current";
9570
+ if (name === FLAIR_MCP_PACKAGE) {
9571
+ // flair-mcp is zero-install via npx (#1168) a null global probe is
9572
+ // the NORMAL state, not "missing". Resolve it from its actual wiring
9573
+ // (the pin in a client MCP config / the SessionStart hook) so the
9574
+ // listing is truthful and the remedy actually works (flair#1208).
9575
+ const home = process.env.HOME ?? homedir();
9576
+ ({ installed, status } = resolveFlairMcpFinding(globalProbe, latest, detectWiredFlairMcp(home)));
9529
9577
  }
9530
9578
  else {
9531
- status = "outdated";
9579
+ installed = globalProbe;
9580
+ if (installed === null) {
9581
+ // openclaw-plugin packages are optional — if openclaw isn't
9582
+ // installed, don't surface a misleading "install with npm" advice.
9583
+ status = kind === "openclaw-plugin" ? "optional" : "missing";
9584
+ }
9585
+ else if (installed === latest) {
9586
+ status = "current";
9587
+ }
9588
+ else {
9589
+ status = "outdated";
9590
+ }
9532
9591
  }
9533
9592
  findings.push({ name, installed, latest, status, kind });
9534
9593
  // Suppress the line for openclaw plugins that are optional-because-
@@ -9553,12 +9612,19 @@ program
9553
9612
  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
9613
  const outdated = findings.filter((f) => f.status === "outdated");
9555
9614
  const missing = findings.filter((f) => f.status === "missing");
9615
+ // flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
9616
+ // the post-upgrade pin refresh below), NEVER `npm install -g` — a global
9617
+ // bin does nothing for an `npx -y -p @tpsdev-ai/flair-mcp` invocation
9618
+ // (#1168/#1208). So a stale-pinned flair-mcp drives a remedy line, not the
9619
+ // npm-install + restart transaction. It is kept out of npmUpgrades here and
9620
+ // surfaced separately below.
9621
+ const flairMcpOutdated = outdated.find((f) => f.name === FLAIR_MCP_PACKAGE) ?? null;
9556
9622
  // openclaw plugins upgrade through `openclaw plugins install`, not `npm
9557
9623
  // install -g` (npm-installed wouldn't connect to OpenClaw's gateway slot).
9558
9624
  // Split outdated into npm-upgradeable vs openclaw-plugin so we can use
9559
9625
  // the right command for each.
9560
9626
  const npmUpgrades = outdated
9561
- .filter((f) => f.kind !== "openclaw-plugin")
9627
+ .filter((f) => f.kind !== "openclaw-plugin" && f.name !== FLAIR_MCP_PACKAGE)
9562
9628
  .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
9563
9629
  const openclawUpgrades = outdated
9564
9630
  .filter((f) => f.kind === "openclaw-plugin")
@@ -9568,15 +9634,26 @@ program
9568
9634
  console.log("\n✅ Everything is up to date.");
9569
9635
  return;
9570
9636
  }
9571
- if (missing.length > 0 && outdated.length === 0) {
9572
- const npmMissing = missing.filter((f) => f.name !== "@tpsdev-ai/flair-mcp");
9573
- const mcpMissing = missing.filter((f) => f.name === "@tpsdev-ai/flair-mcp");
9574
- console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
9575
- if (npmMissing.length > 0) {
9576
- console.log(` Install missing: npm install -g ${npmMissing.map((f) => f.name).join(" ")}`);
9637
+ // Nothing to install via npm/openclaw. What is left is advisory: packages
9638
+ // not detected (missing) and/or a flair-mcp whose wired pin is behind latest
9639
+ // both fixed by re-wiring (`flair doctor --fix`), never by the
9640
+ // npm-install + restart transaction below (#1168/#1208). Print the remedies
9641
+ // and stop.
9642
+ if (totalUpgrades === 0) {
9643
+ if (missing.length > 0) {
9644
+ const npmMissing = missing.filter((f) => f.name !== FLAIR_MCP_PACKAGE);
9645
+ const mcpMissing = missing.some((f) => f.name === FLAIR_MCP_PACKAGE);
9646
+ console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
9647
+ if (npmMissing.length > 0) {
9648
+ console.log(` Install missing: npm install -g ${npmMissing.map((f) => f.name).join(" ")}`);
9649
+ }
9650
+ if (mcpMissing) {
9651
+ console.log(` flair-mcp is zero-install via npx — run: flair doctor --fix to wire the hook`);
9652
+ }
9577
9653
  }
9578
- if (mcpMissing.length > 0) {
9579
- console.log(` flair-mcp is zero-install via npx run: flair doctor --fix to re-wire the hook`);
9654
+ if (flairMcpOutdated) {
9655
+ console.log(`\n⬆️ flair-mcp is wired via npx (pinned ${flairMcpOutdated.installed} latest ${flairMcpOutdated.latest}).`);
9656
+ console.log(` Re-pin it: flair doctor --fix`);
9580
9657
  }
9581
9658
  return;
9582
9659
  }
@@ -12154,7 +12231,8 @@ program
12154
12231
  const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
12155
12232
  client.id === "codex" ? wireCodex(wireEnv) :
12156
12233
  client.id === "gemini" ? wireGemini(wireEnv) :
12157
- wireCursor(wireEnv);
12234
+ client.id === "antigravity" ? wireAntigravity(wireEnv) :
12235
+ wireCursor(wireEnv);
12158
12236
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
12159
12237
  if (wireResult.ok)
12160
12238
  fixed++;
@@ -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
@@ -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} (restart ${label} to pick it up)` };
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
+ }
@@ -73,8 +73,8 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
73
73
  *
74
74
  * Response:
75
75
  * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable,
76
- * teammateFindingsIncluded, agentId, scope, soul, memories, predicted,
77
- * teammateFindings[, currentTaskHint][, predictedHint] }
76
+ * teammateFindingsIncluded, teammateFindingsTruncated, agentId, scope, soul,
77
+ * memories, predicted, teammateFindings, events[, currentTaskHint][, predictedHint] }
78
78
  * The self-describing keys (flair#1182 part 1) — `agentId` (resolved caller),
79
79
  * `scope` (read model applied to the caller), `soul`/`memories`/`predicted`
80
80
  * (the caller's OWN records as structured containers), and `currentTaskHint`
@@ -84,8 +84,24 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
84
84
  * mirror (opt-in via includeContext). Cross-agent teammate findings ship in
85
85
  * the `teammateFindings` container (own memories in `memories`), counted by
86
86
  * `teammateFindingsIncluded` (a separate denominator from `memoriesIncluded`,
87
- * which is own-scoped so it never exceeds `memoriesAvailable`). `tokenEstimate`
88
- * reflects the ACTUAL serialized payload, and `maxTokens` bounds it.
87
+ * which is own-scoped so it never exceeds `memoriesAvailable`). flair#1206 —
88
+ * org events ship in their OWN structured `events` container (ALWAYS present,
89
+ * `[]` when none), so a connector reading the structured payload gets them even
90
+ * when the prose `context` is off (the /mcp default); before #1206 they lived
91
+ * ONLY in the prose string and were orphaned at includeContext=false.
92
+ *
93
+ * flair#1199 CAP CONTRACT (corrected): `maxTokens` is the HARD cap on CONTENT
94
+ * SELECTION — the shared `tokenBudget` starts at `maxTokens` and every admitted
95
+ * soul/memory/finding line is gated against the remaining budget, so the sum of
96
+ * selected CONTENT never exceeds `maxTokens`. `tokenEstimate` HONESTLY reports
97
+ * the real serialized payload (`JSON.stringify(responseBody)`), which includes
98
+ * the structured-container JSON scaffolding and so MAY exceed `maxTokens` by
99
+ * that overhead — measurement (honest reporting) is deliberately decoupled from
100
+ * budgeting (what to select). This is the flair#1207 fix: #1199 had folded a
101
+ * per-item structured overhead + a scaffolding reserve INTO the selection
102
+ * budget, which silently shrank recall below 0.44.6 for the same `maxTokens`;
103
+ * the overhead is a reporting concern (already captured by `tokenEstimate`),
104
+ * never a selection constraint, so it no longer shrinks the content budget.
89
105
  * `predictedHint` is present only when subjects were provided but `predicted`
90
106
  * came back empty.
91
107
  */
@@ -123,17 +139,19 @@ const MAX_CANDIDATE_POOL = 100;
123
139
  // `minScore` request param) — preserved verbatim from the original raw
124
140
  // JS dot-product scan's `.filter((s) => s.score > 0.3)`.
125
141
  const TASK_RELEVANCE_FLOOR = 0.3;
126
- // flair#1199 — per-memory structured-payload overhead charged against the token
127
- // budget IN ADDITION to the rendered prose line. Each included memory ships as a
128
- // structured object ({id, content, durability, createdAt, updatedAt, agentId,
129
- // subject, section, ...}) which is the CANONICAL payload; its JSON keys cost
130
- // ~30-40 tokens beyond the prose line the fill loop measures. Charging it here
131
- // keeps the ACTUAL serialized payload (measured by tokenEstimate) within
132
- // maxTokens the prose line alone under-charged, so the structured containers
133
- // crossed the cap. Sized to the non-content JSON a lean memory carries (id +
134
- // createdAt + updatedAt + agentId + durability + subject + section + key names,
135
- // ~55-70 tokens); conservative (errs slightly high, never low).
136
- const STRUCT_ITEM_OVERHEAD_TOKENS = 70;
142
+ // flair#1207the per-item structured-payload overhead that #1199 charged
143
+ // against the content-selection budget (a `+ STRUCT_ITEM_OVERHEAD_TOKENS = 70`
144
+ // added to every item's cost, PLUS a `structOverheadReserve` pre-deducted from
145
+ // the starting budget) has been REMOVED. It conflated measurement with
146
+ // budgeting: `tokenEstimate` already measures the real serialized payload
147
+ // (JSON.stringify(responseBody)) the structured JSON scaffolding overhead is
148
+ // captured there, honestly. Folding it into the SELECTION budget too
149
+ // double-penalized and silently shrank recall below 0.44.6 for the same
150
+ // `maxTokens` (6 findings 3). The content budget is now `maxTokens` again
151
+ // (0.44.6 selection capacity), and each item's cost is just the rendered prose
152
+ // line while `tokenEstimate` keeps reporting the true serialized size, which
153
+ // may exceed `maxTokens` by the scaffolding overhead. See the module-doc CAP
154
+ // CONTRACT above.
137
155
  // Rough token estimate: ~4 chars per token for English text
138
156
  function estimateTokens(text) {
139
157
  return Math.ceil(text.length / 4);
@@ -222,19 +240,22 @@ export class BootstrapMemories extends Resource {
222
240
  collision: [],
223
241
  events: [],
224
242
  };
225
- // flair#1199 — the structured containers (soul/memories/predicted/
226
- // teammateFindings) are the CANONICAL payload, and `tokenEstimate` now
227
- // reports the ACTUAL serialized bytes. Reserve headroom for the JSON
228
- // scaffolding those containers and the self-describing keys (scope/sections/
229
- // counters) add on top of the raw content, so `maxTokens` bounds the real
230
- // payload not just the prose. Without this the containers ship on top of
231
- // the memory-line budget and blow the cap (the reported 4000→4275+ overrun).
232
- const structOverheadReserve = Math.min(600, Math.floor(maxTokens * 0.15));
243
+ // flair#1207 — the content-SELECTION budget is `maxTokens`, matching 0.44.6
244
+ // capacity. #1199 pre-deducted a `structOverheadReserve` (min(600, 15% of
245
+ // maxTokens)) here to "reserve headroom" for the structured-container JSON
246
+ // scaffolding, on the theory that `maxTokens` should bound the serialized
247
+ // payload. That silently shrank the content budget and, combined with the
248
+ // per-item overhead (also removed), cut recall below 0.44.6 for the same
249
+ // `maxTokens` (#1207). The reserve is gone: `maxTokens` is the HARD cap on
250
+ // CONTENT SELECTION only. `tokenEstimate` (below) still reports the real
251
+ // serialized size honestly — which may exceed `maxTokens` by the scaffolding
252
+ // overhead, exactly as 0.44.6's payload did (0.44.6 just under-measured it).
233
253
  // Single shared budget across soul + every memory section (soul used to be
234
254
  // budgeted SEPARATELY and added ON TOP, so context alone could reach
235
- // 1.4×maxTokens; #1199). Content selected therefore stays within
236
- // maxTokens reserve, and the serialized payload within maxTokens.
237
- let tokenBudget = Math.max(0, maxTokens - structOverheadReserve);
255
+ // 1.4×maxTokens; #1199 folded soul into this shared budget — that part
256
+ // stays). Every admitted line is gated against the remaining budget, so the
257
+ // sum of selected CONTENT never exceeds `maxTokens`.
258
+ let tokenBudget = Math.max(0, maxTokens);
238
259
  // Own memories included in the payload (permanent + recent + predicted +
239
260
  // own task-relevant). Denominator is `memoriesAvailable` (own-scoped), so
240
261
  // memoriesIncluded ≤ memoriesAvailable always holds (#1199 coherent
@@ -246,6 +267,13 @@ export class BootstrapMemories extends Resource {
246
267
  // denominator than own memories): counting these into `memoriesIncluded`
247
268
  // is what let one client see included(9) > available(3).
248
269
  let teammateFindingsIncluded = 0;
270
+ // flair#1207 — teammate findings SKIPPED for size in the task-relevant
271
+ // packing loop. That loop `continue`s past an over-budget record silently;
272
+ // without a counter, a client can't tell "no relevant teammate finding" from
273
+ // "a relevant one existed but didn't fit the budget" (Sherlock's #1207
274
+ // self-describing-size-skip note; own-memory size-skips already increment
275
+ // `memoriesTruncated`, but that loop never did — now both do).
276
+ let teammateFindingsTruncated = 0;
249
277
  // flair#1182 (part 1) — self-describing bootstrap. These structured
250
278
  // container keys are ALWAYS emitted on the response (empty `{}`/`[]` when
251
279
  // the caller has nothing), so a client can tell an *empty* instance from
@@ -267,6 +295,17 @@ export class BootstrapMemories extends Resource {
267
295
  // them. Kept SEPARATE from `memories`/`predicted` (which stay own-only per
268
296
  // the #1182 boundary) and clearly attributed via `source`.
269
297
  const includedTeammateFindings = [];
298
+ // flair#1206 — org events get their OWN structured container. Before #1206
299
+ // they lived ONLY in the prose `context` string ("## Recent Org Events"),
300
+ // so at includeContext=false (the /mcp default) they were counted in
301
+ // `sections.events` and measured into `tokenEstimate` (when prose was on) but
302
+ // NEVER delivered in any field a connector could read — orphaned. Populated
303
+ // from the SAME deduped+sliced set the prose lines are (so count, charge and
304
+ // delivery all key off one thing), ALWAYS emitted (`[]` when none), and the
305
+ // targetIds relevance filter is already applied upstream (see the OrgEvent
306
+ // read below). Declared out here so it is in scope for the response body even
307
+ // if the events read (in a try/catch) yields nothing.
308
+ const includedEvents = [];
270
309
  const leanMemory = (m, section) => ({
271
310
  id: m.id,
272
311
  content: m.content,
@@ -528,7 +567,7 @@ export class BootstrapMemories extends Resource {
528
567
  const permanent = permanentRows.filter((m) => !permanentSupersededIds.has(m.id));
529
568
  for (const m of permanent) {
530
569
  const line = formatMemory(m, agentId);
531
- const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
570
+ const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
532
571
  if (cost <= tokenBudget) {
533
572
  sections.permanent.push(line);
534
573
  includedOwnMemories.push(leanMemory(m, "permanent"));
@@ -600,7 +639,7 @@ export class BootstrapMemories extends Resource {
600
639
  let recentSpent = 0;
601
640
  for (const m of recent) {
602
641
  const line = formatMemory(m, agentId);
603
- const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
642
+ const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
604
643
  if (recentSpent + cost > recentBudget) {
605
644
  memoriesTruncated++;
606
645
  continue;
@@ -643,7 +682,7 @@ export class BootstrapMemories extends Resource {
643
682
  let predictedSpent = 0;
644
683
  for (const m of subjectMemories) {
645
684
  const line = formatMemory(m, agentId);
646
- const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
685
+ const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
647
686
  if (predictedSpent + cost > predictedBudget) {
648
687
  memoriesTruncated++;
649
688
  continue;
@@ -818,9 +857,19 @@ export class BootstrapMemories extends Resource {
818
857
  // section double-spends.
819
858
  for (const { memory: m } of scored) {
820
859
  const line = formatMemory(m, agentId);
821
- const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
822
- if (cost > tokenBudget)
860
+ const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
861
+ if (cost > tokenBudget) {
862
+ // flair#1207 — a size-skip in the score-ordered task-relevant loop
863
+ // is no longer silent: record it on the counter matching the record's
864
+ // denominator (own → memoriesTruncated, teammate → the separate
865
+ // teammateFindingsTruncated), so a client can distinguish "no relevant
866
+ // finding" from "a relevant finding didn't fit the budget".
867
+ if (m._source)
868
+ teammateFindingsTruncated++;
869
+ else
870
+ memoriesTruncated++;
823
871
  continue;
872
+ }
824
873
  if (m._source) {
825
874
  sections.teammate.push(line);
826
875
  // flair#1199 — cross-agent teammate findings join their OWN
@@ -1030,6 +1079,24 @@ export class BootstrapMemories extends Resource {
1030
1079
  const mins = Math.floor(elapsed / 60_000);
1031
1080
  const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
1032
1081
  sections.events.push(`- ${evt.kind}: ${evt.summary} (${relTime})`);
1082
+ // flair#1206 — the SAME deduped+sliced event, structured, so a connector
1083
+ // reading the containers gets it when prose `context` is off (the /mcp
1084
+ // default). Same set as the prose line ⇒ `sections.events` (the count),
1085
+ // the `tokenEstimate` charge (this array is always in the body), and the
1086
+ // delivery all key off one thing. Optional fields (detail/targetIds/scope)
1087
+ // are omitted when absent so the object stays lean. The targetIds
1088
+ // relevance filter and #1200 content-signature dedup were already applied
1089
+ // upstream (eventResults → eventBySignature), so this is a pure move from
1090
+ // prose to structured — no scope widening, no re-introduced duplicates.
1091
+ includedEvents.push({
1092
+ id: evt.id,
1093
+ kind: evt.kind,
1094
+ summary: evt.summary,
1095
+ ...(evt.detail != null ? { detail: evt.detail } : {}),
1096
+ ...(Array.isArray(evt.targetIds) && evt.targetIds.length > 0 ? { targetIds: evt.targetIds } : {}),
1097
+ createdAt: evt.createdAt ?? null,
1098
+ ...(evt.scope != null ? { scope: evt.scope } : {}),
1099
+ });
1033
1100
  }
1034
1101
  }
1035
1102
  catch {
@@ -1165,6 +1232,13 @@ export class BootstrapMemories extends Resource {
1165
1232
  // (always present, `[]` when none), so a connector that consumes the
1166
1233
  // containers still gets them when prose `context` is off.
1167
1234
  teammateFindings: includedTeammateFindings,
1235
+ // flair#1206 — org events as a structured container (always present, `[]`
1236
+ // when none, same self-describing-empty-state pattern as the containers
1237
+ // above). Before #1206 events lived ONLY in the prose `context`, so at the
1238
+ // /mcp default (includeContext=false) they were counted+measured but never
1239
+ // delivered. Deduped (#1200) and targetIds-scoped (same set as the prose
1240
+ // "## Recent Org Events" lines), so count/charge/delivery all agree.
1241
+ events: includedEvents,
1168
1242
  ...(currentTaskHint ? { currentTaskHint } : {}),
1169
1243
  ...(predictedHint ? { predictedHint } : {}),
1170
1244
  ...(trust ? { trust } : {}),
@@ -1192,14 +1266,25 @@ export class BootstrapMemories extends Resource {
1192
1266
  // so it's never confused with the own-memory counters.
1193
1267
  teammateFindingsIncluded,
1194
1268
  memoriesTruncated,
1269
+ // flair#1207 — teammate findings skipped for size in the task-relevant loop
1270
+ // (own size-skips there increment memoriesTruncated). Surfacing this makes
1271
+ // a size-skip self-describing: "a relevant teammate finding didn't fit"
1272
+ // is now distinguishable from "no relevant teammate finding".
1273
+ teammateFindingsTruncated,
1195
1274
  };
1196
1275
  // flair#1199 — tokenEstimate must reflect the ACTUAL serialized payload the
1197
1276
  // caller receives (the structured containers included), not just the prose
1198
1277
  // `context`. The old `soulTokens + memoryTokens` counted only the context
1199
1278
  // string, so it under-reported by ~2× once the structured fields shipped
1200
- // alongside the reported "maxTokens 4000 tokenEstimate 4275 while the
1201
- // real payload was well over the cap". Measured over the assembled body
1202
- // (the ~1-line tokenEstimate field it omits is negligible).
1279
+ // alongside. Measured over the assembled body (the ~1-line tokenEstimate
1280
+ // field it omits is negligible). flair#1207 CAP CONTRACT: this is an HONEST
1281
+ // report of the real serialized size, NOT a value bounded by `maxTokens`.
1282
+ // `maxTokens` is the hard cap on CONTENT SELECTION (the shared tokenBudget);
1283
+ // the structured-container JSON scaffolding is genuine payload the caller
1284
+ // pays for, so tokenEstimate MAY exceed `maxTokens` by that overhead. Do not
1285
+ // "fix" an over-maxTokens tokenEstimate by shrinking selection — that is the
1286
+ // exact #1199→#1207 regression (it dropped relevant findings). If the real
1287
+ // payload consistently overruns for a use case, raise `maxTokens`.
1203
1288
  const tokenEstimate = estimateTokens(JSON.stringify(responseBody));
1204
1289
  return { ...responseBody, tokenEstimate };
1205
1290
  }
@@ -631,7 +631,7 @@ export const TOOLS = {
631
631
  inputSchema: {
632
632
  type: "object",
633
633
  properties: {
634
- maxTokens: { type: "number", description: "Max tokens in output (default 4000)" },
634
+ maxTokens: { type: "number", description: "Content-selection budget in tokens (default 4000): the hard cap on how much soul/memory/finding CONTENT is selected. The actual serialized response (reported by tokenEstimate) may exceed this by the structured-container JSON scaffolding — maxTokens bounds what is selected, not the raw output size. Raise it to include more content." },
635
635
  currentTask: { type: "string", description: "Current task — enables semantic search for relevant memories" },
636
636
  channel: { type: "string", description: "Channel name (discord, tps-mail, claude-code)" },
637
637
  surface: { type: "string", description: "Surface name (tps-build, tps-review, cli-session)" },
@@ -151,13 +151,21 @@ export function buildTrustBlock(record, now = Date.now()) {
151
151
  else if (Number.isFinite(validFromMs) && validFromMs > now) {
152
152
  validityStatus = "future";
153
153
  }
154
- // #1201 — freshness keys off the record's OWN last-write time (updatedAt),
155
- // falling back to createdAt. A record updated today must not read as stale
156
- // off its original createdAt. updatedAt is the record's own field, so this
157
- // reintroduces no lineage-inheritance (#1189).
158
- const freshnessMs = parseTime(updatedAt ?? createdAt);
159
- const ageDays = Number.isFinite(freshnessMs)
160
- ? Math.max(0, Math.floor((now - freshnessMs) / MS_PER_DAY))
154
+ // flair#1201 (refined) carry BOTH temporal signals rather than collapsing
155
+ // to one. `ageDays` is TRUE AGE (days since `createdAt`, fallback updatedAt);
156
+ // `staleDays` is FRESHNESS (days since `updatedAt`, fallback createdAt). The
157
+ // first #1201 pass keyed ageDays off updatedAt only, which overcorrected — a
158
+ // record created weeks ago but edited today then read as "0 days old", losing
159
+ // its true age. Both are the record's OWN fields (never a superseded
160
+ // predecessor's — #1189), so neither reintroduces lineage-inheritance. For a
161
+ // never-updated record updatedAt == createdAt, so ageDays == staleDays.
162
+ const createdMs = parseTime(createdAt ?? updatedAt);
163
+ const ageDays = Number.isFinite(createdMs)
164
+ ? Math.max(0, Math.floor((now - createdMs) / MS_PER_DAY))
165
+ : null;
166
+ const updatedMs = parseTime(updatedAt ?? createdAt);
167
+ const staleDays = Number.isFinite(updatedMs)
168
+ ? Math.max(0, Math.floor((now - updatedMs) / MS_PER_DAY))
161
169
  : null;
162
170
  return {
163
171
  author: typeof record.agentId === "string" ? record.agentId : null,
@@ -172,6 +180,7 @@ export function buildTrustBlock(record, now = Date.now()) {
172
180
  createdAt,
173
181
  updatedAt,
174
182
  ageDays,
183
+ staleDays,
175
184
  supersedes: typeof record.supersedes === "string" ? record.supersedes : null,
176
185
  // flair#744 refinement — confidence band from the result's absolute
177
186
  // similarity (null when there is no signal to judge). Pure, global,
@@ -15,6 +15,7 @@ Where Flair already runs. Each integration shown here is a working surface — t
15
15
  | **Continue.dev** | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Standard MCP server |
16
16
  | **OpenAI Codex CLI** | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Standard MCP server |
17
17
  | **Gemini CLI** | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Standard MCP server |
18
+ | **Antigravity CLI** (`agy`) | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | `~/.gemini/config/mcp_config.json`; pickup by a live `agy` pending verification |
18
19
  | **Goose** (block/goose) | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Goose ships native MCP support |
19
20
  | **LangGraph (TS)** | [`langgraph-flair`](#langgraph-typescript) | FlairClient | Drop-in `BaseStore` |
20
21
  | **OpenClaw** | [`openclaw-flair`](#openclaw) | Ed25519 | Native plugin + context engine |
@@ -69,6 +70,8 @@ FLAIR_AGENT_ID = "codex"
69
70
 
70
71
  **Gemini CLI** (`~/.gemini/settings.json`): same shape as Cursor.
71
72
 
73
+ **Antigravity CLI** (`agy`) (`~/.gemini/config/mcp_config.json` — Antigravity's own MCP config, separate from Gemini CLI's `settings.json`): same shape as Cursor. Newly added; the config path follows Antigravity's documentation, but Flair has not yet verified end-to-end pickup by a live `agy` — after wiring, restart Antigravity and confirm the flair tools appear.
74
+
72
75
  **Continue.dev** (`~/.continue/config.json`):
73
76
  ```json
74
77
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.44.8",
3
+ "version": "0.44.9",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",