switchroom 0.13.61 → 0.13.63

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.
@@ -23466,6 +23466,15 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
23466
23466
  } catch {}
23467
23467
  lines.push(` - ${homePrefix}/.switchroom-config/agents/${a.name}/personal-skills:${homePrefix}/.switchroom-config/agents/${a.name}/personal-skills:rw`);
23468
23468
  }
23469
+ if (existsSync12(`${hostHomeForChecks}/.switchroom/bin/webkite`)) {
23470
+ lines.push(` - ${homePrefix}/.switchroom/bin/webkite:/usr/local/bin/webkite:ro`);
23471
+ }
23472
+ if (existsSync12(`${hostHomeForChecks}/.cloakbrowser`)) {
23473
+ lines.push(` - ${homePrefix}/.cloakbrowser:/state/agent/home/.cloakbrowser:ro`);
23474
+ }
23475
+ if (existsSync12(`${hostHomeForChecks}/.switchroom/webkite/config.toml`)) {
23476
+ lines.push(` - ${homePrefix}/.switchroom/webkite/config.toml:/state/agent/home/.config/webkite/config.toml:ro`);
23477
+ }
23469
23478
  if (bundledSkillsPoolDir && existsSync12(bundledSkillsPoolDir) && !bundledSkillsPoolDir.startsWith(`${hostHomeForChecks}/.switchroom/skills`)) {
23470
23479
  lines.push(` - ${bundledSkillsPoolDir}:${bundledSkillsPoolDir}:ro`);
23471
23480
  }
@@ -27779,6 +27788,13 @@ var init_via_claude = __esm(() => {
27779
27788
  });
27780
27789
 
27781
27790
  // src/vault/broker/acl.ts
