@visorcraft/idlehands 1.1.7 → 1.1.8

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 (106) hide show
  1. package/README.md +32 -0
  2. package/dist/agent/formatting.js +251 -0
  3. package/dist/agent/formatting.js.map +1 -0
  4. package/dist/agent/review-artifact.js +147 -0
  5. package/dist/agent/review-artifact.js.map +1 -0
  6. package/dist/agent/tool-calls.js +226 -0
  7. package/dist/agent/tool-calls.js.map +1 -0
  8. package/dist/agent.js +186 -668
  9. package/dist/agent.js.map +1 -1
  10. package/dist/anton/controller.js +1 -1
  11. package/dist/anton/controller.js.map +1 -1
  12. package/dist/anton/lock.js +0 -3
  13. package/dist/anton/lock.js.map +1 -1
  14. package/dist/anton/parser.js +0 -1
  15. package/dist/anton/parser.js.map +1 -1
  16. package/dist/anton/reporter.js +1 -1
  17. package/dist/anton/reporter.js.map +1 -1
  18. package/dist/bot/commands.js +3 -2
  19. package/dist/bot/commands.js.map +1 -1
  20. package/dist/bot/confirm-telegram.js +2 -1
  21. package/dist/bot/confirm-telegram.js.map +1 -1
  22. package/dist/bot/discord-routing.js +179 -0
  23. package/dist/bot/discord-routing.js.map +1 -0
  24. package/dist/bot/discord-streaming.js +171 -0
  25. package/dist/bot/discord-streaming.js.map +1 -0
  26. package/dist/bot/discord.js +25 -221
  27. package/dist/bot/discord.js.map +1 -1
  28. package/dist/bot/format.js +2 -25
  29. package/dist/bot/format.js.map +1 -1
  30. package/dist/bot/telegram.js +56 -12
  31. package/dist/bot/telegram.js.map +1 -1
  32. package/dist/cli/build-repl-context.js.map +1 -1
  33. package/dist/cli/command-registry.js +2 -1
  34. package/dist/cli/command-registry.js.map +1 -1
  35. package/dist/cli/command-utils.js +27 -0
  36. package/dist/cli/command-utils.js.map +1 -0
  37. package/dist/cli/commands/anton.js +3 -2
  38. package/dist/cli/commands/anton.js.map +1 -1
  39. package/dist/cli/commands/model.js +8 -7
  40. package/dist/cli/commands/model.js.map +1 -1
  41. package/dist/cli/commands/project.js +5 -4
  42. package/dist/cli/commands/project.js.map +1 -1
  43. package/dist/cli/commands/session.js +9 -8
  44. package/dist/cli/commands/session.js.map +1 -1
  45. package/dist/cli/commands/tools.js +4 -3
  46. package/dist/cli/commands/tools.js.map +1 -1
  47. package/dist/cli/input.js +2 -1
  48. package/dist/cli/input.js.map +1 -1
  49. package/dist/cli/repl-dispatch.js +85 -0
  50. package/dist/cli/repl-dispatch.js.map +1 -0
  51. package/dist/cli/runtime-cmds.js +7 -7
  52. package/dist/cli/runtime-cmds.js.map +1 -1
  53. package/dist/cli/service.js +0 -14
  54. package/dist/cli/service.js.map +1 -1
  55. package/dist/cli/setup.js +3 -3
  56. package/dist/cli/setup.js.map +1 -1
  57. package/dist/cli/watch.js +2 -1
  58. package/dist/cli/watch.js.map +1 -1
  59. package/dist/client.js +0 -1
  60. package/dist/client.js.map +1 -1
  61. package/dist/context.js +101 -10
  62. package/dist/context.js.map +1 -1
  63. package/dist/harnesses.js +1 -1
  64. package/dist/harnesses.js.map +1 -1
  65. package/dist/hooks/manager.js +5 -0
  66. package/dist/hooks/manager.js.map +1 -1
  67. package/dist/index.js +13 -64
  68. package/dist/index.js.map +1 -1
  69. package/dist/progress/agent-hooks.js +37 -0
  70. package/dist/progress/agent-hooks.js.map +1 -0
  71. package/dist/progress/ir.js +7 -0
  72. package/dist/progress/ir.js.map +1 -0
  73. package/dist/progress/progress-message-renderer.js +63 -0
  74. package/dist/progress/progress-message-renderer.js.map +1 -0
  75. package/dist/progress/serialize-discord.js +60 -0
  76. package/dist/progress/serialize-discord.js.map +1 -0
  77. package/dist/progress/serialize-telegram.js +55 -0
  78. package/dist/progress/serialize-telegram.js.map +1 -0
  79. package/dist/progress/serialize-tui.js +39 -0
  80. package/dist/progress/serialize-tui.js.map +1 -0
  81. package/dist/progress/tool-summary.js +58 -0
  82. package/dist/progress/tool-summary.js.map +1 -0
  83. package/dist/progress/tool-tail.js +48 -0
  84. package/dist/progress/tool-tail.js.map +1 -0
  85. package/dist/progress/turn-progress.js +215 -0
  86. package/dist/progress/turn-progress.js.map +1 -0
  87. package/dist/replay.js +2 -5
  88. package/dist/replay.js.map +1 -1
  89. package/dist/safety.js +0 -1
  90. package/dist/safety.js.map +1 -1
  91. package/dist/spinner.js +8 -0
  92. package/dist/spinner.js.map +1 -1
  93. package/dist/tools.js +422 -29
  94. package/dist/tools.js.map +1 -1
  95. package/dist/tui/branch-picker.js.map +1 -1
  96. package/dist/tui/command-handler.js.map +1 -1
  97. package/dist/tui/controller.js +89 -28
  98. package/dist/tui/controller.js.map +1 -1
  99. package/dist/tui/render.js +15 -2
  100. package/dist/tui/render.js.map +1 -1
  101. package/dist/tui/state.js +13 -0
  102. package/dist/tui/state.js.map +1 -1
  103. package/dist/upgrade.js.map +1 -1
  104. package/dist/utils.js +17 -0
  105. package/dist/utils.js.map +1 -1
  106. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -14,123 +14,18 @@ import { LensStore } from './lens.js';
14
14
  import { SYS_CONTEXT_SCHEMA, collectSnapshot } from './sys/context.js';
15
15
  import { MCPManager } from './mcp.js';
16
16
  import { LspManager, detectInstalledLspServers } from './lsp.js';
17
+ import { generateMinimalDiff, toolResultSummary, execCommandFromSig, formatDurationMs, looksLikePlanningNarration, capTextByApproxTokens, isLikelyBinaryBuffer, sanitizePathsInMessage, digestToolResult, } from './agent/formatting.js';
18
+ import { parseToolCallsFromContent, getMissingRequiredParams, stripMarkdownFences } from './agent/tool-calls.js';
19
+ export { parseToolCallsFromContent };
20
+ import { reviewArtifactKeys, looksLikeCodeReviewRequest, looksLikeReviewRetrievalRequest, retrievalAllowsStaleArtifact, parseReviewArtifactStalePolicy, parseReviewArtifact, reviewArtifactStaleReason, gitHead, normalizeModelsResponse, } from './agent/review-artifact.js';
17
21
  import fs from 'node:fs/promises';
18
22
  import path from 'node:path';
19
- import { spawnSync } from 'node:child_process';
20
- import { stateDir, BASH_PATH as BASH } from './utils.js';
23
+ import { stateDir, timestampedId } from './utils.js';
21
24
  function makeAbortController() {
22
25
  // Node 24: AbortController is global.
23
26
  return new AbortController();
24
27
  }
25
- /** Generate a minimal unified diff for Phase 7 rich display (max 20 lines, truncated). */
26
- function generateMinimalDiff(before, after, filePath) {
27
- const bLines = before.split('\n');
28
- const aLines = after.split('\n');
29
- const out = [];
30
- out.push(`--- a/${filePath}`);
31
- out.push(`+++ b/${filePath}`);
32
- // Simple line-by-line diff (find changed region)
33
- let diffStart = 0;
34
- while (diffStart < bLines.length && diffStart < aLines.length && bLines[diffStart] === aLines[diffStart])
35
- diffStart++;
36
- let bEnd = bLines.length - 1;
37
- let aEnd = aLines.length - 1;
38
- while (bEnd > diffStart && aEnd > diffStart && bLines[bEnd] === aLines[aEnd]) {
39
- bEnd--;
40
- aEnd--;
41
- }
42
- const contextBefore = Math.max(0, diffStart - 2);
43
- const contextAfter = Math.min(Math.max(bLines.length, aLines.length) - 1, Math.max(bEnd, aEnd) + 2);
44
- const bEndContext = Math.min(bLines.length - 1, contextAfter);
45
- const aEndContext = Math.min(aLines.length - 1, contextAfter);
46
- out.push(`@@ -${contextBefore + 1},${bEndContext - contextBefore + 1} +${contextBefore + 1},${aEndContext - contextBefore + 1} @@`);
47
- let lineCount = 0;
48
- const MAX_LINES = 20;
49
- // Context before change
50
- for (let i = contextBefore; i < diffStart && lineCount < MAX_LINES; i++) {
51
- out.push(` ${bLines[i]}`);
52
- lineCount++;
53
- }
54
- // Removed lines
55
- for (let i = diffStart; i <= bEnd && i < bLines.length && lineCount < MAX_LINES; i++) {
56
- out.push(`-${bLines[i]}`);
57
- lineCount++;
58
- }
59
- // Added lines
60
- for (let i = diffStart; i <= aEnd && i < aLines.length && lineCount < MAX_LINES; i++) {
61
- out.push(`+${aLines[i]}`);
62
- lineCount++;
63
- }
64
- // Context after change
65
- const afterStart = Math.max(bEnd, aEnd) + 1;
66
- for (let i = afterStart; i <= contextAfter && i < Math.max(bLines.length, aLines.length) && lineCount < MAX_LINES; i++) {
67
- const line = i < aLines.length ? aLines[i] : bLines[i] ?? '';
68
- out.push(` ${line}`);
69
- lineCount++;
70
- }
71
- const totalChanges = (bEnd - diffStart + 1) + (aEnd - diffStart + 1);
72
- if (lineCount >= MAX_LINES && totalChanges > MAX_LINES) {
73
- out.push(`[+${totalChanges - MAX_LINES} more lines]`);
74
- }
75
- return out.join('\n');
76
- }
77
- /** Generate a one-line summary of a tool result for hooks/display. */
78
- function toolResultSummary(name, args, content, success) {
79
- if (!success)
80
- return content.slice(0, 120);
81
- switch (name) {
82
- case 'read_file':
83
- case 'read_files': {
84
- const lines = content.split('\n').length;
85
- return `${lines} lines read`;
86
- }
87
- case 'write_file':
88
- return `wrote ${args.path || 'file'}`;
89
- case 'edit_file':
90
- return content.startsWith('ERROR') ? content.slice(0, 120) : `applied edit`;
91
- case 'insert_file':
92
- return `inserted at line ${args.line ?? '?'}`;
93
- case 'exec': {
94
- try {
95
- const r = JSON.parse(content);
96
- const lines = (r.out || '').split('\n').filter(Boolean).length;
97
- return `rc=${r.rc}, ${lines} lines`;
98
- }
99
- catch {
100
- return content.slice(0, 80);
101
- }
102
- }
103
- case 'list_dir': {
104
- const entries = content.split('\n').filter(Boolean).length;
105
- return `${entries} entries`;
106
- }
107
- case 'search_files': {
108
- const matches = (content.match(/^\d+:/gm) || []).length;
109
- return `${matches} matches`;
110
- }
111
- case 'spawn_task': {
112
- const line = content.split(/\r?\n/).find((l) => l.includes('status='));
113
- return line ? line.trim() : 'sub-agent task finished';
114
- }
115
- case 'vault_search':
116
- return `vault results`;
117
- default:
118
- return content.slice(0, 80);
119
- }
120
- }
121
28
  const CACHED_EXEC_OBSERVATION_HINT = '[idlehands hint] Reused cached output for repeated read-only exec call (unchanged observation).';
