@aragon-agent/cli 0.6.4 → 0.6.7

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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +108 -11
  3. package/dist/agent/controller.d.ts +39 -2
  4. package/dist/agent/controller.js +125 -0
  5. package/dist/agent/reducer.d.ts +33 -19
  6. package/dist/agent/reducer.js +62 -71
  7. package/dist/agent/usage.d.ts +34 -0
  8. package/dist/agent/usage.js +43 -0
  9. package/dist/cli.js +48 -1
  10. package/dist/commands/builtins.js +9 -0
  11. package/dist/compaction/compactor.d.ts +60 -24
  12. package/dist/compaction/compactor.js +89 -61
  13. package/dist/compaction/context-command.d.ts +33 -0
  14. package/dist/compaction/context-command.js +166 -0
  15. package/dist/compaction/meter.d.ts +251 -0
  16. package/dist/compaction/meter.js +428 -0
  17. package/dist/compaction/pressure.d.ts +6 -0
  18. package/dist/compaction/pressure.js +4 -0
  19. package/dist/compaction/types.d.ts +41 -0
  20. package/dist/compaction/wiring.d.ts +47 -0
  21. package/dist/compaction/wiring.js +88 -27
  22. package/dist/config/cli-commands.js +5 -0
  23. package/dist/config/env.js +15 -1
  24. package/dist/config/load.d.ts +17 -0
  25. package/dist/config/load.js +45 -1
  26. package/dist/config/schema.d.ts +44 -0
  27. package/dist/config/schema.js +36 -0
  28. package/dist/config/store.js +27 -1
  29. package/dist/exec/events.d.ts +138 -1
  30. package/dist/exec/events.js +42 -0
  31. package/dist/exec/index.js +43 -6
  32. package/dist/exec/progress-reporter.d.ts +23 -0
  33. package/dist/exec/progress-reporter.js +88 -0
  34. package/dist/exec/runner.d.ts +34 -0
  35. package/dist/exec/runner.js +137 -25
  36. package/dist/fast/reviewer.d.ts +19 -1
  37. package/dist/fast/reviewer.js +22 -0
  38. package/dist/fast/types.d.ts +15 -0
  39. package/dist/fast/wiring.d.ts +10 -1
  40. package/dist/fast/wiring.js +18 -1
  41. package/dist/todo/limits.d.ts +4 -17
  42. package/dist/todo/limits.js +4 -17
  43. package/dist/todo/panel-rows.d.ts +8 -6
  44. package/dist/todo/panel-rows.js +19 -6
  45. package/dist/ui/App.js +75 -63
  46. package/dist/ui/AutocompletePopup.d.ts +3 -1
  47. package/dist/ui/AutocompletePopup.js +9 -11
  48. package/dist/ui/BottomStatusRow.d.ts +1 -1
  49. package/dist/ui/Composer.d.ts +3 -1
  50. package/dist/ui/Composer.js +2 -2
  51. package/dist/ui/PromptInput.d.ts +4 -1
  52. package/dist/ui/PromptInput.js +19 -5
  53. package/dist/ui/StatusBar.d.ts +21 -15
  54. package/dist/ui/StatusBar.js +48 -9
  55. package/dist/ui/TeamPanel.d.ts +3 -1
  56. package/dist/ui/TeamPanel.js +13 -9
  57. package/dist/ui/TodoPanel.d.ts +5 -5
  58. package/dist/ui/TodoPanel.js +56 -24
  59. package/dist/ui/UpdateLine.d.ts +1 -1
  60. package/dist/ui/capabilities.js +5 -5
  61. package/dist/ui/exit-snapshot.d.ts +9 -0
  62. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  63. package/dist/ui/layout/autocomplete.d.ts +18 -0
  64. package/dist/ui/layout/autocomplete.js +26 -0
  65. package/dist/ui/layout/rail.d.ts +4 -53
  66. package/dist/ui/layout/rail.js +10 -62
  67. package/dist/ui/layout/team-panel.d.ts +18 -0
  68. package/dist/ui/layout/team-panel.js +22 -0
  69. package/dist/ui/layout/todo-layout.d.ts +24 -0
  70. package/dist/ui/layout/todo-layout.js +27 -0
  71. package/dist/ui/transcript-text.js +6 -2
  72. package/package.json +2 -2
@@ -11,11 +11,25 @@
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
13
  import process from 'node:process';
14
- import { occupiedTokens } from '../compaction/pressure.js';
15
- import { computeCost } from './usage.js';
14
+ import { emptyContextUsage } from '../compaction/meter.js';
15
+ import { addUsage, computeCost } from './usage.js';
16
16
  import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
