@aragon-agent/cli 0.6.2 → 0.6.4

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 (167) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README.md +297 -2
  3. package/dist/agent/controller.d.ts +193 -1
  4. package/dist/agent/controller.js +475 -2
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +189 -0
  8. package/dist/agent/reducer.js +301 -2
  9. package/dist/agent/system-prompt.d.ts +14 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +297 -16
  12. package/dist/commands/builtins.js +165 -24
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +118 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +355 -0
  20. package/dist/compaction/compactor.d.ts +302 -0
  21. package/dist/compaction/compactor.js +810 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +215 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +264 -0
  37. package/dist/compaction/wiring.js +577 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +5 -2
  43. package/dist/config/env.js +140 -3
  44. package/dist/config/load.d.ts +42 -0
  45. package/dist/config/load.js +101 -1
  46. package/dist/config/schema.d.ts +244 -0
  47. package/dist/config/schema.js +170 -0
  48. package/dist/config/store.js +37 -1
  49. package/dist/config/ui-state.d.ts +28 -1
  50. package/dist/config/ui-state.js +9 -0
  51. package/dist/exec/events.d.ts +50 -1
  52. package/dist/exec/runner.js +42 -0
  53. package/dist/input/limits.d.ts +72 -0
  54. package/dist/input/limits.js +63 -0
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/paste-parse.d.ts +71 -0
  58. package/dist/input/paste-parse.js +95 -0
  59. package/dist/input/stdin-filter.d.ts +81 -0
  60. package/dist/input/stdin-filter.js +379 -0
  61. package/dist/logging/install.d.ts +53 -1
  62. package/dist/logging/install.js +81 -1
  63. package/dist/logging/logger.d.ts +1 -1
  64. package/dist/proc/classify.d.ts +52 -0
  65. package/dist/proc/classify.js +143 -0
  66. package/dist/proc/kill-tree.d.ts +42 -0
  67. package/dist/proc/kill-tree.js +119 -0
  68. package/dist/proc/limits.d.ts +84 -0
  69. package/dist/proc/limits.js +84 -0
  70. package/dist/proc/log-ring.d.ts +68 -0
  71. package/dist/proc/log-ring.js +102 -0
  72. package/dist/proc/prompt.d.ts +24 -0
  73. package/dist/proc/prompt.js +49 -0
  74. package/dist/proc/readiness.d.ts +102 -0
  75. package/dist/proc/readiness.js +219 -0
  76. package/dist/proc/supervisor.d.ts +103 -0
  77. package/dist/proc/supervisor.js +465 -0
  78. package/dist/proc/types.d.ts +175 -0
  79. package/dist/proc/types.js +15 -0
  80. package/dist/session/persist.js +55 -1
  81. package/dist/team/report.js +7 -1
  82. package/dist/team/runtime.d.ts +10 -0
  83. package/dist/team/runtime.js +7 -0
  84. package/dist/team/subagent.d.ts +31 -1
  85. package/dist/team/subagent.js +42 -15
  86. package/dist/team/types.d.ts +8 -0
  87. package/dist/tools/bash-tool.d.ts +51 -2
  88. package/dist/tools/bash-tool.js +321 -51
  89. package/dist/tools/fs-tools.d.ts +17 -0
  90. package/dist/tools/index.d.ts +55 -2
  91. package/dist/tools/index.js +39 -1
  92. package/dist/tools/proc-tools.d.ts +25 -0
  93. package/dist/tools/proc-tools.js +102 -0
  94. package/dist/ui/ActivityLine.d.ts +19 -1
  95. package/dist/ui/ActivityLine.js +10 -2
  96. package/dist/ui/App.d.ts +62 -2
  97. package/dist/ui/App.js +546 -16
  98. package/dist/ui/Composer.d.ts +66 -1
  99. package/dist/ui/Composer.js +50 -6
  100. package/dist/ui/PromptInput.d.ts +82 -3
  101. package/dist/ui/PromptInput.js +232 -49
  102. package/dist/ui/StatusBar.d.ts +59 -0
  103. package/dist/ui/StatusBar.js +16 -4
  104. package/dist/ui/TeamPanel.js +7 -1
  105. package/dist/ui/Transcript.d.ts +21 -1
  106. package/dist/ui/Transcript.js +61 -3
  107. package/dist/ui/clipboard.d.ts +52 -0
  108. package/dist/ui/clipboard.js +91 -0
  109. package/dist/ui/composer-limits.d.ts +31 -0
  110. package/dist/ui/composer-limits.js +50 -0
  111. package/dist/ui/composer-rows.d.ts +73 -0
  112. package/dist/ui/composer-rows.js +201 -0
  113. package/dist/ui/editor-reducer.d.ts +37 -0
  114. package/dist/ui/editor-reducer.js +88 -12
  115. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  116. package/dist/ui/entries/CompactionCard.js +158 -0
  117. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  118. package/dist/ui/entries/ServiceCard.js +116 -0
  119. package/dist/ui/entries/UserEntry.d.ts +17 -3
  120. package/dist/ui/entries/UserEntry.js +34 -5
  121. package/dist/ui/frame-differ.d.ts +46 -0
  122. package/dist/ui/frame-differ.js +94 -10
  123. package/dist/ui/gauge.d.ts +16 -1
  124. package/dist/ui/gauge.js +25 -6
  125. package/dist/ui/glyphs.d.ts +11 -0
  126. package/dist/ui/glyphs.js +2 -0
  127. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  128. package/dist/ui/layout/ScrollViewport.js +128 -21
  129. package/dist/ui/layout/budget.d.ts +44 -9
  130. package/dist/ui/layout/budget.js +42 -10
  131. package/dist/ui/layout/follow-state.d.ts +137 -0
  132. package/dist/ui/layout/follow-state.js +150 -0
  133. package/dist/ui/layout/virtual-window.d.ts +11 -0
  134. package/dist/ui/layout/virtual-window.js +85 -1
  135. package/dist/ui/overlays/HelpOverlay.js +9 -1
  136. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  137. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  138. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  139. package/dist/ui/overlays/SettingsScreen.js +74 -2
  140. package/dist/ui/palettes.d.ts +16 -0
  141. package/dist/ui/palettes.js +6 -0
  142. package/dist/ui/paste-frames.d.ts +87 -0
  143. package/dist/ui/paste-frames.js +112 -0
  144. package/dist/ui/paste-tokens.d.ts +90 -0
  145. package/dist/ui/paste-tokens.js +147 -0
  146. package/dist/ui/screen.d.ts +38 -0
  147. package/dist/ui/screen.js +113 -8
  148. package/dist/ui/selection/highlight.d.ts +39 -0
  149. package/dist/ui/selection/highlight.js +64 -0
  150. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  151. package/dist/ui/selection/screen-mirror.js +35 -0
  152. package/dist/ui/selection/selection-controller.d.ts +88 -0
  153. package/dist/ui/selection/selection-controller.js +282 -0
  154. package/dist/ui/selection/selection.d.ts +90 -0
  155. package/dist/ui/selection/selection.js +223 -0
  156. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  157. package/dist/ui/stdout-frame-writer.js +15 -0
  158. package/dist/ui/theme.d.ts +11 -0
  159. package/dist/ui/theme.js +2 -0
  160. package/dist/ui/transcript-text.js +72 -0
  161. package/dist/ui/use-startup-notices.d.ts +27 -1
  162. package/dist/ui/use-startup-notices.js +43 -11
  163. package/dist/ui/use-wheel-routing.d.ts +1 -1
  164. package/dist/ui/use-wheel-routing.js +8 -0
  165. package/package.json +4 -2
  166. package/dist/input/stdin-mouse-filter.d.ts +0 -42
  167. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -0,0 +1,577 @@
