zames_pro 2.5.1 → 2.7.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
@@ -60,6 +60,38 @@ zames --help
60
60
  Global config: `~/.zames/config.json`
61
61
  Local (per project): `.zamesrc.json`
62
62
 
63
+ You can view and change settings without leaving the agent — use the
64
+ `/config` command. Run it without arguments to open an interactive menu
65
+ (↑/↓ to move, Enter to change, `d` to reset, `q` to quit). Booleans and enums
66
+ toggle in place; numbers and strings open an input prompt.
67
+
68
+ ```
69
+ /config interactive settings menu
70
+ /config list print all editable settings
71
+ /config get <path> show a setting
72
+ /config set <path> <value> change a setting
73
+ /config reset <path> reset a setting to its default
74
+ /config path show config file paths
75
+ /config lang <ru|en> switch interface and agent language
76
+ ```
77
+
78
+ Examples:
79
+
80
+ ```
81
+ /config set maxIterations 20
82
+ /config set confirmation.bash false
83
+ /config lang en
84
+ ```
85
+
86
+ Changes are written to the project `.zamesrc.json` and applied right away
87
+ (where possible without a restart).
88
+
89
+ ### Language
90
+
91
+ `/config lang ru` or `/config lang en` switches both the interface language
92
+ (help, messages, spinner) and the language the agent answers you in. The
93
+ locale lives in `ui.locale` in the config file.
94
+
63
95
  Agent data is stored in `~/.zames`: browser profile, logs, undo history, self-review snapshots.
64
96
 
65
97
  ## License
@@ -1,12 +1,13 @@
1
1
  import { buildSystemPrompt } from './system-prompt.js';
2
2
  import { getGitContext, formatGitContext } from './gitTools.js';
3
3
  import { parseXmlToolCalls } from './xml-toolcall.js';
4
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, debugLog = false, }) {
4
+ import { translate } from './i18n.js';
5
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
5
6
  if (freshChat) {
6
7
  await browser.newChat();
7
8
  transcript?.log('new_chat');
8
9
  }
9
- // Сообщаем вызывающему актуальный chat id.
10
+ // Report the current chat id to the caller.
10
11
  let lastReportedChatId = null;
11
12
  const reportChat = async () => {
12
13
  let id = null;
@@ -35,13 +36,14 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
35
36
  workdir,
36
37
  tools,
37
38
  gitContext: gitText,
39
+ locale,
38
40
  });
39
41
  transcript?.log('system_prompt', {
40
42
  length: systemPrompt.length,
41
43
  gitContext: gitText,
42
44
  });
43
45
  onThinking();
44
- // system-prompt — отправка агента: с паузой (agent: true).
46
+ // system-prompt is an agent send: throttled (agent: true).
45
47
  await browser.ask(systemPrompt, { timeout: 60_000, agent: true });
46
48
  await reportChat();
47
49
  }
@@ -51,23 +53,24 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
51
53
  const MAX_MALFORMED_RETRIES = 3;
52
54
  let stallRetries = 0;
53
55
  const MAX_STALL_RETRIES = 5;
54
- // Защита от «агент встал»: DeepSeek иногда присылает финальный текст,
55
- // который лишь ОПИСЫВАЕТ следующий вызов инструмента (или рвёт ответ на
56
- // полуслове), и агент молча завершает задачу, хотя работа не сделана.
57
- // Если финальный ответ похож на «сейчас вызову …» — переспрашиваем, а не
58
- // останавливаемся. Счётчик общий, чтобы не зациклиться на болтливой модели.
56
+ // Guard against "the agent stalled": DeepSeek sometimes sends a final text
57
+ // that merely DESCRIBES the next tool call (or cuts the answer off
58
+ // mid-word), and the agent silently finishes the task even though the work
59
+ // is not done. If the final answer looks like "I'll call ... now" — we
60
+ // re-ask instead of stopping. The counter is shared so we don't loop on a
61
+ // chatty model.
59
62
  let looksDoneRetries = 0;
60
63
  const MAX_LOOKSDONE_RETRIES = 3;
