@aragon-agent/cli 0.6.14 → 0.6.15

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 (113) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +250 -233
  3. package/dist/agent/controller.d.ts +27 -1
  4. package/dist/agent/controller.js +182 -45
  5. package/dist/agent/plan-prompt.d.ts +1 -1
  6. package/dist/agent/plan-prompt.js +4 -3
  7. package/dist/agent/project-guidance-prompt.d.ts +4 -0
  8. package/dist/agent/project-guidance-prompt.js +104 -0
  9. package/dist/agent/reducer.d.ts +2 -0
  10. package/dist/agent/reducer.js +4 -0
  11. package/dist/agent/system-prompt.js +4 -0
  12. package/dist/commands/builtins.js +2 -2
  13. package/dist/compaction/child.d.ts +9 -1
  14. package/dist/compaction/child.js +39 -13
  15. package/dist/compaction/command.js +20 -10
  16. package/dist/compaction/compactor.d.ts +33 -204
  17. package/dist/compaction/compactor.js +311 -726
  18. package/dist/compaction/digest.d.ts +15 -51
  19. package/dist/compaction/digest.js +113 -148
  20. package/dist/compaction/limits.d.ts +19 -142
  21. package/dist/compaction/limits.js +20 -147
  22. package/dist/compaction/memory-identity.d.ts +16 -0
  23. package/dist/compaction/memory-identity.js +63 -0
  24. package/dist/compaction/memory-input.d.ts +25 -0
  25. package/dist/compaction/memory-input.js +106 -0
  26. package/dist/compaction/memory-render.d.ts +4 -0
  27. package/dist/compaction/memory-render.js +47 -0
  28. package/dist/compaction/memory-validation.d.ts +21 -0
  29. package/dist/compaction/memory-validation.js +268 -0
  30. package/dist/compaction/memory.d.ts +79 -0
  31. package/dist/compaction/memory.js +118 -0
  32. package/dist/compaction/meter.d.ts +14 -10
  33. package/dist/compaction/meter.js +53 -19
  34. package/dist/compaction/operation.d.ts +18 -0
  35. package/dist/compaction/operation.js +68 -0
  36. package/dist/compaction/pressure.d.ts +5 -20
  37. package/dist/compaction/pressure.js +13 -23
  38. package/dist/compaction/summarize-call.d.ts +3 -16
  39. package/dist/compaction/summarize-call.js +20 -20
  40. package/dist/compaction/summary-prompt.d.ts +29 -66
  41. package/dist/compaction/summary-prompt.js +132 -178
  42. package/dist/compaction/types.d.ts +21 -3
  43. package/dist/compaction/wiring.d.ts +10 -25
  44. package/dist/compaction/wiring.js +56 -66
  45. package/dist/config/schema.d.ts +1 -1
  46. package/dist/config/schema.js +1 -1
  47. package/dist/exec/events.d.ts +8 -0
  48. package/dist/exec/index.js +13 -6
  49. package/dist/exec/runner.d.ts +8 -0
  50. package/dist/exec/runner.js +26 -2
  51. package/dist/session/persist.d.ts +3 -0
  52. package/dist/session/persist.js +9 -0
  53. package/dist/session/store.js +2 -0
  54. package/dist/session/validate-session.js +34 -0
  55. package/dist/team/subagent.js +8 -0
  56. package/dist/ui/App.js +218 -116
  57. package/dist/ui/BottomStatusRow.d.ts +5 -10
  58. package/dist/ui/BottomStatusRow.js +7 -11
  59. package/dist/ui/Composer.d.ts +4 -2
  60. package/dist/ui/Composer.js +2 -2
  61. package/dist/ui/Header.d.ts +6 -2
  62. package/dist/ui/Header.js +7 -6
  63. package/dist/ui/PromptInput.d.ts +3 -1
  64. package/dist/ui/PromptInput.js +32 -41
  65. package/dist/ui/StatusBar.d.ts +6 -0
  66. package/dist/ui/StatusBar.js +8 -7
  67. package/dist/ui/Transcript.js +1 -1
  68. package/dist/ui/entries/CompactionCard.d.ts +3 -1
  69. package/dist/ui/entries/CompactionCard.js +7 -5
  70. package/dist/ui/frame-differ.js +27 -0
  71. package/dist/ui/frame-observer.js +18 -5
  72. package/dist/ui/interaction-copy.d.ts +73 -73
  73. package/dist/ui/interaction-copy.js +30 -38
  74. package/dist/ui/interaction-hints.d.ts +8 -1
  75. package/dist/ui/interaction-hints.js +56 -69
  76. package/dist/ui/layout/AppShell.d.ts +6 -2
  77. package/dist/ui/layout/AppShell.js +2 -2
  78. package/dist/ui/layout/OverlayFrame.d.ts +2 -0
  79. package/dist/ui/layout/OverlayFrame.js +23 -14
  80. package/dist/ui/layout/ScrollViewport.d.ts +3 -1
  81. package/dist/ui/layout/ScrollViewport.js +10 -2
  82. package/dist/ui/layout/budget.d.ts +25 -26
  83. package/dist/ui/layout/budget.js +29 -22
  84. package/dist/ui/layout/document-layout.d.ts +2 -2
  85. package/dist/ui/layout/document-layout.js +2 -3
  86. package/dist/ui/layout/queue-layout.js +4 -4
  87. package/dist/ui/layout/status-detail-layout.d.ts +9 -0
  88. package/dist/ui/layout/status-detail-layout.js +56 -0
  89. package/dist/ui/layout/status-layout.d.ts +19 -2
  90. package/dist/ui/layout/status-layout.js +95 -84
  91. package/dist/ui/layout/wrap-rows.d.ts +2 -0
  92. package/dist/ui/layout/wrap-rows.js +21 -7
  93. package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
  94. package/dist/ui/overlays/ConfirmDialog.js +22 -3
  95. package/dist/ui/overlays/HelpOverlay.js +9 -2
  96. package/dist/ui/overlays/ModelPicker.d.ts +5 -8
  97. package/dist/ui/overlays/ModelPicker.js +44 -10
  98. package/dist/ui/overlays/ModelProfileEditor.js +2 -0
  99. package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
  100. package/dist/ui/overlays/ModelProfilePicker.js +4 -2
  101. package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
  102. package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
  103. package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
  104. package/dist/ui/overlays/QuestionOverlay.js +45 -32
  105. package/dist/ui/overlays/QueueOverlay.js +2 -2
  106. package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
  107. package/dist/ui/overlays/SettingsScreen.js +10 -3
  108. package/dist/ui/run-metrics.d.ts +23 -0
  109. package/dist/ui/run-metrics.js +27 -0
  110. package/dist/ui/status-feedback.d.ts +33 -0
  111. package/dist/ui/status-feedback.js +36 -0
  112. package/dist/ui/stdout-frame-writer.js +28 -0
  113. package/package.json +2 -2
@@ -1,768 +1,384 @@
1
- /**
2
- * `Compactor` — the `ContextManager` implementation (context-auto-compaction
3
- * §3.4 / §3.6 / §3.7 / §3.8).
4
- *
5
- * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope.
6
- *
7
- * THIS IS THE POLICY HALF OF THE FEATURE. The engine owns exactly one thing -
8
- * structural validity - and every other decision is here: when to fire, what to
9
- * keep, which model summarizes, what the prompt asks for, what happens when the
10
- * call fails, and when to stop spending.
11
- */
12
- import { planCompaction, } from '@aragon-agent/core';
1
+ import { planCompaction, estimatePromptTokens, CONTEXT_SAFETY_MARGIN_TOKENS, } from '@aragon-agent/core';
13
2
  import { getLogger } from '../logging/logger.js';
