@pentoshi/clai 2.0.28 → 2.0.29

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 (105) hide show
  1. package/dist/agent/confirm-port.d.ts +30 -0
  2. package/dist/agent/confirm-port.js +89 -0
  3. package/dist/agent/confirm-port.js.map +1 -0
  4. package/dist/agent/plan-tool.d.ts +26 -0
  5. package/dist/agent/plan-tool.js +188 -0
  6. package/dist/agent/plan-tool.js.map +1 -0
  7. package/dist/agent/runner.d.ts +6 -174
  8. package/dist/agent/runner.js +25 -1606
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/agent/session-policy.d.ts +19 -0
  11. package/dist/agent/session-policy.js +40 -0
  12. package/dist/agent/session-policy.js.map +1 -0
  13. package/dist/agent/stop-summary.d.ts +8 -0
  14. package/dist/agent/stop-summary.js +53 -0
  15. package/dist/agent/stop-summary.js.map +1 -0
  16. package/dist/agent/tool-call-parser.d.ts +155 -0
  17. package/dist/agent/tool-call-parser.js +1107 -0
  18. package/dist/agent/tool-call-parser.js.map +1 -0
  19. package/dist/agent/tool-output-formatting.d.ts +7 -0
  20. package/dist/agent/tool-output-formatting.js +127 -0
  21. package/dist/agent/tool-output-formatting.js.map +1 -0
  22. package/dist/commands/update.js +1 -1
  23. package/dist/prompts/index.d.ts +2 -2
  24. package/dist/prompts/index.js +103 -59
  25. package/dist/prompts/index.js.map +1 -1
  26. package/dist/repl/prompt-line.d.ts +19 -0
  27. package/dist/repl/prompt-line.js +555 -0
  28. package/dist/repl/prompt-line.js.map +1 -0
  29. package/dist/repl/slash-commands.d.ts +24 -0
  30. package/dist/repl/slash-commands.js +317 -0
  31. package/dist/repl/slash-commands.js.map +1 -0
  32. package/dist/repl.d.ts +2 -25
  33. package/dist/repl.js +41 -898
  34. package/dist/repl.js.map +1 -1
  35. package/dist/safety/classifier.js +1 -1
  36. package/dist/safety/classifier.js.map +1 -1
  37. package/dist/store/keys.js +0 -4
  38. package/dist/store/keys.js.map +1 -1
  39. package/dist/store/plan.js +1 -1
  40. package/dist/store/plan.js.map +1 -1
  41. package/dist/tools/nmap-runner.d.ts +19 -0
  42. package/dist/tools/nmap-runner.js +177 -0
  43. package/dist/tools/nmap-runner.js.map +1 -0
  44. package/dist/tools/package-binary.d.ts +1 -0
  45. package/dist/tools/package-binary.js +30 -0
  46. package/dist/tools/package-binary.js.map +1 -0
  47. package/dist/tools/pdf.js +2 -2
  48. package/dist/tools/pdf.js.map +1 -1
  49. package/dist/tools/registry.d.ts +2 -11
  50. package/dist/tools/registry.js +3 -203
  51. package/dist/tools/registry.js.map +1 -1
  52. package/dist/tools/tool-types.d.ts +12 -0
  53. package/dist/tools/tool-types.js +2 -0
  54. package/dist/tools/tool-types.js.map +1 -0
  55. package/dist/tools/web/audit.d.ts +7 -29
  56. package/dist/tools/web/audit.js +7 -29
  57. package/dist/tools/web/audit.js.map +1 -1
  58. package/dist/tools/web/budget.d.ts +5 -28
  59. package/dist/tools/web/budget.js +5 -28
  60. package/dist/tools/web/budget.js.map +1 -1
  61. package/dist/tools/web/capture.d.ts +8 -35
  62. package/dist/tools/web/capture.js +8 -35
  63. package/dist/tools/web/capture.js.map +1 -1
  64. package/dist/tools/web/fetch-core.d.ts +6 -26
  65. package/dist/tools/web/fetch-core.js +6 -26
  66. package/dist/tools/web/fetch-core.js.map +1 -1
  67. package/dist/tools/web/readable.d.ts +7 -13
  68. package/dist/tools/web/readable.js +7 -13
  69. package/dist/tools/web/readable.js.map +1 -1
  70. package/dist/tools/web/search.d.ts +7 -20
  71. package/dist/tools/web/search.js +7 -20
  72. package/dist/tools/web/search.js.map +1 -1
  73. package/dist/tools/web/ssrf-guard.d.ts +8 -27
  74. package/dist/tools/web/ssrf-guard.js +8 -27
  75. package/dist/tools/web/ssrf-guard.js.map +1 -1
  76. package/dist/tui/App.js +19 -88
  77. package/dist/tui/App.js.map +1 -1
  78. package/dist/tui/key-intent.d.ts +36 -0
  79. package/dist/tui/key-intent.js +54 -0
  80. package/dist/tui/key-intent.js.map +1 -0
  81. package/dist/tui/render-lines.js +125 -37
  82. package/dist/tui/render-lines.js.map +1 -1
  83. package/dist/tui/state.js +2 -2
  84. package/dist/tui/state.js.map +1 -1
  85. package/dist/tui/text-format.d.ts +11 -0
  86. package/dist/tui/text-format.js +85 -0
  87. package/dist/tui/text-format.js.map +1 -0
  88. package/dist/ui/ansi-box.d.ts +7 -0
  89. package/dist/ui/ansi-box.js +18 -0
  90. package/dist/ui/ansi-box.js.map +1 -0
  91. package/dist/ui/banner.d.ts +0 -9
  92. package/dist/ui/banner.js +1 -43
  93. package/dist/ui/banner.js.map +1 -1
  94. package/dist/ui/intro-card.d.ts +10 -0
  95. package/dist/ui/intro-card.js +39 -0
  96. package/dist/ui/intro-card.js.map +1 -0
  97. package/dist/ui/markdown.js +1 -1
  98. package/dist/ui/markdown.js.map +1 -1
  99. package/dist/ui/output-pane.js.map +1 -1
  100. package/dist/ui/spinner.js +6 -17
  101. package/dist/ui/spinner.js.map +1 -1
  102. package/dist/ui/wordmark.d.ts +7 -0
  103. package/dist/ui/wordmark.js +42 -0
  104. package/dist/ui/wordmark.js.map +1 -0
  105. package/package.json +1 -1
@@ -1,20 +1,16 @@
1
- import { confirm } from "@inquirer/prompts";
2
1
  import chalk from "chalk";
3
- import { mkdir, writeFile } from "node:fs/promises";
4
2
  import { homedir } from "node:os";
5
- import { fixOwner, handlePermissionError } from "../os/permissions.js";
6
3
  import { join, resolve } from "node:path";
7
4
  import { streamWithProvider, completeWithProvider } from "../llm/router.js";
8
5
  import { randomUUID } from "node:crypto";
9
6
  import { jobManager } from "../tools/jobs.js";
10
- import { currentDateTimeContext, renderAgentSystemPrompt, } from "../prompts/index.js";
7
+ import { renderAgentSystemPrompt, } from "../prompts/index.js";
11
8
  import { getConfig } from "../store/config.js";
12
9
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
13
10
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
14
11
  import { looksInteractiveStdin } from "../tools/shell.js";
15
- import { reduceToolOutput } from "../tools/policies/output-policy.js";
16
12
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
17
- import { compactMessagesWithSummary, estimateMessagesTokens, isCompactionMemoryMessage, } from "./context-manager.js";
13
+ import { compactMessagesWithSummary, estimateMessagesTokens, } from "./context-manager.js";
18
14
  import { auditLog } from "../store/logs.js";
19
15
  import { loadProjectContext } from "../store/project.js";
20
16
  import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
@@ -25,1600 +21,23 @@ import { startThinkingSpinner } from "../ui/spinner.js";
25
21
  import { safeCwd } from "../os/cwd.js";
26
22
  import { analyzeTask } from "./task-analyzer.js";
27
23
  import { LoopGuard } from "./loop-guard.js";
28
- import { createPlan, loadPlan, savePlan, markTask, } from "../store/plan.js";
29
- import { renderPlanChecklist, renderPlanSidePane } from "../ui/plan-pane.js";
24
+ import { loadPlan } from "../store/plan.js";
30
25
  import { pathInsideSandbox } from "../tools/fs.js";