27791
+ function isWebkiteCredentialKeyForAgent(agentConfig, key) {
27792
+ if (!WEBKITE_VAULT_KEYS.has(key))
27793
+ return false;
27794
+ if ((agentConfig?.mcp_servers ?? {})["webkite"] === false)
27795
+ return false;
27796
+ return true;
27797
+ }
27782
27798
  function parseCronUnit(unitName) {
27783
27799
  const m = unitName.match(/^switchroom-([a-zA-Z0-9_-]+)-cron-(\d+)\.service$/);
27784
27800
  if (!m)
@@ -27868,6 +27884,9 @@ function checkAclByAgent(config, agentName, key) {
27868
27884
  if (isGoogleClientCredentialKeyForAgent(config, agentName, key)) {
27869
27885
  return { allow: true };
27870
27886
  }
27887
+ if (isWebkiteCredentialKeyForAgent(agentConfig, key)) {
27888
+ return { allow: true };
27889
+ }
27871
27890
  const agentBot = agentConfig.bot_token;
27872
27891
  const botRef = agentBot && agentBot.length > 0 ? agentBot : config.telegram?.bot_token;
27873
27892
  if (typeof botRef === "string" && botRef.startsWith("vault:")) {
@@ -27941,7 +27960,14 @@ function checkGoogleAccountAcl(config, agentName, account, key) {
27941
27960
  }
27942
27961
  return { allow: true };
27943
27962
  }
27944
- var init_acl = () => {};
27963
+ var WEBKITE_VAULT_KEYS;
27964
+ var init_acl = __esm(() => {
27965
+ WEBKITE_VAULT_KEYS = new Set([
27966
+ "webkite/cloudflare-account-id",
27967
+ "webkite/cloudflare-api-token",
27968
+ "webkite/firecrawl-api-key"
27969
+ ]);
27970
+ });
27945
27971
 
27946
27972
  // src/util/audit-hashchain.ts
27947
27973
  import { createHash as createHash7 } from "node:crypto";
@@ -49057,8 +49083,8 @@ var {
49057
49083
  } = import__.default;
49058
49084
 
49059
49085
  // src/build-info.ts
49060
- var VERSION = "0.13.61";
49061
- var COMMIT_SHA = "43241ade";
49086
+ var VERSION = "0.13.63";
49087
+ var COMMIT_SHA = "9aaa5939";
49062
49088
 
49063
49089
  // src/cli/agent.ts
49064
49090
  init_source();
@@ -49695,6 +49721,42 @@ When the user asks you to forget something ("forget that", "delete X", "drop wha
49695
49721
  When the user asks "what do you know about X / me", "what do you remember about Y", or any memory audit, use \`reflect\` to synthesize an answer across the bank. Return it as honest prose, not a raw dump. If the bank has little on the topic, say so.
49696
49722
 
49697
49723
  Don't wait for a slash command. Don't ask permission. Memory work is table stakes, like a colleague who takes notes and remembers.`;
49724
+ var WEB_FETCH_GUIDANCE = `## Fetching from the web
49725
+
49726
+ The native \`WebFetch\` and \`WebSearch\` tools are disabled in this
49727
+ fleet. Use the \`webkite_*\` MCP tools instead \u2014 they're already
49728
+ wired up and authenticated.
49729
+
49730
+ The 12 tools webkite exposes cover what you actually need:
49731
+
49732
+ - \`webkite_read\` \u2014 fetch a URL and get clean article text or
49733
+ markdown. The everyday "read this page" tool. Pass several URLs
49734
+ to read them in one call.
49735
+ - \`webkite_search\` \u2014 web search; returns URLs + snippets. Use
49736
+ before \`webkite_read\` when you don't already have the URL.
49737
+ - \`webkite_extract\` \u2014 pull specific fields from one page via a
49738
+ JSON Schema you supply. Use when you want structured data, not
49739
+ prose.
49740
+ - \`webkite_crawl\` \u2014 walk a site from a seed URL. Higher-cost than
49741
+ \`read\` or \`map\` \u2014 reserve for "harvest everything under this
49742
+ section."
49743
+ - \`webkite_map\` \u2014 list a site's URLs from its sitemap, no page
49744
+ content. Cheap. Use when you only need URLs.
49745
+ - \`webkite_clip\` \u2014 save a page snapshot (Obsidian, file, or
49746
+ screenshot).
49747
+ - session / cookie / diagnostics helpers.
49748
+
49749
+ **Why this exists:** the fleet ships with a stealth Chromium
49750
+ (cloakbrowser) baked in for bot-gated sites the native fetcher can't
49751
+ touch, plus Cloudflare/Firecrawl fallback when local render fails.
49752
+ \`webkite_*\` Just Works \u2014 don't try to shell out to \`curl\` or
49753
+ \`wget\` as a fallback; if webkite can't fetch a URL, neither can
49754
+ they.
49755
+
49756
+ If you genuinely need WebFetch back for one agent (e.g. a workflow
49757
+ that depends on its specific output shape), set \`mcp_servers.webkite:
49758
+ false\` in that agent's switchroom.yaml block \u2014 webkite goes away
49759
+ and the native tools come back together.`;
49698
49760
  function renderFleetInvariants() {
49699
49761
  return [
49700
49762
  "<!--",
@@ -49717,6 +49779,8 @@ function renderFleetInvariants() {
49717
49779
  TELEGRAM_GUIDANCE,
49718
49780
  "",
49719
49781
  MEMORY_GUIDANCE,
49782
+ "",
49783
+ WEB_FETCH_GUIDANCE,
49720
49784
  ""
49721
49785
  ].join(`
49722
49786
  `);
@@ -49888,6 +49952,22 @@ var DEFAULT_READ_ONLY_PREAPPROVED_TOOLS = [
49888
49952
  "ExitPlanMode",
49889
49953
  "Skill"
49890
49954
  ];
49955
+ var WEBKITE_FLEET_DENY_TOOLS = ["WebFetch", "WebSearch"];
49956
+ var WEBKITE_BINARY_CONTAINER_PATH = "/usr/local/bin/webkite";
49957
+ function webkiteBinaryAvailable() {
49958
+ const override = process.env.SWITCHROOM_WEBKITE_BINARY;
49959
+ if (override !== undefined && override !== "") {
49960
+ return existsSync11(override);
49961
+ }
49962
+ return existsSync11(join8(homedir4(), ".switchroom", "bin", "webkite")) || existsSync11(WEBKITE_BINARY_CONTAINER_PATH);
49963
+ }
49964
+ function webkiteDenyForAgent(agentConfig) {
49965
+ if (agentConfig.mcp_servers?.["webkite"] === false)
49966
+ return [];
49967
+ if (!webkiteBinaryAvailable())
49968
+ return [];
49969
+ return WEBKITE_FLEET_DENY_TOOLS;
49970
+ }
49891
49971
  var SWITCHROOM_DEFAULT_MAIN_MODEL = "claude-sonnet-4-6";
49892
49972
  var SWITCHROOM_DEFAULT_THINKING_EFFORT = "low";
49893
49973
  function dedupe2(items) {
@@ -50455,7 +50535,10 @@ function buildWorkspaceContext(args) {
50455
50535
  user: agentConfig.user,
50456
50536
  agentConfig,
50457
50537
  tools,
50458
- toolsDeny: tools.deny ?? [],
50538
+ toolsDeny: dedupe2([
50539
+ ...tools.deny ?? [],
50540
+ ...webkiteDenyForAgent(agentConfig)
50541
+ ]),
50459
50542
  permissionAllow,
50460
50543
  defaultModeAcceptEdits: hasAllWildcard,
50461
50544
  memory: agentConfig.memory,
@@ -50602,6 +50685,17 @@ function resolveNotionMcpEntry(agentName, agentConfig, switchroomConfig) {
50602
50685
  };
50603
50686
  return entry;
50604
50687
  }
50688
+ function resolveWebkiteMcpEntry(_agentName, agentConfig, _switchroomConfig) {
50689
+ if ((agentConfig.mcp_servers ?? {})["webkite"] === false)
50690
+ return null;
50691
+ return {
50692
+ key: "webkite",
50693
+ value: {
50694
+ command: "webkite",
50695
+ args: ["mcp"]
50696
+ }
50697
+ };
50698
+ }
50605
50699
  var INTEGRATION_MCP_RESOLVERS = [
50606
50700
  {
50607
50701
  label: "Google Workspace",
@@ -50620,6 +50714,12 @@ var INTEGRATION_MCP_RESOLVERS = [
50620
50714
  emitKey: "notion",
50621
50715
  retractionKey: "notion",
50622
50716
  resolve: resolveNotionMcpEntry
50717
+ },
50718
+ {
50719
+ label: "Webkite",
50720
+ emitKey: "webkite",
50721
+ retractionKey: "webkite",
50722
+ resolve: resolveWebkiteMcpEntry
50623
50723
  }
50624
50724
  ];
50625
50725
  function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchroomConfig, userIdOverride, switchroomConfigPath) {
@@ -51443,7 +51543,10 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
51443
51543
  ...AGENT_CONFIG_MCP_TOOLS,
51444
51544
  ...HOSTD_MCP_TOOLS
51445
51545
  ]);
51446
- const desiredDeny = tools.deny ?? [];
51546
+ const desiredDeny = dedupe2([
51547
+ ...tools.deny ?? [],
51548
+ ...webkiteDenyForAgent(agentConfig)
51549
+ ]);
51447
51550
  let topicId = agentConfig.topic_id;
51448
51551
  if (topicId === undefined) {
51449
51552
  try {
@@ -13133,6 +13133,18 @@ function isGoogleClientCredentialKeyForAgent(config, agentName, key) {
13133
13133
  }
13134
13134
 
13135
13135
  // src/vault/broker/acl.ts
13136
+ var WEBKITE_VAULT_KEYS = new Set([
13137
+ "webkite/cloudflare-account-id",
13138
+ "webkite/cloudflare-api-token",
13139
+ "webkite/firecrawl-api-key"
13140
+ ]);
13141
+ function isWebkiteCredentialKeyForAgent(agentConfig, key) {
13142
+ if (!WEBKITE_VAULT_KEYS.has(key))
13143
+ return false;
13144
+ if ((agentConfig?.mcp_servers ?? {})["webkite"] === false)
13145
+ return false;
13146
+ return true;
13147
+ }
13136
13148
  function parseCronUnit(unitName) {
13137
13149
  const m = unitName.match(/^switchroom-([a-zA-Z0-9_-]+)-cron-(\d+)\.service$/);
13138
13150
  if (!m)
@@ -13222,6 +13234,9 @@ function checkAclByAgent(config, agentName, key) {
13222
13234
  if (isGoogleClientCredentialKeyForAgent(config, agentName, key)) {
13223
13235
  return { allow: true };
13224
13236
  }
13237
+ if (isWebkiteCredentialKeyForAgent(agentConfig, key)) {
13238
+ return { allow: true };
13239
+ }
13225
13240
  const agentBot = agentConfig.bot_token;
13226
13241
  const botRef = agentBot && agentBot.length > 0 ? agentBot : config.telegram?.bot_token;
13227
13242
  if (typeof botRef === "string" && botRef.startsWith("vault:")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.61",
3
+ "version": "0.13.63",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -598,6 +598,29 @@ if [ -d "$SR_FLEET_DIR" ]; then
598
598
  SR_FLEET_ARG="--add-dir $SR_FLEET_DIR"
599
599
  fi
600
600
 
601
+ # Webkite credential pre-auth — fetch Cloudflare + Firecrawl creds from
602
+ # the vault-broker at boot and export into the agent process env so
603
+ # `webkite mcp` (spawned by claude with sanitized env per .mcp.json)
604
+ # inherits them via the agent's env on `exec claude`. Best-effort: a
605
+ # missing key falls webkite back to cloakbrowser-only (no cloud render),
606
+ # which still works for most sites. The broker enforces the per-key
607
+ # ACL — if the operator hasn't `--allow`ed this agent on the key, the
608
+ # get returns empty and webkite stays cloakbrowser-only.
609
+ if command -v switchroom >/dev/null 2>&1; then
610
+ for sr_wk_pair in \
611
+ "CLOUDFLARE_ACCOUNT_ID:webkite/cloudflare-account-id" \
612
+ "CLOUDFLARE_API_TOKEN:webkite/cloudflare-api-token" \
613
+ "FIRECRAWL_API_KEY:webkite/firecrawl-api-key"; do
614
+ sr_wk_env="${sr_wk_pair%%:*}"
615
+ sr_wk_key="${sr_wk_pair##*:}"
616
+ sr_wk_val="$(switchroom vault get "$sr_wk_key" 2>/dev/null || true)"
617
+ if [ -n "$sr_wk_val" ]; then
618
+ export "$sr_wk_env=$sr_wk_val"
619
+ fi
620
+ done
621
+ unset sr_wk_pair sr_wk_env sr_wk_key sr_wk_val
622
+ fi
623
+
601
624
  {{#if useSwitchroomPlugin}}
602
625
  if [ -n "$APPEND_PROMPT" ]; then
603
626
  exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
@@ -82,7 +82,7 @@ export interface AnswerStreamConfig {
82
82
  chatId: string,
83
83
  draftId: number,
84
84
  text: string,
85
- params?: { message_thread_id?: number },
85
+ params?: { message_thread_id?: number; parse_mode?: 'HTML' },
86
86
  ) => Promise<unknown>
87
87
  sendMessage: (
88
88
  chatId: string,
@@ -31585,9 +31585,32 @@ function makeEmptyActivityState() {
31585
31585
  function verbForTool(toolName) {
31586
31586
  if (!toolName)
31587
31587
  return null;
31588
- const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName);
31588
+ const mcpMatch = /^mcp__(.+?)__(.+)$/.exec(toolName);
31589
31589
  if (mcpMatch && mcpMatch[1] === "switchroom-telegram")
31590
31590
  return null;
31591
+ if (mcpMatch) {
31592
+ const server = mcpMatch[1].toLowerCase();
31593
+ const mcpTool = mcpMatch[2].toLowerCase();
31594
+ if (server === "hindsight") {
31595
+ if (mcpTool === "recall" || mcpTool === "reflect")
31596
+ return "searched";
31597
+ if (mcpTool === "retain" || mcpTool === "update_memory" || mcpTool === "sync_retain")
31598
+ return "saved";
31599
+ }
31600
+ if (server === "google-workspace" || server === "claude_ai_google_drive" || server === "claude_ai_gmail" || server === "claude_ai_google_calendar") {
31601
+ if (/^(search|list|query|read|get|fetch|download)/i.test(mcpTool))
31602
+ return "searched";
31603
+ if (/^(create|update|write|send|move|copy|duplicate)/i.test(mcpTool))
31604
+ return "edited";
31605
+ }
31606
+ if (server === "notion" || server === "claude_ai_notion") {
31607
+ const action = mcpTool.replace(/^notion-/, "");
31608
+ if (/^(search|fetch|query|get|read)/i.test(action))
31609
+ return "searched";
31610
+ if (/^(create|update|move|duplicate|comment)/i.test(action))
31611
+ return "edited";
31612
+ }
31613
+ }
31591
31614
  const suffix = (mcpMatch ? mcpMatch[2] : toolName).toLowerCase();
31592
31615
  switch (suffix) {
31593
31616
  case "read":
@@ -31639,6 +31662,7 @@ var VERB_PHRASE = {
31639
31662
  fetched: { singular: "fetched a URL", plural: "fetched $N URLs" },
31640
31663
  dispatched: { singular: "dispatched a sub-agent", plural: "dispatched $N sub-agents" },
31641
31664
  noted: { singular: "updated the todo list", plural: "updated the todo list ($N edits)" },
31665
+ saved: { singular: "saved a memory", plural: "saved $N memories" },
31642
31666
  used: { singular: "used a tool", plural: "used $N tools" }
31643
31667
  };
31644
31668
  function formatSummary(state) {
@@ -37961,6 +37985,10 @@ function formatFrameworkFallbackText(fallbackKind, silenceMs, inFlightTools = []
37961
37985
  const dur = formatDurationShort(longest.durationMs);
37962
37986
  const labelTail = longest.label && longest.label.length > 0 ? ` ${truncateLabel(longest.label)}` : "";
37963
37987
  const more = inFlightTools.length > 1 ? ` + ${inFlightTools.length - 1} more` : "";
37988
+ const isMcpRawName = /^mcp__/.test(longest.name);
37989
+ if (isMcpRawName && labelTail !== "") {
37990
+ return `${truncateLabel(longest.label)}${more} for ${dur} ${suffix}`;
37991
+ }
37964
37992
  return `running ${longest.name}${labelTail}${more} for ${dur} ${suffix}`;
37965
37993
  }
37966
37994
  return fallbackKind === "thinking" ? `still thinking\u2026 ${suffix}` : `still working\u2026 ${suffix}`;
@@ -49688,10 +49716,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
49688
49716
  }
49689
49717
 
49690
49718
  // ../src/build-info.ts
49691
- var VERSION = "0.13.61";
49692
- var COMMIT_SHA = "43241ade";
49693
- var COMMIT_DATE = "2026-05-28T01:27:29Z";
49694
- var LATEST_PR = 1940;
49719
+ var VERSION = "0.13.63";
49720
+ var COMMIT_SHA = "9aaa5939";
49721
+ var COMMIT_DATE = "2026-05-28T03:28:57Z";
49722
+ var LATEST_PR = 1945;
49695
49723
  var COMMITS_AHEAD_OF_TAG = 0;
49696
49724
 
49697
49725
  // gateway/boot-version.ts
@@ -53669,7 +53697,7 @@ async function drainActivitySummary(turn) {
53669
53697
  turn.activityDraftId = allocateDraftId();
53670
53698
  }
53671
53699
  const draftId = turn.activityDraftId;
53672
- await sendMessageDraftFn(chat, draftId, html, undefined);
53700
+ await sendMessageDraftFn(chat, draftId, html, { parse_mode: "HTML" });
53673
53701
  } else if (turn.activityMessageId == null) {
53674
53702
  const sent = await robustApiCall(() => bot.api.sendMessage(chat, html, {
53675
53703
  ...thread != null ? { message_thread_id: thread } : {},
@@ -638,7 +638,7 @@ const GRAMMY_VERSION: string = (() => {
638
638
  }
639
639
  })()
640
640
  const sendMessageDraftFn: (
641
- (chatId: string, draftId: number, text: string, params?: { message_thread_id?: number }) => Promise<unknown>
641
+ (chatId: string, draftId: number, text: string, params?: { message_thread_id?: number; parse_mode?: 'HTML' }) => Promise<unknown>
642
642
  ) | undefined =
643
643
  typeof _rawSendMessageDraft === 'function'
644
644
  ? (chatId, draftId, text, params) =>
@@ -6828,7 +6828,7 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6828
6828
  turn.activityDraftId = allocateDraftId()
6829
6829
  }
6830
6830
  const draftId = turn.activityDraftId
6831
- await sendMessageDraftFn!(chat, draftId, html, undefined)
6831
+ await sendMessageDraftFn!(chat, draftId, html, { parse_mode: 'HTML' })
6832
6832
  } else if (turn.activityMessageId == null) {
6833
6833
  const sent = await robustApiCall(
6834
6834
  () => bot.api.sendMessage(chat, html, {
@@ -433,6 +433,15 @@ export function formatFrameworkFallbackText(
433
433
  // running Grep "foo" for 4m (no update from agent in 5 min)
434
434
  // running Grep "foo" + 2 more (4m) (no update from agent in 5 min)
435
435
  // running Grep (no label) for 4m (no update from agent in 5 min)
436
+ //
437
+ // Raw MCP tool names (`mcp__server__tool`) are technical identifiers
438
+ // and look like a leak when surfaced to a user. When the tool name
439
+ // matches that shape AND a human-friendly label is available, drop
440
+ // the raw name and lead with the label instead:
441
+ // Searching memory for 4m (no update from agent in 5 min)
442
+ // Built-in tool names (Grep, Read, Bash) stay as-is — they ARE
443
+ // human-readable, and the label is supplementary detail (e.g. the
444
+ // search pattern) that reads naturally after the verb.
436
445
  if (inFlightTools.length > 0) {
437
446
  const longest = inFlightTools[0]!
438
447
  const dur = formatDurationShort(longest.durationMs)
@@ -442,6 +451,13 @@ export function formatFrameworkFallbackText(
442
451
  const more = inFlightTools.length > 1
443
452
  ? ` + ${inFlightTools.length - 1} more`
444
453
  : ''
454
+ const isMcpRawName = /^mcp__/.test(longest.name)
455
+ if (isMcpRawName && labelTail !== '') {
456
+ // Label-only: "Searching memory for 4m (…)". Drop the raw
457
+ // `mcp__server__tool` and the leading "running" because the
458
+ // label already reads as a gerund phrase.
459
+ return `${truncateLabel(longest.label!)}${more} for ${dur} ${suffix}`
460
+ }
445
461
  return `running ${longest.name}${labelTail}${more} for ${dur} ${suffix}`
446
462
  }
447
463
  return fallbackKind === 'thinking'
@@ -533,6 +533,43 @@ describe('silence-poke — #1292 tool-aware framework fallback', () => {
533
533
  )
534
534
  })
535
535
 
536
+ it('raw mcp__ tool name with a human label drops the technical name and leads with the label', () => {
537
+ // mcp__hindsight__reflect is the internal MCP identifier — looks
538
+ // like a leak when surfaced to a user. The label table emits
539
+ // "Searching memory" for it (see hooks/tool-label-pretool.mjs);
540
+ // the fallback message should lead with the label, not concatenate
541
+ // both.
542
+ const text = formatFrameworkFallbackText('working', 305_000, [
543
+ { name: 'mcp__hindsight__reflect', label: 'Searching memory', durationMs: 305_000 },
544
+ ])
545
+ expect(text).toBe(
546
+ 'Searching memory for 5m (no update from agent in 5 min)',
547
+ )
548
+ })
549
+
550
+ it('raw mcp__ tool name with NO label falls back to the bare name (no leak-but-no-better-option)', () => {
551
+ // If the label table doesn't recognise an MCP tool, we have nothing
552
+ // better to show than the raw name. Better honest-ugly than silent.
553
+ const text = formatFrameworkFallbackText('working', 305_000, [
554
+ { name: 'mcp__some-third-party__do_thing', label: null, durationMs: 305_000 },
555
+ ])
556
+ expect(text).toBe(
557
+ 'running mcp__some-third-party__do_thing for 5m (no update from agent in 5 min)',
558
+ )
559
+ })
560
+
561
+ it('built-in tool (Grep) with a label keeps the prior "running Name label" shape — name is already human-readable', () => {
562
+ // Regression guard: don't accidentally drop the built-in tool name
563
+ // when generalising the MCP rule. "Grep" is human-readable; the
564
+ // label ("foo") is supplementary detail like the search pattern.
565
+ const text = formatFrameworkFallbackText('working', 305_000, [
566
+ { name: 'Grep', label: 'foo', durationMs: 305_000 },
567
+ ])
568
+ expect(text).toBe(
569
+ 'running Grep foo for 5m (no update from agent in 5 min)',
570
+ )
571
+ })
572
+
536
573
  it('empty inFlightTools falls back to the base "still working" wording', () => {
537
574
  expect(
538
575
  formatFrameworkFallbackText('working', 305_000, []),
@@ -32,9 +32,25 @@ describe("verbForTool — tool name → past-tense verb", () => {
32
32
  expect(verbForTool("mcp__switchroom-telegram__react")).toBeNull();
33
33
  });
34
34
 
35
- it("returns 'used' for unknown / non-switchroom MCP tools", () => {
36
- expect(verbForTool("mcp__google-workspace__list_files")).toBe("used");
37
- expect(verbForTool("mcp__notion__query_database")).toBe("used");
35
+ it("maps recognised MCP tools (hindsight, google-workspace, notion) to specific verbs", () => {
36
+ // hindsight: recall/reflect → searched, retain/update_memory → saved
37
+ expect(verbForTool("mcp__hindsight__reflect")).toBe("searched");
38
+ expect(verbForTool("mcp__hindsight__recall")).toBe("searched");
39
+ expect(verbForTool("mcp__hindsight__retain")).toBe("saved");
40
+ expect(verbForTool("mcp__hindsight__update_memory")).toBe("saved");
41
+ // google-workspace / claude.ai variants: read-shaped → searched, write-shaped → edited
42
+ expect(verbForTool("mcp__google-workspace__list_files")).toBe("searched");
43
+ expect(verbForTool("mcp__claude_ai_Gmail__search_messages")).toBe("searched");
44
+ expect(verbForTool("mcp__google-workspace__create_file")).toBe("edited");
45
+ expect(verbForTool("mcp__claude_ai_Google_Drive__download_file_content")).toBe("searched");
46
+ // notion: query/get → searched, create/update → edited
47
+ expect(verbForTool("mcp__notion__query_database")).toBe("searched");
48
+ expect(verbForTool("mcp__claude_ai_Notion__notion-search")).toBe("searched");
49
+ expect(verbForTool("mcp__claude_ai_Notion__notion-update-page")).toBe("edited");
50
+ });
51
+
52
+ it("returns 'used' for genuinely unknown MCP / future tools (generic fallback)", () => {
53
+ expect(verbForTool("mcp__random-third-party__do_thing")).toBe("used");
38
54
  expect(verbForTool("SomeFutureUnknownTool")).toBe("used");
39
55
  });
40
56
 
@@ -112,14 +128,26 @@ describe("register + formatSummary — Claude Code-style summary", () => {
112
128
  expect(formatSummary(s)).toBeNull(); // nothing tracked
113
129
  });
114
130
 
115
- it("includes generic 'used' for unknown MCP tools", () => {
131
+ it("includes generic 'used' for genuinely-unknown MCP tools (fallback)", () => {
116
132
  const s = makeEmptyActivityState();
117
- register(s, "mcp__google-workspace__list_files");
133
+ register(s, "mcp__random-third-party__do_thing");
118
134
  expect(formatSummary(s)).toBe("Used a tool");
119
- register(s, "mcp__google-workspace__create_file");
135
+ register(s, "mcp__another-unknown-server__something_else");
120
136
  expect(formatSummary(s)).toBe("Used 2 tools");
121
137
  });
122
138
 
139
+ it("maps recognised MCP tools to natural-language summaries (no generic 'Used N tools')", () => {
140
+ // hindsight search shows up as 'searched' (memory)
141
+ const s = makeEmptyActivityState();
142
+ register(s, "mcp__hindsight__reflect");
143
+ expect(formatSummary(s)).toBe("Ran a search");
144
+ register(s, "mcp__hindsight__reflect");
145
+ expect(formatSummary(s)).toBe("Ran 2 searches");
146
+ // hindsight retain shows up as 'saved a memory'
147
+ register(s, "mcp__hindsight__retain");
148
+ expect(formatSummary(s)).toBe("Ran 2 searches, saved a memory");
149
+ });
150
+
123
151
  it("tracks firstToolName for forensic / telemetry use", () => {
124
152
  const s = makeEmptyActivityState();
125
153
  register(s, "Read");
@@ -40,6 +40,7 @@ export type ActivityVerb =
40
40
  | "fetched"
41
41
  | "dispatched"
42
42
  | "noted"
43
+ | "saved" // memory-retain class (hindsight, etc.) — distinct from "noted" (TodoWrite)
43
44
  | "used"; // generic fallback
44
45
 
45
46
  /** Object form so `register()` can mutate; pure functions inside the
@@ -66,10 +67,44 @@ export function makeEmptyActivityState(): ActivityState {
66
67
  * like reply/stream_reply) return null — the caller skips them. */
67
68
  export function verbForTool(toolName: string): ActivityVerb | null {
68
69
  if (!toolName) return null;
69
- const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName);
70
+ // Lazy match on the server segment so names containing underscores
71
+ // (e.g. `mcp__claude_ai_Gmail__search`) parse as
72
+ // server="claude_ai_Gmail", tool="search"
73
+ // instead of the prior `[^_]+` which stopped at the first inner `_`.
74
+ const mcpMatch = /^mcp__(.+?)__(.+)$/.exec(toolName);
70
75
  // Skip user-facing Telegram-plugin tools entirely — those ARE the
71
76
  // surface, never to be summarised.
72
77
  if (mcpMatch && mcpMatch[1] === "switchroom-telegram") return null;
78
+
79
+ // MCP allowlist — map common MCP tools to specific verbs so the summary
80
+ // reads as "Searched memory" or "Read 2 files" instead of the generic
81
+ // fallback "Used 2 tools". Tools NOT on this list fall through to the
82
+ // generic "used" verb, which is still better than nothing for one-offs
83
+ // but hurts on heavy MCP turns. Mirrors the label table in
84
+ // `telegram-plugin/hooks/tool-label-pretool.mjs` — keep them in sync.
85
+ if (mcpMatch) {
86
+ // Case-insensitive match — claude.ai prefixes use mixed-case
87
+ // server names ("claude_ai_Gmail", "claude_ai_Google_Drive") so we
88
+ // lowercase both sides before comparing.
89
+ const server = mcpMatch[1].toLowerCase();
90
+ const mcpTool = mcpMatch[2].toLowerCase();
91
+ if (server === "hindsight") {
92
+ if (mcpTool === "recall" || mcpTool === "reflect") return "searched";
93
+ if (mcpTool === "retain" || mcpTool === "update_memory" || mcpTool === "sync_retain") return "saved";
94
+ }
95
+ if (server === "google-workspace" || server === "claude_ai_google_drive" || server === "claude_ai_gmail" || server === "claude_ai_google_calendar") {
96
+ if (/^(search|list|query|read|get|fetch|download)/i.test(mcpTool)) return "searched";
97
+ if (/^(create|update|write|send|move|copy|duplicate)/i.test(mcpTool)) return "edited";
98
+ }
99
+ if (server === "notion" || server === "claude_ai_notion") {
100
+ // claude.ai Notion exposes tools as `notion-search`, `notion-update-page`,
101
+ // etc. Strip the redundant `notion-` prefix before matching the verb.
102
+ const action = mcpTool.replace(/^notion-/, "");
103
+ if (/^(search|fetch|query|get|read)/i.test(action)) return "searched";
104
+ if (/^(create|update|move|duplicate|comment)/i.test(action)) return "edited";
105
+ }
106
+ }
107
+
73
108
  const suffix = (mcpMatch ? mcpMatch[2] : toolName).toLowerCase();
74
109
  switch (suffix) {
75
110
  case "read":
@@ -128,6 +163,7 @@ const VERB_PHRASE: Record<ActivityVerb, VerbPhrase> = {
128
163
  fetched: { singular: "fetched a URL", plural: "fetched $N URLs" },
129
164
  dispatched: { singular: "dispatched a sub-agent", plural: "dispatched $N sub-agents" },
130
165
  noted: { singular: "updated the todo list", plural: "updated the todo list ($N edits)" },
166
+ saved: { singular: "saved a memory", plural: "saved $N memories" },
131
167
  used: { singular: "used a tool", plural: "used $N tools" },
132
168
  };
133
169