switchroom 0.14.12 → 0.14.13

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.
@@ -1,46 +1,35 @@
1
1
  /**
2
- * Build the inline-keyboard permission approval message — title + body.
2
+ * Human-readable text for the Telegram permission approval card.
3
3
  *
4
- * Two related concerns:
4
+ * The operator is (often) non-technical. A card must read as a plain
5
+ * sentence — "Gymbro wants to edit: supplement-log.md" — never a raw
6
+ * tool identifier (`mcp__perplexity__search`, `Edit:`). Two surfaces:
5
7
  *
6
- * `summarizeToolForTitle` (one line, no escaping) is the bare summary
7
- * used in the always-allow rule label and as the body-builder's
8
- * internal building block. Pre-#186 the title was always `🔐
9
- * Permission: ${toolName}` — for a `Skill` or `Bash` call the user
10
- * couldn't tell which skill / command was being approved without
11
- * tapping "See more".
8
+ * `formatPermissionCardBody` the card itself: a one-line natural
9
+ * title plus the agent's stated reason. No tool ids, no scope chrome
10
+ * (scope only appears once the operator taps "🔁 Always…").
12
11
  *
13
- * `formatPermissionCardBody` (multi-line, HTML-escaped for
14
- * parse_mode=HTML) is the body of the card itself. Pre-#1790 the
15
- * collapsed card was a single line operators had to tap "See more"
16
- * to see the agent's stated reason or input preview. This mirrors
17
- * the vault `vault_request_access` card's three-line layout (the
18
- * gold standard) so every approval surface answers "what" + "why"
19
- * without an expand tap.
12
+ * `describeGrant` the confirmation after a grant lands: "Gymbro can
13
+ * now edit any file without asking" phrased from the *scope the
14
+ * operator chose*, so the breadth of an always-allow is legible after
15
+ * the fact, not just before.
20
16
  *
21
- * See #186 (title) and #1790 (body).
17
+ * See #186 (title), #1790 (reason line), and the scoped-card work.
22
18
  */
23
19
 
24
20
  import { basename } from "node:path";
21
+ import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
25
22
 
26
- const COMMAND_TITLE_MAX = 40;
27
- const PATH_TITLE_MAX = 40;
23
+ const COMMAND_TITLE_MAX = 48;
28
24
  const DESCRIPTION_LINE_MAX = 240;
29
- const INPUT_VALUE_MAX = 60;
30
25
 
31
26
  /**
32
- * Human-friendly descriptions for switchroom-managed MCP tools. The
33
- * raw `mcp__<server>__<tool>` name is operator-unfriendly they shouldn't
34
- * have to decode the namespace to understand what the agent is asking
35
- * to do. Use this map to turn the code-level identifier into a verb
36
- * phrase ("Read its own merged config" instead of
37
- * "mcp__agent-config__config_get") for the approval card.
38
- *
39
- * Note: post-#1215 these tools are pre-allowed in scaffolded
40
- * settings.permissions.allow, so the card should fire rarely.
41
- * This map is for the fallback path — agents the operator
42
- * narrowed the allowlist on, or tools added in future PRs that
43
- * haven't shipped the allowlist bump yet.
27
+ * Human verb-phrases for switchroom-managed MCP tools. The raw
28
+ * `mcp__<server>__<tool>` name is operator-hostile. Phrases are written
29
+ * to slot in after "wants to" / "can now" e.g. "read its own merged
30
+ * config". Internal-server tools (agent-config / hostd / hindsight /
31
+ * telegram) read fine alone; external integrations get a "(Server)" tag
32
+ * appended so the operator knows which third party is involved.
44
33
  */
45
34
  const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
46
35
  // agent-config — every agent's self-service surface (#1163, #1215)
@@ -65,160 +54,225 @@ const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
65
54
  "mcp__hindsight__recall": "Recall relevant memories",
66
55
  "mcp__hindsight__retain": "Retain a memory",
67
56
  "mcp__hindsight__reflect": "Reflect across its memory bank",
57
+ // external integrations — common verbs (get a "(Server)" tag)
58
+ "mcp__perplexity__search": "Search the web",
59
+ "mcp__perplexity__ask": "Ask the web",
68
60
  };
69
61
 
