switchroom 0.13.60 → 0.13.62

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.
@@ -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