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/panel/view.ts CHANGED
@@ -52,6 +52,7 @@ const EDIT_TONE: Tone = "edit";
52
52
  const KEPT_PREVIEW_CHARACTERS = 2_000;
53
53
  const MIRROR_TEXT_MAX_LINES = 200;
54
54
  const MIRROR_UNAVAILABLE_TEXT = "No mirror: requests carry the raw history plus the continuity annotations; edits are off.";
55
+ const NOTICES_ONLY_TEXT = "Mode notices-only: requests carry the raw history plus the budget notices; no mirror, so the model cannot edit its context (/clm config mode edit).";
55
56
 
56
57
  /** Body rows available for the page. */
57
58
  export function viewportHeight(size: PanelSize): number {
@@ -194,6 +195,8 @@ function overviewLines(model: PanelModel, state: PanelState, width: number, view
194
195
  ? text("Projection off: the model sees the raw history (/clm on).", "warning")
195
196
  : model.mirrorUnavailable !== undefined
196
197
  ? text(MIRROR_UNAVAILABLE_TEXT, "warning")
198
+ : model.noticesOnly
199
+ ? text(NOTICES_ONLY_TEXT, "warning")
197
200
  : model.lastOutcome?.kind === "rejected"
198
201
  ? text(`Last edit rejected: ${model.lastOutcome.message}`, "warning")
199
202
  : undefined;
@@ -478,12 +481,26 @@ export function settingsPage(model: PanelModel, state: PanelState, width: number
478
481
  const row = settings.rows[selected]!;
479
482
  if (row.description) lines.push([], ...wrapAll([text(row.description, "dim")], width));
480
483
  lines.push([], ...wrapAll([text(row.choices?.length ? "Enter: next value" : "Enter: type a value", "dim")], width));
481
- if (state.message) {
482
- lines.push(...wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width));
483
- }
484
484
  return { lines, focus };
485
485
  }
486
486
 
487
+ /**
488
+ * Lines pinned below the scrolling page: the settings page's result message ("✓ Budget:
489
+ * 20k"), so it stays on screen when the page is longer than the viewport. At most
490
+ * `viewport` − 1 lines, so one page line always shows.
491
+ */
492
+ export function pinnedLines(state: PanelState, width: number, viewport: number): Line[] {
493
+ if (state.page !== "settings" || !state.message) return [];
494
+ const message = wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width);
495
+ return message.slice(0, Math.max(0, viewport - 1));
496
+ }
497
+
498
+ /** Body rows left for the scrolling page once the pinned lines take theirs. */
499
+ export function pageViewport(state: PanelState, size: PanelSize): number {
500
+ const viewport = viewportHeight(size);
501
+ return Math.max(1, viewport - pinnedLines(state, contentWidth(size), viewport).length);
502
+ }
503
+
487
504
  // ---- frame ---------------------------------------------------------------------------
488
505
 
489
506
  /** The whole page at `width` (unclipped; the frame scrolls it). */
@@ -514,7 +531,7 @@ export function helpText(model: PanelModel, state: PanelState, width: number): s
514
531
  }
515
532
 
516
533
  export function maxScroll(model: PanelModel, state: PanelState, size: PanelSize): number {
517
- return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length - viewportHeight(size));
534
+ return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length - pageViewport(state, size));
518
535
  }
519
536
 
520
537
  /** The framed panel, exactly `size.height` lines of `size.width` cells. */