122
- function execCommandFromSig(sig) {
123
- if (!sig.startsWith('exec:'))
124
- return '';
125
- const raw = sig.slice('exec:'.length);
126
- try {
127
- const parsed = JSON.parse(raw);
128
- return typeof parsed?.command === 'string' ? parsed.command : '';
129
- }
130
- catch {
131
- return '';
132
- }
133
- }
134
29
  function looksLikeReadOnlyExecCommand(command) {
135
30
  const cmd = String(command || '').trim().toLowerCase();
136
31
  if (!cmd)
@@ -203,7 +98,7 @@ Rules:
203
98
  - Never use spawn_task to bypass confirmation/safety restrictions (for example blocked package installs). If a command is blocked, adapt the plan or ask the user for approval mode changes.
204
99
  - Read the target file before editing. You need the exact text for search/replace.
205
100
  - Use read_file with search=... to jump to relevant code; avoid reading whole files.
206
- - Use edit_file for surgical changes. Never rewrite entire files when a targeted edit works.
101
+ - Prefer apply_patch or edit_range for code edits (token-efficient). Use edit_file only when exact old_text replacement is necessary.
207
102
  - Use insert_file for insertions (prepend/append/line).
208
103
  - Use exec to run commands, tests, builds; check results before reporting success.
209
104
  - When running commands in a subdirectory, use exec's cwd parameter — NOT "cd /path && cmd". Each exec call is a fresh shell; cd does not persist.
@@ -230,7 +125,7 @@ const DEFAULT_SUB_AGENT_RESULT_TOKEN_CAP = 4000;
230
125
  const APPROVAL_MODE_SET = new Set(['plan', 'reject', 'default', 'auto-edit', 'yolo']);
231
126
  const LSP_TOOL_NAMES = ['lsp_diagnostics', 'lsp_symbols', 'lsp_hover', 'lsp_definition', 'lsp_references'];
232
127
  const LSP_TOOL_NAME_SET = new Set(LSP_TOOL_NAMES);
233
- const FILE_MUTATION_TOOL_SET = new Set(['edit_file', 'write_file', 'insert_file']);
128
+ const FILE_MUTATION_TOOL_SET = new Set(['edit_file', 'edit_range', 'apply_patch', 'write_file', 'insert_file']);
234
129
  function normalizeApprovalMode(value) {
235
130
  if (typeof value !== 'string')
236
131
  return undefined;
@@ -246,66 +141,6 @@ const APPROVAL_MODE_RANK = { plan: 0, reject: 1, default: 2, 'auto-edit': 3, yol
246
141
  function capApprovalMode(requested, parentMode) {
247
142
  return APPROVAL_MODE_RANK[requested] <= APPROVAL_MODE_RANK[parentMode] ? requested : parentMode;
248
143
  }
249
- function formatDurationMs(ms) {
250
- if (!Number.isFinite(ms) || ms <= 0)
251
- return '0.0s';
252
- return `${(ms / 1000).toFixed(1)}s`;
253
- }
254
- function looksLikePlanningNarration(text, finishReason) {
255
- const s = String(text ?? '').trim().toLowerCase();
256
- if (!s)
257
- return false;
258
- // Incomplete streamed answer: likely still needs another turn.
259
- if (finishReason === 'length')
260
- return true;
261
- // Strong completion cues: treat as final answer.
262
- if (/(^|\n)\s*(done|completed|finished|final answer|summary:)\b/.test(s))
263
- return false;
264
- // Typical "thinking out loud"/plan chatter that should continue with tools.
265
- return /\b(let me|i(?:'|’)ll|i will|i'm going to|i am going to|next i(?:'|’)ll|first i(?:'|’)ll|i need to|i should|checking|reviewing|exploring|starting by)\b/.test(s);
266
- }
267
- function approxTokenCharCap(maxTokens) {
268
- const safe = Math.max(64, Math.floor(maxTokens));
269
- return safe * 4;
270
- }
271
- function capTextByApproxTokens(text, maxTokens) {
272
- const raw = String(text ?? '');
273
- const maxChars = approxTokenCharCap(maxTokens);
274
- if (raw.length <= maxChars)
275
- return { text: raw, truncated: false };
276
- const clipped = raw.slice(0, maxChars);
277
- return {
278
- text: `${clipped}\n\n[sub-agent] result truncated to ~${maxTokens} tokens (${raw.length} chars original)`,
279
- truncated: true,
280
- };
281
- }
282
- function isLikelyBinaryBuffer(buf) {
283
- const n = Math.min(buf.length, 512);
284
- for (let i = 0; i < n; i++) {
285
- if (buf[i] === 0)
286
- return true;
287
- }
288
- return false;
289
- }
290
- /**
291
- * Strip absolute paths from a message to prevent cross-project leaks in vault.
292
- * Paths within cwd are replaced with relative equivalents; other absolute paths
293
- * are replaced with just the basename.
294
- */
295
- function sanitizePathsInMessage(message, cwd) {
296
- const normCwd = cwd.replace(/\/+$/, '');
297
- // Match absolute Unix paths (at least 2 segments)
298
- return message.replace(/\/(?:home|tmp|var|usr|opt|etc|root)\/[^\s"',;)\]}>]+/g, (match) => {
299
- const normMatch = match.replace(/\/+$/, '');
300
- if (normMatch.startsWith(normCwd + '/')) {
301
- // Within cwd — make relative
302
- return normMatch.slice(normCwd.length + 1);
303
- }
304
- // Outside cwd — strip to basename
305
- const base = path.basename(normMatch);
306
- return base || match;
307
- });
308
- }
309
144
  async function buildSubAgentContextBlock(cwd, rawFiles) {
310
145
  const values = Array.isArray(rawFiles) ? rawFiles : [];
311
146
  const files = values
@@ -385,155 +220,155 @@ function buildToolsSchema(opts) {
385
220
  properties,
386
221
  required
387
222
  });
223
+ const str = () => ({ type: 'string' });
224
+ const bool = () => ({ type: 'boolean' });
225
+ const int = (min, max) => ({ type: 'integer', ...(min !== undefined && { minimum: min }), ...(max !== undefined && { maximum: max }) });
388
226
  const schemas = [
227
+ // ────────────────────────────────────────────────────────────────────────────
228
+ // Token-safe reads (require limit; allow plain output without per-line numbers)
229
+ // ────────────────────────────────────────────────────────────────────────────
389
230
  {
390
231
  type: 'function',
391
232
  function: {
392
233
  name: 'read_file',
393
- description: 'Read file contents with line numbers. Use search/context to jump to relevant code.',
234
+ description: 'Read a bounded slice of a file.',
394
235
  parameters: obj({
395
- path: { type: 'string' },
396
- offset: { type: 'integer' },
397
- limit: { type: 'integer' },
398
- search: { type: 'string' },
399
- context: { type: 'integer' },
400
- }, ['path'])
401
- }
236
+ path: str(),
237
+ offset: int(1, 1_000_000),
238
+ limit: int(1, 240),
239
+ search: str(),
240
+ context: int(0, 80),
241
+ format: { type: 'string', enum: ['plain', 'numbered', 'sparse'] },
242
+ max_bytes: int(256, 20_000),
243
+ }, ['path', 'limit']),
244
+ },
402
245
  },
403
246
  {
404
247
  type: 'function',
405
248
  function: {
406
249
  name: 'read_files',
407
- description: 'Batch read multiple files.',
250
+ description: 'Batch read bounded file slices.',
408
251
  parameters: obj({
409
252
  requests: {
410
253
  type: 'array',
411
254
  items: obj({
412
- path: { type: 'string' },
413
- offset: { type: 'integer' },
414
- limit: { type: 'integer' },
415
- search: { type: 'string' },
416
- context: { type: 'integer' },
417
- }, ['path'])
418
- }
419
- }, ['requests'])
420
- }
255
+ path: str(),
256
+ offset: int(1, 1_000_000),
257
+ limit: int(1, 240),
258
+ search: str(),
259
+ context: int(0, 80),
260
+ format: { type: 'string', enum: ['plain', 'numbered', 'sparse'] },
261
+ max_bytes: int(256, 20_000),
262
+ }, ['path', 'limit']),
263
+ },
264
+ }, ['requests']),
265
+ },
421
266
  },
267
+ // ────────────────────────────────────────────────────────────────────────────
268
+ // Writes/edits
269
+ // ────────────────────────────────────────────────────────────────────────────
422
270
  {
423
271
  type: 'function',
424
272
  function: {
425
273
  name: 'write_file',
426
- description: 'Write a file (atomic). Creates parents. Makes a backup first.',
427
- parameters: obj({ path: { type: 'string' }, content: { type: 'string' } }, ['path', 'content'])
428
- }
274
+ description: 'Write file (atomic, backup).',
275
+ parameters: obj({ path: str(), content: str() }, ['path', 'content']),
276
+ },
429
277
  },
430
278
  {
431
279
  type: 'function',
432
280
  function: {
433
- name: 'edit_file',
434
- description: 'Search/replace exact text in a file. Fails if old_text not found.',
281
+ name: 'apply_patch',
282
+ description: 'Apply unified diff patch (multi-file).',
435
283
  parameters: obj({
436
- path: { type: 'string' },
437
- old_text: { type: 'string' },
438
- new_text: { type: 'string' },
439
- replace_all: { type: 'boolean' }
440
- }, ['path', 'old_text', 'new_text'])
441
- }
284
+ patch: str(),
285
+ files: { type: 'array', items: str() },
286
+ strip: int(0, 5),
287
+ }, ['patch', 'files']),
288
+ },
442
289
  },
