opencode-clm 0.2.0 → 0.4.0

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.
@@ -10,13 +10,14 @@
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, parseFlag, parseFractions, parseTokens, type ClmSettings } 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" | "cap" | "steering" | "compactPrompt" | "reasoning";
19
+ | "editing" | "budget" | "reserve" | "reminders" | "cooldown" | "gate" | "guard" | "compaction" | "cap" | "steering" | "mode" | "oneTool" | "trailer"
20
+ | "compactPrompt" | "reasoning";
20
21
 
21
22
  /**
22
23
  * Per-session changes, as stored in overrides.json. Only keys that differ from the base are
@@ -25,12 +26,16 @@ export type SettingKey =
25
26
  */
26
27
  export interface ClmOverrides {
27
28
  editing?: boolean;
28
- 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;
29
31
  reserve?: number;
30
32
  /** Reminder fractions; empty disables every reminder, the budget − reserve one included. */
31
33
  reminders?: number[];
34
+ /** Reminder cooldown as a share of the budget; 0 is off. */
35
+ cooldown?: number;
32
36
  gate?: EditGate;
33
37
  guard?: "withhold" | "off";
38
+ compaction?: CompactionMode;
34
39
  cap?: number | null;
35
40
  capHead?: number;
36
41
  /** Absolute path. */
@@ -38,8 +43,14 @@ export interface ClmOverrides {
38
43
  /** Absolute path. */
39
44
  compactPrompt?: string | null;
40
45
  reasoning?: boolean;
46
+ oneTool?: boolean;
47
+ trailer?: boolean;
48
+ mode?: ClmMode;
41
49
  }
42
50
 
51
+ /** A budget percentage as stored in overrides.json: `"50%"`, `"12.5%"`. */
52
+ export type BudgetPercent = `${number}%`;
53
+
43
54
  /** Settings in force for one session: the resolved settings plus whether edits apply. */
44
55
  export interface SettingsValues {
45
56
  editing: boolean;
@@ -49,6 +60,8 @@ export interface SettingsValues {
49
60
  export interface FormatContext {
50
61
  /** Model context window, shown next to a `window` budget. */
51
62
  modelWindow?: number;
63
+ /** Model output limit; a percentage budget is a share of the window minus this. */
64
+ modelOutput?: number;
52
65
  }
53
66
 
54
67
  export interface ParseContext {
@@ -78,6 +91,24 @@ export function compactTokens(value: number): string {
78
91
  return value.toLocaleString("en-US");
79
92
  }
80
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
+
81
112
  const onOff = (value: boolean) => (value ? "on" : "off");
82
113
 
83
114
  function formatReminders(fractions: readonly number[]): string {
@@ -134,19 +165,25 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
134
165
  key: "budget",
135
166
  name: "budget",
136
167
  label: "Budget",
137
- 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.",
138
- 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",
139
170
  format: (values, context) => {
140
- const budget = values.settings.budget.contextBudget;
141
- 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
+ }
142
177
  return `window${context?.modelWindow ? ` (${compactTokens(context.modelWindow)})` : ""}`;
143
178
  },
179
+ // The settings page pre-fills its prompt with the formatted value: `50% (131,072)`.
144
180
  parse: (text) => {
145
- const value = text.trim().toLowerCase();
146
- if (value === "window" || value === "model") return { budget: null };
147
- const budget = parseTokens(value, "budget");
148
- if (budget <= 0) throw new Error(`budget must be a positive number of tokens or "window", got "${text}"`);
149
- 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
+ }
150
187
  },
151
188
  },
152
189
  {
@@ -167,6 +204,17 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
167
204
  format: (values) => formatReminders(values.settings.budget.remindAtFractions),
168
205
  parse: (text) => ({ reminders: parseReminders(text) }),
169
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
+ },
170
218
  {
171
219
  key: "gate",
172
220
  name: "gate",
@@ -185,6 +233,15 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
185
233
  format: (values) => onOff(values.settings.guard === "withhold"),
186
234
  parse: (text) => ({ guard: parseFlag(text.trim().toLowerCase() === "withhold" ? "on" : text, "guard") ? "withhold" : "off" }),
187
235
  },
236
+ {
237
+ key: "compaction",
238
+ name: "compaction",
239
+ label: "OC compaction",
240
+ description: "OpenCode's automatic compaction: off turns it off (a provider overflow is then reported, not compacted); auto and on leave your config's compaction.auto (under auto the overflow guard keeps requests below OpenCode's threshold). Manual /compact always works. The flag is shared by every session of the server. Env: CLM_NATIVE_COMPACTION.",
241
+ choices: ["auto", "off", "on"],
242
+ format: (values) => values.settings.compaction,
243
+ parse: (text) => ({ compaction: parseCompaction(text) }),
244
+ },
188
245
  {
189
246
  key: "cap",
190
247
  name: "cap",
@@ -210,6 +267,33 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
210
267
  return { steering: parsePathSetting(text, context.directory, ["none", "off", ""]) };
211
268
  },
212
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
+ },
279
+ {
280
+ key: "oneTool",
281
+ name: "one-tool",
282
+ label: "One tool per turn",
283
+ description: "Paper-harness parity: run only the first tool call of each model response; later calls in it fail with a note to repeat them. Env: CLM_ONE_TOOL_PER_TURN.",
284
+ choices: ["off", "on"],
285
+ format: (values) => onOff(values.settings.oneTool),
286
+ parse: (text) => ({ oneTool: parseFlag(text, "one-tool") }),
287
+ },
288
+ {
289
+ key: "trailer",
290
+ name: "trailer",
291
+ label: "Size trailer",
292
+ description: "Paper-harness parity: end every successful tool result with \"[context: ~N of B tokens after this result]\". Env: CLM_SIZE_TRAILER.",
293
+ choices: ["off", "on"],
294
+ format: (values) => onOff(values.settings.trailer),
295
+ parse: (text) => ({ trailer: parseFlag(text, "trailer") }),
296
+ },
213
297
  {
214
298
  key: "compactPrompt",
215
299
  name: "compact-prompt",
@@ -239,18 +323,30 @@ const ALIASES: Record<string, SettingKey> = {
239
323
  remind: "reminders",
240
324
  "remind-at": "reminders",
241
325
  remindat: "reminders",
326
+ cooldown: "cooldown",
327
+ "reminder-cooldown": "cooldown",
328
+ remindercooldown: "cooldown",
242
329
  gate: "gate",
243
330
  "edit-gate": "gate",
244
331
  guard: "guard",
245
332
  overflow: "guard",
333
+ compaction: "compaction",
334
+ "native-compaction": "compaction",
246
335
  cap: "cap",
247
336
  observation: "cap",
248
337
  "observation-cap": "cap",
249
338
  observationcap: "cap",
250
339
  steering: "steering",
340
+ "one-tool": "oneTool",
341
+ onetool: "oneTool",
342
+ "one-tool-per-turn": "oneTool",
343
+ trailer: "trailer",
344
+ "size-trailer": "trailer",
345
+ sizetrailer: "trailer",
251
346
  "compact-prompt": "compactPrompt",
252
347
  compactprompt: "compactPrompt",
253
348
  reasoning: "reasoning",
349
+ mode: "mode",
254
350
  };
255
351
 
256
352
  /** Names `/clm config` lists. */
@@ -274,10 +370,13 @@ export function unknownSettingText(name: string): string {
274
370
  export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmSettings {
275
371
  const budget = resolveBudgetPolicy({
276
372
  ...base.budget,
277
- 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 }),
278
376
  reserve: overrides.reserve ?? base.budget.reserve,
279
377
  remindAtFractions: overrides.reminders ?? base.budget.remindAtFractions,
280
378
  remindAtReserve: overrides.reminders !== undefined ? overrides.reminders.length > 0 : base.budget.remindAtReserve,
379
+ reminderCooldown: overrides.cooldown ?? base.budget.reminderCooldown,
281
380
  });
282
381
  const observationCap = resolveObservationCap({
283
382
  maxCharacters: overrides.cap === null ? undefined : overrides.cap ?? base.observationCap.maxCharacters,
@@ -288,6 +387,10 @@ export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmS
288
387
  budget,
289
388
  gate: overrides.gate ?? base.gate,
290
389
  guard: overrides.guard ?? base.guard,
390
+ compaction: overrides.compaction ?? base.compaction,
391
+ oneTool: overrides.oneTool ?? base.oneTool,
392
+ trailer: overrides.trailer ?? base.trailer,
393
+ mode: overrides.mode ?? base.mode,
291
394
  observationCap,
292
395
  steeringPath: overrides.steering === null ? undefined : overrides.steering ?? base.steeringPath,
293
396
  compactPromptPath: overrides.compactPrompt === null ? undefined : overrides.compactPrompt ?? base.compactPromptPath,
@@ -300,16 +403,22 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
300
403
  const settings = values.settings;
301
404
  switch (key) {
302
405
  case "editing": return values.editing;
303
- case "budget": return settings.budget.contextBudget ?? null;
406
+ case "budget": return settings.budget.contextBudget ??
407
+ (settings.budget.contextFraction !== undefined ? percentText(settings.budget.contextFraction) : null);
304
408
  case "reserve": return settings.budget.reserve;
305
409
  case "reminders": return settings.budget.remindAtReserve || settings.budget.remindAtFractions.length > 0 ? [...settings.budget.remindAtFractions] : [];
410
+ case "cooldown": return settings.budget.reminderCooldown;
306
411
  case "gate": return settings.gate;
307
412
  case "guard": return settings.guard;
413
+ case "compaction": return settings.compaction;
414
+ case "oneTool": return settings.oneTool;
415
+ case "trailer": return settings.trailer;
308
416
  case "cap": return settings.observationCap.maxCharacters ?? null;
309
417
  case "capHead": return settings.observationCap.headFraction;
310
418
  case "steering": return settings.steeringPath ?? null;
311
419
  case "compactPrompt": return settings.compactPromptPath ?? null;
312
420
  case "reasoning": return settings.reasoning;
421
+ case "mode": return settings.mode;
313
422
  }
314
423
  }
315
424
 
@@ -321,7 +430,7 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
321
430
  */
322
431
  export function settingsAsOverrides(settings: ClmSettings): ClmOverrides {
323
432
  const values: SettingsValues = { editing: true, settings };
324
- const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "gate", "guard", "cap", "capHead", "steering", "compactPrompt", "reasoning"];
433
+ const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "cooldown", "gate", "guard", "compaction", "cap", "capHead", "steering", "mode", "oneTool", "trailer", "compactPrompt", "reasoning"];
325
434
  return Object.fromEntries(keys.map((key) => [key, baseValue(key, values)])) as ClmOverrides;
326
435
  }
327
436
 
@@ -340,16 +449,21 @@ const isPath = (value: unknown) => value === null || (typeof value === "string"
340
449
  const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
341
450
  editing: (value) => typeof value === "boolean",
342
451
  // Integers: state.json's budget check stores them as counts.
343
- 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,
344
453
  reserve: (value) => Number.isInteger(value) && (value as number) >= 0,
345
454
  reminders: (value) => Array.isArray(value) && value.every((fraction) => isNumber(fraction) && fraction > 0 && fraction < 1),
455
+ cooldown: (value) => isNumber(value) && value >= 0 && value < 1,
346
456
  gate: (value) => value === "fit" || value === "shrink" || value === "none",
347
457
  guard: (value) => value === "withhold" || value === "off",
458
+ compaction: (value) => value === "auto" || value === "off" || value === "on",
459
+ oneTool: (value) => typeof value === "boolean",
460
+ trailer: (value) => typeof value === "boolean",
348
461
  cap: (value) => value === null || (isNumber(value) && value >= MIN_OBSERVATION_CAP),
349
462
  capHead: (value) => isNumber(value) && value > 0 && value <= 1,
350
463
  steering: isPath,
351
464
  compactPrompt: isPath,
352
465
  reasoning: (value) => typeof value === "boolean",
466
+ mode: (value) => value === "edit" || value === "notices-only",
353
467
  };
354
468
 
355
469
  /**
package/src/settings.ts CHANGED
@@ -5,8 +5,7 @@
5
5
  * instead of running with a guess.
6
6
  *
7
7
  * Setting set adapted from pi-clm src/settings.ts (MIT, Copyright 2026 Emanuel Casco).
8
- * pi-clm's per-session overrides (`/clm config` panel) are not ported: OpenCode commands
9
- * cannot open a panel.
8
+ * Per-session overrides (`/clm config`) live in src/settings-table.ts and src/overrides.ts.
10
9
  */
11
10
  import { join, resolve } from "node:path";
12
11
  import { fileURLToPath } from "node:url";
@@ -38,6 +37,40 @@ export interface ClmSettings {
38
37
  commands: boolean;
39
38
  /** Write every transformed request to `<session dir>/requests/nN.json` (debugging). */
40
39
  dumpRequests: boolean;
40
+ /**
41
+ * OpenCode's automatic compaction (`compaction.auto`): `off` turns it off; `auto` pauses it
42
+ * while the guard enforces a budget (src/compaction.ts); `on` leaves the user's config value
43
+ * (pi's names). Manual /compact always works.
44
+ */
45
+ compaction: CompactionMode;
46
+ /** Run only the first tool call of each model response (paper-harness parity). */
47
+ oneTool: boolean;
48
+ /** Append `[context: ~N of B tokens after this result]` to tool results (paper-harness parity). */
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)}`);
66
+ }
67
+
68
+ export type CompactionMode = "auto" | "off" | "on";
69
+
70
+ export function parseCompaction(value: unknown, name = "compaction"): CompactionMode {
71
+ const text = String(value).trim().toLowerCase();
72
+ if (text === "auto" || text === "off" || text === "on") return text;
73
+ throw new Error(`${name} must be auto, off or on, got ${JSON.stringify(value)}`);
41
74
  }
42
75
 
43
76
  export const PACKAGE_DIR = resolve(fileURLToPath(new URL("..", import.meta.url)));
@@ -47,6 +80,7 @@ export const SKILLS_DIR = join(PACKAGE_DIR, "skills");
47
80
  type Env = Record<string, string | undefined>;
48
81
 
49
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"`;
50
84
 
