@oked/sdk 0.1.0

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,899 @@
1
+ /**
2
+ * Context translation - converts raw tool calls into human-readable
3
+ * approvals. Output is a sentence-shaped Rendered value: title + optional
4
+ * subline + optional body (quoted block) + optional footnote.
5
+ *
6
+ * Surfaces (Telegram, dashboard) consume Title/Subline/Body/Footnote keys
7
+ * from the `fields` payload. `describe()` returns just the title for
8
+ * backwards-compatible single-line consumers (audit logs, SMS).
9
+ */
10
+ import { extractShellWriteOps } from "./classify.js";
11
+ const BODY_PREVIEW_MAX = 200;
12
+ const COMMAND_INLINE_MAX = 50;
13
+ const DIFF_HUNK_MAX_LINES = 10;
14
+ export function describe(toolName, toolInput) {
15
+ // Single-line consumers (audit logs, SMS) get title + target inlined.
16
+ const r = summarize(toolName, toolInput);
17
+ if (r.target) {
18
+ // Reorder phrasing for "Delete X recursively" / "Force push branch to remote"
19
+ if (r.title === "Delete file recursively")
20
+ return `Delete ${r.target} recursively`;
21
+ if (/^Delete \d+ files/.test(r.title))
22
+ return r.title;
23
+ if (/^(Drop|Truncate|Delete .* from) \d+/.test(r.title))
24
+ return r.title;
25
+ if (r.title === "Push" || r.title === "Force push")
26
+ return `${r.title} ${r.target}`;
27
+ return `${r.title} ${r.target}`;
28
+ }
29
+ return r.title;
30
+ }
31
+ export function describeFields(toolName, toolInput) {
32
+ const r = summarize(toolName, toolInput);
33
+ const out = { Title: r.title, Kind: r.kind };
34
+ if (r.target)
35
+ out.Target = r.target;
36
+ if (r.annotation)
37
+ out.Annotation = r.annotation;
38
+ if (r.subline)
39
+ out.Subline = r.subline;
40
+ if (r.body)
41
+ out.Body = r.body;
42
+ if (r.footnote)
43
+ out.Footnote = r.footnote;
44
+ return out;
45
+ }
46
+ // ───────────────────────────────────────────────────────────────────────
47
+ // Top-level dispatch
48
+ // ───────────────────────────────────────────────────────────────────────
49
+ function summarize(toolName, toolInput) {
50
+ const fileSizeBytes = typeof toolInput._file_size_bytes === "number" ? toolInput._file_size_bytes : undefined;
51
+ switch (toolName) {
52
+ case "Bash":
53
+ return summarizeBash(toolInput.command || "", fileSizeBytes);
54
+ case "Write":
55
+ return summarizeWrite(toolInput);
56
+ case "Edit":
57
+ return summarizeEdit(toolInput);
58
+ case "NotebookEdit":
59
+ return summarizeNotebookEdit(toolInput);
60
+ case "Agent":
61
+ return summarizeAgent(toolInput);
62
+ }
63
+ if (toolName.startsWith("mcp__")) {
64
+ return summarizeMcp(toolName, toolInput);
65
+ }
66
+ // send_email tool name without mcp__ prefix
67
+ if (toolName === "send_email") {
68
+ return summarizeEmail(toolInput);
69
+ }
70
+ // Shell-exec wrappers: signature is a string command/cmd field
71
+ const shellCommand = (toolInput.command ?? toolInput.cmd);
72
+ if (typeof shellCommand === "string") {
73
+ return summarizeBash(shellCommand, fileSizeBytes);
74
+ }
75
+ // File-write wrappers: signature is a path + content/data string
76
+ const writePath = (toolInput.file_path ?? toolInput.path);
77
+ const writeContent = (toolInput.content ?? toolInput.data ?? toolInput.body);
78
+ if (typeof writePath === "string" && typeof writeContent === "string") {
79
+ return summarizeWrite({ file_path: writePath, content: writeContent });
80
+ }
81
+ return summarizeFallback(toolName, toolInput);
82
+ }
83
+ // ───────────────────────────────────────────────────────────────────────
84
+ // Bash / shell — semantic rerendering
85
+ // ───────────────────────────────────────────────────────────────────────
86
+ function summarizeBash(command, sizeBytes) {
87
+ const cmd = (command || "").trim();
88
+ if (!cmd)
89
+ return { title: "Run empty command", kind: "unknown_bash" };
90
+ // SQL detection runs before shell-write detection so that inline-interpreter
91
+ // payloads (`node -e "..."`, `python -c "..."`) aren't misread as shell
92
+ // redirects. JS arrow functions (`t => t.name`) and similar `=>` constructs
93
+ // inside the quoted body otherwise match the `>` redirect regex.
94
+ const sql = findSqlInCommand(cmd);
95
+ if (sql)
96
+ return summarizeSql(sql, cmd);
97
+ const shellWrite = summarizeShellWrite(cmd);
98
+ if (shellWrite)
99
+ return shellWrite;
100
+ // rm / trash — file deletion
101
+ const rmMatch = cmd.match(/\b(?:rm|trash|trash-put|rmdir)\s+(?:(-rf?|-fr|--recursive|-r)\s+)?(.+)$/);
102
+ if (rmMatch) {
103
+ const recursive = !!rmMatch[1] || /^rm\s+-/.test(cmd);
104
+ const targets = parseRmTargets(rmMatch[2]);
105
+ if (targets.length <= 1) {
106
+ const target = targets[0] || "files";
107
+ const sqlExt = !recursive && /\.sql$/i.test(target);
108
+ return {
109
+ title: recursive ? "Delete file recursively" : sqlExt ? "Delete SQL file" : "Delete file",
110
+ target: shortenPath(stripQuotes(target)),
111
+ annotation: sizeBytes !== undefined ? `(${formatByteCount(sizeBytes)})` : undefined,
112
+ kind: "file_delete",
113
+ };
114
+ }
115
+ return {
116
+ title: recursive
117
+ ? `Delete ${targets.length} files recursively`
118
+ : `Delete ${targets.length} files`,
119
+ target: targets.map(t => shortenPath(stripQuotes(t))).join("\n"),
120
+ kind: "file_delete",
121
+ };
122
+ }
123
+ // git
124
+ if (/\bgit\s+push\s+(?:--force|-f)\b/.test(cmd)) {
125
+ const m = cmd.match(/git\s+push\s+(?:--force|-f)\s+(\S+)\s+(\S+)/);
126
+ return { title: "Force push", target: m ? `${m[2]} → ${m[1]}` : "current branch", kind: "git_force_push" };
127
+ }
128
+ if (/\bgit\s+push\b/.test(cmd)) {
129
+ const m = cmd.match(/git\s+push\s+(\S+)\s+(\S+)/);
130
+ return { title: "Push", target: m ? `${m[2]} → ${m[1]}` : "current branch", kind: "git_push" };
131
+ }
132
+ if (/\bgit\s+reset\s+--hard\b/.test(cmd))
133
+ return { title: "Hard reset — discard all local changes", kind: "git_reset_hard" };
134
+ if (/\bgit\s+clean\s+-f/.test(cmd))
135
+ return { title: "Remove all untracked files", kind: "git_clean" };
136
+ if (/\bgit\s+checkout\s+--\s+\./.test(cmd))
137
+ return { title: "Discard all unstaged changes", kind: "git_checkout" };
138
+ if (/\bgit\s+restore\s+--staged\s+\./.test(cmd))
139
+ return { title: "Unstage all staged changes", kind: "git_restore" };
140
+ if (/\bgit\s+commit\b/.test(cmd)) {
141
+ const m = cmd.match(/-m\s+["']([^"']+)["']/);
142
+ return m ? { title: `Git commit "${m[1]}"`, kind: "git_commit" } : { title: "Git commit", kind: "git_commit" };
143
+ }
144
+ // gh pr create — reversible (PRs can be closed). Extract --title when present.
145
+ if (/\bgh\s+pr\s+create\b/.test(cmd)) {
146
+ const m = cmd.match(/--title\s+["']([^"']+)["']/);
147
+ return m
148
+ ? { title: `Create PR "${truncate(m[1], 60)}"`, kind: "git_pr_create" }
149
+ : { title: "Create pull request", kind: "git_pr_create" };
150
+ }
151
+ // ssh to a remote host — remote side effects can't be undone from here.
152
+ // Skip ssh subcommands that aren't remote-exec (`ssh-keygen`, `ssh-add`,
153
+ // `ssh-keyscan`) by requiring a `user@host` token. Pull the remote command
154
+ // (anything after the host) so the approval card shows what will run there.
155
+ if (/^ssh\b/.test(cmd)) {
156
+ const hostM = cmd.match(/(\S+@\S+)(?:\s+(.+))?$/);
157
+ if (hostM) {
158
+ const target = hostM[1];
159
+ const remoteCmd = hostM[2]?.trim();
160
+ return {
161
+ title: `SSH to ${target}`,
162
+ target,
163
+ body: remoteCmd ? truncateBody(remoteCmd) : undefined,
164
+ kind: "ssh_remote",
165
+ };
166
+ }
167
+ }
168
+ // curl with method
169
+ const curlMethod = cmd.match(/curl\s+[^|]*-X\s*(DELETE|POST|PUT|PATCH)/i);
170
+ if (curlMethod) {
171
+ const url = cmd.match(/https?:\/\/[^\s'"]+/)?.[0] || "";
172
+ const host = url ? extractHost(url) : "URL";
173
+ const method = curlMethod[1].toUpperCase();
174
+ const kind = method === "DELETE" ? "http_delete" : method === "POST" ? "http_post" : method === "PUT" ? "http_put" : "http_post";
175
+ return {
176
+ title: `${method} request to ${host}`,
177
+ body: cmd.length > COMMAND_INLINE_MAX ? truncateBody(cmd) : undefined,
178
+ kind,
179
+ };
180
+ }
181
+ if (/\bwget\b.*\|\s*(bash|sh|zsh)\b/.test(cmd) || /\bcurl\b.*\|\s*(bash|sh|zsh)\b/.test(cmd)) {
182
+ const url = cmd.match(/https?:\/\/[^\s|'"]+/)?.[0];
183
+ return {
184
+ title: `Download and execute script${url ? ` from ${extractHost(url)}` : ""}`,
185
+ body: truncateBody(cmd),
186
+ kind: "http_pipe_to_shell",
187
+ };
188
+ }
189
+ // himalaya — email CLI (run BEFORE the pipeline catch-all so
190
+ // `printf ... | himalaya message send` renders as "Send email", not
191
+ // "Run command"). Parses From/To/Subject from the piped headers so
192
+ // the approval card shows recipient + subject, not a raw shell line.
193
+ if (/\bhimalaya\b/.test(cmd)) {
194
+ const h = summarizeHimalaya(cmd);
195
+ if (h)
196
+ return h;
197
+ }
198
+ // Multi-step pipeline
199
+ if (/&&|\|\||;/.test(cmd))
200
+ return { title: "Run command", body: truncateBody(cmd), kind: "shell_pipeline" };
201
+ // docker
202
+ if (/\bdocker\s+compose\s+down\b/.test(cmd))
203
+ return { title: "Stop and remove Docker containers", kind: "docker_down" };
204
+ if (/\bdocker\s+compose\s+up\b/.test(cmd))
205
+ return { title: "Start Docker containers", kind: "docker_up" };
206
+ if (/\bdocker\s+system\s+prune\b/.test(cmd))
207
+ return { title: "Prune unused Docker resources", kind: "docker_prune" };
208
+ const dockerRmi = cmd.match(/\bdocker\s+rmi\s+(\S+)/);
209
+ if (dockerRmi)
210
+ return { title: "Remove Docker image", target: dockerRmi[1], kind: "docker_rmi" };
211
+ const dockerRm = cmd.match(/\bdocker\s+rm\s+(\S+)/);
212
+ if (dockerRm)
213
+ return { title: "Remove Docker container", target: dockerRm[1], kind: "docker_rm" };
214
+ // npm / npx
215
+ const npmScript = cmd.match(/\bnpm\s+run\s+(\S+)/);
216
+ if (npmScript)
217
+ return { title: `Run npm script ${npmScript[1]}`, kind: "npm_run" };
218
+ if (/\bnpm\s+install\b/.test(cmd))
219
+ return { title: "Install npm dependencies", kind: "npm_install" };
220
+ if (/\bnpm\s+test\b/.test(cmd))
221
+ return { title: "Run npm tests", kind: "npm_test" };
222
+ if (/\bnpm\s+publish\b/.test(cmd))
223
+ return { title: "Publish package to npm", kind: "npm_publish" };
224
+ if (/\bnpm\s+unpublish\b/.test(cmd))
225
+ return { title: "Unpublish package from npm", kind: "npm_unpublish" };
226
+ if (/\bnpx\s+.*\s+deploy\b/.test(cmd))
227
+ return { title: "Deploy via npx", body: truncateBody(cmd), kind: "npx_deploy" };
228
+ // kill / sudo / chmod
229
+ if (/\bkillall\b|\bpkill\b/.test(cmd))
230
+ return { title: "Kill processes", body: truncateBody(cmd), kind: "kill_process" };
231
+ if (/\bkill\b/.test(cmd))
232
+ return { title: "Kill process", body: truncateBody(cmd), kind: "kill_process" };
233
+ if (/\bsudo\b/.test(cmd)) {
234
+ const inner = cmd.replace(/^sudo\s+/, "");
235
+ return { title: `Run as root: ${truncate(inner, COMMAND_INLINE_MAX)}`, body: truncateBody(cmd), kind: "sudo" };
236
+ }
237
+ if (/\bchmod\s+777\b/.test(cmd)) {
238
+ const target = cmd.match(/chmod\s+777\s+(\S+)/)?.[1] || "file";
239
+ return { title: `Make ${target} world-writable (chmod 777)`, kind: "chmod_777" };
240
+ }
241
+ // Long or short — generic command
242
+ if (cmd.length > COMMAND_INLINE_MAX)
243
+ return { title: "Run command", body: truncateBody(cmd), kind: "unknown_bash" };
244
+ return { title: cmd, kind: "unknown_bash" };
245
+ }
246
+ function summarizeShellWrite(cmd) {
247
+ const ops = extractShellWriteOps(cmd);
248
+ if (!ops.length)
249
+ return null;
250
+ const op = ops.find((o) => o.kind !== "copy" && o.kind !== "move") || ops[0];
251
+ const path = shortenPath(op.target);
252
+ switch (op.kind) {
253
+ case "create":
254
+ return { title: "Create file", target: path, body: op.content ?? cmd, kind: "file_create" };
255
+ case "append":
256
+ return { title: "Append to file", target: path, body: op.content ?? cmd, kind: "file_append" };
257
+ case "edit":
258
+ return { title: "Edit file", target: path, body: cmd, kind: "file_edit" };
259
+ case "touch":
260
+ return { title: "Create empty file", target: path, kind: "file_touch" };
261
+ case "copy":
262
+ return {
263
+ title: "Copy file",
264
+ target: op.source ? `${shortenPath(op.source)} → ${path}` : path,
265
+ kind: "file_copy",
266
+ };
267
+ case "move":
268
+ return {
269
+ title: "Move file",
270
+ target: op.source ? `${shortenPath(op.source)} → ${path}` : path,
271
+ kind: "file_move",
272
+ };
273
+ }
274
+ }
275
+ export const SQL_KEYWORDS_RE = /\b(DROP\s+(TABLE|DATABASE|INDEX|VIEW)|TRUNCATE|DELETE\s+FROM|UPDATE\s+\w+\s+SET|INSERT\s+(?:OR\s+\w+\s+)?INTO|CREATE\s+(?:TABLE|INDEX|VIEW)|ALTER\s+TABLE)\b/i;
276
+ // Extract SQL statements from Python/Ruby/JS script bodies — pulls out the
277
+ // string arguments to .execute(), .query(), .run() etc. rather than
278
+ // returning the whole script as the "sql" body.
279
+ function extractSqlFromScriptBody(body) {
280
+ const sqls = [];
281
+ const re = /\.(?:execute|executemany|query|run|exec)\s*\(\s*(?:["'`])([\s\S]+?)(?:["'`])\s*[,)]/gi;
282
+ for (const m of body.matchAll(re)) {
283
+ const sql = m[1].trim().replace(/\s+/g, " ");
284
+ if (SQL_KEYWORDS_RE.test(sql))
285
+ sqls.push(sql);
286
+ }
287
+ return sqls.length > 0 ? sqls.join("\n") : null;
288
+ }
289
+ export function findSqlInCommand(cmd) {
290
+ // Inline interpreter flags: node -e, python -c, ruby -e, perl -e. Checked
291
+ // before the SQL-CLI prefix matchers below because those prefixes (e.g.
292
+ // `sqlite3`) can appear as substrings inside the interpreter body (e.g.
293
+ // `require('better-sqlite3')`) and would extract the wrong fragment.
294
+ const inline = cmd.match(/\b(?:node|python\d?|ruby|perl|deno|bun)\s+-[ec]\s+(?:"([\s\S]+?)"|'([\s\S]+?)')\s*$/);
295
+ if (inline) {
296
+ const body = inline[1] ?? inline[2];
297
+ if (body && SQL_KEYWORDS_RE.test(body)) {
298
+ return extractSqlFromScriptBody(body) ?? body;
299
+ }
300
+ }
301
+ // psql -c "..." / mysql -e "..." / sqlite3 db "..." — outer-quoted statement.
302
+ const dq = cmd.match(/(?:psql|mysql|sqlite3?|mariadb)\b[^"]*"([\s\S]+?)"\s*$/i);
303
+ if (dq)
304
+ return dq[1];
305
+ const sq = cmd.match(/(?:psql|mysql|sqlite3?|mariadb)\b[^']*'([\s\S]+?)'\s*$/i);
306
+ if (sq)
307
+ return sq[1];
308
+ // Heredoc-piped script: <<EOF / <<'EOF' / <<"EOF" / <<-EOF.
309
+ // Single capture for the delimiter (quote-stripped) lets the closing
310
+ // anchor reference it without going through alternation.
311
+ const hd = cmd.match(/<<-?\s*['"]?(\w+)['"]?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\1\b/);
312
+ if (hd) {
313
+ const body = hd[2];
314
+ if (body && SQL_KEYWORDS_RE.test(body)) {
315
+ return extractSqlFromScriptBody(body) ?? body;
316
+ }
317
+ }
318
+ // Direct SQL keywords at top of command
319
+ if (/^\s*(DROP|DELETE\s+FROM|TRUNCATE|UPDATE|INSERT\s+INTO|CREATE\s+TABLE|ALTER\s+TABLE)\b/i.test(cmd)) {
320
+ return cmd;
321
+ }
322
+ return null;
323
+ }
324
+ function summarizeSql(sql, originalCommand) {
325
+ const trimmed = sql.replace(/\s+/g, " ").trim();
326
+ // DROP — collect all targets for compound statements
327
+ const dropMatches = [...trimmed.matchAll(/\bDROP\s+(TABLE|DATABASE|INDEX|VIEW)\s+(?:IF\s+EXISTS\s+)?["`]?([\w.]+)["`]?/gi)];
328
+ if (dropMatches.length === 1) {
329
+ return {
330
+ title: `Drop ${dropMatches[0][1].toLowerCase()}`,
331
+ target: dropMatches[0][2],
332
+ body: sql.length > COMMAND_INLINE_MAX ? truncateBody(sql) : undefined,
333
+ kind: "sql_drop",
334
+ };
335
+ }
336
+ if (dropMatches.length > 1) {
337
+ const names = dropMatches.map(m => m[2]);
338
+ const types = new Set(dropMatches.map(m => m[1].toLowerCase()));
339
+ const typeLabel = types.size === 1 ? `${[...types][0]}s` : "objects";
340
+ return {
341
+ title: `Drop ${dropMatches.length} ${typeLabel}`,
342
+ target: names.join("\n"),
343
+ kind: "sql_drop",
344
+ };
345
+ }
346
+ // TRUNCATE — collect all targets
347
+ const truncMatches = [...trimmed.matchAll(/\bTRUNCATE\s+(?:TABLE\s+)?["`]?([\w.]+)["`]?/gi)];
348
+ if (truncMatches.length === 1) {
349
+ return { title: "Truncate table", target: truncMatches[0][1], annotation: "(delete all rows)", body: truncateBody(sql), kind: "sql_truncate" };
350
+ }
351
+ if (truncMatches.length > 1) {
352
+ return {
353
+ title: `Truncate ${truncMatches.length} tables`,
354
+ target: truncMatches.map(m => m[1]).join("\n"),
355
+ annotation: "(delete all rows)",
356
+ kind: "sql_truncate",
357
+ };
358
+ }
359
+ const alterM = trimmed.match(/\bALTER\s+TABLE\s+["`]?([\w.]+)["`]?/i);
360
+ if (alterM) {
361
+ return { title: "Alter table", target: alterM[1], body: truncateBody(sql), kind: "sql_alter" };
362
+ }
363
+ const createM = trimmed.match(/\bCREATE\s+(TABLE|INDEX|VIEW)\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([\w.]+)["`]?/i);
364
+ if (createM) {
365
+ return { title: `Create SQL ${createM[1].toLowerCase()}`, target: createM[2], body: truncateBody(sql), kind: "sql_create" };
366
+ }
367
+ // DELETE FROM — collect all targets
368
+ const deleteMatches = [...trimmed.matchAll(/\bDELETE\s+FROM\s+["`]?([\w.]+)["`]?/gi)];
369
+ if (deleteMatches.length === 1) {
370
+ const thisStmt = stmtSlice(trimmed, deleteMatches[0].index ?? 0);
371
+ const hasWhere = /\bWHERE\b/i.test(thisStmt);
372
+ return {
373
+ title: hasWhere ? "Delete rows from" : "Delete ALL rows from",
374
+ target: deleteMatches[0][1],
375
+ body: truncateBody(sql),
376
+ kind: hasWhere ? "sql_delete_rows" : "sql_delete_all_rows",
377
+ };
378
+ }
379
+ if (deleteMatches.length > 1) {
380
+ const anyWithoutWhere = deleteMatches.some(m => {
381
+ const stmt = stmtSlice(trimmed, m.index ?? 0);
382
+ return !/\bWHERE\b/i.test(stmt);
383
+ });
384
+ return {
385
+ title: anyWithoutWhere
386
+ ? `Delete ALL rows from ${deleteMatches.length} tables`
387
+ : `Delete rows from ${deleteMatches.length} tables`,
388
+ target: deleteMatches.map(m => m[1]).join("\n"),
389
+ body: truncateBody(sql),
390
+ kind: anyWithoutWhere ? "sql_delete_all_rows" : "sql_delete_rows",
391
+ };
392
+ }
393
+ const updateM = trimmed.match(/\bUPDATE\s+["`]?([\w.]+)["`]?\s+SET/i);
394
+ if (updateM) {
395
+ const thisStmt = stmtSlice(trimmed, updateM.index ?? 0);
396
+ const hasWhere = /\bWHERE\b/i.test(thisStmt);
397
+ return {
398
+ title: hasWhere ? "Update rows in" : "Update EVERY row in",
399
+ target: updateM[1],
400
+ body: truncateBody(sql),
401
+ kind: hasWhere ? "sql_update_rows" : "sql_update_every_row",
402
+ };
403
+ }
404
+ const insertM = trimmed.match(/\bINSERT\s+(?:OR\s+\w+\s+)?INTO\s+["`]?([\w.]+)["`]?/i);
405
+ if (insertM) {
406
+ return { title: "Insert rows into", target: insertM[1], body: truncateBody(sql), kind: "sql_insert" };
407
+ }
408
+ return { title: "Run SQL statement", body: truncateBody(sql || originalCommand), kind: "sql_query" };
409
+ }
410
+ // ───────────────────────────────────────────────────────────────────────
411
+ // himalaya — email CLI
412
+ // ───────────────────────────────────────────────────────────────────────
413
+ /**
414
+ * Render a himalaya invocation as a proper email-domain action ("Send email
415
+ * to X", "Delete email N", "Purge folder Y") instead of a raw shell line.
416
+ *
417
+ * Used for both the approval-card display (so the user sees "Send email to
418
+ * orendor@gmail.com" with the subject + sender, not a printf|himalaya
419
+ * pipeline) and the analytics `operation_kind`.
420
+ *
421
+ * Returns null if the command mentions himalaya but doesn't match a known
422
+ * subcommand pattern — caller falls back to the generic pipeline/unknown
423
+ * renderers.
424
+ */
425
+ function summarizeHimalaya(cmd) {
426
+ // message send — the only path that prompts approval today. Parse the
427
+ // headers from the piped payload so the user sees recipient + subject.
428
+ if (/\bhimalaya\s+(?:\S+\s+)*message\s+send\b/.test(cmd)) {
429
+ const headers = extractEmailHeaders(cmd);
430
+ const to = headers.to;
431
+ const subject = headers.subject;
432
+ const from = headers.from;
433
+ const bodyLines = [];
434
+ if (from)
435
+ bodyLines.push(`From: ${from}`);
436
+ if (subject)
437
+ bodyLines.push(`Subject: ${subject}`);
438
+ const previewBody = headers.body ? truncate(headers.body, 200) : undefined;
439
+ if (previewBody)
440
+ bodyLines.push("", previewBody);
441
+ return {
442
+ title: to ? `Send email to ${to}` : "Send email",
443
+ target: to,
444
+ body: bodyLines.length > 0 ? bodyLines.join("\n") : truncateBody(cmd),
445
+ kind: "email_send",
446
+ };
447
+ }
448
+ // message delete <id...> — irreversible (Gmail moves to trash; other
449
+ // servers may hard-delete).
450
+ const delM = cmd.match(/\bhimalaya\s+(?:\S+\s+)*message\s+delete\s+([\d\s,]+)/);
451
+ if (delM) {
452
+ const ids = delM[1].trim();
453
+ const count = ids.split(/[\s,]+/).filter(Boolean).length;
454
+ return {
455
+ title: count > 1 ? `Delete ${count} emails` : "Delete email",
456
+ target: ids,
457
+ annotation: "(irreversible)",
458
+ kind: "email_delete",
459
+ };
460
+ }
461
+ // folder delete / expunge / purge — wipes a mail folder.
462
+ const folderM = cmd.match(/\bhimalaya\s+(?:\S+\s+)*folder\s+(delete|expunge|purge)\s+(\S+)/);
463
+ if (folderM) {
464
+ const verb = folderM[1].toLowerCase();
465
+ const folder = stripQuotes(folderM[2]);
466
+ const verbLabel = verb === "purge" ? "Purge" : verb === "expunge" ? "Expunge" : "Delete";
467
+ return {
468
+ title: `${verbLabel} folder ${folder}`,
469
+ target: folder,
470
+ annotation: "(irreversible)",
471
+ kind: "email_purge",
472
+ };
473
+ }
474
+ return null;
475
+ }
476
+ /**
477
+ * Extract email headers (From/To/Subject) and body from a shell command
478
+ * that pipes a printf/echo/heredoc into `himalaya message send`. Returns
479
+ * empty strings for anything not found.
480
+ *
481
+ * Handles the two common shapes the agent emits:
482
+ * printf "From: a\nTo: b\nSubject: c\n\nbody" | himalaya message send
483
+ * printf %s "From: a\nTo: b\n..." | himalaya message send
484
+ * cat <<'EOF' | himalaya message send (heredoc body, headers inline)
485
+ */
486
+ function extractEmailHeaders(cmd) {
487
+ // Pull the quoted payload from printf/echo if present.
488
+ const quoted = cmd.match(/(?:printf|echo)\s+(?:%s\s+|-[neE]+\s+)*(['"])([\s\S]*?)\1/);
489
+ // Also support heredoc body (cat <<EOF ... EOF).
490
+ const heredoc = cmd.match(/<<\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\b/);
491
+ let payload = quoted?.[2] ?? heredoc?.[2] ?? cmd;
492
+ // Unescape \n / \r / \t to real characters so header regexes work.
493
+ payload = payload.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
494
+ const grab = (name) => {
495
+ const m = payload.match(new RegExp(`(?:^|\\n)\\s*${name}:\\s*([^\\n]+)`, "i"));
496
+ return m ? m[1].trim() : undefined;
497
+ };
498
+ const from = grab("From");
499
+ const to = grab("To");
500
+ const subject = grab("Subject");
501
+ // Body is everything after the first blank line (RFC 822 separator).
502
+ const blank = payload.search(/\n\s*\n/);
503
+ const body = blank >= 0 ? payload.slice(blank).replace(/^\s+/, "") : undefined;
504
+ return { from, to, subject, body };
505
+ }
506
+ // ───────────────────────────────────────────────────────────────────────
507
+ // File operations
508
+ // ───────────────────────────────────────────────────────────────────────
509
+ function summarizeWrite(input) {
510
+ const filePath = (input.file_path ?? input.path);
511
+ const content = (input.content ?? input.data ?? input.body);
512
+ if (!filePath)
513
+ return { title: "Create file", kind: "file_create" };
514
+ const shortPath = shortenPath(filePath);
515
+ const sensitive = /\.(env|pem|key|secret|token)/.test(filePath);
516
+ const title = sensitive ? "Create sensitive file" : "Create file";
517
+ let annotation;
518
+ let body;
519
+ if (typeof content === "string") {
520
+ const bytes = Buffer.byteLength(content, "utf8");
521
+ annotation = `(${formatByteCount(bytes)})`;
522
+ if (content.trim()) {
523
+ body = content.length > BODY_PREVIEW_MAX
524
+ ? content.slice(0, BODY_PREVIEW_MAX - 1).trimEnd() + "…"
525
+ : content;
526
+ }
527
+ }
528
+ return { title, target: shortPath, annotation, body, kind: "file_create" };
529
+ }
530
+ function summarizeEdit(input) {
531
+ const filePath = input.file_path;
532
+ if (!filePath)
533
+ return { title: "Edit file", kind: "file_edit" };
534
+ const shortPath = shortenPath(filePath);
535
+ const oldString = input.old_string;
536
+ const newString = input.new_string;
537
+ if (typeof oldString === "string" && typeof newString === "string") {
538
+ const diff = miniDiff(oldString, newString);
539
+ let footnote;
540
+ let body = diff.lines.slice(0, DIFF_HUNK_MAX_LINES).join("\n");
541
+ if (diff.lines.length > DIFF_HUNK_MAX_LINES) {
542
+ footnote = `… and ${diff.lines.length - DIFF_HUNK_MAX_LINES} more lines`;
543
+ }
544
+ return {
545
+ title: "Edit file",
546
+ target: shortPath,
547
+ annotation: `+${diff.added} −${diff.removed}`,
548
+ body,
549
+ footnote,
550
+ kind: "file_edit",
551
+ };
552
+ }
553
+ return { title: "Edit file", target: shortPath, kind: "file_edit" };
554
+ }
555
+ function miniDiff(oldStr, newStr) {
556
+ const oldLines = oldStr.split(/\r?\n/);
557
+ const newLines = newStr.split(/\r?\n/);
558
+ const lines = [];
559
+ // Naive diff: emit `- oldLine` then `+ newLine` for the changed block.
560
+ // Keep up to a couple of unchanged surrounding lines.
561
+ for (const l of oldLines)
562
+ lines.push(`- ${l}`);
563
+ for (const l of newLines)
564
+ lines.push(`+ ${l}`);
565
+ return { lines, added: newLines.length, removed: oldLines.length };
566
+ }
567
+ function summarizeNotebookEdit(input) {
568
+ const filePath = input.file_path;
569
+ return filePath
570
+ ? { title: "Edit notebook", target: shortenPath(filePath), kind: "file_edit" }
571
+ : { title: "Edit notebook", kind: "file_edit" };
572
+ }
573
+ // ───────────────────────────────────────────────────────────────────────
574
+ // Agents & MCP
575
+ // ───────────────────────────────────────────────────────────────────────
576
+ function summarizeAgent(input) {
577
+ const prompt = input.prompt;
578
+ if (!prompt)
579
+ return { title: "Launch sub-agent", kind: "agent_launch" };
580
+ const short = truncate(prompt, 80);
581
+ return { title: "Launch sub-agent", body: short, kind: "agent_launch" };
582
+ }
583
+ function summarizeMcp(toolName, input) {
584
+ const parts = toolName.split("__");
585
+ const server = parts[1] || "unknown";
586
+ const tool = parts[2] || "";
587
+ if (tool === "send_email")
588
+ return summarizeEmail(input);
589
+ if (tool === "send_message")
590
+ return summarizeMessage(input, server);
591
+ if (tool === "charge_card" || tool === "create_payment" || tool.includes("payment") || tool.includes("charge")) {
592
+ return summarizePayment(tool, input, server);
593
+ }
594
+ if (tool === "query_database") {
595
+ return { title: `Run SQL query via ${server}`, body: input.query, kind: "mcp_query" };
596
+ }
597
+ if (tool === "submit_form") {
598
+ const url = input.url || "";
599
+ const host = url ? extractHost(url) : null;
600
+ return { title: host ? `Submit form at ${host} via ${server}` : `Submit form via ${server}`, kind: "mcp_submit_form" };
601
+ }
602
+ if (tool.startsWith("delete_")) {
603
+ const target = identifyTarget(input);
604
+ const thing = toWords(tool.replace("delete_", ""));
605
+ return target
606
+ ? { title: `Delete ${thing}`, target, annotation: `via ${server}`, kind: "mcp_delete" }
607
+ : { title: `Delete ${thing} via ${server}`, kind: "mcp_delete" };
608
+ }
609
+ if (tool.startsWith("update_")) {
610
+ const target = identifyTarget(input);
611
+ const thing = toWords(tool.replace("update_", ""));
612
+ return target
613
+ ? { title: `Update ${thing}`, target, annotation: `via ${server}`, kind: "mcp_update" }
614
+ : { title: `Update ${thing} via ${server}`, kind: "mcp_update" };
615
+ }
616
+ if (tool.startsWith("create_")) {
617
+ const name = (input.title ?? input.name ?? input.subject ?? null);
618
+ const thing = toWords(tool.replace("create_", ""));
619
+ return {
620
+ title: name ? `Create ${thing} "${truncate(name, 40)}" via ${server}` : `Create ${thing} via ${server}`,
621
+ kind: "mcp_create",
622
+ };
623
+ }
624
+ if (tool.startsWith("post_") || tool.startsWith("publish_")) {
625
+ const verb = tool.startsWith("post_") ? "Post" : "Publish";
626
+ const thing = toWords(tool.replace(/^(post|publish)_/, ""));
627
+ const name = (input.title ?? input.name ?? input.tag ?? null);
628
+ return {
629
+ title: name ? `${verb} ${thing} "${truncate(name, 40)}" via ${server}` : `${verb} ${thing} via ${server}`,
630
+ kind: tool.startsWith("post_") ? "mcp_post" : "mcp_publish",
631
+ };
632
+ }
633
+ if (tool.startsWith("send_")) {
634
+ const thing = toWords(tool.replace("send_", ""));
635
+ const to = (input.to ?? input.recipient ?? input.user ?? null);
636
+ return {
637
+ title: to ? `Send ${thing} to ${to} via ${server}` : `Send ${thing} via ${server}`,
638
+ kind: "mcp_send",
639
+ };
640
+ }
641
+ if (tool === "fill" || tool === "type") {
642
+ const selector = input.selector || input.locator || "field";
643
+ const value = input.value || "";
644
+ return { title: `Fill ${selector} via ${server}`, body: value || undefined, kind: "mcp_fill" };
645
+ }
646
+ if (tool === "submit") {
647
+ const selector = input.selector || input.locator || "form";
648
+ return { title: `Submit ${selector} via ${server}`, kind: "mcp_submit" };
649
+ }
650
+ if (tool.startsWith("list_") || tool.startsWith("get_") || tool.startsWith("search_")) {
651
+ return { title: `${toWords(tool)} via ${server}`, kind: "unknown_tool" };
652
+ }
653
+ return { title: `${toWords(tool)} via ${server}`, kind: "unknown_tool" };
654
+ }
655
+ function identifyTarget(input) {
656
+ const id = input.id ?? input.name ?? input.path ?? input.target ?? input.repo ?? input.repository ?? null;
657
+ if (id == null)
658
+ return null;
659
+ if (typeof id === "number")
660
+ return `#${id}`;
661
+ if (typeof id === "string")
662
+ return /^\d+$/.test(id) ? `#${id}` : id;
663
+ return null;
664
+ }
665
+ // ───────────────────────────────────────────────────────────────────────
666
+ // Email / payment / chat message — sentence-style
667
+ // ───────────────────────────────────────────────────────────────────────
668
+ function summarizeEmail(input) {
669
+ const join = (v) => {
670
+ if (Array.isArray(v))
671
+ return v.filter((x) => x != null && x !== "").map(String).join(", ") || null;
672
+ if (typeof v === "string" && v.trim())
673
+ return v.trim();
674
+ return null;
675
+ };
676
+ const subject = typeof input.subject === "string" && input.subject.trim() ? input.subject.trim() : null;
677
+ const to = join(input.to ?? input.recipient ?? input.recipients);
678
+ const cc = join(input.cc);
679
+ const bcc = join(input.bcc);
680
+ const from = join(input.from ?? input.sender);
681
+ const senderDomain = from && from.includes("@") ? from.split("@")[1].toLowerCase() : null;
682
+ const body = (input.body ?? input.text ?? input.html ?? input.message);
683
+ const attachments = input.attachments;
684
+ const title = subject ? `Send "${truncate(subject, 60)}"` : (to ? `Send email to ${truncate(to, 60)}` : "Send email");
685
+ const sublineParts = [];
686
+ if (subject && to)
687
+ sublineParts.push(`to ${flagExternal(to, senderDomain)}`);
688
+ if (cc)
689
+ sublineParts.push(`cc ${flagExternal(cc, senderDomain)}`);
690
+ if (bcc)
691
+ sublineParts.push(`bcc ${flagExternal(bcc, senderDomain)}`);
692
+ if (from && (subject || to))
693
+ sublineParts.push(`from ${from}`);
694
+ if (Array.isArray(attachments) && attachments.length) {
695
+ const list = attachments.map((a) => {
696
+ if (typeof a === "string")
697
+ return a;
698
+ if (a && typeof a === "object") {
699
+ const name = a.name ?? a.filename ?? "attachment";
700
+ const size = a.size;
701
+ return typeof size === "number" ? `${name} (${formatByteCount(size)})` : String(name);
702
+ }
703
+ return String(a);
704
+ });
705
+ sublineParts.push(`📎 ${list.join(", ")}`);
706
+ }
707
+ let bodyText;
708
+ if (typeof body === "string" && body.trim()) {
709
+ bodyText = body.length > BODY_PREVIEW_MAX
710
+ ? body.slice(0, BODY_PREVIEW_MAX - 1).trimEnd() + "…"
711
+ : body;
712
+ }
713
+ return {
714
+ title,
715
+ subline: sublineParts.length ? sublineParts.join("\n") : undefined,
716
+ body: bodyText,
717
+ kind: "email_send",
718
+ };
719
+ }
720
+ function flagExternal(recipients, senderDomain) {
721
+ if (!senderDomain)
722
+ return recipients;
723
+ const parts = recipients.split(/,\s*/);
724
+ const external = parts.some((p) => {
725
+ const m = p.match(/@([\w.-]+)/);
726
+ return m && m[1].toLowerCase() !== senderDomain;
727
+ });
728
+ return external ? `${recipients} (external)` : recipients;
729
+ }
730
+ function summarizePayment(tool, input, server) {
731
+ const amount = formatMoney(input.amount, input.currency);
732
+ const card = input.card || "";
733
+ const last4 = (input.last4 ?? input.last_four ?? (card.length >= 4 ? card.slice(-4) : null));
734
+ const merchant = (input.merchant ?? input.payee ?? input.to);
735
+ const memo = (input.memo ?? input.note);
736
+ let title;
737
+ let kind;
738
+ if (tool === "charge_card") {
739
+ title = last4 ? `Charge ${amount} to card ending ${last4}` : `Charge ${amount}`;
740
+ kind = "payment_charge";
741
+ }
742
+ else if (merchant) {
743
+ title = `Send ${amount} to ${merchant}`;
744
+ kind = "payment_create";
745
+ }
746
+ else {
747
+ title = `Create ${amount} payment via ${server}`;
748
+ kind = "payment_create";
749
+ }
750
+ const sublineParts = [];
751
+ if (tool === "charge_card" && merchant)
752
+ sublineParts.push(`merchant ${merchant}`);
753
+ if (typeof memo === "string" && memo.trim())
754
+ sublineParts.push(`memo: ${memo.trim()}`);
755
+ return {
756
+ title,
757
+ subline: sublineParts.length ? sublineParts.join("\n") : undefined,
758
+ kind,
759
+ };
760
+ }
761
+ function summarizeMessage(input, server) {
762
+ const to = (input.to ?? input.chat_id ?? input.channel ?? input.recipient);
763
+ const text = (input.text ?? input.message);
764
+ const title = to ? `Send ${prettyServer(server)} message to ${to}` : `Send ${prettyServer(server)} message`;
765
+ let body;
766
+ if (typeof text === "string" && text.trim()) {
767
+ body = text.length > BODY_PREVIEW_MAX
768
+ ? text.slice(0, BODY_PREVIEW_MAX - 1).trimEnd() + "…"
769
+ : text;
770
+ }
771
+ return { title, body, kind: "chat_message" };
772
+ }
773
+ function prettyServer(server) {
774
+ if (server === "slack")
775
+ return "Slack";
776
+ if (server === "discord")
777
+ return "Discord";
778
+ if (server === "whatsapp")
779
+ return "WhatsApp";
780
+ if (server === "telegram")
781
+ return "Telegram";
782
+ return server;
783
+ }
784
+ // ───────────────────────────────────────────────────────────────────────
785
+ // Fallback
786
+ // ───────────────────────────────────────────────────────────────────────
787
+ function summarizeFallback(toolName, toolInput) {
788
+ const keys = Object.keys(toolInput);
789
+ if (!keys.length)
790
+ return { title: toolName, kind: "unknown_tool" };
791
+ const summary = keys
792
+ .map((k) => {
793
+ const v = toolInput[k];
794
+ const val = typeof v === "string" ? truncate(v, 60) : truncate(JSON.stringify(v), 60);
795
+ return `${k}: ${val}`;
796
+ })
797
+ .join("\n");
798
+ return { title: toolName, body: summary, kind: "unknown_tool" };
799
+ }
800
+ // ───────────────────────────────────────────────────────────────────────
801
+ // Helpers
802
+ // ───────────────────────────────────────────────────────────────────────
803
+ function formatMoney(amount, currency) {
804
+ const num = typeof amount === "number" ? amount : parseFloat(String(amount));
805
+ if (isNaN(num))
806
+ return String(amount);
807
+ const cur = String(currency || "").toLowerCase();
808
+ const symbols = { usd: "$", eur: "€", gbp: "£", jpy: "¥", cad: "CA$", aud: "A$" };
809
+ const symbol = symbols[cur] || (cur ? cur.toUpperCase() + " " : "");
810
+ const wholeCurrencies = ["jpy", "krw", "vnd", "clp"];
811
+ const isWhole = wholeCurrencies.includes(cur);
812
+ const value = isWhole ? num : num / 100;
813
+ return `${symbol}${value.toLocaleString("en-US", { minimumFractionDigits: isWhole ? 0 : 2, maximumFractionDigits: 2 })}`;
814
+ }
815
+ function formatByteCount(bytes) {
816
+ if (bytes < 1024)
817
+ return `${bytes} B`;
818
+ if (bytes < 1024 * 1024)
819
+ return `${Math.round(bytes / 1024)} KB`;
820
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
821
+ }
822
+ function toWords(snake) {
823
+ return snake.replace(/_/g, " ").split(" ").map((w) => (w.length <= 3 ? w.toUpperCase() : w)).join(" ");
824
+ }
825
+ function shortenPath(filePath) {
826
+ return filePath
827
+ .replace(/^\/home\/[^/]+\//, "~/")
828
+ .replace(/^\/Users\/[^/]+\//, "~/");
829
+ }
830
+ function extractHost(url) {
831
+ try {
832
+ return new URL(url).hostname;
833
+ }
834
+ catch {
835
+ return url.slice(0, 40);
836
+ }
837
+ }
838
+ function parseRmTargets(argsString) {
839
+ const targets = [];
840
+ const str = argsString.trim();
841
+ let i = 0;
842
+ while (i < str.length) {
843
+ while (i < str.length && /\s/.test(str[i]))
844
+ i++;
845
+ if (i >= str.length)
846
+ break;
847
+ let token;
848
+ if (str[i] === '"') {
849
+ const close = str.indexOf('"', i + 1);
850
+ if (close === -1) {
851
+ token = str.slice(i + 1);
852
+ i = str.length;
853
+ }
854
+ else {
855
+ token = str.slice(i + 1, close);
856
+ i = close + 1;
857
+ }
858
+ }
859
+ else if (str[i] === "'") {
860
+ const close = str.indexOf("'", i + 1);
861
+ if (close === -1) {
862
+ token = str.slice(i + 1);
863
+ i = str.length;
864
+ }
865
+ else {
866
+ token = str.slice(i + 1, close);
867
+ i = close + 1;
868
+ }
869
+ }
870
+ else {
871
+ const start = i;
872
+ while (i < str.length && !/\s/.test(str[i]))
873
+ i++;
874
+ token = str.slice(start, i);
875
+ }
876
+ if (token && !token.startsWith("-")) {
877
+ targets.push(token);
878
+ }
879
+ }
880
+ return targets;
881
+ }
882
+ function stripQuotes(s) {
883
+ return s.replace(/^['"]|['"]$/g, "");
884
+ }
885
+ function truncateBody(s) {
886
+ return s.length > BODY_PREVIEW_MAX ? s.slice(0, BODY_PREVIEW_MAX - 1).trimEnd() + "…" : s;
887
+ }
888
+ function truncate(s, max) {
889
+ return s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s;
890
+ }
891
+ // Return the portion of `trimmed` starting at `startIdx` up to (but not
892
+ // including) the next SQL statement keyword, so WHERE-checks don't bleed
893
+ // across statement boundaries when multiple statements are joined.
894
+ function stmtSlice(trimmed, startIdx) {
895
+ const after = trimmed.slice(startIdx);
896
+ // Skip past the first keyword+table-name before looking for the next stmt
897
+ const nextKw = after.slice(10).search(/\b(DELETE|INSERT|UPDATE|DROP|CREATE|ALTER|TRUNCATE)\b/i);
898
+ return nextKw >= 0 ? after.slice(0, nextKw + 10) : after;
899
+ }