@aragon-agent/cli 0.6.4 → 0.6.8

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 (119) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +163 -37
  3. package/dist/agent/controller.d.ts +49 -8
  4. package/dist/agent/controller.js +160 -48
  5. package/dist/agent/headless.d.ts +2 -1
  6. package/dist/agent/prompt-options.d.ts +15 -0
  7. package/dist/agent/prompt-options.js +1 -0
  8. package/dist/agent/reducer.d.ts +33 -19
  9. package/dist/agent/reducer.js +62 -71
  10. package/dist/agent/usage.d.ts +34 -0
  11. package/dist/agent/usage.js +43 -0
  12. package/dist/cli.js +131 -54
  13. package/dist/commands/builtins.js +12 -1
  14. package/dist/commands/registry.d.ts +2 -3
  15. package/dist/compaction/compactor.d.ts +60 -24
  16. package/dist/compaction/compactor.js +89 -61
  17. package/dist/compaction/context-command.d.ts +33 -0
  18. package/dist/compaction/context-command.js +166 -0
  19. package/dist/compaction/meter.d.ts +251 -0
  20. package/dist/compaction/meter.js +428 -0
  21. package/dist/compaction/pressure.d.ts +6 -0
  22. package/dist/compaction/pressure.js +4 -0
  23. package/dist/compaction/types.d.ts +41 -0
  24. package/dist/compaction/wiring.d.ts +47 -0
  25. package/dist/compaction/wiring.js +88 -27
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.js +15 -1
  28. package/dist/config/load.d.ts +17 -0
  29. package/dist/config/load.js +45 -1
  30. package/dist/config/schema.d.ts +44 -0
  31. package/dist/config/schema.js +36 -0
  32. package/dist/config/store.js +27 -1
  33. package/dist/exec/events.d.ts +138 -1
  34. package/dist/exec/events.js +42 -0
  35. package/dist/exec/index.js +43 -6
  36. package/dist/exec/progress-reporter.d.ts +23 -0
  37. package/dist/exec/progress-reporter.js +88 -0
  38. package/dist/exec/runner.d.ts +34 -0
  39. package/dist/exec/runner.js +137 -25
  40. package/dist/fast/reviewer.d.ts +19 -1
  41. package/dist/fast/reviewer.js +22 -0
  42. package/dist/fast/types.d.ts +15 -0
  43. package/dist/fast/wiring.d.ts +10 -1
  44. package/dist/fast/wiring.js +18 -1
  45. package/dist/input/interrupt-gesture.d.ts +18 -0
  46. package/dist/input/interrupt-gesture.js +19 -0
  47. package/dist/input/pointer-router.d.ts +11 -0
  48. package/dist/input/pointer-router.js +14 -0
  49. package/dist/todo/limits.d.ts +4 -17
  50. package/dist/todo/limits.js +4 -17
  51. package/dist/todo/panel-rows.d.ts +8 -6
  52. package/dist/todo/panel-rows.js +19 -6
  53. package/dist/todo/store.d.ts +8 -13
  54. package/dist/todo/store.js +13 -14
  55. package/dist/todo/types.d.ts +2 -1
  56. package/dist/ui/App.d.ts +2 -0
  57. package/dist/ui/App.js +233 -183
  58. package/dist/ui/AutocompletePopup.d.ts +3 -1
  59. package/dist/ui/AutocompletePopup.js +9 -11
  60. package/dist/ui/BottomStatusRow.d.ts +1 -1
  61. package/dist/ui/Composer.d.ts +13 -5
  62. package/dist/ui/Composer.js +7 -11
  63. package/dist/ui/PromptCaret.d.ts +11 -0
  64. package/dist/ui/PromptCaret.js +31 -0
  65. package/dist/ui/PromptInput.d.ts +12 -6
  66. package/dist/ui/PromptInput.js +50 -16
  67. package/dist/ui/StatusBar.d.ts +22 -15
  68. package/dist/ui/StatusBar.js +61 -16
  69. package/dist/ui/TeamPanel.d.ts +3 -1
  70. package/dist/ui/TeamPanel.js +13 -9
  71. package/dist/ui/TodoPanel.d.ts +5 -5
  72. package/dist/ui/TodoPanel.js +56 -24
  73. package/dist/ui/Transcript.js +1 -0
  74. package/dist/ui/UpdateLine.d.ts +1 -1
  75. package/dist/ui/capabilities.js +5 -5
  76. package/dist/ui/composer-rows.js +50 -9
  77. package/dist/ui/exit-snapshot.d.ts +9 -0
  78. package/dist/ui/frame-differ.js +2 -19
  79. package/dist/ui/frame-observer.d.ts +17 -0
  80. package/dist/ui/frame-observer.js +81 -0
  81. package/dist/ui/frame-parser.d.ts +7 -0
  82. package/dist/ui/frame-parser.js +18 -0
  83. package/dist/ui/layout/AppShell.d.ts +7 -52
  84. package/dist/ui/layout/AppShell.js +2 -2
  85. package/dist/ui/layout/ScrollIndicator.d.ts +4 -3
  86. package/dist/ui/layout/ScrollIndicator.js +4 -4
  87. package/dist/ui/layout/ScrollViewport.d.ts +13 -66
  88. package/dist/ui/layout/ScrollViewport.js +183 -185
  89. package/dist/ui/layout/autocomplete.d.ts +18 -0
  90. package/dist/ui/layout/autocomplete.js +26 -0
  91. package/dist/ui/layout/budget.d.ts +3 -51
  92. package/dist/ui/layout/budget.js +4 -53
  93. package/dist/ui/layout/document-layout.d.ts +13 -0
  94. package/dist/ui/layout/document-layout.js +11 -0
  95. package/dist/ui/layout/follow-state.d.ts +1 -0
  96. package/dist/ui/layout/follow-state.js +1 -3
  97. package/dist/ui/layout/rail.d.ts +4 -53
  98. package/dist/ui/layout/rail.js +10 -62
  99. package/dist/ui/layout/scroll-indicator.d.ts +1 -1
  100. package/dist/ui/layout/scroll-indicator.js +3 -2
  101. package/dist/ui/layout/scrollbar-geometry.d.ts +17 -0
  102. package/dist/ui/layout/scrollbar-geometry.js +13 -0
  103. package/dist/ui/layout/team-panel.d.ts +18 -0
  104. package/dist/ui/layout/team-panel.js +22 -0
  105. package/dist/ui/layout/todo-layout.d.ts +25 -0
  106. package/dist/ui/layout/todo-layout.js +28 -0
  107. package/dist/ui/layout/viewport-geometry.d.ts +1 -0
  108. package/dist/ui/layout/viewport-geometry.js +1 -0
  109. package/dist/ui/layout/virtual-window.d.ts +1 -0
  110. package/dist/ui/layout/virtual-window.js +1 -1
  111. package/dist/ui/overlays/HelpOverlay.js +5 -2
  112. package/dist/ui/screen.d.ts +1 -1
  113. package/dist/ui/screen.js +4 -6
  114. package/dist/ui/scrollbar-controller.d.ts +30 -0
  115. package/dist/ui/scrollbar-controller.js +124 -0
  116. package/dist/ui/transcript-text.js +6 -2
  117. package/dist/ui/use-wheel-routing.d.ts +2 -0
  118. package/dist/ui/use-wheel-routing.js +6 -0
  119. package/package.json +2 -2
