opencode-clm 0.2.0 → 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/README.md +77 -66
- package/index.ts +730 -36
- package/package.json +6 -6
- package/src/budget.ts +6 -6
- package/src/channel.ts +187 -0
- package/src/clm.ts +369 -20
- 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 +40 -2
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/model.ts +23 -1
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +10 -1
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +51 -3
- package/src/settings.ts +31 -3
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +83 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +11 -0
- package/src/tui/plugin.ts +219 -33
- package/src/tui/remote.ts +199 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-clm",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -63,11 +63,11 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@opentui/core": "0.
|
|
67
|
-
"@opentui/keymap": "0.
|
|
68
|
-
"@opentui/solid": "0.
|
|
69
|
-
"@types/bun": "1.
|
|
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
70
|
"@xterm/headless": "6.0.0",
|
|
71
|
-
"typescript": "
|
|
71
|
+
"typescript": "7.0.2"
|
|
72
72
|
}
|
|
73
73
|
}
|
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
|
|
11
|
-
*
|
|
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
|
|
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`
|
|
17
|
-
*
|
|
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
|
*/
|
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
|
+
}
|