opencode-clm 0.1.0 → 0.2.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.
@@ -0,0 +1,184 @@
1
+ // Read-only access to one session's CLM directory (`<mirrorDir>/clm-<session>/`) for the
2
+ // `/clm` panel: events.jsonl, state.json, snapshot.json and revisions/rN.{json,md}. Missing
3
+ // files are normal (a v0.1 session has no snapshot or rN.json); torn or corrupt lines and
4
+ // files are skipped, never thrown. Written for this package.
5
+
6
+ import { readdir, readFile, stat } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+
9
+ import { safeSessionId } from "./mirror-store.ts";
10
+ import {
11
+ EVENTS_FILE,
12
+ MIRROR_FILE,
13
+ REVISIONS_DIRECTORY,
14
+ SNAPSHOT_FILE,
15
+ type ClmEvent,
16
+ type SessionFiles,
17
+ } from "./panel/files.ts";
18
+ import { STATE_FILE } from "./state.ts";
19
+
20
+ /** `<mirrorDir>/clm-<safe session id>`, the directory MirrorStore creates for the session. */
21
+ export function sessionDirectory(mirrorDir: string, sessionID: string): string {
22
+ return join(mirrorDir, `clm-${safeSessionId(sessionID)}`);
23
+ }
24
+
25
+ /**
26
+ * Parses events.jsonl text. Blank lines are ignored; lines that are not a JSON object (a
27
+ * torn final line from a concurrent append, or corruption) are counted and skipped.
28
+ */
29
+ export function parseEvents(text: string): { events: ClmEvent[]; skipped: number } {
30
+ const events: ClmEvent[] = [];
31
+ let skipped = 0;
32
+ const lines = text.split("\n");
33
+ lines.forEach((line, index) => {
34
+ if (line.trim() === "") return;
35
+ // An unterminated last line is usually an append in progress: parse it if it is
36
+ // whole, but do not count it as unreadable.
37
+ const unterminated = index === lines.length - 1;
38
+ try {
39
+ const value: unknown = JSON.parse(line);
40
+ if (value && typeof value === "object" && !Array.isArray(value)) events.push(value as ClmEvent);
41
+ else if (!unterminated) skipped++;
42
+ } catch {
43
+ if (!unterminated) skipped++;
44
+ }
45
+ });
46
+ return { events, skipped };
47
+ }
48
+
49
+ /**
50
+ * Revision files keyed by path, kept while their mtime and size are unchanged, so a reload
51
+ * on every streamed message reads only new or changed revisions. rN files are written once.
52
+ */
53
+ const revisionCache = new Map<string, { mtimeMs: number; size: number; value: unknown }>();
54
+ const REVISION_CACHE_LIMIT = 2_000;
55
+ /** Total file bytes kept; the oldest entries go first once it is exceeded. */
56
+ const REVISION_CACHE_BYTES = 32 * 1024 * 1024;
57
+ let revisionCacheBytes = 0;
58
+
59
+ function evictRevision(): void {
60
+ const [path, entry] = revisionCache.entries().next().value!;
61
+ revisionCache.delete(path);
62
+ revisionCacheBytes -= entry.size;
63
+ }
64
+
65
+ async function readCached(path: string, kind: "json" | "md", warnings: string[]): Promise<unknown> {
66
+ let info;
67
+ try {
68
+ info = await stat(path);
69
+ } catch (error) {
70
+ if (!isMissing(error)) warnings.push(`Could not read ${path}: ${describe(error)}`);
71
+ return undefined;
72
+ }
73
+ const cached = revisionCache.get(path);
74
+ if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.value;
75
+ const value = kind === "json" ? await readJson(path, warnings) : await readText(path, warnings);
76
+ if (value !== undefined) {
77
+ if (cached) {
78
+ revisionCache.delete(path);
79
+ revisionCacheBytes -= cached.size;
80
+ }
81
+ while (revisionCache.size > 0 && (revisionCache.size >= REVISION_CACHE_LIMIT || revisionCacheBytes + info.size > REVISION_CACHE_BYTES)) evictRevision();
82
+ revisionCache.set(path, { mtimeMs: info.mtimeMs, size: info.size, value });
83
+ revisionCacheBytes += info.size;
84
+ }
85
+ return value;
86
+ }
87
+
88
+ function isRevisionFile(value: unknown): boolean {
89
+ return Boolean(value) && typeof value === "object" && (value as { version?: unknown }).version === 1 &&
90
+ Array.isArray((value as { rows?: unknown }).rows);
91
+ }
92
+
93
+ function isMissing(error: unknown): boolean {
94
+ const code = (error as NodeJS.ErrnoException)?.code;
95
+ return code === "ENOENT" || code === "ENOTDIR";
96
+ }
97
+
98
+ function describe(error: unknown): string {
99
+ return error instanceof Error ? error.message : String(error);
100
+ }
101
+
102
+ async function readText(path: string, warnings: string[]): Promise<string | undefined> {
103
+ try {
104
+ return await readFile(path, "utf8");
105
+ } catch (error) {
106
+ if (!isMissing(error)) warnings.push(`Could not read ${path}: ${describe(error)}`);
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ async function readJson(path: string, warnings: string[]): Promise<unknown> {
112
+ const text = await readText(path, warnings);
113
+ if (text === undefined) return undefined;
114
+ try {
115
+ return JSON.parse(text);
116
+ } catch (error) {
117
+ warnings.push(`${path} is not valid JSON: ${describe(error)}`);
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ /** Reads the session directory of `sessionID` under `mirrorDir`. */
123
+ export function readSessionFiles(mirrorDir: string, sessionID: string): Promise<SessionFiles> {
124
+ return readSessionDirectory(sessionDirectory(mirrorDir, sessionID), sessionID);
125
+ }
126
+
127
+ /** Reads a session directory given its path. Never throws for missing or corrupt files. */
128
+ export async function readSessionDirectory(directory: string, sessionID: string): Promise<SessionFiles> {
129
+ const warnings: string[] = [];
130
+ const files: SessionFiles = {
131
+ sessionID,
132
+ directory,
133
+ found: false,
134
+ events: [],
135
+ skippedEventLines: 0,
136
+ revisions: new Map(),
137
+ revisionTexts: new Map(),
138
+ mirrorPath: join(directory, MIRROR_FILE),
139
+ warnings,
140
+ };
141
+ try {
142
+ files.found = (await stat(directory)).isDirectory();
143
+ } catch (error) {
144
+ if (!isMissing(error)) warnings.push(`Could not read ${directory}: ${describe(error)}`);
145
+ }
146
+ if (!files.found) return files;
147
+
148
+ const [eventsText, state, snapshot] = await Promise.all([
149
+ readText(join(directory, EVENTS_FILE), warnings),
150
+ readJson(join(directory, STATE_FILE), warnings),
151
+ readJson(join(directory, SNAPSHOT_FILE), warnings),
152
+ ]);
153
+ if (eventsText !== undefined) {
154
+ const parsed = parseEvents(eventsText);
155
+ files.events = parsed.events;
156
+ files.skippedEventLines = parsed.skipped;
157
+ }
158
+ if (state !== undefined) files.state = state;
159
+ if (snapshot !== undefined) files.snapshot = snapshot;
160
+
161
+ const revisionsDirectory = join(directory, REVISIONS_DIRECTORY);
162
+ let names: string[] = [];
163
+ try {
164
+ names = await readdir(revisionsDirectory);
165
+ } catch (error) {
166
+ if (!isMissing(error)) warnings.push(`Could not list ${revisionsDirectory}: ${describe(error)}`);
167
+ }
168
+ const jsonRevisions = new Set<number>();
169
+ const mdRevisions = new Set<number>();
170
+ for (const name of names) {
171
+ const match = /^r(\d+)\.(json|md)$/.exec(name);
172
+ if (match) (match[2] === "json" ? jsonRevisions : mdRevisions).add(Number(match[1]));
173
+ }
174
+ await Promise.all([...jsonRevisions].map(async (revision) => {
175
+ const value = await readCached(join(revisionsDirectory, `r${revision}.json`), "json", warnings);
176
+ if (value !== undefined) files.revisions.set(revision, value);
177
+ }));
178
+ // rN.md is only shown when rN.json is missing or invalid.
179
+ await Promise.all([...mdRevisions].filter((revision) => !isRevisionFile(files.revisions.get(revision))).map(async (revision) => {
180
+ const text = await readCached(join(revisionsDirectory, `r${revision}.md`), "md", warnings);
181
+ if (typeof text === "string") files.revisionTexts.set(revision, text);
182
+ }));
183
+ return files;
184
+ }
@@ -0,0 +1,406 @@
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, parseFlag, parseFractions, parseTokens, type ClmSettings } from "./settings.ts";
17
+
18
+ export type SettingKey =
19
+ | "editing" | "budget" | "reserve" | "reminders" | "gate" | "guard" | "cap" | "steering" | "compactPrompt" | "reasoning";
20
+
21
+ /**
22
+ * Per-session changes, as stored in overrides.json. Only keys that differ from the base are
23
+ * kept. `null` means "explicitly unset" for optional settings: budget → model window, cap →
24
+ * off, steering → none, compactPrompt → built-in prompt.
25
+ */
26
+ export interface ClmOverrides {
27
+ editing?: boolean;
28
+ budget?: number | null;
29
+ reserve?: number;
30
+ /** Reminder fractions; empty disables every reminder, the budget − reserve one included. */
31
+ reminders?: number[];
32
+ gate?: EditGate;
33
+ guard?: "withhold" | "off";
34
+ cap?: number | null;
35
+ capHead?: number;
36
+ /** Absolute path. */
37
+ steering?: string | null;
38
+ /** Absolute path. */
39
+ compactPrompt?: string | null;
40
+ reasoning?: boolean;
41
+ }
42
+
43
+ /** Settings in force for one session: the resolved settings plus whether edits apply. */
44
+ export interface SettingsValues {
45
+ editing: boolean;
46
+ settings: ClmSettings;
47
+ }
48
+
49
+ export interface FormatContext {
50
+ /** Model context window, shown next to a `window` budget. */
51
+ modelWindow?: number;
52
+ }
53
+
54
+ export interface ParseContext {
55
+ /** Relative paths resolve against this directory (the project). */
56
+ directory: string;
57
+ }
58
+
59
+ export interface SettingDescriptor {
60
+ key: SettingKey;
61
+ /** The word typed in `/clm config <name>`. */
62
+ name: string;
63
+ label: string;
64
+ /** What it does; ends with the environment variable that sets the default. */
65
+ description: string;
66
+ /** Values Enter cycles through on the settings page; absent → text prompt. */
67
+ choices?: string[];
68
+ placeholder?: string;
69
+ format(values: SettingsValues, context?: FormatContext): string;
70
+ /** Text → override; throws a user-facing message. */
71
+ parse(text: string, context: ParseContext): ClmOverrides;
72
+ }
73
+
74
+ /** `32000` → `32k`, `1500000` → `1.5m`, `2048` → `2,048`. */
75
+ export function compactTokens(value: number): string {
76
+ if (value >= 1_000_000 && value % 10_000 === 0) return `${Number((value / 1_000_000).toFixed(2))}m`;
77
+ if (value >= 1_000 && value % 100 === 0) return `${Number((value / 1_000).toFixed(1))}k`;
78
+ return value.toLocaleString("en-US");
79
+ }
80
+
81
+ const onOff = (value: boolean) => (value ? "on" : "off");
82
+
83
+ function formatReminders(fractions: readonly number[]): string {
84
+ return fractions.length === 0 ? "off" : `${fractions.map((fraction) => Math.round(fraction * 100)).join("/")}%`;
85
+ }
86
+
87
+ function parseReminders(text: string): number[] {
88
+ const parsed = parseFractions(text, "reminders");
89
+ if (parsed === "off") return [];
90
+ if (parsed.some((fraction) => fraction <= 0 || fraction >= 1)) {
91
+ throw new Error(`reminders must be percentages between 0 and 100 like 25/50/75, or off; got "${text}"`);
92
+ }
93
+ return [...parsed].sort((left, right) => left - right);
94
+ }
95
+
96
+ function formatCap(settings: ClmSettings): string {
97
+ const cap = settings.observationCap;
98
+ if (cap.maxCharacters === undefined) return "off";
99
+ const head = Math.round(cap.headFraction * 100);
100
+ return `${compactTokens(cap.maxCharacters)} chars${head === 80 ? "" : ` (${head}% head)`}`;
101
+ }
102
+
103
+ function parseCap(text: string): ClmOverrides {
104
+ const value = text.trim().toLowerCase().replace(/\s*chars?$/, "");
105
+ if (value === "off" || value === "0" || value === "none") return { cap: null };
106
+ const [chars = "", head, ...rest] = value.split(":");
107
+ if (rest.length > 0) throw new Error(`observation cap must be characters[:head fraction] like 10k or 10k:0.5, got "${text}"`);
108
+ const cap = parseTokens(chars, "observation cap");
109
+ if (cap < MIN_OBSERVATION_CAP) throw new Error(`observation cap must be at least ${MIN_OBSERVATION_CAP} characters, got "${text}"`);
110
+ if (head === undefined || head === "") return { cap };
111
+ const capHead = Number(head);
112
+ if (!(capHead > 0 && capHead <= 1)) throw new Error(`observation cap head fraction must be in (0, 1], got "${head}"`);
113
+ return { cap, capHead };
114
+ }
115
+
116
+ function parsePathSetting(text: string, directory: string, unset: string[]): string | null {
117
+ const value = text.trim();
118
+ if (unset.includes(value.toLowerCase())) return null;
119
+ return resolve(directory, value);
120
+ }
121
+
122
+ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
123
+ {
124
+ key: "editing",
125
+ name: "editing",
126
+ label: "CLM editing",
127
+ 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.",
128
+ choices: ["on", "off"],
129
+ // The plugin-wide `enabled: false` (CLM_ENABLED=0) wins over any session value.
130
+ format: (values) => (values.settings.enabled ? onOff(values.editing) : "off (plugin disabled)"),
131
+ parse: (text) => ({ editing: parseFlag(text, "editing") }),
132
+ },
133
+ {
134
+ key: "budget",
135
+ name: "budget",
136
+ 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",
139
+ format: (values, context) => {
140
+ const budget = values.settings.budget.contextBudget;
141
+ if (budget !== undefined) return compactTokens(budget);
142
+ return `window${context?.modelWindow ? ` (${compactTokens(context.modelWindow)})` : ""}`;
143
+ },
144
+ 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 };
150
+ },
151
+ },
152
+ {
153
+ key: "reserve",
154
+ name: "reserve",
155
+ label: "Reserve",
156
+ description: "Tokens kept free below the budget for the reply; the final reminder and the overflow guard act at budget − reserve. Env: CLM_RESERVE.",
157
+ placeholder: "2048 or 2k",
158
+ format: (values) => compactTokens(values.settings.budget.reserve),
159
+ parse: (text) => ({ reserve: parseTokens(text, "reserve") }),
160
+ },
161
+ {
162
+ key: "reminders",
163
+ name: "reminders",
164
+ label: "Reminders",
165
+ 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.",
166
+ choices: ["25/50/75%", "50/75/90%", "75/90%", "90%", "off"],
167
+ format: (values) => formatReminders(values.settings.budget.remindAtFractions),
168
+ parse: (text) => ({ reminders: parseReminders(text) }),
169
+ },
170
+ {
171
+ key: "gate",
172
+ name: "gate",
173
+ label: "Edit gate",
174
+ 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.",
175
+ choices: ["fit", "shrink", "none"],
176
+ format: (values) => values.settings.gate,
177
+ parse: (text) => ({ gate: toEditGate(text) }),
178
+ },
179
+ {
180
+ key: "guard",
181
+ name: "guard",
182
+ label: "Overflow guard",
183
+ description: "Above budget − reserve, hold back the oldest new tool results (full text saved to files) so the request fits. Env: CLM_OVERFLOW.",
184
+ choices: ["on", "off"],
185
+ format: (values) => onOff(values.settings.guard === "withhold"),
186
+ parse: (text) => ({ guard: parseFlag(text.trim().toLowerCase() === "withhold" ? "on" : text, "guard") ? "withhold" : "off" }),
187
+ },
188
+ {
189
+ key: "cap",
190
+ name: "cap",
191
+ label: "Observation cap",
192
+ 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.",
193
+ // Written as the format shows them, so cycling finds the current value.
194
+ choices: ["off", "5k chars", "10k chars", "20k chars", "50k chars"],
195
+ placeholder: "10k or 10k:0.5",
196
+ format: (values) => formatCap(values.settings),
197
+ parse: parseCap,
198
+ },
199
+ {
200
+ key: "steering",
201
+ name: "steering",
202
+ label: "Steering",
203
+ description: "Markdown guidance appended to the system prompt: a path, \"house\" for the bundled brief, or none. Env: CLM_STEERING.",
204
+ choices: ["none", "house-brief.md"],
205
+ placeholder: "path/to/brief.md, house, or none",
206
+ format: (values) => (values.settings.steeringPath ? basename(values.settings.steeringPath) : "none"),
207
+ parse: (text, context) => {
208
+ const value = text.trim().toLowerCase();
209
+ if (["house", "house-brief", "house-brief.md"].includes(value)) return { steering: HOUSE_STEERING };
210
+ return { steering: parsePathSetting(text, context.directory, ["none", "off", ""]) };
211
+ },
212
+ },
213
+ {
214
+ key: "compactPrompt",
215
+ name: "compact-prompt",
216
+ label: "Compact prompt",
217
+ 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.",
218
+ placeholder: "path/to/prompt.md, or default",
219
+ format: (values) => (values.settings.compactPromptPath ? basename(values.settings.compactPromptPath) : "default"),
220
+ parse: (text, context) => ({ compactPrompt: parsePathSetting(text, context.directory, ["default", "none", "off", ""]) }),
221
+ },
222
+ {
223
+ key: "reasoning",
224
+ name: "reasoning",
225
+ label: "Reasoning",
226
+ description: "Show the assistant's reasoning in the mirror. Env: CLM_REASONING.",
227
+ choices: ["on", "off"],
228
+ format: (values) => onOff(values.settings.reasoning),
229
+ parse: (text) => ({ reasoning: parseFlag(text, "reasoning") }),
230
+ },
231
+ ];
232
+
233
+ const ALIASES: Record<string, SettingKey> = {
234
+ editing: "editing",
235
+ enabled: "editing",
236
+ budget: "budget",
237
+ reserve: "reserve",
238
+ reminders: "reminders",
239
+ remind: "reminders",
240
+ "remind-at": "reminders",
241
+ remindat: "reminders",
242
+ gate: "gate",
243
+ "edit-gate": "gate",
244
+ guard: "guard",
245
+ overflow: "guard",
246
+ cap: "cap",
247
+ observation: "cap",
248
+ "observation-cap": "cap",
249
+ observationcap: "cap",
250
+ steering: "steering",
251
+ "compact-prompt": "compactPrompt",
252
+ compactprompt: "compactPrompt",
253
+ reasoning: "reasoning",
254
+ };
255
+
256
+ /** Names `/clm config` lists. */
257
+ export const SETTING_NAMES: readonly string[] = SETTINGS_TABLE.map((item) => item.name);
258
+
259
+ /** The descriptor for a typed name (case-insensitive, aliases accepted). */
260
+ export function settingDescriptor(name: string): SettingDescriptor | undefined {
261
+ const key = ALIASES[name.trim().toLowerCase()];
262
+ return key ? SETTINGS_TABLE.find((item) => item.key === key) : undefined;
263
+ }
264
+
265
+ export function unknownSettingText(name: string): string {
266
+ return `Unknown setting "${name}". Settings: ${SETTING_NAMES.join(", ")}.`;
267
+ }
268
+
269
+ /**
270
+ * Base settings with overrides applied. Throws when the result is invalid (the same checks
271
+ * as plugin options). Reminders changed here also switch the budget − reserve reminder on or
272
+ * off with them, as `remindAt: off` does.
273
+ */
274
+ export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmSettings {
275
+ const budget = resolveBudgetPolicy({
276
+ ...base.budget,
277
+ contextBudget: overrides.budget === null ? undefined : overrides.budget ?? base.budget.contextBudget,
278
+ reserve: overrides.reserve ?? base.budget.reserve,
279
+ remindAtFractions: overrides.reminders ?? base.budget.remindAtFractions,
280
+ remindAtReserve: overrides.reminders !== undefined ? overrides.reminders.length > 0 : base.budget.remindAtReserve,
281
+ });
282
+ const observationCap = resolveObservationCap({
283
+ maxCharacters: overrides.cap === null ? undefined : overrides.cap ?? base.observationCap.maxCharacters,
284
+ headFraction: overrides.capHead ?? base.observationCap.headFraction,
285
+ });
286
+ return {
287
+ ...base,
288
+ budget,
289
+ gate: overrides.gate ?? base.gate,
290
+ guard: overrides.guard ?? base.guard,
291
+ observationCap,
292
+ steeringPath: overrides.steering === null ? undefined : overrides.steering ?? base.steeringPath,
293
+ compactPromptPath: overrides.compactPrompt === null ? undefined : overrides.compactPrompt ?? base.compactPromptPath,
294
+ reasoning: overrides.reasoning ?? base.reasoning,
295
+ };
296
+ }
297
+
298
+ /** The override-shaped value of each key in `values` (null for an unset optional). */
299
+ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
300
+ const settings = values.settings;
301
+ switch (key) {
302
+ case "editing": return values.editing;
303
+ case "budget": return settings.budget.contextBudget ?? null;
304
+ case "reserve": return settings.budget.reserve;
305
+ case "reminders": return settings.budget.remindAtReserve || settings.budget.remindAtFractions.length > 0 ? [...settings.budget.remindAtFractions] : [];
306
+ case "gate": return settings.gate;
307
+ case "guard": return settings.guard;
308
+ case "cap": return settings.observationCap.maxCharacters ?? null;
309
+ case "capHead": return settings.observationCap.headFraction;
310
+ case "steering": return settings.steeringPath ?? null;
311
+ case "compactPrompt": return settings.compactPromptPath ?? null;
312
+ case "reasoning": return settings.reasoning;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Every table setting of `settings` in override form (editing excluded: its base is
318
+ * state.json). The server writes this into snapshot.json, so a TUI whose environment
319
+ * resolves differently validates and merges against the server's base:
320
+ * `applyOverrides(ownBase, serverBase)` reproduces it.
321
+ */
322
+ export function settingsAsOverrides(settings: ClmSettings): ClmOverrides {
323
+ const values: SettingsValues = { editing: true, settings };
324
+ const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "gate", "guard", "cap", "capHead", "steering", "compactPrompt", "reasoning"];
325
+ return Object.fromEntries(keys.map((key) => [key, baseValue(key, values)])) as ClmOverrides;
326
+ }
327
+
328
+ /** `current` with `change` applied and every key equal to the base dropped, so "changed" means changed. */
329
+ export function mergeOverrides(base: SettingsValues, current: ClmOverrides, change: ClmOverrides): ClmOverrides {
330
+ const next: Record<string, unknown> = { ...current, ...change };
331
+ for (const key of Object.keys(next) as Array<keyof ClmOverrides>) {
332
+ if (next[key] === undefined || JSON.stringify(next[key]) === JSON.stringify(baseValue(key, base))) delete next[key];
333
+ }
334
+ return next as ClmOverrides;
335
+ }
336
+
337
+ const isNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
338
+ const isPath = (value: unknown) => value === null || (typeof value === "string" && value.trim() !== "");
339
+
340
+ const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
341
+ editing: (value) => typeof value === "boolean",
342
+ // Integers: state.json's budget check stores them as counts.
343
+ budget: (value) => value === null || (Number.isInteger(value) && (value as number) > 0),
344
+ reserve: (value) => Number.isInteger(value) && (value as number) >= 0,
345
+ reminders: (value) => Array.isArray(value) && value.every((fraction) => isNumber(fraction) && fraction > 0 && fraction < 1),
346
+ gate: (value) => value === "fit" || value === "shrink" || value === "none",
347
+ guard: (value) => value === "withhold" || value === "off",
348
+ cap: (value) => value === null || (isNumber(value) && value >= MIN_OBSERVATION_CAP),
349
+ capHead: (value) => isNumber(value) && value > 0 && value <= 1,
350
+ steering: isPath,
351
+ compactPrompt: isPath,
352
+ reasoning: (value) => typeof value === "boolean",
353
+ };
354
+
355
+ /**
356
+ * The well-formed part of stored overrides and the names of what was dropped. A hand edit or
357
+ * a file from another version must never break a session.
358
+ */
359
+ export function sanitizeOverrides(raw: unknown): { overrides: ClmOverrides; ignored: string[] } {
360
+ if (raw === undefined || raw === null) return { overrides: {}, ignored: [] };
361
+ if (typeof raw !== "object" || Array.isArray(raw)) return { overrides: {}, ignored: ["overrides"] };
362
+ const overrides: Record<string, unknown> = {};
363
+ const ignored: string[] = [];
364
+ for (const [key, value] of Object.entries(raw)) {
365
+ if (value === undefined) continue;
366
+ if (Object.hasOwn(VALID, key) && VALID[key as keyof ClmOverrides](value)) overrides[key] = value;
367
+ else ignored.push(key);
368
+ }
369
+ return { overrides: overrides as ClmOverrides, ignored };
370
+ }
371
+
372
+ /** Keys whose value in `effective` differs from `base`. */
373
+ export function changedSettings(base: SettingsValues, effective: SettingsValues): SettingKey[] {
374
+ return SETTINGS_TABLE.filter((item) =>
375
+ item.format(base) !== item.format(effective) ||
376
+ (item.key === "steering" && base.settings.steeringPath !== effective.settings.steeringPath) ||
377
+ (item.key === "compactPrompt" && base.settings.compactPromptPath !== effective.settings.compactPromptPath),
378
+ ).map((item) => item.key);
379
+ }
380
+
381
+ /** `budget 20k, guard off` for status text; empty when nothing changed. */
382
+ export function changedSummary(base: SettingsValues, effective: SettingsValues, context?: FormatContext): string {
383
+ return changedSettings(base, effective)
384
+ .map((key) => SETTINGS_TABLE.find((item) => item.key === key)!)
385
+ .map((item) => `${item.name} ${item.format(effective, context)}`)
386
+ .join(", ");
387
+ }
388
+
389
+ /** `Budget: 20k — <description>`, the answer to `/clm config <setting>`. */
390
+ export function describeSetting(item: SettingDescriptor, values: SettingsValues, context?: FormatContext): string {
391
+ return `${item.label}: ${item.format(values, context)} — ${item.description}`;
392
+ }
393
+
394
+ /** Every setting with its value, for `/clm config` outside the TUI. */
395
+ export function settingsText(base: SettingsValues, effective: SettingsValues, context?: FormatContext, warning?: string): string {
396
+ const changed = new Set(changedSettings(base, effective));
397
+ const rows = SETTINGS_TABLE.map((item) =>
398
+ `${item.label.padEnd(16)} ${item.format(effective, context)}${changed.has(item.key) ? ` (changed; default ${item.format(base, context)})` : ""}`);
399
+ return [
400
+ "CLM settings for this session (changes apply from the next request):",
401
+ ...rows,
402
+ ...(warning ? [`warning: ${warning}`] : []),
403
+ "",
404
+ `Change with /clm config <${SETTING_NAMES.join("|")}> <value>, or /clm config reset.`,
405
+ ].join("\n");
406
+ }
package/src/settings.ts CHANGED
@@ -63,7 +63,7 @@ export function parseTokens(value: unknown, name: string): number {
63
63
  return Math.round(Number(match[1]) * scale);
64
64
  }
65
65
 
66
- function parseFlag(value: unknown, name: string): boolean {
66
+ export function parseFlag(value: unknown, name: string): boolean {
67
67
  if (typeof value === "boolean") return value;
68
68
  const key = String(value).trim().toLowerCase();
69
69
  if (["1", "true", "on", "yes"].includes(key)) return true;
@@ -71,7 +71,7 @@ function parseFlag(value: unknown, name: string): boolean {
71
71
  throw new Error(`${name} must be true or false, got ${JSON.stringify(value)}`);
72
72
  }
73
73
 
74
- function parseFractions(value: unknown, name: string): number[] | "off" {
74
+ export function parseFractions(value: unknown, name: string): number[] | "off" {
75
75
  if (value === false) return "off";
76
76
  const parts: unknown[] = Array.isArray(value)
77
77
  ? value