62
+ const INTERNAL_MCP_SERVERS = new Set([
63
+ "agent-config",
64
+ "hostd",
65
+ "hindsight",
66
+ "switchroom-telegram",
67
+ ]);
68
+
69
+ /**
70
+ * Build the multi-line card body for an approval prompt.
71
+ *
72
+ * 🔐 <b>Gymbro</b> wants to edit: supplement-log.md
73
+ * why: <i>logging today's lifts</i>
74
+ *
75
+ * Output is HTML-escaped for `parse_mode: 'HTML'`. The agent name is
76
+ * capitalized for the sentence; dropped (with "wants to") when null —
77
+ * the bridge client can be anonymous during early-boot edge cases.
78
+ */
79
+ export function formatPermissionCardBody(opts: {
80
+ toolName: string;
81
+ inputPreview: string | undefined;
82
+ description: string | undefined;
83
+ agentName: string | null;
84
+ }): string {
85
+ const action = naturalAction(opts.toolName, opts.inputPreview);
86
+ const lines: string[] = [];
87
+
88
+ if (opts.agentName && opts.agentName.length > 0) {
89
+ lines.push(
90
+ `🔐 <b>${escapeTgHtml(capFirst(opts.agentName))}</b> wants to ${escapeTgHtml(action)}`,
91
+ );
92
+ } else {
93
+ lines.push(`🔐 ${escapeTgHtml(capFirst(action))}`);
94
+ }
95
+
96
+ const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
97
+ const truncatedWhy =
98
+ rawWhy.length > DESCRIPTION_LINE_MAX
99
+ ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "…"
100
+ : rawWhy;
101
+ lines.push(
102
+ truncatedWhy.length > 0
103
+ ? `why: <i>${escapeTgHtml(truncatedWhy)}</i>`
104
+ : `why: <i>not provided</i>`,
105
+ );
106
+
107
+ return lines.join("\n");
108
+ }
109
+
70
110
  /**
71
- * Build a title fragment for a permission prompt. Returns the toolName
72
- * for any tool we don't recognise the helper is intentionally
73
- * conservative: better to keep the bare name than render gibberish from
74
- * a malformed input_preview.
111
+ * The natural-language action for a tool call the part that reads
112
+ * after "wants to". No tool identifiers, no scope.
75
113
  */
