@aragon-agent/cli 0.6.13 → 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.
- package/CHANGELOG.md +41 -0
- package/README.md +250 -222
- package/dist/agent/controller.d.ts +36 -2
- package/dist/agent/controller.js +244 -57
- package/dist/agent/model-profile-settings.js +11 -2
- package/dist/agent/plan-prompt.d.ts +1 -1
- package/dist/agent/plan-prompt.js +4 -3
- package/dist/agent/project-guidance-prompt.d.ts +4 -0
- package/dist/agent/project-guidance-prompt.js +104 -0
- package/dist/agent/reducer.d.ts +2 -0
- package/dist/agent/reducer.js +5 -0
- package/dist/agent/system-prompt.js +4 -0
- package/dist/boot/guard.d.ts +3 -3
- package/dist/boot/guard.js +3 -3
- package/dist/cli.js +33 -15
- package/dist/commands/builtins.js +2 -2
- package/dist/compaction/child.d.ts +9 -1
- package/dist/compaction/child.js +43 -13
- package/dist/compaction/command.js +20 -10
- package/dist/compaction/compactor.d.ts +33 -204
- package/dist/compaction/compactor.js +311 -723
- package/dist/compaction/context-command.js +5 -3
- package/dist/compaction/digest.d.ts +15 -51
- package/dist/compaction/digest.js +113 -148
- package/dist/compaction/limits.d.ts +19 -142
- package/dist/compaction/limits.js +20 -147
- package/dist/compaction/memory-identity.d.ts +16 -0
- package/dist/compaction/memory-identity.js +63 -0
- package/dist/compaction/memory-input.d.ts +25 -0
- package/dist/compaction/memory-input.js +106 -0
- package/dist/compaction/memory-render.d.ts +4 -0
- package/dist/compaction/memory-render.js +47 -0
- package/dist/compaction/memory-validation.d.ts +21 -0
- package/dist/compaction/memory-validation.js +268 -0
- package/dist/compaction/memory.d.ts +79 -0
- package/dist/compaction/memory.js +118 -0
- package/dist/compaction/meter.d.ts +14 -10
- package/dist/compaction/meter.js +55 -19
- package/dist/compaction/operation.d.ts +18 -0
- package/dist/compaction/operation.js +68 -0
- package/dist/compaction/pressure.d.ts +6 -20
- package/dist/compaction/pressure.js +14 -23
- package/dist/compaction/summarize-call.d.ts +3 -16
- package/dist/compaction/summarize-call.js +20 -20
- package/dist/compaction/summary-prompt.d.ts +29 -66
- package/dist/compaction/summary-prompt.js +132 -178
- package/dist/compaction/types.d.ts +23 -3
- package/dist/compaction/wiring.d.ts +10 -25
- package/dist/compaction/wiring.js +60 -67
- package/dist/config/load.d.ts +3 -1
- package/dist/config/load.js +8 -4
- package/dist/config/schema.d.ts +3 -3
- package/dist/config/schema.js +1 -1
- package/dist/exec/events.d.ts +8 -0
- package/dist/exec/index.js +13 -6
- package/dist/exec/runner.d.ts +8 -0
- package/dist/exec/runner.js +26 -2
- package/dist/session/persist.d.ts +3 -0
- package/dist/session/persist.js +9 -0
- package/dist/session/store.js +2 -0
- package/dist/session/validate-session.js +34 -0
- package/dist/team/subagent.js +8 -0
- package/dist/ui/App.js +221 -126
- package/dist/ui/BottomStatusRow.d.ts +5 -10
- package/dist/ui/BottomStatusRow.js +7 -11
- package/dist/ui/Composer.d.ts +4 -2
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/Header.d.ts +6 -2
- package/dist/ui/Header.js +7 -6
- package/dist/ui/Logo.d.ts +3 -5
- package/dist/ui/Logo.js +4 -8
- package/dist/ui/PromptInput.d.ts +3 -1
- package/dist/ui/PromptInput.js +35 -42
- package/dist/ui/StatusBar.d.ts +6 -0
- package/dist/ui/StatusBar.js +8 -7
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/capabilities.d.ts +2 -0
- package/dist/ui/capabilities.js +6 -0
- package/dist/ui/color-runtime.d.ts +5 -0
- package/dist/ui/color-runtime.js +29 -0
- package/dist/ui/entries/CompactionCard.d.ts +3 -1
- package/dist/ui/entries/CompactionCard.js +7 -5
- package/dist/ui/frame-differ.js +27 -0
- package/dist/ui/frame-observer.js +18 -5
- package/dist/ui/glyphs.js +10 -8
- package/dist/ui/ink-runtime.d.ts +3 -0
- package/dist/ui/ink-runtime.js +30 -0
- package/dist/ui/interaction-copy.d.ts +73 -73
- package/dist/ui/interaction-copy.js +30 -38
- package/dist/ui/interaction-hints.d.ts +8 -1
- package/dist/ui/interaction-hints.js +56 -69
- package/dist/ui/layout/AppShell.d.ts +6 -2
- package/dist/ui/layout/AppShell.js +2 -2
- package/dist/ui/layout/OverlayFrame.d.ts +2 -0
- package/dist/ui/layout/OverlayFrame.js +23 -14
- package/dist/ui/layout/ScrollViewport.d.ts +3 -1
- package/dist/ui/layout/ScrollViewport.js +10 -2
- package/dist/ui/layout/budget.d.ts +25 -26
- package/dist/ui/layout/budget.js +29 -22
- package/dist/ui/layout/document-layout.d.ts +2 -2
- package/dist/ui/layout/document-layout.js +2 -3
- package/dist/ui/layout/queue-layout.js +4 -4
- package/dist/ui/layout/status-detail-layout.d.ts +9 -0
- package/dist/ui/layout/status-detail-layout.js +56 -0
- package/dist/ui/layout/status-layout.d.ts +19 -2
- package/dist/ui/layout/status-layout.js +95 -83
- package/dist/ui/layout/wrap-rows.d.ts +2 -0
- package/dist/ui/layout/wrap-rows.js +21 -7
- package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
- package/dist/ui/overlays/ConfirmDialog.js +22 -3
- package/dist/ui/overlays/HelpOverlay.js +9 -2
- package/dist/ui/overlays/ModelPicker.d.ts +5 -8
- package/dist/ui/overlays/ModelPicker.js +44 -10
- package/dist/ui/overlays/ModelProfileEditor.js +2 -0
- package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
- package/dist/ui/overlays/ModelProfilePicker.js +4 -2
- package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
- package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
- package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
- package/dist/ui/overlays/QuestionOverlay.js +45 -32
- package/dist/ui/overlays/QueueOverlay.js +2 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
- package/dist/ui/overlays/SettingsScreen.js +10 -3
- package/dist/ui/render-cache.d.ts +2 -2
- package/dist/ui/render-cache.js +4 -3
- package/dist/ui/run-metrics.d.ts +23 -0
- package/dist/ui/run-metrics.js +27 -0
- package/dist/ui/status-feedback.d.ts +33 -0
- package/dist/ui/status-feedback.js +36 -0
- package/dist/ui/stdout-frame-writer.js +28 -0
- 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 {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this.pendingManual
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
214
|
-
if (this.selfDisabled)
|
|
103
|
+
if (this.selfDisabled || !shouldCompactAt(pressure, config.threshold))
|
|
215
104
|
return false;
|
|
216
|
-
return
|
|
217
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
251
|
-
this.
|
|
252
|
-
this.callAbort = null;
|
|
253
|
-
this.callAbortReason = null;
|
|
128
|
+
catch (error) {
|
|
129
|
+
return this.keep(state, errorText(error));
|
|
254
130
|
}
|
|
255
131
|
}
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 =
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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,
|
|
503
|
-
const
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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 info = this.modelInfo();
|
|
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: info.contextWindow,
|
|
582
|
-
windowKnown: true,
|
|
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
|
-
|
|
296
|
+
return outcome;
|
|
598
297
|
}
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
|
303
|
+
return;
|
|
674
304
|
}
|
|
305
|
+
const p = state.pressure;
|
|
675
306
|
const record = {
|
|
676
|
-
index:
|
|
677
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
364
|
+
measure(_probe) { return this.meter().current(); }
|
|
365
|
+
lastMeasured() { return this.meter().lastPublished(); }
|
|
728
366
|
recordApplied(tokensBefore, tokensAfter) {
|
|
729
|
-
|
|
730
|
-
this.tokensReclaimed += reclaimed;
|
|
367
|
+
this.tokensReclaimed += Math.max(0, tokensBefore - tokensAfter);
|
|
731
368
|
}
|
|
732
369
|
sessionTotals() {
|
|
733
|
-
return {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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,71 +386,43 @@ export class Compactor {
|
|
|
770
386
|
if (tier.ok)
|
|
771
387
|
return { ref: tier.ref, role: 'fast', fromFastTier: true };
|
|
772
388
|
}
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
791
|
-
this.
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
}
|
|
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
|
+
});
|
|
799
407
|
return this.ownMeter;
|
|
800
408
|
}
|
|
801
409
|
mainRef() {
|
|
802
410
|
const config = this.deps.getConfig();
|
|
803
|
-
return {
|
|
804
|
-
|
|
805
|
-
modelId: config.model,
|
|
806
|
-
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
modelInfo() {
|
|
810
|
-
return this.deps.getModelInfoFor(this.mainRef());
|
|
411
|
+
return { providerId: config.provider, modelId: config.model,
|
|
412
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}) };
|
|
811
413
|
}
|
|
812
|
-
|
|
414
|
+
settingsKey() {
|
|
813
415
|
const config = this.deps.getConfig();
|
|
814
|
-
|
|
815
|
-
|
|
416
|
+
return JSON.stringify([config.provider, config.model, config.baseUrl, config.compaction,
|
|
417
|
+
this.deps.getSystemPrompt()]);
|
|
816
418
|
}
|
|
817
419
|
noteUsage(ref, usage) {
|
|
818
420
|
this.sessionUsage = accumulateUsage(this.sessionUsage, usage);
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
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)
|
|
823
424
|
this.pricingUnknown = true;
|
|
824
|
-
this.
|
|
425
|
+
this.costUsd += costUsd;
|
|
426
|
+
this.deps.emit({ type: 'usage', usage, modelRef: ref, costUsd, pricingUnknown });
|
|
825
427
|
}
|
|
826
428
|
}
|
|
827
|
-
/** A "turn" is a `user`-role message; `tool_result` is a separate role. */
|
|
828
|
-
function countUserTurns(messages) {
|
|
829
|
-
return messages.reduce((acc, m) => (m.role === 'user' ? acc + 1 : acc), 0);
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* The relief-only outcome, recognized by the one reason string that produces it.
|
|
833
|
-
*
|
|
834
|
-
* A REASON RATHER THAN A NEW `mode` ON THE PORT (DH-5): `CompactionOutcome.mode`
|
|
835
|
-
* is a public contract with two members, and widening it to carry a distinction
|
|
836
|
-
* only the CLI's card acts on would be a breaking change for a reporting nicety.
|
|
837
|
-
*/
|
|
838
|
-
function reliefOnly(outcome) {
|
|
839
|
-
return outcome.action === 'replace' && outcome.reason === 'tail_relief_only';
|
|
840
|
-
}
|