@tpsdev-ai/flair 0.44.7 → 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++;
@@ -14328,7 +14406,13 @@ program
14328
14406
  console.error(`${render.icons.error} No context available.`);
14329
14407
  process.exit(1);
14330
14408
  }
14331
- const tokensUsed = result.tokenEstimate ?? 0;
14409
+ // flair#1199 the budget footer reflects the PROSE the human injects
14410
+ // (stdout = result.context), not the full serialized payload. tokenEstimate
14411
+ // now measures the whole response (structured containers + prose), which
14412
+ // the CLI's structured fields the human doesn't read would inflate.
14413
+ const tokensUsed = typeof result.context === "string" && result.context.length > 0
14414
+ ? Math.ceil(result.context.length / 4)
14415
+ : (result.tokenEstimate ?? 0);
14332
14416
  const maxTokens = parseInt(opts.maxTokens, 10);
14333
14417
  const included = result.memoriesIncluded ?? 0;
14334
14418
  const truncated = result.memoriesTruncated ?? 0;
@@ -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
+ }
@@ -59,7 +59,11 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
59
59
  *
60
60
  * Request:
61
61
  * { agentId, currentTask?, maxTokens?, includeSoul?, since?,
62
- * channel?, surface?, subjects?, entities? }
62
+ * channel?, surface?, subjects?, entities?, includeContext? }
63
+ * `includeContext` (flair#1199): whether to assemble the prose `context`
64
+ * mirror. Default true here (the resource/REST/CLI path); the /mcp bootstrap
65
+ * wrapper passes false so a token-budgeted connector — which reads the
66
+ * structured containers — never receives the same bodies twice.
63
67
  * `entities` (flair#681): the caller's own declared attention-plane
64
68
  * vocabulary strings (see resources/entity-vocab.ts) for collision
65
69
  * surfacing's entity-overlap join. Invalid entries are silently dropped
@@ -69,12 +73,37 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
69
73
  *
70
74
  * Response:
71
75
  * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable,
72
- * agentId, scope, soul, memories, predicted[, currentTaskHint] }
76
+ * teammateFindingsIncluded, teammateFindingsTruncated, agentId, scope, soul,
77
+ * memories, predicted, teammateFindings, events[, currentTaskHint][, predictedHint] }
73
78
  * The self-describing keys (flair#1182 part 1) — `agentId` (resolved caller),
74
79
  * `scope` (read model applied to the caller), `soul`/`memories`/`predicted`
75
80
  * (the caller's OWN records as structured containers), and `currentTaskHint`
76
81
  * (present only when currentTask is absent/blank) — are ALWAYS emitted so a
77
82
  * client can tell an empty instance from one that doesn't support them.
83
+ * flair#1199 — the structured containers are CANONICAL; `context` is a prose
84
+ * mirror (opt-in via includeContext). Cross-agent teammate findings ship in
85
+ * the `teammateFindings` container (own memories in `memories`), counted by
86
+ * `teammateFindingsIncluded` (a separate denominator from `memoriesIncluded`,
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.
105
+ * `predictedHint` is present only when subjects were provided but `predicted`
106
+ * came back empty.
78
107
  */
79
108
  // Collision surfacing (flair#681) tunables.
80
109
  const COLLISION_WINDOW_DAYS = 7;
@@ -110,6 +139,19 @@ const MAX_CANDIDATE_POOL = 100;
110
139
  // `minScore` request param) — preserved verbatim from the original raw
111
140
  // JS dot-product scan's `.filter((s) => s.score > 0.3)`.
112
141
  const TASK_RELEVANCE_FLOOR = 0.3;
142
+ // flair#1207 — the 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.
113
155
  // Rough token estimate: ~4 chars per token for English text
114
156
  function estimateTokens(text) {
115
157
  return Math.ceil(text.length / 4);
@@ -151,7 +193,16 @@ export class BootstrapMemories extends Resource {
151
193
  subjects, // e.g., ["flair", "auth"] — entities to preload context for
152
194
  includeTrust = false, // flair#744 slice 1 — opt-in per-memory trust block
153
195
  abstain = false, // flair#744 slice 2 — opt-in task-relevance abstention
154
- } = data || {};
196
+ // flair#1199 whether to assemble the prose `context` string. The
197
+ // structured containers (soul/memories/predicted/teammateFindings) are the
198
+ // CANONICAL payload; `context` is a human/agent-readable MIRROR of the same
199
+ // bytes. Default TRUE here (the resource/REST/CLI path has always emitted
200
+ // prose, and every direct caller reads it) — but the /mcp bootstrap wrapper
201
+ // (resources/mcp-tools.ts) passes `false` by default, so a token-budgeted
202
+ // connector, which consumes the structured fields, never receives the same
203
+ // bodies twice. When false, `context` is a compact structural pointer (no
204
+ // bodies), so nothing crosses the wire twice on that path.
205
+ includeContext = true, } = data || {};
155
206
  // Authenticated identity lives on getContext().request — `this.request` is
156
207
  // NOT populated on Harper v5 Resources. Reading it returned undefined and
157
208
  // the scope check was silently bypassed, letting a non-admin agent read
@@ -189,27 +240,80 @@ export class BootstrapMemories extends Resource {
189
240
  collision: [],
190
241
  events: [],
191
242
  };
192
- let tokenBudget = maxTokens;
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).
253
+ // Single shared budget across soul + every memory section (soul used to be
254
+ // budgeted SEPARATELY and added ON TOP, so context alone could reach
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);
259
+ // Own memories included in the payload (permanent + recent + predicted +
260
+ // own task-relevant). Denominator is `memoriesAvailable` (own-scoped), so
261
+ // memoriesIncluded ≤ memoriesAvailable always holds (#1199 coherent
262
+ // counters). Cross-agent teammate findings are counted separately below.
193
263
  let memoriesIncluded = 0;
194
264
  let memoriesAvailable = 0;
195
265
  let memoriesTruncated = 0;
266
+ // flair#1199 — cross-agent teammate findings included (a DIFFERENT
267
+ // denominator than own memories): counting these into `memoriesIncluded`
268
+ // is what let one client see included(9) > available(3).
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;
196
277
  // flair#1182 (part 1) — self-describing bootstrap. These structured
197
278
  // container keys are ALWAYS emitted on the response (empty `{}`/`[]` when
198
279
  // the caller has nothing), so a client can tell an *empty* instance from
199
280
  // one that doesn't support these keys at all — and can read the caller's
200
281
  // own soul/memories as structured data instead of parsing the `context`
201
- // markdown string. Scoped to the CALLER'S OWN records only
202
- // (permanent/recent/relevant/predicted are all agentId==self reads);
203
- // teammate findings stay in `context`/`sections.teammate` and are never
204
- // duplicated here, so these containers carry no other agent's data.
282
+ // markdown string. `soul`/`memories`/`predicted` are scoped to the CALLER'S
283
+ // OWN records only (permanent/recent/relevant/predicted are all agentId==self
284
+ // reads). flair#1199 cross-agent teammate findings now have their OWN
285
+ // structured container (`teammateFindings`, below), attributed via `source`,
286
+ // rather than living only in the prose `context` (which is opt-in as of
287
+ // #1199); the own-only containers still carry no other agent's data.
205
288
  const soulMap = {};
206
289
  const includedOwnMemories = [];
207
290
  const includedPredicted = [];
291
+ // flair#1199 — teammate (cross-agent) findings get their OWN structured
292
+ // container. Before #1199 they lived ONLY in the prose `context`; now that
293
+ // `context` is an opt-in mirror (default off on the /mcp path), they need a
294
+ // structured home so a connector that consumes the containers still sees
295
+ // them. Kept SEPARATE from `memories`/`predicted` (which stay own-only per
296
+ // the #1182 boundary) and clearly attributed via `source`.
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 = [];
208
309
  const leanMemory = (m, section) => ({
209
310
  id: m.id,
210
311
  content: m.content,
211
312
  durability: m.durability ?? null,
212
313
  createdAt: m.createdAt ?? null,
314
+ // #1201 — the record's own last-write time, so a structured consumer can
315
+ // compute freshness off the same anchor the trust block's ageDays uses.
316
+ updatedAt: m.updatedAt ?? null,
213
317
  agentId: m.agentId ?? agentId,
214
318
  subject: m.subject ?? null,
215
319
  section,
@@ -257,12 +361,15 @@ export class BootstrapMemories extends Resource {
257
361
  if (maxChars > 100) {
258
362
  const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
259
363
  sections.soul.push(truncated);
260
- soulTokens += estimateTokens(truncated);
364
+ const cost = estimateTokens(truncated);
365
+ soulTokens += cost;
366
+ tokenBudget -= cost; // #1199 — soul draws from the shared budget
261
367
  }
262
368
  continue;
263
369
  }
264
370
  sections.soul.push(entry.line);
265
371
  soulTokens += entry.tokens;
372
+ tokenBudget -= entry.tokens; // #1199 — soul draws from the shared budget
266
373
  }
267
374
  }
268
375
  // --- 1b. Skill assignments (ordered by priority, conflict detection) ---
@@ -388,14 +495,23 @@ export class BootstrapMemories extends Resource {
388
495
  // requested, so a non-trust bootstrap fetches (and returns) exactly what it
389
496
  // did before. These records are never returned raw when the block is off,
390
497
  // so widening the select cannot change the off-path response bytes.
498
+ // #1201 — `updatedAt` is projected on BOTH paths (not just the trust path):
499
+ // the structured `memories`/`predicted` containers carry it so a consumer
500
+ // can compute freshness, and the trust block's ageDays keys off it.
391
501
  const OWN_SELECT = includeTrust
392
- ? ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags", "provenance", "usageCount", "validFrom"]
393
- : ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags"];
502
+ ? ["id", "agentId", "content", "durability", "createdAt", "updatedAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags", "provenance", "usageCount", "validFrom"]
503
+ : ["id", "agentId", "content", "durability", "createdAt", "updatedAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags"];
394
504
  // flair#744 slice 1 — the Memory records that became visible lines in the
395
505
  // memory-bearing sections (permanent/recent/predicted/relevant/teammate),
396
506
  // collected as they're added so the opt-in `trust` array below can carry a
397
507
  // self-contained block per included memory. Stays empty (and unused) when
398
- // includeTrust is off.
508
+ // includeTrust is off. flair#1201 — each entry carries the SECTION it landed
509
+ // in, so a trust entry's `matchQuality` is legible: null on a lifecycle
510
+ // section (permanent/recent/predicted — not a retrieval surface) reads as
511
+ // "not scored", not as a scoring failure, and a band on a retrieval section
512
+ // (relevant/teammate) is applied by the SAME rule to own and teammate
513
+ // records. Fixes the "own recent → null while teammate → strong looks like
514
+ // my own records scored worse" misread.
399
515
  const includedTrustMemories = [];
400
516
  // flair#744 slice 2 — the best absolute semantic similarity seen while
401
517
  // scoring the task-relevant candidate pool (section 4). Drives the opt-in
@@ -451,12 +567,12 @@ export class BootstrapMemories extends Resource {
451
567
  const permanent = permanentRows.filter((m) => !permanentSupersededIds.has(m.id));
452
568
  for (const m of permanent) {
453
569
  const line = formatMemory(m, agentId);
454
- const cost = estimateTokens(line);
570
+ const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
455
571
  if (cost <= tokenBudget) {
456
572
  sections.permanent.push(line);
457
573
  includedOwnMemories.push(leanMemory(m, "permanent"));
458
574
  if (includeTrust)
459
- includedTrustMemories.push(m);
575
+ includedTrustMemories.push({ m, section: "permanent" });
460
576
  tokenBudget -= cost;
461
577
  memoriesIncluded++;
462
578
  }
@@ -523,7 +639,7 @@ export class BootstrapMemories extends Resource {
523
639
  let recentSpent = 0;
524
640
  for (const m of recent) {
525
641
  const line = formatMemory(m, agentId);
526
- const cost = estimateTokens(line);
642
+ const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
527
643
  if (recentSpent + cost > recentBudget) {
528
644
  memoriesTruncated++;
529
645
  continue;
@@ -531,7 +647,7 @@ export class BootstrapMemories extends Resource {
531
647
  sections.recent.push(line);
532
648
  includedOwnMemories.push(leanMemory(m, "recent"));
533
649
  if (includeTrust)
534
- includedTrustMemories.push(m);
650
+ includedTrustMemories.push({ m, section: "recent" });
535
651
  recentSpent += cost;
536
652
  tokenBudget -= cost;
537
653
  memoriesIncluded++;
@@ -566,7 +682,7 @@ export class BootstrapMemories extends Resource {
566
682
  let predictedSpent = 0;
567
683
  for (const m of subjectMemories) {
568
684
  const line = formatMemory(m, agentId);
569
- const cost = estimateTokens(line);
685
+ const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
570
686
  if (predictedSpent + cost > predictedBudget) {
571
687
  memoriesTruncated++;
572
688
  continue;
@@ -574,7 +690,7 @@ export class BootstrapMemories extends Resource {
574
690
  sections.predicted.push(line);
575
691
  includedPredicted.push(leanMemory(m, "predicted"));
576
692
  if (includeTrust)
577
- includedTrustMemories.push(m);
693
+ includedTrustMemories.push({ m, section: "predicted" });
578
694
  predictedSpent += cost;
579
695
  tokenBudget -= cost;
580
696
  memoriesIncluded++;
@@ -741,22 +857,52 @@ export class BootstrapMemories extends Resource {
741
857
  // section double-spends.
742
858
  for (const { memory: m } of scored) {
743
859
  const line = formatMemory(m, agentId);
744
- const cost = estimateTokens(line);
745
- 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++;
746
871
  continue;
872
+ }
747
873
  if (m._source) {
748
874
  sections.teammate.push(line);
875
+ // flair#1199 — cross-agent teammate findings join their OWN
876
+ // structured container (attributed via `source`), so a connector
877
+ // consuming the containers still sees them when prose `context` is
878
+ // off. Counted separately (teammateFindingsIncluded), NOT into
879
+ // memoriesIncluded — that different-denominator mix is what let
880
+ // included exceed available.
881
+ includedTeammateFindings.push({
882
+ id: m.id,
883
+ content: m.content,
884
+ durability: m.durability ?? null,
885
+ createdAt: m.createdAt ?? null,
886
+ updatedAt: m.updatedAt ?? null,
887
+ subject: m.subject ?? null,
888
+ source: m._source,
889
+ section: "teammate",
890
+ });
891
+ if (includeTrust)
892
+ includedTrustMemories.push({ m, section: "teammate" });
893
+ tokenBudget -= cost;
894
+ teammateFindingsIncluded++;
749
895
  }
750
896
  else {
751
897
  sections.relevant.push(line);
752
898
  // flair#1182 — own task-relevant records join the `memories`
753
- // container; teammate (`_source`) records stay in `context` only.
899
+ // container.
754
900
  includedOwnMemories.push(leanMemory(m, "relevant"));
901
+ if (includeTrust)
902
+ includedTrustMemories.push({ m, section: "relevant" });
903
+ tokenBudget -= cost;
904
+ memoriesIncluded++;
755
905
  }
756
- if (includeTrust)
757
- includedTrustMemories.push(m);
758
- tokenBudget -= cost;
759
- memoriesIncluded++;
760
906
  }
761
907
  }
762
908
  }
@@ -905,12 +1051,52 @@ export class BootstrapMemories extends Resource {
905
1051
  continue;
906
1052
  eventResults.push(event);
907
1053
  }
908
- eventResults.sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
909
- for (const evt of eventResults.slice(0, 10)) {
1054
+ // flair#1200 collapse byte-identical duplicate events before rendering.
1055
+ // The same logical event can land in the table more than once (a producer
1056
+ // that double-fires, or the same broadcast emitted from two paths); each
1057
+ // physical row has a distinct id/createdAt (OrgEvent.post keys the id off
1058
+ // a millisecond timestamp), so they aren't caught by primary-key upsert
1059
+ // and render as exact dupes. Org-event slots are scarce (10), so dedup
1060
+ // BEFORE the slice — otherwise ~half the slots are wasted on duplicates.
1061
+ // Keyed on the CONTENT (kind + summary + detail + targets), keeping the
1062
+ // most-recent occurrence per signature.
1063
+ const eventBySignature = new Map();
1064
+ for (const evt of eventResults) {
1065
+ const sig = JSON.stringify([
1066
+ evt.kind ?? "",
1067
+ evt.summary ?? "",
1068
+ evt.detail ?? "",
1069
+ Array.isArray(evt.targetIds) ? [...evt.targetIds].sort() : (evt.targetIds ?? null),
1070
+ ]);
1071
+ const prev = eventBySignature.get(sig);
1072
+ if (!prev || (evt.createdAt || "") > (prev.createdAt || ""))
1073
+ eventBySignature.set(sig, evt);
1074
+ }
1075
+ const dedupedEvents = [...eventBySignature.values()]
1076
+ .sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
1077
+ for (const evt of dedupedEvents.slice(0, 10)) {
910
1078
  const elapsed = Date.now() - new Date(evt.createdAt).getTime();
911
1079
  const mins = Math.floor(elapsed / 60_000);
912
1080
  const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
913
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
+ });
914
1100
  }
915
1101
  }
916
1102
  catch {
@@ -959,9 +1145,27 @@ export class BootstrapMemories extends Resource {
959
1145
  if (sections.events.length > 0) {
960
1146
  parts.push("## Recent Org Events\n" + sections.events.join("\n"));
961
1147
  }
962
- const context = parts.join("\n\n");
1148
+ const fullContext = parts.join("\n\n");
1149
+ // flair#1199 — the structured containers are canonical; `context` is a prose
1150
+ // MIRROR of the same bytes. When includeContext is off (the /mcp default),
1151
+ // ship a compact structural pointer instead of re-embedding every body — so
1152
+ // no field's bytes cross the wire twice. Always a string, so a client can
1153
+ // still tell an empty instance from an unsupported one. When on, the prose
1154
+ // is the full assembled context (the resource/REST/CLI behaviour, unchanged).
1155
+ const context = includeContext
1156
+ ? fullContext
1157
+ : (fullContext.length === 0
1158
+ ? ""
1159
+ : `Structured payload in soul/memories/predicted/teammateFindings `
1160
+ + `(${memoriesIncluded} own + ${teammateFindingsIncluded} teammate memories, `
1161
+ + `${sections.soul.length} soul entries). Pass includeContext:true for the assembled prose context.`);
963
1162
  const soulTokens = sections.soul.reduce((sum, line) => sum + estimateTokens(line), 0);
964
- const memoryTokens = maxTokens - tokenBudget;
1163
+ // #1199 memory-line token spend (informational breakdown), independent of
1164
+ // the reserve/soul now sharing the budget. Sum of the rendered memory lines.
1165
+ const memoryTokens = [
1166
+ ...sections.permanent, ...sections.recent, ...sections.predicted,
1167
+ ...sections.relevant, ...sections.teammate,
1168
+ ].reduce((sum, line) => sum + estimateTokens(line), 0);
965
1169
  // flair#744 slice 1 — opt-in per-memory trust block. Bootstrap renders
966
1170
  // memories as text lines rather than result objects, so the block is
967
1171
  // surfaced as a `trust` array of self-contained entries (each carries its
@@ -969,9 +1173,12 @@ export class BootstrapMemories extends Resource {
969
1173
  // HERE, in the response tail, strictly after all read-scope resolution and
970
1174
  // purely for the response — never consulted for any authority decision
971
1175
  // (#735-spirit zero-authority invariant). Default OFF ⇒ the `trust` key is
972
- // absent ⇒ the response is byte-identical to pre-slice-1.
1176
+ // absent ⇒ the response is byte-identical to pre-slice-1. flair#1201 — each
1177
+ // entry carries its `section` so `matchQuality: null` on a lifecycle section
1178
+ // reads as "not a retrieval surface", not as a scoring failure on the
1179
+ // caller's own records.
973
1180
  const trust = includeTrust
974
- ? includedTrustMemories.map((m) => ({ id: m.id, ...buildTrustBlock(m) }))
1181
+ ? includedTrustMemories.map(({ m, section }) => ({ id: m.id, section, ...buildTrustBlock(m) }))
975
1182
  : undefined;
976
1183
  // flair#744 slice 2 — opt-in abstention verdict for the task-relevance
977
1184
  // surface. Present ONLY when `abstain` is requested (byte-identical to
@@ -1000,7 +1207,17 @@ export class BootstrapMemories extends Resource {
1000
1207
  const currentTaskHint = taskProvided
1001
1208
  ? undefined
1002
1209
  : "No currentTask was provided. Pass currentTask (a short description of what you're working on) to enable task-relevant memory retrieval, teammate findings, and collision surfacing.";
1003
- return {
1210
+ // flair#1199 — when `subjects` were provided but nothing surfaced in
1211
+ // `predicted`, say WHY (like currentTaskHint), so an empty `predicted: []`
1212
+ // next to a non-empty `subjects` doesn't read as broken. Predicted fills
1213
+ // from your OWN non-permanent memories whose `subject` matches one of the
1214
+ // provided subjects; it stays empty until you've tagged memories that way.
1215
+ const predictedHint = (predictedSubjects.length > 0 && includedPredicted.length === 0)
1216
+ ? `No memories tagged with the requested subjects (${predictedSubjects.join(", ")}) were found. `
1217
+ + `predicted surfaces your own non-permanent memories whose subject matches one of the provided `
1218
+ + `subjects — it fills as you store memories tagged with these subjects.`
1219
+ : undefined;
1220
+ const responseBody = {
1004
1221
  context,
1005
1222
  // flair#1182 (part 1) — always-present self-describing keys: who the
1006
1223
  // server resolved the caller as, the read model applied, and the caller's
@@ -1011,7 +1228,19 @@ export class BootstrapMemories extends Resource {
1011
1228
  soul: soulMap,
1012
1229
  memories: includedOwnMemories,
1013
1230
  predicted: includedPredicted,
1231
+ // flair#1199 — cross-agent teammate findings as a structured container
1232
+ // (always present, `[]` when none), so a connector that consumes the
1233
+ // containers still gets them when prose `context` is off.
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,
1014
1242
  ...(currentTaskHint ? { currentTaskHint } : {}),
1243
+ ...(predictedHint ? { predictedHint } : {}),
1015
1244
  ...(trust ? { trust } : {}),
1016
1245
  ...(abstention ? { abstention } : {}),
1017
1246
  sections: {
@@ -1027,12 +1256,36 @@ export class BootstrapMemories extends Resource {
1027
1256
  collision: sections.collision.length,
1028
1257
  events: sections.events.length,
1029
1258
  },
1030
- tokenEstimate: soulTokens + memoryTokens,
1031
1259
  soulTokens,
1032
1260
  memoryTokens,
1261
+ // flair#1199 — own memories included (denominator: memoriesAvailable, also
1262
+ // own-scoped), so memoriesIncluded ≤ memoriesAvailable always holds.
1033
1263
  memoriesIncluded,
1034
1264
  memoriesAvailable,
1265
+ // Cross-agent teammate findings included — a SEPARATE denominator, labelled
1266
+ // so it's never confused with the own-memory counters.
1267
+ teammateFindingsIncluded,
1035
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,
1036
1274
  };
1275
+ // flair#1199 — tokenEstimate must reflect the ACTUAL serialized payload the
1276
+ // caller receives (the structured containers included), not just the prose
1277
+ // `context`. The old `soulTokens + memoryTokens` counted only the context
1278
+ // string, so it under-reported by ~2× once the structured fields shipped
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`.
1288
+ const tokenEstimate = estimateTokens(JSON.stringify(responseBody));
1289
+ return { ...responseBody, tokenEstimate };
1037
1290
  }
1038
1291
  }
@@ -364,6 +364,13 @@ async function bootstrap(agent, args) {
364
364
  surface: args?.surface,
365
365
  subjects: args?.subjects,
366
366
  entities: args?.entities,
367
+ // flair#1199 — a /mcp connector consumes the STRUCTURED containers
368
+ // (soul/memories/predicted/teammateFindings), so the prose `context` mirror
369
+ // is OFF by default here: shipping both doubled the payload past maxTokens
370
+ // (the reported ~2× overrun). The resource itself defaults includeContext
371
+ // true (the REST/CLI prose path); this wrapper flips it for the connector,
372
+ // and forwards an explicit true when a caller wants the prose anyway.
373
+ includeContext: args?.includeContext === true,
367
374
  };
368
375
  // flair#744 slice 1 — opt-in per-memory trust block array. Forwarded ONLY
369
376
  // when requested so a plain bootstrap delegates a byte-identical body.
@@ -624,7 +631,7 @@ export const TOOLS = {
624
631
  inputSchema: {
625
632
  type: "object",
626
633
  properties: {
627
- 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." },
628
635
  currentTask: { type: "string", description: "Current task — enables semantic search for relevant memories" },
629
636
  channel: { type: "string", description: "Channel name (discord, tps-mail, claude-code)" },
630
637
  surface: { type: "string", description: "Surface name (tps-build, tps-review, cli-session)" },
@@ -636,6 +643,7 @@ export const TOOLS = {
636
643
  },
637
644
  includeTrust: { type: "boolean", description: "Also return a `trust` array with a per-included-memory trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
638
645
  abstain: { type: "boolean", description: "Opt into a task-relevance abstention verdict: also return an `abstention` object ({ abstained, bestScore, threshold }) reporting whether any memory covered `currentTask` above a global confidence threshold. Default false." },
646
+ includeContext: { type: "boolean", description: "Also return the prose `context` string — a human-readable mirror of the structured soul/memories/predicted/teammateFindings containers (which are the canonical payload). Default false here: the structured fields already carry everything, so shipping the prose too would double the payload." },
639
647
  },
640
648
  },
641
649
  },
@@ -141,6 +141,7 @@ export function buildTrustBlock(record, now = Date.now()) {
141
141
  const validFrom = typeof record.validFrom === "string" ? record.validFrom : null;
142
142
  const validTo = typeof record.validTo === "string" ? record.validTo : null;
143
143
  const createdAt = typeof record.createdAt === "string" ? record.createdAt : null;
144
+ const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : null;
144
145
  const validToMs = parseTime(validTo);
145
146
  const validFromMs = parseTime(validFrom);
146
147
  let validityStatus = "valid";
@@ -150,10 +151,22 @@ export function buildTrustBlock(record, now = Date.now()) {
150
151
  else if (Number.isFinite(validFromMs) && validFromMs > now) {
151
152
  validityStatus = "future";
152
153
  }
153
- const createdMs = parseTime(createdAt);
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);
154
163
  const ageDays = Number.isFinite(createdMs)
155
164
  ? Math.max(0, Math.floor((now - createdMs) / MS_PER_DAY))
156
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))
169
+ : null;
157
170
  return {
158
171
  author: typeof record.agentId === "string" ? record.agentId : null,
159
172
  provenanceStatus: verifiedAuthor ? "verified" : "unattributed",
@@ -165,7 +178,9 @@ export function buildTrustBlock(record, now = Date.now()) {
165
178
  validFrom,
166
179
  validTo,
167
180
  createdAt,
181
+ updatedAt,
168
182
  ageDays,
183
+ staleDays,
169
184
  supersedes: typeof record.supersedes === "string" ? record.supersedes : null,
170
185
  // flair#744 refinement — confidence band from the result's absolute
171
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.7",
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",