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.
@@ -57,6 +57,12 @@ function capNetBody(body) {
57
57
  return body.slice(0, head) + '\n...[truncated]...\n' + body.slice(-1024);
58
58
  }
59
59
  export class QwenBrowser {
60
+ /**
61
+ * True while a CONTEXT-LIMIT compaction runs in a fresh chat: that chat
62
+ * starts EMPTY (no system prompt yet), so a COMPACT prompt is tiny and must
63
+ * NOT be refused with a rate limit by the adaptive throttle.
64
+ */
65
+ compactInProgress = false;
60
66
  providerName = 'qwen';
61
67
  /** The chat a send must join (set by the agent loop; see types.ts). */
62
68
  resumeChatId = null;
@@ -122,6 +128,7 @@ export class QwenBrowser {
122
128
  _appliedThinking;
123
129
  _appliedWebSearch;
124
130
  _apiAuth;
131
+ /** Last error from fetchChatMessages ('' on success). */
125
132
  _lastHistoryError;
126
133
  _netBody;
127
134
  _netHookInstalled;
@@ -1650,6 +1657,10 @@ export class QwenBrowser {
1650
1657
  async _waitForSendSlot(agent) {
1651
1658
  if (!agent)
1652
1659
  return;
1660
+ // A COMPACTION send goes into a FRESH chat with a tiny prompt; the
1661
+ // fixed gap learned in the OLD chat must not delay the recovery.
1662
+ if (this.compactInProgress)
1663
+ return;
1653
1664
  if (!this._lastSentAt)
1654
1665
  return;
1655
1666
  const gap = this.sendIntervalMs() - (Date.now() - this._lastSentAt);
@@ -71,13 +71,25 @@ export function scrapeQwenChatMessages(opts) {
71
71
  const uniq = Array.from(new Set(containers));
72
72
  // Keep only the OUTERMOST block of a nested pair.
73
73
  const blocks = uniq.filter((b) => !uniq.some((o) => o !== b && o.contains(b)));
74
+ // A REASONING block is UI state, not the answer. It is not always a PARENT
75
+ // of the text (a provider may render it as a sibling of the answer), so the
76
+ // message text is read from a CLONE with the reasoning subtrees removed —
77
+ // otherwise the restored dialogue printed the whole THINK phase.
78
+ const contentOf = (e) => {
79
+ const clone = e.cloneNode(true);
80
+ for (const t of Array.from(clone.querySelectorAll('*'))) {
81
+ if (thinkRe.test((t.className || '').toString()))
82
+ t.remove();
83
+ }
84
+ return (clone.textContent || '').trim();
85
+ };
74
86
  const out = [];
75
87
  for (const c of blocks) {
76
88
  let node = c.querySelector('[class*="markdown"], [class*="answer"]');
77
89
  const isAssistant = assistantRe.test(c.className || '') || !!node;
78
90
  if (!node)
79
91
  node = c;
80
- const text = (node.textContent || '').trim();
92
+ const text = contentOf(node);
81
93
  if (!text)
82
94
  continue;
83
95
  out.push({ role: isAssistant ? 'assistant' : 'user', text });
@@ -1,3 +1,4 @@
1
+ import { SERVER_BUSY_RE } from './qwen-ui.js';
1
2
  // Extraction of the RAW model answer from Qwen Chat's (chat.qwen.ai) network
2
3
  // data. The DOM distorts the answer (markdown/LaTeX/link auto-detection), so
3
4
  // — exactly like the DeepSeek path (net-capture.ts) — the answer the agent
@@ -316,7 +317,12 @@ export function isServerBusyText(body) {
316
317
  const errNotice = /event:\s*error|"type"\s*:\s*"error"|"error"\s*:/i.test(b);
317
318
  if (!errNotice && hasAnswerContent(b))
318
319
  return false;
319
- return /server is busy|service unavailable|try again later/i.test(b);
320
+ // A bare notice without JSON framing (`Service unavailable`) is still a
321
+ // notice; a sentence that merely mentions one is not (SERVER_BUSY_RE needs
322
+ // the clause to start at the beginning / after punctuation / after a JSON
323
+ // key).
324
+ return (SERVER_BUSY_RE.test(b) ||
325
+ /^(?:please )?try again later[.!]?$/i.test(b.trim()));
320
326
  }
321
327
  // The chat's CONTEXT WINDOW is exhausted. Qwen refuses the turn and no retry
322
328
  // in the same chat can succeed - the only way out is a compaction into a
package/dist/qwen-ui.js CHANGED
@@ -171,7 +171,14 @@ export const LOGIN_SELECTORS = [
171
171
  // ---------- service statuses / errors ----------
172
172
  // Qwen's rate-limit / busy notices (best-effort; the toast may be short-lived).
173
173
  export const RATE_LIMIT_RE = /too many requests|rate limit|too frequent|please try again later|слишком часто/i;
174
- export const SERVER_BUSY_RE = /server is busy|service unavailable|try again later/i;
174
+ // A notice, not a phrase inside the answer (same lesson as the DeepSeek
175
+ // pattern): the classifier also requires an error payload or an answer-less
176
+ // body, and the phrase must stand alone.
177
+ // A NOTICE, not a phrase inside a reply: the phrase must start a clause
178
+ // (start of the text, after a newline/punctuation/colon, or right after a
179
+ // `"content":"` / `"error":"` JSON key) and end one. A normal answer that
180
+ // says "try again later" therefore does not look like a dead server.
181
+ export const SERVER_BUSY_RE = /(?:^|[\n!?:]\s*|\.\s+|"(?:content|error)"\s*:\s*")(?:server is busy|service unavailable|try again later)(?=[.!,]|"|$)/im;
175
182
  // ---------- toggles (thinking mode / web search) ----------
176
183
  // Qwen exposes thinking as a MODE dropdown (`.qwen-thinking-selector`, label
177
184
  // "Auto" by default) with options Auto / Thinking / Fast — NOT a boolean
@@ -0,0 +1,304 @@
1
+ // The RESTORED-DIALOGUE layer: what the operator sees when a chat is
2
+ // reopened (/resume, /resume-id, startup). Every provider's stored history is
3
+ // raw protocol text — the system prompt, tool results, tool-call JSON, the
4
+ // model's safety block — so a restore must KEEP only the operator's own turns
5
+ // and the agent's `respond` message. The formatting used to live inside the
6
+ // DeepSeek DOM scraper, which is why only DeepSeek produced a readable
7
+ // dialogue: Qwen/GLM store the SAME raw text through the same endpoints, so
8
+ // normalization and truncation moved HERE, where every provider shares them.
9
+ import { parseToolCall } from './answer-parse.js';
10
+ const NL = String.fromCharCode(10);
11
+ /** How many messages of the dialogue are printed by default. */
12
+ export const RESTORED_HISTORY_LIMIT = 20;
13
+ /**
14
+ * Longest restored message KEPT for display. The system prompt (leaked into
15
+ * history) is far bigger and is filtered by the noise checks below; a real
16
+ * answer above this size is shortened instead of being dropped.
17
+ */
18
+ const RESTORED_MESSAGE_MAX = 100_000;
19
+ /**
20
+ * DeepSeek stores the assistant turn VERBATIM: either a raw tool-call JSON
21
+ * ({"tool":"Read","args":{...}}, frequently dirty/truncated) or a `respond`
22
+ * call that carries the real message for the operator
23
+ * ({"tool":"respond","args":{"message":"..."}}). The operator only wants the
24
+ * `respond` message (and plain-text answers) — a tool-call is protocol noise.
25
+ *
26
+ * Returns the tool name and, for `respond`, the unwrapped message. Tolerant on
27
+ * purpose: the stored JSON is often dirty (a stray `}}`, a missing "args"
28
+ * wrapper), so a bare JSON.parse is not enough.
29
+ */
30
+ export function parseAssistantToolCall(text) {
31
+ const t = String(text || '').trim();
32
+ if (!t)
33
+ return null;
34
+ // Broken heads like `<|tool": ...` or `tool": "Bash"` are tolerated.
35
+ const m = t.match(/tool"?\s*:\s*"([^"\s]+)"/);
36
+ if (!m)
37
+ return null;
38
+ const tool = m[1];
39
+ if (tool !== 'respond')
40
+ return { tool };
41
+ const greedy = t.match(/"message"\s*:\s*"([\s\S]*)"\s*\}*\s*$/);
42
+ let raw = greedy ? greedy[1] : '';
43
+ if (!greedy) {
44
+ // The value is not a quoted string (a number, a boolean, an array, a
45
+ // nested object): there is no operator-facing MESSAGE here. The old
46
+ // fallback sliced the raw text from the key and the restored turn printed
47
+ // `:42}}` / `:[1,2]}}` as if it were the model's answer.
48
+ const value = t.slice(t.indexOf('"message"') + 9).replace(/^\s*:\s*/, '');
49
+ if (!value.startsWith('"'))
50
+ return { tool, message: '' };
51
+ raw = value.replace(/^"/, '');
52
+ }
53
+ return { tool, message: unescapeJsonString(raw) };
54
+ }
55
+ /** Decode the JSON escape sequences that appear inside a captured string. */
56
+ export function unescapeJsonString(s) {
57
+ // A `\uD800`-style escape that is NOT followed by a low surrogate (a
58
+ // truncated or hand-written payload) would become a LONE surrogate - a
59
+ // broken code point the restored dialogue prints as `?`. Decode a valid PAIR
60
+ // as one code point and drop an unpaired half.
61
+ let pendingHigh = '';
62
+ return String(s || '')
63
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_x, h) => {
64
+ const cp = parseInt(h, 16);
65
+ if (cp >= 0xd800 && cp <= 0xdbff) {
66
+ pendingHigh = String.fromCharCode(cp);
67
+ return '';
68
+ }
69
+ if (cp >= 0xdc00 && cp <= 0xdfff) {
70
+ const high = pendingHigh;
71
+ pendingHigh = '';
72
+ return high ? high + String.fromCharCode(cp) : '';
73
+ }
74
+ pendingHigh = '';
75
+ return String.fromCharCode(cp);
76
+ })
77
+ .replace(/\\n/g, NL)
78
+ .replace(/\\r/g, String.fromCharCode(13))
79
+ .replace(/\\t/g, String.fromCharCode(9))
80
+ .replace(/\\"/g, '"')
81
+ .replace(/\\\\/g, '\\');
82
+ }
83
+ /**
84
+ * Normalize one restored message for display:
85
+ * - USER: drop the protocol noise (system-prompt, tool results, nudges) — it
86
+ * is not the operator's words.
87
+ * - ASSISTANT: unwrap a `respond` call to its message; drop any other
88
+ * tool-call (protocol noise); keep plain-text answers.
89
+ * Returns null when the message must not be shown.
90
+ */
91
+ export function normalizeRestoredMessage(m) {
92
+ let text = String((m && m.text) || '').trim();
93
+ if (!text)
94
+ return null;
95
+ // The size cap exists to keep a leaked SYSTEM PROMPT (or another giant
96
+ // protocol blob) out of the restored dialogue. It must NOT drop a real
97
+ // answer: a long report/diff/document easily exceeds the cap, and dropping
98
+ // it silently left the operator with a dialogue where the agent's own reply
99
+ // was missing. Keep such a message but shorten it for display.
100
+ if (text.length > RESTORED_MESSAGE_MAX) {
101
+ // Never split a surrogate PAIR: the cut is in UTF-16 units, so a high half
102
+ // alone would reach the terminal as a broken code point (same rule as
103
+ // truncateToolResult / capReport / cutHookText).
104
+ let cut = RESTORED_MESSAGE_MAX;
105
+ const last = text.charCodeAt(cut - 1);
106
+ if (last >= 0xd800 && last <= 0xdbff)
107
+ cut--;
108
+ text = text.slice(0, cut) + '…';
109
+ }
110
+ if (m.role === 'user') {
111
+ if (/^Tool result for /.test(text))
112
+ return null;
113
+ if (/^You are a coding agent running in a terminal/.test(text))
114
+ return null;
115
+ if (/^You stopped after a tool result/.test(text))
116
+ return null;
117
+ // The agent loop's own watchdog nudge is protocol noise written by US, not
118
+ // by the operator: when an answer timed out the loop resends this text as
119
+ // the "user" message. It used to leak into the restored dialogue (a dozen
120
+ // copies after a flaky run), so drop it like the other nudges.
121
+ if (/^The previous turn was cut off/.test(text))
122
+ return null;
123
+ if (/^\[system\]/.test(text))
124
+ return null;
125
+ if (/^The user ran \//.test(text))
126
+ return null;
127
+ // `/compact` posts its summary as a USER turn, prefixed with a marker the
128
+ // loop writes (not the operator): a restored dialogue should show the
129
+ // TASK, not the mechanical handover.
130
+ if (/^Compact the conversation so far/.test(text))
131
+ return null;
132
+ if (/^This is the handover summary/.test(text))
133
+ return null;
134
+ const stripped = stripUserFraming(text);
135
+ if (!stripped)
136
+ return null;
137
+ return { role: 'user', text: stripped };
138
+ }
139
+ // Reasoning is UI state, not the answer.
140
+ text = stripThinkBlocks(text);
141
+ if (!text)
142
+ return null;
143
+ // Only a real DSML MARKER is protocol noise: a bare mention of the feature
144
+ // (an answer explaining the DSML parser) is a normal reply.
145
+ if (/[<|]\s*DSML\s*[>|]/i.test(text))
146
+ return null;
147
+ if (/^<system>/i.test(text))
148
+ return null;
149
+ // The model sometimes emits its own safety-classification block as a plain
150
+ // "answer"; it is not for the operator.
151
+ if (/<ds_safety>/i.test(text))
152
+ return null;
153
+ if (/^Safe$/i.test(text))
154
+ return null;
155
+ // EVERY format the parser knows, not just the legacy `{"tool":...}` head.
156
+ const generic = toolCallOf(text);
157
+ if (generic) {
158
+ if (generic.tool !== 'respond')
159
+ return null;
160
+ const msg = (generic.message || '').trim();
161
+ if (!msg)
162
+ return null;
163
+ if (/<\/?ds_safety\b/i.test(msg))
164
+ return null;
165
+ return { role: 'assistant', text: msg };
166
+ }
167
+ const legacy = parseAssistantToolCall(text);
168
+ if (legacy) {
169
+ if (legacy.tool !== 'respond')
170
+ return null;
171
+ const msg = (legacy.message || '').trim();
172
+ if (!msg)
173
+ return null;
174
+ if (/<\/?ds_safety\b/i.test(msg))
175
+ return null;
176
+ return { role: 'assistant', text: msg };
177
+ }
178
+ // A plain-text answer (written without the respond wrapper) — show as-is.
179
+ return { role: 'assistant', text };
180
+ }
181
+ /** Back-compat predicate: is the message worth showing at all? */
182
+ export function isDisplayableMessage(m) {
183
+ return normalizeRestoredMessage(m) !== null;
184
+ }
185
+ /**
186
+ * Keep only the last `limit` messages, dropping protocol noise and UNWRAPPING
187
+ * the assistant `respond` calls to their operator-facing text.
188
+ */
189
+ export function trimRestoredMessages(messages, limit = RESTORED_HISTORY_LIMIT) {
190
+ const clean = [];
191
+ for (const m of messages || []) {
192
+ if (!m || typeof m.text !== 'string')
193
+ continue;
194
+ const n = normalizeRestoredMessage({
195
+ role: m.role === 'user' ? 'user' : 'assistant',
196
+ text: String(m.text),
197
+ });
198
+ if (n)
199
+ clean.push(n);
200
+ }
201
+ if (limit > 0 && clean.length > limit)
202
+ return clean.slice(clean.length - limit);
203
+ return clean;
204
+ }
205
+ /**
206
+ * Render the restored dialogue as plain text lines: "❯ ..." for the operator,
207
+ * "● ..." for the agent. Markdown rendering is done by the caller (it needs
208
+ * the terminal width); this helper is pure and unit-tested.
209
+ */
210
+ export function formatRestoredHistory(messages, opts = {}) {
211
+ const list = trimRestoredMessages(messages, opts.limit ?? RESTORED_HISTORY_LIMIT);
212
+ const out = [];
213
+ for (const m of list) {
214
+ const marker = m.role === 'user' ? '❯ ' : '● ';
215
+ out.push(marker + m.text.trim());
216
+ }
217
+ return out.join(NL + NL);
218
+ }
219
+ /**
220
+ * Remove the reasoning a provider renders INTO the message text.
221
+ *
222
+ * Qwen/GLM expose the THINK phase as part of the turn, and a DeepSeek DOM
223
+ * scrape keeps the collapsible "Thought for N seconds" header after the
224
+ * reasoning subtree is stripped. Reasoning is NOT the answer, so a restored
225
+ * dialogue must not show it.
226
+ */
227
+ export function stripThinkBlocks(text) {
228
+ let t = String(text || '');
229
+ // A fenced or tagged reasoning block, the shape every provider uses when it
230
+ // stores the phase inline.
231
+ t = t.replace(/<(think|thinking|reasoning)>[\s\S]*?<\/\1>\s*/gi, '');
232
+ t = t.replace(/```(?:think|thinking|reasoning)\s*[\s\S]*?```\s*/gi, '');
233
+ // "Thought for 1 second" / "Think for 12s" — a header LINE. Live DOM
234
+ // scrape: the header, then the answer ("Thought for 1 second" + NEWLINE +
235
+ // "OK2"); the reasoning BODY was already removed with its subtree.
236
+ // A single newline: with the blank-line form the `[\s\S]*?` below ate
237
+ // the whole answer and the restored turn vanished.
238
+ t = t.replace(/^\s*(?:thought|thinking|think)\s+for\s+[^\n]*\n?/i, '');
239
+ // The DOM may hand the header WITHOUT its newline ("Thought for 1
240
+ // secondOK2"): drop the header up to the first non-space run only when a
241
+ // line break or the end of the text follows it.
242
+ return t.trim();
243
+ }
244
+ /**
245
+ * Drop a provider's OWN framing from a user turn so the restored dialogue
246
+ * shows the operator's words: DeepSeek stores "You are a coding agent ..." as a
247
+ * USER message and prefixes a task with `<system>...</system>`.
248
+ */
249
+ export function stripUserFraming(text) {
250
+ let t = String(text || '');
251
+ for (let i = 0; i < 3; i++) {
252
+ const next = t.replace(/^\s*<system>[\s\S]*?<\/system>\s*/i, '');
253
+ if (next === t)
254
+ break;
255
+ t = next;
256
+ }
257
+ return t.trim();
258
+ }
259
+ /**
260
+ * A turn that is a TOOL CALL (any provider, any format the parser knows) is
261
+ * protocol noise in the restored dialogue, EXCEPT a `respond` call, whose
262
+ * message is the agent's real answer.
263
+ *
264
+ * Returns the call when it is one, `null` otherwise, and the operator-facing
265
+ * message for a `respond`.
266
+ */
267
+ export function toolCallOf(text) {
268
+ const t = String(text || '').trim();
269
+ if (!t)
270
+ return null;
271
+ // A real call starts with a bracket, a marker or the `tool` key; the strict
272
+ // parser is only consulted then, so ordinary prose is never re-parsed (and a
273
+ // bare "tool:" inside a report does not turn the report into a call).
274
+ if (!/^[\s\[\{<]/.test(t) && !/tool"?\s*:/.test(t.slice(0, 200))) {
275
+ return null;
276
+ }
277
+ let calls = [];
278
+ try {
279
+ const parsed = parseToolCall(t);
280
+ calls = (Array.isArray(parsed) ? parsed : parsed ? [parsed] : []);
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ if (!calls.length)
286
+ return null;
287
+ const first = calls[0];
288
+ if (first.tool === 'respond') {
289
+ const msg = first.args?.message;
290
+ return { tool: 'respond', message: typeof msg === 'string' ? msg : '' };
291
+ }
292
+ return { tool: first.tool };
293
+ }
294
+ /** Convert a provider history to the shared restored-message shape. */
295
+ export function toRestoredMessages(messages) {
296
+ const out = [];
297
+ for (const m of messages || []) {
298
+ if (!m || typeof m.text !== 'string')
299
+ continue;
300
+ const role = m.role === 'user' ? 'user' : 'assistant';
301
+ out.push({ role, text: m.text });
302
+ }
303
+ return out;
304
+ }
@@ -252,6 +252,14 @@ export class TuiEditor {
252
252
  }
253
253
  }
254
254
  // ---------- submission ----------
255
+ /**
256
+ * Submit a message as if the operator pressed Enter. The demo recorder
257
+ * (scripts/demo-replay.mts) needs the REAL entry path — the turn rule and
258
+ * the bar-marked rows — without a keypress.
259
+ */
260
+ submitQuestion(text) {
261
+ this.handleSubmit(text, []);
262
+ }
255
263
  handleSubmit(display, atts) {
256
264
  const text = expandPastes(this.store.getSnapshot().pastes, display);
257
265
  this.attachments.reset();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.90.16",
3
+ "version": "2.90.18",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen or GLM — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {