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 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.0",
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": { "url": "https://github.com/bcmyguest/opencode-clm/issues" },
10
- "repository": { "type": "git", "url": "git+https://github.com/bcmyguest/opencode-clm.git" },
11
- "keywords": ["opencode", "opencode-plugin", "context-management", "context-window", "context-language-models", "clm"],
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": { ".": "./index.ts" },
14
- "files": ["index.ts", "src/**/*.ts", "skills/**", "steering/**", "LICENSE", "NOTICE", "README.md", "CHANGELOG.md"],
15
- "engines": { "bun": ">=1.2", "opencode": ">=1.18" },
16
- "publishConfig": { "access": "public" },
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": { "@opencode-ai/plugin": "^1.18.34" },
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 (${reading.source === "config" ? "configured" : "model window"}, reserve ${formatTokens(reading.reserve)})`,
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
- resolvedBudget() {
254
- return resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
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
  ? {
@@ -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
- const staleMetadata = () =>
529
- rejected(
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
- `The first line must be exactly: ${metadataLine}`,
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();
@@ -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
  /**