zames_pro 2.95.4 → 2.95.6
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 +30 -0
- package/README.md +2 -2
- package/dist/changelog.js +16 -4
- package/dist/commands.js +65 -5
- package/dist/compact.js +9 -2
- package/dist/index.js +8 -0
- package/dist/run-task.js +45 -2
- package/llms-full.txt +1 -1
- package/package.json +9 -1
- package/dist/ask-retry.js +0 -10
- package/dist/ask-state.js +0 -80
- package/dist/browser-base.js +0 -69
- package/dist/provider-errors.js +0 -53
- package/dist/retry-policy.js +0 -98
- package/dist/send-slot.js +0 -36
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.95.6] - 2026-11-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **compact:** carry the todo checklist and the /goal condition into the new chat
|
|
15
|
+
- **site:** a generated changelog page and one source for RU/EN layout strings
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **changelog:** keep a drafted group heading off the previous bullet
|
|
20
|
+
|
|
21
|
+
## [2.95.5] - 2026-11-05
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **site:** a 404 page, hreflang alternates and cache-busted assets
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **tui:** keep the AskUser question and answer in the log
|
|
30
|
+
- **changelog:** keep a manual Unreleased note inside its group
|
|
31
|
+
- **site:** stop the landing title repeating the site name in og:title
|
|
32
|
+
- **site:** give the social preview its own static image
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **tui:** paint the AskUser question in the tool color
|
|
37
|
+
- drop dead provider-unification modules and stale doc text
|
|
38
|
+
- **site:** a deeper palette, bigger type and calmer cards
|
|
39
|
+
|
|
10
40
|
## [2.95.4] - 2026-11-05
|
|
11
41
|
|
|
12
42
|
### Fixed
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ and runs right after it, in the same chat. `Esc` aborts the current generation,
|
|
|
72
72
|
- [Why zames?](#why-zames) · [Requirements](#requirements) · [Signing in](#signing-in)
|
|
73
73
|
- [Switching providers (DeepSeek / Qwen / GLM)](#switching-providers-deepseek--qwen--glm)
|
|
74
74
|
- [Installation](#installation) · [Usage](#usage) · [FAQ](#faq)
|
|
75
|
-
- Reference: [Commands and tools](docs/commands.md) · [MCP](docs/mcp.md) · [Configuration and themes](docs/configuration.md) · [Troubleshooting](docs/troubleshooting.md)
|
|
75
|
+
- Reference: [Commands and tools](docs/commands.md) · [MCP](docs/mcp.md) · [Configuration and themes](docs/configuration.md) · [Troubleshooting](docs/troubleshooting.md) · [Changelog](https://viqto0r.github.io/zames_pro/changelog.html)
|
|
76
76
|
- [Keywords and alternatives](#keywords-and-alternatives) · [Links](#links) · [License](#license)
|
|
77
77
|
|
|
78
78
|
## How it works
|
|
@@ -534,7 +534,7 @@ zames # один раз войти, дальше прост
|
|
|
534
534
|
- [Почему zames?](#почему-zames) · [Требования](#требования) · [Вход в аккаунт](#вход-в-аккаунт)
|
|
535
535
|
- [Переключение провайдера (DeepSeek / Qwen / GLM)](#переключение-провайдера-deepseek--qwen--glm)
|
|
536
536
|
- [Установка](#установка) · [Использование](#использование) · [FAQ](#faq)
|
|
537
|
-
- Справочник: [Команды и инструменты](docs/commands-ru.md) · [MCP](docs/mcp-ru.md) · [Конфигурация и темы](docs/configuration-ru.md) · [Troubleshooting](docs/troubleshooting-ru.md)
|
|
537
|
+
- Справочник: [Команды и инструменты](docs/commands-ru.md) · [MCP](docs/mcp-ru.md) · [Конфигурация и темы](docs/configuration-ru.md) · [Troubleshooting](docs/troubleshooting-ru.md) · [История изменений](https://viqto0r.github.io/zames_pro/changelog-ru.html)
|
|
538
538
|
- [Ключевые слова и аналоги](#ключевые-слова-и-аналоги) · [Ссылки](#ссылки) · [Лицензия](#лицензия)
|
|
539
539
|
|
|
540
540
|
## Как это работает
|
package/dist/changelog.js
CHANGED
|
@@ -97,13 +97,25 @@ export function syncUnreleased(changelog, body) {
|
|
|
97
97
|
// carries several groups (`## Added` … `## Fixed`) without a blank line
|
|
98
98
|
// between them, the next group used to stay glued to the last bullet
|
|
99
99
|
// (`- a### Fixed`) — it was not a heading at all, so the item vanished
|
|
100
|
-
// from the rendered changelog.
|
|
101
|
-
|
|
100
|
+
// from the rendered changelog. The lookahead is what keeps a CONTINUATION
|
|
101
|
+
// line of a multi-line bullet in place: gluing on `\n` as well joined the
|
|
102
|
+
// indent line to the next bullet and truncated the note.
|
|
103
|
+
// The draft's groups are still `## ` at this point (they become `### `
|
|
104
|
+
// below), and the FIRST group is already preceded by the blank line of
|
|
105
|
+
// the draft — so a group always starts a line, and this rule only has to
|
|
106
|
+
// punch in the missing blank line after a bullet.
|
|
107
|
+
.replace(/([^\n])(?=## )/g, '$1' + nl + nl)
|
|
102
108
|
.trim()
|
|
103
109
|
// The draft renders groups as `## Added` (top-level), but INSIDE the file
|
|
104
110
|
// a version's groups are `### Added` (a subsection of the version heading).
|
|
105
|
-
// `## [` (the next version) is left alone.
|
|
106
|
-
|
|
111
|
+
// `## [` (the next version) is left alone. The FIRST group follows the
|
|
112
|
+
// version heading directly (no bullet before it), so the blank-line rule
|
|
113
|
+
// above never fired for it — `## [Unreleased]` came out as a bare `#`
|
|
114
|
+
// followed by `# Added`. The heading owns a blank line, always.
|
|
115
|
+
.replace(/^## (?!\[)/gm, '### ')
|
|
116
|
+
// One blank line between the version heading and its first group, not
|
|
117
|
+
// the three the rules above can stack up.
|
|
118
|
+
.replace(/^\n+(?=### )/gm, '');
|
|
107
119
|
const section = cleanBody ? nl + nl + cleanBody + nl : nl;
|
|
108
120
|
return changelog.slice(0, afterAnchor) + section + tail;
|
|
109
121
|
}
|
package/dist/commands.js
CHANGED
|
@@ -512,20 +512,80 @@ export function isUsableCompactSummary(text) {
|
|
|
512
512
|
return false;
|
|
513
513
|
return true;
|
|
514
514
|
}
|
|
515
|
+
function todoMark(s) {
|
|
516
|
+
return s === 'completed' ? '[x]' : s === 'in_progress' ? '[~]' : '[ ]';
|
|
517
|
+
}
|
|
518
|
+
/** The TODO block carried into the new chat. Empty when there are no tasks. */
|
|
519
|
+
export function renderCompactTodos(todos = []) {
|
|
520
|
+
const items = Array.isArray(todos) ? todos : [];
|
|
521
|
+
if (!items.length)
|
|
522
|
+
return '';
|
|
523
|
+
const lines = items.map((x, i) => String(i + 1) +
|
|
524
|
+
'. ' +
|
|
525
|
+
todoMark(String(x.status)) +
|
|
526
|
+
' ' +
|
|
527
|
+
String(x.content));
|
|
528
|
+
const done = items.filter((x) => x.status === 'completed').length;
|
|
529
|
+
return ('## Task checklist (carried over by the agent, still in force)' +
|
|
530
|
+
NL +
|
|
531
|
+
'This list is SESSION state: it survived the compaction unchanged. ' +
|
|
532
|
+
'Do NOT recreate it from the summary — keep it up to date with ' +
|
|
533
|
+
'TodoWrite instead.' +
|
|
534
|
+
NL +
|
|
535
|
+
'Todo list (' +
|
|
536
|
+
String(done) +
|
|
537
|
+
'/' +
|
|
538
|
+
String(items.length) +
|
|
539
|
+
' done):' +
|
|
540
|
+
NL +
|
|
541
|
+
lines.join(NL));
|
|
542
|
+
}
|
|
543
|
+
/** The goal block carried into the new chat. Empty when no goal is set. */
|
|
544
|
+
export function renderCompactGoal(goal) {
|
|
545
|
+
const g = String(goal?.goal ?? '').trim();
|
|
546
|
+
if (!g)
|
|
547
|
+
return '';
|
|
548
|
+
const head = goal?.isCondition
|
|
549
|
+
? '## Completion goal (carried over, still in force)'
|
|
550
|
+
: '## Session goal (carried over, still in force)';
|
|
551
|
+
return (head +
|
|
552
|
+
NL +
|
|
553
|
+
'This goal is still active in this session; the compaction did not ' +
|
|
554
|
+
'cancel it. Keep working toward it.' +
|
|
555
|
+
NL +
|
|
556
|
+
NL +
|
|
557
|
+
g);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Re-pin the SESSION state after a compaction. The chat is new, but the todo
|
|
561
|
+
* store and the /goal file live in the process and on disk, so they survive —
|
|
562
|
+
* only the model's KNOWLEDGE of them does not. Sent as its own message right
|
|
563
|
+
* after the carryover, so it reads as current state, not as history.
|
|
564
|
+
*/
|
|
565
|
+
export function buildCompactToolResult(todos = [], goal = null) {
|
|
566
|
+
return [renderCompactTodos(todos), renderCompactGoal(goal)]
|
|
567
|
+
.filter(Boolean)
|
|
568
|
+
.join(NL + NL);
|
|
569
|
+
}
|
|
515
570
|
/**
|
|
516
571
|
* Wrap the model's summary into the text posted as the first message of the
|
|
517
572
|
* NEW chat. The system prompt is sent separately (sendSystemPrompt), so here
|
|
518
573
|
* we only mark the block as a carried-over context and add the operator's
|
|
519
|
-
* original
|
|
574
|
+
* original task. The todo checklist and the /goal condition are appended too:
|
|
575
|
+
* they are session state the model-written summary is not guaranteed to keep.
|
|
520
576
|
*/
|
|
521
|
-
export function buildCompactCarryover(summary, task) {
|
|
577
|
+
export function buildCompactCarryover(summary, task, todos = [], goal = null) {
|
|
522
578
|
const body = String(summary ?? '').trim();
|
|
523
|
-
const
|
|
579
|
+
const goalText = String(task ?? '').trim();
|
|
524
580
|
let out = 'Context carried over from a previous chat (compacted). ' +
|
|
525
581
|
'Treat it as the history of our work so far and continue from the current state.';
|
|
526
582
|
out += NL + NL + body;
|
|
527
|
-
if (
|
|
528
|
-
out += NL + NL + 'Original task: ' +
|
|
583
|
+
if (goalText) {
|
|
584
|
+
out += NL + NL + 'Original task: ' + goalText;
|
|
585
|
+
}
|
|
586
|
+
for (const extra of [renderCompactTodos(todos), renderCompactGoal(goal)]) {
|
|
587
|
+
if (extra)
|
|
588
|
+
out += NL + NL + extra;
|
|
529
589
|
}
|
|
530
590
|
return out;
|
|
531
591
|
}
|
package/dist/compact.js
CHANGED
|
@@ -16,7 +16,7 @@ export const COMPACT_FALLBACK_LIMIT = 40;
|
|
|
16
16
|
* The caller owns the browser and the throttle; this function only sends.
|
|
17
17
|
*/
|
|
18
18
|
export async function performCompact(opts) {
|
|
19
|
-
const { browser, workdir, locale, task, transcript = null, ui = null, buildSystemPrompt, tools, answerTimeoutMs, fallbackLimit = COMPACT_FALLBACK_LIMIT, skipUiLock = false, quiet = false, selfImprovement = false, contextExhausted = false, } = opts;
|
|
19
|
+
const { browser, workdir, locale, task, transcript = null, ui = null, buildSystemPrompt, tools, answerTimeoutMs, fallbackLimit = COMPACT_FALLBACK_LIMIT, skipUiLock = false, quiet = false, selfImprovement = false, contextExhausted = false, todos = [], goal = null, } = opts;
|
|
20
20
|
const t = translate(locale);
|
|
21
21
|
// The active provider's LABEL for operator-facing text: performCompact is
|
|
22
22
|
// shared by every backend, so it cannot hardcode "DeepSeek". `providerArg`
|
|
@@ -189,7 +189,14 @@ export async function performCompact(opts) {
|
|
|
189
189
|
timeout: 60_000,
|
|
190
190
|
agent: false,
|
|
191
191
|
});
|
|
192
|
-
|
|
192
|
+
// The checklist and the goal are SESSION state (todoStore /
|
|
193
|
+
// .zames-goal), not chat state: they survive a compaction by themselves,
|
|
194
|
+
// but the model must be told they are still in force, otherwise it
|
|
195
|
+
// re-creates the tasks or treats the goal as gone because the summary
|
|
196
|
+
// does not mention it. They ride INSIDE the carryover, not as a separate
|
|
197
|
+
// turn: an extra send is another rate-limit pause AND every extra
|
|
198
|
+
// message of the new chat is another turn the model may answer.
|
|
199
|
+
await browser.ask(buildCompactCarryover(summary, task, todos, goal), {
|
|
193
200
|
timeout: 60_000,
|
|
194
201
|
agent: false,
|
|
195
202
|
});
|
package/dist/index.js
CHANGED
|
@@ -1302,6 +1302,8 @@ async function main() {
|
|
|
1302
1302
|
skipUiLock: true,
|
|
1303
1303
|
selfImprovement: devMode,
|
|
1304
1304
|
contextExhausted: true,
|
|
1305
|
+
todos: getTodos(todoStore),
|
|
1306
|
+
goal: sessionGoal ? { goal: sessionGoal, isCondition: true } : null,
|
|
1305
1307
|
});
|
|
1306
1308
|
if (!res.ok)
|
|
1307
1309
|
return false;
|
|
@@ -3125,6 +3127,8 @@ async function main() {
|
|
|
3125
3127
|
answerTimeoutMs: providerAnswerTimeoutMs(),
|
|
3126
3128
|
fallbackLimit: COMPACT_FALLBACK_LIMIT,
|
|
3127
3129
|
selfImprovement: devMode,
|
|
3130
|
+
todos: getTodos(todoStore),
|
|
3131
|
+
goal: sessionGoal ? { goal: sessionGoal, isCondition: true } : null,
|
|
3128
3132
|
});
|
|
3129
3133
|
if (res.ok) {
|
|
3130
3134
|
currentChatId = res.chatId;
|
|
@@ -4730,6 +4734,10 @@ async function main() {
|
|
|
4730
4734
|
quiet: true,
|
|
4731
4735
|
skipUiLock: true,
|
|
4732
4736
|
selfImprovement: devMode,
|
|
4737
|
+
todos: getTodos(todoStore),
|
|
4738
|
+
goal: sessionGoal
|
|
4739
|
+
? { goal: sessionGoal, isCondition: true }
|
|
4740
|
+
: null,
|
|
4733
4741
|
});
|
|
4734
4742
|
if (res.ok) {
|
|
4735
4743
|
currentChatId = res.chatId;
|
package/dist/run-task.js
CHANGED
|
@@ -2,8 +2,35 @@ import { theme } from './theme.js';
|
|
|
2
2
|
import { withGoal, mergeMessages, isSlashCommand, formatDuration, } from './commands.js';
|
|
3
3
|
import { buildEvaluatorPrompt, buildContinuationDirective, parseGoalVerdict, GOAL_MAX_TURNS, } from './goal.js';
|
|
4
4
|
import { formatCompactTokens, pasteReplacement, expandPastes } from './input.js';
|
|
5
|
-
import { DEFAULT_LOCALE } from './i18n.js';
|
|
5
|
+
import { DEFAULT_LOCALE, translate } from './i18n.js';
|
|
6
6
|
import { providerAskDeadlineMs } from './config.js';
|
|
7
|
+
// The two log lines an `AskUser` turn leaves behind: the question with its
|
|
8
|
+
// options (the recommended one starred) and the operator's answer. PURE, so the
|
|
9
|
+
// wording is unit-tested without a live modal; `runTask` prints them through
|
|
10
|
+
// the editor after the modal closes (the modal's own frame is erased on
|
|
11
|
+
// unmount, and without these lines the question would leave no trace in the
|
|
12
|
+
// terminal or the chat history).
|
|
13
|
+
const ASK_NL = String.fromCharCode(10);
|
|
14
|
+
export function formatAskUserLog(question, options, recommended, answer, t) {
|
|
15
|
+
const qLines = question
|
|
16
|
+
.split(ASK_NL)
|
|
17
|
+
.map((l) => ' ' + l.trim())
|
|
18
|
+
.join(ASK_NL);
|
|
19
|
+
const optLines = options
|
|
20
|
+
.map((o, i) => {
|
|
21
|
+
const mark = i === recommended ? '* ' : '- ';
|
|
22
|
+
const tag = i === recommended ? ' (' + t('askuser.recommended') + ')' : '';
|
|
23
|
+
return ' ' + mark + o + tag;
|
|
24
|
+
})
|
|
25
|
+
.join(ASK_NL);
|
|
26
|
+
return {
|
|
27
|
+
question: '◆ ' + t('askuser.title') + ASK_NL + qLines + ASK_NL + optLines,
|
|
28
|
+
answer: '❯ ' +
|
|
29
|
+
t('askuser.your_answer') +
|
|
30
|
+
' ' +
|
|
31
|
+
(answer === null || answer === '' ? t('askuser.hint_dismiss') : answer),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
7
34
|
// Ask the operator to approve a tool call flagged by an `ask` permission rule
|
|
8
35
|
// While the LineEditor owns the terminal it must be paused first, or the
|
|
9
36
|
// confirmation prompt and the input line fight for the same keys. A non-TTY run
|
|
@@ -429,18 +456,34 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
429
456
|
? async (req) => {
|
|
430
457
|
if (!editor)
|
|
431
458
|
return null;
|
|
459
|
+
// The modal is a SEPARATE Ink app: its frame is erased when it
|
|
460
|
+
// unmounts, so without this the question and the operator's
|
|
461
|
+
// answer leave no trace — neither in the terminal nor in the
|
|
462
|
+
// chat history. Print them as a normal log turn instead.
|
|
463
|
+
const tr = translate(locale);
|
|
432
464
|
editor.pause();
|
|
465
|
+
let answer = null;
|
|
433
466
|
try {
|
|
434
467
|
const { askQuestionInk } = await import('./tui/run-ask.js');
|
|
435
|
-
|
|
468
|
+
answer = await askQuestionInk({
|
|
436
469
|
question: req.question,
|
|
437
470
|
options: req.options,
|
|
438
471
|
recommended: req.recommended,
|
|
439
472
|
allowText: req.allowText,
|
|
440
473
|
});
|
|
474
|
+
return answer;
|
|
441
475
|
}
|
|
442
476
|
finally {
|
|
477
|
+
// Print AFTER resume, never before pause: on the alternate
|
|
478
|
+
// screen the modal unmount wipes everything painted into the
|
|
479
|
+
// old app, so the question and the answer would vanish.
|
|
443
480
|
editor.resume();
|
|
481
|
+
const logged = formatAskUserLog(req.question, req.options, req.recommended, answer, tr);
|
|
482
|
+
// The question block is painted in the TOOL color (the warm
|
|
483
|
+
// one tool calls use) so it reads as a distinct event, not
|
|
484
|
+
// as another grey service line next to the agent's answers.
|
|
485
|
+
editor.printAbove(theme.tool(logged.question));
|
|
486
|
+
editor.printAbove(theme.user(logged.answer));
|
|
444
487
|
}
|
|
445
488
|
}
|
|
446
489
|
: onAskUser,
|
package/llms-full.txt
CHANGED
|
@@ -57,7 +57,7 @@ and runs right after it, in the same chat. `Esc` aborts the current generation,
|
|
|
57
57
|
- [Why zames?](#why-zames) · [Requirements](#requirements) · [Signing in](#signing-in)
|
|
58
58
|
- [Switching providers (DeepSeek / Qwen / GLM)](#switching-providers-deepseek--qwen--glm)
|
|
59
59
|
- [Installation](#installation) · [Usage](#usage) · [FAQ](#faq)
|
|
60
|
-
- Reference: [Commands and tools](docs/commands.md) · [MCP](docs/mcp.md) · [Configuration and themes](docs/configuration.md) · [Troubleshooting](docs/troubleshooting.md)
|
|
60
|
+
- Reference: [Commands and tools](docs/commands.md) · [MCP](docs/mcp.md) · [Configuration and themes](docs/configuration.md) · [Troubleshooting](docs/troubleshooting.md) · [Changelog](https://viqto0r.github.io/zames_pro/changelog.html)
|
|
61
61
|
- [Keywords and alternatives](#keywords-and-alternatives) · [Links](#links) · [License](#license)
|
|
62
62
|
|
|
63
63
|
## How it works
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.95.
|
|
3
|
+
"version": "2.95.6",
|
|
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": {
|
|
@@ -44,9 +44,12 @@
|
|
|
44
44
|
"render-how": "tsx scripts/render-how-it-works.mts",
|
|
45
45
|
"build:readme": "tsx scripts/build-readme.mts",
|
|
46
46
|
"build:llms": "tsx scripts/build-llms.mts",
|
|
47
|
+
"build:changelog": "tsx scripts/build-changelog.mts",
|
|
48
|
+
"build:site-data": "tsx scripts/build-site-data.mts",
|
|
47
49
|
"postinstall": "node scripts/postinstall.mjs",
|
|
48
50
|
"prepare": "husky",
|
|
49
51
|
"glm-smoke": "tsx scripts/glm-smoke.mts",
|
|
52
|
+
"glm-warmup": "tsx scripts/glm-warmup.mts",
|
|
50
53
|
"seo:topics": "tsx scripts/seo-topics.mts",
|
|
51
54
|
"ui-smoke": "tsx scripts/ui-smoke.mts"
|
|
52
55
|
},
|
|
@@ -169,5 +172,10 @@
|
|
|
169
172
|
"funding": {
|
|
170
173
|
"type": "github",
|
|
171
174
|
"url": "https://github.com/sponsors/Viqto0r"
|
|
175
|
+
},
|
|
176
|
+
"overrides": {
|
|
177
|
+
"@typescript-eslint/typescript-estree": {
|
|
178
|
+
"minimatch": "^11.0.4"
|
|
179
|
+
}
|
|
172
180
|
}
|
|
173
181
|
}
|
package/dist/ask-retry.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// N1 stage 4: the retry POLICY of a provider's ask() as a PURE function.
|
|
2
|
-
//
|
|
3
|
-
// The real implementation now lives in src/retry-policy.ts (it grew the
|
|
4
|
-
// incremental-backoff mode); this module stays as the historical import
|
|
5
|
-
// point so the providers and the tests do not churn.
|
|
6
|
-
//
|
|
7
|
-
// WHY the split: the DECISION (family -> wait / compact / give-up) is pure
|
|
8
|
-
// and unit-tested, while the loop (sends, throttling, aborts) stays in the
|
|
9
|
-
// browser class. A new backend only supplies the `classify` function.
|
|
10
|
-
export { decideRetry } from './retry-policy.js';
|
package/dist/ask-state.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
// N3: the PHASE DECISION of DeepSeek's `_askOnce()` as a pure state machine.
|
|
2
|
-
//
|
|
3
|
-
// `_askOnce` (src/browser.ts) is the most fragile function of the project: a
|
|
4
|
-
// start-wait loop, a finish loop, the Continue button, the stability window
|
|
5
|
-
// and four detectors (rate limit / context limit / truncated / finished
|
|
6
|
-
// without answer). Until now it was covered only by live smoke runs, and a
|
|
7
|
-
// wrong ordering of the checks kept reappearing (the "Stopped" hang, the
|
|
8
|
-
// "agent stopped with a Continue button", the 15-minute spinner on a dead
|
|
9
|
-
// turn). The Playwright part keeps COLLECTING an observation; the TRANSITION
|
|
10
|
-
// below is pure and unit-tested against the real cases from DESIGN-NOTES.
|
|
11
|
-
/** Grace ticks before a FINISHED-with-answer turn is returned (default 2). */
|
|
12
|
-
export const NET_FINISHED_TICKS = 2;
|
|
13
|
-
/** Ticks a dead (FINISHED, no answer, no Continue) turn waits before giving up. */
|
|
14
|
-
export const DEAD_TURN_TICKS = 6;
|
|
15
|
-
/**
|
|
16
|
-
* One transition of the ask() state machine.
|
|
17
|
-
*
|
|
18
|
-
* ORDER MATTERS and is the whole point of this function:
|
|
19
|
-
* 1. an abort wins over everything (the operator stopped);
|
|
20
|
-
* 2. the CONTEXT limit is checked BEFORE the rate limit — both look like a
|
|
21
|
-
* toast, and retrying an exhausted chat is useless;
|
|
22
|
-
* 3. rate limit / server busy are retryable errors;
|
|
23
|
-
* 4. a truncated turn PREFERS the Continue button (it RESUMES the turn) and
|
|
24
|
-
* only falls back to a resend when there is no button;
|
|
25
|
-
* 5. a PAUSED generation (`continueVisible`) is never "settled": the
|
|
26
|
-
* stability early-return must be checked AFTER it, otherwise a partial
|
|
27
|
-
* answer is returned with Continue still on screen.
|
|
28
|
-
*/
|
|
29
|
-
export function nextPhase(phase, o, opts) {
|
|
30
|
-
if (o.aborted)
|
|
31
|
-
return { kind: 'return', text: 'dom' };
|
|
32
|
-
if (o.contextLimit)
|
|
33
|
-
return { kind: 'throw', error: 'context-limit' };
|
|
34
|
-
if (o.rateLimited)
|
|
35
|
-
return { kind: 'throw', error: 'rate-limit' };
|
|
36
|
-
if (o.serverBusy)
|
|
37
|
-
return { kind: 'throw', error: 'server-busy' };
|
|
38
|
-
// A truncated / answer-less turn: click Continue when possible (it resumes
|
|
39
|
-
// the SAME turn); otherwise resend.
|
|
40
|
-
if (o.truncated || o.noAnswer) {
|
|
41
|
-
if (opts.autoContinue && o.continueVisible) {
|
|
42
|
-
return { kind: 'click-continue' };
|
|
43
|
-
}
|
|
44
|
-
return { kind: 'throw', error: 'incomplete' };
|
|
45
|
-
}
|
|
46
|
-
if (phase === 'start') {
|
|
47
|
-
if (o.isNew)
|
|
48
|
-
return { kind: 'started' };
|
|
49
|
-
if (o.hasText && o.notGenerating && !o.continueVisible) {
|
|
50
|
-
return { kind: 'return', text: 'dom' };
|
|
51
|
-
}
|
|
52
|
-
// A reasoning pause during the THINK phase: click Continue so the answer
|
|
53
|
-
// starts flowing instead of waiting for the start deadline.
|
|
54
|
-
if (opts.autoContinue && opts.deepThinking && o.continueVisible) {
|
|
55
|
-
return { kind: 'click-continue' };
|
|
56
|
-
}
|
|
57
|
-
return { kind: 'wait' };
|
|
58
|
-
}
|
|
59
|
-
// --- finish loop ---------------------------------------------------------
|
|
60
|
-
// The SSE body is ground truth: FINISHED with an answer, after a short grace
|
|
61
|
-
// window, is complete.
|
|
62
|
-
if (o.finishedWithAnswer && opts.netFinishedTicks >= NET_FINISHED_TICKS) {
|
|
63
|
-
return { kind: 'return', text: 'net' };
|
|
64
|
-
}
|
|
65
|
-
// A dead turn (the server ended it with nothing and there is no Continue to
|
|
66
|
-
// resume): give up quickly so ask() resends instead of a long spinner.
|
|
67
|
-
if (opts.deadTicks >= DEAD_TURN_TICKS && !o.continueVisible) {
|
|
68
|
-
return { kind: 'throw', error: 'incomplete' };
|
|
69
|
-
}
|
|
70
|
-
// A PAUSED generation looks stable: check it BEFORE the stability return.
|
|
71
|
-
if (opts.autoContinue && opts.deepThinking && o.continueVisible) {
|
|
72
|
-
return { kind: 'click-continue' };
|
|
73
|
-
}
|
|
74
|
-
if (o.isNew && opts.stableReached) {
|
|
75
|
-
if (o.notGenerating)
|
|
76
|
-
return { kind: 'return', text: 'dom' };
|
|
77
|
-
return { kind: 'wait' };
|
|
78
|
-
}
|
|
79
|
-
return { kind: 'wait' };
|
|
80
|
-
}
|
package/dist/browser-base.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// N1 stage 1: the helpers that were byte-identical in DeepSeekBrowser,
|
|
2
|
-
// QwenBrowser and GlmBrowser. Composition, not inheritance: every provider
|
|
3
|
-
// class owns a page and calls these with it, so the provider-specific logic
|
|
4
|
-
// stays where it is and only the shared mechanics live here.
|
|
5
|
-
/** Best-effort: a page error while marking the tab title must never break a
|
|
6
|
-
* send loop, so every failure is swallowed. */
|
|
7
|
-
export async function setBusyTitle(page, busy, setInDom) {
|
|
8
|
-
try {
|
|
9
|
-
await page.evaluate(setInDom, busy);
|
|
10
|
-
}
|
|
11
|
-
catch { }
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* The first selector that has a VISIBLE element. `timeout` is a per-selector
|
|
15
|
-
* budget, not a total one (the providers rely on that: a login form is tried
|
|
16
|
-
* with a long budget while optional probes use a short one).
|
|
17
|
-
*
|
|
18
|
-
* Two probe styles exist in the providers (`waitFor({state:'visible'})` in
|
|
19
|
-
* DeepSeek/Qwen and `count()` + `isVisible()` in GLM). `style: 'wait'` keeps
|
|
20
|
-
* the first (it also covers a late-mounting element); `style: 'check'` keeps
|
|
21
|
-
* the second (no waiting state, a cheap poll).
|
|
22
|
-
*/
|
|
23
|
-
export async function findVisible(page, selectors, timeout = 1000, style = 'wait') {
|
|
24
|
-
for (const sel of selectors) {
|
|
25
|
-
const loc = page.locator(sel).last();
|
|
26
|
-
if (style === 'wait') {
|
|
27
|
-
try {
|
|
28
|
-
await loc.waitFor({ state: 'visible', timeout });
|
|
29
|
-
return loc;
|
|
30
|
-
}
|
|
31
|
-
catch { }
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
if ((await loc.count()) === 0)
|
|
36
|
-
continue;
|
|
37
|
-
if (await loc.isVisible({ timeout }).catch(() => false))
|
|
38
|
-
return loc;
|
|
39
|
-
}
|
|
40
|
-
catch { }
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Sleep in 100 ms chunks so Esc and the ask() watchdog stay responsive.
|
|
46
|
-
* Returns true when the wait was INTERRUPTED (abort / cancelled ask), so the
|
|
47
|
-
* caller can stop early. `onTick` fires about once a second with the time
|
|
48
|
-
* left — that is what animates the throttle countdown.
|
|
49
|
-
*/
|
|
50
|
-
export async function sleepInterruptible(page, ms, isInterrupted, onTick) {
|
|
51
|
-
const step = 100;
|
|
52
|
-
let left = ms;
|
|
53
|
-
let sinceTick = 0;
|
|
54
|
-
while (left > 0) {
|
|
55
|
-
if (isInterrupted())
|
|
56
|
-
return true;
|
|
57
|
-
const chunk = Math.min(step, left);
|
|
58
|
-
await page.waitForTimeout(chunk);
|
|
59
|
-
left -= chunk;
|
|
60
|
-
if (onTick) {
|
|
61
|
-
sinceTick += chunk;
|
|
62
|
-
if (sinceTick >= 1000) {
|
|
63
|
-
sinceTick = 0;
|
|
64
|
-
onTick(left);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return isInterrupted();
|
|
69
|
-
}
|
package/dist/provider-errors.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// N1 stage 2: the error families shared by the chat providers.
|
|
2
|
-
//
|
|
3
|
-
// Each provider used to declare its own copy (`QwenRateLimitError`,
|
|
4
|
-
// `GlmRateLimitError`, ...), so the retry policy in every `ask()` re-listed the
|
|
5
|
-
// same six families and a new one had to be added three times. The classes
|
|
6
|
-
// here are the SHARED vocabulary; the provider keeps its own pre-check
|
|
7
|
-
// (`instanceof`) only where the semantics genuinely differ (guest limits and
|
|
8
|
-
// the age gate exist on one backend only).
|
|
9
|
-
//
|
|
10
|
-
// `provider` is carried for the operator-facing message only — the retry
|
|
11
|
-
// decision is per CLASS, never per string.
|
|
12
|
-
export class ProviderRateLimitError extends Error {
|
|
13
|
-
constructor(detail) {
|
|
14
|
-
super('rate limit: ' + detail);
|
|
15
|
-
this.name = 'ProviderRateLimitError';
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
export class ProviderServerBusyError extends Error {
|
|
19
|
-
constructor(detail) {
|
|
20
|
-
super('server busy: ' + detail);
|
|
21
|
-
this.name = 'ProviderServerBusyError';
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
export class ProviderContextLimitError extends Error {
|
|
25
|
-
constructor(detail) {
|
|
26
|
-
super('context limit: ' + detail);
|
|
27
|
-
this.name = 'ProviderContextLimitError';
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
export class ProviderIncompleteError extends Error {
|
|
31
|
-
constructor(detail) {
|
|
32
|
-
super('generation incomplete: ' + detail);
|
|
33
|
-
this.name = 'ProviderIncompleteError';
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
export class ProviderGuestLimitError extends Error {
|
|
37
|
-
constructor(detail) {
|
|
38
|
-
super('guest limit: ' + detail);
|
|
39
|
-
this.name = 'ProviderGuestLimitError';
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
export class ProviderAgeGateError extends Error {
|
|
43
|
-
constructor(detail) {
|
|
44
|
-
super('age gate: ' + detail);
|
|
45
|
-
this.name = 'ProviderAgeGateError';
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
export class ProviderCaptchaError extends Error {
|
|
49
|
-
constructor(detail) {
|
|
50
|
-
super('captcha: ' + detail);
|
|
51
|
-
this.name = 'ProviderCaptchaError';
|
|
52
|
-
}
|
|
53
|
-
}
|
package/dist/retry-policy.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// The retry POLICY of a provider's ask() as a PURE function.
|
|
2
|
-
//
|
|
3
|
-
// WHY: the three providers ran the same loop — call _askOnce, classify the
|
|
4
|
-
// thrown error into a family, retry that family within its own budget — and
|
|
5
|
-
// each copy had its own order of checks and its own wording. The loop itself
|
|
6
|
-
// (sends, throttling, aborts) stays in the browser class; only the DECISION is
|
|
7
|
-
// here, so every branch is unit-tested without a browser.
|
|
8
|
-
//
|
|
9
|
-
// Incremental retries (default): the three transient families (and the
|
|
10
|
-
// agent-loop watchdog) no longer count their attempts; the WAIT grows from the
|
|
11
|
-
// family's base (rate limit: 5m, busy: 3s, incomplete: 2s) up to the ceiling
|
|
12
|
-
// (browser.maxRetryWaitMs, default 30m), and the only way out is a cancel
|
|
13
|
-
// (Esc/Ctrl+C) or a FATAL error (bad login, captcha, guest limit) — the same
|
|
14
|
-
// place it was before.
|
|
15
|
-
//
|
|
16
|
-
// One thing matters for understanding this module: a retry that waits the
|
|
17
|
-
// SAME fixed pause forever is not endless, it is a busy loop that never lets
|
|
18
|
-
// the block expire. The escalation is what makes "infinite" safe.
|
|
19
|
-
import { backoffWaitMs, canRetry, } from './retry-backoff.js';
|
|
20
|
-
/**
|
|
21
|
-
* Decide what to do after a failed attempt.
|
|
22
|
-
*
|
|
23
|
-
* classify maps the thrown error to a family; it lives in the provider
|
|
24
|
-
* because the backend-specific markers (guest limit, age gate, captcha) are
|
|
25
|
-
* provider knowledge. A null classification falls through to the generic
|
|
26
|
-
* attempt budget.
|
|
27
|
-
*
|
|
28
|
-
* backoff carries the retry mode (infiniteRetries, the ceiling) — see
|
|
29
|
-
* src/retry-backoff.ts.
|
|
30
|
-
*/
|
|
31
|
-
export function decideRetry(family, state, budgets, waits, backoff, opts = {}) {
|
|
32
|
-
// Strict === true: the infinite mode is opt-in by an EXPLICIT config field.
|
|
33
|
-
// A caller that omits it keeps the historical counted budget, so a
|
|
34
|
-
// partially built config can never silently retry forever.
|
|
35
|
-
const infinite = backoff?.infiniteRetries === true; // strict
|
|
36
|
-
const cfg = {
|
|
37
|
-
infiniteRetries: infinite,
|
|
38
|
-
maxRetries: backoff?.maxRetries ?? 6,
|
|
39
|
-
baseWaitMs: waits.rateLimitMs,
|
|
40
|
-
maxWaitMs: backoff?.maxWaitMs ?? 0,
|
|
41
|
-
};
|
|
42
|
-
// The cause of a transient failure decides the FIRST wait — a rate block is
|
|
43
|
-
// waited out for minutes, a transient busy notice for seconds — and the
|
|
44
|
-
// escalation adds to it from there.
|
|
45
|
-
const baseWait = (f) => f === 'server-busy'
|
|
46
|
-
? waits.serverBusyMs
|
|
47
|
-
: f === 'incomplete'
|
|
48
|
-
? waits.incompleteMs
|
|
49
|
-
: waits.rateLimitMs;
|
|
50
|
-
const waitFor = (f, attempts) => backoffWaitMs({ ...cfg, baseWaitMs: baseWait(f) }, attempts, {
|
|
51
|
-
// A server Retry-After is the best clock we have; it is still clamped
|
|
52
|
-
// to the ceiling and never shortens the escalation.
|
|
53
|
-
serverWaitMs: f === 'rate-limit' ? waits.retryAfterMs : undefined,
|
|
54
|
-
jitter: opts.jitter,
|
|
55
|
-
});
|
|
56
|
-
switch (family) {
|
|
57
|
-
case 'fatal':
|
|
58
|
-
return { kind: 'give-up', family };
|
|
59
|
-
case 'context-limit':
|
|
60
|
-
if (state.contextLimitRetries >= budgets.maxContextLimitRetries) {
|
|
61
|
-
return { kind: 'give-up', family };
|
|
62
|
-
}
|
|
63
|
-
return { kind: 'compact', family };
|
|
64
|
-
case 'rate-limit':
|
|
65
|
-
if (!canRetry(cfg, state.rateLimitRetries)) {
|
|
66
|
-
return { kind: 'give-up', family };
|
|
67
|
-
}
|
|
68
|
-
return {
|
|
69
|
-
kind: 'wait',
|
|
70
|
-
family,
|
|
71
|
-
waitMs: waitFor('rate-limit', state.rateLimitRetries + 1),
|
|
72
|
-
};
|
|
73
|
-
case 'server-busy':
|
|
74
|
-
if (!canRetry(cfg, state.serverBusyRetries)) {
|
|
75
|
-
return { kind: 'give-up', family };
|
|
76
|
-
}
|
|
77
|
-
return {
|
|
78
|
-
kind: 'wait',
|
|
79
|
-
family,
|
|
80
|
-
waitMs: waitFor('server-busy', state.serverBusyRetries + 1),
|
|
81
|
-
};
|
|
82
|
-
case 'incomplete':
|
|
83
|
-
if (!canRetry(cfg, state.incompleteRetries)) {
|
|
84
|
-
return { kind: 'give-up', family };
|
|
85
|
-
}
|
|
86
|
-
return {
|
|
87
|
-
kind: 'wait',
|
|
88
|
-
family,
|
|
89
|
-
waitMs: waitFor('incomplete', state.incompleteRetries + 1),
|
|
90
|
-
};
|
|
91
|
-
default:
|
|
92
|
-
// A generic failure is retried while the TOTAL attempt budget lasts.
|
|
93
|
-
if (state.attempt >= budgets.askRetries - 1) {
|
|
94
|
-
return { kind: 'give-up', family: 'generic' };
|
|
95
|
-
}
|
|
96
|
-
return { kind: 'generic', waitMs: 2000 * (state.attempt + 1) };
|
|
97
|
-
}
|
|
98
|
-
}
|
package/dist/send-slot.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// N1 stage 3: the SEND SLOT — the pause before a send that keeps the chat's
|
|
2
|
-
// rate limit happy.
|
|
3
|
-
//
|
|
4
|
-
// Every provider had its own `_waitForSendSlot()` with the same shape (skip a
|
|
5
|
-
// non-agent send, compute the remaining gap, report the animated countdown,
|
|
6
|
-
// mark the state 'paused', sleep interruptibly) and each computed the gap its
|
|
7
|
-
// own way (DeepSeek: interval + thinking bonus; Qwen: interval; GLM: the
|
|
8
|
-
// adaptive sendRuntime). The MECHANICS are shared here; the provider injects
|
|
9
|
-
// the gap and the plumbing.
|
|
10
|
-
/**
|
|
11
|
-
* Wait for the send slot. Returns true when the wait was INTERRUPTED (the
|
|
12
|
-
* caller must then abort the send).
|
|
13
|
-
*/
|
|
14
|
-
export async function waitForSendSlot(agent, deps) {
|
|
15
|
-
if (!agent)
|
|
16
|
-
return false;
|
|
17
|
-
const gap = deps.gapMs();
|
|
18
|
-
if (gap <= 0)
|
|
19
|
-
return false;
|
|
20
|
-
const report = (leftMs) => {
|
|
21
|
-
const secs = Math.max(0, Math.ceil(leftMs / 1000));
|
|
22
|
-
if (deps.onPause) {
|
|
23
|
-
try {
|
|
24
|
-
deps.onPause(secs);
|
|
25
|
-
}
|
|
26
|
-
catch { }
|
|
27
|
-
}
|
|
28
|
-
else if (deps.onPauseText) {
|
|
29
|
-
deps.onPauseText(secs);
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
report(gap);
|
|
33
|
-
if (deps.emitState)
|
|
34
|
-
deps.emitState('paused');
|
|
35
|
-
return await deps.sleep(gap, report);
|
|
36
|
-
}
|