@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.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/classify.d.ts +17 -0
- package/dist/classify.js +454 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +36 -0
- package/dist/degraded.d.ts +19 -0
- package/dist/degraded.js +25 -0
- package/dist/describe.d.ts +23 -0
- package/dist/describe.js +899 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +28 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +268 -0
- package/dist/kinds.d.ts +10 -0
- package/dist/kinds.js +9 -0
- package/dist/rules.d.ts +97 -0
- package/dist/rules.js +105 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/package.json +51 -0
package/dist/describe.js
ADDED
|
@@ -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
|
+
}
|