zames_pro 2.90.15 → 2.90.18

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,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.90.18] - 2026-10-30
11
+
12
+ ### Fixed
13
+
14
+ - self-healing live interaction with the chat
15
+ - **restore:** drop the loop's timeout nudge
16
+ - **deepseek:** do not read a protocol marker mentioned in our own text
17
+ - **restore:** keep the loop's timeout nudge out of the restored dialogue
18
+ - **browser:** stop misreading a completed turn as truncated
19
+
20
+ ### Changed
21
+
22
+ - drop the _netTurnBody and restore-nudge changes
23
+
24
+ ## [2.90.17] - 2026-10-30
25
+
26
+ ### Fixed
27
+
28
+ - self-healing live interaction with the chat
29
+ - **restore:** drop the loop's timeout nudge
30
+ - **deepseek:** do not read a protocol marker mentioned in our own text
31
+ - **restore:** keep the loop's timeout nudge out of the restored dialogue
32
+ - **browser:** stop misreading a completed turn as truncated
33
+
34
+ ### Changed
35
+
36
+ - drop the _netTurnBody and restore-nudge changes
37
+
38
+ ## [2.90.16] - 2026-10-29
39
+
40
+ ### Fixed
41
+
42
+ - **browser:** apply the sanitized headless UA to the relaunch explicitly
43
+
10
44
  ## [2.90.15] - 2026-10-29
11
45
 
12
46
  ### Changed
@@ -119,68 +119,109 @@ export function scrapeChatList(limit) {
119
119
  * builds match both an outer and an inner container, which printed each turn
120
120
  * twice). The model's reasoning (think class) is skipped.
121
121
  */