3
+ import { computeCost } from '../agent/usage.js';
14
4
  import { resolveFastTier } from '../fast/resolve.js';
15
5
  import { COMPACTION_LIMITS } from './limits.js';
16
- import { attemptTailRelief, reliefSummary, tailBudgetTokens } from './tail-budget.js';
17
- import { buildDigest, isPriorSummaryBlock } from './digest.js';
18
- import { buildAnchor, buildCompactedBlock, countProtectedPrefix, readGeneration, truncationBody, } from './summary-prompt.js';
19
- import { accumulateUsage, errorText, runSummarizeCall } from './summarize-call.js';
20
- import { computePressure, shouldCompactAt } from './pressure.js';
6
+ import { buildDigest } from './digest.js';
7
+ import { buildCompactedBlock, countProtectedPrefix } from './summary-prompt.js';
8
+ import { accumulateUsage, errorText, runSummarizeCall, buildSummarizeRequest } from './summarize-call.js';
9
+ import { shouldCompactAt } from './pressure.js';
21
10
  import { ContextMeter } from './meter.js';
11
+ import { CompactionOperation } from './operation.js';
12
+ import { prepareMemoryInput } from './memory-input.js';
13
+ import { mergeMemory, parseMemoryDelta } from './memory.js';
14
+ import { validateMemoryDelta } from './memory-validation.js';
15
+ import { renderMemoryMarkdown } from './memory-render.js';
16
+ import { createCompactionIdentity, verifyCompactionIdentity, } from './memory-identity.js';
22
17
  export class Compactor {
23
18
  deps;
24
19
  log = getLogger().child('compaction');
25
- // --- Per-run state (reset on `agent_start`) ---
26
20
  compactionsThisRun = 0;
27
21
  lastCompactionTurn = -Infinity;
28
- // --- Per-session state ---
29
22
  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
23
  declined = 0;
39
24
  tokensReclaimed = 0;
40
25
  sessionUsage = { inputTokens: 0, outputTokens: 0 };
41
26
  pricingUnknown = false;
42
- inFlight = false;
27
+ costUsd = 0;
43
28
  consecutiveNoProgress = 0;
44
29
  selfDisabled = false;
45
30
  selfDisabledReason;
46
31
  pendingManual = null;
47
32
  generation = 0;
48
- /**
49
- * The fallback meter, built on first use when no host injected one (R-1).
50
- *
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`.
55
- */
56
33
  ownMeter = null;
57
- /**
58
- * The messages the last splice dropped, held ONLY until the wiring collects
59
- * them for the archive (W4 / RV-3).
60
- *
61
- * NOT ON `CompactionRecord`: that record is dispatched into the reducer and
62
- * retained in view state for the card's lifetime, so putting a megabyte of
63
- * history on it would park the dropped conversation in React state - precisely
64
- * the memory this compaction just reclaimed.
65
- *
66
- * SET ONLY WHEN ARCHIVING IS ON, so with the key off the slice is never
67
- * retained and W4 costs nothing.
68
- */
69
34
  lastDropped = null;
70
- // --- Abort plumbing for the summarization call (see `summarize-call.ts`) ---
71
- callAbort = null;
72
- callAbortReason = null;
73
- /** Guard 2's ceiling for this instance (hardening §3.4.2 mechanism B / DH-16). */
35
+ active = null;
36
+ historyEpoch = 0;
37
+ serial = 0;
38
+ identity;
39
+ legacyFailureNotified = false;
74
40
  maxPerRun;
75
41
  constructor(deps, opts) {
76
42
  this.deps = deps;
77
43
  this.maxPerRun = opts?.maxPerRun ?? COMPACTION_LIMITS.maxPerRun;
78
44
  }
79
- // =========================================================================
80
- // Lifetime
81
- // =========================================================================
82
- /** `agent_start` — the per-run cap resets, the session totals do not. */
83
45
  onRunStart() {
84
46
  this.compactionsThisRun = 0;
85
47
  this.lastCompactionTurn = -Infinity;
86
48
  }
87
- /**
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.
93
- *
94
- * `messages` / `systemPrompt` stay in the signature and are ignored: the meter
95
- * reads them from its own lazy deps, and every existing caller and test keeps
96
- * compiling.
97
- */
49
+ onRunEnd() { this.clearPendingManual(); }
98
50
  onTurnEnd(usage, _messages, _systemPrompt) {
99
51
  this.meter().onTurnEnd(usage);
100
52
  }
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.
112
- *
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.
115
- */
116
- /**
117
- * The dropped messages, released as they are returned (W4 / RV-3).
118
- *
119
- * `take` RATHER THAN `get`: the reference is dropped on read, so a wiring that
120
- * declines to archive (key off, or `applied: false`) cannot pin it and a second
121
- * read cannot double-write. `settlePending` calls this on EVERY verdict and
122
- * discards on the ones that do not archive, which is what guarantees the array
123
- * cannot outlive one compaction regardless of which branch was taken.
124
- */
53
+ onHistoryReplaced(identity) {
54
+ this.historyEpoch += 1;
55
+ this.clearPendingManual();
56
+ this.abort();
57
+ this.identity = identity;
58
+ this.generation = identity && verifyCompactionIdentity(this.deps.getMessages(), identity)
59
+ ? identity.generation : 0;
60
+ this.meter().onHistoryReplaced();
61
+ }
62
+ getIdentity() { return this.identity; }
125
63
  takeLastDropped() {
126
64
  const dropped = this.lastDropped;
127
65
  this.lastDropped = null;
128
66
  return dropped;
129
67
  }
130
- /** Cancel an in-flight summarization (Esc, `dispose`). */
131
68
  abort() {
132
- if (this.callAbort) {
133
- this.callAbortReason = 'cancelled';
134
- this.callAbort.abort();
135
- }
69
+ this.clearPendingManual();
70
+ this.active?.operation.abort();
136
71
  }
137
- // =========================================================================
138
- // The manual queue (§4.4 / D-17)
139
- // =========================================================================
140
- /**
141
- * Ask for a compaction at the next turn boundary.
142
- *
143
- * THE BOUNDARY IS THE ONLY MOMENT THE ENGINE IS SINGLE-THREADED WITH RESPECT
144
- * TO THE HISTORY, by construction. Replacing `messages` under a live loop is a
145
- * race; a flag `shouldCompact` honours there is not.
146
- */
147
72
  queueManual(instructions) {
148
- this.pendingManual = instructions ? { instructions } : {};
149
- }
150
- hasPendingManual() {
151
- return this.pendingManual !== null;
152
- }
153
- clearPendingManual() {
154
- this.pendingManual = null;
73
+ if ((instructions?.length ?? 0) > COMPACTION_LIMITS.instructionsChars)
74
+ throw new Error('instructions_too_large');
75
+ if (this.active) {
76
+ this.deps.notify('info', 'Compaction is already running.');
77
+ return;
78
+ }
79
+ if (this.pendingManual)
80
+ this.deps.notify('info', 'Updated queued compaction request.');
81
+ this.pendingManual = { requestId: ++this.serial, historyEpoch: this.historyEpoch,
82
+ ...(instructions ? { instructions } : {}) };
155
83
  }
156
- // =========================================================================
157
- // ContextManager — the sync gate
158
- // =========================================================================
159
- /**
160
- * SYNCHRONOUS AND CHEAP. Two deliberate effects: the pressure cache, and
161
- * `deps.onPressure` (hardening DH-15).
162
- *
163
- * THE MEASUREMENT IS TAKEN ONCE, ABOVE THE GUARDS, AND REPORTED
164
- * UNCONDITIONALLY. Below them it is skipped on five paths — the per-run cap,
165
- * the cooldown, manual, overflow and guard 4 — and TWO OF THOSE NEVER CLEAR
166
- * WITHIN A RUN. A gauge fed from the measurement would therefore go dark
167
- * exactly when compaction is exhausted and the window is still filling, which
168
- * is the one state where the corrected number is the user's only warning. It
169
- * would silently revert to the measured base — the very figure W1 exists
170
- * because it under-reports.
171
- *
172
- * `!config.enabled` STAYS THE FIRST STATEMENT, so a session that turned the
173
- * feature off pays nothing at all and AC-1's byte-identity claim is untouched.
174
- */
84
+ hasPendingManual() { return this.pendingManual !== null; }
85
+ clearPendingManual() { this.pendingManual = null; }
175
86
  shouldCompact(probe) {
176
87
  const config = this.deps.getConfig().compaction;
177
88
  if (!config.enabled)
178
89
  return false;
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);
90
+ const pressure = this.meter().current();
186
91
  this.deps.onPressure?.(pressure);
187
- const manual = this.pendingManual !== null;
188
- // Guard 2 — the per-run cap. BOTH TRIGGERS, HARD. It is the bound that makes
189
- // every other guard's failure mode "spends a little" rather than "spends
190
- // forever". Read from the INSTANCE, not the module: a child manager gets a
191
- // tighter one (W3 / DH-16).
192
- if (this.compactionsThisRun >= this.maxPerRun)
92
+ if (this.active)
193
93
  return false;
194
- // Guard 1 — cooldown, in `turnIndex` units. A compaction that just ran needs
195
- // a real turn before its effect is measurable, because `lastUsage` is still
196
- // the pre-compaction number until the next `turn_end`.
197
- //
198
- // BYPASSED FOR OVERFLOW, and that is not a courtesy: the cooldown cannot be
199
- // allowed to block the only path that makes an already-refused request
200
- // sendable. A manual request is a user instruction and bypasses it too.
201
- const cooled = probe.turnIndex - this.lastCompactionTurn >= COMPACTION_LIMITS.minTurnsBetween;
202
- if (probe.trigger !== 'overflow' && !manual && !cooled)
94
+ if (this.compactionsThisRun >= this.maxPerRun) {
95
+ if (this.pendingManual) {
96
+ this.clearPendingManual();
97
+ this.deps.notify('warn', 'Compaction limit reached for this run.');
98
+ }
203
99
  return false;
204
- // An explicit `/compact` is a user instruction: it does not consult the
205
- // threshold, and guard 4 does not suppress it.
206
- if (manual)
207
- return true;
208
- // The reactive path is answering a request the provider has ALREADY REFUSED,
209
- // so it never consults occupancy — and guard 4 must not be able to switch it
210
- // off, which is the entire answer to R-5 (D-24 / P1-13).
211
- if (probe.trigger === 'overflow')
100
+ }
101
+ if (this.pendingManual?.historyEpoch === this.historyEpoch)
212
102
  return true;
213
- // Guard 4 — self-disable suppresses the PRESSURE trigger only.
214
- if (this.selfDisabled)
103
+ if (this.selfDisabled || !shouldCompactAt(pressure, config.threshold))
215
104
  return false;
216
- return shouldCompactAt(pressure, config.threshold, {
217
- maxOutputTokens: this.effectiveMaxOutputTokens(),
218
- });
105
+ return probe.trigger === 'overflow' ||
106
+ probe.turnIndex - this.lastCompactionTurn >= COMPACTION_LIMITS.minTurnsBetween;
219
107
  }