17
17
  /** Default auto-dismiss window for a toast. */
18
18
  export const DEFAULT_TOAST_TTL_MS = 2500;
19
+ /**
20
+ * The zero session total.
21
+ *
22
+ * ONE LITERAL FOR TWO SITES (`initialViewState` and `resetConversation`). They
23
+ * used to spell the object out twice, which is how `cacheReadTokens` would have
24
+ * been added to one of them and not the other.
25
+ */
26
+ const EMPTY_USAGE_TOTAL = {
27
+ inputTokens: 0,
28
+ outputTokens: 0,
29
+ cacheReadTokens: 0,
30
+ cacheWriteTokens: 0,
31
+ costUsd: 0,
32
+ };
19
33
  /**
20
34
  * The initial view state, optionally seeded from config.
21
35
  *
@@ -33,8 +47,8 @@ export function initialViewState(seed = {}) {
33
47
  return {
34
48
  entries: [],
35
49
  status: 'idle',
36
- usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
37
- contextTokens: 0,
50
+ usageTotal: EMPTY_USAGE_TOTAL,
51
+ context: emptyContextUsage(),
38
52
  overlay: null,
39
53
  thinkingVisible: seed.thinkingVisible ?? false,
40
54
  toasts: [],
@@ -48,7 +62,6 @@ export function initialViewState(seed = {}) {
48
62
  retry: null,
49
63
  fast: null,
50
64
  compaction: null,
51
- contextTokensEstimated: false,
52
65
  seq: 0,
53
66
  teamEntryId: undefined,
54
67
  todoEntryId: undefined,
@@ -610,22 +623,12 @@ export function viewReducer(state, action) {
610
623
  ...state,
611
624
  streamingId: undefined,
612
625
  turnProduced: true,
613
- // ONE FUNCTION, SHARED WITH THE COMPACTION TRIGGER (§3.4.1 / R-11 /
614
- // AC-3). The old expression — `inputTokens + outputTokens` — silently
615
- // UNDER-reports for anyone behind a caching gateway, because Anthropic
616
- // reports `input_tokens` EXCLUDING cached tokens and puts them in two
617
- // separate fields. That was a live defect in the number this gauge shows;
618
- // it becomes a much worse one the moment a trigger fires on a different
619
- // number from the one the user is watching.
620
- contextTokens: occupiedTokens(action.usage),
621
- // A MEASUREMENT SUPERSEDES A DERIVATION (§6.2). `compactionEnd` writes an
622
- // estimate so the bar falls immediately; this is where it stops being one.
623
- contextTokensEstimated: false,
624
- usageTotal: {
625
- inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
626
- outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
627
- costUsd: state.usageTotal.costUsd + action.costDelta,
628
- },
626
+ // `contextTokens` IS NOT WRITTEN HERE ANY MORE (I-1). The occupancy this
627
+ // turn produced reaches the view through `ContextMeter`, which subscribes
628
+ // to the same `turn_end` and publishes the measured pressure - window,
629
+ // percentage and approximation markers included, none of which this
630
+ // branch ever had. Two branches writing one number is what P0-1 was.
631
+ usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta),
629
632
  // The turn produced an answer, so whatever it had to retry, it recovered.
630
633
  ...settleRetryCard(state, entries, 'recovered'),
631
634
  };
@@ -763,8 +766,13 @@ export function viewReducer(state, action) {
763
766
  droppedEntries: 0,
764
767
  streamingId: undefined,
765
768
  expandedToolIds: {},
766
- contextTokens: 0,
767
- usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
769
+ // `context` IS DELIBERATELY UNTOUCHED (I-1). `builtins.ts` calls
770
+ // `controller.clearMessages()` BEFORE dispatching this, and that marks
771
+ // the meter dirty and schedules a publication; zeroing here would make
772
+ // the final state `0` rather than the meter's real answer - which for an
773
+ // emptied conversation is the system prompt's own couple of percent, not
774
+ // nothing. `usageTotal` DOES reset: that is session spend, not occupancy.
775
+ usageTotal: EMPTY_USAGE_TOTAL,
768
776
  teamEntryId: undefined,
769
777
  // `/reset` clears `messages`, so the belief is gone and the list goes
770
778
  // with it — the other half of I-2. `controller.clearMessages()` clears
@@ -774,10 +782,6 @@ export function viewReducer(state, action) {
774
782
  retryEntryId: undefined,
775
783
  retry: null,
776
784
  compactionEntryId: undefined,
777
- // `contextTokens` is zeroed above, so the flag that says it is a
778
- // DERIVATION has to go with it — otherwise a fresh conversation renders
779
- // `~0%` and the tilde is a lie about a number that is simply zero.
780
- contextTokensEstimated: false,
781
785
  };
782
786
  case 'setOverlay':
783
787
  return { ...state, overlay: action.overlay };
@@ -860,18 +864,11 @@ export function viewReducer(state, action) {
860
864
  }
861
865
  case 'teamUsage':
862
866
  // `usageTotal` AND NOTHING ELSE (§3.9 / AC-15). In particular NOT
863
- // `contextTokens`: that gauge shows the LEAD's context occupancy against
867
+ // `context`: that gauge shows the LEAD's context occupancy against
864
868
  // the model's window, and folding five children into it would read 180%
865
869
  // on a perfectly healthy session. Child spend is real money and has to
866
870
  // appear in the cost readout; it is not the lead's context.
867
- return {
868
- ...state,
869
- usageTotal: {
870
- inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
871
- outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
872
- costUsd: state.usageTotal.costUsd + action.costDelta,
873
- },
874
- };
871
+ return { ...state, usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta) };
875
872
  case 'teamEnd': {
876
873
  const { outcome } = action;
877
874
  const entries = state.teamEntryId
@@ -991,17 +988,10 @@ export function viewReducer(state, action) {
991
988
  }
992
989
  case 'fastUsage':
993
990
  // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` (§3.6 / AC-18). In
994
- // particular NOT `contextTokens`: that gauge shows the LEAD's context
991
+ // particular NOT `context`: that gauge shows the LEAD's context
995
992
  // occupancy against the lead model's window, and a review is a separate
996
993
  // conversation with a different model entirely.
997
- return {
998
- ...state,
999
- usageTotal: {
1000
- inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
1001
- outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
1002
- costUsd: state.usageTotal.costUsd + action.costDelta,
1003
- },
1004
- };
994
+ return { ...state, usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta) };
1005
995
  case 'fastTier':
1006
996
  return { ...state, fast: action.snapshot };
1007
997
  // --- Context compaction (context-auto-compaction §5.5) ----------------
@@ -1128,40 +1118,41 @@ export function viewReducer(state, action) {
1128
1118
  }
1129
1119
  case 'compactionUsage':
1130
1120
  // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` and `fastUsage`. In
1131
- // particular NOT `contextTokens`: that gauge shows the LEAD's context
1121
+ // particular NOT `context`: that gauge shows the LEAD's context
1132
1122
  // occupancy against the lead model's window, and a summarization is a
1133
1123
  // separate conversation with a possibly different model entirely.
1134
- return {
1135
- ...state,
1136
- usageTotal: {
1137
- inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
1138
- outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
1139
- costUsd: state.usageTotal.costUsd + action.costDelta,
1140
- },
1141
- };
1124
+ return { ...state, usageTotal: addUsage(state.usageTotal, action.usage, action.costDelta) };
1142
1125
  case 'compactionSnapshot':
1143
1126
  return { ...state, compaction: action.snapshot };
1144
1127
  /**
1145
- * THE GAUGE MUST FALL IMMEDIATELY (§6.2 / AC-5).
1128
+ * THE ONE WRITER OF `state.context` (I-1).
1146
1129
  *
1147
- * `contextTokens` is otherwise only written at `turnEnd`, so a compaction
1148
- * that took the session from 92 % to 24 % would leave the bar at 92 % until
1149
- * the next turn completes — and the user would reasonably conclude that
1150
- * nothing happened. This is a small change and it is the difference between
1151
- * the feature feeling real and feeling broken.
1130
+ * THE IDENTITY SHORT-CIRCUIT BELOW IS LOAD-BEARING, NOT A MICRO-OPTIMISATION
1131
+ * (§5.2 / R-3). The meter publishes on a 400 ms tick throughout a turn and
1132
+ * most of those ticks measure the same number - the history did not change
1133
+ * between two tool calls of the same size, or the percentage rounded to the
1134
+ * same integer. Returning the SAME state object lets React skip the whole
1135
+ * subtree; returning a fresh one would make a permanently visible row into a
1136
+ * 2.5 Hz re-render source and walk the render governor up its ladder for no
1137
+ * visible change at all.
1152
1138
  *
1153
- * The value arrives ALREADY CALIBRATED from `App` (§6.2 / P1-11): it replaces
1154
- * a MEASUREMENT that included the tool schemas, and core's raw estimator
1155
- * never counts them, so writing the raw figure would make the bar fall too
1156
- * far and then visibly climb again on the next `turn_end` for no reason the
1157
- * user can see.
1139
+ * `ContextUsageSnapshot` carries no timestamp for exactly this reason: a
1140
+ * field that moves every tick would make this comparison never hold.
1158
1141
  */
