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
package/src/overrides.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session settings changes on disk: `<session dir>/overrides.json` =
|
|
3
|
+
* `{ version: 1, overrides }`, holding only what differs from the base (the plugin options,
|
|
4
|
+
* environment and defaults; for `editing`, state.json's `enabled`). A separate file because
|
|
5
|
+
* state.json belongs to the server's save queue: the TUI writes this file and never state.json.
|
|
6
|
+
*
|
|
7
|
+
* Writers (the TUI panel, typed `/clm config` in the TUI, and the server's `/clm config`)
|
|
8
|
+
* all go through `changeSetting`: parse → merge → drop base-equal keys → stage (resolve the
|
|
9
|
+
* policies, load a steering document or compact prompt strictly) → write atomically. A
|
|
10
|
+
* change that fails any step is reported and not written. The server reads the file before
|
|
11
|
+
* each request (`ClmSession.refreshSettings`). Staging follows pi-clm src/index.ts
|
|
12
|
+
* `changeSetting` / `stageSettings` (MIT, Copyright 2026 Emanuel Casco); written for this package.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, stat } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { writeJsonAtomic } from "./atomic.ts";
|
|
18
|
+
import { loadCompactPrompt } from "./compact.ts";
|
|
19
|
+
import {
|
|
20
|
+
applyOverrides,
|
|
21
|
+
describeSetting,
|
|
22
|
+
mergeOverrides,
|
|
23
|
+
sanitizeOverrides,
|
|
24
|
+
settingDescriptor,
|
|
25
|
+
unknownSettingText,
|
|
26
|
+
type ClmOverrides,
|
|
27
|
+
type FormatContext,
|
|
28
|
+
type SettingsValues,
|
|
29
|
+
} from "./settings-table.ts";
|
|
30
|
+
import type { ClmSettings } from "./settings.ts";
|
|
31
|
+
import { loadSteeringDocument, type SteeringDocument } from "./steering.ts";
|
|
32
|
+
|
|
33
|
+
export const OVERRIDES_FILE = "overrides.json";
|
|
34
|
+
export const OVERRIDES_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
export interface OverridesRead {
|
|
37
|
+
overrides: ClmOverrides;
|
|
38
|
+
/** Keys dropped as invalid, or a description of why the whole file was unusable. */
|
|
39
|
+
warning?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function overridesPath(sessionDirectory: string): string {
|
|
43
|
+
return join(sessionDirectory, OVERRIDES_FILE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reads and sanitizes overrides.json. Missing → no overrides. Never throws. */
|
|
47
|
+
export async function readOverrides(sessionDirectory: string): Promise<OverridesRead> {
|
|
48
|
+
const path = overridesPath(sessionDirectory);
|
|
49
|
+
let text: string;
|
|
50
|
+
try {
|
|
51
|
+
text = await readFile(path, "utf8");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { overrides: {} };
|
|
54
|
+
return { overrides: {}, warning: `could not read ${path}: ${describe(error)}` };
|
|
55
|
+
}
|
|
56
|
+
return parseOverridesText(text, path);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseOverridesText(text: string, path = OVERRIDES_FILE): OverridesRead {
|
|
60
|
+
let parsed: unknown;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(text);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { overrides: {}, warning: `${path} is not valid JSON (${describe(error)}); using the defaults` };
|
|
65
|
+
}
|
|
66
|
+
if (!parsed || typeof parsed !== "object" || (parsed as { version?: unknown }).version !== OVERRIDES_VERSION) {
|
|
67
|
+
return { overrides: {}, warning: `${path} has an unknown format; using the defaults` };
|
|
68
|
+
}
|
|
69
|
+
const { overrides, ignored } = sanitizeOverrides((parsed as { overrides?: unknown }).overrides);
|
|
70
|
+
return ignored.length > 0 ? { overrides, warning: `ignored invalid saved settings: ${ignored.join(", ")}` } : { overrides };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function writeOverrides(sessionDirectory: string, overrides: ClmOverrides): Promise<void> {
|
|
74
|
+
await writeJsonAtomic(overridesPath(sessionDirectory), { version: OVERRIDES_VERSION, overrides });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface StagedSettings {
|
|
78
|
+
settings: ClmSettings;
|
|
79
|
+
steering?: SteeringDocument;
|
|
80
|
+
/** Set when the steering document could not be loaded (non-strict staging only). */
|
|
81
|
+
steeringError?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolves base + overrides without activating anything. Strict (an interactive change):
|
|
86
|
+
* a steering document or compact prompt that does not load rejects the change. Not strict
|
|
87
|
+
* (reading the file before a request): a steering failure is recorded and the session runs
|
|
88
|
+
* protocol-only. `loaded` reuses an already loaded document for the same path.
|
|
89
|
+
*/
|
|
90
|
+
export function stageSettings(
|
|
91
|
+
base: ClmSettings,
|
|
92
|
+
overrides: ClmOverrides,
|
|
93
|
+
options: { strict: boolean; loaded?: SteeringDocument },
|
|
94
|
+
): StagedSettings {
|
|
95
|
+
const settings = applyOverrides(base, overrides);
|
|
96
|
+
const staged: StagedSettings = { settings };
|
|
97
|
+
const path = settings.steeringPath;
|
|
98
|
+
if (path) {
|
|
99
|
+
if (options.loaded && !options.strict && options.loaded.path === path) {
|
|
100
|
+
staged.steering = options.loaded;
|
|
101
|
+
} else {
|
|
102
|
+
try {
|
|
103
|
+
staged.steering = loadSteeringDocument(path);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (options.strict) throw new Error(`steering document not loaded: ${describe(error)}`);
|
|
106
|
+
staged.steeringError = describe(error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (options.strict && settings.compactPromptPath && settings.compactPromptPath !== base.compactPromptPath) {
|
|
111
|
+
loadCompactPrompt(settings.compactPromptPath);
|
|
112
|
+
}
|
|
113
|
+
return staged;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ChangeRequest {
|
|
117
|
+
/** The session directory (`<mirrorDir>/clm-<id>`). */
|
|
118
|
+
sessionDirectory: string;
|
|
119
|
+
/** Settings at load (options, environment, defaults). */
|
|
120
|
+
base: ClmSettings;
|
|
121
|
+
/** `editing` base: state.json's `enabled`. */
|
|
122
|
+
baseEditing: boolean;
|
|
123
|
+
/** Relative paths resolve against the project directory. */
|
|
124
|
+
projectDirectory: string;
|
|
125
|
+
format?: FormatContext;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ChangeResult {
|
|
129
|
+
overrides: ClmOverrides;
|
|
130
|
+
values: SettingsValues;
|
|
131
|
+
/** `Budget: 20k`. */
|
|
132
|
+
text: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function valuesOf(base: ClmSettings, baseEditing: boolean, overrides: ClmOverrides, settings?: ClmSettings): SettingsValues {
|
|
136
|
+
return { editing: overrides.editing ?? baseEditing, settings: settings ?? applyOverrides(base, overrides) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `/clm config <setting> <value>` and the panel's apply: validate, then write. Throws a
|
|
141
|
+
* user-facing message; nothing is written then.
|
|
142
|
+
*/
|
|
143
|
+
export async function changeSetting(request: ChangeRequest, name: string, text: string): Promise<ChangeResult> {
|
|
144
|
+
const descriptor = settingDescriptor(name);
|
|
145
|
+
if (!descriptor) throw new Error(unknownSettingText(name));
|
|
146
|
+
const parsed = descriptor.parse(text, { directory: request.projectDirectory });
|
|
147
|
+
const current = await readOverrides(request.sessionDirectory);
|
|
148
|
+
const baseValues = valuesOf(request.base, request.baseEditing, {}, request.base);
|
|
149
|
+
const overrides = mergeOverrides(baseValues, current.overrides, parsed);
|
|
150
|
+
const staged = stageSettings(request.base, overrides, { strict: true });
|
|
151
|
+
try {
|
|
152
|
+
await writeOverrides(request.sessionDirectory, overrides);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new Error(`Settings unchanged: saving ${overridesPath(request.sessionDirectory)} failed (${describe(error)}).`);
|
|
155
|
+
}
|
|
156
|
+
const values = valuesOf(request.base, request.baseEditing, overrides, staged.settings);
|
|
157
|
+
return { overrides, values, text: `${descriptor.label}: ${descriptor.format(values, request.format)}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `/clm config reset`: drop this session's changes. */
|
|
161
|
+
export async function resetSettings(sessionDirectory: string): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
await writeOverrides(sessionDirectory, {});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new Error(`Settings unchanged: saving ${overridesPath(sessionDirectory)} failed (${describe(error)}).`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Current values of one session, read from its overrides.json (non-strict, never throws for
|
|
171
|
+
* bad files). `mtimeMs`: when overrides.json was last written, if it exists.
|
|
172
|
+
*/
|
|
173
|
+
export async function sessionValues(sessionDirectory: string, base: ClmSettings, baseEditing: boolean): Promise<SessionValues> {
|
|
174
|
+
const read = await readOverrides(sessionDirectory);
|
|
175
|
+
const mtimeMs = await stat(overridesPath(sessionDirectory)).then((info) => info.mtimeMs, () => undefined);
|
|
176
|
+
return overridesValues(read, base, baseEditing, mtimeMs);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface SessionValues {
|
|
180
|
+
values: SettingsValues;
|
|
181
|
+
warning?: string;
|
|
182
|
+
overrides: ClmOverrides;
|
|
183
|
+
mtimeMs?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `sessionValues` for overrides already read (e.g. through the server's file API). */
|
|
187
|
+
export function overridesValues(read: OverridesRead, base: ClmSettings, baseEditing: boolean, mtimeMs?: number): SessionValues {
|
|
188
|
+
const at = mtimeMs !== undefined ? { mtimeMs } : {};
|
|
189
|
+
try {
|
|
190
|
+
return { values: valuesOf(base, baseEditing, read.overrides), overrides: read.overrides, ...(read.warning ? { warning: read.warning } : {}), ...at };
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { values: valuesOf(base, baseEditing, {}, base), overrides: {}, warning: `ignored saved settings: ${describe(error)}`, ...at };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `/clm config <setting>`: `Label: value — description`. */
|
|
197
|
+
export function showSetting(name: string, values: SettingsValues, format?: FormatContext): string {
|
|
198
|
+
const descriptor = settingDescriptor(name);
|
|
199
|
+
if (!descriptor) throw new Error(unknownSettingText(name));
|
|
200
|
+
return describeSetting(descriptor, values, format);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function describe(error: unknown): string {
|
|
204
|
+
return error instanceof Error ? error.message : String(error);
|
|
205
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Parses a typed `/clm …` line into what the TUI should do with it. Pure; written for
|
|
2
|
+
// this package (the subcommands follow pi-clm's `/clm` and `/clm config`).
|
|
3
|
+
|
|
4
|
+
export type Page = "overview" | "input" | "edits" | "settings";
|
|
5
|
+
export const PAGES: readonly Page[] = ["overview", "input", "edits", "settings"];
|
|
6
|
+
|
|
7
|
+
export type ClmCommand =
|
|
8
|
+
/** Open the panel on a page. */
|
|
9
|
+
| { kind: "open"; page: Page }
|
|
10
|
+
/** `/clm config <setting>`: show one setting. */
|
|
11
|
+
| { kind: "config-show"; setting: string }
|
|
12
|
+
/** `/clm config <setting> <value…>`: change one setting. */
|
|
13
|
+
| { kind: "config-set"; setting: string; value: string }
|
|
14
|
+
| { kind: "config-reset" }
|
|
15
|
+
| { kind: "status" }
|
|
16
|
+
| { kind: "path" }
|
|
17
|
+
| { kind: "enable"; enabled: boolean }
|
|
18
|
+
/** Must reach the server command (it rewrites state.json): let the line through. */
|
|
19
|
+
| { kind: "server"; args: string }
|
|
20
|
+
| { kind: "usage"; text: string };
|
|
21
|
+
|
|
22
|
+
export const CLM_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | budget [value] | config [setting [value]]]";
|
|
23
|
+
|
|
24
|
+
/** Page names and the short forms pi accepts. */
|
|
25
|
+
const PAGE_ALIASES: Record<string, Page> = {
|
|
26
|
+
overview: "overview",
|
|
27
|
+
timeline: "overview",
|
|
28
|
+
input: "input",
|
|
29
|
+
edits: "edits",
|
|
30
|
+
edit: "edits",
|
|
31
|
+
settings: "settings",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `undefined` when the text is not a `/clm` line (e.g. `/clm-compact` or plain text);
|
|
36
|
+
* otherwise the action. Leading and trailing whitespace is ignored; subcommand names are
|
|
37
|
+
* case-insensitive, values keep their case (paths).
|
|
38
|
+
*/
|
|
39
|
+
export function parseClmCommand(text: string): ClmCommand | undefined {
|
|
40
|
+
const match = /^\/clm(?:\s+([\s\S]*))?$/.exec(text.trim());
|
|
41
|
+
if (!match) return undefined;
|
|
42
|
+
const args = (match[1] ?? "").trim();
|
|
43
|
+
if (args === "") return { kind: "open", page: "overview" };
|
|
44
|
+
const [head = "", ...rest] = args.split(/\s+/);
|
|
45
|
+
const word = head.toLowerCase();
|
|
46
|
+
const page = PAGE_ALIASES[word];
|
|
47
|
+
if (page && rest.length === 0) return { kind: "open", page };
|
|
48
|
+
switch (word) {
|
|
49
|
+
case "status":
|
|
50
|
+
return rest.length === 0 ? { kind: "status" } : { kind: "usage", text: CLM_USAGE };
|
|
51
|
+
case "path":
|
|
52
|
+
return rest.length === 0 ? { kind: "path" } : { kind: "usage", text: CLM_USAGE };
|
|
53
|
+
case "on":
|
|
54
|
+
case "off":
|
|
55
|
+
return rest.length === 0 ? { kind: "enable", enabled: word === "on" } : { kind: "usage", text: CLM_USAGE };
|
|
56
|
+
case "reset":
|
|
57
|
+
// Extra words are a typo, not a reset: dropping the revision cannot be undone.
|
|
58
|
+
return rest.length === 0 ? { kind: "server", args } : { kind: "usage", text: CLM_USAGE };
|
|
59
|
+
case "budget": {
|
|
60
|
+
// pi's shorthand for `/clm config budget …`.
|
|
61
|
+
const value = args.replace(/^\S+\s*/, "");
|
|
62
|
+
return value === "" ? { kind: "config-show", setting: "budget" } : { kind: "config-set", setting: "budget", value };
|
|
63
|
+
}
|
|
64
|
+
case "config": {
|
|
65
|
+
if (rest.length === 0) return { kind: "open", page: "settings" };
|
|
66
|
+
const setting = rest[0]!.toLowerCase();
|
|
67
|
+
if (setting === "reset" && rest.length === 1) return { kind: "config-reset" };
|
|
68
|
+
// The value is the rest of the line as typed, inner spaces kept.
|
|
69
|
+
const value = args.replace(/^\S+\s+\S+\s*/, "");
|
|
70
|
+
return value === "" ? { kind: "config-show", setting } : { kind: "config-set", setting, value };
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
return { kind: "usage", text: CLM_USAGE };
|
|
74
|
+
}
|
|
75
|
+
}
|