zames_pro 2.88.2 → 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 (52) hide show
  1. package/CHANGELOG.md +54 -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-commands.js +12 -4
  12. package/dist/config-menu.js +179 -81
  13. package/dist/config-schema.js +562 -0
  14. package/dist/config.js +19 -405
  15. package/dist/dev-commands.js +27 -0
  16. package/dist/dev-watch.js +96 -0
  17. package/dist/first-run.js +80 -0
  18. package/dist/glm-browser.js +133 -32
  19. package/dist/i18n/common.js +6 -3
  20. package/dist/i18n/config.js +78 -3
  21. package/dist/i18n/deepseek.js +1 -0
  22. package/dist/i18n/wizard.js +25 -0
  23. package/dist/i18n.js +6 -1
  24. package/dist/index.js +1299 -924
  25. package/dist/info-commands.js +61 -0
  26. package/dist/input/layout.js +38 -1
  27. package/dist/input.js +2 -2
  28. package/dist/password-store.js +48 -0
  29. package/dist/prompt-budget.js +23 -0
  30. package/dist/provider-errors.js +53 -0
  31. package/dist/provider-fallback.js +46 -0
  32. package/dist/qwen-browser.js +155 -39
  33. package/dist/run-task.js +2 -1
  34. package/dist/scheduler-commands.js +41 -0
  35. package/dist/send-slot.js +36 -0
  36. package/dist/session-commands.js +42 -0
  37. package/dist/spinner.js +3 -3
  38. package/dist/swallow.js +45 -0
  39. package/dist/system-prompt.js +2 -2
  40. package/dist/tree-commands.js +36 -0
  41. package/dist/tui/ConfigMenu.js +149 -64
  42. package/dist/tui/FullScreenApp.js +13 -4
  43. package/dist/tui/InputLine.js +20 -3
  44. package/dist/tui/LogView.js +11 -5
  45. package/dist/tui/StatusBar.js +4 -1
  46. package/dist/tui/TuiApp.js +11 -2
  47. package/dist/tui/layout.js +14 -1
  48. package/dist/tui/store.js +2 -1
  49. package/dist/tui/ui-colors.js +2 -0
  50. package/dist/width.js +10 -5
  51. package/llms-full.txt +16 -292
  52. package/package.json +2 -2
package/dist/browser.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { chromium, } from 'playwright';
2
+ import { noteSwallowed } from './swallow.js';
2
3
  import { interactiveBrowserLogin, loginIsInteractive, canShowWindow, } from './browser-login.js';
3
4
  import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, isFinishedWithAnswer, } from './net-capture.js';
4
5
  import { rewriteFailedAttachments } from './commands.js';
@@ -46,6 +47,8 @@ export class DeepSeekBrowser {
46
47
  // recovery is capped independently of askRetries.
47
48
  maxContextLimitRetries;
48
49
  onContextLimit;
50
+ /** N8: switch to an authorized fallback provider (see agent-loop). */
51
+ onProviderExhausted;
49
52
  serverBusyWaitMs;
50
53
  // Retries for a turn the server truncated (generation_err / INCOMPLETE).
51
54
  maxIncompleteRetries;
@@ -192,6 +195,7 @@ export class DeepSeekBrowser {
192
195
  this.incompleteWaitMs = incompleteWaitMs;
193
196
  this.maxContextLimitRetries = maxContextLimitRetries;
194
197
  this.onContextLimit = onContextLimit || null;
198
+ this.onProviderExhausted = null;
195
199
  this.autoContinue = autoContinue;
196
200
  this.continueMinGapMs = continueMinGapMs;
197
201
  this.loginTimeoutMs = loginTimeoutMs;
@@ -264,7 +268,9 @@ export class DeepSeekBrowser {
264
268
  this.onNotice(text);
265
269
  return;
266
270
  }
267
- catch { }
271
+ catch (e) {
272
+ noteSwallowed('_notice', e, this.debug);
273
+ }
268
274
  }
269
275
  console.error(text);
270
276
  }
@@ -275,7 +281,9 @@ export class DeepSeekBrowser {
275
281
  try {
276
282
  this.onSendState(state);
277
283
  }
278
- catch { }
284
+ catch (e) {
285
+ noteSwallowed('_emitSendState', e, this.debug);
286
+ }
279
287
  }
280
288
  }