443
290
  {
444
291
  type: 'function',
445
292
  function: {
446
- name: 'insert_file',
447
- description: 'Insert text at a specific line (0=prepend, -1=append).',
293
+ name: 'edit_range',
294
+ description: 'Replace a line range in a file.',
448
295
  parameters: obj({
449
- path: { type: 'string' },
450
- line: { type: 'integer' },
451
- text: { type: 'string' }
452
- }, ['path', 'line', 'text'])
453
- }
296
+ path: str(),
297
+ start_line: int(1),
298
+ end_line: int(1),
299
+ replacement: str(),
300
+ }, ['path', 'start_line', 'end_line', 'replacement']),
301
+ },
302
+ },
303
+ {
304
+ type: 'function',
305
+ function: {
306
+ name: 'edit_file',
307
+ description: 'Legacy exact replace (requires old_text). Prefer apply_patch/edit_range.',
308
+ parameters: obj({ path: str(), old_text: str(), new_text: str(), replace_all: bool() }, ['path', 'old_text', 'new_text']),
309
+ },
454
310
  },
311
+ {
312
+ type: 'function',
313
+ function: {
314
+ name: 'insert_file',
315
+ description: 'Insert text at line (0=prepend, -1=append).',
316
+ parameters: obj({ path: str(), line: int(), text: str() }, ['path', 'line', 'text']),
317
+ },
318
+ },
319
+ // ────────────────────────────────────────────────────────────────────────────
320
+ // Bounded listings/search (expose existing caps)
321
+ // ────────────────────────────────────────────────────────────────────────────
455
322
  {
456
323
  type: 'function',
457
324
  function: {
458
325
  name: 'list_dir',
459
- description: 'List directory contents (optional recursive, max depth 3).',
460
- parameters: obj({
461
- path: { type: 'string' },
462
- recursive: { type: 'boolean' },
463
- }, ['path'])
464
- }
326
+ description: 'List directory entries.',
327
+ parameters: obj({ path: str(), recursive: bool(), max_entries: int(1, 500) }, ['path']),
328
+ },
465
329
  },
466
330
  {
467
331
  type: 'function',
468
332
  function: {
469
333
  name: 'search_files',
470
- description: 'Search for a regex pattern in files under a directory.',
471
- parameters: obj({
472
- pattern: { type: 'string' },
473
- path: { type: 'string' },
474
- include: { type: 'string' },
475
- }, ['pattern', 'path'])
476
- }
334
+ description: 'Search regex in files.',
335
+ parameters: obj({ pattern: str(), path: str(), include: str(), max_results: int(1, 100) }, ['pattern', 'path']),
336
+ },
477
337
  },
338
+ // ────────────────────────────────────────────────────────────────────────────
339
+ // Exec (minified schema)
340
+ // ────────────────────────────────────────────────────────────────────────────
478
341
  {
479
342
  type: 'function',
480
343
  function: {
481
344
  name: 'exec',
482
- description: 'Run a shell command (bash -c) with timeout; returns JSON rc/out/err. Each call is a new shell — cwd does not persist between calls.',
483
- parameters: obj({
484
- command: { type: 'string', description: 'Shell command to run' },
485
- cwd: { type: 'string', description: 'Working directory (default: project root). Use this instead of cd.' },
486
- timeout: { type: 'integer', description: 'Timeout in seconds (default: 30, max: 120). Use 60-120 for npm install, builds, or test suites.' }
487
- }, ['command'])
488
- }
489
- }
345
+ description: 'Run bash -c; returns JSON rc/out/err.',
346
+ parameters: obj({ command: str(), cwd: str(), timeout: int(1, 120) }, ['command']),
347
+ },
348
+ },
490
349
  ];
491
350
  if (opts?.allowSpawnTask !== false) {
492
351
  schemas.push({
493
352
  type: 'function',
494
353
  function: {
495
354
  name: 'spawn_task',
496
- description: 'Delegate a focused task to an isolated sub-agent session (no parent chat history).',
355
+ description: 'Run a sub-agent task (no parent history).',
497
356
  parameters: obj({
498
- task: { type: 'string', description: 'Instruction for the sub-agent' },
499
- context_files: {
500
- type: 'array',
501
- description: 'Optional extra files to inject into sub-agent context',
502
- items: { type: 'string' },
503
- },
504
- model: { type: 'string', description: 'Optional model override for this task' },
505
- endpoint: { type: 'string', description: 'Optional endpoint override for this task' },
506
- max_iterations: { type: 'integer', description: 'Optional max turn cap for the sub-agent' },
507
- max_tokens: { type: 'integer', description: 'Optional max completion tokens for the sub-agent' },
508
- timeout_sec: { type: 'integer', description: 'Optional timeout for this sub-agent run (seconds)' },
509
- system_prompt: { type: 'string', description: 'Optional sub-agent system prompt override for this task' },
357
+ task: str(),
358
+ context_files: { type: 'array', items: str() },
359
+ model: str(),
360
+ endpoint: str(),
361
+ max_iterations: int(),
362
+ max_tokens: int(),
363
+ timeout_sec: int(),
364
+ system_prompt: str(),
510
365
  approval_mode: { type: 'string', enum: ['plan', 'reject', 'default', 'auto-edit', 'yolo'] },
511
- }, ['task'])
512
- }
366
+ }, ['task']),
367
+ },
513
368
  });
514
369
  }
515
370
  if (opts?.activeVaultTools) {
516
- schemas.push({
517
- type: 'function',
518
- function: {
519
- name: 'vault_search',
520
- description: 'Search vault entries (notes and previous tool outputs) to reuse prior high-signal findings.',
521
- parameters: obj({
522
- query: { type: 'string' },
523
- limit: { type: 'integer' }
524
- }, ['query'])
525
- }
526
- }, {
527
- type: 'function',
528
- function: {
529
- name: 'vault_note',
530
- description: 'Persist a concise, high-signal note into the Trifecta vault.',
531
- parameters: obj({
532
- key: { type: 'string' },
533
- value: { type: 'string' }
534
- }, ['key', 'value'])
535
- }
536
- });
371
+ schemas.push({ type: 'function', function: { name: 'vault_search', description: 'Search vault.', parameters: obj({ query: str(), limit: int() }, ['query']) } }, { type: 'function', function: { name: 'vault_note', description: 'Write vault note.', parameters: obj({ key: str(), value: str() }, ['key', 'value']) } });
537
372
  }
538
373
  // Phase 9: sys_context tool is only available in sys mode.
