opencode-clm 0.1.1 → 0.3.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/NOTICE +9 -1
- package/README.md +112 -299
- package/index.ts +778 -34
- package/package.json +19 -4
- package/src/atomic.ts +27 -0
- package/src/budget.ts +12 -10
- package/src/channel.ts +187 -0
- package/src/clm.ts +610 -27
- package/src/commands.ts +18 -0
- package/src/compaction.ts +243 -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 +41 -3
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +205 -0
- package/src/panel/command.ts +75 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +107 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +569 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +460 -0
- package/src/panel/view.ts +554 -0
- package/src/presentation.ts +6 -0
- package/src/projection.ts +8 -0
- package/src/session-files.ts +286 -0
- package/src/settings-table.ts +454 -0
- package/src/settings.ts +33 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +241 -0
- package/src/tui/intercept.ts +60 -0
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +205 -0
- package/src/tui/plugin.ts +466 -0
- package/src/tui/remote.ts +199 -0
- package/tui.ts +7 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings `/clm config` and the panel's settings page can change per session: one
|
|
3
|
+
* descriptor per setting (label, description, panel choices, format, parse), the override
|
|
4
|
+
* shape stored in `overrides.json`, and the pure functions that merge, sanitize and compare
|
|
5
|
+
* overrides. Pure; the file side is in overrides.ts.
|
|
6
|
+
*
|
|
7
|
+
* Table design adapted from pi-clm src/settings.ts (MIT, Copyright 2026 Emanuel Casco). The
|
|
8
|
+
* settings, texts and parsers are this package's: values parse with the same functions as
|
|
9
|
+
* plugin options and environment variables (settings.ts), so all three accept the same text.
|
|
10
|
+
*/
|
|
11
|
+
import { basename, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { resolveBudgetPolicy } from "./budget.ts";
|
|
14
|
+
import { MIN_OBSERVATION_CAP, resolveObservationCap } from "./observation.ts";
|
|
15
|
+
import { toEditGate, type EditGate } from "./policy.ts";
|
|
16
|
+
import { HOUSE_STEERING, parseCompaction, parseFlag, parseFractions, parseTokens, type ClmSettings, type CompactionMode } from "./settings.ts";
|
|
17
|
+
|
|
18
|
+
export type SettingKey =
|
|
19
|
+
| "editing" | "budget" | "reserve" | "reminders" | "gate" | "guard" | "compaction" | "cap" | "steering" | "oneTool" | "trailer"
|
|
20
|
+
| "compactPrompt" | "reasoning";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-session changes, as stored in overrides.json. Only keys that differ from the base are
|
|
24
|
+
* kept. `null` means "explicitly unset" for optional settings: budget → model window, cap →
|
|
25
|
+
* off, steering → none, compactPrompt → built-in prompt.
|
|
26
|
+
*/
|
|
27
|
+
export interface ClmOverrides {
|
|
28
|
+
editing?: boolean;
|
|
29
|
+
budget?: number | null;
|
|
30
|
+
reserve?: number;
|
|
31
|
+
/** Reminder fractions; empty disables every reminder, the budget − reserve one included. */
|
|
32
|
+
reminders?: number[];
|
|
33
|
+
gate?: EditGate;
|
|
34
|
+
guard?: "withhold" | "off";
|
|
35
|
+
compaction?: CompactionMode;
|
|
36
|
+
cap?: number | null;
|
|
37
|
+
capHead?: number;
|
|
38
|
+
/** Absolute path. */
|
|
39
|
+
steering?: string | null;
|
|
40
|
+
/** Absolute path. */
|
|
41
|
+
compactPrompt?: string | null;
|
|
42
|
+
reasoning?: boolean;
|
|
43
|
+
oneTool?: boolean;
|
|
44
|
+
trailer?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Settings in force for one session: the resolved settings plus whether edits apply. */
|
|
48
|
+
export interface SettingsValues {
|
|
49
|
+
editing: boolean;
|
|
50
|
+
settings: ClmSettings;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FormatContext {
|
|
54
|
+
/** Model context window, shown next to a `window` budget. */
|
|
55
|
+
modelWindow?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ParseContext {
|
|
59
|
+
/** Relative paths resolve against this directory (the project). */
|
|
60
|
+
directory: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SettingDescriptor {
|
|
64
|
+
key: SettingKey;
|
|
65
|
+
/** The word typed in `/clm config <name>`. */
|
|
66
|
+
name: string;
|
|
67
|
+
label: string;
|
|
68
|
+
/** What it does; ends with the environment variable that sets the default. */
|
|
69
|
+
description: string;
|
|
70
|
+
/** Values Enter cycles through on the settings page; absent → text prompt. */
|
|
71
|
+
choices?: string[];
|
|
72
|
+
placeholder?: string;
|
|
73
|
+
format(values: SettingsValues, context?: FormatContext): string;
|
|
74
|
+
/** Text → override; throws a user-facing message. */
|
|
75
|
+
parse(text: string, context: ParseContext): ClmOverrides;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `32000` → `32k`, `1500000` → `1.5m`, `2048` → `2,048`. */
|
|
79
|
+
export function compactTokens(value: number): string {
|
|
80
|
+
if (value >= 1_000_000 && value % 10_000 === 0) return `${Number((value / 1_000_000).toFixed(2))}m`;
|
|
81
|
+
if (value >= 1_000 && value % 100 === 0) return `${Number((value / 1_000).toFixed(1))}k`;
|
|
82
|
+
return value.toLocaleString("en-US");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const onOff = (value: boolean) => (value ? "on" : "off");
|
|
86
|
+
|
|
87
|
+
function formatReminders(fractions: readonly number[]): string {
|
|
88
|
+
return fractions.length === 0 ? "off" : `${fractions.map((fraction) => Math.round(fraction * 100)).join("/")}%`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseReminders(text: string): number[] {
|
|
92
|
+
const parsed = parseFractions(text, "reminders");
|
|
93
|
+
if (parsed === "off") return [];
|
|
94
|
+
if (parsed.some((fraction) => fraction <= 0 || fraction >= 1)) {
|
|
95
|
+
throw new Error(`reminders must be percentages between 0 and 100 like 25/50/75, or off; got "${text}"`);
|
|
96
|
+
}
|
|
97
|
+
return [...parsed].sort((left, right) => left - right);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatCap(settings: ClmSettings): string {
|
|
101
|
+
const cap = settings.observationCap;
|
|
102
|
+
if (cap.maxCharacters === undefined) return "off";
|
|
103
|
+
const head = Math.round(cap.headFraction * 100);
|
|
104
|
+
return `${compactTokens(cap.maxCharacters)} chars${head === 80 ? "" : ` (${head}% head)`}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseCap(text: string): ClmOverrides {
|
|
108
|
+
const value = text.trim().toLowerCase().replace(/\s*chars?$/, "");
|
|
109
|
+
if (value === "off" || value === "0" || value === "none") return { cap: null };
|
|
110
|
+
const [chars = "", head, ...rest] = value.split(":");
|
|
111
|
+
if (rest.length > 0) throw new Error(`observation cap must be characters[:head fraction] like 10k or 10k:0.5, got "${text}"`);
|
|
112
|
+
const cap = parseTokens(chars, "observation cap");
|
|
113
|
+
if (cap < MIN_OBSERVATION_CAP) throw new Error(`observation cap must be at least ${MIN_OBSERVATION_CAP} characters, got "${text}"`);
|
|
114
|
+
if (head === undefined || head === "") return { cap };
|
|
115
|
+
const capHead = Number(head);
|
|
116
|
+
if (!(capHead > 0 && capHead <= 1)) throw new Error(`observation cap head fraction must be in (0, 1], got "${head}"`);
|
|
117
|
+
return { cap, capHead };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parsePathSetting(text: string, directory: string, unset: string[]): string | null {
|
|
121
|
+
const value = text.trim();
|
|
122
|
+
if (unset.includes(value.toLowerCase())) return null;
|
|
123
|
+
return resolve(directory, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
|
|
127
|
+
{
|
|
128
|
+
key: "editing",
|
|
129
|
+
name: "editing",
|
|
130
|
+
label: "CLM editing",
|
|
131
|
+
description: "Whether this session renders the mirror and applies the model's edits; off sends the raw history. Same as /clm on and /clm off.",
|
|
132
|
+
choices: ["on", "off"],
|
|
133
|
+
// The plugin-wide `enabled: false` (CLM_ENABLED=0) wins over any session value.
|
|
134
|
+
format: (values) => (values.settings.enabled ? onOff(values.editing) : "off (plugin disabled)"),
|
|
135
|
+
parse: (text) => ({ editing: parseFlag(text, "editing") }),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: "budget",
|
|
139
|
+
name: "budget",
|
|
140
|
+
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",
|
|
143
|
+
format: (values, context) => {
|
|
144
|
+
const budget = values.settings.budget.contextBudget;
|
|
145
|
+
if (budget !== undefined) return compactTokens(budget);
|
|
146
|
+
return `window${context?.modelWindow ? ` (${compactTokens(context.modelWindow)})` : ""}`;
|
|
147
|
+
},
|
|
148
|
+
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 };
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
key: "reserve",
|
|
158
|
+
name: "reserve",
|
|
159
|
+
label: "Reserve",
|
|
160
|
+
description: "Tokens kept free below the budget for the reply; the final reminder and the overflow guard act at budget − reserve. Env: CLM_RESERVE.",
|
|
161
|
+
placeholder: "2048 or 2k",
|
|
162
|
+
format: (values) => compactTokens(values.settings.budget.reserve),
|
|
163
|
+
parse: (text) => ({ reserve: parseTokens(text, "reserve") }),
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
key: "reminders",
|
|
167
|
+
name: "reminders",
|
|
168
|
+
label: "Reminders",
|
|
169
|
+
description: "Budget fractions at which a [CLM BUDGET] note reaches the model, plus one at budget − reserve; off disables them all. Env: CLM_REMIND_AT.",
|
|
170
|
+
choices: ["25/50/75%", "50/75/90%", "75/90%", "90%", "off"],
|
|
171
|
+
format: (values) => formatReminders(values.settings.budget.remindAtFractions),
|
|
172
|
+
parse: (text) => ({ reminders: parseReminders(text) }),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
key: "gate",
|
|
176
|
+
name: "gate",
|
|
177
|
+
label: "Edit gate",
|
|
178
|
+
description: "Which edits that grow the context are accepted: fit (while it stays within budget − reserve), shrink (none), none (no size check). Env: CLM_EDIT_GATE.",
|
|
179
|
+
choices: ["fit", "shrink", "none"],
|
|
180
|
+
format: (values) => values.settings.gate,
|
|
181
|
+
parse: (text) => ({ gate: toEditGate(text) }),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
key: "guard",
|
|
185
|
+
name: "guard",
|
|
186
|
+
label: "Overflow guard",
|
|
187
|
+
description: "Above budget − reserve, hold back the oldest new tool results (full text saved to files) so the request fits. Env: CLM_OVERFLOW.",
|
|
188
|
+
choices: ["on", "off"],
|
|
189
|
+
format: (values) => onOff(values.settings.guard === "withhold"),
|
|
190
|
+
parse: (text) => ({ guard: parseFlag(text.trim().toLowerCase() === "withhold" ? "on" : text, "guard") ? "withhold" : "off" }),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
key: "compaction",
|
|
194
|
+
name: "compaction",
|
|
195
|
+
label: "OC compaction",
|
|
196
|
+
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.",
|
|
197
|
+
choices: ["auto", "off", "on"],
|
|
198
|
+
format: (values) => values.settings.compaction,
|
|
199
|
+
parse: (text) => ({ compaction: parseCompaction(text) }),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
key: "cap",
|
|
203
|
+
name: "cap",
|
|
204
|
+
label: "Observation cap",
|
|
205
|
+
description: "Keep at most this many characters of each tool result in the context (head and tail); 10k:0.5 keeps 5k + 5k. Env: CLM_OBSERVATION_CAP.",
|
|
206
|
+
// Written as the format shows them, so cycling finds the current value.
|
|
207
|
+
choices: ["off", "5k chars", "10k chars", "20k chars", "50k chars"],
|
|
208
|
+
placeholder: "10k or 10k:0.5",
|
|
209
|
+
format: (values) => formatCap(values.settings),
|
|
210
|
+
parse: parseCap,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
key: "steering",
|
|
214
|
+
name: "steering",
|
|
215
|
+
label: "Steering",
|
|
216
|
+
description: "Markdown guidance appended to the system prompt: a path, \"house\" for the bundled brief, or none. Env: CLM_STEERING.",
|
|
217
|
+
choices: ["none", "house-brief.md"],
|
|
218
|
+
placeholder: "path/to/brief.md, house, or none",
|
|
219
|
+
format: (values) => (values.settings.steeringPath ? basename(values.settings.steeringPath) : "none"),
|
|
220
|
+
parse: (text, context) => {
|
|
221
|
+
const value = text.trim().toLowerCase();
|
|
222
|
+
if (["house", "house-brief", "house-brief.md"].includes(value)) return { steering: HOUSE_STEERING };
|
|
223
|
+
return { steering: parsePathSetting(text, context.directory, ["none", "off", ""]) };
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
key: "oneTool",
|
|
228
|
+
name: "one-tool",
|
|
229
|
+
label: "One tool per turn",
|
|
230
|
+
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.",
|
|
231
|
+
choices: ["off", "on"],
|
|
232
|
+
format: (values) => onOff(values.settings.oneTool),
|
|
233
|
+
parse: (text) => ({ oneTool: parseFlag(text, "one-tool") }),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
key: "trailer",
|
|
237
|
+
name: "trailer",
|
|
238
|
+
label: "Size trailer",
|
|
239
|
+
description: "Paper-harness parity: end every successful tool result with \"[context: ~N of B tokens after this result]\". Env: CLM_SIZE_TRAILER.",
|
|
240
|
+
choices: ["off", "on"],
|
|
241
|
+
format: (values) => onOff(values.settings.trailer),
|
|
242
|
+
parse: (text) => ({ trailer: parseFlag(text, "trailer") }),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
key: "compactPrompt",
|
|
246
|
+
name: "compact-prompt",
|
|
247
|
+
label: "Compact prompt",
|
|
248
|
+
description: "Template /clm-compact sends in place of the built-in prompt; may use {{mirror}} {{current}} {{budget}} {{instructions}}. A path, or default. Env: CLM_COMPACT_PROMPT.",
|
|
249
|
+
placeholder: "path/to/prompt.md, or default",
|
|
250
|
+
format: (values) => (values.settings.compactPromptPath ? basename(values.settings.compactPromptPath) : "default"),
|
|
251
|
+
parse: (text, context) => ({ compactPrompt: parsePathSetting(text, context.directory, ["default", "none", "off", ""]) }),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
key: "reasoning",
|
|
255
|
+
name: "reasoning",
|
|
256
|
+
label: "Reasoning",
|
|
257
|
+
description: "Show the assistant's reasoning in the mirror. Env: CLM_REASONING.",
|
|
258
|
+
choices: ["on", "off"],
|
|
259
|
+
format: (values) => onOff(values.settings.reasoning),
|
|
260
|
+
parse: (text) => ({ reasoning: parseFlag(text, "reasoning") }),
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const ALIASES: Record<string, SettingKey> = {
|
|
265
|
+
editing: "editing",
|
|
266
|
+
enabled: "editing",
|
|
267
|
+
budget: "budget",
|
|
268
|
+
reserve: "reserve",
|
|
269
|
+
reminders: "reminders",
|
|
270
|
+
remind: "reminders",
|
|
271
|
+
"remind-at": "reminders",
|
|
272
|
+
remindat: "reminders",
|
|
273
|
+
gate: "gate",
|
|
274
|
+
"edit-gate": "gate",
|
|
275
|
+
guard: "guard",
|
|
276
|
+
overflow: "guard",
|
|
277
|
+
compaction: "compaction",
|
|
278
|
+
"native-compaction": "compaction",
|
|
279
|
+
cap: "cap",
|
|
280
|
+
observation: "cap",
|
|
281
|
+
"observation-cap": "cap",
|
|
282
|
+
observationcap: "cap",
|
|
283
|
+
steering: "steering",
|
|
284
|
+
"one-tool": "oneTool",
|
|
285
|
+
onetool: "oneTool",
|
|
286
|
+
"one-tool-per-turn": "oneTool",
|
|
287
|
+
trailer: "trailer",
|
|
288
|
+
"size-trailer": "trailer",
|
|
289
|
+
sizetrailer: "trailer",
|
|
290
|
+
"compact-prompt": "compactPrompt",
|
|
291
|
+
compactprompt: "compactPrompt",
|
|
292
|
+
reasoning: "reasoning",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/** Names `/clm config` lists. */
|
|
296
|
+
export const SETTING_NAMES: readonly string[] = SETTINGS_TABLE.map((item) => item.name);
|
|
297
|
+
|
|
298
|
+
/** The descriptor for a typed name (case-insensitive, aliases accepted). */
|
|
299
|
+
export function settingDescriptor(name: string): SettingDescriptor | undefined {
|
|
300
|
+
const key = ALIASES[name.trim().toLowerCase()];
|
|
301
|
+
return key ? SETTINGS_TABLE.find((item) => item.key === key) : undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function unknownSettingText(name: string): string {
|
|
305
|
+
return `Unknown setting "${name}". Settings: ${SETTING_NAMES.join(", ")}.`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Base settings with overrides applied. Throws when the result is invalid (the same checks
|
|
310
|
+
* as plugin options). Reminders changed here also switch the budget − reserve reminder on or
|
|
311
|
+
* off with them, as `remindAt: off` does.
|
|
312
|
+
*/
|
|
313
|
+
export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmSettings {
|
|
314
|
+
const budget = resolveBudgetPolicy({
|
|
315
|
+
...base.budget,
|
|
316
|
+
contextBudget: overrides.budget === null ? undefined : overrides.budget ?? base.budget.contextBudget,
|
|
317
|
+
reserve: overrides.reserve ?? base.budget.reserve,
|
|
318
|
+
remindAtFractions: overrides.reminders ?? base.budget.remindAtFractions,
|
|
319
|
+
remindAtReserve: overrides.reminders !== undefined ? overrides.reminders.length > 0 : base.budget.remindAtReserve,
|
|
320
|
+
});
|
|
321
|
+
const observationCap = resolveObservationCap({
|
|
322
|
+
maxCharacters: overrides.cap === null ? undefined : overrides.cap ?? base.observationCap.maxCharacters,
|
|
323
|
+
headFraction: overrides.capHead ?? base.observationCap.headFraction,
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
...base,
|
|
327
|
+
budget,
|
|
328
|
+
gate: overrides.gate ?? base.gate,
|
|
329
|
+
guard: overrides.guard ?? base.guard,
|
|
330
|
+
compaction: overrides.compaction ?? base.compaction,
|
|
331
|
+
oneTool: overrides.oneTool ?? base.oneTool,
|
|
332
|
+
trailer: overrides.trailer ?? base.trailer,
|
|
333
|
+
observationCap,
|
|
334
|
+
steeringPath: overrides.steering === null ? undefined : overrides.steering ?? base.steeringPath,
|
|
335
|
+
compactPromptPath: overrides.compactPrompt === null ? undefined : overrides.compactPrompt ?? base.compactPromptPath,
|
|
336
|
+
reasoning: overrides.reasoning ?? base.reasoning,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The override-shaped value of each key in `values` (null for an unset optional). */
|
|
341
|
+
function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
|
|
342
|
+
const settings = values.settings;
|
|
343
|
+
switch (key) {
|
|
344
|
+
case "editing": return values.editing;
|
|
345
|
+
case "budget": return settings.budget.contextBudget ?? null;
|
|
346
|
+
case "reserve": return settings.budget.reserve;
|
|
347
|
+
case "reminders": return settings.budget.remindAtReserve || settings.budget.remindAtFractions.length > 0 ? [...settings.budget.remindAtFractions] : [];
|
|
348
|
+
case "gate": return settings.gate;
|
|
349
|
+
case "guard": return settings.guard;
|
|
350
|
+
case "compaction": return settings.compaction;
|
|
351
|
+
case "oneTool": return settings.oneTool;
|
|
352
|
+
case "trailer": return settings.trailer;
|
|
353
|
+
case "cap": return settings.observationCap.maxCharacters ?? null;
|
|
354
|
+
case "capHead": return settings.observationCap.headFraction;
|
|
355
|
+
case "steering": return settings.steeringPath ?? null;
|
|
356
|
+
case "compactPrompt": return settings.compactPromptPath ?? null;
|
|
357
|
+
case "reasoning": return settings.reasoning;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Every table setting of `settings` in override form (editing excluded: its base is
|
|
363
|
+
* state.json). The server writes this into snapshot.json, so a TUI whose environment
|
|
364
|
+
* resolves differently validates and merges against the server's base:
|
|
365
|
+
* `applyOverrides(ownBase, serverBase)` reproduces it.
|
|
366
|
+
*/
|
|
367
|
+
export function settingsAsOverrides(settings: ClmSettings): ClmOverrides {
|
|
368
|
+
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"];
|
|
370
|
+
return Object.fromEntries(keys.map((key) => [key, baseValue(key, values)])) as ClmOverrides;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** `current` with `change` applied and every key equal to the base dropped, so "changed" means changed. */
|
|
374
|
+
export function mergeOverrides(base: SettingsValues, current: ClmOverrides, change: ClmOverrides): ClmOverrides {
|
|
375
|
+
const next: Record<string, unknown> = { ...current, ...change };
|
|
376
|
+
for (const key of Object.keys(next) as Array<keyof ClmOverrides>) {
|
|
377
|
+
if (next[key] === undefined || JSON.stringify(next[key]) === JSON.stringify(baseValue(key, base))) delete next[key];
|
|
378
|
+
}
|
|
379
|
+
return next as ClmOverrides;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const isNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
383
|
+
const isPath = (value: unknown) => value === null || (typeof value === "string" && value.trim() !== "");
|
|
384
|
+
|
|
385
|
+
const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
|
|
386
|
+
editing: (value) => typeof value === "boolean",
|
|
387
|
+
// Integers: state.json's budget check stores them as counts.
|
|
388
|
+
budget: (value) => value === null || (Number.isInteger(value) && (value as number) > 0),
|
|
389
|
+
reserve: (value) => Number.isInteger(value) && (value as number) >= 0,
|
|
390
|
+
reminders: (value) => Array.isArray(value) && value.every((fraction) => isNumber(fraction) && fraction > 0 && fraction < 1),
|
|
391
|
+
gate: (value) => value === "fit" || value === "shrink" || value === "none",
|
|
392
|
+
guard: (value) => value === "withhold" || value === "off",
|
|
393
|
+
compaction: (value) => value === "auto" || value === "off" || value === "on",
|
|
394
|
+
oneTool: (value) => typeof value === "boolean",
|
|
395
|
+
trailer: (value) => typeof value === "boolean",
|
|
396
|
+
cap: (value) => value === null || (isNumber(value) && value >= MIN_OBSERVATION_CAP),
|
|
397
|
+
capHead: (value) => isNumber(value) && value > 0 && value <= 1,
|
|
398
|
+
steering: isPath,
|
|
399
|
+
compactPrompt: isPath,
|
|
400
|
+
reasoning: (value) => typeof value === "boolean",
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The well-formed part of stored overrides and the names of what was dropped. A hand edit or
|
|
405
|
+
* a file from another version must never break a session.
|
|
406
|
+
*/
|
|
407
|
+
export function sanitizeOverrides(raw: unknown): { overrides: ClmOverrides; ignored: string[] } {
|
|
408
|
+
if (raw === undefined || raw === null) return { overrides: {}, ignored: [] };
|
|
409
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return { overrides: {}, ignored: ["overrides"] };
|
|
410
|
+
const overrides: Record<string, unknown> = {};
|
|
411
|
+
const ignored: string[] = [];
|
|
412
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
413
|
+
if (value === undefined) continue;
|
|
414
|
+
if (Object.hasOwn(VALID, key) && VALID[key as keyof ClmOverrides](value)) overrides[key] = value;
|
|
415
|
+
else ignored.push(key);
|
|
416
|
+
}
|
|
417
|
+
return { overrides: overrides as ClmOverrides, ignored };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Keys whose value in `effective` differs from `base`. */
|
|
421
|
+
export function changedSettings(base: SettingsValues, effective: SettingsValues): SettingKey[] {
|
|
422
|
+
return SETTINGS_TABLE.filter((item) =>
|
|
423
|
+
item.format(base) !== item.format(effective) ||
|
|
424
|
+
(item.key === "steering" && base.settings.steeringPath !== effective.settings.steeringPath) ||
|
|
425
|
+
(item.key === "compactPrompt" && base.settings.compactPromptPath !== effective.settings.compactPromptPath),
|
|
426
|
+
).map((item) => item.key);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** `budget 20k, guard off` for status text; empty when nothing changed. */
|
|
430
|
+
export function changedSummary(base: SettingsValues, effective: SettingsValues, context?: FormatContext): string {
|
|
431
|
+
return changedSettings(base, effective)
|
|
432
|
+
.map((key) => SETTINGS_TABLE.find((item) => item.key === key)!)
|
|
433
|
+
.map((item) => `${item.name} ${item.format(effective, context)}`)
|
|
434
|
+
.join(", ");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** `Budget: 20k — <description>`, the answer to `/clm config <setting>`. */
|
|
438
|
+
export function describeSetting(item: SettingDescriptor, values: SettingsValues, context?: FormatContext): string {
|
|
439
|
+
return `${item.label}: ${item.format(values, context)} — ${item.description}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Every setting with its value, for `/clm config` outside the TUI. */
|
|
443
|
+
export function settingsText(base: SettingsValues, effective: SettingsValues, context?: FormatContext, warning?: string): string {
|
|
444
|
+
const changed = new Set(changedSettings(base, effective));
|
|
445
|
+
const rows = SETTINGS_TABLE.map((item) =>
|
|
446
|
+
`${item.label.padEnd(16)} ${item.format(effective, context)}${changed.has(item.key) ? ` (changed; default ${item.format(base, context)})` : ""}`);
|
|
447
|
+
return [
|
|
448
|
+
"CLM settings for this session (changes apply from the next request):",
|
|
449
|
+
...rows,
|
|
450
|
+
...(warning ? [`warning: ${warning}`] : []),
|
|
451
|
+
"",
|
|
452
|
+
`Change with /clm config <${SETTING_NAMES.join("|")}> <value>, or /clm config reset.`,
|
|
453
|
+
].join("\n");
|
|
454
|
+
}
|
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,24 @@ 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
|
+
|
|
52
|
+
export type CompactionMode = "auto" | "off" | "on";
|
|
53
|
+
|
|
54
|
+
export function parseCompaction(value: unknown, name = "compaction"): CompactionMode {
|
|
55
|
+
const text = String(value).trim().toLowerCase();
|
|
56
|
+
if (text === "auto" || text === "off" || text === "on") return text;
|
|
57
|
+
throw new Error(`${name} must be auto, off or on, got ${JSON.stringify(value)}`);
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
export const PACKAGE_DIR = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
@@ -55,6 +72,11 @@ function pick(options: Record<string, unknown>, key: string, env: Env, envKey: s
|
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
/** `32000`, `32k`, `1.5m`, `32_000`, `32,000`. */
|
|
75
|
+
/** The default `mirrorDir`: `<project>/.opencode/clm`, inside the project, so the model's tools reach it without an external-directory permission. */
|
|
76
|
+
export function defaultMirrorDir(directory: string): string {
|
|
77
|
+
return join(directory, ".opencode", "clm");
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
export function parseTokens(value: unknown, name: string): number {
|
|
59
81
|
const text = String(value).trim().toLowerCase().replace(/[_,]/g, "");
|
|
60
82
|
const match = /^(\d+(?:\.\d+)?)(k|m)?$/.exec(text);
|
|
@@ -63,7 +85,7 @@ export function parseTokens(value: unknown, name: string): number {
|
|
|
63
85
|
return Math.round(Number(match[1]) * scale);
|
|
64
86
|
}
|
|
65
87
|
|
|
66
|
-
function parseFlag(value: unknown, name: string): boolean {
|
|
88
|
+
export function parseFlag(value: unknown, name: string): boolean {
|
|
67
89
|
if (typeof value === "boolean") return value;
|
|
68
90
|
const key = String(value).trim().toLowerCase();
|
|
69
91
|
if (["1", "true", "on", "yes"].includes(key)) return true;
|
|
@@ -71,7 +93,7 @@ function parseFlag(value: unknown, name: string): boolean {
|
|
|
71
93
|
throw new Error(`${name} must be true or false, got ${JSON.stringify(value)}`);
|
|
72
94
|
}
|
|
73
95
|
|
|
74
|
-
function parseFractions(value: unknown, name: string): number[] | "off" {
|
|
96
|
+
export function parseFractions(value: unknown, name: string): number[] | "off" {
|
|
75
97
|
if (value === false) return "off";
|
|
76
98
|
const parts: unknown[] = Array.isArray(value)
|
|
77
99
|
? value
|
|
@@ -119,6 +141,9 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
|
|
|
119
141
|
const factorRaw = pick(options, "estimateFactor", env, "CLM_ESTIMATE_FACTOR");
|
|
120
142
|
const reasoningRaw = pick(options, "reasoning", env, "CLM_REASONING");
|
|
121
143
|
const dumpRaw = pick(options, "dumpRequests", env, "CLM_DUMP_REQUESTS");
|
|
144
|
+
const compactionRaw = pick(options, "compaction", env, "CLM_NATIVE_COMPACTION");
|
|
145
|
+
const oneToolRaw = pick(options, "oneTool", env, "CLM_ONE_TOOL_PER_TURN");
|
|
146
|
+
const trailerRaw = pick(options, "trailer", env, "CLM_SIZE_TRAILER");
|
|
122
147
|
|
|
123
148
|
const overrides: Partial<BudgetPolicyConfig> = {};
|
|
124
149
|
if (budgetRaw !== undefined) {
|
|
@@ -177,7 +202,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
|
|
|
177
202
|
gate: gateRaw === undefined ? "fit" : withName("gate (CLM_EDIT_GATE)", () => toEditGate(gateRaw)),
|
|
178
203
|
guard,
|
|
179
204
|
observationCap,
|
|
180
|
-
mirrorDir: dirRaw === undefined ?
|
|
205
|
+
mirrorDir: dirRaw === undefined ? defaultMirrorDir(directory) : resolve(directory, String(dirRaw)),
|
|
181
206
|
steeringPath,
|
|
182
207
|
compactPromptPath,
|
|
183
208
|
estimateFactor,
|
|
@@ -185,5 +210,8 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
|
|
|
185
210
|
skill: options.skill === undefined ? true : parseFlag(options.skill, "skill"),
|
|
186
211
|
commands: options.commands === undefined ? true : parseFlag(options.commands, "commands"),
|
|
187
212
|
dumpRequests: dumpRaw === undefined ? false : parseFlag(dumpRaw, "dumpRequests"),
|
|
213
|
+
compaction: compactionRaw === undefined ? "auto" : parseCompaction(compactionRaw),
|
|
214
|
+
oneTool: oneToolRaw === undefined ? false : parseFlag(oneToolRaw, "oneTool"),
|
|
215
|
+
trailer: trailerRaw === undefined ? false : parseFlag(trailerRaw, "trailer"),
|
|
188
216
|
};
|
|
189
217
|
}
|
|
@@ -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
|
+
}
|