122
- export function scrapeChatMessages(opts) {
123
- const think = new RegExp(opts.thinkRe, 'i');
124
- const assistantRe = new RegExp(opts.assistantRe, 'i');
125
- const inThink = (e) => {
126
- let n = e;
127
- while (n) {
128
- const cls = (n.className || '').toString();
129
- if (think.test(cls))
130
- return true;
131
- n = n.parentElement;
132
- }
133
- return false;
134
- };
135
- const textOf = (e) => {
136
- const h = e;
137
- const t = h.innerText || h.textContent || '';
138
- const NL = String.fromCharCode(10);
139
- return t.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
140
- };
141
- const looksAssistant = (e) => {
142
- const cls = (e.className || '').toString();
143
- if (assistantRe.test(cls))
144
- return true;
145
- if (e.querySelector(opts.assistantSel))
146
- return true;
147
- if (e.querySelector(opts.markdownSel))
148
- return true;
149
- return false;
150
- };
151
- let blocks = [];
152
- for (const s of opts.containerSels) {
153
- const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
154
- if (found.length) {
155
- blocks = found;
156
- break;
157
- }
158
- }
159
- if (blocks.length > 1) {
160
- const unique = Array.from(new Set(blocks));
161
- blocks = unique.filter((b) => !unique.some((other) => other !== b && other.contains(b)));
162
- }
163
- const out = [];
164
- if (blocks.length) {
165
- for (const b of blocks) {
166
- const t = textOf(b);
167
- if (!t)
168
- continue;
169
- out.push({ role: looksAssistant(b) ? 'assistant' : 'user', text: t });
170
- }
171
- if (out.length)
172
- return out;
173
- }
174
- for (const s of opts.answerSels) {
175
- const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
176
- if (!list.length)
177
- continue;
178
- for (const el of list) {
179
- const t = textOf(el);
180
- if (t)
181
- out.push({ role: 'assistant', text: t });
182
- }
183
- break;
184
- }
185
- return out;
122
+ // The in-page scraper is a SOURCE STRING compiled inside the page, not a
123
+ // function reference: tsx/esbuild injects a `__name(...)` helper into every
124
+ // NAMED function expression it transpiles, Playwright serializes that helper
125
+ // into the page, and the page has no such global — the whole scraper then threw
126
+ // `ReferenceError: __name is not defined` and the DOM fallback returned an
127
+ // EMPTY dialogue. Keeping the text here makes the in-page code explicit and
128
+ // testable (it is compiled with `new Function` by `scrapeChatMessagesInPage`).
129
+ const SCRAPE_CHAT_MESSAGES_SOURCE = [
130
+ 'function scrapeChatMessages(opts) {',
131
+ " const think = new RegExp(opts.thinkRe, 'i')",
132
+ " const assistantRe = new RegExp(opts.assistantRe, 'i')",
133
+ ' const inThink = (e) => {',
134
+ ' let n = e',
135
+ ' while (n) {',
136
+ " if (think.test((n.className || '').toString())) return true",
137
+ ' n = n.parentElement',
138
+ ' }',
139
+ ' return false',
140
+ ' }',
141
+ ' const textOf = (e) => {',
142
+ " const t = e.innerText || e.textContent || ''",
143
+ ' const NL = String.fromCharCode(10)',
144
+ " return t.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim()",
145
+ ' }',
146
+ ' // A REASONING block is UI state, not the answer, and it is not always a',
147
+ ' // PARENT of the text: DeepSeek renders it as a sibling that precedes the',
148
+ ' // markdown answer, so reading innerText printed the whole reasoning in the',
149
+ ' // restored dialogue. Remove the reasoning SUBTREES, then read the rest.',
150
+ ' const contentOf = (e) => {',
151
+ ' // The reasoning text is INSIDE the message, so it must be removed',
152
+ ' // before reading: the header line (Thought for N seconds) AND the',
153
+ ' // body that follows it. The answer is the part AFTER the body.',
154
+ ' const clone = e.cloneNode(true)',
155
+ ' let hadThink = false',
156
+ " for (const t of Array.from(clone.querySelectorAll('*'))) {",
157
+ " if (think.test((t.className || '').toString())) {",
158
+ ' hadThink = true',
159
+ ' t.remove()',
160
+ ' }',
161
+ ' }',
162
+ ' // The reasoning BODY can be a bare TEXT NODE (no element to remove),',
163
+ ' // and a cloned node may even lose the block structure, so the text is',
164
+ ' // taken from the ORIGINAL message and split into paragraphs: the',
165
+ ' // reasoning is the first one, the answer is the last one.',
166
+ ' const full = textOf(e)',
167
+ ' const nl = String.fromCharCode(10)',
168
+ ' const blocks = full.split(nl + nl).filter((p) => p.trim())',
169
+ " if (!hadThink && !/^(thought|thinking|think) for/.test(blocks[0] || '')) {",
170
+ ' return full.trim()',
171
+ ' }',
172
+ " const answer = blocks[blocks.length - 1] || ''",
173
+ " const hdr = new RegExp('^(thought|thinking|think) for[^' + nl + ']*' + nl + '*')",
174
+ " return answer.replace(hdr, '').trim()",
175
+ ' }',
176
+ ' // A USER bubble carries only the operator text; an assistant turn has a',
177
+ ' // rendered markdown wrapper (live: div.ds-markdown with',
178
+ ' // .ds-assistant-message-main-content).',
179
+ ' const looksAssistant = (e) => {',
180
+ " if (assistantRe.test((e.className || '').toString())) return true",
181
+ ' if (e.querySelector(opts.assistantSel)) return true',
182
+ ' if (e.querySelector(opts.markdownSel)) return true',
183
+ ' return false',
184
+ ' }',
185
+ ' let blocks = []',
186
+ ' for (const s of opts.containerSels) {',
187
+ ' const found = Array.from(document.querySelectorAll(s)).filter(',
188
+ ' (e) => !inThink(e) && contentOf(e).length > 0,',
189
+ ' )',
190
+ ' if (found.length) {',
191
+ ' blocks = found',
192
+ ' break',
193
+ ' }',
194
+ ' }',
195
+ ' if (blocks.length > 1) {',
196
+ ' const unique = Array.from(new Set(blocks))',
197
+ ' blocks = unique.filter(',
198
+ ' (b) => !unique.some((other) => other !== b && other.contains(b)),',
199
+ ' )',
200
+ ' }',
201
+ ' const out = []',
202
+ ' if (blocks.length) {',
203
+ ' for (const b of blocks) {',
204
+ ' const t = contentOf(b)',
205
+ ' if (!t) continue',
206
+ " out.push({ role: looksAssistant(b) ? 'assistant' : 'user', text: t })",
207
+ ' }',
208
+ ' if (out.length) return out',
209
+ ' }',
210
+ ' for (const s of opts.answerSels) {',
211
+ ' const list = Array.from(document.querySelectorAll(s)).filter(',
212
+ ' (e) => !inThink(e),',
213
+ ' )',
214
+ ' if (!list.length) continue',
215
+ ' for (const el of list) {',
216
+ ' const t = contentOf(el)',
217
+ " if (t) out.push({ role: 'assistant', text: t })",
218
+ ' }',
219
+ ' break',
220
+ ' }',
221
+ ' return out',
222
+ '}',
223
+ ].join(String.fromCharCode(10));
224
+ /** The in-page scraper SOURCE (compiled inside the page, see above). */
225
+ export function scrapeChatMessagesSource() {
226
+ return SCRAPE_CHAT_MESSAGES_SOURCE;
186
227
  }
@@ -300,6 +300,18 @@ export function isRateLimitText(text) {
300
300
  // start a new chat." and will refuse every further send into this chat. Distinct
301
301
  // from the rate limit (which clears with time) and from a server hiccup — the
302
302
  // only recovery is compaction, so ask() must not retry in the same chat.
303
+ /**
304
+ * A toast container can include the surrounding CONVERSATION, so a task that
305
+ * merely quotes "Length limit reached" (as the smoke script does while
306
+ * testing this detector) looked like a refused send and killed the run. A
307
+ * real notice is its OWN message: SHORT, and the phrase starts it.
308
+ */
309
+ export function isContextLimitNotice(text) {
310
+ const t = String(text || '').trim();
311
+ if (!t || t.length > 400)
312
+ return false;
313
+ return isContextLimitText(t);
314
+ }
303
315
  export function isContextLimitText(text) {
304
316
  return CONTEXT_LIMIT_RE.test(String(text || ''));
305
317
  }
@@ -383,3 +395,19 @@ export async function nukeProfile(dir = USER_DATA_DIR) {
383
395
  }
384
396
  catch { }
385
397
  }
398
+ /**
399
+ * PURE: does this browser still need the headless User-Agent fix?
400
+ *
401
+ * `_fixHeadlessUserAgent()` bails out for an "already configured" UA — but
402
+ * using `if (this.userAgent) return` was wrong: the field is also the ENGINE's
403
+ * own UA captured from the page, and a config UA that merely CONTAINS the
404
+ * `HeadlessChrome` marker needs the fix just as much. The only value that can
405
+ * skip the fix is one that is already sanitized (and non-empty — an empty
406
+ * string means "ask the engine", not "use a blank UA").
407
+ */
408
+ export function needsHeadlessUaFix(ua) {
409
+ const value = String(ua ?? '');
410
+ if (!value)
411
+ return true;
412
+ return sanitizeHeadlessUA(value) !== value;
413
+ }
package/dist/browser.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { chromium, } from 'playwright';
2
2
  import { noteSwallowed } from './swallow.js';
3
3
  import { interactiveBrowserLogin, loginIsInteractive, canShowWindow, } from './browser-login.js';
4
- import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, isFinishedWithAnswer, } from './net-capture.js';
4
+ import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, isFinishedAwaitingAnswer, isFinishedWithAnswer, } from './net-capture.js';
5
5
  import { rewriteFailedAttachments } from './commands.js';