281
289
  // Best-effort: a page error while marking the tab title must never break the
@@ -284,7 +292,9 @@ export class DeepSeekBrowser {
284
292
  try {
285
293
  await this.page.evaluate(setBusyTitleInDom, busy);
286
294
  }
287
- catch { }
295
+ catch (e) {
296
+ noteSwallowed('_setBusyTitle', e, this.debug);
297
+ }
288
298
  }
289
299
  async launch() {
290
300
  // NOTE: we deliberately do NOT kill stray chrome processes here. Per the
@@ -417,7 +427,9 @@ export class DeepSeekBrowser {
417
427
  if (h['x-ds-pow-response'])
418
428
  this._apiPow = h['x-ds-pow-response'];
419
429
  }
420
- catch { }
430
+ catch (e) {
431
+ noteSwallowed('_installNetHook', e, this.debug);
432
+ }
421
433
  });
422
434
  }
423
435
  async _onResponse(resp) {
@@ -506,14 +518,23 @@ export class DeepSeekBrowser {
506
518
  }
507
519
  }
508
520
  }
509
- catch { }
521
+ catch (e) {
522
+ noteSwallowed('_onResponse', e, this.debug);
523
+ }
510
524
  }
511
525
  async restart() {
526
+ // The dialogue the operator was in. A relaunch lands on the BASE url, whose
527
+ // URL has no id, so getCurrentChatId() answers null and the next
528
+ // `freshChat` opened a SECOND chat — the resumed conversation vanished.
529
+ // Remember it BEFORE the context dies and re-open it after the launch.
530
+ const chatBefore = (await this.getCurrentChatId().catch(() => null)) ?? null;
512
531
  try {
513
532
  if (this.context)
514
533
  await this.context.close();
515
534
  }
516
- catch { }
535
+ catch (e) {
536
+ noteSwallowed('restart', e, this.debug);
537
+ }
517
538
  // A restart must REUSE the existing profile. `launch()` would clean
518
539
  // Singleton files, which can race with a sibling agent on the same
519
540
  // profile; here we just relaunch the persistent context in place. A fresh
@@ -521,6 +542,16 @@ export class DeepSeekBrowser {
521
542
  // second window is created while one is alive.
522
543
  await this._launchOnce();
523
544
  await this._fixHeadlessUserAgent();
545
+ // Put the operator BACK into the chat instead of leaving them in a new one.
546
+ // A failure is not fatal: the caller (ask) retries the send and the loop's
547
+ // resumeChatId still points at the right chat.
548
+ if (chatBefore) {
549
+ const ok = await this.openChat(chatBefore).catch(() => false);
550
+ if (ok)
551
+ this._netChatId = chatBefore;
552
+ else if (this.debug)
553
+ console.error('restart: could not reopen chat ' + chatBefore);
554
+ }
524
555
  }
525
556
  /**
526
557
  * PURE-ish gate: may the login open a visible window? False for --task, a
@@ -576,7 +607,9 @@ export class DeepSeekBrowser {
576
607
  if (u)
577
608
  await u.fill(this.auth.username).catch(() => { });
578
609
  }
579
- catch { }
610
+ catch (e) {
611
+ noteSwallowed('_browserLogin', e, this.debug);
612
+ }
580
613
  },
581
614
  captureCredentials: async () => {
582
615
  try {
@@ -591,7 +624,9 @@ export class DeepSeekBrowser {
591
624
  try {
592
625
  this.onAuthSave?.(user, pass);
593
626
  }
594
- catch { }
627
+ catch (e) {
628
+ noteSwallowed('_browserLogin', e, this.debug);
629
+ }
595
630
  });
596
631
  await this.page.evaluate(() => {
597
632
  const w = window;
@@ -609,11 +644,15 @@ export class DeepSeekBrowser {
609
644
  if (fn)
610
645
  fn(u ? u.value || '' : '', p ? p.value || '' : '');
611
646
  }
612
- catch { }
647
+ catch (e) {
648
+ noteSwallowed('_browserLogin', e, this.debug);
649
+ }
613
650
  }, true);
614
651
  });
615
652
  }
616
- catch { }
653
+ catch (e) {
654
+ noteSwallowed('_browserLogin', e, this.debug);
655
+ }
617
656
  },
618
657
  persistSession: () => this._persistSessionIfNeeded(),
619
658
  // Route login notices through the SAME notice hook as every other
@@ -660,7 +699,9 @@ export class DeepSeekBrowser {
660
699
  });
661
700
  api = res.ok;
662
701
  }
663
- catch { }
702
+ catch (e) {
703
+ noteSwallowed('probeLogin', e, this.debug);
704
+ }
664
705
  const body = document.body ? document.body.innerText : '';
665
706
  const signInVisible = /log ?in|sign ?in|войти/i.test(body);
666
707
  return { hasToken, api, signInVisible };
@@ -691,12 +732,16 @@ export class DeepSeekBrowser {
691
732
  if (session.length)
692
733
  this._carryCookies = session;
693
734
  }
694
- catch { }
735
+ catch (e) {
736
+ noteSwallowed('relaunchSafe', e, this.debug);
737
+ }
695
738
  try {
696
739
  if (this.context)
697
740
  await this.context.close();
698
741
  }
699
- catch { }
742
+ catch (e) {
743
+ noteSwallowed('relaunchSafe', e, this.debug);
744
+ }
700
745
  const deadline = Date.now() + 15000;
701
746
  while (Date.now() < deadline) {
702
747
  if (!(await profileLooksLocked()))
@@ -748,7 +793,9 @@ export class DeepSeekBrowser {
748
793
  }
749
794
  }
750
795
  }
751
- catch { }
796
+ catch (e) {
797
+ noteSwallowed('_openLoginPage', e, this.debug);
798
+ }
752
799
  }
753
800
  async waitForLogin() {
754
801
  // 1) Already signed in (session cookie in the persistent profile)?
@@ -1003,7 +1050,9 @@ export class DeepSeekBrowser {
1003
1050
  if (await tryLogin())
1004
1051
  return true;
1005
1052
  }
1006
- catch { }
1053
+ catch (e) {
1054
+ noteSwallowed('_fillLoginForm', e, this.debug);
1055
+ }
1007
1056
  }
1008
1057
  for (const sel of LOGIN_SUBMIT_SELECTORS) {
1009
1058
  const btn = this.page.locator(sel).last();
@@ -1016,7 +1065,9 @@ export class DeepSeekBrowser {
1016
1065
  if (await tryLogin())
1017
1066
  return true;
1018
1067
  }
1019
- catch { }
1068
+ catch (e) {
1069
+ noteSwallowed('_fillLoginForm', e, this.debug);
1070
+ }
1020
1071
  }
1021
1072
  // Button inside the same form as the password field.
1022
1073
  const formClicked = await this.page
@@ -1123,7 +1174,9 @@ export class DeepSeekBrowser {
1123
1174
  submitted = true;
1124
1175
  break;
1125
1176
  }
1126
- catch { }
1177
+ catch (e) {
1178
+ noteSwallowed('_handleMfaIfPresent', e, this.debug);
1179
+ }
1127
1180
  }
1128
1181
  if (!submitted)
1129
1182
  await codeInput.press('Enter').catch(() => { });
@@ -1159,7 +1212,9 @@ export class DeepSeekBrowser {
1159
1212
  await btn.click({ timeout: 2000 });
1160
1213
  return true;
1161
1214
  }
1162
- catch { }
1215
+ catch (e) {
1216
+ noteSwallowed('_clickMfaSendCode', e, this.debug);
1217
+ }
1163
1218
  }
1164
1219
  return false;
1165
1220
  }
@@ -1267,7 +1322,9 @@ export class DeepSeekBrowser {
1267
1322
  await loc.waitFor({ state: 'visible', timeout });
1268
1323
  return loc;
1269
1324
  }
1270
- catch { }
1325
+ catch (e) {
1326
+ noteSwallowed('_findVisible', e, this.debug);
1327
+ }
1271
1328
  }
1272
1329
  return null;
1273
1330
  }
@@ -1377,7 +1434,9 @@ export class DeepSeekBrowser {
1377
1434
  return true;
1378
1435
  }
1379
1436
  }
1380
- catch { }
1437
+ catch (e) {
1438
+ noteSwallowed('_continueButtonVisible', e, this.debug);
1439
+ }
1381
1440
  // Fallback: the raw DOM scan (older/newer DeepSeek builds where the role
1382
1441
  // is absent).
1383
1442
  return this.page.evaluate(continueVisibleInDom).catch(() => false);
@@ -1418,7 +1477,9 @@ export class DeepSeekBrowser {
1418
1477
  }
1419
1478
  }
1420
1479
  }
1421
- catch { }
1480
+ catch (e) {
1481
+ noteSwallowed('_clickContinueIfVisible', e, this.debug);
1482
+ }
1422
1483
  if (found) {
1423
1484
  // One Continue click per `continueMinGapMs`: a `chat/continue` request
1424
1485
  // hits the rate limit like a send, and a run that keeps getting
@@ -1439,7 +1500,9 @@ export class DeepSeekBrowser {
1439
1500
  try {
1440
1501
  this.onSendPause(Math.max(0, Math.ceil(leftMs / 1000)));
1441
1502
  }
1442
- catch { }
1503
+ catch (e) {
1504
+ noteSwallowed('_clickContinueIfVisible', e, this.debug);
1505
+ }
1443
1506
  }
1444
1507
  });
1445
1508
  if (aborted)
@@ -1455,7 +1518,9 @@ export class DeepSeekBrowser {
1455
1518
  this._notice(this._t('ds.continue_clicked'));
1456
1519
  return true;
1457
1520
  }
1458
- catch { }
1521
+ catch (e) {
1522
+ noteSwallowed('_clickContinueIfVisible', e, this.debug);
1523
+ }
1459
1524
  }
1460
1525
  // 2) Fallback: raw DOM scan + full pointer/mouse event sequence (an older
1461
1526
  // build without a proper role, or a click intercepted by an overlay).
@@ -1493,7 +1558,9 @@ export class DeepSeekBrowser {
1493
1558
  return { loc: b, pressed };
1494
1559
  }
1495
1560
  }
1496
- catch { }
1561
+ catch (e) {
1562
+ noteSwallowed('_findToggle', e, this.debug);
1563
+ }
1497
1564
  }
1498
1565
  return null;
1499
1566
  }
@@ -1508,7 +1575,9 @@ export class DeepSeekBrowser {
1508
1575
  }
1509
1576
  return;
1510
1577
  }
1511
- catch { }
1578
+ catch (e) {
1579
+ noteSwallowed('_setToggle', e, this.debug);
1580
+ }
1512
1581
  }
1513
1582
  async _applyToggles() {
1514
1583
  await this._setToggle(DEEP_THINKING_RE, this.deepThinking);
@@ -1539,7 +1608,9 @@ export class DeepSeekBrowser {
1539
1608
  if (found)
1540
1609
  return found.pressed;
1541
1610
  }
1542
- catch { }
1611
+ catch (e) {
1612
+ noteSwallowed('getToggleStates', e, this.debug);
1613
+ }
1543
1614
  return fallback;
1544
1615
  };
1545
1616
  return {
@@ -1568,7 +1639,9 @@ export class DeepSeekBrowser {
1568
1639
  }
1569
1640
  }
1570
1641
  }
1571
- catch { }
1642
+ catch (e) {
1643
+ noteSwallowed('stopGeneration', e, this.debug);
1644
+ }
1572
1645
  // 2) Legacy selector path.
1573
1646
  const btn = await this._findVisible(STOP_SELECTORS, 500);
1574
1647
  if (btn) {
@@ -1576,7 +1649,9 @@ export class DeepSeekBrowser {
1576
1649
  await btn.click({ timeout: 1500 });
1577
1650
  return true;
1578
1651
  }
1579
- catch { }
1652
+ catch (e) {
1653
+ noteSwallowed('stopGeneration', e, this.debug);
1654
+ }
1580
1655
  }
1581
1656
  // 3) Raw DOM scan + the full pointer/mouse sequence (older builds without
1582
1657
  // a proper role, or a click intercepted by an overlay).
@@ -1589,7 +1664,9 @@ export class DeepSeekBrowser {
1589
1664
  await this.page.keyboard.press('Escape');
1590
1665
  return true;
1591
1666
  }
1592
- catch { }
1667
+ catch (e) {
1668
+ noteSwallowed('stopGeneration', e, this.debug);
1669
+ }
1593
1670
  return false;
1594
1671
  }
1595
1672
  async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
@@ -1597,6 +1674,8 @@ export class DeepSeekBrowser {
1597
1674
  let attempt = 0;
1598
1675
  let rateLimitRetries = 0;
1599
1676
  let serverBusyRetries = 0;
1677
+ // N8: the fallback switch is tried ONCE per ask(), not per family.
1678
+ let fallbackAttempted = false;
1600
1679
  // Bumped when DeepSeek truncated the turn (`generation_err`/INCOMPLETE).
1601
1680
  // The retry does NOT consume a regular ask() attempt: it is a server-side
1602
1681
  // hiccup, not a failed send, and it is more likely in the reasoning
@@ -1733,6 +1812,32 @@ export class DeepSeekBrowser {
1733
1812
  this._lastSentAt = Date.now();
1734
1813
  continue;
1735
1814
  }
1815
+ // The retry BUDGETS of the specific families are exhausted (or this
1816
+ // is a generic failure on the last attempt): give the injected N8
1817
+ // hook ONE chance to continue the task on an AUTHORIZED fallback
1818
+ // provider. It performs the switch and, when it succeeds, the SAME
1819
+ // prompt is resent on the new backend.
1820
+ if (!fallbackAttempted && this.onProviderExhausted) {
1821
+ const budgetLeft = rateLimitRetries < this.maxRateLimitRetries ||
1822
+ serverBusyRetries < this.maxServerBusyRetries ||
1823
+ attempt < this.askRetries - 1;
1824
+ if (!budgetLeft) {
1825
+ fallbackAttempted = true;
1826
+ let switched = false;
1827
+ try {
1828
+ switched = await this.onProviderExhausted(e);
1829
+ }
1830
+ catch (re) {
1831
+ noteSwallowed('_onProviderExhausted', re, this.debug);
1832
+ }
1833
+ if (switched) {
1834
+ attempt = 0;
1835
+ rateLimitRetries = 0;
1836
+ serverBusyRetries = 0;
1837
+ continue;
1838
+ }
1839
+ }
1840
+ }
1736
1841
  this._notice(theme.warn(this._t('ds.ask_retry', {
1737
1842
  attempt: attempt + 1,
1738
1843
  max: this.askRetries,
@@ -1790,7 +1895,9 @@ export class DeepSeekBrowser {
1790
1895
  await input.fill(text, { timeout: 5000 });
1791
1896
  return;
1792
1897
  }
1793
- catch { }
1898
+ catch (e) {
1899
+ noteSwallowed('_setInputText', e, this.debug);
1900
+ }
1794
1901
  }
1795
1902
  await input.click();
1796
1903
  await this.page.keyboard.press('Control+A');
@@ -1852,7 +1959,9 @@ export class DeepSeekBrowser {
1852
1959
  await this._awaitUploadPreview(files.map((f) => f.name), 2500);
1853
1960
  return failed;
1854
1961
  }
1855
- catch { }
1962
+ catch (e) {
1963
+ noteSwallowed('_attachFiles', e, this.debug);
1964
+ }
1856
1965
  }
1857
1966
  // Fallback: set the files directly on the hidden <input type=file>.
1858
1967
  const input = this.page.locator('input[type="file"]').first();
@@ -1891,7 +2000,9 @@ export class DeepSeekBrowser {
1891
2000
  try {
1892
2001
  this.onSendPause(secs);
1893
2002
  }
1894
- catch { }
2003
+ catch (e) {
2004
+ noteSwallowed('_waitRateLimit', e, this.debug);
2005
+ }
1895
2006
  }
1896
2007
  };
1897
2008
  this._emitSendState('paused');
@@ -1933,7 +2044,9 @@ export class DeepSeekBrowser {
1933
2044
  try {
1934
2045
  this.onSendPause(secs);
1935
2046
  }
1936
- catch { }
2047
+ catch (e) {
2048
+ noteSwallowed('_waitForSendSlot', e, this.debug);
2049
+ }
1937
2050
  }
1938
2051
  else {
1939
2052
  // Fallback when no UI hook is wired (a direct ask() from a script):
@@ -1984,7 +2097,9 @@ export class DeepSeekBrowser {
1984
2097
  const dir = path.join(os.homedir(), '.zames');
1985
2098
  void fs.appendFile(path.join(dir, 'ask-debug.log'), line).catch(() => { });
1986
2099
  }
1987
- catch { }
2100
+ catch (e) {
2101
+ noteSwallowed('_askDebug', e, this.debug);
2102
+ }
1988
2103
  }
1989
2104
  async _askOnce(prompt, { timeout, agent, attachments = [], }) {
1990
2105
  // We reset the abort flag ONLY at the very start of the send.
@@ -2026,7 +2141,9 @@ export class DeepSeekBrowser {
2026
2141
  try {
2027
2142
  this.onSendStart();
2028
2143
  }
2029
- catch { }
2144
+ catch (e) {
2145
+ noteSwallowed('_askOnce', e, this.debug);
2146
+ }
2030
2147
  }
2031
2148
  this._emitSendState('generating');
2032
2149
  void this._setBusyTitle(true);
@@ -2080,7 +2197,9 @@ export class DeepSeekBrowser {
2080
2197
  sent = true;
2081
2198
  break;
2082
2199
  }
2083
- catch { }
2200
+ catch (e) {
2201
+ noteSwallowed('_askOnce', e, this.debug);
2202
+ }
2084
2203
  }
2085
2204
  if (!sent)
2086
2205
  await this.page.waitForTimeout(150);
@@ -2106,7 +2225,9 @@ export class DeepSeekBrowser {
2106
2225
  this._lastSentAt = Date.now();
2107
2226
  }
2108
2227
  }
2109
- catch { }
2228
+ catch (e) {
2229
+ noteSwallowed('_askOnce', e, this.debug);
2230
+ }
2110
2231
  // Wait for the start: either Stop appeared, or the answer text changed,
2111
2232
  // or the page grew (a new answer node / longer last answer). In parallel we
2112
2233
  // catch the rate-limit toast (only toasts, not the whole body).
@@ -2598,7 +2719,9 @@ export class DeepSeekBrowser {
2598
2719
  await this.page.waitForTimeout(500);
2599
2720
  return;
2600
2721
  }
2601
- catch { }
2722
+ catch (e) {
2723
+ noteSwallowed('_ensureSidebarOpen', e, this.debug);
2724
+ }
2602
2725
  }
2603
2726
  }
2604
2727
  async listChats(limit = 30) {
@@ -2626,7 +2749,9 @@ export class DeepSeekBrowser {
2626
2749
  await this.page.waitForTimeout(100);
2627
2750
  }
2628
2751
  }
2629
- catch { }
2752
+ catch (e) {
2753
+ noteSwallowed('openChat', e, this.debug);
2754
+ }
2630
2755
  }
2631
2756
  try {
2632
2757
  await this.page.goto(chatUrl(id), {
@@ -2815,11 +2940,15 @@ export class DeepSeekBrowser {
2815
2940
  }
2816
2941
  }
2817
2942
  }
2818
- catch { }
2943
+ catch (e) {
2944
+ noteSwallowed('close', e, this.debug);
2945
+ }
2819
2946
  try {
2820
2947
  if (this.context)
2821
2948
  await this.context.close();
2822
2949
  }
2823
- catch { }
2950
+ catch (e) {
2951
+ noteSwallowed('close', e, this.debug);
2952
+ }
2824
2953
  }
2825
2954
  }
@@ -0,0 +1,58 @@
1
+ // N2: the slash-command REGISTRY.
2
+ //
3
+ // `main()` in index.ts used to be one long chain of `if (lower === '/x')`
4
+ // branches (~60 of them), and help, the «/» hints, the dev-only guard and the
5
+ // dispatch were built from DIFFERENT lists — a command could be dispatched but
6
+ // missing from the help, or the other way round. A command is now a DATA
7
+ // ENTRY: name, aliases, whether it is dev-only, and a handler that receives a
8
+ // `CommandContext`. The registry below owns the informational group first; the
9
+ // remaining groups move in as they are extracted (one group per commit), and
10
+ // `printHelp`/`buildSlashCommands`/`isDevOnlyCommand` keep their existing
11
+ // sources until the last group lands.
12
+ /**
13
+ * Match a typed line against the registry. Returns the command and its
14
+ * arguments (everything after the matched name), or null. Matching is
15
+ * case-insensitive and requires a word boundary: `/statusx` must NOT match
16
+ * `/status`.
17
+ */
18
+ export function matchCommand(commands, input) {
19
+ const trimmed = (input || '').trim();
20
+ if (!trimmed.startsWith('/'))
21
+ return null;
22
+ const body = trimmed.slice(1);
23
+ const space = body.search(/\s/);
24
+ const name = (space < 0 ? body : body.slice(0, space)).toLowerCase();
25
+ const rest = space < 0 ? '' : body.slice(space).trim();
26
+ for (const c of commands) {
27
+ if (c.name.toLowerCase() === name)
28
+ return { command: c, rest };
29
+ for (const a of c.aliases || []) {
30
+ if (a.toLowerCase() === name)
31
+ return { command: c, rest };
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Dispatch a line through the registry. Returns false when nothing matched, so
38
+ * the caller can fall through to the next group (or the task path).
39
+ *
40
+ * A dev-only command typed by a regular user must be REJECTED with a clear
41
+ * message — hiding it from the help is not enough, the user can type it by
42
+ * hand. `devMode` and `onDevOnlyRejected` are injected for that.
43
+ */
44
+ export async function dispatchCommand(commands, input, deps) {
45
+ const hit = matchCommand(commands, input);
46
+ if (!hit)
47
+ return false;
48
+ if (hit.command.devOnly && !deps.devMode) {
49
+ deps.onDevOnlyRejected?.(hit.command.name);
50
+ return true;
51
+ }
52
+ await hit.command.handler({
53
+ input: (input || '').trim(),
54
+ rest: hit.rest,
55
+ print: deps.print,
56
+ });
57
+ return true;
58
+ }
package/dist/commands.js CHANGED
@@ -2,7 +2,8 @@ import { providerLabel, providerHasModelPicker } from './providers.js';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import { isImageName } from './attachments.js';
5
- import { translate } from './i18n.js';
5
+ import { SYSTEM_PROMPT_BUDGET_BYTES, kb, overBudget } from './prompt-budget.js';
6
+ import { translate, DEFAULT_LOCALE, } from './i18n.js';
6
7
  // Helpers for the slash commands (/diff, /cost, /export, /doctor, /review,
7
8
  // /add-dir, /queue, /goal, custom commands). Pure functions, unit-tested
8
9
  // without a live agent/browser; index.ts only renders their output.
@@ -345,6 +346,28 @@ export function renderDoctor(d, t = translate('en')) {
345
346
  tools: String(d.mcpTools),
346
347
  }));
347
348
  row(d.transcriptOk, 'transcript', onoff(d.transcriptOk));
349
+ if (d.passwordStore) {
350
+ row(!d.passwordStore.degraded, 'password', d.passwordStore.store +
351
+ (d.passwordStore.degraded ? ' (keychain unavailable)' : ''));
352
+ }
353
+ if (d.swallowedTop && d.swallowedTop.length) {
354
+ const txt = d.swallowedTop.map((x) => x.tag + ':' + x.count).join(', ');
355
+ // WARN, not OK: a swallowed failure is a SELECTOR (or a page read) that
356
+ // broke and was hidden - exactly what this row exists to surface.
357
+ row(false, 'swallowed', txt);
358
+ }
359
+ if (typeof d.systemPromptBytes === 'number') {
360
+ const budget = d.systemPromptBudget ?? SYSTEM_PROMPT_BUDGET_BYTES;
361
+ const size = kb(d.systemPromptBytes);
362
+ const ctx = typeof d.contextBytes === 'number' && d.contextBytes > 0
363
+ ? ' + ' + kb(d.contextBytes) + ' context'
364
+ : '';
365
+ row(!overBudget(d.systemPromptBytes, budget), 'prompt', size +
366
+ ctx +
367
+ (overBudget(d.systemPromptBytes, budget)
368
+ ? ' > ' + kb(budget) + ' budget'
369
+ : ' (budget ' + kb(budget) + ')'));
370
+ }
348
371
  if (typeof d.contextLimit === 'number' && d.contextLimit > 0) {
349
372
  row(true, 'context', t('doctor.tokens', {
350
373
  v: d.contextLimit.toLocaleString('en-US'),
@@ -595,7 +618,7 @@ export function formatRestoredHistory(messages, opts = {}) {
595
618
  * the system prompt), so the model is told to keep facts, decisions, file
596
619
  * paths, commands and the exact current state of the work.
597
620
  */
598
- export function buildCompactPrompt(locale = 'ru') {
621
+ export function buildCompactPrompt(locale = DEFAULT_LOCALE) {
599
622
  if (locale === 'en') {
600
623
  return ('Compact the conversation so far into a handover summary for a NEW chat. ' +
601
624
  'This summary is the ONLY context the new chat will start with, so it must be self-sufficient. ' +