ocuclaw 1.2.4 → 1.3.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.
Files changed (59) hide show
  1. package/README.md +18 -5
  2. package/dist/config/runtime-config.js +81 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +38 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/downstream-server.js +700 -534
  28. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  29. package/dist/runtime/plugin-update-service.js +216 -0
  30. package/dist/runtime/protocol-adapter.js +9 -0
  31. package/dist/runtime/provider-usage-select.js +168 -0
  32. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  33. package/dist/runtime/relay-core.js +1209 -204
  34. package/dist/runtime/relay-health-monitor.js +172 -0
  35. package/dist/runtime/relay-operation-registry.js +263 -0
  36. package/dist/runtime/relay-service.js +201 -1
  37. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  38. package/dist/runtime/relay-worker-entry.js +32 -0
  39. package/dist/runtime/relay-worker-health.js +272 -0
  40. package/dist/runtime/relay-worker-protocol.js +285 -0
  41. package/dist/runtime/relay-worker-queue.js +202 -0
  42. package/dist/runtime/relay-worker-supervisor.js +1081 -0
  43. package/dist/runtime/relay-worker-transport.js +1051 -0
  44. package/dist/runtime/session-context-service.js +189 -0
  45. package/dist/runtime/session-service.js +615 -24
  46. package/dist/runtime/upstream-runtime.js +1167 -60
  47. package/dist/tools/device-info-tool.js +242 -0
  48. package/dist/tools/glasses-ui-cron.js +427 -0
  49. package/dist/tools/glasses-ui-descriptors.js +261 -0
  50. package/dist/tools/glasses-ui-limits.js +21 -0
  51. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  52. package/dist/tools/glasses-ui-recipes.js +746 -0
  53. package/dist/tools/glasses-ui-surfaces.js +278 -0
  54. package/dist/tools/glasses-ui-template.js +182 -0
  55. package/dist/tools/glasses-ui-tool.js +1147 -0
  56. package/dist/tools/session-title-tool.js +209 -0
  57. package/dist/version.js +2 -0
  58. package/openclaw.plugin.json +163 -15
  59. package/package.json +12 -4