539
374
  if (opts?.sysMode) {
@@ -544,54 +379,36 @@ function buildToolsSchema(opts) {
544
379
  type: 'function',
545
380
  function: {
546
381
  name: 'lsp_diagnostics',
547
- description: 'Get current LSP diagnostics (errors/warnings) for a file or the whole project. Structured — replaces running build commands to check for errors.',
548
- parameters: obj({
549
- path: { type: 'string', description: 'File path (omit for project-wide diagnostics)' },
550
- severity: { type: 'integer', description: '1=Error, 2=Warning, 3=Info, 4=Hint (default: config threshold)' },
551
- }, [])
382
+ description: 'Get LSP diagnostics (errors/warnings) for file or project.',
383
+ parameters: obj({ path: str(), severity: int() }, [])
552
384
  }
553
385
  }, {
554
386
  type: 'function',
555
387
  function: {
556
388
  name: 'lsp_symbols',
557
- description: 'List all symbols (functions, classes, variables) in a file via LSP.',
558
- parameters: obj({
559
- path: { type: 'string' },
560
- }, ['path'])
389
+ description: 'List symbols (functions, classes, vars) in a file.',
390
+ parameters: obj({ path: str() }, ['path'])
561
391
  }
562
392
  }, {
563
393
  type: 'function',
564
394
  function: {
565
395
  name: 'lsp_hover',
566
- description: 'Get type info and documentation for a symbol at a position.',
567
- parameters: obj({
568
- path: { type: 'string' },
569
- line: { type: 'integer' },
570
- character: { type: 'integer' },
571
- }, ['path', 'line', 'character'])
396
+ description: 'Get type/docs for symbol at position.',
397
+ parameters: obj({ path: str(), line: int(), character: int() }, ['path', 'line', 'character'])
572
398
  }
573
399
  }, {
574
400
  type: 'function',
575
401
  function: {
576
402
  name: 'lsp_definition',
577
- description: 'Go to definition of a symbol at a given position.',
578
- parameters: obj({
579
- path: { type: 'string' },
580
- line: { type: 'integer' },
581
- character: { type: 'integer' },
582
- }, ['path', 'line', 'character'])
403
+ description: 'Go to definition of symbol at position.',
404
+ parameters: obj({ path: str(), line: int(), character: int() }, ['path', 'line', 'character'])
583
405
  }
584
406
  }, {
585
407
  type: 'function',
586
408
  function: {
587
409
  name: 'lsp_references',
588
- description: 'Find all references to a symbol at a given position.',
589
- parameters: obj({
590
- path: { type: 'string' },
591
- line: { type: 'integer' },
592
- character: { type: 'integer' },
593
- max_results: { type: 'integer', description: 'Cap results (default 50)' },
594
- }, ['path', 'line', 'character'])
410
+ description: 'Find all references to symbol at position.',
411
+ parameters: obj({ path: str(), line: int(), character: int(), max_results: int() }, ['path', 'line', 'character'])
595
412
  }
596
413
  });
597
414
  }
@@ -600,203 +417,6 @@ function buildToolsSchema(opts) {
600
417
  }
601
418
  return schemas;
602
419
  }
603
- /** @internal Exported for testing. Parses tool calls from model content when tool_calls array is empty. */
604
- export function parseToolCallsFromContent(content) {
605
- // Fallback parser: if model printed JSON tool_calls in content.
606
- const trimmed = content.trim();
607
- const tryParse = (s) => {
608
- try {
609
- return JSON.parse(s);
610
- }
611
- catch {
612
- return null;
613
- }
614
- };
615
- // Case 1: whole content is JSON
616
- const whole = tryParse(trimmed);
617
- if (whole?.tool_calls && Array.isArray(whole.tool_calls))
618
- return whole.tool_calls;
619
- if (whole?.name && whole?.arguments) {
620
- return [
621
- {
622
- id: 'call_0',
623
- type: 'function',
624
- function: { name: String(whole.name), arguments: JSON.stringify(whole.arguments) }
625
- }
626
- ];
627
- }
628
- // Case 2: raw JSON array of tool calls (model writes [{name, arguments}, ...])
629
- const arrStart = trimmed.indexOf('[');
630
- const arrEnd = trimmed.lastIndexOf(']');
631
- if (arrStart !== -1 && arrEnd !== -1 && arrEnd > arrStart) {
632
- const arrSub = tryParse(trimmed.slice(arrStart, arrEnd + 1));
633
- if (Array.isArray(arrSub) && arrSub.length > 0 && arrSub[0]?.name) {
634
- return arrSub.map((item, i) => ({
635
- id: `call_${i}`,
636
- type: 'function',
637
- function: {
638
- name: String(item.name),
639
- arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {})
640
- }
641
- }));
642
- }
643
- }
644
- // Case 3: find a JSON object substring (handles tool_calls wrapper OR single tool-call)
645
- const start = trimmed.indexOf('{');
646
- const end = trimmed.lastIndexOf('}');
647
- if (start !== -1 && end !== -1 && end > start) {
648
- const sub = tryParse(trimmed.slice(start, end + 1));
649
- if (sub?.tool_calls && Array.isArray(sub.tool_calls))
650
- return sub.tool_calls;
651
- if (sub?.name && sub?.arguments) {
652
- return [
653
- {
654
- id: 'call_0',
655
- type: 'function',
656
- function: { name: String(sub.name), arguments: typeof sub.arguments === 'string' ? sub.arguments : JSON.stringify(sub.arguments) }
657
- }
658
- ];
659
- }
660
- }
661
- // Case 4: XML tool calls — used by Qwen, Hermes, and other models whose chat
662
- // templates emit <tool_call><function=name><parameter=key>value</parameter></function></tool_call>.
663
- // When llama-server's XML→JSON conversion fails (common with large write_file content),
664
- // the raw XML leaks into the content field. This recovers it.
665
- const xmlCalls = parseXmlToolCalls(trimmed);
666
- if (xmlCalls?.length)
667
- return xmlCalls;
668
- // Case 5: Lightweight function-tag calls (seen in some Qwen content-mode outputs):
669
- // <function=tool_name>
670
- // {...json args...}
671
- // </function>
672
- // or single-line <function=tool_name>{...}</function>
673
- const fnTagCalls = parseFunctionTagToolCalls(trimmed);
674
- if (fnTagCalls?.length)
675
- return fnTagCalls;
676
- return null;
677
- }
678
- /**
679
- * Parse XML-style tool calls from content.
680
- * Format: <tool_call><function=name><parameter=key>value</parameter>...</function></tool_call>
681
- * Handles multiple tool call blocks and arbitrary parameter names/values.
682
- */
683
- function parseXmlToolCalls(content) {
684
- // Quick bailout: no point parsing if there's no <tool_call> marker
685
- if (!content.includes('<tool_call>'))
686
- return null;
687
- const calls = [];
688
- // Match each <tool_call>...</tool_call> block.
689
- // Using a manual scan instead of a single greedy regex to handle nested angle brackets
690
- // in parameter values (e.g. TypeScript generics, JSX, comparison operators).
691
- let searchFrom = 0;
692
- while (searchFrom < content.length) {
693
- const blockStart = content.indexOf('<tool_call>', searchFrom);
694
- if (blockStart === -1)
695
- break;
696
- const blockEnd = content.indexOf('</tool_call>', blockStart);
697
- if (blockEnd === -1)
698
- break; // Truncated — can't recover partial tool calls
699
- const block = content.slice(blockStart + '<tool_call>'.length, blockEnd);
700
- searchFrom = blockEnd + '</tool_call>'.length;
701
- // Extract function name: <function=name>...</function>
702
- const fnMatch = block.match(/<function=(\w[\w.-]*)>/);
703
- if (!fnMatch)
704
- continue;
705
- const fnName = fnMatch[1];
706
- const fnStart = block.indexOf(fnMatch[0]) + fnMatch[0].length;
707
- const fnEnd = block.lastIndexOf('</function>');
708
- const fnBody = fnEnd !== -1 ? block.slice(fnStart, fnEnd) : block.slice(fnStart);
709
- // Extract parameters: <parameter=key>value</parameter>
710
- // Uses bracket-matching (depth counting) so that parameter values containing
711
- // literal <parameter=...>...</parameter> (e.g. writing XML files) are handled
712
- // correctly instead of being truncated at the inner close tag.
713
- const args = {};
714
- const openRe = /<parameter=(\w[\w.-]*)>/g;
715
- const closeTag = '</parameter>';
716
- let paramMatch;
717
- while ((paramMatch = openRe.exec(fnBody)) !== null) {
718
- const paramName = paramMatch[1];
719
- const valueStart = paramMatch.index + paramMatch[0].length;
720
- // Bracket-match: find the </parameter> that balances this open tag.
721
- // Depth starts at 1; nested <parameter=...> increments, </parameter> decrements.
722
- let depth = 1;
723
- let scanPos = valueStart;
724
- let closeIdx = -1;
725
- while (scanPos < fnBody.length && depth > 0) {
726
- const nextOpen = fnBody.indexOf('<parameter=', scanPos);
727
- const nextClose = fnBody.indexOf(closeTag, scanPos);
728
- if (nextClose === -1)
729
- break; // No more close tags — truncated
730
- if (nextOpen !== -1 && nextOpen < nextClose) {
731
- // An open tag comes before the next close — increase depth
732
- depth++;
733
- scanPos = nextOpen + 1; // advance past '<' to avoid re-matching
734
- }
735
- else {
736
- // Close tag comes first — decrease depth
737
- depth--;
738
- if (depth === 0) {
739
- closeIdx = nextClose;
740
- }
741
- scanPos = nextClose + closeTag.length;
742
- }
743
- }
744
- if (closeIdx === -1) {
745
- // No matching close tag — take rest of body as value (truncated output)
746
- args[paramName] = fnBody.slice(valueStart).trim();
747
- break;
748
- }
749
- // Trim exactly the template-added leading/trailing newline, preserve internal whitespace
750
- let value = fnBody.slice(valueStart, closeIdx);
751
- if (value.startsWith('\n'))
752
- value = value.slice(1);
753
- if (value.endsWith('\n'))
754
- value = value.slice(0, -1);
755
- args[paramName] = value;
756
- // Advance the regex past the close tag so the next openRe.exec starts after it
757
- openRe.lastIndex = closeIdx + closeTag.length;
758
- }
759
- if (fnName && Object.keys(args).length > 0) {
760
- calls.push({
761
- id: `call_xml_${calls.length}`,
762
- type: 'function',
763
- function: {
764
- name: fnName,
765
- arguments: JSON.stringify(args)
766
- }
767
- });
768
- }
769
- }
770
- return calls.length > 0 ? calls : null;
771
- }
772
- /** Check for missing required params by tool name — universal pre-dispatch validation */
773
- function getMissingRequiredParams(toolName, args) {
774
- const required = {
775
- read_file: ['path'],
776
- read_files: ['requests'],
777
- write_file: ['path', 'content'],
778
- edit_file: ['path', 'old_text', 'new_text'],
779
- insert_file: ['path', 'line', 'text'],
780
- list_dir: ['path'],
781
- search_files: ['pattern', 'path'],
782
- exec: ['command'],
783
- spawn_task: ['task'],
784
- sys_context: [],
785
- vault_search: ['query'],
786
- vault_note: ['key', 'value']
787
- };
788
- const req = required[toolName];
789
- if (!req)
790
- return [];
791
- return req.filter(p => args[p] === undefined || args[p] === null);
792
- }
793
- /** Strip markdown code fences (```json ... ```) from tool argument strings */
794
- function stripMarkdownFences(s) {
795
- const trimmed = s.trim();
796
- // Match ```json\n...\n``` or ```\n...\n```
797
- const m = /^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/.exec(trimmed);
798
- return m ? m[1] : s;
799
- }
800
420
  function isReadOnlyTool(name) {
801
421
  return name === 'read_file' || name === 'read_files' || name === 'list_dir' || name === 'search_files' || name === 'vault_search' || name === 'sys_context';
802
422
  }