@@ -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
  *
@@ -723,7 +724,9 @@ const COMMANDS = [
723
724
  // slash history already has. Putting this canned paragraph under the
724
725
  // composer's up-arrow as well would be the same event recorded twice, in
725
726
  // the more verbose of the two forms (AC-17).
726
- ctx.submit(buildContinuationMessage(snapshot), { userInitiated: false });
727
+ ctx.submit(buildContinuationMessage(snapshot), {
728
+ userInitiated: false, todoPolicy: 'continue',
729
+ });
727
730
  return;
728
731
  }
729
732
  ctx.notify('warn', `Unknown argument "${verb}" - use ` +
@@ -809,6 +812,14 @@ const COMMANDS = [
809
812
  description: 'Context compaction: status | on | off | threshold <n> | keep <n> | <instructions>',
810
813
  run: (ctx) => runCompactCommand(ctx),
811
814
  },
815
+ {
816
+ // REGISTRATION ONLY, for the reason `/compact` records one entry up: this
817
+ // file is past the 1000-line guideline and the report has five sections.
818
+ // The body is in `compaction/context-command.ts`.
819
+ name: 'context',
820
+ description: 'Show context occupancy, the window it is measured against, and session spend',
821
+ run: (ctx) => runContextCommand(ctx),
822
+ },
812
823
  {
813
824
  name: 'expand',
814
825
  // DELIBERATELY LEFT TOOL-ONLY, and the asymmetry with Ctrl+O is a decision
@@ -2,6 +2,7 @@
2
2
  * Slash-command registry + dispatcher (spec §5.2). A leading `/` in the prompt
3
3
  * input is routed here instead of to the agent; `//` is the literal-`/` escape.
4
4
  */
5
+ import type { SubmitMessageOptions } from '../agent/prompt-options.js';
5
6
  import type { AgentController } from '../agent/controller.js';
6
7
  import type { AgentMode } from '../agent/agent-mode.js';
7
8
  import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
@@ -38,9 +39,7 @@ export interface CommandContext {
38
39
  * (P1-5): `builtins.ts` reaches `submitMessage` only through here, so
39
40
  * `/todo continue` cannot opt out of prompt history without it.
40
41
  */
41
- submit: (text: string, opts?: {
42
- userInitiated?: boolean;
43
- }) => void;
42
+ submit: (text: string, opts?: SubmitMessageOptions) => void;
44
43
  /**
45
44
  * The live auto-continue budget, for `/todo status`'s `(2/25 used)` clause
46
45
  * (todo-plan-followthrough §4.3).
@@ -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;
@@ -17,7 +17,8 @@ import { attemptTailRelief, reliefSummary, tailBudgetTokens } from './tail-budge
17
17
  import { buildDigest, isPriorSummaryBlock } from './digest.js';
18
18
  import { buildAnchor, buildCompactedBlock, countProtectedPrefix, readGeneration, truncationBody, } from './summary-prompt.js';
19
19
  import { accumulateUsage, errorText, runSummarizeCall } from './summarize-call.js';
20
- import { computeEstimateOffset, computePressure, shouldCompactAt } from './pressure.js';
20
+ import { computePressure, shouldCompactAt } from './pressure.js';
21
+ import { ContextMeter } from './meter.js';
21
22
  export class Compactor {
22
23
  deps;
23
24
  log = getLogger().child('compaction');
@@ -44,18 +45,15 @@ export class Compactor {
44
45
  selfDisabledReason;
45
46
  pendingManual = null;
46
47
  generation = 0;
47
- /** Calibration for every ESTIMATED figure (D-23). Recomputed on every turn_end. */
48
- estimateOffset;
49
- /** The last pressure computed, so the status bar and `/compact status` agree. */
50
- lastPressure = null;
51
48
  /**
52
- * The history LENGTH the measurement in `lastUsage` covered (hardening W1).
49
+ * The fallback meter, built on first use when no host injected one (R-1).
53
50
  *
54
- * `undefined` DISABLES THE DELTA and reproduces round 1's arithmetic exactly.
55
- * Every host-side mutation that is not an append must clear it - see
56
- * `invalidateMeasurement`.
51
+ * `estimateOffset`, `measuredPrefixLength` and `lastPressure` used to be
52
+ * FIELDS HERE. They moved to `ContextMeter` wholesale
53
+ * (context-usage-gauge-accuracy W1), because keeping them here is what put the
54
+ * only measurement in the process behind `compaction.enabled`.
57
55
  */
58
- measuredPrefixLength;
56
+ ownMeter = null;
59
57
  /**
60
58
  * The messages the last splice dropped, held ONLY until the wiring collects
61
59
  * them for the archive (W4 / RV-3).
@@ -87,28 +85,34 @@ export class Compactor {
87
85
  this.lastCompactionTurn = -Infinity;
88
86
  }
89
87
  /**
90
- * `turn_end` — recompute the calibration offset (D-23).
88
+ * `turn_end` - recompute the calibration offset (D-23).
91
89
  *
92
- * RE-MEASURED EVERY TURN, not once, so it tracks a toolset that changed
93
- * mid-session (`/tools`, a skill that registers one).
90
+ * A THIN FORWARDER since context-usage-gauge-accuracy W1. The meter subscribes
91
+ * to `turn_end` itself, so BOTH PATHS MUST BE IDEMPOTENT: this one recomputes
92
+ * exactly what the other one did from the same inputs, which it is.
93
+ *
94
+ * `messages` / `systemPrompt` stay in the signature and are ignored: the meter
95
+ * reads them from its own lazy deps, and every existing caller and test keeps
96
+ * compiling.
94
97
  */
95
- onTurnEnd(usage, messages, systemPrompt) {
96
- this.estimateOffset = computeEstimateOffset(usage, messages, systemPrompt);
97
- // THE SAME ARRAY `computeEstimateOffset` JUST MEASURED, so the two numbers
98
- // describe the same request by construction (hardening §3.2.3).
99
- this.measuredPrefixLength = messages.length;
98
+ onTurnEnd(usage, _messages, _systemPrompt) {
99
+ this.meter().onTurnEnd(usage);
100
100
  }
101
- /**
102
- * Any host-side history mutation that is NOT an append (hardening §3.2.3).
101
+ /*
102
+ * `invalidateMeasurement()` IS GONE, DELIBERATELY (I-9 / RV-2 round two).
103
+ *
104
+ * It had exactly two production callers and their reset depths are now
105
+ * OPPOSITE: `CompactionWiring.onHistoryReplaced` (a `/clear`, `/reset` or
106
+ * `/resume`) must drop `estimateOffset`, while `settlePending`'s applied
107
+ * branch (a splice) must KEEP it - the toolset and the model are unchanged, so
108
+ * the calibration still holds and dropping it makes the gauge fall too far and
109
+ * climb back (I-5 / I-8). The two used to share a method only because the one
110
+ * field it cleared happened to be their intersection; that intersection no
111
+ * longer exists. Both call sites now name the depth they mean, on the meter.
103
112
  *
104
- * `/clear`, `/resume`, and the splice itself: after any of them a recorded
105
- * index means nothing, and the delta would be measured against the wrong
106
- * array. `estimateAppendedTokens` bounds-checks as well, so a MISSED call here
107
- * costs accuracy - the number degrades to round 1's - and never correctness.
113
+ * Reinstating a forwarder here would hide "which caller gets which depth"
114
+ * behind a name that mentions neither, and BOTH wrong choices are silent.
108
115
  */
109
- invalidateMeasurement() {
110
- this.measuredPrefixLength = undefined;
111
- }
112
116
  /**
113
117
  * The dropped messages, released as they are returned (W4 / RV-3).
114
118
  *
@@ -172,17 +176,13 @@ export class Compactor {
172
176
  const config = this.deps.getConfig().compaction;
173
177
  if (!config.enabled)
174
178
  return false;
175
- // THE HISTORY IS PASSED IN, NEVER OMITTED (§3.4.2). Without it the estimate
176
- // branch of `computePressure` measures an EMPTY array and reports 0 %, so a
177
- // resumed 180 k-token session — where `lastUsage` is `undefined` by
178
- // definition and this branch is the only one available — would never trigger.
179
- // The measured branch reads `messages` only to estimate what was appended
180
- // after the measurement (hardening §3.2.2); the slice is one turn wide.
181
- const pressure = this.measure({
182
- ...(probe.lastUsage ? { lastUsage: probe.lastUsage } : {}),
183
- messages: this.deps.getMessages(),
184
- systemPrompt: this.deps.getSystemPrompt(),
185
- });
179
+ // THE METER READS THE LIVE HISTORY ITSELF (W1), so the estimate branch can no
180
+ // longer be handed an empty array by a caller that forgot - which used to be
181
+ // a 0 % reading on precisely the session that most needs a trigger, the
182
+ // resumed 180 k-token one where `lastUsage` is `undefined` by definition.
183
+ // The probe's own usage is still passed, because it is the authority for
184
+ // THIS checkpoint.
185
+ const pressure = this.meter().measureWith(probe.lastUsage);
186
186
  this.deps.onPressure?.(pressure);
187
187
  const manual = this.pendingManual !== null;
188
188
  // Guard 2 — the per-run cap. BOTH TRIGGERS, HARD. It is the bound that makes
@@ -568,12 +568,17 @@ export class Compactor {
568
568
  checkProgress(ctx, uiTrigger, after) {
569
569
  const config = this.deps.getConfig().compaction;
570
570
  const info = this.modelInfo();
571
+ // THE OFFSET NOW LIVES ON THE METER (W1), and it is read from the last
572
+ // PUBLISHED pressure rather than re-derived: `Pressure.estimateOffset` is
573
+ // carried on every measurement, so this is the same number the field used to
574
+ // hold, without the compactor owning a second copy of it.
575
+ const estimateOffset = this.meter().lastPublished()?.estimateOffset;
571
576
  const projected = computePressure({
572
577
  messages: after,
573
578
  systemPrompt: ctx.systemPrompt,
574
579
  contextWindow: info.contextWindow,
575
580
  windowKnown: true,
576
- ...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
581
+ ...(estimateOffset !== undefined ? { estimateOffset } : {}),
577
582
  });
578
583
  const target = config.threshold - COMPACTION_LIMITS.minReclaimRatio;
579
584
  const madeProgress = projected.ratio < target;
@@ -619,7 +624,9 @@ export class Compactor {
619
624
  finish(args, outcome, applied, keepRecentTurnsUsed, announced) {
620
625
  const before = args.ctx.messages.length;
621
626
  const after = outcome.action === 'replace' ? outcome.messages.length : before;
622
- const tokensBefore = this.lastPressure?.occupied ?? 0;
627
+ // THE OCCUPANCY BEFORE THE SPLICE (I-10). `lastMeasured()` is the pure cache
628
+ // read on purpose; anything that re-measures here reports 0 reclaimed.
629
+ const tokensBefore = this.lastMeasured()?.occupied ?? 0;
623
630
  // `'relieved'` IS A CLI-ONLY WORD (RV-5 / DH-20). The port reports
624
631
  // `'truncated'` for a relief-only compaction, which is accurate for the
625
632
  // engine's narrower question; the card's `summarized N messages with
@@ -690,32 +697,30 @@ export class Compactor {
690
697
  // Reporting helpers
691
698
  // =========================================================================
692
699
  /**
693
- * The pressure the trigger and the gauge both read.
700
+ * The pressure the trigger and the gauge both read - MEASURED NOW.
694
701
  *
695
- * CACHED on every evaluation, so `/compact status` reports the same number the
696
- * last decision used rather than recomputing one that has since moved.
702
+ * A FORWARDER since W1: the decision point supplies its own authoritative
703
+ * `lastUsage` (the probe's), and the meter owns everything else. `messages` /
704
+ * `systemPrompt` stay in the signature and are ignored, because the meter
705
+ * reads the LIVE history through its own deps - which is strictly better than
706
+ * a caller that could pass `[]` and get a 0 % reading (the old default here).
697
707
  */
698
708
  measure(probe) {
699
- const info = this.modelInfo();
700
- const pressure = computePressure({
701
- ...(probe.lastUsage ? { lastUsage: probe.lastUsage } : {}),
702
- messages: probe.messages ?? [],
703
- systemPrompt: probe.systemPrompt ?? '',
704
- contextWindow: info.contextWindow,
705
- windowKnown: this.deps.isPricedModel({
706
- providerId: this.mainRef().providerId,
707
- modelId: this.mainRef().modelId,
708
- }),
709
- ...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
710
- ...(this.measuredPrefixLength !== undefined
711
- ? { measuredPrefixLength: this.measuredPrefixLength }
712
- : {}),
713
- });
714
- this.lastPressure = pressure;
715
- return pressure;
709
+ return this.meter().measureWith(probe.lastUsage);
716
710
  }
711
+ /**
712
+ * The last pressure PUBLISHED. NEVER re-measured - `lastPublished`, not
713
+ * `current` (I-10 / RV-3).
714
+ *
715
+ * `finish()` below reads this for `tokensBefore`, which is the sole upstream of
716
+ * `tokensReclaimed`, the card's "reclaimed N tokens" and `/compact status`'s
717
+ * session total. All three want the occupancy BEFORE the splice. Point this at
718
+ * `meter.current()` and the moment the meter is dirty - which after a splice it
719
+ * always is - `tokensBefore` becomes the POST-compaction figure, `reclaimed`
720
+ * collapses to 0, and the card renders that with nothing logging a fault.
721
+ */
717
722
  lastMeasured() {
718
- return this.lastPressure;
723
+ return this.meter().lastPublished();
719
724
  }
720
725
  /** Written by the wiring once the engine has reported its verdict. */
721
726
  recordApplied(tokensBefore, tokensAfter) {
@@ -768,6 +773,29 @@ export class Compactor {
768
773
  return null;
769
774
  return { ref, fromFastTier: false };
770
775
  }
776
+ /**
777
+ * The meter this compactor reads, injected or private (R-1).
778
+ *
779
+ * MEMOIZED, because a fresh meter on every call would have no `estimateOffset`
780
+ * and no cached pressure - `lastMeasured()` would answer `null` forever and
781
+ * `tokensReclaimed` would sit at 0. The fallback exists so the ten existing
782
+ * `compaction-*.test.ts` construction sites compile unchanged; production
783
+ * injects, and `context-gauge-wiring.test.ts` asserts the single instance.
784
+ */
785
+ meter() {
786
+ if (this.deps.meter)
787
+ return this.deps.meter;
788
+ if (!this.ownMeter) {
789
+ this.ownMeter = new ContextMeter({
790
+ getMessages: () => this.deps.getMessages(),
791
+ getSystemPrompt: () => this.deps.getSystemPrompt(),
792
+ getModelInfo: () => this.modelInfo(),
793
+ isWindowKnown: () => this.deps.isPricedModel(this.mainRef()),
794
+ getWindowOverride: () => this.deps.getConfig().contextWindow,
795
+ });
796
+ }
797
+ return this.ownMeter;
798
+ }
771
799
  mainRef() {
772
800
  const config = this.deps.getConfig();
773
801
  return {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The `/context` slash command (context-usage-gauge-accuracy §3.7 / W6).
3
+ *
4
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope, and this
5
+ * text lands in a transcript `notice` - produced outside `src/ui/**`, where no
6
+ * terminal capabilities are available, so it must be safe on a legacy `cmd.exe`.
7
+ *
8
+ * IT LIVES HERE RATHER THAN IN `builtins.ts` FOR THE REASON `/compact` DOES
9
+ * (C-16 / P1-12): `CLAUDE.md` caps a source file at 1000 lines and `builtins.ts`
10
+ * is already past it. `builtins.ts` gets the registration and nothing else.
11
+ *
12
+ * THIS IS THE GUARANTEED REPORTING SURFACE FOR OCCUPANCY. The status bar drops
13
+ * readouts on a narrow terminal, the gauge is a bar and a rounded percentage,
14
+ * and neither can say WHERE the denominator came from or WHAT the session total
15
+ * includes. Every question a user asks after distrusting the bar is answered
16
+ * here, at any width, in any mode - which is also why the honesty rules below
17
+ * are not decoration.
18
+ */
19
+ import type { CommandContext } from '../commands/registry.js';
20
+ /**
21
+ * `/context` - occupancy, denominator, freshness, compaction, session spend.
22
+ *
23
+ * THE OCCUPANCY COMES FROM `controller.getContextUsage()` AND NOWHERE ELSE
24
+ * (RV-8). The obvious alternative, `getCompactionSnapshot().pressure`, returns
25
+ * `offCompactionSnapshot()`'s HARDCODED ZERO pressure whenever compaction was
26
+ * never registered - so a report built on it would read `0%` for exactly the
27
+ * sessions this whole feature exists to fix. `/compact status` gets away with
28
+ * reading it only because it returns early on that branch and never prints an
29
+ * occupancy line at all.
30
+ */
31
+ export declare function formatContextReport(ctx: CommandContext): string;
32
+ /** `/context` takes no arguments; anything after it is ignored. */
33
+ export declare function runContextCommand(ctx: CommandContext): void;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The `/context` slash command (context-usage-gauge-accuracy §3.7 / W6).
3
+ *
4
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope, and this
5
+ * text lands in a transcript `notice` - produced outside `src/ui/**`, where no
6
+ * terminal capabilities are available, so it must be safe on a legacy `cmd.exe`.
7
+ *
8
+ * IT LIVES HERE RATHER THAN IN `builtins.ts` FOR THE REASON `/compact` DOES
9
+ * (C-16 / P1-12): `CLAUDE.md` caps a source file at 1000 lines and `builtins.ts`
10
+ * is already past it. `builtins.ts` gets the registration and nothing else.
11
+ *
12
+ * THIS IS THE GUARANTEED REPORTING SURFACE FOR OCCUPANCY. The status bar drops
13
+ * readouts on a narrow terminal, the gauge is a bar and a rounded percentage,
14
+ * and neither can say WHERE the denominator came from or WHAT the session total
15
+ * includes. Every question a user asks after distrusting the bar is answered
16
+ * here, at any width, in any mode - which is also why the honesty rules below
17
+ * are not decoration.
18
+ */
19
+ import { formatCost, formatTokens, promptTokensOf } from '../agent/usage.js';
20
+ /**
21
+ * `/context` - occupancy, denominator, freshness, compaction, session spend.
22
+ *
23
+ * THE OCCUPANCY COMES FROM `controller.getContextUsage()` AND NOWHERE ELSE
24
+ * (RV-8). The obvious alternative, `getCompactionSnapshot().pressure`, returns
25
+ * `offCompactionSnapshot()`'s HARDCODED ZERO pressure whenever compaction was
26
+ * never registered - so a report built on it would read `0%` for exactly the
27
+ * sessions this whole feature exists to fix. `/compact status` gets away with
28
+ * reading it only because it returns early on that branch and never prints an
29
+ * occupancy line at all.
30
+ */
31
+ export function formatContextReport(ctx) {
32
+ const controller = ctx.controller;
33
+ const usage = controller.getContextUsage();
34
+ const lines = ['Context'];
35
+ lines.push(` Occupancy ${occupancyLine(usage)}`);
36
+ lines.push(` Window ${windowLine(usage)}`);
37
+ lines.push(` Since measured ${freshnessLine(usage)}`);
38
+ lines.push(` Compaction ${compactionLine(ctx)}`);
39
+ lines.push(` Session spend ${spendLine(ctx)}`);
40
+ // THE SENTENCE THAT MAKES THE LINE ABOVE HONEST (D-7 / N-3). The session
41
+ // total folds in subagents, the fast tier and compaction's own summarization
42
+ // calls - three other models' tokens in a figure sitting next to the lead's
43
+ // occupancy. Splitting it into four counters would need four units of
44
+ // provenance argument for one line of output; SAYING what it contains costs
45
+ // one line and answers the same question.
46
+ lines.push(' includes subagent, fast-tier and compaction spend,');
47
+ lines.push(' not just this conversation');
48
+ return lines.join('\n');
49
+ }
50
+ /**
51
+ * `43% 86.2k of 200k [measured 85.1k + 1.1k estimated]`
52
+ *
53
+ * THE BREAKDOWN IS THE POINT. "43%" alone cannot be checked by a user who thinks
54
+ * it is wrong; naming how much of it is a provider measurement and how much is
55
+ * our own guess about messages appended since tells them whether to distrust the
56
+ * number or the model.
57
+ */
58
+ function occupancyLine(usage) {
59
+ const approximate = usage.source === 'estimate' || usage.deltaTokens > 0;
60
+ const pct = `${approximate || !usage.windowKnown ? '~' : ''}${usage.pct}%`;
61
+ const measured = Math.max(0, usage.occupied - usage.deltaTokens);
62
+ const breakdown = usage.source === 'estimate'
63
+ ? `[estimated ${formatTokens(usage.occupied)}]`
64
+ : usage.deltaTokens > 0
65
+ ? `[measured ${formatTokens(measured)} + ${formatTokens(usage.deltaTokens)} estimated]`
66
+ : `[measured ${formatTokens(measured)}]`;
67
+ return `${pct} ${formatTokens(usage.occupied)} of ${formatTokens(usage.window)} ${breakdown}`;
68
+ }
69
+ /**
70
+ * Where the denominator came from. THREE SOURCES, ALL NAMED.
71
+ *
72
+ * THE PLACEHOLDER BRANCH IS THE ONE THAT MATTERS. `buildRuntimeModel` returns a
73
+ * flat 128000 for any model the static table has never seen - a custom
74
+ * `baseUrl`, a self-hosted id, a model released last week - and before this
75
+ * command there was nothing that would admit the number was invented. A user
76
+ * reading `128000` next to their 1M-token model concludes the gauge is broken;
77
+ * they are right, and this line tells them which key fixes it.
78
+ */
79
+ function windowLine(usage) {
80
+ if (usage.windowOverridden) {
81
+ return `${usage.window} from contextWindow in config.json (overrides the model table)`;
82
+ }
83
+ if (!usage.windowKnown) {
84
+ return (`${usage.window} PLACEHOLDER - this model is not in the table; ` +
85
+ 'set contextWindow to correct it');
86
+ }
87
+ return `${usage.window} from the model table`;
88
+ }
89
+ /**
90
+ * How stale the measured half is.
91
+ *
92
+ * `deltaTokens` IS A TOKEN COUNT, NOT A MESSAGE COUNT, and this line says so in
93
+ * tokens for that reason: the meter estimates the appended SLICE, and turning
94
+ * that back into "4 messages" would be a second derivation of something nobody
95
+ * recorded.
96
+ */
97
+ function freshnessLine(usage) {
98
+ if (usage.source === 'estimate') {
99
+ return 'no provider-reported usage yet - the whole figure is an estimate';
100
+ }
101
+ if (usage.deltaTokens <= 0) {
102
+ return 'nothing appended since the last provider-reported usage';
103
+ }
104
+ return `${formatTokens(usage.deltaTokens)} estimated tokens appended since the last provider-reported usage`;
105
+ }
106
+ /**
107
+ * The compaction state, IN FIVE FORMS (RV-8).
108
+ *
109
+ * A SINGLE FORM WOULD BE A LIE FOUR TIMES OUT OF FIVE. The obvious version of
110
+ * this line - "on - triggers at 90% (amber at 75%)" - is printed unconditionally
111
+ * only if you forget that compaction has four other observable states, and the
112
+ * first of them is `aragon --no-compaction`, where that sentence promises a
113
+ * rescue that cannot come. A report that exists to be trusted does not get to
114
+ * have one branch.
115
+ *
116
+ * THE PREDICATES ARE THE ONES `formatCompactionStatus` ALREADY USES
117
+ * (`compaction/command.ts`), not a second set: two ladders over the same three
118
+ * booleans will eventually disagree, and the disagreement would be between two
119
+ * commands the user runs precisely because they already distrust something.
120
+ */
121
+ function compactionLine(ctx) {
122
+ const controller = ctx.controller;
123
+ if (!controller.isCompactionRegistered()) {
124
+ return 'off - not registered for this session (started with --no-compaction)';
125
+ }
126
+ const snapshot = controller.getCompactionSnapshot();
127
+ if (snapshot.selfDisabled) {
128
+ const reason = snapshot.selfDisabledReason ?? 'no progress';
129
+ return `self-disabled (${reason}) - no compaction will run until /compact on`;
130
+ }
131
+ if (!controller.isCompactionEnabled()) {
132
+ return 'off for this session - /compact on re-enables it';
133
+ }
134
+ const config = controller.getCompactionConfig();
135
+ const thresholds = `triggers at ${Math.round(config.threshold * 100)}% ` +
136
+ `(amber at ${Math.round(config.warnThreshold * 100)}%)`;
137
+ if (!snapshot.live) {
138
+ return `on, but no summarizer model resolves - ${thresholds}`;
139
+ }
140
+ const count = snapshot.compactions;
141
+ return (`on - ${thresholds}, ${count} this session, ` +
142
+ `${formatTokens(snapshot.tokensReclaimed)} reclaimed`);
143
+ }
144
+ /**
145
+ * `1.24M in (incl. 940k cache read, 12k cache write) / 48.2k out, $3.21`
146
+ *
147
+ * THE CACHE PARENTHETICAL IS OMITTED WHEN BOTH TERMS ARE ZERO, which is every
148
+ * direct connection - this CLI never sends `cache_control`, so the terms are
149
+ * non-zero only behind a gateway that caches on the user's behalf. Those are
150
+ * exactly the users for whom the pre-feature `^` under-reported (P1-3), and the
151
+ * clause is how they can see that it no longer does.
152
+ */
153
+ function spendLine(ctx) {
154
+ const total = ctx.state.usageTotal;
155
+ const cached = total.cacheReadTokens + total.cacheWriteTokens;
156
+ const parenthetical = cached > 0
157
+ ? ` (incl. ${formatTokens(total.cacheReadTokens)} cache read, ` +
158
+ `${formatTokens(total.cacheWriteTokens)} cache write)`
159
+ : '';
160
+ return (`${formatTokens(promptTokensOf(total))} in${parenthetical} / ` +
161
+ `${formatTokens(total.outputTokens)} out, ${formatCost(total.costUsd)}`);
162
+ }
163
+ /** `/context` takes no arguments; anything after it is ignored. */
164
+ export function runContextCommand(ctx) {
165
+ ctx.notify('info', formatContextReport(ctx));
166
+ }