@@ -0,0 +1,906 @@
1
+ import path from "node:path";
2
+
3
+ const DEFAULT_MAX_LABEL_CHARS = 120;
4
+ // No plugin-side preview clipping below the overall label budget: the client's
5
+ // display-width clip (pretext pixel truncation on glasses, Compose ellipsis on
6
+ // phone) is the final physical backstop — see the activity-status redesign spec
7
+ // §8.2. The old 30-char TOOL_PREVIEW_CHARS cap pre-truncated previews well under
8
+ // the glasses line's real pixel capacity, leaving ~90-170px of dead space.
9
+
10
+ const REDACT_QUERY_KEYS = "(token|access_token|api_key|key|password|secret)";
11
+
12
+ function isObject(value) {
13
+ return value && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+
16
+ function asString(value) {
17
+ return typeof value === "string" ? value : null;
18
+ }
19
+
20
+ function isNullishToken(value) {
21
+ if (typeof value !== "string") return false;
22
+ const normalized = value.trim().toLowerCase();
23
+ return (
24
+ normalized === "null" ||
25
+ normalized === "undefined" ||
26
+ normalized === "(null)" ||
27
+ normalized === "(undefined)" ||
28
+ normalized === "none"
29
+ );
30
+ }
31
+
32
+ function normalizeLowerToken(value) {
33
+ const text = asString(value);
34
+ return text ? text.trim().toLowerCase() : "";
35
+ }
36
+
37
+ function pickString(obj, keys) {
38
+ const entry = pickStringEntry(obj, keys);
39
+ return entry ? entry.value : null;
40
+ }
41
+
42
+ function pickStringEntry(obj, keys) {
43
+ if (!isObject(obj)) return null;
44
+ for (const key of keys) {
45
+ const value = asString(obj[key]);
46
+ if (value && value.trim()) return { key, value };
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function shortText(text, maxChars) {
52
+ if (!text) return "";
53
+ if (text.length <= maxChars) return text;
54
+ if (maxChars <= 3) return ".".repeat(Math.max(maxChars, 0));
55
+ return `${text.slice(0, maxChars - 3)}...`;
56
+ }
57
+
58
+ function collapseWhitespace(text) {
59
+ return text.replace(/\s+/g, " ").trim();
60
+ }
61
+
62
+ function redactSecrets(rawText) {
63
+ if (!rawText) return "";
64
+ let text = String(rawText);
65
+
66
+ text = text.replace(
67
+ new RegExp(`([?&]${REDACT_QUERY_KEYS}=)[^&#\\s]+`, "gi"),
68
+ "$1[redacted]",
69
+ );
70
+ text = text.replace(
71
+ /((?:api[_-]?key|token|password|secret)\s*[=:]\s*)([^,\s"'`]+)/gi,
72
+ "$1[redacted]",
73
+ );
74
+ text = text.replace(/(authorization\s*:\s*bearer\s+)[^\s"'`]+/gi, "$1[redacted]");
75
+ text = text.replace(/\bBearer\s+[A-Za-z0-9._-]{8,}\b/g, "Bearer [redacted]");
76
+ text = text.replace(/\b(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g, "[redacted]");
77
+
78
+ return text;
79
+ }
80
+
81
+ function sanitizeText(rawText, maxChars) {
82
+ const redacted = redactSecrets(rawText);
83
+ const collapsed = collapseWhitespace(redacted);
84
+ return shortText(collapsed, maxChars);
85
+ }
86
+
87
+ function hostFromUrl(urlString) {
88
+ if (!urlString) return null;
89
+ try {
90
+ const parsed = new URL(urlString);
91
+ return parsed.host || null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function extractFirstUrl(text) {
98
+ if (!text) return null;
99
+ const match = text.match(/https?:\/\/[^\s"'`]+/i);
100
+ return match ? match[0] : null;
101
+ }
102
+
103
+ function extractBrowserQueryFromCommand(command) {
104
+ const match = command.match(/[?&]q=([^&"'`\s]+)/i);
105
+ if (!match) return null;
106
+ try {
107
+ return decodeURIComponent(match[1].replace(/\+/g, " "));
108
+ } catch {
109
+ return match[1].replace(/\+/g, " ");
110
+ }
111
+ }
112
+
113
+ function stripQuotes(value) {
114
+ if (!value) return value;
115
+ return String(value).replace(/^['"]+|['"]+$/g, "");
116
+ }
117
+
118
+ function escapeRegex(value) {
119
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
+ }
121
+
122
+ function filenameFromPath(pathValue) {
123
+ if (!pathValue || typeof pathValue !== "string") return null;
124
+ const cleaned = stripQuotes(pathValue.trim());
125
+ if (!cleaned) return null;
126
+ const normalized = cleaned.replace(/[;,)]+$/g, "");
127
+ if (!normalized) return null;
128
+ if (isNullishToken(normalized)) return null;
129
+ // Ignore shell variable/subshell paths like "$f", "${file}", or "$(mktemp)".
130
+ if (/\$[({]?[A-Za-z_][A-Za-z0-9_]*[)}]?/.test(normalized) || /\$\(.+\)/.test(normalized)) {
131
+ return null;
132
+ }
133
+ if (/^(?:\/dev\/(?:null|stdout|stderr)|nul)$/i.test(normalized)) return null;
134
+ return path.basename(normalized);
135
+ }
136
+
137
+ function pickMktempTemplatePath(rawArgs) {
138
+ if (!rawArgs || typeof rawArgs !== "string") return null;
139
+ const tokens = rawArgs
140
+ .match(/"[^"]*"|'[^']*'|[^\s]+/g)
141
+ ?.map((token) => stripQuotes(token).trim())
142
+ ?.filter(Boolean);
143
+ if (!tokens || tokens.length === 0) return null;
144
+
145
+ for (let index = tokens.length - 1; index >= 0; index -= 1) {
146
+ const token = tokens[index];
147
+ if (!token || token === "mktemp" || token.startsWith("-")) continue;
148
+ if (isNullishToken(token)) continue;
149
+ return token;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ function extractMktempBindings(command) {
155
+ if (!command || typeof command !== "string") return [];
156
+ const out = [];
157
+ const regex = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\$\(\s*mktemp\b([^)]*)\)/g;
158
+ let match;
159
+ while ((match = regex.exec(command)) !== null) {
160
+ const varName = match[1];
161
+ const templatePath = pickMktempTemplatePath(match[2]);
162
+ const fileName = filenameFromPath(templatePath);
163
+ if (!fileName) continue;
164
+ out.push({ varName, fileName });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ function commandRefsVarWithRedirect(command, varName, operator) {
170
+ if (!command || !varName) return false;
171
+ const varRef = `\\$\\{?${escapeRegex(varName)}\\}?`;
172
+ const op = escapeRegex(operator);
173
+ const regex = new RegExp(`(?:^|\\s)${op}\\s*(?:["']?${varRef}["']?)`);
174
+ return regex.test(command);
175
+ }
176
+
177
+ function commandReadsVarWithCat(command, varName) {
178
+ if (!command || !varName) return false;
179
+ const varRef = `\\$\\{?${escapeRegex(varName)}\\}?`;
180
+ const regex = new RegExp(`(?:^|\\s)cat\\s+(?:["']?${varRef}["']?)`);
181
+ return regex.test(command);
182
+ }
183
+
184
+ function categoryFromToolName(lowName) {
185
+ if (lowName.startsWith("browser") || lowName === "web" || lowName === "web.search" || lowName === "web_search") return "browser";
186
+ if (lowName === "read" || lowName === "write" || lowName === "edit" || lowName.startsWith("fs.")) return "filesystem";
187
+ if (lowName === "search" || lowName.startsWith("vector") || lowName === "grep" || lowName === "find") return "search";
188
+ if (lowName === "exec" || lowName === "bash" || lowName.startsWith("shell")) return "terminal";
189
+ return "generic";
190
+ }
191
+
192
+ function intentFromToolName(lowName, args) {
193
+ const query = pickString(args, ["query", "q", "term", "search"]);
194
+
195
+ switch (lowName) {
196
+ case "read":
197
+ case "fs.read":
198
+ return "fs.read";
199
+ case "write":
200
+ case "apply_patch":
201
+ case "fs.write":
202
+ return "fs.write";
203
+ case "edit":
204
+ case "fs.edit":
205
+ return "fs.edit";
206
+ case "search":
207
+ case "grep":
208
+ case "find":
209
+ return "search.files";
210
+ case "browser.search":
211
+ case "web.search":
212
+ return "search.web";
213
+ case "browser.click":
214
+ case "browser.navigate":
215
+ return "browser.navigate";
216
+ case "browser.fill":
217
+ return "browser.fill";
218
+ case "browser":
219
+ case "web":
220
+ return query ? "search.web" : "browser.browse";
221
+ case "exec":
222
+ case "bash":
223
+ return "terminal.exec";
224
+ case "git":
225
+ return "terminal.git";
226
+ case "llm_task":
227
+ return "agent.subtask";
228
+ case "agent_send":
229
+ return "agent.coordinate";
230
+ case "message":
231
+ return "message.send";
232
+ case "sessions_list":
233
+ case "sessions_read":
234
+ case "session_status":
235
+ return "session.manage";
236
+ case "canvas":
237
+ return "canvas.edit";
238
+ case "fetch":
239
+ return "network.fetch";
240
+ default:
241
+ break;
242
+ }
243
+
244
+ if (lowName.startsWith("browser")) {
245
+ if (lowName.includes("fill")) return "browser.fill";
246
+ if (lowName.includes("click") || lowName.includes("navigate")) return "browser.navigate";
247
+ if (lowName.includes("search")) return "search.web";
248
+ return "browser.browse";
249
+ }
250
+ if (lowName.startsWith("web")) {
251
+ return lowName.includes("search") ? "search.web" : "browser.browse";
252
+ }
253
+ if (lowName.includes("search")) {
254
+ return lowName.includes("web") || lowName.includes("browser")
255
+ ? "search.web"
256
+ : "search.files";
257
+ }
258
+ if (lowName.startsWith("fs.")) {
259
+ if (lowName.includes("read")) return "fs.read";
260
+ if (lowName.includes("edit")) return "fs.edit";
261
+ return "fs.write";
262
+ }
263
+ if (lowName.startsWith("session")) return "session.manage";
264
+ if (lowName.startsWith("http")) return "network.fetch";
265
+ if (lowName.startsWith("git")) return "terminal.git";
266
+ if (lowName.startsWith("shell")) return "terminal.exec";
267
+ return "generic";
268
+ }
269
+
270
+ const SHELL_WRAPPER_RE = /^(?:\/usr\/bin\/env\s+)?(?:\/(?:usr\/)?bin\/)?(?:ba|z|da)?sh\s+((?:-\w+\s+)+)([\s\S]*)$/;
271
+
272
+ function unwrapShellCommand(raw) {
273
+ let cmd = raw ? String(raw).trim() : "";
274
+ for (let depth = 0; depth < 2; depth += 1) {
275
+ const match = cmd.match(SHELL_WRAPPER_RE);
276
+ if (!match || !/c/.test(match[1])) break;
277
+ let payload = match[2].trim();
278
+ const quote = payload.charAt(0);
279
+ if (quote === '"' || quote === "'") {
280
+ if (payload.length < 2 || !payload.endsWith(quote)) break; // unbalanced -- keep original
281
+ payload = payload.slice(1, -1);
282
+ if (quote === '"') payload = payload.replace(/\\(["\\$`])/g, "$1");
283
+ }
284
+ if (!payload.trim()) break;
285
+ cmd = payload.trim();
286
+ }
287
+ // Strip ONE leading `cd <dir> &&` prefix so the real command classifies.
288
+ const cdMatch = cmd.match(/^cd\s+[^;&|]+&&\s*([\s\S]+)$/);
289
+ if (cdMatch) cmd = cdMatch[1].trim();
290
+ return cmd;
291
+ }
292
+
293
+ const SHELL_KEYWORDS = new Set([
294
+ "if", "then", "else", "elif", "fi", "do", "done",
295
+ "while", "for", "until", "case", "esac", "{", "}", "(", ")", "!", "time",
296
+ ]);
297
+
298
+ // Split into command-position token lists: one list per &&/||/;/|/
299
+ // newline segment, with shell keywords, [ ... ] tests, and leading
300
+ // VAR=value assignments stripped. Quoted separators are not shell-parsed
301
+ // (deterministic v1); the head token of the first segment -- which drives
302
+ // classification -- is unaffected by that limitation.
303
+ function commandSegments(command) {
304
+ const out = [];
305
+ for (const rawSeg of String(command || "").split(/&&|\|\||[;|\n]/)) {
306
+ let tokens = rawSeg.trim().match(/"[^"]*"|'[^']*'|\S+/g) || [];
307
+ for (;;) {
308
+ const head = tokens.length ? stripQuotes(tokens[0]) : null;
309
+ if (head === null) break;
310
+ if (SHELL_KEYWORDS.has(head)) { tokens = tokens.slice(1); continue; }
311
+ if (head === "[") {
312
+ const close = tokens.indexOf("]");
313
+ tokens = close >= 0 ? tokens.slice(close + 1) : [];
314
+ continue;
315
+ }
316
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) { tokens = tokens.slice(1); continue; }
317
+ break;
318
+ }
319
+ if (tokens.length) out.push(tokens.map((t) => stripQuotes(t)));
320
+ }
321
+ return out;
322
+ }
323
+
324
+ function execResult(label, category, intent, subject = null, subjectKind = null) {
325
+ return { label, category, intent, subject: subject || null, subjectKind: subjectKind || null };
326
+ }
327
+
328
+ const TEST_RUNNER_BINS = new Set(["pytest", "jest", "vitest", "mocha"]);
329
+ const PKG_MANAGER_BINS = new Set(["npm", "pnpm", "yarn"]);
330
+
331
+ function classifyExecSegment(tokens) {
332
+ const head = tokens[0];
333
+ const bin = path.basename(head);
334
+ const rest = tokens.slice(1);
335
+ const joined = tokens.join(" ");
336
+ const firstFileArg = (extra = null) => {
337
+ for (const tok of rest) {
338
+ if (tok.startsWith("-")) continue;
339
+ if (extra && extra(tok)) continue;
340
+ return tok;
341
+ }
342
+ return null;
343
+ };
344
+
345
+ if (bin === "cat") {
346
+ const fileName = filenameFromPath(firstFileArg());
347
+ if (fileName) return execResult(`Reading ${fileName}...`, "filesystem", "fs.read", fileName, "file");
348
+ }
349
+ if (bin === "sed") {
350
+ // In-place sed is a WRITE — label it honestly before the read arm.
351
+ const inPlace = rest.some(
352
+ (t) => t === "-i" || t.startsWith("-i.") || t === "--in-place" || t.startsWith("--in-place="),
353
+ );
354
+ if (inPlace) {
355
+ const editTargets = rest.filter((t) => !t.startsWith("-"));
356
+ const editFileName = filenameFromPath(editTargets.length ? editTargets[editTargets.length - 1] : null);
357
+ if (editFileName) {
358
+ return execResult(`Editing ${editFileName}...`, "filesystem", "fs.edit", editFileName, "file");
359
+ }
360
+ }
361
+ const range = joined.match(/-n\s*['"]?(\d+),(\d+)p/);
362
+ const candidates = rest.filter((t) => !t.startsWith("-") && !/^\d+,\d+p$/.test(t));
363
+ const fileName = filenameFromPath(candidates.length ? candidates[candidates.length - 1] : null);
364
+ if (fileName) {
365
+ const suffix = range ? ` (lines ${range[1]}-${range[2]})` : "";
366
+ return execResult(`Reading ${fileName}${suffix}...`, "filesystem", "fs.read", fileName, "file");
367
+ }
368
+ }
369
+ if (bin === "head" || bin === "tail") {
370
+ const fileName = filenameFromPath(firstFileArg((tok) => /^\d+$/.test(tok)));
371
+ if (fileName) return execResult(`Reading ${fileName}...`, "filesystem", "fs.read", fileName, "file");
372
+ }
373
+ if (bin === "ls") return execResult("Listing files...", "search", "search.files");
374
+ if (bin === "wc") return execResult("Counting matches...", "search", "search.files");
375
+ if (bin === "grep" || bin === "rg" || bin === "find" || bin === "fd" || bin === "ag") {
376
+ return execResult("Searching files...", "search", "search.files", null, "phrase");
377
+ }
378
+ if (bin === "git") {
379
+ // Skip value-taking global flags (`git -C <path> status`, `git -c k=v
380
+ // commit`) so the flag's ARGUMENT is never mislabeled as the subcommand.
381
+ let sub = null;
382
+ for (let index = 0; index < rest.length; index += 1) {
383
+ const token = rest[index];
384
+ if (token === "-C" || token === "-c") { index += 1; continue; }
385
+ if (token.startsWith("-")) continue;
386
+ sub = token;
387
+ break;
388
+ }
389
+ return execResult(sub ? `Running git ${sub}...` : "Running git...", "terminal", "terminal.git");
390
+ }
391
+ if (
392
+ (PKG_MANAGER_BINS.has(bin) && /\btest\b/.test(joined)) ||
393
+ TEST_RUNNER_BINS.has(bin) ||
394
+ (bin === "node" && /(^|\s)--test\b/.test(joined)) ||
395
+ ((bin === "cargo" || bin === "go") && rest[0] === "test") ||
396
+ (/^gradlew?$/.test(bin) && /\btest\b/.test(joined))
397
+ ) {
398
+ return execResult("Running tests...", "terminal", "terminal.exec");
399
+ }
400
+ if (bin === "tsc" || bin === "mypy" || (PKG_MANAGER_BINS.has(bin) && /\b(typecheck|tsc)\b/.test(joined))) {
401
+ return execResult("Checking types...", "terminal", "terminal.exec");
402
+ }
403
+ if (
404
+ (PKG_MANAGER_BINS.has(bin) && /\bbuild\b/.test(joined)) ||
405
+ bin === "make" ||
406
+ (/^gradlew?$/.test(bin) && /\b(build|assemble)\b/.test(joined)) ||
407
+ ((bin === "cargo" || bin === "go") && rest[0] === "build")
408
+ ) {
409
+ return execResult("Building project...", "terminal", "terminal.exec");
410
+ }
411
+ if (bin === "openclaw") return execResult("Running openclaw...", "terminal", "terminal.exec");
412
+ if (bin === "test") return execResult("Checking files...", "search", "search.files");
413
+ if (bin === "python" || bin === "python3" || bin === "node") {
414
+ return execResult("Running a script...", "terminal", "terminal.exec");
415
+ }
416
+ return null;
417
+ }
418
+
419
+ function labelFromExecCommand(command) {
420
+ const original = command ? String(command).trim() : "";
421
+ if (!original) {
422
+ return execResult("Running a command...", "terminal", "terminal.exec");
423
+ }
424
+ const raw = unwrapShellCommand(original);
425
+
426
+ // 1. agent-browser (substring, unchanged behavior)
427
+ if (raw.includes("agent-browser")) {
428
+ const query = extractBrowserQueryFromCommand(raw);
429
+ if (query) {
430
+ return execResult(
431
+ `Searching "${sanitizeText(query, DEFAULT_MAX_LABEL_CHARS)}"...`,
432
+ "browser", "search.web", query, "query",
433
+ );
434
+ }
435
+ const browserUrl = extractFirstUrl(raw);
436
+ if (browserUrl) {
437
+ const host = hostFromUrl(browserUrl);
438
+ return execResult(host ? `Browsing ${host}...` : "Using browser...", "browser", "browser.browse", host, "host");
439
+ }
440
+ return execResult("Using browser...", "browser", "browser.browse");
441
+ }
442
+
443
+ const segments = commandSegments(raw);
444
+
445
+ // 2. curl/wget LEADING the command (first segment only — a trailing
446
+ // `&& curl …` must not outrank the leading command's first-token arm;
447
+ // pipelines ending in curl still resolve via the URL fallback below)
448
+ if (segments.length > 0) {
449
+ const leadBin = path.basename(segments[0][0]);
450
+ if (leadBin === "curl" || leadBin === "wget") {
451
+ const url = extractFirstUrl(raw);
452
+ const host = hostFromUrl(url);
453
+ return host
454
+ ? execResult(`Fetching from ${host}...`, "network", "network.fetch", host, "host")
455
+ : execResult("Fetching data...", "network", "network.fetch");
456
+ }
457
+ }
458
+
459
+ // 3. redirects + mktemp (unchanged relative order, BEFORE the read arms —
460
+ // preserves the pinned mktemp test)
461
+ const appendMatch = raw.match(/(?:^|\s)>>\s*([^\s]+)/);
462
+ if (appendMatch) {
463
+ const fileName = filenameFromPath(appendMatch[1]);
464
+ if (fileName) return execResult(`Appending to ${fileName}...`, "filesystem", "fs.write", fileName, "file");
465
+ }
466
+ const writeMatch = raw.match(/(?:^|\s)>\s*([^\s]+)/);
467
+ if (writeMatch) {
468
+ const fileName = filenameFromPath(writeMatch[1]);
469
+ if (fileName) return execResult(`Writing ${fileName}...`, "filesystem", "fs.write", fileName, "file");
470
+ }
471
+ const mktempBindings = extractMktempBindings(raw);
472
+ for (const binding of mktempBindings) {
473
+ if (commandRefsVarWithRedirect(raw, binding.varName, ">>")) {
474
+ return execResult(`Appending to ${binding.fileName}...`, "filesystem", "fs.write", binding.fileName, "file");
475
+ }
476
+ }
477
+ for (const binding of mktempBindings) {
478
+ if (commandRefsVarWithRedirect(raw, binding.varName, ">")) {
479
+ return execResult(`Writing ${binding.fileName}...`, "filesystem", "fs.write", binding.fileName, "file");
480
+ }
481
+ }
482
+ for (const binding of mktempBindings) {
483
+ if (commandReadsVarWithCat(raw, binding.varName)) {
484
+ return execResult(`Reading ${binding.fileName}...`, "filesystem", "fs.read", binding.fileName, "file");
485
+ }
486
+ }
487
+
488
+ // 4. find/grep/rg piped into wc → counting
489
+ if (
490
+ segments.length >= 2 &&
491
+ ["find", "grep", "rg"].includes(path.basename(segments[0][0])) &&
492
+ segments.some((tokens) => path.basename(tokens[0]) === "wc")
493
+ ) {
494
+ return execResult("Counting matches...", "search", "search.files");
495
+ }
496
+
497
+ // 5. first-token arms, first matching segment wins
498
+ for (const tokens of segments) {
499
+ const hit = classifyExecSegment(tokens);
500
+ if (hit) return hit;
501
+ }
502
+
503
+ // 6. bare-URL substring fallback (deliberately demoted below first-token arms)
504
+ if (/https?:\/\//i.test(raw)) {
505
+ const url = extractFirstUrl(raw);
506
+ const host = hostFromUrl(url);
507
+ if (host) return execResult(`Fetching from ${host}...`, "network", "network.fetch", host, "host");
508
+ return execResult("Fetching data...", "network", "network.fetch");
509
+ }
510
+
511
+ // 7. raw fallback on the UNWRAPPED inner command
512
+ return execResult(`Running: ${sanitizeText(raw, DEFAULT_MAX_LABEL_CHARS)}`, "terminal", "terminal.exec");
513
+ }
514
+
515
+ function mapToolLabel(toolName, activityPath, args, options) {
516
+ const maxLabelChars = options.maxLabelChars;
517
+ const stabilityKey = (options && options.stabilityKey) || null;
518
+ const lowName = String(toolName || "").toLowerCase();
519
+ const rawPathValue = asString(activityPath) || pickString(args, [
520
+ "path",
521
+ "filePath",
522
+ "file_path",
523
+ "filepath",
524
+ "file",
525
+ "target",
526
+ "outputPath",
527
+ "output_path",
528
+ "output",
529
+ "destination",
530
+ "dest",
531
+ ]);
532
+ const pathValue = rawPathValue && !isNullishToken(rawPathValue) ? rawPathValue : null;
533
+ const fileName = filenameFromPath(pathValue);
534
+ const query = pickString(args, ["query", "q", "term", "search"]);
535
+ const url = pickString(args, ["url", "href", "uri"]);
536
+ const command = pickString(args, ["command", "cmd", "shell"]);
537
+
538
+ switch (lowName) {
539
+ case "write":
540
+ case "apply_patch":
541
+ case "fs.write":
542
+ return {
543
+ label: `Writing ${fileName || "file"}...`,
544
+ shortLabel: buildShortLabel({ intent: "fs.write", subject: fileName, subjectKind: "file", stabilityKey }),
545
+ detail: pathValue || command || null,
546
+ category: "filesystem",
547
+ intent: "fs.write",
548
+ };
549
+ case "read":
550
+ case "fs.read":
551
+ return {
552
+ label: `Reading ${fileName || "file"}...`,
553
+ shortLabel: buildShortLabel({ intent: "fs.read", subject: fileName, subjectKind: "file", stabilityKey }),
554
+ detail: pathValue || command || null,
555
+ category: "filesystem",
556
+ intent: "fs.read",
557
+ };
558
+ case "edit":
559
+ case "fs.edit":
560
+ return {
561
+ label: `Editing ${fileName || "file"}...`,
562
+ shortLabel: buildShortLabel({ intent: "fs.edit", subject: fileName, subjectKind: "file", stabilityKey }),
563
+ detail: pathValue || command || null,
564
+ category: "filesystem",
565
+ intent: "fs.edit",
566
+ };
567
+ case "search":
568
+ if (query) {
569
+ const queryPreview = sanitizeText(query, DEFAULT_MAX_LABEL_CHARS);
570
+ return {
571
+ label: `Searching for "${queryPreview}"...`,
572
+ shortLabel: buildShortLabel({ intent: "search.files", subject: null, subjectKind: "phrase", stabilityKey }),
573
+ detail: query,
574
+ category: "search",
575
+ intent: "search.files",
576
+ };
577
+ }
578
+ return {
579
+ label: "Searching files...",
580
+ detail: pathValue || null,
581
+ category: "search",
582
+ intent: "search.files",
583
+ };
584
+ case "bash":
585
+ case "exec": {
586
+ const fromCommand = labelFromExecCommand(command);
587
+ return {
588
+ label: fromCommand.label,
589
+ shortLabel: buildShortLabel({
590
+ intent: fromCommand.intent,
591
+ subject: fromCommand.subject,
592
+ subjectKind: fromCommand.subjectKind,
593
+ stabilityKey,
594
+ }),
595
+ detail: command || pathValue || null,
596
+ category: fromCommand.category,
597
+ intent: fromCommand.intent,
598
+ };
599
+ }
600
+ case "web_search":
601
+ case "browser.search":
602
+ case "web.search":
603
+ if (query) {
604
+ const queryPreview = sanitizeText(query, DEFAULT_MAX_LABEL_CHARS);
605
+ return {
606
+ label: `Searching the web for "${queryPreview}"...`,
607
+ shortLabel: buildShortLabel({ intent: "search.web", subject: query, subjectKind: "query", stabilityKey }),
608
+ detail: query,
609
+ category: "browser",
610
+ intent: "search.web",
611
+ };
612
+ }
613
+ if (url) {
614
+ return {
615
+ label: "Searching the web...",
616
+ detail: url,
617
+ category: "browser",
618
+ intent: "search.web",
619
+ };
620
+ }
621
+ return {
622
+ label: "Searching the web...",
623
+ detail: null,
624
+ category: "browser",
625
+ intent: "search.web",
626
+ };
627
+ case "browser":
628
+ case "web":
629
+ if (query) {
630
+ const queryPreview = sanitizeText(query, DEFAULT_MAX_LABEL_CHARS);
631
+ return {
632
+ label: `Searching the web for "${queryPreview}"...`,
633
+ shortLabel: buildShortLabel({ intent: "search.web", subject: query, subjectKind: "query", stabilityKey }),
634
+ detail: query,
635
+ category: "browser",
636
+ intent: "search.web",
637
+ };
638
+ }
639
+ if (url) {
640
+ return {
641
+ label: "Browsing the web...",
642
+ detail: url,
643
+ category: "browser",
644
+ intent: "browser.browse",
645
+ };
646
+ }
647
+ return {
648
+ label: "Browsing the web...",
649
+ detail: null,
650
+ category: "browser",
651
+ intent: "browser.browse",
652
+ };
653
+ case "browser.click":
654
+ return {
655
+ label: "Navigating a webpage...",
656
+ detail: url || null,
657
+ category: "browser",
658
+ intent: "browser.navigate",
659
+ };
660
+ case "browser.fill":
661
+ return {
662
+ label: "Filling out a form...",
663
+ detail: url || null,
664
+ category: "browser",
665
+ intent: "browser.fill",
666
+ };
667
+ case "browser.navigate":
668
+ return {
669
+ label: "Opening a webpage...",
670
+ detail: url || null,
671
+ category: "browser",
672
+ intent: "browser.navigate",
673
+ };
674
+ case "llm_task":
675
+ return {
676
+ label: "Running a sub-task...",
677
+ detail: null,
678
+ category: "generic",
679
+ intent: "agent.subtask",
680
+ };
681
+ case "agent_send":
682
+ return {
683
+ label: "Coordinating with another agent...",
684
+ detail: null,
685
+ category: "generic",
686
+ intent: "agent.coordinate",
687
+ };
688
+ case "message":
689
+ return {
690
+ label: "Sending a message...",
691
+ detail: null,
692
+ category: "generic",
693
+ intent: "message.send",
694
+ };
695
+ case "sessions_list":
696
+ case "sessions_read":
697
+ case "session_status":
698
+ return {
699
+ label: "Checking sessions...",
700
+ detail: null,
701
+ category: "generic",
702
+ intent: "session.manage",
703
+ };
704
+ case "canvas":
705
+ return {
706
+ label: "Working on canvas...",
707
+ detail: null,
708
+ category: "generic",
709
+ intent: "canvas.edit",
710
+ };
711
+ case "set_session_title":
712
+ return {
713
+ label: "Updating session title...",
714
+ detail: pickString(args, ["title"]) || null,
715
+ category: "generic",
716
+ intent: "session.title.update",
717
+ };
718
+ case "get_evenrealities_device_info":
719
+ return {
720
+ label: "Checking Even Realities hardware...",
721
+ detail: null,
722
+ category: "generic",
723
+ intent: "device.check",
724
+ };
725
+ case "render_glasses_ui":
726
+ return {
727
+ label: "Showing interface...",
728
+ detail: null,
729
+ category: "generic",
730
+ intent: "device.check",
731
+ };
732
+ case "memory_search":
733
+ return {
734
+ label: "Searching memory...",
735
+ detail: query || null,
736
+ category: "search",
737
+ intent: "search.files",
738
+ };
739
+ case "process":
740
+ return {
741
+ label: "Checking a background task...",
742
+ detail: null,
743
+ category: "generic",
744
+ intent: "agent.subtask",
745
+ };
746
+ case "web_fetch": {
747
+ const fetchHost = hostFromUrl(url);
748
+ return {
749
+ label: fetchHost ? `Fetching from ${fetchHost}...` : "Fetching data...",
750
+ detail: url || null,
751
+ category: "network",
752
+ intent: "network.fetch",
753
+ };
754
+ }
755
+ case "tool_search":
756
+ return {
757
+ label: "Loading tools...",
758
+ detail: query || null,
759
+ category: "generic",
760
+ intent: "generic",
761
+ };
762
+ case "cron":
763
+ return {
764
+ label: normalizeLowerToken(pickString(args, ["action"])) === "add"
765
+ ? "Scheduling a task..."
766
+ : "Checking schedules...",
767
+ detail: null,
768
+ category: "generic",
769
+ intent: "session.manage",
770
+ };
771
+ case "gateway":
772
+ return {
773
+ label: "Managing the gateway...",
774
+ detail: null,
775
+ category: "generic",
776
+ intent: "session.manage",
777
+ };
778
+ case "sessions_spawn":
779
+ case "spawn_agent":
780
+ case "subagents":
781
+ return {
782
+ label: "Starting a sub-agent...",
783
+ detail: null,
784
+ category: "generic",
785
+ intent: "agent.subtask",
786
+ };
787
+ case "sessions_send":
788
+ return {
789
+ label: "Coordinating with another agent...",
790
+ detail: null,
791
+ category: "generic",
792
+ intent: "agent.coordinate",
793
+ };
794
+ case "sessions_history":
795
+ return {
796
+ label: "Checking sessions...",
797
+ detail: null,
798
+ category: "generic",
799
+ intent: "session.manage",
800
+ };
801
+ default:
802
+ if (fileName) {
803
+ return {
804
+ label: `${toolName} ${fileName}...`,
805
+ detail: pathValue || null,
806
+ category: categoryFromToolName(lowName),
807
+ intent: intentFromToolName(lowName, args),
808
+ };
809
+ }
810
+ return {
811
+ label: `Using ${toolName}...`,
812
+ detail: query || url || command || null,
813
+ category: categoryFromToolName(lowName),
814
+ intent: intentFromToolName(lowName, args),
815
+ };
816
+ }
817
+ }
818
+
819
+ const SHORT_LABEL_MAX_CHARS = 64;
820
+ const SHORT_LABEL_TARGET_CHARS = 42;
821
+
822
+ // Per-intent verb palettes (spec section 6.3, user-approved 2026-06-06). Applied
823
+ // ONLY to deterministic fallback labels — never to agent-authored summaries.
824
+ const VERB_PALETTES = {
825
+ "search.web": ["researching", "looking up", "searching for"],
826
+ "fs.read": ["reading", "checking", "opening"],
827
+ "fs.write": ["writing", "updating", "editing", "patching"],
828
+ "fs.edit": ["writing", "updating", "editing", "patching"],
829
+ };
830
+ const PHRASE_PALETTES = {
831
+ "search.files": ["searching files", "scanning code"],
832
+ };
833
+
834
+ const QUERY_STOPWORDS = new Set([
835
+ "best", "top", "the", "a", "an", "of", "for", "in", "on",
836
+ "how", "to", "what", "which", "is", "are", "and", "or", "vs", "about",
837
+ ]);
838
+
839
+ function trimQueryForShortLabel(query) {
840
+ const words = String(query || "").split(/\s+/).filter(Boolean);
841
+ const kept = words.filter((word) => !QUERY_STOPWORDS.has(word.toLowerCase()));
842
+ return (kept.length ? kept : words).slice(0, 4).join(" ");
843
+ }
844
+
845
+ function fnv1aHash(text) {
846
+ let hash = 0x811c9dc5;
847
+ const input = String(text || "");
848
+ for (let i = 0; i < input.length; i += 1) {
849
+ hash ^= input.charCodeAt(i);
850
+ hash = Math.imul(hash, 0x01000193) >>> 0;
851
+ }
852
+ return hash >>> 0;
853
+ }
854
+
855
+ // Deterministic header-safe short label, or null when the intent has no
856
+ // palette (fixed-phrase arms ARE their own short form; emit-when-differs
857
+ // in the adapter keeps them off the wire).
858
+ function buildShortLabel(input) {
859
+ const obj = isObject(input) ? input : null;
860
+ const intent = obj ? asString(obj.intent) : null;
861
+ const stabilityKey = (obj ? asString(obj.stabilityKey) : null) || "";
862
+ const subjectKind = obj ? asString(obj.subjectKind) : null;
863
+ if (!intent) return null;
864
+ const phrases = subjectKind === "phrase" ? PHRASE_PALETTES[intent] : undefined;
865
+ if (phrases) {
866
+ return `${phrases[fnv1aHash(stabilityKey) % phrases.length]}...`;
867
+ }
868
+ const verbs = VERB_PALETTES[intent];
869
+ if (!verbs) return null;
870
+ let subject = obj && obj.subject ? String(obj.subject).trim() : "";
871
+ if (!subject || subjectKind === "fixed") return null;
872
+ // Redact BEFORE trimming/truncation: slicing a raw secret below the
873
+ // redaction patterns' length floors (e.g. sk- + 16 chars) would let a
874
+ // partial token escape the adapter's emission-time sanitizeText.
875
+ subject = redactSecrets(subject).trim();
876
+ if (!subject) return null;
877
+ if (subjectKind === "query") subject = trimQueryForShortLabel(subject);
878
+ const verb = verbs[fnv1aHash(stabilityKey) % verbs.length];
879
+ // Informativeness floor: keep the distinguishing token; over budget the
880
+ // SUBJECT truncates and the trailing "..." doubles as the ellipsis
881
+ // (no mixed "…..." glyph run).
882
+ const budgetForSubject = SHORT_LABEL_TARGET_CHARS - verb.length - 4; // " " + "..."
883
+ if (subject.length > budgetForSubject) {
884
+ subject = subject.slice(0, Math.max(budgetForSubject, 8)).trimEnd();
885
+ }
886
+ return `${verb} ${subject}...`;
887
+ }
888
+
889
+ export {
890
+ DEFAULT_MAX_LABEL_CHARS,
891
+ SHORT_LABEL_MAX_CHARS,
892
+ isObject,
893
+ asString,
894
+ normalizeLowerToken,
895
+ pickString,
896
+ pickStringEntry,
897
+ collapseWhitespace,
898
+ sanitizeText,
899
+ categoryFromToolName,
900
+ intentFromToolName,
901
+ unwrapShellCommand,
902
+ commandSegments,
903
+ labelFromExecCommand,
904
+ mapToolLabel,
905
+ buildShortLabel,
906
+ };