61
64
  for (let i = 0; i < maxIterations; i++) {
62
65
  onThinking();
63
- // Первое сообщение (task) — пользовательский ввод: без паузы.
64
- // Последующие (tool-result и просьбы переотправить) — агентские:
65
- // с паузой, чтобы не упираться в лимит частоты.
66
+ // The first message (task) is user input: no throttle.
67
+ // Subsequent ones (tool-result and resend requests) are agent sends:
68
+ // throttled so we don't hit the rate limit.
66
69
  const isFirst = i === 0;
67
70
  const rawResponse = await browser.ask(message, { agent: !isFirst });
68
71
  await reportChat();
69
72
  transcript?.log('assistant_raw', { response: rawResponse });
70
- // Пользователь прервал генерацию (Esc/Ctrl+C).
73
+ // The user aborted generation (Esc/Ctrl+C).
71
74
  if (/^\s*\(прервано пользователем\)\s*$/.test(rawResponse)) {
72
75
  transcript?.log('user_aborted');
73
76
  return rawResponse;
@@ -86,14 +89,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
86
89
  }
87
90
  }
88
91
  if (!parsed) {
89
- // Ответ похож на (возможно, обрезанный) вызов инструмента. Ловим не
90
- // только явный JSON, но и XML/DSML-формы, «грязные» варианты и
91
- // незакрытые фрагменты: если такой ответ молча принять за финальный,
92
- // агент встанет, хотя модель пыталась позвать инструмент.
93
- const looksLikeToolCall = /("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(rawResponse) ||
94
- /\{\s*"?(tool|name|args)"?\s*:/.test(rawResponse) ||
95
- /<\s*\|?\s*(DSML|invoke|parameter)/i.test(rawResponse) ||
96
- /^\s*\[?\s*\{[^}]*$/.test(rawResponse.trim());
92
+ // The answer looks like a (possibly truncated) tool call. We catch not
93
+ // only explicit JSON but also XML/DSML forms, "dirty" variants and
94
+ // unclosed fragments: if such an answer is silently taken as final, the
95
+ // agent stalls even though the model tried to call a tool.
96
+ const looksLikeToolCall = responseLooksLikeToolCall(rawResponse);
97
97
  if (looksLikeToolCall && malformedRetries < MAX_MALFORMED_RETRIES) {
98
98
  malformedRetries++;
99
99
  transcript?.log('malformed_toolcall', {
@@ -115,11 +115,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
115
115
  continue;
116
116
  }
117
117
  const trimmed = (rawResponse || '').trim();
118
- // Служебный ответ — это КОРОТКАЯ заглушка DeepSeek («Reading…») или
119
- // короткое уведомление о лимите. Слова про rate limit в ДЛИННОМ
120
- // ответе — это, как правило, сам агент цитирует код/логи (в транскрипте
121
- // был ровно такой случай: ответ на 1365 символов про ask() и лимиты),
122
- // и принимать его за «служебный» нельзя, иначе агент зря переспрашивает.
118
+ // A service answer is a SHORT DeepSeek placeholder ("Reading…") or a
119
+ // short rate-limit notice. Words about the rate limit in a LONG answer
120
+ // are usually the agent itself quoting code/logs (the transcript had
121
+ // exactly such a case: a 1365-char answer about ask() and limits), and
122
+ // it must not be taken as "service", otherwise the agent re-asks in vain.
123
123
  const looksService = !trimmed ||
124
124
  trimmed.length < 2 ||
125
125
  /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i.test(trimmed) ||
@@ -144,11 +144,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
144
144
  'Если задача выполнена — вызови инструмент respond с итоговым сообщением.';
145
145
  continue;
146
146
  }
147
- // Ответ похож на «сейчас вызову инструмент», но вызова в нём нет.
148
- // DeepSeek иногда так обрывает ход: пишет «Now update README…» или
149
- // «Let me run the tests…» и замолкает. Если принять это за финал,
150
- // агент встаёт, не сделав работу. Просим продолжить и на этот раз
151
- // обязательно вызвать инструмент (или respond, если правда готово).
147
+ // The answer looks like "I'll call a tool now", but contains no call.
148
+ // DeepSeek sometimes cuts the turn like this: writes "Now update
149
+ // README…" or "Let me run the tests…" and goes silent. If this is taken
150
+ // as final, the agent stalls without doing the work. We ask it to
151
+ // continue and to actually call a tool this time (or respond if truly done).
152
152
  if (looksLikeUnfinishedWork(trimmed) && looksDoneRetries < MAX_LOOKSDONE_RETRIES) {
153
153
  looksDoneRetries++;
154
154
  transcript?.log('unfinished_retry', {
@@ -170,6 +170,14 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
170
170
  'сообщением оператору.';
171
171
  continue;
172
172
  }
173
+ // All re-ask attempts are exhausted, yet the answer still looks like a
174
+ // tool call. Most likely this is a silent stall: we show the operator a
175
+ // warning in the terminal (not only in the transcript) so they see the
176
+ // problem immediately instead of wondering why the agent stalled.
177
+ if (responseLooksLikeToolCall(rawResponse)) {
178
+ transcript?.log('suspicious_final', { response: rawResponse });
179
+ onWarning(translate(locale)('msg.suspicious_stop'));
180
+ }
173
181
  onAssistantMessage(rawResponse);
174
182
  transcript?.log('assistant_final', { message: rawResponse });
175
183
  return rawResponse;
@@ -180,9 +188,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
180
188
  const msg = typeof respondCall.args.message === 'string'
181
189
  ? respondCall.args.message
182
190
  : String(respondCall.args.message ?? '');
183
- // Пустой respond — не финал: модель позвала respond, но не написала
184
- // итог. Если так завершить, оператор не увидит ничего, а задача
185
- // «зависнет». Просим продолжить (в пределах stallRetries).
191
+ // An empty respond is not final: the model called respond but wrote no
192
+ // summary. Finishing like this would show the operator nothing and the
193
+ // task would "hang". We ask it to continue (within stallRetries).
186
194
  if (!msg.trim() && stallRetries < MAX_STALL_RETRIES) {
187
195
  stallRetries++;
188
196
  transcript?.log('empty_respond', { attempt: stallRetries });
@@ -245,20 +253,45 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
245
253
  }
246
254
  return 'Достигнут лимит итераций.';
247
255
  }
248
- // Текст, который обещает вызов инструмента в будущем времени, но самого
249
- // вызова не содержит. DeepSeek регулярно так «зависает»: пишет
250
- // «Now update README to mention …», «Let me run the tests», «Сейчас проверю»
251
- // и останавливается. Такие ответы нельзя принимать за финальные — иначе
252
- // агент встаёт, не выполнив работу. Держим эвристику узкой (будущее время /
253
- // намерение), чтобы не ловить обычные отчёты о выполненной работе.
256
+ // The answer looks like a tool call, but parseToolCall() did not recognize it.
257
+ // Used as a safeguard against "the agent called a tool and stopped": in that
258
+ // case runAgentLoop asks the model to resend the call instead of finishing the
259
+ // task. We catch both explicit formats and "broken" call heads
260
+ // (`<|tool": ...`, `**tool**:`, `tool": ...`), and truncated calls.
261
+ export function responseLooksLikeToolCall(rawResponse) {
262
+ const raw = rawResponse || '';
263
+ return (
264
+ // Explicit tool-call format markers: the JSON key "tool", XML/DSML tags,
265
+ // function_call, etc.
266
+ /("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
267
+ // "tool" without an opening quote/bracket, with a junk prefix
268
+ // (`<|tool":`, `**tool**:`, `- tool:`): a call key, not prose.
269
+ /(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
270
+ // Keys in single quotes or unquoted: {'tool': 'Read', ...}.
271
+ /[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
272
+ // Truncated call: starts like a JSON object but is not closed, and has an
273
+ // argument key (args/command/path/...). We require the opening bracket at
274
+ // the start (after spaces/prefix) so we don't catch ordinary prose with
275
+ // colons like "path: ...".
276
+ /^\s*[\[\{]/.test(raw) &&
277
+ /["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw) ||
278
+ /<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
279
+ /^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
280
+ }
281
+ // Text that promises a tool call in the future tense but contains no call
282
+ // itself. DeepSeek regularly "hangs" like this: it writes
283
+ // "Now update README to mention …", "Let me run the tests", "I'll check now"
284
+ // and stops. Such answers must not be taken as final — otherwise the agent
285
+ // stalls without doing the work. We keep the heuristic narrow (future tense /
286
+ // intent) so we don't catch ordinary reports of completed work.
254
287
  function looksLikeUnfinishedWork(text) {
255
288
  const t = (text || '').trim();
256
289
  if (!t)
257
290
  return false;
258
- // Длинные ответы (отчёты) не трогаем — там может быть что угодно.
291
+ // Long answers (reports) are left alone — anything can be in there.
259
292
  if (t.length > 600)
260
293
  return false;
261
- // Уже есть финальный маркер — считаем ответ завершённым.
294
+ // A final marker is already present — treat the answer as complete.
262
295
  if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
263
296
  return false;
264
297
  }
@@ -699,6 +732,82 @@ function findMatching(text, openIdx, openCh, closeCh) {
699
732
  }
700
733
  return -1;
701
734
  }
735
+ // The model sometimes returns a tool call with single-quoted keys/strings
736
+ // ("{'tool': 'Read', 'args': {...}}") or unquoted keys
737
+ // ("{tool: \"Read\", args: {...}}"). This is not valid JSON, and without
738
+ // normalization such an answer is silently taken as final — the agent stalls
739
+ // without calling a tool. We normalize it to double quotes.
740
+ function normalizePseudoJson(str) {
741
+ // Unquoted keys: {tool: ...} or , args: ... → "tool": / "args":
742
+ let out = str.replace(/([\{\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
743
+ out = out.replace(/,\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, ', "$1":');
744
+ // Single quotes → double quotes. We don't touch the content of already
745
+ // double-quoted strings in a row, and escape stray double quotes inside
746
+ // single quotes.
747
+ let res = '';
748
+ let inDouble = false;
749
+ let inSingle = false;
750
+ for (let i = 0; i < out.length; i++) {
751
+ const c = out[i];
752
+ if (c === '\\' && (inDouble || inSingle)) {
753
+ res += c;
754
+ if (i + 1 < out.length) {
755
+ res += out[i + 1];
756
+ i++;
757
+ }
758
+ continue;
759
+ }
760
+ if (c === '"' && !inSingle) {
761
+ inDouble = !inDouble;
762
+ res += c;
763
+ continue;
764
+ }
765
+ if (c === "'" && !inDouble) {
766
+ if (!inSingle) {
767
+ inSingle = true;
768
+ res += '"';
769
+ }
770
+ else {
771
+ inSingle = false;
772
+ res += '"';
773
+ }
774
+ continue;
775
+ }
776
+ if (inSingle && c === '"') {
777
+ res += '\\"';
778
+ continue;
779
+ }
780
+ res += c;
781
+ }
782
+ return res;
783
+ }
784
+ // The model sometimes corrupts the head of a call: `<|tool": "Bash", "args": {...}`,
785
+ // `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. Such answers have no
786
+ // opening `{`, and the `tool` key lost its first quote. If such an answer is
787
+ // taken as final, the agent silently stalls (a frequent "stop").
788
+ // We repair it: trim the junk prefix up to the word tool, add `{` and
789
+ // balance the key quotes.
790
+ function repairToolCallPreamble(text) {
791
+ const t = (text || '').trim();
792
+ const m = t.match(/(?:^|[^A-Za-z0-9_])(?:\*\*)?(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/);
793
+ if (!m || m.index === undefined)
794
+ return null;
795
+ // We look for the start from the first quote/bracket around the key, otherwise from the word tool.
796
+ let start = m.index;
797
+ const brace = t.indexOf('{', Math.max(0, start - 1));
798
+ if (brace !== -1 && brace < start)
799
+ start = brace;
800
+ let frag = t.slice(start);
801
+ // If the fragment does not start with `{` — we add it.
802
+ if (!frag.startsWith('{')) {
803
+ // The key may have lost its opening quote: tool": → "tool".
804
+ // We trim the leading junk up to the word tool and normalize the key quotes.
805
+ frag = frag.replace(/^[^A-Za-z0-9_]*/, '');
806
+ frag = frag.replace(/^(?:\*\*)?(["'`\u2018\u2019\u201c\u201d]*)(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/, '"$2":');
807
+ frag = '{' + frag;
808
+ }
809
+ return frag;
810
+ }
702
811
  export function parseToolCall(text) {
703
812
  if (!text || typeof text !== 'string')
704
813
  return null;
@@ -708,8 +817,23 @@ export function parseToolCall(text) {
708
817
  .replace(/```$/i, '')
709
818
  .trim();
710
819
  const candidates = extractJsonObjects(cleaned);
711
- for (let i = candidates.length - 1; i >= 0; i--) {
820
+ // We collect EVERY recognized call, not just the first one found. The model
821
+ // often emits several separate {"tool": ...} objects in one answer instead of
822
+ // a single JSON array. Returning only one of them used to drop the rest and
823
+ // could leave the agent "stalled after a tool call" with pending work.
824
+ const collected = [];
825
+ const tryCollect = (parsed) => {
826
+ if (!parsed)
827
+ return false;
828
+ if (Array.isArray(parsed))
829
+ collected.push(...parsed);
830
+ else
831
+ collected.push(parsed);
832
+ return true;
833
+ };
834
+ for (let i = 0; i < candidates.length; i++) {
712
835
  const raw = candidates[i];
836
+ // A JSON array of calls is authoritative: if present, use all of it.
713
837
  const arrFirst = tryParseArray(raw);
714
838
  if (arrFirst)
715
839
  return arrFirst;
@@ -717,22 +841,48 @@ export function parseToolCall(text) {
717
841
  if (arrRepaired)
718
842
  return arrRepaired;
719
843
  const first = tryParse(raw);
720
- if (first)
721
- return first;
844
+ if (first) {
845
+ tryCollect(first);
846
+ continue;
847
+ }
722
848
  const repaired = raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\');
723
849
  const second = tryParse(repaired);
724
- if (second)
725
- return second;
850
+ if (second) {
851
+ tryCollect(second);
852
+ continue;
853
+ }
726
854
  const ctrl = repairRawControlChars(raw);
727
855
  const third = tryParse(ctrl);
728
- if (third)
729
- return third;
856
+ if (third) {
857
+ tryCollect(third);
858
+ continue;
859
+ }
730
860
  const ctrlArr = tryParseArray(ctrl);
731
861
  if (ctrlArr)
732
862
  return ctrlArr;
733
863
  }
864
+ if (collected.length === 1)
865
+ return collected[0];
866
+ if (collected.length > 1)
867
+ return collected;
868
+ // Pseudo-JSON (single quotes / unquoted keys) — normalize and try to parse
869
+ // as a regular call before going permissive.
870
+ if (/['"]?tool['"]?\s*:/.test(cleaned)) {
871
+ const norm = normalizePseudoJson(cleaned);
872
+ if (norm !== cleaned) {
873
+ for (const raw of extractJsonObjects(norm)) {
874
+ const a = tryParseArray(raw);
875
+ if (a)
876
+ return a;
877
+ const o = tryParse(raw);
878
+ if (o)
879
+ return o;
880
+ }
881
+ }
882
+ }
734
883
  const permissive = parseToolCallPermissive(cleaned) ||
735
- parseToolCallPermissive(repairRawControlChars(cleaned));
884
+ parseToolCallPermissive(repairRawControlChars(cleaned)) ||
885
+ parseToolCallPermissive(normalizePseudoJson(cleaned));
736
886
  if (permissive)
737
887
  return { ...permissive, _permissive: true };
738
888
  const toolIdx = cleaned.search(/["']?tool["']?\s:/);
@@ -747,6 +897,31 @@ export function parseToolCall(text) {
747
897
  const xmlCalls = parseXmlToolCalls(cleaned);
748
898
  if (xmlCalls)
749
899
  return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls];
900
+ // Last attempt: "fix" a corrupted call head (`<|tool": ...`,
901
+ // `tool": ...`, `**tool**: ...`, `- tool: ...`). We do this ONLY as a
902
+ // fallback, after regular parsing — otherwise it's easy to corrupt valid
903
+ // JSON (e.g. an array of calls starts with `[`, containing `{"tool":`).
904
+ const preamble = repairToolCallPreamble(cleaned);
905
+ if (preamble && preamble !== cleaned) {
906
+ const reps = [
907
+ preamble,
908
+ repairRawControlChars(preamble),
909
+ normalizePseudoJson(preamble),
910
+ ];
911
+ for (const rep of reps) {
912
+ for (const raw of extractJsonObjects(rep)) {
913
+ const a = tryParseArray(raw);
914
+ if (a)
915
+ return a;
916
+ const o = tryParse(raw);
917
+ if (o)
918
+ return o;
919
+ }
920
+ }
921
+ const perm = parseToolCallPermissive(preamble);
922
+ if (perm)
923
+ return { ...perm, _permissive: true };
924
+ }
750
925
  return null;
751
926
  }
752
927
  function extractPreToolText(text) {
package/dist/browser.js CHANGED
@@ -25,10 +25,9 @@ const SEND_SELECTORS = [
25
25
  'button[aria-label*="send" i]',
26
26
  'button[aria-label*="отправ" i]',
27
27
  ];
28
- // ВАЖНО: сюда НЕЛЬЗЯ добавлять общий 'div[role="button"][class*="ds-button--primary"]'
29
- // — под него попадает кнопка отправки, которая видна всегда, и тогда
30
- // _isGenerating() вечно возвращает true, из-за чего ответ никогда не
31
- // считается готовым.
28
+ // IMPORTANT: you MUST NOT add the generic 'div[role="button"][class*="ds-button--primary"]'
29
+ // here — it matches the send button, which is always visible, and then
30
+ // _isGenerating() always returns true, so the answer is never considered ready.
32
31
  const STOP_SELECTORS = [
33
32
  'div[role="button"][aria-label*="stop" i]',
34
33
  'div[role="button"][aria-label*="останов" i]',
@@ -36,16 +35,16 @@ const STOP_SELECTORS = [
36
35
  'button:has-text("Остановить")',
37
36
  'button[aria-label*="Stop" i]',
38
37
  ];
39
- // Служебные статусы интерфейса DeepSeek, которые НЕ являются ответом модели.
40
- // Иначе агент принимает статус (Reading...) за ответ и ломает разбор.
38
+ // DeepSeek UI service statuses that are NOT the model's answer.
39
+ // Otherwise the agent takes a status (Reading...) for an answer and breaks parsing.
41
40
  const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
42
- // Ответ DeepSeek при превышении лимита частоты.
41
+ // DeepSeek's answer when the rate limit is exceeded.
43
42
  const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|повторите позже|try again later)/i;
44
43
  export function isRateLimitText(text) {
45
44
  return RATE_LIMIT_RE.test(String(text || ''));
46
45
  }
47
- // Ошибка «слишком часто»: отличается от прочих, чтобы ask() ждал долго
48
- // (лимиты DeepSeek сбрасываются за минуты) и повторял отправку сам.
46
+ // The "too frequent" error: distinct from others so ask() waits a long time
47
+ // (DeepSeek limits reset over minutes) and retries the send itself.
49
48
  export class RateLimitError extends Error {
50
49
  constructor(detail) {
51
50
  super('Messages too frequent. Try again later. ' + detail);
@@ -100,9 +99,9 @@ export class DeepSeekBrowser {
100
99
  maxRateLimitRetries;
101
100
  _lastSentAt;
102
101
  _abort;
103
- // Пользователь нажал Esc/Ctrl+C — «стоп» для ВСЕЙ текущей пачки задач
104
- // (включая очередь). В отличие от _abort (сбрасывается на каждую
105
- // отправку), этот флаг живёт до явного запуска новой задачи с промпта.
102
+ // The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
103
+ // tasks (including the queue). Unlike _abort (reset on every send), this
104
+ // flag lives until a new task is explicitly started from the prompt.
106
105
  _stopped;
107
106
  context;
108
107
  page;
@@ -284,9 +283,9 @@ export class DeepSeekBrowser {
284
283
  return null;
285
284
  }
286
285
  async _readLastAnswerText() {
287
- // Если удалось перехватить сырой текст ответа по сети (без рендер-
288
- // искажений DeepSeek) и он относится к текущему ответу — отдаём его.
289
- // Это защищает $, экранированные переводы строк и т.п. в аргументах.
286
+ // If we managed to intercept the raw answer text over the network
287
+ // (without DeepSeek's render distortions) and it belongs to the current
288
+ // answer — we return it. This protects $, escaped newlines, etc. in arguments.
290
289
  if (this._netCapture && this._netCaptureAt >= this._lastSentAt) {
291
290
  return this._netCapture;
292
291
  }
@@ -304,9 +303,9 @@ export class DeepSeekBrowser {
304
303
  return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
305
304
  }, ANSWER_SELECTORS);
306
305
  }
307
- // Читаем ТОЛЬКО видимые тосты/уведомления/ошибки, а не весь текст
308
- // страницы. Иначе ловим «try again later» из служебных/скрытых блоков
309
- // и уходим в ложное ожидание лимита на 5 минут.
306
+ // We read ONLY visible toasts/notifications/errors, not the whole page
307
+ // text. Otherwise we catch "try again later" from service/hidden blocks
308
+ // and go into a false 5-minute rate-limit wait.
310
309
  async _readPageText() {
311
310
  return await this.page
312
311
  .evaluate(() => {
@@ -340,9 +339,9 @@ export class DeepSeekBrowser {
340
339
  return '';
341
340
  return raw;
342
341
  }
343
- // Найти кнопку Stop в интерфейсе DeepSeek. Полагаться только на класс
344
- // нельзя: во время генерации кнопка отправки (та же circle-кнопка)
345
- // меняет иконку на «квадрат» (stop), сохраняя классы.
342
+ // Find the Stop button in the DeepSeek UI. We can't rely on the class
343
+ // alone: during generation the send button (the same circle button)
344
+ // changes its icon to a "square" (stop) while keeping the classes.
346
345
  async _stopButtonVisible() {
347
346
  const explicit = await this._findVisible(STOP_SELECTORS, 250);
348
347
  if (explicit)
@@ -361,7 +360,7 @@ export class DeepSeekBrowser {
361
360
  (b.textContent || '')).toLowerCase();
362
361
  if (/stop|останов/.test(label))
363
362
  return true;
364
- // Иконка-квадрат = кнопка Stop; стрелка (path без rect) = отправка.
363
+ // A square icon = Stop button; an arrow (path without rect) = send.
365
364
  const svg = b.querySelector('svg');
366
365
  if (svg && svg.querySelector('rect'))
367
366
  return true;
@@ -420,9 +419,9 @@ export class DeepSeekBrowser {
420
419
  }
421
420
  catch (e) {
422
421
  lastErr = e;
423
- // Лимит частоты: DeepSeek не принял сообщение. Ждём долго и
424
- // повторяем отправку в ТОТ ЖЕ чат (без newChat — иначе теряется
425
- // контекст). Паузы не расходуют обычные попытки ask().
422
+ // Rate limit: DeepSeek did not accept the message. We wait a long time
423
+ // and resend into the SAME chat (without newChat — otherwise the
424
+ // context is lost). The waits don't consume the regular ask() attempts.
426
425
  if (e instanceof RateLimitError) {
427
426
  rateLimitRetries++;
428
427
  if (rateLimitRetries > this.maxRateLimitRetries) {
@@ -495,9 +494,9 @@ export class DeepSeekBrowser {
495
494
  await this.page.keyboard.insertText(text);
496
495
  }
497
496
  }
498
- // Пауза между отправками. Применяется ТОЛЬКО к сообщениям агента
499
- // (tool-result, system-prompt), чтобы не упираться в лимит частоты.
500
- // Пользовательский ввод отправляется без задержки.
497
+ // Pause between sends. Applied ONLY to agent messages
498
+ // (tool-result, system-prompt) so we don't hit the rate limit.
499
+ // User input is sent without delay.
501
500
  async _waitForSendSlot(agent) {
502
501
  if (!agent)
503
502
  return;
@@ -506,11 +505,11 @@ export class DeepSeekBrowser {
506
505
  const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
507
506
  if (gap <= 0)
508
507
  return;
509
- console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой...`));
508
+ console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
510
509
  await this.page.waitForTimeout(gap);
511
510
  }
512
511
  async _askOnce(prompt, { timeout, agent }) {
513
- // Сбрасываем флаг прерывания ТОЛЬКО в самом начале отправки.
512
+ // We reset the abort flag ONLY at the very start of the send.
514
513
  this._abort = false;
515
514
  const input = await this._findVisible(INPUT_SELECTORS, 10_000);
516
515
  if (!input) {
@@ -540,9 +539,9 @@ export class DeepSeekBrowser {
540
539
  await this.page.keyboard.press('Enter');
541
540
  }
542
541
  this._lastSentAt = Date.now();
543
- // Ждём старта: либо появился Stop, либо изменился текст ответа,
544
- // либо вырос общий объём текста на странице. Параллельно ловим
545
- // тост о превышении лимита частоты (только тосты, не весь body).
542
+ // Wait for the start: either Stop appeared, or the answer text changed,
543
+ // or the total amount of text on the page grew. In parallel we catch
544
+ // the rate-limit toast (only toasts, not the whole body).
546
545
  const startDeadline = Date.now() + 15_000;
547
546
  const startBodyLen = await this.page
548
547
  .evaluate(() => document.body.innerText.length)
@@ -567,15 +566,15 @@ export class DeepSeekBrowser {
567
566
  await this.page.waitForTimeout(300);
568
567
  }
569
568
  if (!started) {
570
- // Больше НЕ проверяем лимит по всему тексту страницы — это давало
571
- // ложные срабатывания и 5-минутные паузы. Просто сообщаем, что
572
- // генерация не началась.
569
+ // We NO LONGER check the limit over the whole page text — that caused
570
+ // false positives and 5-minute waits. We just report that
571
+ // generation did not start.
573
572
  throw new Error('Ответ не начал генерироваться за 15с. Возможно, сообщение не отправилось.');
574
573
  }
575
- // Ждём, пока ответ перестанет меняться. Условие "не генерируется"
576
- // проверяем через рост текста, а НЕ через _isGenerating().
577
- // Параллельно ловим тост лимита частоты, если он всплывёт во время
578
- // генерации (читаем только тосты, ложных срабатываний нет).
574
+ // Wait until the answer stops changing. We check the "not generating"
575
+ // condition via text growth, NOT via _isGenerating().
576
+ // In parallel we catch the rate-limit toast if it pops up during
577
+ // generation (we read only toasts, so there are no false positives).
579
578
  const deadline = Date.now() + timeout;
580
579
  let last = '';
581
580
  let stable = 0;
@@ -584,8 +583,8 @@ export class DeepSeekBrowser {
584
583
  if (this._abort) {
585
584
  return last || '(прервано пользователем)';
586
585
  }
587
- // Тост лимита проверяем не каждый тик, а раз в ~5 тиков, чтобы не
588
- // дёргать DOM лишний раз.
586
+ // We check the limit toast not every tick but about once per 5 ticks,
587
+ // so we don't poke the DOM unnecessarily.
589
588
  if (tick++ % 5 === 0) {
590
589
  const pageText = await this._readPageText();
591
590
  if (isRateLimitText(pageText)) {