31
- /** Render the plan as a right-side pane on wide terminals, else inline. */
32
- function renderPlanForTerminal(plan) {
33
- const cols = process.stdout.columns ?? 0;
34
- const side = process.stdout.isTTY
35
- ? renderPlanSidePane(plan, cols)
36
- : undefined;
37
- return side ?? renderPlanChecklist(plan);
38
- }
39
- export function createSessionPolicy(sessionId) {
40
- return {
41
- allow: new Set(),
42
- pentestAuthorized: { value: false },
43
- sessionId: sessionId ??
44
- `sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
45
- planApproved: { value: false },
46
- };
47
- }
48
- export function preprocessJson(raw) {
49
- let inString = false;
50
- let escaped = false;
51
- let result = "";
52
- for (let i = 0; i < raw.length; i++) {
53
- const char = raw[i];
54
- if (char === '"' && !escaped) {
55
- inString = !inString;
56
- result += char;
57
- }
58
- else if (inString) {
59
- if (char === "\n") {
60
- result += "\\n";
61
- }
62
- else if (char === "\r") {
63
- result += "\\r";
64
- }
65
- else if (char === "\t") {
66
- result += "\\t";
67
- }
68
- else {
69
- result += char;
70
- }
71
- }
72
- else {
73
- if (char === "," && i + 1 < raw.length) {
74
- let nextNonWs = "";
75
- for (let j = i + 1; j < raw.length; j++) {
76
- if (!/\s/.test(raw[j])) {
77
- nextNonWs = raw[j];
78
- break;
79
- }
80
- }
81
- if (nextNonWs === "}" || nextNonWs === "]") {
82
- continue;
83
- }
84
- }
85
- result += char;
86
- }
87
- if (char === "\\" && inString) {
88
- escaped = !escaped;
89
- }
90
- else {
91
- escaped = false;
92
- }
93
- }
94
- return result;
95
- }
96
- /**
97
- * Last-resort lenient repair for tool-call JSON that strict JSON.parse
98
- * rejected. Models frequently emit "almost JSON": smart/curly quotes from a
99
- * copy-paste, Python-style True/False/None literals, or an object that is
100
- * wholly single-quoted. We only apply these transforms when a strict parse
101
- * has already failed, so well-formed JSON is never touched.
102
- */
103
- function lenientJsonParse(text) {
104
- const candidates = [];
105
- // 1. Normalize unicode/smart quotes to ASCII quotes.
106
- const deSmart = text
107
- .replace(/[\u201C\u201D\u201E\u2033]/g, '"')
108
- .replace(/[\u2018\u2019\u201A\u2032]/g, "'");
109
- candidates.push(deSmart);
110
- // 2. Python/JS literals → JSON literals (outside of double-quoted strings).
111
- candidates.push(replaceOutsideStrings(deSmart));
112
- // 3. Single-quoted object → double-quoted (only when there are no double
113
- // quotes already, so we don't corrupt strings that contain apostrophes).
114
- if (!deSmart.includes('"') && deSmart.includes("'")) {
115
- candidates.push(deSmart.replace(/'/g, '"'));
116
- }
117
- for (const candidate of candidates) {
118
- try {
119
- return JSON.parse(preprocessJson(candidate).trim());
120
- }
121
- catch {
122
- // try the next repair
123
- }
124
- }
125
- return undefined;
126
- }
127
- /** Replace bare Python/JS literals (True/False/None/NaN) with JSON equivalents,
128
- * skipping anything inside a double-quoted string. */
129
- function replaceOutsideStrings(text) {
130
- let inString = false;
131
- let escaped = false;
132
- let out = "";
133
- let i = 0;
134
- while (i < text.length) {
135
- const ch = text[i];
136
- if (inString) {
137
- out += ch;
138
- if (escaped)
139
- escaped = false;
140
- else if (ch === "\\")
141
- escaped = true;
142
- else if (ch === '"')
143
- inString = false;
144
- i += 1;
145
- continue;
146
- }
147
- if (ch === '"') {
148
- inString = true;
149
- out += ch;
150
- i += 1;
151
- continue;
152
- }
153
- const rest = text.slice(i);
154
- const m = /^(True|False|None|NaN|undefined)\b/.exec(rest);
155
- if (m) {
156
- const word = m[1];
157
- out +=
158
- word === "True"
159
- ? "true"
160
- : word === "False"
161
- ? "false"
162
- : word === "NaN"
163
- ? "0"
164
- : "null"; // None / undefined
165
- i += word.length;
166
- continue;
167
- }
168
- out += ch;
169
- i += 1;
170
- }
171
- return out;
172
- }
173
- function tryParseCall(raw) {
174
- let parsed;
175
- try {
176
- parsed = JSON.parse(preprocessJson(raw).trim());
177
- }
178
- catch {
179
- // Strict parse failed — try lenient repairs before giving up so a model
180
- // that emits smart quotes / single quotes / Python literals still works.
181
- const repaired = lenientJsonParse(raw.trim());
182
- if (repaired && typeof repaired === "object" && !Array.isArray(repaired)) {
183
- parsed = repaired;
184
- }
185
- }
186
- if (!parsed)
187
- return undefined;
188
- const anyParsed = parsed;
189
- // Accept name under several keys models commonly use.
190
- const nameRaw = typeof parsed.name === "string"
191
- ? parsed.name
192
- : typeof anyParsed.tool_name === "string"
193
- ? anyParsed.tool_name
194
- : undefined;
195
- if (typeof nameRaw === "string" && nameRaw.length > 0) {
196
- // Strip a leading "functions." namespace some models add.
197
- const name = nameRaw.replace(/^functions\./, "");
198
- // Many OpenAI/Hermes/Qwen-trained models emit {"name","arguments"}
199
- // (or "parameters"/"input") instead of {"name","args"} — accept any.
200
- const argsSrc = pickObject(parsed.args) ??
201
- pickObject(parsed.arguments) ??
202
- pickObject(anyParsed.parameters) ??
203
- pickObject(anyParsed.input);
204
- if (argsSrc) {
205
- return { name, args: argsSrc };
206
- }
207
- // Allow an empty args object explicitly written as {} or null (common for
208
- // sysinfo), but do NOT invent args for objects that merely happen to
209
- // contain a "name" key (e.g. {"name":"shell.exec"} with no command).
210
- if (parsed.args === null || parsed.arguments === null) {
211
- return { name, args: {} };
212
- }
213
- // Flattened form: the args are emitted as SIBLINGS of `name` rather than
214
- // nested (e.g. {"name":"web.fetch","url":"…","responseMode":"raw"}). Treat
215
- // the non-reserved keys as args, but only when at least one is a known
216
- // tool-arg key so plain data objects carrying a `name` are not misread.
217
- const flat = {};
218
- for (const key of Object.keys(anyParsed)) {
219
- if (!FLATTENED_RESERVED_KEYS.has(key))
220
- flat[key] = anyParsed[key];
221
- }
222
- const flatKeys = Object.keys(flat);
223
- if (flatKeys.length > 0 && flatKeys.some((k) => TOOL_ARG_KEYS.has(k))) {
224
- return { name, args: flat };
225
- }
226
- }
227
- return undefined;
228
- }
229
- // Keys that name the tool or wrap its arguments — excluded when recovering a
230
- // flattened tool call whose args sit alongside `name`.
231
- const FLATTENED_RESERVED_KEYS = new Set([
232
- "name",
233
- "tool_name",
234
- "args",
235
- "arguments",
236
- "parameters",
237
- "input",
238
- ]);
239
- function pickObject(value) {
240
- return value && typeof value === "object" && !Array.isArray(value)
241
- ? value
242
- : undefined;
243
- }
244
- // Kimi K2 / Moonshot models on NVIDIA NIM emit tool calls using a
245
- // sentinel-token format that looks like:
246
- // <|tool_calls_section_begin|>
247
- // <|tool_call_begin|>functions.shell.exec:0<|tool_call_argument_begin|>
248
- // {"command":"ls"}
249
- // <|tool_call_end|>
250
- // <|tool_calls_section_end|>
251
- // The `functions.` prefix is optional, the trailing `:N` index is optional,
252
- // and the surrounding section markers may be absent on truncated streams.
253
- const KIMI_TOOL_CALL_RE = /<\|tool_call_begin\|>\s*(?:functions\.)?([A-Za-z][\w.]*?)(?::\d+)?\s*<\|tool_call_argument_begin\|>\s*(\{[\s\S]*?\})\s*<\|tool_call_end\|>/i;
254
- function parseKimiToolCall(text) {
255
- const match = text.match(KIMI_TOOL_CALL_RE);
256
- if (!match)
257
- return undefined;
258
- const name = match[1];
259
- return tryParseCall(JSON.stringify({ name, args: tryJson(match[2]) ?? {} }));
260
- }
261
- // Recognized XML-ish tool-call wrappers some models emit (with or without a
262
- // matching close tag). Used so we can recover a call even when the model
263
- // forgot the closing tag, while plain prose never matches.
264
- const XML_BLOCK_OPENERS = /<tool_call>|<function_calls>|<invoke>|<ant:invoke>/i;
265
- /**
266
- * Scan `text` starting at the index of the first `{`, returning the balanced
267
- * JSON substring (respecting strings) or undefined if braces never balance.
268
- * Lets us recover a tool-call JSON object the model emitted without a closing
269
- * wrapper tag, where a non-greedy regex would stop at the first inner brace.
270
- */
271
- function extractBalancedJson(text) {
272
- const start = text.indexOf("{");
273
- if (start < 0)
274
- return undefined;
275
- let depth = 0;
276
- let inString = false;
277
- let escaped = false;
278
- for (let i = start; i < text.length; i += 1) {
279
- const ch = text[i];
280
- if (escaped) {
281
- escaped = false;
282
- continue;
283
- }
284
- if (ch === "\\") {
285
- escaped = true;
286
- continue;
287
- }
288
- if (ch === '"') {
289
- inString = !inString;
290
- continue;
291
- }
292
- if (inString)
293
- continue;
294
- if (ch === "{")
295
- depth += 1;
296
- else if (ch === "}") {
297
- depth -= 1;
298
- if (depth === 0)
299
- return text.slice(start, i + 1);
300
- }
301
- }
302
- return undefined;
303
- }
304
- function parseXmlToolCall(text) {
305
- // Pattern 1 (name + args/arguments/parameters JSON):
306
- // <tool_call>
307
- // <name>tool.name</name>
308
- // <args>{...}</args> (or <arguments> / <parameters>)
309
- // <tool_call>
310
- const xmlNameArgs = text.match(/<tool_call>[\s\S]*?<name>\s*([\w.]+?)\s*<\/name>\s*<(?:args|arguments|parameters)>\s*(\{[\s\S]*?\})\s*<\/(?:args|arguments|parameters)>[\s\S]*?<\/tool_call>/i);
311
- if (xmlNameArgs?.[1] && xmlNameArgs?.[2]) {
312
- try {
313
- const args = JSON.parse(preprocessJson(xmlNameArgs[2]));
314
- return {
315
- name: xmlNameArgs[1],
316
- args: args,
317
- };
318
- }
319
- catch { }
320
- }
321
- // Pattern 1b (MiMo alternative):
322
- // <tool_call>
323
- // <tool_name>tool.name</tool_name>
324
- // <parameters>{...}</parameters> (or <arguments> / <args>)
325
- // <tool_call>
326
- const xmlToolNameParams = text.match(/<tool_call>[\s\S]*?<tool_name>\s*([\w.]+?)\s*<\/tool_name>\s*<(?:parameters|arguments|args)>\s*(\{[\s\S]*?\})\s*<\/(?:parameters|arguments|args)>[\s\S]*?<\/tool_call>/i);
327
- if (xmlToolNameParams?.[1] && xmlToolNameParams?.[2]) {
328
- try {
329
- const args = JSON.parse(preprocessJson(xmlToolNameParams[2]));
330
- return {
331
- name: xmlToolNameParams[1],
332
- args: args,
333
- };
334
- }
335
- catch { }
336
- }
337
- // Pattern 1c (MiMo function/parameter format), with or WITHOUT a
338
- // surrounding <tool_call> wrapper (some models emit the bare function block):
339
- // <function=tool.name>
340
- // <parameter=name>value</parameter>
341
- // </function>
342
- const xmlFunctionBlock = text.match(/<function=([\w.]+?)>([\s\S]*?)<\/function>/i);
343
- if (xmlFunctionBlock?.[1] && xmlFunctionBlock?.[2]) {
344
- const name = xmlFunctionBlock[1];
345
- const inner = xmlFunctionBlock[2];
346
- const args = {};
347
- const paramRegex = /<parameter=([\w.]+?)>([\s\S]*?)<\/parameter>/gi;
348
- let paramMatch;
349
- while ((paramMatch = paramRegex.exec(inner)) !== null) {
350
- const paramName = paramMatch[1];
351
- const paramValueStr = paramMatch[2].trim();
352
- let paramValue = paramValueStr;
353
- try {
354
- if (/^(?:\[|\{|true|false|null|\d+(\.\d+)?$)/i.test(paramValueStr)) {
355
- paramValue = JSON.parse(preprocessJson(paramValueStr));
356
- }
357
- }
358
- catch { }
359
- args[paramName] = paramValue;
360
- }
361
- return { name, args };
362
- }
363
- // Pattern 2: JSON object inside a recognized wrapper (closed). The wrapper
364
- // may be the tool_call sentinel, <function_calls>, or <invoke>/<ant:invoke>.
365
- // Backtracking off the closing tag handles nested {} in the args.
366
- const wrappers = [
367
- "<tool_call>",
368
- "<function_calls>",
369
- "<invoke>",
370
- "<ant:invoke>",
371
- ];
372
- for (const open of wrappers) {
373
- const close = open === "<function_calls>"
374
- ? "</function_calls>"
375
- : open === "<invoke>"
376
- ? "</invoke>"
377
- : open === "<ant:invoke>"
378
- ? "</ant:invoke>"
379
- : "</tool_call>";
380
- const re = new RegExp(open.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
381
- "[\\s\\S]*?(?:<tool>)?\\s*(\\{[\\s\\S]*?\\})\\s*" +
382
- close.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
383
- const match = text.match(re);
384
- if (match?.[1]) {
385
- const call = tryParseCall(match[1]);
386
- if (call)
387
- return call;
388
- }
389
- }
390
- // Pattern 3: a recognized opener is present but there is no matching close
391
- // (model forgot it, or the stream was cut). Use a balanced brace scan from
392
- // the first `{` after the opener so nested args survive, then try to parse.
393
- const openerMatch = XML_BLOCK_OPENERS.exec(text);
394
- if (openerMatch) {
395
- const after = text.slice(openerMatch.index + openerMatch[0].length);
396
- const json = extractBalancedJson(after);
397
- if (json) {
398
- const call = tryParseCall(json);
399
- if (call)
400
- return call;
401
- }
402
- // Also try the <name>...</name><arguments>{...}</arguments> tag shape
403
- // without a closing wrapper (Hermes/Qwen often omit it).
404
- const tagShape = after.match(/<name>\s*([\w.]+?)\s*<\/name>\s*<(?:args|arguments|parameters)>\s*(\{[\s\S]*?\})\s*<\/(?:args|arguments|parameters)>/i);
405
- if (tagShape?.[1] && tagShape?.[2]) {
406
- try {
407
- const args = JSON.parse(preprocessJson(tagShape[2]));
408
- return { name: tagShape[1], args: args };
409
- }
410
- catch { }
411
- }
412
- }
413
- return undefined;
414
- }
415
- function tryJson(raw) {
416
- try {
417
- const preprocessed = preprocessJson(raw);
418
- const parsed = JSON.parse(preprocessed);
419
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
420
- return parsed;
421
- }
422
- }
423
- catch {
424
- // ignore
425
- }
426
- return undefined;
427
- }
428
- /** Strip any leftover Kimi/Moonshot sentinel tokens from final answers
429
- * so a model that mixes prose and tool-call markers never bleeds raw
430
- * `<|tool_call_begin|>` strings to the terminal. */
431
- function stripSentinelTokens(text) {
432
- return text
433
- .replace(/<\|tool_calls_section_begin\|>[\s\S]*?<\|tool_calls_section_end\|>/gi, "")
434
- .replace(/<\|tool_call_begin\|>[\s\S]*?<\|tool_call_end\|>/gi, "")
435
- .replace(/<\|tool_calls?(?:_section)?_(?:begin|end)\|>/gi, "")
436
- .replace(/<\|tool_call_argument_begin\|>/gi, "")
437
- .replace(/<\|tool_[a-z_]*\|>/gi, "")
438
- .trim();
439
- }
440
- export function parseToolCall(text, options = {}) {
441
- // 1. ```tool ... ``` (standard format)
442
- const fenced = text.match(/```tool\s*\n?([\s\S]*?)```/i);
443
- if (fenced?.[1]) {
444
- const call = tryParseCall(fenced[1]);
445
- if (call)
446
- return call;
447
- }
448
- // 2. <tool_call>...</tool_call> (XML formats)
449
- const xmlCall = parseXmlToolCall(text);
450
- if (xmlCall)
451
- return xmlCall;
452
- // 3. Kimi/Moonshot sentinel format (used by kimi-k2 family on NIM).
453
- const kimi = parseKimiToolCall(text);
454
- if (kimi)
455
- return kimi;
456
- // In strict mode, stop here. Headings, generic fenced blocks, and trailing
457
- // JSON are too easy to accidentally trigger when the model is showing a
458
- // worked example.
459
- if (options.strict)
460
- return undefined;
461
- // 4. ### tool / ## tool / # tool heading + JSON
462
- const heading = text.match(/#{1,3}\s*tool\s*\n\s*(\{[\s\S]*\})/i);
463
- if (heading?.[1]) {
464
- const call = tryParseCall(heading[1]);
465
- if (call)
466
- return call;
467
- }
468
- // 5. **tool** heading + JSON
469
- const bold = text.match(/\*\*tool\*\*\s*\n\s*(\{[\s\S]*\})/i);
470
- if (bold?.[1]) {
471
- const call = tryParseCall(bold[1]);
472
- if (call)
473
- return call;
474
- }
475
- // 6. Any fenced block (```json, ```, etc.) containing name+args
476
- const anyFenced = text.match(/```\w*\s*\n?([\s\S]*?)```/);
477
- if (anyFenced?.[1]) {
478
- const call = tryParseCall(anyFenced[1]);
479
- if (call)
480
- return call;
481
- }
482
- // 7. Trailing JSON object with "name" and "args"
483
- const trailingJson = text.match(/(\{"name"\s*:\s*"[^"]+"\s*,\s*"args"\s*:\s*\{[\s\S]*?\}\s*\})\s*$/);
484
- if (trailingJson?.[1]) {
485
- const call = tryParseCall(trailingJson[1]);
486
- if (call)
487
- return call;
488
- }
489
- return undefined;
490
- }
491
- // Argument keys that the built-in tools accept. Used to recognize when a
492
- // model emitted a bare args object (e.g. {"path":"file.pdf"}) — intending a
493
- // tool call but forgetting the {"name","args"} wrapper and the ```tool fence.
494
- const TOOL_ARG_KEYS = new Set([
495
- "command",
496
- "path",
497
- "paths",
498
- "url",
499
- "query",
500
- "target",
501
- "pattern",
502
- "tool",
503
- "tools",
504
- "files",
505
- "content",
506
- "calls",
507
- "record",
508
- "ports",
509
- "profile",
510
- "id",
511
- "lang",
512
- "dpi",
513
- "psm",
514
- "recursive",
515
- "oldText",
516
- "newText",
517
- "expectedReplacements",
518
- "goal",
519
- "tasks",
520
- "taskId",
521
- "state",
522
- "method",
523
- "body",
524
- "headers",
525
- "maxBytes",
526
- "maxResults",
527
- "cwd",
528
- "name",
529
- "concurrency",
530
- // Extra optional keys that commonly ride along with a bare args object so
531
- // it is still recognized (and its tool inferred) instead of leaking to the
532
- // screen — e.g. shell.exec with {"command":"…","timeoutMs":300000}.
533
- "timeoutMs",
534
- "flags",
535
- "iOwnThis",
536
- "own",
537
- "note",
538
- "kind",
539
- "detail",
540
- "maxEntries",
541
- "checkBinary",
542
- "scanType",
543
- "whois",
544
- "dns",
545
- "nmap",
546
- "bytes",
547
- "responseMode",
548
- "includeHeaders",
549
- "includeTls",
550
- "includeTiming",
551
- "includeRedirectChain",
552
- "redactSensitive",
553
- ]);
554
- /**
555
- * When a model emits a bare args object with no {"name", "args"} wrapper and
556
- * no ```tool fence, infer which tool it MEANT from the argument keys so we
557
- * can run it directly instead of nudging the model to re-emit (the user
558
- * should not have to type "run"). Only unambiguous key signatures map to a
559
- * tool; genuinely ambiguous shapes (a lone `path` could be fs.read / fs.list
560
- * / pdf.read / image.ocr; a lone `target` could be whois / dns / scan) return
561
- * undefined so the caller falls back to a re-emit nudge. Inferred calls still
562
- * pass through the normal safety classifier + confirmation, so inference can
563
- * never bypass a confirm/block gate.
564
- */
565
- export function inferToolFromArgs(obj) {
566
- const has = (key) => Object.prototype.hasOwnProperty.call(obj, key);
567
- if (has("command"))
568
- return "shell.exec";
569
- if (has("files"))
570
- return "fs.writeMany";
571
- if (has("calls"))
572
- return "tool.batch";
573
- if (has("oldText") || has("newText"))
574
- return "fs.edit";
575
- if (has("content") && has("path"))
576
- return "fs.write";
577
- if (has("pattern"))
578
- return "fs.search";
579
- if (has("query"))
580
- return "web.search";
581
- if (has("tools"))
582
- return "tool.check";
583
- if (has("goal") && has("tasks"))
584
- return "plan.create";
585
- if (has("taskId") || has("state"))
586
- return "task.update";
587
- if (has("tool"))
588
- return "pkg.install";
589
- if (has("record") && has("target"))
590
- return "dns.lookup";
591
- if (has("ports") && has("target"))
592
- return "net.scan";
593
- if (has("url")) {
594
- // A url with an explicit method/body is a raw HTTP request (http.fetch);
595
- // a lone url is a content read (web.fetch).
596
- return has("method") || has("body") ? "http.fetch" : "web.fetch";
597
- }
598
- return undefined;
599
- }
600
- /**
601
- * Strip a single wrapping ```json / ``` fence (if any) and return the inner
602
- * text trimmed. Leaves un-fenced text unchanged.
603
- */
604
- function stripLoneFence(text) {
605
- const fenced = text.trim().match(/^```[a-zA-Z]*\s*\n?([\s\S]*?)\n?```$/);
606
- return (fenced?.[1] ?? text).trim();
607
- }
608
- /**
609
- * Try to recover a bare-args tool call from a single candidate text snippet.
610
- * Returns the recognized result or undefined if the text isn't a recoverable
611
- * tool call. Used by both the whole-text path and the embedded-fence path.
612
- */
613
- function tryRecognizeBareArgs(inner) {
614
- const trimmed = inner.trim();
615
- if (!trimmed.startsWith("{") || !trimmed.endsWith("}"))
616
- return undefined;
617
- let parsed;
618
- try {
619
- parsed = JSON.parse(trimmed);
620
- }
621
- catch {
622
- return undefined;
623
- }
624
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
625
- return undefined;
626
- }
627
- const obj = parsed;
628
- // Complete {name, args} call the earlier matchers didn't catch.
629
- const direct = tryParseCall(trimmed);
630
- if (direct)
631
- return { call: direct };
632
- // Bare args object: every key is a known tool-arg key.
633
- const keys = Object.keys(obj);
634
- if (keys.length === 0 || keys.length > 6)
635
- return undefined;
636
- const allKnown = keys.every((key) => TOOL_ARG_KEYS.has(key));
637
- if (!allKnown)
638
- return undefined;
639
- const inferred = inferToolFromArgs(obj);
640
- if (inferred) {
641
- return { call: { name: inferred, args: obj } };
642
- }
643
- return { argsOnly: true };
644
- }
645
- /**
646
- * When a model means to call a tool but emits ONLY a bare JSON object —
647
- * either a proper {"name","args"} that the strict matchers missed, or a bare
648
- * args object like {"path":"file.pdf"} with the wrapper/fence dropped — this
649
- * recognizes it. Returns:
650
- * - { call } when the object is a complete {name, args} tool call, or
651
- * - { argsOnly: true } when it looks like a bare args object (so the caller
652
- * can nudge the model to re-emit a properly named, fenced tool call).
653
- * Returns undefined for anything that is plainly a normal prose/JSON answer.
654
- *
655
- * Also handles the case where a model emits prose followed by a non-`tool`
656
- * fenced code block (e.g. ```web\n{"url":"..."}\n```) that contains a bare
657
- * args object — the fence is scanned even when it's not the sole content.
658
- */
659
- export function recognizeBareToolJson(text) {
660
- // ── Primary path: the whole (de-fenced) text is a bare JSON object ──────
661
- const inner = stripLoneFence(text);
662
- const primary = tryRecognizeBareArgs(inner);
663
- if (primary)
664
- return primary;
665
- // ── Secondary path: scan for any fenced block embedded in the text ──────
666
- // This catches models that prepend prose before emitting a bare-args fence,
667
- // e.g. "Let me fetch it.\n\n```web\n{\"url\":\"https://...\"}\n```"
668
- // We skip ```tool fences — those are handled by parseToolCall already.
669
- const embeddedFenceRe = /```([a-zA-Z]*)\s*\n?([\s\S]*?)```/g;
670
- let m;
671
- while ((m = embeddedFenceRe.exec(text)) !== null) {
672
- const lang = m[1] ?? "";
673
- const body = (m[2] ?? "").trim();
674
- // Skip ```tool blocks — parseToolCall owns those.
675
- if (lang.toLowerCase() === "tool")
676
- continue;
677
- // Skip empty or multi-line JSON that spans more than a simple object.
678
- if (!body.startsWith("{") || !body.endsWith("}"))
679
- continue;
680
- const result = tryRecognizeBareArgs(body);
681
- if (result)
682
- return result;
683
- }
684
- return undefined;
685
- }
686
- /**
687
- * Detect an opened-but-unparseable tool call. This happens when the model's
688
- * output is truncated by the token limit mid-JSON: we see the ```tool fence
689
- * (or a bare {"name":"...","args" prefix) open, but parseToolCall returns
690
- * undefined because the JSON never closed. Without this, the broken block
691
- * leaks to the screen as a "final answer" and the requested action (e.g. a
692
- * multi-file fs.writeMany scaffold) silently never runs.
693
- */
694
- export function looksLikeTruncatedToolCall(text) {
695
- // An opened ```tool fence with no closing fence.
696
- const openFence = /```tool\s*\n?/i.test(text);
697
- const closeFence = /```tool[\s\S]*?```/i.test(text);
698
- if (openFence && !closeFence)
699
- return true;
700
- // A tool-call JSON object that started but whose braces never balanced.
701
- const jsonStart = text.search(/\{\s*"name"\s*:\s*"[A-Za-z][\w.]*"\s*,\s*"args"/);
702
- if (jsonStart >= 0) {
703
- const slice = text.slice(jsonStart);
704
- let depth = 0;
705
- let inString = false;
706
- let escaped = false;
707
- let balanced = false;
708
- for (const ch of slice) {
709
- if (escaped) {
710
- escaped = false;
711
- continue;
712
- }
713
- if (ch === "\\") {
714
- escaped = true;
715
- continue;
716
- }
717
- if (ch === '"')
718
- inString = !inString;
719
- if (inString)
720
- continue;
721
- if (ch === "{")
722
- depth += 1;
723
- else if (ch === "}") {
724
- depth -= 1;
725
- if (depth === 0) {
726
- balanced = true;
727
- break;
728
- }
729
- }
730
- }
731
- if (!balanced)
732
- return true;
733
- }
734
- return false;
735
- }
736
- /**
737
- * Count the number of ```tool fenced blocks in a message. Models sometimes
738
- * emit MULTIPLE tool calls in one response (e.g. fs.writeMany + npm install +
739
- * npm run dev). Only the FIRST is parsed and executed; the rest are silently
740
- * dropped and leak to the screen as code fences, while the model believes it
741
- * ran all of them — a major cause of "everything is done" fabrications. We
742
- * detect this so the runner can run the first and explicitly tell the model
743
- * the others did NOT run and must be re-sent one at a time.
744
- */
745
- export function countToolFences(text) {
746
- const matches = text.match(/```tool\s*\n[\s\S]*?```/gi);
747
- return matches ? matches.length : 0;
748
- }
749
- /**
750
- * Parse EVERY explicitly-delimited tool call in a message, in document
751
- * order. Unlike parseToolCall (which returns only the first), this lets the
752
- * runner execute a batch the model emitted in one turn — e.g. the natural
753
- * "task.update in_progress → do the work → task.update done" sequence, or
754
- * several fs.write calls. Only the unambiguous, delimited formats are
755
- * collected (```tool fences, <tool_call> XML, and Kimi sentinel blocks) so a
756
- * worked example in prose is far less likely to be mistaken for a call.
757
- * The runner executes them sequentially and STOPS the batch on the first
758
- * failure so the model can react, mirroring how Claude Code batches reads
759
- * and edits but pauses when something breaks.
760
- */
761
- export function parseAllToolCalls(text) {
762
- const found = [];
763
- let m;
764
- const fenceRe = /```tool\s*\n?([\s\S]*?)```/gi;
765
- while ((m = fenceRe.exec(text)) !== null) {
766
- const call = tryParseCall(m[1] ?? "");
767
- if (call)
768
- found.push({ index: m.index, call });
769
- }
770
- const xmlRe = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
771
- while ((m = xmlRe.exec(text)) !== null) {
772
- const call = parseXmlToolCall(m[0]);
773
- if (call)
774
- found.push({ index: m.index, call });
775
- }
776
- const kimiRe = new RegExp(KIMI_TOOL_CALL_RE.source, "gi");
777
- while ((m = kimiRe.exec(text)) !== null) {
778
- const call = tryParseCall(JSON.stringify({ name: m[1], args: tryJson(m[2] ?? "{}") ?? {} }));
779
- if (call)
780
- found.push({ index: m.index, call });
781
- }
782
- // Bare <function=name>…</function> blocks (no <tool_call> wrapper) — some
783
- // models emit one or several of these. Route each through parseXmlToolCall
784
- // so the <parameter=…> args are decoded. Skip any that overlap a
785
- // <tool_call> block already captured above (avoid double-counting).
786
- const fnRe = /<function=[\w.]+?>[\s\S]*?<\/function>/gi;
787
- while ((m = fnRe.exec(text)) !== null) {
788
- const alreadyCaptured = found.some((f) => m.index >= f.index && m.index < f.index + 12);
789
- const overlapsToolCall = /<tool_call>/i.test(text.slice(Math.max(0, m.index - 24), m.index));
790
- if (alreadyCaptured || overlapsToolCall)
791
- continue;
792
- const call = parseXmlToolCall(m[0]);
793
- if (call)
794
- found.push({ index: m.index, call });
795
- }
796
- found.sort((a, b) => a.index - b.index);
797
- // De-duplicate calls that two different matchers picked up at (nearly) the
798
- // same spot so a single XML call isn't executed twice.
799
- const deduped = [];
800
- for (const entry of found) {
801
- if (deduped.some((d) => sameToolCall(d.call, entry.call) &&
802
- Math.abs(d.index - entry.index) < 64)) {
803
- continue;
804
- }
805
- deduped.push(entry);
806
- }
807
- return deduped.map((f) => f.call);
808
- }
809
- /** Structural equality for two tool calls (name + canonical args JSON). */
810
- export function sameToolCall(a, b) {
811
- if (a.name !== b.name)
812
- return false;
813
- try {
814
- return JSON.stringify(a.args) === JSON.stringify(b.args);
815
- }
816
- catch {
817
- return false;
818
- }
819
- }
820
- /**
821
- * Partition a batch of tool calls (in document order) into execution groups.
822
- * A run of consecutive parallel-safe calls forms one group to be run
823
- * concurrently (bounded by maxGroupSize); every non-parallel-safe call is its
824
- * own single-element group, i.e. a sequential barrier. Because plan updates
825
- * and side-effecting tools are never parallel-safe, they always split the
826
- * batch — which keeps parallelism scoped within a single task and prevents
827
- * plan-state races and overlapping writes.
828
- */
829
- export function groupToolCallsForExecution(calls, isParallelSafe, maxGroupSize = 4) {
830
- const groups = [];
831
- let cursor = 0;
832
- while (cursor < calls.length) {
833
- const group = [calls[cursor]];
834
- if (isParallelSafe(calls[cursor])) {
835
- let j = cursor + 1;
836
- while (j < calls.length &&
837
- group.length < maxGroupSize &&
838
- isParallelSafe(calls[j])) {
839
- group.push(calls[j]);
840
- j += 1;
841
- }
842
- }
843
- groups.push(group);
844
- cursor += group.length;
845
- }
846
- return groups;
847
- }
848
- /**
849
- * Build the conversation to hand back to the caller at turn end. Strips system
850
- * prompts (they're re-added each turn) but keeps the user turn plus every
851
- * assistant tool-call and tool result, then appends the final answer if it
852
- * isn't already the last message. Persisting this is what lets a resumed
853
- * session give the model back what it actually did — commands, outputs, and
854
- * results — instead of only its prose answers.
855
- */
856
- export function buildTurnHistory(messages, answer) {
857
- // Drop system messages (the main prompt, plan context, and reflections are
858
- // all re-injected each turn) EXCEPT compacted session memory, which is the
859
- // only record of summarized older turns and must survive a resume.
860
- const convo = messages.filter((m) => m.role !== "system" || isCompactionMemoryMessage(m));
861
- const last = convo[convo.length - 1];
862
- if (answer &&
863
- !(last && last.role === "assistant" && last.content === answer)) {
864
- convo.push({ role: "assistant", content: answer });
865
- }
866
- return convo;
867
- }
868
- /**
869
- * Collapse pathological repetition before a message is stored in history.
870
- * Some models degenerate into emitting the same short phrase hundreds of
871
- * times ("We need to wait.We need to wait.…"), which otherwise bloats the
872
- * context window and wastes tokens on every subsequent turn. We keep a few
873
- * copies and note the collapse so the meaning is preserved without the bulk.
874
- */
875
- export function collapseRepeatedText(text) {
876
- if (!text || text.length < 1500)
877
- return text;
878
- try {
879
- return text.replace(/(.{3,80}?)\1{6,}/gs, (match, unit) => `${unit.repeat(3)} …[repeated ~${Math.round(match.length / Math.max(1, unit.length))}× — collapsed]`);
880
- }
881
- catch {
882
- return text;
883
- }
884
- }
885
- /** Extract the text before the tool call block for display purposes */
886
- function textBeforeToolCall(text) {
887
- const patterns = [
888
- /```tool\s*\n?[\s\S]*$/i,
889
- /<tool_call>[\s\S]*$/i,
890
- // Kimi/Moonshot sentinel block — strip from the section opener
891
- // (or the first call opener if the section header is missing).
892
- /<\|tool_calls_section_begin\|>[\s\S]*$/i,
893
- /<\|tool_call_begin\|>[\s\S]*$/i,
894
- /#{1,3}\s*tool\s*\n\s*\{[\s\S]*$/i,
895
- /\*\*tool\*\*\s*\n\s*\{[\s\S]*$/i,
896
- /```\w*\s*\n?\{[\s\S]*?"name"[\s\S]*$/i,
897
- /\{"name"\s*:\s*"[^"]+"\s*,\s*"args"\s*:\s*\{[\s\S]*$/i,
898
- ];
899
- for (const pattern of patterns) {
900
- const idx = text.search(pattern);
901
- if (idx >= 0) {
902
- return text.slice(0, idx).trim();
903
- }
904
- }
905
- return text.trim();
906
- }
907
- export function formatToolArgs(call) {
908
- if (call.name === "shell.exec")
909
- return String(call.args.command ?? "");
910
- if (call.name === "net.scan")
911
- return `${call.args.target ?? ""}${call.args.ports ? ` -p ${call.args.ports}` : ""}${call.args.flags ? ` ${call.args.flags}` : ""}`;
912
- if (call.name === "pentest.recon")
913
- return String(call.args.target ?? "");
914
- if (call.name === "dns.lookup")
915
- return `${call.args.target ?? ""}${call.args.record ? ` ${call.args.record}` : " A"}`;
916
- if (call.name === "whois.lookup")
917
- return String(call.args.target ?? "");
918
- if (call.name === "fs.read" || call.name === "fs.write")
919
- return String(call.args.path ?? "");
920
- if (call.name === "fs.writeMany") {
921
- const files = Array.isArray(call.args.files) ? call.args.files : [];
922
- const names = files
923
- .map((f) => f && typeof f === "object"
924
- ? String(f.path ?? "")
925
- : "")
926
- .filter(Boolean);
927
- const preview = names.slice(0, 4).join(", ");
928
- return `${names.length} file(s)${preview ? `: ${preview}${names.length > 4 ? ", …" : ""}` : ""}`;
929
- }
930
- if (call.name === "fs.search")
931
- return String(call.args.pattern ?? "");
932
- if (call.name === "image.ocr" || call.name === "pdf.read")
933
- return String(call.args.path ?? "");
934
- if (call.name === "http.fetch" || call.name === "web.fetch")
935
- return String(call.args.url ?? "");
936
- if (call.name === "web.search")
937
- return String(call.args.query ?? "");
938
- if (call.name === "pkg.install")
939
- return String(call.args.tool ?? "");
940
- if (call.name === "fs.list")
941
- return String(call.args.path ?? safeCwd());
942
- return JSON.stringify(call.args);
943
- }
944
- const VOLATILE_SIGNAL_RE = /\b(?:current(?:ly)?|latest|newest|today|now|right now|live|recent|breaking|news|release[sd]?|version|prices?|stocks?|market|rates?|weather|forecast|elections?|results?|rankings?|standings?|stats?|cve|advis(?:ory|ories)|vulnerabilit(?:y|ies))\b/i;
945
- const VOLATILE_ROLE_QUERY_RE = /\b(?:who(?:\s+is|'s)?|whos|name|tell\s+me|what(?:\s+is|'s)?)\b[\s\S]{0,120}\b(?:cm|chief\s+minister|prime\s+minister|president|governor|mayor|ministers?|cabinet|leader|head\s+of|ceo|cto|cfo|coo|chair(?:man|woman|person)?|coach|captain)\b/i;
946
- const ROLE_OF_ENTITY_RE = /\b(?:cm|chief\s+minister|prime\s+minister|president|governor|mayor|ministers?|ceo|cto|cfo|coo|chair(?:man|woman|person)?|coach|captain)\s+(?:of|for|in)\b/i;
947
- const EXPLICIT_WEB_LOOKUP_RE = /\b(?:search\s+(?:the\s+)?(?:web|internet|online)|look\s*up|google|verify\s+(?:online|on\s+the\s+web)|check\s+(?:online|the\s+web|internet))\b/i;
948
- const STATIC_DISAMBIGUATION_RE = /\b(?:stand\s+for|stands\s+for|meaning|definition|define|abbreviation|centimeters?|centimetres?)\b/i;
949
- const LOCAL_RUNTIME_RE = /\b(?:current\s+(?:directory|dir|cwd|working\s+directory|folder|path|user|shell|process(?:es)?|branch|git\s+branch|network|ip|interfaces?|working\s+tree)|pwd|whoami)\b/i;
950
- // Signals that the current turn is (or continues) a coding / scaffolding
951
- // task. These are intentionally broad — over-budgeting a build is cheap
952
- // (the loop still stops as soon as the model gives a final answer) while
953
- // under-budgeting silently truncates a half-built project.
954
- const BUILD_TASK_RE = /\b(?:build|create|scaffold|generate|make|set\s*up|setup|bootstrap|init(?:ialize)?|implement|add|write|develop|code|refactor|migrate|convert|wire\s*up|integrate)\b[\s\S]{0,80}\b(?:app|application|project|site|website|web\s*app|server|api|service|component|page|module|feature|cli|script|library|package|frontend|backend|fullstack|game|bot|dashboard|form|endpoint|database|schema|test|tests|suite)\b/i;
955
- const BUILD_STACK_RE = /\b(?:react|next(?:\.?js)?|vue|svelte|angular|vite|webpack|express|fastify|nest(?:js)?|django|flask|fastapi|rails|laravel|spring|node(?:\.?js)?|typescript|tailwind|redux|prisma|mongoose|graphql|docker|kubernetes)\b/i;
956
- // Pentest / security keywords — these tasks are inherently multi-step and
957
- // always deserve the full step budget, just like build tasks.
958
- const PENTEST_TASK_RE = /\b(?:pentest|pen[\s-]?test|penetration|security\s*(?:test|audit|scan|assess)|csrf|xss|sqli|sql[\s-]?inject|rce|lfi|rfi|ssrf|idor|xxe|brute[\s-]?force|enumerat|exploit|vulnerabilit|recon|bug[\s-]?bounty|ctf|capture[\s-]?the[\s-]?flag|red[\s-]?team|offensive|nmap|nikto|nuclei|ffuf|gobuster|sqlmap|hydra|metasploit)\b/i;
959
- /**
960
- * Detect pentest/security tasks that need the full step budget.
961
- * Mirrors looksLikeBuildTask but for security work.
962
- */
963
- export function looksLikePentestTask(prompt, history) {
964
- if (PENTEST_TASK_RE.test(prompt))
965
- return true;
966
- if (history && history.length > 0) {
967
- const recent = history.slice(-6);
968
- for (const msg of recent) {
969
- if (msg.role === "user" && PENTEST_TASK_RE.test(msg.content))
970
- return true;
971
- }
972
- }
973
- return false;
974
- }
975
- // Short continuation prompts that, on their own, carry no build signal but
976
- // clearly mean "keep going with what we were doing".
977
- const CONTINUATION_RE = /^(?:do\s+it|build\s+it|build\s+fully|build\s+it\s+fully|go\s+ahead|continue|proceed|keep\s+going|finish(?:\s+it)?|complete(?:\s+it)?|yes|ok(?:ay)?|make\s+it|run\s+it|next|on\s+your\s+own|build\s+(?:fully\s+)?on\s+your\s+own)\b/i;
978
- const INCOMPLETE_RE = /\b(?:not\s+complete|incomplete|isn'?t\s+(?:done|complete|working|finished)|doesn'?t\s+work|still\s+(?:broken|missing|failing)|missing\s+(?:files?|parts?)|finish\s+(?:the|it)|complete\s+(?:the|it))\b/i;
979
- // The synthetic message injected when the user runs /implement to approve a
980
- // plan ("I approve the plan. Execute it now, task by task…"). It must always
981
- // count as a build/continuation turn — it contains the word "now", which
982
- // would otherwise trip the volatile-info freshness guard and divert the run
983
- // into a pointless web.search instead of executing the plan.
984
- const PLAN_EXECUTION_RE = /\b(?:approve the plan|execute it (?:now|task by task)|task by task|execute the plan|implement the plan)\b/i;
985
- // Informational / comparison / explanation intent. These questions want an
986
- // ANSWER, not a build — even when they mention a framework or an install
987
- // step (e.g. "compare installation steps in react vite", "how do I set up
988
- // tailwind", "tailwind 3 vs 4"). They must NOT trigger the explore→plan
989
- // build workflow.
990
- const INFORMATIONAL_SIGNAL_RE = /\b(?:compare|comparison|contrast|differ(?:ence|ences|s)?|pros\s+and\s+cons|trade-?offs?|versus|vs\.?|cheat\s*sheet|explain|describe|summari[sz]e|overview)\b/i;
991
- const INTERROGATIVE_LEAD_RE = /^(?:what|which|why|how|when|who|where|is|are|do|does|did|can|could|should|would|will)\b/i;
992
- /**
993
- * Does a single message imply an actual build/scaffold task (as opposed to a
994
- * question about one)? Comparison/explanation signals and plain questions are
995
- * treated as informational and return false even when they name a stack.
996
- */
997
- function messageImpliesBuild(text) {
998
- if (!text)
999
- return false;
1000
- if (INFORMATIONAL_SIGNAL_RE.test(text))
1001
- return false;
1002
- // Explicit "build/create/scaffold … <thing>" is always a build.
1003
- if (BUILD_TASK_RE.test(text))
1004
- return true;
1005
- // A bare question (interrogative lead or trailing "?") that merely mentions
1006
- // a stack is informational, not a build.
1007
- if (text.endsWith("?") || INTERROGATIVE_LEAD_RE.test(text))
1008
- return false;
1009
- return BUILD_STACK_RE.test(text);
1010
- }
1011
- /**
1012
- * Decide whether this turn should get the build workflow (explore → plan →
1013
- * implement) and a generous step budget. Looks at the current prompt first,
1014
- * then falls back to recent USER turns so a terse follow-up inherits an
1015
- * ongoing build — but NOT the agent's own (possibly mistaken) plan narration.
1016
- */
1017
- export function looksLikeBuildTask(prompt, history) {
1018
- const text = prompt.replace(/\s+/g, " ").trim();
1019
- // Continuation / "not done yet" / plan-execution always count as build.
1020
- if (CONTINUATION_RE.test(text) ||
1021
- INCOMPLETE_RE.test(text) ||
1022
- PLAN_EXECUTION_RE.test(text)) {
1023
- return true;
1024
- }
1025
- if (messageImpliesBuild(text)) {
1026
- return true;
1027
- }
1028
- // Inspect recent USER turns only: if the user was already building
1029
- // something, treat a terse follow-up as part of that build. (Assistant
1030
- // turns are excluded so a misfired plan can't keep re-triggering build.)
1031
- if (history && history.length > 0) {
1032
- const recent = history.slice(-6);
1033
- for (const msg of recent) {
1034
- if (msg.role !== "user")
1035
- continue;
1036
- if (messageImpliesBuild(msg.content.replace(/\s+/g, " ").trim())) {
1037
- return true;
1038
- }
1039
- }
1040
- }
1041
- return false;
1042
- }
1043
- // Matrix of action-verb narration: the model says it is *about to* do
1044
- // something but hasn't. Used to detect "narrate, don't act" stalls.
1045
- const ACTION_NARRATION_RE = /\b(?:let me|let's|i'?ll|i will|i'?m going to|i am going to|i need to|i should|i'?m about to|going to|now i'?ll|first[,]?\s*i'?ll)\s+(?:now\s+|first\s+|quickly\s+|just\s+|go\s+ahead\s+and\s+)?(?:explore|list|read|fetch|browse|check|inspect|examine|look|create|run|start|write|build|add|scaffold|set\s*up|setup|install|initialize|init|generate|make|review|open|find|search|verify|update|edit|modify|fix|implement|gather|assess|scan|audit)\b/i;
1046
- /**
1047
- * Detect a message that narrates an *upcoming* action ("let me explore the
1048
- * directory", "I'll create the components") rather than an actual answer or
1049
- * tool call. Used to catch models that describe intent but emit no tool call,
1050
- * which would otherwise end the turn with nothing done. A real completion
1051
- * summary (past tense, longer, or containing a code block) is NOT flagged.
1052
- */
1053
- export function looksLikeActionNarration(text) {
1054
- const t = text.trim();
1055
- if (t.length === 0 || t.length > 600)
1056
- return false;
1057
- if (t.includes("```"))
1058
- return false;
1059
- return ACTION_NARRATION_RE.test(t);
1060
- }
1061
- /**
1062
- * Detect a message that narrates a PLAN as prose ("Goal: … Tasks: 1. … Please
1063
- * approve the plan") instead of calling plan.create. Such a turn leaves no
1064
- * real plan, so the user can't /implement it — we nudge the model to emit the
1065
- * plan.create tool call instead.
1066
- */
1067
- export function looksLikePlanNarration(text) {
1068
- const t = text.trim();
1069
- if (t.length < 40)
1070
- return false;
1071
- const approval = /\b(?:approve|approval|once approved|request changes|await(?:ing)?\s+(?:your\s+)?approval)\b/i.test(t);
1072
- const goal = /\bgoal\b/i.test(t);
1073
- const tasks = /\b(?:tasks?|steps?)\b/i.test(t) ||
1074
- /(?:^|\n)\s*(?:t?1[.)]|step\s*1)\b/im.test(t);
1075
- return approval || (goal && tasks);
1076
- }
1077
- export function requiresFreshWebSearch(prompt) {
1078
- const text = prompt.replace(/\s+/g, " ").trim();
1079
- if (!text)
1080
- return false;
1081
- if (STATIC_DISAMBIGUATION_RE.test(text) || LOCAL_RUNTIME_RE.test(text)) {
1082
- return false;
1083
- }
1084
- // Plan-execution and terse continuation turns are never "fetch current
1085
- // info" turns, even when they contain words like "now". (We intentionally
1086
- // do NOT exclude on build-stack keywords here — "latest vite version" is a
1087
- // legitimate version lookup. The runAgentLoop caller additionally gates the
1088
- // guard on looksLikeBuildTask so a real scaffold turn never searches.)
1089
- if (PLAN_EXECUTION_RE.test(text) || CONTINUATION_RE.test(text)) {
1090
- return false;
1091
- }
1092
- return (VOLATILE_SIGNAL_RE.test(text) ||
1093
- VOLATILE_ROLE_QUERY_RE.test(text) ||
1094
- ROLE_OF_ENTITY_RE.test(text) ||
1095
- EXPLICIT_WEB_LOOKUP_RE.test(text));
1096
- }
1097
- /**
1098
- * Detect a low-quality "everything in one step" plan task. A single task that
1099
- * itself enumerates many files/actions (multiple commas, an "and", several
1100
- * slashes, or an overlong title) means the model lumped the whole build into
1101
- * one checkbox instead of producing a real ordered checklist.
1102
- */
1103
- export function isLumpedSingleTask(taskTitles) {
1104
- if (taskTitles.length !== 1)
1105
- return false;
1106
- const only = taskTitles[0];
1107
- return ((only.match(/,/g)?.length ?? 0) >= 2 ||
1108
- /\band\b/i.test(only) ||
1109
- (only.match(/\//g)?.length ?? 0) >= 2 ||
1110
- only.length > 90);
1111
- }
1112
- function freshnessGuardMessage(now = new Date()) {
1113
- return (`Freshness guard for this turn: the latest user prompt appears to ask for current, volatile, or externally verifiable information. The present moment is ${currentDateTimeContext(now)}. ` +
1114
- "Before answering, call web.search FIRST with a concise query derived from the user prompt. " +
1115
- "Shape the search query for the newest timeline by including current/latest or the current year/month when useful. " +
1116
- "Do not answer from the snippets alone when detail matters — set fetchTop (e.g. fetchTop:2) to read the top result pages, or follow up with web.fetch on the most relevant URL, then answer from what the pages actually say and cite them. " +
1117
- "If web.search fails or has no results, say that current information is unavailable instead of guessing from memory.");
1118
- }
1119
- /**
1120
- * Directive injected for build/scaffold turns. Forces the careful
1121
- * explore → understand → plan → implement loop instead of a one-shot dump,
1122
- * and forbids stopping before the goal is reached.
1123
- */
1124
- function buildWorkflowDirective() {
1125
- return [
1126
- "BUILD WORKFLOW (this is a build/scaffold/feature task — follow this order EXACTLY; deviation is a failure):",
1127
- "1. EXPLORE: fs.list the working directory (and key subdirs) to see what already exists. Use tool.batch to parallelize reads.",
1128
- "2. UNDERSTAND: fs.read the files that matter (like package.json for js related and same for other languages too, config, entry points, existing components). Detect the existing stack/tooling and MATCH it. If the dir is empty or only has a stub, start fresh with a sensible modern default and say so.",
1129
- "3. PLAN: call plan.create with a COMPREHENSIVE plan — a detailed `detail` (stack chosen and WHY, architecture, how you'll verify) and 4-8 SEPARATE, ordered, high-quality tasks. The FIRST task initializes the project (scaffolder); the MIDDLE tasks MUST implement the ACTUAL FEATURE the user asked for by REPLACING the scaffolder's boilerplate (e.g. rewrite src/App.jsx into the real todo/blog/etc. UI, add components, state, styles); the LAST task verifies with a build. Scaffolding + install + run ALONE is NOT acceptable — that just leaves the Vite starter page. Each task is one distinct, verifiable action. Then STOP and wait for the user to /implement.",
1130
- "4. IMPLEMENT: once approved, work task by task in STRICT ORDER. For each task: call task.update {taskId, state:'in_progress'} → do the real work → VERIFY it actually succeeded (read a file you wrote, check the command's exit/output) → call task.update {taskId, state:'done'}, then move to the NEXT task. You MAY emit several tool calls in one message; they run in document order (independent read-only lookups run in parallel, while task.update and any write/command runs one at a time), and the batch STOPS if one fails. Keep every batch scoped to ONE task. A clean rhythm is: task.update in_progress + the work + task.update done together. Keep going until EVERY task is done. Do NOT claim work you didn't actually run.",
1131
- "",
1132
- "INITIALIZE WITH THE OFFICIAL SCAFFOLDER FIRST (do NOT hand-write build configs):",
1133
- '- React/Vue/Svelte/vanilla → `npm create vite@latest <appname> -- --template react` (templates: react, react-ts, vue, vue-ts, svelte, vanilla). Next.js → `npx --yes create-next-app@latest <appname> --yes --eslint --no-tailwind --app --src-dir --import-alias "@/*"`. Node API → `npm init -y`.',
1134
- "- GET THE TEMPLATE FLAG RIGHT. With `npm create vite@latest NAME -- --template react` the `--` IS required (it forwards --template to create-vite). With `npx create-vite@latest NAME --template react` do NOT add `--` (npx passes args straight through, so `-- --template react` makes npx DROP the flag and you silently get the WRONG, vanilla template). Pick ONE form and keep the template flag attached. After scaffolding, fs.read the generated index.html / src entry to CONFIRM you got React (a src/main.jsx + App.jsx, not a vanilla main.js/counter.js). If it's the wrong template, delete the folder and re-run with the correct command.",
1135
- "- RUN SCAFFOLDERS NON-INTERACTIVELY and into a NEW SUBFOLDER (`<appname>`). Scaffolders REFUSE to run in a non-empty directory and then print 'Operation cancelled' — and the current dir frequently already has a file like .DS_Store. So scaffold into a subfolder (always works). `--yes` does NOT fix the non-empty-dir cancel; a subfolder does. NEVER background a scaffolder with `&` or pipe `yes |` into it.",
1136
- '- If a scaffolder cannot be driven non-interactively or keeps failing, FALL BACK to hand-writing a minimal Vite setup (package.json with "type":"module", @vitejs/plugin-react, index.html that loads /src/main.jsx, src/main.jsx, src/App.jsx) then `npm install`. That never prompts and you control every file.',
1137
- "- VERIFY the init actually worked before marking the task done: fs.read package.json (it must now exist AND list react + react-dom) and fs.read index.html (it must reference your jsx entry). 'Operation cancelled' / non-zero exit means the task FAILED — do not proceed as if it succeeded.",
1138
- "",
1139
- "CRITICAL RULES during IMPLEMENTATION:",
1140
- "- You may batch tool calls: emit one or several ```tool blocks in a message. They run in document order — independent READ-ONLY lookups (fs.read/list/search, dns/whois, http.fetch GET, web.search/fetch, sysinfo) run in parallel, while task.update and any write/command (fs.write*, shell.exec, pkg.install, net.scan) run one at a time. If any call fails, the rest of that batch is cancelled so you can react — so order dependent steps correctly and keep every batch scoped to ONE task. A good batch is task.update(in_progress) + the work + task.update(done) for ONE task.",
1141
- "- Do NOT re-explore. Step 1 (EXPLORE) was already completed during planning. Start executing the first pending task immediately.",
1142
- "- ONE task at a time, in ORDER. Do NOT skip ahead to task 3 before task 2 is done.",
1143
- "- KEEP EACH FILE SMALL ENOUGH TO WRITE IN ONE CALL. If a fs.write is reported as 'cut off (output too long)', the file was NOT fully written and is likely broken/invalid — re-write it, splitting a large component into smaller files if needed. NEVER leave a half-written file and move on.",
1144
- "- VERIFY each step before marking it done: after writing files, fs.read the file back and confirm it is COMPLETE and syntactically valid (balanced braces/parens/JSX tags); after an install, check it exited 0. Marking a task done without a successful, verified tool call is the worst failure.",
1145
- "- VERIFY THE BUILD, not just the dev server. `vite` / `npm run dev` reports 'ready' even when your App.jsx has syntax errors (the error only shows in the browser). To actually confirm the app works, run `npm run build` (it fails on real syntax/JSX errors) and check it exits 0. Seeing 'VITE ready' is NOT proof the app renders.",
1146
- "- If a tool call FAILS (error output, non-zero exit, file missing), the task is NOT done. Mark it 'failed', diagnose WHY, fix it, and retry until it succeeds.",
1147
- "- NEVER claim a task is done, files were created, a dependency is installed, or a server is running unless the tool call ACTUALLY succeeded and you saw the success output. If you have not run it, say so.",
1148
- "- Start a dev server with shell.start (background job), NOT `npm run dev &` via shell.exec.",
1149
- "- THE DELIVERABLE IS THE WORKING FEATURE, NOT THE SCAFFOLD. After scaffolding you MUST replace the starter boilerplate (Vite's default App.jsx counter, Next's starter page, etc.) with the actual app the user asked for. If the user asked for a todo app, src/App.jsx must contain a real todo UI with state — finishing with the untouched Vite starter page is a FAILURE even if the build passes.",
1150
- "",
1151
- "FORBIDDEN before plan approval (/implement): you MUST NOT use fs.write, fs.writeMany, fs.edit, shell.exec, shell.start, pkg.install, or pkg.uninstall. The ONLY tool allowed before approval is plan.create (and the read/list tools for exploration). If you are nudged to 'take action' before a plan exists, your action MUST be plan.create.",
1152
- "If the task is genuinely trivial (a single tiny file), you may skip the plan — but for an app/feature, ALWAYS plan first.",
1153
- ].join("\n");
1154
- }
1155
- export function shouldDimToolChatter(call) {
1156
- return call.name === "web.search";
1157
- }
1158
- /**
1159
- * Re-assert raw mode AND resume stdin after an inquirer prompt
1160
- * (confirm/password). inquirer's readline interface pauses stdin and
1161
- * switches it to cooked mode when it closes; if we only flip raw mode back
1162
- * on but leave stdin paused, no `keypress`/`data` events flow to the REPL's
1163
- * ESC/Ctrl+C abort handler — so a long-running tool launched right after a
1164
- * confirmation can no longer be aborted (the user had to kill the terminal).
1165
- * Calling resume() restores the event flow.
1166
- */
1167
- function restoreInteractiveStdin() {
1168
- if (!process.stdin.isTTY)
1169
- return;
1170
- try {
1171
- if (!process.stdin.isRaw) {
1172
- process.stdin.setRawMode(true);
1173
- }
1174
- process.stdin.resume();
1175
- }
1176
- catch {
1177
- /* ignore */
1178
- }
1179
- }
1180
- /**
1181
- * Build a rich summary when the agent stops (user declined to continue or
1182
- * maxIterations ceiling hit). Includes plan state, key findings, and clear
1183
- * resume instructions so a later "continue" can pick up exactly here.
1184
- */
1185
- async function buildRichStopSummary(messages, session, steps) {
1186
- const plan = await loadPlan(session.sessionId).catch(() => undefined);
1187
- const parts = [];
1188
- parts.push(`Session paused after ${steps} steps.\n`);
1189
- // Plan state
1190
- if (plan) {
1191
- parts.push("## Plan Status");
1192
- parts.push(`Goal: ${plan.goal}`);
1193
- for (const task of plan.tasks) {
1194
- const icon = task.state === "done"
1195
- ? "✓"
1196
- : task.state === "in_progress"
1197
- ? "▶"
1198
- : task.state === "failed"
1199
- ? "✗"
1200
- : task.state === "skipped"
1201
- ? "↷"
1202
- : "·";
1203
- parts.push(` ${icon} [${task.id}] (${task.state}) ${task.title}${task.note ? ` — ${task.note}` : ""}`);
1204
- }
1205
- const next = plan.tasks.find((t) => t.state === "pending" || t.state === "in_progress");
1206
- if (next) {
1207
- parts.push(`\nNext task to resume: ${next.id} — "${next.title}"`);
1208
- }
1209
- const doneCount = plan.tasks.filter((t) => t.state === "done").length;
1210
- parts.push(`\nProgress: ${doneCount}/${plan.tasks.length} tasks done.`);
1211
- }
1212
- // Key findings from tool results (last 20 tool messages)
1213
- const toolMsgs = messages.filter((m) => m.role === "tool").slice(-20);
1214
- if (toolMsgs.length > 0) {
1215
- parts.push("\n## Key Findings So Far");
1216
- for (const msg of toolMsgs) {
1217
- const firstLine = msg.content.split("\n")[0] ?? "";
1218
- // Extract tool name from the structured format "Tool <name> result ..."
1219
- const toolMatch = firstLine.match(/^Tool (\S+) result/);
1220
- if (toolMatch) {
1221
- parts.push(`- ${toolMatch[1]}: ${firstLine.slice(0, 150)}`);
1222
- }
1223
- else {
1224
- parts.push(`- ${firstLine.slice(0, 150)}`);
1225
- }
1226
- }
1227
- }
1228
- parts.push("\n## To Resume");
1229
- parts.push('Type "continue" to pick up from where this session left off.');
1230
- return parts.join("\n");
1231
- }
1232
- /**
1233
- * Tools allowed while an UN-approved plan is active. Before the user runs
1234
- * /implement, the agent may only (re)create the plan and do read-only
1235
- * exploration to refine it — never execute. Everything else is blocked by
1236
- * the plan-awaiting-approval gate so a stray/recovered tool call can't start
1237
- * running the plan, and so free-text after a plan is treated as a revision.
1238
- */
1239
- const PRE_APPROVAL_ALLOWED_TOOLS = new Set([
1240
- "plan.create",
1241
- "task.update",
1242
- "fs.read",
1243
- "fs.list",
1244
- "fs.search",
1245
- "sysinfo",
1246
- "tool.batch",
1247
- "net.context",
1248
- ]);
1249
- export function isPreApprovalAllowedTool(name) {
1250
- return PRE_APPROVAL_ALLOWED_TOOLS.has(name);
1251
- }
26
+ import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeActionNarration, looksLikePlanNarration, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, shouldDimToolChatter, } from "./tool-call-parser.js";
27
+ import { createSessionPolicy, isPreApprovalAllowedTool, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
28
+ import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
29
+ import { planContextMessage, handlePlanTool, } from "./plan-tool.js";
30
+ import { inquirerConfirmPort, restoreInteractiveStdin, ensurePentestAuthorization, confirmToolExecution, } from "./confirm-port.js";
31
+ import { buildRichStopSummary } from "./stop-summary.js";
32
+ // Re-exported so existing imports of these names from "./runner.js" keep
33
+ // working unchanged — the parsing/classification engine now lives in
34
+ // tool-call-parser.ts, and the session/plan/confirm/formatting helpers now
35
+ // live in their own dedicated modules.
36
+ export * from "./tool-call-parser.js";
37
+ export { createSessionPolicy, isPreApprovalAllowedTool, shouldEnableImageOcr, } from "./session-policy.js";
1252
38
  export function styleToolChatter(call, text) {
1253
39
  return shouldDimToolChatter(call) ? chalk.dim(text) : text;
1254
40
  }
1255
- function isAbortError(error, signal) {
1256
- return (Boolean(signal?.aborted) ||
1257
- (error instanceof Error && error.name === "AbortError"));
1258
- }
1259
- /** OCR is opt-in when real image pixels are already attached to the model. */
1260
- export function shouldEnableImageOcr(prompt, hasAttachedImages) {
1261
- if (!hasAttachedImages)
1262
- return true;
1263
- return /\b(?:ocr|optical character recognition|tesseract)\b/i.test(prompt);
1264
- }
1265
- function safeArtifactName(name) {
1266
- return (name.replace(/[^a-z0-9_.-]+/gi, "-").replace(/^-+|-+$/g, "") ||
1267
- "tool-output");
1268
- }
1269
- async function saveToolOutput(call, output) {
1270
- if (!output.trim())
1271
- return undefined;
1272
- const dir = join(homedir(), ".clai", "outputs");
1273
- try {
1274
- await mkdir(dir, { recursive: true });
1275
- await fixOwner(dir);
1276
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1277
- const path = join(dir, `${stamp}-${safeArtifactName(call.name)}.txt`);
1278
- await writeFile(path, `${output}\n`, "utf8");
1279
- await fixOwner(path);
1280
- return path;
1281
- }
1282
- catch (err) {
1283
- handlePermissionError(err);
1284
- }
1285
- }
1286
- function summarizeOutput(output, maxChars = 8_000) {
1287
- if (output.length <= maxChars)
1288
- return { text: output, truncated: false };
1289
- const lines = output.split(/\r?\n/);
1290
- const head = [];
1291
- const tail = [];
1292
- let used = 0;
1293
- const half = Math.floor(maxChars / 2);
1294
- for (const line of lines) {
1295
- const cost = line.length + 1;
1296
- if (used + cost > half)
1297
- break;
1298
- head.push(line);
1299
- used += cost;
1300
- }
1301
- used = 0;
1302
- for (let index = lines.length - 1; index >= 0; index -= 1) {
1303
- const line = lines[index];
1304
- const cost = line.length + 1;
1305
- if (used + cost > half)
1306
- break;
1307
- tail.unshift(line);
1308
- used += cost;
1309
- }
1310
- return {
1311
- text: [
1312
- ...head,
1313
- `... (${lines.length.toLocaleString()} output lines truncated) ...`,
1314
- ...tail,
1315
- ].join("\n"),
1316
- truncated: true,
1317
- };
1318
- }
1319
- // Tools whose output is the actual content the model needs verbatim (file
1320
- // bodies, listings, search hits). Running these through the security-signal
1321
- // `genericReducer` was wrong: it ranks lines by pentest keywords and drops
1322
- // the rest, so source code came back as a fragmentary head+tail — the model
1323
- // saw a "truncated" file and kept re-reading it in wasted retries. For these
1324
- // we pass the raw content through (up to a generous cap) and point the model
1325
- // at the saved artifact when it exceeds the cap.
1326
- const PASSTHROUGH_TOOLS = new Set([
1327
- "fs.read",
1328
- "fs.list",
1329
- "fs.search",
1330
- "fs.edit",
1331
- "pdf.read",
1332
- ]);
1333
- const PASSTHROUGH_CAP_CHARS = 400_000;
1334
- // web.fetch/http.fetch pull in arbitrary third-party pages/API responses that
1335
- // can be hundreds of KB (e.g. a large OpenAPI spec). Unlike local files the
1336
- // model asked to read, this content is never bounded by the user's own
1337
- // project, so it must be capped like every other tool's context output —
1338
- // otherwise a single fetch can single-handedly blow the context budget and
1339
- // starve the model of room to actually respond (observed as empty/garbled
1340
- // completions on smaller-context-window models after a big fetch).
1341
- const WEB_FETCH_CAP_CHARS = 20_000;
1342
- function formatToolContext(call, result) {
1343
- const output = result.output.trim();
1344
- if (!output)
1345
- return "";
1346
- if (call.name === "web.fetch" || call.name === "http.fetch") {
1347
- const { text, truncated } = summarizeOutput(output, WEB_FETCH_CAP_CHARS);
1348
- if (!truncated)
1349
- return text;
1350
- const saved = result.outputPath
1351
- ? `\n\n[Response exceeds ${WEB_FETCH_CAP_CHARS.toLocaleString()} chars; showing the head and tail. The FULL response is saved at: ${result.outputPath} — re-fetch a narrower URL or read the saved file if you need the middle.]`
1352
- : `\n\n[Response exceeds ${WEB_FETCH_CAP_CHARS.toLocaleString()} chars; only head and tail shown.]`;
1353
- return `${text}${saved}`.trim();
1354
- }
1355
- // Pass-through tools: never reduce — the model needs the real content.
1356
- if (PASSTHROUGH_TOOLS.has(call.name)) {
1357
- const { text, truncated } = summarizeOutput(output, PASSTHROUGH_CAP_CHARS);
1358
- if (!truncated)
1359
- return text;
1360
- const saved = result.outputPath
1361
- ? `\n\n[File content exceeds ${PASSTHROUGH_CAP_CHARS.toLocaleString()} chars; showing the head and tail. The FULL content is saved at: ${result.outputPath} — re-read specific line ranges with fs.read if you need the middle.]`
1362
- : `\n\n[File content exceeds ${PASSTHROUGH_CAP_CHARS.toLocaleString()} chars; only head and tail shown. Read it in smaller chunks if the middle is needed.]`;
1363
- return `${text}${saved}`.trim();
1364
- }
1365
- let reduced;
1366
- try {
1367
- const command = call.name === "shell.exec" ? String(call.args.command ?? "") : call.name;
1368
- const policy = reduceToolOutput(output, {
1369
- toolName: call.name,
1370
- command,
1371
- });
1372
- reduced = policy.summary.trim();
1373
- }
1374
- catch {
1375
- reduced = undefined;
1376
- }
1377
- // Hard cap on the reduced text — reducers should already be small, but
1378
- // never let one accidentally explode model context.
1379
- const base = reduced && reduced.length > 0 ? reduced : output;
1380
- const summary = summarizeOutput(base, 8_000);
1381
- const saved = result.outputPath
1382
- ? `\nFull output saved to: ${result.outputPath}`
1383
- : "";
1384
- return `${summary.text}${saved}`.trim();
1385
- }
1386
- const inquirerConfirmPort = {
1387
- async confirmTool(call) {
1388
- return confirm({
1389
- message: chalk.yellow(` run ${call.name}: ${formatToolArgs(call)}?`),
1390
- default: true,
1391
- });
1392
- },
1393
- async confirmPentest() {
1394
- return confirm({
1395
- message: chalk.red("clai only assists with security testing on systems you own or have written permission to test. Confirm for this session?"),
1396
- default: false,
1397
- });
1398
- },
1399
- async confirmContinue(steps) {
1400
- return confirm({
1401
- message: chalk.yellow(` ${steps} steps reached — continue?`),
1402
- default: true,
1403
- });
1404
- },
1405
- async confirmAgentSwitch(info) {
1406
- const tools = info.tools.length > 0 ? ` (${info.tools.join(", ")})` : "";
1407
- return confirm({
1408
- message: chalk.yellow(` this needs agent mode${tools} — switch and run it?`),
1409
- default: true,
1410
- });
1411
- },
1412
- };
1413
- async function ensurePentestAuthorization(call, autoConfirm, session, confirmPort) {
1414
- if (!isPentestToolCall(call))
1415
- return true;
1416
- // Persistent auth (via `clai authorize-pentest AGREE`) wins.
1417
- if (getConfig().pentestAuthorized)
1418
- return true;
1419
- // Session auth flipped earlier in this session — no re-prompt.
1420
- if (session.pentestAuthorized.value)
1421
- return true;
1422
- if (autoConfirm) {
1423
- // -y is session-scoped only. We do NOT touch the persistent config so
1424
- // a one-shot `-y` cannot silently authorize later interactive runs.
1425
- session.pentestAuthorized.value = true;
1426
- return true;
1427
- }
1428
- const ok = await confirmPort.confirmPentest();
1429
- if (!ok)
1430
- return false;
1431
- session.pentestAuthorized.value = true;
1432
- return true;
1433
- }
1434
- async function confirmToolExecution(call, autoConfirm, session, confirmPort) {
1435
- const config = getConfig();
1436
- if (autoConfirm)
1437
- return true;
1438
- if (session.allow.has(call.name))
1439
- return true;
1440
- // Persistent allowlist kept for backwards compat with users who set it
1441
- // through `clai config` directly, but `/allow` only mutates the session
1442
- // set so authorizations never leak across processes.
1443
- if (config.allowAlwaysTools.includes(call.name))
1444
- return true;
1445
- return confirmPort.confirmTool(call);
1446
- }
1447
- /** Build the system-context block describing the session's active plan. */
1448
- function planContextMessage(plan, approved) {
1449
- const lines = [];
1450
- lines.push(`ACTIVE PLAN for this session (goal: ${plan.goal}, status: ${plan.status}):`);
1451
- if (plan.detail.trim())
1452
- lines.push(plan.detail.trim());
1453
- lines.push("Tasks:");
1454
- plan.tasks.forEach((t, i) => {
1455
- lines.push(` ${i + 1}. [${t.id}] (${t.state}) ${t.title}`);
1456
- });
1457
- if (approved) {
1458
- const firstPending = plan.tasks.find((t) => t.state === "pending");
1459
- lines.push("The user APPROVED this plan. Execute it task by task NOW.");
1460
- if (firstPending) {
1461
- lines.push(`START WITH TASK ${firstPending.id} (${firstPending.title}). ` +
1462
- "Do NOT re-do tasks already marked done, and do NOT skip ahead to later tasks.");
1463
- }
1464
- lines.push("STRICT ORDER: call task.update {taskId, state:'in_progress'} → do the real work → " +
1465
- "call task.update {taskId, state:'done'} ONLY after the tool calls actually succeed. " +
1466
- "If a tool fails, mark the task 'failed' with a note, fix the problem, then retry. " +
1467
- "Do NOT mark a task done when its commands error out. " +
1468
- "Never claim something ran without a successful tool call.");
1469
- }
1470
- else {
1471
- lines.push("This plan is NOT yet approved, so you MUST NOT execute any of its tasks yet. " +
1472
- "Any new free-text message from the user right now is a PLAN REVISION, not approval — even if it " +
1473
- "sounds like an instruction (e.g. 'do not install new tools', 'use only X', 'also add Y', 'skip task 2'). " +
1474
- "Treat it as feedback: call plan.create AGAIN with the revised goal/detail/tasks to produce an updated " +
1475
- "plan, then STOP and wait. Do NOT call shell.exec, pkg.install, net.scan, tool.check, fs.write, or any " +
1476
- "other execution tool. The user will APPROVE with /implement, or CANCEL with /discard. Only after " +
1477
- "/implement may you begin executing.");
1478
- }
1479
- return lines.join("\n");
1480
- }
1481
- /**
1482
- * Handle plan.create / task.update inline. These are session-scoped and
1483
- * persisted via the plan store so the user can view the plan (Ctrl+P) and
1484
- * the agent keeps it in context across the whole session.
1485
- */
1486
- async function handlePlanTool(call, session, ctx) {
1487
- void ctx;
1488
- if (call.name === "plan.create") {
1489
- const goal = typeof call.args.goal === "string" ? call.args.goal : "";
1490
- const detail = typeof call.args.detail === "string" ? call.args.detail : "";
1491
- const kind = typeof call.args.kind === "string" ? call.args.kind : "general";
1492
- const rawTasks = Array.isArray(call.args.tasks) ? call.args.tasks : [];
1493
- const taskTitles = rawTasks
1494
- .map((t) => (typeof t === "string" ? t : ""))
1495
- .filter(Boolean);
1496
- if (!goal || taskTitles.length === 0) {
1497
- return {
1498
- handled: true,
1499
- ok: false,
1500
- display: chalk.red(" ✗ plan.create needs a non-empty goal and at least one task title\n"),
1501
- modelNote: "plan.create failed: provide a string goal and a non-empty tasks array of step titles.",
1502
- };
1503
- }
1504
- // Reject a low-quality "everything in one step" plan. A single task that
1505
- // itself enumerates many files/actions (commas, "and", slashes) is a sign
1506
- // the model lumped the whole build into one checkbox — split it so the
1507
- // user gets a real, trackable checklist and the executor works step by step.
1508
- if (isLumpedSingleTask(taskTitles)) {
1509
- return {
1510
- handled: true,
1511
- ok: false,
1512
- display: chalk.red(" ✗ plan.create: that single task lumps the whole build into one step\n"),
1513
- modelNote: "plan.create rejected: you put everything into ONE task. Break it into 3-8 SEPARATE, " +
1514
- "ordered tasks — each a distinct action, e.g. 'scaffold package.json + vite config', " +
1515
- "'create index.html + entry (main.jsx)', 'build App + Post components', 'add posts data + styles', " +
1516
- "'install deps and run dev server to verify'. Call plan.create again with that tasks array.",
1517
- };
1518
- }
1519
- const plan = createPlan({
1520
- sessionId: session.sessionId,
1521
- goal,
1522
- detail,
1523
- taskTitles,
1524
- kind,
1525
- });
1526
- await savePlan(plan).catch(() => undefined);
1527
- // A freshly (re)created plan resets approval — the user must /implement.
1528
- session.planApproved.value = false;
1529
- const checklist = renderPlanForTerminal(plan);
1530
- const display = chalk.cyan(" ● planning\n") +
1531
- checklist +
1532
- "\n" +
1533
- chalk.dim(" ✦ plan created — press Ctrl+P to view it, /implement to approve and run it,\n" +
1534
- " or /discard to cancel it. Any other message refines this plan.\n");
1535
- return {
1536
- handled: true,
1537
- ok: true,
1538
- plan,
1539
- display,
1540
- modelNote: `Plan saved with ${plan.tasks.length} task(s). STOP here and wait — produce NO other tool calls now. ` +
1541
- "Do NOT start executing tasks until the user approves with /implement. " +
1542
- "If the user's next message gives feedback instead of /implement, that is a REVISION: call plan.create " +
1543
- "again with the updated plan and STOP again. The user may cancel the whole plan with /discard. " +
1544
- "Only after /implement do you begin, working task by task, calling task.update to mark each " +
1545
- "in_progress before and done after you finish it.",
1546
- };
1547
- }
1548
- // task.update
1549
- const plan = await loadPlan(session.sessionId).catch(() => undefined);
1550
- if (!plan) {
1551
- return {
1552
- handled: true,
1553
- ok: false,
1554
- display: chalk.red(" ✗ task.update: no active plan — call plan.create first\n"),
1555
- modelNote: "task.update failed: there is no active plan. Call plan.create first.",
1556
- };
1557
- }
1558
- const taskId = typeof call.args.taskId === "string" ? call.args.taskId : "";
1559
- const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
1560
- const note = typeof call.args.note === "string" ? call.args.note : undefined;
1561
- const validStates = [
1562
- "pending",
1563
- "in_progress",
1564
- "done",
1565
- "failed",
1566
- "skipped",
1567
- ];
1568
- if (!validStates.includes(stateRaw)) {
1569
- return {
1570
- handled: true,
1571
- ok: false,
1572
- display: chalk.red(` ✗ task.update: state must be one of ${validStates.join(", ")}\n`),
1573
- modelNote: `task.update failed: state must be one of ${validStates.join(", ")}.`,
1574
- };
1575
- }
1576
- // Only one task may be in_progress at a time. This forces genuine
1577
- // task-by-task execution: the model must close (done/failed/skipped) the
1578
- // current task before opening the next one, instead of leaving a task
1579
- // "in_progress" as an umbrella while it quietly works through the rest
1580
- // of the plan underneath it.
1581
- if (stateRaw === "in_progress") {
1582
- const otherInProgress = plan.tasks.find((t) => t.id !== taskId && t.state === "in_progress");
1583
- if (otherInProgress) {
1584
- return {
1585
- handled: true,
1586
- ok: false,
1587
- display: chalk.red(` \u2717 task.update: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress\n`),
1588
- modelNote: `task.update failed: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress. ` +
1589
- "Finish it first \u2014 call task.update with state 'done' (or 'failed'/'skipped' with a note) " +
1590
- `for [${otherInProgress.id}] before starting [${taskId}].`,
1591
- };
1592
- }
1593
- }
1594
- const ok = markTask(plan, taskId, stateRaw, note);
1595
- if (!ok) {
1596
- const ids = plan.tasks.map((t) => t.id).join(", ");
1597
- return {
1598
- handled: true,
1599
- ok: false,
1600
- display: chalk.red(` ✗ task.update: unknown taskId "${taskId}" (have: ${ids})\n`),
1601
- modelNote: `task.update failed: unknown taskId. Valid ids: ${ids}.`,
1602
- };
1603
- }
1604
- if (plan.status === "draft" || plan.status === "approved") {
1605
- plan.status = "in_progress";
1606
- }
1607
- const allDone = plan.tasks.every((t) => t.state === "done" || t.state === "skipped" || t.state === "failed");
1608
- if (allDone)
1609
- plan.status = "completed";
1610
- await savePlan(plan).catch(() => undefined);
1611
- const checklist = renderPlanForTerminal(plan);
1612
- return {
1613
- handled: true,
1614
- ok: true,
1615
- plan,
1616
- display: checklist + "\n",
1617
- modelNote: allDone
1618
- ? "Task updated. ALL tasks are now finished. Verify the result and give your final summary."
1619
- : "Task updated. Continue with the next pending task.",
1620
- };
1621
- }
1622
41
  export async function runAgentLoop(prompt, options = {}) {
1623
42
  const writesDirectly = !options.onEvent;
1624
43
  const emit = (event) => options.onEvent?.(event);
@@ -1746,7 +165,7 @@ export async function runAgentLoop(prompt, options = {}) {
1746
165
  let model = options.model ?? config.defaultModel;
1747
166
  let lastAnswer = "";
1748
167
  const session = options.session ?? createSessionPolicy();
1749
- // ── Active plan context ────────────────────────────────────────────
168
+ // Active plan context
1750
169
  // If this session already has a plan, inject it so the model keeps it in
1751
170
  // context. When the user has approved it (via /implement) we instruct the
1752
171
  // agent to execute task by task; otherwise the agent should refine/wait.
@@ -1817,7 +236,7 @@ export async function runAgentLoop(prompt, options = {}) {
1817
236
  // as a final answer. Bounded so a model that truly can't emit the format
1818
237
  // still terminates.
1819
238
  let actionIntentRetries = 0;
1820
- // ── Multi-tool execution queue ─────────────────────────────────────
239
+ // Multi-tool execution queue
1821
240
  // Models naturally emit several tool calls in one message — e.g. the
1822
241
  // plan-execution rhythm "task.update in_progress → do the work →
1823
242
  // task.update done", or a batch of fs.write calls. Rather than running
@@ -1828,7 +247,7 @@ export async function runAgentLoop(prompt, options = {}) {
1828
247
  // cleared whenever a call fails, is blocked, or needs the model to react,
1829
248
  // so the model always sees errors and stays in control.
1830
249
  let pendingCalls = [];
1831
- // ── Step budget ───────────────────────────────────────────────────
250
+ // Step budget
1832
251
  // The budget governs how many *productive* steps (a tool execution or a
1833
252
  // final answer) the agent may take. Recovery iterations — nudging a model
1834
253
  // that only produced thinking, asking it to re-emit a malformed tool call,
@@ -1947,7 +366,7 @@ export async function runAgentLoop(prompt, options = {}) {
1947
366
  blockOrCancel: true,
1948
367
  };
1949
368
  }
1950
- // ── Task-scoped execution gate ───────────────────────────────────
369
+ // Task-scoped execution gate
1951
370
  // Once a plan is approved, every non-plan tool call must run while
1952
371
  // exactly one task is "in_progress". This stops a model from batching
1953
372
  // tool calls for many/all tasks in one turn and only touching task
@@ -2244,7 +663,7 @@ export async function runAgentLoop(prompt, options = {}) {
2244
663
  }
2245
664
  return { ok: result.ok, call, result, contextOutput };
2246
665
  }
2247
- // ── Automatic context compaction ───────────────────────────────────────
666
+ // Automatic context compaction
2248
667
  // As a long turn accumulates tool outputs and reasoning, the context can
2249
668
  // grow past what the model can hold. We proactively summarize the older
2250
669
  // turns into a single continuation memory (the SAME model-written summary
@@ -2323,7 +742,7 @@ export async function runAgentLoop(prompt, options = {}) {
2323
742
  // `step` is the productive-step index (used for display + audit). It only
2324
743
  // advances when the previous iteration actually executed a tool.
2325
744
  step = productiveSteps;
2326
- // ── Step budget gate: ask the user instead of hard-stopping ────────
745
+ // Step budget gate: ask the user instead of hard-stopping
2327
746
  if (productiveSteps >= stepBudget) {
2328
747
  const askContinue = confirmPort.confirmContinue ?? inquirerConfirmPort.confirmContinue;
2329
748
  let shouldContinue = false;
@@ -2498,7 +917,7 @@ export async function runAgentLoop(prompt, options = {}) {
2498
917
  writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
2499
918
  }
2500
919
  }
2501
- // ── Empty-response recovery ───────────────────────────────────────
920
+ // Empty-response recovery
2502
921
  // Some models occasionally return an empty completion: a reasoning
2503
922
  // model that spent its whole budget on hidden &lt;think&gt; reasoning and emitted
2504
923
  // no visible text, OR (more perniciously) a gateway hiccup that
@@ -2665,7 +1084,7 @@ export async function runAgentLoop(prompt, options = {}) {
2665
1084
  // Normal final-answer path: strip any stray sentinel tokens that
2666
1085
  // somehow leaked into prose so the answer renders cleanly.
2667
1086
  const cleaned = stripSentinelTokens(assistantText.visible);
2668
- // ── Act, don't narrate ────────────────────────────────────────────
1087
+ // Act, don't narrate
2669
1088
  // Build/scaffold/plan turns must DO something. If the model returns
2670
1089
  // prose with NO tool call, it is narrating intent ("Let me first
2671
1090
  // explore the directory…") or writing a PLAN as prose ("Goal: … Tasks:
@@ -2744,7 +1163,7 @@ export async function runAgentLoop(prompt, options = {}) {
2744
1163
  });
2745
1164
  continue;
2746
1165
  }
2747
- // ── Premature-completion guard (approved plan still has work) ──────
1166
+ // Premature-completion guard (approved plan still has work)
2748
1167
  // If the user approved a plan and the model now gives a final answer
2749
1168
  // while tasks are still pending/in_progress — without having run the
2750
1169
  // work — it is fabricating completion (the exact "all tasks completed,
@@ -2828,7 +1247,7 @@ export async function runAgentLoop(prompt, options = {}) {
2828
1247
  if (allCalls.length > 1) {
2829
1248
  writeNotice("info", `${allCalls.length} tool calls in this message — running scoped (independent read-only lookups in parallel, everything else in order)`, chalk.dim(` ℹ ${allCalls.length} tool calls — read-only lookups in parallel, the rest in order\n`));
2830
1249
  }
2831
- // ── Scoped-parallel batch execution ────────────────────────────────
1250
+ // Scoped-parallel batch execution
2832
1251
  // The model may emit several calls in one message. We partition them,
2833
1252
  // IN DOCUMENT ORDER, into segments:
2834
1253
  // • A run of consecutive READ-ONLY, safe-classified calls (the same