@@ -805,6 +425,10 @@ function planModeSummary(name, args) {
805
425
  switch (name) {
806
426
  case 'write_file':
807
427
  return `write ${args.path ?? 'unknown'} (${typeof args.content === 'string' ? args.content.split('\n').length : '?'} lines)`;
428
+ case 'apply_patch':
429
+ return `apply patch to ${Array.isArray(args.files) ? args.files.length : '?'} file(s)`;
430
+ case 'edit_range':
431
+ return `edit ${args.path ?? 'unknown'} lines ${args.start_line ?? '?'}-${args.end_line ?? '?'}`;
808
432
  case 'edit_file':
809
433
  return `edit ${args.path ?? 'unknown'} (replace ${typeof args.old_text === 'string' ? args.old_text.split('\n').length : '?'} lines)`;
810
434
  case 'insert_file':
@@ -839,148 +463,6 @@ function userDisallowsDelegation(content) {
839
463
  /\b(?:spawn[_\-\s]?task|sub[\-\s]?agents?|delegate|delegation)\b[^\n.]{0,50}\b(?:do not|don't|dont|not allowed|forbidden|no)\b/.test(text);
840
464
  return negationNearDelegation;
841
465
  }
842
- function reviewArtifactKeys(projectDir) {
843
- const { projectId } = projectIndexKeys(projectDir);
844
- return {
845
- projectId,
846
- latestKey: `artifact:review:latest:${projectId}`,
847
- byIdPrefix: `artifact:review:item:${projectId}:`,
848
- };
849
- }
850
- function looksLikeCodeReviewRequest(text) {
851
- const t = text.toLowerCase();
852
- if (!t.trim())
853
- return false;
854
- if (/^\s*\/review\b/.test(t))
855
- return true;
856
- if (/\b(?:code\s+review|security\s+review|review\s+the\s+(?:code|diff|changes|repo|repository|pr)|audit\s+the\s+code)\b/.test(t))
857
- return true;
858
- return /\breview\b/.test(t) && /\b(?:code|repo|repository|diff|changes|pull\s*request|pr)\b/.test(t);
859
- }
860
- function looksLikeReviewRetrievalRequest(text) {
861
- const t = text.toLowerCase();
862
- if (!t.trim())
863
- return false;
864
- if (/^\s*\/review\s+(?:print|show|replay|latest|last|full)\b/.test(t))
865
- return true;
866
- if (!/\breview\b/.test(t))
867
- return false;
868
- if (/\bprint\s+stale\s+review\s+anyway\b/.test(t))
869
- return true;
870
- if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\breview\b[^\n.]{0,40}\b(?:again|back)\b/.test(t))
871
- return true;
872
- if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\b(?:full|entire|complete|whole)\b[^\n.]{0,80}\breview\b/.test(t))
873
- return true;
874
- if (/\b(?:full|entire|complete|whole)\b[^\n.]{0,30}\bcode\s+review\b/.test(t) && /\b(?:print|show|display|repeat|paste|send|output|give)\b/.test(t))
875
- return true;
876
- if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\b(?:last|latest|previous)\b[^\n.]{0,40}\breview\b/.test(t))
877
- return true;
878
- return false;
879
- }
880
- function retrievalAllowsStaleArtifact(text) {
881
- const t = text.toLowerCase();
882
- if (!t.trim())
883
- return false;
884
- if (/\bprint\s+stale\s+review\s+anyway\b/.test(t))
885
- return true;
886
- if (/\b(?:force|override|ignore)\b[^\n.]{0,80}\b(?:stale|old|previous)\b[^\n.]{0,80}\breview\b/.test(t))
887
- return true;
888
- if (/\b(?:stale|old|previous)\b[^\n.]{0,80}\breview\b[^\n.]{0,80}\b(?:anyway|still|force|override|ignore)\b/.test(t))
889
- return true;
890
- return false;
891
- }
892
- function parseReviewArtifactStalePolicy(raw) {
893
- const v = typeof raw === 'string' ? raw.toLowerCase().trim() : '';
894
- if (v === 'block')
895
- return 'block';
896
- return 'warn';
897
- }
898
- function parseReviewArtifact(raw) {
899
- try {
900
- const parsed = JSON.parse(raw);
901
- if (!parsed || typeof parsed !== 'object')
902
- return null;
903
- if (parsed.kind !== 'code_review')
904
- return null;
905
- if (typeof parsed.id !== 'string' || !parsed.id)
906
- return null;
907
- if (typeof parsed.createdAt !== 'string' || !parsed.createdAt)
908
- return null;
909
- if (typeof parsed.model !== 'string')
910
- return null;
911
- if (typeof parsed.projectId !== 'string' || !parsed.projectId)
912
- return null;
913
- if (typeof parsed.projectDir !== 'string' || !parsed.projectDir)
914
- return null;
915
- if (typeof parsed.prompt !== 'string')
916
- return null;
917
- if (typeof parsed.content !== 'string')
918
- return null;
919
- return parsed;
920
- }
921
- catch {
922
- return null;
923
- }
924
- }
925
- function gitHead(cwd) {
926
- const inside = spawnSync(BASH, ['-lc', 'git rev-parse --is-inside-work-tree'], {
927
- cwd,
928
- encoding: 'utf8',
929
- timeout: 1000,
930
- });
931
- if (inside.status !== 0 || !String(inside.stdout || '').trim().startsWith('true'))
932
- return undefined;
933
- const head = spawnSync(BASH, ['-lc', 'git rev-parse HEAD'], {
934
- cwd,
935
- encoding: 'utf8',
936
- timeout: 1000,
937
- });
938
- if (head.status !== 0)
939
- return undefined;
940
- const sha = String(head.stdout || '').trim();
941
- return sha || undefined;
942
- }
943
- function shortSha(sha) {
944
- if (!sha)
945
- return 'unknown';
946
- return sha.slice(0, 8);
947
- }
948
- function reviewArtifactStaleReason(artifact, cwd) {
949
- const currentHead = gitHead(cwd);
950
- const currentDirty = isGitDirty(cwd);
951
- if (artifact.gitHead && currentHead && artifact.gitHead !== currentHead) {
952
- return `Stored review was generated at commit ${shortSha(artifact.gitHead)}; repository is now at ${shortSha(currentHead)}.`;
953
- }
954
- if (artifact.gitDirty === false && currentDirty) {
955
- return 'Stored review was generated on a clean tree; working tree now has uncommitted changes.';
956
- }
957
- return '';
958
- }
959
- function normalizeModelsResponse(raw) {
960
- if (Array.isArray(raw)) {
961
- return {
962
- data: raw
963
- .map((m) => {
964
- if (!m)
965
- return null;
966
- if (typeof m === 'string')
967
- return { id: m };
968
- if (typeof m.id === 'string' && m.id)
969
- return m;
970
- return null;
971
- })
972
- .filter(Boolean)
973
- };
974
- }
975
- if (raw && Array.isArray(raw.data)) {
976
- return {
977
- data: raw.data
978
- .map((m) => (m && typeof m.id === 'string' && m.id ? m : null))
979
- .filter(Boolean)
980
- };
981
- }
982
- return { data: [] };
983
- }
984
466
  export async function createSession(opts) {
985
467
  const cfg = opts.config;
986
468
  let client = opts.runtime?.client ?? new OpenAIClient(cfg.endpoint, opts.apiKey, cfg.verbose);
@@ -1014,7 +496,7 @@ export async function createSession(opts) {
1014
496
  modelMeta,
1015
497
  });
