opencode-clm 0.3.0 → 0.4.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/src/settings.ts CHANGED
@@ -47,6 +47,22 @@ export interface ClmSettings {
47
47
  oneTool: boolean;
48
48
  /** Append `[context: ~N of B tokens after this result]` to tool results (paper-harness parity). */
49
49
  trailer: boolean;
50
+ /**
51
+ * `edit`: the model edits its context through the mirror. `notices-only`: no mirror, no
52
+ * protocol prompt and no edits; budget notices, reminders, the guard, the cap and the
53
+ * trailer still apply (to compare telling the model its budget with letting it edit).
54
+ */
55
+ mode: ClmMode;
56
+ }
57
+
58
+ export type ClmMode = "edit" | "notices-only";
59
+
60
+ /** `edit`, `notices-only`, or `notices` (an alias of `notices-only`). */
61
+ export function parseMode(value: unknown, name = "mode"): ClmMode {
62
+ const text = String(value).trim().toLowerCase();
63
+ if (text === "edit") return "edit";
64
+ if (text === "notices-only" || text === "notices") return "notices-only";
65
+ throw new Error(`${name} must be edit or notices-only, got ${JSON.stringify(value)}`);
50
66
  }
51
67
 
52
68
  export type CompactionMode = "auto" | "off" | "on";
@@ -59,11 +75,12 @@ export function parseCompaction(value: unknown, name = "compaction"): Compaction
59
75
 
60
76
  export const PACKAGE_DIR = resolve(fileURLToPath(new URL("..", import.meta.url)));
61
77
  export const HOUSE_STEERING = join(PACKAGE_DIR, "steering", "house-brief.md");
62
- export const SKILLS_DIR = join(PACKAGE_DIR, "skills");
78
+ export const SKILLS_DIR = join(PACKAGE_DIR, ".agents/skills/clm-context");
63
79
 
64
80
  type Env = Record<string, string | undefined>;
65
81
 
66
82
  const OFF_WORDS = ["none", "off"];
83
+ const BUDGET_FORMS = `a number of tokens (32000, 32k), a percentage of the model window (1% to 100%), or "window"`;
67
84
 
68
85
  function pick(options: Record<string, unknown>, key: string, env: Env, envKey: string): unknown {
69
86
  if (options[key] !== undefined) return options[key];
@@ -85,6 +102,34 @@ export function parseTokens(value: unknown, name: string): number {
85
102
  return Math.round(Number(match[1]) * scale);
86
103
  }
87
104
 
105
+ export type BudgetValue =
106
+ | { kind: "tokens"; tokens: number }
107
+ | { kind: "fraction"; fraction: number }
108
+ | { kind: "window" };
109
+
110
+ /**
111
+ * A budget setting: `32000` / `32k` (tokens), `50%` (of the model window minus its output
112
+ * limit; 1% to 100%), or `window` / `model` (the whole of it).
113
+ */
114
+ export function parseBudget(value: unknown, name = "budget"): BudgetValue {
115
+ const text = String(value).trim().toLowerCase();
116
+ if (text === "window" || text === "model") return { kind: "window" };
117
+ if (text.endsWith("%")) {
118
+ const number = text.slice(0, -1).trim();
119
+ const percent = /^\d+(?:\.\d+)?$/.test(number) ? Number(number) : Number.NaN;
120
+ if (!(percent >= 1 && percent <= 100)) throw new Error(`${name} must be ${BUDGET_FORMS}, got ${JSON.stringify(value)}`);
121
+ return { kind: "fraction", fraction: percent / 100 };
122
+ }
123
+ let tokens: number;
124
+ try {
125
+ tokens = parseTokens(value, name);
126
+ } catch {
127
+ throw new Error(`${name} must be ${BUDGET_FORMS}, got ${JSON.stringify(value)}`);
128
+ }
129
+ if (tokens <= 0) throw new Error(`${name} must be a positive number of tokens or "window", got ${JSON.stringify(value)}`);
130
+ return { kind: "tokens", tokens };
131
+ }
132
+
88
133
  export function parseFlag(value: unknown, name: string): boolean {
89
134
  if (typeof value === "boolean") return value;
90
135
  const key = String(value).trim().toLowerCase();
@@ -111,6 +156,23 @@ export function parseFractions(value: unknown, name: string): number[] | "off" {
111
156
  return [...new Set(fractions)];
112
157
  }
113
158
 
159
+ const COOLDOWN_FORMS = `a share of the budget between 0 and 1, as a percentage (10%) or a fraction (0.1), or off`;
160
+
161
+ /**
162
+ * The reminder cooldown: `10%` or `0.1` (a share of the budget, strictly between 0 and 1), or
163
+ * `off` / `none` / `0` for no cooldown (0).
164
+ */
165
+ export function parseCooldown(value: unknown, name = "reminderCooldown"): number {
166
+ if (value === false) return 0;
167
+ const text = String(value).trim().toLowerCase();
168
+ if (text === "off" || text === "none" || text === "0" || text === "0%") return 0;
169
+ const percent = text.endsWith("%");
170
+ const number = percent ? text.slice(0, -1).trim() : text;
171
+ const parsed = /^\d*\.?\d+$/.test(number) ? Number(number) / (percent ? 100 : 1) : Number.NaN;
172
+ if (!(parsed > 0 && parsed < 1)) throw new Error(`${name} must be ${COOLDOWN_FORMS}, got ${JSON.stringify(value)}`);
173
+ return parsed;
174
+ }
175
+
114
176
  function withName<T>(name: string, parse: () => T): T {
115
177
  try {
116
178
  return parse();
@@ -132,6 +194,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
132
194
  const budgetRaw = pick(options, "budget", env, "CLM_BUDGET");
133
195
  const reserveRaw = pick(options, "reserve", env, "CLM_RESERVE");
134
196
  const remindRaw = pick(options, "remindAt", env, "CLM_REMIND_AT");
197
+ const cooldownRaw = pick(options, "reminderCooldown", env, "CLM_REMINDER_COOLDOWN");
135
198
  const gateRaw = pick(options, "gate", env, "CLM_EDIT_GATE");
136
199
  const guardRaw = pick(options, "guard", env, "CLM_OVERFLOW");
137
200
  const capRaw = pick(options, "observationCap", env, "CLM_OBSERVATION_CAP");
@@ -144,11 +207,13 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
144
207
  const compactionRaw = pick(options, "compaction", env, "CLM_NATIVE_COMPACTION");
145
208
  const oneToolRaw = pick(options, "oneTool", env, "CLM_ONE_TOOL_PER_TURN");
146
209
  const trailerRaw = pick(options, "trailer", env, "CLM_SIZE_TRAILER");
210
+ const modeRaw = pick(options, "mode", env, "CLM_MODE");
147
211
 
148
212
  const overrides: Partial<BudgetPolicyConfig> = {};
149
213
  if (budgetRaw !== undefined) {
150
- overrides.contextBudget = String(budgetRaw).trim().toLowerCase() === "window" ? undefined : parseTokens(budgetRaw, "budget");
151
- if (overrides.contextBudget === 0) throw new Error(`budget must be a positive number of tokens or "window", got ${JSON.stringify(budgetRaw)}`);
214
+ const parsed = parseBudget(budgetRaw, "budget");
215
+ overrides.contextBudget = parsed.kind === "tokens" ? parsed.tokens : undefined;
216
+ overrides.contextFraction = parsed.kind === "fraction" ? parsed.fraction : undefined;
152
217
  }
153
218
  if (reserveRaw !== undefined) overrides.reserve = parseTokens(reserveRaw, "reserve");
154
219
  if (remindRaw !== undefined) {
@@ -164,6 +229,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
164
229
  overrides.remindAtFractions = fractions;
165
230
  }
166
231
  }
232
+ if (cooldownRaw !== undefined) overrides.reminderCooldown = parseCooldown(cooldownRaw, "reminderCooldown");
167
233
  const budget = withName("budget", () => resolveBudgetPolicy({ ...DEFAULT_BUDGET_POLICY, ...overrides }));
168
234
 
169
235
  let guard: ClmSettings["guard"] = "withhold";
@@ -213,5 +279,6 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
213
279
  compaction: compactionRaw === undefined ? "auto" : parseCompaction(compactionRaw),
214
280
  oneTool: oneToolRaw === undefined ? false : parseFlag(oneToolRaw, "oneTool"),
215
281
  trailer: trailerRaw === undefined ? false : parseFlag(trailerRaw, "trailer"),
282
+ mode: modeRaw === undefined ? "edit" : parseMode(modeRaw),
216
283
  };
217
284
  }
package/src/state.ts CHANGED
@@ -96,6 +96,23 @@ function isMessageArray(value: unknown): value is LiveContextMessage[] {
96
96
  );
97
97
  }
