@aragon-agent/cli 0.6.3 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +203 -13
  3. package/dist/agent/controller.d.ts +158 -2
  4. package/dist/agent/controller.js +400 -1
  5. package/dist/agent/reducer.d.ts +102 -19
  6. package/dist/agent/reducer.js +162 -71
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/agent/usage.d.ts +34 -0
  10. package/dist/agent/usage.js +43 -0
  11. package/dist/cli.js +132 -23
  12. package/dist/commands/builtins.js +101 -1
  13. package/dist/compaction/child.js +5 -0
  14. package/dist/compaction/command.js +14 -1
  15. package/dist/compaction/compactor.d.ts +94 -24
  16. package/dist/compaction/compactor.js +247 -98
  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 +77 -0
  24. package/dist/compaction/wiring.d.ts +56 -0
  25. package/dist/compaction/wiring.js +107 -25
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.d.ts +1 -1
  28. package/dist/config/env.js +63 -2
  29. package/dist/config/load.d.ts +28 -0
  30. package/dist/config/load.js +83 -1
  31. package/dist/config/schema.d.ts +129 -0
  32. package/dist/config/schema.js +69 -0
  33. package/dist/config/store.js +47 -1
  34. package/dist/exec/events.d.ts +138 -1
  35. package/dist/exec/events.js +42 -0
  36. package/dist/exec/index.js +43 -6
  37. package/dist/exec/progress-reporter.d.ts +23 -0
  38. package/dist/exec/progress-reporter.js +88 -0
  39. package/dist/exec/runner.d.ts +34 -0
  40. package/dist/exec/runner.js +137 -25
  41. package/dist/fast/reviewer.d.ts +19 -1
  42. package/dist/fast/reviewer.js +22 -0
  43. package/dist/fast/types.d.ts +15 -0
  44. package/dist/fast/wiring.d.ts +10 -1
  45. package/dist/fast/wiring.js +18 -1
  46. package/dist/input/limits.d.ts +72 -0
  47. package/dist/input/limits.js +63 -0
  48. package/dist/input/paste-parse.d.ts +71 -0
  49. package/dist/input/paste-parse.js +95 -0
  50. package/dist/input/stdin-filter.d.ts +81 -0
  51. package/dist/input/stdin-filter.js +379 -0
  52. package/dist/logging/install.d.ts +2 -0
  53. package/dist/logging/install.js +53 -1
  54. package/dist/proc/classify.d.ts +52 -0
  55. package/dist/proc/classify.js +143 -0
  56. package/dist/proc/kill-tree.d.ts +42 -0
  57. package/dist/proc/kill-tree.js +119 -0
  58. package/dist/proc/limits.d.ts +84 -0
  59. package/dist/proc/limits.js +84 -0
  60. package/dist/proc/log-ring.d.ts +68 -0
  61. package/dist/proc/log-ring.js +102 -0
  62. package/dist/proc/prompt.d.ts +24 -0
  63. package/dist/proc/prompt.js +49 -0
  64. package/dist/proc/readiness.d.ts +102 -0
  65. package/dist/proc/readiness.js +219 -0
  66. package/dist/proc/supervisor.d.ts +103 -0
  67. package/dist/proc/supervisor.js +465 -0
  68. package/dist/proc/types.d.ts +175 -0
  69. package/dist/proc/types.js +15 -0
  70. package/dist/session/persist.js +36 -1
  71. package/dist/todo/limits.d.ts +4 -17
  72. package/dist/todo/limits.js +4 -17
  73. package/dist/todo/panel-rows.d.ts +8 -6
  74. package/dist/todo/panel-rows.js +19 -6
  75. package/dist/tools/bash-tool.d.ts +51 -2
  76. package/dist/tools/bash-tool.js +321 -51
  77. package/dist/tools/fs-tools.d.ts +17 -0
  78. package/dist/tools/index.d.ts +55 -2
  79. package/dist/tools/index.js +39 -1
  80. package/dist/tools/proc-tools.d.ts +25 -0
  81. package/dist/tools/proc-tools.js +102 -0
  82. package/dist/ui/App.d.ts +15 -2
  83. package/dist/ui/App.js +330 -69
  84. package/dist/ui/AutocompletePopup.d.ts +3 -1
  85. package/dist/ui/AutocompletePopup.js +9 -11
  86. package/dist/ui/BottomStatusRow.d.ts +1 -1
  87. package/dist/ui/Composer.d.ts +56 -1
  88. package/dist/ui/Composer.js +50 -6
  89. package/dist/ui/PromptInput.d.ts +52 -3
  90. package/dist/ui/PromptInput.js +201 -55
  91. package/dist/ui/StatusBar.d.ts +40 -15
  92. package/dist/ui/StatusBar.js +51 -9
  93. package/dist/ui/TeamPanel.d.ts +3 -1
  94. package/dist/ui/TeamPanel.js +13 -9
  95. package/dist/ui/TodoPanel.d.ts +5 -5
  96. package/dist/ui/TodoPanel.js +56 -24
  97. package/dist/ui/Transcript.js +29 -1
  98. package/dist/ui/UpdateLine.d.ts +1 -1
  99. package/dist/ui/capabilities.js +5 -5
  100. package/dist/ui/composer-limits.d.ts +31 -0
  101. package/dist/ui/composer-limits.js +50 -0
  102. package/dist/ui/composer-rows.d.ts +73 -0
  103. package/dist/ui/composer-rows.js +201 -0
  104. package/dist/ui/editor-reducer.d.ts +37 -0
  105. package/dist/ui/editor-reducer.js +88 -12
  106. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  107. package/dist/ui/entries/ServiceCard.js +116 -0
  108. package/dist/ui/entries/UserEntry.d.ts +17 -3
  109. package/dist/ui/entries/UserEntry.js +34 -5
  110. package/dist/ui/exit-snapshot.d.ts +9 -0
  111. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  112. package/dist/ui/layout/autocomplete.d.ts +18 -0
  113. package/dist/ui/layout/autocomplete.js +26 -0
  114. package/dist/ui/layout/budget.d.ts +34 -6
  115. package/dist/ui/layout/budget.js +32 -7
  116. package/dist/ui/layout/rail.d.ts +4 -53
  117. package/dist/ui/layout/rail.js +10 -62
  118. package/dist/ui/layout/team-panel.d.ts +18 -0
  119. package/dist/ui/layout/team-panel.js +22 -0
  120. package/dist/ui/layout/todo-layout.d.ts +24 -0
  121. package/dist/ui/layout/todo-layout.js +27 -0
  122. package/dist/ui/layout/virtual-window.js +51 -1
  123. package/dist/ui/overlays/HelpOverlay.js +3 -0
  124. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  125. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  126. package/dist/ui/overlays/SettingsScreen.js +14 -1
  127. package/dist/ui/paste-frames.d.ts +87 -0
  128. package/dist/ui/paste-frames.js +112 -0
  129. package/dist/ui/paste-tokens.d.ts +90 -0
  130. package/dist/ui/paste-tokens.js +147 -0
  131. package/dist/ui/screen.d.ts +16 -0
  132. package/dist/ui/screen.js +45 -2
  133. package/dist/ui/selection/selection-controller.d.ts +1 -1
  134. package/dist/ui/transcript-text.js +55 -2
  135. package/dist/ui/use-wheel-routing.d.ts +1 -1
  136. package/package.json +2 -2
  137. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  138. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -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');
@@ -26,6 +27,15 @@ export class Compactor {
26
27
  lastCompactionTurn = -Infinity;
27
28
  // --- Per-session state ---
28
29
  compactions = 0;
30
+ /**
31
+ * How many `compact()` calls returned WITHOUT announcing (quiet-noop D-9).
32
+ *
33
+ * A SESSION TOTAL, LIKE `compactions`, and reset by neither `onRunStart()` nor
34
+ * `clearSelfDisable()`: `/compact on` overrides a guard, it does not rewrite
35
+ * history. `/compact status` is its only reader, and it is the whole of what
36
+ * keeps a decline reportable once the transcript stops carrying one.
37
+ */
38
+ declined = 0;
29
39
  tokensReclaimed = 0;
30
40
  sessionUsage = { inputTokens: 0, outputTokens: 0 };
31
41
  pricingUnknown = false;
@@ -35,18 +45,15 @@ export class Compactor {
35
45
  selfDisabledReason;
36
46
  pendingManual = null;
37
47
  generation = 0;
38
- /** Calibration for every ESTIMATED figure (D-23). Recomputed on every turn_end. */
39
- estimateOffset;
40
- /** The last pressure computed, so the status bar and `/compact status` agree. */
41
- lastPressure = null;
42
48
  /**
43
- * 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).
44
50
  *
45
- * `undefined` DISABLES THE DELTA and reproduces round 1's arithmetic exactly.
46
- * Every host-side mutation that is not an append must clear it - see
47
- * `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`.
48
55
  */
49
- measuredPrefixLength;
56
+ ownMeter = null;
50
57
  /**
51
58
  * The messages the last splice dropped, held ONLY until the wiring collects
52
59
  * them for the archive (W4 / RV-3).
@@ -78,28 +85,34 @@ export class Compactor {
78
85
  this.lastCompactionTurn = -Infinity;
79
86
  }
80
87
  /**
81
- * `turn_end` — recompute the calibration offset (D-23).
88
+ * `turn_end` - recompute the calibration offset (D-23).
89
+ *
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.
82
93
  *
83
- * RE-MEASURED EVERY TURN, not once, so it tracks a toolset that changed
84
- * mid-session (`/tools`, a skill that registers one).
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.
85
97
  */
86
- onTurnEnd(usage, messages, systemPrompt) {
87
- this.estimateOffset = computeEstimateOffset(usage, messages, systemPrompt);
88
- // THE SAME ARRAY `computeEstimateOffset` JUST MEASURED, so the two numbers
89
- // describe the same request by construction (hardening §3.2.3).
90
- this.measuredPrefixLength = messages.length;
98
+ onTurnEnd(usage, _messages, _systemPrompt) {
99
+ this.meter().onTurnEnd(usage);
91
100
  }
92
- /**
93
- * 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.
94
112
  *
95
- * `/clear`, `/resume`, and the splice itself: after any of them a recorded
96
- * index means nothing, and the delta would be measured against the wrong
97
- * array. `estimateAppendedTokens` bounds-checks as well, so a MISSED call here
98
- * 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.
99
115
  */
100
- invalidateMeasurement() {
101
- this.measuredPrefixLength = undefined;
102
- }
103
116
  /**
104
117
  * The dropped messages, released as they are returned (W4 / RV-3).
105
118
  *
@@ -163,17 +176,13 @@ export class Compactor {
163
176
  const config = this.deps.getConfig().compaction;
164
177
  if (!config.enabled)
165
178
  return false;
166
- // THE HISTORY IS PASSED IN, NEVER OMITTED (§3.4.2). Without it the estimate
167
- // branch of `computePressure` measures an EMPTY array and reports 0 %, so a
168
- // resumed 180 k-token session — where `lastUsage` is `undefined` by
169
- // definition and this branch is the only one available — would never trigger.
170
- // The measured branch reads `messages` only to estimate what was appended
171
- // after the measurement (hardening §3.2.2); the slice is one turn wide.
172
- const pressure = this.measure({
173
- ...(probe.lastUsage ? { lastUsage: probe.lastUsage } : {}),
174
- messages: this.deps.getMessages(),
175
- systemPrompt: this.deps.getSystemPrompt(),
176
- });
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);
177
186
  this.deps.onPressure?.(pressure);
178
187
  const manual = this.pendingManual !== null;
179
188
  // Guard 2 — the per-run cap. BOTH TRIGGERS, HARD. It is the bound that makes
@@ -218,22 +227,19 @@ export class Compactor {
218
227
  const uiTrigger = manual ? 'manual' : ctx.trigger;
219
228
  const config = this.deps.getConfig().compaction;
220
229
  const summarizer = this.resolveSummarizer();
230
+ // GUARDS 1 AND 2 COUNT *ATTEMPTS*, NOT ANNOUNCEMENTS, and they stay here
231
+ // (quiet-noop §3.3). A decline still consumed a checkpoint: it still has to
232
+ // serve the cooldown and it still has to be charged to the per-run cap, or a
233
+ // history that can never be compacted would re-attempt on every turn for the
234
+ // whole run. `inFlight` stays for the same reason - the chip reports what the
235
+ // compactor is DOING, not what it has decided to say.
221
236
  this.compactionsThisRun += 1;
222
237
  this.lastCompactionTurn = ctx.turnIndex;
223
- this.compactions += 1;
224
238
  this.inFlight = true;
225
- const index = this.compactions;
226
- this.deps.emit({
227
- type: 'compaction_start',
228
- index,
229
- trigger: uiTrigger,
230
- model: summarizer?.ref.modelId ?? '',
231
- });
232
239
  const started = Date.now();
233
240
  try {
234
241
  return await this.runCompaction({
235
242
  ctx,
236
- index,
237
243
  uiTrigger,
238
244
  config,
239
245
  summarizer,
@@ -250,8 +256,56 @@ export class Compactor {
250
256
  // =========================================================================
251
257
  // Internals
252
258
  // =========================================================================
259
+ /**
260
+ * Open the card (quiet-noop §3.2).
261
+ *
262
+ * THE ONLY EMITTER OF `compaction_start`, AND THE ONLY PLACE `this.compactions`
263
+ * ADVANCES. A compaction that never reaches an announce point is a DECLINE: it
264
+ * changed nothing, called nothing and spent nothing, and it is deliberately
265
+ * indistinguishable - on every human surface except `/compact status`'s
266
+ * `declined` count (D-9) - from a turn at which `shouldCompact` returned false.
267
+ * `finish` is the other half of that rule.
268
+ *
269
+ * IT RETURNS THE ANNOUNCEMENT RATHER THAN STORING IT (P1-1), and the caller
270
+ * keeps it in a LOCAL for the lifetime of one `runCompaction` call - the same
271
+ * slot `index` occupied before this change. An instance field would have needed
272
+ * a no-re-entrancy argument, and the obvious one is WRONG: guard 1 lives in
273
+ * `shouldCompact`, while the idle `/compact` path calls `compact()` straight
274
+ * through `wiring.compactNow()` without consulting it. What actually keeps two
275
+ * compactions apart is that `command.ts` QUEUES instead of calling `compactNow`
276
+ * while the loop runs - a property of a different file, which is exactly why
277
+ * this one must not depend on it. A clobbered field would leave `finish` unable
278
+ * to settle the card it opened, and a card that never settles pins
279
+ * `Transcript`'s monotonic boundary for the rest of the session.
280
+ */
281
+ announce(trigger, summarizer) {
282
+ this.compactions += 1;
283
+ this.deps.emit({
284
+ type: 'compaction_start',
285
+ index: this.compactions,
286
+ trigger,
287
+ model: summarizer?.ref.modelId ?? '',
288
+ });
289
+ return { index: this.compactions };
290
+ }
253
291
  async runCompaction(args) {
254
292
  const { ctx, config, summarizer } = args;
293
+ // THE ANNOUNCEMENT IS A LOCAL, NEVER A FIELD (quiet-noop §3.2 / P1-1), and
294
+ // `??=` is what makes the eager path and the two commit points idempotent
295
+ // with respect to each other.
296
+ let announced = null;
297
+ const announce = (model) => {
298
+ announced ??= this.announce(args.uiTrigger, model);
299
+ };
300
+ // A REQUESTED compaction and a REACTIVE one announce BEFORE they know the
301
+ // answer, for two different reasons. `manual` is a user instruction, and its
302
+ // QUEUED form (`/compact` typed mid-run) has NO other reporting surface - a
303
+ // silent decline there would be a command that did nothing and said nothing.
304
+ // `overflow` runs after the provider has ALREADY refused the request; if it
305
+ // cannot recover, the card is the only place that says why the run is about
306
+ // to die. Both paths stay byte-identical to the round before this one.
307
+ if (args.uiTrigger !== 'pressure')
308
+ announce(summarizer);
255
309
  const before = ctx.messages;
256
310
  const protectedPrefix = countProtectedPrefix(before);
257
311
  // THE ONE LEVER THE GUARDS DO NOT HAVE (D-24). A tail that alone exceeds the
@@ -271,6 +325,12 @@ export class Compactor {
271
325
  // `plan === null` branch tries it before conceding.
272
326
  const reliefOnly = this.relieveInPlace(before, protectedPrefix, ctx.systemPrompt);
273
327
  if (reliefOnly) {
328
+ // COMMIT POINT A (quiet-noop §3.4). Relief changes the history the run
329
+ // continues from - a bounded, announced data loss - so it is announced
330
+ // even though no model was called. `null` summarizer for the same reason
331
+ // `finish` writes `model: ''` on a relief-only record: naming a model here
332
+ // would claim a call that never ran.
333
+ announce(null);
274
334
  this.checkProgress(ctx, args.uiTrigger, reliefOnly.messages);
275
335
  return this.finish({ ...args, tailRelief: reliefSummary(reliefOnly) }, {
276
336
  action: 'replace',
@@ -282,17 +342,51 @@ export class Compactor {
282
342
  mode: 'truncated',
283
343
  messages: reliefOnly.messages,
284
344
  reason: 'tail_relief_only',
285
- }, { droppedMessages: 0 }, keepRecentTurns);
345
+ }, { droppedMessages: 0 }, keepRecentTurns, announced);
286
346
  }
287
347
  this.noteNoProgress(args.uiTrigger, 'nothing_to_drop');
288
- return this.finish(args, { action: 'keep', reason: 'nothing_to_drop' }, null, keepRecentTurns);
348
+ return this.finish(args, { action: 'keep', reason: 'nothing_to_drop' }, null, keepRecentTurns, announced);
289
349
  }
290
350
  const anchor = buildAnchor(before);
291
351
  if (!anchor) {
292
352
  // A history with no `user` message at all cannot have a goal anchor, and
293
353
  // D-6 makes the anchor non-negotiable. Refusing is the honest outcome.
294
- return this.finish(args, { action: 'keep', reason: 'no_anchor' }, null, keepRecentTurns);
354
+ //
355
+ // STRUCTURALLY UNREACHABLE, AND KEPT ANYWAY (quiet-noop E-23 / §3.4). This
356
+ // is reached only after `plan !== null`, and `planCompaction` returns a plan
357
+ // only when some cut leaves `countTurns >= keep` behind it - `countTurns`
358
+ // counts `user`-role messages and `keep` is at least 1, so a plan PROVES the
359
+ // one thing `buildAnchor` looks for. A provably dead guard is worth strictly
360
+ // more than the non-null assertion that would replace it. It stays ABOVE the
361
+ // commit point on purpose: if the plan/anchor relationship ever changes, the
362
+ // reason that appears is one decided before any work began, and silence is
363
+ // what it should get - but any new PRE-COMMIT decline must then either
364
+ // charge guard 4 (as `nothing_to_drop` does) or be proven unreachable in
365
+ // turn, because guard 4 is what bounds how long a silent decline can repeat.
366
+ return this.finish(args, { action: 'keep', reason: 'no_anchor' }, null, keepRecentTurns, announced);
295
367
  }
368
+ // COMMIT POINT B (quiet-noop §3.4). Everything below this line either spends
369
+ // money (`runLadder`), rewrites the history, or faults - and all three are the
370
+ // user's business.
371
+ //
372
+ // BEFORE `buildDigest`, NOT AFTER. A throw inside the digest renderer reaches
373
+ // the engine as `manager_threw`, and the engine settles the card from the
374
+ // verdict alone; with no card open, that fault would be invisible on every
375
+ // human surface. Announcing first costs one event on a path that is about to
376
+ // spend seconds and dollars.
377
+ //
378
+ // WHAT IT STILL DOES NOT COVER (recorded, not closed - quiet-noop §12 / IF-2).
379
+ // A throw in the SYNCHRONOUS region ABOVE this line - `countProtectedPrefix`,
380
+ // `planCompaction`, `relieveInPlace`, `buildAnchor` - also reaches the engine
381
+ // as `manager_threw`, and on `pressure` that fault now has no card, charges no
382
+ // guard (`noteNoProgress` is never reached) and does not advance `declined`
383
+ // (`finish` is never called) - so a host bug there would repeat at every
384
+ // checkpoint for a whole run in silence. `manager_timeout` cannot land there:
385
+ // that region holds no `await`, which is the property AC-Q12 pins. Closing it
386
+ // means hoisting the announcement out of this function, which is precisely
387
+ // what P1-1 decided against - so it is written down rather than fixed under
388
+ // another change's name.
389
+ announce(summarizer);
296
390
  const head = before.slice(protectedPrefix, plan.cutIndex);
297
391
  const priorBlock = before.slice(0, protectedPrefix).find((m) => isPriorSummaryBlock(m));
298
392
  // The prior block is carried into the DIGEST rather than the tail, so the
@@ -312,14 +406,14 @@ export class Compactor {
312
406
  ...(args.instructions ? { instructions: args.instructions } : {}),
313
407
  });
314
408
  if (ladder.kind === 'aborted') {
315
- return this.finish(args, { action: 'keep', reason: 'aborted' }, null, keepRecentTurns);
409
+ return this.finish(args, { action: 'keep', reason: 'aborted' }, null, keepRecentTurns, announced);
316
410
  }
317
411
  if (ladder.kind === 'failed' && config.onFailure === 'stop') {
318
412
  // Rung 4. The run proceeds and will probably hit `context_overflow`, which
319
413
  // is what the user asked for by setting this.
320
414
  this.deps.notify('error', `Context compaction failed (${ladder.reason}) and compaction.onFailure is "stop". ` +
321
415
  'The next request may exceed the context window. Try /compact again, or /clear, or /reset.');
322
- return this.finish(args, { action: 'keep', reason: `summarize_failed: ${ladder.reason}` }, null, keepRecentTurns);
416
+ return this.finish(args, { action: 'keep', reason: `summarize_failed: ${ladder.reason}` }, null, keepRecentTurns, announced);
323
417
  }
324
418
  const truncated = ladder.kind === 'failed';
325
419
  const mode = truncated ? 'truncated' : 'summarized';
@@ -365,7 +459,7 @@ export class Compactor {
365
459
  messages,
366
460
  ...(truncated ? {} : { summary: ladder.text }),
367
461
  ...(truncated ? { reason: `truncated: ${ladder.reason}` } : {}),
368
- }, { droppedMessages: plan.droppedMessages, summary: truncated ? undefined : ladder.text }, keepRecentTurns);
462
+ }, { droppedMessages: plan.droppedMessages, summary: truncated ? undefined : ladder.text }, keepRecentTurns, announced);
369
463
  }
