zames_pro 2.73.7 → 2.73.8
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 +7 -0
- package/dist/agent-loop.js +58 -0
- package/dist/attach-flow.js +48 -0
- package/dist/browser.js +13 -39
- package/dist/commands.js +4 -0
- package/dist/extraTools.js +2 -2
- package/dist/i18n/common.js +2 -2
- package/dist/i18n/doctor.js +4 -0
- package/dist/i18n/help.js +1 -0
- package/dist/i18n/qwen.js +8 -0
- package/dist/index.js +18 -2
- package/dist/input/layout.js +29 -0
- package/dist/input.js +23 -2
- package/dist/qwen-browser.js +75 -41
- package/dist/qwen-ui.js +6 -0
- package/dist/shell.js +16 -0
- package/dist/tools.js +2 -2
- package/dist/transcript.js +22 -1
- package/dist/xml-toolcall.js +29 -0
- package/package.json +1 -1
- package/scripts/qwen-smoke.mts +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.73.8] - 2026-10-12
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- bound qwen net body, share attach flow, count transcript backpressure, buffer split paste
|
|
15
|
+
- lose fewer tool args, unstick the arg-error loop, harden launch
|
|
16
|
+
|
|
10
17
|
## [2.73.7] - 2026-10-12
|
|
11
18
|
|
|
12
19
|
### Changed
|
package/dist/agent-loop.js
CHANGED
|
@@ -232,6 +232,18 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
232
232
|
let repeatCallCount = 0;
|
|
233
233
|
const MAX_REPEAT_CALLS = 4;
|
|
234
234
|
let repeatWarned = false;
|
|
235
|
+
// Consecutive tool calls that fail with an ARGUMENT error (`Missing required
|
|
236
|
+
// argument`, `Invalid required argument`). Such a call has a DIFFERENT
|
|
237
|
+
// signature each turn (a long `content` changes), so the identical-signature
|
|
238
|
+
// detector above never fires and the model can burn many iterations on the
|
|
239
|
+
// same omission. Track the tool + the error text instead; after a few in a
|
|
240
|
+
// row, nudge with the exact missing field and stop re-running it.
|
|
241
|
+
let argErrSig = '';
|
|
242
|
+
let argErrCount = 0;
|
|
243
|
+
const MAX_ARG_ERRORS = 3;
|
|
244
|
+
// Set when the argument-error series hits the cap; appended to the message
|
|
245
|
+
// AFTER the tool results are formatted (they rebuild `message`).
|
|
246
|
+
let argErrNudge = '';
|
|
235
247
|
let afterToolRetries = 0;
|
|
236
248
|
// toolsRanInTask: how many tools actually ran in THIS task. The protocol
|
|
237
249
|
// guard uses it to tell "started, then slipped into chat mode" (a reasoning
|
|
@@ -976,6 +988,45 @@ ${diag}`;
|
|
|
976
988
|
durationMs: toolMs,
|
|
977
989
|
});
|
|
978
990
|
results.push({ tool: call.tool, result });
|
|
991
|
+
// Argument-error series: `Missing required argument`/`Invalid required
|
|
992
|
+
// argument` from the req() guard. The signature changes each turn when
|
|
993
|
+
// the model forgets one field but rewrites the rest, so the identical
|
|
994
|
+
// detector misses it. Count by tool + error text; at the cap, tell the
|
|
995
|
+
// model EXACTLY which field is missing instead of re-running the call.
|
|
996
|
+
const resStr = String(result);
|
|
997
|
+
const argErr = resStr.match(/(?:Missing|Invalid) required argument[: ]+([A-Za-z0-9_]+)/);
|
|
998
|
+
if (argErr) {
|
|
999
|
+
const sig = call.tool + ':' + argErr[0];
|
|
1000
|
+
if (sig === argErrSig)
|
|
1001
|
+
argErrCount++;
|
|
1002
|
+
else {
|
|
1003
|
+
argErrSig = sig;
|
|
1004
|
+
argErrCount = 1;
|
|
1005
|
+
}
|
|
1006
|
+
if (argErrCount >= MAX_ARG_ERRORS) {
|
|
1007
|
+
argErrCount = 0;
|
|
1008
|
+
argErrSig = '';
|
|
1009
|
+
safeWarning(translate(locale)('msg.arg_error_loop'));
|
|
1010
|
+
// The message the model sees is REBUILT from `results` after the
|
|
1011
|
+
// batch loop, so a nudged `message` here would be overwritten.
|
|
1012
|
+
// Record the nudge and append it after the results are formatted.
|
|
1013
|
+
argErrNudge =
|
|
1014
|
+
'IMPORTANT: the tool `' +
|
|
1015
|
+
call.tool +
|
|
1016
|
+
'` failed with "' +
|
|
1017
|
+
argErr[0] +
|
|
1018
|
+
'". You keep calling it WITHOUT the required argument `' +
|
|
1019
|
+
argErr[1] +
|
|
1020
|
+
'`. Add `' +
|
|
1021
|
+
argErr[1] +
|
|
1022
|
+
'` to the arguments of the SAME call and retry — do NOT repeat ' +
|
|
1023
|
+
'the identical invocation.';
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
argErrSig = '';
|
|
1028
|
+
argErrCount = 0;
|
|
1029
|
+
}
|
|
979
1030
|
// The operator pressed Esc/Ctrl+C while the tool was running. The tool
|
|
980
1031
|
// itself has finished (we cannot kill an arbitrary child process from
|
|
981
1032
|
// here), but we must NOT feed its result back to the model and must NOT
|
|
@@ -1023,6 +1074,13 @@ ${diag}`;
|
|
|
1023
1074
|
})
|
|
1024
1075
|
.join('\n\n');
|
|
1025
1076
|
}
|
|
1077
|
+
// Append the argument-error nudge (set inside the batch loop) now that
|
|
1078
|
+
// `message` has been rebuilt from `results`. Cleared so it fires once per
|
|
1079
|
+
// series, not on every later turn.
|
|
1080
|
+
if (argErrNudge) {
|
|
1081
|
+
message = message + '\n\n' + argErrNudge;
|
|
1082
|
+
argErrNudge = '';
|
|
1083
|
+
}
|
|
1026
1084
|
// Auto-compact at the ONLY safe seam — after a tool result and before
|
|
1027
1085
|
// the next send. Never mid-generation, never during a rate-limit wait, and
|
|
1028
1086
|
// never between the task and the first send (this block runs only after a
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
/**
|
|
3
|
+
* Read every file into a Playwright `setFiles` payload. A file that cannot be
|
|
4
|
+
* read is pushed onto `failed` (with a notice through `onReadError`) instead of
|
|
5
|
+
* throwing, so a single unreadable file never aborts the whole attach. Returns
|
|
6
|
+
* the successfully read payloads.
|
|
7
|
+
*/
|
|
8
|
+
export async function readAttachBuffers(files, onReadError) {
|
|
9
|
+
const payload = [];
|
|
10
|
+
const failed = [];
|
|
11
|
+
for (const f of files) {
|
|
12
|
+
try {
|
|
13
|
+
const buffer = await fs.readFile(f.path);
|
|
14
|
+
payload.push({ name: f.name, mimeType: f.mime, buffer });
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
failed.push({ path: f.path, name: f.name });
|
|
18
|
+
onReadError(f.name, e.message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { payload, failed };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wait for the upload preview (the attached-file chip above the input) to
|
|
25
|
+
* appear, instead of a fixed sleep. The signal is the FILE NAME showing up on
|
|
26
|
+
* the page: files are attached BEFORE the text is typed, so the name cannot
|
|
27
|
+
* appear there for any other reason. `timeoutMs` is the ceiling, so a build
|
|
28
|
+
* where the name is not detected behaves exactly like the old fixed sleep.
|
|
29
|
+
*/
|
|
30
|
+
export async function awaitUploadPreview(page, names, timeoutMs) {
|
|
31
|
+
const clean = names.map((n) => String(n || '').trim()).filter(Boolean);
|
|
32
|
+
if (!clean.length) {
|
|
33
|
+
await page.waitForTimeout(timeoutMs);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const deadline = Date.now() + timeoutMs;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
for (const name of clean) {
|
|
39
|
+
const n = await page
|
|
40
|
+
.getByText(name, { exact: false })
|
|
41
|
+
.count()
|
|
42
|
+
.catch(() => 0);
|
|
43
|
+
if (n > 0)
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
await page.waitForTimeout(150);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/browser.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chromium, } from 'playwright';
|
|
2
2
|
import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
|
|
3
3
|
import { rewriteFailedAttachments } from './commands.js';
|
|
4
|
+
import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
|
|
4
5
|
import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
|
|
5
6
|
import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, MFA_CODE_SELECTORS, MFA_SUBMIT_SELECTORS, MFA_SEND_CODE_SELECTORS, MFA_SEND_CODE_RE, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, ANSWER_URL_RE, } from './deepseek-ui.js';
|
|
6
7
|
import { probeUiHealth, } from './ui-health.js';
|
|
@@ -360,7 +361,13 @@ export class DeepSeekBrowser {
|
|
|
360
361
|
}
|
|
361
362
|
this.page = this.context.pages()[0] || (await this.context.newPage());
|
|
362
363
|
this._installNetHook();
|
|
363
|
-
|
|
364
|
+
// A slow network / cold CDN can exceed Playwright's 30s goto timeout.
|
|
365
|
+
// That must NOT abort launch(): the page is usually already usable and
|
|
366
|
+
// waitForLogin() polls. Bailing out made a transient slowness a hard
|
|
367
|
+
// startup failure.
|
|
368
|
+
await this.page
|
|
369
|
+
.goto(CHAT_URL, { waitUntil: 'domcontentloaded' })
|
|
370
|
+
.catch(() => { });
|
|
364
371
|
}
|
|
365
372
|
_installNetHook() {
|
|
366
373
|
const pg = this['page'];
|
|
@@ -1516,21 +1523,7 @@ export class DeepSeekBrowser {
|
|
|
1516
1523
|
// message to [attach-failed: name]. The model must not believe a file was
|
|
1517
1524
|
// attached when it was not.
|
|
1518
1525
|
async _attachFiles(files) {
|
|
1519
|
-
const failed =
|
|
1520
|
-
const bufPayload = [];
|
|
1521
|
-
for (const f of files) {
|
|
1522
|
-
try {
|
|
1523
|
-
const buffer = await fs.readFile(f.path);
|
|
1524
|
-
bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
|
|
1525
|
-
}
|
|
1526
|
-
catch (e) {
|
|
1527
|
-
failed.push({ path: f.path, name: f.name });
|
|
1528
|
-
this._notice(theme.warn(this._t('ds.attach_read_failed', {
|
|
1529
|
-
name: f.name,
|
|
1530
|
-
error: e.message,
|
|
1531
|
-
})));
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1526
|
+
const { payload: bufPayload, failed } = await readAttachBuffers(files, (name, error) => this._notice(theme.warn(this._t('ds.attach_read_failed', { name, error }))));
|
|
1534
1527
|
if (!bufPayload.length)
|
|
1535
1528
|
return failed;
|
|
1536
1529
|
// Preferred path: click the attach button and let the file chooser event
|
|
@@ -1575,30 +1568,11 @@ export class DeepSeekBrowser {
|
|
|
1575
1568
|
await this._awaitUploadPreview(files.map((f) => f.name), 2000);
|
|
1576
1569
|
return failed;
|
|
1577
1570
|
}
|
|
1578
|
-
// Wait for the upload preview (the attached-file chip above the
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
// so the name cannot appear there for any other reason. The old fixed value
|
|
1582
|
-
// stays as the TIMEOUT CEILING, so a build where the name is not detected
|
|
1583
|
-
// behaves exactly as before.
|
|
1571
|
+
// Wait for the upload preview (the attached-file chip above the input) to
|
|
1572
|
+
// appear, instead of a fixed sleep. Delegates to the shared helper so both
|
|
1573
|
+
// providers use the SAME preview policy.
|
|
1584
1574
|
async _awaitUploadPreview(names, timeoutMs) {
|
|
1585
|
-
|
|
1586
|
-
if (!clean.length) {
|
|
1587
|
-
await this.page.waitForTimeout(timeoutMs);
|
|
1588
|
-
return;
|
|
1589
|
-
}
|
|
1590
|
-
const deadline = Date.now() + timeoutMs;
|
|
1591
|
-
while (Date.now() < deadline) {
|
|
1592
|
-
for (const name of clean) {
|
|
1593
|
-
const n = await this.page
|
|
1594
|
-
.getByText(name, { exact: false })
|
|
1595
|
-
.count()
|
|
1596
|
-
.catch(() => 0);
|
|
1597
|
-
if (n > 0)
|
|
1598
|
-
return;
|
|
1599
|
-
}
|
|
1600
|
-
await this.page.waitForTimeout(150);
|
|
1601
|
-
}
|
|
1575
|
+
await awaitUploadPreview(this.page, names, timeoutMs);
|
|
1602
1576
|
}
|
|
1603
1577
|
// Wait out a rate-limit pause, showing an animated countdown through
|
|
1604
1578
|
// onSendPause so the operator sees the agent is alive (not frozen). Returns
|
package/dist/commands.js
CHANGED
|
@@ -1044,8 +1044,12 @@ export const SLASH_COMMANDS = [
|
|
|
1044
1044
|
export function buildSlashCommandHints(opts) {
|
|
1045
1045
|
const { devMode, t } = opts;
|
|
1046
1046
|
const table = opts.commands ?? SLASH_COMMANDS;
|
|
1047
|
+
// `/model` picks a QWEN model; DeepSeek has no such command. Hide it unless
|
|
1048
|
+
// the Qwen provider is active (or the caller did not pass a provider).
|
|
1049
|
+
const hideModel = opts.provider !== undefined && opts.provider !== 'qwen';
|
|
1047
1050
|
const base = table
|
|
1048
1051
|
.filter((c) => devMode || !isDevOnlyCommand(c.name))
|
|
1052
|
+
.filter((c) => !(hideModel && c.name === '/model'))
|
|
1049
1053
|
.map((c) => ({ name: c.name, description: t(c.key) }));
|
|
1050
1054
|
const known = new Set(base.map((c) => c.name.toLowerCase()));
|
|
1051
1055
|
for (const d of opts.dynamic ?? []) {
|
package/dist/extraTools.js
CHANGED
|
@@ -178,11 +178,11 @@ export function createExtraTools(workdir, { undo, todos } = {}) {
|
|
|
178
178
|
// "undefined" in the working directory.
|
|
179
179
|
const req = (v, name) => {
|
|
180
180
|
if (v === undefined || v === null) {
|
|
181
|
-
throw new Error(`Missing required argument: ${name}
|
|
181
|
+
throw new Error(`Missing required argument: ${name}. Add "${name}" to the tool call arguments and retry.`);
|
|
182
182
|
}
|
|
183
183
|
const s = String(v);
|
|
184
184
|
if (s === '' || s === 'undefined' || s === 'null') {
|
|
185
|
-
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}
|
|
185
|
+
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}. Provide a non-empty value for "${name}" and retry.`);
|
|
186
186
|
}
|
|
187
187
|
return s;
|
|
188
188
|
};
|
package/dist/i18n/common.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Common UI strings: app/common, status, tasks, messages, diff, context, undo, cd.
|
|
2
2
|
export const commonMessages = {
|
|
3
3
|
'app.tagline': {
|
|
4
|
-
ru: 'агент поверх
|
|
5
|
-
en: 'coding agent over
|
|
4
|
+
ru: 'агент поверх {provider} через Playwright',
|
|
5
|
+
en: 'coding agent over {provider} via Playwright',
|
|
6
6
|
},
|
|
7
7
|
'common.yes': { ru: 'да', en: 'yes' },
|
|
8
8
|
'common.no': { ru: 'нет', en: 'no' },
|
package/dist/i18n/doctor.js
CHANGED
|
@@ -91,6 +91,10 @@ export const doctorMessages = {
|
|
|
91
91
|
ru: 'Агент повторяет один и тот же вызов инструмента — переключаю на другой подход.',
|
|
92
92
|
en: 'The agent keeps repeating the same tool call — switching it to a different approach.',
|
|
93
93
|
},
|
|
94
|
+
'msg.arg_error_loop': {
|
|
95
|
+
ru: 'Агент раз за разом вызывает инструмент без обязательного аргумента — прошу добавить его.',
|
|
96
|
+
en: 'The agent keeps calling a tool without a required argument — asking it to add the field.',
|
|
97
|
+
},
|
|
94
98
|
'msg.suspicious_stop': {
|
|
95
99
|
ru: 'агент, похоже, остановился, не распознав вызов инструмента. Ответ сохранён в транскрипте (событие suspicious_final). Можно попросить продолжить или переотправить задачу.',
|
|
96
100
|
en: 'the agent seems to have stopped without recognizing a tool call. The response is saved in the transcript (suspicious_final event). You can ask it to continue or resend the task.',
|
package/dist/i18n/help.js
CHANGED
|
@@ -103,6 +103,7 @@ export const helpMessages = {
|
|
|
103
103
|
'help.sec.git': { ru: 'Git', en: 'Git' },
|
|
104
104
|
'help.sec.agent': { ru: 'Агент', en: 'Agent' },
|
|
105
105
|
'help.sec.context': { ru: 'Контекст и настройки', en: 'Context & settings' },
|
|
106
|
+
'help.sec.provider': { ru: 'Провайдер чата', en: 'Chat provider' },
|
|
106
107
|
'help.sec.files': { ru: 'Файлы и прочее', en: 'Files & misc' },
|
|
107
108
|
'help.cmd.new': {
|
|
108
109
|
ru: '/new, /clear новый чат (сброс контекста)',
|
package/dist/i18n/qwen.js
CHANGED
|
@@ -50,6 +50,14 @@ export const qwenMessages = {
|
|
|
50
50
|
ru: 'Не удалось получить список моделей Qwen: {v}',
|
|
51
51
|
en: 'Failed to fetch the Qwen model list: {v}',
|
|
52
52
|
},
|
|
53
|
+
'qwen.model_deepseek_only': {
|
|
54
|
+
ru: '/model доступен только для провайдера Qwen. Сейчас активен DeepSeek (модель выбирается в веб-UI). Переключиться: /provider qwen',
|
|
55
|
+
en: '/model is only available for the Qwen provider. DeepSeek is active (its model is chosen in the web UI). Switch with: /provider qwen',
|
|
56
|
+
},
|
|
57
|
+
'qwen.age_gate_block': {
|
|
58
|
+
ru: '✖ Qwen показывает окно "Confirm your age" (гостевой режим) и не даёт отправить сообщение. Войдите в аккаунт Qwen (команда /provider login) — в гостевом режиме первое сообщение блокируется.',
|
|
59
|
+
en: '✖ Qwen shows the "Confirm your age" dialog (guest mode) and blocks the send. Sign in to Qwen (/provider login) — in guest mode the first message is blocked.',
|
|
60
|
+
},
|
|
53
61
|
'provider.switched': {
|
|
54
62
|
ru: 'Провайдер чата: {v}.',
|
|
55
63
|
en: 'Chat provider: {v}.',
|
package/dist/index.js
CHANGED
|
@@ -367,7 +367,11 @@ function printHelp() {
|
|
|
367
367
|
t('help.cmd.exit') +
|
|
368
368
|
'\n';
|
|
369
369
|
console.log(`
|
|
370
|
-
${theme.bold('zames')} — ${t('app.tagline'
|
|
370
|
+
${theme.bold('zames')} — ${t('app.tagline', {
|
|
371
|
+
provider: browserProviderUrl()
|
|
372
|
+
.replace(/^https?:\/\//, '')
|
|
373
|
+
.split('/')[0],
|
|
374
|
+
})}
|
|
371
375
|
|
|
372
376
|
${theme.bold(t('help.options'))}
|
|
373
377
|
--dir <path> ${t('help.opt.dir')}
|
|
@@ -447,6 +451,9 @@ ${theme.bold(t('help.sec.context'))}
|
|
|
447
451
|
${t('help.cmd.config')}
|
|
448
452
|
${t('help.cmd.lang')}
|
|
449
453
|
|
|
454
|
+
${theme.bold(t('help.sec.provider'))}
|
|
455
|
+
${t('help.cmd.provider')}${config.provider === 'qwen' ? '\n ' + t('help.cmd.model') : ''}
|
|
456
|
+
|
|
450
457
|
${theme.bold(t('help.sec.files'))}
|
|
451
458
|
${t('help.cmd.transcript')}
|
|
452
459
|
${t('help.cmd.undo')}
|
|
@@ -576,6 +583,7 @@ function buildSlashCommands() {
|
|
|
576
583
|
devMode,
|
|
577
584
|
t,
|
|
578
585
|
dynamic: dynamicCommands,
|
|
586
|
+
provider: config.provider,
|
|
579
587
|
});
|
|
580
588
|
}
|
|
581
589
|
// We put the agent's temporary files (one-off scripts, etc.) in
|
|
@@ -2208,7 +2216,10 @@ async function main() {
|
|
|
2208
2216
|
if (lower === '/model' || lower.startsWith('/model ')) {
|
|
2209
2217
|
const arg = trimmed.slice('/model'.length).trim();
|
|
2210
2218
|
if (config.provider !== 'qwen') {
|
|
2211
|
-
|
|
2219
|
+
// `/model` is Qwen-only: it picks a model in Qwen's header dropdown.
|
|
2220
|
+
// DeepSeek exposes no model selection from zames, so the command is
|
|
2221
|
+
// hidden from the hints and only explains itself when typed by hand.
|
|
2222
|
+
console.log(theme.system(t('qwen.model_deepseek_only')));
|
|
2212
2223
|
continue;
|
|
2213
2224
|
}
|
|
2214
2225
|
const qb = browser;
|
|
@@ -3501,6 +3512,11 @@ async function main() {
|
|
|
3501
3512
|
if (mcpPool)
|
|
3502
3513
|
await mcpPool.close().catch(() => { });
|
|
3503
3514
|
await transcript.close();
|
|
3515
|
+
// Surface a transcript that hit backpressure (events buffered in memory, not
|
|
3516
|
+
// on disk) instead of leaving it silent.
|
|
3517
|
+
const trNote = transcript.healthNote();
|
|
3518
|
+
if (trNote)
|
|
3519
|
+
console.log(theme.warn(trNote));
|
|
3504
3520
|
// One dim line so the operator can find the log and the session after
|
|
3505
3521
|
// the run (previously the path was only available via /transcript).
|
|
3506
3522
|
console.log(theme.dim(t('msg.exit_summary', {
|
package/dist/input/layout.js
CHANGED
|
@@ -367,3 +367,32 @@ export function formatCompactTokens(tokens) {
|
|
|
367
367
|
}
|
|
368
368
|
// Re-exported for the editor's layout selftest (node src/input.js --selftest).
|
|
369
369
|
export { NL, CR, ESC, PASTE_START, PASTE_END, safeJson };
|
|
370
|
+
/**
|
|
371
|
+
* Split off a trailing PARTIAL PASTE marker so a control sequence fragmented
|
|
372
|
+
* across two terminal reads is reassembled by the caller instead of leaking
|
|
373
|
+
* into the buffer as literal text. A bracketed paste sends `ESC[200~` …
|
|
374
|
+
* `ESC[201~`; a read can end anywhere inside one of those markers.
|
|
375
|
+
*
|
|
376
|
+
* Returns `{ head, tail }` where `tail` is the held-back partial marker (or
|
|
377
|
+
* null when the string does not end inside a marker). Only tails that START
|
|
378
|
+
* with ESC and are a genuine PREFIX of PASTE_START / PASTE_END are held, so an
|
|
379
|
+
* ordinary trailing `[2` typed by hand is NOT swallowed.
|
|
380
|
+
*/
|
|
381
|
+
export function splitPasteTail(s) {
|
|
382
|
+
const str = String(s ?? '');
|
|
383
|
+
if (!str)
|
|
384
|
+
return { head: str, tail: null };
|
|
385
|
+
// The longest marker prefix to consider is one char short of the marker.
|
|
386
|
+
const markers = [PASTE_START, PASTE_END];
|
|
387
|
+
const maxHold = Math.max(...markers.map((m) => m.length)) - 1;
|
|
388
|
+
const upto = Math.min(maxHold, str.length - 1);
|
|
389
|
+
for (let len = upto; len >= 1; len--) {
|
|
390
|
+
const tail = str.slice(str.length - len);
|
|
391
|
+
if (!tail.startsWith(ESC))
|
|
392
|
+
continue;
|
|
393
|
+
if (markers.some((m) => m.length > len && m.startsWith(tail))) {
|
|
394
|
+
return { head: str.slice(0, str.length - len), tail };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { head: str, tail: null };
|
|
398
|
+
}
|
package/dist/input.js
CHANGED
|
@@ -4,12 +4,12 @@ import { contentWidth } from './width.js';
|
|
|
4
4
|
import { randomThinkingPhrase, stripEllipsis, DOTS, renderDots, } from './spinner.js';
|
|
5
5
|
import { translate } from './i18n.js';
|
|
6
6
|
import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
|
|
7
|
-
import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, pasteReplacement, expandPastes, safeJson, visRows, visLen, charWidth, layoutInput, formatTokenStatus, tokenStatusLevel, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, } from './input/layout.js';
|
|
7
|
+
import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, pasteReplacement, expandPastes, safeJson, visRows, visLen, charWidth, layoutInput, formatTokenStatus, tokenStatusLevel, truncateToWidth, wrapToWidth, splitPasteTail, CONTEXT_LIMIT, } from './input/layout.js';
|
|
8
8
|
// The pure layout/formatting helpers (visual rows, cursor position, visible
|
|
9
9
|
// width, paste markers, token formatting) live in ./input/layout.js. They are
|
|
10
10
|
// re-exported here so every existing import from './input.js' keeps working,
|
|
11
11
|
// and so tests can import them from either module (C3 step 3).
|
|
12
|
-
export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
|
|
12
|
+
export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, splitPasteTail, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
|
|
13
13
|
// PURE slash-command suggestion logic (match / paging / completion), split out
|
|
14
14
|
// of the class so the fuzzy match is unit-tested without driving the editor.
|
|
15
15
|
import { matchSlashCommands, suggestWindow, completeSlashCommand, } from './input/suggest.js';
|
|
@@ -99,6 +99,12 @@ export class LineEditor {
|
|
|
99
99
|
// head of a control sequence split across two reads. We hold it briefly and
|
|
100
100
|
// fire onEscape only if no tail arrives.
|
|
101
101
|
_escPending;
|
|
102
|
+
// Tail of a control sequence split across two `data` chunks. Only the
|
|
103
|
+
// PASTE_START / PASTE_END markers need it: a chunk that ends on `ESC[2` etc.
|
|
104
|
+
// is stored here and re-joined with the next chunk, so a bracketed paste whose
|
|
105
|
+
// start marker was fragmented is still recognized (otherwise the paste bytes
|
|
106
|
+
// leaked into the buffer as literal text).
|
|
107
|
+
_ctrlTail;
|
|
102
108
|
_escTimer;
|
|
103
109
|
// History of sent messages (for the up/down arrows).
|
|
104
110
|
history;
|
|
@@ -219,6 +225,7 @@ export class LineEditor {
|
|
|
219
225
|
this._resizeTimer = null;
|
|
220
226
|
this._escPending = false;
|
|
221
227
|
this._escTimer = null;
|
|
228
|
+
this._ctrlTail = '';
|
|
222
229
|
this._onResize = () => {
|
|
223
230
|
// Debounce: a window drag fires a burst of SIGWINCH events. We wait a
|
|
224
231
|
// short moment, then redraw the block at the NEW width.
|
|
@@ -1574,6 +1581,20 @@ export class LineEditor {
|
|
|
1574
1581
|
}
|
|
1575
1582
|
s = ESC + s;
|
|
1576
1583
|
}
|
|
1584
|
+
// Reattach a control-sequence tail held from the PREVIOUS chunk (a
|
|
1585
|
+
// PASTE_START/PASTE_END marker split across reads). See _ctrlTail.
|
|
1586
|
+
if (this._ctrlTail) {
|
|
1587
|
+
s = this._ctrlTail + s;
|
|
1588
|
+
this._ctrlTail = '';
|
|
1589
|
+
}
|
|
1590
|
+
// A chunk may end in the MIDDLE of a PASTE marker (`ESC[2`, `ESC[20`, …).
|
|
1591
|
+
// Hold that tail back so the next chunk completes it; otherwise the
|
|
1592
|
+
// fragment is inserted as literal text and the paste is not bracketed.
|
|
1593
|
+
const held = splitPasteTail(s);
|
|
1594
|
+
if (held.tail) {
|
|
1595
|
+
this._ctrlTail = held.tail;
|
|
1596
|
+
s = held.head;
|
|
1597
|
+
}
|
|
1577
1598
|
if (!this._inPaste && s === ESC && !this._searchMode) {
|
|
1578
1599
|
// Bare ESC in its own chunk: wait a moment for the possible tail of a
|
|
1579
1600
|
// control sequence before treating it as the Escape key.
|
package/dist/qwen-browser.js
CHANGED
|
@@ -21,12 +21,13 @@ import os from 'os';
|
|
|
21
21
|
import { chromium } from 'playwright';
|
|
22
22
|
import { translate, DEFAULT_LOCALE } from './i18n.js';
|
|
23
23
|
import { rewriteFailedAttachments } from './commands.js';
|
|
24
|
+
import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
|
|
24
25
|
import { theme } from './theme.js';
|
|
25
26
|
import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
|
|
26
27
|
import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, AUTH_MARKER_FILE, } from './browser-pure.js';
|
|
27
28
|
import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
|
|
28
29
|
import { probeUiHealth, } from './ui-health.js';
|
|
29
|
-
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, 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';
|
|
30
|
+
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';
|
|
30
31
|
import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, isServerBusyText, } from './qwen-net-capture.js';
|
|
31
32
|
// A SEPARATE profile from DeepSeek's ~/.zames/profile. Two persistent Chromium
|
|
32
33
|
// contexts on ONE --user-data-dir cannot coexist (SingletonLock), and keeping
|
|
@@ -40,6 +41,18 @@ function escapeRegExp(s) {
|
|
|
40
41
|
const CHAT_RE = /chat\.qwen\.ai/i;
|
|
41
42
|
const ANSWER_URL_RE = /\/api\/v2\/chat\/completions/i;
|
|
42
43
|
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
44
|
+
// Cap the stored RAW SSE body. The truncated/no-answer detectors only read the
|
|
45
|
+
// markers (`quasi_status`, `finish_reason`) and the SLICE shown in errors
|
|
46
|
+
// (first/last 300 chars), so keeping a whole multi-MB stream in memory for the
|
|
47
|
+
// rest of the turn is wasteful. The first 64 KB is plenty for every marker we
|
|
48
|
+
// match; the tail (used in error messages) is kept by cutting the middle.
|
|
49
|
+
const MAX_NET_BODY = 64 * 1024;
|
|
50
|
+
function capNetBody(body) {
|
|
51
|
+
if (body.length <= MAX_NET_BODY)
|
|
52
|
+
return body;
|
|
53
|
+
const head = MAX_NET_BODY - 1024;
|
|
54
|
+
return body.slice(0, head) + '\n...[truncated]...\n' + body.slice(-1024);
|
|
55
|
+
}
|
|
43
56
|
export class QwenBrowser {
|
|
44
57
|
providerName = 'qwen';
|
|
45
58
|
headless;
|
|
@@ -268,7 +281,14 @@ export class QwenBrowser {
|
|
|
268
281
|
}
|
|
269
282
|
this.page = this.context.pages()[0] || (await this.context.newPage());
|
|
270
283
|
this._installNetHook();
|
|
271
|
-
|
|
284
|
+
// A slow network (or a cold CDN) can make the initial navigation exceed
|
|
285
|
+
// Playwright's 30s goto timeout. That must NOT abort launch(): the page is
|
|
286
|
+
// usually already usable, and every later step (_awaitInput, waitForLogin)
|
|
287
|
+
// polls. Bailing out here turned a transient slowness into a hard startup
|
|
288
|
+
// failure.
|
|
289
|
+
await this.page
|
|
290
|
+
.goto(CHAT_URL, { waitUntil: 'domcontentloaded' })
|
|
291
|
+
.catch(() => { });
|
|
272
292
|
await this._dismissOverlays();
|
|
273
293
|
}
|
|
274
294
|
async restart() {
|
|
@@ -292,6 +312,22 @@ export class QwenBrowser {
|
|
|
292
312
|
.catch(() => { });
|
|
293
313
|
const gate = this.page.locator(AGE_GATE_MODAL);
|
|
294
314
|
if (await gate.count().catch(() => 0)) {
|
|
315
|
+
// The gate has a YEAR dropdown (default already set) plus Continue.
|
|
316
|
+
// A raw click on Continue can be a no-op until a year is committed,
|
|
317
|
+
// so open the dropdown and pick an option FIRST, then Continue — and
|
|
318
|
+
// finally verify the modal actually closed. Verified live: the year
|
|
319
|
+
// must be selected via the trigger (clicking the label does nothing).
|
|
320
|
+
await this.page
|
|
321
|
+
.locator(AGE_GATE_YEAR_SELECT)
|
|
322
|
+
.first()
|
|
323
|
+
.click({ timeout: 2000 })
|
|
324
|
+
.catch(() => { });
|
|
325
|
+
await this.page.waitForTimeout(400);
|
|
326
|
+
const opt = this.page.locator(AGE_GATE_YEAR_OPTION).first();
|
|
327
|
+
if (await opt.count().catch(() => 0)) {
|
|
328
|
+
await opt.click({ timeout: 2000 }).catch(() => { });
|
|
329
|
+
await this.page.waitForTimeout(300);
|
|
330
|
+
}
|
|
295
331
|
await this.page
|
|
296
332
|
.locator(AGE_GATE_CONTINUE)
|
|
297
333
|
.first()
|
|
@@ -360,7 +396,7 @@ export class QwenBrowser {
|
|
|
360
396
|
// tomorrow).
|
|
361
397
|
if (isGuestLimitText(body)) {
|
|
362
398
|
this._netGuestLimit = true;
|
|
363
|
-
this._netBody = body;
|
|
399
|
+
this._netBody = capNetBody(body);
|
|
364
400
|
this._netCaptureAt = Date.now();
|
|
365
401
|
}
|
|
366
402
|
const isAnswerUrl = ANSWER_URL_RE.test(url);
|
|
@@ -377,7 +413,7 @@ export class QwenBrowser {
|
|
|
377
413
|
// sent it by hand. Bump the throttle so our next send waits.
|
|
378
414
|
this._lastSentAt = Date.now();
|
|
379
415
|
}
|
|
380
|
-
this._netBody = body;
|
|
416
|
+
this._netBody = capNetBody(body);
|
|
381
417
|
const cid = body.match(UUID_RE);
|
|
382
418
|
if (cid)
|
|
383
419
|
this._netChatId = cid[0];
|
|
@@ -1323,6 +1359,11 @@ export class QwenBrowser {
|
|
|
1323
1359
|
if (e instanceof QwenGuestLimitError) {
|
|
1324
1360
|
throw new Error(this._t('qwen.guest_limit'));
|
|
1325
1361
|
}
|
|
1362
|
+
if (e instanceof QwenAgeGateError) {
|
|
1363
|
+
// Not retryable: a resend hits the SAME gate. Fail at once with a
|
|
1364
|
+
// clear "sign in" message instead of burning the retry budget.
|
|
1365
|
+
throw new Error(this._t('qwen.age_gate_block'));
|
|
1366
|
+
}
|
|
1326
1367
|
if (/rate limit|too frequent/i.test(msg)) {
|
|
1327
1368
|
if (rateLimitRetries >= this.maxRateLimitRetries)
|
|
1328
1369
|
break;
|
|
@@ -1400,21 +1441,7 @@ export class QwenBrowser {
|
|
|
1400
1441
|
// the input). Returns the files that FAILED so the prompt can rewrite their
|
|
1401
1442
|
// markers. Mirrors DeepSeekBrowser._attachFiles.
|
|
1402
1443
|
async _attachFiles(files) {
|
|
1403
|
-
const failed =
|
|
1404
|
-
const bufPayload = [];
|
|
1405
|
-
for (const f of files) {
|
|
1406
|
-
try {
|
|
1407
|
-
const buffer = await fs.readFile(f.path);
|
|
1408
|
-
bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
|
|
1409
|
-
}
|
|
1410
|
-
catch (e) {
|
|
1411
|
-
failed.push({ path: f.path, name: f.name });
|
|
1412
|
-
this._notice(theme.warn(this._t('qwen.attach_read_failed', {
|
|
1413
|
-
name: f.name,
|
|
1414
|
-
error: e.message,
|
|
1415
|
-
})));
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1444
|
+
const { payload: bufPayload, failed } = await readAttachBuffers(files, (name, error) => this._notice(theme.warn(this._t('qwen.attach_read_failed', { name, error }))));
|
|
1418
1445
|
if (!bufPayload.length)
|
|
1419
1446
|
return failed;
|
|
1420
1447
|
// Primary flow (verified live): click the "+" (`.mode-select-open`) to
|
|
@@ -1459,27 +1486,10 @@ export class QwenBrowser {
|
|
|
1459
1486
|
failed.push({ path: f.path, name: f.name });
|
|
1460
1487
|
return failed;
|
|
1461
1488
|
}
|
|
1462
|
-
// Wait for the upload chip (the file name above the input)
|
|
1463
|
-
//
|
|
1464
|
-
// cannot appear on the page for any other reason.
|
|
1489
|
+
// Wait for the upload chip (the file name above the input). Delegates to the
|
|
1490
|
+
// shared helper so both providers use the SAME preview policy.
|
|
1465
1491
|
async _awaitUploadPreview(names, timeoutMs) {
|
|
1466
|
-
|
|
1467
|
-
if (!clean.length) {
|
|
1468
|
-
await this.page.waitForTimeout(timeoutMs);
|
|
1469
|
-
return;
|
|
1470
|
-
}
|
|
1471
|
-
const deadline = Date.now() + timeoutMs;
|
|
1472
|
-
while (Date.now() < deadline) {
|
|
1473
|
-
for (const name of clean) {
|
|
1474
|
-
const n = await this.page
|
|
1475
|
-
.getByText(name, { exact: false })
|
|
1476
|
-
.count()
|
|
1477
|
-
.catch(() => 0);
|
|
1478
|
-
if (n > 0)
|
|
1479
|
-
return;
|
|
1480
|
-
}
|
|
1481
|
-
await this.page.waitForTimeout(150);
|
|
1482
|
-
}
|
|
1492
|
+
await awaitUploadPreview(this.page, names, timeoutMs);
|
|
1483
1493
|
}
|
|
1484
1494
|
async _setInputText(input, text) {
|
|
1485
1495
|
const tag = await input.evaluate((el) => el.tagName.toLowerCase());
|
|
@@ -1572,6 +1582,11 @@ export class QwenBrowser {
|
|
|
1572
1582
|
const preSendSignal = await this._chatSignal();
|
|
1573
1583
|
let sent = false;
|
|
1574
1584
|
while (!sent && Date.now() < sendDeadline) {
|
|
1585
|
+
// The age-confirmation gate can POP UP on the FIRST send (it is not
|
|
1586
|
+
// present at page load). While it is on screen it swallows the click on
|
|
1587
|
+
// the send button, so the message never goes out. Dismiss it BEFORE each
|
|
1588
|
+
// send attempt; a no-op once it is gone.
|
|
1589
|
+
await this._dismissOverlays();
|
|
1575
1590
|
// (1) the send button, if present/enabled/visible. We remember whether a
|
|
1576
1591
|
// click was actually issued: pressing Enter in the SAME round could send
|
|
1577
1592
|
// the message a SECOND time if the click already worked but the input
|
|
@@ -1626,8 +1641,19 @@ export class QwenBrowser {
|
|
|
1626
1641
|
this._lastSentAt = Date.now();
|
|
1627
1642
|
if (!sent) {
|
|
1628
1643
|
// The text is still in the input after the whole deadline: the message
|
|
1629
|
-
// was NOT sent.
|
|
1630
|
-
//
|
|
1644
|
+
// was NOT sent. A common cause is the age-confirmation gate that pops up
|
|
1645
|
+
// for a GUEST and (verified live) does not always close programmatically
|
|
1646
|
+
// — it swallows the click on the send button. Report that explicitly so
|
|
1647
|
+
// the operator knows to sign in instead of seeing a generic error.
|
|
1648
|
+
const gate = await this.page
|
|
1649
|
+
.locator(AGE_GATE_MODAL)
|
|
1650
|
+
.count()
|
|
1651
|
+
.catch(() => 0);
|
|
1652
|
+
if (gate > 0) {
|
|
1653
|
+
throw new QwenAgeGateError('age-confirmation modal blocks the send');
|
|
1654
|
+
}
|
|
1655
|
+
// Throw so ask() retries the send instead of waiting out the full answer
|
|
1656
|
+
// timeout for a turn that never started.
|
|
1631
1657
|
throw new Error(this._t('qwen.send_button_missing'));
|
|
1632
1658
|
}
|
|
1633
1659
|
// Wait for the answer to start, then for it to settle.
|
|
@@ -1729,6 +1755,14 @@ class QwenGuestLimitError extends Error {
|
|
|
1729
1755
|
this.name = 'QwenGuestLimitError';
|
|
1730
1756
|
}
|
|
1731
1757
|
}
|
|
1758
|
+
// The guest age-confirmation gate swallowed the send. NOT retryable: a resend
|
|
1759
|
+
// hits the same gate, so ask() fails at once instead of burning its retries.
|
|
1760
|
+
class QwenAgeGateError extends Error {
|
|
1761
|
+
constructor(detail) {
|
|
1762
|
+
super('age gate: ' + detail);
|
|
1763
|
+
this.name = 'QwenAgeGateError';
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1732
1766
|
class QwenIncompleteError extends Error {
|
|
1733
1767
|
constructor(detail) {
|
|
1734
1768
|
super('generation incomplete: ' + detail);
|
package/dist/qwen-ui.js
CHANGED
|
@@ -80,7 +80,13 @@ export const MODEL_TRIGGER_SELECTOR = '.wms-trigger';
|
|
|
80
80
|
// A first-time visitor must confirm their birth year before chatting. The
|
|
81
81
|
// dialog is an ant-modal; the Continue button is the black footer button.
|
|
82
82
|
export const AGE_GATE_MODAL = '.age-confirmation-modal';
|
|
83
|
+
// The Continue button inside the age gate (the black footer button).
|
|
83
84
|
export const AGE_GATE_CONTINUE = '.age-confirmation-modal .qwen-chat-v2-btn-black';
|
|
85
|
+
// The year dropdown TRIGGER inside the age gate. It must be clicked to open
|
|
86
|
+
// the year menu; clicking the label alone does not select a year.
|
|
87
|
+
export const AGE_GATE_YEAR_SELECT = '.age-confirmation-year .qwen-chat-v2-dropdown-menu-select';
|
|
88
|
+
// A year OPTION in the opened dropdown (the menu item text is the year).
|
|
89
|
+
export const AGE_GATE_YEAR_OPTION = '.qwen-chat-v2-dropdown-menu-item';
|
|
84
90
|
// The cookie banner's accept button (matched by its visible label).
|
|
85
91
|
export const COOKIE_ACCEPT_RE = /^accept all cookies$/i;
|
|
86
92
|
// ---------- messages / answers ----------
|
package/dist/shell.js
CHANGED
|
@@ -43,6 +43,22 @@ export function assertCommandInsideRoot(root, command) {
|
|
|
43
43
|
throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
// Indirect re-parse / chdir that a static `cd` scan cannot follow. These are
|
|
47
|
+
// the documented escape hatches (`sh -c 'cd /'`, `python -c
|
|
48
|
+
// "os.chdir('/')"`): they execute a fresh interpreter which reads the target
|
|
49
|
+
// only at RUNTIME, so no static scan can prove it stays inside the root.
|
|
50
|
+
// Refusing them is the honest barrier — best-effort, not OS isolation.
|
|
51
|
+
if (/\b(?:sh|bash|dash|zsh|ksh)\b[^\n]*\s-[a-z]*c\b/i.test(cmd)) {
|
|
52
|
+
throw new Error('Sandbox: running a shell via -c is forbidden (cannot verify the root)');
|
|
53
|
+
}
|
|
54
|
+
if (/\bchroot\b/i.test(cmd)) {
|
|
55
|
+
throw new Error('Sandbox: chroot is forbidden');
|
|
56
|
+
}
|
|
57
|
+
// A chdir performed by an interpreter (Python/Perl/Ruby/Node) or by a raw
|
|
58
|
+
// syscall wrapper. Matched loosely on the common spellings.
|
|
59
|
+
if (/\bos\.chdir\s*\(|\bchdir\s*\(|\bProcess\.chdir\b|process\.chdir\s*\(|\bchdir\b\s*['"]?\//i.test(cmd)) {
|
|
60
|
+
throw new Error('Sandbox: an in-process directory change (chdir) is forbidden');
|
|
61
|
+
}
|
|
46
62
|
}
|
|
47
63
|
function formatExecResult(err, stdout, stderr, timeout, okExitCodes) {
|
|
48
64
|
const out = (stdout || '').toString();
|
package/dist/tools.js
CHANGED
|
@@ -40,11 +40,11 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
|
|
|
40
40
|
};
|
|
41
41
|
const req = (v, name) => {
|
|
42
42
|
if (v === undefined || v === null) {
|
|
43
|
-
throw new Error(`Missing required argument: ${name}
|
|
43
|
+
throw new Error(`Missing required argument: ${name}. Add "${name}" to the tool call arguments and retry.`);
|
|
44
44
|
}
|
|
45
45
|
const s = String(v);
|
|
46
46
|
if (s === '' || s === 'undefined' || s === 'null') {
|
|
47
|
-
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}
|
|
47
|
+
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}. Provide a non-empty value for "${name}" and retry.`);
|
|
48
48
|
}
|
|
49
49
|
return s;
|
|
50
50
|
};
|
package/dist/transcript.js
CHANGED
|
@@ -49,10 +49,19 @@ export class Transcript {
|
|
|
49
49
|
if (!this.enabled || !this.stream)
|
|
50
50
|
return;
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
// `write() === false` means the internal buffer is FULL (slow disk /
|
|
53
|
+
// network mount). Node keeps buffering in memory, so a long run of fast
|
|
54
|
+
// events can grow unboundedly; we COUNT the backpressured writes so
|
|
55
|
+
// close() can report them. We still write (dropping trace lines silently
|
|
56
|
+
// would be worse), but the counter makes the condition visible.
|
|
57
|
+
const ok = this.stream.write(line);
|
|
58
|
+
if (!ok)
|
|
59
|
+
this.backpressure++;
|
|
53
60
|
}
|
|
54
61
|
catch { }
|
|
55
62
|
}
|
|
63
|
+
/** Number of writes that hit backpressure (write() === false). */
|
|
64
|
+
backpressure = 0;
|
|
56
65
|
// Flush the stream and resolve once the data reached the file. The callers
|
|
57
66
|
// run on process.exit paths (one-shot --task, browser error, clean exit), and
|
|
58
67
|
// end() without awaiting the callback lets the process die before the buffer
|
|
@@ -70,4 +79,16 @@ export class Transcript {
|
|
|
70
79
|
stream.end(() => resolve());
|
|
71
80
|
});
|
|
72
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* A one-line health note for the operator, or null when the stream stayed
|
|
84
|
+
* healthy. Callers print it on exit so a slow/broken transcript file (the
|
|
85
|
+
* events were buffered in memory instead of reaching disk) is not silent.
|
|
86
|
+
*/
|
|
87
|
+
healthNote() {
|
|
88
|
+
if (this.backpressure === 0)
|
|
89
|
+
return null;
|
|
90
|
+
return ('transcript: ' +
|
|
91
|
+
this.backpressure +
|
|
92
|
+
' write(s) hit backpressure (slow disk?) — events may be delayed');
|
|
93
|
+
}
|
|
73
94
|
}
|
package/dist/xml-toolcall.js
CHANGED
|
@@ -18,6 +18,26 @@ function readAttr(attrs, name) {
|
|
|
18
18
|
const m = attrs.match(re);
|
|
19
19
|
return m ? m[2] : null;
|
|
20
20
|
}
|
|
21
|
+
// Read EVERY key="value" pair from an <invoke ...> tag. The model sometimes
|
|
22
|
+
// emits a tool argument as an ATTRIBUTE of the invoke tag itself (e.g.
|
|
23
|
+
// `<invoke name="Write" path="chess.html">` with only the big `content` as a
|
|
24
|
+
// <parameter> child). readAttr() kept only the tool name, so such an argument
|
|
25
|
+
// was silently DROPPED and the tool failed with 'Missing required argument'.
|
|
26
|
+
// Keys are returned in source order so the first occurrence wins.
|
|
27
|
+
function readAllAttrs(attrs) {
|
|
28
|
+
const Q = String.fromCharCode(34);
|
|
29
|
+
const out = {};
|
|
30
|
+
const re = new RegExp('([A-Za-z_][A-Za-z0-9_]*)[ ]*=[ ]*[' + Q + ']([^' + Q + ']*)[' + Q + ']', 'g');
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = re.exec(attrs)) !== null) {
|
|
33
|
+
const key = m[1];
|
|
34
|
+
if (key === 'name')
|
|
35
|
+
continue;
|
|
36
|
+
if (!(key in out))
|
|
37
|
+
out[key] = unescapeXml(m[2]);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
21
41
|
// The model sometimes produces a "hybrid" call: the tool name is an attribute
|
|
22
42
|
// AND the arguments are inline JSON in the SAME opening tag, without any
|
|
23
43
|
// <parameter> children. A real case from the transcript:
|
|
@@ -127,6 +147,15 @@ export function parseXmlToolCalls(text) {
|
|
|
127
147
|
const closeMatch = closeRe.exec(rest);
|
|
128
148
|
const body = closeMatch ? rest.slice(0, closeMatch.index) : rest;
|
|
129
149
|
const args = parseParameters(body);
|
|
150
|
+
// Arguments may ALSO arrive as attributes of the invoke tag itself
|
|
151
|
+
// (`<invoke name="Write" path="chess.html">`); merge them in, letting an
|
|
152
|
+
// explicit <parameter> of the same name win (it carries string=true and
|
|
153
|
+
// the exact value). This is what stops `path` from being silently lost.
|
|
154
|
+
const attrArgs = readAllAttrs(attrs);
|
|
155
|
+
for (const k of Object.keys(attrArgs)) {
|
|
156
|
+
if (!(k in args))
|
|
157
|
+
args[k] = attrArgs[k];
|
|
158
|
+
}
|
|
130
159
|
// Hybrid form: the arguments are inline JSON in the SAME invoke tag
|
|
131
160
|
// (e.g. `<|DSML|invoke name="GitAdd", "args" {"paths": "..."}>`), with no
|
|
132
161
|
// <parameter> children. Fall back to the raw invoke head + body.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.73.
|
|
3
|
+
"version": "2.73.8",
|
|
4
4
|
"description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — 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": {
|
package/scripts/qwen-smoke.mts
CHANGED
|
@@ -163,6 +163,38 @@ async function main() {
|
|
|
163
163
|
} catch (e) {
|
|
164
164
|
record('toolCall', false, (e as Error).message)
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
// History restore: after the sends above the chat must have messages.
|
|
168
|
+
try {
|
|
169
|
+
const id = await browser.getCurrentChatId()
|
|
170
|
+
const msgs = id ? await browser.fetchChatMessages(id) : []
|
|
171
|
+
record(
|
|
172
|
+
'history',
|
|
173
|
+
msgs.length > 0,
|
|
174
|
+
'id=' + (id || '-') + ' msgs=' + msgs.length,
|
|
175
|
+
)
|
|
176
|
+
} catch (e) {
|
|
177
|
+
record('history', false, (e as Error).message)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Attachments: a SIGNED-IN session uploads a file; a GUEST cannot, so a
|
|
181
|
+
// guest failure is reported as a SKIP (not a failure) — the guest limit is
|
|
182
|
+
// a site restriction, not a regression.
|
|
183
|
+
try {
|
|
184
|
+
const tmpFile = path.join(SMOKE_HOME, 'zames-smoke-note.txt')
|
|
185
|
+
fs.writeFileSync(tmpFile, 'The secret word is BANANA')
|
|
186
|
+
const failed = await browser._attachFiles([
|
|
187
|
+
{ path: tmpFile, name: 'zames-smoke-note.txt', mime: 'text/plain' },
|
|
188
|
+
])
|
|
189
|
+
const attached = failed.length === 0
|
|
190
|
+
const isGuest = !(await browser.isLoggedIn().catch(() => false))
|
|
191
|
+
if (attached) record('attachments', true, '')
|
|
192
|
+
else if (isGuest)
|
|
193
|
+
record('attachments', true, 'skipped (guest cannot upload)')
|
|
194
|
+
else record('attachments', false, JSON.stringify(failed))
|
|
195
|
+
} catch (e) {
|
|
196
|
+
record('attachments', false, (e as Error).message)
|
|
197
|
+
}
|
|
166
198
|
}
|
|
167
199
|
|
|
168
200
|
await browser.close().catch(() => {})
|