1016
498
  let supportsVision = supportsVisionModel(model, modelMeta, harness);
1017
- const sessionId = `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
499
+ const sessionId = `session-${timestampedId()}`;
1018
500
  const hookCfg = cfg.hooks ?? {};
1019
501
  const hookManager = opts.runtime?.hookManager ?? new HookManager({
1020
502
  enabled: hookCfg.enabled !== false,
@@ -1082,7 +564,7 @@ export async function createSession(opts) {
1082
564
  ? Number(cfg.mcp_call_timeout_sec)
1083
565
  : (Number.isFinite(cfg.mcp?.call_timeout_sec) ? Number(cfg.mcp?.call_timeout_sec) : 30);
1084
566
  const builtInToolNames = [
1085
- 'read_file', 'read_files', 'write_file', 'edit_file', 'insert_file',
567
+ 'read_file', 'read_files', 'write_file', 'apply_patch', 'edit_range', 'edit_file', 'insert_file',
1086
568
  'list_dir', 'search_files', 'exec', 'vault_search', 'vault_note', 'sys_context',
1087
569
  ...(spawnTaskEnabled ? ['spawn_task'] : []),
1088
570
  ];
@@ -2058,11 +1540,25 @@ export async function createSession(opts) {
2058
1540
  const hookObj = typeof hooks === 'function' ? { onToken: hooks } : hooks ?? {};
2059
1541
  let turns = 0;
2060
1542
  let toolCalls = 0;
2061
- const askId = `ask-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1543
+ const askId = `ask-${timestampedId()}`;
2062
1544
  const emitToolCall = async (call) => {
2063
1545
  hookObj.onToolCall?.(call);
2064
1546
  await hookManager.emit('tool_call', { askId, turn: turns, call });
2065
1547
  };
1548
+ const emitToolStream = (stream) => {
1549
+ try {
1550
+ void hookObj.onToolStream?.(stream);
1551
+ }
1552
+ catch {
1553
+ // best effort
1554
+ }
1555
+ try {
1556
+ void hookManager.emit('tool_stream', { askId, turn: turns, stream });
1557
+ }
1558
+ catch {
1559
+ // best effort
1560
+ }
1561
+ };
2066
1562
  const emitToolResult = async (result) => {
2067
1563
  await hookObj.onToolResult?.(result);
2068
1564
  await hookManager.emit('tool_result', { askId, turn: turns, result });
@@ -2142,7 +1638,7 @@ export async function createSession(opts) {
2142
1638
  if (!clean)
2143
1639
  return;
2144
1640
  const createdAt = new Date().toISOString();
2145
- const id = `review-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1641
+ const id = `review-${timestampedId()}`;
2146
1642
  const artifact = {
2147
1643
  id,
2148
1644
  kind: 'code_review',
@@ -2178,6 +1674,7 @@ export async function createSession(opts) {
2178
1674
  // identical tool call signature counts across this ask() run
2179
1675
  const sigCounts = new Map();
2180
1676
  const toolNameByCallId = new Map();
1677
+ const toolArgsByCallId = new Map();
2181
1678
  // Loop-break helper state: bump mutationVersion whenever a tool mutates files.
2182
1679
  // We also record the mutationVersion at which a given signature was last seen.
2183
1680
  let mutationVersion = 0;
@@ -2219,6 +1716,42 @@ export async function createSession(opts) {
2219
1716
  }
2220
1717
  return msg;
2221
1718
  };
1719
+ const compactToolMessageForHistory = async (toolCallId, rawContent) => {
1720
+ const toolName = toolNameByCallId.get(toolCallId) ?? 'tool';
1721
+ const toolArgs = toolArgsByCallId.get(toolCallId) ?? {};
1722
+ const rawMsg = { role: 'tool', tool_call_id: toolCallId, content: rawContent };
1723
+ // Persist full-fidelity output immediately so live context can stay small.
1724
+ if (vault && typeof vault.archiveToolResult === 'function') {
1725
+ try {
1726
+ await vault.archiveToolResult(rawMsg, toolName);
1727
+ }
1728
+ catch (e) {
1729
+ console.warn(`[warn] vault archive failed: ${e instanceof Error ? e.message : String(e)}`);
1730
+ }
1731
+ }
1732
+ let compact = rawContent;
1733
+ if (lens) {
1734
+ try {
1735
+ const lensCompact = await lens.summarizeToolOutput(rawContent, toolName, typeof toolArgs.path === 'string' ? String(toolArgs.path) : undefined);
1736
+ if (typeof lensCompact === 'string' && lensCompact.length && lensCompact.length < compact.length) {
1737
+ compact = lensCompact;
1738
+ }
1739
+ }
1740
+ catch {
1741
+ // ignore lens failures; fallback to raw
1742
+ }
1743
+ }
1744
+ const success = !String(rawContent).startsWith('ERROR:');
1745
+ const digested = digestToolResult(toolName, toolArgs, compact, success);
1746
+ if (digested !== rawContent) {
1747
+ return {
1748
+ role: 'tool',
1749
+ tool_call_id: toolCallId,
1750
+ content: `${digested}\n[full output archived in vault: tool=${toolName}, call_id=${toolCallId}]`,
1751
+ };
1752
+ }
1753
+ return rawMsg;
1754
+ };
2222
1755
  const persistFailure = async (error, contextLine) => {
2223
1756
  if (!vault)
2224
1757
  return;
@@ -2280,7 +1813,6 @@ export async function createSession(opts) {
2280
1813
  }
2281
1814
  await maybeAutoDetectModelChange();
2282
1815
  const beforeMsgs = messages;
2283
- const beforeTokens = estimateTokensFromMessages(beforeMsgs);
2284
1816
  const compacted = enforceContextBudget({
2285
1817
  messages: beforeMsgs,
2286
1818
  contextWindow,
@@ -2289,7 +1821,6 @@ export async function createSession(opts) {
2289
1821
  compactAt: cfg.compact_at ?? 0.8,
2290
1822
  toolSchemaTokens: estimateToolSchemaTokens(getToolsSchema()),
2291
1823
  });
2292
- const compactedDropped = beforeMsgs.length > compacted.length || estimateTokensFromMessages(compacted) < beforeTokens;
2293
1824
  const compactedByRefs = new Set(compacted);
2294
1825
  const dropped = beforeMsgs.filter((m) => !compactedByRefs.has(m));
2295
1826
  if (dropped.length && vault) {
@@ -2532,7 +2063,11 @@ export async function createSession(opts) {
2532
2063
  if (visible && hookObj.onToken)
2533
2064
  hookObj.onToken('\n');
2534
2065
  toolCalls += toolCallsArr.length;
2535
- messages.push({ role: 'assistant', content: visible || '', tool_calls: toolCallsArr });
2066
+ const assistantToolCallText = visible || '';
2067
+ const compactAssistantToolCallText = assistantToolCallText.length > 900
2068
+ ? `${assistantToolCallText.slice(0, 900)}\n[history-compacted: assistant narration truncated before tool execution]`
2069
+ : assistantToolCallText;
2070
+ messages.push({ role: 'assistant', content: compactAssistantToolCallText, tool_calls: toolCallsArr });
2536
2071
  // sigCounts is scoped to the entire ask() run (see above)
2537
2072
  // Bridge ConfirmationProvider → legacy confirm callback for tools.
2538
2073
  // If a ConfirmationProvider is given, wrap it; otherwise fall back to raw callback.
@@ -2655,7 +2190,7 @@ export async function createSession(opts) {
2655
2190
  `Hint: you repeated the same tool call ${loopThreshold} times with identical arguments. ` +
2656
2191
  `If the call succeeded, move on to the next step. ` +
2657
2192
  `If it failed, check that all required parameters are present and correct. ` +
2658
- `For write_file/edit_file, ensure 'content'/'old_text'/'new_text' are included as strings.`);
2193
+ `For write_file/edit_file/apply_patch/edit_range, ensure required args are present (content/old_text/new_text/patch/files/start_line/end_line/replacement).`);
2659
2194
  }
2660
2195
  }
2661
2196
  // Update consecutive tracking: save this turn's signatures for next turn comparison.
@@ -2684,6 +2219,8 @@ export async function createSession(opts) {
2684
2219
  const hasMcpTool = mcpManager?.hasTool(name) === true;
2685
2220
  if (!builtInFn && !isLspTool && !hasMcpTool && !isSpawnTask)
2686
2221
  throw new Error(`unknown tool: ${name}`);
2222
+ // Keep parsed args by call-id so we can digest/archive tool outputs with context.
2223
+ toolArgsByCallId.set(callId, args && typeof args === 'object' && !Array.isArray(args) ? args : {});
2687
2224
  // Pre-dispatch check for missing required params.
2688
2225
  // Universal: catches omitted params early with a clear, instructive error
2689
2226
  // before the tool itself throws a less helpful message.
@@ -2798,7 +2335,13 @@ export async function createSession(opts) {
2798
2335
  content = await runSpawnTask(args);
2799
2336
  }
2800
2337
  else if (builtInFn) {
2801
- const value = await builtInFn(ctx, args);
2338
+ const callCtx = {
2339
+ ...ctx,
2340
+ toolCallId: callId,
2341
+ toolName: name,
2342
+ onToolStream: emitToolStream,
2343
+ };
2344
+ const value = await builtInFn(callCtx, args);
2802
2345
  content = typeof value === 'string' ? value : JSON.stringify(value);
2803
2346
  if (name === 'exec') {
2804
2347
  // Successful exec clears blocked-loop counters.
@@ -3019,7 +2562,8 @@ export async function createSession(opts) {
3019
2562
  if (ac.signal.aborted)
3020
2563
  break;
3021
2564
  for (const r of results) {
3022
- messages.push({ role: 'tool', tool_call_id: r.id, content: r.content });
2565
+ const compactToolMsg = await compactToolMessageForHistory(r.id, r.content);
2566
+ messages.push(compactToolMsg);
3023
2567
  }
3024
2568
  if (readOnlyExecTurnHints.length) {
3025
2569
  const previews = readOnlyExecTurnHints
@@ -3293,30 +2837,4 @@ async function autoPickModel(client, cached) {
3293
2837
  clearTimeout(timer);
3294
2838
  }
3295
2839
  }
3296
- function parseFunctionTagToolCalls(content) {
3297
- const m = content.match(/<function=([\w.-]+)>([\s\S]*?)<\/function>/i);
3298
- if (!m)
3299
- return null;
3300
- const name = m[1];
3301
- const body = (m[2] ?? '').trim();
3302
- // If body contains JSON object, use it as arguments; else empty object.
3303
- let args = '{}';
3304
- const jsonStart = body.indexOf('{');
3305
- const jsonEnd = body.lastIndexOf('}');
3306
- if (jsonStart !== -1 && jsonEnd > jsonStart) {
3307
- const sub = body.slice(jsonStart, jsonEnd + 1);
3308
- try {
3309
- JSON.parse(sub);
3310
- args = sub;
3311
- }
3312
- catch {
3313
- // keep {}
3314
- }
3315
- }
3316
- return [{
3317
- id: 'call_0',
3318
- type: 'function',
3319
- function: { name, arguments: args }
3320
- }];
3321
- }
3322
2840
  //# sourceMappingURL=agent.js.map