6
6
  import { recoverFromContextLimit } from './context-limit.js';
7
7
  import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
8
- import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
8
+ import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessagesSource, } from './browser-chats.js';
9
9
  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';
10
10
  import { probeUiHealth, } from './ui-health.js';
11
11
  import { readPageToastsInDom, readLoginErrorInDom, readMfaAccountInDom, readLastAnswerInDom, chatSignalInDom, continueVisibleCheapInDom, stopButtonVisibleInDom, clickStopInDom, continueVisibleInDom, clickContinueInDom, readUserAgentInDom, findLoginInputIndexInDom, submitLoginFormInDom, setBusyTitleInDom, pasteTextIntoElement, readElementText, elementTagNameInDom, isElementEnabledInDom, readInputTrimmedTextInDom, dumpDomCountsInDom, } from './browser-dom.js';
@@ -23,7 +23,7 @@ export { STOP_NAME_RE, CONTINUE_NAME_RE };
23
23
  // imported for the class below and RE-EXPORTED here, so the public API of this
24
24
  // module is unchanged (tests and other modules import these names from
25
25
  // './browser.js').
26
- 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
+ import { askPassword, USER_DATA_DIR, parseHeadlessUACache, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, headlessUACacheReady, sanitizeHeadlessUA, needsHeadlessUaFix, writeAuthMarker, isGenerationIncompleteText, isRateLimitText, isServerBusyText, isContextLimitNotice, isContextLimitText, normText, cleanAnswer, RateLimitError, ServerBusyError, GenerationIncompleteError, ContextLimitError, cleanSingletonFiles, profileLooksLocked, nukeProfile, } from './browser-pure.js';
27
27
  export { askPassword, parseHeadlessUACache, headlessUACacheReady, sanitizeHeadlessUA, isGenerationIncompleteText, isRateLimitText, isServerBusyText, isContextLimitText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, ContextLimitError, };
