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.
- package/CHANGELOG.md +1 -1
- package/README.md +87 -66
- package/index.ts +753 -40
- package/package.json +6 -6
- package/src/budget.ts +166 -22
- package/src/channel.ts +187 -0
- package/src/clm.ts +478 -57
- package/src/compaction.ts +251 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +40 -2
- package/src/opencode.ts +59 -0
- package/src/overflow.ts +6 -3
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/keys.ts +11 -1
- package/src/panel/model.ts +26 -2
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +39 -10
- package/src/presentation.ts +8 -2
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +131 -17
- package/src/settings.ts +100 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +89 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +14 -2
- package/src/tui/plugin.ts +222 -35
- package/src/tui/remote.ts +199 -0
package/src/settings-table.ts
CHANGED
|
@@ -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" | "
|
|
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
|
-
|
|
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:
|
|
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
|
|
141
|
-
if (
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
*
|
|
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
|
-
|
|
126
|
-
|
|
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 ?
|
|
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
|
+
}
|