@aragon-agent/cli 0.5.8 → 0.5.9

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 (123) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/README.md +1429 -1163
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +40 -0
  9. package/dist/agent/reducer.d.ts +245 -3
  10. package/dist/agent/reducer.js +490 -17
  11. package/dist/agent/retry-view.d.ts +68 -0
  12. package/dist/agent/retry-view.js +95 -0
  13. package/dist/agent/system-prompt.d.ts +11 -0
  14. package/dist/agent/system-prompt.js +2 -0
  15. package/dist/cli.js +86 -2
  16. package/dist/commands/builtins.d.ts +1 -1
  17. package/dist/commands/builtins.js +308 -2
  18. package/dist/config/cli-commands.d.ts +53 -2
  19. package/dist/config/cli-commands.js +136 -3
  20. package/dist/config/env.d.ts +6 -3
  21. package/dist/config/env.js +103 -3
  22. package/dist/config/load.d.ts +49 -0
  23. package/dist/config/load.js +83 -1
  24. package/dist/config/schema.d.ts +212 -2
  25. package/dist/config/schema.js +207 -1
  26. package/dist/config/store.js +20 -1
  27. package/dist/fast/critique.d.ts +43 -0
  28. package/dist/fast/critique.js +62 -0
  29. package/dist/fast/digest.d.ts +42 -0
  30. package/dist/fast/digest.js +85 -0
  31. package/dist/fast/frames.d.ts +99 -0
  32. package/dist/fast/frames.js +180 -0
  33. package/dist/fast/limits.d.ts +98 -0
  34. package/dist/fast/limits.js +98 -0
  35. package/dist/fast/prompt.d.ts +72 -0
  36. package/dist/fast/prompt.js +94 -0
  37. package/dist/fast/resolve.d.ts +43 -0
  38. package/dist/fast/resolve.js +105 -0
  39. package/dist/fast/review-call.d.ts +99 -0
  40. package/dist/fast/review-call.js +130 -0
  41. package/dist/fast/reviewer.d.ts +248 -0
  42. package/dist/fast/reviewer.js +664 -0
  43. package/dist/fast/types.d.ts +128 -0
  44. package/dist/fast/types.js +14 -0
  45. package/dist/fast/wiring.d.ts +170 -0
  46. package/dist/fast/wiring.js +316 -0
  47. package/dist/logging/install.d.ts +15 -1
  48. package/dist/logging/install.js +37 -4
  49. package/dist/logging/logger.d.ts +1 -1
  50. package/dist/session/persist.js +57 -1
  51. package/dist/team/normalize.d.ts +26 -1
  52. package/dist/team/normalize.js +23 -3
  53. package/dist/team/prompt.d.ts +11 -0
  54. package/dist/team/prompt.js +10 -0
  55. package/dist/team/report.d.ts +18 -0
  56. package/dist/team/report.js +27 -1
  57. package/dist/team/runtime.d.ts +13 -2
  58. package/dist/team/runtime.js +33 -6
  59. package/dist/team/subagent.d.ts +19 -1
  60. package/dist/team/subagent.js +72 -10
  61. package/dist/team/task-tool.d.ts +26 -0
  62. package/dist/team/task-tool.js +48 -4
  63. package/dist/team/types.d.ts +80 -1
  64. package/dist/tools/bash-tool.js +19 -3
  65. package/dist/tools/diff.d.ts +25 -5
  66. package/dist/tools/diff.js +72 -41
  67. package/dist/tools/file-change-store.d.ts +34 -0
  68. package/dist/tools/file-change-store.js +57 -0
  69. package/dist/tools/fs-tools.d.ts +26 -0
  70. package/dist/tools/fs-tools.js +62 -5
  71. package/dist/tools/index.d.ts +31 -0
  72. package/dist/tools/index.js +7 -1
  73. package/dist/tools/patch.d.ts +115 -0
  74. package/dist/tools/patch.js +453 -0
  75. package/dist/tools/terminal-output.d.ts +107 -0
  76. package/dist/tools/terminal-output.js +198 -0
  77. package/dist/tools/tool-output-store.d.ts +68 -0
  78. package/dist/tools/tool-output-store.js +102 -0
  79. package/dist/ui/ActivityLine.d.ts +48 -0
  80. package/dist/ui/ActivityLine.js +22 -0
  81. package/dist/ui/App.d.ts +1 -4
  82. package/dist/ui/App.js +273 -37
  83. package/dist/ui/BottomStatusRow.d.ts +43 -0
  84. package/dist/ui/BottomStatusRow.js +14 -0
  85. package/dist/ui/Composer.d.ts +1 -6
  86. package/dist/ui/Composer.js +2 -2
  87. package/dist/ui/PromptInput.d.ts +1 -15
  88. package/dist/ui/PromptInput.js +1 -38
  89. package/dist/ui/StatusBar.d.ts +37 -0
  90. package/dist/ui/StatusBar.js +5 -3
  91. package/dist/ui/TeamPanel.d.ts +5 -4
  92. package/dist/ui/TeamPanel.js +1 -1
  93. package/dist/ui/TodoPanel.d.ts +5 -4
  94. package/dist/ui/Transcript.d.ts +26 -3
  95. package/dist/ui/Transcript.js +76 -10
  96. package/dist/ui/activity-phrases.d.ts +33 -0
  97. package/dist/ui/activity-phrases.js +50 -0
  98. package/dist/ui/entries/AssistantEntry.d.ts +20 -0
  99. package/dist/ui/entries/AssistantEntry.js +3 -2
  100. package/dist/ui/entries/DiffView.d.ts +67 -0
  101. package/dist/ui/entries/DiffView.js +103 -0
  102. package/dist/ui/entries/FastCard.d.ts +53 -0
  103. package/dist/ui/entries/FastCard.js +87 -0
  104. package/dist/ui/entries/RetryCard.d.ts +56 -0
  105. package/dist/ui/entries/RetryCard.js +60 -0
  106. package/dist/ui/entries/TeamCard.js +6 -1
  107. package/dist/ui/entries/ToolCard.d.ts +38 -1
  108. package/dist/ui/entries/ToolCard.js +68 -5
  109. package/dist/ui/glyphs.d.ts +9 -0
  110. package/dist/ui/glyphs.js +2 -0
  111. package/dist/ui/layout/AppShell.d.ts +13 -22
  112. package/dist/ui/layout/AppShell.js +3 -38
  113. package/dist/ui/layout/virtual-window.d.ts +1 -1
  114. package/dist/ui/layout/virtual-window.js +109 -5
  115. package/dist/ui/overlays/HelpOverlay.js +16 -2
  116. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  117. package/dist/ui/overlays/SettingsScreen.js +147 -1
  118. package/dist/ui/transcript-text.js +27 -0
  119. package/dist/ui/use-wheel-routing.d.ts +16 -32
  120. package/dist/ui/use-wheel-routing.js +21 -38
  121. package/package.json +2 -2
  122. package/dist/ui/layout/regions.d.ts +0 -55
  123. package/dist/ui/layout/regions.js +0 -52
