zames_pro 2.90.15 → 2.90.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  //
@@ -27,7 +27,7 @@ import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
27
27
  import { theme } from './theme.js';
28
28
  import { recoverFromContextLimit } from './context-limit.js';
29
29
  import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
30
- import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, writeAuthMarker, } from './browser-pure.js';
30
+ import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, needsHeadlessUaFix, cleanSingletonFiles, profileLooksLocked, nukeProfile, writeAuthMarker, } from './browser-pure.js';
31
31
  import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
32
32
  import { probeUiHealth, } from './ui-health.js';
33
33
  import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, AGE_GATE_YEAR_SELECT, AGE_GATE_YEAR_OPTION, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
@@ -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;
@@ -264,7 +271,12 @@ export class QwenBrowser {
264
271
  async _fixHeadlessUserAgent() {
265
272
  if (!this.headless)
266
273
  return;
267
- if (this.userAgent)
274
+ // A UA that is ALREADY sanitized came from the cache; there is nothing to
275
+ // fix and nothing to relaunch for. Any other value (including an empty
276
+ // one) is the engine's own UA, which may still carry the marker — the old
277
+ // blanket `if (this.userAgent) return` skipped the fix for exactly that
278
+ // case, leaving `HeadlessChrome` in the live browser.
279
+ if (!needsHeadlessUaFix(this.userAgent))
268
280
  return;
269
281
  try {
270
282
  const ua = await this.page.evaluate(readUserAgentInDom);
@@ -272,18 +284,20 @@ export class QwenBrowser {
272
284
  if (fixed === ua)
273
285
  return;
274
286
  this.userAgent = fixed;
275
- void writeCachedHeadlessUA(headlessEngineKey(), fixed);
287
+ await writeCachedHeadlessUA(headlessEngineKey(), fixed);
276
288
  if (this.debug)
277
289
  console.error('qwen profile: headless UA -> relaunching plain Chrome UA');
278
290
  await this.context.close().catch(() => { });
279
- await this._launchOnce();
291
+ // Apply the UA to THIS launch explicitly: re-reading it from the fresh
292
+ // page would keep the marker the fix exists to remove.
293
+ await this._launchOnce(fixed);
280
294
  }
281
295
  catch (e) {
282
296
  if (this.debug)
283
297
  console.error('qwen profile: UA fix failed:', e.message);
284
298
  }
285
299
  }
286
- async _launchOnce() {
300
+ async _launchOnce(uaOverride) {
287
301
  const options = {
288
302
  headless: this.headless,
289
303
  slowMo: 10,
@@ -291,8 +305,9 @@ export class QwenBrowser {
291
305
  };
292
306
  if (this.channel)
293
307
  options.channel = this.channel;
294
- if (this.userAgent)
295
- options.userAgent = this.userAgent;
308
+ const ua = uaOverride || this.userAgent;
309
+ if (ua)
310
+ options.userAgent = ua;
296
311
  try {
297
312
  this.context = await chromium.launchPersistentContext(QWEN_USER_DATA_DIR, options);
298
313
  }
@@ -1642,6 +1657,10 @@ export class QwenBrowser {
1642
1657
  async _waitForSendSlot(agent) {
1643
1658
  if (!agent)
1644
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;
1645
1664
  if (!this._lastSentAt)
1646
1665
  return;
1647
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.15",
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": {