@@ -522,15 +539,18 @@ export function renderPanel(model: PanelModel, state: PanelState, size: PanelSiz
522
539
  const width = Math.max(8, size.width);
523
540
  const inner = width - 2;
524
541
  const content = contentWidth({ width, height: size.height });
525
- const viewport = viewportHeight(size);
526
- const page = pageLines(model, state, content, viewport);
542
+ const body = viewportHeight(size);
543
+ const page = pageLines(model, state, content, body);
544
+ const pinned = pinnedLines(state, content, body);
545
+ const viewport = pageViewport(state, size);
527
546
  const scroll = Math.max(0, Math.min(state.scroll, page.length - viewport));
528
- const visible = page.slice(scroll, scroll + viewport);
529
- while (visible.length < viewport) visible.push([]);
547
+ // The pinned lines follow the page directly when it fits, else sit at the bottom.
548
+ const visible = [...page.slice(scroll, scroll + viewport), ...pinned];
549
+ while (visible.length < body) visible.push([]);
530
550
 
531
551
  const border = (value: string) => span(value, "border");
532
552
  const row = (line: Line): Line => normalize([border("│"), span(" "), ...fit(line, inner - 1), border("│")]);
533
- const title = ` Live Context Viewer · r${model.revision} `;
553
+ const title = ` Live Context Viewer · r${model.revision}${model.noticesOnly && model.enabled ? " · notices-only" : ""} `;
534
554
  const titleText = truncate([span(title)], inner, "").map((part) => part.text).join("");
535
555
  const left = Math.max(0, Math.floor((inner - titleText.length) / 2));
536
556
  const right = Math.max(0, inner - titleText.length - left);
@@ -98,6 +98,8 @@ export interface ClmStatus {
98
98
  changed?: string;
99
99
  /** Saved settings that could not be used. */
100
100
  settingsWarning?: string;
101
+ /** Set in `mode notices-only`; absent in `edit`. */
102
+ mode?: "notices-only";
101
103
  }
102
104
 
103
105
  /** One line for a toast. */
@@ -105,7 +107,8 @@ export function statusLine(status: ClmStatus): string {
105
107
  const size = status.reading
106
108
  ? ` · ${formatTokens(status.reading.estimated)} of ${formatTokens(status.reading.budget)} tok`
107
109
  : "";
108
- return `CLM r${status.revision} · ${status.accepted} accepted / ${status.rejected} rejected${size}`;
110
+ const mode = status.mode ? ` · mode ${status.mode}` : "";
111
+ return `CLM r${status.revision}${mode} · ${status.accepted} accepted / ${status.rejected} rejected${size}`;
109
112
  }
110
113
 
111
114
  /** Multi-line status for `/clm`. */
@@ -113,6 +116,7 @@ export function statusText(status: ClmStatus): string {
113
116
  const lines = [
114
117
  `CLM status for session ${status.sessionID}`,
115
118
  `mirror: ${status.mirrorPath}`,
119
+ ...(status.mode ? ["mode notices-only: budget notices and the overflow guard run; no mirror and no protocol prompt, so the model cannot edit its context"] : []),
116
120
  `revision ${status.revision} · edits accepted ${status.accepted} · rejected ${status.rejected} · gate ${status.gate} · guard ${status.guard}`,
117
121
  status.reading
118
122
  ? `${budgetSummaryLine(status.reading)} · model window ${status.modelWindow ? formatTokens(status.modelWindow) : "unknown"}`
@@ -121,7 +125,9 @@ export function statusText(status: ClmStatus): string {
121
125
  if (status.fit) lines.push(budgetFitLine(status.fit));
122
126
  const checkpoint = status.checkpoint;
123
127
  lines.push(
124
- checkpoint
128
+ checkpoint && status.mode
129
+ ? `revision ${checkpoint.revision} kept but not applied in notices-only mode: the model sees the raw history`
130
+ : checkpoint
125
131
  ? `active revision ${checkpoint.revision}: covers ${checkpoint.anchorCount} raw messages; ~${formatTokens(checkpoint.beforeEstimate)}→${formatTokens(checkpoint.afterEstimate)} tokens when accepted`
126
132
  : "no accepted edit: the model sees the raw history",
127
133
  );
package/src/projection.ts CHANGED
@@ -13,6 +13,15 @@
13
13
  // whole messages (ids change) or truncates the parts of a kept message (its toolCall and
14
14
  // toolResult disappear, so the digest changes); both still fail closed.
15
15
  //
16
+ // One mismatch source is OpenCode's own late writes: it keeps updating message objects
17
+ // after `experimental.chat.messages.transform` returns (an assistant message being
18
+ // finalized, turn diffs attached to a user message). A checkpoint taken mid-turn digests
19
+ // those still-mutable tail messages, and the late write flips the digest. `sourceStamps`
20
+ // (per-message digest + captured `time.updated`) lets `applyProjection` attribute such a
21
+ // mismatch to a post-capture OpenCode write and re-anchor the checkpoint to the stable
22
+ // head instead of dropping the revision (see `reanchorTail`); anything not attributable
23
+ // stays fail-closed.
24
+ //
16
25
  // pi-clm's `recoverProjectionFromRetryErrors` is not ported. Pi writes failed assistant
17
26
  // responses to its session log but drops them from live state, so a resumed log hashes
18
27
  // differently. OpenCode keeps the error on the assistant message (`info.error`), and the
@@ -29,6 +38,19 @@ export const PROJECTION_SHORTER_REASON = "Raw context is shorter than the projec
29
38
 
30
39
  export type EstimateUnit = "characters" | "tokens";
31
40
 
41
+ /** One source message's content digest plus the write time captured with it. */
42
+ export interface SourceStamp {
43
+ digest: string;
44
+ updatedAt: number;
45
+ /**
46
+ * Digest of the same message with OpenCode's `<system-reminder>` blocks removed
47
+ * (whitespace-normalized). OpenCode appends and removes mode-change reminders on user
48
+ * messages between requests without moving any write-time field; a change confined to
49
+ * those blocks is attributable against this fingerprint.
50
+ */
51
+ strippedDigest?: string;
52
+ }
53
+
32
54
  export interface ProjectionCheckpoint {
33
55
  version: 1;
34
56
  revision: number;
@@ -38,6 +60,12 @@ export interface ProjectionCheckpoint {
38
60
  sourceIds: string[];
39
61
  /** `digestSourcePrefix` of those messages. */
40
62
  sourceDigest: string;
63
+ /**
64
+ * Per-message digest and captured write time (length is sourceMessageCount). Lets a
65
+ * mismatch be attributed to specific messages and a late OpenCode write of the tail be
66
+ * re-anchored instead of dropped; absent on checkpoints written by older versions.
67
+ */
68
+ sourceStamps?: SourceStamp[];
41
69
  projectedMessages: LiveContextMessage[];
42
70
  beforeEstimate: number;
43
71
  afterEstimate: number;
@@ -47,11 +75,21 @@ export interface ProjectionCheckpoint {
47
75
  editTrace?: ContextEditTrace;
48
76
  }
49
77
 
78
+ /** The trimmed anchor returned when a late tail rewrite was re-anchored instead of dropped. */
79
+ export interface ProjectionReanchor {
80
+ sourceMessageCount: number;
81
+ sourceIds: string[];
82
+ sourceStamps: SourceStamp[];
83
+ sourceDigest: string;
84
+ }
85
+
50
86
  export type ProjectionApplication =
51
87
  | {
52
88
  valid: true;
53
89
  messages: LiveContextMessage[];
54
90
  suffix: LiveContextMessage[];
91
+ /** Set when the anchor was trimmed to the stable head (see `reanchorTail`). */
92
+ reanchor?: ProjectionReanchor;
55
93
  }
56
94
  | {
57
95
  valid: false;
@@ -64,6 +102,60 @@ export function sourceMessageId(message: LiveContextMessage): string {
64
102
  return typeof message.ocMessageID === "string" ? message.ocMessageID : "";
65
103
  }
66
104
 
105
+ /** The prune-invariant form of one source message (see `digestSourcePrefix`). */
106
+ function sourceDigestForm(message: LiveContextMessage): LiveContextMessage {
107
+ if (message.role !== "toolResult") return message;
108
+ return {
109
+ role: message.role,
110
+ toolCallId: message.toolCallId,
111
+ toolName: message.toolName,
112
+ isError: message.isError,
113
+ ocMessageID: message.ocMessageID,
114
+ };
115
+ }
116
+
117
+ const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
118
+
119
+ /** Whitespace-normalized text without OpenCode's reminder blocks, for the stripped fingerprint. */
120
+ function normalizeReminderFingerprint(text: string): string {
121
+ return text
122
+ .replace(/\r\n/g, "\n")
123
+ .replace(SYSTEM_REMINDER_RE, "")
124
+ .replace(/\n{2,}/g, "\n\n")
125
+ .trim();
126
+ }
127
+
128
+ /**
129
+ * The message with OpenCode's `<system-reminder>` blocks removed from its text parts; parts
130
+ * that become empty are dropped. OpenCode appends and removes mode-change reminders on user
131
+ * messages between requests without touching any write-time field, so a digest over this
132
+ * form tells a reminder-only edit apart from a real content change.
133
+ */
134
+ export function stripSystemReminders(message: LiveContextMessage): LiveContextMessage {
135
+ const content = message.content;
136
+ if (!Array.isArray(content)) return message;
137
+ const stripped: unknown[] = [];
138
+ for (const part of content) {
139
+ if (!part || typeof part !== "object") {
140
+ stripped.push(part);
141
+ continue;
142
+ }
143
+ const block = part as Record<string, unknown>;
144
+ if (block.type !== "text" || typeof block.text !== "string") {
145
+ stripped.push(block);
146
+ continue;
147
+ }
148
+ const text = normalizeReminderFingerprint(block.text);
149
+ if (text !== "") stripped.push({ ...block, text });
150
+ }
151
+ return { ...message, content: stripped };
152
+ }
153
+
154
+ /** `digestSourceMessage` of the message without OpenCode's system-reminder blocks. */
155
+ export function digestSourceMessageWithoutReminders(message: LiveContextMessage): string {
156
+ return digestMessages([sourceDigestForm(stripSystemReminders(message))]);
157
+ }
158
+
67
159
  /**
68
160
  * Digest a raw source prefix in a form OpenCode's prune cannot change: every `toolResult`
69
161
  * keeps only role, toolCallId, toolName, isError and ocMessageID. Content is dropped for
@@ -71,18 +163,22 @@ export function sourceMessageId(message: LiveContextMessage): string {
71
163
  * created and may be cleared when it is validated.
72
164
  */
73
165
  export function digestSourcePrefix(messages: readonly LiveContextMessage[]): string {
74
- return digestMessages(
75
- messages.map((message) => {
76
- if (message.role !== "toolResult") return message;
77
- return {
78
- role: message.role,
79
- toolCallId: message.toolCallId,
80
- toolName: message.toolName,
81
- isError: message.isError,
82
- ocMessageID: message.ocMessageID,
83
- };
84
- }),
85
- );
166
+ return digestMessages(messages.map(sourceDigestForm));
167
+ }
168
+
169
+ /** `digestSourcePrefix` of one message: per-message attribution for tail re-anchoring. */
170
+ export function digestSourceMessage(message: LiveContextMessage): string {
171
+ return digestMessages([sourceDigestForm(message)]);
172
+ }
173
+
174
+ /** A message's stamp: its content digest, the write time OpenCode last wrote, and the
175
+ * reminder-stripped fingerprint. */
176
+ export function sourceStamp(message: LiveContextMessage): SourceStamp {
177
+ return {
178
+ digest: digestSourceMessage(message),
179
+ updatedAt: typeof message.updatedAt === "number" ? message.updatedAt : 0,
180
+ strippedDigest: digestSourceMessageWithoutReminders(message),
181
+ };
86
182
  }
87
183
 
88
184
  /**
@@ -109,6 +205,7 @@ export function createProjectionCheckpoint(options: {
109
205
  sourceMessageCount: options.sourceMessages.length,
110
206
  sourceIds: options.sourceMessages.map(sourceMessageId),
111
207
  sourceDigest: digestSourcePrefix(options.sourceMessages),
208
+ sourceStamps: options.sourceMessages.map(sourceStamp),
112
209
  projectedMessages: [...options.projectedMessages],
113
210
  beforeEstimate: options.beforeEstimate,
114
211
  afterEstimate: options.afterEstimate,
@@ -124,7 +221,11 @@ export function createProjectionCheckpoint(options: {
124
221
 
125
222
  /**
126
223
  * Replace the checkpoint's raw source prefix and keep every raw message appended after
127
- * it. A mismatch fails closed and returns a copy of the untouched raw context.
224
+ * it. A mismatch fails closed and returns a copy of the untouched raw context — unless the
225
+ * checkpoint has per-message stamps and every changed message was written by OpenCode after
226
+ * it was captured (the current turn's tail still being finalized): then the anchor is
227
+ * trimmed to the stable head, the trimmed tail flows as ordinary suffix, and the caller
228
+ * persists `reanchor` on the checkpoint.
128
229
  */
129
230
  export function applyProjection(
130
231
  rawMessages: readonly LiveContextMessage[],
@@ -147,8 +248,50 @@ export function applyProjection(
147
248
  return { valid: false, messages: [...rawMessages], reason: PROJECTION_SOURCE_IDS_REASON };
148
249
  }
149
250
  if (digestSourcePrefix(prefix) !== checkpoint.sourceDigest) {
150
- return { valid: false, messages: [...rawMessages], reason: PROJECTION_PREFIX_MISMATCH_REASON };
251
+ return reanchorTail(rawMessages, prefix, checkpoint) ??
252
+ { valid: false, messages: [...rawMessages], reason: PROJECTION_PREFIX_MISMATCH_REASON };
151
253
  }
152
254
  const suffix = rawMessages.slice(checkpoint.sourceMessageCount);
153
255
  return { valid: true, messages: [...checkpoint.projectedMessages, ...suffix], suffix };
154
256
  }
257
+
258
+ /**
259
+ * A mismatch OpenCode itself caused. Every changed message must be explained by an
260
+ * OpenCode write: either its write time (`time.completed`/`updated`, not `created`) moved
261
+ * past the stamp, or the change is confined to OpenCode's `<system-reminder>` mode-change
262
+ * blocks (verified against the reminder-stripped per-message digest). That is the tail of
263
+ * the current turn still being finalized or a mode switch — not a revert, a compaction or
264
+ * a foreign transform, which change ids or content in ways neither check explains and stay
265
+ * fail-closed. The anchor is trimmed to the last stable message; the trimmed tail becomes
266
+ * ordinary suffix, the same contract as pi-clm's rebasing for appended messages.
267
+ */
268
+ function reanchorTail(
269
+ rawMessages: readonly LiveContextMessage[],
270
+ prefix: readonly LiveContextMessage[],
271
+ checkpoint: ProjectionCheckpoint,
272
+ ): ProjectionApplication | undefined {
273
+ const stamps = checkpoint.sourceStamps;
274
+ if (!Array.isArray(stamps) || stamps.length !== prefix.length) return undefined;
275
+ const changed = prefix.map((message, index) => digestSourceMessage(message) !== stamps[index]!.digest);
276
+ const first = changed.indexOf(true);
277
+ if (first <= 0) return undefined;
278
+ const attributable = prefix.every((message, index) => {
279
+ if (!changed[index]) return true;
280
+ const stamp = stamps[index]!;
281
+ if (typeof message.updatedAt === "number" && message.updatedAt > stamp.updatedAt) return true;
282
+ // OpenCode appends and removes `<system-reminder>` blocks (mode changes) on user
283
+ // messages without moving any write time; a change confined to those blocks is still
284
+ // an OpenCode edit. Everything else fails closed.
285
+ return typeof stamp.strippedDigest === "string" && digestSourceMessageWithoutReminders(message) === stamp.strippedDigest;
286
+ });
287
+ if (!attributable) return undefined;
288
+ const stable = prefix.slice(0, first);
289
+ const reanchor: ProjectionReanchor = {
290
+ sourceMessageCount: stable.length,
291
+ sourceIds: stable.map(sourceMessageId),
292
+ sourceStamps: stable.map(sourceStamp),
293
+ sourceDigest: digestSourcePrefix(stable),
294
+ };
295
+ const suffix = rawMessages.slice(stable.length);
296
+ return { valid: true, messages: [...checkpoint.projectedMessages, ...suffix], suffix, reanchor };
297
+ }
@@ -10,13 +10,13 @@
10
10
  */
11
11
  import { basename, resolve } from "node:path";
12
12
 
13
- import { resolveBudgetPolicy } from "./budget.ts";
13
+ import { FALLBACK_BUDGET, formatPercent, resolveBudget, resolveBudgetPolicy, type BudgetPolicyConfig } from "./budget.ts";
14
14
  import { MIN_OBSERVATION_CAP, resolveObservationCap } from "./observation.ts";
15
15
  import { toEditGate, type EditGate } from "./policy.ts";
16
- import { HOUSE_STEERING, parseCompaction, parseFlag, parseFractions, parseTokens, type ClmSettings, type CompactionMode } from "./settings.ts";
16
+ import { HOUSE_STEERING, parseBudget, parseCompaction, parseCooldown, parseFlag, parseFractions, parseMode, parseTokens, type ClmMode, type ClmSettings, type CompactionMode } from "./settings.ts";
17
17
 
18
18
  export type SettingKey =
19
- | "editing" | "budget" | "reserve" | "reminders" | "gate" | "guard" | "compaction" | "cap" | "steering" | "oneTool" | "trailer"
19
+ | "editing" | "budget" | "reserve" | "reminders" | "cooldown" | "gate" | "guard" | "compaction" | "cap" | "steering" | "mode" | "oneTool" | "trailer"
20
20
  | "compactPrompt" | "reasoning";
21
21
 
22
22
  /**
@@ -26,10 +26,13 @@ export type SettingKey =
26
26
  */
27
27
  export interface ClmOverrides {
28
28
  editing?: boolean;
29
- budget?: number | null;
29
+ /** Tokens, a share of the model window as text (`"50%"`), or null for the whole window. */
30
+ budget?: number | BudgetPercent | null;
30
31
  reserve?: number;
31
32
  /** Reminder fractions; empty disables every reminder, the budget − reserve one included. */
32
33
  reminders?: number[];
34
+ /** Reminder cooldown as a share of the budget; 0 is off. */
35
+ cooldown?: number;
33
36
  gate?: EditGate;
34
37
  guard?: "withhold" | "off";
35
38
  compaction?: CompactionMode;
@@ -42,8 +45,12 @@ export interface ClmOverrides {
42
45
  reasoning?: boolean;
43
46
  oneTool?: boolean;
44
47
  trailer?: boolean;
48
+ mode?: ClmMode;
45
49
  }
46
50
 
51
+ /** A budget percentage as stored in overrides.json: `"50%"`, `"12.5%"`. */
52
+ export type BudgetPercent = `${number}%`;
53
+
47
54
  /** Settings in force for one session: the resolved settings plus whether edits apply. */
48
55
  export interface SettingsValues {
49
56
  editing: boolean;
@@ -53,6 +60,8 @@ export interface SettingsValues {
53
60
  export interface FormatContext {
54
61
  /** Model context window, shown next to a `window` budget. */
55
62
  modelWindow?: number;
63
+ /** Model output limit; a percentage budget is a share of the window minus this. */
64
+ modelOutput?: number;
56
65
  }
57
66
 
58
67
  export interface ParseContext {
@@ -82,6 +91,24 @@ export function compactTokens(value: number): string {
82
91
  return value.toLocaleString("en-US");
83
92
  }
84
93
 
94
+ const percentText = (fraction: number) => formatPercent(fraction) as BudgetPercent;
95
+
96
+ /** `"50%"` → 0.5; undefined for anything but a percentage from 1% to 100%. */
97
+ export function budgetPercentFraction(value: unknown): number | undefined {
98
+ if (typeof value !== "string" || !/^\d+(?:\.\d+)?%$/.test(value)) return undefined;
99
+ const percent = Number(value.slice(0, -1));
100
+ return percent >= 1 && percent <= 100 ? percent / 100 : undefined;
101
+ }
102
+
103
+ /** The budget fields of a policy for a `budget` override. */
104
+ function budgetFields(budget: number | BudgetPercent | null): Pick<BudgetPolicyConfig, "contextBudget" | "contextFraction"> {
105
+ if (budget === null) return { contextBudget: undefined, contextFraction: undefined };
106
+ if (typeof budget === "number") return { contextBudget: budget, contextFraction: undefined };
107
+ const fraction = budgetPercentFraction(budget);
108
+ if (fraction === undefined) throw new Error(`budget must be a percentage from 1% to 100%, got ${JSON.stringify(budget)}`);
109
+ return { contextBudget: undefined, contextFraction: fraction };
110
+ }
111
+
85
112
  const onOff = (value: boolean) => (value ? "on" : "off");
86
113
 
87
114
  function formatReminders(fractions: readonly number[]): string {
@@ -138,19 +165,25 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
138
165
  key: "budget",
139
166
  name: "budget",
140
167
  label: "Budget",
141
- description: "Token budget the reminders, the edit gate and the overflow guard measure against, OpenCode's system prompt and tool schemas included. A number (32k) or \"window\" for the model's context window. Env: CLM_BUDGET.",
142
- placeholder: "32k, 1.5m, or window",
168
+ description: `Token budget the reminders, the edit gate and the overflow guard measure against, OpenCode's system prompt and tool schemas included. A percentage of the model window minus its output limit (50%; ${compactTokens(FALLBACK_BUDGET)} while the window is unknown), a number (32k), or "window" for all of it. Env: CLM_BUDGET.`,
169
+ placeholder: "50%, 32k, 1.5m, or window",
143
170
  format: (values, context) => {
144
- const budget = values.settings.budget.contextBudget;
145
- if (budget !== undefined) return compactTokens(budget);
171
+ const policy = values.settings.budget;
172
+ if (policy.contextBudget !== undefined) return compactTokens(policy.contextBudget);
173
+ if (policy.contextFraction !== undefined) {
174
+ const resolved = context?.modelWindow ? resolveBudget(policy, context.modelWindow, context.modelOutput) : undefined;
175
+ return `${formatPercent(policy.contextFraction)}${resolved ? ` (${compactTokens(resolved.budget)})` : ""}`;
176
+ }
146
177
  return `window${context?.modelWindow ? ` (${compactTokens(context.modelWindow)})` : ""}`;
147
178
  },
179
+ // The settings page pre-fills its prompt with the formatted value: `50% (131,072)`.
148
180
  parse: (text) => {
149
- const value = text.trim().toLowerCase();
150
- if (value === "window" || value === "model") return { budget: null };
151
- const budget = parseTokens(value, "budget");
152
- if (budget <= 0) throw new Error(`budget must be a positive number of tokens or "window", got "${text}"`);
153
- return { budget };
181
+ const parsed = parseBudget(text.replace(/\s*\([^)]*\)\s*$/, ""), "budget");
182
+ switch (parsed.kind) {
183
+ case "window": return { budget: null };
184
+ case "fraction": return { budget: percentText(parsed.fraction) };
185
+ case "tokens": return { budget: parsed.tokens };
186
+ }
154
187
  },
155
188
  },
156
189
  {
@@ -171,6 +204,17 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
171
204
  format: (values) => formatReminders(values.settings.budget.remindAtFractions),
172
205
  parse: (text) => ({ reminders: parseReminders(text) }),
173
206
  },
207
+ {
208
+ key: "cooldown",
209
+ // Named after the reminders it governs; `cooldown` is an alias.
210
+ name: "reminder-cooldown",
211
+ label: "Reminder cooldown",
212
+ description: "After an accepted edit, percentage reminders stay silent until the context grows by this share of the budget; tiers crossed meanwhile are skipped. The budget − reserve reminder always fires. A share (10%, 0.1) or off. Env: CLM_REMINDER_COOLDOWN.",
213
+ choices: ["10%", "25%", "5%", "off"],
214
+ placeholder: "10%, 0.1, or off",
215
+ format: (values) => (values.settings.budget.reminderCooldown > 0 ? formatPercent(values.settings.budget.reminderCooldown) : "off"),
216
+ parse: (text) => ({ cooldown: parseCooldown(text, "reminder-cooldown") }),
217
+ },
174
218
  {
175
219
  key: "gate",
176
220
  name: "gate",
@@ -223,6 +267,15 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
223
267
  return { steering: parsePathSetting(text, context.directory, ["none", "off", ""]) };
224
268
  },
225
269
  },
270
+ {
271
+ key: "mode",
272
+ name: "mode",
273
+ label: "Mode",
274
+ description: "edit: the model edits its context through the mirror. notices-only: no mirror, no protocol prompt and no edits; budget notices and reminders, the overflow guard, the observation cap and the size trailer still apply, to compare telling the model its budget with letting it edit. An accepted revision is kept but not applied while notices-only is in force. Env: CLM_MODE.",
275
+ choices: ["edit", "notices-only"],
276
+ format: (values) => values.settings.mode,
277
+ parse: (text) => ({ mode: parseMode(text) }),
278
+ },
226
279
  {
227
280
  key: "oneTool",
228
281
  name: "one-tool",
@@ -270,6 +323,9 @@ const ALIASES: Record<string, SettingKey> = {
270
323
  remind: "reminders",
271
324
  "remind-at": "reminders",
272
325
  remindat: "reminders",
326
+ cooldown: "cooldown",
327
+ "reminder-cooldown": "cooldown",
328
+ remindercooldown: "cooldown",
273
329
  gate: "gate",
274
330
  "edit-gate": "gate",
275
331
  guard: "guard",
@@ -290,6 +346,7 @@ const ALIASES: Record<string, SettingKey> = {
290
346
  "compact-prompt": "compactPrompt",
291
347
  compactprompt: "compactPrompt",
292
348
  reasoning: "reasoning",
349
+ mode: "mode",
293
350
  };
294
351
 
295
352
  /** Names `/clm config` lists. */
@@ -313,10 +370,13 @@ export function unknownSettingText(name: string): string {
313
370
  export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmSettings {
314
371
  const budget = resolveBudgetPolicy({
315
372
  ...base.budget,
316
- contextBudget: overrides.budget === null ? undefined : overrides.budget ?? base.budget.contextBudget,
373
+ ...(overrides.budget !== undefined
374
+ ? budgetFields(overrides.budget)
375
+ : { contextBudget: base.budget.contextBudget, contextFraction: base.budget.contextFraction }),
317
376
  reserve: overrides.reserve ?? base.budget.reserve,
318
377
  remindAtFractions: overrides.reminders ?? base.budget.remindAtFractions,
319
378
  remindAtReserve: overrides.reminders !== undefined ? overrides.reminders.length > 0 : base.budget.remindAtReserve,
379
+ reminderCooldown: overrides.cooldown ?? base.budget.reminderCooldown,
320
380
  });
321
381
  const observationCap = resolveObservationCap({
322
382
  maxCharacters: overrides.cap === null ? undefined : overrides.cap ?? base.observationCap.maxCharacters,
@@ -330,6 +390,7 @@ export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmS
330
390
  compaction: overrides.compaction ?? base.compaction,
331
391
  oneTool: overrides.oneTool ?? base.oneTool,
332
392
  trailer: overrides.trailer ?? base.trailer,
393
+ mode: overrides.mode ?? base.mode,
333
394
  observationCap,
334
395
  steeringPath: overrides.steering === null ? undefined : overrides.steering ?? base.steeringPath,
335
396
  compactPromptPath: overrides.compactPrompt === null ? undefined : overrides.compactPrompt ?? base.compactPromptPath,
@@ -342,9 +403,11 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
342
403
  const settings = values.settings;
343
404
  switch (key) {
344
405
  case "editing": return values.editing;
345
- case "budget": return settings.budget.contextBudget ?? null;
406
+ case "budget": return settings.budget.contextBudget ??
407
+ (settings.budget.contextFraction !== undefined ? percentText(settings.budget.contextFraction) : null);
346
408
  case "reserve": return settings.budget.reserve;
347
409
  case "reminders": return settings.budget.remindAtReserve || settings.budget.remindAtFractions.length > 0 ? [...settings.budget.remindAtFractions] : [];
410
+ case "cooldown": return settings.budget.reminderCooldown;
348
411
  case "gate": return settings.gate;
349
412
  case "guard": return settings.guard;
350
413
  case "compaction": return settings.compaction;
@@ -355,6 +418,7 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
355
418
  case "steering": return settings.steeringPath ?? null;
356
419
  case "compactPrompt": return settings.compactPromptPath ?? null;
357
420
  case "reasoning": return settings.reasoning;
421
+ case "mode": return settings.mode;
358
422
  }
359
423
  }
360
424
 
@@ -366,7 +430,7 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
366
430
  */
367
431
  export function settingsAsOverrides(settings: ClmSettings): ClmOverrides {
368
432
  const values: SettingsValues = { editing: true, settings };
369
- const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "gate", "guard", "compaction", "cap", "capHead", "steering", "oneTool", "trailer", "compactPrompt", "reasoning"];
433
+ const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "cooldown", "gate", "guard", "compaction", "cap", "capHead", "steering", "mode", "oneTool", "trailer", "compactPrompt", "reasoning"];
370
434
  return Object.fromEntries(keys.map((key) => [key, baseValue(key, values)])) as ClmOverrides;
371
435
  }
372
436
 
@@ -385,9 +449,10 @@ const isPath = (value: unknown) => value === null || (typeof value === "string"
385
449
  const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
386
450
  editing: (value) => typeof value === "boolean",
387
451
  // Integers: state.json's budget check stores them as counts.
388
- budget: (value) => value === null || (Number.isInteger(value) && (value as number) > 0),
452
+ budget: (value) => value === null || (Number.isInteger(value) && (value as number) > 0) || budgetPercentFraction(value) !== undefined,
389
453
  reserve: (value) => Number.isInteger(value) && (value as number) >= 0,
390
454
  reminders: (value) => Array.isArray(value) && value.every((fraction) => isNumber(fraction) && fraction > 0 && fraction < 1),
455
+ cooldown: (value) => isNumber(value) && value >= 0 && value < 1,
391
456
  gate: (value) => value === "fit" || value === "shrink" || value === "none",
392
457
  guard: (value) => value === "withhold" || value === "off",
393
458
  compaction: (value) => value === "auto" || value === "off" || value === "on",
@@ -398,6 +463,7 @@ const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
398
463
  steering: isPath,
399
464
  compactPrompt: isPath,
400
465
  reasoning: (value) => typeof value === "boolean",
466
+ mode: (value) => value === "edit" || value === "notices-only",
401
467
  };
402
468
 
403
469
  /**