28
28
  export class DeepSeekBrowser {
29
29
  providerName = 'deepseek';
@@ -130,6 +130,7 @@ export class DeepSeekBrowser {
130
130
  _apiPow;
131
131
  // Last error from fetchChatMessages ('' on success). Surfaced under --debug
132
132
  // so a missing restored history can be diagnosed.
133
+ /** Last error from fetchChatMessages ('' on success). */
133
134
  _lastHistoryError;
134
135
  // The RAW SSE body of the LAST answer response. Unlike `_netCapture`, this
135
136
  // is kept even when the body carries no RESPONSE text (a truncated turn:
@@ -330,6 +331,12 @@ export class DeepSeekBrowser {
330
331
  async _fixHeadlessUserAgent() {
331
332
  if (!this.headless)
332
333
  return;
334
+ // An already-sanitized UA (from the cache or a previous fix) needs no
335
+ // relaunch; an EMPTY field or a config UA that still carries the marker
336
+ // must be fixed, which the old blanket `if (this.userAgent) return` skipped
337
+ // on Qwen/GLM.
338
+ if (!needsHeadlessUaFix(this.userAgent))
339
+ return;
333
340
  try {
334
341
  const ua = await this.page.evaluate(readUserAgentInDom);
335
342
  const fixed = sanitizeHeadlessUA(ua);
@@ -338,18 +345,28 @@ export class DeepSeekBrowser {
338
345
  this.userAgent = fixed;
339
346
  // Remember the good UA for this engine build so the NEXT headless start
340
347
  // uses it immediately (no relaunch). Keyed by executable path.
341
- void writeCachedHeadlessUA(headlessEngineKey(), fixed);
348
+ await writeCachedHeadlessUA(headlessEngineKey(), fixed);
342
349
  if (this.debug)
343
350
  console.error('profile: headless UA -> relaunching with a plain Chrome UA');
344
351
  await this.context.close().catch(() => { });
345
- await this._launchOnce();
352
+ // Pass the UA EXPLICITLY: it was just sanitized, and relying on the field
353
+ // meant the relaunch could read the page's own UA back and keep the
354
+ // HeadlessChrome marker (a 403 / login wall from DeepSeek's CDN).
355
+ await this._launchOnce(fixed);
346
356
  }
347
357
  catch (e) {
348
358
  if (this.debug)
349
359
  console.error('profile: UA fix failed:', e.message);
350
360
  }
351
361
  }
352
- async _launchOnce() {
362
+ /**
363
+ * Launch the persistent context. `uaOverride` applies a User-Agent to THIS
364
+ * launch without storing it first: the UA fix reads the engine UA from the
365
+ * page it is about to close, so passing the value explicitly is the only way
366
+ * the relaunch cannot fall back to the un-sanitized UA (a stored field was
367
+ * read by the OLD page and then re-read by the new one).
368
+ */
369
+ async _launchOnce(uaOverride) {
353
370
  const options = {
354
371
  headless: this.headless,
355
372
  // 30ms slowMo per Playwright action added up over thousands of DOM
@@ -360,8 +377,9 @@ export class DeepSeekBrowser {
360
377
  };
361
378
  if (this.channel)
362
379
  options.channel = this.channel;
363
- if (this.userAgent)
364
- options.userAgent = this.userAgent;
380
+ const ua = uaOverride || this.userAgent;
381
+ if (ua)
382
+ options.userAgent = ua;
365
383
  try {
366
384
  this.context = await chromium.launchPersistentContext(USER_DATA_DIR, options);
367
385
  }
@@ -491,7 +509,7 @@ export class DeepSeekBrowser {
491
509
  // `!extracted` guard skipped the check and the finish loop waited out
492
510
  // the whole askDeadlineMs. Run the check on the RAW body regardless of
493
511
  // whether a (bogus) fragment was extracted.
494
- if (isFinishedWithoutAnswer(body)) {
512
+ if (isFinishedWithoutAnswer(body) && !isFinishedAwaitingAnswer(body)) {
495
513
  // The UI shows Stopped + Continue. Record it with a fresh timestamp
496
514
  // so the ask() loops do not wait out the timeout — they click
497
515
  // Continue / resend.
@@ -2057,6 +2075,8 @@ export class DeepSeekBrowser {
2057
2075
  report(gap);
2058
2076
  this._emitSendState('paused');
2059
2077
  await this._sleepInterruptible(gap, report);
2078
+ if (!this._abort && !this._askCancelled)
2079
+ this._emitSendState('settled');
2060
2080
  }
2061
2081
  // Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
2062
2082
  // Returns true if the sleep was cut short by _abort.
@@ -2251,15 +2271,22 @@ export class DeepSeekBrowser {
2251
2271
  // Checked BEFORE the rate limit and the server hiccup: DeepSeek shows the
2252
2272
  // context notice INSTEAD of an answer, and both of those branches would
2253
2273
  // retry the same send into a chat that can never accept it again.
2254
- if (isContextLimitText(pageText)) {
2274
+ // A toast container can carry the TEXT of the conversation: a task
2275
+ // that merely MENTIONS "Length limit reached" (the smoke script does)
2276
+ // made the send look refused and every later turn died on the
2277
+ // compaction path. Only a notice that is its own message counts — see
2278
+ // isContextLimitNotice().
2279
+ if (isContextLimitNotice(pageText)) {
2255
2280
  throw new ContextLimitError(pageText.slice(0, 300));
2256
2281
  }
2257
2282
  if (isRateLimitText(pageText)) {
2258
2283
  throw new RateLimitError(pageText.slice(0, 300));
2259
2284
  }
2260
- if (isServerBusyText(pageText)) {
2261
- throw new ServerBusyError(pageText.slice(0, 300));
2262
- }
2285
+ // NOT checked here: a server-busy NOTICE lives in a toast that can
2286
+ // carry the surrounding answer text, and a normal answer that merely
2287
+ // mentions "the server is busy" made this throw and RESENT a healthy
2288
+ // turn (live: 6 Continue clicks on one answer). The SSE body is the
2289
+ // authoritative place for it (_netServerBusy, see _onResponse).
2263
2290
  // The rate limit may be visible ONLY in the SSE body (the DOM toast is
2264
2291
  // short-lived and easy to miss). The old code threw ds.send_no_new_answer
2265
2292
  // at the deadline while the chat plainly showed "Messages too frequent".
@@ -2273,7 +2300,29 @@ export class DeepSeekBrowser {
2273
2300
  // truncated body has NO RESPONSE fragment, so extractAnswer() returned
2274
2301
  // '' and `_netCapture` was empty — guarding on it made this branch dead
2275
2302
  // code and the agent hung on "Stopped" until the timeout.
2276
- if (this._netCaptureAt >= this._lastSentAt &&
2303
+ // A truncated turn whose first RESPONSE fragment is only a HEAD (a bare
2304
+ // `{` or a newline) is NOT dead: DeepSeek streams the answer and the rest
2305
+ // arrives in APPEND chunks a moment later, so keep waiting instead of
2306
+ // resending. Without this every DSML/XML answer produced the false
2307
+ // "DeepSeek оборвал ответ" message.
2308
+ const awaitingAnswer = this._netCaptureAt >= this._lastSentAt &&
2309
+ isFinishedAwaitingAnswer(this._netBody);
2310
+ if (awaitingAnswer) {
2311
+ this._askDebug('START-loop awaiting-answer (head-only snapshot)');
2312
+ }
2313
+ // A COMPLETE answer wins over a stale truncation marker: the model
2314
+ // may quote `generation_err` (our own detector source) or DeepSeek may
2315
+ // end the turn with a marker AFTER the answer arrived. Resending or
2316
+ // clicking Continue then LOSES a good answer (live: turn 10 repeated
2317
+ // turn 9 and hit the watchdog).
2318
+ const doneAnswer = this._netCaptureAt >= this._lastSentAt &&
2319
+ isFinishedWithAnswer(this._netBody);
2320
+ if (doneAnswer) {
2321
+ this._askDebug('START-loop FINISHED-with-answer, return');
2322
+ return this._cleanAnswer(this._netCapture);
2323
+ }
2324
+ if (!awaitingAnswer &&
2325
+ this._netCaptureAt >= this._lastSentAt &&
2277
2326
  isGenerationIncompleteText(this._netBody)) {
2278
2327
  // PREFER the Continue button: it RESUMES the same truncated turn (a
2279
2328
  // `chat/continue` request). Resending the prompt creates a NEW turn and
@@ -2309,6 +2358,11 @@ export class DeepSeekBrowser {
2309
2358
  this.deepThinking &&
2310
2359
  (await this._clickContinueIfVisible())) {
2311
2360
  this._askDebug('CLICKED Continue (start-loop)');
2361
+ // A click RESUMES the turn. Without the wait the next tick clicks
2362
+ // again the instant the button is still painted, and a "messages
2363
+ // too frequent" hits from our OWN burst of chat/continue requests.
2364
+ this._lastContinueAt = Date.now();
2365
+ await this.page.waitForTimeout(500);
2312
2366
  }
2313
2367
  const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
2314
2368
  // Cheap growth signal instead of the whole-body length.
@@ -2352,7 +2406,14 @@ export class DeepSeekBrowser {
2352
2406
  const paused = this.autoContinue &&
2353
2407
  this.deepThinking &&
2354
2408
  (await this._continueButtonVisible());
2355
- if (differs && cur === lastStartCur && notGenerating && !paused) {
2409
+ // A head-only snapshot with no appended text yet is NOT settled: the
2410
+ // answer is still arriving, so a repeated DOM read must not be returned
2411
+ // as a finished answer (that was the "DeepSeek оборвал ответ" loop).
2412
+ if (!awaitingAnswer &&
2413
+ differs &&
2414
+ cur === lastStartCur &&
2415
+ notGenerating &&
2416
+ !paused) {
2356
2417
  settledTicks++;
2357
2418
  if (settledTicks >= 2) {
2358
2419
  this._askDebug('SETTLED-differs return len=' + cur.length);
@@ -2474,9 +2535,9 @@ export class DeepSeekBrowser {
2474
2535
  if (isRateLimitText(pageText)) {
2475
2536
  throw new RateLimitError(pageText.slice(0, 300));
2476
2537
  }
2477
- if (isServerBusyText(pageText)) {
2478
- throw new ServerBusyError(pageText.slice(0, 300));
2479
- }
2538
+ // The server-busy check is deliberately ABSENT: its toast can contain
2539
+ // the answer text, and a healthy answer that mentioned it threw here
2540
+ // (see the start-wait comment).
2480
2541
  }
2481
2542
  // The rate limit may exist only in the SSE body (the DOM toast is
2482
2543
  // short-lived). Without this the loop waited out the deadline and threw
@@ -2496,10 +2557,30 @@ export class DeepSeekBrowser {
2496
2557
  // and duplicates the work — only do that when there is no button to
2497
2558
  // click. Runs on the RAW `_netBody` (a truncated body has no RESPONSE,
2498
2559
  // so `_netCapture` is empty and the old guard was dead code).
2499
- const truncated = this._netCaptureAt >= this._lastSentAt &&
2560
+ // A COMPLETE answer is NOT truncated, whatever marker the body also
2561
+ // carries (a quoted `generation_err`, a late status): acting on it
2562
+ // would resend a healthy turn (live: a turn repeated the previous
2563
+ // answer and hit the watchdog).
2564
+ const doneAnswer = this._netCaptureAt >= this._lastSentAt &&
2565
+ isFinishedWithAnswer(this._netBody);
2566
+ const truncated = !doneAnswer &&
2567
+ this._netCaptureAt >= this._lastSentAt &&
2500
2568
  isGenerationIncompleteText(this._netBody);
2501
- const noAnswer = this._netNoAnswer && this._netCaptureAt >= this._lastSentAt;
2569
+ const noAnswer = !doneAnswer &&
2570
+ this._netNoAnswer &&
2571
+ this._netCaptureAt >= this._lastSentAt;
2502
2572
  if ((truncated || noAnswer) && this.autoContinue) {
2573
+ // MEASURED: a COMPLETE DSML tool call ends with the event `close`
2574
+ // and `{click_behavior:'none', auto_resume:false}`, yet the DOM may
2575
+ // still paint a "Continue" control for a moment. Clicking it sends a
2576
+ // chat/continue request into an ENDED turn — the reply is "Messages
2577
+ // too frequent" and the answer is lost. If the SSE says FINISHED
2578
+ // WITH an answer, accept it first; Continue is for a turn that has
2579
+ // NOT finished.
2580
+ if (isFinishedWithAnswer(this._netBody)) {
2581
+ this._askDebug('SKIP Continue: FINISHED with answer');
2582
+ return this._cleanAnswer(this._netCapture);
2583
+ }
2503
2584
  if (await this._clickContinueIfVisible()) {
2504
2585
  this._askDebug('CLICKED Continue (finish-loop, truncated=' +
2505
2586
  truncated +
@@ -2591,7 +2672,9 @@ export class DeepSeekBrowser {
2591
2672
  if (this._netCaptureAt >= this._lastSentAt &&
2592
2673
  isFinishedWithAnswer(this._netBody) === false &&
2593
2674
  isGenerationIncompleteText(this._netBody) === false &&
2594
- (this._netNoAnswer || isFinishedWithoutAnswer(this._netBody))) {
2675
+ (this._netNoAnswer ||
2676
+ (isFinishedWithoutAnswer(this._netBody) &&
2677
+ !isFinishedAwaitingAnswer(this._netBody)))) {
2595
2678
  deadTicks++;
2596
2679
  if (deadTicks >= 6 && !(await this._continueButtonVisible())) {
2597
2680
  this._askDebug('THROW dead-turn (finished without answer)');
@@ -2618,6 +2701,13 @@ export class DeepSeekBrowser {
2618
2701
  (await this._continueButtonVisible())) {
2619
2702
  if (await this._clickContinueIfVisible()) {
2620
2703
  this._askDebug('CLICKED Continue (before early return)');
2704
+ // A model-generated tool call often ENDS with DeepSeek's own
2705
+ // 'click to continue' offer (a Continue button inside the
2706
+ // ANSWER), which is not a pause. Returning here is what the
2707
+ // loop wants; the old unconditional `continue` spun until the
2708
+ // watchdog and looked like a stalled agent.
2709
+ this._askDebug('RETURN stable-with-continue len=' + cur.length);
2710
+ return cur;
2621
2711
  }
2622
2712
  stable = 0;
2623
2713
  }
@@ -2837,6 +2927,11 @@ export class DeepSeekBrowser {
2837
2927
  };
2838
2928
  }
2839
2929
  }, { chatId: id, auth, pow, historyUrl: HISTORY_MESSAGES_URL })
2930
+ // A Playwright page TIMES OUT on its own (30s default) and the
2931
+ // in-page fetch has no timeout at all: on a rate-limited / hung chat
2932
+ // the promise never settles, so /compact (and /resume) waited forever
2933
+ // and reported a bare "unknown". Fail fast instead — the caller has a
2934
+ // local fallback.
2840
2935
  .catch((e) => ({
2841
2936
  error: 'evaluate failed: ' + e.message,
2842
2937
  messages: null,
@@ -2879,22 +2974,34 @@ export class DeepSeekBrowser {
2879
2974
  // bottom. Used by /resume and /resume-id so the operator sees the restored
2880
2975
  // context in the terminal instead of just "Chat opened.".
2881
2976
  //
2882
- // Best-effort DOM scraping (the in-page scrapeChatMessages in
2883
- // browser-chats.ts, kept as a named export so the class stays thin): role
2977
+ // Best-effort DOM scraping (the in-page scrapeChatMessages SOURCE in
2978
+ // browser-chats.ts — a source string, NOT a function reference, because tsx
2979
+ // injects a `__name` helper into a transpiled function and the page has no
2980
+ // such global, which silently emptied this fallback): role
2884
2981
  // detection relies on the ds-assistant-message-* class, anything that is not
2885
2982
  // an assistant block is a user message, and the model's reasoning
2886
2983
  // (.ds-think-content) is skipped exactly like in _readLastAnswerText.
2887
2984
  async readChatMessages() {
2888
2985
  if (!this.page)
2889
2986
  return [];
2987
+ // The scraper travels as SOURCE and is compiled inside the page: tsx
2988
+ // injects a `__name` helper into a transpiled function reference, the page
2989
+ // has no such global, and the whole fallback threw a ReferenceError (the
2990
+ // restored dialogue came back empty).
2890
2991
  const raw = await this.page
2891
- .evaluate(scrapeChatMessages, {
2892
- containerSels: MESSAGE_CONTAINER_SELECTORS,
2893
- answerSels: RESTORE_ANSWER_SELECTORS,
2894
- thinkRe: THINK_CLASS_RE.source,
2895
- assistantRe: ASSISTANT_CLASS_RE.source,
2896
- assistantSel: ASSISTANT_SELECTOR,
2897
- markdownSel: ANSWER_MARKDOWN_SELECTOR,
2992
+ .evaluate((payload) => {
2993
+ const factory = new Function(payload.source + '; return scrapeChatMessages');
2994
+ return factory()(payload.opts);
2995
+ }, {
2996
+ source: scrapeChatMessagesSource(),
2997
+ opts: {
2998
+ containerSels: MESSAGE_CONTAINER_SELECTORS,
2999
+ answerSels: RESTORE_ANSWER_SELECTORS,
3000
+ thinkRe: THINK_CLASS_RE.source,
3001
+ assistantRe: ASSISTANT_CLASS_RE.source,
3002
+ assistantSel: ASSISTANT_SELECTOR,
3003
+ markdownSel: ANSWER_MARKDOWN_SELECTOR,
3004
+ },
2898
3005
  })
2899
3006
  .catch(() => []);
2900
3007
  return raw;