@@ -6,7 +6,7 @@
6
6
  * invariant).
7
7
  */
8
8
  import { isAbsolute } from 'node:path';
9
- import { ABSOLUTE_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, } from '@aragon-agent/core';
9
+ import { ABSOLUTE_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_RETRY_POLICY, MIN_MAX_OUTPUT_TOKENS, RETRY_LIMITS, } from '@aragon-agent/core';
10
10
  import { clampLogLevel } from '../logging/levels.js';
11
11
  import { TEAM_LIMITS } from '../team/limits.js';
12
12
  // ---------------------------------------------------------------------------
@@ -213,6 +213,46 @@ function clampInt(v, fallback, range) {
213
213
  const n = coercePositiveInt(v, fallback);
214
214
  return Math.min(range.max, Math.max(range.min, n));
215
215
  }
216
+ /**
217
+ * `clampInt` for a key whose range legitimately includes ZERO.
218
+ *
219
+ * `clampInt` delegates to `coercePositiveInt`, which returns the FALLBACK for
220
+ * `n <= 0` — so a range whose floor is `0` cannot express `0` through it, and the
221
+ * value silently becomes the default. That trap is invisible at the call site and
222
+ * is what `retry.maxRetries` needs this helper for: `0` is that key's documented
223
+ * floor and its kill switch, and four documented controls send it
224
+ * (`/retry max 0`, `--retry-max 0`, `ARAGON_RETRY_MAX=0`,
225
+ * `aragon config set retry.maxRetries 0`). Through `clampInt` all four would set
226
+ * the MAXIMUM instead of the minimum, with no error anywhere.
227
+ *
228
+ * Own coercion: `Number.parseInt`, reject only non-finite and negative, then
229
+ * floor and clamp.
230
+ */
231
+ function clampIntAllowingZero(v, fallback, range) {
232
+ if (v === null || v === undefined || v === '')
233
+ return clampToRange(fallback, range);
234
+ const n = typeof v === 'number' ? v : Number.parseInt(String(v), 10);
235
+ if (!Number.isFinite(n) || n < 0)
236
+ return clampToRange(fallback, range);
237
+ return clampToRange(Math.floor(n), range);
238
+ }
239
+ /**
240
+ * `clampInt` for a key that is legitimately a FLOAT.
241
+ *
242
+ * `coercePositiveInt` floors, and `2.5` is a legitimate backoff factor — the same
243
+ * coercion trap `clampIntAllowingZero` above closes for `0`, one type over.
244
+ */
245
+ function clampNumber(v, fallback, range) {
246
+ if (v === null || v === undefined || v === '')
247
+ return clampToRange(fallback, range);
248
+ const n = typeof v === 'number' ? v : Number.parseFloat(String(v));
249
+ if (!Number.isFinite(n))
250
+ return clampToRange(fallback, range);
251
+ return clampToRange(n, range);
252
+ }
253
+ function clampToRange(n, range) {
254
+ return Math.min(range.max, Math.max(range.min, n));
255
+ }
216
256
  function stringArray(v, fallback) {
217
257
  if (!Array.isArray(v))
218
258
  return fallback;
@@ -380,12 +420,169 @@ export function clampTodoConfig(raw) {
380
420
  followThrough: followMode(src.followThrough, DEFAULT_TODO_CONFIG.followThrough),
381
421
  };
382
422
  }
423
+ export const DEFAULT_RETRY_CONFIG = {
424
+ enabled: true,
425
+ maxRetries: DEFAULT_RETRY_POLICY.maxRetries,
426
+ initialDelayMs: DEFAULT_RETRY_POLICY.initialDelayMs,
427
+ maxDelayMs: DEFAULT_RETRY_POLICY.maxDelayMs,
428
+ multiplier: DEFAULT_RETRY_POLICY.multiplier,
429
+ jitter: DEFAULT_RETRY_POLICY.jitter,
430
+ respectRetryAfter: DEFAULT_RETRY_POLICY.respectRetryAfter,
431
+ maxElapsedMs: DEFAULT_RETRY_POLICY.maxElapsedMs,
432
+ onPartialStream: DEFAULT_RETRY_POLICY.onPartialStream,
433
+ };
434
+ /**
435
+ * `hardMaxRetries` re-exported rather than re-spelled, for the reason
436
+ * `HARD_MAX_SUBAGENTS` records: the ceiling is enforced in TWO places — here on
437
+ * the config value, and again by `normalizePolicy` inside the engine — and two
438
+ * copies of the same 20 is exactly how a hand-edited `config.json` ends up able
439
+ * to raise a ceiling the mechanism fixed.
440
+ */
441
+ export const HARD_MAX_RETRIES = RETRY_LIMITS.hardMaxRetries;
442
+ /** FLOOR 0 — and `clampIntAllowingZero` is what makes that reachable. */
443
+ const RETRY_MAX_RANGE = { min: 0, max: HARD_MAX_RETRIES };
444
+ const RETRY_INITIAL_DELAY_RANGE = { min: 100, max: 30_000 };
445
+ const RETRY_MAX_DELAY_RANGE = { min: 1000, max: RETRY_LIMITS.absoluteMaxDelayMs };
446
+ const RETRY_MULTIPLIER_RANGE = { min: 1, max: 5 };
447
+ const RETRY_ELAPSED_RANGE = { min: 10_000, max: 1_800_000 };
448
+ /**
449
+ * THE single gate for every retry-config read AND write, mirroring
450
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` / `clampTodoConfig`
451
+ * for the reason those record: hardening only the read path leaves a bad value on
452
+ * disk that reverts to the default on every launch, which presents to the user as
453
+ * "my setting won't stick".
454
+ *
455
+ * Three invariants live inside the gate:
456
+ *
457
+ * - `maxDelayMs = max(maxDelayMs, initialDelayMs)` — a ceiling under the floor
458
+ * makes the ladder a flat line and the countdown a lie.
459
+ * - `multiplier` goes through `clampNumber`, not `clampInt`: `2.5` is a
460
+ * legitimate factor and `coercePositiveInt` would floor it to 2.
461
+ * - `maxRetries` goes through `clampIntAllowingZero`. THIS IS THE ONE THAT SHIPS
462
+ * BROKEN WITHOUT IT (R-16): `clampInt` returns the fallback for `n <= 0`, so
463
+ * `clampRetryConfig({ maxRetries: 0 })` would yield 10 — the maximum instead
464
+ * of the minimum, silently, for the key that gates the whole feature.
465
+ *
466
+ * `maxElapsedMs` intentionally keeps plain `clampInt`: its floor is 10 000, so it
467
+ * has no `0` to express.
468
+ */
469
+ export function clampRetryConfig(raw) {
470
+ const src = (raw && typeof raw === 'object' ? raw : {});
471
+ const initialDelayMs = clampInt(src.initialDelayMs, DEFAULT_RETRY_CONFIG.initialDelayMs, RETRY_INITIAL_DELAY_RANGE);
472
+ const maxDelayMs = Math.max(initialDelayMs, clampInt(src.maxDelayMs, DEFAULT_RETRY_CONFIG.maxDelayMs, RETRY_MAX_DELAY_RANGE));
473
+ return {
474
+ enabled: bool(src.enabled, DEFAULT_RETRY_CONFIG.enabled),
475
+ maxRetries: clampIntAllowingZero(src.maxRetries, DEFAULT_RETRY_CONFIG.maxRetries, RETRY_MAX_RANGE),
476
+ initialDelayMs,
477
+ maxDelayMs,
478
+ multiplier: clampNumber(src.multiplier, DEFAULT_RETRY_CONFIG.multiplier, RETRY_MULTIPLIER_RANGE),
479
+ jitter: bool(src.jitter, DEFAULT_RETRY_CONFIG.jitter),
480
+ respectRetryAfter: bool(src.respectRetryAfter, DEFAULT_RETRY_CONFIG.respectRetryAfter),
481
+ maxElapsedMs: clampInt(src.maxElapsedMs, DEFAULT_RETRY_CONFIG.maxElapsedMs, RETRY_ELAPSED_RANGE),
482
+ onPartialStream: bool(src.onPartialStream, DEFAULT_RETRY_CONFIG.onPartialStream),
483
+ };
484
+ }
485
+ /**
486
+ * `RetryConfig` (user intent) -> `RetryPolicy` (engine policy), or `null` for
487
+ * "the user said no".
488
+ *
489
+ * `null` rather than a policy with `maxRetries: 0` because `ProviderRegistry`
490
+ * short-circuits on a null policy and returns the adapter's iterator DIRECTLY —
491
+ * an opted-out session is then allocation-identical to a pre-feature build.
492
+ *
493
+ * TOTAL, AND IT ACCEPTS A MISSING SECTION. `loadConfig` always supplies one, but
494
+ * a `CliConfig` built by hand — every controller test in this package does exactly
495
+ * that, and so would an embedder — legitimately omits it, and a `TypeError` from a
496
+ * pure translation function is the wrong way to find out. Routing through
497
+ * `clampRetryConfig` also means an out-of-range hand-built value can never reach
498
+ * the engine, which is the same "one gate, both directions" rule every other
499
+ * section in this file follows.
500
+ */
501
+ export function toRetryPolicy(cfg) {
502
+ const safe = clampRetryConfig(cfg);
503
+ if (!safe.enabled || safe.maxRetries <= 0)
504
+ return null;
505
+ return {
506
+ maxRetries: safe.maxRetries,
507
+ initialDelayMs: safe.initialDelayMs,
508
+ maxDelayMs: safe.maxDelayMs,
509
+ multiplier: safe.multiplier,
510
+ jitter: safe.jitter,
511
+ respectRetryAfter: safe.respectRetryAfter,
512
+ maxElapsedMs: safe.maxElapsedMs,
513
+ onPartialStream: safe.onPartialStream,
514
+ };
515
+ }
516
+ export const DEFAULT_FAST_CONFIG = {
517
+ // R-d, and the one default in this file that is a REQUIREMENT rather than a
518
+ // judgement: a feature that silently spends money on a second provider is not
519
+ // a default (D-3).
520
+ enabled: false,
521
+ provider: '',
522
+ model: '',
523
+ baseUrl: '',
524
+ thinkingLevel: 'off',
525
+ delegate: true,
526
+ review: true,
527
+ reviewEveryTurns: 5,
528
+ reviewContextTurns: 3,
529
+ reviewMaxChars: 280,
530
+ // 40 is roughly 200 reviewed turns at the default cadence: comfortably past a
531
+ // full working session, short of a runaway (D-H2).
532
+ reviewMaxPerSession: 40,
533
+ };
534
+ const REVIEW_EVERY_RANGE = { min: 1, max: 50 };
535
+ const REVIEW_CONTEXT_RANGE = { min: 1, max: 10 };
536
+ const REVIEW_CHARS_RANGE = { min: 80, max: 600 };
537
+ /** No "unlimited" sentinel: a magic `0` reads as "disabled" to the next person
538
+ * who meets it, and 500 is already past any session a human sits through. */
539
+ const REVIEW_SESSION_RANGE = { min: 1, max: 500 };
540
+ /** Trim a string field, falling back when the value is not a string at all. */
541
+ function text(v, fallback) {
542
+ return typeof v === 'string' ? v.trim() : fallback;
543
+ }
544
+ /**
545
+ * THE single gate for every fast-config read AND write, mirroring
546
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` / `clampTodoConfig`
547
+ * for the reason those four record: hardening only the read path leaves a bad
548
+ * value on disk that reverts to the default on every launch, which presents to
549
+ * the user as "my setting won't stick".
550
+ *
551
+ * `provider` is CLAMPED TO `''` rather than rejected when it names something
552
+ * with no adapter. `''` means "inherit the main provider", which is the safe
553
+ * reading of a typo — and `resolveFastTier` reports `no_adapter` for the case a
554
+ * user really did configure a dead vendor as the MAIN one.
555
+ */
556
+ export function clampFastConfig(raw) {
557
+ const src = (raw && typeof raw === 'object' ? raw : {});
558
+ const provider = text(src.provider, DEFAULT_FAST_CONFIG.provider);
559
+ return {
560
+ enabled: bool(src.enabled, DEFAULT_FAST_CONFIG.enabled),
561
+ provider: provider.length === 0 || isAdapterProvider(provider) ? provider : '',
562
+ model: text(src.model, DEFAULT_FAST_CONFIG.model),
563
+ baseUrl: text(src.baseUrl, DEFAULT_FAST_CONFIG.baseUrl),
564
+ thinkingLevel: clampThinkingLevel(src.thinkingLevel, DEFAULT_FAST_CONFIG.thinkingLevel),
565
+ delegate: bool(src.delegate, DEFAULT_FAST_CONFIG.delegate),
566
+ review: bool(src.review, DEFAULT_FAST_CONFIG.review),
567
+ reviewEveryTurns: clampInt(src.reviewEveryTurns, DEFAULT_FAST_CONFIG.reviewEveryTurns, REVIEW_EVERY_RANGE),
568
+ reviewContextTurns: clampInt(src.reviewContextTurns, DEFAULT_FAST_CONFIG.reviewContextTurns, REVIEW_CONTEXT_RANGE),
569
+ reviewMaxChars: clampInt(src.reviewMaxChars, DEFAULT_FAST_CONFIG.reviewMaxChars, REVIEW_CHARS_RANGE),
570
+ reviewMaxPerSession: clampInt(src.reviewMaxPerSession, DEFAULT_FAST_CONFIG.reviewMaxPerSession, REVIEW_SESSION_RANGE),
571
+ };
572
+ }
383
573
  export const DEFAULT_CONFIG = {
384
574
  version: CONFIG_VERSION,
385
575
  provider: 'anthropic',
386
576
  model: DEFAULT_MODEL,
387
577
  baseUrl: null,
388
578
  thinkingLevel: 'off',
579
+ // Off by default — the product default this whole round exists to establish
580
+ // (D-1). One config key restores the old behaviour permanently.
581
+ showThinking: false,
582
+ // ON by default (D-28), and the OPPOSITE of the key above it on purpose: this
583
+ // one shows work that is otherwise invisible for the whole duration of the
584
+ // most expensive thing the agent does.
585
+ liveToolOutput: true,
389
586
  // The engine owns this number; spelling it here again is how the CLI and the
390
587
  // provider adapters end up disagreeing about what "the default" is.
391
588
  maxTokens: DEFAULT_MAX_OUTPUT_TOKENS,
@@ -415,6 +612,15 @@ export const DEFAULT_CONFIG = {
415
612
  // `todo` key at all, so this line is as required as the two merges in
416
613
  // `store.ts` (P0-1).
417
614
  todo: DEFAULT_TODO_CONFIG,
615
+ // Same requirement one section later: without this line a user with no `retry`
616
+ // key resolves `config.retry` to `undefined` and `toRetryPolicy` throws.
617
+ retry: DEFAULT_RETRY_CONFIG,
618
+ // And one section later again: the load path's first spread is what supplies
619
+ // `fast` to a user whose `config.json` predates this feature, which is every
620
+ // existing user. Without it `config.fast.enabled` reads `undefined`, which is
621
+ // falsy and so LOOKS correct — right up until `/fast on` writes a section that
622
+ // has no other keys in it (P0-1's shape, one feature later).
623
+ fast: DEFAULT_FAST_CONFIG,
418
624
  };
419
625
  /**
420
626
  * Runtime state that used to be stored in `config.json` by mistake
@@ -14,7 +14,7 @@ import { dirname } from 'node:path';
14
14
  import process from 'node:process';
15
15
  import { getConfigPath, getSessionsDir } from './app-paths.js';
16
16
  import { registerSecretsFrom } from '../logging/secret-registry.js';
17
- import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampMaxTokens, clampSkillsConfig, clampTeamConfig, clampTodoConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
17
+ import { CONFIG_VERSION, DEFAULT_CONFIG, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
18
18
  /**
19
19
  * Normalize `maxTokens` for both directions of the config file.
20
20
  *
@@ -126,6 +126,14 @@ export function loadPersistedConfig() {
126
126
  // the READ half of the pair that keeps `/todo panel off` from silently
127
127
  // unregistering the tool on the next launch (P0-1 / AC-35).
128
128
  todo: clampTodoConfig({ ...DEFAULT_CONFIG.todo, ...(partial.todo ?? {}) }),
129
+ // `retry` is the sixth. The READ half: without it a config file with no
130
+ // `retry` key yields `undefined` and `toRetryPolicy` throws on the value it
131
+ // is handed (R-9 / AC-20).
132
+ retry: clampRetryConfig({ ...DEFAULT_CONFIG.retry, ...(partial.retry ?? {}) }),
133
+ // `fast` is the seventh. Same one-level rule, same merge — and this is the
134
+ // READ half of the pair that keeps `/fast review 8` from silently switching
135
+ // the whole tier off on the next launch (C-3 / R-10).
136
+ fast: clampFastConfig({ ...DEFAULT_CONFIG.fast, ...(partial.fast ?? {}) }),
129
137
  };
130
138
  }
131
139
  /**
@@ -177,6 +185,17 @@ export function updatePersistedConfig(patch) {
177
185
  // spread turns into "turning the panel off silently unregisters the tool",
178
186
  // permanently, from the next launch onward, with nothing anywhere saying so.
179
187
  todo: clampTodoConfig({ ...current.todo, ...(patch.todo ?? {}) }),
188
+ // The sixth section. `/retry max 5` sends `{ retry: { maxRetries: 5 } }`,
189
+ // which a shallow spread turns into "changing the retry count silently
190
+ // reverted `respectRetryAfter` and `jitter`" (R-9 / AC-19).
191
+ retry: clampRetryConfig({ ...current.retry, ...(patch.retry ?? {}) }),
192
+ // The seventh section, and THE WRITE HALF IS THE ONE THAT BITES (R-10).
193
+ // `/fast review 8` sends `{ fast: { reviewEveryTurns: 8 } }`; without this
194
+ // line the shallow top-level spread REPLACES the section, `enabled` is
195
+ // written absent, and the tier the user configured yesterday is gone
196
+ // tomorrow with no error anywhere. This package has paid for that once
197
+ // already, in `todo` (P0-1).
198
+ fast: clampFastConfig({ ...current.fast, ...(patch.fast ?? {}) }),
180
199
  };
181
200
  writeConfigFile(merged);
182
201
  return merged;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `normalizeCritique` — turn whatever the fast model wrote into one of three
3
+ * outcomes (fast-model-tier §3.5.3). PURE: no I/O, no state.
4
+ *
5
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
6
+ */
7
+ /**
8
+ * What a review amounted to.
9
+ *
10
+ * `ok` and `empty` are NOT INJECTED (D-16). They still produce a transcript
11
+ * entry and a log record, so the user can see the harness is alive, but the lead
12
+ * pays no context for the message "nothing to say".
13
+ */
14
+ export type Critique = {
15
+ kind: 'ok';
16
+ } | {
17
+ kind: 'advice';
18
+ text: string;
19
+ } | {
20
+ kind: 'empty';
21
+ };
22
+ /**
23
+ * THE ON-TRACK TEST IS A NORMALIZED REGEX, NEVER STRING EQUALITY (D-17).
24
+ *
25
+ * The system prompt asks for the exact string `OK - on track.`, and models
26
+ * mostly comply - but one that writes `Ok, on track` would, under an equality
27
+ * test, be classified as ADVICE and injected into the main context on every
28
+ * cycle, forever, saying nothing. That is worse than having no reviewer: it is a
29
+ * permanent context tax that looks like the feature working.
30
+ *
31
+ * So: trim, lowercase, drop trailing punctuation, then `/^ok\b.*on track/`.
32
+ * `'OK, but the tests are failing'` still classifies as advice, because it does
33
+ * not say "on track".
34
+ */
35
+ export declare function isOnTrack(text: string): boolean;
36
+ /**
37
+ * Normalize and classify one raw review answer.
38
+ *
39
+ * @param raw Whatever the model returned.
40
+ * @param maxChars `fast.reviewMaxChars` — the ceiling the user chose on how much
41
+ * of their own context a critique may spend.
42
+ */
43
+ export declare function normalizeCritique(raw: string, maxChars: number): Critique;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `normalizeCritique` — turn whatever the fast model wrote into one of three
3
+ * outcomes (fast-model-tier §3.5.3). PURE: no I/O, no state.
4
+ *
5
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
6
+ */
7
+ /** Strip a fenced code block wrapper the model may have put around its prose. */
8
+ function stripFences(raw) {
9
+ const trimmed = raw.trim();
10
+ if (!trimmed.startsWith('```'))
11
+ return trimmed;
12
+ const withoutOpen = trimmed.replace(/^```[a-zA-Z0-9_-]*\s*\r?\n?/, '');
13
+ return withoutOpen.replace(/\r?\n?```\s*$/, '').trim();
14
+ }
15
+ /**
16
+ * THE ON-TRACK TEST IS A NORMALIZED REGEX, NEVER STRING EQUALITY (D-17).
17
+ *
18
+ * The system prompt asks for the exact string `OK - on track.`, and models
19
+ * mostly comply - but one that writes `Ok, on track` would, under an equality
20
+ * test, be classified as ADVICE and injected into the main context on every
21
+ * cycle, forever, saying nothing. That is worse than having no reviewer: it is a
22
+ * permanent context tax that looks like the feature working.
23
+ *
24
+ * So: trim, lowercase, drop trailing punctuation, then `/^ok\b.*on track/`.
25
+ * `'OK, but the tests are failing'` still classifies as advice, because it does
26
+ * not say "on track".
27
+ */
28
+ export function isOnTrack(text) {
29
+ const normalized = text
30
+ .trim()
31
+ .toLowerCase()
32
+ .replace(/[.!\s]+$/, '')
33
+ .replace(/\s+/g, ' ');
34
+ return /^ok\b.*on track/.test(normalized);
35
+ }
36
+ /**
37
+ * Normalize and classify one raw review answer.
38
+ *
39
+ * @param raw Whatever the model returned.
40
+ * @param maxChars `fast.reviewMaxChars` — the ceiling the user chose on how much
41
+ * of their own context a critique may spend.
42
+ */
43
+ export function normalizeCritique(raw, maxChars) {
44
+ const stripped = stripFences(raw ?? '');
45
+ // Whitespace is collapsed BEFORE the length test: a model that answers with
46
+ // four blank lines and a sentence must not spend its budget on the blanks.
47
+ const collapsed = stripped.replace(/\s+/g, ' ').trim();
48
+ if (collapsed.length === 0)
49
+ return { kind: 'empty' };
50
+ if (isOnTrack(collapsed))
51
+ return { kind: 'ok' };
52
+ const limit = Math.max(1, Math.floor(maxChars) || 1);
53
+ if (collapsed.length <= limit)
54
+ return { kind: 'advice', text: collapsed };
55
+ // Clamp on a word boundary when one is close enough that the sentence still
56
+ // reads; otherwise a hard cut, because a critique is prose and a mid-word
57
+ // truncation is what makes advice look like corruption.
58
+ const cut = collapsed.slice(0, limit);
59
+ const space = cut.lastIndexOf(' ');
60
+ const text = space > limit * 0.6 ? cut.slice(0, space) : cut;
61
+ return { kind: 'advice', text };
62
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `buildReviewDigest` — the bounded text one review is asked about
3
+ * (fast-model-tier §3.5.2). PURE: no I/O, no clock, no state.
4
+ *
5
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THE REVIEWER NEVER READS `agent.state.messages`, AND THAT IS THE SINGLE MOST
8
+ * IMPORTANT ECONOMIC DECISION IN THE FEATURE (D-6 / R-2). That history holds
9
+ * whole file bodies: a session that has read four 60 KB files carries 240 KB of
10
+ * them. Shipping it to the "cheap" model to ask whether the run is on track
11
+ * would make a review cost MORE than the main-model turn it is reviewing - the
12
+ * exact inversion this feature exists to prevent. The digest is bounded at
13
+ * `FAST_LIMITS.digestMaxChars` BY CONSTRUCTION, so a review's input cost is flat
14
+ * regardless of session length.
15
+ *
16
+ * Anyone tempted to "just pass the messages, the model has the context anyway"
17
+ * is about to turn a 400-token call into a 60 000-token one, and nothing in the
18
+ * UI would show it except the bill.
19
+ */
20
+ import type { TurnFrame } from './types.js';
21
+ export interface ReviewDigestParams {
22
+ /** The reviewer's ring buffer, oldest first. */
23
+ frames: TurnFrame[];
24
+ /** The user's request for this run, already clamped by the caller. */
25
+ goal: string;
26
+ /** `fast.reviewContextTurns` — how many SEALED frames to include. */
27
+ contextTurns: number;
28
+ }
29
+ /**
30
+ * Build the digest.
31
+ *
32
+ * SEALED FRAMES ONLY (RV-2 / D-22). The turn being reviewed is sealed by
33
+ * definition - sealing is what triggered the review - so the window is the
34
+ * newest `contextTurns` sealed frames, and a frame that is still accumulating is
35
+ * never half-rendered into a prompt.
36
+ *
37
+ * NEWEST FRAMES SURVIVE THE BOUND. When the assembled text exceeds
38
+ * `digestMaxChars` the OLDEST frame is dropped and the document is rebuilt,
39
+ * because the reviewer's question is about what just happened. Trimming from the
40
+ * tail would hand it the beginning of a run and ask about the end of one.
41
+ */
42
+ export declare function buildReviewDigest(params: ReviewDigestParams): string;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `buildReviewDigest` — the bounded text one review is asked about
3
+ * (fast-model-tier §3.5.2). PURE: no I/O, no clock, no state.
4
+ *
5
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THE REVIEWER NEVER READS `agent.state.messages`, AND THAT IS THE SINGLE MOST
8
+ * IMPORTANT ECONOMIC DECISION IN THE FEATURE (D-6 / R-2). That history holds
9
+ * whole file bodies: a session that has read four 60 KB files carries 240 KB of
10
+ * them. Shipping it to the "cheap" model to ask whether the run is on track
11
+ * would make a review cost MORE than the main-model turn it is reviewing - the
12
+ * exact inversion this feature exists to prevent. The digest is bounded at
13
+ * `FAST_LIMITS.digestMaxChars` BY CONSTRUCTION, so a review's input cost is flat
14
+ * regardless of session length.
15
+ *
16
+ * Anyone tempted to "just pass the messages, the model has the context anyway"
17
+ * is about to turn a 400-token call into a 60 000-token one, and nothing in the
18
+ * UI would show it except the bill.
19
+ */
20
+ import { FAST_LIMITS } from './limits.js';
21
+ /** Clip to `maxChars` on a word boundary when one is close enough to matter. */
22
+ function clip(text, maxChars) {
23
+ if (text.length <= maxChars)
24
+ return text;
25
+ const cut = text.slice(0, maxChars);
26
+ const space = cut.lastIndexOf(' ');
27
+ return space > maxChars * 0.6 ? cut.slice(0, space) : cut;
28
+ }
29
+ /** One tool as the digest names it: `read_file(src/app.ts) 12ms`. */
30
+ function renderTool(tool) {
31
+ const arg = tool.arg ? `(${clip(tool.arg, FAST_LIMITS.toolArgChars)})` : '';
32
+ const failed = tool.isError ? ' FAILED' : '';
33
+ const ms = tool.ms === undefined ? '' : ` ${Math.round(tool.ms)}ms`;
34
+ return `${tool.name}${arg}${failed}${ms}`;
35
+ }
36
+ function renderFrame(frame) {
37
+ const lines = [`Turn ${frame.index}:`];
38
+ if (frame.tools.length > 0) {
39
+ lines.push(` tools: ${frame.tools.map(renderTool).join(', ')}`);
40
+ }
41
+ else {
42
+ lines.push(' tools: (none)');
43
+ }
44
+ if (frame.error)
45
+ lines.push(` error: ${clip(frame.error, 200)}`);
46
+ const tail = frame.textTail.trim();
47
+ if (tail.length > 0)
48
+ lines.push(` said: ${clip(tail, FAST_LIMITS.turnTailChars)}`);
49
+ return lines;
50
+ }
51
+ /**
52
+ * Build the digest.
53
+ *
54
+ * SEALED FRAMES ONLY (RV-2 / D-22). The turn being reviewed is sealed by
55
+ * definition - sealing is what triggered the review - so the window is the
56
+ * newest `contextTurns` sealed frames, and a frame that is still accumulating is
57
+ * never half-rendered into a prompt.
58
+ *
59
+ * NEWEST FRAMES SURVIVE THE BOUND. When the assembled text exceeds
60
+ * `digestMaxChars` the OLDEST frame is dropped and the document is rebuilt,
61
+ * because the reviewer's question is about what just happened. Trimming from the
62
+ * tail would hand it the beginning of a run and ask about the end of one.
63
+ */
64
+ export function buildReviewDigest(params) {
65
+ const sealed = params.frames.filter((f) => f.sealed);
66
+ const want = Math.max(1, Math.floor(params.contextTurns) || 1);
67
+ let window = sealed.slice(-want);
68
+ const goal = clip(params.goal.trim(), FAST_LIMITS.goalChars);
69
+ const assemble = (frames) => [
70
+ `Goal: ${goal.length > 0 ? goal : '(not captured)'}`,
71
+ '',
72
+ ...frames.flatMap(renderFrame),
73
+ ].join('\n');
74
+ let out = assemble(window);
75
+ while (out.length > FAST_LIMITS.digestMaxChars && window.length > 1) {
76
+ window = window.slice(1);
77
+ out = assemble(window);
78
+ }
79
+ // A single frame can still be over budget on its own (a 600-char tail plus
80
+ // twelve tools), so the hard clamp is unconditional. It is a CEILING, not the
81
+ // trimming strategy: the loop above is what decides WHAT is lost.
82
+ return out.length > FAST_LIMITS.digestMaxChars
83
+ ? out.slice(0, FAST_LIMITS.digestMaxChars)
84
+ : out;
85
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * TurnFrameRing — the reviewer's bounded record of what the lead just did
3
+ * (fast-model-tier §3.5.1, extracted here by fast-model-tier-hardening W3).
4
+ *
5
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THIS IS THE `data` HALF OF THE SPLIT, AND IT IS A LEAF ON PURPOSE. `reviewer.ts`
8
+ * was at 871 of the repo's 1 000-line cap before round 2 added a budget to it, so
9
+ * the ring and the transport moved out. The split line is drawn along data and
10
+ * transport BECAUSE BOTH ARE LEAVES: the protocol — the two drain guards and the
11
+ * call-cancellation stamping — stays whole in `reviewer.ts`, where a module
12
+ * boundary through it would reopen a P0 (§3.3 / AC-H11 / AC-H16).
13
+ *
14
+ * WHAT THIS CLASS OWNS: the open frame, the retired ring, the outstanding-call
15
+ * counter and the tool-argument map. WHAT IT DECIDES: nothing. It reports that a
16
+ * batch has emptied; the caller decides what that means.
17
+ */
18
+ import type { AssistantMessage, StreamEvent } from '@aragon-agent/core';
19
+ import type { TurnFrame } from './types.js';
20
+ export interface TurnFrameRingDeps {
21
+ /**
22
+ * Whether a frame can still be consumed by a review this run.
23
+ *
24
+ * A PREDICATE, NOT A BOOLEAN, and read inside `recordDelta` rather than at its
25
+ * call site: this runs on every `text_delta` of every turn, and evaluating the
26
+ * caller's gate eagerly would move a config read onto the hottest path in the
27
+ * package for the deltas that are discarded by type anyway.
28
+ */
29
+ canRecord(): boolean;
30
+ }
31
+ export declare class TurnFrameRing {
32
+ private readonly deps;
33
+ private retained;
34
+ private open;
35
+ /**
36
+ * Tool calls still to run in the current batch.
37
+ *
38
+ * ASSIGNED at `turn_end`, NEVER CARRIED ACROSS TURNS (RV-7), and the
39
+ * difference is not cosmetic. Two paths end a batch without emitting a
40
+ * `tool_execution_end` for every call: checkpoint 2 skips the whole batch
41
+ * (`agent-loop.ts:199-207`) and an abort breaks out mid-batch (`:211`). A
42
+ * counter that only ever decremented would sit permanently above zero after
43
+ * either, and the injection window would never open again for the rest of the
44
+ * run. Re-assigning at each `turn_end` makes both cases self-heal on the next
45
+ * turn, which is correct: those batches genuinely had no window.
46
+ */
47
+ private outstanding;
48
+ private completedTurns;
49
+ /** Tool args captured at `tool_execution_start`, keyed by call id. */
50
+ private readonly toolArgs;
51
+ constructor(deps: TurnFrameRingDeps);
52
+ /** Completed turns of the CURRENT run — the cadence and staleness clock. */
53
+ get turns(): number;
54
+ /** Always 0 at the injection window; logged there as the assertion, recorded. */
55
+ get outstandingCalls(): number;
56
+ /** The ring, oldest first. `buildReviewDigest` filters it for sealed frames. */
57
+ frames(): TurnFrame[];
58
+ /** `agent_start`. */
59
+ reset(): void;
60
+ /** `agent_end` — the ring survives for a late answer; the open frame does not. */
61
+ endRun(): void;
62
+ /** `turn_start`. */
63
+ beginTurn(): void;
64
+ /**
65
+ * The frame recorder.
66
+ *
67
+ * GATED ON `text_delta` AS AN ALLOW-TEST, NEVER A DENY-LIST (RV-10 / R-16).
68
+ * `agent-loop.ts:170` forwards EVERY stream event through `message_update` -
69
+ * `text_delta`, `thinking_delta`, `tool_call_delta`, `done`, and (as of
70
+ * `llm-api-retry-backoff`) `retry_scheduled` / `retry_attempt` - so this is a
71
+ * synchronous listener on the hottest path in the package. A deny-list would
72
+ * have silently started recording the two new members the moment they shipped.
73
+ *
74
+ * The remaining conditions are economic rather than structural: after the last
75
+ * permitted review of a run there is nothing left that can consume a frame.
76
+ */
77
+ recordDelta(streamEvent: StreamEvent): void;
78
+ /** `tool_execution_start`. */
79
+ noteToolArgs(toolCallId: string, args: unknown): void;
80
+ /** `turn_end`. A turn with NO tool calls ends the run, so its frame seals here. */
81
+ endTurn(message: AssistantMessage): void;
82
+ /**
83
+ * `tool_execution_end`. Returns whether this call EMPTIED the batch — which is
84
+ * both the sealing point (§3.5.1) and the one injection window (§3.5.4). The
85
+ * ring reports the fact; `reviewer.ts` owns what follows from it.
86
+ */
87
+ endTool(toolCallId: string, toolName: string, isError: boolean, duration: number): boolean;
88
+ /**
89
+ * Seal the open frame and retire it into the ring.
90
+ *
91
+ * A REVIEW IS TRIGGERED ON SEALING, NOT AT `turn_end` (RV-2 / D-22).
92
+ * `turn_end` fires at `agent-loop.ts:188`, BEFORE any tool runs, while
93
+ * `TurnFrame.tools` carries `isError` and `ms` - fields that exist only once
94
+ * `tool_execution_end` has been emitted (`:249-256`). Triggering there meant
95
+ * the newest frame of every digest was structurally missing the single fact a
96
+ * reviewer most needs: whether the batch it is reviewing just failed.
97
+ */
98
+ seal(): void;
99
+ }