98
98
 
99
+ /** Per-message stamps (`projection.ts` SourceStamp): a hex digest and a finite `updatedAt`. */
100
+ function isSourceStamps(value: unknown, count: unknown): boolean {
101
+ return (
102
+ Array.isArray(value) &&
103
+ value.length === count &&
104
+ value.every(
105
+ (stamp) =>
106
+ isObject(stamp) &&
107
+ typeof stamp.digest === "string" &&
108
+ /^[a-f0-9]{64}$/.test(stamp.digest) &&
109
+ Number.isFinite(stamp.updatedAt) &&
110
+ (stamp.strippedDigest === undefined ||
111
+ (typeof stamp.strippedDigest === "string" && /^[a-f0-9]{64}$/.test(stamp.strippedDigest))),
112
+ )
113
+ );
114
+ }
115
+
99
116
  function isContextEditTrace(value: unknown): value is ContextEditTrace {
100
117
  if (!isObject(value)) return false;
101
118
  const trace = value as Partial<ContextEditTrace>;
@@ -134,6 +151,7 @@ export function isProjectionCheckpoint(value: unknown): value is ProjectionCheck
134
151
  checkpoint.sourceIds.every((id) => typeof id === "string") &&
135
152
  typeof checkpoint.sourceDigest === "string" &&
136
153
  /^[a-f0-9]{64}$/.test(checkpoint.sourceDigest) &&
154
+ (checkpoint.sourceStamps === undefined || isSourceStamps(checkpoint.sourceStamps, checkpoint.sourceMessageCount)) &&
137
155
  isMessageArray(checkpoint.projectedMessages) &&
138
156
  Number.isFinite(checkpoint.beforeEstimate) &&
139
157
  Number.isFinite(checkpoint.afterEstimate) &&
@@ -238,12 +256,43 @@ export async function loadLiveContextState(directory: string): Promise<LoadedSta
238
256
  delete parsed.budgetCheck;
239
257
  repaired = `${path} had an invalid budgetCheck; dropped it, the fixed overhead is measured again.`;
240
258
  }