220
- // =========================================================================
221
- // ContextManager — the work
222
- // =========================================================================
223
108
  async compact(ctx) {
109
+ if (this.active)
110
+ return { action: 'keep', reason: 'already_running' };
224
111
  const manual = this.pendingManual;
225
- const instructions = manual?.instructions;
226
112
  this.pendingManual = null;
227
- const uiTrigger = manual ? 'manual' : ctx.trigger;
228
- const config = this.deps.getConfig().compaction;
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.
113
+ const state = {
114
+ operation: new CompactionOperation(++this.serial, ctx.signal, COMPACTION_LIMITS.operationTimeoutMs),
115
+ epoch: this.historyEpoch, settings: this.settingsKey(),
116
+ trigger: manual ? 'manual' : ctx.trigger, pressure: this.meter().current(),
117
+ threshold: this.deps.getConfig().compaction.threshold,
118
+ started: Date.now(), messages: [...ctx.messages], model: '',
119
+ };
120
+ this.active = state;
236
121
  this.compactionsThisRun += 1;
237
122
  this.lastCompactionTurn = ctx.turnIndex;
238
- this.inFlight = true;
239
- const started = Date.now();
240
123
  try {
241
- return await this.runCompaction({
242
- ctx,
243
- uiTrigger,
244
- config,
245
- summarizer,
246
- ...(instructions ? { instructions } : {}),
247
- started,
248
- });
124
+ if (state.operation.signal.aborted)
125
+ throw new Error('aborted');
126
+ return await this.prepareCandidate(state, ctx, manual?.instructions);
249
127
  }
250
- finally {
251
- this.inFlight = false;
252
- this.callAbort = null;
253
- this.callAbortReason = null;
128
+ catch (error) {
129
+ return this.keep(state, errorText(error));
254
130
  }
255
131
  }
256
- // =========================================================================
257
- // Internals
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
- }
291
- async runCompaction(args) {
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);
309
- const before = ctx.messages;
310
- const protectedPrefix = countProtectedPrefix(before);
311
- // THE ONE LEVER THE GUARDS DO NOT HAVE (D-24). A tail that alone exceeds the
312
- // window is the single failure mode none of the four guards can fix — they
313
- // can only stop spending on it. An overflow attempt halves `keepRecentTurns`
314
- // FOR ITSELF, in memory, never persisted: it is applied exactly where the
315
- // provider has PROVEN the request impossible.
316
- const keepRecentTurns = ctx.trigger === 'overflow'
317
- ? Math.max(1, Math.floor(config.keepRecentTurns / 2))
318
- : config.keepRecentTurns;
319
- const plan = planCompaction(before, { keepRecentTurns, protectedPrefix });
320
- if (!plan) {
321
- // THE CASE THAT KILLS A RUN TODAY (hardening §3.3.3 / W2). "Nothing to
322
- // drop" and "the tail is too big" are the SAME outcome in round 1, and both
323
- // are terminal: every rung of the ladder operates on the head, and there is
324
- // no head left. Relief is the one lever that still applies, so the
325
- // `plan === null` branch tries it before conceding.
326
- const reliefOnly = this.relieveInPlace(before, protectedPrefix, ctx.systemPrompt);
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);
334
- this.checkProgress(ctx, args.uiTrigger, reliefOnly.messages);
335
- return this.finish({ ...args, tailRelief: reliefSummary(reliefOnly) }, {
336
- action: 'replace',
337
- // `truncated` ACROSS THE PORT (DH-5 / DH-20). The engine's question
338
- // is only "was this history reduced without a summary", and the
339
- // answer is yes. `finish` maps this reason to the CLI's own
340
- // `'relieved'`, which is what stops the card claiming a summary that
341
- // never ran.
342
- mode: 'truncated',
343
- messages: reliefOnly.messages,
344
- reason: 'tail_relief_only',
345
- }, { droppedMessages: 0 }, keepRecentTurns, announced);
346
- }
347
- this.noteNoProgress(args.uiTrigger, 'nothing_to_drop');
348
- return this.finish(args, { action: 'keep', reason: 'nothing_to_drop' }, null, keepRecentTurns, announced);
349
- }
350
- const anchor = buildAnchor(before);
351
- if (!anchor) {
352
- // A history with no `user` message at all cannot have a goal anchor, and
353
- // D-6 makes the anchor non-negotiable. Refusing is the honest outcome.
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);
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);
390
- const head = before.slice(protectedPrefix, plan.cutIndex);
391
- const priorBlock = before.slice(0, protectedPrefix).find((m) => isPriorSummaryBlock(m));
392
- // The prior block is carried into the DIGEST rather than the tail, so the
393
- // summarizer merges it forward (D-22).
394
- const digestHead = priorBlock ? [priorBlock, ...head] : head;
395
- const digest = buildDigest({
396
- head: digestHead,
397
- ...(summarizer
398
- ? { summarizerWindow: this.deps.getModelInfoFor(summarizer.ref).contextWindow }
399
- : {}),
400
- });
401
- const ladder = await this.runLadder({
402
- ctx,
403
- summarizer,
404
- digest: digest.text,
405
- hasPriorSummary: priorBlock !== undefined,
406
- ...(args.instructions ? { instructions: args.instructions } : {}),
407
- });
408
- if (ladder.kind === 'aborted') {
409
- return this.finish(args, { action: 'keep', reason: 'aborted' }, null, keepRecentTurns, announced);
410
- }
411
- if (ladder.kind === 'failed' && config.onFailure === 'stop') {
412
- // Rung 4. The run proceeds and will probably hit `context_overflow`, which
413
- // is what the user asked for by setting this.
414
- this.deps.notify('error', `Context compaction failed (${ladder.reason}) and compaction.onFailure is "stop". ` +
415
- 'The next request may exceed the context window. Try /compact again, or /clear, or /reset.');
416
- return this.finish(args, { action: 'keep', reason: `summarize_failed: ${ladder.reason}` }, null, keepRecentTurns, announced);
132
+ async prepareCandidate(state, ctx, instructions) {
133
+ const config = this.deps.getConfig().compaction;
134
+ if (this.identity && !verifyCompactionIdentity(state.messages, this.identity)) {
135
+ return this.keep(state, 'invalid_prior_memory');
417
136
  }
418
- const truncated = ladder.kind === 'failed';
419
- const mode = truncated ? 'truncated' : 'summarized';
420
- const generation = readGeneration(before) + 1;
421
- const block = buildCompactedBlock({
422
- summary: truncated ? truncationBody(plan.droppedMessages) : ladder.text,
423
- replaced: plan.droppedMessages,
424
- turns: countUserTurns(head),
425
- anchor: anchor.shape,
426
- generation,
427
- truncated,
137
+ const plan = planCompaction(state.messages, {
138
+ keepRecentTurns: config.keepRecentTurns,
139
+ protectedPrefix: countProtectedPrefix(state.messages, this.identity),
428
140
  });
429
- const spliced = [anchor.message, block, ...before.slice(plan.cutIndex)];
430
- // THE TAIL'S OWN SIZE IS ONLY KNOWABLE AFTER THE HEAD IS GONE. Projecting it
431
- // before the splice would be projecting against a number that is about to
432
- // change by 80 %. `from: 2` because the anchor and the block are never
433
- // eligible: the anchor is the user's own goal statement (D-6 makes it
434
- // non-negotiable) and the block is the summary this compaction just paid for.
435
- const relief = this.relieveInPlace(spliced, 2, ctx.systemPrompt);
436
- const messages = relief ? relief.messages : spliced;
437
- // ARCHIVED BEFORE THE VERDICT, HELD UNTIL THE WIRING COLLECTS IT (W4 / RV-3).
438
- // `head` is the only place the dropped messages exist as an array, and this
439
- // function has returned long before `settlePending` runs. Retained ONLY when
440
- // the key is on, so `compaction.archive: false` never holds a reference.
141
+ if (!plan)
142
+ return this.keep(state, 'nothing_to_drop');
143
+ const input = prepareMemoryInput({ messages: state.messages, cutIndex: plan.cutIndex,
144
+ ...(this.identity ? { identity: this.identity } : {}) });
145
+ const selected = this.selectInput(input, instructions);
146
+ this.checkMinimumCandidate(input, selected.digest, state, ctx.systemPrompt);
147
+ const memory = await this.summarize(state, input, selected, instructions);
148
+ if (!this.isCurrent(state, ctx))
149
+ return this.keep(state, 'stale_history');
150
+ const block = buildCompactedBlock({ memory, replaced: input.cutIndex,
151
+ turns: input.tail.filter((m) => m.role === 'user').length,
152
+ anchor: input.anchorShape, generation: memory.generation, truncated: false });
153
+ const candidate = [input.anchor, block, ...input.tail];
154
+ this.checkCandidate(candidate, state, ctx.systemPrompt);
155
+ state.candidate = candidate;
156
+ state.generation = memory.generation;
441
157
  if (config.archive)
442
- this.lastDropped = [...head];
443
- if (truncated) {
444
- // Rung 3 is a VISIBLE, BOUNDED DEGRADATION rather than silent data loss:
445
- // it says so on the card, on the JSON event stream, and inside the block
446
- // the model itself reads.
447
- this.deps.notify('warn', `Context compaction could not summarize (${ladder.reason}); ` +
448
- `${plan.droppedMessages} earlier messages were dropped without a summary.`);
158
+ this.lastDropped = state.messages.slice(0, input.cutIndex);
159
+ const summary = renderMemoryMarkdown(memory);
160
+ this.finishRecord(state, { action: 'replace', mode: 'summarized', messages: candidate }, summary);
161
+ return { action: 'replace', mode: 'summarized', messages: candidate, summary };
162
+ }
163
+ selectInput(input, instructions) {
164
+ const choice = this.resolveSummarizer();
165
+ if (!choice)
166
+ throw new Error('no_summarizer_model');
167
+ try {
168
+ return { choice, digest: this.digestFor(input, choice, instructions) };
169
+ }
170
+ catch (error) {
171
+ if (!choice.fromFastTier || !errorText(error).includes('digest_budget_exceeded'))
172
+ throw error;
173
+ const main = this.mainChoice();
174
+ return { choice: main, digest: this.digestFor(input, main, instructions) };
449
175
  }
450
- this.generation = generation;
451
- // GUARD 3 SEES THE RELIEVED HISTORY, so relief counts as progress for free -
452
- // `checkProgress` measures the final array, which is the one being adopted.
453
- // A test pins it (AC-H7), because "for free" is the kind of property a
454
- // refactor breaks silently.
455
- this.checkProgress(ctx, args.uiTrigger, messages);
456
- return this.finish({ ...args, ...(relief ? { tailRelief: reliefSummary(relief) } : {}) }, {
457
- action: 'replace',
458
- mode,
459
- messages,
460
- ...(truncated ? {} : { summary: ladder.text }),
461
- ...(truncated ? { reason: `truncated: ${ladder.reason}` } : {}),
462
- }, { droppedMessages: plan.droppedMessages, summary: truncated ? undefined : ladder.text }, keepRecentTurns, announced);
463
176
  }
464
- /** Tail relief against this session's window (hardening §3.3.3 / W2). */
465
- relieveInPlace(messages, from, systemPrompt) {
466
- return attemptTailRelief({
467
- messages,
468
- from,
469
- systemPrompt,
470
- budget: tailBudgetTokens({
471
- contextWindow: this.meter().current().contextWindow,
472
- maxOutputTokens: this.effectiveMaxOutputTokens(),
473
- }),
474
- log: (event, fields) => this.log.info(event, fields),
475
- });
177
+ checkMinimumCandidate(input, digest, state, systemPrompt) {
178
+ const memory = {
179
+ schemaVersion: 2, generation: input.generation,
180
+ originalTask: { sourceId: input.originalTaskSourceId, location: 'anchor' },
181
+ userMessages: input.userMessages, items: input.prior?.items ?? [],
182
+ ...(input.legacySummary ? { legacySummary: input.legacySummary } : {}),
183
+ coverage: {
184
+ summarizedMessages: (input.prior?.coverage.summarizedMessages ?? 0) +
185
+ digest.coverage.summarizedMessages,
186
+ clippedToolResults: [...(input.prior?.coverage.clippedToolResults ?? []),
187
+ ...digest.coverage.clippedToolResults],
188
+ legacyIncomplete: !!input.prior?.coverage.legacyIncomplete || !!input.legacySummary,
189
+ },
190
+ };
191
+ const block = buildCompactedBlock({ memory, replaced: input.cutIndex,
192
+ turns: input.tail.filter((message) => message.role === 'user').length,
193
+ anchor: input.anchorShape, generation: input.generation });
194
+ this.checkCandidate([input.anchor, block, ...input.tail], state, systemPrompt);
195
+ }
196
+ digestFor(input, choice, instructions) {
197
+ const window = this.deps.getModelInfoFor(choice.ref).contextWindow;
198
+ const digest = buildDigest({ head: input.head, anchor: input.anchor, generation: input.generation,
199
+ startIndex: input.headStartIndex, summarizerWindow: window,
200
+ ...(input.prior ? { priorMemory: input.prior } : {}),
201
+ ...(input.legacySummary ? { legacySummary: input.legacySummary } : {}) });
202
+ const request = buildSummarizeRequest({ ref: choice.ref, apiKey: '', digest: digest.text,
203
+ hasPriorSummary: !!input.prior, signal: new AbortController().signal, now: 0,
204
+ ...(instructions ? { instructions } : {}) });
205
+ if (estimatePromptTokens(request.messages, request.systemPrompt) +
206
+ COMPACTION_LIMITS.summaryOutputTokens + CONTEXT_SAFETY_MARGIN_TOKENS >= window) {
207
+ throw new Error('digest_budget_exceeded');
208
+ }
209
+ return digest;
476
210
  }
477
- /**
478
- * The failure ladder (§3.7).
479
- *
480
- * Rung 1 is the fast tier (or the main model); rung 2 is ONE retry on the MAIN
481
- * model when rung 1 used the fast tier, because "a fast model that cannot
482
- * summarize" is the most likely single failure. Rungs 3 and 4 are the caller's,
483
- * because they depend on `onFailure`.
484
- */
485
- async runLadder(args) {
486
- const { summarizer } = args;
487
- if (!summarizer)
488
- return { kind: 'failed', reason: 'no_summarizer_model' };
489
- const first = await this.callOnce(summarizer, args);
490
- if (first.kind === 'ok' || first.kind === 'aborted')
491
- return first;
492
- // Rung 2 — retry, on the MAIN model when rung 1 was the fast tier.
493
- const retryChoice = summarizer.fromFastTier
494
- ? { ref: this.mainRef(), role: this.deps.mainRole ?? 'main', fromFastTier: false }
495
- : summarizer;
496
- this.log.warn('summarize_retry', { reason: first.reason, model: retryChoice.ref.modelId });
497
- const second = await this.callOnce(retryChoice, args);
498
- if (second.kind === 'ok' || second.kind === 'aborted')
499
- return second;
500
- return { kind: 'failed', reason: second.reason };
211
+ async summarize(state, input, selected, instructions) {
212
+ let choice = selected.choice;
213
+ let digest = selected.digest;
214
+ let previousError;
215
+ for (let attempt = 0; attempt < 2; attempt += 1) {
216
+ if (state.operation.signal.aborted)
217
+ throw new Error('aborted');
218
+ if (attempt > 0) {
219
+ choice = this.mainChoice();
220
+ digest = this.digestFor(input, choice, instructions);
221
+ }
222
+ const text = await this.callOnce(state, choice, {
223
+ digest: digest.text, hasPriorSummary: !!input.prior,
224
+ ...(instructions ? { instructions } : {}),
225
+ ...(previousError ? { validationError: previousError } : {}),
226
+ }).catch((error) => { previousError = errorText(error); return null; });
227
+ if (state.operation.signal.aborted)
228
+ throw new Error(previousError ?? 'aborted');
229
+ if (text === null)
230
+ continue;
231
+ try {
232
+ const delta = parseMemoryDelta(text);
233
+ validateMemoryDelta(delta, { sourceMap: digest.sourceMap, prior: input.prior,
234
+ hasSummarizableMessages: digest.coverage.summarizedMessages > 0 });
235
+ return mergeMemory({ input, delta, sourceMap: digest.sourceMap, coverage: digest.coverage });
236
+ }
237
+ catch (error) {
238
+ previousError = errorText(error);
239
+ if (!/invalid_memory_delta|empty_memory_delta/.test(previousError))
240
+ throw error;
241
+ }
242
+ }
243
+ throw new Error(previousError ?? 'invalid_memory_delta');
501
244
  }
502
- async callOnce(choice, args) {
503
- const { ref, role } = choice;
504
- const apiKey = this.deps.getApiKey(ref.providerId, role);
245
+ async callOnce(state, choice, input) {
246
+ const apiKey = this.deps.getApiKey(choice.ref.providerId, choice.role);
505
247
  if (!apiKey)
506
- return { kind: 'failed', reason: 'no_api_key' };
507
- if (args.ctx.signal.aborted)
508
- return { kind: 'aborted' };
509
- // The controller is owned HERE, not in `summarize-call.ts`, so the caller can
510
- // stamp WHICH of "timeout" and "cancelled" happened before aborting — the
511
- // distinction the retry layer flattens away (see that file's header).
512
- const controller = new AbortController();
513
- this.callAbort = controller;
514
- this.callAbortReason = null;
515
- const onRunAbort = () => {
516
- this.callAbortReason = 'cancelled';
517
- controller.abort();
518
- };
519
- args.ctx.signal.addEventListener('abort', onRunAbort, { once: true });
520
- try {
521
- const outcome = await runSummarizeCall({
522
- ref,
523
- apiKey,
524
- digest: args.digest,
525
- hasPriorSummary: args.hasPriorSummary,
526
- ...(args.instructions ? { instructions: args.instructions } : {}),
527
- signal: controller.signal,
528
- now: Date.now(),
529
- complete: (providerId, request) => this.deps.complete(providerId, request),
530
- onTimeout: () => {
531
- this.callAbortReason = 'timeout';
532
- controller.abort();
533
- },
534
- });
535
- if (!outcome.ok) {
536
- if (this.callAbortReason === 'cancelled' || args.ctx.signal.aborted)
537
- return { kind: 'aborted' };
538
- const reason = this.callAbortReason === 'timeout' ? 'timeout' : errorText(outcome.error);
539
- return { kind: 'failed', reason };
540
- }
541
- this.noteUsage(ref, outcome.usage);
542
- if (outcome.text.trim().length === 0)
543
- return { kind: 'failed', reason: 'empty_summary' };
544
- return { kind: 'ok', text: outcome.text.trim() };
248
+ throw new Error('no_api_key');
249
+ this.announce(state, choice);
250
+ const outcome = await state.operation.run(() => runSummarizeCall({
251
+ ...input, ref: choice.ref, apiKey, signal: state.operation.signal, now: Date.now(),
252
+ complete: (providerId, request) => this.deps.complete(providerId, request),
253
+ onTimeout: () => state.operation.abort('timeout'),
254
+ onUsage: (usage) => this.noteUsage(choice.ref, usage),
255
+ }), COMPACTION_LIMITS.callTimeoutMs);
256
+ if (!outcome.ok)
257
+ throw outcome.error;
258
+ return outcome.text;
259
+ }
260
+ checkCandidate(candidate, state, systemPrompt) {
261
+ const before = estimatePromptTokens([...state.messages], systemPrompt);
262
+ const after = estimatePromptTokens(candidate, systemPrompt);
263
+ if (candidate.length > state.messages.length || after >= before) {
264
+ throw new Error('no_token_reclaim');
545
265
  }
546
- finally {
547
- args.ctx.signal.removeEventListener('abort', onRunAbort);
266
+ if (after + Math.max(0, state.pressure.estimateOffset ?? 0) >=
267
+ state.pressure.contextWindow - CONTEXT_SAFETY_MARGIN_TOKENS) {
268
+ throw new Error('protected_memory_too_large');
548
269
  }
549
270
  }
550
- /**
551
- * Guard 3 — the progress requirement (§3.8).
552
- *
553
- * ADVISORY ON OVERFLOW, NEVER A VETO (D-24 / P1-13). The arithmetic that makes
554
- * this guard sensible for the proactive trigger INVERTS on the reactive one: a
555
- * pressure compaction that reclaims little has spent money to buy two turns and
556
- * should stop, while an overflow compaction that reclaims little is the
557
- * difference between a continuing run and a dead one, because the provider has
558
- * already refused the request.
559
- *
560
- * The projection is ESTIMATED — no usage exists for a history that has not been
561
- * sent yet — and is therefore CALIBRATED with `estimateOffset` (D-23).
562
- *
563
- * IT TAKES THE *UI* TRIGGER, not `ctx.trigger`. A queued `/compact` reaches the
564
- * loop as an ordinary `pressure` checkpoint (that is what the queue IS), so
565
- * charging its result to guard 4's counter would let a user who types
566
- * `/compact` twice on a short conversation self-disable the proactive trigger
567
- * for the rest of the session — and be told, wrongly, that their recent turns
568
- * exceed the threshold. `uiTrigger` is the only value that knows the difference.
569
- */
570
- checkProgress(ctx, uiTrigger, after) {
571
- const config = this.deps.getConfig().compaction;
572
- const pressure = this.meter().current();
573
- // THE OFFSET NOW LIVES ON THE METER (W1), and it is read from the last
574
- // PUBLISHED pressure rather than re-derived: `Pressure.estimateOffset` is
575
- // carried on every measurement, so this is the same number the field used to
576
- // hold, without the compactor owning a second copy of it.
577
- const estimateOffset = this.meter().lastPublished()?.estimateOffset;
578
- const projected = computePressure({
579
- messages: after,
580
- systemPrompt: ctx.systemPrompt,
581
- contextWindow: pressure.contextWindow,
582
- windowKnown: pressure.windowKnown,
583
- ...(estimateOffset !== undefined ? { estimateOffset } : {}),
584
- });
585
- const target = config.threshold - COMPACTION_LIMITS.minReclaimRatio;
586
- const madeProgress = projected.ratio < target;
587
- this.log.info('compaction_progress', {
588
- trigger: uiTrigger,
589
- projectedRatio: Number(projected.ratio.toFixed(3)),
590
- target: Number(target.toFixed(3)),
591
- madeProgress,
592
- });
593
- if (madeProgress) {
594
- this.consecutiveNoProgress = 0;
271
+ isCurrent(state, ctx) {
272
+ return this.active === state && state.epoch === this.historyEpoch &&
273
+ state.settings === this.settingsKey() && !ctx.signal.aborted &&
274
+ !state.operation.signal.aborted;
275
+ }
276
+ announce(state, choice) {
277
+ state.model = choice.ref.modelId;
278
+ if (state.index !== undefined)
595
279
  return;
280
+ state.index = ++this.compactions;
281
+ this.deps.emit({ type: 'compaction_start', index: state.index,
282
+ trigger: state.trigger, model: state.model });
283
+ }
284
+ keep(state, reason) {
285
+ state.reason = reason;
286
+ const outcome = { action: 'keep', reason };
287
+ if (this.active !== state)
288
+ return outcome;
289
+ this.finishRecord(state, outcome);
290
+ if (state.trigger === 'manual')
291
+ this.deps.notify('warn', `History preserved: ${reason}.`);
292
+ if (this.deps.getConfig().compaction.onFailure === 'truncate' && !this.legacyFailureNotified) {
293
+ this.legacyFailureNotified = true;
294
+ this.deps.notify('info', 'Legacy truncate configuration: preserving history on failure.');
596
295
  }
597
- this.noteNoProgress(uiTrigger, 'insufficient_reclaim');
296
+ return outcome;
598
297
  }
599
- /**
600
- * Guard 4 — stuck detection (§3.8).
601
- *
602
- * PRESSURE ONLY, AND `pressure` HERE MEANS THE *UI* TRIGGER. A session that
603
- * self-disables still survives an overflow rather than dying at the next
604
- * request, and if the tail really is too large the overflow attempt is the one
605
- * that can do something about it (D-24). A `manual` compaction is excluded for
606
- * a different reason: this guard exists to stop UNREQUESTED spend, and
607
- * `/compact` on a conversation with nothing worth dropping is a user who asked
608
- * — twice — not a runaway loop.
609
- */
610
- noteNoProgress(trigger, reason) {
611
- if (trigger !== 'pressure')
612
- return;
613
- this.consecutiveNoProgress += 1;
614
- if (this.consecutiveNoProgress < COMPACTION_LIMITS.stuckLimit || this.selfDisabled)
298
+ finishRecord(state, outcome, summary) {
299
+ if (this.active !== state)
615
300
  return;
616
- this.selfDisabled = true;
617
- this.selfDisabledReason = reason;
618
- this.log.warn('compaction_self_disabled', { reason });
619
- // THE WORDING NAMES THE ACTUAL CAUSE AND THE ACTUAL REMEDIES. A notice that
620
- // says only "compaction is off" teaches the user that the feature is broken;
621
- // this one tells them which number to change.
622
- this.deps.notify('warn', 'Auto-compaction is off for this session: the most recent ' +
623
- `${this.deps.getConfig().compaction.keepRecentTurns} turns alone exceed the threshold. ` +
624
- 'Lower compaction.keepRecentTurns, or use /clear or /reset.');
625
- }
626
- finish(args, outcome, applied, keepRecentTurnsUsed, announced) {
627
- const before = args.ctx.messages.length;
628
- const after = outcome.action === 'replace' ? outcome.messages.length : before;
629
- // THE OCCUPANCY BEFORE THE SPLICE (I-10). `lastMeasured()` is the pure cache
630
- // read on purpose; anything that re-measures here reports 0 reclaimed.
631
- const tokensBefore = this.lastMeasured()?.occupied ?? 0;
632
- // `'relieved'` IS A CLI-ONLY WORD (RV-5 / DH-20). The port reports
633
- // `'truncated'` for a relief-only compaction, which is accurate for the
634
- // engine's narrower question; the card's `summarized N messages with
635
- // <model>` line would otherwise render "summarized 0 messages with
636
- // claude-haiku-4-5" for a compaction in which no model was called.
637
- const mode = reliefOnly(outcome)
638
- ? 'relieved'
639
- : outcome.action === 'replace'
640
- ? outcome.mode
641
- : 'none';
642
- // NO MODEL ON A RELIEF-ONLY RECORD, because no model was called. The card
643
- // names the summarizer from this field, and naming one here would be the
644
- // same claim `mode: 'relieved'` exists to stop making.
645
- const model = reliefOnly(outcome) ? '' : args.summarizer?.ref.modelId ?? '';
646
- const durationMs = Date.now() - args.started;
647
- // THE ON-DISK RECORD OF EVERY ATTEMPT IS UNCHANGED (quiet-noop §9). This
648
- // feature makes the TRANSCRIPT quiet, not the logs: a declined compaction is
649
- // still one `compaction` line here, still one `compaction_start` /
650
- // `compaction_end` pair from CORE in the JSONL sink, and still reflected in
651
- // `/compact status`.
652
- this.log.info('compaction', {
653
- trigger: args.uiTrigger,
654
- announced: announced !== null,
655
- before,
656
- after,
657
- droppedMessages: applied?.droppedMessages ?? 0,
658
- mode,
659
- model,
660
- durationMs,
661
- });
662
- // A SILENT DECLINE (quiet-noop §3.1). No card was opened, so there is nothing
663
- // to settle and nothing to say IN THE TRANSCRIPT. Returning here is what makes
664
- // a declined checkpoint indistinguishable from one at which `shouldCompact`
665
- // said no.
666
- //
667
- // `declined` IS THE ONE THING THAT STILL SPEAKS (D-9). Quiet is the goal;
668
- // invisible is not, and `/compact status` is a published guaranteed surface
669
- // (`context-auto-compaction` §6.5). One integer here is what keeps that
670
- // promise true while the card goes away.
671
- if (!announced) {
301
+ if (state.index === undefined) {
672
302
  this.declined += 1;
673
- return outcome;
303
+ return;
674
304
  }
305
+ const p = state.pressure;
675
306
  const record = {
676
- index: announced.index,
677
- trigger: args.uiTrigger,
678
- mode,
679
- // OPTIMISTIC AND CORRECTED BY THE WIRING (§3.2.1). Only the ENGINE knows
680
- // whether the splice passed the structural gate, and it says so on
681
- // `compaction_end`; the wiring rewrites this before the card settles.
682
- applied: outcome.action === 'replace',
307
+ index: state.index, trigger: state.trigger,
308
+ mode: outcome.action === 'replace' ? 'summarized' : 'none', applied: false,
683
309
  ...(outcome.action === 'keep' ? { reason: outcome.reason } : {}),
684
- ...(outcome.action === 'replace' && outcome.reason ? { reason: outcome.reason } : {}),
685
- messagesBefore: before,
686
- messagesAfter: after,
687
- tokensBefore,
688
- tokensAfter: 0, // filled by the wiring from the engine's own estimator
689
- ...(applied?.summary ? { summary: applied.summary } : {}),
690
- model,
691
- durationMs,
692
- ...(args.ctx.trigger === 'overflow' ? { keepRecentTurnsUsed } : {}),
693
- ...(args.tailRelief ? { tailRelief: args.tailRelief } : {}),
310
+ messagesBefore: state.messages.length,
311
+ messagesAfter: outcome.action === 'replace' ? outcome.messages.length : state.messages.length,
312
+ tokensBefore: p.occupied, tokensAfter: 0, model: state.model,
313
+ durationMs: Date.now() - state.started, ...(summary ? { summary } : {}),
314
+ memoryVersion: 2,
315
+ decision: { occupied: p.occupied, contextWindow: p.contextWindow,
316
+ threshold: state.threshold,
317
+ source: p.source, deltaTokens: p.deltaTokens },
694
318
  };
695
319
  this.deps.emit({ type: 'compaction_end', record });
696
- return outcome;
697
320
  }
698
- // =========================================================================
699
- // Reporting helpers
700
- // =========================================================================
701
- /**
702
- * The pressure the trigger and the gauge both read - MEASURED NOW.
703
- *
704
- * A FORWARDER since W1: the decision point supplies its own authoritative
705
- * `lastUsage` (the probe's), and the meter owns everything else. `messages` /
706
- * `systemPrompt` stay in the signature and are ignored, because the meter
707
- * reads the LIVE history through its own deps - which is strictly better than
708
- * a caller that could pass `[]` and get a 0 % reading (the old default here).
709
- */
710
- measure(probe) {
711
- return this.meter().measureWith(probe.lastUsage);
321
+ /** Commit only the engine/host verdict; candidates have no authority before this point. */
322
+ settleOperation(verdict) {
323
+ const state = this.active;
324
+ if (!state || !state.operation.settle())
325
+ return false;
326
+ this.active = null;
327
+ if (verdict.applied && state.candidate) {
328
+ this.identity = createCompactionIdentity(state.candidate);
329
+ this.generation = state.generation ?? this.generation;
330
+ this.recordApplied(verdict.tokensBefore ?? state.pressure.occupied, verdict.tokensAfter);
331
+ this.meter().onHistorySpliced();
332
+ const projected = (verdict.tokensAfter + (state.pressure.estimateOffset ?? 0)) /
333
+ state.pressure.contextWindow;
334
+ if (projected < this.deps.getConfig().compaction.threshold - COMPACTION_LIMITS.minReclaimRatio) {
335
+ this.consecutiveNoProgress = 0;
336
+ }
337
+ else
338
+ this.noteNoProgress(state.trigger, 'insufficient_reclaim');
339
+ }
340
+ else {
341
+ const reason = verdict.reason ?? state.reason ?? 'invalid_history';
342
+ if (!/abort|cancel|stale/.test(reason))
343
+ this.noteNoProgress(state.trigger, reason);
344
+ }
345
+ this.log.info('compaction_settled', {
346
+ operationId: state.operation.id, trigger: state.trigger, applied: verdict.applied,
347
+ generation: this.generation, messagesBefore: state.messages.length,
348
+ messagesAfter: verdict.applied ? state.candidate?.length : state.messages.length,
349
+ tokensBefore: verdict.tokensBefore, tokensAfter: verdict.tokensAfter,
350
+ reason: (verdict.reason ?? state.reason)?.split(':', 1)[0]?.replace(/[^a-z_]/g, ''),
351
+ });
352
+ return true;
712
353
  }
713
- /**
714
- * The last pressure PUBLISHED. NEVER re-measured - `lastPublished`, not
715
- * `current` (I-10 / RV-3).
716
- *
717
- * `finish()` below reads this for `tokensBefore`, which is the sole upstream of
718
- * `tokensReclaimed`, the card's "reclaimed N tokens" and `/compact status`'s
719
- * session total. All three want the occupancy BEFORE the splice. Point this at
720
- * `meter.current()` and the moment the meter is dirty - which after a splice it
721
- * always is - `tokensBefore` becomes the POST-compaction figure, `reclaimed`
722
- * collapses to 0, and the card renders that with nothing logging a fault.
723
- */
724
- lastMeasured() {
725
- return this.meter().lastPublished();
354
+ noteNoProgress(trigger, reason) {
355
+ if (trigger === 'manual')
356
+ return;
357
+ this.consecutiveNoProgress += 1;
358
+ if (this.consecutiveNoProgress < COMPACTION_LIMITS.stuckLimit || this.selfDisabled)
359
+ return;
360
+ this.selfDisabled = true;
361
+ this.selfDisabledReason = reason;
362
+ this.deps.notify('warn', `Auto-compaction paused: ${reason}. History preserved. Use /compact.`);
726
363
  }
727
- /** Written by the wiring once the engine has reported its verdict. */
364
+ measure(_probe) { return this.meter().current(); }
365
+ lastMeasured() { return this.meter().lastPublished(); }
728
366
  recordApplied(tokensBefore, tokensAfter) {
729
- const reclaimed = Math.max(0, tokensBefore - tokensAfter);
730
- this.tokensReclaimed += reclaimed;
367
+ this.tokensReclaimed += Math.max(0, tokensBefore - tokensAfter);
731
368
  }
732
369
  sessionTotals() {
733
- return {
734
- compactions: this.compactions,
735
- declined: this.declined,
736
- tokensReclaimed: this.tokensReclaimed,
737
- usage: this.sessionUsage,
738
- pricingUnknown: this.pricingUnknown,
739
- inFlight: this.inFlight,
740
- selfDisabled: this.selfDisabled,
741
- ...(this.selfDisabledReason ? { selfDisabledReason: this.selfDisabledReason } : {}),
742
- generation: this.generation,
743
- };
370
+ return { compactions: this.compactions, declined: this.declined,
371
+ tokensReclaimed: this.tokensReclaimed, usage: this.sessionUsage, costUsd: this.costUsd,
372
+ pricingUnknown: this.pricingUnknown, inFlight: this.active !== null,
373
+ selfDisabled: this.selfDisabled, generation: this.generation,
374
+ ...(this.selfDisabledReason ? { selfDisabledReason: this.selfDisabledReason } : {}) };
744
375
  }
745
- /** `/compact on` after a self-disable — the user overriding the guard. */
746
376
  clearSelfDisable() {
747
377
  this.selfDisabled = false;
748
378
  this.selfDisabledReason = undefined;
749
379
  this.consecutiveNoProgress = 0;
750
380
  }
751
- isSelfDisabled() {
752
- return this.selfDisabled;
753
- }
754
- /**
755
- * Which model summarizes (§3.6.4).
756
- *
757
- * SAY THE DEFAULT OUT LOUD (P2-8): FOR ALMOST EVERYONE THIS IS THE MAIN MODEL.
758
- * `resolveFastTier` returns `{ok: false, reason: 'disabled'}` on
759
- * `fast.enabled !== true` alone, and `fast.enabled` defaults to `false` — so
760
- * `useFastTier: true` describes a PREFERENCE, not the common path, and the
761
- * out-of-the-box first compaction is a ~30 k-token call on the session's own
762
- * model. Three things make that the right trade rather than a hidden cost: it
763
- * happens at most `maxPerRun` times per run, the alternative is losing the
764
- * entire run, and the spend is on the card with the summarizer's own price.
765
- */
381
+ isSelfDisabled() { return this.selfDisabled; }
766
382
  resolveSummarizer() {
767
383
  const config = this.deps.getConfig();
768
384
  if (config.compaction.useFastTier) {
@@ -770,74 +386,43 @@ export class Compactor {
770
386
  if (tier.ok)
771
387
  return { ref: tier.ref, role: 'fast', fromFastTier: true };
772
388
  }
773
- const ref = this.mainRef();
774
- if (!this.deps.hasKey(ref.providerId, this.deps.mainRole ?? 'main'))
775
- return null;
776
- return { ref, role: this.deps.mainRole ?? 'main', fromFastTier: false };
389
+ const choice = this.mainChoice();
390
+ return this.deps.hasKey(choice.ref.providerId, choice.role) ? choice : null;
391
+ }
392
+ mainChoice() {
393
+ return { ref: this.mainRef(), role: this.deps.mainRole ?? 'main', fromFastTier: false };
777
394
  }
778
- /**
779
- * The meter this compactor reads, injected or private (R-1).
780
- *
781
- * MEMOIZED, because a fresh meter on every call would have no `estimateOffset`
782
- * and no cached pressure - `lastMeasured()` would answer `null` forever and
783
- * `tokensReclaimed` would sit at 0. The fallback exists so the ten existing
784
- * `compaction-*.test.ts` construction sites compile unchanged; production
785
- * injects, and `context-gauge-wiring.test.ts` asserts the single instance.
786
- */
787
395
  meter() {
788
396
  if (this.deps.meter)
789
397
  return this.deps.meter;
790
- if (!this.ownMeter) {
791
- this.ownMeter = new ContextMeter({
792
- getMessages: () => this.deps.getMessages(),
793
- getSystemPrompt: () => this.deps.getSystemPrompt(),
794
- getModelInfo: () => this.modelInfo(),
795
- isWindowKnown: () => {
796
- const source = this.modelInfo().contextWindowSource;
797
- return source === undefined ? this.deps.isPricedModel(this.mainRef()) : source !== 'fallback';
798
- },
799
- getWindowOverride: () => this.deps.getConfig().contextWindow,
800
- });
801
- }
398
+ this.ownMeter ??= new ContextMeter({
399
+ getMessages: this.deps.getMessages, getSystemPrompt: this.deps.getSystemPrompt,
400
+ getModelInfo: () => this.deps.getModelInfoFor(this.mainRef()),
401
+ isWindowKnown: () => {
402
+ const source = this.deps.getModelInfoFor(this.mainRef()).contextWindowSource;
403
+ return source === undefined ? this.deps.isPricedModel(this.mainRef()) : source !== 'fallback';
404
+ },
405
+ getWindowOverride: () => this.deps.getConfig().contextWindow,
406
+ });
802
407
  return this.ownMeter;
803
408
  }
804
409
  mainRef() {
805
410
  const config = this.deps.getConfig();
806
- return {
807
- providerId: config.provider,
808
- modelId: config.model,
809
- ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
810
- };
811
- }
812
- modelInfo() {
813
- return this.deps.getModelInfoFor(this.mainRef());
411
+ return { providerId: config.provider, modelId: config.model,
412
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}) };
814
413
  }
815
- effectiveMaxOutputTokens() {
414
+ settingsKey() {
816
415
  const config = this.deps.getConfig();
817
- // `undefined` means AUTO; the model's own ceiling is the honest bound then.
818
- return config.maxTokens ?? this.modelInfo().maxOutputTokens;
416
+ return JSON.stringify([config.provider, config.model, config.baseUrl, config.compaction,
417
+ this.deps.getSystemPrompt()]);
819
418
  }
820
419
  noteUsage(ref, usage) {
821
420
  this.sessionUsage = accumulateUsage(this.sessionUsage, usage);
822
- // UNKNOWN PRICING IS NOT ZERO PRICING. It is sticky: once a summarization has
823
- // run on an unpriced model the session total is no longer a complete figure,
824
- // and a later priced call does not make it one again.
825
- if (!this.deps.isPricedModel(ref))
421
+ const pricingUnknown = !this.deps.isPricedModel(ref);
422
+ const costUsd = pricingUnknown ? 0 : computeCost(usage, this.deps.getModelInfoFor(ref).cost);
423
+ if (pricingUnknown)
826
424
  this.pricingUnknown = true;
827
- this.deps.emit({ type: 'usage', usage });
425
+ this.costUsd += costUsd;
426
+ this.deps.emit({ type: 'usage', usage, modelRef: ref, costUsd, pricingUnknown });
828
427
  }
829
428
  }
830
- /** A "turn" is a `user`-role message; `tool_result` is a separate role. */
831
- function countUserTurns(messages) {
832
- return messages.reduce((acc, m) => (m.role === 'user' ? acc + 1 : acc), 0);
833
- }
834
- /**
835
- * The relief-only outcome, recognized by the one reason string that produces it.
836
- *
837
- * A REASON RATHER THAN A NEW `mode` ON THE PORT (DH-5): `CompactionOutcome.mode`
838
- * is a public contract with two members, and widening it to carry a distinction
839
- * only the CLI's card acts on would be a breaking change for a reporting nicety.
840
- */
841
- function reliefOnly(outcome) {
842
- return outcome.action === 'replace' && outcome.reason === 'tail_relief_only';
843
- }