zames_pro 2.13.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/agent-loop.js +43 -43
- package/dist/extraTools.js +352 -0
- package/dist/gitTools.js +13 -13
- package/dist/index.js +2 -1
- package/dist/system-prompt.js +1 -1
- package/dist/tools.js +100 -46
- package/dist/web.js +15 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,23 @@ zames --version
|
|
|
76
76
|
zames --help
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
## Tools
|
|
80
|
+
|
|
81
|
+
The agent has the same style of tools as Claude Code / Codex CLI:
|
|
82
|
+
|
|
83
|
+
- **File tools** — `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`. `Read`
|
|
84
|
+
returns raw content by default; pass `numbered=true` to get `cat -n`-style
|
|
85
|
+
line numbers (for reference only — do not paste them into `Edit`).
|
|
86
|
+
- **Extra tools** — `LS` (list a directory), `MultiEdit` (several edits to one
|
|
87
|
+
file applied atomically), `TodoWrite` (session task checklist), `ApplyPatch`
|
|
88
|
+
(multi-file patch in Codex's V4A format: `*** Begin Patch` … `*** End Patch`).
|
|
89
|
+
- **Git** — `GitStatus`, `GitDiff`, `GitLog`, `GitAdd`, `GitCommit`, `GitPush`.
|
|
90
|
+
- **Web** — `WebFetch`, `WebSearch`.
|
|
91
|
+
- **Service** — `respond` (final answer to the operator, ends the task).
|
|
92
|
+
|
|
93
|
+
All file tools stay inside the working directory (sandbox). `Write`/`Edit` and
|
|
94
|
+
`MultiEdit`/`ApplyPatch` make a backup (undo) before touching a file.
|
|
95
|
+
|
|
79
96
|
## Project context, skills and memory
|
|
80
97
|
|
|
81
98
|
Like Codex / Claude Code, zames reads project instructions and reusable
|
package/dist/agent-loop.js
CHANGED
|
@@ -194,20 +194,20 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
194
194
|
attempt: afterToolRetries,
|
|
195
195
|
error: e.message,
|
|
196
196
|
});
|
|
197
|
-
safeWarning('browser.ask()
|
|
197
|
+
safeWarning('browser.ask() did not return an answer within ' +
|
|
198
198
|
Math.round(askDeadlineMs / 1000) +
|
|
199
|
-
'
|
|
199
|
+
's — retrying the request.');
|
|
200
200
|
if (afterToolRetries < MAX_AFTER_TOOL_RETRIES) {
|
|
201
201
|
afterToolRetries++;
|
|
202
202
|
await new Promise((r) => setTimeout(r, 1500));
|
|
203
203
|
continue;
|
|
204
204
|
}
|
|
205
205
|
transcript?.log('ask_timeout_exhausted', {
|
|
206
|
-
message: 'ask()
|
|
206
|
+
message: 'ask() did not return an answer and the retry limit is exhausted',
|
|
207
207
|
});
|
|
208
|
-
safeWarning('browser.ask()
|
|
209
|
-
'
|
|
210
|
-
return 'ask() watchdog:
|
|
208
|
+
safeWarning('browser.ask() stopped responding; retry limit exhausted, ' +
|
|
209
|
+
'stopping. No model answer — check the DeepSeek chat manually.');
|
|
210
|
+
return 'ask() watchdog: no model answer received';
|
|
211
211
|
}
|
|
212
212
|
await reportChat();
|
|
213
213
|
transcript?.log('assistant_raw', { response: rawResponse });
|
|
@@ -262,10 +262,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
262
262
|
response: String(rawResponse || '').slice(0, 200),
|
|
263
263
|
});
|
|
264
264
|
message =
|
|
265
|
-
'
|
|
266
|
-
'
|
|
267
|
-
'
|
|
268
|
-
'
|
|
265
|
+
'You stopped after a tool result. Continue the work: ' +
|
|
266
|
+
'reply with EXACTLY one JSON tool-call object, no text before or after, ' +
|
|
267
|
+
'for example: {"tool": "Bash", "args": {"command": "..."}}. ' +
|
|
268
|
+
'If the task is really done — call respond with the final message.';
|
|
269
269
|
await new Promise((r) => setTimeout(r, 1500));
|
|
270
270
|
continue;
|
|
271
271
|
}
|
|
@@ -287,7 +287,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
287
287
|
if (parsedCalls.some((p) => p && p._permissive)) {
|
|
288
288
|
transcript?.log('permissive_parse', { response: rawResponse });
|
|
289
289
|
if (debugLog) {
|
|
290
|
-
console.error('
|
|
290
|
+
console.error('warning: tool-call recognized by the permissive parser');
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
if (!parsed) {
|
|
@@ -304,17 +304,17 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
304
304
|
response: rawResponse,
|
|
305
305
|
});
|
|
306
306
|
if (debugLog) {
|
|
307
|
-
console.error('
|
|
307
|
+
console.error('warning: the answer looks like a tool-call but was not recognized (attempt ' +
|
|
308
308
|
malformedRetries +
|
|
309
309
|
'/' +
|
|
310
310
|
MAX_MALFORMED_RETRIES +
|
|
311
311
|
')');
|
|
312
312
|
}
|
|
313
313
|
message =
|
|
314
|
-
'
|
|
315
|
-
'
|
|
316
|
-
'
|
|
317
|
-
'
|
|
314
|
+
'Your previous answer was not recognized as a tool call. ' +
|
|
315
|
+
'Reply with EXACTLY one JSON tool-call object, no text before or after. ' +
|
|
316
|
+
'Do NOT use XML/DSML tags — plain JSON only. ' +
|
|
317
|
+
'For example: {"tool": "Read", "args": {"path": "src/index.js"}}';
|
|
318
318
|
continue;
|
|
319
319
|
}
|
|
320
320
|
const trimmed = (rawResponse || '').trim();
|
|
@@ -336,16 +336,16 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
336
336
|
response: rawResponse,
|
|
337
337
|
});
|
|
338
338
|
if (debugLog) {
|
|
339
|
-
console.error('
|
|
339
|
+
console.error('warning: empty/service answer, asking to continue (attempt ' +
|
|
340
340
|
stallRetries +
|
|
341
341
|
'/' +
|
|
342
342
|
MAX_STALL_RETRIES +
|
|
343
343
|
')');
|
|
344
344
|
}
|
|
345
345
|
message =
|
|
346
|
-
'
|
|
347
|
-
'
|
|
348
|
-
'
|
|
346
|
+
'Continue the task. If you need a tool — reply with EXACTLY ' +
|
|
347
|
+
'one JSON tool-call object: {"tool": "...", "args": {...}}. ' +
|
|
348
|
+
'If the task is done — call the respond tool with the final message.';
|
|
349
349
|
continue;
|
|
350
350
|
}
|
|
351
351
|
// The answer looks like "I'll call a tool now", but contains no call.
|
|
@@ -361,18 +361,18 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
361
361
|
response: rawResponse,
|
|
362
362
|
});
|
|
363
363
|
if (debugLog) {
|
|
364
|
-
console.error('
|
|
364
|
+
console.error('warning: the answer looks like unfinished work, asking to continue (attempt ' +
|
|
365
365
|
looksDoneRetries +
|
|
366
366
|
'/' +
|
|
367
367
|
MAX_LOOKSDONE_RETRIES +
|
|
368
368
|
')');
|
|
369
369
|
}
|
|
370
370
|
message =
|
|
371
|
-
'
|
|
372
|
-
'
|
|
373
|
-
'
|
|
374
|
-
'
|
|
375
|
-
'
|
|
371
|
+
'It looks like you meant to call a tool but did not. ' +
|
|
372
|
+
'If the task is not finished — reply with EXACTLY one JSON ' +
|
|
373
|
+
'tool-call object, no text before or after. ' +
|
|
374
|
+
'If the task is really done — call respond with the final ' +
|
|
375
|
+
'message to the operator.';
|
|
376
376
|
continue;
|
|
377
377
|
}
|
|
378
378
|
// STRICT: only tool calls and respond reach the operator. Plain text is
|
|
@@ -387,12 +387,12 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
387
387
|
});
|
|
388
388
|
message =
|
|
389
389
|
(justRanTool
|
|
390
|
-
? '
|
|
391
|
-
: '
|
|
392
|
-
'
|
|
393
|
-
'
|
|
390
|
+
? 'You stopped after a tool call and wrote plain text. '
|
|
391
|
+
: 'You wrote plain text without a tool call. ') +
|
|
392
|
+
'The task is not finished. Reply with EXACTLY one JSON tool-call ' +
|
|
393
|
+
'object, no text before or after, for example: ' +
|
|
394
394
|
'{\"tool\": \"Bash\", \"args\": {\"command\": \"...\"}}. ' +
|
|
395
|
-
'
|
|
395
|
+
'If the task is really done — call respond with the final message.';
|
|
396
396
|
continue;
|
|
397
397
|
}
|
|
398
398
|
// Budget exhausted. Ask for respond EXACTLY once more; if the model
|
|
@@ -404,8 +404,8 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
404
404
|
response: rawResponse.slice(0, 500),
|
|
405
405
|
});
|
|
406
406
|
message =
|
|
407
|
-
'
|
|
408
|
-
'
|
|
407
|
+
'Last step: call the respond tool with the final message ' +
|
|
408
|
+
'to the operator. Do not write plain text — only a respond call, for example: ' +
|
|
409
409
|
'{\"tool\": \"respond\", \"args\": {\"message\": \"...\"}}';
|
|
410
410
|
continue;
|
|
411
411
|
}
|
|
@@ -450,16 +450,16 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
450
450
|
stallRetries++;
|
|
451
451
|
transcript?.log('empty_respond', { attempt: stallRetries });
|
|
452
452
|
if (debugLog) {
|
|
453
|
-
console.error('
|
|
453
|
+
console.error('warning: empty respond, asking to continue (attempt ' +
|
|
454
454
|
stallRetries +
|
|
455
455
|
'/' +
|
|
456
456
|
MAX_STALL_RETRIES +
|
|
457
457
|
')');
|
|
458
458
|
}
|
|
459
459
|
message =
|
|
460
|
-
'
|
|
461
|
-
'
|
|
462
|
-
'
|
|
460
|
+
'You called respond with an empty message. If the task is done — ' +
|
|
461
|
+
'call respond with the final message to the operator. If not — ' +
|
|
462
|
+
'continue the work with a tool call.';
|
|
463
463
|
continue;
|
|
464
464
|
}
|
|
465
465
|
// An EMPTY respond must NEVER be a silent final: the operator would see
|
|
@@ -469,9 +469,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
469
469
|
// the task normally.
|
|
470
470
|
if (!isMeaningfulRespond(msg)) {
|
|
471
471
|
transcript?.log('empty_respond_exhausted', { response: rawResponse });
|
|
472
|
-
safeWarning('
|
|
473
|
-
'
|
|
474
|
-
return '
|
|
472
|
+
safeWarning('The model called respond without text, and the retry limit is exhausted. ' +
|
|
473
|
+
'Check the DeepSeek chat manually.');
|
|
474
|
+
return 'The model produced no final message (empty respond).';
|
|
475
475
|
}
|
|
476
476
|
safeAssistantMessage(msg);
|
|
477
477
|
transcript?.log('assistant_final', { message: msg });
|
|
@@ -485,7 +485,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
485
485
|
for (const call of callsToRun) {
|
|
486
486
|
const tool = tools.find((t) => t.name === call.tool);
|
|
487
487
|
if (!tool) {
|
|
488
|
-
const err =
|
|
488
|
+
const err = `Unknown tool: ${call.tool}`;
|
|
489
489
|
safeToolResult(err);
|
|
490
490
|
transcript?.log('tool_error', { tool: call.tool, error: err });
|
|
491
491
|
results.push({ tool: call.tool, result: err });
|
|
@@ -498,7 +498,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
498
498
|
result = await tool.fn(call.args);
|
|
499
499
|
}
|
|
500
500
|
catch (e) {
|
|
501
|
-
result =
|
|
501
|
+
result = `Error: ${e.message}`;
|
|
502
502
|
}
|
|
503
503
|
safeToolResult(result);
|
|
504
504
|
transcript?.log('tool_result', {
|
|
@@ -536,7 +536,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
536
536
|
.join('\n\n');
|
|
537
537
|
}
|
|
538
538
|
}
|
|
539
|
-
return '
|
|
539
|
+
return 'Iteration limit reached.';
|
|
540
540
|
}
|
|
541
541
|
// The answer looks like a tool call, but parseToolCall() did not recognize it.
|
|
542
542
|
// Used as a safeguard against "the agent called a tool and stopped": in that
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
// Extra tools that bring zames closer to Claude Code / Codex CLI:
|
|
4
|
+
// * LS - list a directory (Claude Code has LS; Codex has list_dir)
|
|
5
|
+
// * MultiEdit - several edits to ONE file applied atomically (Claude Code)
|
|
6
|
+
// * ApplyPatch - multi-file V4A-style patch (Codex apply_patch)
|
|
7
|
+
// * TodoWrite - session task checklist (Claude Code TodoWrite)
|
|
8
|
+
//
|
|
9
|
+
// The core file tools (Read/Write/Edit/Bash/Glob/Grep) live in tools.ts.
|
|
10
|
+
// These are additive: they do not change the existing tool behaviour.
|
|
11
|
+
const NL = String.fromCharCode(10);
|
|
12
|
+
function decodeContent(content, contentBase64) {
|
|
13
|
+
if (typeof contentBase64 === 'string' && contentBase64.length) {
|
|
14
|
+
return Buffer.from(contentBase64, 'base64').toString('utf-8');
|
|
15
|
+
}
|
|
16
|
+
return String(content ?? '');
|
|
17
|
+
}
|
|
18
|
+
// The todo list is per-process session state. It is deliberately NOT
|
|
19
|
+
// persisted: a fresh run starts with an empty list, like Claude Code's
|
|
20
|
+
// session checklist.
|
|
21
|
+
const todoList = [];
|
|
22
|
+
export function getTodos() {
|
|
23
|
+
return todoList.map((t) => ({ ...t }));
|
|
24
|
+
}
|
|
25
|
+
export function resetTodos() {
|
|
26
|
+
todoList.length = 0;
|
|
27
|
+
}
|
|
28
|
+
export function renderTodos(items) {
|
|
29
|
+
if (!items.length)
|
|
30
|
+
return 'Todo list is empty.';
|
|
31
|
+
const mark = (s) => s === 'completed' ? '[x]' : s === 'in_progress' ? '[~]' : '[ ]';
|
|
32
|
+
const lines = items.map((t, i) => `${i + 1}. ${mark(t.status)} ${t.content}`);
|
|
33
|
+
const done = items.filter((t) => t.status === 'completed').length;
|
|
34
|
+
return `Todo list (${done}/${items.length} done):${NL}${lines.join(NL)}`;
|
|
35
|
+
}
|
|
36
|
+
export function normalizeTodos(list) {
|
|
37
|
+
const items = [];
|
|
38
|
+
for (const raw of list) {
|
|
39
|
+
const it = (raw || {});
|
|
40
|
+
const content = String(it.content ?? it.text ?? '').trim();
|
|
41
|
+
if (!content)
|
|
42
|
+
continue;
|
|
43
|
+
const s = String(it.status ?? 'pending');
|
|
44
|
+
const status = s === 'completed' || s === 'done'
|
|
45
|
+
? 'completed'
|
|
46
|
+
: s === 'in_progress' || s === 'active'
|
|
47
|
+
? 'in_progress'
|
|
48
|
+
: 'pending';
|
|
49
|
+
items.push({ content, status });
|
|
50
|
+
}
|
|
51
|
+
return items;
|
|
52
|
+
}
|
|
53
|
+
/** Parse a V4A-style patch body into operations. Exported for tests. */
|
|
54
|
+
export function parsePatch(text) {
|
|
55
|
+
const raw = String(text ?? '').replace(/\r\n/g, NL);
|
|
56
|
+
if (!/^\s*\*\*\*\s*Begin Patch/m.test(raw)) {
|
|
57
|
+
return { ops: [], error: 'Patch must start with "*** Begin Patch".' };
|
|
58
|
+
}
|
|
59
|
+
if (!/\*\*\*\s*End Patch/.test(raw)) {
|
|
60
|
+
return { ops: [], error: 'Patch must end with "*** End Patch".' };
|
|
61
|
+
}
|
|
62
|
+
const beginIdx = raw.indexOf('*** Begin Patch');
|
|
63
|
+
const endIdx = raw.lastIndexOf('*** End Patch');
|
|
64
|
+
const beginLineEnd = raw.indexOf(NL, beginIdx);
|
|
65
|
+
const body = raw.slice(beginLineEnd === -1 ? beginIdx : beginLineEnd + 1, endIdx);
|
|
66
|
+
const ops = [];
|
|
67
|
+
let current = null;
|
|
68
|
+
for (const line of body.split(NL)) {
|
|
69
|
+
const header = line.match(/^\*\*\*\s*(Add|Update|Delete) File:\s*(.+?)\s*$/);
|
|
70
|
+
if (header) {
|
|
71
|
+
const kind = header[1].toLowerCase();
|
|
72
|
+
const file = header[2].trim();
|
|
73
|
+
if (!file)
|
|
74
|
+
return { ops: [], error: 'Patch header has an empty path.' };
|
|
75
|
+
if (path.isAbsolute(file) || /(^|[\\/])\.\.([\\/]|$)/.test(file)) {
|
|
76
|
+
return { ops: [], error: 'Patch path must stay inside the project: ' + file };
|
|
77
|
+
}
|
|
78
|
+
current = { op: kind, file, lines: [] };
|
|
79
|
+
ops.push(current);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (current)
|
|
83
|
+
current.lines.push(line);
|
|
84
|
+
}
|
|
85
|
+
if (!ops.length)
|
|
86
|
+
return { ops: [], error: 'Patch contains no file operations.' };
|
|
87
|
+
return { ops };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Apply an update hunk to existing content using context matching.
|
|
91
|
+
* The hunk is a list of lines prefixed with ' ' (context), '-' (remove),
|
|
92
|
+
* '+' (add). We locate the context block by exact match, then by ignoring
|
|
93
|
+
* trailing whitespace, then by ignoring all whitespace (like Codex).
|
|
94
|
+
*/
|
|
95
|
+
export function applyUpdateHunk(content, hunkLines) {
|
|
96
|
+
const hasCRLF = content.includes('\r\n');
|
|
97
|
+
const fileLines = content.split(/\r?\n/);
|
|
98
|
+
const oldLines = [];
|
|
99
|
+
const newLines = [];
|
|
100
|
+
for (const l of hunkLines) {
|
|
101
|
+
if (l.startsWith('@@'))
|
|
102
|
+
continue;
|
|
103
|
+
if (l.startsWith('+')) {
|
|
104
|
+
newLines.push(l.slice(1));
|
|
105
|
+
}
|
|
106
|
+
else if (l.startsWith('-')) {
|
|
107
|
+
oldLines.push(l.slice(1));
|
|
108
|
+
}
|
|
109
|
+
else if (l.startsWith(' ')) {
|
|
110
|
+
oldLines.push(l.slice(1));
|
|
111
|
+
newLines.push(l.slice(1));
|
|
112
|
+
}
|
|
113
|
+
else if (l === '') {
|
|
114
|
+
oldLines.push('');
|
|
115
|
+
newLines.push('');
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
oldLines.push(l);
|
|
119
|
+
newLines.push(l);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!oldLines.length) {
|
|
123
|
+
return { ok: false, error: 'Hunk has no context or removed lines to anchor on.' };
|
|
124
|
+
}
|
|
125
|
+
const norm = (s, mode) => {
|
|
126
|
+
if (mode === 'exact')
|
|
127
|
+
return s;
|
|
128
|
+
if (mode === 'trim')
|
|
129
|
+
return s.replace(/[ \t]+$/, '');
|
|
130
|
+
return s.replace(/\s+/g, '');
|
|
131
|
+
};
|
|
132
|
+
for (const mode of ['exact', 'trim', 'ws']) {
|
|
133
|
+
const anchor = oldLines.map((l) => norm(l, mode));
|
|
134
|
+
for (let i = 0; i + anchor.length <= fileLines.length; i++) {
|
|
135
|
+
let match = true;
|
|
136
|
+
for (let j = 0; j < anchor.length; j++) {
|
|
137
|
+
if (norm(fileLines[i + j], mode) !== anchor[j]) {
|
|
138
|
+
match = false;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!match)
|
|
143
|
+
continue;
|
|
144
|
+
const before = fileLines.slice(0, i);
|
|
145
|
+
const after = fileLines.slice(i + anchor.length);
|
|
146
|
+
const joined = [...before, ...newLines, ...after].join(hasCRLF ? '\r\n' : NL);
|
|
147
|
+
return { ok: true, content: joined };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
error: 'Context not found in the file (hunk did not match): ' +
|
|
153
|
+
oldLines[0].slice(0, 60),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// ---------- factory ----------
|
|
157
|
+
export function createExtraTools(workdir, { undo } = {}) {
|
|
158
|
+
const root = path.resolve(workdir);
|
|
159
|
+
const safe = (p) => {
|
|
160
|
+
const resolved = path.resolve(root, p);
|
|
161
|
+
const rel = path.relative(root, resolved);
|
|
162
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
163
|
+
throw new Error('Access outside the working directory is forbidden: ' + p);
|
|
164
|
+
}
|
|
165
|
+
return resolved;
|
|
166
|
+
};
|
|
167
|
+
// Guard against missing/placeholder required args: a missing `path` becomes
|
|
168
|
+
// String(undefined) === "undefined" and would create a file literally named
|
|
169
|
+
// "undefined" in the working directory.
|
|
170
|
+
const req = (v, name) => {
|
|
171
|
+
if (v === undefined || v === null) {
|
|
172
|
+
throw new Error(`Missing required argument: ${name}`);
|
|
173
|
+
}
|
|
174
|
+
const s = String(v);
|
|
175
|
+
if (s === '' || s === 'undefined' || s === 'null') {
|
|
176
|
+
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}`);
|
|
177
|
+
}
|
|
178
|
+
return s;
|
|
179
|
+
};
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
name: 'LS',
|
|
183
|
+
description: 'List the contents of a directory (files and subfolders). ' +
|
|
184
|
+
'path defaults to the working directory. Folders are marked with a slash.',
|
|
185
|
+
parameters: { path: 'string?', ignore: 'string?' },
|
|
186
|
+
fn: async ({ path: p, ignore }) => {
|
|
187
|
+
const target = p ? safe(req(p, 'path')) : root;
|
|
188
|
+
const stat = await fs.stat(target).catch(() => null);
|
|
189
|
+
if (!stat)
|
|
190
|
+
throw new Error('No such directory: ' + p);
|
|
191
|
+
if (!stat.isDirectory())
|
|
192
|
+
throw new Error('Not a directory: ' + p);
|
|
193
|
+
const ignoreList = (ignore ? String(ignore) : '')
|
|
194
|
+
.split(',')
|
|
195
|
+
.map((s) => s.trim())
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
const entries = await fs.readdir(target, { withFileTypes: true });
|
|
198
|
+
const rows = [];
|
|
199
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
200
|
+
if (ignoreList.includes(e.name))
|
|
201
|
+
continue;
|
|
202
|
+
if (e.name === 'node_modules' || e.name === '.git') {
|
|
203
|
+
rows.push(e.name + '/ (skipped)');
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (e.isDirectory()) {
|
|
207
|
+
rows.push(e.name + '/');
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const full = path.join(target, e.name);
|
|
211
|
+
const st = await fs.stat(full).catch(() => null);
|
|
212
|
+
rows.push(e.name + (st ? ` (${st.size} bytes)` : ''));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return rows.length ? rows.join(NL) : '(empty)';
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'MultiEdit',
|
|
220
|
+
description: 'Several targeted edits to ONE file in a single call. edits is an array of ' +
|
|
221
|
+
'{old_string, new_string, replace_all?}. Edits are applied ' +
|
|
222
|
+
'in order; if any is not found — none are applied. ' +
|
|
223
|
+
'For a single edit use Edit.',
|
|
224
|
+
parameters: {
|
|
225
|
+
path: 'string',
|
|
226
|
+
edits: 'array',
|
|
227
|
+
edits_base64: 'string?',
|
|
228
|
+
},
|
|
229
|
+
fn: async ({ path: p, edits, edits_base64 }) => {
|
|
230
|
+
const file = safe(req(p, 'path'));
|
|
231
|
+
let list;
|
|
232
|
+
if (typeof edits_base64 === 'string' && edits_base64.length) {
|
|
233
|
+
list = JSON.parse(Buffer.from(edits_base64, 'base64').toString('utf-8'));
|
|
234
|
+
}
|
|
235
|
+
else if (Array.isArray(edits)) {
|
|
236
|
+
list = edits;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
throw new Error('edits must be an array of edits.');
|
|
240
|
+
}
|
|
241
|
+
if (!list.length)
|
|
242
|
+
throw new Error('edits is empty — nothing to apply.');
|
|
243
|
+
let content = await fs.readFile(file, 'utf-8');
|
|
244
|
+
for (let i = 0; i < list.length; i++) {
|
|
245
|
+
const e = list[i];
|
|
246
|
+
const oldStr = String(e.old_string ?? '');
|
|
247
|
+
const newStr = String(e.new_string ?? '');
|
|
248
|
+
if (oldStr === '') {
|
|
249
|
+
throw new Error(`edits[${i}]: old_string is empty.`);
|
|
250
|
+
}
|
|
251
|
+
const occurrences = content.split(oldStr).length - 1;
|
|
252
|
+
if (occurrences === 0) {
|
|
253
|
+
throw new Error(`edits[${i}]: string not found: "${oldStr.slice(0, 60)}..."`);
|
|
254
|
+
}
|
|
255
|
+
if (occurrences > 1 && !e.replace_all) {
|
|
256
|
+
throw new Error(`edits[${i}]: string occurs ${occurrences} times. ` +
|
|
257
|
+
'Make old_string more specific or pass replace_all=true.');
|
|
258
|
+
}
|
|
259
|
+
content = e.replace_all
|
|
260
|
+
? content.split(oldStr).join(newStr)
|
|
261
|
+
: content.replace(oldStr, newStr);
|
|
262
|
+
}
|
|
263
|
+
if (undo)
|
|
264
|
+
await undo.backup(file);
|
|
265
|
+
await fs.writeFile(file, content, 'utf-8');
|
|
266
|
+
return `Edited (${list.length} edits): ${p}`;
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'TodoWrite',
|
|
271
|
+
description: 'Create/update the session task list. Accepts todos — an array of ' +
|
|
272
|
+
'{content, status}, where status: pending | in_progress | completed. ' +
|
|
273
|
+
'Helps track multi-step tasks. Replaces the whole list.',
|
|
274
|
+
parameters: { todos: 'array', todos_base64: 'string?' },
|
|
275
|
+
fn: async ({ todos, todos_base64 }) => {
|
|
276
|
+
let list;
|
|
277
|
+
if (typeof todos_base64 === 'string' && todos_base64.length) {
|
|
278
|
+
list = JSON.parse(Buffer.from(todos_base64, 'base64').toString('utf-8'));
|
|
279
|
+
}
|
|
280
|
+
else if (Array.isArray(todos)) {
|
|
281
|
+
list = todos;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
throw new Error('todos must be an array.');
|
|
285
|
+
}
|
|
286
|
+
const items = normalizeTodos(list);
|
|
287
|
+
todoList.length = 0;
|
|
288
|
+
todoList.push(...items);
|
|
289
|
+
return renderTodos(todoList);
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'ApplyPatch',
|
|
294
|
+
description: 'Apply a patch to several files in a single call (Codex format). ' +
|
|
295
|
+
'Body: "*** Begin Patch", operations "*** Add File: <path>" (lines with +), ' +
|
|
296
|
+
'"*** Update File: <path>" (context with a space, removals with -, additions ' +
|
|
297
|
+
'with +, @@ anchors optional), "*** Delete File: <path>", then ' +
|
|
298
|
+
'"*** End Patch". Paths must be relative and inside the project.',
|
|
299
|
+
parameters: { patch: 'string?', patch_base64: 'string?' },
|
|
300
|
+
fn: async ({ patch, patch_base64 }) => {
|
|
301
|
+
const text = decodeContent(patch, patch_base64);
|
|
302
|
+
const { ops, error } = parsePatch(text);
|
|
303
|
+
if (error)
|
|
304
|
+
throw new Error(error);
|
|
305
|
+
// Stage every change first, then write.
|
|
306
|
+
const writes = [];
|
|
307
|
+
for (const op of ops) {
|
|
308
|
+
const file = safe(op.file);
|
|
309
|
+
if (op.op === 'delete') {
|
|
310
|
+
writes.push({ file, content: null });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (op.op === 'add') {
|
|
314
|
+
const body = op.lines
|
|
315
|
+
.filter((l) => l.startsWith('+'))
|
|
316
|
+
.map((l) => l.slice(1))
|
|
317
|
+
.join(NL);
|
|
318
|
+
writes.push({ file, content: body + NL });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
let current;
|
|
322
|
+
try {
|
|
323
|
+
current = await fs.readFile(file, 'utf-8');
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
throw new Error('Update File: no such file ' + op.file);
|
|
327
|
+
}
|
|
328
|
+
const res = applyUpdateHunk(current, op.lines);
|
|
329
|
+
if (!res.ok) {
|
|
330
|
+
throw new Error('Update File ' + op.file + ': ' + res.error);
|
|
331
|
+
}
|
|
332
|
+
writes.push({ file, content: res.content });
|
|
333
|
+
}
|
|
334
|
+
const summary = [];
|
|
335
|
+
for (const w of writes) {
|
|
336
|
+
if (undo)
|
|
337
|
+
await undo.backup(w.file);
|
|
338
|
+
if (w.content === null) {
|
|
339
|
+
await fs.unlink(w.file).catch(() => { });
|
|
340
|
+
summary.push('deleted ' + path.relative(root, w.file));
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
await fs.mkdir(path.dirname(w.file), { recursive: true });
|
|
344
|
+
await fs.writeFile(w.file, w.content, 'utf-8');
|
|
345
|
+
summary.push('written ' + path.relative(root, w.file));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return 'Patch applied:' + NL + summary.join(NL);
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
}
|
package/dist/gitTools.js
CHANGED
|
@@ -18,16 +18,16 @@ export function runGit(cmd, cwd, timeout = 15_000) {
|
|
|
18
18
|
const errStr = (stderr || '').toString();
|
|
19
19
|
if (!err) {
|
|
20
20
|
const combined = (out + errStr).trim();
|
|
21
|
-
resolve(combined || '(
|
|
21
|
+
resolve(combined || '(command produced no output)');
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
const parts = [];
|
|
25
25
|
if (err.killed)
|
|
26
|
-
parts.push(`⏱
|
|
26
|
+
parts.push(`⏱ Timeout ${timeout}ms`);
|
|
27
27
|
else if (err.code !== undefined && err.code !== null)
|
|
28
28
|
parts.push(`Exit code: ${err.code}`);
|
|
29
29
|
else
|
|
30
|
-
parts.push(
|
|
30
|
+
parts.push(`Error: ${err.message}`);
|
|
31
31
|
if (out.trim())
|
|
32
32
|
parts.push(out.trim());
|
|
33
33
|
if (errStr.trim())
|
|
@@ -114,7 +114,7 @@ export function createGitTools(workdir) {
|
|
|
114
114
|
return [
|
|
115
115
|
{
|
|
116
116
|
name: 'GitStatus',
|
|
117
|
-
description: '
|
|
117
|
+
description: 'Show the git status of the working directory. Returns an error if this is not a git repository.',
|
|
118
118
|
parameters: {},
|
|
119
119
|
fn: async () => {
|
|
120
120
|
const err = await ensureRepo();
|
|
@@ -125,7 +125,7 @@ export function createGitTools(workdir) {
|
|
|
125
125
|
},
|
|
126
126
|
{
|
|
127
127
|
name: 'GitDiff',
|
|
128
|
-
description: '
|
|
128
|
+
description: 'Show git diff. By default unstaged changes. Can be limited with path and staged=true.',
|
|
129
129
|
parameters: { path: 'string?', staged: 'boolean?' },
|
|
130
130
|
fn: async ({ path: p, staged }) => {
|
|
131
131
|
const err = await ensureRepo();
|
|
@@ -141,7 +141,7 @@ export function createGitTools(workdir) {
|
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
name: 'GitLog',
|
|
144
|
-
description: '
|
|
144
|
+
description: 'Show the last N commits (default 10).',
|
|
145
145
|
parameters: { count: 'number?' },
|
|
146
146
|
fn: async ({ count }) => {
|
|
147
147
|
const err = await ensureRepo();
|
|
@@ -152,7 +152,7 @@ export function createGitTools(workdir) {
|
|
|
152
152
|
},
|
|
153
153
|
{
|
|
154
154
|
name: 'GitAdd',
|
|
155
|
-
description: '
|
|
155
|
+
description: 'Stage files. If paths is not given — git add -A (everything).',
|
|
156
156
|
parameters: { paths: 'string?' },
|
|
157
157
|
fn: async ({ paths }) => {
|
|
158
158
|
const err = await ensureRepo();
|
|
@@ -165,7 +165,7 @@ export function createGitTools(workdir) {
|
|
|
165
165
|
},
|
|
166
166
|
{
|
|
167
167
|
name: 'GitCommit',
|
|
168
|
-
description: '
|
|
168
|
+
description: 'Commit staged changes (if nothing is staged — git add -A first). Does not push.',
|
|
169
169
|
parameters: { message: 'string' },
|
|
170
170
|
fn: async ({ message }) => {
|
|
171
171
|
const err = await ensureRepo();
|
|
@@ -173,14 +173,14 @@ export function createGitTools(workdir) {
|
|
|
173
173
|
return err;
|
|
174
174
|
const msg = String(message || '');
|
|
175
175
|
if (!msg.trim()) {
|
|
176
|
-
return '
|
|
176
|
+
return 'Error: message is empty.';
|
|
177
177
|
}
|
|
178
178
|
// We stage everything only if the index is empty (as the description promises).
|
|
179
179
|
const staged = await runGit('git diff --cached --name-only', workdir, 10_000);
|
|
180
180
|
const hasStaged = !/^\s*/.test(staged) &&
|
|
181
|
-
staged.trim() !== '(
|
|
181
|
+
staged.trim() !== '(command produced no output)';
|
|
182
182
|
const addResult = hasStaged
|
|
183
|
-
? '(
|
|
183
|
+
? '(index is not empty — add -A skipped)'
|
|
184
184
|
: await runGit('git add -A', workdir, 20_000);
|
|
185
185
|
const fs = await import('fs/promises');
|
|
186
186
|
const path = await import('path');
|
|
@@ -198,7 +198,7 @@ export function createGitTools(workdir) {
|
|
|
198
198
|
},
|
|
199
199
|
{
|
|
200
200
|
name: 'GitPush',
|
|
201
|
-
description: '
|
|
201
|
+
description: 'Push a branch to origin. Requires configured credentials (SSH or Windows Credential Manager).',
|
|
202
202
|
parameters: { branch: 'string?', setUpstream: 'boolean?' },
|
|
203
203
|
fn: async ({ branch, setUpstream }) => {
|
|
204
204
|
const err = await ensureRepo();
|
|
@@ -208,7 +208,7 @@ export function createGitTools(workdir) {
|
|
|
208
208
|
? String(branch).trim()
|
|
209
209
|
: (await runGit('git branch --show-current', workdir, 5000)).trim();
|
|
210
210
|
if (!b)
|
|
211
|
-
return '
|
|
211
|
+
return 'Error: could not determine the branch to push.';
|
|
212
212
|
const flag = setUpstream ? '-u ' : '';
|
|
213
213
|
const target = quoteArgs(b);
|
|
214
214
|
return runGit(`git push ${flag}origin ${target}`, workdir, 120_000);
|
package/dist/index.js
CHANGED
|
@@ -93,6 +93,7 @@ const SRC_EXT = /[.]ts$/.test(new URL(import.meta.url).pathname) ? '.ts' : '.js'
|
|
|
93
93
|
const RELOADABLE = [
|
|
94
94
|
'mcp',
|
|
95
95
|
'tools',
|
|
96
|
+
'extraTools',
|
|
96
97
|
'agent-loop',
|
|
97
98
|
'system-prompt',
|
|
98
99
|
'gitTools',
|
|
@@ -825,7 +826,7 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
825
826
|
// case no assistant message was shown, and the operator saw the run
|
|
826
827
|
// just "stop" after a tool call with no explanation. Surface it.
|
|
827
828
|
if (outcome &&
|
|
828
|
-
(outcome.startsWith('
|
|
829
|
+
(outcome.startsWith('Iteration limit reached') ||
|
|
829
830
|
outcome.startsWith('ask() watchdog'))) {
|
|
830
831
|
ui.warning(outcome);
|
|
831
832
|
transcript?.log('agent_no_answer', { outcome });
|
package/dist/system-prompt.js
CHANGED
|
@@ -50,7 +50,7 @@ export function renderContextSection(context) {
|
|
|
50
50
|
export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', attachments = [], context = null, }) {
|
|
51
51
|
const t = translate(locale);
|
|
52
52
|
const toolDescriptions = tools
|
|
53
|
-
.map((t2) => `### ${t2.name}\n${t2.description}\
|
|
53
|
+
.map((t2) => `### ${t2.name}\n${t2.description}\nParameters: ${JSON.stringify(t2.parameters)}`)
|
|
54
54
|
.join('\n\n');
|
|
55
55
|
const gitSection = gitContext
|
|
56
56
|
? `\n## Git context\n\n${gitContext}\n`
|
package/dist/tools.js
CHANGED
|
@@ -3,6 +3,11 @@ import path from 'path';
|
|
|
3
3
|
import { exec } from 'child_process';
|
|
4
4
|
import { createGitTools } from './gitTools.js';
|
|
5
5
|
import { createWebTools } from './web.js';
|
|
6
|
+
import { createExtraTools } from './extraTools.js';
|
|
7
|
+
// NOTE: tool descriptions and tool-result strings are AGENT-FACING, not
|
|
8
|
+
// user-facing. They must be in ENGLISH (like Claude Code / Codex). Only the
|
|
9
|
+
// strings the OPERATOR sees (help, spinner, service messages) are localized —
|
|
10
|
+
// those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
|
|
6
11
|
export function createTools(workdir, { undo } = {}) {
|
|
7
12
|
const root = path.resolve(workdir);
|
|
8
13
|
const safe = (p) => {
|
|
@@ -11,7 +16,7 @@ export function createTools(workdir, { undo } = {}) {
|
|
|
11
16
|
// (C:\work\proj vs C:\work\proj-old). We compute via relative().
|
|
12
17
|
const rel = path.relative(root, resolved);
|
|
13
18
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
14
|
-
throw new Error(
|
|
19
|
+
throw new Error(`Access outside the working directory is forbidden: ${p}`);
|
|
15
20
|
}
|
|
16
21
|
return resolved;
|
|
17
22
|
};
|
|
@@ -28,7 +33,7 @@ export function createTools(workdir, { undo } = {}) {
|
|
|
28
33
|
const target = path.resolve(root, raw);
|
|
29
34
|
const rel = path.relative(root, target);
|
|
30
35
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
31
|
-
throw new Error('Sandbox:
|
|
36
|
+
throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
};
|
|
@@ -51,21 +56,21 @@ export function createTools(workdir, { undo } = {}) {
|
|
|
51
56
|
const errStr = (stderr || '').toString();
|
|
52
57
|
if (!err) {
|
|
53
58
|
const combined = (out + errStr).trim();
|
|
54
|
-
resolve(combined || '(
|
|
59
|
+
resolve(combined || '(command produced no output)');
|
|
55
60
|
return;
|
|
56
61
|
}
|
|
57
62
|
const parts = [];
|
|
58
63
|
if (err.killed) {
|
|
59
|
-
parts.push(`⏱
|
|
64
|
+
parts.push(`⏱ Timeout after ${timeout}ms — process killed.`);
|
|
60
65
|
}
|
|
61
66
|
else if (err.code !== undefined && err.code !== null) {
|
|
62
67
|
parts.push(`Exit code: ${err.code}`);
|
|
63
68
|
}
|
|
64
69
|
else if (err.signal) {
|
|
65
|
-
parts.push(
|
|
70
|
+
parts.push(`Killed by signal: ${err.signal}`);
|
|
66
71
|
}
|
|
67
72
|
else {
|
|
68
|
-
parts.push(
|
|
73
|
+
parts.push(`Error: ${err.message}`);
|
|
69
74
|
}
|
|
70
75
|
if (out.trim())
|
|
71
76
|
parts.push(`stdout:\n${out.trim()}`);
|
|
@@ -84,45 +89,76 @@ export function createTools(workdir, { undo } = {}) {
|
|
|
84
89
|
}
|
|
85
90
|
return String(content ?? '');
|
|
86
91
|
};
|
|
92
|
+
// Guard against missing/placeholder required args. Without this, a tool call
|
|
93
|
+
// that omits `path` (or sends it as null) becomes String(undefined) ===
|
|
94
|
+
// "undefined" and the tool silently creates a file literally named
|
|
95
|
+
// "undefined" in the working directory. `req()` rejects that up front.
|
|
96
|
+
const req = (v, name) => {
|
|
97
|
+
if (v === undefined || v === null) {
|
|
98
|
+
throw new Error(`Missing required argument: ${name}`);
|
|
99
|
+
}
|
|
100
|
+
const s = String(v);
|
|
101
|
+
if (s === '' || s === 'undefined' || s === 'null') {
|
|
102
|
+
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}`);
|
|
103
|
+
}
|
|
104
|
+
return s;
|
|
105
|
+
};
|
|
87
106
|
const baseTools = [
|
|
88
107
|
{
|
|
89
108
|
name: 'Read',
|
|
90
|
-
description: '
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
description: 'Read a file. Optional: offset and limit (lines). ' +
|
|
110
|
+
'numbered=true adds cat -n style line numbers (1-based) for reference ' +
|
|
111
|
+
'only — do NOT copy the numbers into Edit old_string. ' +
|
|
112
|
+
'Lines longer than 2000 chars are truncated.',
|
|
113
|
+
parameters: {
|
|
114
|
+
path: 'string',
|
|
115
|
+
offset: 'number?',
|
|
116
|
+
limit: 'number?',
|
|
117
|
+
numbered: 'boolean?',
|
|
118
|
+
},
|
|
119
|
+
fn: async ({ path: p, offset, limit, numbered }) => {
|
|
120
|
+
const file = safe(req(p, 'path'));
|
|
94
121
|
const content = await fs.readFile(file, 'utf-8');
|
|
122
|
+
if (content === '')
|
|
123
|
+
return '(file is empty)';
|
|
95
124
|
const lines = content.split('\n');
|
|
96
125
|
const start = offset ?? 0;
|
|
97
126
|
const end = limit ? start + limit : lines.length;
|
|
98
|
-
|
|
127
|
+
const slice = lines.slice(start, end);
|
|
128
|
+
const clip = (l) => l.length > 2000 ? l.slice(0, 2000) + ' …[line truncated]' : l;
|
|
129
|
+
if (!numbered)
|
|
130
|
+
return slice.map(clip).join('\n');
|
|
131
|
+
const width = String(start + slice.length).length;
|
|
132
|
+
return slice
|
|
133
|
+
.map((l, i) => String(start + i + 1).padStart(width) + '\t' + clip(l))
|
|
134
|
+
.join('\n');
|
|
99
135
|
},
|
|
100
136
|
},
|
|
101
137
|
{
|
|
102
138
|
name: 'Write',
|
|
103
|
-
description: '
|
|
104
|
-
'
|
|
105
|
-
'
|
|
139
|
+
description: 'Create or overwrite a file. content — text; content_base64 — the same ' +
|
|
140
|
+
'content base64-encoded (use it if the text contains $, backslashes, ' +
|
|
141
|
+
'newlines or other characters that may get corrupted in transit).',
|
|
106
142
|
parameters: {
|
|
107
143
|
path: 'string',
|
|
108
144
|
content: 'string?',
|
|
109
145
|
content_base64: 'string?',
|
|
110
146
|
},
|
|
111
147
|
fn: async ({ path: p, content, content_base64 }) => {
|
|
112
|
-
const file = safe(
|
|
148
|
+
const file = safe(req(p, 'path'));
|
|
113
149
|
const text = decodeContent(content, content_base64);
|
|
114
150
|
if (undo)
|
|
115
151
|
await undo.backup(file);
|
|
116
152
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
117
153
|
await fs.writeFile(file, text, 'utf-8');
|
|
118
|
-
return
|
|
154
|
+
return `File written: ${p}`;
|
|
119
155
|
},
|
|
120
156
|
},
|
|
121
157
|
{
|
|
122
158
|
name: 'Edit',
|
|
123
|
-
description: '
|
|
124
|
-
'old_base64/new_base64 —
|
|
125
|
-
'
|
|
159
|
+
description: 'Replace a string in a file. old_string must occur exactly once. ' +
|
|
160
|
+
'old_base64/new_base64 — the same strings base64-encoded (if the text ' +
|
|
161
|
+
'contains special characters that may get corrupted).',
|
|
126
162
|
parameters: {
|
|
127
163
|
path: 'string',
|
|
128
164
|
old_string: 'string?',
|
|
@@ -131,81 +167,99 @@ export function createTools(workdir, { undo } = {}) {
|
|
|
131
167
|
new_base64: 'string?',
|
|
132
168
|
},
|
|
133
169
|
fn: async ({ path: p, old_string, new_string, old_base64, new_base64 }) => {
|
|
134
|
-
const file = safe(
|
|
170
|
+
const file = safe(req(p, 'path'));
|
|
135
171
|
const oldStr = decodeContent(old_string, old_base64);
|
|
136
172
|
const newStr = decodeContent(new_string, new_base64);
|
|
137
173
|
if (oldStr === '') {
|
|
138
|
-
throw new Error('old_string
|
|
174
|
+
throw new Error('old_string is empty — nothing to replace.');
|
|
139
175
|
}
|
|
140
176
|
let content = await fs.readFile(file, 'utf-8');
|
|
141
177
|
const occurrences = content.split(oldStr).length - 1;
|
|
142
178
|
if (occurrences === 0) {
|
|
143
|
-
throw new Error(
|
|
179
|
+
throw new Error(`String not found in ${p}: "${oldStr.slice(0, 60)}..."`);
|
|
144
180
|
}
|
|
145
181
|
if (occurrences > 1) {
|
|
146
|
-
throw new Error(
|
|
182
|
+
throw new Error(`String occurs ${occurrences} times in ${p}. Make old_string more specific.`);
|
|
147
183
|
}
|
|
148
184
|
if (undo)
|
|
149
185
|
await undo.backup(file);
|
|
150
186
|
content = content.replace(oldStr, newStr);
|
|
151
187
|
await fs.writeFile(file, content, 'utf-8');
|
|
152
|
-
return
|
|
188
|
+
return `Edited: ${p}`;
|
|
153
189
|
},
|
|
154
190
|
},
|
|
155
191
|
{
|
|
156
192
|
name: 'Bash',
|
|
157
|
-
description: '
|
|
158
|
-
'
|
|
159
|
-
'
|
|
160
|
-
'
|
|
193
|
+
description: 'Run a shell command in the working directory (cmd.exe on Windows, sh on Linux/macOS). ' +
|
|
194
|
+
'Do not use for long-running processes (servers) — they will hit the timeout. ' +
|
|
195
|
+
'Do not use for commands that require interactive input. ' +
|
|
196
|
+
'For git use the Git* tools. For the web use WebFetch / WebSearch.',
|
|
161
197
|
parameters: { command: 'string', timeout: 'number?' },
|
|
162
198
|
fn: async ({ command, timeout }) => {
|
|
163
|
-
assertCommandInsideRoot(
|
|
164
|
-
return runShell(
|
|
199
|
+
assertCommandInsideRoot(req(command, 'command'));
|
|
200
|
+
return runShell(req(command, 'command'), timeout);
|
|
165
201
|
},
|
|
166
202
|
},
|
|
167
203
|
{
|
|
168
204
|
name: 'Glob',
|
|
169
|
-
description: '
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
description: 'Find files by glob pattern (e.g. "**/*.js"). ' +
|
|
206
|
+
'path (optional) — search directory inside the project.',
|
|
207
|
+
parameters: { pattern: 'string', path: 'string?' },
|
|
208
|
+
fn: async ({ pattern, path: searchPath }) => {
|
|
172
209
|
const { glob } = await import('fs/promises');
|
|
210
|
+
const cwd = searchPath ? safe(String(searchPath)) : workdir;
|
|
173
211
|
const results = [];
|
|
174
|
-
for await (const f of glob(
|
|
212
|
+
for await (const f of glob(req(pattern, 'pattern'), { cwd })) {
|
|
175
213
|
// Sandbox: ignore anything that goes outside root.
|
|
176
|
-
const abs = path.resolve(
|
|
214
|
+
const abs = path.resolve(cwd, f);
|
|
177
215
|
const rel = path.relative(root, abs);
|
|
178
216
|
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
179
217
|
continue;
|
|
180
218
|
results.push(f);
|
|
181
219
|
}
|
|
182
|
-
return results.length ? results.join('\n') : '
|
|
220
|
+
return results.length ? results.join('\n') : 'No matches found.';
|
|
183
221
|
},
|
|
184
222
|
},
|
|
185
223
|
{
|
|
186
224
|
name: 'Grep',
|
|
187
|
-
description: '
|
|
188
|
-
|
|
189
|
-
|
|
225
|
+
description: 'Search file contents (regular expression). include — file name ' +
|
|
226
|
+
'filter (e.g. "*.ts"). output: content (default, matching lines with ' +
|
|
227
|
+
'numbers), files_only (paths only), count (match count per file).',
|
|
228
|
+
parameters: {
|
|
229
|
+
pattern: 'string',
|
|
230
|
+
path: 'string?',
|
|
231
|
+
include: 'string?',
|
|
232
|
+
output: 'string?',
|
|
233
|
+
},
|
|
234
|
+
fn: async ({ pattern, path: searchPath, include, output }) => {
|
|
235
|
+
const pat = req(pattern, 'pattern');
|
|
190
236
|
const target = searchPath ? safe(String(searchPath)) : workdir;
|
|
237
|
+
const inc = include ? String(include).trim() : '';
|
|
238
|
+
const mode = String(output || 'content').toLowerCase();
|
|
191
239
|
if (process.platform === 'win32') {
|
|
192
|
-
const escaped =
|
|
193
|
-
const scope = searchPath
|
|
194
|
-
? '"' + target + '\\*'
|
|
195
|
-
: '*';
|
|
240
|
+
const escaped = pat.replace(/"/g, '\\"');
|
|
241
|
+
const scope = searchPath ? '"' + target + '\\*' : '*';
|
|
196
242
|
return runShell(`findstr /s /n /r /c:"${escaped}" ` + scope);
|
|
197
243
|
}
|
|
198
|
-
|
|
244
|
+
const flags = mode === 'files_only'
|
|
245
|
+
? '-rlE'
|
|
246
|
+
: mode === 'count'
|
|
247
|
+
? '-rcE'
|
|
248
|
+
: '-rnE';
|
|
249
|
+
const includeArg = inc ? ' --include=' + JSON.stringify(inc) : '';
|
|
250
|
+
return runShell(`grep ${flags}${includeArg} ${JSON.stringify(pat)} ` +
|
|
251
|
+
`${JSON.stringify(target)} || true`);
|
|
199
252
|
},
|
|
200
253
|
},
|
|
201
254
|
];
|
|
202
255
|
const gitTools = createGitTools(workdir);
|
|
203
256
|
const webTools = createWebTools();
|
|
257
|
+
const extraTools = createExtraTools(workdir, { undo });
|
|
204
258
|
const respondTool = {
|
|
205
259
|
name: 'respond',
|
|
206
|
-
description: '
|
|
260
|
+
description: 'Give the final answer to the user and finish the task.',
|
|
207
261
|
parameters: { message: 'string' },
|
|
208
262
|
fn: async ({ message }) => message,
|
|
209
263
|
};
|
|
210
|
-
return [...baseTools, ...gitTools, ...webTools, respondTool];
|
|
264
|
+
return [...baseTools, ...extraTools, ...gitTools, ...webTools, respondTool];
|
|
211
265
|
}
|
package/dist/web.js
CHANGED
|
@@ -112,9 +112,9 @@ export function createWebTools() {
|
|
|
112
112
|
return [
|
|
113
113
|
{
|
|
114
114
|
name: 'WebFetch',
|
|
115
|
-
description: '
|
|
116
|
-
'
|
|
117
|
-
'
|
|
115
|
+
description: 'Fetch a URL and return the cleaned text of the page (without HTML tags). ' +
|
|
116
|
+
'Use it to read docs, articles, READMEs on GitHub. ' +
|
|
117
|
+
'If the page is rendered by JavaScript and the text is empty — retry with render=true.',
|
|
118
118
|
parameters: {
|
|
119
119
|
url: 'string',
|
|
120
120
|
render: 'boolean?',
|
|
@@ -122,7 +122,7 @@ export function createWebTools() {
|
|
|
122
122
|
},
|
|
123
123
|
fn: async ({ url, render, maxChars }) => {
|
|
124
124
|
if (!/^https?:\/\//i.test(String(url))) {
|
|
125
|
-
return
|
|
125
|
+
return `Error: URL must start with http:// or https://`;
|
|
126
126
|
}
|
|
127
127
|
const limit = Math.min(Number(maxChars) || MAX_TEXT, 60_000);
|
|
128
128
|
try {
|
|
@@ -143,21 +143,21 @@ export function createWebTools() {
|
|
|
143
143
|
if (text.length < 200) {
|
|
144
144
|
return (`HTTP ${r.status} ${r.url}\n` +
|
|
145
145
|
`Content-Type: ${r.contentType}\n\n` +
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
`The page is almost empty in raw HTML (${text.length} chars). ` +
|
|
147
|
+
`It looks like the content is rendered by JavaScript. ` +
|
|
148
|
+
`Retry with render=true.\n\n---\n${text}`);
|
|
149
149
|
}
|
|
150
150
|
return formatResult(r.status, r.url, text, limit);
|
|
151
151
|
}
|
|
152
152
|
catch (e) {
|
|
153
|
-
return
|
|
153
|
+
return `Error fetching ${url}: ${e.message}`;
|
|
154
154
|
}
|
|
155
155
|
},
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
name: 'WebSearch',
|
|
159
|
-
description: '
|
|
160
|
-
'
|
|
159
|
+
description: 'Search the web via DuckDuckGo HTML (no API key). ' +
|
|
160
|
+
'Returns a list of results: title, URL, short description.',
|
|
161
161
|
parameters: {
|
|
162
162
|
query: 'string',
|
|
163
163
|
maxResults: 'number?',
|
|
@@ -165,7 +165,7 @@ export function createWebTools() {
|
|
|
165
165
|
fn: async ({ query, maxResults }) => {
|
|
166
166
|
const q = encodeURIComponent(String(query || ''));
|
|
167
167
|
if (!q)
|
|
168
|
-
return '
|
|
168
|
+
return 'Error: query is empty.';
|
|
169
169
|
const limit = Math.min(Math.max(Number(maxResults) || 8, 1), 20);
|
|
170
170
|
try {
|
|
171
171
|
const r = await httpFetch(`https://html.duckduckgo.com/html/?q=${q}`, {
|
|
@@ -175,7 +175,7 @@ export function createWebTools() {
|
|
|
175
175
|
},
|
|
176
176
|
});
|
|
177
177
|
if (r.status !== 200) {
|
|
178
|
-
return `DuckDuckGo
|
|
178
|
+
return `DuckDuckGo returned HTTP ${r.status}`;
|
|
179
179
|
}
|
|
180
180
|
// We parse with simple regexes. The html.duckduckgo.com format is stable.
|
|
181
181
|
const results = [];
|
|
@@ -194,14 +194,14 @@ export function createWebTools() {
|
|
|
194
194
|
results.push({ title, url: href, snippet });
|
|
195
195
|
}
|
|
196
196
|
if (!results.length) {
|
|
197
|
-
return
|
|
197
|
+
return `No results found. The DuckDuckGo output format may have changed.`;
|
|
198
198
|
}
|
|
199
199
|
const lines = results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}` +
|
|
200
200
|
(r.snippet ? `\n ${r.snippet}` : ''));
|
|
201
201
|
return lines.join('\n\n');
|
|
202
202
|
}
|
|
203
203
|
catch (e) {
|
|
204
|
-
return
|
|
204
|
+
return `Search error: ${e.message}`;
|
|
205
205
|
}
|
|
206
206
|
},
|
|
207
207
|
},
|
|
@@ -215,5 +215,5 @@ function formatResult(status, url, text, limit) {
|
|
|
215
215
|
truncated = true;
|
|
216
216
|
}
|
|
217
217
|
return (`HTTP ${status} ${url}\n\n${out}` +
|
|
218
|
-
(truncated ? `\n\n[
|
|
218
|
+
(truncated ? `\n\n[...truncated at ${limit} chars]` : ''));
|
|
219
219
|
}
|