@visorcraft/idlehands 1.4.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/dist/agent/constants.js +12 -0
  2. package/dist/agent/constants.js.map +1 -0
  3. package/dist/agent/errors.js +8 -0
  4. package/dist/agent/errors.js.map +1 -0
  5. package/dist/agent/exec-helpers.js +105 -0
  6. package/dist/agent/exec-helpers.js.map +1 -0
  7. package/dist/agent/model-pick.js +21 -0
  8. package/dist/agent/model-pick.js.map +1 -0
  9. package/dist/agent/session-utils.js +63 -0
  10. package/dist/agent/session-utils.js.map +1 -0
  11. package/dist/agent/subagent-context.js +78 -0
  12. package/dist/agent/subagent-context.js.map +1 -0
  13. package/dist/agent/tool-loop-guard.js.map +1 -1
  14. package/dist/agent/tool-policy.js +54 -0
  15. package/dist/agent/tool-policy.js.map +1 -0
  16. package/dist/agent/tools-schema.js +281 -0
  17. package/dist/agent/tools-schema.js.map +1 -0
  18. package/dist/agent.js +136 -630
  19. package/dist/agent.js.map +1 -1
  20. package/dist/anton/controller.js +42 -139
  21. package/dist/anton/controller.js.map +1 -1
  22. package/dist/anton/lint-baseline.js +64 -0
  23. package/dist/anton/lint-baseline.js.map +1 -0
  24. package/dist/anton/preflight.js.map +1 -1
  25. package/dist/anton/prompt.js +71 -71
  26. package/dist/anton/reporter.js.map +1 -1
  27. package/dist/anton/runtime-ready.js +120 -0
  28. package/dist/anton/runtime-ready.js.map +1 -0
  29. package/dist/anton/session.js +8 -6
  30. package/dist/anton/session.js.map +1 -1
  31. package/dist/anton/verifier-utils.js +148 -0
  32. package/dist/anton/verifier-utils.js.map +1 -0
  33. package/dist/anton/verifier.js +26 -227
  34. package/dist/anton/verifier.js.map +1 -1
  35. package/dist/bot/anton-auto-pin.js +12 -0
  36. package/dist/bot/anton-auto-pin.js.map +1 -0
  37. package/dist/bot/anton-commands.js +137 -0
  38. package/dist/bot/anton-commands.js.map +1 -0
  39. package/dist/bot/anton-run.js +144 -0
  40. package/dist/bot/anton-run.js.map +1 -0
  41. package/dist/bot/anton-status-format.js +18 -0
  42. package/dist/bot/anton-status-format.js.map +1 -0
  43. package/dist/bot/basic-commands.js +114 -0
  44. package/dist/bot/basic-commands.js.map +1 -0
  45. package/dist/bot/command-format.js.map +1 -1
  46. package/dist/bot/command-logic.js +8 -728
  47. package/dist/bot/command-logic.js.map +1 -1
  48. package/dist/bot/commands.js +18 -1
  49. package/dist/bot/commands.js.map +1 -1
  50. package/dist/bot/discord-anton-autopin.js +29 -0
  51. package/dist/bot/discord-anton-autopin.js.map +1 -0
  52. package/dist/bot/discord-anton.js +45 -0
  53. package/dist/bot/discord-anton.js.map +1 -0
  54. package/dist/bot/discord-commands.js +20 -52
  55. package/dist/bot/discord-commands.js.map +1 -1
  56. package/dist/bot/discord-result.js +9 -0
  57. package/dist/bot/discord-result.js.map +1 -0
  58. package/dist/bot/discord-routing.js.map +1 -1
  59. package/dist/bot/discord.js +42 -12
  60. package/dist/bot/discord.js.map +1 -1
  61. package/dist/bot/escalation-commands.js +145 -0
  62. package/dist/bot/escalation-commands.js.map +1 -0
  63. package/dist/bot/escalation.js.map +1 -1
  64. package/dist/bot/git-status-command.js +28 -0
  65. package/dist/bot/git-status-command.js.map +1 -0
  66. package/dist/bot/model-endpoint.js +25 -0
  67. package/dist/bot/model-endpoint.js.map +1 -0
  68. package/dist/bot/session-history.js +61 -0
  69. package/dist/bot/session-history.js.map +1 -0
  70. package/dist/bot/session-settings.js +89 -0
  71. package/dist/bot/session-settings.js.map +1 -0
  72. package/dist/bot/telegram-commands.js +15 -7
  73. package/dist/bot/telegram-commands.js.map +1 -1
  74. package/dist/bot/telegram.js +13 -28
  75. package/dist/bot/telegram.js.map +1 -1
  76. package/dist/cli/agent-turn.js +8 -2
  77. package/dist/cli/agent-turn.js.map +1 -1
  78. package/dist/cli/commands/anton.js +8 -3
  79. package/dist/cli/commands/anton.js.map +1 -1
  80. package/dist/cli/commands/model.js +1 -3
  81. package/dist/cli/commands/model.js.map +1 -1
  82. package/dist/cli/commands/project.js +1 -1
  83. package/dist/cli/commands/project.js.map +1 -1
  84. package/dist/cli/commands/secrets.js +1 -1
  85. package/dist/cli/commands/secrets.js.map +1 -1
  86. package/dist/cli/commands/session.js +22 -12
  87. package/dist/cli/commands/session.js.map +1 -1
  88. package/dist/cli/guided-onboarding.js +20 -0
  89. package/dist/cli/guided-onboarding.js.map +1 -0
  90. package/dist/cli/runtime-cmds.js +8 -133
  91. package/dist/cli/runtime-cmds.js.map +1 -1
  92. package/dist/cli/runtime-common.js +35 -0
  93. package/dist/cli/runtime-common.js.map +1 -0
  94. package/dist/cli/runtime-detect.js +12 -0
  95. package/dist/cli/runtime-detect.js.map +1 -0
  96. package/dist/cli/runtime-host-command.js +7 -0
  97. package/dist/cli/runtime-host-command.js.map +1 -0
  98. package/dist/cli/runtime-probe-defaults.js +63 -0
  99. package/dist/cli/runtime-probe-defaults.js.map +1 -0
  100. package/dist/cli/runtime-scan-ports.js +30 -0
  101. package/dist/cli/runtime-scan-ports.js.map +1 -0
  102. package/dist/cli/setup-bot-step.js +51 -0
  103. package/dist/cli/setup-bot-step.js.map +1 -0
  104. package/dist/cli/setup-runtime-forms.js +214 -0
  105. package/dist/cli/setup-runtime-forms.js.map +1 -0
  106. package/dist/cli/setup-style.js +8 -0
  107. package/dist/cli/setup-style.js.map +1 -0
  108. package/dist/cli/setup-ui.js +146 -0
  109. package/dist/cli/setup-ui.js.map +1 -0
  110. package/dist/cli/setup.js +11 -449
  111. package/dist/cli/setup.js.map +1 -1
  112. package/dist/client/error-utils.js +37 -0
  113. package/dist/client/error-utils.js.map +1 -0
  114. package/dist/client/pressure.js +77 -0
  115. package/dist/client/pressure.js.map +1 -0
  116. package/dist/client.js +24 -122
  117. package/dist/client.js.map +1 -1
  118. package/dist/config.js +34 -17
  119. package/dist/config.js.map +1 -1
  120. package/dist/git.js +8 -2
  121. package/dist/git.js.map +1 -1
  122. package/dist/hooks/types.js.map +1 -1
  123. package/dist/index.js.map +1 -1
  124. package/dist/progress/message-edit-scheduler.js.map +1 -1
  125. package/dist/progress/turn-progress.js.map +1 -1
  126. package/dist/runtime/executor.js +4 -1
  127. package/dist/runtime/executor.js.map +1 -1
  128. package/dist/runtime/health.js.map +1 -1
  129. package/dist/runtime/host-runner.js.map +1 -1
  130. package/dist/safety.js +3 -2
  131. package/dist/safety.js.map +1 -1
  132. package/dist/shared/config-utils.js.map +1 -1
  133. package/dist/tools/exec-core.js +252 -0
  134. package/dist/tools/exec-core.js.map +1 -0
  135. package/dist/tools/exec-pty.js +89 -0
  136. package/dist/tools/exec-pty.js.map +1 -0
  137. package/dist/tools/exec-utils.js +94 -0
  138. package/dist/tools/exec-utils.js.map +1 -0
  139. package/dist/tools/file-discovery.js +144 -0
  140. package/dist/tools/file-discovery.js.map +1 -0
  141. package/dist/tools/file-mutations.js +326 -0
  142. package/dist/tools/file-mutations.js.map +1 -0
  143. package/dist/tools/file-read.js +133 -0
  144. package/dist/tools/file-read.js.map +1 -0
  145. package/dist/tools/patch-apply.js +168 -0
  146. package/dist/tools/patch-apply.js.map +1 -0
  147. package/dist/tools/path-safety.js.map +1 -1
  148. package/dist/tools/replay-utils.js +25 -0
  149. package/dist/tools/replay-utils.js.map +1 -0
  150. package/dist/tools/search-utils.js +55 -0
  151. package/dist/tools/search-utils.js.map +1 -0
  152. package/dist/tools/sys-notes.js +34 -0
  153. package/dist/tools/sys-notes.js.map +1 -0
  154. package/dist/tools/text-utils.js +164 -0
  155. package/dist/tools/text-utils.js.map +1 -0
  156. package/dist/tools/undo.js +1 -1
  157. package/dist/tools/undo.js.map +1 -1
  158. package/dist/tools/vault-tools.js +36 -0
  159. package/dist/tools/vault-tools.js.map +1 -0
  160. package/dist/tools.js +19 -1460
  161. package/dist/tools.js.map +1 -1
  162. package/dist/tui/controller.js +5 -2
  163. package/dist/tui/controller.js.map +1 -1
  164. package/dist/tui/render.js.map +1 -1
  165. package/dist/utils.js +2 -2
  166. package/dist/utils.js.map +1 -1
  167. package/dist/vault.js +1 -1
  168. package/dist/vault.js.map +1 -1
  169. package/dist/watchdog.js +1 -3
  170. package/dist/watchdog.js.map +1 -1
  171. package/package.json +2 -1
package/dist/tools.js CHANGED
@@ -1,1405 +1,48 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { spawn, spawnSync } from 'node:child_process';
4
- import { ToolError } from './tools/tool-error.js';
5
- import { checkExecSafety, checkPathSafety, isProtectedDeleteTarget } from './safety.js';
6
1
  import { sys_context as sysContextTool } from './sys/context.js';
