zames_pro 2.81.23 → 2.82.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.82.2] - 2026-10-22
11
+
12
+ ### Fixed
13
+
14
+ - **banner:** a zero content width still renders the real fields
15
+
16
+ ## [2.82.1] - 2026-10-22
17
+
18
+ ### Fixed
19
+
20
+ - **shell:** an empty command answers instead of throwing
21
+
22
+ ## [2.82.0] - 2026-10-22
23
+
24
+ ### Fixed
25
+
26
+ - **compact:** recover from an exhausted chat context on every provider
27
+
10
28
  ## [2.81.23] - 2026-10-22
11
29
 
12
30
  ### Fixed
@@ -1,4 +1,5 @@
1
1
  import { buildSystemPrompt } from './system-prompt.js';
2
+ import { isContextLimitText } from './browser-pure.js';
2
3
  import { providerLabel as providerHumanLabel } from './providers.js';
3
4
  import { loadProjectContext } from './context.js';
4
5
  import { getGitContext, formatGitContext } from './gitTools.js';
@@ -7,7 +8,7 @@ import { substituteAttachmentMarkers } from './commands.js';
7
8
  import { loadHooks, runPreToolUse, runPostToolUse, runLifecycleHooks, } from './hooks.js';
8
9
  import { loadPermissions, decidePermission, } from './permissions.js';
9
10
  import { shouldRunDiagnostics, runDiagnostics, } from './diagnostics.js';
