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 +28 -0
- package/dist/browser-chats.js +105 -64
- package/dist/browser-pure.js +12 -0
- package/dist/browser.js +115 -25
- package/dist/commands.js +1 -178
- package/dist/compact.js +42 -4
- package/dist/deepseek-ui.js +43 -7
- package/dist/glm-browser.js +14 -0
- package/dist/glm-chats.js +13 -1
- package/dist/glm-ui.js +4 -1
- package/dist/index.js +10 -6
- package/dist/net-capture.js +65 -7
- package/dist/qwen-browser.js +11 -0
- package/dist/qwen-chats.js +13 -1
- package/dist/qwen-net-capture.js +7 -1
- package/dist/qwen-ui.js +8 -1
- package/dist/restore-history.js +304 -0
- package/dist/tui/TuiEditor.js +8 -0
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
244
|
+
error: reason || 'compaction failed',
|
|
207
245
|
};
|
|
208
246
|
}
|
|
209
247
|
finally {
|
package/dist/deepseek-ui.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
package/dist/glm-browser.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
945
|
-
const messages = mod.
|
|
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.
|
|
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.
|
|
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.
|
|
3722
|
+
const msgs = mod.restore.trimRestoredMessages(all, 0);
|
|
3719
3723
|
if (msgs.length) {
|
|
3720
|
-
summary = mod.
|
|
3724
|
+
summary = mod.restore.formatRestoredHistory(msgs, {
|
|
3721
3725
|
limit: 40,
|
|
3722
3726
|
});
|
|
3723
3727
|
}
|
package/dist/net-capture.js
CHANGED
|
@@ -138,11 +138,28 @@ function pickAnswerText(node) {
|
|
|
138
138
|
return inner;
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
|
|
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
|
-
|
|
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":"((?:[^"\\]
|
|
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
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
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
|
//
|