7
- import { isWithinDir, resolvePath, redactPath, checkCwdWarning, enforceMutationWithinCwd, } from './tools/path-safety.js';
8
- import { normalizePatchPath, extractTouchedFilesFromPatch, } from './tools/patch.js';
9
- import { atomicWrite, backupFile } from './tools/undo.js';
10
- import { shellEscape, BASH_PATH } from './utils.js';
2
+ import { execTool } from './tools/exec-core.js';
3
+ import { listDirTool, searchFilesTool } from './tools/file-discovery.js';
4
+ import { editFileTool, editRangeTool, insertFileTool, writeFileTool, } from './tools/file-mutations.js';
5
+ import { readFileTool, readFilesTool } from './tools/file-read.js';
6
+ import { applyPatchTool } from './tools/patch-apply.js';
7
+ import { vaultNoteTool, vaultSearchTool } from './tools/vault-tools.js';
11
8
  // Re-export from extracted modules so existing imports don't break
12
9
  export { atomicWrite, undo_path } from './tools/undo.js';
13
- // Backup/undo system imported from tools/undo.ts (atomicWrite, backupFile, undo_path)
14
- /**
15
- * Build a read-back snippet showing the region around a mutation.
16
- * Returns numbered lines ±contextLines around the changed area, capped to avoid bloat.
17
- */
18
- function mutationReadback(fileContent, changedStartLine, changedEndLine, contextLines = 5, maxLines = 40) {
19
- const lines = fileContent.split(/\r?\n/);
20
- const totalLines = lines.length;
21
- const from = Math.max(0, changedStartLine - contextLines);
22
- const to = Math.min(totalLines, changedEndLine + contextLines);
23
- let slice = lines.slice(from, to);
24
- let truncated = false;
25
- if (slice.length > maxLines) {
26
- slice = slice.slice(0, maxLines);
27
- truncated = true;
28
- }
29
- const numbered = slice.map((l, i) => `${from + i + 1}: ${l}`).join('\n');
30
- const header = `\n--- current state of lines ${from + 1}-${from + slice.length} (of ${totalLines}) ---\n`;
31
- return header + numbered + (truncated ? '\n...(truncated)' : '');
32
- }
33
- const DEFAULT_MAX_EXEC_BYTES = 16384;
34
- let ptyUnavailableWarned = false;
35
- async function loadNodePty() {
36
- try {
37
- const mod = await import('node-pty');
38
- return mod;
39
- }
40
- catch {
41
- if (!ptyUnavailableWarned) {
42
- ptyUnavailableWarned = true;
43
- console.error('[warn] node-pty not available; interactive sudo is disabled. Install build tools (python3, make, g++) and reinstall to enable it.');
44
- }
45
- return null;
46
- }
47
- }
48
- /** Best-effort MIME type guess from magic bytes + extension (§7/§8). */
49
- function guessMimeType(filePath, buf) {
50
- // Magic byte signatures
51
- if (buf.length >= 4) {
52
- if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
53
- return 'image/png';
54
- if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
55
- return 'image/jpeg';
56
- if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46)
57
- return 'image/gif';
58
- if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf.length >= 12 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50)
59
- return 'image/webp'; // RIFF+WEBP
60
- if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46)
61
- return 'application/pdf';
62
- if (buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04)
63
- return 'application/zip';
64
- if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46)
65
- return 'application/x-elf';
66
- if (buf[0] === 0x1f && buf[1] === 0x8b)
67
- return 'application/gzip';
68
- }
69
- // Fall back to extension
70
- const ext = path.extname(filePath).toLowerCase();
71
- const extMap = {
72
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
73
- '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
74
- '.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar',
75
- '.wasm': 'application/wasm', '.so': 'application/x-sharedlib',
76
- '.exe': 'application/x-executable', '.o': 'application/x-object',
77
- };
78
- return extMap[ext] ?? 'application/octet-stream';
79
- }
80
- function stripAnsi(s) {
81
- // eslint-disable-next-line no-control-regex
82
- return s
83
- .replace(/\u001b\[[0-9;]*[A-Za-z]/g, '') // CSI sequences (SGR, cursor, erase, scroll, etc.)
84
- .replace(/\u001b\][^\u0007]*\u0007/g, '') // OSC sequences
85
- .replace(/\u001b[()][AB012]/g, '') // Character set selection
86
- .replace(/\u001b[=>Nc7-9]/g, ''); // Other common single-char escapes
87
- }
88
- function dedupeRepeats(lines, maxLineLen = 400) {
89
- const out = [];
90
- let prev = null;
91
- let count = 0;
92
- const flush = () => {
93
- if (prev == null)
94
- return;
95
- if (count <= 1)
96
- out.push(prev);
97
- else
98
- out.push(prev, `[repeated ${count - 1} more times]`);
99
- };
100
- for (const raw of lines) {
101
- const line = raw.length > maxLineLen ? raw.slice(0, maxLineLen) + '…' : raw;
102
- if (prev === line) {
103
- count++;
104
- continue;
105
- }
106
- flush();
107
- prev = line;
108
- count = 1;
109
- }
110
- flush();
111
- return out;
112
- }
113
- function collapseStackTraces(lines) {
114
- const out = [];
115
- let inStack = false;
116
- let stackCount = 0;
117
- let firstFrame = '';
118
- let lastError = '';
119
- const isStackFrame = (l) => /^\s+at\s/.test(l);
120
- const flush = () => {
121
- if (!inStack)
122
- return;
123
- if (firstFrame)
124
- out.push(firstFrame);
125
- if (stackCount > 1)
126
- out.push(` [${stackCount - 1} more frames]`);
127
- if (lastError)
128
- out.push(lastError);
129
- inStack = false;
130
- stackCount = 0;
131
- firstFrame = '';
132
- lastError = '';
133
- };
134
- for (const line of lines) {
135
- if (isStackFrame(line)) {
136
- if (!inStack) {
137
- inStack = true;
138
- stackCount = 1;
139
- firstFrame = line;
140
- }
141
- else {
142
- stackCount++;
143
- }
144
- }
145
- else {
146
- if (inStack) {
147
- // Lines between stack frames that look like error messages
148
- if (/^\w*(Error|Exception|Caused by)/.test(line.trim())) {
149
- lastError = line;
150
- continue;
151
- }
152
- flush();
153
- }
154
- out.push(line);
155
- }
156
- }
157
- flush();
158
- return out;
159
- }
160
- function truncateBytes(s, maxBytes, totalBytesHint) {
161
- const b = Buffer.from(s, 'utf8');
162
- const total = typeof totalBytesHint === 'number' && Number.isFinite(totalBytesHint) ? totalBytesHint : b.length;
163
- if (b.length <= maxBytes)
164
- return { text: s, truncated: false };
165
- // cut to boundary
166
- const cut = b.subarray(0, maxBytes);
167
- return { text: cut.toString('utf8') + `\n[truncated, ${total} bytes total]`, truncated: true };
168
- }
169
- async function checkpointReplay(ctx, payload) {
170
- if (!ctx.replay)
171
- return '';
172
- let note;
173
- if (ctx.lens && payload.before && payload.after) {
174
- try {
175
- note = await ctx.lens.summarizeDiffToText(payload.before.toString('utf8'), payload.after.toString('utf8'), payload.filePath);
176
- }
177
- catch {
178
- // ignore and fallback to raw checkpoint
179
- }
180
- }
181
- try {
182
- await ctx.replay.checkpoint({ ...payload, note });
183
- return '';
184
- }
185
- catch (e) {
186
- return ` replay_skipped: ${e?.message ?? String(e)}`;
187
- }
188
- }
10
+ export { snapshotBeforeEdit } from './tools/sys-notes.js';
189
11
  export async function read_file(ctx, args) {
190
- const p = resolvePath(ctx, args?.path);
191
- const absCwd = path.resolve(ctx.cwd);
192
- const redactedPath = redactPath(p, absCwd);
193
- const offset = args?.offset != null ? Number(args.offset) : undefined;
194
- const rawLimit = args?.limit != null ? Number(args.limit) : undefined;
195
- let limit = Number.isFinite(rawLimit) && rawLimit > 0
196
- ? Math.max(1, Math.floor(rawLimit))
197
- : 200;
198
- if (ctx.maxReadLines != null && ctx.maxReadLines > 0) {
199
- limit = Math.min(limit, ctx.maxReadLines);
200
- }
201
- const search = typeof args?.search === 'string' ? args.search : undefined;
202
- const rawContext = args?.context != null ? Number(args.context) : undefined;
203
- const context = Number.isFinite(rawContext) && rawContext >= 0
204
- ? Math.max(0, Math.min(200, Math.floor(rawContext)))
205
- : 10;
206
- const formatRaw = typeof args?.format === 'string' ? args.format.trim().toLowerCase() : 'numbered';
207
- const format = (formatRaw === 'plain' || formatRaw === 'numbered' || formatRaw === 'sparse')
208
- ? formatRaw
209
- : 'numbered';
210
- const rawMaxBytes = args?.max_bytes != null ? Number(args.max_bytes) : undefined;
211
- const maxBytes = Number.isFinite(rawMaxBytes) && rawMaxBytes > 0
212
- ? Math.min(256 * 1024, Math.max(256, Math.floor(rawMaxBytes)))
213
- : 20 * 1024;
214
- if (!p)
215
- throw new Error('read_file: missing path');
216
- // Detect directories early with a helpful message instead of cryptic EISDIR
217
- try {
218
- const stat = await fs.stat(p);
219
- if (stat.isDirectory()) {
220
- return `read_file: "${redactedPath}" is a directory, not a file. Use list_dir to see its contents, or search_files to find specific code.`;
221
- }
222
- }
223
- catch (e) {
224
- // stat failure (ENOENT etc.) — let readFile handle it for the standard error path
225
- }
226
- const buf = await fs.readFile(p).catch((e) => {
227
- throw new Error(`read_file: cannot read ${p}: ${e?.message ?? String(e)}`);
228
- });
229
- // Binary detection: NUL byte in first 512 bytes (§8)
230
- for (let i = 0; i < Math.min(buf.length, 512); i++) {
231
- if (buf[i] === 0) {
232
- const mimeGuess = guessMimeType(p, buf);
233
- return `[binary file, ${buf.length} bytes, detected type: ${mimeGuess}]`;
234
- }
235
- }
236
- const text = buf.toString('utf8');
237
- if (!text) {
238
- return `# ${p}\n[file is empty (0 bytes)]`;
239
- }
240
- const lines = text.split(/\r?\n/);
241
- let start = 1;
242
- let end = Math.min(lines.length, limit);
243
- let matchLines = [];
244
- if (search) {
245
- matchLines = [];
246
- for (let i = 0; i < lines.length; i++) {
247
- if (lines[i].includes(search))
248
- matchLines.push(i + 1);
249
- }
250
- if (!matchLines.length) {
251
- return truncateBytes(`# ${p}\n# search not found: ${JSON.stringify(search)}\n# file has ${lines.length} lines`, maxBytes).text;
252
- }
253
- const firstIdx = matchLines[0];
254
- // Window around the first match, but never return more than `limit` lines.
255
- start = Math.max(1, firstIdx - context);
256
- end = Math.min(lines.length, firstIdx + context);
257
- if (end - start + 1 > limit) {
258
- const half = Math.floor(limit / 2);
259
- start = Math.max(1, firstIdx - half);
260
- end = Math.min(lines.length, start + limit - 1);
261
- }
262
- }
263
- else if (offset && offset >= 1) {
264
- start = Math.max(1, Math.floor(offset));
265
- end = Math.min(lines.length, start + limit - 1);
266
- }
267
- const matchSet = new Set(matchLines);
268
- const out = [];
269
- out.push(`# ${p} (lines ${start}-${end} of ${lines.length})`);
270
- if (search) {
271
- const shown = matchLines.slice(0, 20);
272
- out.push(`# matches at lines: ${shown.join(', ')}${matchLines.length > shown.length ? ' …' : ''}`);
273
- }
274
- const renderNumbered = (ln, body) => `${ln}| ${body}`;
275
- for (let ln = start; ln <= end; ln++) {
276
- const body = lines[ln - 1] ?? '';
277
- if (format === 'plain') {
278
- out.push(body);
279
- continue;
280
- }
281
- if (format === 'numbered') {
282
- out.push(renderNumbered(ln, body));
283
- continue;
284
- }
285
- // sparse: number anchor lines + matches; otherwise raw text.
286
- const isAnchor = ln === start || ln === end || (ln - start) % 10 === 0;
287
- if (isAnchor || matchSet.has(ln))
288
- out.push(renderNumbered(ln, body));
289
- else
290
- out.push(body);
291
- }
292
- if (end < lines.length)
293
- out.push(`# ... (${lines.length - end} more lines)`);
294
- return truncateBytes(out.join('\n'), maxBytes).text;
12
+ return readFileTool(ctx, args);
295
13
  }
296
14
  export async function read_files(ctx, args) {
297
- const reqs = Array.isArray(args?.requests) ? args.requests : [];
298
- if (!reqs.length)
299
- throw new ToolError('invalid_args', 'read_files: missing requests[]', false, 'Provide requests as an array of {path, limit,...} objects.');
300
- const parts = [];
301
- let failures = 0;
302
- for (let i = 0; i < reqs.length; i++) {
303
- const r = reqs[i];
304
- const p = typeof r?.path === 'string' ? r.path : `request[${i}]`;
305
- try {
306
- parts.push(await read_file(ctx, r));
307
- }
308
- catch (e) {
309
- failures++;
310
- const te = ToolError.fromError(e, 'internal');
311
- parts.push(`[file:${p}] ERROR: code=${te.code} msg=${te.message}`);
312
- }
313
- parts.push('');
314
- }
315
- if (failures > 0) {
316
- parts.push(`# read_files completed with partial failures: ${failures}/${reqs.length}`);
317
- }
318
- return parts.join('\n');
15
+ return readFilesTool(ctx, args);
319
16
  }
320
17
  export async function write_file(ctx, args) {
321
- const p = resolvePath(ctx, args?.path);
322
- const absCwd = path.resolve(ctx.cwd);
323
- const redactedPath = redactPath(p, absCwd);
324
- // Content may arrive as a string (normal) or as a parsed JSON object
325
- // (when llama-server's XML parser auto-parses JSON content values).
326
- const raw = args?.content;
327
- const contentWasObject = raw != null && typeof raw === 'object';
328
- const content = typeof raw === 'string' ? raw
329
- : (contentWasObject ? JSON.stringify(raw, null, 2) : undefined);
330
- // Warn when content arrives as an object (model passed JSON object instead of string)
331
- // to help diagnose serialization-induced loops where the model retries thinking it failed.
332
- if (contentWasObject) {
333
- console.warn(`[write_file] Warning: content for "${args?.path}" arrived as ${typeof raw} — auto-serialized to JSON string. If this was intentional (e.g. package.json), the write succeeded.`);
334
- }
335
- if (!p)
336
- throw new Error('write_file: missing path');
337
- if (content == null)
338
- throw new Error('write_file: missing content (got ' + typeof raw + ')');
339
- const overwrite = Boolean(args?.overwrite ?? args?.force);
340
- enforceMutationWithinCwd('write_file', p, ctx);
341
- const cwdWarning = checkCwdWarning('write_file', p, ctx);
342
- // Path safety check (Phase 9)
343
- const pathVerdict = checkPathSafety(p);
344
- if (pathVerdict.tier === 'forbidden') {
345
- throw new Error(`write_file: ${pathVerdict.reason}`);
346
- }
347
- if (pathVerdict.tier === 'cautious' && !ctx.noConfirm) {
348
- if (ctx.confirm) {
349
- const ok = await ctx.confirm(pathVerdict.prompt || `Write to ${redactedPath}?`, { tool: 'write_file', args: { path: p } });
350
- if (!ok)
351
- throw new Error(`write_file: cancelled by user (${pathVerdict.reason})`);
352
- }
353
- else {
354
- throw new Error(`write_file: blocked (${pathVerdict.reason}) without --no-confirm/--yolo`);
355
- }
356
- }
357
- const existingStat = await fs.stat(p).catch(() => null);
358
- if (existingStat?.isFile() && existingStat.size > 0 && !overwrite) {
359
- throw new Error(`write_file: refusing to overwrite existing non-empty file ${redactedPath} without explicit overwrite=true (or force=true). ` +
360
- `Use edit_range/apply_patch for surgical edits, or set overwrite=true for intentional full-file replacement.`);
361
- }
362
- if (ctx.dryRun) {
363
- const mode = existingStat?.isFile() ? (existingStat.size > 0 ? 'overwrite' : 'update-empty') : 'create';
364
- return `dry-run: would write ${p} (${Buffer.byteLength(content, 'utf8')} bytes, mode=${mode}${overwrite ? ', explicit-overwrite' : ''})${cwdWarning}`;
365
- }
366
- // Phase 9d: snapshot /etc/ files before editing
367
- if (ctx.mode === 'sys' && ctx.vault) {
368
- await snapshotBeforeEdit(ctx.vault, p).catch(() => { });
369
- }
370
- const beforeBuf = await fs.readFile(p).catch(() => Buffer.from(''));
371
- await backupFile(p, ctx);
372
- await atomicWrite(p, content);
373
- ctx.onMutation?.(p);
374
- const afterBuf = Buffer.from(content, 'utf8');
375
- const replayNote = await checkpointReplay(ctx, { op: 'write_file', filePath: p, before: beforeBuf, after: afterBuf });
376
- const contentLines = content.split(/\r?\n/);
377
- const readback = contentLines.length <= 40
378
- ? mutationReadback(content, 0, contentLines.length)
379
- : mutationReadback(content, 0, 20) + '\n...\n' + mutationReadback(content, contentLines.length - 10, contentLines.length);
380
- return `wrote ${redactedPath} (${Buffer.byteLength(content, 'utf8')} bytes)${replayNote}${cwdWarning}${readback}`;
18
+ return writeFileTool(ctx, args);
381
19
  }
382
20
  export async function insert_file(ctx, args) {
383
- const p = resolvePath(ctx, args?.path);
384
- const absCwd = path.resolve(ctx.cwd);
385
- const redactedPath = redactPath(p, absCwd);
386
- const line = Number(args?.line);
387
- const rawText = args?.text;
388
- const text = typeof rawText === 'string' ? rawText
389
- : (rawText != null && typeof rawText === 'object' ? JSON.stringify(rawText, null, 2) : undefined);
390
- if (!p)
391
- throw new Error('insert_file: missing path');
392
- if (!Number.isFinite(line))
393
- throw new Error('insert_file: missing/invalid line');
394
- if (text == null)
395
- throw new Error('insert_file: missing text (got ' + typeof rawText + ')');
396
- enforceMutationWithinCwd('insert_file', p, ctx);
397
- // Path safety check (Phase 9)
398
- const pathVerdict = checkPathSafety(p);
399
- if (pathVerdict.tier === 'forbidden') {
400
- throw new Error(`insert_file: ${pathVerdict.reason}`);
401
- }
402
- if (pathVerdict.tier === 'cautious' && !ctx.noConfirm) {
403
- if (ctx.confirm) {
404
- const ok = await ctx.confirm(pathVerdict.prompt || `Insert into ${redactedPath}?`, { tool: 'insert_file', args: { path: p } });
405
- if (!ok)
406
- throw new Error(`insert_file: cancelled by user (${pathVerdict.reason})`);
407
- }
408
- else {
409
- throw new Error(`insert_file: blocked (${pathVerdict.reason}) without --no-confirm/--yolo`);
410
- }
411
- }
412
- if (ctx.dryRun)
413
- return `dry-run: would insert into ${redactedPath} at line=${line} (${Buffer.byteLength(text, 'utf8')} bytes)`;
414
- // Phase 9d: snapshot /etc/ files before editing
415
- if (ctx.mode === 'sys' && ctx.vault) {
416
- await snapshotBeforeEdit(ctx.vault, p).catch(() => { });
417
- }
418
- const beforeText = await fs.readFile(p, 'utf8').catch(() => '');
419
- // Detect original newline style
420
- const eol = beforeText.includes('\r\n') ? '\r\n' : '\n';
421
- // Handle empty file: just write the inserted text directly (avoid spurious leading newline).
422
- if (beforeText === '') {
423
- const out = text;
424
- await backupFile(p, ctx);
425
- await atomicWrite(p, out);
426
- ctx.onMutation?.(p);
427
- const replayNote = await checkpointReplay(ctx, {
428
- op: 'insert_file',
429
- filePath: p,
430
- before: Buffer.from(beforeText, 'utf8'),
431
- after: Buffer.from(out, 'utf8')
432
- });
433
- const cwdWarning = checkCwdWarning('insert_file', p, ctx);
434
- const readback = mutationReadback(out, 0, out.split(/\r?\n/).length);
435
- return `inserted into ${redactedPath} at 0${replayNote}${cwdWarning}${readback}`;
436
- }
437
- const lines = beforeText.split(/\r?\n/);
438
- let idx;
439
- if (line === -1)
440
- idx = lines.length;
441
- else
442
- idx = Math.max(0, Math.min(lines.length, line));
443
- // When appending to a file that ends with a newline, the split produces a
444
- // trailing empty element (e.g. "a\n" → ["a",""]). Inserting at lines.length
445
- // pushes content AFTER that empty element, producing a double-newline on rejoin.
446
- // Fix: when appending (line === -1) and the last element is empty (trailing newline),
447
- // insert before the trailing empty element instead.
448
- if (line === -1 && lines.length > 0 && lines[lines.length - 1] === '') {
449
- idx = lines.length - 1;
450
- }
451
- const insertLines = text.split(/\r?\n/);
452
- lines.splice(idx, 0, ...insertLines);
453
- const out = lines.join(eol);
454
- await backupFile(p, ctx);
455
- await atomicWrite(p, out);
456
- ctx.onMutation?.(p);
457
- const replayNote = await checkpointReplay(ctx, {
458
- op: 'insert_file',
459
- filePath: p,
460
- before: Buffer.from(beforeText, 'utf8'),
461
- after: Buffer.from(out, 'utf8')
462
- });
463
- const cwdWarning = checkCwdWarning('insert_file', p, ctx);
464
- const insertEndLine = idx + insertLines.length;
465
- const readback = mutationReadback(out, idx, insertEndLine);
466
- return `inserted into ${redactedPath} at ${idx}${replayNote}${cwdWarning}${readback}`;
21
+ return insertFileTool(ctx, args);
467
22
  }
