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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-clm",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin for Context Language Models: the model edits a file mirror of its own context, and the edited mirror becomes its next input.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,10 +23,13 @@
23
23
  ],
24
24
  "main": "./index.ts",
25
25
  "exports": {
26
- ".": "./index.ts"
26
+ ".": "./index.ts",
27
+ "./server": "./index.ts",
28
+ "./tui": "./tui.ts"
27
29
  },
28
30
  "files": [
29
31
  "index.ts",
32
+ "tui.ts",
30
33
  "src/**/*.ts",
31
34
  "skills/**",
32
35
  "steering/**",
@@ -51,8 +54,20 @@
51
54
  "dependencies": {
52
55
  "@opencode-ai/plugin": "^1.18.34"
53
56
  },
57
+ "peerDependencies": {
58
+ "@opentui/core": ">=0.4.5"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "@opentui/core": {
62
+ "optional": true
63
+ }
64
+ },
54
65
  "devDependencies": {
55
- "@types/bun": "1.3.14",
56
- "typescript": "5.9.3"
66
+ "@opentui/core": "0.5.14",
67
+ "@opentui/keymap": "0.5.14",
68
+ "@opentui/solid": "0.5.14",
69
+ "@types/bun": "1.4.2",
70
+ "@xterm/headless": "6.0.0",
71
+ "typescript": "7.0.2"
57
72
  }
58
73
  }
