zames_pro 2.90.16 → 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,34 @@ 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
+
10
38
  ## [2.90.16] - 2026-10-29
11
39
 
12
40
  ### Fixed
@@ -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
  }
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, needsHeadlessUaFix, 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:
@@ -508,7 +509,7 @@ export class DeepSeekBrowser {
508
509
  // `!extracted` guard skipped the check and the finish loop waited out
509
510
  // the whole askDeadlineMs. Run the check on the RAW body regardless of
510
511
  // whether a (bogus) fragment was extracted.
511
- if (isFinishedWithoutAnswer(body)) {
512
+ if (isFinishedWithoutAnswer(body) && !isFinishedAwaitingAnswer(body)) {
512
513
  // The UI shows Stopped + Continue. Record it with a fresh timestamp
513
514
  // so the ask() loops do not wait out the timeout — they click
514
515
  // Continue / resend.
@@ -2074,6 +2075,8 @@ export class DeepSeekBrowser {
2074
2075
  report(gap);
2075
2076
  this._emitSendState('paused');
2076
2077
  await this._sleepInterruptible(gap, report);
2078
+ if (!this._abort && !this._askCancelled)
2079
+ this._emitSendState('settled');
2077
2080
  }
2078
2081
  // Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
2079
2082
  // Returns true if the sleep was cut short by _abort.
@@ -2268,15 +2271,22 @@ export class DeepSeekBrowser {
2268
2271
  // Checked BEFORE the rate limit and the server hiccup: DeepSeek shows the
2269
2272
  // context notice INSTEAD of an answer, and both of those branches would
2270
2273
  // retry the same send into a chat that can never accept it again.
2271
- 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)) {
2272
2280
  throw new ContextLimitError(pageText.slice(0, 300));
2273
2281
  }
2274
2282
  if (isRateLimitText(pageText)) {
2275
2283
  throw new RateLimitError(pageText.slice(0, 300));
2276
2284
  }
2277
- if (isServerBusyText(pageText)) {
2278
- throw new ServerBusyError(pageText.slice(0, 300));
2279
- }
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).
2280
2290
  // The rate limit may be visible ONLY in the SSE body (the DOM toast is
2281
2291
  // short-lived and easy to miss). The old code threw ds.send_no_new_answer
2282
2292
  // at the deadline while the chat plainly showed "Messages too frequent".
@@ -2290,7 +2300,29 @@ export class DeepSeekBrowser {
2290
2300
  // truncated body has NO RESPONSE fragment, so extractAnswer() returned
2291
2301
  // '' and `_netCapture` was empty — guarding on it made this branch dead
2292
2302
  // code and the agent hung on "Stopped" until the timeout.
2293
- 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 &&
2294
2326
  isGenerationIncompleteText(this._netBody)) {
2295
2327
  // PREFER the Continue button: it RESUMES the same truncated turn (a
2296
2328
  // `chat/continue` request). Resending the prompt creates a NEW turn and
@@ -2326,6 +2358,11 @@ export class DeepSeekBrowser {
2326
2358
  this.deepThinking &&
2327
2359
  (await this._clickContinueIfVisible())) {
2328
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);
2329
2366
  }
2330
2367
  const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
2331
2368
  // Cheap growth signal instead of the whole-body length.
@@ -2369,7 +2406,14 @@ export class DeepSeekBrowser {
2369
2406
  const paused = this.autoContinue &&
2370
2407
  this.deepThinking &&
2371
2408
  (await this._continueButtonVisible());
2372
- 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) {
2373
2417
  settledTicks++;
2374
2418
  if (settledTicks >= 2) {
2375
2419
  this._askDebug('SETTLED-differs return len=' + cur.length);
@@ -2491,9 +2535,9 @@ export class DeepSeekBrowser {
2491
2535
  if (isRateLimitText(pageText)) {
2492
2536
  throw new RateLimitError(pageText.slice(0, 300));
2493
2537
  }
2494
- if (isServerBusyText(pageText)) {
2495
- throw new ServerBusyError(pageText.slice(0, 300));
2496
- }
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).
2497
2541
  }
2498
2542
  // The rate limit may exist only in the SSE body (the DOM toast is
2499
2543
  // short-lived). Without this the loop waited out the deadline and threw
@@ -2513,10 +2557,30 @@ export class DeepSeekBrowser {
2513
2557
  // and duplicates the work — only do that when there is no button to
2514
2558
  // click. Runs on the RAW `_netBody` (a truncated body has no RESPONSE,
2515
2559
  // so `_netCapture` is empty and the old guard was dead code).
2516
- 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 &&
2517
2568
  isGenerationIncompleteText(this._netBody);
2518
- const noAnswer = this._netNoAnswer && this._netCaptureAt >= this._lastSentAt;
2569
+ const noAnswer = !doneAnswer &&
2570
+ this._netNoAnswer &&
2571
+ this._netCaptureAt >= this._lastSentAt;
2519
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
+ }
2520
2584
  if (await this._clickContinueIfVisible()) {
2521
2585
  this._askDebug('CLICKED Continue (finish-loop, truncated=' +
2522
2586
  truncated +
@@ -2608,7 +2672,9 @@ export class DeepSeekBrowser {
2608
2672
  if (this._netCaptureAt >= this._lastSentAt &&
2609
2673
  isFinishedWithAnswer(this._netBody) === false &&
2610
2674
  isGenerationIncompleteText(this._netBody) === false &&
2611
- (this._netNoAnswer || isFinishedWithoutAnswer(this._netBody))) {
2675
+ (this._netNoAnswer ||
2676
+ (isFinishedWithoutAnswer(this._netBody) &&
2677
+ !isFinishedAwaitingAnswer(this._netBody)))) {
2612
2678
  deadTicks++;
2613
2679
  if (deadTicks >= 6 && !(await this._continueButtonVisible())) {
2614
2680
  this._askDebug('THROW dead-turn (finished without answer)');
@@ -2635,6 +2701,13 @@ export class DeepSeekBrowser {
2635
2701
  (await this._continueButtonVisible())) {
2636
2702
  if (await this._clickContinueIfVisible()) {
2637
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;
2638
2711
  }
2639
2712
  stable = 0;
2640
2713
  }
@@ -2854,6 +2927,11 @@ export class DeepSeekBrowser {
2854
2927
  };
2855
2928
  }
2856
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.
2857
2935
  .catch((e) => ({
2858
2936
  error: 'evaluate failed: ' + e.message,
2859
2937
  messages: null,
@@ -2896,22 +2974,34 @@ export class DeepSeekBrowser {
2896
2974
  // bottom. Used by /resume and /resume-id so the operator sees the restored
2897
2975
  // context in the terminal instead of just "Chat opened.".
2898
2976
  //
2899
- // Best-effort DOM scraping (the in-page scrapeChatMessages in
2900
- // 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
2901
2981
  // detection relies on the ds-assistant-message-* class, anything that is not
2902
2982
  // an assistant block is a user message, and the model's reasoning
2903
2983
  // (.ds-think-content) is skipped exactly like in _readLastAnswerText.
2904
2984
  async readChatMessages() {
2905
2985
  if (!this.page)
2906
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).
2907
2991
  const raw = await this.page
2908
- .evaluate(scrapeChatMessages, {
2909
- containerSels: MESSAGE_CONTAINER_SELECTORS,
2910
- answerSels: RESTORE_ANSWER_SELECTORS,
2911
- thinkRe: THINK_CLASS_RE.source,
2912
- assistantRe: ASSISTANT_CLASS_RE.source,
2913
- assistantSel: ASSISTANT_SELECTOR,
2914
- 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
+ },
2915
3005
  })
2916
3006
  .catch(() => []);
2917
3007
  return raw;