1
+ /**
2
+ * `CompactionWiring` — the controller-side glue for context compaction
3
+ * (context-auto-compaction §3.2.1 / §5.2).
4
+ *
5
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope.
6
+ *
7
+ * CONSTRUCT BEFORE THE `Agent`, ATTACH AFTER IT (C-12 / D-19), and the split is
8
+ * the whole reason this class has an `attach` method at all:
9
+ *
10
+ * - The PORT has to be INSIDE the `Agent`'s constructor argument, because
11
+ * `AgentConfig.contextManager` has no setter and the loop reads it from the
12
+ * loop context.
13
+ * - The WIRING has to SUBSCRIBE to the `Agent`, because `compaction_end` is
14
+ * where the engine's `applied` verdict arrives and `agent_start` is what
15
+ * resets the per-run cap.
16
+ *
17
+ * `AgentController` cannot do both in one step: `new Agent({...})` runs before
18
+ * every optional subsystem, precisely because `FastWiring`'s constructor
19
+ * subscribes. So THIS CONSTRUCTOR MUST NOT SUBSCRIBE. A field assigned after the
20
+ * `new Agent` call and spread into it conditionally produces a build where the
21
+ * feature is registered, enabled, and SILENTLY ABSENT - the exact shape of
22
+ * failure `controller.ts`'s `toolOutputs` comment documents.
23
+ *
24
+ * `manager()` returns a STABLE object for the controller's lifetime, whose two
25
+ * methods delegate to a compactor that reads config LIVE - which is what lets
26
+ * `/compact off`, `/compact threshold 0.8` and a settings-screen save take effect
27
+ * mid-session without touching the `Agent`.
28
+ *
29
+ * DECLINED COMPACTIONS PRODUCE NO CLI EVENTS (quiet-noop §3.6). A `pressure`
30
+ * compaction that changed nothing, called nothing and spent nothing never emits
31
+ * `compaction_start`, so `onCompactorEvent` never opens a card and `settlePending`
32
+ * finds neither a held record nor a skeleton to synthesize. CORE still emits its
33
+ * own pair for every attempt, which is why `settlePending` still runs - it just
34
+ * refreshes the snapshot and returns. `onCompactorEvent`'s `if (!this.open)`
35
+ * guard is now unreachable for declines and stays for the abort race it was
36
+ * written for.
37
+ */
38
+ import { DEFAULT_RETRY_POLICY, estimatePromptTokens, initProviders, validateHistory, } from '@aragon-agent/core';
39
+ import { getLogger } from '../logging/logger.js';
40
+ import { mintRunId, writeArchive } from './archive.js';
41
+ import { createChildContextManager } from './child.js';
42
+ import { Compactor } from './compactor.js';
43
+ /**
44
+ * One retry, not ten and not zero - the `fast/wiring.ts` argument unchanged
45
+ * (D-10).
46
+ *
47
+ * Inheriting the user's `/retry` policy would let a background repair compete
48
+ * with the user's own work for a rate-limited provider's quota, and it would put
49
+ * up to ten backoffs inside a window the idle watchdog is PAUSED across.
50
+ */
51
+ const COMPACTION_RETRY_POLICY = { ...DEFAULT_RETRY_POLICY, maxRetries: 1 };
52
+ /** The status of a session that never had compaction (the wiring is `null`). */
53
+ export function offCompactionSnapshot() {
54
+ return {
55
+ live: false,
56
+ model: '',
57
+ compactions: 0,
58
+ tokensReclaimed: 0,
59
+ usage: { inputTokens: 0, outputTokens: 0 },
60
+ pricingUnknown: false,
61
+ inFlight: false,
62
+ selfDisabled: false,
63
+ generation: 0,
64
+ pressure: {
65
+ occupied: 0,
66
+ contextWindow: 0,
67
+ ratio: 0,
68
+ headroom: 0,
69
+ source: 'estimate',
70
+ windowKnown: false,
71
+ deltaTokens: 0,
72
+ },
73
+ };
74
+ }
75
+ export class CompactionWiring {
76
+ deps;
77
+ log = getLogger().child('compaction');
78
+ listeners = new Set();
79
+ compactor;
80
+ /** The compactor's OWN transport (D-10). Lazy - see `getRegistry()`. */
81
+ registry = null;
82
+ unsubscribe = null;
83
+ /** The stable port handed to `new Agent({...})`. Never reassigned. */
84
+ port;
85
+ /**
86
+ * The record the compactor produced, waiting for the ENGINE's verdict.
87
+ *
88
+ * `applied` is not knowable on the host side: validation happens after
89
+ * `compact()` returns, inside the loop. So the compactor's record is held here
90
+ * and corrected when the core `compaction_end` arrives (§3.2.1). The IDLE
91
+ * `/compact` path has no core event and settles its own record directly.
92
+ */
93
+ pending = null;
94
+ /**
95
+ * What the CLI-local `compaction_start` announced, and `null` once the card has
96
+ * been settled.
97
+ *
98
+ * THE ENGINE'S VERDICT CAN ARRIVE BEFORE THE COMPACTOR'S RECORD, and Esc is the
99
+ * ordinary way it happens: `runCompaction` races `compact()` against the run's
100
+ * signal, so an abort resolves the race and emits `compaction_end` while
101
+ * `compact()` is still unwinding its own call. With nothing held here,
102
+ * `settlePending` would find no record, return, and leave the card `live:
103
+ * true` FOREVER — which is C-8's failure mode exactly: `Transcript`'s settled
104
+ * boundary is monotonic, so the whole tail re-renders on every frame for the
105
+ * rest of the session, and the card goes on claiming a compaction is running.
106
+ * `manager_timeout` and a `compact()` that rejects outright reach the same
107
+ * place.
108
+ *
109
+ * So the verdict settles the card from whatever is available — the compactor's
110
+ * record when it exists, this skeleton when it does not — and clearing this
111
+ * field is what tells a LATE record that its card is already closed.
112
+ */
113
+ open = null;
114
+ /** Live flag, flipped by `/compact on|off`. Starts equal to `enabled`. */
115
+ enabled;
116
+ /**
117
+ * Identifies THIS RUN's archives in a directory several runs share (RV-6).
118
+ *
119
+ * NOT `SessionMeta.id`: that exists only once a session has been saved, and the
120
+ * unattended long run this feature serves is exactly the one nobody saved.
121
+ * Short, filename-safe, and only ever compared for equality - it is a grouping
122
+ * key, not an identity.
123
+ */
124
+ runId = mintRunId();
125
+ constructor(deps) {
126
+ this.deps = deps;
127
+ this.enabled = deps.getConfig().compaction.enabled;
128
+ this.compactor = new Compactor({
129
+ getConfig: deps.getConfig,
130
+ hasKey: deps.hasKey,
131
+ getApiKey: deps.getApiKey,
132
+ getModelInfoFor: deps.getModelInfoFor,
133
+ isPricedModel: deps.isPricedModel,
134
+ // `shouldCompact` reads these on the ESTIMATE branch (§3.4.2). Forwarded
135
+ // rather than defaulted to `[]`, because an empty history estimates to 0 %
136
+ // and the resumed-session case would then never trigger.
137
+ getMessages: deps.getMessages,
138
+ getSystemPrompt: deps.getSystemPrompt,
139
+ complete: (providerId, request) => this.getRegistry().complete(providerId, request),
140
+ emit: (event) => this.onCompactorEvent(event),
141
+ notify: deps.notify,
142
+ // ONE NUMBER ON SCREEN AND IN THE TRIGGER (R-11 / DH-15). The checkpoint
143
+ // has already measured; publishing THAT pressure rather than re-measuring
144
+ // is what keeps the two from disagreeing by one turn's tool results.
145
+ onPressure: (pressure) => this.emit({ type: 'snapshot', snapshot: this.snapshotWith(pressure) }),
146
+ });
147
+ // ONE OBJECT, FOR THE CONTROLLER'S LIFETIME. The `Agent` therefore needs no
148
+ // setter and `contextManager` is never reassigned; the LIVE switch is inside
149
+ // `shouldCompact`, not in the identity of this object.
150
+ this.port = {
151
+ shouldCompact: (probe) => this.enabled && this.compactor.shouldCompact(probe),
152
+ compact: (ctx) => this.compactor.compact(ctx),
153
+ };
154
+ }
155
+ /**
156
+ * The port for `new Agent({...})`.
157
+ *
158
+ * Spread CONDITIONALLY at the call site so `contextManager` stays genuinely
159
+ * `undefined` when the feature is off (AC-1): the loop's gate is
160
+ * `if (!ctx.contextManager) return false` against an ABSENT field, not a live
161
+ * object whose predicate says no.
162
+ */
163
+ manager() {
164
+ return this.port;
165
+ }
166
+ /**
167
+ * Subscribe to the lead agent's stream. IDEMPOTENT, and separate from the
168
+ * constructor - see the file header.
169
+ */
170
+ attach(subscribe) {
171
+ if (this.unsubscribe)
172
+ return;
173
+ this.unsubscribe = subscribe((event) => this.onAgentEvent(event));
174
+ }
175
+ dispose() {
176
+ this.compactor.abort();
177
+ this.unsubscribe?.();
178
+ this.unsubscribe = null;
179
+ this.listeners.clear();
180
+ }
181
+ // -----------------------------------------------------------------------
182
+ // Flags
183
+ // -----------------------------------------------------------------------
184
+ isEnabled() {
185
+ return this.enabled;
186
+ }
187
+ setEnabled(enabled) {
188
+ this.enabled = enabled;
189
+ // Turning it back on is the user overriding guard 4, which is the only way
190
+ // out of a self-disable short of a restart.
191
+ if (enabled)
192
+ this.compactor.clearSelfDisable();
193
+ this.emit({ type: 'snapshot', snapshot: this.snapshot() });
194
+ }
195
+ /** Re-emit the snapshot after any config mutation, so the chip and the gauge move. */
196
+ onConfigChanged() {
197
+ this.emit({ type: 'snapshot', snapshot: this.snapshot() });
198
+ }
199
+ abort() {
200
+ this.compactor.abort();
201
+ }
202
+ /**
203
+ * The host replaced or cleared the history (hardening §3.2.3, sites 3 and 4).
204
+ *
205
+ * `/clear`, `/resume`, and any future host-side rewrite. A recorded prefix
206
+ * length indexes an array that no longer exists, so the delta must be
207
+ * abandoned rather than applied to a different history.
208
+ */
209
+ onHistoryReplaced() {
210
+ this.compactor.invalidateMeasurement();
211
+ }
212
+ /**
213
+ * The per-child manager factory, or `null` when children get none
214
+ * (hardening §3.4 / W3).
215
+ *
216
+ * `null` RATHER THAN A NO-OP FACTORY, so `subagent.ts` can spread NOTHING and
217
+ * the child's loop gate tests a genuinely absent `contextManager` - the same
218
+ * byte-identity argument `AgentController` makes for the lead (AC-H10).
219
+ */
220
+ childFactory() {
221
+ if (!this.deps.getConfig().compaction.subagents)
222
+ return null;
223
+ return (req) => createChildContextManager(req, {
224
+ complete: (providerId, request) => this.getRegistry().complete(providerId, request),
225
+ getConfig: this.deps.getConfig,
226
+ hasKey: this.deps.hasKey,
227
+ getApiKey: this.deps.getApiKey,
228
+ getModelInfoFor: this.deps.getModelInfoFor,
229
+ isPricedModel: this.deps.isPricedModel,
230
+ // A CHILD'S COMPACTION SPEND JOINS THE SESSION TOTALS THROUGH THE LEAD'S
231
+ // SINK (AC-H12). It is real money on the summarizer's price table, and a
232
+ // background repair inside a background worker is exactly the spend a
233
+ // user would otherwise never see.
234
+ onUsage: (usage) => this.emit({ type: 'usage', usage }),
235
+ });
236
+ }
237
+ // -----------------------------------------------------------------------
238
+ // The manual queue (§4.4)
239
+ // -----------------------------------------------------------------------
240
+ queueManual(instructions) {
241
+ this.compactor.queueManual(instructions);
242
+ }
243
+ hasPendingManual() {
244
+ return this.compactor.hasPendingManual();
245
+ }
246
+ clearPendingManual() {
247
+ this.compactor.clearPendingManual();
248
+ }
249
+ /**
250
+ * The IDLE `/compact` path (§4.4 / D-25).
251
+ *
252
+ * IT VALIDATES BEFORE IT SPLICES, AND THAT IS NOT OPTIONAL (P1-7). When the
253
+ * agent is idle there is no loop, so §3.3 step 5 does not run — and the only
254
+ * way to replace the history is `Agent.replaceMessages`, which validates
255
+ * nothing. D-4 declares that gate unbypassable and R-2 calls an invalid splice
256
+ * the worst possible outcome, so this path mirrors the engine's steps 3-5
257
+ * exactly, INCLUDING the failure vocabulary: the card, the log and
258
+ * `/compact status` cannot tell the two paths apart.
259
+ *
260
+ * No watchdog handling is needed — `runLoopWithLifecycle`'s `finally` already
261
+ * called `watchdog.stop()`, so nothing is armed while idle.
262
+ *
263
+ * ITS CALLER MUST ADOPT `messages` — THE ARCHIVE IS ALREADY WRITTEN BY THE
264
+ * TIME THIS RETURNS (hardening RV-7). This path settles `applied: true` here,
265
+ * one statement before the caller splices, so the "never archive a live
266
+ * history" rule holds only because that caller applies unconditionally. That is
267
+ * a fact about one call site rather than an invariant, which is why it is
268
+ * written down where it can be checked.
269
+ */
270
+ async compactNow(args) {
271
+ this.compactor.queueManual(args.instructions);
272
+ // BOTH SIDES FROM CORE'S OWN ESTIMATOR, exactly as the in-loop path gets them
273
+ // from `compaction_end` (§5.1). There is no engine event here to supply them,
274
+ // and the two numbers are the card's headline claim ("118.4k -> 23.1k") and
275
+ // the input to `tokensReclaimed`: a `tokensAfter` of 0 would render a
276
+ // compaction that freed the entire window and inflate the session total by
277
+ // the whole occupancy. Two figures produced by one function are comparable;
278
+ // a measured "before" against a zeroed "after" is not.
279
+ const tokensBefore = estimatePromptTokens(args.messages, args.systemPrompt);
280
+ const outcome = await this.compactor.compact({
281
+ messageCount: args.messages.length,
282
+ turnIndex: 0,
283
+ trigger: 'pressure',
284
+ ...(args.lastUsage ? { lastUsage: args.lastUsage } : {}),
285
+ messages: args.messages,
286
+ systemPrompt: args.systemPrompt,
287
+ model: args.model,
288
+ signal: args.signal,
289
+ });
290
+ // NOTHING CHANGED, so "after" IS "before" — the same thing the engine reports
291
+ // when it refuses a splice.
292
+ if (outcome.action !== 'replace') {
293
+ this.settlePending({
294
+ applied: false,
295
+ reason: outcome.reason,
296
+ tokensBefore,
297
+ tokensAfter: tokensBefore,
298
+ });
299
+ return { ok: false, reason: outcome.reason };
300
+ }
301
+ const check = validateHistory(outcome.messages, { previousLength: args.messages.length });
302
+ if (!check.ok) {
303
+ const reason = `invalid_history: ${check.reason}`;
304
+ this.settlePending({ applied: false, reason, tokensBefore, tokensAfter: tokensBefore });
305
+ return { ok: false, reason };
306
+ }
307
+ this.settlePending({
308
+ applied: true,
309
+ tokensBefore,
310
+ tokensAfter: estimatePromptTokens(outcome.messages, args.systemPrompt),
311
+ });
312
+ return { ok: true, messages: outcome.messages };
313
+ }
314
+ // -----------------------------------------------------------------------
315
+ // Status and events
316
+ // -----------------------------------------------------------------------
317
+ snapshot() {
318
+ const pressure = this.compactor.lastMeasured() ??
319
+ this.compactor.measure({
320
+ messages: this.deps.getMessages(),
321
+ systemPrompt: this.deps.getSystemPrompt(),
322
+ });
323
+ return this.snapshotWith(pressure);
324
+ }
325
+ /**
326
+ * `snapshot()` with the pressure argument substituted for the re-measurement.
327
+ *
328
+ * SO THE CHECKPOINT DOES NOT MEASURE TWICE. `shouldCompact` has already walked
329
+ * the appended slice; measuring again here would double the one cost DH-15
330
+ * accepted, and - worse - the second measurement could differ from the one the
331
+ * decision was made on if anything appended in between.
332
+ */
333
+ snapshotWith(pressure) {
334
+ const totals = this.compactor.sessionTotals();
335
+ const summarizer = this.compactor.resolveSummarizer();
336
+ return {
337
+ live: this.enabled && summarizer !== null,
338
+ model: summarizer?.ref.modelId ?? '',
339
+ compactions: totals.compactions,
340
+ // OMITTED WHEN ZERO (quiet-noop D-9), so a healthy session's snapshot is
341
+ // shaped exactly as it was before this feature and `/compact status` has
342
+ // nothing extra to render.
343
+ ...(totals.declined > 0 ? { declined: totals.declined } : {}),
344
+ tokensReclaimed: totals.tokensReclaimed,
345
+ usage: totals.usage,
346
+ pricingUnknown: totals.pricingUnknown,
347
+ inFlight: totals.inFlight,
348
+ selfDisabled: totals.selfDisabled,
349
+ ...(totals.selfDisabledReason ? { selfDisabledReason: totals.selfDisabledReason } : {}),
350
+ generation: totals.generation,
351
+ pressure,
352
+ };
353
+ }
354
+ /** The summarizer that would be used right now, for `/compact status`. */
355
+ summarizerRef() {
356
+ return this.compactor.resolveSummarizer()?.ref ?? null;
357
+ }
358
+ subscribe(listener) {
359
+ this.listeners.add(listener);
360
+ return () => this.listeners.delete(listener);
361
+ }
362
+ // -----------------------------------------------------------------------
363
+ // Internals
364
+ // -----------------------------------------------------------------------
365
+ /**
366
+ * The transport one summarization call goes through - separate from the lead's,
367
+ * and fail-fast (D-10).
368
+ *
369
+ * LAZY, SO OFF STAYS BYTE-IDENTICAL. Nothing is constructed until a compaction
370
+ * actually calls, so a session that never crosses the threshold allocates no
371
+ * registry and registers no adapters.
372
+ */
373
+ getRegistry() {
374
+ if (this.registry === null) {
375
+ const create = this.deps.createRegistry ?? initProviders;
376
+ this.registry = create({ retryPolicy: COMPACTION_RETRY_POLICY });
377
+ this.log.info('compaction_registry_created', {
378
+ maxRetries: COMPACTION_RETRY_POLICY.maxRetries,
379
+ });
380
+ }
381
+ return this.registry;
382
+ }
383
+ onCompactorEvent(event) {
384
+ if (event.type === 'compaction_start') {
385
+ this.open = { index: event.index, trigger: event.trigger, model: event.model };
386
+ this.pending = null;
387
+ this.emit(event);
388
+ return;
389
+ }
390
+ if (event.type === 'compaction_end') {
391
+ // DROPPED WHEN THE CARD IS ALREADY CLOSED. An abort or the engine's hard
392
+ // ceiling settles the card from the verdict alone, and this record is what
393
+ // arrives afterwards, when `compact()` finally unwinds; emitting it would
394
+ // open a second card for a compaction the user has already been told about.
395
+ if (!this.open)
396
+ return;
397
+ // Otherwise HELD, NOT FORWARDED. `applied` is the engine's verdict and has
398
+ // not happened yet; forwarding an optimistic record would settle the card
399
+ // with a claim the structural gate may be about to refuse.
400
+ this.pending = event.record;
401
+ return;
402
+ }
403
+ this.emit(event);
404
+ }
405
+ onAgentEvent(event) {
406
+ if (event.type === 'agent_start') {
407
+ this.compactor.onRunStart();
408
+ return;
409
+ }
410
+ if (event.type === 'turn_end') {
411
+ // The calibration offset, re-measured every turn (D-23). Read the history
412
+ // AFTER the turn's assistant message is pushed? No: `turn_end` is emitted
413
+ // BEFORE the push, so this is exactly the history the reported `usage`
414
+ // was measured against, which is what makes the difference meaningful.
415
+ this.compactor.onTurnEnd(event.usage, this.deps.getMessages(), this.deps.getSystemPrompt());
416
+ // The gauge and the trigger read one function (R-11 / AC-3), so the
417
+ // snapshot's pressure is refreshed from the same measurement the status bar
418
+ // is about to render.
419
+ this.compactor.measure({
420
+ lastUsage: event.usage,
421
+ messages: this.deps.getMessages(),
422
+ systemPrompt: this.deps.getSystemPrompt(),
423
+ });
424
+ this.emit({ type: 'snapshot', snapshot: this.snapshot() });
425
+ return;
426
+ }
427
+ if (event.type === 'compaction_end') {
428
+ this.settlePending({
429
+ applied: event.applied,
430
+ ...(event.reason !== undefined ? { reason: event.reason } : {}),
431
+ tokensBefore: event.estimatedTokensBefore,
432
+ tokensAfter: event.estimatedTokensAfter,
433
+ });
434
+ }
435
+ }
436
+ /**
437
+ * Settle the held record with the authoritative verdict, then emit it.
438
+ *
439
+ * `tokensBefore` / `tokensAfter` come from CORE'S OWN estimator on BOTH sides
440
+ * (§5.1), so the card's headline claim compares two numbers in one unit rather
441
+ * than a measurement against a guess. `tokensReclaimed` is accumulated from the
442
+ * same pair, which is what keeps `/compact status` and the card agreeing.
443
+ */
444
+ settlePending(verdict) {
445
+ // `?? skeletonRecord()` IS THE ABORT PATH, not padding. See `open` above: the
446
+ // engine's verdict can land while `compact()` is still unwinding, and a card
447
+ // that never settles re-renders the tail on every frame for the rest of the
448
+ // session. A skeleton settle can only ever carry `applied: false` — the
449
+ // engine cannot report `true` without a `replace` outcome, which means the
450
+ // compactor's own record already exists — so its zeroed counts are never
451
+ // rendered.
452
+ const record = this.pending ?? this.skeletonRecord();
453
+ this.pending = null;
454
+ this.open = null;
455
+ // CALLED ON EVERY VERDICT AND DISCARDED ON THE ONES THAT DO NOT ARCHIVE
456
+ // (RV-3). `takeLastDropped` releases the reference as it returns it, so this
457
+ // one line is what guarantees the dropped history cannot outlive one
458
+ // compaction no matter which branch the verdict took - including the early
459
+ // `return` below.
460
+ const dropped = this.compactor.takeLastDropped();
461
+ if (!record) {
462
+ // A SILENT DECLINE (quiet-noop §3.6). No card - but the gauge and the chip
463
+ // are still refreshed: `inFlight` has just gone false and `/compact status`
464
+ // reads this same snapshot, which is where the `declined` count surfaces.
465
+ // `snapshot()` uses the CACHED pressure (`compactor.lastMeasured()`), so
466
+ // this costs no re-measurement, and guard 4 caps the number of declines per
467
+ // session at `stuckLimit`.
468
+ this.emit({ type: 'snapshot', snapshot: this.snapshot() });
469
+ return;
470
+ }
471
+ const settled = {
472
+ ...record,
473
+ applied: verdict.applied,
474
+ ...(verdict.reason !== undefined ? { reason: verdict.reason } : {}),
475
+ ...(verdict.tokensBefore !== undefined ? { tokensBefore: verdict.tokensBefore } : {}),
476
+ tokensAfter: verdict.tokensAfter,
477
+ mode: verdict.applied ? record.mode : 'none',
478
+ };
479
+ if (verdict.applied) {
480
+ this.compactor.recordApplied(settled.tokensBefore, settled.tokensAfter);
481
+ // SITE 1 OF THE FOUR INVALIDATION SITES (hardening §3.2.3). The in-loop
482
+ // splice replaced the history, so every recorded index means nothing; the
483
+ // manual path settles through here too, so one call covers both.
484
+ this.compactor.invalidateMeasurement();
485
+ const path = this.archive(settled, dropped);
486
+ if (path)
487
+ settled.archivePath = path;
488
+ }
489
+ this.emit({ type: 'compaction_end', record: settled });
490
+ this.emit({ type: 'snapshot', snapshot: this.snapshot() });
491
+ }
492
+ /**
493
+ * Write the archive, if there is one to write (hardening §3.5.3 / W4).
494
+ *
495
+ * ONLY ON `applied: true`, because the rule the writer enforces is "never
496
+ * archive a history that is still live" - and the two paths reach that verdict
497
+ * at different moments relative to the splice. In loop, the ENGINE decides
498
+ * after `validateHistory`, so this runs AFTER adoption. On the manual path the
499
+ * WIRING decides and `compactNow`'s caller adopts on the next statement, so
500
+ * this runs BEFORE adoption; `compactNow`'s doc comment carries that obligation
501
+ * for its one caller (RV-7).
502
+ *
503
+ * BEST-EFFORT AND SYNCHRONOUS-BUT-GUARDED. `writeArchive` never throws and
504
+ * logs its own failure; a failed archive never affects the compaction, and it
505
+ * never notifies - a toast for a best-effort audit file trains users to ignore
506
+ * toasts.
507
+ */
508
+ archive(record, dropped) {
509
+ if (!this.deps.getConfig().compaction.archive)
510
+ return null;
511
+ // A RELIEF-ONLY COMPACTION DROPS NOTHING AND IS STILL ARCHIVED, which is why
512
+ // `mode` is `summarized | truncated | relieved` in §3.5.2's document rather
513
+ // than the first two. `/compact history` numbers its rows by COMPACTION
514
+ // INDEX, so skipping this one leaves a hole at exactly the index a user
515
+ // investigating a clipped tail would ask for, and `/compact show <n>` answers
516
+ // "no archive" for the compaction that damaged their data (AC-H9).
517
+ //
518
+ // ANY OTHER VERDICT WITH NO RETAINED SLICE was compacted while the key was
519
+ // off and genuinely has nothing to record - writing `droppedCount: 0` for it
520
+ // would claim a splice dropped nothing when it dropped a hundred messages.
521
+ if (!dropped && record.mode !== 'relieved')
522
+ return null;
523
+ return writeArchive({
524
+ runId: this.runId,
525
+ index: record.index,
526
+ trigger: record.trigger,
527
+ mode: record.mode,
528
+ model: record.model,
529
+ generation: this.compactor.sessionTotals().generation,
530
+ tokensBefore: record.tokensBefore,
531
+ tokensAfter: record.tokensAfter,
532
+ messagesBefore: record.messagesBefore,
533
+ messagesAfter: record.messagesAfter,
534
+ ...(record.summary !== undefined ? { summary: record.summary } : {}),
535
+ ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
536
+ dropped: dropped ?? [],
537
+ ...(this.deps.archiveDir ? { dir: this.deps.archiveDir } : {}),
538
+ });
539
+ }
540
+ /** This run's archive key, for `/compact history` and `/compact show`. */
541
+ archiveRunId() {
542
+ return this.runId;
543
+ }
544
+ /**
545
+ * The minimum a card needs to settle when the compactor's own record has not
546
+ * arrived (see `open`). `null` when no card was ever opened — the
547
+ * `shouldCompact`-threw path, where the engine emits a pair of events the CLI
548
+ * stream knows nothing about.
549
+ */
550
+ skeletonRecord() {
551
+ if (!this.open)
552
+ return null;
553
+ return {
554
+ index: this.open.index,
555
+ trigger: this.open.trigger,
556
+ mode: 'none',
557
+ applied: false,
558
+ messagesBefore: 0,
559
+ messagesAfter: 0,
560
+ tokensBefore: 0,
561
+ tokensAfter: 0,
562
+ model: this.open.model,
563
+ durationMs: 0,
564
+ };
565
+ }
566
+ emit(event) {
567
+ for (const listener of [...this.listeners]) {
568
+ try {
569
+ listener(event);
570
+ }
571
+ catch {
572
+ // One bad subscriber must not take the run down with it - the rule
573
+ // `TeamRuntime.emit` and `FastWiring.emit` both already state.
574
+ }
575
+ }
576
+ }
577
+ }
@@ -112,6 +112,20 @@ export declare function getUpdateLockPath(): string;
112
112
  export declare function getUpdateInstallLogPath(): string;