10
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, onAskUser = null, }) {
11
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, onContextLimit = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, onAskUser = null, }) {
11
12
  // Resolve the hook config ONCE per task: a read per tool call would be
12
13
  // wasteful, and a mid-task edit of hooks.json is not something to chase.
13
14
  // `undefined` means "read .zames/hooks.json"; an explicit null disables
@@ -48,6 +49,15 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
48
49
  // editor's repaint, so the operator only saw the spinner. `onNotice` prints
49
50
  // the notice ABOVE the input line.
50
51
  browser.onNotice = safe(onNotice);
52
+ // The chat ran out of context and refuses further sends ("Length limit
53
+ // reached. Please start a new chat."). The browser cannot recover by itself:
54
+ // compaction needs the system prompt, the tools and the session bookkeeping
55
+ // that live above it, so the callback is attached here, where the caller's
56
+ // hook lives (the same seam pattern as onAutoCompact).
57
+ if ('onContextLimit' in browser) {
58
+ ;
59
+ browser.onContextLimit = onContextLimit;
60
+ }
51
61
  const safeAssistantThought = safe(onAssistantThought);
52
62
  const safeToolCall = safe(onToolCall);
53
63
  const safeToolResult = safe(onToolResult);
@@ -494,6 +504,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
494
504
  // tool-call and not a real final answer — treat it as a service
495
505
  // answer so the loop re-asks instead of stopping on it.
496
506
  /<ds_safety>|<\/ds_safety>/i.test(trimmed) ||
507
+ // An exhausted-context notice can also arrive as the turn's TEXT (it
508
+ // is short and carries no tool call). Treat it as a service answer so
509
+ // the loop re-asks instead of finishing on "start a new chat".
510
+ isContextLimitText(trimmed) ||
497
511
  (trimmed.length <= 200 &&
498
512
  /(messages? too frequent|too many requests|rate limit|server (is )?busy|service (is )?unavailable|слишком часто|try again later)/i.test(trimmed));
499
513
  if (looksService && stallRetries < MAX_STALL_RETRIES) {
package/dist/banner.js CHANGED
@@ -14,7 +14,14 @@ function width(s) {
14
14
  * longer than the inner width are truncated with an ellipsis.
15
15
  */
16
16
  function box(lines, maxWidth, border = '#c8b06a') {
17
- const inner = Math.max(10, Math.min(maxWidth - 4, Math.max(...lines.map(width)) + 2));
17
+ // A caller may pass 0 ("auto") or a tiny width on a narrow terminal. With
18
+ // `maxWidth - 4` going negative the inner width collapsed to the 10-column
19
+ // floor while every content line was clipped to `inner - 2`, so the title,
20
+ // version and directory were replaced by a bare `…` — the banner was drawn
21
+ // but said nothing. Fall back to a width that still fits the real fields.
22
+ const widest = Math.max(...lines.map(width)) + 2;
23
+ const base = Number.isFinite(maxWidth) && maxWidth >= 12 ? maxWidth : widest + 4;
24
+ const inner = Math.max(10, Math.min(base - 4, widest));
18
25
  const top = theme.prompt('╭' + '─'.repeat(inner) + '╮');
19
26
  const bottom = theme.prompt('╰' + '─'.repeat(inner) + '╯');
20
27
  const body = lines.map((l) => {
@@ -2,7 +2,7 @@ import path from 'path';
2
2
  import os from 'os';
3
3
  import fs from 'fs/promises';
4
4
  import { chromium } from 'playwright';
5
- import { GENERATION_ERR_RE, GENERATION_TIMEOUT_RE, INCOMPLETE_STATUS_RE, RATE_LIMIT_RE, SERVER_BUSY_RE, STATUS_RE, } from './deepseek-ui.js';
5
+ import { CONTEXT_LIMIT_RE, GENERATION_ERR_RE, GENERATION_TIMEOUT_RE, INCOMPLETE_STATUS_RE, RATE_LIMIT_RE, SERVER_BUSY_RE, STATUS_RE, } from './deepseek-ui.js';
6
6
  // Drop service placeholders ("Reading…", "Thinking…") so they are not mistaken
7
7
  // for an answer; keep everything else as-is (including the whitespace the
8
8
  // tool-call relies on). Pure: no `this`, no page.
@@ -283,6 +283,13 @@ export function isGenerationIncompleteText(text) {
283
283
  export function isRateLimitText(text) {
284
284
  return RATE_LIMIT_RE.test(String(text || ''));
285
285
  }
286
+ // The chat ran out of context: DeepSeek answers "Length limit reached. Please
287
+ // start a new chat." and will refuse every further send into this chat. Distinct
288
+ // from the rate limit (which clears with time) and from a server hiccup — the
289
+ // only recovery is compaction, so ask() must not retry in the same chat.
290
+ export function isContextLimitText(text) {
291
+ return CONTEXT_LIMIT_RE.test(String(text || ''));
292
+ }
286
293
  export function isServerBusyText(text) {
287
294
  const t = String(text || '');
288
295
  // Rate limit takes priority (it needs the long wait).
@@ -313,6 +320,16 @@ export class ServerBusyError extends Error {
313
320
  this.name = 'ServerBusyError';
314
321
  }
315
322
  }
323
+ // The chat's context window is exhausted ("Length limit reached. Please start
324
+ // a new chat."). The server rejects EVERY send into this chat, so a retry in
325
+ // place is pointless: the caller must compact (open a NEW chat with a handover
326
+ // summary) and only then resend.
327
+ export class ContextLimitError extends Error {
328
+ constructor(detail) {
329
+ super('Chat context limit reached. ' + detail);
330
+ this.name = 'ContextLimitError';
331
+ }
332
+ }
316
333
  // The generation was truncated by the server (`generation_err` / INCOMPLETE).
317
334
  // Distinct from ServerBusyError: the DeepSeek backend answered, but the turn
318
335
  // did not finish, and the UI offers a "Continue" button. The agent retries the
package/dist/browser.js CHANGED
@@ -2,6 +2,7 @@ import { chromium, } from 'playwright';
2
2
  import { interactiveBrowserLogin, loginIsInteractive, canShowWindow, } from './browser-login.js';
3
3
  import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, isFinishedWithAnswer, } from './net-capture.js';
4
4
  import { rewriteFailedAttachments } from './commands.js';
5
+ import { recoverFromContextLimit } from './context-limit.js';
5
6
  import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
6
7
  import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
7
8
  import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, MFA_CODE_SELECTORS, MFA_SUBMIT_SELECTORS, MFA_SEND_CODE_SELECTORS, MFA_SEND_CODE_RE, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, ANSWER_URL_RE, } from './deepseek-ui.js';
@@ -21,8 +22,8 @@ export { STOP_NAME_RE, CONTINUE_NAME_RE };
21
22
  // imported for the class below and RE-EXPORTED here, so the public API of this
22
23
  // module is unchanged (tests and other modules import these names from
23
24
  // './browser.js').
24
- import { askPassword, USER_DATA_DIR, parseHeadlessUACache, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, headlessUACacheReady, sanitizeHeadlessUA, writeAuthMarker, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, cleanAnswer, RateLimitError, ServerBusyError, GenerationIncompleteError, cleanSingletonFiles, profileLooksLocked, nukeProfile, } from './browser-pure.js';
25
- export { askPassword, parseHeadlessUACache, headlessUACacheReady, sanitizeHeadlessUA, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, };
25
+ import { askPassword, USER_DATA_DIR, parseHeadlessUACache, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, headlessUACacheReady, sanitizeHeadlessUA, writeAuthMarker, isGenerationIncompleteText, isRateLimitText, isServerBusyText, isContextLimitText, normText, cleanAnswer, RateLimitError, ServerBusyError, GenerationIncompleteError, ContextLimitError, cleanSingletonFiles, profileLooksLocked, nukeProfile, } from './browser-pure.js';
26
+ export { askPassword, parseHeadlessUACache, headlessUACacheReady, sanitizeHeadlessUA, isGenerationIncompleteText, isRateLimitText, isServerBusyText, isContextLimitText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, ContextLimitError, };
26
27
  export class DeepSeekBrowser {
27
28
  providerName = 'deepseek';
28
29
  headless;
@@ -40,6 +41,11 @@ export class DeepSeekBrowser {
40
41
  rateLimitWaitMs;
41
42
  maxRateLimitRetries;
42
43
  maxServerBusyRetries;
44
+ // Ask() budget for the context-limit recovery. A compaction that does not
45
+ // free the window leaves the chat just as rejected as before, so the
46
+ // recovery is capped independently of askRetries.
47
+ maxContextLimitRetries;
48
+ onContextLimit;
43
49
  serverBusyWaitMs;
44
50
  // Retries for a turn the server truncated (generation_err / INCOMPLETE).
45
51
  maxIncompleteRetries;
@@ -168,7 +174,7 @@ export class DeepSeekBrowser {
168
174
  _windowClosed;
169
175
  /** Session cookies captured before a relaunch (no `expires` -> in-memory). */
170
176
  _carryCookies;
171
- constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, thinkingExtraMs = 2000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, autoContinue = true, continueMinGapMs = 1500, loginTimeoutMs = 600000, deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, onMfaPrompt, userAgent, } = {}) {
177
+ constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, thinkingExtraMs = 2000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, autoContinue = true, continueMinGapMs = 1500, loginTimeoutMs = 600000, deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, onMfaPrompt, userAgent, } = {}) {
172
178
  this.headless = headless;
173
179
  this.debug = debug;
174
180
  this.channel = channel;
@@ -184,6 +190,8 @@ export class DeepSeekBrowser {
184
190
  this.serverBusyWaitMs = serverBusyWaitMs;
185
191
  this.maxIncompleteRetries = maxIncompleteRetries;
186
192
  this.incompleteWaitMs = incompleteWaitMs;
193
+ this.maxContextLimitRetries = maxContextLimitRetries;
194
+ this.onContextLimit = onContextLimit || null;
187
195
  this.autoContinue = autoContinue;
188
196
  this.continueMinGapMs = continueMinGapMs;
189
197
  this.loginTimeoutMs = loginTimeoutMs;
@@ -1594,6 +1602,10 @@ export class DeepSeekBrowser {
1594
1602
  // hiccup, not a failed send, and it is more likely in the reasoning
1595
1603
  // (deep thinking) mode, where the long THINK phase gets cut off.
1596
1604
  let incompleteRetries = 0;
1605
+ // Bumped when the chat's context window is exhausted. Recovery is a
1606
+ // COMPACTION (a new chat), not a resend, so it has its own budget: a
1607
+ // compaction that frees nothing leaves the chat just as rejected.
1608
+ let contextLimitRetries = 0;
1597
1609
  // Mark that OUR code has an ask in flight. A completion/continue body
1598
1610
  // that arrives with _askInFlight === 0 was the operator's own turn, and
1599
1611
  // _onResponse uses that to bump _lastSentAt so the next agent send waits
@@ -1693,6 +1705,34 @@ export class DeepSeekBrowser {
1693
1705
  return '(прервано пользователем)';
1694
1706
  continue;
1695
1707
  }
1708
+ // The chat's context window is exhausted: every further send into
1709
+ // this chat is refused ("Length limit reached. Please start a new
1710
+ // chat."). Retrying here is useless — the caller compacts the chat
1711
+ // and opens a fresh one, then the SAME prompt is resent there. The
1712
+ // resend does not consume a regular ask() attempt.
1713
+ if (e instanceof ContextLimitError) {
1714
+ contextLimitRetries++;
1715
+ const res = await recoverFromContextLimit({
1716
+ recover: () => this.onContextLimit?.() ?? Promise.resolve(false),
1717
+ maxRetries: this.maxContextLimitRetries,
1718
+ attempt: contextLimitRetries,
1719
+ notice: (text) => this._notice(text),
1720
+ t: (k, p) => this._t(k, p),
1721
+ keys: {
1722
+ compact: 'ds.context_limit_compact',
1723
+ compactFailed: 'ds.context_limit_compact_failed',
1724
+ giveUp: 'ds.context_limit_give_up',
1725
+ },
1726
+ provider: 'DeepSeek',
1727
+ }, e);
1728
+ if (!res.resend)
1729
+ throw res.error || e;
1730
+ // The compaction has already typed into the new chat, so the send
1731
+ // throttle must count from that moment. Leaving `_lastSentAt` at
1732
+ // the failed send's time adds nothing: that send never went out.
1733
+ this._lastSentAt = Date.now();
1734
+ continue;
1735
+ }
1696
1736
  this._notice(theme.warn(this._t('ds.ask_retry', {
1697
1737
  attempt: attempt + 1,
1698
1738
  max: this.askRetries,
@@ -2085,6 +2125,12 @@ export class DeepSeekBrowser {
2085
2125
  if (this._abort || this._askCancelled)
2086
2126
  return '(прервано пользователем)';
2087
2127
  const pageText = await this._readPageText();
2128
+ // Checked BEFORE the rate limit and the server hiccup: DeepSeek shows the
2129
+ // context notice INSTEAD of an answer, and both of those branches would
2130
+ // retry the same send into a chat that can never accept it again.
2131
+ if (isContextLimitText(pageText)) {
2132
+ throw new ContextLimitError(pageText.slice(0, 300));
2133
+ }
2088
2134
  if (isRateLimitText(pageText)) {
2089
2135
  throw new RateLimitError(pageText.slice(0, 300));
2090
2136
  }
@@ -2315,6 +2361,12 @@ export class DeepSeekBrowser {
2315
2361
  if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
2316
2362
  throw new RateLimitError(this._netBody.slice(0, 300) || 'rate limit (from network)');
2317
2363
  }
2364
+ // The exhausted context can likewise be visible only in the captured
2365
+ // body rather than in a toast, so ask() must see it here as well.
2366
+ if (this._netCaptureAt >= this._lastSentAt &&
2367
+ isContextLimitText(this._netBody)) {
2368
+ throw new ContextLimitError(this._netBody.slice(0, 300));
2369
+ }
2318
2370
  // The server truncated the turn (generation_err / INCOMPLETE) or ended
2319
2371
  // it with only reasoning. PREFER the Continue button: it RESUMES the
2320
2372
  // same turn (`chat/continue`). Resending the prompt creates a NEW turn
package/dist/compact.js CHANGED
@@ -15,7 +15,7 @@ export const COMPACT_FALLBACK_LIMIT = 40;
15
15
  * The caller owns the browser and the throttle; this function only sends.
16
16
  */
17
17
  export async function performCompact(opts) {
18
- const { browser, workdir, locale, task, transcript = null, ui = null, buildSystemPrompt, tools, answerTimeoutMs, fallbackLimit = COMPACT_FALLBACK_LIMIT, skipUiLock = false, quiet = false, selfImprovement = false, } = opts;
18
+ const { browser, workdir, locale, task, transcript = null, ui = null, buildSystemPrompt, tools, answerTimeoutMs, fallbackLimit = COMPACT_FALLBACK_LIMIT, skipUiLock = false, quiet = false, selfImprovement = false, contextExhausted = false, } = opts;
19
19
  const t = translate(locale);
20
20
  const log = (msg) => {
21
21
  if (!quiet)
@@ -51,10 +51,13 @@ export async function performCompact(opts) {
51
51
  transcript?.log('hook_pre_compact', { output: out });
52
52
  }
53
53
  catch { }
54
- // 1) Ask the OLD chat to summarize itself.
54
+ // 1) Ask the OLD chat to summarize itself. On an EXHAUSTED chat that send
55
+ // is refused ("Length limit reached"), so go straight to the local
56
+ // fallback below — retrying the refused send wastes minutes on the rate
57
+ // limit and can never produce a summary.
55
58
  let summary = '';
56
59
  let summaryErr = '';
57
- for (let attempt = 1; attempt <= COMPACT_SUMMARY_ATTEMPTS; attempt++) {
60
+ for (let attempt = 1; !contextExhausted && attempt <= COMPACT_SUMMARY_ATTEMPTS; attempt++) {
58
61
  let prompt = buildCompactPrompt(locale);
59
62
  if (attempt > 1) {
60
63
  prompt +=
@@ -132,15 +135,33 @@ export async function performCompact(opts) {
132
135
  beforeTokens,
133
136
  });
134
137
  // 2) New chat + system prompt + the summary as the first message.
135
- await browser.newChat();
136
- await browser.ask(buildSystemPrompt({ workdir, tools, locale, selfImprovement }), {
137
- timeout: 60_000,
138
- agent: false,
139
- });
140
- await browser.ask(buildCompactCarryover(summary, task), {
141
- timeout: 60_000,
142
- agent: false,
143
- });
138
+ // The recovery path (contextExhausted) is RE-ENTRANT: this compaction was
139
+ // started BY a refused send, so a refusal during these sends would call
140
+ // back into compaction while it is still running. Drop the callback for
141
+ // the duration — by now the exhausted chat is behind us and a refusal here
142
+ // means the compaction itself cannot proceed, which the caller reports.
143
+ const prevOnContextLimit = browser.onContextLimit;
144
+ if (contextExhausted && 'onContextLimit' in browser) {
145
+ ;
146
+ browser.onContextLimit = null;
147
+ }
148
+ try {
149
+ await browser.newChat();
150
+ await browser.ask(buildSystemPrompt({ workdir, tools, locale, selfImprovement }), {
151
+ timeout: 60_000,
152
+ agent: false,
153
+ });
154
+ await browser.ask(buildCompactCarryover(summary, task), {
155
+ timeout: 60_000,
156
+ agent: false,
157
+ });
158
+ }
159
+ finally {
160
+ if (contextExhausted && 'onContextLimit' in browser) {
161
+ ;
162
+ browser.onContextLimit = prevOnContextLimit || null;
163
+ }
164
+ }
144
165
  const newChatId = browser.getCurrentChatId
145
166
  ? await browser.getCurrentChatId()
146
167
  : null;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Run ONE context-limit recovery. The caller resends only on `resend: true`;
3
+ * on `resend: false` it throws `error`, which is the ORIGINAL failure so the
4
+ * operator keeps the real reason (a wrapper would hide the server text).
5
+ */
6
+ export async function recoverFromContextLimit(deps, original) {
7
+ const { recover, maxRetries, attempt, notice, t, keys, provider } = deps;
8
+ const giveUp = () => {
9
+ notice(t(keys.giveUp, { provider }));
10
+ return { resend: false, error: original };
11
+ };
12
+ if (!recover || attempt > maxRetries)
13
+ return giveUp();
14
+ notice(t(keys.compact, { provider }));
15
+ let recovered = false;
16
+ try {
17
+ recovered = await recover();
18
+ }
19
+ catch (e) {
20
+ notice(t(keys.compactFailed, { error: e.message }));
21
+ }
22
+ if (!recovered)
23
+ return giveUp();
24
+ return { resend: true };
25
+ }
@@ -243,6 +243,16 @@ export const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate lim
243
243
  // rate limit, these usually clear in seconds, so we retry quickly instead of
244
244
  // waiting minutes.
245
245
  export const SERVER_BUSY_RE = /(server (is )?busy|server error|service (is )?unavailable|temporarily unavailable|internal server error|502|503|504|server overloaded|сервер занят|сервер перегружен|сервис недоступен|внутренняя ошибка|попробуйте позже)/i;
246
+ // The chat's CONTEXT WINDOW is exhausted. DeepSeek refuses to continue the
247
+ // conversation and answers with a non-retryable notice instead of a model
248
+ // turn: "Length limit reached. Please start a new chat." (the Russian UI:
249
+ // «Достигнут лимит длины» / «начните новый чат»). Retrying the same send can
250
+ // never succeed and every such attempt burned the askRetries budget, after
251
+ // which the agent stopped with a bare error — the operator had to notice the
252
+ // notice in the chat by hand. The only way out is compaction (a NEW chat with
253
+ // a handover summary), so this must be its own error class, not a rate limit
254
+ // or a server hiccup.
255
+ export const CONTEXT_LIMIT_RE = /(length limit (?:has been )?reached|context (?:window|length) (?:is )?(?:exceeded|full)|maximum context|start a new (?:chat|conversation)|достигнут лимит длин|лимит длин[ыа] (?:достигнут|исчерпан)|начните новый чат|начните нов(ый|ую) (диалог|бесед))/i;
246
256
  // ---------- network protocol (SSE) ----------
247
257
  export const GENERATION_ERR_RE = /"finish_reason":\s*"generation_err"/i;
248
258
  // The server gave up on the turn ("Server busy, please try again later."):
@@ -21,6 +21,7 @@ import os from 'os';
21
21
  import { chromium } from 'playwright';
22
22
  import { translate, DEFAULT_LOCALE } from './i18n.js';
23
23
  import { rewriteFailedAttachments } from './commands.js';
24
+ import { recoverFromContextLimit } from './context-limit.js';
24
25
  import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
25
26
  import { theme } from './theme.js';
26
27
  import { scrapeGlmChatList, scrapeGlmChatMessages, parseGlmChatList, parseGlmHistory, } from './glm-chats.js';
@@ -28,7 +29,7 @@ import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadle
28
29
  import { readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, readInputTrimmedTextInDom, isElementEnabledInDom, setBusyTitleInDom, chatSignalInDom, } from './browser-dom.js';
29
30
  import { probeUiHealth, } from './ui-health.js';
30
31
  import { CHAT_URL, LOGIN_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, UPLOAD_BUTTON_SELECTOR, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODELS_URL, MODEL_TRIGGER_SELECTOR, modelIdFromTriggerId, CHAT_LIST_URL, chatHistoryUrl, AUTH_URL, ANSWER_URL_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, USER_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, } from './glm-ui.js';
31
- import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isQuotaExhaustedText, isServerBusyText, isGuestLimitText, isCaptchaText, extractChatId, } from './glm-net-capture.js';
32
+ import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isQuotaExhaustedText, isServerBusyText, isGuestLimitText, isContextLimitText, isCaptchaText, extractChatId, } from './glm-net-capture.js';
32
33
  import { SendRuntime } from './send-runtime.js';
33
34
  import { readGlmBodyFeatures, unsafeGlmModes, glmFlagsMismatch, } from './glm-flags.js';
34
35
  import { interactiveBrowserLogin, loginIsInteractive, canShowWindow, } from './browser-login.js';
@@ -56,6 +57,9 @@ export class GlmBrowser {
56
57
  serverBusyWaitMs;
57
58
  maxIncompleteRetries;
58
59
  incompleteWaitMs;
60
+ // Ask() budget for the context-limit recovery (a compaction, not a resend).
61
+ maxContextLimitRetries;
62
+ onContextLimit;
59
63
  startWaitMs;
60
64
  sendButtonWaitMs;
61
65
  model;
@@ -81,6 +85,9 @@ export class GlmBrowser {
81
85
  _netRateLimited;
82
86
  _netGuestLimit;
83
87
  _netServerBusy;
88
+ // True when the chat context window is exhausted: it refuses every
89
+ // further send, so ask() must compact into a fresh chat, not retry here.
90
+ _netContextLimit;
84
91
  _netCaptcha;
85
92
  _netChatId;
86
93
  _lastTokenUsage;
@@ -112,7 +119,7 @@ export class GlmBrowser {
112
119
  * removes most of the wait. Empty = leave the site's default.
113
120
  */
114
121
  reasoningEffort;
115
- constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 300000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 0, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = false, auth, locale, onAuthSave, onLoginPrompt, disableAgentRuntime = true, reasoningEffort = 'low', sendTelemetry = true, logsDir, loginTimeoutMs = 600000, userAgent, } = {}) {
122
+ constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 300000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 0, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = false, auth, locale, onAuthSave, onLoginPrompt, disableAgentRuntime = true, reasoningEffort = 'low', sendTelemetry = true, logsDir, loginTimeoutMs = 600000, userAgent, } = {}) {
116
123
  this.headless = headless;
117
124
  this.debug = debug;
118
125
  this.channel = channel;
@@ -127,6 +134,8 @@ export class GlmBrowser {
127
134
  this.serverBusyWaitMs = serverBusyWaitMs;
128
135
  this.maxIncompleteRetries = maxIncompleteRetries;
129
136
  this.incompleteWaitMs = incompleteWaitMs;
137
+ this.maxContextLimitRetries = maxContextLimitRetries;
138
+ this.onContextLimit = onContextLimit || null;
130
139
  this.startWaitMs = startWaitMs;
131
140
  this.sendButtonWaitMs = sendButtonWaitMs;
132
141
  this.model = model;
@@ -157,6 +166,7 @@ export class GlmBrowser {
157
166
  this._netRateLimited = false;
158
167
  this._netGuestLimit = false;
159
168
  this._netServerBusy = false;
169
+ this._netContextLimit = false;
160
170
  this._netCaptcha = false;
161
171
  this._netChatId = null;
162
172
  this._lastTokenUsage = null;
@@ -838,6 +848,10 @@ export class GlmBrowser {
838
848
  if (isServerBusyText(body)) {
839
849
  this._netServerBusy = true;
840
850
  }
851
+ if (isContextLimitText(body)) {
852
+ this._netContextLimit = true;
853
+ this._netCapture = '';
854
+ }
841
855
  this._netCaptureAt = Date.now();
842
856
  }
843
857
  }
@@ -1364,6 +1378,9 @@ export class GlmBrowser {
1364
1378
  let rateLimitRetries = 0;
1365
1379
  let serverBusyRetries = 0;
1366
1380
  let incompleteRetries = 0;
1381
+ // The exhausted-context recovery is a COMPACTION (a new chat), not a
1382
+ // resend, so it has its own budget.
1383
+ let contextLimitRetries = 0;
1367
1384
  this._askInFlight++;
1368
1385
  try {
1369
1386
  while (attempt < this.askRetries) {
@@ -1390,6 +1407,28 @@ export class GlmBrowser {
1390
1407
  this.sendRuntime.onOutcome('rate_limit', { errorText: 'quota' });
1391
1408
  throw new Error(this._t('glm.quota_exhausted'));
1392
1409
  }
1410
+ // Checked BEFORE the rate-limit/captcha branches below: those
1411
+ // retry IN PLACE, which an exhausted chat can never accept.
1412
+ if (e instanceof GlmContextLimitError) {
1413
+ contextLimitRetries++;
1414
+ const res = await recoverFromContextLimit({
1415
+ recover: () => this.onContextLimit?.() ?? Promise.resolve(false),
1416
+ maxRetries: this.maxContextLimitRetries,
1417
+ attempt: contextLimitRetries,
1418
+ notice: (text) => this._notice(text),
1419
+ t: (k, p) => this._t(k, p),
1420
+ keys: {
1421
+ compact: 'glm.context_limit_compact',
1422
+ compactFailed: 'glm.context_limit_compact_failed',
1423
+ giveUp: 'glm.context_limit_give_up',
1424
+ },
1425
+ provider: 'GLM',
1426
+ }, e);
1427
+ if (!res.resend)
1428
+ throw res.error || e;
1429
+ this._lastSentAt = Date.now();
1430
+ continue;
1431
+ }
1393
1432
  if (e instanceof GlmCaptchaError) {
1394
1433
  this.sendRuntime.onOutcome('guest_limit', { errorText: 'captcha' });
1395
1434
  throw new Error(this._t('glm.captcha'));
@@ -1573,6 +1612,7 @@ export class GlmBrowser {
1573
1612
  this._netRateLimited = false;
1574
1613
  this._netGuestLimit = false;
1575
1614
  this._netServerBusy = false;
1615
+ this._netContextLimit = false;
1576
1616
  this._netCaptcha = false;
1577
1617
  this._netBody = '';
1578
1618
  await this._applyToggles();
@@ -1656,6 +1696,9 @@ export class GlmBrowser {
1656
1696
  if (isRateLimitText(pageText)) {
1657
1697
  throw new GlmRateLimitError(pageText.slice(0, 300));
1658
1698
  }
1699
+ if (this._netContextLimit && this._netCaptureAt >= this._lastSentAt) {
1700
+ throw new GlmContextLimitError(this._netBody.slice(0, 300));
1701
+ }
1659
1702
  if (this._netCaptcha && this._netCaptureAt >= this._lastSentAt) {
1660
1703
  throw new GlmCaptchaError(this._netBody.slice(0, 200));
1661
1704
  }
@@ -1691,6 +1734,9 @@ export class GlmBrowser {
1691
1734
  if (isRateLimitText(pageText)) {
1692
1735
  throw new GlmRateLimitError(pageText.slice(0, 300));
1693
1736
  }
1737
+ if (this._netContextLimit && this._netCaptureAt >= this._lastSentAt) {
1738
+ throw new GlmContextLimitError(this._netBody.slice(0, 300));
1739
+ }
1694
1740
  if (this._netCaptcha && this._netCaptureAt >= this._lastSentAt) {
1695
1741
  throw new GlmCaptchaError(this._netBody.slice(0, 200));
1696
1742
  }
@@ -1756,6 +1802,14 @@ class GlmCaptchaError extends Error {
1756
1802
  this.name = 'GlmCaptchaError';
1757
1803
  }
1758
1804
  }
1805
+ // The chat context window is exhausted and the turn was refused. NOT
1806
+ // retryable in place: ask() compacts into a fresh chat and resends there.
1807
+ class GlmContextLimitError extends Error {
1808
+ constructor(detail) {
1809
+ super('context limit: ' + detail);
1810
+ this.name = 'GlmContextLimitError';
1811
+ }
1812
+ }
1759
1813
  class GlmIncompleteError extends Error {
1760
1814
  constructor(detail) {
1761
1815
  super('generation incomplete: ' + detail);
@@ -345,3 +345,23 @@ export function extractChatId(body) {
345
345
  const m2 = b.match(/"id"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/i);
346
346
  return m2 ? m2[1] : null;
347
347
  }
348
+ /**
349
+ * True when the RAW body says the chat's CONTEXT WINDOW is exhausted - the
350
+ * same hard failure as DeepSeek's length limit: every further send into that
351
+ * chat is refused, so the only recovery is a compaction into a fresh chat.
352
+ *
353
+ * Same guard as the other z.ai classifiers: the notice must be an ERROR
354
+ * payload (or carry no answer text). The model can legitimately WRITE about
355
+ * context limits, and treating such an answer as a refusal loses a good turn.
356
+ */
357
+ export function isContextLimitText(body) {
358
+ const b = String(body || '');
359
+ if (!b)
360
+ return false;
361
+ const explicit = /context (?:length|window) (?:is )?(?:exceeded|too long|limit)|maximum context length|exceeds? the (?:maximum |model'?s )?context|too many (?:input )?tokens|length limit (?:has been )?reached|start a new (?:chat|conversation)|лимит длин|превышен[ао]? (?:максимальн[аяу]? )?(?:длина|контекст)|начните новый чат/i.test(b);
362
+ if (!explicit)
363
+ return false;
364
+ if (!ERROR_PAYLOAD_RE.test(b) && hasAnswerContent(b))
365
+ return false;
366
+ return true;
367
+ }
@@ -56,6 +56,18 @@ export const deepseekMessages = {
56
56
  ru: '✖ Не удалось получить ответ от {provider} после {max} попыток: {error}',
57
57
  en: '✖ Could not get an answer from {provider} after {max} attempts: {error}',
58
58
  },
59
+ 'ds.context_limit_compact': {
60
+ ru: '🗜️ {provider}: лимит длины чата исчерпан («Length limit reached») — сжимаю историю и продолжаю в новом чате...',
61
+ en: '🗜️ {provider}: the chat hit its length limit ("Length limit reached") — compacting the history and continuing in a fresh chat...',
62
+ },
63
+ 'ds.context_limit_compact_failed': {
64
+ ru: '⚠ Не удалось сжать историю: {error}',
65
+ en: '⚠ Could not compact the history: {error}',
66
+ },
67
+ 'ds.context_limit_give_up': {
68
+ ru: '✖ {provider}: лимит длины чата исчерпан, и сжать историю не удалось — этот чат больше не принимает сообщения. Открой новый чат (/new) и повтори задачу.',
69
+ en: '✖ {provider}: the chat length limit is reached and the history could not be compacted — this chat no longer accepts messages. Open a new chat (/new) and repeat the task.',
70
+ },
59
71
  'ds.input_missing': {
60
72
  ru: '✖ Не найдено поле ввода на странице {provider}. Запусти /debug-dom и поправь INPUT_SELECTORS.',
61
73
  en: '✖ The {provider} input field was not found. Run /debug-dom and fix INPUT_SELECTORS.',
package/dist/i18n/glm.js CHANGED
@@ -43,6 +43,18 @@ export const glmMessages = {
43
43
  ru: '⚠ Отправка не удалась (попытка {attempt}/{max} на этот запрос): {error} — повторяю...',
44
44
  en: '⚠ Send failed (attempt {attempt}/{max} for this request): {error} — retrying...',
45
45
  },
46
+ 'glm.context_limit_compact': {
47
+ ru: '🗜️ {provider}: лимит длины чата исчерпан — сжимаю историю и продолжаю в новом чате...',
48
+ en: '🗜️ {provider}: the chat hit its context limit — compacting the history and continuing in a fresh chat...',
49
+ },
50
+ 'glm.context_limit_compact_failed': {
51
+ ru: '⚠ Не удалось сжать историю: {error}',
52
+ en: '⚠ Could not compact the history: {error}',
53
+ },
54
+ 'glm.context_limit_give_up': {
55
+ ru: '✖ {provider}: лимит длины чата исчерпан, и сжать историю не удалось. Открой новый чат (/new) и повтори задачу.',
56
+ en: '✖ {provider}: the chat context limit is reached and the history could not be compacted. Open a new chat (/new) and repeat the task.',
57
+ },
46
58
  'glm.ask_failed': {
47
59
  ru: '✖ Не удалось получить ответ от {provider} после {max} попыток: {error}',
48
60
  en: '✖ Could not get an answer from {provider} after {max} attempts: {error}',
package/dist/i18n/qwen.js CHANGED
@@ -145,6 +145,18 @@ export const qwenMessages = {
145
145
  ru: '⚠ Отправка не удалась (попытка {attempt}/{max} на этот запрос): {error} — повторяю...',
146
146
  en: '⚠ Send failed (attempt {attempt}/{max} for this request): {error} — retrying...',
147
147
  },
148
+ 'qwen.context_limit_compact': {
149
+ ru: '🗜️ {provider}: лимит длины чата исчерпан — сжимаю историю и продолжаю в новом чате...',
150
+ en: '🗜️ {provider}: the chat hit its context limit — compacting the history and continuing in a fresh chat...',
151
+ },
152
+ 'qwen.context_limit_compact_failed': {
153
+ ru: '⚠ Не удалось сжать историю: {error}',
154
+ en: '⚠ Could not compact the history: {error}',
155
+ },
156
+ 'qwen.context_limit_give_up': {
157
+ ru: '✖ {provider}: лимит длины чата исчерпан, и сжать историю не удалось. Открой новый чат (/new) и повтори задачу.',
158
+ en: '✖ {provider}: the chat context limit is reached and the history could not be compacted. Open a new chat (/new) and repeat the task.',
159
+ },
148
160
  'qwen.ask_failed': {
149
161
  ru: '✖ Не удалось получить ответ от {provider} после {max} попыток: {error}',
150
162
  en: '✖ Could not get an answer from {provider} after {max} attempts: {error}',
package/dist/index.js CHANGED
@@ -797,7 +797,7 @@ function applyModelContextLimit(model) {
797
797
  * the choice is a class, not a flag; the shared timings live in
798
798
  * config.browser and only the provider's own section is overridden.
799
799
  */
800
- function createProviderBrowser(provider) {
800
+ function createProviderBrowser(provider, onContextLimit = null) {
801
801
  // Saved credentials go into the ACTIVE provider's section: browser.auth
802
802
  // belongs to DeepSeek (so old configs keep working), qwen.auth / glm.auth
803
803
  // are their own. Sharing one pair meant a DeepSeek login was typed into the
@@ -840,6 +840,7 @@ function createProviderBrowser(provider) {
840
840
  loginTimeoutMs: config.browser.loginTimeoutMs,
841
841
  locale: currentLocale,
842
842
  onAuthSave: persistAuth,
843
+ onContextLimit,
843
844
  });
844
845
  case 'glm':
845
846
  return new GlmBrowser({
@@ -860,6 +861,7 @@ function createProviderBrowser(provider) {
860
861
  loginTimeoutMs: config.browser.loginTimeoutMs,
861
862
  locale: currentLocale,
862
863
  onAuthSave: persistAuth,
864
+ onContextLimit,
863
865
  });
864
866
  case 'deepseek':
865
867
  default:
@@ -870,6 +872,7 @@ function createProviderBrowser(provider) {
870
872
  ...config.browser,
871
873
  locale: currentLocale,
872
874
  onAuthSave: persistAuth,
875
+ onContextLimit,
873
876
  });
874
877
  }
875
878
  }
@@ -1061,7 +1064,15 @@ async function main() {
1061
1064
  // `let`, not `const`: a live /provider switch disposes this instance and
1062
1065
  // builds the other one IN PLACE (see the /provider block in the main loop),
1063
1066
  // so the URL changes without restarting zames.
1064
- let browser = createProviderBrowser(asProviderId(config.provider));
1067
+ // The context-limit recovery is a closure over main() session state, but
1068
+ // the browser is built BEFORE that state exists and it needs the callback
1069
+ // at construction. This holder breaks the cycle: the factory reads it
1070
+ // lazily on every recovery, and it is filled right after the session
1071
+ // variables are set up (before the first task). Null = the provider fails
1072
+ // with a clear message instead of retrying into a dead chat.
1073
+ let contextLimitRecovery = null;
1074
+ const onContextLimit = () => contextLimitRecovery ? contextLimitRecovery() : Promise.resolve(false);
1075
+ let browser = createProviderBrowser(asProviderId(config.provider), onContextLimit);
1065
1076
  const bootSpinner = mod.createSpinner(currentLocale);
1066
1077
  bootSpinner.thinking();
1067
1078
  try {
@@ -1188,6 +1199,44 @@ async function main() {
1188
1199
  }
1189
1200
  let freshChatNext = true;
1190
1201
  let sendSystemPromptNext = true;
1202
+ /**
1203
+ * Recover from "Length limit reached. Please start a new chat.": the chat
1204
+ * refuses EVERY further send, so the only way to keep working is compaction
1205
+ * into a fresh chat. Returns true when a new chat holds the system prompt
1206
+ * and the handover, i.e. the browser may resend the refused message there.
1207
+ *
1208
+ * `contextExhausted` skips the summary call inside performCompact: asking the
1209
+ * OLD chat to summarize itself is refused exactly like the send that failed,
1210
+ * so the summary is built from the history endpoint instead.
1211
+ */
1212
+ const recoverFromContextLimit = async () => {
1213
+ const res = await performCompact({
1214
+ browser,
1215
+ currentChatId,
1216
+ workdir: currentWorkdir,
1217
+ locale: currentLocale,
1218
+ transcript,
1219
+ ui: editor,
1220
+ buildSystemPrompt: mod.buildSystemPrompt,
1221
+ tools: mod.createTools(currentWorkdir, { undo, todos: todoStore }),
1222
+ answerTimeoutMs: providerAnswerTimeoutMs(),
1223
+ quiet: true,
1224
+ skipUiLock: true,
1225
+ selfImprovement: devMode,
1226
+ contextExhausted: true,
1227
+ });
1228
+ if (!res.ok)
1229
+ return false;
1230
+ currentChatId = res.chatId;
1231
+ if (currentChatId)
1232
+ saveLastChat(currentChatId, currentWorkdir);
1233
+ // The fresh chat already carries the system prompt and the handover, and
1234
+ // the session continues in it — later tasks must not open yet another chat.
1235
+ freshChatNext = false;
1236
+ sendSystemPromptNext = false;
1237
+ return true;
1238
+ };
1239
+ contextLimitRecovery = recoverFromContextLimit;
1191
1240
  // Resuming an existing chat already has the system-prompt at its start, so
1192
1241
  // resending it is wasteful and pollutes the context. Default: do NOT resend.
1193
1242
  // `--resend-prompt` or `browser.resendPromptOnResume` forces it (e.g. when
@@ -2688,7 +2737,7 @@ async function main() {
2688
2737
  // If we closed the old browser before the new one launched and the
2689
2738
  // launch failed, `browser` would point at a dead instance and every
2690
2739
  // later command would break.
2691
- const nextBrowser = createProviderBrowser(arg);
2740
+ const nextBrowser = createProviderBrowser(arg, onContextLimit);
2692
2741
  // Wire the Ink login BEFORE waitForLogin(), or the new backend would
2693
2742
  // fall back to readline and pause the TUI for its sign-in.
2694
2743
  wireLoginCallbacks(nextBrowser);
@@ -4144,6 +4193,7 @@ async function main() {
4144
4193
  return res.ok ? res.chatId : null;
4145
4194
  }
4146
4195
  : null,
4196
+ onContextLimit: recoverFromContextLimit,
4147
4197
  autoCompactPct: config.browser.autoCompactPct,
4148
4198
  contextLimit: effectiveContextLimit(),
4149
4199
  getTokenUsage: () => browser.getLastTokenUsage(),
@@ -24,12 +24,13 @@ import { rewriteFailedAttachments } from './commands.js';
24
24
  import { interactiveBrowserLogin, loginIsInteractive, canShowWindow, } from './browser-login.js';
25
25
  import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
26
26
  import { theme } from './theme.js';
27
+ import { recoverFromContextLimit } from './context-limit.js';
27
28
  import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
28
29
  import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, writeAuthMarker, } from './browser-pure.js';
29
30
  import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
30
31
  import { probeUiHealth, } from './ui-health.js';
31
32
  import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, AGE_GATE_YEAR_SELECT, AGE_GATE_YEAR_OPTION, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
32
- import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, isServerBusyText, } from './qwen-net-capture.js';
33
+ import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, isContextLimitText, isServerBusyText, } from './qwen-net-capture.js';
33
34
  // A SEPARATE profile from DeepSeek's ~/.zames/profile. Two persistent Chromium
34
35
  // contexts on ONE --user-data-dir cannot coexist (SingletonLock), and keeping
35
36
  // Qwen's cookies apart also means switching providers never logs the other
@@ -104,6 +105,10 @@ export class QwenBrowser {
104
105
  // body is the reliable signal. ask() then backs off instead of waiting out
105
106
  // the whole answer deadline.
106
107
  _netServerBusy;
108
+ // True when the chat's CONTEXT WINDOW is exhausted. The chat refuses every
109
+ // further send, so this is NOT retryable in place: the caller compacts into
110
+ // a fresh chat (onContextLimit) and ask() resends there.
111
+ _netContextLimit;
107
112
  _netChatId;
108
113
  _lastTokenUsage;
109
114
  _toggles;
@@ -126,7 +131,12 @@ export class QwenBrowser {
126
131
  loginTimeoutMs;
127
132
  _windowClosed;
128
133
  _carryCookies;
129
- constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, loginTimeoutMs = 600000, userAgent, } = {}) {
134
+ // Ask() budget for the context-limit recovery. A compaction that frees
135
+ // nothing leaves the chat just as rejected, so this is independent of
136
+ // askRetries.
137
+ maxContextLimitRetries;
138
+ onContextLimit;
139
+ constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, loginTimeoutMs = 600000, userAgent, } = {}) {
130
140
  this.headless = headless;
131
141
  this.debug = debug;
132
142
  this.channel = channel;
@@ -141,6 +151,8 @@ export class QwenBrowser {
141
151
  this.serverBusyWaitMs = serverBusyWaitMs;
142
152
  this.maxIncompleteRetries = maxIncompleteRetries;
143
153
  this.incompleteWaitMs = incompleteWaitMs;
154
+ this.maxContextLimitRetries = maxContextLimitRetries;
155
+ this.onContextLimit = onContextLimit || null;
144
156
  this.startWaitMs = startWaitMs;
145
157
  this.sendButtonWaitMs = sendButtonWaitMs;
146
158
  this.model = model;
@@ -167,6 +179,7 @@ export class QwenBrowser {
167
179
  this._netRateLimited = false;
168
180
  this._netGuestLimit = false;
169
181
  this._netServerBusy = false;
182
+ this._netContextLimit = false;
170
183
  this._netChatId = null;
171
184
  this._lastTokenUsage = null;
172
185
  this._toggles = { deepThinking, webSearch };
@@ -455,6 +468,10 @@ export class QwenBrowser {
455
468
  this._netNoAnswer = true;
456
469
  this._netCapture = '';
457
470
  }
471
+ if (isContextLimitText(body)) {
472
+ this._netContextLimit = true;
473
+ this._netCapture = '';
474
+ }
458
475
  if (isRateLimitText(body)) {
459
476
  this._netRateLimited = true;
460
477
  this._netCapture = '';
@@ -1604,6 +1621,9 @@ export class QwenBrowser {
1604
1621
  let rateLimitRetries = 0;
1605
1622
  let serverBusyRetries = 0;
1606
1623
  let incompleteRetries = 0;
1624
+ // The exhausted-context recovery is a COMPACTION (a new chat), not a
1625
+ // resend, so it has its own budget.
1626
+ let contextLimitRetries = 0;
1607
1627
  this._askInFlight++;
1608
1628
  try {
1609
1629
  while (attempt < this.askRetries) {
@@ -1631,6 +1651,32 @@ export class QwenBrowser {
1631
1651
  // clear "sign in" message instead of burning the retry budget.
1632
1652
  throw new Error(this._t('qwen.age_gate_block'));
1633
1653
  }
1654
+ // The chat ran out of context: it refuses every further send, so
1655
+ // resending in place can never succeed. The injected callback
1656
+ // compacts the chat into a fresh one and the SAME prompt is
1657
+ // resent there.
1658
+ if (e instanceof QwenContextLimitError) {
1659
+ contextLimitRetries++;
1660
+ const res = await recoverFromContextLimit({
1661
+ recover: () => this.onContextLimit?.() ?? Promise.resolve(false),
1662
+ maxRetries: this.maxContextLimitRetries,
1663
+ attempt: contextLimitRetries,
1664
+ notice: (text) => this._notice(text),
1665
+ t: (k, p) => this._t(k, p),
1666
+ keys: {
1667
+ compact: 'qwen.context_limit_compact',
1668
+ compactFailed: 'qwen.context_limit_compact_failed',
1669
+ giveUp: 'qwen.context_limit_give_up',
1670
+ },
1671
+ provider: 'Qwen',
1672
+ }, e);
1673
+ if (!res.resend)
1674
+ throw res.error || e;
1675
+ // The compaction already typed into the new chat, so the send
1676
+ // throttle must count from that moment.
1677
+ this._lastSentAt = Date.now();
1678
+ continue;
1679
+ }
1634
1680
  if (/rate limit|too frequent/i.test(msg)) {
1635
1681
  if (rateLimitRetries >= this.maxRateLimitRetries)
1636
1682
  break;
@@ -1830,6 +1876,7 @@ export class QwenBrowser {
1830
1876
  this._netRateLimited = false;
1831
1877
  this._netGuestLimit = false;
1832
1878
  this._netServerBusy = false;
1879
+ this._netContextLimit = false;
1833
1880
  this._netBody = '';
1834
1881
  await this._applyToggles();
1835
1882
  if (attachments.length) {
@@ -1937,6 +1984,9 @@ export class QwenBrowser {
1937
1984
  if (isRateLimitText(pageText)) {
1938
1985
  throw new QwenRateLimitError(pageText.slice(0, 300));
1939
1986
  }
1987
+ if (this._netContextLimit && this._netCaptureAt >= this._lastSentAt) {
1988
+ throw new QwenContextLimitError(this._netBody.slice(0, 300));
1989
+ }
1940
1990
  if (this._netGuestLimit && this._netCaptureAt >= this._lastSentAt) {
1941
1991
  throw new QwenGuestLimitError(this._netBody.slice(0, 200));
1942
1992
  }
@@ -1971,6 +2021,9 @@ export class QwenBrowser {
1971
2021
  if (isRateLimitText(pageText)) {
1972
2022
  throw new QwenRateLimitError(pageText.slice(0, 300));
1973
2023
  }
2024
+ if (this._netContextLimit && this._netCaptureAt >= this._lastSentAt) {
2025
+ throw new QwenContextLimitError(this._netBody.slice(0, 300));
2026
+ }
1974
2027
  if (this._netGuestLimit && this._netCaptureAt >= this._lastSentAt) {
1975
2028
  throw new QwenGuestLimitError(this._netBody.slice(0, 200));
1976
2029
  }
@@ -2033,6 +2086,14 @@ class QwenAgeGateError extends Error {
2033
2086
  this.name = 'QwenAgeGateError';
2034
2087
  }
2035
2088
  }
2089
+ // The chat hit its CONTEXT LIMIT and refuses the turn. NOT retryable in
2090
+ // place: ask() compacts into a fresh chat and resends the prompt there.
2091
+ class QwenContextLimitError extends Error {
2092
+ constructor(detail) {
2093
+ super('context limit: ' + detail);
2094
+ this.name = 'QwenContextLimitError';
2095
+ }
2096
+ }
2036
2097
  class QwenIncompleteError extends Error {
2037
2098
  constructor(detail) {
2038
2099
  super('generation incomplete: ' + detail);
@@ -283,3 +283,21 @@ export function isServerBusyText(body) {
283
283
  return false;
284
284
  return /server is busy|service unavailable|try again later/i.test(b);
285
285
  }
286
+ // The chat's CONTEXT WINDOW is exhausted. Qwen refuses the turn and no retry
287
+ // in the same chat can succeed - the only way out is a compaction into a
288
+ // fresh chat. Same guard as the other classifiers: the notice must be an
289
+ // error payload or a body with NO answer content, because the model can
290
+ // legitimately WRITE about context limits and treating that answer as a
291
+ // refusal would lose a good turn.
292
+ export function isContextLimitText(body) {
293
+ const b = String(body || '');
294
+ if (!b)
295
+ return false;
296
+ const explicit = /context (?:length|window) (?:is )?(?:exceeded|too long|limit)|maximum context length|exceeds? the (?:maximum |model'?s )?context|too many (?:input )?tokens|length limit (?:has been )?reached|start a new (?:chat|conversation)|лимит длин|превышен[ао]? (?:максимальн[аяу]? )?(?:длина|контекст)|начните новый чат/i.test(b);
297
+ if (!explicit)
298
+ return false;
299
+ const errNotice = /event:\s*error|"type"\s*:\s*"error"|"error"\s*:/i.test(b);
300
+ if (!errNotice && hasAnswerContent(b))
301
+ return false;
302
+ return true;
303
+ }
package/dist/run-task.js CHANGED
@@ -284,7 +284,7 @@ async function evaluateGoal(browser, condition, config, transcript) {
284
284
  }
285
285
  export async function runTask(deps, browser, tools, taskText, workdir, opts, attachments = []) {
286
286
  const { t, locale, debug, maxIter, config, runAgentLoop, createSpinner } = deps;
287
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, goalIsCondition = false, onGoalCleared = null, goalMaxTurns = GOAL_MAX_TURNS, goalQuery = null, todosQuery = null, selfImprovement = false, onSubagent = null, onAskUser = undefined, } = opts;
287
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, onContextLimit = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, goalIsCondition = false, onGoalCleared = null, goalMaxTurns = GOAL_MAX_TURNS, goalQuery = null, todosQuery = null, selfImprovement = false, onSubagent = null, onAskUser = undefined, } = opts;
288
288
  // The ACTIVE goal for the duration of this run. It is mutable because a
289
289
  // completion goal can be cleared mid-run (verdict MET/IMPOSSIBLE, or the
290
290
  // operator runs `/goal clear` while the task is in flight). `goalQuery`
@@ -411,6 +411,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
411
411
  // outer currentChatId; it serializes with the throttle because the
412
412
  // loop awaits it.
413
413
  onAutoCompact,
414
+ onContextLimit,
414
415
  autoCompactPct,
415
416
  contextLimit,
416
417
  getTokenUsage,
package/dist/shell.js CHANGED
@@ -105,6 +105,16 @@ export function runShell(workdir, command, timeout = 30_000, signal) {
105
105
  const safeTimeout = typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0
106
106
  ? Math.floor(timeout)
107
107
  : 30_000;
108
+ // An EMPTY command is rejected by exec SYNCHRONOUSLY
109
+ // (`ERR_INVALID_ARG_VALUE: The argument 'file' cannot be empty`), so the
110
+ // throw escaped the promise below and the caller crashed instead of
111
+ // reporting the problem: the Bash tool after req() accepted a
112
+ // whitespace-only command, and the operator's `!` escape never passed
113
+ // through req() at all. Answer with the same wording an empty successful
114
+ // run uses, so every caller just prints it.
115
+ if (!String(command ?? '').trim()) {
116
+ return Promise.resolve('(command produced no output)');
117
+ }
108
118
  return new Promise((resolve) => {
109
119
  const options = {
110
120
  cwd: workdir,
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  import { useSyncExternalStore } from 'react';
13
13
  import { Box, Text, useWindowSize, useInput } from 'ink';
14
14
  import { LogList } from './LogView.js';
15
- import { windowByRows, totalRows } from './log-window.js';
15
+ import { windowByRows, totalRows, entryRows } from './log-window.js';
16
16
  import { StatusBar } from './StatusBar.js';
17
17
  import { InputLine } from './InputLine.js';
18
18
  import { contentWidth } from '../width.js';
@@ -75,10 +75,22 @@ export function FullScreenApp({ store, inputProps, }) {
75
75
  // it half empty. `scroll` is measured in ROWS here (0 = newest at the
76
76
  // bottom).
77
77
  const all = state.log;
78
+ // The viewport height is RESERVED with the floor (never more rows than the
79
+ // pane can hold) and the box is allowed to take the slack: rounding the
80
+ // reservation UP on every entry made the panel taller than the terminal on a
81
+ // short window, so the input rows fell below the last screen row and the
82
+ // caret drifted under the box while a long multi-line buffer was typed.
83
+ const totalFloor = totalRows(all, cols, 'floor');
78
84
  const total = totalRows(all, cols);
79
85
  const maxScroll = Math.max(0, total - logHeight);
80
86
  const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
81
87
  const visible = windowByRows(all, logHeight, scroll, cols);
88
+ // Rows the WINDOWED content really needs. The reservation above rounds DOWN
89
+ // per entry (floor), so the difference is the slack the content needs. The
90
+ // box takes at most `logHeight` and at least one row; never more, because
91
+ // that is exactly what pushed the input rows off a short screen.
92
+ const filledRows = visible.reduce((n, e) => n + entryRows(e, cols), 0);
93
+ const logBoxHeight = Math.max(1, Math.min(logHeight, Math.max(filledRows, 1)));
82
94
  // Scroll the log with PgUp/PgDn; Home/End jump to the very top/bottom. The
83
95
  // input line OWNS Home/End while its buffer is non-empty (they move the
84
96
  // caret), so the jump only applies to an EMPTY buffer — which is exactly
@@ -111,9 +123,17 @@ export function FullScreenApp({ store, inputProps, }) {
111
123
  const next = cur + delta * 3;
112
124
  store.set({ scroll: Math.max(0, Math.min(maxScroll, next)) });
113
125
  }, state.mouseScroll);
114
- return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", height: logHeight, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: "#5b616e", children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
126
+ return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: "#5b616e", children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
115
127
  // Cursor rows above the input box: header + log viewport + status.
116
- cursorTop: HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked }), _jsx(Text, { color: "#5b616e", wrap: "truncate", children: ' ' +
128
+ // The log pane is FLEXIBLE now, so the panel below it starts right
129
+ // after everything above: header + as many log rows as fit + the
130
+ // suggestion popup + status. `cursorTop` counts the rows ABOVE the
131
+ // input rows; the box border row is added by InputLine.
132
+ cursorTop: HEADER_ROWS +
133
+ Math.max(1, termRows - panelRows) +
134
+ suggestRows +
135
+ STATUS_ROWS +
136
+ 1, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked }), _jsx(Text, { color: "#5b616e", wrap: "truncate", children: ' ' +
117
137
  translate(state.locale)(state.mouseScroll ? 'editor.keys_hint' : 'editor.keys_hint_select') })] }));
118
138
  }
119
139
  void contentWidth;
@@ -41,21 +41,30 @@ function visWidth(s) {
41
41
  * How many terminal rows one log entry occupies at `cols` columns. A line
42
42
  * longer than the width wraps (Ink uses hard wrapping for these rows). An
43
43
  * empty line still takes one row.
44
+ *
45
+ * `floor` gives the UNDER-estimate used to RESERVE the log viewport height;
46
+ * the exact `ceil` is what the content really renders, so the two together let
47
+ * the log box take the slack (see FullScreenApp) without ever overflowing —
48
+ * an overflow is what pushed the input rows below the terminal and left the
49
+ * caret drifting under the box on a short terminal.
44
50
  */
45
- export function entryRows(entry, cols) {
51
+ export function entryRows(entry, cols, mode = 'exact') {
46
52
  const width = Math.max(1, cols);
47
53
  const lines = String(entry.text ?? '').split(String.fromCharCode(10));
48
54
  let rows = 0;
49
55
  for (const line of lines) {
50
- rows += Math.max(1, Math.ceil(visWidth(line) / width));
56
+ const w = visWidth(line);
57
+ const n = mode === 'floor' ? Math.floor(w / width) : Math.ceil(w / width);
58
+ rows += Math.max(1, n);
51
59
  }
52
60
  return Math.max(1, rows);
53
61
  }
54
62
  /**
55
63
  * Slice the log so its RENDERED height fits `height` rows, keeping `scroll`
56
64
  * rows hidden from the bottom (0 = pinned to the newest). Returns the visible
57
- * entries. A single entry taller than the viewport is kept whole (its tail is
58
- * shown) rather than dropped.
65
+ * entries. A single entry taller than the viewport is kept whole (its tail
66
+ * is shown) rather than dropped — the pane is overflow="hidden" with
67
+ * justifyContent="flex-end", so the TAIL is what stays on screen.
59
68
  */
60
69
  export function windowByRows(entries, height, scroll, cols) {
61
70
  const cap = Math.max(1, Math.floor(height));
@@ -85,9 +94,9 @@ export function windowByRows(entries, height, scroll, cols) {
85
94
  return picked;
86
95
  }
87
96
  /** Total rendered rows of a list of entries (for maxScroll). */
88
- export function totalRows(entries, cols) {
97
+ export function totalRows(entries, cols, mode = 'exact') {
89
98
  let n = 0;
90
99
  for (const e of entries)
91
- n += entryRows(e, cols);
100
+ n += entryRows(e, cols, mode);
92
101
  return n;
93
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.81.23",
3
+ "version": "2.82.2",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {