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 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
@@ -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
- 'останавливаюсь. Ответа модели нет — проверьте чат DeepSeek вручную.');
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
- 'ответь РОВНО одним JSON-объектом вызова инструмента, без текста до и после, ' +
267
- 'например: {"tool": "Bash", "args": {"command": "..."}}. ' +
268
- 'Если задача действительно выполнена — вызови respond с итоговым сообщением.';
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('внимание: tool-call распознан нестрогим парсером');
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('внимание: ответ похож на tool-call, но не распознан (попытка ' +
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
- 'Ответь РОВНО одним JSON-объектом вызова инструмента, без текста до и после. ' +
316
- 'НЕ используй XML/DSML-теги — только JSON. ' +
317
- 'Например: {"tool": "Read", "args": {"path": "src/index.js"}}';
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
- 'одним JSON-объектом вызова: {"tool": "...", "args": {...}}. ' +
348
- 'Если задача выполнена — вызови инструмент respond с итоговым сообщением.';
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
- 'Если задача ещё не выполнена — ответь РОВНО одним JSON-объектом ' +
373
- 'вызова инструмента, без текста до и после. ' +
374
- 'Если задача действительно выполнена — вызови respond с итоговым ' +
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
- 'Задача ещё не завершена. Ответь РОВНО одним JSON-объектом вызова ' +
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
- 'Если задача действительно выполнена — вызови respond с итоговым сообщением.';
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
- 'Последний шаг: вызови инструмент respond с итоговым сообщением ' +
408
- 'оператору. Не пиши обычный текст — только вызов respond, например: ' +
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('внимание: пустой respond, прошу продолжить (попытка ' +
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
- 'Ты вызвал respond с пустым message. Если задача выполнена — ' +
461
- 'вызови respond с итоговым сообщением оператору. Если нет — ' +
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('Модель вызвала respond без текста, и лимит повторов исчерпан. ' +
473
- 'Проверьте чат DeepSeek вручную.');
474
- return 'Модель не сформировала итоговое сообщение (пустой respond).';
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 = `Неизвестный инструмент: ${call.tool}`;
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 = `Ошибка: ${e.message}`;
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(`⏱ Таймаут ${timeout}ms`);
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(`Ошибка: ${err.message}`);
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: 'Показать git status рабочей директории. Возвращает ошибку, если это не git-репозиторий.',
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: 'Показать git diff. По умолчанию незастейдженные изменения. Можно ограничить path и указать staged=true.',
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: 'Показать последние N коммитов (по умолчанию 10).',
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: 'Добавить файлы в индекс. Если paths не задан — git add -A (всё).',
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: 'Закоммитить застейдженное (если ничего не застейджено — сначала git add -A). Не пушит.',
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 'Ошибка: message пустой.';
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
- ? '(индекс уже не пуст — add -A пропущен)'
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: 'Запушить ветку в origin. Требует настроенных креденшелов (SSH или Windows Credential Manager).',
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 'Ошибка: не удалось определить ветку для push.';
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 });
@@ -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}\nПараметры: ${JSON.stringify(t2.parameters)}`)
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(`Доступ за пределы рабочей директории: ${p}`);
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: выход за пределы ' + root + ' запрещён (cd ' + raw + ')');
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(`⏱ Таймаут после ${timeout}ms — процесс убит.`);
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(`Убит сигналом: ${err.signal}`);
70
+ parts.push(`Killed by signal: ${err.signal}`);
66
71
  }
67
72
  else {
68
- parts.push(`Ошибка: ${err.message}`);
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: 'Прочитать содержимое файла. Опционально: offset и limit (строки).',
91
- parameters: { path: 'string', offset: 'number?', limit: 'number?' },
92
- fn: async ({ path: p, offset, limit }) => {
93
- const file = safe(String(p));
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
- return lines.slice(start, end).join('\n');
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: 'Создать или перезаписать файл. content — текст; content_base64 — тот же ' +
104
- 'контент в base64 (используй, если текст содержит , обратные слэши, ' +
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(String(p));
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 `Файл записан: ${p}`;
154
+ return `File written: ${p}`;
119
155
  },
120
156
  },
121
157
  {
122
158
  name: 'Edit',
123
- description: 'Точечная замена строки в файле. old_string должен встречаться один раз. ' +
124
- 'old_base64/new_base64 — те же строки в 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(String(p));
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(`Строка не найдена в ${p}: "${oldStr.slice(0, 60)}..."`);
179
+ throw new Error(`String not found in ${p}: "${oldStr.slice(0, 60)}..."`);
144
180
  }
145
181
  if (occurrences > 1) {
146
- throw new Error(`Строка встречается ${occurrences} раз в ${p}. Уточните old_string.`);
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 `Отредактирован: ${p}`;
188
+ return `Edited: ${p}`;
153
189
  },
154
190
  },
155
191
  {
156
192
  name: 'Bash',
157
- description: 'Выполнить shell-команду в рабочей директории (cmd.exe на Windows, sh на Linux/macOS). ' +
158
- 'Не использовать для long-running процессов (серверы) — уйдут в таймаут. ' +
159
- 'Не использовать для команд, требующих интерактивного ввода. ' +
160
- 'Для git — инструменты Git*. Для интернета — WebFetch / WebSearch.',
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(String(command));
164
- return runShell(String(command), timeout);
199
+ assertCommandInsideRoot(req(command, 'command'));
200
+ return runShell(req(command, 'command'), timeout);
165
201
  },
166
202
  },
167
203
  {
168
204
  name: 'Glob',
169
- description: 'Найти файлы по glob-паттерну (например, "**/*.js").',
170
- parameters: { pattern: 'string' },
171
- fn: async ({ pattern }) => {
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(String(pattern), { cwd: workdir })) {
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(workdir, f);
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
- parameters: { pattern: 'string', path: 'string?' },
189
- fn: async ({ pattern, path: searchPath }) => {
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 = String(pattern).replace(/"/g, '\\"');
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
- return runShell(`grep -rn -E ${JSON.stringify(pattern)} ${JSON.stringify(target)} || true`);
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: 'Загрузить URL и вернуть очищенный текст страницы (без HTML-тегов). ' +
116
- 'Используй для чтения документации, статей, README на GitHub. ' +
117
- 'Если страница рендерится JavaScript-ом и текст пустой — попробуй ещё раз с render=true.',
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 `Ошибка: URL должен начинаться с http:// или https://`;
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
- `Страница почти пустая в сыром HTML (${text.length} символов). ` +
147
- `Похоже, контент рендерится JavaScript-ом. ` +
148
- `Повтори вызов с render=true.\n\n---\n${text}`);
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 `Ошибка загрузки ${url}: ${e.message}`;
153
+ return `Error fetching ${url}: ${e.message}`;
154
154
  }
155
155
  },
156
156
  },
157
157
  {
158
158
  name: 'WebSearch',
159
- description: 'Поиск в интернете через DuckDuckGo HTML (без API-ключа). ' +
160
- 'Возвращает список результатов: заголовок, URL, краткое описание.',
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 'Ошибка: query пустой.';
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 вернул HTTP ${r.status}`;
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 `Результатов не найдено. Возможно, изменился формат выдачи DuckDuckGo.`;
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 `Ошибка поиска: ${e.message}`;
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[...обрезано на ${limit} символах]` : ''));
218
+ (truncated ? `\n\n[...truncated at ${limit} chars]` : ''));
219
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {