switchroom 0.13.57 → 0.13.59

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Tool-activity summary — Claude Code-style natural-language progress
3
+ * line that batches tool_use events for a turn into a single Telegram
4
+ * message that updates in place.
5
+ *
6
+ * Replaces the per-tool intent surface (#1924). The screenshot from
7
+ * Claude Code's own UI shows lines like:
8
+ *
9
+ * "Ran 5 commands, read a file"
10
+ * "Edited a file, read a file, ran a command"
11
+ *
12
+ * Past tense, comma-joined, singular/plural-aware. One message per
13
+ * "phase" (turn start → first reply), progressively edited as tools
14
+ * accumulate. NOT raw tool calls — descriptions of what the agent has
15
+ * been doing.
16
+ *
17
+ * Why this beats per-tool labels:
18
+ * - One Telegram message per phase (low signal-to-noise vs N
19
+ * messages on a heavy turn)
20
+ * - The user sees ACCUMULATED work in a glanceable form, not a flood
21
+ * - Plays nicely with the existing answer-lane stream that handles
22
+ * the actual reply text
23
+ *
24
+ * Tracking shape: per-turn counters keyed by `verb` (the action class
25
+ * derived from tool name). One counter per verb so the summary line
26
+ * collapses neatly regardless of which specific Read/Bash/WebSearch
27
+ * the model chose. `register()` increments the counter; `formatSummary()`
28
+ * renders the current state.
29
+ */
30
+
31
+ const READ_VERBS = new Set(["read"]);
32
+ const WRITE_VERBS = new Set(["wrote", "created", "edited"]);
33
+
34
+ export type ActivityVerb =
35
+ | "read"
36
+ | "edited"
37
+ | "created"
38
+ | "ran"
39
+ | "searched"
40
+ | "fetched"
41
+ | "dispatched"
42
+ | "noted"
43
+ | "used"; // generic fallback
44
+
45
+ /** Object form so `register()` can mutate; pure functions inside the
46
+ * module work against this shape (easier to unit-test than a Map). */
47
+ export interface ActivityState {
48
+ counts: Partial<Record<ActivityVerb, number>>;
49
+ /** Order verbs were first observed this turn. The summary renders in
50
+ * this order so the line reads as a chronological natural-language
51
+ * account: "edited a file, read a file, ran a command" matches the
52
+ * agent's actual sequence of actions. Stable — once a verb is added
53
+ * to this list, it never moves. */
54
+ order: ActivityVerb[];
55
+ /** First non-trivial tool name observed this turn (for telemetry / future
56
+ * "what kicked this off" forensic). Not used in the rendered summary. */
57
+ firstToolName: string | null;
58
+ }
59
+
60
+ export function makeEmptyActivityState(): ActivityState {
61
+ return { counts: {}, order: [], firstToolName: null };
62
+ }
63
+
64
+ /** Map a tool name → verb. Mirrors the existing `tool-intent-surface.ts`
65
+ * verb table but in past tense. Tools that don't map (or surface tools
66
+ * like reply/stream_reply) return null — the caller skips them. */
67
+ export function verbForTool(toolName: string): ActivityVerb | null {
68
+ if (!toolName) return null;
69
+ const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName);
70
+ // Skip user-facing Telegram-plugin tools entirely — those ARE the
71
+ // surface, never to be summarised.
72
+ if (mcpMatch && mcpMatch[1] === "switchroom-telegram") return null;
73
+ const suffix = (mcpMatch ? mcpMatch[2] : toolName).toLowerCase();
74
+ switch (suffix) {
75
+ case "read":
76
+ return "read";
77
+ case "write":
78
+ return "created";
79
+ case "edit":
80
+ case "multiedit":
81
+ case "notebookedit":
82
+ return "edited";
83
+ case "bash":
84
+ case "bashoutput":
85
+ case "killshell":
86
+ return "ran";
87
+ case "websearch":
88
+ case "grep":
89
+ case "glob":
90
+ return "searched";
91
+ case "webfetch":
92
+ return "fetched";
93
+ case "task":
94
+ case "agent":
95
+ return "dispatched";
96
+ case "todowrite":
97
+ case "todoread":
98
+ return "noted";
99
+ default:
100
+ return "used";
101
+ }
102
+ }
103
+
104
+ /** Mutates `state` to record one tool_use of `toolName`. Returns true
105
+ * iff the activity state changed (so the caller knows to refresh the
106
+ * rendered summary). */
107
+ export function register(state: ActivityState, toolName: string): boolean {
108
+ const verb = verbForTool(toolName);
109
+ if (!verb) return false;
110
+ if (state.firstToolName == null) state.firstToolName = toolName;
111
+ const prior = state.counts[verb] ?? 0;
112
+ if (prior === 0) state.order.push(verb);
113
+ state.counts[verb] = prior + 1;
114
+ return true;
115
+ }
116
+
117
+ interface VerbPhrase {
118
+ singular: string;
119
+ plural: string;
120
+ }
121
+
122
+ const VERB_PHRASE: Record<ActivityVerb, VerbPhrase> = {
123
+ read: { singular: "read a file", plural: "read $N files" },
124
+ edited: { singular: "edited a file", plural: "edited $N files" },
125
+ created: { singular: "created a file", plural: "created $N files" },
126
+ ran: { singular: "ran a command", plural: "ran $N commands" },
127
+ searched: { singular: "ran a search", plural: "ran $N searches" },
128
+ fetched: { singular: "fetched a URL", plural: "fetched $N URLs" },
129
+ dispatched: { singular: "dispatched a sub-agent", plural: "dispatched $N sub-agents" },
130
+ noted: { singular: "updated the todo list", plural: "updated the todo list ($N edits)" },
131
+ used: { singular: "used a tool", plural: "used $N tools" },
132
+ };
133
+
134
+ /** Render the activity state as a single natural-language line.
135
+ * Verbs are rendered in `state.order` — first-occurrence order — so
136
+ * the line reads chronologically ("edited a file, read a file, ran
137
+ * a command" mirrors the agent's actual action sequence). Returns
138
+ * null when the state is empty (nothing to show yet). */
139
+ export function formatSummary(state: ActivityState): string | null {
140
+ const phrases: string[] = [];
141
+ for (const verb of state.order) {
142
+ const n = state.counts[verb] ?? 0;
143
+ if (n <= 0) continue;
144
+ const p = VERB_PHRASE[verb];
145
+ phrases.push(n === 1 ? p.singular : p.plural.replace("$N", String(n)));
146
+ }
147
+ if (phrases.length === 0) return null;
148
+ // Capitalize first letter so the sentence reads as a statement.
149
+ const sentence = phrases.join(", ");
150
+ return sentence.charAt(0).toUpperCase() + sentence.slice(1);
151
+ }
152
+
153
+ /** Convenience: ergonomic full pipeline for callers that just want
154
+ * "given the new tool name and prior state, give me the updated rendered
155
+ * text or null if nothing changed". Returns null when the tool is a
156
+ * surface tool / no-op (so the caller can skip the Telegram edit). */
157
+ export function registerAndRender(
158
+ state: ActivityState,
159
+ toolName: string,
160
+ ): string | null {
161
+ const changed = register(state, toolName);
162
+ if (!changed) return null;
163
+ return formatSummary(state);
164
+ }
@@ -1,128 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { deriveIntentSurface } from "../tool-intent-surface.js";
3
-
4
- describe("deriveIntentSurface — gateway lifts model's tool intent into framework-voice status", () => {
5
- describe("tool-class verb mapping", () => {
6
- it("Bash → running", () => {
7
- const out = deriveIntentSurface("Bash", { command: "ls -la /var/log" });
8
- expect(out.text).toContain("<i>running:</i>");
9
- expect(out.text).toContain("ls -la /var/log");
10
- });
11
-
12
- it("WebSearch → searching", () => {
13
- const out = deriveIntentSurface("WebSearch", { query: "Victoria drink driving" });
14
- expect(out.text).toContain("<i>searching:</i>");
15
- expect(out.text).toContain("Victoria drink driving");
16
- });
17
-
18
- it("WebFetch → fetching (hostname extracted)", () => {
19
- const out = deriveIntentSurface("WebFetch", { url: "https://example.com/a/b" });
20
- expect(out.text).toContain("<i>fetching:</i>");
21
- expect(out.text).toContain("example.com");
22
- });
23
-
24
- it("Read → reading (basename only)", () => {
25
- const out = deriveIntentSurface("Read", { file_path: "/etc/os-release" });
26
- expect(out.text).toContain("<i>reading:</i>");
27
- expect(out.text).toContain("os-release");
28
- expect(out.text).not.toContain("/etc/");
29
- });
30
-
31
- it("Write → writing", () => {
32
- const out = deriveIntentSurface("Write", { file_path: "/tmp/hello.sh" });
33
- expect(out.text).toContain("<i>writing:</i>");
34
- expect(out.text).toContain("hello.sh");
35
- });
36
-
37
- it("Edit / MultiEdit / NotebookEdit → editing", () => {
38
- for (const t of ["Edit", "MultiEdit", "NotebookEdit"]) {
39
- expect(
40
- deriveIntentSurface(t, { file_path: "/a/foo.ts" }).text,
41
- ).toContain("<i>editing:</i>");
42
- }
43
- });
44
-
45
- it("Grep / Glob → searching", () => {
46
- expect(
47
- deriveIntentSurface("Grep", { pattern: "TODO", path: "src/" }).text,
48
- ).toContain("<i>searching:</i>");
49
- expect(
50
- deriveIntentSurface("Glob", { pattern: "**/*.ts" }).text,
51
- ).toContain("<i>searching:</i>");
52
- });
53
-
54
- it("Task / Agent → dispatching", () => {
55
- expect(
56
- deriveIntentSurface("Task", { description: "review the auth code" }).text,
57
- ).toContain("<i>dispatching:</i>");
58
- });
59
- });
60
-
61
- describe("user-facing tools stay quiet (never re-surfaced)", () => {
62
- const surfaceTools = [
63
- "mcp__switchroom-telegram__reply",
64
- "mcp__switchroom-telegram__stream_reply",
65
- "mcp__switchroom-telegram__edit_message",
66
- "mcp__switchroom-telegram__react",
67
- "mcp__switchroom-telegram__send_typing",
68
- "mcp__switchroom-telegram__progress_update",
69
- ];
70
- for (const tool of surfaceTools) {
71
- it(`returns null for ${tool}`, () => {
72
- expect(
73
- deriveIntentSurface(tool, { text: "hi", chat_id: "1" }).text,
74
- ).toBeNull();
75
- });
76
- }
77
- });
78
-
79
- describe("unknown MCP tools", () => {
80
- it("uses 'using <tool>' for unknown MCP tool servers", () => {
81
- const out = deriveIntentSurface(
82
- "mcp__google-workspace__list_drive_files",
83
- { folderId: "abc" },
84
- );
85
- expect(out.text).toMatch(/<i>using list[ _]drive[ _]files:?<\/i>/);
86
- });
87
-
88
- it("falls back gracefully when input has no recognisable label field", () => {
89
- const out = deriveIntentSurface("Bash", { weird: "no-command-here" });
90
- // No label resolved → verb-only output
91
- expect(out.text).toBe("<i>running</i>");
92
- });
93
- });
94
-
95
- describe("privacy / safety", () => {
96
- it("escapes HTML in the label so a malicious input can't inject markup", () => {
97
- const out = deriveIntentSurface("Bash", {
98
- command: "echo '<script>alert(1)</script>'",
99
- });
100
- expect(out.text).not.toContain("<script>");
101
- expect(out.text).toContain("&lt;script&gt;");
102
- });
103
-
104
- it("truncates long labels to keep the surface message tight", () => {
105
- const longCmd = "echo " + "x".repeat(500);
106
- const out = deriveIntentSurface("Bash", { command: longCmd });
107
- // toolLabel already truncates Bash to 40 chars; safety cap then
108
- // bounds anything else to MAX_LABEL_LEN.
109
- expect((out.text ?? "").length).toBeLessThan(200);
110
- });
111
-
112
- it("returns null when toolName is empty (defensive)", () => {
113
- expect(deriveIntentSurface("", { command: "x" }).text).toBeNull();
114
- });
115
- });
116
-
117
- describe("precomputed label precedence", () => {
118
- it("uses precomputed label when present (matches toolLabel's contract)", () => {
119
- const out = deriveIntentSurface(
120
- "Bash",
121
- { command: "ls" },
122
- "checking the logs",
123
- );
124
- expect(out.text).toContain("<i>running:</i>");
125
- expect(out.text).toContain("checking the logs");
126
- });
127
- });
128
- });
@@ -1,155 +0,0 @@
1
- /**
2
- * Tool-intent surface — lifts the model's already-formed `tool_use`
3
- * intent (tool name + input) into a brief user-visible Telegram
4
- * message when the model goes to work without first calling reply.
5
- *
6
- * Companion to the PreToolUse ack-first gate (#1921). The gate forces
7
- * the model to author a brief acknowledgement via the reply tool
8
- * before any other tool runs. THIS surface is the lower-overhead
9
- * sibling: when the model's own `tool_use` stream already carries the
10
- * intent (e.g. `Bash {command: "ls -la /var/log"}`), the gateway can
11
- * pass that intent through as the user-visible "we're alive and this
12
- * is what we're doing" beat, without the model having to call any
13
- * extra tool.
14
- *
15
- * Why both. The gate produces MODEL-VOICE acks ("on it — checking the
16
- * logs") — warmer, persona-driven. The surface produces FRAMEWORK-
17
- * VOICE pass-throughs ("_running:_ ls -la /var/log") — honest and
18
- * cheaper. They compose: if the gate fires, the model authors an ack
19
- * which lands first; the surface stays quiet (already-acked). If the
20
- * gate fails (kill-switched / regression / hook spawn failure), the
21
- * surface still lands — defence in depth.
22
- *
23
- * Output format: italicised framework verb + colon + the model's own
24
- * `toolLabel()` output. Italics are the conventional "framework
25
- * narrating, not the model speaking" marker; the verb signals which
26
- * lane the work is in. Length capped at ~140 chars by `toolLabel()`
27
- * already; nothing more is added on top.
28
- *
29
- * Privacy posture. The model's `tool_use.input` may contain user-
30
- * provided strings (web search queries, file paths the user named).
31
- * Those are already going to land in chat history one way or another
32
- * (e.g. via the model's reply describing what it did), so surfacing
33
- * a brief label here doesn't expand the leakage surface materially.
34
- * `toolLabel()` already truncates and HTML-escapes its output via
35
- * the renderer.
36
- */
37
-
38
- import { toolLabel } from "./tool-labels.js";
39
-
40
- const MAX_LABEL_LEN = 140;
41
-
42
- /**
43
- * Compute the user-facing "framework verb" for a tool. Verbs match
44
- * the action class so the user reads "running" for Bash, "searching"
45
- * for WebSearch, etc. Tools without a friendly verb fall back to
46
- * `using <ToolName>` — better than blanking out.
47
- */
48
- function frameworkVerbFor(toolName: string): string {
49
- // Strip "mcp__<server>__" prefix to match suffixes consistently.
50
- // Most MCP tools surface as `mcp__<server>__<tool>` in the stream.
51
- const m = /^mcp__[^_]+__(.+)$/.exec(toolName);
52
- const suffix = (m ? m[1] : toolName).toLowerCase();
53
-
54
- switch (suffix) {
55
- case "bash":
56
- case "bashoutput":
57
- case "killshell":
58
- return "running";
59
- case "websearch":
60
- case "grep":
61
- case "glob":
62
- return "searching";
63
- case "webfetch":
64
- return "fetching";
65
- case "read":
66
- return "reading";
67
- case "write":
68
- return "writing";
69
- case "edit":
70
- case "multiedit":
71
- case "notebookedit":
72
- return "editing";
73
- case "todowrite":
74
- case "todoread":
75
- return "noting";
76
- case "task":
77
- case "agent":
78
- return "dispatching";
79
- case "toolsearch":
80
- return "loading tools";
81
- default:
82
- // For unknown / MCP tools, prefer a short generic — "using gdrive"
83
- // is more honest than guessing.
84
- if (m) return `using ${m[1].replace(/_/g, " ")}`;
85
- return `using ${toolName}`;
86
- }
87
- }
88
-
89
- /** A tool that surfaces in the chat itself (reply / stream_reply / etc.)
90
- * — these tools ARE the user surface, so the gateway never re-surfaces
91
- * them. Mirrors `isTelegramSurfaceTool` in `tool-names.ts`. */
92
- function isUserFacingTool(toolName: string): boolean {
93
- const m = /^mcp__switchroom-telegram__(.+)$/.exec(toolName);
94
- const suffix = m ? m[1] : toolName;
95
- return (
96
- suffix === "reply" ||
97
- suffix === "stream_reply" ||
98
- suffix === "edit_message" ||
99
- suffix === "react" ||
100
- suffix === "send_typing" ||
101
- suffix === "pin_message" ||
102
- suffix === "delete_message" ||
103
- suffix === "forward_message" ||
104
- suffix === "download_attachment" ||
105
- suffix === "get_recent_messages" ||
106
- suffix === "progress_update"
107
- );
108
- }
109
-
110
- export interface SurfaceTextResult {
111
- /** Final HTML text the gateway sends to Telegram, or null when the
112
- * surface should NOT fire (tool is user-facing, label is empty, etc.) */
113
- text: string | null;
114
- }
115
-
116
- /**
117
- * Pure decision: given a tool name + input + optional precomputed label
118
- * (from the existing PreToolUse label hook), return the HTML the
119
- * gateway should send, or null to stay quiet.
120
- *
121
- * Exposed for unit tests; the gateway wires this into the `tool_use`
122
- * session-event handler.
123
- */
124
- export function deriveIntentSurface(
125
- toolName: string,
126
- toolInput: Record<string, unknown> | undefined,
127
- precomputedLabel?: string,
128
- ): SurfaceTextResult {
129
- if (!toolName) return { text: null };
130
- if (isUserFacingTool(toolName)) return { text: null };
131
-
132
- const label = toolLabel(toolName, toolInput, undefined, precomputedLabel);
133
- if (!label || !label.trim()) {
134
- // No label available for this tool/input shape — fall back to just
135
- // the verb so the user at least sees "_running_" rather than
136
- // nothing. Keeps the beat reliable on weird inputs.
137
- return {
138
- text: `<i>${escapeHtml(frameworkVerbFor(toolName))}</i>`,
139
- };
140
- }
141
-
142
- const verb = frameworkVerbFor(toolName);
143
- // `toolLabel()` may include backticks / quotes — let those through
144
- // (Telegram HTML doesn't choke on them) but escape any stray inline
145
- // HTML markers so a malicious or odd input can't inject markup.
146
- const safeLabel = escapeHtml(label).slice(0, MAX_LABEL_LEN);
147
- return { text: `<i>${escapeHtml(verb)}:</i> ${safeLabel}` };
148
- }
149
-
150
- function escapeHtml(s: string): string {
151
- return s
152
- .replace(/&/g, "&amp;")
153
- .replace(/</g, "&lt;")
154
- .replace(/>/g, "&gt;");
155
- }