zames_pro 2.89.0 → 2.90.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +32 -585
  3. package/dist/agent-loop.js +47 -4
  4. package/dist/ask-retry.js +49 -0
  5. package/dist/ask-state.js +80 -0
  6. package/dist/browser-base.js +69 -0
  7. package/dist/browser.js +170 -41
  8. package/dist/command-registry.js +58 -0
  9. package/dist/commands.js +25 -2
  10. package/dist/completion-check.js +77 -0
  11. package/dist/config-schema.js +8 -0
  12. package/dist/config.js +14 -0
  13. package/dist/dev-commands.js +27 -0
  14. package/dist/dev-watch.js +96 -0
  15. package/dist/first-run.js +80 -0
  16. package/dist/glm-browser.js +133 -32
  17. package/dist/i18n/common.js +6 -3
  18. package/dist/i18n/config.js +16 -0
  19. package/dist/i18n/wizard.js +25 -0
  20. package/dist/i18n.js +6 -1
  21. package/dist/index.js +1289 -919
  22. package/dist/info-commands.js +61 -0
  23. package/dist/input/layout.js +38 -1
  24. package/dist/input.js +2 -2
  25. package/dist/password-store.js +48 -0
  26. package/dist/prompt-budget.js +23 -0
  27. package/dist/provider-errors.js +53 -0
  28. package/dist/provider-fallback.js +46 -0
  29. package/dist/qwen-browser.js +155 -39
  30. package/dist/run-task.js +2 -1
  31. package/dist/scheduler-commands.js +41 -0
  32. package/dist/send-slot.js +36 -0
  33. package/dist/session-commands.js +42 -0
  34. package/dist/spinner.js +3 -3
  35. package/dist/swallow.js +45 -0
  36. package/dist/system-prompt.js +2 -2
  37. package/dist/tree-commands.js +36 -0
  38. package/dist/tui/FullScreenApp.js +13 -4
  39. package/dist/tui/InputLine.js +20 -3
  40. package/dist/tui/LogView.js +11 -5
  41. package/dist/tui/StatusBar.js +4 -1
  42. package/dist/tui/TuiApp.js +11 -2
  43. package/dist/tui/layout.js +14 -1
  44. package/dist/tui/store.js +2 -1
  45. package/dist/tui/ui-colors.js +2 -0
  46. package/dist/width.js +10 -5
  47. package/llms-full.txt +16 -292
  48. package/package.json +2 -2
@@ -3,12 +3,16 @@ import { isContextLimitText } from './browser-pure.js';
3
3
  import { providerLabel as providerHumanLabel } from './providers.js';
4
4
  import { loadProjectContext } from './context.js';
5
5
  import { getGitContext, formatGitContext } from './gitTools.js';
6
- import { translate } from './i18n.js';
6
+ import { translate, DEFAULT_LOCALE } from './i18n.js';
7
7
  import { substituteAttachmentMarkers } from './commands.js';
8
+ import { needsCompletionNudge, COMPLETION_NUDGE, } from './completion-check.js';
8
9
  import { loadHooks, runPreToolUse, runPostToolUse, runLifecycleHooks, } from './hooks.js';
9
10
  import { loadPermissions, decidePermission, } from './permissions.js';
10
11
  import { shouldRunDiagnostics, runDiagnostics, } from './diagnostics.js';
11
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, onContextLimit = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, onAskUser = null, }) {
12
+ // The tools that WRITE/EDIT a file (and take a `path`). The completion nudge
13
+ // only cares about these — `Bash`/`Git*` in MUTATING_TOOLS are not edits.
14
+ const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'ApplyPatch']);
15
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = DEFAULT_LOCALE, askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, onContextLimit = null, onProviderExhausted = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, onAskUser = null, }) {
12
16
  // Resolve the hook config ONCE per task: a read per tool call would be
13
17
  // wasteful, and a mid-task edit of hooks.json is not something to chase.
14
18
  // `undefined` means "read .zames/hooks.json"; an explicit null disables
@@ -58,6 +62,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
58
62
  ;
59
63
  browser.onContextLimit = onContextLimit;
60
64
  }
65
+ if ('onProviderExhausted' in browser) {
66
+ ;
67
+ browser.onProviderExhausted = onProviderExhausted;
68
+ }
61
69
  const safeAssistantThought = safe(onAssistantThought);
62
70
  const safeToolCall = safe(onToolCall);
63
71
  const safeToolResult = safe(onToolResult);
@@ -79,8 +87,18 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
79
87
  }
80
88
  };
