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 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
- .replace(/([^\n])\n?(?=## )/g, '$1' + nl + nl)
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
- .replace(/^## (?!\[)/gm, '### ');
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 goal so the model does not lose it.
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 goal = String(task ?? '').trim();
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 (goal) {
528
- out += NL + NL + 'Original task: ' + goal;
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
- await browser.ask(buildCompactCarryover(summary, task), {
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
- return await askQuestionInk({
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.4",
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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }