@aragon-agent/cli 0.5.8 → 0.5.10
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 +1157 -747
- package/README.md +1552 -1163
- package/dist/agent/coalesce.d.ts +13 -0
- package/dist/agent/coalesce.js +50 -11
- package/dist/agent/controller.d.ts +141 -2
- package/dist/agent/controller.js +396 -4
- package/dist/agent/headless.d.ts +8 -0
- package/dist/agent/headless.js +40 -0
- package/dist/agent/reducer.d.ts +245 -3
- package/dist/agent/reducer.js +490 -17
- package/dist/agent/retry-view.d.ts +68 -0
- package/dist/agent/retry-view.js +95 -0
- package/dist/agent/system-prompt.d.ts +11 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/boot/guard.d.ts +135 -0
- package/dist/boot/guard.js +218 -0
- package/dist/boot/rollback.d.ts +86 -0
- package/dist/boot/rollback.js +185 -0
- package/dist/cli.js +368 -3
- package/dist/commands/builtins.d.ts +1 -1
- package/dist/commands/builtins.js +426 -2
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +31 -0
- package/dist/config/app-paths.js +37 -0
- package/dist/config/cli-commands.d.ts +79 -2
- package/dist/config/cli-commands.js +185 -3
- package/dist/config/env.d.ts +15 -3
- package/dist/config/env.js +143 -3
- package/dist/config/load.d.ts +66 -0
- package/dist/config/load.js +107 -1
- package/dist/config/schema.d.ts +268 -2
- package/dist/config/schema.js +288 -1
- package/dist/config/store.js +32 -1
- package/dist/fast/critique.d.ts +43 -0
- package/dist/fast/critique.js +62 -0
- package/dist/fast/digest.d.ts +42 -0
- package/dist/fast/digest.js +85 -0
- package/dist/fast/frames.d.ts +99 -0
- package/dist/fast/frames.js +180 -0
- package/dist/fast/limits.d.ts +98 -0
- package/dist/fast/limits.js +98 -0
- package/dist/fast/prompt.d.ts +72 -0
- package/dist/fast/prompt.js +94 -0
- package/dist/fast/resolve.d.ts +43 -0
- package/dist/fast/resolve.js +105 -0
- package/dist/fast/review-call.d.ts +99 -0
- package/dist/fast/review-call.js +130 -0
- package/dist/fast/reviewer.d.ts +248 -0
- package/dist/fast/reviewer.js +664 -0
- package/dist/fast/types.d.ts +128 -0
- package/dist/fast/types.js +14 -0
- package/dist/fast/wiring.d.ts +170 -0
- package/dist/fast/wiring.js +316 -0
- package/dist/launcher.d.ts +14 -0
- package/dist/launcher.js +43 -0
- package/dist/logging/install.d.ts +15 -1
- package/dist/logging/install.js +37 -4
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.js +57 -1
- package/dist/team/normalize.d.ts +26 -1
- package/dist/team/normalize.js +23 -3
- package/dist/team/prompt.d.ts +11 -0
- package/dist/team/prompt.js +10 -0
- package/dist/team/report.d.ts +18 -0
- package/dist/team/report.js +27 -1
- package/dist/team/runtime.d.ts +13 -2
- package/dist/team/runtime.js +33 -6
- package/dist/team/subagent.d.ts +19 -1
- package/dist/team/subagent.js +72 -10
- package/dist/team/task-tool.d.ts +26 -0
- package/dist/team/task-tool.js +48 -4
- package/dist/team/types.d.ts +80 -1
- package/dist/tools/bash-tool.js +19 -3
- package/dist/tools/diff.d.ts +25 -5
- package/dist/tools/diff.js +72 -41
- package/dist/tools/file-change-store.d.ts +34 -0
- package/dist/tools/file-change-store.js +57 -0
- package/dist/tools/fs-tools.d.ts +26 -0
- package/dist/tools/fs-tools.js +62 -5
- package/dist/tools/index.d.ts +31 -0
- package/dist/tools/index.js +7 -1
- package/dist/tools/patch.d.ts +115 -0
- package/dist/tools/patch.js +453 -0
- package/dist/tools/terminal-output.d.ts +107 -0
- package/dist/tools/terminal-output.js +198 -0
- package/dist/tools/tool-output-store.d.ts +68 -0
- package/dist/tools/tool-output-store.js +102 -0
- package/dist/ui/ActivityLine.d.ts +48 -0
- package/dist/ui/ActivityLine.js +22 -0
- package/dist/ui/App.d.ts +11 -4
- package/dist/ui/App.js +359 -37
- package/dist/ui/BottomStatusRow.d.ts +65 -0
- package/dist/ui/BottomStatusRow.js +17 -0
- package/dist/ui/Composer.d.ts +1 -6
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/PromptInput.d.ts +1 -15
- package/dist/ui/PromptInput.js +1 -38
- package/dist/ui/StatusBar.d.ts +37 -0
- package/dist/ui/StatusBar.js +5 -3
- package/dist/ui/TeamPanel.d.ts +5 -4
- package/dist/ui/TeamPanel.js +1 -1
- package/dist/ui/TodoPanel.d.ts +5 -4
- package/dist/ui/Transcript.d.ts +26 -3
- package/dist/ui/Transcript.js +76 -10
- package/dist/ui/UpdateLine.d.ts +44 -0
- package/dist/ui/UpdateLine.js +129 -0
- package/dist/ui/activity-phrases.d.ts +33 -0
- package/dist/ui/activity-phrases.js +50 -0
- package/dist/ui/entries/AssistantEntry.d.ts +20 -0
- package/dist/ui/entries/AssistantEntry.js +3 -2
- package/dist/ui/entries/DiffView.d.ts +67 -0
- package/dist/ui/entries/DiffView.js +103 -0
- package/dist/ui/entries/FastCard.d.ts +53 -0
- package/dist/ui/entries/FastCard.js +87 -0
- package/dist/ui/entries/RetryCard.d.ts +56 -0
- package/dist/ui/entries/RetryCard.js +60 -0
- package/dist/ui/entries/TeamCard.js +6 -1
- package/dist/ui/entries/ToolCard.d.ts +38 -1
- package/dist/ui/entries/ToolCard.js +68 -5
- package/dist/ui/glyphs.d.ts +9 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/layout/AppShell.d.ts +13 -22
- package/dist/ui/layout/AppShell.js +3 -38
- package/dist/ui/layout/virtual-window.d.ts +1 -1
- package/dist/ui/layout/virtual-window.js +109 -5
- package/dist/ui/overlays/HelpOverlay.js +16 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
- package/dist/ui/overlays/SettingsScreen.js +147 -1
- package/dist/ui/transcript-text.js +27 -0
- package/dist/ui/use-wheel-routing.d.ts +16 -32
- package/dist/ui/use-wheel-routing.js +21 -38
- package/dist/update/classify-failure.d.ts +32 -0
- package/dist/update/classify-failure.js +60 -0
- package/dist/update/install-lock.d.ts +55 -0
- package/dist/update/install-lock.js +184 -0
- package/dist/update/install-source.d.ts +97 -0
- package/dist/update/install-source.js +218 -0
- package/dist/update/installer.d.ts +108 -0
- package/dist/update/installer.js +242 -0
- package/dist/update/limits.d.ts +111 -0
- package/dist/update/limits.js +111 -0
- package/dist/update/npm-view.d.ts +62 -0
- package/dist/update/npm-view.js +133 -0
- package/dist/update/registry.d.ts +52 -0
- package/dist/update/registry.js +161 -0
- package/dist/update/semver.d.ts +89 -0
- package/dist/update/semver.js +295 -0
- package/dist/update/service.d.ts +193 -0
- package/dist/update/service.js +636 -0
- package/dist/update/state.d.ts +103 -0
- package/dist/update/state.js +142 -0
- package/dist/update/types.d.ts +169 -0
- package/dist/update/types.js +68 -0
- package/package.json +3 -3
- package/dist/ui/layout/regions.d.ts +0 -55
- package/dist/ui/layout/regions.js +0 -52
package/dist/config/schema.js
CHANGED
|
@@ -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,244 @@ 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
|
+
}
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Auto-update (cli-auto-update §4.1)
|
|
575
|
+
//
|
|
576
|
+
// The EIGHTH nested section in the persisted config, after `apiKeys`, `skills`,
|
|
577
|
+
// `log`, `team`, `todo`, `retry` and `fast` — and, like all of them, SCALARS
|
|
578
|
+
// ONLY, exactly one level deep. `store.ts` merges these sections BY HAND and
|
|
579
|
+
// that merge is only correct while nesting stays flat (C-5).
|
|
580
|
+
//
|
|
581
|
+
// BOTH OF `store.ts`'s MERGES MUST LEARN ABOUT THIS SECTION, AND THE OMISSION IS
|
|
582
|
+
// SILENT (C-4 / R-10). `config set update.mode notify` sends
|
|
583
|
+
// `{ update: { mode: 'notify' } }`; without the write merge the shallow
|
|
584
|
+
// top-level spread REPLACES the whole section and `checkIntervalMs`, `registry`
|
|
585
|
+
// and `distTag` silently revert on disk. This package has paid for that twice
|
|
586
|
+
// already — `todo` P0-1 and `fast` R-10 — which is why it is written down a
|
|
587
|
+
// third time rather than assumed to be common knowledge.
|
|
588
|
+
//
|
|
589
|
+
// FOUR KEYS AND NO MORE (D-16). `mode` covers on / notify / off; a fifth key
|
|
590
|
+
// (`autoInstall`) would be a second way to say the same thing, and two ways to
|
|
591
|
+
// express one setting is how they end up disagreeing.
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
/** `auto` installs; `notify` only reports; `off` disables the subsystem. */
|
|
594
|
+
export const UPDATE_MODES = ['auto', 'notify', 'off'];
|
|
595
|
+
export function isUpdateMode(v) {
|
|
596
|
+
return typeof v === 'string' && UPDATE_MODES.includes(v);
|
|
597
|
+
}
|
|
598
|
+
export const DEFAULT_UPDATE_CONFIG = {
|
|
599
|
+
mode: 'auto',
|
|
600
|
+
// FOUR HOURS, in ms. `checkIntervalMs` and not `checkIntervalHours` (D-17):
|
|
601
|
+
// every other duration in this config is in ms (`toolTimeoutMs`,
|
|
602
|
+
// `idleTimeoutMs`, `team.dispatchTimeoutMs`, `retry.initialDelayMs`), and one
|
|
603
|
+
// unit per file is worth more than one shorter number.
|
|
604
|
+
checkIntervalMs: 14_400_000,
|
|
605
|
+
registry: '',
|
|
606
|
+
distTag: 'latest',
|
|
607
|
+
};
|
|
608
|
+
/** 15 minutes to 7 days. The floor is a politeness bound on the registry. */
|
|
609
|
+
const UPDATE_INTERVAL_RANGE = { min: 900_000, max: 604_800_000 };
|
|
610
|
+
/** npm's own dist-tag grammar, loosely: no slashes, no spaces, no `@`. */
|
|
611
|
+
const DIST_TAG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
612
|
+
/**
|
|
613
|
+
* THE single gate for every update-config read AND write, mirroring
|
|
614
|
+
* `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` / `clampTodoConfig`
|
|
615
|
+
* / `clampRetryConfig` / `clampFastConfig` for the reason all six record:
|
|
616
|
+
* hardening only the read path leaves a bad value on disk that reverts to the
|
|
617
|
+
* default on every launch, which presents to the user as "my setting won't
|
|
618
|
+
* stick".
|
|
619
|
+
*
|
|
620
|
+
* `registry` is CLAMPED TO `''` rather than rejected when it is not an
|
|
621
|
+
* `http(s):` URL. `''` means "derive it" (§3.3), which is the safe reading of a
|
|
622
|
+
* typo — the alternative is a background timer that throws on a value the user
|
|
623
|
+
* cannot see it reading.
|
|
624
|
+
*/
|
|
625
|
+
export function clampUpdateConfig(raw) {
|
|
626
|
+
const src = (raw && typeof raw === 'object' ? raw : {});
|
|
627
|
+
const registry = text(src.registry, DEFAULT_UPDATE_CONFIG.registry);
|
|
628
|
+
const distTag = text(src.distTag, DEFAULT_UPDATE_CONFIG.distTag);
|
|
629
|
+
return {
|
|
630
|
+
mode: isUpdateMode(src.mode) ? src.mode : DEFAULT_UPDATE_CONFIG.mode,
|
|
631
|
+
checkIntervalMs: clampInt(src.checkIntervalMs, DEFAULT_UPDATE_CONFIG.checkIntervalMs, UPDATE_INTERVAL_RANGE),
|
|
632
|
+
registry: isRegistryUrl(registry) ? registry : DEFAULT_UPDATE_CONFIG.registry,
|
|
633
|
+
distTag: DIST_TAG_RE.test(distTag) ? distTag : DEFAULT_UPDATE_CONFIG.distTag,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function isRegistryUrl(raw) {
|
|
637
|
+
if (raw.length === 0)
|
|
638
|
+
return false;
|
|
639
|
+
try {
|
|
640
|
+
const url = new URL(raw);
|
|
641
|
+
// `http:` is accepted for a LAN mirror; nothing else is a registry.
|
|
642
|
+
return url.protocol === 'https:' || url.protocol === 'http:';
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
383
648
|
export const DEFAULT_CONFIG = {
|
|
384
649
|
version: CONFIG_VERSION,
|
|
385
650
|
provider: 'anthropic',
|
|
386
651
|
model: DEFAULT_MODEL,
|
|
387
652
|
baseUrl: null,
|
|
388
653
|
thinkingLevel: 'off',
|
|
654
|
+
// Off by default — the product default this whole round exists to establish
|
|
655
|
+
// (D-1). One config key restores the old behaviour permanently.
|
|
656
|
+
showThinking: false,
|
|
657
|
+
// ON by default (D-28), and the OPPOSITE of the key above it on purpose: this
|
|
658
|
+
// one shows work that is otherwise invisible for the whole duration of the
|
|
659
|
+
// most expensive thing the agent does.
|
|
660
|
+
liveToolOutput: true,
|
|
389
661
|
// The engine owns this number; spelling it here again is how the CLI and the
|
|
390
662
|
// provider adapters end up disagreeing about what "the default" is.
|
|
391
663
|
maxTokens: DEFAULT_MAX_OUTPUT_TOKENS,
|
|
@@ -415,6 +687,21 @@ export const DEFAULT_CONFIG = {
|
|
|
415
687
|
// `todo` key at all, so this line is as required as the two merges in
|
|
416
688
|
// `store.ts` (P0-1).
|
|
417
689
|
todo: DEFAULT_TODO_CONFIG,
|
|
690
|
+
// Same requirement one section later: without this line a user with no `retry`
|
|
691
|
+
// key resolves `config.retry` to `undefined` and `toRetryPolicy` throws.
|
|
692
|
+
retry: DEFAULT_RETRY_CONFIG,
|
|
693
|
+
// And one section later again: the load path's first spread is what supplies
|
|
694
|
+
// `fast` to a user whose `config.json` predates this feature, which is every
|
|
695
|
+
// existing user. Without it `config.fast.enabled` reads `undefined`, which is
|
|
696
|
+
// falsy and so LOOKS correct — right up until `/fast on` writes a section that
|
|
697
|
+
// has no other keys in it (P0-1's shape, one feature later).
|
|
698
|
+
fast: DEFAULT_FAST_CONFIG,
|
|
699
|
+
// And one section later again, for the third time. Without this line a user
|
|
700
|
+
// whose `config.json` predates auto-update resolves `config.update.mode` to
|
|
701
|
+
// `undefined`, which is neither `'auto'` nor `'off'` — so the §3.8 gate
|
|
702
|
+
// (`cfg.update.mode !== 'off'`) would read TRUE and the feature would be on
|
|
703
|
+
// for a reason nobody could point at.
|
|
704
|
+
update: DEFAULT_UPDATE_CONFIG,
|
|
418
705
|
};
|
|
419
706
|
/**
|
|
420
707
|
* Runtime state that used to be stored in `config.json` by mistake
|
package/dist/config/store.js
CHANGED
|
@@ -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, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
|
|
18
18
|
/**
|
|
19
19
|
* Normalize `maxTokens` for both directions of the config file.
|
|
20
20
|
*
|
|
@@ -126,6 +126,19 @@ 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 ?? {}) }),
|
|
137
|
+
// `update` is the eighth. Same one-level rule, same merge — and the READ
|
|
138
|
+
// half is what supplies a whole section to every user whose `config.json`
|
|
139
|
+
// predates auto-update, which is all of them. Without it `config.update.mode`
|
|
140
|
+
// reads `undefined`, and the §3.8 gate tests `!== 'off'`.
|
|
141
|
+
update: clampUpdateConfig({ ...DEFAULT_CONFIG.update, ...(partial.update ?? {}) }),
|
|
129
142
|
};
|
|
130
143
|
}
|
|
131
144
|
/**
|
|
@@ -177,6 +190,24 @@ export function updatePersistedConfig(patch) {
|
|
|
177
190
|
// spread turns into "turning the panel off silently unregisters the tool",
|
|
178
191
|
// permanently, from the next launch onward, with nothing anywhere saying so.
|
|
179
192
|
todo: clampTodoConfig({ ...current.todo, ...(patch.todo ?? {}) }),
|
|
193
|
+
// The sixth section. `/retry max 5` sends `{ retry: { maxRetries: 5 } }`,
|
|
194
|
+
// which a shallow spread turns into "changing the retry count silently
|
|
195
|
+
// reverted `respectRetryAfter` and `jitter`" (R-9 / AC-19).
|
|
196
|
+
retry: clampRetryConfig({ ...current.retry, ...(patch.retry ?? {}) }),
|
|
197
|
+
// The seventh section, and THE WRITE HALF IS THE ONE THAT BITES (R-10).
|
|
198
|
+
// `/fast review 8` sends `{ fast: { reviewEveryTurns: 8 } }`; without this
|
|
199
|
+
// line the shallow top-level spread REPLACES the section, `enabled` is
|
|
200
|
+
// written absent, and the tier the user configured yesterday is gone
|
|
201
|
+
// tomorrow with no error anywhere. This package has paid for that once
|
|
202
|
+
// already, in `todo` (P0-1).
|
|
203
|
+
fast: clampFastConfig({ ...current.fast, ...(patch.fast ?? {}) }),
|
|
204
|
+
// The eighth section, and the third time this file records the same trap.
|
|
205
|
+
// `config set update.mode notify` sends `{ update: { mode: 'notify' } }`;
|
|
206
|
+
// without this line the shallow top-level spread REPLACES the section,
|
|
207
|
+
// `checkIntervalMs` / `registry` / `distTag` are written absent, and an
|
|
208
|
+
// enterprise mirror the user configured last month is gone with no error
|
|
209
|
+
// anywhere. AC-20 is the assertion that proves both halves exist.
|
|
210
|
+
update: clampUpdateConfig({ ...current.update, ...(patch.update ?? {}) }),
|
|
180
211
|
};
|
|
181
212
|
writeConfigFile(merged);
|
|
182
213
|
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
|
+
}
|