370
464
  /** Tail relief against this session's window (hardening §3.3.3 / W2). */
371
465
  relieveInPlace(messages, from, systemPrompt) {
@@ -474,12 +568,17 @@ export class Compactor {
474
568
  checkProgress(ctx, uiTrigger, after) {
475
569
  const config = this.deps.getConfig().compaction;
476
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;
477
576
  const projected = computePressure({
478
577
  messages: after,
479
578
  systemPrompt: ctx.systemPrompt,
480
579
  contextWindow: info.contextWindow,
481
580
  windowKnown: true,
482
- ...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
581
+ ...(estimateOffset !== undefined ? { estimateOffset } : {}),
483
582
  });
484
583
  const target = config.threshold - COMPACTION_LIMITS.minReclaimRatio;
485
584
  const madeProgress = projected.ratio < target;
@@ -522,19 +621,59 @@ export class Compactor {
522
621
  `${this.deps.getConfig().compaction.keepRecentTurns} turns alone exceed the threshold. ` +
523
622
  'Lower compaction.keepRecentTurns, or use /clear or /reset.');
524
623
  }
525
- finish(args, outcome, applied, keepRecentTurnsUsed) {
624
+ finish(args, outcome, applied, keepRecentTurnsUsed, announced) {
526
625
  const before = args.ctx.messages.length;
527
626
  const after = outcome.action === 'replace' ? outcome.messages.length : before;
528
- 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;
630
+ // `'relieved'` IS A CLI-ONLY WORD (RV-5 / DH-20). The port reports
631
+ // `'truncated'` for a relief-only compaction, which is accurate for the
632
+ // engine's narrower question; the card's `summarized N messages with
633
+ // <model>` line would otherwise render "summarized 0 messages with
634
+ // claude-haiku-4-5" for a compaction in which no model was called.
635
+ const mode = reliefOnly(outcome)
636
+ ? 'relieved'
637
+ : outcome.action === 'replace'
638
+ ? outcome.mode
639
+ : 'none';
640
+ // NO MODEL ON A RELIEF-ONLY RECORD, because no model was called. The card
641
+ // names the summarizer from this field, and naming one here would be the
642
+ // same claim `mode: 'relieved'` exists to stop making.
643
+ const model = reliefOnly(outcome) ? '' : args.summarizer?.ref.modelId ?? '';
644
+ const durationMs = Date.now() - args.started;
645
+ // THE ON-DISK RECORD OF EVERY ATTEMPT IS UNCHANGED (quiet-noop §9). This
646
+ // feature makes the TRANSCRIPT quiet, not the logs: a declined compaction is
647
+ // still one `compaction` line here, still one `compaction_start` /
648
+ // `compaction_end` pair from CORE in the JSONL sink, and still reflected in
649
+ // `/compact status`.
650
+ this.log.info('compaction', {
651
+ trigger: args.uiTrigger,
652
+ announced: announced !== null,
653
+ before,
654
+ after,
655
+ droppedMessages: applied?.droppedMessages ?? 0,
656
+ mode,
657
+ model,
658
+ durationMs,
659
+ });
660
+ // A SILENT DECLINE (quiet-noop §3.1). No card was opened, so there is nothing
661
+ // to settle and nothing to say IN THE TRANSCRIPT. Returning here is what makes
662
+ // a declined checkpoint indistinguishable from one at which `shouldCompact`
663
+ // said no.
664
+ //
665
+ // `declined` IS THE ONE THING THAT STILL SPEAKS (D-9). Quiet is the goal;
666
+ // invisible is not, and `/compact status` is a published guaranteed surface
667
+ // (`context-auto-compaction` §6.5). One integer here is what keeps that
668
+ // promise true while the card goes away.
669
+ if (!announced) {
670
+ this.declined += 1;
671
+ return outcome;
672
+ }
529
673
  const record = {
530
- index: args.index,
674
+ index: announced.index,
531
675
  trigger: args.uiTrigger,
532
- // `'relieved'` IS A CLI-ONLY WORD (RV-5 / DH-20). The port reports
533
- // `'truncated'` for a relief-only compaction, which is accurate for the
534
- // engine's narrower question; the card's `summarized N messages with
535
- // <model>` line would otherwise render "summarized 0 messages with
536
- // claude-haiku-4-5" for a compaction in which no model was called.
537
- mode: reliefOnly(outcome) ? 'relieved' : outcome.action === 'replace' ? outcome.mode : 'none',
676
+ mode,
538
677
  // OPTIMISTIC AND CORRECTED BY THE WIRING (§3.2.1). Only the ENGINE knows
539
678
  // whether the splice passed the structural gate, and it says so on
540
679
  // `compaction_end`; the wiring rewrites this before the card settles.
@@ -546,23 +685,11 @@ export class Compactor {
546
685
  tokensBefore,
547
686
  tokensAfter: 0, // filled by the wiring from the engine's own estimator
548
687
  ...(applied?.summary ? { summary: applied.summary } : {}),
549
- // NO MODEL ON A RELIEF-ONLY RECORD, because no model was called. The card
550
- // names the summarizer from this field, and naming one here would be the
551
- // same claim `mode: 'relieved'` exists to stop making.
552
- model: reliefOnly(outcome) ? '' : args.summarizer?.ref.modelId ?? '',
553
- durationMs: Date.now() - args.started,
688
+ model,
689
+ durationMs,
554
690
  ...(args.ctx.trigger === 'overflow' ? { keepRecentTurnsUsed } : {}),
555
691
  ...(args.tailRelief ? { tailRelief: args.tailRelief } : {}),
556
692
  };
557
- this.log.info('compaction', {
558
- trigger: args.uiTrigger,
559
- before,
560
- after,
561
- droppedMessages: applied?.droppedMessages ?? 0,
562
- mode: record.mode,
563
- model: record.model,
564
- durationMs: record.durationMs,
565
- });
566
693
  this.deps.emit({ type: 'compaction_end', record });
567
694
  return outcome;
568
695
  }
@@ -570,32 +697,30 @@ export class Compactor {
570
697
  // Reporting helpers
571
698
  // =========================================================================
572
699
  /**
573
- * The pressure the trigger and the gauge both read.
700
+ * The pressure the trigger and the gauge both read - MEASURED NOW.
574
701
  *
575
- * CACHED on every evaluation, so `/compact status` reports the same number the
576
- * 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).
577
707
  */
578
708
  measure(probe) {
579
- const info = this.modelInfo();
580
- const pressure = computePressure({
581
- ...(probe.lastUsage ? { lastUsage: probe.lastUsage } : {}),
582
- messages: probe.messages ?? [],
583
- systemPrompt: probe.systemPrompt ?? '',
584
- contextWindow: info.contextWindow,
585
- windowKnown: this.deps.isPricedModel({
586
- providerId: this.mainRef().providerId,
587
- modelId: this.mainRef().modelId,
588
- }),
589
- ...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
590
- ...(this.measuredPrefixLength !== undefined
591
- ? { measuredPrefixLength: this.measuredPrefixLength }
592
- : {}),
593
- });
594
- this.lastPressure = pressure;
595
- return pressure;
709
+ return this.meter().measureWith(probe.lastUsage);
596
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
+ */
597
722
  lastMeasured() {
598
- return this.lastPressure;
723
+ return this.meter().lastPublished();
599
724
  }
600
725
  /** Written by the wiring once the engine has reported its verdict. */
601
726
  recordApplied(tokensBefore, tokensAfter) {
@@ -605,6 +730,7 @@ export class Compactor {
605
730
  sessionTotals() {
606
731
  return {
607
732
  compactions: this.compactions,
733
+ declined: this.declined,
608
734
  tokensReclaimed: this.tokensReclaimed,
609
735
  usage: this.sessionUsage,
610
736
  pricingUnknown: this.pricingUnknown,
@@ -647,6 +773,29 @@ export class Compactor {
647
773
  return null;
648
774
  return { ref, fromFastTier: false };
649
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
+ }
650
799
  mainRef() {
651
800
  const config = this.deps.getConfig();
652
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;