468
23
  export async function edit_file(ctx, args) {
469
- const p = resolvePath(ctx, args?.path);
470
- const absCwd = path.resolve(ctx.cwd);
471
- const redactedPath = redactPath(p, absCwd);
472
- const rawOld = args?.old_text;
473
- const oldText = typeof rawOld === 'string' ? rawOld
474
- : (rawOld != null && typeof rawOld === 'object' ? JSON.stringify(rawOld, null, 2) : undefined);
475
- const rawNew = args?.new_text;
476
- const newText = typeof rawNew === 'string' ? rawNew
477
- : (rawNew != null && typeof rawNew === 'object' ? JSON.stringify(rawNew, null, 2) : undefined);
478
- const replaceAll = Boolean(args?.replace_all);
479
- if (!p)
480
- throw new Error('edit_file: missing path');
481
- if (oldText == null)
482
- throw new Error('edit_file: missing old_text');
483
- if (newText == null)
484
- throw new Error('edit_file: missing new_text');
485
- enforceMutationWithinCwd('edit_file', p, ctx);
486
- // Path safety check (Phase 9)
487
- const pathVerdict = checkPathSafety(p);
488
- if (pathVerdict.tier === 'forbidden') {
489
- throw new Error(`edit_file: ${pathVerdict.reason}`);
490
- }
491
- if (pathVerdict.tier === 'cautious' && !ctx.noConfirm) {
492
- if (ctx.confirm) {
493
- const ok = await ctx.confirm(pathVerdict.prompt || `Edit ${redactedPath}?`, { tool: 'edit_file', args: { path: p, old_text: oldText, new_text: newText } });
494
- if (!ok)
495
- throw new Error(`edit_file: cancelled by user (${pathVerdict.reason})`);
496
- }
497
- else {
498
- throw new Error(`edit_file: blocked (${pathVerdict.reason}) without --no-confirm/--yolo`);
499
- }
500
- }
501
- // Phase 9d: snapshot /etc/ files before editing
502
- if (ctx.mode === 'sys' && ctx.vault) {
503
- await snapshotBeforeEdit(ctx.vault, p).catch(() => { });
504
- }
505
- const cur = await fs.readFile(p, 'utf8').catch((e) => {
506
- throw new Error(`edit_file: cannot read ${redactedPath}: ${e?.message ?? String(e)}`);
507
- });
508
- const idx = cur.indexOf(oldText);
509
- if (idx === -1) {
510
- // Find closest near-match via normalized comparison
511
- const normalize = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
512
- const needle = normalize(oldText);
513
- const curLines = cur.split(/\r?\n/);
514
- const needleLines = oldText.split(/\r?\n/).length;
515
- let bestScore = 0;
516
- let bestLine = -1;
517
- let bestText = '';
518
- for (let i = 0; i < curLines.length; i++) {
519
- // Build a window of the same number of lines as old_text
520
- const windowEnd = Math.min(curLines.length, i + needleLines);
521
- const window = curLines.slice(i, windowEnd).join('\n');
522
- const normWindow = normalize(window);
523
- // Similarity: count matching character bigrams (handles differences anywhere, not just prefix).
524
- const score = bigramSimilarity(needle, normWindow);
525
- if (score > bestScore) {
526
- bestScore = score;
527
- bestLine = i + 1;
528
- bestText = window;
529
- }
530
- }
531
- let hint = '';
532
- if (bestScore > 0.3 && bestLine > 0) {
533
- const preview = bestText.length > 600 ? bestText.slice(0, 600) + '…' : bestText;
534
- hint = `\nClosest match at line ${bestLine} (${Math.round(bestScore * 100)}% similarity):\n${preview}`;
535
- }
536
- else if (!cur.trim()) {
537
- hint = `\nFile is empty.`;
538
- }
539
- else {
540
- hint = `\nFile head (first 400 chars):\n${cur.slice(0, 400)}`;
541
- }
542
- throw new Error(`edit_file: old_text not found in ${redactedPath}. Re-read the file and retry with exact text.${hint}`);
543
- }
544
- const next = replaceAll ? cur.split(oldText).join(newText) : cur.slice(0, idx) + newText + cur.slice(idx + oldText.length);
545
- if (ctx.dryRun)
546
- return `dry-run: would edit ${redactedPath} (replace_all=${replaceAll})`;
547
- await backupFile(p, ctx);
548
- await atomicWrite(p, next);
549
- ctx.onMutation?.(p);
550
- const replayNote = await checkpointReplay(ctx, {
551
- op: 'edit_file',
552
- filePath: p,
553
- before: Buffer.from(cur, 'utf8'),
554
- after: Buffer.from(next, 'utf8')
555
- });
556
- const cwdWarning = checkCwdWarning('edit_file', p, ctx);
557
- // Read-back: find the line range of the replacement in the new content
558
- const editStartLine = next.slice(0, idx).split(/\r?\n/).length - 1;
559
- const editEndLine = editStartLine + newText.split(/\r?\n/).length;
560
- const readback = mutationReadback(next, editStartLine, editEndLine);
561
- return `edited ${redactedPath} (replace_all=${replaceAll})${replayNote}${cwdWarning}${readback}`;
562
- }
563
- // Patch parsing helpers imported from tools/patch.ts:
564
- // PatchTouchInfo, normalizePatchPath, extractTouchedFilesFromPatch
565
- async function runCommandWithStdin(cmd, cmdArgs, stdinText, cwd, maxOutBytes) {
566
- return await new Promise((resolve, reject) => {
567
- const child = spawn(cmd, cmdArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
568
- const outChunks = [];
569
- const errChunks = [];
570
- let outSeen = 0;
571
- let errSeen = 0;
572
- let outCaptured = 0;
573
- let errCaptured = 0;
574
- const pushCapped = (chunks, buf, kind) => {
575
- const n = buf.length;
576
- if (kind === 'out')
577
- outSeen += n;
578
- else
579
- errSeen += n;
580
- const captured = kind === 'out' ? outCaptured : errCaptured;
581
- const remaining = maxOutBytes - captured;
582
- if (remaining <= 0)
583
- return;
584
- const take = n <= remaining ? buf : buf.subarray(0, remaining);
585
- chunks.push(Buffer.from(take));
586
- if (kind === 'out')
587
- outCaptured += take.length;
588
- else
589
- errCaptured += take.length;
590
- };
591
- child.stdout.on('data', (d) => pushCapped(outChunks, Buffer.from(d), 'out'));
592
- child.stderr.on('data', (d) => pushCapped(errChunks, Buffer.from(d), 'err'));
593
- child.on('error', (e) => reject(new Error(`${cmd}: ${e?.message ?? String(e)}`)));
594
- child.on('close', (code) => {
595
- const outRaw = stripAnsi(Buffer.concat(outChunks).toString('utf8'));
596
- const errRaw = stripAnsi(Buffer.concat(errChunks).toString('utf8'));
597
- const outT = truncateBytes(outRaw, maxOutBytes, outSeen);
598
- const errT = truncateBytes(errRaw, maxOutBytes, errSeen);
599
- resolve({ rc: code ?? 0, out: outT.text, err: errT.text });
600
- });
601
- child.stdin.write(String(stdinText ?? ''), 'utf8');
602
- child.stdin.end();
603
- });
24
+ return editFileTool(ctx, args);
604
25
  }
605
26
  export async function edit_range(ctx, args) {
606
- const p = resolvePath(ctx, args?.path);
607
- const startLine = Number(args?.start_line);
608
- const endLine = Number(args?.end_line);
609
- const rawReplacement = args?.replacement;
610
- const replacement = typeof rawReplacement === 'string' ? rawReplacement
611
- : (rawReplacement != null && typeof rawReplacement === 'object' ? JSON.stringify(rawReplacement, null, 2) : undefined);
612
- if (!p)
613
- throw new Error('edit_range: missing path');
614
- if (!Number.isFinite(startLine) || startLine < 1)
615
- throw new Error('edit_range: missing/invalid start_line');
616
- if (!Number.isFinite(endLine) || endLine < startLine)
617
- throw new Error('edit_range: missing/invalid end_line');
618
- if (replacement == null)
619
- throw new Error('edit_range: missing replacement (got ' + typeof rawReplacement + ')');
620
- const hasLiteralEscapedNewlines = replacement.includes('\\n');
621
- const hasRealNewlines = replacement.includes('\n') || replacement.includes('\r');
622
- if (hasLiteralEscapedNewlines && !hasRealNewlines) {
623
- throw new Error('edit_range: replacement appears double-escaped (contains literal "\\n" sequences). ' +
624
- 'Resend replacement with REAL newline characters (multi-line string), not escaped backslash-n text.');
625
- }
626
- enforceMutationWithinCwd('edit_range', p, ctx);
627
- // Path safety check (Phase 9)
628
- const pathVerdict = checkPathSafety(p);
629
- if (pathVerdict.tier === 'forbidden') {
630
- throw new Error(`edit_range: ${pathVerdict.reason}`);
631
- }
632
- if (pathVerdict.tier === 'cautious' && !ctx.noConfirm) {
633
- if (ctx.confirm) {
634
- const ok = await ctx.confirm(pathVerdict.prompt || `Edit range in ${p}?`, { tool: 'edit_range', args: { path: p, start_line: startLine, end_line: endLine } });
635
- if (!ok)
636
- throw new Error(`edit_range: cancelled by user (${pathVerdict.reason})`);
637
- }
638
- else {
639
- throw new Error(`edit_range: blocked (${pathVerdict.reason}) without --no-confirm/--yolo`);
640
- }
641
- }
642
- if (ctx.dryRun)
643
- return `dry-run: would edit_range ${p} lines ${startLine}-${endLine} (${Buffer.byteLength(replacement, 'utf8')} bytes)`;
644
- // Phase 9d: snapshot /etc/ files before editing
645
- if (ctx.mode === 'sys' && ctx.vault) {
646
- await snapshotBeforeEdit(ctx.vault, p).catch(() => { });
647
- }
648
- const beforeText = await fs.readFile(p, 'utf8').catch((e) => {
649
- throw new Error(`edit_range: cannot read ${p}: ${e?.message ?? String(e)}`);
650
- });
651
- const eol = beforeText.includes('\r\n') ? '\r\n' : '\n';
652
- const lines = beforeText.split(/\r?\n/);
653
- if (startLine > lines.length) {
654
- throw new Error(`edit_range: start_line ${startLine} out of range (file has ${lines.length} lines)`);
655
- }
656
- if (endLine > lines.length) {
657
- throw new Error(`edit_range: end_line ${endLine} out of range (file has ${lines.length} lines)`);
658
- }
659
- const startIdx = startLine - 1;
660
- const deleteCount = endLine - startLine + 1;
661
- // For deletion, allow empty replacement to remove the range without leaving a blank line.
662
- const replacementLines = replacement === '' ? [] : replacement.split(/\r?\n/);
663
- lines.splice(startIdx, deleteCount, ...replacementLines);
664
- const out = lines.join(eol);
665
- await backupFile(p, ctx);
666
- await atomicWrite(p, out);
667
- ctx.onMutation?.(p);
668
- const replayNote = await checkpointReplay(ctx, {
669
- op: 'edit_range',
670
- filePath: p,
671
- before: Buffer.from(beforeText, 'utf8'),
672
- after: Buffer.from(out, 'utf8')
673
- });
674
- const cwdWarning = checkCwdWarning('edit_range', p, ctx);
675
- const rangeEndLine = startIdx + replacementLines.length;
676
- const readback = mutationReadback(out, startIdx, rangeEndLine);
677
- return `edited ${p} lines ${startLine}-${endLine}${replayNote}${cwdWarning}${readback}`;
27
+ return editRangeTool(ctx, args);
678
28
  }
679
29
  export async function apply_patch(ctx, args) {
680
- const rawPatch = args?.patch;
681
- const patchText = typeof rawPatch === 'string' ? rawPatch
682
- : (rawPatch != null && typeof rawPatch === 'object' ? JSON.stringify(rawPatch, null, 2) : undefined);
683
- const rawFiles = Array.isArray(args?.files) ? args.files : [];
684
- const files = rawFiles
685
- .map((f) => (typeof f === 'string' ? f.trim() : ''))
686
- .filter(Boolean);
687
- const stripRaw = Number(args?.strip);
688
- const strip = Number.isFinite(stripRaw) ? Math.max(0, Math.min(5, Math.floor(stripRaw))) : 0;
689
- if (!patchText)
690
- throw new Error('apply_patch: missing patch');
691
- if (!files.length)
692
- throw new Error('apply_patch: missing files[]');
693
- const touched = extractTouchedFilesFromPatch(patchText);
694
- if (!touched.paths.length) {
695
- throw new Error('apply_patch: patch contains no recognizable file headers');
696
- }
697
- const declared = new Set(files.map(normalizePatchPath));
698
- const unknown = touched.paths.filter((p) => !declared.has(p));
699
- if (unknown.length) {
700
- throw new Error(`apply_patch: patch touches undeclared file(s): ${unknown.join(', ')}`);
701
- }
702
- const absPaths = touched.paths.map((rel) => resolvePath(ctx, rel));
703
- for (const abs of absPaths) {
704
- enforceMutationWithinCwd('apply_patch', abs, ctx);
705
- }
706
- // Path safety check (Phase 9)
707
- const verdicts = absPaths.map((p) => ({ p, v: checkPathSafety(p) }));
708
- const forbidden = verdicts.filter(({ v }) => v.tier === 'forbidden');
709
- if (forbidden.length) {
710
- throw new Error(`apply_patch: ${forbidden[0].v.reason} (${forbidden[0].p})`);
711
- }
712
- const cautious = verdicts.filter(({ v }) => v.tier === 'cautious');
713
- if (cautious.length && !ctx.noConfirm) {
714
- if (ctx.confirm) {
715
- const preview = patchText.length > 4000 ? patchText.slice(0, 4000) + '\n[truncated]' : patchText;
716
- const ok = await ctx.confirm(`Apply patch touching ${touched.paths.length} file(s)?\n- ${touched.paths.join('\n- ')}\n\nProceed? (y/N) `, { tool: 'apply_patch', args: { files: touched.paths, strip }, diff: preview });
717
- if (!ok)
718
- throw new Error('apply_patch: cancelled by user');
719
- }
720
- else {
721
- throw new Error('apply_patch: blocked (cautious paths) without --no-confirm/--yolo');
722
- }
723
- }
724
- const maxToolBytes = ctx.maxExecBytes ?? DEFAULT_MAX_EXEC_BYTES;
725
- const stripArg = `-p${strip}`;
726
- // Dry-run: validate the patch applies cleanly, but do not mutate files.
727
- if (ctx.dryRun) {
728
- const haveGit = !spawnSync('git', ['--version'], { stdio: 'ignore' }).error;
729
- if (haveGit) {
730
- const chk = await runCommandWithStdin('git', ['apply', stripArg, '--check', '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
731
- if (chk.rc !== 0)
732
- throw new Error(`apply_patch: git apply --check failed:\n${chk.err || chk.out}`);
733
- }
734
- else {
735
- const chk = await runCommandWithStdin('patch', [stripArg, '--dry-run', '--batch'], patchText, ctx.cwd, maxToolBytes);
736
- if (chk.rc !== 0)
737
- throw new Error(`apply_patch: patch --dry-run failed:\n${chk.err || chk.out}`);
738
- }
739
- const redactedPaths = touched.paths.map((rel) => redactPath(resolvePath(ctx, rel), path.resolve(ctx.cwd)));
740
- return `dry-run: patch would apply cleanly (${touched.paths.length} files): ${redactedPaths.join(', ')}`;
741
- }
742
- // Snapshot + backup before applying
743
- const beforeMap = new Map();
744
- for (const abs of absPaths) {
745
- // Phase 9d: snapshot /etc/ files before editing
746
- if (ctx.mode === 'sys' && ctx.vault) {
747
- await snapshotBeforeEdit(ctx.vault, abs).catch(() => { });
748
- }
749
- const before = await fs.readFile(abs).catch(() => Buffer.from(''));
750
- beforeMap.set(abs, before);
751
- await backupFile(abs, ctx);
752
- }
753
- // Apply with git apply if available; fallback to patch.
754
- const haveGit = !spawnSync('git', ['--version'], { stdio: 'ignore' }).error;
755
- if (haveGit) {
756
- const chk = await runCommandWithStdin('git', ['apply', stripArg, '--check', '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
757
- if (chk.rc !== 0)
758
- throw new Error(`apply_patch: git apply --check failed:\n${chk.err || chk.out}`);
759
- const app = await runCommandWithStdin('git', ['apply', stripArg, '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
760
- if (app.rc !== 0)
761
- throw new Error(`apply_patch: git apply failed:\n${app.err || app.out}`);
762
- }
763
- else {
764
- const chk = await runCommandWithStdin('patch', [stripArg, '--dry-run', '--batch'], patchText, ctx.cwd, maxToolBytes);
765
- if (chk.rc !== 0)
766
- throw new Error(`apply_patch: patch --dry-run failed:\n${chk.err || chk.out}`);
767
- const app = await runCommandWithStdin('patch', [stripArg, '--batch'], patchText, ctx.cwd, maxToolBytes);
768
- if (app.rc !== 0)
769
- throw new Error(`apply_patch: patch failed:\n${app.err || app.out}`);
770
- }
771
- // Replay checkpoints + mutation hooks
772
- let replayNotes = '';
773
- let cwdWarnings = '';
774
- for (const abs of absPaths) {
775
- const after = await fs.readFile(abs).catch(() => Buffer.from(''));
776
- ctx.onMutation?.(abs);
777
- const replayNote = await checkpointReplay(ctx, {
778
- op: 'apply_patch',
779
- filePath: abs,
780
- before: beforeMap.get(abs) ?? Buffer.from(''),
781
- after
782
- });
783
- replayNotes += replayNote;
784
- cwdWarnings += checkCwdWarning('apply_patch', abs, ctx);
785
- }
786
- const redactedPaths = touched.paths.map((rel) => redactPath(resolvePath(ctx, rel), path.resolve(ctx.cwd)));
787
- // Read-back for each patched file (brief, 3 context lines, max 2 files to avoid bloat)
788
- let patchReadback = '';
789
- for (const abs of absPaths.slice(0, 2)) {
790
- try {
791
- const content = await fs.readFile(abs, 'utf8');
792
- const totalLines = content.split(/\r?\n/).length;
793
- const rp = redactPath(abs, path.resolve(ctx.cwd));
794
- patchReadback += `\n--- ${rp} (${totalLines} lines) first 20 lines ---\n`;
795
- patchReadback += content.split(/\r?\n/).slice(0, 20).map((l, i) => `${i + 1}: ${l}`).join('\n');
796
- }
797
- catch { /* skip unreadable */ }
798
- }
799
- if (absPaths.length > 2)
800
- patchReadback += `\n...(${absPaths.length - 2} more files)`;
801
- return `applied patch (${touched.paths.length} files): ${redactedPaths.join(', ')}${replayNotes}${cwdWarnings}${patchReadback}`;
30
+ return applyPatchTool(ctx, args);
802
31
  }
803
32
  export async function list_dir(ctx, args) {
804
- const p = resolvePath(ctx, args?.path ?? '.');
805
- const recursive = Boolean(args?.recursive);
806
- const maxEntries = Math.min(args?.max_entries ? Number(args.max_entries) : 200, 500);
807
- if (!p)
808
- throw new Error('list_dir: missing path');
809
- const absCwd = path.resolve(ctx.cwd);
810
- const lines = [];
811
- let count = 0;
812
- async function walk(dir, depth) {
813
- if (count >= maxEntries)
814
- return;
815
- const ents = await fs.readdir(dir, { withFileTypes: true }).catch((e) => {
816
- throw new Error(`list_dir: cannot read ${dir}: ${e?.message ?? String(e)}`);
817
- });
818
- for (const ent of ents) {
819
- if (count >= maxEntries)
820
- return;
821
- const full = path.join(dir, ent.name);
822
- const st = await fs.lstat(full).catch(() => null);
823
- const kind = ent.isDirectory() ? 'dir' : ent.isSymbolicLink() ? 'link' : 'file';
824
- lines.push(`${kind}\t${st?.size ?? 0}\t${redactPath(full, absCwd)}`);
825
- count++;
826
- if (recursive && ent.isDirectory() && depth < 3) {
827
- await walk(full, depth + 1);
828
- }
829
- }
830
- }
831
- await walk(p, 0);
832
- if (count >= maxEntries)
833
- lines.push(`[truncated after ${maxEntries} entries]`);
834
- if (!lines.length)
835
- return `[empty directory: ${redactPath(p, absCwd)}]`;
836
- return lines.join('\n');
33
+ return listDirTool(ctx, args);
837
34
  }
838
35
  export async function search_files(ctx, args) {
839
- const root = resolvePath(ctx, args?.path ?? '.');
840
- const pattern = typeof args?.pattern === 'string' ? args.pattern : undefined;
841
- const include = typeof args?.include === 'string' ? args.include : undefined;
842
- const maxResults = Math.min(args?.max_results ? Number(args.max_results) : 50, 100);
843
- if (!root)
844
- throw new Error('search_files: missing path');
845
- if (!pattern)
846
- throw new Error('search_files: missing pattern');
847
- const absCwd = path.resolve(ctx.cwd);
848
- // Prefer rg if available (fast, bounded output)
849
- if (await hasRg()) {
850
- const cmd = ['rg', '-n', '--no-heading', '--color', 'never', pattern, root];
851
- if (include)
852
- cmd.splice(1, 0, '-g', include);
853
- try {
854
- const rawJson = await exec(ctx, { command: cmd.map(shellEscape).join(' '), timeout: 30 });
855
- const parsed = JSON.parse(rawJson);
856
- // rg exits 1 when no matches found (not an error), 2+ for real errors.
857
- if (parsed.rc === 1 && !parsed.out?.trim()) {
858
- return `No matches for pattern \"${pattern}\" in ${root}. STOP — do NOT read files individually to search. Try a broader regex pattern, different keywords, or use exec: grep -rn \"keyword\" ${root}`;
859
- }
860
- if (parsed.rc >= 2) {
861
- // Real rg error — fall through to regex fallback below
862
- }
863
- else {
864
- const rgOutput = parsed.out ?? '';
865
- if (rgOutput) {
866
- const lines = rgOutput.split(/\r?\n/).filter(Boolean).slice(0, maxResults);
867
- if (lines.length >= maxResults)
868
- lines.push(`[truncated after ${maxResults} results]`);
869
- // Redact paths in rg output
870
- const redactedLines = lines.map(line => {
871
- const colonIdx = line.indexOf(':');
872
- if (colonIdx === -1)
873
- return line;
874
- const filePath = line.substring(0, colonIdx);
875
- const rest = line.substring(colonIdx + 1);
876
- return redactPath(filePath, absCwd) + ':' + rest;
877
- });
878
- return redactedLines.join('\n');
879
- }
880
- }
881
- }
882
- catch {
883
- // JSON parse failed or exec error — fall through to regex fallback
884
- }
885
- }
886
- // Slow fallback
887
- let re;
888
- try {
889
- re = new RegExp(pattern);
890
- }
891
- catch (e) {
892
- throw new ToolError('invalid_args', `search_files: invalid regex pattern: ${e?.message ?? String(e)}`, false, 'Escape regex metacharacters (\\\\, [, ], (, ), +, *, ?). If you intended literal text, use an escaped/literal pattern.');
893
- }
894
- const out = [];
895
- async function walk(dir, depth) {
896
- if (out.length >= maxResults)
897
- return;
898
- const ents = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
899
- for (const ent of ents) {
900
- if (out.length >= maxResults)
901
- return;
902
- const full = path.join(dir, ent.name);
903
- if (ent.isDirectory()) {
904
- if (ent.name === 'node_modules' || ent.name === '.git' || ent.name === 'dist' || ent.name === 'build')
905
- continue;
906
- if (depth < 6)
907
- await walk(full, depth + 1);
908
- continue;
909
- }
910
- if (!ent.isFile())
911
- continue;
912
- if (include && !globishMatch(ent.name, include))
913
- continue;
914
- // Skip binary files (NUL byte in first 512 bytes)
915
- const rawBuf = await fs.readFile(full).catch(() => null);
916
- if (!rawBuf)
917
- continue;
918
- let isBinary = false;
919
- for (let bi = 0; bi < Math.min(rawBuf.length, 512); bi++) {
920
- if (rawBuf[bi] === 0) {
921
- isBinary = true;
922
- break;
923
- }
924
- }
925
- if (isBinary)
926
- continue;
927
- const buf = rawBuf.toString('utf8');
928
- const lines = buf.split(/\r?\n/);
929
- for (let i = 0; i < lines.length; i++) {
930
- if (re.test(lines[i])) {
931
- out.push(`${redactPath(full, absCwd)}:${i + 1}:${lines[i]}`);
932
- if (out.length >= maxResults)
933
- return;
934
- }
935
- }
936
- }
937
- }
938
- await walk(root, 0);
939
- if (out.length >= maxResults)
940
- out.push(`[truncated after ${maxResults} results]`);
941
- if (!out.length)
942
- return `No matches for pattern \"${pattern}\" in ${redactPath(root, absCwd)}.`;
943
- return out.join('\n');
944
- }
945
- function stripSimpleQuotedSegments(s) {
946
- // Best-effort quote stripping for lightweight shell pattern checks.
947
- return s
948
- .replace(/"(?:[^"\\]|\\.)*"/g, '""')
949
- .replace(/'(?:[^'\\]|\\.)*'/g, "''")
950
- .replace(/`(?:[^`\\]|\\.)*`/g, '``');
951
- }
952
- function hasBackgroundExecIntent(command) {
953
- const stripped = stripSimpleQuotedSegments(command);
954
- // Detect standalone '&' token (background), but ignore && and redirection forms
955
- // like >&2, <&, and &>.
956
- return /(^|[;\s])&(?![&><\d])(?=($|[;\s]))/.test(stripped);
957
- }
958
- function safeFireAndForget(fn) {
959
- if (!fn)
960
- return;
961
- try {
962
- const r = fn();
963
- if (r && typeof r.catch === 'function')
964
- r.catch(() => { });
965
- }
966
- catch {
967
- // best effort only
968
- }
969
- }
970
- function makeExecStreamer(ctx) {
971
- const cb = ctx.onToolStream;
972
- if (!cb)
973
- return null;
974
- const id = ctx.toolCallId ?? '';
975
- const name = ctx.toolName ?? 'exec';
976
- const intervalMs = Math.max(50, Math.floor(ctx.toolStreamIntervalMs ?? 750));
977
- const maxChunkChars = Math.max(80, Math.floor(ctx.toolStreamMaxChunkChars ?? 900));
978
- const maxBufferChars = Math.max(maxChunkChars, Math.floor(ctx.toolStreamMaxBufferChars ?? 12_000));
979
- let outBuf = '';
980
- let errBuf = '';
981
- let lastEmit = 0;
982
- let timer = null;
983
- const emit = (stream, chunk) => {
984
- const trimmed = chunk.length > maxChunkChars ? chunk.slice(-maxChunkChars) : chunk;
985
- const ev = { id, name, stream, chunk: trimmed };
986
- safeFireAndForget(() => cb(ev));
987
- };
988
- const schedule = () => {
989
- if (timer)
990
- return;
991
- const delay = Math.max(0, intervalMs - (Date.now() - lastEmit));
992
- timer = setTimeout(() => {
993
- timer = null;
994
- flush(false);
995
- }, delay);
996
- };
997
- const flush = (force = false) => {
998
- const now = Date.now();
999
- if (!force && now - lastEmit < intervalMs) {
1000
- schedule();
1001
- return;
1002
- }
1003
- lastEmit = now;
1004
- if (outBuf) {
1005
- emit('stdout', outBuf);
1006
- outBuf = '';
1007
- }
1008
- if (errBuf) {
1009
- emit('stderr', errBuf);
1010
- errBuf = '';
1011
- }
1012
- };
1013
- const push = (stream, textRaw) => {
1014
- const text = stripAnsi(textRaw).replace(/\r/g, '\n');
1015
- if (!text)
1016
- return;
1017
- if (stream === 'stdout')
1018
- outBuf += text;
1019
- else
1020
- errBuf += text;
1021
- if (outBuf.length > maxBufferChars)
1022
- outBuf = outBuf.slice(-maxBufferChars);
1023
- if (errBuf.length > maxBufferChars)
1024
- errBuf = errBuf.slice(-maxBufferChars);
1025
- schedule();
1026
- };
1027
- const done = () => {
1028
- if (timer) {
1029
- clearTimeout(timer);
1030
- timer = null;
1031
- }
1032
- flush(true);
1033
- };
1034
- return { push, done };
36
+ return searchFilesTool(ctx, args, exec);
1035
37
  }
1036
38
  export async function exec(ctx, args) {
1037
- const command = typeof args?.command === 'string' ? args.command : undefined;
1038
- const cwd = args?.cwd ? resolvePath(ctx, args.cwd) : ctx.cwd;
1039
- const defaultTimeout = ctx.mode === 'sys' ? 60 : 30;
1040
- const timeout = Math.min(args?.timeout ? Number(args.timeout) : defaultTimeout, 120);
1041
- if (!command)
1042
- throw new Error('exec: missing command');
1043
- // Out-of-cwd enforcement: block exec cwd or `cd` navigating outside the project.
1044
- // Exception: in yolo/auto-edit mode, allow with a warning instead of blocking.
1045
- const absCwd = path.resolve(ctx.cwd);
1046
- const allowOutsideCwd = ctx.approvalMode === 'yolo' || ctx.approvalMode === 'auto-edit';
1047
- let execCwdWarning = '';
1048
- if (args?.cwd) {
1049
- const absExecCwd = path.resolve(cwd);
1050
- if (!isWithinDir(absExecCwd, absCwd)) {
1051
- if (!allowOutsideCwd) {
1052
- throw new Error(`exec: BLOCKED — cwd "${absExecCwd}" is outside the working directory "${absCwd}". Use relative paths and work within the project directory.`);
1053
- }
1054
- execCwdWarning = `\n[WARNING] cwd "${absExecCwd}" is outside the working directory "${absCwd}". Proceeding due to ${ctx.approvalMode} mode.`;
1055
- }
1056
- }
1057
- if (command) {
1058
- // Detect absolute paths in `cd` commands
1059
- // - Unix: /path
1060
- // - Windows: C:\path or C:/path or \path
1061
- const cdPattern = /\bcd\s+(['"]?)(\/[^\s'";&|]+|[a-zA-Z]:[\\/][^\s'";&|]*)\1/g;
1062
- let cdMatch;
1063
- while ((cdMatch = cdPattern.exec(command)) !== null) {
1064
- const cdTarget = path.resolve(cdMatch[2]);
1065
- if (!isWithinDir(cdTarget, absCwd)) {
1066
- if (!allowOutsideCwd) {
1067
- throw new Error(`exec: BLOCKED — command navigates to "${cdTarget}" which is outside the working directory "${absCwd}". Use relative paths and work within the project directory.`);
1068
- }
1069
- execCwdWarning = `\n[WARNING] Command navigates to "${cdTarget}" which is outside the working directory "${absCwd}". Proceeding due to ${ctx.approvalMode} mode.`;
1070
- }
1071
- }
1072
- // Detect absolute paths in file-creating commands (mkdir, cat >, tee, touch, etc.)
1073
- const absPathPattern = /(?:mkdir|cat\s*>|tee|touch|cp|mv|rm|rmdir)\s+(?:-\S+\s+)*(['"]?)(\/[^\s'";&|]+|[a-zA-Z]:[\\/][^\s'";&|]*)\1/g;
1074
- let apMatch;
1075
- while ((apMatch = absPathPattern.exec(command)) !== null) {
1076
- const absTarget = path.resolve(apMatch[2]);
1077
- if (!isWithinDir(absTarget, absCwd)) {
1078
- if (!allowOutsideCwd) {
1079
- throw new Error(`exec: BLOCKED — command targets "${absTarget}" which is outside the working directory "${absCwd}". Use relative paths to work within the project directory.`);
1080
- }
1081
- execCwdWarning = `\n[WARNING] Command targets "${absTarget}" which is outside the working directory "${absCwd}". Proceeding due to ${ctx.approvalMode} mode.`;
1082
- }
1083
- }
1084
- }
1085
- if (hasBackgroundExecIntent(command)) {
1086
- throw new Error('exec: blocked background command (contains `&`). ' +
1087
- 'Long-running/background jobs can stall one-shot sessions. ' +
1088
- 'Run foreground smoke checks only, or use a dedicated service manager outside this task.');
1089
- }
1090
- // ── Safety tier check (Phase 9) ──
1091
- const verdict = checkExecSafety(command);
1092
- // Forbidden: ALWAYS blocked, even in yolo/noConfirm mode. No override.
1093
- if (verdict.tier === 'forbidden') {
1094
- throw new Error(`exec: ${verdict.reason} — command: ${command}`);
1095
- }
1096
- // Extra protection: block rm targeting protected root directories
1097
- if (isProtectedDeleteTarget(command)) {
1098
- throw new Error(`exec: BLOCKED: rm targeting protected directory — command: ${command}`);
1099
- }
1100
- // Cautious: require confirmation unless yolo/noConfirm
1101
- if (verdict.tier === 'cautious' && !ctx.noConfirm) {
1102
- if (ctx.confirm) {
1103
- const ok = await ctx.confirm(verdict.prompt || `About to run:\n\n${command}\n\nProceed? (y/N) `, { tool: 'exec', args: { command } });
1104
- if (!ok) {
1105
- throw new Error(`exec: cancelled by user (${verdict.reason}): ${command}`);
1106
- }
1107
- }
1108
- else {
1109
- if (verdict.reason === 'package install/remove') {
1110
- throw new Error(`exec: blocked (${verdict.reason}) without --no-confirm/--yolo: ${command}\n` +
1111
- `STOP: this is a session-level approval restriction. Adding --yolo/--no-confirm inside the shell command does NOT override it. ` +
1112
- `Re-run the parent session with --no-confirm or --yolo to allow package operations. ` +
1113
- `Alternatively, the user can install packages manually and re-run this task. ` +
1114
- `Do NOT use spawn_task to bypass this restriction.`);
1115
- }
1116
- throw new Error(`exec: blocked (${verdict.reason}) without --no-confirm/--yolo: ${command}`);
1117
- }
1118
- }
1119
- if (ctx.dryRun)
1120
- return `dry-run: would exec in ${cwd}: ${command}`;
1121
- // ── Sudo handling (Phase 9c) ──
1122
- // Non-TTY: probe for NOPASSWD / cached credentials before running.
1123
- if (/^\s*sudo\s/.test(command) && !process.stdin.isTTY) {
1124
- try {
1125
- const probe = spawnSync('sudo', ['-n', 'true'], { timeout: 5000, stdio: 'ignore' });
1126
- if (probe.status !== 0) {
1127
- throw new Error('exec: sudo requires a TTY for password input, but stdin is not a TTY. ' +
1128
- 'Options: run idlehands interactively, configure NOPASSWD for this command, or pre-cache sudo credentials.');
1129
- }
1130
- }
1131
- catch (e) {
1132
- if (e.message?.includes('sudo requires a TTY'))
1133
- throw e;
1134
- // spawnSync error (sudo not found, etc.) — let the actual command fail naturally
1135
- }
1136
- }
1137
- const maxBytes = ctx.maxExecBytes ?? DEFAULT_MAX_EXEC_BYTES;
1138
- const captureLimit = ctx.maxExecCaptureBytes ?? Math.max(maxBytes * 64, 256 * 1024);
1139
- // TTY interactive sudo path (Phase 9c): use node-pty when available.
1140
- if (/^\s*sudo\s/.test(command) && process.stdin.isTTY) {
1141
- const pty = await loadNodePty();
1142
- if (!pty) {
1143
- throw new Error('exec: interactive sudo requires node-pty, but it is not installed. Install optional dependency `node-pty` (build tools: python3, make, g++) or use non-interactive sudo (NOPASSWD/cached credentials).');
1144
- }
1145
- return await execWithPty({
1146
- pty,
1147
- command,
1148
- cwd,
1149
- timeout,
1150
- maxBytes,
1151
- captureLimit,
1152
- signal: ctx.signal,
1153
- execCwdWarning,
1154
- });
1155
- }
1156
- // Validate cwd exists — spawn throws a cryptic ENOENT if it doesn't.
1157
- try {
1158
- await fs.access(cwd);
1159
- }
1160
- catch {
1161
- throw new Error(`exec: working directory does not exist: ${cwd}`);
1162
- }
1163
- // Use spawn with shell:true — lets Node.js resolve the shell internally,
1164
- // avoiding ENOENT issues with explicit bash paths in certain environments.
1165
- const child = spawn(command, [], {
1166
- cwd,
1167
- stdio: ['ignore', 'pipe', 'pipe'],
1168
- shell: BASH_PATH,
1169
- detached: true,
1170
- });
1171
- const outChunks = [];
1172
- const errChunks = [];
1173
- let outSeen = 0;
1174
- let errSeen = 0;
1175
- let outCaptured = 0;
1176
- let errCaptured = 0;
1177
- let killed = false;
1178
- const killProcessGroup = () => {
1179
- const pid = child.pid;
1180
- if (!pid)
1181
- return;
1182
- try {
1183
- // detached:true places the shell in its own process group.
1184
- process.kill(-pid, 'SIGKILL');
1185
- }
1186
- catch {
1187
- try {
1188
- child.kill('SIGKILL');
1189
- }
1190
- catch { }
1191
- }
1192
- };
1193
- const killTimer = setTimeout(() => {
1194
- killed = true;
1195
- killProcessGroup();
1196
- }, Math.max(1, timeout) * 1000);
1197
- // §11: kill child process if parent abort signal fires (Ctrl+C).
1198
- const onAbort = () => { killed = true; killProcessGroup(); };
1199
- ctx.signal?.addEventListener('abort', onAbort, { once: true });
1200
- const pushCapped = (chunks, buf, kind) => {
1201
- const n = buf.length;
1202
- if (kind === 'out')
1203
- outSeen += n;
1204
- else
1205
- errSeen += n;
1206
- const captured = kind === 'out' ? outCaptured : errCaptured;
1207
- const remaining = captureLimit - captured;
1208
- if (remaining <= 0)
1209
- return;
1210
- const take = n <= remaining ? buf : buf.subarray(0, remaining);
1211
- chunks.push(Buffer.from(take));
1212
- if (kind === 'out')
1213
- outCaptured += take.length;
1214
- else
1215
- errCaptured += take.length;
1216
- };
1217
- const streamer = makeExecStreamer(ctx);
1218
- child.stdout.on('data', (d) => {
1219
- pushCapped(outChunks, d, 'out');
1220
- streamer?.push('stdout', d.toString('utf8'));
1221
- });
1222
- child.stderr.on('data', (d) => {
1223
- pushCapped(errChunks, d, 'err');
1224
- streamer?.push('stderr', d.toString('utf8'));
1225
- });
1226
- const rc = await new Promise((resolve, reject) => {
1227
- child.on('error', (err) => {
1228
- clearTimeout(killTimer);
1229
- ctx.signal?.removeEventListener('abort', onAbort);
1230
- reject(new Error(`exec: failed to spawn shell (cwd=${cwd}): ${err.message} (${err.code ?? 'unknown'})`));
1231
- });
1232
- child.on('close', (code) => resolve(code ?? 0));
1233
- });
1234
- clearTimeout(killTimer);
1235
- ctx.signal?.removeEventListener('abort', onAbort);
1236
- streamer?.done();
1237
- const outRaw = stripAnsi(Buffer.concat(outChunks).toString('utf8'));
1238
- const errRaw = stripAnsi(Buffer.concat(errChunks).toString('utf8'));
1239
- const outLines = collapseStackTraces(dedupeRepeats(outRaw.split(/\r?\n/))).join('\n').trimEnd();
1240
- const errLines = collapseStackTraces(dedupeRepeats(errRaw.split(/\r?\n/))).join('\n').trimEnd();
1241
- const outT = truncateBytes(outLines, maxBytes, outSeen);
1242
- const errT = truncateBytes(errLines, maxBytes, errSeen);
1243
- let outText = outT.text;
1244
- let errText = errT.text;
1245
- const capOut = outSeen > outCaptured;
1246
- const capErr = errSeen > errCaptured;
1247
- // If we had to cap capture but the post-processed output ended up short
1248
- // (e.g., massive repeated output collapsed), still surface that truncation.
1249
- if (capOut && !outT.truncated) {
1250
- outText = truncateBytes(outText + `\n[capture truncated, ${outSeen} bytes total]`, maxBytes, outSeen).text;
1251
- }
1252
- if (capErr && !errT.truncated) {
1253
- errText = truncateBytes(errText + `\n[capture truncated, ${errSeen} bytes total]`, maxBytes, errSeen).text;
1254
- }
1255
- if (killed) {
1256
- errText = (errText ? errText + '\n' : '') + `[killed after ${timeout}s timeout]`;
1257
- }
1258
- // When any command produces no output, add an explicit semantic hint so the
1259
- // model understands the result and doesn't retry the same command in a loop.
1260
- if (!outText && !errText && !killed) {
1261
- if (rc === 0) {
1262
- outText = '[command completed successfully with no output. Do NOT retry — the command worked but produced no output. Move on to the next step.]';
1263
- }
1264
- else if (rc === 1) {
1265
- outText = '[no matches found — the command returned zero results (exit code 1). Do NOT retry this command with the same arguments. The target simply has no matches. Move on or try different search terms/parameters.]';
1266
- }
1267
- else {
1268
- outText = `[command exited with code ${rc} and produced no output. Do NOT retry with identical arguments — diagnose the issue or try a different approach.]`;
1269
- }
1270
- }
1271
- const result = {
1272
- rc,
1273
- out: outText,
1274
- err: errText,
1275
- truncated: outT.truncated || errT.truncated || capOut || capErr,
1276
- ...(execCwdWarning && { warnings: [execCwdWarning.trim()] })
1277
- };
1278
- // Phase 9d: auto-note system changes in sys mode
1279
- if (ctx.mode === 'sys' && ctx.vault && rc === 0) {
1280
- autoNoteSysChange(ctx.vault, command, outText).catch(() => { });
1281
- }
1282
- return JSON.stringify(result);
1283
- }
1284
- async function execWithPty(args) {
1285
- const { pty, command, cwd, timeout, maxBytes, captureLimit, signal, execCwdWarning } = args;
1286
- const proc = pty.spawn(BASH_PATH, ['-c', command], {
1287
- name: 'xterm-color',
1288
- cwd,
1289
- cols: 120,
1290
- rows: 30,
1291
- env: process.env,
1292
- });
1293
- const chunks = [];
1294
- let seen = 0;
1295
- let captured = 0;
1296
- let killed = false;
1297
- const onDataDisposable = proc.onData((data) => {
1298
- // Real-time stream for interactive UX
1299
- if (process.stdout.isTTY) {
1300
- process.stdout.write(data);
1301
- }
1302
- const n = Buffer.byteLength(data, 'utf8');
1303
- seen += n;
1304
- const remaining = captureLimit - captured;
1305
- if (remaining <= 0)
1306
- return;
1307
- if (n <= remaining) {
1308
- chunks.push(data);
1309
- captured += n;
1310
- }
1311
- else {
1312
- const buf = Buffer.from(data, 'utf8');
1313
- const slice = buf.subarray(0, remaining).toString('utf8');
1314
- chunks.push(slice);
1315
- captured += Buffer.byteLength(slice, 'utf8');
1316
- }
1317
- });
1318
- const kill = () => {
1319
- killed = true;
1320
- try {
1321
- proc.kill();
1322
- }
1323
- catch {
1324
- // ignore
1325
- }
1326
- };
1327
- const killTimer = setTimeout(kill, Math.max(1, timeout) * 1000);
1328
- const onAbort = () => kill();
1329
- signal?.addEventListener('abort', onAbort, { once: true });
1330
- const rc = await new Promise((resolve) => {
1331
- proc.onExit((e) => resolve(Number(e?.exitCode ?? 0)));
1332
- });
1333
- clearTimeout(killTimer);
1334
- signal?.removeEventListener('abort', onAbort);
1335
- onDataDisposable?.dispose?.();
1336
- const raw = stripAnsi(chunks.join(''));
1337
- const lines = collapseStackTraces(dedupeRepeats(raw.split(/\r?\n/))).join('\n').trimEnd();
1338
- const outT = truncateBytes(lines, maxBytes, seen);
1339
- let outText = outT.text;
1340
- const cap = seen > captured;
1341
- if (cap && !outT.truncated) {
1342
- outText = truncateBytes(outText + `\n[capture truncated, ${seen} bytes total]`, maxBytes, seen).text;
1343
- }
1344
- let errText = '';
1345
- if (killed) {
1346
- errText = `[killed after ${timeout}s timeout]`;
1347
- }
1348
- // Semantic hints for empty output (same as non-pty exec path).
1349
- if (!outText && !errText && !killed) {
1350
- if (rc === 0) {
1351
- outText = '[command completed successfully with no output. Do NOT retry — the command worked but produced no output. Move on to the next step.]';
1352
- }
1353
- else if (rc === 1) {
1354
- outText = '[no matches found — the command returned zero results (exit code 1). Do NOT retry this command with the same arguments. The target simply has no matches. Move on or try different search terms/parameters.]';
1355
- }
1356
- else {
1357
- outText = `[command exited with code ${rc} and produced no output. Do NOT retry with identical arguments — diagnose the issue or try a different approach.]`;
1358
- }
1359
- }
1360
- const result = {
1361
- rc,
1362
- out: outText,
1363
- err: errText,
1364
- truncated: outT.truncated || cap || killed,
1365
- ...(execCwdWarning && { warnings: [execCwdWarning.trim()] })
1366
- };
1367
- return JSON.stringify(result);
39
+ return execTool(ctx, args);
1368
40
  }
1369
41
  export async function vault_note(ctx, args) {
1370
- const key = typeof args?.key === 'string' ? args.key.trim() : '';
1371
- const value = typeof args?.value === 'string' ? args.value : undefined;
1372
- if (!key)
1373
- throw new Error('vault_note: missing key');
1374
- if (value == null)
1375
- throw new Error('vault_note: missing value');
1376
- if (ctx.dryRun)
1377
- return `dry-run: would add vault note ${JSON.stringify(key)}`;
1378
- if (!ctx.vault) {
1379
- throw new Error('vault_note: vault disabled');
1380
- }
1381
- const id = await ctx.vault.note(key, String(value));
1382
- return `vault_note: saved ${id}`;
42
+ return vaultNoteTool(ctx, args);
1383
43
  }
1384
44
  export async function vault_search(ctx, args) {
1385
- const query = typeof args?.query === 'string' ? args.query.trim() : '';
1386
- const limit = Number(args?.limit);
1387
- if (!query)
1388
- return 'vault_search: missing query';
1389
- const n = Number.isFinite(limit) && limit > 0 ? Math.min(50, Math.max(1, Math.floor(limit))) : 8;
1390
- if (!ctx.vault)
1391
- return 'vault disabled';
1392
- const results = await ctx.vault.search(query, n);
1393
- if (!results.length) {
1394
- return `vault_search: no results for ${JSON.stringify(query)}`;
1395
- }
1396
- const lines = results.map((r) => {
1397
- const title = r.kind === 'note' ? `note:${r.key}` : `tool:${r.tool || r.key || 'unknown'}`;
1398
- const body = r.value ?? r.snippet ?? r.content ?? '';
1399
- const short = body.replace(/\s+/g, ' ').slice(0, 160);
1400
- return `${r.updatedAt} ${title} ${JSON.stringify(short)}`;
1401
- });
1402
- return lines.join('\n');
45
+ return vaultSearchTool(ctx, args);
1403
46
  }
1404
47
  /** Phase 9: sys_context tool (mode-gated in agent schema). */
1405
48
  export async function sys_context(ctx, args) {
@@ -1407,88 +50,4 @@ export async function sys_context(ctx, args) {
1407
50
  }
1408
51
  // Path safety helpers imported from tools/path-safety.ts:
1409
52
  // isWithinDir, resolvePath, redactPath, checkCwdWarning, enforceMutationWithinCwd
1410
- async function hasRg() {
1411
- const isWin = process.platform === 'win32';
1412
- if (!isWin) {
1413
- try {
1414
- await fs.access('/usr/bin/rg');
1415
- return true;
1416
- }
1417
- catch { /* skip */ }
1418
- }
1419
- // try PATH
1420
- return await new Promise((resolve) => {
1421
- const selector = isWin ? 'where' : 'command -v';
1422
- const sub = isWin ? ['rg'] : ['-c', `${selector} rg >/dev/null 2>&1`];
1423
- const cmd = isWin ? selector : BASH_PATH;
1424
- const c = spawn(cmd, sub, { stdio: 'ignore' });
1425
- c.on('error', () => resolve(false));
1426
- c.on('close', (code) => resolve(code === 0));
1427
- });
1428
- }
1429
- /** Sørensen-Dice coefficient on character bigrams. Returns 0–1. */
1430
- function bigramSimilarity(a, b) {
1431
- if (a.length < 2 && b.length < 2)
1432
- return a === b ? 1 : 0;
1433
- const bigrams = (s) => {
1434
- const m = new Map();
1435
- for (let i = 0; i < s.length - 1; i++) {
1436
- const bi = s.slice(i, i + 2);
1437
- m.set(bi, (m.get(bi) ?? 0) + 1);
1438
- }
1439
- return m;
1440
- };
1441
- const aB = bigrams(a);
1442
- const bB = bigrams(b);
1443
- let overlap = 0;
1444
- for (const [k, v] of aB) {
1445
- overlap += Math.min(v, bB.get(k) ?? 0);
1446
- }
1447
- const total = (a.length - 1) + (b.length - 1);
1448
- return total > 0 ? (2 * overlap) / total : 0;
1449
- }
1450
- function globishMatch(name, glob) {
1451
- // supports only simple '*.ext' and exact matches
1452
- if (glob === name)
1453
- return true;
1454
- const m = /^\*\.(.+)$/.exec(glob);
1455
- if (m)
1456
- return name.endsWith('.' + m[1]);
1457
- return false;
1458
- }
1459
- // ---------------------------------------------------------------------------
1460
- // Phase 9d: System memory helpers
1461
- // ---------------------------------------------------------------------------
1462
- /** Patterns that indicate system-modifying commands worth auto-noting. */
1463
- const SYS_CHANGE_PATTERNS = [
1464
- /\b(apt|apt-get|dnf|yum|pacman|pip|npm)\s+(install|remove|purge|upgrade|update)\b/i,
1465
- /\bsystemctl\s+(start|stop|restart|enable|disable)\b/i,
1466
- /\bufw\s+(allow|deny|delete|enable|disable)\b/i,
1467
- /\biptables\s+(-A|-I|-D)\b/i,
1468
- /\buseradd\b/i,
1469
- /\buserdel\b/i,
1470
- /\bcrontab\b/i,
1471
- ];
1472
- /** Auto-note significant system changes to Vault (sys mode only). */
1473
- async function autoNoteSysChange(vault, command, output) {
1474
- const isSignificant = SYS_CHANGE_PATTERNS.some(p => p.test(command));
1475
- if (!isSignificant)
1476
- return;
1477
- const summary = output.length > 200 ? output.slice(0, 197) + '...' : output;
1478
- const value = `Command: ${command}\nOutput: ${summary}`;
1479
- await vault.note(`sys:${command.slice(0, 80)}`, value);
1480
- }
1481
- /** Snapshot a file's contents to Vault before editing (for /etc/ config tracking). */
1482
- export async function snapshotBeforeEdit(vault, filePath) {
1483
- if (!filePath.startsWith('/etc/'))
1484
- return;
1485
- try {
1486
- const content = await fs.readFile(filePath, 'utf8');
1487
- const snippet = content.length > 500 ? content.slice(0, 497) + '...' : content;
1488
- await vault.note(`sys:pre-edit:${filePath}`, `Snapshot before edit:\n${snippet}`);
1489
- }
1490
- catch {
1491
- // File doesn't exist yet or not readable — skip
1492
- }
1493
- }
1494
53
  //# sourceMappingURL=tools.js.map