259
+ // A malformed outcome costs only itself: the panel loses one line, the checkpoint stays.
260
+ if (isObject(parsed) && parsed.lastOutcome !== undefined && !isOutcome(parsed.lastOutcome)) {
261
+ delete parsed.lastOutcome;
262
+ repaired = `${path} had an invalid lastOutcome; dropped it, the last outcome is unknown.`;
263
+ }
264
+ // Malformed per-message stamps cost only tail re-anchoring: the checkpoint keeps working
265
+ // with the whole-prefix digest and fails closed on a mismatch, as before.
266
+ if (
267
+ isObject(parsed) &&
268
+ isObject(parsed.checkpoint) &&
269
+ parsed.checkpoint.sourceStamps !== undefined &&
270
+ !isSourceStamps(parsed.checkpoint.sourceStamps, parsed.checkpoint.sourceMessageCount)
271
+ ) {
272
+ delete parsed.checkpoint.sourceStamps;
273
+ repaired = `${path} had invalid sourceStamps; dropped them, tail re-anchoring is off for this checkpoint.`;
274
+ }
241
275
  if (!isLiveContextState(parsed)) {
242
- return { state: initialLiveContextState(), warning: `${path} has an invalid shape; starting clean.` };
276
+ return { state: initialLiveContextState(), warning: `${path} has an invalid shape (${stateProblem(parsed)}); starting clean.` };
243
277
  }
244
278
  return repaired ? { state: parsed, repaired } : { state: parsed };
245
279
  }
246
280
 
281
+ /** Names the first state check `value` fails, so a load warning locates the bad field. */
282
+ function stateProblem(value: unknown): string {
283
+ if (!isObject(value)) return "not an object";
284
+ if (value.version !== STATE_VERSION) return `unsupported version ${JSON.stringify(value.version)}`;
285
+ if (typeof value.enabled !== "boolean") return "enabled is not a boolean";
286
+ if (!isCount(value.revision)) return "revision is not a count";
287
+ if (value.checkpoint !== undefined) {
288
+ if (!isProjectionCheckpoint(value.checkpoint)) return "checkpoint is invalid";
289
+ if (value.checkpoint.revision !== value.revision) return "checkpoint revision does not match the state revision";
290
+ }
291
+ if (value.lastOutcome !== undefined && !isOutcome(value.lastOutcome)) return "lastOutcome is invalid";
292
+ if (value.budgetCheck !== undefined && !isBudgetCheck(value.budgetCheck)) return "budgetCheck is invalid";
293
+ return "unknown";
294
+ }
295
+
247
296
  /**
248
297
  * Write `state.json` atomically with mode 0600: temp file, fsync, rename, then fsync the
249
298
  * directory (best effort). Concurrent saves are not ordered: the last rename wins, so an
package/src/tui/data.ts CHANGED
@@ -155,14 +155,15 @@ export function sizeLine(latest: Pick<TimelinePoint, "tokens" | "measured" | "es
155
155
  * `clm 12k / 32k · r2` (newest size against the budget in force), `clm off · r2`, or
156
156
  * undefined before the session has CLM data.
157
157
  */
158
- export function footerText(model: Pick<PanelModel, "found" | "enabled" | "revision" | "timeline" | "budget">): string | undefined {
158
+ export function footerText(model: Pick<PanelModel, "found" | "enabled" | "revision" | "timeline" | "budget"> & Partial<Pick<PanelModel, "noticesOnly">>): string | undefined {
159
159
  if (!model.found) return undefined;
160
160
  if (!model.enabled) return `clm off · r${model.revision}`;
161
+ const mode = model.noticesOnly ? " notices-only" : "";
161
162
  const latest = model.timeline.points.at(-1);
162
163
  const size = latest === undefined
163
164
  ? ""
164
165
  : ` ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}${model.budget !== undefined ? ` / ${formatTokenCount(model.budget)}` : ""}`;
165
- return `clm${size} · r${model.revision}`;
166
+ return `clm${mode}${size} · r${model.revision}`;
166
167
  }
167
168
 
168
169
  /** Key of the settings page's "Reset to defaults" row, and the choice that resets. */
@@ -199,6 +200,11 @@ export function detailLines(
199
200
  ? "Estimate not calibrated yet (characters ÷ 4 until the provider reports a size)"
200
201
  : `Estimate ×${calibration.factor.toFixed(2)}, calibrated from ${calibration.samples} provider count${calibration.samples === 1 ? "" : "s"}`);
201
202
  }