113
113
  export declare function getLogsDir(): string;
114
114
  export declare function getSessionsDir(): string;
115
+ /**
116
+ * `<home>/compaction` — the compaction archive
117
+ * (context-auto-compaction-hardening §3.5 / W4).
118
+ *
119
+ * ITS OWN ROOT, AND EXPLICITLY NOT INSIDE `sessions/`. `logs/`, `sessions/` and
120
+ * `skills/` are all SCAN roots — the note above `getUpdateStatePath` says it in
121
+ * as many words: every extra file inside one is another thing each walk has to
122
+ * recognise and skip. An archive dropped into `sessions/` would be offered to the
123
+ * user by `/resume` as a session it cannot load.
124
+ *
125
+ * SHARED BY EVERY `aragon` ON THE MACHINE, which is why the files inside it carry
126
+ * a per-process run id and why retention is two-tier (§3.5.2 / RV-6).
127
+ */
128
+ export declare function getCompactionArchiveDir(): string;
115
129
  /** `<home>` — parent of `skills/`, and where sidecar state such as usage lives. */
116
130
  export declare function getUserDataDir(): string;
117
131
  export declare function getUserSkillsDir(): string;
@@ -183,6 +183,22 @@ export function getLogsDir() {
183
183
  export function getSessionsDir() {
184
184
  return join(resolution.root, 'sessions');
185
185
  }
186
+ /**
187
+ * `<home>/compaction` — the compaction archive
188
+ * (context-auto-compaction-hardening §3.5 / W4).
189
+ *
190
+ * ITS OWN ROOT, AND EXPLICITLY NOT INSIDE `sessions/`. `logs/`, `sessions/` and
191
+ * `skills/` are all SCAN roots — the note above `getUpdateStatePath` says it in
192
+ * as many words: every extra file inside one is another thing each walk has to
193
+ * recognise and skip. An archive dropped into `sessions/` would be offered to the
194
+ * user by `/resume` as a session it cannot load.
195
+ *
196
+ * SHARED BY EVERY `aragon` ON THE MACHINE, which is why the files inside it carry
197
+ * a per-process run id and why retention is two-tier (§3.5.2 / RV-6).
198
+ */
199
+ export function getCompactionArchiveDir() {
200
+ return join(resolution.root, 'compaction');
201
+ }
186
202
  /** `<home>` — parent of `skills/`, and where sidecar state such as usage lives. */
187
203
  export function getUserDataDir() {
188
204
  return resolution.root;
@@ -54,6 +54,24 @@ export declare const FAST_CONFIG_SET_KEYS: readonly ["fast.enabled", "fast.provi
54
54
  * `Set update.mode = notify`.
55
55
  */
56
56
  export declare const UPDATE_CONFIG_SET_KEYS: readonly ["update.mode", "update.checkIntervalMs", "update.registry", "update.distTag"];
57
+ /**
58
+ * Config keys under `compaction.` that `aragon config set` accepts
59
+ * (context-auto-compaction §4.2).
60
+ *
61
+ * A DEDICATED SETTER RATHER THAN A GENERIC DOTTED WRITER, for the reason the
62
+ * five sets above each record: a generic setter would write `compaction` as a
63
+ * FLAT string key (`"compaction.enabled": "false"` at the top level of the
64
+ * file), the section would silently never take effect, and `config set` would
65
+ * still print `Set compaction.enabled = false`.
66
+ *
67
+ * THIS SET IS WHAT MAKES THE KILL SWITCH REACHABLE FROM A SCRIPT. `config list`
68
+ * enumerates the section dynamically and so shows these keys whether they are
69
+ * here or not; `config set` does not, so without this block
70
+ * `aragon config set compaction.enabled false` fails with "Unknown config key"
71
+ * while the key is visibly listed one command over. Manual-test row 9 — the
72
+ * persisted-off-survives-a-flagless-run check — runs exactly that command.
73
+ */
74
+ export declare const COMPACTION_CONFIG_SET_KEYS: readonly ["compaction.enabled", "compaction.threshold", "compaction.warnThreshold", "compaction.keepRecentTurns", "compaction.useFastTier", "compaction.onFailure", "compaction.subagents", "compaction.archive"];
57
75
  /**
58
76
  * Translate a `log.*` dotted key into a patch, or `null` for anything else.
59
77
  *
@@ -120,6 +138,29 @@ export declare function applyFastConfigSet(key: string, value: string): Partial<
120
138
  * while the caller echoes the STORED value (AC-20).
121
139
  */
122
140
  export declare function applyUpdateConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
141
+ /**
142
+ * Translate a `compaction.*` dotted key into a patch, or `null` for anything
143
+ * else (context-auto-compaction §4.2).
144
+ *
145
+ * EVERY KEY ROUTES THROUGH `clampCompactionConfig`, not through a local parse,
146
+ * for the reason `applyRetryConfigSet`, `applyFastConfigSet` and
147
+ * `applyUpdateConfigSet` all record: one gate for both directions means no key
148
+ * here can pick up a different coercion later, and `config set
149
+ * compaction.threshold 0.2` writes the clamped `0.5` while the caller echoes the
150
+ * STORED value.
151
+ *
152
+ * `threshold` and `warnThreshold` additionally accept a PERCENTAGE, because that
153
+ * is the unit the status bar shows and `/compact threshold` accepts.
154
+ *
155
+ * A VALUE THE PARSER REJECTS RESOLVES TO THE DEFAULT, NOT TO THE CURRENT SETTING
156
+ * — the same thing `config set retry.maxRetries banana` does, because `clampOne`
157
+ * merges the field onto `DEFAULT_COMPACTION_CONFIG` before clamping it. That is
158
+ * the house behaviour and it is not silent: `cli.tsx` echoes the STORED value, so
159
+ * a typo prints the number that was actually written rather than the one that was
160
+ * typed. Unlike the settings screen and `/compact threshold`, both of which
161
+ * genuinely ignore an unparseable value, this surface has no third state.
162
+ */
163
+ export declare function applyCompactionConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
123
164
  /**
124
165
  * `log.redactSecrets false` is the one setting that lets credentials reach the
125
166
  * disk. It is command-line-only (there is no settings-screen entry) and it says