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/dist/commands.js CHANGED
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { isImageName } from './attachments.js';
5
5
  import { SYSTEM_PROMPT_BUDGET_BYTES, kb, overBudget } from './prompt-budget.js';
6
+ import { parseAssistantToolCall } from './restore-history.js';
6
7
  import { translate, DEFAULT_LOCALE, } from './i18n.js';
7
8
  // Helpers for the slash commands (/diff, /cost, /export, /doctor, /review,
8
9
  // /add-dir, /queue, /goal, custom commands). Pure functions, unit-tested
@@ -437,184 +438,6 @@ export function resolveExtraDir(input, workdir, t = translate('en')) {
437
438
  }
438
439
  return { path: abs };
439
440
  }
440
- /** How many messages of the dialogue are printed by default. */
441
- export const RESTORED_HISTORY_LIMIT = 20;
442
- /**
443
- * Longest restored message KEPT for display. The system prompt (leaked into
444
- * history) is far bigger and is filtered by the noise checks below; a real
445
- * answer above this size is shortened instead of being dropped.
446
- */
447
- const RESTORED_MESSAGE_MAX = 100_000;
448
- /**
449
- * DeepSeek stores the assistant turn VERBATIM: either a raw tool-call JSON
450
- * ({"tool":"Read","args":{...}}, frequently dirty/truncated) or a `respond`
451
- * call that carries the real message for the operator
452
- * ({"tool":"respond","args":{"message":"..."}}). The operator only wants the
453
- * `respond` message (and plain-text answers) — a tool-call is protocol noise.
454
- *
455
- * Returns the tool name and, for `respond`, the unwrapped message. Tolerant on
456
- * purpose: the stored JSON is often dirty (a stray `}}`, a missing "args"
457
- * wrapper), so a bare JSON.parse is not enough.
458
- */
459
- export function parseAssistantToolCall(text) {
460
- const t = String(text || '').trim();
461
- if (!t)
462
- return null;
463
- // Broken heads like `<|tool": ...` or `tool": "Bash"` are tolerated.
464
- const m = t.match(/tool"?\s*:\s*"([^"\s]+)"/);
465
- if (!m)
466
- return null;
467
- const tool = m[1];
468
- if (tool !== 'respond')
469
- return { tool };
470
- const greedy = t.match(/"message"\s*:\s*"([\s\S]*)"\s*\}*\s*$/);
471
- let raw = greedy ? greedy[1] : '';
472
- if (!greedy) {
473
- // The value is not a quoted string (a number, a boolean, an array, a
474
- // nested object): there is no operator-facing MESSAGE here. The old
475
- // fallback sliced the raw text from the key and the restored turn printed
476
- // `:42}}` / `:[1,2]}}` as if it were the model's answer.
477
- const value = t.slice(t.indexOf('"message"') + 9).replace(/^\s*:\s*/, '');
478
- if (!value.startsWith('"'))
479
- return { tool, message: '' };
480
- raw = value.replace(/^"/, '');
481
- }
482
- return { tool, message: unescapeJsonString(raw) };
483
- }
484
- /** Decode the JSON escape sequences that appear inside a captured string. */
485
- export function unescapeJsonString(s) {
486
- // A `\uD800`-style escape that is NOT followed by a low surrogate (a
487
- // truncated or hand-written payload) would become a LONE surrogate - a
488
- // broken code point the restored dialogue prints as `?`. Decode a valid PAIR
489
- // as one code point and drop an unpaired half.
490
- let pendingHigh = '';
491
- return String(s || '')
492
- .replace(/\\u([0-9a-fA-F]{4})/g, (_x, h) => {
493
- const cp = parseInt(h, 16);
494
- if (cp >= 0xd800 && cp <= 0xdbff) {
495
- pendingHigh = String.fromCharCode(cp);
496
- return '';
497
- }
498
- if (cp >= 0xdc00 && cp <= 0xdfff) {
499
- const high = pendingHigh;
500
- pendingHigh = '';
501
- return high ? high + String.fromCharCode(cp) : '';
502
- }
503
- pendingHigh = '';
504
- return String.fromCharCode(cp);
505
- })
506
- .replace(/\\n/g, NL)
507
- .replace(/\\r/g, String.fromCharCode(13))
508
- .replace(/\\t/g, String.fromCharCode(9))
509
- .replace(/\\"/g, '"')
510
- .replace(/\\\\/g, '\\');
511
- }
512
- /**
513
- * Normalize one restored message for display:
514
- * - USER: drop the protocol noise (system-prompt, tool results, nudges) — it
515
- * is not the operator's words.
516
- * - ASSISTANT: unwrap a `respond` call to its message; drop any other
517
- * tool-call (protocol noise); keep plain-text answers.
518
- * Returns null when the message must not be shown.
519
- */
520
- export function normalizeRestoredMessage(m) {
521
- let text = String((m && m.text) || '').trim();
522
- if (!text)
523
- return null;
524
- // The size cap exists to keep a leaked SYSTEM PROMPT (or another giant
525
- // protocol blob) out of the restored dialogue. It must NOT drop a real
526
- // answer: a long report/diff/document easily exceeds the cap, and dropping
527
- // it silently left the operator with a dialogue where the agent's own reply
528
- // was missing. Keep such a message but shorten it for display.
529
- if (text.length > RESTORED_MESSAGE_MAX) {
530
- // Never split a surrogate PAIR: the cut is in UTF-16 units, so a high half
531
- // alone would reach the terminal as a broken code point (same rule as
532
- // truncateToolResult / capReport / cutHookText).
533
- let cut = RESTORED_MESSAGE_MAX;
534
- const last = text.charCodeAt(cut - 1);
535
- if (last >= 0xd800 && last <= 0xdbff)
536
- cut--;
537
- text = text.slice(0, cut) + '…';
538
- }
539
- if (m.role === 'user') {
540
- if (/^Tool result for /.test(text))
541
- return null;
542
- if (/^You are a coding agent running in a terminal/.test(text))
543
- return null;
544
- if (/^You stopped after a tool result/.test(text))
545
- return null;
546
- if (/^\[system\]/.test(text))
547
- return null;
548
- if (/^The user ran \//.test(text))
549
- return null;
550
- return { role: 'user', text };
551
- }
552
- // Only a real DSML MARKER is protocol noise: a bare mention of the feature
553
- // (an answer explaining the DSML parser) is a normal reply, and the old
554
- // `/DSML/i` check dropped it from the restored dialogue (the same bug the
555
- // /compact summary validator already fixed).
556
- if (/[<|]\s*DSML\s*[>|]/i.test(text))
557
- return null;
558
- if (/^<system>/i.test(text))
559
- return null;
560
- // The model sometimes emits its own safety-classification block as a plain
561
- // "answer"; it is not for the operator.
562
- if (/<ds_safety>/i.test(text))
563
- return null;
564
- if (/^Safe$/i.test(text))
565
- return null;
566
- const call = parseAssistantToolCall(text);
567
- if (call) {
568
- if (call.tool !== 'respond')
569
- return null;
570
- const msg = (call.message || '').trim();
571
- if (!msg)
572
- return null;
573
- // The safety-classification block can sit INSIDE the respond message; the
574
- // plain-text path already drops it, and printing the marker as the answer
575
- // is the same leak.
576
- if (/<\/?ds_safety\b/i.test(msg))
577
- return null;
578
- return { role: 'assistant', text: msg };
579
- }
580
- // A plain-text answer (written without the respond wrapper) — show as-is.
581
- return { role: 'assistant', text };
582
- }
583
- /** Back-compat predicate: is the message worth showing at all? */
584
- export function isDisplayableMessage(m) {
585
- return normalizeRestoredMessage(m) !== null;
586
- }
587
- /**
588
- * Keep only the last `limit` messages, dropping protocol noise and UNWRAPPING
589
- * the assistant `respond` calls to their operator-facing text.
590
- */
591
- export function trimRestoredMessages(messages, limit = RESTORED_HISTORY_LIMIT) {
592
- const clean = [];
593
- for (const m of messages || []) {
594
- if (!m || typeof m.text !== 'string')
595
- continue;
596
- const n = normalizeRestoredMessage(m);
597
- if (n)
598
- clean.push(n);
599
- }
600
- if (limit > 0 && clean.length > limit)
601
- return clean.slice(clean.length - limit);
602
- return clean;
603
- }
604
- /**
605
- * Render the restored dialogue as plain text lines: "❯ ..." for the operator,
606
- * "● ..." for the agent. Markdown rendering is done by the caller (it needs
607
- * the terminal width); this helper is pure and unit-tested.
608
- */
609
- export function formatRestoredHistory(messages, opts = {}) {
610
- const list = trimRestoredMessages(messages, opts.limit ?? RESTORED_HISTORY_LIMIT);
611
- const out = [];
612
- for (const m of list) {
613
- const marker = m.role === 'user' ? '❯ ' : '● ';
614
- out.push(marker + m.text.trim());
615
- }
616
- return out.join(NL + NL);
617
- }
618
441
  // ---------- /compact ----------
619
442
  /**
620
443
  * The prompt that asks the model to compress the current chat into a handover
package/dist/compact.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { translate } from './i18n.js';
2
- import { buildCompactPrompt, buildCompactCarryover, isUsableCompactSummary, trimRestoredMessages, formatRestoredHistory, } from './commands.js';
2
+ import { buildCompactPrompt, buildCompactCarryover, isUsableCompactSummary, } from './commands.js';
3
+ import { trimRestoredMessages, formatRestoredHistory, } from './restore-history.js';
3
4
  import { loadHooks, runLifecycleHooks } from './hooks.js';
4
5
  const NL = String.fromCharCode(10);
5
6
  /** How many times the summary call is retried before the local fallback. */
@@ -108,10 +109,12 @@ export async function performCompact(opts) {
108
109
  }
109
110
  if (!summary) {
110
111
  let fallback = '';
112
+ let historyLen = 0;
111
113
  try {
112
114
  const all = browser.fetchChatMessages && currentChatId
113
115
  ? await browser.fetchChatMessages(currentChatId).catch(() => [])
114
116
  : [];
117
+ historyLen = all.length;
115
118
  const msgs = trimRestoredMessages(all, 0);
116
119
  if (msgs.length) {
117
120
  fallback = formatRestoredHistory(msgs, { limit: fallbackLimit });
@@ -124,7 +127,33 @@ export async function performCompact(opts) {
124
127
  log(t('compact.summary_fallback', { v: String(fallbackLimit) }));
125
128
  }
126
129
  else {
127
- const err = summaryErr || t('common.unknown');
130
+ // When the history endpoint gave NOTHING there is no model error to
131
+ // report: say WHICH count came back, otherwise the operator sees a
132
+ // bare "unknown" and cannot tell a blocked fetch from an empty chat.
133
+ // `_lastHistoryError` is part of the provider contract (types.ts), so
134
+ // it is read WITHOUT a cast.
135
+ const histErr = String(browser._lastHistoryError || '');
136
+ if (process.env.ZAMES_COMPACT_DEBUG)
137
+ console.error('compact debug: historyLen=' +
138
+ historyLen +
139
+ ' id=' +
140
+ currentChatId +
141
+ ' err=' +
142
+ (histErr || '-'));
143
+ // The reason must be ACTIONABLE: a model error, a BLOCKED history
144
+ // fetch (auth/HTTP) and a TOO-SHORT history end here, and a bare
145
+ // "unknown" hides which one it was.
146
+ const err = summaryErr
147
+ ? summaryErr
148
+ : histErr
149
+ ? t('compact.summary_failed', { v: histErr })
150
+ : historyLen <= 0
151
+ ? t('compact.summary_failed', { v: 'history empty' }) +
152
+ ' (' +
153
+ historyLen +
154
+ ')'
155
+ : t('compact.summary_failed', { v: 'history too short' });
156
+ ' (' + historyLen + ')';
128
157
  log(t('compact.summary_failed', { v: err }));
129
158
  return {
130
159
  ok: false,
@@ -146,6 +175,10 @@ export async function performCompact(opts) {
146
175
  // the duration — by now the exhausted chat is behind us and a refusal here
147
176
  // means the compaction itself cannot proceed, which the caller reports.
148
177
  const prevOnContextLimit = browser.onContextLimit;
178
+ // Compact sends skip the adaptive throttle: a fresh chat with a tiny
179
+ // prompt must not be refused for "too frequent" (see _waitForSendSlot).
180
+ const holder = browser;
181
+ holder.compactInProgress = true;
149
182
  if (contextExhausted && 'onContextLimit' in browser) {
150
183
  ;
151
184
  browser.onContextLimit = null;
@@ -162,6 +195,7 @@ export async function performCompact(opts) {
162
195
  });
163
196
  }
164
197
  finally {
198
+ holder.compactInProgress = false;
165
199
  if (contextExhausted && 'onContextLimit' in browser) {
166
200
  ;
167
201
  browser.onContextLimit = prevOnContextLimit || null;
@@ -197,13 +231,17 @@ export async function performCompact(opts) {
197
231
  };
198
232
  }
199
233
  catch (e) {
200
- log(e.message);
234
+ // A thrown NON-Error (a rejected Playwright call, a string) used to give
235
+ // `error: undefined` and the caller reported a bare "unknown": the real
236
+ // reason for a failed compaction was lost.
237
+ const reason = e instanceof Error ? e.message : String(e || 'compaction failed');
238
+ log(reason);
201
239
  return {
202
240
  ok: false,
203
241
  chatId: currentChatId,
204
242
  summaryChars: 0,
205
243
  beforeTokens,
206
- error: e.message,
244
+ error: reason || 'compaction failed',
207
245
  };
208
246
  }
209
247
  finally {
@@ -94,12 +94,17 @@ export const RESTORE_ANSWER_SELECTORS = [
94
94
  'div[class*="ds-markdown"]',
95
95
  ];
96
96
  // Message-level containers for the restored dialogue (broad first).
97
+ // MEASURED on the live page (2-turn chat): `[data-message-id]` no longer
98
+ // exists, `[class*="chat-message"]`/`[class*="message-item"]` match nothing,
99
+ // and the bare `[class*="_message"]` catch-all was a class-name HASH tail
100
+ // (`_63c77b1`) — so the whole DOM fallback returned an EMPTY dialogue. The
101
+ // real containers are `div.ds-message` (one per turn) and its scroll wrapper.
97
102
  export const MESSAGE_CONTAINER_SELECTORS = [
103
+ 'div.ds-message',
98
104
  '[data-message-id]',
99
105
  '[class*="ds-message"]',
100
106
  '[class*="chat-message"]',
101
107
  '[class*="message-item"]',
102
- '[class*="_message"]',
103
108
  ];
104
109
  // A message block is "assistant" when it carries the assistant class OR a
105
110
  // rendered markdown wrapper (a user bubble has no rendered markdown). Used by
@@ -216,7 +221,10 @@ export const TOGGLE_SELECTORS = [
216
221
  'div[role="button"][aria-pressed]',
217
222
  'button[aria-pressed]',
218
223
  ];
219
- export const DEEP_THINKING_RE = /глубок|deep\s*think/i;
224
+ // `deep\s*think` does NOT match DeepSeek's live label "DeepThink" (no space),
225
+ // so the reasoning toggle was never found and never applied — the agent ran
226
+ // without reasoning no matter what `browser.deepThinking` said.
227
+ export const DEEP_THINKING_RE = /глубок|deep\s*think|think/i;
220
228
  export const WEB_SEARCH_RE = /поиск|search/i;
221
229
  // ---------- attachments ----------
222
230
  export const ATTACH_SELECTORS = [
@@ -242,7 +250,15 @@ export const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate lim
242
250
  // Transient server-side hiccups (DeepSeek overloaded / hiccup). Unlike the
243
251
  // rate limit, these usually clear in seconds, so we retry quickly instead of
244
252
  // waiting minutes.
245
- export const SERVER_BUSY_RE = /(server (is )?busy|server error|service (is )?unavailable|temporarily unavailable|internal server error|\b50[234]\b|server overloaded|сервер занят|сервер перегружен|сервис недоступен|внутренняя ошибка|попробуйте позже)/i;
253
+ // A toast container (role=alert / .toast) can include the TEXT OF THE
254
+ // CONVERSATION around its notice, so a normal answer that merely SAYS
255
+ // "try again later" or "the server is busy" looked like a dead server: the
256
+ // loop threw ServerBusyError, clicked Continue and RESENT a healthy turn
257
+ // (live: 6 Continue clicks on one answer). Match the notice SHAPE: the
258
+ // phrase must be a STANDALONE CLAUSE (start/end of the text, after a
259
+ // newline, or next to punctuation) and a sentence merely discussing it
260
+ // ("if the server is busy, ...") must not match.
261
+ export const SERVER_BUSY_RE = /(?:^|[\n!?]\s*|\.\s+|:\s+)(?:(?:(?:http|error|status|code)\s*[:#]?\s*)?5(?:02|03|04)(?:\s+(?:server\s+)?(?:error|unavailable|service\s+unavailable))?|server (?:is )?(?:temporarily )?(?:busy|overloaded)|service (?:is )?unavailable|temporarily unavailable|internal server error|bad gateway|gateway timeout|сервер (?:временно )?(?:занят|перегружен)|сервис недоступен|внутренняя ошибка сервера)(?:[.!,]|$)/im;
246
262
  // The chat's CONTEXT WINDOW is exhausted. DeepSeek refuses to continue the
247
263
  // conversation and answers with a non-retryable notice instead of a model
248
264
  // turn: "Length limit reached. Please start a new chat." (the Russian UI:
@@ -254,25 +270,45 @@ export const SERVER_BUSY_RE = /(server (is )?busy|server error|service (is )?una
254
270
  // or a server hiccup.
255
271
  export const CONTEXT_LIMIT_RE = /(length limit (?:has been )?reached|context (?:window|length) (?:is )?(?:exceeded|full)|maximum context|start a new (?:chat|conversation)|достигнут лимит длин|лимит длин[ыа] (?:достигнут|исчерпан)|начните новый чат|начните нов(ый|ую) (диалог|бесед))/i;
256
272
  // ---------- network protocol (SSE) ----------
257
- export const GENERATION_ERR_RE = /"finish_reason":\s*"generation_err"/i;
273
+ // Every protocol marker is anchored to the START OF A LINE and to the exact
274
+ // shape it has in the stream. Why: the old patterns matched the quoted pair
275
+ // ANYWHERE in the body, and a chat/completion REQUEST body carries the whole
276
+ // conversation — our own tool results and answers included. A task or a tool
277
+ // output that merely MENTIONED the marker (e.g. while working on this very
278
+ // detector) then looked like a truncated turn and the agent resent a task the
279
+ // server never truncated. Inside a JSON string value those quotes are
280
+ // ESCAPED, so a line-anchored pattern cannot match them.
281
+ // A marker is read from the RAW SSE body, which ALSO carries the RESPONSE
282
+ // text of the model — and a tool call quoting this very code used to trip
283
+ // the detector. The anchor is therefore (a) the start of a LINE (the body is
284
+ // JSON-escaped, so a quote inside the text is `\"` and never a line start)
285
+ // and (b) the marker key must be the FIRST quoted key of its object, i.e.
286
+ // no unescaped `"` may precede it on the line. `[^\\n]*?` stops at the
287
+ // first quote (which broke the BATCH shape `"p":"response",..."quasi_status"`),
288
+ // `.*?` alone stops at the first UNESCAPED quote — exactly what is wanted.
289
+ // `(?:[^"\\]|\\.)*?` skips ESCAPED quotes (`\"` inside the model text)
290
+ // and stops at the first REAL one — so the marker may sit after other keys
291
+ // of the same line, but a quote inside our own text never anchors it.
292
+ const MARKER_START = '^(?:data: ?|\\{).*?"';
293
+ export const GENERATION_ERR_RE = new RegExp(MARKER_START + 'finish_reason": ?"generation_err"', 'im');
258
294
  // The server gave up on the turn ("Server busy, please try again later."):
259
295
  // finish_reason is `generation_timeout` and there is NO RESPONSE fragment, so
260
296
  // extractAnswer() returns '' and the finish loop would wait out the whole
261
297
  // timeout. Same failure family as generation_err — the turn did not complete,
262
298
  // so ask() must resend instead of hanging.
263
- export const GENERATION_TIMEOUT_RE = /"finish_reason":\s*"generation_timeout"/i;
299
+ export const GENERATION_TIMEOUT_RE = new RegExp(MARKER_START + 'finish_reason": ?"generation_timeout"', 'im');
264
300
  // DeepSeek emits the generation status in TWO shapes: the initial message as a
265
301
  // JSON property (`"quasi_status":"FINISHED"` inside v.response) and later
266
302
  // BATCH updates (`{"p":"quasi_status","v":"FINISHED"}`). The old regex only
267
303
  // matched the BATCH form, so a FINISHED-without-answer turn that came in the
268
304
  // PROPERTY form (the real "Server is temporarily unavailable" case) was NOT
269
305
  // detected — the agent hung on "Stopped" until the timeout. Match both.
270
- export const INCOMPLETE_STATUS_RE = /"quasi_status"(?::|,"v":)"INCOMPLETE"/i;
306
+ export const INCOMPLETE_STATUS_RE = new RegExp(MARKER_START + 'quasi_status"(?: ?: ?| ?,"v": ?)"INCOMPLETE"', 'im');
271
307
  // A turn that ENDED (`quasi_status: FINISHED`) but produced NO answer text —
272
308
  // the reasoning was generated and the model stopped WITHOUT a RESPONSE
273
309
  // fragment. The UI shows Stopped + Continue. Matches both the JSON-property
274
310
  // and the BATCH shape (see INCOMPLETE_STATUS_RE).
275
- export const FINISHED_STATUS_RE = /"quasi_status"(?::|,"v":)"FINISHED"/i;
311
+ export const FINISHED_STATUS_RE = new RegExp(MARKER_START + 'quasi_status"(?: ?: ?| ?,"v": ?)"FINISHED"', 'im');
276
312
  // Only a completion/continue turn carries a real answer. Gating the capture
277
313
  // assignment to these URLs keeps a future endpoint (which happens to return a
278
314
  // `content`/`text` field) from clobbering the answer and looking fresh.
@@ -43,6 +43,12 @@ function capNetBody(body) {
43
43
  return body.slice(0, head) + '\n...[truncated]...\n' + body.slice(-1024);
44
44
  }
45
45
  export class GlmBrowser {
46
+ /**
47
+ * True while a CONTEXT-LIMIT compaction runs in a fresh chat: that chat
48
+ * starts EMPTY (no system prompt yet), so a COMPACT prompt is tiny and must
49
+ * NOT be refused with a rate limit by the adaptive throttle.
50
+ */
51
+ compactInProgress = false;
46
52
  /** The chat a send must join (set by the agent loop; see types.ts). */
47
53
  resumeChatId = null;
48
54
  providerName = 'glm';
@@ -98,6 +104,7 @@ export class GlmBrowser {
98
104
  _lastTokenUsage;
99
105
  _toggles;
100
106
  _apiAuth;
107
+ /** Last error from fetchChatMessages ('' on success). */
101
108
  _lastHistoryError;
102
109
  _netBody;
103
110
  _netHookInstalled;
@@ -1395,6 +1402,13 @@ export class GlmBrowser {
1395
1402
  async _waitForSendSlot(agent) {
1396
1403
  if (!agent)
1397
1404
  return;
1405
+ // A COMPACTION send is not a user turn: it goes into a FRESH chat whose
1406
+ // whole content is the (tiny) system prompt + summary. Waiting out a
1407
+ // block learned in the OLD chat, or being refused for "too frequent" on
1408
+ // a two-message chat, killed the only recovery path. The throttle is
1409
+ // irrelevant there — the send carries a few hundred tokens.
1410
+ if (this.compactInProgress)
1411
+ return;
1398
1412
  const gap = this.sendRuntime.waitMs();
1399
1413
  if (gap <= 0)
1400
1414
  return;
package/dist/glm-chats.js CHANGED
@@ -66,12 +66,24 @@ export function scrapeGlmChatMessages(opts) {
66
66
  }
67
67
  const uniq = Array.from(new Set(containers));
68
68
  const blocks = uniq.filter((b) => !uniq.some((o) => o !== b && o.contains(b)));
69
+ // A REASONING block is UI state, not the answer. It is not always a PARENT
70
+ // of the text (a provider may render it as a sibling of the answer), so the
71
+ // message text is read from a CLONE with the reasoning subtrees removed —
72
+ // otherwise the restored dialogue printed the whole THINK phase.
73
+ const contentOf = (e) => {
74
+ const clone = e.cloneNode(true);
75
+ for (const t of Array.from(clone.querySelectorAll('*'))) {
76
+ if (thinkRe.test((t.className || '').toString()))
77
+ t.remove();
78
+ }
79
+ return (clone.textContent || '').trim();
80
+ };
69
81
  const out = [];
70
82
  for (const c of blocks) {
71
83
  const role = userRe.test((c.className || '').toString())
72
84
  ? 'user'
73
85
  : 'assistant';
74
- const text = (c.textContent || '').trim();
86
+ const text = contentOf(c);
75
87
  if (!text)
76
88
  continue;
77
89
  out.push({ role, text });
package/dist/glm-ui.js CHANGED
@@ -145,7 +145,10 @@ export function glmContextWindow(model) {
145
145
  // so a normal answer that merely mentions "try again" is not mistaken for a
146
146
  // limit.
147
147
  export const RATE_LIMIT_RE = /too many requests|rate limit|too frequent|please try again later|слишком часто/i;
148
- export const SERVER_BUSY_RE = /server is busy|service unavailable|try again later|high demand/i;
148
+ // The same lesson as the DeepSeek pattern: a normal answer may SAY "try
149
+ // again later" or "high demand", so the phrase must be a standalone notice
150
+ // and not a clause inside the reply.
151
+ export const SERVER_BUSY_RE = /(?:^|[\n!?]\s*|\.\s+|:\s+)(?:server is busy|service unavailable|high demand|try again later)(?:[.!,]|$)/im;
149
152
  // ---------- attachments ----------
150
153
  // The observed upload control (a real filechooser on click).
151
154
  export const UPLOAD_BUTTON_SELECTOR = '#upload-file-button, button[class*="upload"]';
package/dist/index.js CHANGED
@@ -292,11 +292,13 @@ const RELOADABLE = [
292
292
  // long-lived LineEditor, itself not reloadable) and spinner.ts, which already
293
293
  // picks up a fresh copy transitively.
294
294
  'commands',
295
+ 'restore-history',
295
296
  'run-task',
296
297
  'attach-refs',
297
298
  ];
298
299
  const mod = {
299
300
  commands: null,
301
+ restore: null,
300
302
  attach: null,
301
303
  runTask: null,
302
304
  createTools,
@@ -361,6 +363,8 @@ async function reloadModules() {
361
363
  mod.backlogNeedsPruning = pick('backlog', 'backlogNeedsPruning');
362
364
  if (pick('commands', 'missingCommandArgs'))
363
365
  mod.commands = loaded.get('commands');
366
+ if (pick('restore-history', 'trimRestoredMessages'))
367
+ mod.restore = loaded.get('restore-history');
364
368
  if (pick('attach-refs', 'resolveAttachPath'))
365
369
  mod.attach = loaded.get('attach-refs');
366
370
  if (pick('run-task', 'runTask'))
@@ -941,8 +945,8 @@ async function printRestoredHistory(browser, ui, chatId = null) {
941
945
  // those. The "truncated" note must compare the DISPLAYABLE count with the
942
946
  // limit (comparing `all.length` always fired, because tool-calls are
943
947
  // dropped and the raw history is always longer).
944
- const displayable = mod.commands.trimRestoredMessages(all, 0);
945
- const messages = mod.commands.trimRestoredMessages(all);
948
+ const displayable = mod.restore.trimRestoredMessages(all, 0);
949
+ const messages = mod.restore.trimRestoredMessages(all);
946
950
  // Always surface the diagnostic line when the history could not be turned
947
951
  // into anything printable — otherwise "the dialogue is empty" is a dead end
948
952
  // (was the fetch blocked? did the chat really have no user turns?).
@@ -995,7 +999,7 @@ async function printRestoredHistory(browser, ui, chatId = null) {
995
999
  }
996
1000
  if (displayable.length > messages.length) {
997
1001
  out(theme.dim(t('chats.history_truncated', {
998
- n: String(mod.commands.RESTORED_HISTORY_LIMIT),
1002
+ n: String(mod.restore.RESTORED_HISTORY_LIMIT),
999
1003
  })));
1000
1004
  }
1001
1005
  }
@@ -3682,7 +3686,7 @@ async function main() {
3682
3686
  if (cid) {
3683
3687
  const all = await browser.fetchChatMessages(cid).catch(() => []);
3684
3688
  chatHasMessages =
3685
- mod.commands.trimRestoredMessages(all, 0).length > 0;
3689
+ mod.restore.trimRestoredMessages(all, 0).length > 0;
3686
3690
  }
3687
3691
  }
3688
3692
  if (chatHasMessages) {
@@ -3715,9 +3719,9 @@ async function main() {
3715
3719
  const all = cid
3716
3720
  ? await browser.fetchChatMessages(cid).catch(() => [])
3717
3721
  : [];
3718
- const msgs = mod.commands.trimRestoredMessages(all, 0);
3722
+ const msgs = mod.restore.trimRestoredMessages(all, 0);
3719
3723
  if (msgs.length) {
3720
- summary = mod.commands.formatRestoredHistory(msgs, {
3724
+ summary = mod.restore.formatRestoredHistory(msgs, {
3721
3725
  limit: 40,
3722
3726
  });
3723
3727
  }
@@ -138,11 +138,28 @@ function pickAnswerText(node) {
138
138
  return inner;
139
139
  }
140
140
  }
141
- for (const key of ['content', 'text', 'answer', 'response']) {
141
+ // A DeepSeek response OBJECT ({"response":{fragments:[...]}}) must not be
142
+ // read as a string answer: `response` is a NODE here, and only a string
143
+ // under it is text.
144
+ for (const key of ['content', 'text', 'answer']) {
142
145
  const v = obj[key];
143
146
  if (typeof v === 'string' && v)
144
147
  return v;
145
148
  }
149
+ const respText = obj.response;
150
+ if (typeof respText === 'string' && respText)
151
+ return respText;
152
+ // The DeepSeek shape: v.response.fragments[] -> type RESPONSE -> content.
153
+ const frags = obj.response?.fragments;
154
+ if (Array.isArray(frags)) {
155
+ let acc = '';
156
+ for (const fr of frags) {
157
+ if (fr && fr.type === 'RESPONSE' && typeof fr.content === 'string')
158
+ acc += fr.content;
159
+ }
160
+ if (acc)
161
+ return acc;
162
+ }
146
163
  for (const key of ['delta', 'message', 'data', 'payload', 'v']) {
147
164
  const v = obj[key];
148
165
  if (v && typeof v === 'object') {
@@ -179,7 +196,10 @@ const MIN_REAL_ANSWER_CHARS = 1;
179
196
  * a closing `}` / `]` / `)` left by a truncation that began mid-structure).
180
197
  * Listing only the opening brackets let a lone `}` count as a COMPLETE answer.
181
198
  */
182
- const TRUNCATED_HEAD_RE = /^[\s{}[\]()]*$/;
199
+ // A lone `{`, `[`, `<`, a bracket pair or whitespace: the SNAPSHOT of a
200
+ // RESPONSE that is still streaming, never an answer. `<` matters because a
201
+ // DSML call starts with a bare `<>` chunk.
202
+ const TRUNCATED_HEAD_RE = /^[\s{}[\]()<>]*$/;
183
203
  // How many chars of RESPONSE text the body carries, or -1 when there is NO
184
204
  // RESPONSE fragment at all. The fragment is SSE/JSON-encoded, so the content
185
205
  // may contain `\"` escapes AND raw `"` inside the JSON (the capture is the RAW
@@ -196,8 +216,12 @@ function responseContentLength(body) {
196
216
  // captured without the SSE framing) measured as 0 even when it carried a
197
217
  // real SHORT answer. Count the content strings from the raw body and decode
198
218
  // them, so an escaped quote does not inflate the count.
219
+ // The chunk may contain a RAW newline (SSE wraps long lines and a fragment
220
+ // decoded here can carry one), which `.`-based classes do not match: the
221
+ // old `\.` alternative then ended the match at the first line break and a
222
+ // MULTI-LINE answer measured as a single head char. `[\s\S]` covers it.
199
223
  let len = 0;
200
- const re = /"type":"RESPONSE"[^}]*?"content":"((?:[^"\\]|\\.)*)"/g;
224
+ const re = /"type":"RESPONSE"[^}]*?"content":"((?:[^"\\]|\\[\s\S])*)"/g;
201
225
  let m;
202
226
  while ((m = re.exec(b)) !== null) {
203
227
  let text = m[1];
@@ -241,6 +265,37 @@ export function isFinishedWithoutAnswer(body) {
241
265
  // treat the absence of a real answer as "no answer produced".
242
266
  return true;
243
267
  }
268
+ /**
269
+ * True when a FINISHED body still carries NO complete answer — its only
270
+ * RESPONSE text is a truncated head (empty, a bare `{`, or a lone newline).
271
+ *
272
+ * Such a body used to be reported as a DEAD turn right away, but DeepSeek
273
+ * streams the answer: the first fragment is only a SNAPSHOT of the first bytes
274
+ * (`"content":"{\\""` for a DSML/XML call) and the rest arrives in APPEND
275
+ * chunks a moment later. Treating the snapshot as final made the finish loop
276
+ * click Continue / throw `GenerationIncompleteError` on turns that were still
277
+ * answering, so the real answer never reached the parser and the operator saw
278
+ * `DeepSeek оборвал ответ` / `не ответил за 120с`.
279
+ *
280
+ * The snapshot is rebuilt from the RAW body on every network event, so the
281
+ * finish loop just keeps waiting while this returns true: the next event
282
+ * carries more text and `isFinishedWithAnswer()` takes over.
283
+ */
284
+ export function isFinishedAwaitingAnswer(body) {
285
+ const b = String(body || '');
286
+ if (!b)
287
+ return false;
288
+ if (!FINISHED_STATUS_RE.test(b))
289
+ return false;
290
+ // Measured on 83 live FINISHED bodies: the fragment-regex measure called a
291
+ // COMPLETE DSML/JSON tool call (<200 chars) a snapshot, so the finish loop
292
+ // kept waiting and re-sent healthy turns. The real signal is a FINISHED body
293
+ // with NO answer at all, or nothing but a truncated head (a lone `{`, `<`,
294
+ // a newline) — that is a SNAPSHOT of a call still arriving. Anything else
295
+ // already carries the answer.
296
+ const text = extractAnswer(b).trim();
297
+ return text === '' || TRUNCATED_HEAD_RE.test(text);
298
+ }
244
299
  /**
245
300
  * True when the SSE body shows a turn that ENDED (`quasi_status: FINISHED`)
246
301
  * AND carried a non-empty RESPONSE fragment — i.e. the server is done and the
@@ -257,10 +312,13 @@ export function isFinishedWithAnswer(body) {
257
312
  return false;
258
313
  if (!FINISHED_STATUS_RE.test(b))
259
314
  return false;
260
- // A truncated RESPONSE head (a lone `{`) is NOT a complete answer: the
261
- // finish loop would return it as the model's text and the tool-call parser
262
- // would fail on it. Require real content (see responseTextLength).
263
- return responseContentLength(b) >= MIN_REAL_ANSWER_CHARS;
315
+ // The measure is the DECODED answer text, not the fragment regex: a live
316
+ // DSML/JSON tool call arrives as a short RESPONSE whose first bytes are a
317
+ // bare `<` / `{`, and the regex read that as a 1-char "answer", so a
318
+ // COMPLETE turn was never recognised as finished (the loop kept waiting and
319
+ // re-sent it). A truncated head is still not an answer.
320
+ const text = extractAnswer(b).trim();
321
+ return text !== '' && !TRUNCATED_HEAD_RE.test(text);
264
322
  }
265
323
  // The CONTEXT size (in tokens) DeepSeek reports for the current answer.
266
324
  //