203
+ if (effective.settings.mode === "notices-only") {
204
+ lines.push(effective.editing
205
+ ? "Mode notices-only: budget notices and the guard run; no mirror, the model cannot edit its context"
206
+ : "Mode notices-only (CLM editing is off, so nothing runs)");
207
+ }
202
208
  const limit = model.budgetInfo?.limit;
203
209
  lines.push(effective.settings.guard === "off"
204
210
  ? "Guard off"
package/src/tui/panel.ts CHANGED
@@ -9,7 +9,7 @@ import type { TextChunk } from "@opentui/core";
9
9
  import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
10
10
 
11
11
  import type { Page } from "../panel/command.ts";
12
- import { reduce, type Effect, type Key } from "../panel/keys.ts";
12
+ import { reduce, withMessage, type Effect, type Key } from "../panel/keys.ts";
13
13
  import type { Line, Tone } from "../panel/lines.ts";
14
14
  import type { PanelModel } from "../panel/model.ts";
15
15
  import { initialPanelState, renderPanel, type PanelSize, type PanelState } from "../panel/view.ts";
@@ -165,7 +165,8 @@ export function createPanel(api: PanelApi, options: PanelOptions): PanelControll
165
165
  await controller.reload();
166
166
  const row = model?.settings.rows.find((candidate) => candidate.key === setting);
167
167
  const shown = `${row?.label ?? setting}: ${row?.value ?? value}`;
168
- state = { ...state, message: error ? { text: error, warning: true } : { text: shown, warning: false } };
168
+ const message = error ? { text: error, warning: true } : { text: shown, warning: false };
169
+ state = model ? withMessage(state, message, model, size()) : { ...state, message };
169
170
  render();
170
171
  };
171
172
 
package/src/tui/plugin.ts CHANGED
@@ -139,8 +139,9 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
139
139
  const preferSettings = overridesNewer(current.overridesAt, files.snapshot);
140
140
  const model = buildPanelModel(files, { ...(latest ? { latest } : {}), ...(budget ? { budget } : {}), ...(preferSettings ? { preferSettings } : {}) });
141
141
  model.enabled = current.values.effective.editing;
142
+ if (current.values.effective.settings.mode === "notices-only") model.noticesOnly = true;
142
143
  model.settings = settingsView(current.values, model, {
143
- format: { ...(limits.context ? { modelWindow: limits.context } : {}) },
144
+ format: { ...(limits.context ? { modelWindow: limits.context } : {}), ...(limits.output ? { modelOutput: limits.output } : {}) },
144
145
  ...(current.warning ? { warning: current.warning } : {}),
145
146
  });
146
147
  model.settings.rows.push(resetRow(model.settings.changed.length));
@@ -456,7 +457,7 @@ export function statusSummary(model: PanelModel): string {
456
457
  if (!model.found) return `No CLM data for this session (${model.directory}).`;
457
458
  const latest = model.timeline.points.at(-1);
458
459
  const parts = [
459
- `CLM ${model.enabled ? "on" : "off"} · revision ${model.revision}`,
460
+ `CLM ${model.enabled ? "on" : "off"}${model.enabled && model.noticesOnly ? " · mode notices-only" : ""} · revision ${model.revision}`,
460
461
  latest ? `last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}` : "no requests yet",
461
462
  model.budget !== undefined ? `budget ${formatTokenCount(model.budget)}` : undefined,
462
463
  model.budgetInfo?.overhead !== undefined ? `fixed overhead ~${formatTokenCount(model.budgetInfo.overhead)}` : undefined,
package/src/types.ts CHANGED
@@ -4,6 +4,8 @@ export type LiveContextMessage = {
4
4
  role: string;
5
5
  content?: unknown;
6
6
  timestamp?: number;
7
+ /** OpenCode's `info.time.updated`: the in-place write marker used for tail re-anchoring. */
8
+ updatedAt?: number;
7
9
  [key: string]: unknown;
8
10
  };
9
11
 
File without changes