@aragon-agent/cli 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +321 -0
- package/README.md +297 -2
- package/dist/agent/controller.d.ts +193 -1
- package/dist/agent/controller.js +475 -2
- package/dist/agent/headless.d.ts +8 -0
- package/dist/agent/headless.js +47 -0
- package/dist/agent/reducer.d.ts +189 -0
- package/dist/agent/reducer.js +301 -2
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +297 -16
- package/dist/commands/builtins.js +165 -24
- package/dist/commands/registry.d.ts +27 -0
- package/dist/compaction/archive.d.ts +175 -0
- package/dist/compaction/archive.js +267 -0
- package/dist/compaction/child.d.ts +97 -0
- package/dist/compaction/child.js +118 -0
- package/dist/compaction/command.d.ts +41 -0
- package/dist/compaction/command.js +355 -0
- package/dist/compaction/compactor.d.ts +302 -0
- package/dist/compaction/compactor.js +810 -0
- package/dist/compaction/digest.d.ts +62 -0
- package/dist/compaction/digest.js +164 -0
- package/dist/compaction/limits.d.ts +165 -0
- package/dist/compaction/limits.js +172 -0
- package/dist/compaction/pressure.d.ts +135 -0
- package/dist/compaction/pressure.js +167 -0
- package/dist/compaction/summarize-call.d.ts +70 -0
- package/dist/compaction/summarize-call.js +86 -0
- package/dist/compaction/summary-prompt.d.ts +85 -0
- package/dist/compaction/summary-prompt.js +197 -0
- package/dist/compaction/tail-budget.d.ts +52 -0
- package/dist/compaction/tail-budget.js +67 -0
- package/dist/compaction/types.d.ts +215 -0
- package/dist/compaction/types.js +22 -0
- package/dist/compaction/wiring.d.ts +264 -0
- package/dist/compaction/wiring.js +577 -0
- package/dist/config/app-paths.d.ts +14 -0
- package/dist/config/app-paths.js +16 -0
- package/dist/config/cli-commands.d.ts +41 -0
- package/dist/config/cli-commands.js +77 -1
- package/dist/config/env.d.ts +5 -2
- package/dist/config/env.js +140 -3
- package/dist/config/load.d.ts +42 -0
- package/dist/config/load.js +101 -1
- package/dist/config/schema.d.ts +244 -0
- package/dist/config/schema.js +170 -0
- package/dist/config/store.js +37 -1
- package/dist/config/ui-state.d.ts +28 -1
- package/dist/config/ui-state.js +9 -0
- package/dist/exec/events.d.ts +50 -1
- package/dist/exec/runner.js +42 -0
- package/dist/input/limits.d.ts +72 -0
- package/dist/input/limits.js +63 -0
- package/dist/input/mouse-events.d.ts +31 -5
- package/dist/input/mouse-events.js +86 -27
- package/dist/input/paste-parse.d.ts +71 -0
- package/dist/input/paste-parse.js +95 -0
- package/dist/input/stdin-filter.d.ts +81 -0
- package/dist/input/stdin-filter.js +379 -0
- package/dist/logging/install.d.ts +53 -1
- package/dist/logging/install.js +81 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/classify.d.ts +52 -0
- package/dist/proc/classify.js +143 -0
- package/dist/proc/kill-tree.d.ts +42 -0
- package/dist/proc/kill-tree.js +119 -0
- package/dist/proc/limits.d.ts +84 -0
- package/dist/proc/limits.js +84 -0
- package/dist/proc/log-ring.d.ts +68 -0
- package/dist/proc/log-ring.js +102 -0
- package/dist/proc/prompt.d.ts +24 -0
- package/dist/proc/prompt.js +49 -0
- package/dist/proc/readiness.d.ts +102 -0
- package/dist/proc/readiness.js +219 -0
- package/dist/proc/supervisor.d.ts +103 -0
- package/dist/proc/supervisor.js +465 -0
- package/dist/proc/types.d.ts +175 -0
- package/dist/proc/types.js +15 -0
- package/dist/session/persist.js +55 -1
- package/dist/team/report.js +7 -1
- package/dist/team/runtime.d.ts +10 -0
- package/dist/team/runtime.js +7 -0
- package/dist/team/subagent.d.ts +31 -1
- package/dist/team/subagent.js +42 -15
- package/dist/team/types.d.ts +8 -0
- package/dist/tools/bash-tool.d.ts +51 -2
- package/dist/tools/bash-tool.js +321 -51
- package/dist/tools/fs-tools.d.ts +17 -0
- package/dist/tools/index.d.ts +55 -2
- package/dist/tools/index.js +39 -1
- package/dist/tools/proc-tools.d.ts +25 -0
- package/dist/tools/proc-tools.js +102 -0
- package/dist/ui/ActivityLine.d.ts +19 -1
- package/dist/ui/ActivityLine.js +10 -2
- package/dist/ui/App.d.ts +62 -2
- package/dist/ui/App.js +546 -16
- package/dist/ui/Composer.d.ts +66 -1
- package/dist/ui/Composer.js +50 -6
- package/dist/ui/PromptInput.d.ts +82 -3
- package/dist/ui/PromptInput.js +232 -49
- package/dist/ui/StatusBar.d.ts +59 -0
- package/dist/ui/StatusBar.js +16 -4
- package/dist/ui/TeamPanel.js +7 -1
- package/dist/ui/Transcript.d.ts +21 -1
- package/dist/ui/Transcript.js +61 -3
- package/dist/ui/clipboard.d.ts +52 -0
- package/dist/ui/clipboard.js +91 -0
- package/dist/ui/composer-limits.d.ts +31 -0
- package/dist/ui/composer-limits.js +50 -0
- package/dist/ui/composer-rows.d.ts +73 -0
- package/dist/ui/composer-rows.js +201 -0
- package/dist/ui/editor-reducer.d.ts +37 -0
- package/dist/ui/editor-reducer.js +88 -12
- package/dist/ui/entries/CompactionCard.d.ts +75 -0
- package/dist/ui/entries/CompactionCard.js +158 -0
- package/dist/ui/entries/ServiceCard.d.ts +66 -0
- package/dist/ui/entries/ServiceCard.js +116 -0
- package/dist/ui/entries/UserEntry.d.ts +17 -3
- package/dist/ui/entries/UserEntry.js +34 -5
- package/dist/ui/frame-differ.d.ts +46 -0
- package/dist/ui/frame-differ.js +94 -10
- package/dist/ui/gauge.d.ts +16 -1
- package/dist/ui/gauge.js +25 -6
- package/dist/ui/glyphs.d.ts +11 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/layout/ScrollViewport.d.ts +43 -6
- package/dist/ui/layout/ScrollViewport.js +128 -21
- package/dist/ui/layout/budget.d.ts +44 -9
- package/dist/ui/layout/budget.js +42 -10
- package/dist/ui/layout/follow-state.d.ts +137 -0
- package/dist/ui/layout/follow-state.js +150 -0
- package/dist/ui/layout/virtual-window.d.ts +11 -0
- package/dist/ui/layout/virtual-window.js +85 -1
- package/dist/ui/overlays/HelpOverlay.js +9 -1
- package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
- package/dist/ui/overlays/QuestionOverlay.js +5 -1
- package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
- package/dist/ui/overlays/SettingsScreen.js +74 -2
- package/dist/ui/palettes.d.ts +16 -0
- package/dist/ui/palettes.js +6 -0
- package/dist/ui/paste-frames.d.ts +87 -0
- package/dist/ui/paste-frames.js +112 -0
- package/dist/ui/paste-tokens.d.ts +90 -0
- package/dist/ui/paste-tokens.js +147 -0
- package/dist/ui/screen.d.ts +38 -0
- package/dist/ui/screen.js +113 -8
- package/dist/ui/selection/highlight.d.ts +39 -0
- package/dist/ui/selection/highlight.js +64 -0
- package/dist/ui/selection/screen-mirror.d.ts +24 -0
- package/dist/ui/selection/screen-mirror.js +35 -0
- package/dist/ui/selection/selection-controller.d.ts +88 -0
- package/dist/ui/selection/selection-controller.js +282 -0
- package/dist/ui/selection/selection.d.ts +90 -0
- package/dist/ui/selection/selection.js +223 -0
- package/dist/ui/stdout-frame-writer.d.ts +33 -0
- package/dist/ui/stdout-frame-writer.js +15 -0
- package/dist/ui/theme.d.ts +11 -0
- package/dist/ui/theme.js +2 -0
- package/dist/ui/transcript-text.js +72 -0
- package/dist/ui/use-startup-notices.d.ts +27 -1
- package/dist/ui/use-startup-notices.js +43 -11
- package/dist/ui/use-wheel-routing.d.ts +1 -1
- package/dist/ui/use-wheel-routing.js +8 -0
- package/package.json +4 -2
- package/dist/input/stdin-mouse-filter.d.ts +0 -42
- package/dist/input/stdin-mouse-filter.js +0 -153
|
@@ -0,0 +1,810 @@
|
|
|
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';
|
|
13
|
+
import { getLogger } from '../logging/logger.js';
|
|
14
|
+
import { resolveFastTier } from '../fast/resolve.js';
|
|
15
|
+
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 { computeEstimateOffset, computePressure, shouldCompactAt } from './pressure.js';
|
|
21
|
+
export class Compactor {
|
|
22
|
+
deps;
|
|
23
|
+
log = getLogger().child('compaction');
|
|
24
|
+
// --- Per-run state (reset on `agent_start`) ---
|
|
25
|
+
compactionsThisRun = 0;
|
|
26
|
+
lastCompactionTurn = -Infinity;
|
|
27
|
+
// --- Per-session state ---
|
|
28
|
+
compactions = 0;
|
|
29
|
+
/**
|
|
30
|
+
* How many `compact()` calls returned WITHOUT announcing (quiet-noop D-9).
|
|
31
|
+
*
|
|
32
|
+
* A SESSION TOTAL, LIKE `compactions`, and reset by neither `onRunStart()` nor
|
|
33
|
+
* `clearSelfDisable()`: `/compact on` overrides a guard, it does not rewrite
|
|
34
|
+
* history. `/compact status` is its only reader, and it is the whole of what
|
|
35
|
+
* keeps a decline reportable once the transcript stops carrying one.
|
|
36
|
+
*/
|
|
37
|
+
declined = 0;
|
|
38
|
+
tokensReclaimed = 0;
|
|
39
|
+
sessionUsage = { inputTokens: 0, outputTokens: 0 };
|
|
40
|
+
pricingUnknown = false;
|
|
41
|
+
inFlight = false;
|
|
42
|
+
consecutiveNoProgress = 0;
|
|
43
|
+
selfDisabled = false;
|
|
44
|
+
selfDisabledReason;
|
|
45
|
+
pendingManual = null;
|
|
46
|
+
generation = 0;
|
|
47
|
+
/** Calibration for every ESTIMATED figure (D-23). Recomputed on every turn_end. */
|
|
48
|
+
estimateOffset;
|
|
49
|
+
/** The last pressure computed, so the status bar and `/compact status` agree. */
|
|
50
|
+
lastPressure = null;
|
|
51
|
+
/**
|
|
52
|
+
* The history LENGTH the measurement in `lastUsage` covered (hardening W1).
|
|
53
|
+
*
|
|
54
|
+
* `undefined` DISABLES THE DELTA and reproduces round 1's arithmetic exactly.
|
|
55
|
+
* Every host-side mutation that is not an append must clear it - see
|
|
56
|
+
* `invalidateMeasurement`.
|
|
57
|
+
*/
|
|
58
|
+
measuredPrefixLength;
|
|
59
|
+
/**
|
|
60
|
+
* The messages the last splice dropped, held ONLY until the wiring collects
|
|
61
|
+
* them for the archive (W4 / RV-3).
|
|
62
|
+
*
|
|
63
|
+
* NOT ON `CompactionRecord`: that record is dispatched into the reducer and
|
|
64
|
+
* retained in view state for the card's lifetime, so putting a megabyte of
|
|
65
|
+
* history on it would park the dropped conversation in React state - precisely
|
|
66
|
+
* the memory this compaction just reclaimed.
|
|
67
|
+
*
|
|
68
|
+
* SET ONLY WHEN ARCHIVING IS ON, so with the key off the slice is never
|
|
69
|
+
* retained and W4 costs nothing.
|
|
70
|
+
*/
|
|
71
|
+
lastDropped = null;
|
|
72
|
+
// --- Abort plumbing for the summarization call (see `summarize-call.ts`) ---
|
|
73
|
+
callAbort = null;
|
|
74
|
+
callAbortReason = null;
|
|
75
|
+
/** Guard 2's ceiling for this instance (hardening §3.4.2 mechanism B / DH-16). */
|
|
76
|
+
maxPerRun;
|
|
77
|
+
constructor(deps, opts) {
|
|
78
|
+
this.deps = deps;
|
|
79
|
+
this.maxPerRun = opts?.maxPerRun ?? COMPACTION_LIMITS.maxPerRun;
|
|
80
|
+
}
|
|
81
|
+
// =========================================================================
|
|
82
|
+
// Lifetime
|
|
83
|
+
// =========================================================================
|
|
84
|
+
/** `agent_start` — the per-run cap resets, the session totals do not. */
|
|
85
|
+
onRunStart() {
|
|
86
|
+
this.compactionsThisRun = 0;
|
|
87
|
+
this.lastCompactionTurn = -Infinity;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `turn_end` — recompute the calibration offset (D-23).
|
|
91
|
+
*
|
|
92
|
+
* RE-MEASURED EVERY TURN, not once, so it tracks a toolset that changed
|
|
93
|
+
* mid-session (`/tools`, a skill that registers one).
|
|
94
|
+
*/
|
|
95
|
+
onTurnEnd(usage, messages, systemPrompt) {
|
|
96
|
+
this.estimateOffset = computeEstimateOffset(usage, messages, systemPrompt);
|
|
97
|
+
// THE SAME ARRAY `computeEstimateOffset` JUST MEASURED, so the two numbers
|
|
98
|
+
// describe the same request by construction (hardening §3.2.3).
|
|
99
|
+
this.measuredPrefixLength = messages.length;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Any host-side history mutation that is NOT an append (hardening §3.2.3).
|
|
103
|
+
*
|
|
104
|
+
* `/clear`, `/resume`, and the splice itself: after any of them a recorded
|
|
105
|
+
* index means nothing, and the delta would be measured against the wrong
|
|
106
|
+
* array. `estimateAppendedTokens` bounds-checks as well, so a MISSED call here
|
|
107
|
+
* costs accuracy - the number degrades to round 1's - and never correctness.
|
|
108
|
+
*/
|
|
109
|
+
invalidateMeasurement() {
|
|
110
|
+
this.measuredPrefixLength = undefined;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The dropped messages, released as they are returned (W4 / RV-3).
|
|
114
|
+
*
|
|
115
|
+
* `take` RATHER THAN `get`: the reference is dropped on read, so a wiring that
|
|
116
|
+
* declines to archive (key off, or `applied: false`) cannot pin it and a second
|
|
117
|
+
* read cannot double-write. `settlePending` calls this on EVERY verdict and
|
|
118
|
+
* discards on the ones that do not archive, which is what guarantees the array
|
|
119
|
+
* cannot outlive one compaction regardless of which branch was taken.
|
|
120
|
+
*/
|
|
121
|
+
takeLastDropped() {
|
|
122
|
+
const dropped = this.lastDropped;
|
|
123
|
+
this.lastDropped = null;
|
|
124
|
+
return dropped;
|
|
125
|
+
}
|
|
126
|
+
/** Cancel an in-flight summarization (Esc, `dispose`). */
|
|
127
|
+
abort() {
|
|
128
|
+
if (this.callAbort) {
|
|
129
|
+
this.callAbortReason = 'cancelled';
|
|
130
|
+
this.callAbort.abort();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// =========================================================================
|
|
134
|
+
// The manual queue (§4.4 / D-17)
|
|
135
|
+
// =========================================================================
|
|
136
|
+
/**
|
|
137
|
+
* Ask for a compaction at the next turn boundary.
|
|
138
|
+
*
|
|
139
|
+
* THE BOUNDARY IS THE ONLY MOMENT THE ENGINE IS SINGLE-THREADED WITH RESPECT
|
|
140
|
+
* TO THE HISTORY, by construction. Replacing `messages` under a live loop is a
|
|
141
|
+
* race; a flag `shouldCompact` honours there is not.
|
|
142
|
+
*/
|
|
143
|
+
queueManual(instructions) {
|
|
144
|
+
this.pendingManual = instructions ? { instructions } : {};
|
|
145
|
+
}
|
|
146
|
+
hasPendingManual() {
|
|
147
|
+
return this.pendingManual !== null;
|
|
148
|
+
}
|
|
149
|
+
clearPendingManual() {
|
|
150
|
+
this.pendingManual = null;
|
|
151
|
+
}
|
|
152
|
+
// =========================================================================
|
|
153
|
+
// ContextManager — the sync gate
|
|
154
|
+
// =========================================================================
|
|
155
|
+
/**
|
|
156
|
+
* SYNCHRONOUS AND CHEAP. Two deliberate effects: the pressure cache, and
|
|
157
|
+
* `deps.onPressure` (hardening DH-15).
|
|
158
|
+
*
|
|
159
|
+
* THE MEASUREMENT IS TAKEN ONCE, ABOVE THE GUARDS, AND REPORTED
|
|
160
|
+
* UNCONDITIONALLY. Below them it is skipped on five paths — the per-run cap,
|
|
161
|
+
* the cooldown, manual, overflow and guard 4 — and TWO OF THOSE NEVER CLEAR
|
|
162
|
+
* WITHIN A RUN. A gauge fed from the measurement would therefore go dark
|
|
163
|
+
* exactly when compaction is exhausted and the window is still filling, which
|
|
164
|
+
* is the one state where the corrected number is the user's only warning. It
|
|
165
|
+
* would silently revert to the measured base — the very figure W1 exists
|
|
166
|
+
* because it under-reports.
|
|
167
|
+
*
|
|
168
|
+
* `!config.enabled` STAYS THE FIRST STATEMENT, so a session that turned the
|
|
169
|
+
* feature off pays nothing at all and AC-1's byte-identity claim is untouched.
|
|
170
|
+
*/
|
|
171
|
+
shouldCompact(probe) {
|
|
172
|
+
const config = this.deps.getConfig().compaction;
|
|
173
|
+
if (!config.enabled)
|
|
174
|
+
return false;
|
|
175
|
+
// THE HISTORY IS PASSED IN, NEVER OMITTED (§3.4.2). Without it the estimate
|
|
176
|
+
// branch of `computePressure` measures an EMPTY array and reports 0 %, so a
|
|
177
|
+
// resumed 180 k-token session — where `lastUsage` is `undefined` by
|
|
178
|
+
// definition and this branch is the only one available — would never trigger.
|
|
179
|
+
// The measured branch reads `messages` only to estimate what was appended
|
|
180
|
+
// after the measurement (hardening §3.2.2); the slice is one turn wide.
|
|
181
|
+
const pressure = this.measure({
|
|
182
|
+
...(probe.lastUsage ? { lastUsage: probe.lastUsage } : {}),
|
|
183
|
+
messages: this.deps.getMessages(),
|
|
184
|
+
systemPrompt: this.deps.getSystemPrompt(),
|
|
185
|
+
});
|
|
186
|
+
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)
|
|
193
|
+
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)
|
|
203
|
+
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')
|
|
212
|
+
return true;
|
|
213
|
+
// Guard 4 — self-disable suppresses the PRESSURE trigger only.
|
|
214
|
+
if (this.selfDisabled)
|
|
215
|
+
return false;
|
|
216
|
+
return shouldCompactAt(pressure, config.threshold, {
|
|
217
|
+
maxOutputTokens: this.effectiveMaxOutputTokens(),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// =========================================================================
|
|
221
|
+
// ContextManager — the work
|
|
222
|
+
// =========================================================================
|
|
223
|
+
async compact(ctx) {
|
|
224
|
+
const manual = this.pendingManual;
|
|
225
|
+
const instructions = manual?.instructions;
|
|
226
|
+
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.
|
|
236
|
+
this.compactionsThisRun += 1;
|
|
237
|
+
this.lastCompactionTurn = ctx.turnIndex;
|
|
238
|
+
this.inFlight = true;
|
|
239
|
+
const started = Date.now();
|
|
240
|
+
try {
|
|
241
|
+
return await this.runCompaction({
|
|
242
|
+
ctx,
|
|
243
|
+
uiTrigger,
|
|
244
|
+
config,
|
|
245
|
+
summarizer,
|
|
246
|
+
...(instructions ? { instructions } : {}),
|
|
247
|
+
started,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
this.inFlight = false;
|
|
252
|
+
this.callAbort = null;
|
|
253
|
+
this.callAbortReason = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
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);
|
|
417
|
+
}
|
|
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,
|
|
428
|
+
});
|
|
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.
|
|
441
|
+
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.`);
|
|
449
|
+
}
|
|
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
|
+
}
|
|
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.modelInfo().contextWindow,
|
|
472
|
+
maxOutputTokens: this.effectiveMaxOutputTokens(),
|
|
473
|
+
}),
|
|
474
|
+
log: (event, fields) => this.log.info(event, fields),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
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.ref, 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 mainRef = this.mainRef();
|
|
494
|
+
const retryRef = summarizer.fromFastTier && mainRef.modelId !== summarizer.ref.modelId ? mainRef : summarizer.ref;
|
|
495
|
+
this.log.warn('summarize_retry', { reason: first.reason, model: retryRef.modelId });
|
|
496
|
+
const second = await this.callOnce(retryRef, args);
|
|
497
|
+
if (second.kind === 'ok' || second.kind === 'aborted')
|
|
498
|
+
return second;
|
|
499
|
+
return { kind: 'failed', reason: second.reason };
|
|
500
|
+
}
|
|
501
|
+
async callOnce(ref, args) {
|
|
502
|
+
const apiKey = this.deps.getApiKey(ref.providerId);
|
|
503
|
+
if (!apiKey)
|
|
504
|
+
return { kind: 'failed', reason: 'no_api_key' };
|
|
505
|
+
if (args.ctx.signal.aborted)
|
|
506
|
+
return { kind: 'aborted' };
|
|
507
|
+
// The controller is owned HERE, not in `summarize-call.ts`, so the caller can
|
|
508
|
+
// stamp WHICH of "timeout" and "cancelled" happened before aborting — the
|
|
509
|
+
// distinction the retry layer flattens away (see that file's header).
|
|
510
|
+
const controller = new AbortController();
|
|
511
|
+
this.callAbort = controller;
|
|
512
|
+
this.callAbortReason = null;
|
|
513
|
+
const onRunAbort = () => {
|
|
514
|
+
this.callAbortReason = 'cancelled';
|
|
515
|
+
controller.abort();
|
|
516
|
+
};
|
|
517
|
+
args.ctx.signal.addEventListener('abort', onRunAbort, { once: true });
|
|
518
|
+
try {
|
|
519
|
+
const outcome = await runSummarizeCall({
|
|
520
|
+
ref,
|
|
521
|
+
apiKey,
|
|
522
|
+
digest: args.digest,
|
|
523
|
+
hasPriorSummary: args.hasPriorSummary,
|
|
524
|
+
...(args.instructions ? { instructions: args.instructions } : {}),
|
|
525
|
+
signal: controller.signal,
|
|
526
|
+
now: Date.now(),
|
|
527
|
+
complete: (providerId, request) => this.deps.complete(providerId, request),
|
|
528
|
+
onTimeout: () => {
|
|
529
|
+
this.callAbortReason = 'timeout';
|
|
530
|
+
controller.abort();
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
if (!outcome.ok) {
|
|
534
|
+
if (this.callAbortReason === 'cancelled' || args.ctx.signal.aborted)
|
|
535
|
+
return { kind: 'aborted' };
|
|
536
|
+
const reason = this.callAbortReason === 'timeout' ? 'timeout' : errorText(outcome.error);
|
|
537
|
+
return { kind: 'failed', reason };
|
|
538
|
+
}
|
|
539
|
+
this.noteUsage(ref, outcome.usage);
|
|
540
|
+
if (outcome.text.trim().length === 0)
|
|
541
|
+
return { kind: 'failed', reason: 'empty_summary' };
|
|
542
|
+
return { kind: 'ok', text: outcome.text.trim() };
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
args.ctx.signal.removeEventListener('abort', onRunAbort);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Guard 3 — the progress requirement (§3.8).
|
|
550
|
+
*
|
|
551
|
+
* ADVISORY ON OVERFLOW, NEVER A VETO (D-24 / P1-13). The arithmetic that makes
|
|
552
|
+
* this guard sensible for the proactive trigger INVERTS on the reactive one: a
|
|
553
|
+
* pressure compaction that reclaims little has spent money to buy two turns and
|
|
554
|
+
* should stop, while an overflow compaction that reclaims little is the
|
|
555
|
+
* difference between a continuing run and a dead one, because the provider has
|
|
556
|
+
* already refused the request.
|
|
557
|
+
*
|
|
558
|
+
* The projection is ESTIMATED — no usage exists for a history that has not been
|
|
559
|
+
* sent yet — and is therefore CALIBRATED with `estimateOffset` (D-23).
|
|
560
|
+
*
|
|
561
|
+
* IT TAKES THE *UI* TRIGGER, not `ctx.trigger`. A queued `/compact` reaches the
|
|
562
|
+
* loop as an ordinary `pressure` checkpoint (that is what the queue IS), so
|
|
563
|
+
* charging its result to guard 4's counter would let a user who types
|
|
564
|
+
* `/compact` twice on a short conversation self-disable the proactive trigger
|
|
565
|
+
* for the rest of the session — and be told, wrongly, that their recent turns
|
|
566
|
+
* exceed the threshold. `uiTrigger` is the only value that knows the difference.
|
|
567
|
+
*/
|
|
568
|
+
checkProgress(ctx, uiTrigger, after) {
|
|
569
|
+
const config = this.deps.getConfig().compaction;
|
|
570
|
+
const info = this.modelInfo();
|
|
571
|
+
const projected = computePressure({
|
|
572
|
+
messages: after,
|
|
573
|
+
systemPrompt: ctx.systemPrompt,
|
|
574
|
+
contextWindow: info.contextWindow,
|
|
575
|
+
windowKnown: true,
|
|
576
|
+
...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
|
|
577
|
+
});
|
|
578
|
+
const target = config.threshold - COMPACTION_LIMITS.minReclaimRatio;
|
|
579
|
+
const madeProgress = projected.ratio < target;
|
|
580
|
+
this.log.info('compaction_progress', {
|
|
581
|
+
trigger: uiTrigger,
|
|
582
|
+
projectedRatio: Number(projected.ratio.toFixed(3)),
|
|
583
|
+
target: Number(target.toFixed(3)),
|
|
584
|
+
madeProgress,
|
|
585
|
+
});
|
|
586
|
+
if (madeProgress) {
|
|
587
|
+
this.consecutiveNoProgress = 0;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
this.noteNoProgress(uiTrigger, 'insufficient_reclaim');
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Guard 4 — stuck detection (§3.8).
|
|
594
|
+
*
|
|
595
|
+
* PRESSURE ONLY, AND `pressure` HERE MEANS THE *UI* TRIGGER. A session that
|
|
596
|
+
* self-disables still survives an overflow rather than dying at the next
|
|
597
|
+
* request, and if the tail really is too large the overflow attempt is the one
|
|
598
|
+
* that can do something about it (D-24). A `manual` compaction is excluded for
|
|
599
|
+
* a different reason: this guard exists to stop UNREQUESTED spend, and
|
|
600
|
+
* `/compact` on a conversation with nothing worth dropping is a user who asked
|
|
601
|
+
* — twice — not a runaway loop.
|
|
602
|
+
*/
|
|
603
|
+
noteNoProgress(trigger, reason) {
|
|
604
|
+
if (trigger !== 'pressure')
|
|
605
|
+
return;
|
|
606
|
+
this.consecutiveNoProgress += 1;
|
|
607
|
+
if (this.consecutiveNoProgress < COMPACTION_LIMITS.stuckLimit || this.selfDisabled)
|
|
608
|
+
return;
|
|
609
|
+
this.selfDisabled = true;
|
|
610
|
+
this.selfDisabledReason = reason;
|
|
611
|
+
this.log.warn('compaction_self_disabled', { reason });
|
|
612
|
+
// THE WORDING NAMES THE ACTUAL CAUSE AND THE ACTUAL REMEDIES. A notice that
|
|
613
|
+
// says only "compaction is off" teaches the user that the feature is broken;
|
|
614
|
+
// this one tells them which number to change.
|
|
615
|
+
this.deps.notify('warn', 'Auto-compaction is off for this session: the most recent ' +
|
|
616
|
+
`${this.deps.getConfig().compaction.keepRecentTurns} turns alone exceed the threshold. ` +
|
|
617
|
+
'Lower compaction.keepRecentTurns, or use /clear or /reset.');
|
|
618
|
+
}
|
|
619
|
+
finish(args, outcome, applied, keepRecentTurnsUsed, announced) {
|
|
620
|
+
const before = args.ctx.messages.length;
|
|
621
|
+
const after = outcome.action === 'replace' ? outcome.messages.length : before;
|
|
622
|
+
const tokensBefore = this.lastPressure?.occupied ?? 0;
|
|
623
|
+
// `'relieved'` IS A CLI-ONLY WORD (RV-5 / DH-20). The port reports
|
|
624
|
+
// `'truncated'` for a relief-only compaction, which is accurate for the
|
|
625
|
+
// engine's narrower question; the card's `summarized N messages with
|
|
626
|
+
// <model>` line would otherwise render "summarized 0 messages with
|
|
627
|
+
// claude-haiku-4-5" for a compaction in which no model was called.
|
|
628
|
+
const mode = reliefOnly(outcome)
|
|
629
|
+
? 'relieved'
|
|
630
|
+
: outcome.action === 'replace'
|
|
631
|
+
? outcome.mode
|
|
632
|
+
: 'none';
|
|
633
|
+
// NO MODEL ON A RELIEF-ONLY RECORD, because no model was called. The card
|
|
634
|
+
// names the summarizer from this field, and naming one here would be the
|
|
635
|
+
// same claim `mode: 'relieved'` exists to stop making.
|
|
636
|
+
const model = reliefOnly(outcome) ? '' : args.summarizer?.ref.modelId ?? '';
|
|
637
|
+
const durationMs = Date.now() - args.started;
|
|
638
|
+
// THE ON-DISK RECORD OF EVERY ATTEMPT IS UNCHANGED (quiet-noop §9). This
|
|
639
|
+
// feature makes the TRANSCRIPT quiet, not the logs: a declined compaction is
|
|
640
|
+
// still one `compaction` line here, still one `compaction_start` /
|
|
641
|
+
// `compaction_end` pair from CORE in the JSONL sink, and still reflected in
|
|
642
|
+
// `/compact status`.
|
|
643
|
+
this.log.info('compaction', {
|
|
644
|
+
trigger: args.uiTrigger,
|
|
645
|
+
announced: announced !== null,
|
|
646
|
+
before,
|
|
647
|
+
after,
|
|
648
|
+
droppedMessages: applied?.droppedMessages ?? 0,
|
|
649
|
+
mode,
|
|
650
|
+
model,
|
|
651
|
+
durationMs,
|
|
652
|
+
});
|
|
653
|
+
// A SILENT DECLINE (quiet-noop §3.1). No card was opened, so there is nothing
|
|
654
|
+
// to settle and nothing to say IN THE TRANSCRIPT. Returning here is what makes
|
|
655
|
+
// a declined checkpoint indistinguishable from one at which `shouldCompact`
|
|
656
|
+
// said no.
|
|
657
|
+
//
|
|
658
|
+
// `declined` IS THE ONE THING THAT STILL SPEAKS (D-9). Quiet is the goal;
|
|
659
|
+
// invisible is not, and `/compact status` is a published guaranteed surface
|
|
660
|
+
// (`context-auto-compaction` §6.5). One integer here is what keeps that
|
|
661
|
+
// promise true while the card goes away.
|
|
662
|
+
if (!announced) {
|
|
663
|
+
this.declined += 1;
|
|
664
|
+
return outcome;
|
|
665
|
+
}
|
|
666
|
+
const record = {
|
|
667
|
+
index: announced.index,
|
|
668
|
+
trigger: args.uiTrigger,
|
|
669
|
+
mode,
|
|
670
|
+
// OPTIMISTIC AND CORRECTED BY THE WIRING (§3.2.1). Only the ENGINE knows
|
|
671
|
+
// whether the splice passed the structural gate, and it says so on
|
|
672
|
+
// `compaction_end`; the wiring rewrites this before the card settles.
|
|
673
|
+
applied: outcome.action === 'replace',
|
|
674
|
+
...(outcome.action === 'keep' ? { reason: outcome.reason } : {}),
|
|
675
|
+
...(outcome.action === 'replace' && outcome.reason ? { reason: outcome.reason } : {}),
|
|
676
|
+
messagesBefore: before,
|
|
677
|
+
messagesAfter: after,
|
|
678
|
+
tokensBefore,
|
|
679
|
+
tokensAfter: 0, // filled by the wiring from the engine's own estimator
|
|
680
|
+
...(applied?.summary ? { summary: applied.summary } : {}),
|
|
681
|
+
model,
|
|
682
|
+
durationMs,
|
|
683
|
+
...(args.ctx.trigger === 'overflow' ? { keepRecentTurnsUsed } : {}),
|
|
684
|
+
...(args.tailRelief ? { tailRelief: args.tailRelief } : {}),
|
|
685
|
+
};
|
|
686
|
+
this.deps.emit({ type: 'compaction_end', record });
|
|
687
|
+
return outcome;
|
|
688
|
+
}
|
|
689
|
+
// =========================================================================
|
|
690
|
+
// Reporting helpers
|
|
691
|
+
// =========================================================================
|
|
692
|
+
/**
|
|
693
|
+
* The pressure the trigger and the gauge both read.
|
|
694
|
+
*
|
|
695
|
+
* CACHED on every evaluation, so `/compact status` reports the same number the
|
|
696
|
+
* last decision used rather than recomputing one that has since moved.
|
|
697
|
+
*/
|
|
698
|
+
measure(probe) {
|
|
699
|
+
const info = this.modelInfo();
|
|
700
|
+
const pressure = computePressure({
|
|
701
|
+
...(probe.lastUsage ? { lastUsage: probe.lastUsage } : {}),
|
|
702
|
+
messages: probe.messages ?? [],
|
|
703
|
+
systemPrompt: probe.systemPrompt ?? '',
|
|
704
|
+
contextWindow: info.contextWindow,
|
|
705
|
+
windowKnown: this.deps.isPricedModel({
|
|
706
|
+
providerId: this.mainRef().providerId,
|
|
707
|
+
modelId: this.mainRef().modelId,
|
|
708
|
+
}),
|
|
709
|
+
...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
|
|
710
|
+
...(this.measuredPrefixLength !== undefined
|
|
711
|
+
? { measuredPrefixLength: this.measuredPrefixLength }
|
|
712
|
+
: {}),
|
|
713
|
+
});
|
|
714
|
+
this.lastPressure = pressure;
|
|
715
|
+
return pressure;
|
|
716
|
+
}
|
|
717
|
+
lastMeasured() {
|
|
718
|
+
return this.lastPressure;
|
|
719
|
+
}
|
|
720
|
+
/** Written by the wiring once the engine has reported its verdict. */
|
|
721
|
+
recordApplied(tokensBefore, tokensAfter) {
|
|
722
|
+
const reclaimed = Math.max(0, tokensBefore - tokensAfter);
|
|
723
|
+
this.tokensReclaimed += reclaimed;
|
|
724
|
+
}
|
|
725
|
+
sessionTotals() {
|
|
726
|
+
return {
|
|
727
|
+
compactions: this.compactions,
|
|
728
|
+
declined: this.declined,
|
|
729
|
+
tokensReclaimed: this.tokensReclaimed,
|
|
730
|
+
usage: this.sessionUsage,
|
|
731
|
+
pricingUnknown: this.pricingUnknown,
|
|
732
|
+
inFlight: this.inFlight,
|
|
733
|
+
selfDisabled: this.selfDisabled,
|
|
734
|
+
...(this.selfDisabledReason ? { selfDisabledReason: this.selfDisabledReason } : {}),
|
|
735
|
+
generation: this.generation,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
/** `/compact on` after a self-disable — the user overriding the guard. */
|
|
739
|
+
clearSelfDisable() {
|
|
740
|
+
this.selfDisabled = false;
|
|
741
|
+
this.selfDisabledReason = undefined;
|
|
742
|
+
this.consecutiveNoProgress = 0;
|
|
743
|
+
}
|
|
744
|
+
isSelfDisabled() {
|
|
745
|
+
return this.selfDisabled;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Which model summarizes (§3.6.4).
|
|
749
|
+
*
|
|
750
|
+
* SAY THE DEFAULT OUT LOUD (P2-8): FOR ALMOST EVERYONE THIS IS THE MAIN MODEL.
|
|
751
|
+
* `resolveFastTier` returns `{ok: false, reason: 'disabled'}` on
|
|
752
|
+
* `fast.enabled !== true` alone, and `fast.enabled` defaults to `false` — so
|
|
753
|
+
* `useFastTier: true` describes a PREFERENCE, not the common path, and the
|
|
754
|
+
* out-of-the-box first compaction is a ~30 k-token call on the session's own
|
|
755
|
+
* model. Three things make that the right trade rather than a hidden cost: it
|
|
756
|
+
* happens at most `maxPerRun` times per run, the alternative is losing the
|
|
757
|
+
* entire run, and the spend is on the card with the summarizer's own price.
|
|
758
|
+
*/
|
|
759
|
+
resolveSummarizer() {
|
|
760
|
+
const config = this.deps.getConfig();
|
|
761
|
+
if (config.compaction.useFastTier) {
|
|
762
|
+
const tier = resolveFastTier(config, this.deps.hasKey);
|
|
763
|
+
if (tier.ok)
|
|
764
|
+
return { ref: tier.ref, fromFastTier: true };
|
|
765
|
+
}
|
|
766
|
+
const ref = this.mainRef();
|
|
767
|
+
if (!this.deps.hasKey(ref.providerId))
|
|
768
|
+
return null;
|
|
769
|
+
return { ref, fromFastTier: false };
|
|
770
|
+
}
|
|
771
|
+
mainRef() {
|
|
772
|
+
const config = this.deps.getConfig();
|
|
773
|
+
return {
|
|
774
|
+
providerId: config.provider,
|
|
775
|
+
modelId: config.model,
|
|
776
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
modelInfo() {
|
|
780
|
+
return this.deps.getModelInfoFor(this.mainRef());
|
|
781
|
+
}
|
|
782
|
+
effectiveMaxOutputTokens() {
|
|
783
|
+
const config = this.deps.getConfig();
|
|
784
|
+
// `undefined` means AUTO; the model's own ceiling is the honest bound then.
|
|
785
|
+
return config.maxTokens ?? this.modelInfo().maxOutputTokens;
|
|
786
|
+
}
|
|
787
|
+
noteUsage(ref, usage) {
|
|
788
|
+
this.sessionUsage = accumulateUsage(this.sessionUsage, usage);
|
|
789
|
+
// UNKNOWN PRICING IS NOT ZERO PRICING. It is sticky: once a summarization has
|
|
790
|
+
// run on an unpriced model the session total is no longer a complete figure,
|
|
791
|
+
// and a later priced call does not make it one again.
|
|
792
|
+
if (!this.deps.isPricedModel(ref))
|
|
793
|
+
this.pricingUnknown = true;
|
|
794
|
+
this.deps.emit({ type: 'usage', usage });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/** A "turn" is a `user`-role message; `tool_result` is a separate role. */
|
|
798
|
+
function countUserTurns(messages) {
|
|
799
|
+
return messages.reduce((acc, m) => (m.role === 'user' ? acc + 1 : acc), 0);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* The relief-only outcome, recognized by the one reason string that produces it.
|
|
803
|
+
*
|
|
804
|
+
* A REASON RATHER THAN A NEW `mode` ON THE PORT (DH-5): `CompactionOutcome.mode`
|
|
805
|
+
* is a public contract with two members, and widening it to carry a distinction
|
|
806
|
+
* only the CLI's card acts on would be a breaking change for a reporting nicety.
|
|
807
|
+
*/
|
|
808
|
+
function reliefOnly(outcome) {
|
|
809
|
+
return outcome.action === 'replace' && outcome.reason === 'tail_relief_only';
|
|
810
|
+
}
|