opencode-clm 0.1.0 → 0.1.1
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/README.md +26 -1
- package/index.ts +4 -0
- package/package.json +39 -9
- package/src/budget.ts +123 -1
- package/src/clm.ts +123 -3
- package/src/context-document.ts +31 -4
- package/src/presentation.ts +4 -1
- package/src/state.ts +49 -2
package/README.md
CHANGED
|
@@ -56,6 +56,31 @@ option.
|
|
|
56
56
|
|
|
57
57
|
Booleans accept `true/false`, `1/0`, `on/off` and `yes/no`.
|
|
58
58
|
|
|
59
|
+
**Choosing `budget`.** The budget counts the whole request, OpenCode's system prompt and
|
|
60
|
+
tool schemas included. With OpenCode 1.18 and its default tools those take about 17,000
|
|
61
|
+
to 19,000 tokens of every request (measured with provider counts), before the
|
|
62
|
+
conversation starts. A budget must hold that overhead, the `reserve`, and a working margin
|
|
63
|
+
for the conversation; the plugin uses 8,000 tokens, room for the task, a few tool results
|
|
64
|
+
and edits. With the default reserve that gives a minimum of about 19,000 + 2,048 + 8,000 ≈
|
|
65
|
+
29,000 tokens; the default 32,000 clears it. More tools (MCP servers, plugins) or a
|
|
66
|
+
steering document raise the overhead.
|
|
67
|
+
|
|
68
|
+
When the budget is smaller, the session raises it. On the first request that follows a
|
|
69
|
+
measured reply, the plugin takes the provider's input count minus its own conversation
|
|
70
|
+
estimate as the fixed overhead. It measures only while the conversation is at most a
|
|
71
|
+
quarter of that count, so the estimate's error stays small; a session resumed with a long
|
|
72
|
+
history is measured once its context is short. If the provider reports no usage, the
|
|
73
|
+
plugin's own size estimate stands in until a provider count arrives. If budget − reserve
|
|
74
|
+
leaves less than 8,000 tokens after the overhead, the effective budget becomes overhead +
|
|
75
|
+
8,000 + reserve, never above the model window minus its output limit (uncapped while the
|
|
76
|
+
window is unknown). The plugin then logs a `budget-too-small` event, shows a warning
|
|
77
|
+
toast, and tells the model once. When the window cap leaves less than the margin, the
|
|
78
|
+
notice says so; lower `reserve` or use a model with a larger window. The overhead is
|
|
79
|
+
stored in `state.json`, so the raise holds after a restart, and it is measured once per
|
|
80
|
+
session: switching agent or model, or adding an MCP server, later does not update it.
|
|
81
|
+
`/clm` shows the fixed overhead, the usable budget (budget − reserve − overhead) and the
|
|
82
|
+
effective budget when raised.
|
|
83
|
+
|
|
59
84
|
A steering document carries context-management strategy (when to compact, what to keep);
|
|
60
85
|
the plugin itself states only the editing protocol. The plugin reads the document once at
|
|
61
86
|
load and appends it to the system prompt; `/clm` shows its name and SHA-256 prefix.
|
|
@@ -179,7 +204,7 @@ Each session has a directory `<mirrorDir>/clm-<session id>/` (mode 0700; files 0
|
|
|
179
204
|
| file | content |
|
|
180
205
|
|---|---|
|
|
181
206
|
| `LIVE_CONTEXT.md` | the mirror |
|
|
182
|
-
| `state.json` | enabled flag, revision, active checkpoint |
|
|
207
|
+
| `state.json` | enabled flag, revision, active checkpoint, measured fixed overhead and budget decision |
|
|
183
208
|
| `annotations.jsonl` | continuity annotations |
|
|
184
209
|
| `events.jsonl` | one line per request, edit, reset, notice and error |
|
|
185
210
|
| `revisions/rN.md` | the mirror text of each accepted revision |
|
package/index.ts
CHANGED
|
@@ -238,6 +238,10 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
238
238
|
try {
|
|
239
239
|
const result = await clm.transform(raw);
|
|
240
240
|
replaceInPlace(raw, result.messages);
|
|
241
|
+
if (result.alert) {
|
|
242
|
+
toast(result.alert, "warning");
|
|
243
|
+
log("warn", result.alert);
|
|
244
|
+
}
|
|
241
245
|
} catch (error) {
|
|
242
246
|
// Fail open: the request goes out with the raw history.
|
|
243
247
|
await clm.log({ event: "error", message: describe(error), stack: error instanceof Error ? error.stack : undefined });
|
package/package.json
CHANGED
|
@@ -1,26 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-clm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "OpenCode plugin for Context Language Models: the model edits a file mirror of its own context, and the edited mirror becomes its next input.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "B Guest",
|
|
8
8
|
"homepage": "https://github.com/bcmyguest/opencode-clm#readme",
|
|
9
|
-
"bugs": {
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/bcmyguest/opencode-clm/issues"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/bcmyguest/opencode-clm.git"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"context-management",
|
|
20
|
+
"context-window",
|
|
21
|
+
"context-language-models",
|
|
22
|
+
"clm"
|
|
23
|
+
],
|
|
12
24
|
"main": "./index.ts",
|
|
13
|
-
"exports": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./index.ts"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"index.ts",
|
|
30
|
+
"src/**/*.ts",
|
|
31
|
+
"skills/**",
|
|
32
|
+
"steering/**",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"NOTICE",
|
|
35
|
+
"README.md",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"bun": ">=1.2",
|
|
40
|
+
"opencode": ">=1.18"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
17
45
|
"scripts": {
|
|
18
46
|
"test": "bun test",
|
|
19
47
|
"typecheck": "tsc --noEmit",
|
|
20
48
|
"check": "tsc --noEmit && bun test",
|
|
21
49
|
"test:e2e": "OPENCODE_CLM_E2E=1 bun test test/e2e"
|
|
22
50
|
},
|
|
23
|
-
"dependencies": {
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@opencode-ai/plugin": "^1.18.34"
|
|
53
|
+
},
|
|
24
54
|
"devDependencies": {
|
|
25
55
|
"@types/bun": "1.3.14",
|
|
26
56
|
"typescript": "5.9.3"
|
package/src/budget.ts
CHANGED
|
@@ -39,6 +39,14 @@ export const DEFAULT_BUDGET_POLICY: BudgetPolicyConfig = {
|
|
|
39
39
|
remindAtReserve: true,
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Room the conversation needs on top of OpenCode's fixed overhead (system prompt and tool
|
|
44
|
+
* schemas) and the reserve: the pinned task, a few tool results and the model's own edits.
|
|
45
|
+
* A budget with less room than this makes the overflow guard withhold nearly every tool
|
|
46
|
+
* result; `budgetFit` then raises the session's effective budget.
|
|
47
|
+
*/
|
|
48
|
+
export const WORKING_MARGIN = 8000;
|
|
49
|
+
|
|
42
50
|
export interface BudgetReading {
|
|
43
51
|
/** Effective budget in tokens (config or model window). */
|
|
44
52
|
budget: number;
|
|
@@ -64,6 +72,8 @@ export interface BudgetReading {
|
|
|
64
72
|
observedStale?: boolean;
|
|
65
73
|
/** Where `budget` came from, for status text. */
|
|
66
74
|
source: "config" | "model-window";
|
|
75
|
+
/** Set when `budget` was raised above the configured value to cover the fixed overhead (see `budgetFit`). */
|
|
76
|
+
raisedFrom?: number;
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/** A reminder threshold in absolute tokens plus a stable label for re-arming. */
|
|
@@ -112,6 +122,115 @@ export function resolveBudget(
|
|
|
112
122
|
return undefined;
|
|
113
123
|
}
|
|
114
124
|
|
|
125
|
+
/** Budget arithmetic for status text, the TUI panel and the budget-too-small check. */
|
|
126
|
+
export interface BudgetFit {
|
|
127
|
+
/** Budget before any raise (configured, or the model window). */
|
|
128
|
+
configured: number;
|
|
129
|
+
reserve: number;
|
|
130
|
+
/** OpenCode's fixed overhead per request in tokens; undefined until measured. */
|
|
131
|
+
overhead?: number;
|
|
132
|
+
/** configured − reserve − overhead: what the conversation gets without a raise (may be negative). */
|
|
133
|
+
usable?: number;
|
|
134
|
+
/** True when configured − reserve leaves less than `margin` after the overhead. */
|
|
135
|
+
tooSmall: boolean;
|
|
136
|
+
/** overhead + margin + reserve: the smallest budget that leaves the margin. */
|
|
137
|
+
minimum?: number;
|
|
138
|
+
/** The budget CLM applies: `configured`, or raised toward `minimum` (capped by `cap`). */
|
|
139
|
+
effective: number;
|
|
140
|
+
/** effective − reserve − overhead. */
|
|
141
|
+
effectiveUsable?: number;
|
|
142
|
+
raised: boolean;
|
|
143
|
+
/** True when `cap` stopped the raise short of `minimum`. */
|
|
144
|
+
capped: boolean;
|
|
145
|
+
margin: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pure: how the budget compares with OpenCode's fixed overhead. When budget − reserve leaves
|
|
150
|
+
* less than `margin` after the overhead, the effective budget rises to overhead + margin +
|
|
151
|
+
* reserve, but never above `cap` (the model window minus its output limit) nor below the
|
|
152
|
+
* configured budget.
|
|
153
|
+
*/
|
|
154
|
+
export function budgetFit(
|
|
155
|
+
configured: number,
|
|
156
|
+
reserve: number,
|
|
157
|
+
overhead: number | undefined,
|
|
158
|
+
cap?: number,
|
|
159
|
+
margin = WORKING_MARGIN,
|
|
160
|
+
): BudgetFit {
|
|
161
|
+
if (overhead === undefined) {
|
|
162
|
+
return { configured, reserve, tooSmall: false, effective: configured, raised: false, capped: false, margin };
|
|
163
|
+
}
|
|
164
|
+
const usable = configured - reserve - overhead;
|
|
165
|
+
const minimum = overhead + margin + reserve;
|
|
166
|
+
const tooSmall = usable < margin;
|
|
167
|
+
const limit = cap !== undefined && Number.isFinite(cap) && cap > 0 ? cap : Number.POSITIVE_INFINITY;
|
|
168
|
+
const effective = tooSmall ? Math.max(configured, Math.min(minimum, limit)) : configured;
|
|
169
|
+
return {
|
|
170
|
+
configured,
|
|
171
|
+
reserve,
|
|
172
|
+
overhead,
|
|
173
|
+
usable,
|
|
174
|
+
tooSmall,
|
|
175
|
+
minimum,
|
|
176
|
+
effective,
|
|
177
|
+
effectiveUsable: effective - reserve - overhead,
|
|
178
|
+
raised: effective > configured,
|
|
179
|
+
capped: tooSmall && minimum > limit,
|
|
180
|
+
margin,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Status line for `budgetFit`: fixed overhead, usable budget, and the raise if any. */
|
|
185
|
+
export function budgetFitLine(fit: BudgetFit): string {
|
|
186
|
+
if (fit.overhead === undefined) return "fixed overhead (system prompt + tool schemas): not measured yet";
|
|
187
|
+
const parts = [
|
|
188
|
+
`fixed overhead (system prompt + tool schemas) ${formatTokens(fit.overhead)} tok`,
|
|
189
|
+
`usable ${formatTokens(fit.usable!)} tok (budget ${formatTokens(fit.configured)} − reserve ${formatTokens(fit.reserve)} − overhead)`,
|
|
190
|
+
];
|
|
191
|
+
if (fit.raised) {
|
|
192
|
+
parts.push(
|
|
193
|
+
`effective budget ${formatTokens(fit.effective)} tok (raised${fit.capped ? ", capped by the model window" : ""}; usable ${formatTokens(fit.effectiveUsable!)} tok)`,
|
|
194
|
+
);
|
|
195
|
+
} else if (fit.tooSmall) {
|
|
196
|
+
parts.push("too small, and the model window allows no raise");
|
|
197
|
+
}
|
|
198
|
+
return parts.join(" · ");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The one-time model notice for a budget that cannot hold OpenCode's fixed overhead plus a
|
|
203
|
+
* working margin. Text written for this package.
|
|
204
|
+
*/
|
|
205
|
+
export function budgetTooSmallNoticeText(fit: BudgetFit): string {
|
|
206
|
+
const head =
|
|
207
|
+
`[CLM BUDGET] The configured budget of ${formatTokens(fit.configured)} tokens is too small for this session: ` +
|
|
208
|
+
`OpenCode's system prompt and tool schemas take about ${formatTokens(fit.overhead ?? 0)} tokens of every request, ` +
|
|
209
|
+
`which leaves ${formatTokens(Math.max(0, fit.usable ?? 0))} tokens for the conversation after the ${formatTokens(fit.reserve)}-token reserve.`;
|
|
210
|
+
const left = formatTokens(Math.max(0, fit.effectiveUsable ?? 0));
|
|
211
|
+
if (fit.capped) {
|
|
212
|
+
const raise = fit.raised ? `CLM raised this session's budget to ${formatTokens(fit.effective)} tokens, the most the model window allows, ` : "The model window allows no larger budget, ";
|
|
213
|
+
return (
|
|
214
|
+
`${head} ${raise}which still leaves only ${left} tokens for the conversation. ` +
|
|
215
|
+
"The overflow guard will hold back most tool results: keep the context mirror short and read files in small parts."
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return (
|
|
219
|
+
`${head} CLM raised this session's budget to ${formatTokens(fit.effective)} tokens, ` +
|
|
220
|
+
`about ${left} tokens for the conversation. ` +
|
|
221
|
+
"Budget reminders and the overflow guard count against the raised budget from now on."
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The user-facing toast for the same case: what happened and what to change. */
|
|
226
|
+
export function budgetTooSmallAlertText(fit: BudgetFit): string {
|
|
227
|
+
const head = `Budget ${formatTokens(fit.configured)} is too small: OpenCode's system prompt and tools take ~${formatTokens(fit.overhead ?? 0)} tokens.`;
|
|
228
|
+
if (fit.capped) {
|
|
229
|
+
return `${head} The model window limits this session to ${formatTokens(fit.effective)}, leaving ~${formatTokens(Math.max(0, fit.effectiveUsable ?? 0))} for the conversation; lower reserve, use fewer tools, or use a model with a larger context window.`;
|
|
230
|
+
}
|
|
231
|
+
return `${head} Raised to ${formatTokens(fit.effective)} for this session; set budget to at least ${formatTokens(fit.minimum!)}.`;
|
|
232
|
+
}
|
|
233
|
+
|
|
115
234
|
export function budgetTiers(policy: BudgetPolicyConfig, budget: number, reserve: number): BudgetTier[] {
|
|
116
235
|
const tiers: BudgetTier[] = policy.remindAtFractions.map((fraction) => ({
|
|
117
236
|
label: `${Math.round(fraction * 100)}%`,
|
|
@@ -176,8 +295,11 @@ function calibrated(reading: Pick<BudgetReading, "calibration">): boolean {
|
|
|
176
295
|
|
|
177
296
|
/** One line for status text: budget, estimate, and observation, each labelled. */
|
|
178
297
|
export function budgetSummaryLine(reading: BudgetReading): string {
|
|
298
|
+
const origin = reading.raisedFrom !== undefined
|
|
299
|
+
? `raised from ${formatTokens(reading.raisedFrom)} to cover the fixed overhead`
|
|
300
|
+
: reading.source === "config" ? "configured" : "model window";
|
|
179
301
|
const parts = [
|
|
180
|
-
`budget ${formatTokens(reading.budget)} tok (${
|
|
302
|
+
`budget ${formatTokens(reading.budget)} tok (${origin}, reserve ${formatTokens(reading.reserve)})`,
|
|
181
303
|
`estimated next request ${formatTokens(reading.estimated)}${calibrated(reading) ? ` (×${reading.calibration!.toFixed(2)} calibrated)` : ""}`,
|
|
182
304
|
];
|
|
183
305
|
parts.push(
|
package/src/clm.ts
CHANGED
|
@@ -37,12 +37,16 @@ import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
|
|
37
37
|
import { join } from "node:path";
|
|
38
38
|
|
|
39
39
|
import {
|
|
40
|
+
budgetFit,
|
|
40
41
|
budgetNoticeText,
|
|
41
42
|
budgetTiers,
|
|
43
|
+
budgetTooSmallAlertText,
|
|
44
|
+
budgetTooSmallNoticeText,
|
|
42
45
|
BudgetTracker,
|
|
43
46
|
EstimateCalibrator,
|
|
44
47
|
formatTokens,
|
|
45
48
|
resolveBudget,
|
|
49
|
+
type BudgetFit,
|
|
46
50
|
type BudgetReading,
|
|
47
51
|
} from "./budget.ts";
|
|
48
52
|
import { applyContextDocument, renderContextDocument, renderMessage } from "./context-document.ts";
|
|
@@ -63,9 +67,11 @@ import type { ClmStatus } from "./presentation.ts";
|
|
|
63
67
|
import { applyProjection, createProjectionCheckpoint, type ProjectionCheckpoint } from "./projection.ts";
|
|
64
68
|
import type { ClmSettings } from "./settings.ts";
|
|
65
69
|
import {
|
|
70
|
+
keptFields,
|
|
66
71
|
loadLiveContextState,
|
|
67
72
|
resetProjectionState,
|
|
68
73
|
saveLiveContextState,
|
|
74
|
+
type BudgetCheck,
|
|
69
75
|
type LiveContextState,
|
|
70
76
|
} from "./state.ts";
|
|
71
77
|
import type { SteeringDocument } from "./steering.ts";
|
|
@@ -94,6 +100,8 @@ export interface TransformResult {
|
|
|
94
100
|
/** Calibrated estimate of this request, tokens (system prompt and tool schemas included when known); undefined when CLM did not run. */
|
|
95
101
|
estimated?: number;
|
|
96
102
|
reading?: BudgetReading;
|
|
103
|
+
/** User-facing warning for a toast (the budget-too-small check); set on one request only. */
|
|
104
|
+
alert?: string;
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
export interface MirrorCheck {
|
|
@@ -112,6 +120,12 @@ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
112
120
|
|
|
113
121
|
const CHARS_PER_TOKEN = 4;
|
|
114
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Largest share of the provider count the conversation estimate may hold for an overhead
|
|
125
|
+
* measurement; above it the estimate's error would dominate the result.
|
|
126
|
+
*/
|
|
127
|
+
const MAX_CONVERSATION_SHARE = 0.25;
|
|
128
|
+
|
|
115
129
|
/** Index after the pinned prefix: everything up to and including the first user message. */
|
|
116
130
|
export function pinnedCount(raw: readonly OcMessage[]): number {
|
|
117
131
|
const first = raw.findIndex((message) => message.info.role === "user");
|
|
@@ -187,6 +201,8 @@ export class ClmSession {
|
|
|
187
201
|
requests = 0;
|
|
188
202
|
|
|
189
203
|
private pendingNotices: string[] = [];
|
|
204
|
+
/** Raw length and conversation estimate (calibrated, tokens) of the last request, for the overhead measurement. */
|
|
205
|
+
private previousRequest?: { rawCount: number; conversation: number };
|
|
190
206
|
private invalidationStreak = 0;
|
|
191
207
|
/**
|
|
192
208
|
* Per-process seed of the stable document id. Fresh on every open, so a document id never
|
|
@@ -215,6 +231,7 @@ export class ClmSession {
|
|
|
215
231
|
const loaded = await loadLiveContextState(store.directory);
|
|
216
232
|
const session = new ClmSession(sessionID, store, settings, loaded);
|
|
217
233
|
if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
|
|
234
|
+
if (loaded.repaired) await session.log({ event: "state-repaired", message: loaded.repaired });
|
|
218
235
|
return session;
|
|
219
236
|
}
|
|
220
237
|
|
|
@@ -250,8 +267,29 @@ export class ClmSession {
|
|
|
250
267
|
return notices.reduce((total, notice) => total + this.textTokens(notice), 0);
|
|
251
268
|
}
|
|
252
269
|
|
|
253
|
-
|
|
254
|
-
|
|
270
|
+
/**
|
|
271
|
+
* The budget in force: the configured one (or the model window), raised when the
|
|
272
|
+
* session's budget check found it too small for OpenCode's fixed overhead (`budgetFit`).
|
|
273
|
+
*/
|
|
274
|
+
resolvedBudget(): Pick<BudgetReading, "budget" | "reserve" | "source" | "raisedFrom"> | undefined {
|
|
275
|
+
const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
|
|
276
|
+
if (!base) return undefined;
|
|
277
|
+
const fit = budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap());
|
|
278
|
+
return fit.raised ? { ...base, budget: fit.effective, raisedFrom: base.budget } : base;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Model window minus its output limit, when known: the ceiling for a raised budget. */
|
|
282
|
+
private windowCap(): number | undefined {
|
|
283
|
+
return resolveBudget({ ...this.settings.budget, contextBudget: undefined }, this.limits.context, this.limits.output)?.budget;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Budget arithmetic for `/clm` and a TUI panel: fixed overhead, usable budget, effective
|
|
288
|
+
* budget. Undefined while the budget is unknown; `overhead` is unset until measured.
|
|
289
|
+
*/
|
|
290
|
+
budgetFit(): BudgetFit | undefined {
|
|
291
|
+
const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
|
|
292
|
+
return base ? budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap()) : undefined;
|
|
255
293
|
}
|
|
256
294
|
|
|
257
295
|
/** budget − reserve, or undefined while the budget is unknown. */
|
|
@@ -260,6 +298,81 @@ export class ClmSession {
|
|
|
260
298
|
return resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
|
|
261
299
|
}
|
|
262
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Fixed overhead of a request (system prompt + tool schemas), measured once per session.
|
|
303
|
+
*
|
|
304
|
+
* Request 1 cannot measure it: OpenCode calls `experimental.chat.system.transform` after
|
|
305
|
+
* this hook, and the provider count arrives with the reply. So a later transform measures
|
|
306
|
+
* it: provider-reported input of the previous request minus that request's conversation
|
|
307
|
+
* estimate. The estimate's error lands in the overhead, so the measurement waits for a
|
|
308
|
+
* request whose conversation is at most MAX_CONVERSATION_SHARE of the provider count
|
|
309
|
+
* (a restarted or upgraded session with a long history is measured after the model
|
|
310
|
+
* shrinks its context, or not at all).
|
|
311
|
+
*
|
|
312
|
+
* Fallback: the chars/4 hook sizes, only when the provider answered without reporting
|
|
313
|
+
* usage. They miss OpenCode's built-in tool schemas (Effect schemas, not measured;
|
|
314
|
+
* several thousand tokens low), so a fallback result is replaced by the first provider
|
|
315
|
+
* measurement.
|
|
316
|
+
*/
|
|
317
|
+
private measureOverhead(raw: readonly OcMessage[], observed: ObservedRequest | undefined): Pick<BudgetCheck, "overhead" | "source"> | undefined {
|
|
318
|
+
const previous = this.previousRequest;
|
|
319
|
+
if (!previous) return undefined;
|
|
320
|
+
if (observed && observed.index >= previous.rawCount) {
|
|
321
|
+
if (previous.conversation > observed.tokens * MAX_CONVERSATION_SHARE) return undefined;
|
|
322
|
+
return { overhead: Math.round(observed.tokens - previous.conversation), source: "provider" };
|
|
323
|
+
}
|
|
324
|
+
if (this.state.budgetCheck) return undefined; // an estimate never replaces a measurement
|
|
325
|
+
const answered = raw.slice(previous.rawCount)
|
|
326
|
+
.some((message) => message.info.role === "assistant" && !message.info.error && !message.info.summary);
|
|
327
|
+
const { systemTokens, toolTokens } = this.scope;
|
|
328
|
+
if (answered && systemTokens !== undefined && toolTokens !== undefined) {
|
|
329
|
+
return { overhead: this.calibrator.apply(systemTokens + toolTokens), source: "estimate" };
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The budget-too-small check, once per session (plus once more when a provider count
|
|
336
|
+
* replaces a fallback estimate). Decision: raise the effective budget to overhead +
|
|
337
|
+
* WORKING_MARGIN + reserve (capped by the model window) rather than switch the overflow
|
|
338
|
+
* guard off. With the guard off, one large tool result can push the request past the
|
|
339
|
+
* model window; with the budget raised, reminders, the fit gate and the guard keep
|
|
340
|
+
* working, measured against a budget the conversation can actually use. The overhead is
|
|
341
|
+
* persisted in `state.budgetCheck` and the raise is derived from it on every request, so
|
|
342
|
+
* it survives a restart, and the event, toast and notice do not repeat.
|
|
343
|
+
*/
|
|
344
|
+
private async checkBudget(measured: Pick<BudgetCheck, "overhead" | "source">): Promise<string | undefined> {
|
|
345
|
+
const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
|
|
346
|
+
if (!base) return undefined;
|
|
347
|
+
const previous = this.state.budgetCheck;
|
|
348
|
+
const fit = budgetFit(base.budget, base.reserve, measured.overhead, this.windowCap());
|
|
349
|
+
const check: BudgetCheck = {
|
|
350
|
+
...measured,
|
|
351
|
+
configured: base.budget,
|
|
352
|
+
reserve: base.reserve,
|
|
353
|
+
effective: fit.effective,
|
|
354
|
+
raised: fit.raised,
|
|
355
|
+
at: new Date().toISOString(),
|
|
356
|
+
};
|
|
357
|
+
// activateOnFailure: a failed save still applies the raise in this process.
|
|
358
|
+
await this.updateState((state) => ({ ...state, budgetCheck: check }), true).catch(() => undefined);
|
|
359
|
+
// A replaced estimate that already reported "too small" does not warn again.
|
|
360
|
+
const warnedBefore = previous !== undefined && budgetFit(previous.configured, previous.reserve, previous.overhead).tooSmall;
|
|
361
|
+
const warn = fit.tooSmall && !warnedBefore;
|
|
362
|
+
await this.log({
|
|
363
|
+
event: warn ? "budget-too-small" : "budget-check",
|
|
364
|
+
...check,
|
|
365
|
+
usable: fit.usable,
|
|
366
|
+
margin: fit.margin,
|
|
367
|
+
minimum: fit.minimum,
|
|
368
|
+
capped: fit.capped,
|
|
369
|
+
...(previous ? { replaces: previous.source, previousOverhead: previous.overhead } : {}),
|
|
370
|
+
});
|
|
371
|
+
if (!warn) return undefined;
|
|
372
|
+
this.pendingNotices.push(budgetTooSmallNoticeText(fit));
|
|
373
|
+
return budgetTooSmallAlertText(fit);
|
|
374
|
+
}
|
|
375
|
+
|
|
263
376
|
// ---- persistence -------------------------------------------------------------------
|
|
264
377
|
|
|
265
378
|
/**
|
|
@@ -420,6 +533,7 @@ export class ClmSession {
|
|
|
420
533
|
revision,
|
|
421
534
|
checkpoint,
|
|
422
535
|
lastOutcome: { kind: "applied", message: applied, ...sizes, at },
|
|
536
|
+
...keptFields(state),
|
|
423
537
|
};
|
|
424
538
|
});
|
|
425
539
|
} catch (error) {
|
|
@@ -489,6 +603,7 @@ export class ClmSession {
|
|
|
489
603
|
enabled: state.enabled,
|
|
490
604
|
revision: state.revision + 1,
|
|
491
605
|
lastOutcome: { kind: "compacted", message, at },
|
|
606
|
+
...keptFields(state),
|
|
492
607
|
}), true).catch(() => {
|
|
493
608
|
written = false;
|
|
494
609
|
return this.state;
|
|
@@ -536,6 +651,9 @@ export class ClmSession {
|
|
|
536
651
|
const source = flatten(raw.slice(pinned));
|
|
537
652
|
const observed = lastProviderReported(raw);
|
|
538
653
|
this.calibrator.observe(observed);
|
|
654
|
+
// Before the guard and the reading, so a raise applies to this request already.
|
|
655
|
+
const measured = this.state.budgetCheck?.source === "provider" ? undefined : this.measureOverhead(raw, observed);
|
|
656
|
+
const alert = measured ? await this.checkBudget(measured) : undefined;
|
|
539
657
|
|
|
540
658
|
let projection = applyProjection(source, this.state.checkpoint);
|
|
541
659
|
// A late or repeated compaction signal must not swallow a later revert: rebase only
|
|
@@ -675,6 +793,7 @@ export class ClmSession {
|
|
|
675
793
|
this.calibrator.record(systemTokens + toolTokens + conversation, raw.length);
|
|
676
794
|
}
|
|
677
795
|
|
|
796
|
+
this.previousRequest = { rawCount: raw.length, conversation: conversationTokens + this.calibrator.apply(this.noticeTokens(notices)) };
|
|
678
797
|
this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: snapshot.blocks.length };
|
|
679
798
|
if (this.settings.dumpRequests) {
|
|
680
799
|
const directory = join(this.store.directory, "requests");
|
|
@@ -694,7 +813,7 @@ export class ClmSession {
|
|
|
694
813
|
calibration: this.calibrator.factor,
|
|
695
814
|
notices: notices.map((notice) => notice.slice(0, 80)),
|
|
696
815
|
});
|
|
697
|
-
return { messages, notices, estimated: reading?.estimated ?? requestTokens, reading };
|
|
816
|
+
return { messages, notices, estimated: reading?.estimated ?? requestTokens, reading, ...(alert ? { alert } : {}) };
|
|
698
817
|
}
|
|
699
818
|
|
|
700
819
|
/** Inputs for presentation.ts `statusText` / `statusLine`. */
|
|
@@ -709,6 +828,7 @@ export class ClmSession {
|
|
|
709
828
|
gate: this.settings.gate,
|
|
710
829
|
guard: this.settings.guard,
|
|
711
830
|
reading: this.lastReading,
|
|
831
|
+
fit: this.budgetFit(),
|
|
712
832
|
modelWindow: this.limits.context,
|
|
713
833
|
checkpoint: checkpoint
|
|
714
834
|
? {
|
package/src/context-document.ts
CHANGED
|
@@ -525,13 +525,40 @@ export function applyContextDocument(
|
|
|
525
525
|
|
|
526
526
|
const parsed = parseDocument(editedText, snapshot);
|
|
527
527
|
const metadataLine = snapshot.text.split("\n")[0] ?? "";
|
|
528
|
-
|
|
529
|
-
|
|
528
|
+
// States the current revision, what is wrong with the file, and the exact line to
|
|
529
|
+
// write, so the model can fix the file in one step.
|
|
530
|
+
const staleMetadata = () => {
|
|
531
|
+
const current = `The mirror is at revision ${snapshot.revision}.`;
|
|
532
|
+
let found: string;
|
|
533
|
+
let cause: string;
|
|
534
|
+
if (parsed.revision === undefined) {
|
|
535
|
+
found = "your file has no [[LIVE_CONTEXT ...]] line 1";
|
|
536
|
+
cause = parsed.blocks.length === 0 && /^\s*\[\[CTX_TURN /m.test(editedText)
|
|
537
|
+
? `${current} Its block headers come from an older copy of the mirror: read the file again and apply your change to its current text.`
|
|
538
|
+
: `${current} Put the line below back as line 1 and keep the rest of your edit.`;
|
|
539
|
+
} else if (parsed.revision !== snapshot.revision) {
|
|
540
|
+
found = `your file says revision ${parsed.revision}`;
|
|
541
|
+
cause = `${current} It was rewritten after the copy you edited was taken: read the file again and apply your change to its current text, leaving line 1 as it is.`;
|
|
542
|
+
} else {
|
|
543
|
+
const fields = [
|
|
544
|
+
...(parsed.version !== snapshot.version ? ["version"] : []),
|
|
545
|
+
...(parsed.documentId !== snapshot.documentId ? ["document id"] : []),
|
|
546
|
+
...(!snapshot.stableDocument && parsed.baselineDigest !== snapshot.baselineDigest ? ["baseline digest"] : []),
|
|
547
|
+
];
|
|
548
|
+
found = fields.length > 0
|
|
549
|
+
? `your file says revision ${parsed.revision}, but its ${fields.join(" and ")} differ${fields.length === 1 ? "s" : ""}`
|
|
550
|
+
: `your file's line 1 is current, but its block headers belong to another copy of the mirror`;
|
|
551
|
+
cause = fields.length > 0
|
|
552
|
+
? `${current} Line 1 was changed or copied from another mirror: restore it exactly and keep the rest of your edit.`
|
|
553
|
+
: `${current} Read the file again and apply your change to its current text.`;
|
|
554
|
+
}
|
|
555
|
+
return rejected(
|
|
530
556
|
snapshot,
|
|
531
557
|
beforeEstimate,
|
|
532
|
-
`Mirror metadata does not match the current context revision (expected revision ${snapshot.revision}, document ${snapshot.documentId.slice(0, 12)}
|
|
533
|
-
|
|
558
|
+
`Mirror metadata does not match the current context revision (expected revision ${snapshot.revision}, document ${snapshot.documentId.slice(0, 12)}…; ${found}). ` +
|
|
559
|
+
`${cause} The first line must be exactly: ${metadataLine}`,
|
|
534
560
|
);
|
|
561
|
+
};
|
|
535
562
|
// A file with no current block headers but with framing lines from another document
|
|
536
563
|
// (or a stale revision) is an old mirror copied back, not a summary: reject it.
|
|
537
564
|
if (parsed.blocks.length === 0 && hasForeignFraming(editedText, snapshot)) return staleMetadata();
|
package/src/presentation.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* document.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { budgetSummaryLine, formatTokens, type BudgetReading } from "./budget.ts";
|
|
11
|
+
import { budgetFitLine, budgetSummaryLine, formatTokens, type BudgetFit, type BudgetReading } from "./budget.ts";
|
|
12
12
|
import type { EditGate } from "./policy.ts";
|
|
13
13
|
import { steeringStatusLine, type SteeringDocument } from "./steering.ts";
|
|
14
14
|
|
|
@@ -86,6 +86,8 @@ export interface ClmStatus {
|
|
|
86
86
|
guard: "withhold" | "off";
|
|
87
87
|
/** Latest budget reading; undefined before the first request. */
|
|
88
88
|
reading?: BudgetReading;
|
|
89
|
+
/** Fixed overhead, usable and effective budget (`budgetFit`); undefined while the budget is unknown. */
|
|
90
|
+
fit?: BudgetFit;
|
|
89
91
|
/** Model context window in tokens, when known. */
|
|
90
92
|
modelWindow?: number;
|
|
91
93
|
/** The accepted revision now in effect, if any. */
|
|
@@ -112,6 +114,7 @@ export function statusText(status: ClmStatus): string {
|
|
|
112
114
|
? `${budgetSummaryLine(status.reading)} · model window ${status.modelWindow ? formatTokens(status.modelWindow) : "unknown"}`
|
|
113
115
|
: "budget: unknown until the first request",
|
|
114
116
|
];
|
|
117
|
+
if (status.fit) lines.push(budgetFitLine(status.fit));
|
|
115
118
|
const checkpoint = status.checkpoint;
|
|
116
119
|
lines.push(
|
|
117
120
|
checkpoint
|
package/src/state.ts
CHANGED
|
@@ -24,6 +24,24 @@ export interface LiveContextOutcome {
|
|
|
24
24
|
at: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The once-per-session measurement of OpenCode's fixed overhead (system prompt and tool
|
|
29
|
+
* schemas). Its presence means the budget-too-small check has run; the session then derives
|
|
30
|
+
* its effective budget from `overhead` (clm.ts), so a raise survives a restart.
|
|
31
|
+
*/
|
|
32
|
+
export interface BudgetCheck {
|
|
33
|
+
/** Fixed overhead per request, tokens. */
|
|
34
|
+
overhead: number;
|
|
35
|
+
/** `provider`: the provider's count minus the conversation estimate; `estimate`: chars/4 sizes. */
|
|
36
|
+
source: "provider" | "estimate";
|
|
37
|
+
/** Budget and reserve in force when the check ran, and what it decided. */
|
|
38
|
+
configured: number;
|
|
39
|
+
reserve: number;
|
|
40
|
+
effective: number;
|
|
41
|
+
raised: boolean;
|
|
42
|
+
at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
export interface LiveContextState {
|
|
28
46
|
version: 1;
|
|
29
47
|
enabled: boolean;
|
|
@@ -31,12 +49,15 @@ export interface LiveContextState {
|
|
|
31
49
|
revision: number;
|
|
32
50
|
checkpoint?: ProjectionCheckpoint;
|
|
33
51
|
lastOutcome?: LiveContextOutcome;
|
|
52
|
+
budgetCheck?: BudgetCheck;
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
export interface LoadedState {
|
|
37
56
|
state: LiveContextState;
|
|
38
57
|
/** Set when a state file existed but could not be used; the state is then initial. */
|
|
39
58
|
warning?: string;
|
|
59
|
+
/** Set when an invalid optional field was dropped and the rest of the state kept. */
|
|
60
|
+
repaired?: string;
|
|
40
61
|
}
|
|
41
62
|
|
|
42
63
|
const SOURCE_KINDS = new Set(["kept", "edited", "removed", "restored", "normalized"]);
|
|
@@ -137,6 +158,24 @@ function isOutcome(value: unknown): value is LiveContextOutcome {
|
|
|
137
158
|
);
|
|
138
159
|
}
|
|
139
160
|
|
|
161
|
+
function isBudgetCheck(value: unknown): value is BudgetCheck {
|
|
162
|
+
if (!isObject(value)) return false;
|
|
163
|
+
return (
|
|
164
|
+
isCount(value.overhead) &&
|
|
165
|
+
(value.source === "provider" || value.source === "estimate") &&
|
|
166
|
+
isCount(value.configured) &&
|
|
167
|
+
isCount(value.reserve) &&
|
|
168
|
+
isCount(value.effective) &&
|
|
169
|
+
typeof value.raised === "boolean" &&
|
|
170
|
+
typeof value.at === "string"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The fields every state rewrite carries over (revision changes keep the budget check). */
|
|
175
|
+
export function keptFields(state: LiveContextState): Pick<LiveContextState, "budgetCheck"> {
|
|
176
|
+
return state.budgetCheck ? { budgetCheck: state.budgetCheck } : {};
|
|
177
|
+
}
|
|
178
|
+
|
|
140
179
|
export function isLiveContextState(value: unknown): value is LiveContextState {
|
|
141
180
|
if (!isObject(value)) return false;
|
|
142
181
|
const state = value as Partial<LiveContextState>;
|
|
@@ -147,7 +186,8 @@ export function isLiveContextState(value: unknown): value is LiveContextState {
|
|
|
147
186
|
// Accept writes the checkpoint and the state with the same revision; reset moves past it.
|
|
148
187
|
(state.checkpoint === undefined ||
|
|
149
188
|
(isProjectionCheckpoint(state.checkpoint) && state.checkpoint.revision === state.revision)) &&
|
|
150
|
-
(state.lastOutcome === undefined || isOutcome(state.lastOutcome))
|
|
189
|
+
(state.lastOutcome === undefined || isOutcome(state.lastOutcome)) &&
|
|
190
|
+
(state.budgetCheck === undefined || isBudgetCheck(state.budgetCheck))
|
|
151
191
|
);
|
|
152
192
|
}
|
|
153
193
|
|
|
@@ -162,6 +202,7 @@ export function resetProjectionState(
|
|
|
162
202
|
enabled: state.enabled,
|
|
163
203
|
revision: state.revision + 1,
|
|
164
204
|
lastOutcome: { kind: "reset", message, at },
|
|
205
|
+
...keptFields(state),
|
|
165
206
|
};
|
|
166
207
|
}
|
|
167
208
|
|
|
@@ -191,10 +232,16 @@ export async function loadLiveContextState(directory: string): Promise<LoadedSta
|
|
|
191
232
|
warning: `${path} has unsupported version ${JSON.stringify(parsed.version)}; starting clean.`,
|
|
192
233
|
};
|
|
193
234
|
}
|
|
235
|
+
// A malformed budget check costs only itself: the session re-measures the overhead.
|
|
236
|
+
let repaired: string | undefined;
|
|
237
|
+
if (isObject(parsed) && parsed.budgetCheck !== undefined && !isBudgetCheck(parsed.budgetCheck)) {
|
|
238
|
+
delete parsed.budgetCheck;
|
|
239
|
+
repaired = `${path} had an invalid budgetCheck; dropped it, the fixed overhead is measured again.`;
|
|
240
|
+
}
|
|
194
241
|
if (!isLiveContextState(parsed)) {
|
|
195
242
|
return { state: initialLiveContextState(), warning: `${path} has an invalid shape; starting clean.` };
|
|
196
243
|
}
|
|
197
|
-
return { state: parsed };
|
|
244
|
+
return repaired ? { state: parsed, repaired } : { state: parsed };
|
|
198
245
|
}
|
|
199
246
|
|
|
200
247
|
/**
|