package/src/atomic.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Atomic replacement of small session files (overrides.json, snapshot.json, rN.json): write
2
+ // a temp file with mode 0600 next to the target, then rename it over the target, so a
3
+ // reader never sees a torn file. Written for this package.
4
+ import { randomUUID } from "node:crypto";
5
+ import { mkdir, open, rename, rm } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+
8
+ export async function writeFileAtomic(path: string, text: string): Promise<void> {
9
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
10
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
11
+ try {
12
+ const handle = await open(temporary, "wx", 0o600);
13
+ try {
14
+ await handle.writeFile(text, "utf8");
15
+ } finally {
16
+ await handle.close();
17
+ }
18
+ await rename(temporary, path);
19
+ } catch (error) {
20
+ await rm(temporary, { force: true }).catch(() => undefined);
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ export function writeJsonAtomic(path: string, value: unknown): Promise<void> {
26
+ return writeFileAtomic(path, `${JSON.stringify(value, null, 1)}\n`);
27
+ }
package/src/budget.ts CHANGED
@@ -7,14 +7,14 @@
7
7
  *
8
8
  * Two measurements are kept apart and always labelled:
9
9
  *
10
- * - `estimated`: the harness estimate of the conversation in the *next* request (pinned
11
- * task + editable context + notices). Available before every call.
10
+ * - `estimated`: the harness estimate of the *next* request (pinned task + editable
11
+ * context + notices, plus the system prompt and tool schemas once their sizes are
12
+ * known). Available before every call.
12
13
  * - `observed`: the provider-reported input size of the *previous* request (OpenCode's
13
- * assistant `tokens`). Authoritative but one call late, and it includes the system
14
- * prompt and tool schemas, which the budget does not cover.
14
+ * assistant `tokens`). Authoritative but one call late.
15
15
  *
16
- * The estimate alone governs reminder tiers and the reminder text; `observed` is shown
17
- * for reference only, because it covers more than the budget does. The harness never
16
+ * The estimate alone governs reminder tiers and the reminder text; `observed` calibrates
17
+ * the estimate and is shown for reference. The harness never
18
18
  * blocks a request; the overflow guard (overflow.ts) withholds old tool output when the
19
19
  * estimate exceeds budget - reserve.
20
20
  */
@@ -360,7 +360,7 @@ export function budgetNoticeText(
360
360
  * odd sample cannot run away.
361
361
  */
362
362
  export class EstimateCalibrator {
363
- private pending: { estimate: number; rawCount: number } | undefined;
363
+ private pending: { estimate: number; rawCount: number; offset: number } | undefined;
364
364
  private current = 1;
365
365
  private samples = 0;
366
366
 
@@ -390,9 +390,11 @@ export class EstimateCalibrator {
390
390
  * Remember the raw (uncalibrated) estimate of the request about to be sent. The estimate
391
391
  * must cover the same scope as the provider count it will be compared with; an estimate
392
392
  * that omits the system prompt and tool schemas reads their size as undercounting.
393
+ * `offset`: tokens of the provider count the estimate does not cover, already measured
394
+ * (the fixed overhead); they are subtracted from the observation before the comparison.
393
395
  */
394
- record(estimate: number, rawCount: number): void {
395
- if (estimate > 0) this.pending = { estimate, rawCount };
396
+ record(estimate: number, rawCount: number, offset = 0): void {
397
+ if (estimate > 0) this.pending = { estimate, rawCount, offset };
396
398
  }
397
399
 
398
400
  /**
@@ -402,7 +404,7 @@ export class EstimateCalibrator {
402
404
  */
403
405
  observe(observed: { tokens: number; index: number } | undefined): number {
404
406
  if (!observed || !this.pending || observed.index < this.pending.rawCount) return this.current;
405
- const sample = observed.tokens / this.pending.estimate;
407
+ const sample = (observed.tokens - this.pending.offset) / this.pending.estimate;
406
408
  this.pending = undefined;
407
409
  if (!Number.isFinite(sample) || sample <= 0) return this.current;
408
410
  const smoothing = this.options.smoothing ?? 0.5;
package/src/channel.ts ADDED
@@ -0,0 +1,187 @@
1
+ // The turn-free channel between the TUI plugin and the server plugin. Both sides speak
2
+ // through OpenCode's `tui.command.execute` bus event: `POST /tui/publish` accepts any
3
+ // command string, the server publishes it on the instance bus, and every listener sees it:
4
+ // the server plugin's `event` hook, each TUI's own `api.event` and the TUI app, which
5
+ // dispatches the string as a keymap command (an unknown name is a no-op). Requests and
6
+ // replies are prefixed, base64url-encoded JSON, so they carry no spaces and never collide
7
+ // with a real keymap command. Written for this package. Verified against opencode 1.18.34:
8
+ // publish handler server/routes/instance/httpapi/handlers/tui.ts:84-93; command schema
9
+ // packages/schema/src/tui-event.ts:13-37; plugin `event` hook plugin/index.ts:255-260 (events
10
+ // of the plugin's own directory only); TUI app dispatch packages/tui/src/app.tsx:987-990.
11
+
12
+ /** `tui.command.execute` event type (opencode packages/schema/src/tui-event.ts). */
13
+ export const COMMAND_EVENT = "tui.command.execute";
14
+ export const REQUEST_PREFIX = "opencode-clm.request:";
15
+ export const REPLY_PREFIX = "opencode-clm.reply:";
16
+ export const CHANNEL_VERSION = 1;
17
+
18
+ export type ChannelOperation =
19
+ /** Where the server keeps the session's files. Answer: `directory` and `root`. */
20
+ | { op: "locate" }
21
+ /** `/clm config <setting> <value>`, `/clm on|off` and the panel's apply. */
22
+ | { op: "set"; setting: string; value: string }
23
+ /** `/clm config reset`. */
24
+ | { op: "settings-reset" }
25
+ /** `/clm reset`: drop the accepted revision. */
26
+ | { op: "reset" }
27
+ /** A file of the session directory (`path` relative to it). Answer: `content`, absent when missing. */
28
+ | { op: "read"; path: string }
29
+ /** A directory of the session directory (`""` = itself). Answer: `names`, absent when missing. */
30
+ | { op: "list"; path: string };
31
+
32
+ /** Largest file a `read` reply carries; replies travel on the instance bus to every client. */
33
+ export const MAX_READ_BYTES = 4 * 1024 * 1024;
34
+
35
+ export type ChannelRequest = ChannelOperation & { v: typeof CHANNEL_VERSION; id: string; session: string };
36
+
37
+ export interface ChannelReply {
38
+ v: typeof CHANNEL_VERSION;
39
+ id: string;
40
+ ok: boolean;
41
+ /** User-facing result or error. */
42
+ text: string;
43
+ /** `locate`: the session directory on the server. */
44
+ directory?: string;
45
+ /** `locate`: the server's instance directory, the root `file.read` paths are relative to. */
46
+ root?: string;
47
+ /** `read`: the file text. */
48
+ content?: string;
49
+ /** `list`: the entry names. */
50
+ names?: string[];
51
+ }
52
+
53
+ function encode(prefix: string, value: unknown): string {
54
+ return prefix + Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
55
+ }
56
+
57
+ function decode(prefix: string, command: unknown): Record<string, unknown> | undefined {
58
+ if (typeof command !== "string" || !command.startsWith(prefix)) return undefined;
59
+ try {
60
+ const value: unknown = JSON.parse(Buffer.from(command.slice(prefix.length), "base64url").toString("utf8"));
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
62
+ const record = value as Record<string, unknown>;
63
+ if (record.v !== CHANNEL_VERSION || typeof record.id !== "string" || record.id === "") return undefined;
64
+ return record;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ export function encodeRequest(request: ChannelRequest): string {
71
+ return encode(REQUEST_PREFIX, request);
72
+ }
73
+
74
+ /** The request in a command string; undefined for anything else (foreign or malformed). */
75
+ export function decodeRequest(command: unknown): ChannelRequest | undefined {
76
+ const record = decode(REQUEST_PREFIX, command);
77
+ if (!record || typeof record.session !== "string" || record.session === "") return undefined;
78
+ switch (record.op) {
79
+ case "locate":
80
+ case "settings-reset":
81
+ case "reset":
82
+ return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: record.op };
83
+ case "read":
84
+ case "list":
85
+ if (typeof record.path !== "string") return undefined;
86
+ return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: record.op, path: record.path };
87
+ case "set":
88
+ if (typeof record.setting !== "string" || typeof record.value !== "string") return undefined;
89
+ return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: "set", setting: record.setting, value: record.value };
90
+ default:
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ export function encodeReply(reply: ChannelReply): string {
96
+ return encode(REPLY_PREFIX, reply);
97
+ }
98
+
99
+ export function decodeReply(command: unknown): ChannelReply | undefined {
100
+ const record = decode(REPLY_PREFIX, command);
101
+ if (!record || typeof record.ok !== "boolean" || typeof record.text !== "string") return undefined;
102
+ return {
103
+ v: CHANNEL_VERSION,
104
+ id: record.id as string,
105
+ ok: record.ok,
106
+ text: record.text,
107
+ ...(typeof record.directory === "string" ? { directory: record.directory } : {}),
108
+ ...(typeof record.root === "string" ? { root: record.root } : {}),
109
+ ...(typeof record.content === "string" ? { content: record.content } : {}),
110
+ ...(Array.isArray(record.names) && record.names.every((name) => typeof name === "string") ? { names: record.names as string[] } : {}),
111
+ };
112
+ }
113
+
114
+ /** The command string of a `tui.command.execute` event, if the value is one. */
115
+ export function commandOf(event: unknown): string | undefined {
116
+ if (!event || typeof event !== "object") return undefined;
117
+ const { type, properties } = event as { type?: unknown; properties?: { command?: unknown } };
118
+ if (type !== COMMAND_EVENT) return undefined;
119
+ return typeof properties?.command === "string" ? properties.command : undefined;
120
+ }
121
+
122
+ /** The TUI side: send a request, wait for the reply with the same id. */
123
+ export interface ChannelClientDeps {
124
+ /** Publishes a `tui.command.execute` event with this command string. */
125
+ publish(command: string): Promise<void>;
126
+ /** Subscribes to `tui.command.execute` command strings; returns the unsubscribe. */
127
+ subscribe(handler: (command: string) => void): () => void;
128
+ timeoutMs?: number;
129
+ /** Request ids; random by default. */
130
+ newId?: () => string;
131
+ }
132
+
133
+ export interface ChannelClient {
134
+ /** `timeoutMs` overrides the client's default for this request. */
135
+ request(session: string, operation: ChannelOperation, timeoutMs?: number): Promise<ChannelReply>;
136
+ dispose(): void;
137
+ }
138
+
139
+ export class ChannelTimeout extends Error {
140
+ constructor(readonly timeoutMs: number) {
141
+ super(
142
+ `the opencode-clm server plugin did not answer within ${Math.round(timeoutMs / 100) / 10} s: the server does not load ` +
143
+ "opencode-clm, loads an older version without the TUI channel, or has CLM disabled (enabled: false)",
144
+ );
145
+ }
146
+ }
147
+
148
+ export const CHANNEL_TIMEOUT_MS = 5_000;
149
+ /** `locate` is asked on panel loads; a short wait keeps a missing server plugin from stalling them. */
150
+ export const LOCATE_TIMEOUT_MS = 1_000;
151
+
152
+ export function createChannelClient(deps: ChannelClientDeps): ChannelClient {
153
+ const pending = new Map<string, (reply: ChannelReply) => void>();
154
+ const unsubscribe = deps.subscribe((command) => {
155
+ const reply = decodeReply(command);
156
+ if (!reply) return;
157
+ pending.get(reply.id)?.(reply);
158
+ });
159
+ const newId = deps.newId ?? (() => crypto.randomUUID());
160
+ const defaultTimeoutMs = deps.timeoutMs ?? CHANNEL_TIMEOUT_MS;
161
+ return {
162
+ request(session, operation, timeoutMs = defaultTimeoutMs) {
163
+ const id = newId();
164
+ const request = { ...operation, v: CHANNEL_VERSION, id, session } as ChannelRequest;
165
+ return new Promise<ChannelReply>((resolve, reject) => {
166
+ const timer = setTimeout(() => {
167
+ pending.delete(id);
168
+ reject(new ChannelTimeout(timeoutMs));
169
+ }, timeoutMs);
170
+ pending.set(id, (reply) => {
171
+ clearTimeout(timer);
172
+ pending.delete(id);
173
+ resolve(reply);
174
+ });
175
+ deps.publish(encodeRequest(request)).catch((error: unknown) => {
176
+ clearTimeout(timer);
177
+ pending.delete(id);
178
+ reject(error instanceof Error ? error : new Error(String(error)));
179
+ });
180
+ });
181
+ },
182
+ dispose() {
183
+ unsubscribe();
184
+ pending.clear();
185
+ },
186
+ };
187
+ }