81
89
  if (freshChat) {
82
- await browser.newChat();
83
- transcript?.log('new_chat');
90
+ // A RESUMED chat must win over "fresh": the caller marks a chat fresh only
91
+ // when it has no live id (a page restart dropped it). Sending into newChat()
92
+ // there opened a SECOND chat and the operator saw the dialogue vanish —
93
+ // the very regression this branch prevents. A provider that cannot navigate
94
+ // simply ignores the id.
95
+ const resumeId = browser.resumeChatId || null;
96
+ const reopened = resumeId && browser.openChat
97
+ ? await browser.openChat(resumeId).catch(() => false)
98
+ : false;
99
+ if (!reopened)
100
+ await browser.newChat();
101
+ transcript?.log(reopened ? 'reopen_chat' : 'new_chat', resumeId ? { id: resumeId } : undefined);
84
102
  // SessionStart hook: a fresh chat is the start of a session, so a
85
103
  // hook can inject context or set up state. Best-effort, output is logged.
86
104
  try {
@@ -268,6 +286,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
268
286
  // paragraph that is neither a tool call nor a real respond) from a genuine
269
287
  // short answer. We key on the STRUCTURE (work already started), not words.
270
288
  let toolsRanInTask = 0;
289
+ // Ordered history of the tool calls in this task — feeds the post-edit
290
+ // "did you verify the artifact?" nudge (src/completion-check.ts).
291
+ const runSteps = [];
292
+ // The nudge is sent at most ONCE per task: it is a hint, not a guard.
293
+ let completionNudgeSent = false;
271
294
  const MAX_AFTER_TOOL_RETRIES = Math.max(0, Math.floor(maxAfterToolRetries));
272
295
  // One-time hint when the context nears full while auto-compact is OFF:
273
296
  // the operator is told about /compact instead of silently degrading. Set
@@ -745,6 +768,16 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
745
768
  ' chat manually.');
746
769
  return 'The model produced no final message (empty respond).';
747
770
  }
771
+ // A runnable code file was written/edited and NOTHING ran since: the
772
+ // model may be finishing on an INCOMPLETE artifact (a real case: a page
773
+ // with no game loop, reported DONE). Ask ONCE to verify it; the model
774
+ // can still finish with a confirmed respond.
775
+ if (!completionNudgeSent && needsCompletionNudge(runSteps)) {
776
+ completionNudgeSent = true;
777
+ transcript?.log('completion_nudge', { steps: runSteps.length });
778
+ message = COMPLETION_NUDGE;
779
+ continue;
780
+ }
748
781
  safeAssistantMessage(msg);
749
782
  transcript?.log('assistant_final', { message: msg });
750
783
  return msg;
@@ -1159,6 +1192,16 @@ ${diag}`;
1159
1192
  // Reset the watchdog so the next empty/repeated answer is nudged.
1160
1193
  justRanTool = true;
1161
1194
  toolsRanInTask++;
1195
+ // Remember the calls (and whether they mutated a code file) for the
1196
+ // post-edit completion nudge below.
1197
+ for (const r of results) {
1198
+ const callArgs = callsToRun.find((c) => c.tool === r.tool)?.args;
1199
+ runSteps.push({
1200
+ tool: r.tool,
1201
+ mutating: EDIT_TOOLS.has(r.tool),
1202
+ path: typeof callArgs?.path === 'string' ? callArgs.path : undefined,
1203
+ });
1204
+ }
1162
1205
  // The tool really executed on this turn. The NEXT iteration will treat
1163
1206
  // a repeat of this answer as stale only because of this flag (see NEXT,
1164
1207
  // not the current, justRanTool check).
@@ -0,0 +1,49 @@
1
+ // N1 stage 4: the retry POLICY of a provider's `ask()` as a PURE function.
2
+ //
3
+ // The three providers ran the same loop — call `_askOnce`, classify the thrown
4
+ // error into a family (rate limit / server busy / incomplete / context limit /
5
+ // provider-specific fatal), retry that family within its own budget, otherwise
6
+ // fall through to a generic attempt counter — and each copy had its own order
7
+ // of checks and its own wording. The loop itself (sends, throttling, aborts)
8
+ // stays in the browser class; only the DECISION is here, so every branch is
9
+ // unit-tested without a browser.
10
+ /**
11
+ * Decide what to do after a failed attempt.
12
+ *
13
+ * `classify` maps the thrown error to a family; it lives in the provider
14
+ * because the backend-specific markers (guest limit, age gate, captcha) are
15
+ * provider knowledge. A `null` classification falls through to the generic
16
+ * attempt budget.
17
+ */
18
+ export function decideRetry(family, state, budgets, waits) {
19
+ switch (family) {
20
+ case 'fatal':
21
+ return { kind: 'give-up', family };
22
+ case 'context-limit':
23
+ if (state.contextLimitRetries >= budgets.maxContextLimitRetries) {
24
+ return { kind: 'give-up', family };
25
+ }
26
+ return { kind: 'compact', family };
27
+ case 'rate-limit':
28
+ if (state.rateLimitRetries >= budgets.maxRateLimitRetries) {
29
+ return { kind: 'give-up', family };
30
+ }
31
+ return { kind: 'wait', family, waitMs: waits.rateLimitMs };
32
+ case 'server-busy':
33
+ if (state.serverBusyRetries >= budgets.maxServerBusyRetries) {
34
+ return { kind: 'give-up', family };
35
+ }
36
+ return { kind: 'wait', family, waitMs: waits.serverBusyMs };
37
+ case 'incomplete':
38
+ if (state.incompleteRetries >= budgets.maxIncompleteRetries) {
39
+ return { kind: 'give-up', family };
40
+ }
41
+ return { kind: 'wait', family, waitMs: waits.incompleteMs };
42
+ default:
43
+ // A generic failure is retried while the TOTAL attempt budget lasts.
44
+ if (state.attempt >= budgets.askRetries - 1) {
45
+ return { kind: 'give-up', family: 'generic' };
46
+ }
47
+ return { kind: 'generic', waitMs: 2000 * (state.attempt + 1) };
48
+ }
49
+ }
@@ -0,0 +1,80 @@
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
+ }
@@ -0,0 +1,69 @@
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
+ }