1159
- case 'contextTokensEstimated':
1160
- return {
1161
- ...state,
1162
- contextTokens: Math.max(0, Math.round(action.tokens)),
1163
- contextTokensEstimated: true,
1164
- };
1142
+ case 'contextUsage': {
1143
+ const prev = state.context;
1144
+ const next = action.snapshot;
1145
+ if (prev.occupied === next.occupied &&
1146
+ prev.window === next.window &&
1147
+ prev.pct === next.pct &&
1148
+ prev.source === next.source &&
1149
+ prev.deltaTokens === next.deltaTokens &&
1150
+ prev.windowKnown === next.windowKnown &&
1151
+ prev.windowOverridden === next.windowOverridden) {
1152
+ return state;
1153
+ }
1154
+ return { ...state, context: next };
1155
+ }
1165
1156
  // --- API retry (llm-api-retry-backoff §6.4) ---------------------------
1166
1157
  case 'retryScheduled': {
1167
1158
  const snapshot = {
@@ -3,8 +3,42 @@
3
3
  * (which is expressed in USD per 1M tokens). See spec §6.2 status bar.
4
4
  */
5
5
  import type { ModelCost, TokenUsage } from '@aragon-agent/core';
6
+ import type { UsageTotal } from './reducer.js';
6
7
  /** Compute the USD cost of a single `TokenUsage` given a model's cost table. */
7
8
  export declare function computeCost(usage: TokenUsage, cost?: ModelCost): number;
9
+ /**
10
+ * Fold one `TokenUsage` into a running session total
11
+ * (context-usage-gauge-accuracy §3.6 / W5).
12
+ *
13
+ * THE ONE ADDITION SITE, and collapsing four hand-written copies into it is the
14
+ * point rather than a tidy-up. The reducer had the same three lines in four
15
+ * branches - `turnEnd`, `teamUsage`, `fastUsage`, `compactionUsage` - and all
16
+ * four silently omitted the cache terms, so the status bar's `^` read LOWER than
17
+ * the `$` beside it on any session behind a caching gateway. A fifth source
18
+ * added later would have made the identical omission; now it cannot.
19
+ *
20
+ * `costDelta` IS PRECOMPUTED BY THE CALLER, exactly as the four branches already
21
+ * required: the summarizer and the fast tier may be different models with
22
+ * different price tables, and a reducer that priced its own would have to know
23
+ * about three of them.
24
+ */
25
+ export declare function addUsage(total: UsageTotal, usage: TokenUsage, costDelta: number): UsageTotal;
26
+ /**
27
+ * The number the status bar's `^` shows: the PROMPT side of the session total.
28
+ *
29
+ * DELIBERATELY THE SAME TERMS AS `occupiedTokens` MINUS `outputTokens`
30
+ * (`compaction/pressure.ts`). Anthropic reports `input_tokens` EXCLUDING cached
31
+ * tokens and puts them in two separate fields, so `inputTokens` alone
32
+ * under-reports for anyone behind a caching gateway - while `computeCost` above
33
+ * has always priced all three. That is the "three units on one row" defect
34
+ * (P1-3): the percentage counted cache, the dollars counted cache, and the arrow
35
+ * did not.
36
+ *
37
+ * `$` IS STILL NOT THE SAME TOTAL, and the distinction is worth keeping straight
38
+ * (RV-14): `computeCost` also prices OUTPUT tokens. What this function asserts is
39
+ * narrower and exact - both cache terms are counted, on both sides.
40
+ */
41
+ export declare function promptTokensOf(total: UsageTotal): number;
8
42
  /** Format a USD amount for the status bar (`$0.0000`, or `$1.23` when large). */
9
43
  export declare function formatCost(usd: number): string;
10
44
  /** Format a token count compactly (`1.2k`, `3.4M`). */
@@ -12,6 +12,49 @@ export function computeCost(usage, cost) {
12
12
  perM(usage.cacheReadTokens ?? 0, cost.cacheRead) +
13
13
  perM(usage.cacheWriteTokens ?? 0, cost.cacheWrite));
14
14
  }
15
+ /**
16
+ * Fold one `TokenUsage` into a running session total
17
+ * (context-usage-gauge-accuracy §3.6 / W5).
18
+ *
19
+ * THE ONE ADDITION SITE, and collapsing four hand-written copies into it is the
20
+ * point rather than a tidy-up. The reducer had the same three lines in four
21
+ * branches - `turnEnd`, `teamUsage`, `fastUsage`, `compactionUsage` - and all
22
+ * four silently omitted the cache terms, so the status bar's `^` read LOWER than
23
+ * the `$` beside it on any session behind a caching gateway. A fifth source
24
+ * added later would have made the identical omission; now it cannot.
25
+ *
26
+ * `costDelta` IS PRECOMPUTED BY THE CALLER, exactly as the four branches already
27
+ * required: the summarizer and the fast tier may be different models with
28
+ * different price tables, and a reducer that priced its own would have to know
29
+ * about three of them.
30
+ */
31
+ export function addUsage(total, usage, costDelta) {
32
+ return {
33
+ inputTokens: total.inputTokens + usage.inputTokens,
34
+ outputTokens: total.outputTokens + usage.outputTokens,
35
+ cacheReadTokens: total.cacheReadTokens + (usage.cacheReadTokens ?? 0),
36
+ cacheWriteTokens: total.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
37
+ costUsd: total.costUsd + costDelta,
38
+ };
39
+ }
40
+ /**
41
+ * The number the status bar's `^` shows: the PROMPT side of the session total.
42
+ *
43
+ * DELIBERATELY THE SAME TERMS AS `occupiedTokens` MINUS `outputTokens`
44
+ * (`compaction/pressure.ts`). Anthropic reports `input_tokens` EXCLUDING cached
45
+ * tokens and puts them in two separate fields, so `inputTokens` alone
46
+ * under-reports for anyone behind a caching gateway - while `computeCost` above
47
+ * has always priced all three. That is the "three units on one row" defect
48
+ * (P1-3): the percentage counted cache, the dollars counted cache, and the arrow
49
+ * did not.
50
+ *
51
+ * `$` IS STILL NOT THE SAME TOTAL, and the distinction is worth keeping straight
52
+ * (RV-14): `computeCost` also prices OUTPUT tokens. What this function asserts is
53
+ * narrower and exact - both cache terms are counted, on both sides.
54
+ */
55
+ export function promptTokensOf(total) {
56
+ return total.inputTokens + total.cacheReadTokens + total.cacheWriteTokens;
57
+ }
15
58
  /** Format a USD amount for the status bar (`$0.0000`, or `$1.23` when large). */
16
59
  export function formatCost(usd) {
17
60
  if (usd <= 0)
package/dist/cli.js CHANGED
@@ -27,7 +27,7 @@ import { COMPACTION_CONFIG_SET_KEYS, FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS,
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
30
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
30
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
31
31
  import { makeGetApiKey } from './config/load.js';
32
32
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
33
33
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -153,6 +153,11 @@ function toFlags(opts) {
153
153
  fastModel: opts.fastModel,
154
154
  fastProvider: opts.fastProvider,
155
155
  fastReview: opts.fastReview,
156
+ // …and again for `--fast-delegate` / `--no-fast-delegate`, one feature
157
+ // later — NINE features, one trap. It is `fast.delegate`'s only channel:
158
+ // there is no `ARAGON_FAST_DELEGATE`, so missing this line leaves a wrapper
159
+ // with no way at all to say "do not delegate on this run".
160
+ fastDelegate: opts.fastDelegate,
156
161
  // `--verbose` is just a shorthand for `--log-level debug`; an explicit
157
162
  // `--log-level` still wins, so the two can be combined without surprise.
158
163
  logLevel: opts.logLevel ?? (opts.verbose ? 'debug' : undefined),
@@ -924,6 +929,11 @@ const CONFIG_SET_KEYS = new Set([
924
929
  'baseUrl',
925
930
  'thinkingLevel',
926
931
  'maxTokens',
932
+ // context-usage-gauge-accuracy §3.6. Listed here AND given its own branch in
933
+ // `runConfigSet` below - a key present here but absent there falls through
934
+ // every case, writes nothing, and still prints `Set contextWindow = 1000000`
935
+ // (P1-2).
936
+ 'contextWindow',
927
937
  'theme',
928
938
  'confirmTools',
929
939
  'toolTimeoutMs',
@@ -1020,6 +1030,30 @@ function runConfigSetMaxTokens(value) {
1020
1030
  const merged = updatePersistedConfig({ maxTokens: stored });
1021
1031
  process.stdout.write(`Set maxTokens = ${merged.maxTokens ?? 'auto'}\n`);
1022
1032
  }
1033
+ /**
1034
+ * `aragon config set contextWindow <n|auto>`.
1035
+ *
1036
+ * ITS OWN BRANCH, AHEAD OF THE SWITCH, for the reason `maxTokens` has one: it
1037
+ * must echo the STORED value rather than the typed one, so
1038
+ * `config set contextWindow 99999999` reports the clamped 5000000 instead of a
1039
+ * number that was never written. It also REJECTS nonsense rather than clamping
1040
+ * it - a non-numeric argument here is almost always a typo for a number, and
1041
+ * storing AUTO for `20O000` would quietly discard the user's intent.
1042
+ */
1043
+ function runConfigSetContextWindow(value) {
1044
+ const trimmed = value.trim();
1045
+ const auto = isAutoToken(trimmed);
1046
+ if (!auto && !/^\d+$/.test(trimmed)) {
1047
+ process.stderr.write(`Invalid contextWindow "${value}". Use a number of tokens or "auto".\n`);
1048
+ process.exitCode = 2;
1049
+ return;
1050
+ }
1051
+ // `null` GOES IN UNCLAMPED (RV-12). Running AUTO through the clamp yields
1052
+ // 8000 and removes "auto" from the config permanently.
1053
+ const stored = auto ? null : clampContextWindow(trimmed, null);
1054
+ const merged = updatePersistedConfig({ contextWindow: stored });
1055
+ process.stdout.write(`Set contextWindow = ${merged.contextWindow ?? 'auto'}\n`);
1056
+ }
1023
1057
  function runConfigSet(key, value) {
1024
1058
  if (!CONFIG_SET_KEYS.has(key)) {
1025
1059
  process.stderr.write(`Unknown config key "${key}". Known keys: ${[...CONFIG_SET_KEYS].join(', ')}.\n`);
@@ -1040,6 +1074,11 @@ function runConfigSet(key, value) {
1040
1074
  runConfigSetMaxTokens(value);
1041
1075
  return;
1042
1076
  }
1077
+ // The second tri-state key, and it needs its own branch for the same reason.
1078
+ if (key === 'contextWindow') {
1079
+ runConfigSetContextWindow(value);
1080
+ return;
1081
+ }
1043
1082
  const teamPatch = applyTeamConfigSet(key, value);
1044
1083
  if (teamPatch) {
1045
1084
  // The written value is echoed from the CLAMPED result rather than from the
@@ -1491,6 +1530,14 @@ function buildProgram() {
1491
1530
  .option('--fast-model <id>', 'The fast model id (implies nothing about --fast)')
1492
1531
  .option('--fast-provider <id>', 'Provider for the fast model (default: the main provider)')
1493
1532
  .option('--fast-review <n|off>', 'Turns between automatic fast reviews, or "off"')
1533
+ // BOTH FORMS, POSITIVE FIRST, and here it matters more than anywhere above:
1534
+ // `fast.delegate` is PERSISTED and defaults to TRUE, so declaring only
1535
+ // `--no-fast-delegate` would make commander default `opts.fastDelegate` to
1536
+ // `true` and force `delegate: true` on EVERY run that passed no flag at all
1537
+ // — silently overwriting a user's stored `false`. Same polarity, same trap
1538
+ // and same fix as `--update` / `--compaction`.
1539
+ .option('--fast-delegate', 'Let the fast tier take mechanical sub-steps (the default)')
1540
+ .option('--no-fast-delegate', 'Run every sub-step on the main model')
1494
1541
  // BOTH FORMS, positive first, for the reason recorded seven times above —
1495
1542
  // and this is the pair whose omission would be worst. `update.mode` is
1496
1543
  // PERSISTED and defaults to `'auto'`, so a lone `--no-update` would make
@@ -17,6 +17,7 @@ import { getLogger } from '../logging/logger.js';
17
17
  import { copyText } from '../ui/clipboard.js';
18
18
  import { perfCommand } from './perf.js';
19
19
  import { runCompactCommand } from '../compaction/command.js';
20
+ import { runContextCommand } from '../compaction/context-command.js';
20
21
  /**
21
22
  * `/fast status` — THE GUARANTEED REPORTING SURFACE for the tier (§4.4 / R-8).
22
23
  *
@@ -809,6 +810,14 @@ const COMMANDS = [
809
810
  description: 'Context compaction: status | on | off | threshold <n> | keep <n> | <instructions>',
810
811
  run: (ctx) => runCompactCommand(ctx),
811
812
  },
813
+ {
814
+ // REGISTRATION ONLY, for the reason `/compact` records one entry up: this
815
+ // file is past the 1000-line guideline and the report has five sections.
816
+ // The body is in `compaction/context-command.ts`.
817
+ name: 'context',
818
+ description: 'Show context occupancy, the window it is measured against, and session spend',
819
+ run: (ctx) => runContextCommand(ctx),
820
+ },
812
821
  {
813
822
  name: 'expand',
814
823
  // DELIBERATELY LEFT TOOL-ONLY, and the asymmetry with Ctrl+O is a decision
@@ -12,6 +12,7 @@
12
12
  import { type Message, type ModelInfo, type ModelRef, type TokenUsage, type AssistantMessage, type CompactionContext, type CompactionOutcome, type CompactionProbe, type ContextManager, type LLMRequest } from '@aragon-agent/core';
13
13
  import type { CliConfig } from '../config/schema.js';
14
14
  import type { NoticeLevel } from '../agent/reducer.js';
15
+ import { ContextMeter } from './meter.js';
15
16
  import type { CompactionEvent, Pressure } from './types.js';
16
17
  export interface CompactorDeps {
17
18
  /** Read LIVE on every call — the controller replaces the object on mutation. */
@@ -42,6 +43,24 @@ export interface CompactorDeps {
42
43
  complete: (providerId: string, request: LLMRequest) => Promise<AssistantMessage>;
43
44
  emit: (event: CompactionEvent) => void;
44
45
  notify: (level: NoticeLevel, text: string) => void;
46
+ /**
47
+ * The ONE owner of occupancy measurement (context-usage-gauge-accuracy W1).
48
+ *
49
+ * THE COMPACTOR NO LONGER MEASURES. It used to hold `estimateOffset`,
50
+ * `measuredPrefixLength` and `lastPressure` itself, which locked the only
51
+ * measuring code in the process behind `compaction.enabled` - and a session
52
+ * that turned compaction OFF is exactly the one whose gauge then had a single
53
+ * sample per turn and nothing else. Reading a meter the controller owns makes
54
+ * "the trigger and the gauge read one number" a STRUCTURAL fact rather than a
55
+ * discipline (§3.3).
56
+ *
57
+ * OPTIONAL SO EVERY EXISTING CONSTRUCTION SITE COMPILES (R-1), and lazily
58
+ * replaced by a private one built from the deps already here. PRODUCTION MUST
59
+ * INJECT: `context-gauge-wiring.test.ts` asserts one instance per process
60
+ * (`controller.getContextMeter()` is the same object the wiring forwards), and
61
+ * two meters in one session means two answers to one question.
62
+ */
63
+ meter?: ContextMeter;
45
64
  /**
46
65
  * Publish the occupancy this checkpoint measured (hardening §3.2.4 / DH-15).
47
66
  *
@@ -96,18 +115,15 @@ export declare class Compactor implements ContextManager {
96
115
  private selfDisabledReason;
97
116
  private pendingManual;
98
117
  private generation;
99
- /** Calibration for every ESTIMATED figure (D-23). Recomputed on every turn_end. */
100
- private estimateOffset;
101
- /** The last pressure computed, so the status bar and `/compact status` agree. */
102
- private lastPressure;
103
118
  /**
104
- * The history LENGTH the measurement in `lastUsage` covered (hardening W1).
119
+ * The fallback meter, built on first use when no host injected one (R-1).
105
120
  *
106
- * `undefined` DISABLES THE DELTA and reproduces round 1's arithmetic exactly.
107
- * Every host-side mutation that is not an append must clear it - see
108
- * `invalidateMeasurement`.
121
+ * `estimateOffset`, `measuredPrefixLength` and `lastPressure` used to be
122
+ * FIELDS HERE. They moved to `ContextMeter` wholesale
123
+ * (context-usage-gauge-accuracy W1), because keeping them here is what put the
124
+ * only measurement in the process behind `compaction.enabled`.
109
125
  */
110
- private measuredPrefixLength;
126
+ private ownMeter;
111
127
  /**
112
128
  * The messages the last splice dropped, held ONLY until the wiring collects
113
129
  * them for the archive (W4 / RV-3).
@@ -129,21 +145,17 @@ export declare class Compactor implements ContextManager {
129
145
  /** `agent_start` — the per-run cap resets, the session totals do not. */
130
146
  onRunStart(): void;
131
147
  /**
132
- * `turn_end` — recompute the calibration offset (D-23).
148
+ * `turn_end` - recompute the calibration offset (D-23).
133
149
  *
134
- * RE-MEASURED EVERY TURN, not once, so it tracks a toolset that changed
135
- * mid-session (`/tools`, a skill that registers one).
136
- */
137
- onTurnEnd(usage: TokenUsage, messages: readonly Message[], systemPrompt: string): void;
138
- /**
139
- * Any host-side history mutation that is NOT an append (hardening §3.2.3).
150
+ * A THIN FORWARDER since context-usage-gauge-accuracy W1. The meter subscribes
151
+ * to `turn_end` itself, so BOTH PATHS MUST BE IDEMPOTENT: this one recomputes
152
+ * exactly what the other one did from the same inputs, which it is.
140
153
  *
141
- * `/clear`, `/resume`, and the splice itself: after any of them a recorded
142
- * index means nothing, and the delta would be measured against the wrong
143
- * array. `estimateAppendedTokens` bounds-checks as well, so a MISSED call here
144
- * costs accuracy - the number degrades to round 1's - and never correctness.
154
+ * `messages` / `systemPrompt` stay in the signature and are ignored: the meter
155
+ * reads them from its own lazy deps, and every existing caller and test keeps
156
+ * compiling.
145
157
  */
146
- invalidateMeasurement(): void;
158
+ onTurnEnd(usage: TokenUsage, _messages: readonly Message[], _systemPrompt: string): void;
147
159
  /**
148
160
  * The dropped messages, released as they are returned (W4 / RV-3).
149
161
  *
@@ -255,15 +267,29 @@ export declare class Compactor implements ContextManager {
255
267
  private noteNoProgress;
256
268
  private finish;
257
269
  /**
258
- * The pressure the trigger and the gauge both read.
270
+ * The pressure the trigger and the gauge both read - MEASURED NOW.
259
271
  *
260
- * CACHED on every evaluation, so `/compact status` reports the same number the
261
- * last decision used rather than recomputing one that has since moved.
272
+ * A FORWARDER since W1: the decision point supplies its own authoritative
273
+ * `lastUsage` (the probe's), and the meter owns everything else. `messages` /
274
+ * `systemPrompt` stay in the signature and are ignored, because the meter
275
+ * reads the LIVE history through its own deps - which is strictly better than
276
+ * a caller that could pass `[]` and get a 0 % reading (the old default here).
262
277
  */
263
278
  measure(probe: Pick<CompactionProbe, 'lastUsage'> & {
264
279
  messages?: readonly Message[];
265
280
  systemPrompt?: string;
266
281
  }): Pressure;
282
+ /**
283
+ * The last pressure PUBLISHED. NEVER re-measured - `lastPublished`, not
284
+ * `current` (I-10 / RV-3).
285
+ *
286
+ * `finish()` below reads this for `tokensBefore`, which is the sole upstream of
287
+ * `tokensReclaimed`, the card's "reclaimed N tokens" and `/compact status`'s
288
+ * session total. All three want the occupancy BEFORE the splice. Point this at
289
+ * `meter.current()` and the moment the meter is dirty - which after a splice it
290
+ * always is - `tokensBefore` becomes the POST-compaction figure, `reclaimed`
291
+ * collapses to 0, and the card renders that with nothing logging a fault.
292
+ */
267
293
  lastMeasured(): Pressure | null;
268
294
  /** Written by the wiring once the engine has reported its verdict. */
269
295
  recordApplied(tokensBefore: number, tokensAfter: number): void;
@@ -295,6 +321,16 @@ export declare class Compactor implements ContextManager {
295
321
  * entire run, and the spend is on the card with the summarizer's own price.
296
322
  */
297
323
  resolveSummarizer(): SummarizerChoice | null;
324
+ /**
325
+ * The meter this compactor reads, injected or private (R-1).
326
+ *
327
+ * MEMOIZED, because a fresh meter on every call would have no `estimateOffset`
328
+ * and no cached pressure - `lastMeasured()` would answer `null` forever and
329
+ * `tokensReclaimed` would sit at 0. The fallback exists so the ten existing
330
+ * `compaction-*.test.ts` construction sites compile unchanged; production
331
+ * injects, and `context-gauge-wiring.test.ts` asserts the single instance.
332
+ */
333
+ private meter;
298
334
  private mainRef;
299
335
  private modelInfo;
300
336
  private effectiveMaxOutputTokens;