51
85
  function pick(options: Record<string, unknown>, key: string, env: Env, envKey: string): unknown {
52
86
  if (options[key] !== undefined) return options[key];
@@ -55,6 +89,11 @@ function pick(options: Record<string, unknown>, key: string, env: Env, envKey: s
55
89
  }
56
90
 
57
91
  /** `32000`, `32k`, `1.5m`, `32_000`, `32,000`. */
92
+ /** The default `mirrorDir`: `<project>/.opencode/clm`, inside the project, so the model's tools reach it without an external-directory permission. */
93
+ export function defaultMirrorDir(directory: string): string {
94
+ return join(directory, ".opencode", "clm");
95
+ }
96
+
58
97
  export function parseTokens(value: unknown, name: string): number {
59
98
  const text = String(value).trim().toLowerCase().replace(/[_,]/g, "");
60
99
  const match = /^(\d+(?:\.\d+)?)(k|m)?$/.exec(text);
@@ -63,6 +102,34 @@ export function parseTokens(value: unknown, name: string): number {
63
102
  return Math.round(Number(match[1]) * scale);
64
103
  }
65
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
+
66
133
  export function parseFlag(value: unknown, name: string): boolean {
67
134
  if (typeof value === "boolean") return value;
68
135
  const key = String(value).trim().toLowerCase();
@@ -89,6 +156,23 @@ export function parseFractions(value: unknown, name: string): number[] | "off" {
89
156
  return [...new Set(fractions)];
90
157
  }
91
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
+
92
176
  function withName<T>(name: string, parse: () => T): T {
93
177
  try {
94
178
  return parse();
@@ -110,6 +194,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
110
194
  const budgetRaw = pick(options, "budget", env, "CLM_BUDGET");
111
195
  const reserveRaw = pick(options, "reserve", env, "CLM_RESERVE");
112
196
  const remindRaw = pick(options, "remindAt", env, "CLM_REMIND_AT");
197
+ const cooldownRaw = pick(options, "reminderCooldown", env, "CLM_REMINDER_COOLDOWN");
113
198
  const gateRaw = pick(options, "gate", env, "CLM_EDIT_GATE");
114
199
  const guardRaw = pick(options, "guard", env, "CLM_OVERFLOW");
115
200
  const capRaw = pick(options, "observationCap", env, "CLM_OBSERVATION_CAP");
@@ -119,11 +204,16 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
119
204
  const factorRaw = pick(options, "estimateFactor", env, "CLM_ESTIMATE_FACTOR");
120
205
  const reasoningRaw = pick(options, "reasoning", env, "CLM_REASONING");
121
206
  const dumpRaw = pick(options, "dumpRequests", env, "CLM_DUMP_REQUESTS");
207
+ const compactionRaw = pick(options, "compaction", env, "CLM_NATIVE_COMPACTION");
208
+ const oneToolRaw = pick(options, "oneTool", env, "CLM_ONE_TOOL_PER_TURN");
209
+ const trailerRaw = pick(options, "trailer", env, "CLM_SIZE_TRAILER");
210
+ const modeRaw = pick(options, "mode", env, "CLM_MODE");
122
211
 
123
212
  const overrides: Partial<BudgetPolicyConfig> = {};
124
213
  if (budgetRaw !== undefined) {
125
- overrides.contextBudget = String(budgetRaw).trim().toLowerCase() === "window" ? undefined : parseTokens(budgetRaw, "budget");
126
- 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;
127
217
  }
128
218
  if (reserveRaw !== undefined) overrides.reserve = parseTokens(reserveRaw, "reserve");
129
219
  if (remindRaw !== undefined) {
@@ -139,6 +229,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
139
229
  overrides.remindAtFractions = fractions;
140
230
  }
141
231
  }
232
+ if (cooldownRaw !== undefined) overrides.reminderCooldown = parseCooldown(cooldownRaw, "reminderCooldown");
142
233
  const budget = withName("budget", () => resolveBudgetPolicy({ ...DEFAULT_BUDGET_POLICY, ...overrides }));
143
234
 
144
235
  let guard: ClmSettings["guard"] = "withhold";
@@ -177,7 +268,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
177
268
  gate: gateRaw === undefined ? "fit" : withName("gate (CLM_EDIT_GATE)", () => toEditGate(gateRaw)),
178
269
  guard,
179
270
  observationCap,
180
- mirrorDir: dirRaw === undefined ? join(directory, ".opencode", "clm") : resolve(directory, String(dirRaw)),
271
+ mirrorDir: dirRaw === undefined ? defaultMirrorDir(directory) : resolve(directory, String(dirRaw)),
181
272
  steeringPath,
182
273
  compactPromptPath,
183
274
  estimateFactor,
@@ -185,5 +276,9 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
185
276
  skill: options.skill === undefined ? true : parseFlag(options.skill, "skill"),
186
277
  commands: options.commands === undefined ? true : parseFlag(options.commands, "commands"),
187
278
  dumpRequests: dumpRaw === undefined ? false : parseFlag(dumpRaw, "dumpRequests"),
279
+ compaction: compactionRaw === undefined ? "auto" : parseCompaction(compactionRaw),
280
+ oneTool: oneToolRaw === undefined ? false : parseFlag(oneToolRaw, "oneTool"),
281
+ trailer: trailerRaw === undefined ? false : parseFlag(trailerRaw, "trailer"),
282
+ mode: modeRaw === undefined ? "edit" : parseMode(modeRaw),
188
283
  };
189
284
  }
@@ -0,0 +1,102 @@
1
+ // Tab completion for typed `/clm …` lines in the TUI prompt. OpenCode has no argument
2
+ // completion for commands (its slash menu closes at the first space), so the TUI plugin
3
+ // completes inline: Tab extends the line to the longest common prefix of the matching items
4
+ // and lists them when several remain. Items follow pi-clm's `getArgumentCompletions`
5
+ // (pi src/index.ts:961-980) plus `budget` and setting values. Written for this package.
6
+
7
+ import { PAGES } from "../panel/command.ts";
8
+ import { SETTING_NAMES, settingDescriptor } from "../settings-table.ts";
9
+ import type { FocusedPrompt, InterceptContext } from "./intercept.ts";
10
+
11
+ const PREFIX = "/clm ";
12
+
13
+ /** pi's word list: pages, status, config, `config <setting>`, config reset, on, off, reset, path; plus budget. */
14
+ export const CLM_COMPLETIONS: readonly string[] = [
15
+ ...PAGES,
16
+ "status",
17
+ "config",
18
+ ...SETTING_NAMES.map((name) => `config ${name}`),
19
+ "config reset",
20
+ "on",
21
+ "off",
22
+ "reset",
23
+ "path",
24
+ "budget",
25
+ ];
26
+
27
+ /** Items for the text after `/clm `: subcommands, or a setting's choices after `config <setting> `. */
28
+ export function clmCompletions(args: string): string[] {
29
+ const lower = args.toLowerCase();
30
+ const value = /^config\s+(\S+)\s+(.*)$/i.exec(args);
31
+ if (value) {
32
+ const choices = settingDescriptor(value[1]!)?.choices ?? [];
33
+ return choices
34
+ .filter((choice) => choice.toLowerCase().startsWith(value[2]!.toLowerCase()))
35
+ .map((choice) => `config ${value[1]} ${choice}`);
36
+ }
37
+ return CLM_COMPLETIONS.filter((item) => item.startsWith(lower));
38
+ }
39
+
40
+ function commonPrefix(items: readonly string[]): string {
41
+ let prefix = items[0] ?? "";
42
+ for (const item of items) {
43
+ while (!item.startsWith(prefix)) prefix = prefix.slice(0, -1);
44
+ }
45
+ return prefix;
46
+ }
47
+
48
+ export interface Completion {
49
+ /** The new prompt text; the same text when no item extends it. */
50
+ text: string;
51
+ /** Items still matching; more than one when the line stays ambiguous. */
52
+ items: string[];
53
+ }
54
+
55
+ /** `undefined` unless `text` is `/clm ` followed by something an item completes. */
56
+ export function completeClmLine(text: string): Completion | undefined {
57
+ if (!text.startsWith(PREFIX)) return undefined;
58
+ const args = text.slice(PREFIX.length).replace(/^\s+/, "");
59
+ const items = clmCompletions(args);
60
+ if (items.length === 0) return undefined;
61
+ if (items.length === 1) return { text: `${PREFIX}${items[0]} `, items };
62
+ const common = commonPrefix(items);
63
+ return { text: common.length > args.length ? `${PREFIX}${common}` : text, items };
64
+ }
65
+
66
+ export interface TabDeps {
67
+ focused(): (FocusedPrompt & { cursorOffset?: unknown }) | null | undefined;
68
+ /** False when `/clm` is not this package's (as for Enter). */
69
+ owned(): boolean;
70
+ /** Shows the remaining items when Tab cannot extend the line. */
71
+ show(items: readonly string[]): void;
72
+ }
73
+
74
+ /**
75
+ * Called for every key. Consumes Tab (no modifiers) on a `/clm …` line with the cursor at the
76
+ * end when an item matches; everything else, `/clm` without a space (OpenCode's own slash
77
+ * menu completes that) and lines with no match included, passes through to the host's Tab.
78
+ * Returns the completion applied, for tests.
79
+ */
80
+ export function interceptTab(context: InterceptContext, deps: TabDeps): Completion | undefined {
81
+ const event = context.event;
82
+ if (event.name !== "tab" || event.shift || event.ctrl || event.meta) return undefined;
83
+ const focused = deps.focused();
84
+ if (!focused || typeof focused.plainText !== "string" || typeof focused.setText !== "function") return undefined;
85
+ const text = focused.plainText;
86
+ if (typeof focused.cursorOffset === "number" && focused.cursorOffset !== text.length) return undefined;
87
+ const completion = completeClmLine(text);
88
+ if (!completion) return undefined;
89
+ try {
90
+ if (!deps.owned()) return undefined;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ context.consume();
95
+ if (completion.text !== text) {
96
+ focused.setText(completion.text);
97
+ if (typeof focused.cursorOffset === "number") focused.cursorOffset = completion.text.length;
98
+ } else {
99
+ deps.show(completion.items);
100
+ }
101
+ return completion;
102
+ }