76
- export function summarizeToolForTitle(
114
+ export function naturalAction(
77
115
  toolName: string,
78
116
  inputPreview: string | undefined,
79
117
  ): string {
80
- // MCP tools: `mcp__<server>__<verb>`. Prefer a curated human
81
- // description (so the card reads "Read its own merged config"
82
- // instead of "mcp__agent-config__config_get"). Fall through to a
83
- // generic `<server>: <verb-with-spaces>` shape for unknown MCP
84
- // tools and finally to the raw name when even that fails. When
85
- // we have an input preview, append the first arg-value pair so
86
- // the operator sees what's being requested without expanding —
87
- // e.g. `Read its own merged config (key: coolify/api-token)`
88
- // rather than just `Read its own merged config`. (#1790)
89
- if (toolName.startsWith("mcp__")) {
90
- const curated = MCP_TOOL_DESCRIPTIONS[toolName];
91
- const base = curated
92
- ? curated
93
- : (() => {
94
- const parts = toolName.split("__");
95
- if (parts.length >= 3) {
96
- const server = parts[1]!;
97
- const verb = parts.slice(2).join("__").replace(/_/g, " ");
98
- return `${server}: ${verb}`;
99
- }
100
- return toolName;
101
- })();
102
- const argHint = firstScalarArgHint(parseInput(inputPreview));
103
- return argHint ? `${base} (${argHint})` : base;
104
- }
105
-
106
118
  const input = parseInput(inputPreview);
107
- if (!input) return toolName;
119
+
120
+ if (toolName.startsWith("mcp__")) return naturalMcpAction(toolName, input);
108
121
 
109
122
  switch (toolName) {
110
- case "Skill": {
111
- // Claude Code's Skill tool input shape has shifted across versions
112
- // and skill flavours. Read defensively from every known field
113
- // before falling back. The skill name is the most identifying
114
- // field of the prompt; never drop it silently.
115
- //
116
- // (#1790) Final fallback added: when no skill-name key matches,
117
- // try `command` (some Skill variants pass the invocation under
118
- // that key), then the first scalar arg-value pair. Pre-fix the
119
- // default returned a bare `Skill` with zero context — operators
120
- // saw "🔐 Permission: Skill" with no way to tell what was being
121
- // asked without tapping See more.
122
- const skill =
123
- readString(input, "skill") ??
124
- readString(input, "skill_name") ??
125
- readString(input, "skillName") ??
126
- readString(input, "name") ??
127
- skillBasenameFromPath(input);
128
- if (skill) return `${toolName} (${skill})`;
129
- const command = readString(input, "command");
130
- if (command) return `${toolName}: ${truncate(command, COMMAND_TITLE_MAX)}`;
131
- const argHint = firstScalarArgHint(input);
132
- return argHint ? `${toolName} (${argHint})` : toolName;
133
- }
134
- case "Bash": {
135
- const command = readString(input, "command");
136
- return command ? `${toolName}: ${truncate(command, COMMAND_TITLE_MAX)}` : toolName;
137
- }
138
- case "Read":
139
123
  case "Edit":
140
- case "Write":
141
124
  case "MultiEdit":
142
125
  case "NotebookEdit": {
143
- const filePath = readString(input, "file_path") ?? readString(input, "notebook_path");
144
- return filePath ? `${toolName}: ${truncate(basename(filePath), PATH_TITLE_MAX)}` : toolName;
126
+ const f = fileBase(input);
127
+ return f ? `edit: ${f}` : "edit files";
128
+ }
129
+ case "Write": {
130
+ const f = fileBase(input);
131
+ return f ? `write: ${f}` : "write files";
132
+ }
133
+ case "Read": {
134
+ const f = fileBase(input);
135
+ return f ? `read: ${f}` : "read files";
136
+ }
137
+ case "Bash": {
138
+ const c = input ? readString(input, "command") : null;
139
+ return c ? `run: ${truncate(c, COMMAND_TITLE_MAX)}` : "run shell commands";
140
+ }
141
+ case "Skill": {
142
+ const s = input ? resolveSkillName(input) : null;
143
+ return s ? `use the ${s} skill` : "use a skill";
145
144
  }
146
145
  case "Glob":
147
146
  case "Grep": {
148
- const pattern = readString(input, "pattern");
149
- return pattern ? `${toolName}: ${truncate(pattern, COMMAND_TITLE_MAX)}` : toolName;
147
+ const p = input ? readString(input, "pattern") : null;
148
+ return p ? `search files for: ${truncate(p, COMMAND_TITLE_MAX)}` : "search files";
150
149
  }
151
- case "WebFetch":
152
150
  case "WebSearch": {
153
- const query = readString(input, "url") ?? readString(input, "query");
154
- return query ? `${toolName}: ${truncate(query, COMMAND_TITLE_MAX)}` : toolName;
151
+ const q = input ? readString(input, "query") : null;
152
+ return q ? `search the web for: ${truncate(q, COMMAND_TITLE_MAX)}` : "search the web";
153
+ }
154
+ case "WebFetch": {
155
+ const u = input ? readString(input, "url") : null;
156
+ return u ? `fetch a web page: ${truncate(u, COMMAND_TITLE_MAX)}` : "fetch a web page";
155
157
  }
158
+ case "Task":
159
+ case "Agent":
160
+ return "dispatch a sub-agent";
161
+ case "TodoWrite":
162
+ return "update its task list";
163
+ case "ExitPlanMode":
164
+ return "exit plan mode";
156
165
  default:
157
- return toolName;
166
+ return `use ${toolName}`;
167
+ }
168
+ }
169
+
170
+ function naturalMcpAction(
171
+ toolName: string,
172
+ input: Record<string, unknown> | null,
173
+ ): string {
174
+ void input;
175
+ const parts = toolName.split("__");
176
+ const server = parts.length >= 2 ? parts[1]! : "";
177
+ const curated = MCP_TOOL_DESCRIPTIONS[toolName];
178
+ if (curated) {
179
+ const phrase = lowerFirst(curated);
180
+ return INTERNAL_MCP_SERVERS.has(server)
181
+ ? phrase
182
+ : `${phrase} (${prettyMcpServer(server)})`;
158
183
  }
184
+ if (parts.length >= 3) {
185
+ const verb = parts.slice(2).join("__").replace(/_/g, " ");
186
+ return INTERNAL_MCP_SERVERS.has(server)
187
+ ? verb
188
+ : `${verb} (${prettyMcpServer(server)})`;
189
+ }
190
+ return `use ${toolName}`;
159
191
  }
160
192
 
161
193
  /**
162
- * Build the multi-line collapsed body of an approval card (#1790).
163
- *
164
- * Pre-fix the card was a single line `🔐 Permission: <title>` —
165
- * and the agent's stated `description` plus the input preview only
166
- * surfaced when the operator tapped "See more". For skill / generic
167
- * tool prompts the title alone (e.g. `Skill (mail)`) is rarely
168
- * enough to approve at a glance; the operator needs to see *why*
169
- * before they tap Allow / Deny.
170
- *
171
- * Layout mirrors the `vault_request_access` card (the gold standard):
194
+ * Confirmation phrase describing a grant that just landed, derived from
195
+ * the *scope option the operator chose* — so an always-allow's breadth
196
+ * is legible after the fact. Slots in after "<Agent> can now …":
172
197
  *
173
- * 🔐 <agent> · <tool summary>
174
- * why: <description-or-"not provided">
175
- *
176
- * The agent line is dropped when `agentName` is null (the
177
- * gateway's bridge client may be anonymous during early-boot edge
178
- * cases — better to render the title than a misleading blank).
179
- *
180
- * Output is HTML-escaped and intended for `parse_mode: 'HTML'`
181
- * via Telegram's Bot API.
198
+ * "edit any file" / "edit supplement-log.md" / "run npm commands" /
199
+ * "use the mail skill" / "use any Perplexity tool"
182
200
  */
183
- export function formatPermissionCardBody(opts: {
184
- toolName: string;
185
- inputPreview: string | undefined;
186
- description: string | undefined;
187
- agentName: string | null;
188
- }): string {
189
- const summary = summarizeToolForTitle(opts.toolName, opts.inputPreview);
190
- const lines: string[] = [];
201
+ export function describeGrant(
202
+ toolName: string,
203
+ inputPreview: string | undefined,
204
+ option: ScopeOption,
205
+ ): string {
206
+ const rule = option.rule;
191
207
 
192
- const agentBit = opts.agentName && opts.agentName.length > 0
193
- ? `<b>${escapeTgHtml(opts.agentName)}</b> · `
194
- : "";
195
- lines.push(`🔐 ${agentBit}${escapeTgHtml(summary)}`);
208
+ // MCP wildcard "use any <Server> tool".
209
+ if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
210
+ const server = rule.split("__")[1] ?? "";
211
+ return `use any ${prettyMcpServer(server)} tool`;
212
+ }
196
213
 
197
- // The agent's stated reason. Always render the line — when the
198
- // agent omitted a `description`, render an explicit
199
- // `why: <i>not provided</i>` rather than skip silently, so the
200
- // missing-rationale is visible as an agent-side failure (matches
201
- // the vault card's #1790 treatment of an omitted `reason`).
202
- const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
203
- const truncatedWhy =
204
- rawWhy.length > DESCRIPTION_LINE_MAX
205
- ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "…"
206
- : rawWhy;
207
- if (truncatedWhy.length > 0) {
208
- lines.push(`why: <i>${escapeTgHtml(truncatedWhy)}</i>`);
209
- } else {
210
- lines.push(`why: <i>not provided</i>`);
214
+ const scoped = /^([A-Za-z]+)\((.+)\)$/.exec(rule);
215
+ if (scoped) {
216
+ const t = scoped[1]!;
217
+ const arg = scoped[2]!;
218
+ if (t === "Skill") return `use the ${arg} skill`;
219
+ if (t === "Bash") {
220
+ const m = /^([^:]+):\*$/.exec(arg);
221
+ return m ? `run ${m[1]} commands` : "run that command";
222
+ }
223
+ if (t === "Edit" || t === "MultiEdit" || t === "NotebookEdit")
224
+ return `edit ${basename(arg)}`;
225
+ if (t === "Write") return `write ${basename(arg)}`;
226
+ if (t === "Read") return `read ${basename(arg)}`;
227
+ return naturalAction(toolName, inputPreview);
211
228
  }
212
229
 
213
- return lines.join("\n");
230
+ // Bare tool name — the broad, whole-category grants.
231
+ switch (rule) {
232
+ case "Edit":
233
+ case "MultiEdit":
234
+ case "NotebookEdit":
235
+ return "edit any file";
236
+ case "Write":
237
+ return "write any file";
238
+ case "Read":
239
+ return "read any file";
240
+ case "Bash":
241
+ return "run any command";
242
+ case "Skill":
243
+ return "use any skill";
244
+ default:
245
+ // Exact MCP tool or a broad-only built-in — fall back to the
246
+ // request's natural action.
247
+ return naturalAction(toolName, inputPreview);
248
+ }
214
249
  }
215
250
 
216
- /**
217
- * Minimal HTML escape for Telegram `parse_mode=HTML`. Mirrors
218
- * `escapeHtmlForTg` in gateway.ts; duplicated here to keep
219
- * permission-title.ts free of a gateway import (the file is
220
- * referenced by both server.ts and gateway.ts).
221
- */
251
+ function resolveSkillName(input: Record<string, unknown>): string | null {
252
+ return (
253
+ readString(input, "skill") ??
254
+ readString(input, "skill_name") ??
255
+ readString(input, "skillName") ??
256
+ readString(input, "name") ??
257
+ skillBasenameFromPath(input)
258
+ );
259
+ }
260
+
261
+ function fileBase(input: Record<string, unknown> | null): string | null {
262
+ if (!input) return null;
263
+ const p = readString(input, "file_path") ?? readString(input, "notebook_path");
264
+ return p ? basename(p) : null;
265
+ }
266
+
267
+ function lowerFirst(text: string): string {
268
+ return text.length > 0 ? text.charAt(0).toLowerCase() + text.slice(1) : text;
269
+ }
270
+
271
+ function capFirst(text: string): string {
272
+ return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text;
273
+ }
274
+
275
+ /** Minimal HTML escape for Telegram `parse_mode=HTML`. */
222
276
  function escapeTgHtml(text: string): string {
223
277
  return text
224
278
  .replace(/&/g, "&amp;")
@@ -226,40 +280,6 @@ function escapeTgHtml(text: string): string {
226
280
  .replace(/>/g, "&gt;");
227
281
  }
228
282
 
229
- /**
230
- * Return a `key: value` hint for the first scalar (string / number /
231
- * boolean) arg in the input preview. Used as a last-ditch context
232
- * line on uncurated MCP tools and Skill calls whose canonical
233
- * skill-name fields are all missing.
234
- *
235
- * Skips obviously-routing keys (`chat_id`, `message_thread_id`,
236
- * `request_id`) that aren't useful to a human operator deciding
237
- * whether to approve. Returns `null` when nothing scalar remains.
238
- */
239
- function firstScalarArgHint(
240
- input: Record<string, unknown> | null,
241
- ): string | null {
242
- if (!input) return null;
243
- const SKIP = new Set([
244
- "chat_id",
245
- "chatId",
246
- "message_thread_id",
247
- "messageThreadId",
248
- "request_id",
249
- "requestId",
250
- ]);
251
- for (const [key, value] of Object.entries(input)) {
252
- if (SKIP.has(key)) continue;
253
- if (typeof value === "string" && value.length > 0) {
254
- return `${key}: ${truncate(value, INPUT_VALUE_MAX)}`;
255
- }
256
- if (typeof value === "number" || typeof value === "boolean") {
257
- return `${key}: ${String(value)}`;
258
- }
259
- }
260
- return null;
261
- }
262
-
263
283
  function parseInput(raw: string | undefined): Record<string, unknown> | null {
264
284
  if (!raw || typeof raw !== "string") return null;
265
285
  const trimmed = raw.trim();
@@ -280,21 +300,13 @@ function readString(input: Record<string, unknown>, key: string): string | null
280
300
  return typeof value === "string" && value.length > 0 ? value : null;
281
301
  }
282
302
 
283
- /**
284
- * Some Skill tool variants pass the skill as a directory path (e.g.
285
- * `skills/mail/SKILL.md` or `~/.switchroom/skills/mail`). Lift the
286
- * skill name out of the path so the popup still says `Skill (mail)`
287
- * instead of dumping the full path or bare `Skill`.
288
- */
289
303
  function skillBasenameFromPath(input: Record<string, unknown>): string | null {
290
304
  const path = readString(input, "path") ?? readString(input, "skill_path");
291
305
  if (!path) return null;
292
- // Strip a trailing /SKILL.md or filename so we land on the directory
293
- // basename — that's the canonical skill name in switchroom's layout.
294
306
  const trimmed = path.replace(/\/SKILL\.md$/i, "").replace(/\/$/, "");
295
307
  const lastSlash = trimmed.lastIndexOf("/");
296
- const basename = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
297
- return basename.length > 0 ? basename : null;
308
+ const base = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
309
+ return base.length > 0 ? base : null;
298
310
  }
299
311
 
300
312
  function truncate(text: string, max: number): string {