@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
@@ -0,0 +1,251 @@
1
+ /**
2
+ * `ContextMeter` - the ONE owner of "how full is the context right now"
3
+ * (context-usage-gauge-accuracy §3.2 / W1).
4
+ *
5
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope
6
+ * (`glyphs.test.ts::inScope`). If this module is ever moved to a new tree, the
7
+ * scanner's hardcoded directory list must be widened IN THE SAME COMMIT - a
8
+ * scanner that silently stops scanning is worse than no scanner, and this
9
+ * package has paid for that edit eleven times (I-7).
10
+ *
11
+ * IT LIVES HERE BUT IT IS NOT PART OF COMPACTION, and that is deliberate. The
12
+ * whole point of this class is that occupancy is measurable when compaction is
13
+ * OFF: `AgentController` builds a `CompactionWiring` only when
14
+ * `compaction.enabled` is true, so a measurement owned by the compactor is
15
+ * unreachable for exactly the sessions that most need a working gauge. It sits
16
+ * in this directory because `pressure.ts` - the arithmetic it drives - already
17
+ * does, and because moving it would cost a scanner edit for no gain (R-10).
18
+ *
19
+ * THE STATE MACHINE IN ONE PARAGRAPH. Every mutation of the history marks the
20
+ * meter DIRTY; every read that finds it dirty re-measures SYNCHRONOUSLY. That
21
+ * ordering is what removes the subscription-order dependency in the APPEND
22
+ * direction: it does not matter whether this class or the compaction wiring
23
+ * hears `tool_execution_end` first, because whoever reads gets a number that
24
+ * includes the appended message either way (I-2). It does NOT remove the
25
+ * dependency in the REPLACE direction - a splice does not announce itself to a
26
+ * listener that has not run yet - which is why the splice marks this dirty from
27
+ * `CompactionWiring.settlePending`'s own synchronous code (I-9), and why
28
+ * `current()` carries a structural belt for a missed invalidation site.
29
+ */
30
+ import type { AgentEvent, Message, ModelInfo, TokenUsage } from '@aragon-agent/core';
31
+ import type { ContextUsageSnapshot, Pressure } from './types.js';
32
+ /**
33
+ * How long a scheduled publish waits.
34
+ *
35
+ * IT MUST BE A DELAY, NOT ZERO, AND NOT A SYNCHRONOUS MEASUREMENT (I-3). The
36
+ * loop emits `tool_execution_end` BEFORE it pushes the `tool_result` message, so
37
+ * anything that measures inside that event measures a history missing the single
38
+ * largest message of the turn - silently, and every turn. A `setTimeout` is
39
+ * necessarily later than that synchronous push.
40
+ *
41
+ * 400 ms also caps the publish rate at 2.5 Hz, which together with the reducer's
42
+ * identity short-circuit keeps a permanently visible row from becoming a
43
+ * re-render source (R-3).
44
+ */
45
+ export declare const CONTEXT_METER_TICK_MS = 400;
46
+ export interface ContextMeterDeps {
47
+ /**
48
+ * The engine's live history and system prompt.
49
+ *
50
+ * LAZY, and they must be: the controller constructs this before the `Agent`
51
+ * exists, so a closure that touches `this.agent` is only valid from inside a
52
+ * call made later.
53
+ */
54
+ getMessages(): readonly Message[];
55
+ getSystemPrompt(): string;
56
+ /** `ModelInfo` for the MAIN model. Re-read on every measurement - `/model` exists. */
57
+ getModelInfo(): ModelInfo;
58
+ /** Whether the STATIC model table knows this model, i.e. the window is real. */
59
+ isWindowKnown(): boolean;
60
+ /**
61
+ * The user's `contextWindow` override, or `null` for AUTO. Re-read on every
62
+ * measurement, so a live settings edit moves the denominator without a relaunch.
63
+ */
64
+ getWindowOverride(): number | null;
65
+ }
66
+ export type ContextUsageListener = (usage: ContextUsageSnapshot) => void;
67
+ /**
68
+ * The projection `ViewState` and `/context` read (§4.1).
69
+ *
70
+ * THE ONLY CONVERTER between `Pressure` and `ContextUsageSnapshot`. Two of them
71
+ * would eventually round the percentage differently, and "the status bar says 43
72
+ * and `/context` says 44" is the exact class of disagreement this whole feature
73
+ * exists to remove.
74
+ */
75
+ export declare function toContextUsage(p: Pressure): ContextUsageSnapshot;
76
+ /** The reading a meter with nothing measured yet reports. */
77
+ export declare function emptyContextUsage(): ContextUsageSnapshot;
78
+ export declare class ContextMeter {
79
+ private readonly deps;
80
+ private readonly listeners;
81
+ private unsubscribe;
82
+ private timer;
83
+ private disposed;
84
+ /**
85
+ * The authoritative usage of the last completed turn.
86
+ *
87
+ * Its presence is what selects `computePressure`'s MEASURED branch, so
88
+ * dropping it is how every reset degrades to an estimate rather than to a
89
+ * number describing a history that no longer exists.
90
+ */
91
+ private lastUsage;
92
+ /**
93
+ * The systematic difference between what `estimatePromptTokens` counts and
94
+ * what the provider bills (D-23) - chiefly the tool schemas.
95
+ *
96
+ * SURVIVES A SPLICE AND NOT A SESSION CHANGE (I-8). See `onHistorySpliced`.
97
+ */
98
+ private estimateOffset;
99
+ /** `messages.length` at the `turn_end` that produced `lastUsage`. */
100
+ private measuredPrefixLength;
101
+ /** The last pressure PUBLISHED. Never re-derived on read - see `lastPublished`. */
102
+ private last;
103
+ /** Whether the history has changed since the last measurement. */
104
+ private dirty;
105
+ constructor(deps: ContextMeterDeps);
106
+ /**
107
+ * Occupancy right now. FOR THE SCREEN.
108
+ *
109
+ * Re-measures synchronously when dirty (I-2), and publishes when it does - the
110
+ * gauge has to fall in the SAME FRAME as the compaction that caused it (AC-1),
111
+ * and the caller that triggers this re-measurement is `CompactionWiring`'s own
112
+ * synchronous `snapshot()` inside `settlePending`. Leaving the publish to the
113
+ * scheduled tick instead would be worse than slow: the tick fires only when
114
+ * `dirty`, and this call is what clears it.
115
+ *
116
+ * NEVER THROWS. A meter that can throw is a status bar that can take the
117
+ * process down.
118
+ */
119
+ current(): Pressure;
120
+ /**
121
+ * The last pressure this meter PUBLISHED. FOR THE BOOKS. Never re-measures.
122
+ *
123
+ * `Compactor.lastMeasured()` forwards here and not to `current()`, and the
124
+ * distinction is load-bearing (I-10): `compactor.ts`'s `tokensBefore` is the
125
+ * sole upstream of `tokensReclaimed`, the card's "reclaimed N tokens" line and
126
+ * `/compact status`'s session total. It wants the occupancy BEFORE the splice.
127
+ * Wired to `current()` it would re-measure the already-spliced history, report
128
+ * `reclaimed 0`, and render that number without a single log line about it.
129
+ */
130
+ lastPublished(): Pressure | null;
131
+ /** The published projection, or a zeroed one before the first measurement. */
132
+ currentUsage(): ContextUsageSnapshot;
133
+ /**
134
+ * Measure with the caller's own authoritative usage. FOR A DECISION POINT.
135
+ *
136
+ * `Compactor.shouldCompact` has a `CompactionProbe.lastUsage` the meter may
137
+ * not have seen yet, so it supplies it rather than trusting the field here.
138
+ */
139
+ measureWith(lastUsage: TokenUsage | undefined): Pressure;
140
+ /**
141
+ * `turn_end` - the one moment in the whole flow that is FULLY measured.
142
+ *
143
+ * The loop emits it BEFORE pushing the assistant message, so the history right
144
+ * now is exactly the history the reported `usage` was billed for: the offset
145
+ * and the prefix length describe the same array by construction, and
146
+ * `deltaTokens` is 0. Published immediately for that reason - there is no
147
+ * better number to wait for.
148
+ */
149
+ onTurnEnd(usage: TokenUsage): void;
150
+ /**
151
+ * A compaction spliced the history (I-8, the SHALLOW reset).
152
+ *
153
+ * `estimateOffset` IS KEPT, and that is the whole reason this method is not
154
+ * `onHistoryReplaced`. The offset describes "what this toolset costs on this
155
+ * model above what the estimator counts"; a splice changes neither the toolset
156
+ * nor the model, so it is still valid - and it is the only calibration the
157
+ * estimate branch has until the next `turn_end`. Dropping it makes the gauge
158
+ * fall several thousand tokens too far and then visibly climb back on the next
159
+ * turn, for no reason the user can see (I-5).
160
+ *
161
+ * IDEMPOTENT. Both `settlePending` (the authority, I-9) and this meter's own
162
+ * `compaction_end` handler reach it, and the second one must cost nothing.
163
+ */
164
+ onHistorySpliced(): void;
165
+ /**
166
+ * The history came from somewhere else entirely (I-8, the DEEP reset):
167
+ * `/clear`, `/reset`, `/resume`.
168
+ *
169
+ * `estimateOffset` GOES TOO, unlike the shallow reset above. `/resume` can
170
+ * change the model in the same breath (`builtins.ts`'s restore path does), and
171
+ * an offset carried across that is a calibration constant applied to something
172
+ * it never calibrated. The first reading afterwards is therefore a bare
173
+ * estimate - biased LOW, and corrected by the very next `turn_end`. Biased low
174
+ * and self-correcting beats biased high and permanent.
175
+ */
176
+ onHistoryReplaced(): void;
177
+ /**
178
+ * Only the DENOMINATOR moved (`/model`, a `contextWindow` edit).
179
+ *
180
+ * DELIBERATELY NOT A RESET. The measured base is still a true statement about
181
+ * the history, so throwing it away would trade a correct numerator for a whole
182
+ * history estimate and produce a visible jump on a common operation. The
183
+ * numerator's calibration is one turn stale until the next `turn_end`
184
+ * refreshes it, which is the trade this method names (RV-13).
185
+ */
186
+ onWindowChanged(): void;
187
+ /**
188
+ * Something was appended. Mark dirty, arrange a throttled publish, MEASURE
189
+ * NOTHING (I-3).
190
+ */
191
+ scheduleTick(): void;
192
+ /** Publish to this meter's own subscribers. Returns an unsubscribe function. */
193
+ subscribe(listener: ContextUsageListener): () => void;
194
+ /**
195
+ * Subscribe to the lead agent's stream. IDEMPOTENT.
196
+ *
197
+ * THIS TABLE IS NOT THE SOURCE OF COMPACTION CORRECTNESS (I-9). The splice is
198
+ * marked dirty by `CompactionWiring.settlePending` inside its own synchronous
199
+ * code, BEFORE it emits either event, so a reader is correct regardless of
200
+ * which listener the emitter reaches first. The `compaction_end` row below is
201
+ * a second, idempotent path that serves the manual `/compact` route and any
202
+ * future splicer.
203
+ */
204
+ attach(subscribe: (listener: (event: AgentEvent) => void) => () => void): void;
205
+ dispose(): void;
206
+ private onAgentEvent;
207
+ /**
208
+ * The structural belt for a missed invalidation site (I-9).
209
+ *
210
+ * A recorded prefix that is LONGER than the history it indexes describes an
211
+ * array that no longer exists. Rather than merely zeroing the delta - which is
212
+ * what `estimateAppendedTokens` does, keeping the wrong measured base - this
213
+ * drops the base too and forces the estimate branch.
214
+ *
215
+ * THE COMPARISON IS STRICT, AND THAT BOUNDARY IS LOAD-BEARING (IF-1). The
216
+ * design document wrote `length <= measuredPrefixLength`, which is off by one:
217
+ * `turn_end` is emitted BEFORE the assistant message is pushed, so the instant
218
+ * `onTurnEnd` records the prefix the history is EXACTLY that long. An
219
+ * inclusive test therefore discards a measurement one statement after taking
220
+ * it - but only when this meter's listener runs before the compaction wiring's,
221
+ * because the wiring's `snapshot()` is what reads in that window. That is a
222
+ * silent, subscription-order-dependent fallback to the estimate branch on
223
+ * every single turn, which is the exact class of bug this feature exists to
224
+ * remove. A measurement taken at length N describes every history of length
225
+ * >= N; only a SHORTER one is impossible.
226
+ *
227
+ * Returns whether anything was discarded.
228
+ */
229
+ private discardStalePrefix;
230
+ /**
231
+ * Measure, cache, clear the dirty flag, publish. The ONE write path.
232
+ *
233
+ * Every public mutator funnels through here so "what was published" and "what
234
+ * is cached" cannot drift, which is what makes `lastPublished()` a meaningful
235
+ * answer rather than a stale one.
236
+ */
237
+ private measureAndPublish;
238
+ private compute;
239
+ /**
240
+ * Where the denominator comes from, and whether it can be trusted.
241
+ *
242
+ * AN OVERRIDE MAKES THE WINDOW KNOWN (I-6): the user asserted it, so the `~`
243
+ * that means "this denominator is a guess" has no business staying on the
244
+ * percentage. `windowOverridden` travels separately so `/context` can still
245
+ * name the source - a hand-typed window that is wrong is exactly the case that
246
+ * most needs to be visible.
247
+ */
248
+ private resolveWindow;
249
+ private readSystemPrompt;
250
+ private publish;
251
+ }
@@ -0,0 +1,428 @@
1
+ /**
2
+ * `ContextMeter` - the ONE owner of "how full is the context right now"
3
+ * (context-usage-gauge-accuracy §3.2 / W1).
4
+ *
5
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope
6
+ * (`glyphs.test.ts::inScope`). If this module is ever moved to a new tree, the
7
+ * scanner's hardcoded directory list must be widened IN THE SAME COMMIT - a
8
+ * scanner that silently stops scanning is worse than no scanner, and this
9
+ * package has paid for that edit eleven times (I-7).
10
+ *
11
+ * IT LIVES HERE BUT IT IS NOT PART OF COMPACTION, and that is deliberate. The
12
+ * whole point of this class is that occupancy is measurable when compaction is
13
+ * OFF: `AgentController` builds a `CompactionWiring` only when
14
+ * `compaction.enabled` is true, so a measurement owned by the compactor is
15
+ * unreachable for exactly the sessions that most need a working gauge. It sits
16
+ * in this directory because `pressure.ts` - the arithmetic it drives - already
17
+ * does, and because moving it would cost a scanner edit for no gain (R-10).
18
+ *
19
+ * THE STATE MACHINE IN ONE PARAGRAPH. Every mutation of the history marks the
20
+ * meter DIRTY; every read that finds it dirty re-measures SYNCHRONOUSLY. That
21
+ * ordering is what removes the subscription-order dependency in the APPEND
22
+ * direction: it does not matter whether this class or the compaction wiring
23
+ * hears `tool_execution_end` first, because whoever reads gets a number that
24
+ * includes the appended message either way (I-2). It does NOT remove the
25
+ * dependency in the REPLACE direction - a splice does not announce itself to a
26
+ * listener that has not run yet - which is why the splice marks this dirty from
27
+ * `CompactionWiring.settlePending`'s own synchronous code (I-9), and why
28
+ * `current()` carries a structural belt for a missed invalidation site.
29
+ */
30
+ import { computeEstimateOffset, computePressure } from './pressure.js';
31
+ /**
32
+ * How long a scheduled publish waits.
33
+ *
34
+ * IT MUST BE A DELAY, NOT ZERO, AND NOT A SYNCHRONOUS MEASUREMENT (I-3). The
35
+ * loop emits `tool_execution_end` BEFORE it pushes the `tool_result` message, so
36
+ * anything that measures inside that event measures a history missing the single
37
+ * largest message of the turn - silently, and every turn. A `setTimeout` is
38
+ * necessarily later than that synchronous push.
39
+ *
40
+ * 400 ms also caps the publish rate at 2.5 Hz, which together with the reducer's
41
+ * identity short-circuit keeps a permanently visible row from becoming a
42
+ * re-render source (R-3).
43
+ */
44
+ export const CONTEXT_METER_TICK_MS = 400;
45
+ /**
46
+ * The projection `ViewState` and `/context` read (§4.1).
47
+ *
48
+ * THE ONLY CONVERTER between `Pressure` and `ContextUsageSnapshot`. Two of them
49
+ * would eventually round the percentage differently, and "the status bar says 43
50
+ * and `/context` says 44" is the exact class of disagreement this whole feature
51
+ * exists to remove.
52
+ */
53
+ export function toContextUsage(p) {
54
+ return {
55
+ occupied: p.occupied,
56
+ window: p.contextWindow,
57
+ // FROM `ratio`, NOT FROM A SECOND DIVISION. `computePressure` already
58
+ // clamped it to [0, 1] and already decided what a zero window means.
59
+ pct: Math.max(0, Math.min(100, Math.round(p.ratio * 100))),
60
+ source: p.source,
61
+ deltaTokens: p.deltaTokens,
62
+ windowKnown: p.windowKnown,
63
+ windowOverridden: p.windowOverridden,
64
+ };
65
+ }
66
+ /** The reading a meter with nothing measured yet reports. */
67
+ export function emptyContextUsage() {
68
+ return {
69
+ occupied: 0,
70
+ window: 0,
71
+ pct: 0,
72
+ source: 'estimate',
73
+ deltaTokens: 0,
74
+ windowKnown: false,
75
+ windowOverridden: false,
76
+ };
77
+ }
78
+ export class ContextMeter {
79
+ deps;
80
+ listeners = new Set();
81
+ unsubscribe = null;
82
+ timer = null;
83
+ disposed = false;
84
+ /**
85
+ * The authoritative usage of the last completed turn.
86
+ *
87
+ * Its presence is what selects `computePressure`'s MEASURED branch, so
88
+ * dropping it is how every reset degrades to an estimate rather than to a
89
+ * number describing a history that no longer exists.
90
+ */
91
+ lastUsage;
92
+ /**
93
+ * The systematic difference between what `estimatePromptTokens` counts and
94
+ * what the provider bills (D-23) - chiefly the tool schemas.
95
+ *
96
+ * SURVIVES A SPLICE AND NOT A SESSION CHANGE (I-8). See `onHistorySpliced`.
97
+ */
98
+ estimateOffset;
99
+ /** `messages.length` at the `turn_end` that produced `lastUsage`. */
100
+ measuredPrefixLength;
101
+ /** The last pressure PUBLISHED. Never re-derived on read - see `lastPublished`. */
102
+ last = null;
103
+ /** Whether the history has changed since the last measurement. */
104
+ dirty = true;
105
+ constructor(deps) {
106
+ this.deps = deps;
107
+ }
108
+ // =========================================================================
109
+ // Reading
110
+ // =========================================================================
111
+ /**
112
+ * Occupancy right now. FOR THE SCREEN.
113
+ *
114
+ * Re-measures synchronously when dirty (I-2), and publishes when it does - the
115
+ * gauge has to fall in the SAME FRAME as the compaction that caused it (AC-1),
116
+ * and the caller that triggers this re-measurement is `CompactionWiring`'s own
117
+ * synchronous `snapshot()` inside `settlePending`. Leaving the publish to the
118
+ * scheduled tick instead would be worse than slow: the tick fires only when
119
+ * `dirty`, and this call is what clears it.
120
+ *
121
+ * NEVER THROWS. A meter that can throw is a status bar that can take the
122
+ * process down.
123
+ */
124
+ current() {
125
+ // THE BELT (I-9), AND IT RUNS EVEN WHEN NOT DIRTY. The case it exists for is
126
+ // a splice that NOBODY announced, and in that case a missing `dirty` flag is
127
+ // precisely the symptom. `estimateAppendedTokens` bounds-checks too, but it
128
+ // only zeroes the DELTA and keeps the wrong measured base, so that belt does
129
+ // not cover this.
130
+ if (this.discardStalePrefix())
131
+ this.dirty = true;
132
+ if (!this.dirty && this.last)
133
+ return this.last;
134
+ return this.measureAndPublish(this.lastUsage);
135
+ }
136
+ /**
137
+ * The last pressure this meter PUBLISHED. FOR THE BOOKS. Never re-measures.
138
+ *
139
+ * `Compactor.lastMeasured()` forwards here and not to `current()`, and the
140
+ * distinction is load-bearing (I-10): `compactor.ts`'s `tokensBefore` is the
141
+ * sole upstream of `tokensReclaimed`, the card's "reclaimed N tokens" line and
142
+ * `/compact status`'s session total. It wants the occupancy BEFORE the splice.
143
+ * Wired to `current()` it would re-measure the already-spliced history, report
144
+ * `reclaimed 0`, and render that number without a single log line about it.
145
+ */
146
+ lastPublished() {
147
+ return this.last;
148
+ }
149
+ /** The published projection, or a zeroed one before the first measurement. */
150
+ currentUsage() {
151
+ return toContextUsage(this.current());
152
+ }
153
+ // =========================================================================
154
+ // Writing
155
+ // =========================================================================
156
+ /**
157
+ * Measure with the caller's own authoritative usage. FOR A DECISION POINT.
158
+ *
159
+ * `Compactor.shouldCompact` has a `CompactionProbe.lastUsage` the meter may
160
+ * not have seen yet, so it supplies it rather than trusting the field here.
161
+ */
162
+ measureWith(lastUsage) {
163
+ if (this.discardStalePrefix())
164
+ this.dirty = true;
165
+ return this.measureAndPublish(lastUsage);
166
+ }
167
+ /**
168
+ * `turn_end` - the one moment in the whole flow that is FULLY measured.
169
+ *
170
+ * The loop emits it BEFORE pushing the assistant message, so the history right
171
+ * now is exactly the history the reported `usage` was billed for: the offset
172
+ * and the prefix length describe the same array by construction, and
173
+ * `deltaTokens` is 0. Published immediately for that reason - there is no
174
+ * better number to wait for.
175
+ */
176
+ onTurnEnd(usage) {
177
+ const messages = this.deps.getMessages();
178
+ const systemPrompt = this.readSystemPrompt();
179
+ this.lastUsage = usage;
180
+ this.estimateOffset = computeEstimateOffset(usage, messages, systemPrompt);
181
+ this.measuredPrefixLength = messages.length;
182
+ this.measureAndPublish(usage);
183
+ }
184
+ /**
185
+ * A compaction spliced the history (I-8, the SHALLOW reset).
186
+ *
187
+ * `estimateOffset` IS KEPT, and that is the whole reason this method is not
188
+ * `onHistoryReplaced`. The offset describes "what this toolset costs on this
189
+ * model above what the estimator counts"; a splice changes neither the toolset
190
+ * nor the model, so it is still valid - and it is the only calibration the
191
+ * estimate branch has until the next `turn_end`. Dropping it makes the gauge
192
+ * fall several thousand tokens too far and then visibly climb back on the next
193
+ * turn, for no reason the user can see (I-5).
194
+ *
195
+ * IDEMPOTENT. Both `settlePending` (the authority, I-9) and this meter's own
196
+ * `compaction_end` handler reach it, and the second one must cost nothing.
197
+ */
198
+ onHistorySpliced() {
199
+ this.lastUsage = undefined;
200
+ this.measuredPrefixLength = undefined;
201
+ if (this.dirty)
202
+ return;
203
+ this.dirty = true;
204
+ this.scheduleTick();
205
+ }
206
+ /**
207
+ * The history came from somewhere else entirely (I-8, the DEEP reset):
208
+ * `/clear`, `/reset`, `/resume`.
209
+ *
210
+ * `estimateOffset` GOES TOO, unlike the shallow reset above. `/resume` can
211
+ * change the model in the same breath (`builtins.ts`'s restore path does), and
212
+ * an offset carried across that is a calibration constant applied to something
213
+ * it never calibrated. The first reading afterwards is therefore a bare
214
+ * estimate - biased LOW, and corrected by the very next `turn_end`. Biased low
215
+ * and self-correcting beats biased high and permanent.
216
+ */
217
+ onHistoryReplaced() {
218
+ this.lastUsage = undefined;
219
+ this.measuredPrefixLength = undefined;
220
+ this.estimateOffset = undefined;
221
+ this.dirty = true;
222
+ this.scheduleTick();
223
+ }
224
+ /**
225
+ * Only the DENOMINATOR moved (`/model`, a `contextWindow` edit).
226
+ *
227
+ * DELIBERATELY NOT A RESET. The measured base is still a true statement about
228
+ * the history, so throwing it away would trade a correct numerator for a whole
229
+ * history estimate and produce a visible jump on a common operation. The
230
+ * numerator's calibration is one turn stale until the next `turn_end`
231
+ * refreshes it, which is the trade this method names (RV-13).
232
+ */
233
+ onWindowChanged() {
234
+ this.dirty = true;
235
+ this.measureAndPublish(this.lastUsage);
236
+ }
237
+ /**
238
+ * Something was appended. Mark dirty, arrange a throttled publish, MEASURE
239
+ * NOTHING (I-3).
240
+ */
241
+ scheduleTick() {
242
+ this.dirty = true;
243
+ // NO SUBSCRIBERS, NO TIMER (I-11). `AgentController` is shared with
244
+ // `aragon exec`, where nothing reads this number; a headless run must not
245
+ // pay a timer per tool call, and a pending tick must never be able to hold
246
+ // the process open for an extra 400 ms. `current()` is unaffected - it
247
+ // measures on demand, so `wiring.snapshot()` and `/context` still work in a
248
+ // host with no UI.
249
+ if (this.listeners.size === 0)
250
+ return;
251
+ if (this.timer !== null || this.disposed)
252
+ return;
253
+ const handle = setTimeout(() => {
254
+ this.timer = null;
255
+ if (!this.dirty || this.disposed)
256
+ return;
257
+ this.measureAndPublish(this.lastUsage);
258
+ }, CONTEXT_METER_TICK_MS);
259
+ // The other half of I-11: an armed tick must not keep the event loop alive.
260
+ const unrefable = handle;
261
+ if (typeof unrefable.unref === 'function')
262
+ unrefable.unref();
263
+ this.timer = handle;
264
+ }
265
+ // =========================================================================
266
+ // Plumbing
267
+ // =========================================================================
268
+ /** Publish to this meter's own subscribers. Returns an unsubscribe function. */
269
+ subscribe(listener) {
270
+ this.listeners.add(listener);
271
+ return () => {
272
+ this.listeners.delete(listener);
273
+ };
274
+ }
275
+ /**
276
+ * Subscribe to the lead agent's stream. IDEMPOTENT.
277
+ *
278
+ * THIS TABLE IS NOT THE SOURCE OF COMPACTION CORRECTNESS (I-9). The splice is
279
+ * marked dirty by `CompactionWiring.settlePending` inside its own synchronous
280
+ * code, BEFORE it emits either event, so a reader is correct regardless of
281
+ * which listener the emitter reaches first. The `compaction_end` row below is
282
+ * a second, idempotent path that serves the manual `/compact` route and any
283
+ * future splicer.
284
+ */
285
+ attach(subscribe) {
286
+ if (this.unsubscribe)
287
+ return;
288
+ this.unsubscribe = subscribe((event) => this.onAgentEvent(event));
289
+ }
290
+ dispose() {
291
+ this.disposed = true;
292
+ if (this.timer !== null) {
293
+ clearTimeout(this.timer);
294
+ this.timer = null;
295
+ }
296
+ this.unsubscribe?.();
297
+ this.unsubscribe = null;
298
+ this.listeners.clear();
299
+ }
300
+ // =========================================================================
301
+ // Internals
302
+ // =========================================================================
303
+ onAgentEvent(event) {
304
+ switch (event.type) {
305
+ case 'turn_end':
306
+ this.onTurnEnd(event.usage);
307
+ return;
308
+ case 'tool_execution_end':
309
+ // DELAYED, NEVER SYNCHRONOUS. See `CONTEXT_METER_TICK_MS` (I-3).
310
+ this.scheduleTick();
311
+ return;
312
+ case 'compaction_end':
313
+ // The engine's verdict. `applied` means the history really was replaced.
314
+ if (event.applied)
315
+ this.onHistorySpliced();
316
+ else
317
+ this.scheduleTick();
318
+ return;
319
+ case 'agent_end':
320
+ this.scheduleTick();
321
+ return;
322
+ default:
323
+ // `message_update` is a STREAM event and is deliberately ignored (N-2):
324
+ // the output tokens of an in-flight reply are not knowable until `done`,
325
+ // and guessing them would add a third approximation to a number that
326
+ // already carries two.
327
+ return;
328
+ }
329
+ }
330
+ /**
331
+ * The structural belt for a missed invalidation site (I-9).
332
+ *
333
+ * A recorded prefix that is LONGER than the history it indexes describes an
334
+ * array that no longer exists. Rather than merely zeroing the delta - which is
335
+ * what `estimateAppendedTokens` does, keeping the wrong measured base - this
336
+ * drops the base too and forces the estimate branch.
337
+ *
338
+ * THE COMPARISON IS STRICT, AND THAT BOUNDARY IS LOAD-BEARING (IF-1). The
339
+ * design document wrote `length <= measuredPrefixLength`, which is off by one:
340
+ * `turn_end` is emitted BEFORE the assistant message is pushed, so the instant
341
+ * `onTurnEnd` records the prefix the history is EXACTLY that long. An
342
+ * inclusive test therefore discards a measurement one statement after taking
343
+ * it - but only when this meter's listener runs before the compaction wiring's,
344
+ * because the wiring's `snapshot()` is what reads in that window. That is a
345
+ * silent, subscription-order-dependent fallback to the estimate branch on
346
+ * every single turn, which is the exact class of bug this feature exists to
347
+ * remove. A measurement taken at length N describes every history of length
348
+ * >= N; only a SHORTER one is impossible.
349
+ *
350
+ * Returns whether anything was discarded.
351
+ */
352
+ discardStalePrefix() {
353
+ if (this.measuredPrefixLength === undefined)
354
+ return false;
355
+ const length = this.deps.getMessages().length;
356
+ if (length >= this.measuredPrefixLength)
357
+ return false;
358
+ this.lastUsage = undefined;
359
+ this.measuredPrefixLength = undefined;
360
+ return true;
361
+ }
362
+ /**
363
+ * Measure, cache, clear the dirty flag, publish. The ONE write path.
364
+ *
365
+ * Every public mutator funnels through here so "what was published" and "what
366
+ * is cached" cannot drift, which is what makes `lastPublished()` a meaningful
367
+ * answer rather than a stale one.
368
+ */
369
+ measureAndPublish(lastUsage) {
370
+ const pressure = this.compute(lastUsage);
371
+ this.last = pressure;
372
+ this.dirty = false;
373
+ this.publish(pressure);
374
+ return pressure;
375
+ }
376
+ compute(lastUsage) {
377
+ const window = this.resolveWindow();
378
+ return computePressure({
379
+ ...(lastUsage ? { lastUsage } : {}),
380
+ messages: this.deps.getMessages(),
381
+ systemPrompt: this.readSystemPrompt(),
382
+ contextWindow: window.contextWindow,
383
+ windowKnown: window.windowKnown,
384
+ windowOverridden: window.windowOverridden,
385
+ ...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
386
+ ...(this.measuredPrefixLength !== undefined
387
+ ? { measuredPrefixLength: this.measuredPrefixLength }
388
+ : {}),
389
+ });
390
+ }
391
+ /**
392
+ * Where the denominator comes from, and whether it can be trusted.
393
+ *
394
+ * AN OVERRIDE MAKES THE WINDOW KNOWN (I-6): the user asserted it, so the `~`
395
+ * that means "this denominator is a guess" has no business staying on the
396
+ * percentage. `windowOverridden` travels separately so `/context` can still
397
+ * name the source - a hand-typed window that is wrong is exactly the case that
398
+ * most needs to be visible.
399
+ */
400
+ resolveWindow() {
401
+ const override = this.deps.getWindowOverride();
402
+ if (override !== null && Number.isFinite(override) && override > 0) {
403
+ return { contextWindow: override, windowKnown: true, windowOverridden: true };
404
+ }
405
+ return {
406
+ contextWindow: this.deps.getModelInfo().contextWindow,
407
+ windowKnown: this.deps.isWindowKnown(),
408
+ windowOverridden: false,
409
+ };
410
+ }
411
+ readSystemPrompt() {
412
+ return this.deps.getSystemPrompt();
413
+ }
414
+ publish(pressure) {
415
+ if (this.listeners.size === 0)
416
+ return;
417
+ const usage = toContextUsage(pressure);
418
+ for (const listener of [...this.listeners]) {
419
+ try {
420
+ listener(usage);
421
+ }
422
+ catch {
423
+ // One bad subscriber must not take the run down with it - the rule
424
+ // `CompactionWiring.emit` and `TeamRuntime.emit` both already state.
425
+ }
426
+ }
427
+ }
428
+ }