opencode-clm 0.4.1 → 0.5.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/README.md +22 -0
- package/index.ts +42 -478
- package/package.json +6 -6
- package/src/clm.ts +18 -2
- package/src/host.ts +512 -0
- package/src/mirror-guard.ts +3 -1
- package/src/opencode-v2.ts +305 -0
- package/src/presentation.ts +6 -0
- package/src/settings.ts +3 -1
- package/src/v2.ts +457 -0
- package/tui.ts +3 -1
package/src/v2.ts
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode 2 entry point (`setup`): the same CLM behaviour as `server` in index.ts, wired to
|
|
3
|
+
* OpenCode 2's plugin API (packages/plugin/src/promise, 2.1.1). One default export serves both
|
|
4
|
+
* hosts: OpenCode 2 reads `id` + `setup`, OpenCode 1 calls `server`.
|
|
5
|
+
*
|
|
6
|
+
* Hook mapping, checked against OpenCode 2.1.1:
|
|
7
|
+
* - `session.hook("context")` replaces OpenCode 1's `experimental.chat.messages.transform`,
|
|
8
|
+
* `experimental.chat.system.transform` and `tool.definition`: one event carries the system
|
|
9
|
+
* parts, the provider-level messages (src/opencode-v2.ts converts them) and the tool
|
|
10
|
+
* definitions. It fires for the agent loop only; titles and other helper requests have
|
|
11
|
+
* their own hooks, so no helper-prompt sniffing is needed.
|
|
12
|
+
* - `session.hook("compaction")` replaces `experimental.session.compacting` and the
|
|
13
|
+
* compaction's own messages transform: the pending edit is committed, the accepted revision
|
|
14
|
+
* applied to the history being summarized, and the annotations go in as a system part.
|
|
15
|
+
* - Commands registered through `command.transform` run plugin code instead of sending a
|
|
16
|
+
* template. Their text is posted with `session.synthetic` and `resume: false`: shown at
|
|
17
|
+
* once, with no model turn (the model reads it at its next turn, as it reads OpenCode 1's
|
|
18
|
+
* relay). The `description` is what makes OpenCode 2 draw the message at all.
|
|
19
|
+
* - `tool.hook("execute.after")` replaces `tool.execute.after`.
|
|
20
|
+
* - Events come from `event.subscribe`: `session.step.ended` carries the provider's token
|
|
21
|
+
* count (OpenCode 1 kept it on the message), `session.compaction.ended` marks a compaction.
|
|
22
|
+
*
|
|
23
|
+
* The `compaction` setting: OpenCode 2's compaction settings are internal, so `auto` (pi-clm's
|
|
24
|
+
* and the paper's default) is had by keeping the budget under OpenCode's threshold (`prepare`)
|
|
25
|
+
* instead of by switching OpenCode's compaction off per request; `on` leaves the threshold in
|
|
26
|
+
* play; `off` cannot be done by a plugin and is logged. No toasts (server plugins have no TUI
|
|
27
|
+
* channel): problems and OpenCode's compactions go to `<mirrorDir>/plugin.log`, compactions
|
|
28
|
+
* also to `/clm status`. No TUI panel.
|
|
29
|
+
*/
|
|
30
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
import { tool } from "@opencode-ai/plugin";
|
|
34
|
+
|
|
35
|
+
import { ClmSession } from "./clm.ts";
|
|
36
|
+
import { COMPACT_COMMAND, STATUS_COMMAND } from "./commands.ts";
|
|
37
|
+
import { oneToolText, ToolCallCounter } from "./compaction.ts";
|
|
38
|
+
import { annotateArgs, ANNOTATE_DESCRIPTION, ANNOTATE_TOOL, continuityTools, recallArgs, RECALL_DESCRIPTION, RECALL_TOOL } from "./continuity.ts";
|
|
39
|
+
import { checkSessionID, createHost, describe, type SessionRecord, type ToastVariant } from "./host.ts";
|
|
40
|
+
import { fromV2, toV2, type V2Message, type V2Tokens } from "./opencode-v2.ts";
|
|
41
|
+
import { systemGuidance } from "./presentation.ts";
|
|
42
|
+
import { resolveSettings, SKILLS_DIR } from "./settings.ts";
|
|
43
|
+
import { loadSteeringDocument, steeringPromptSection, type SteeringDocument } from "./steering.ts";
|
|
44
|
+
|
|
45
|
+
const PLUGIN_ID = "opencode-clm";
|
|
46
|
+
|
|
47
|
+
/** Longest description OpenCode 2's desktop draws (1024, kept under it with the ellipsis). */
|
|
48
|
+
const DESCRIPTION_MAX = 1000;
|
|
49
|
+
|
|
50
|
+
// Structural types for the parts of OpenCode 2's plugin context this module uses; the
|
|
51
|
+
// `@opencode/plugin` package is not a dependency (OpenCode 1 installs do not have it).
|
|
52
|
+
type Registration = { dispose(): Promise<void> };
|
|
53
|
+
interface ContextEvent {
|
|
54
|
+
readonly sessionID: string;
|
|
55
|
+
readonly agent: string;
|
|
56
|
+
readonly model: { providerID: string; id: string };
|
|
57
|
+
system: Array<{ type: "text"; text: string }>;
|
|
58
|
+
messages: V2Message[];
|
|
59
|
+
tools: Record<string, { description: string; input: unknown }>;
|
|
60
|
+
}
|
|
61
|
+
interface ToolBefore {
|
|
62
|
+
tool: string;
|
|
63
|
+
readonly sessionID: string;
|
|
64
|
+
readonly id: string;
|
|
65
|
+
input: unknown;
|
|
66
|
+
}
|
|
67
|
+
type ToolAfter = {
|
|
68
|
+
readonly tool: string;
|
|
69
|
+
readonly sessionID: string;
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly input: unknown;
|
|
72
|
+
} & ({ readonly status: "completed"; result: { output?: unknown; content?: string | ReadonlyArray<{ type: string; text?: string }>; metadata?: Record<string, unknown> } } | { readonly status: "error"; error: unknown });
|
|
73
|
+
interface CommandInvocation {
|
|
74
|
+
readonly sessionID: string;
|
|
75
|
+
readonly prompt: { text: string; [key: string]: unknown };
|
|
76
|
+
readonly delivery: string;
|
|
77
|
+
}
|
|
78
|
+
interface V2Session {
|
|
79
|
+
readonly sessionID: string;
|
|
80
|
+
}
|
|
81
|
+
export interface V2Context {
|
|
82
|
+
readonly location: { directory: string };
|
|
83
|
+
readonly options: Readonly<Record<string, any>>;
|
|
84
|
+
readonly session: {
|
|
85
|
+
get(input: { sessionID: string }): Promise<any>;
|
|
86
|
+
update(input: { sessionID: string; metadata?: Record<string, unknown> }): Promise<unknown>;
|
|
87
|
+
prompt(input: Record<string, unknown>): Promise<unknown>;
|
|
88
|
+
synthetic(input: { sessionID: string; text: string; description?: string; resume?: boolean; delivery?: string }): Promise<{ id?: string } | undefined>;
|
|
89
|
+
hook(name: "context" | "compaction", callback: (event: ContextEvent) => Promise<void> | void): Promise<Registration>;
|
|
90
|
+
};
|
|
91
|
+
readonly tool: {
|
|
92
|
+
transform(callback: (editor: { add(tool: Record<string, unknown>): void }) => void): Promise<Registration>;
|
|
93
|
+
hook(name: "execute.before", callback: (event: ToolBefore) => Promise<void> | void): Promise<Registration>;
|
|
94
|
+
hook(name: "execute.after", callback: (event: ToolAfter) => Promise<void> | void): Promise<Registration>;
|
|
95
|
+
};
|
|
96
|
+
readonly command: {
|
|
97
|
+
transform(callback: (editor: { add(command: { name: string; description?: string; execute(input: CommandInvocation): Promise<void> }): void }) => void): Promise<Registration>;
|
|
98
|
+
};
|
|
99
|
+
readonly skill: {
|
|
100
|
+
transform(callback: (editor: { add(skill: Record<string, unknown>): void; get(id: string): unknown }) => void): Promise<Registration>;
|
|
101
|
+
};
|
|
102
|
+
readonly model: { list(): Promise<{ data?: Array<{ providerID: string; id: string; limit?: { context?: number; input?: number; output?: number } }> } | undefined> };
|
|
103
|
+
readonly event: { subscribe(options: { signal: AbortSignal }): AsyncIterable<{ type?: string; created?: number; data?: Record<string, any> }> };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The bundled skill as an OpenCode 2 `Skill.Info` (OpenCode 1 reads the directory through `skills.paths`). */
|
|
107
|
+
function bundledSkill(): Record<string, unknown> | undefined {
|
|
108
|
+
const path = `${SKILLS_DIR}/SKILL.md`;
|
|
109
|
+
let text: string;
|
|
110
|
+
try {
|
|
111
|
+
text = readFileSync(path, "utf8");
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
116
|
+
const fields = new Map<string, string>();
|
|
117
|
+
for (const line of (match?.[1] ?? "").split(/\r?\n/)) {
|
|
118
|
+
const field = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line);
|
|
119
|
+
if (field) fields.set(field[1]!, field[2]!.trim());
|
|
120
|
+
}
|
|
121
|
+
const name = fields.get("name") ?? "clm-context";
|
|
122
|
+
return { id: name, name, description: fields.get("description"), path, content: match?.[2] ?? text };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Text of a completed tool result's content (a string or a list of content items). */
|
|
126
|
+
function contentText(content: string | ReadonlyArray<{ type: string; text?: string }> | undefined): string {
|
|
127
|
+
if (typeof content === "string") return content;
|
|
128
|
+
return (content ?? []).filter((item) => item.type === "text").map((item) => item.text ?? "").join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `content` with `text` appended as its own text item (a string stays a string). */
|
|
132
|
+
function appendContent(content: string | ReadonlyArray<{ type: string; text?: string }> | undefined, text: string) {
|
|
133
|
+
if (typeof content === "string" || content === undefined) {
|
|
134
|
+
const current = content ?? "";
|
|
135
|
+
return current ? `${current}${current.endsWith("\n") ? "\n" : "\n\n"}${text}` : text;
|
|
136
|
+
}
|
|
137
|
+
return [...content, { type: "text", text }];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function setup(ctx: V2Context): Promise<(() => void) | void> {
|
|
141
|
+
const directory = ctx.location.directory;
|
|
142
|
+
const settings = resolveSettings(ctx.options ?? {}, process.env, directory);
|
|
143
|
+
if (!settings.enabled) return;
|
|
144
|
+
let steering: SteeringDocument | undefined;
|
|
145
|
+
if (settings.steeringPath) {
|
|
146
|
+
try {
|
|
147
|
+
steering = loadSteeringDocument(settings.steeringPath);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error(`[${PLUGIN_ID}] steering document not loaded, sessions run without it: ${describe(error)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// No TUI channel or log API for a server plugin: warnings and errors go to stderr and to
|
|
154
|
+
// `<mirrorDir>/plugin.log` (the mirror parent is private and git-ignored).
|
|
155
|
+
const logFile = join(settings.mirrorDir, "plugin.log");
|
|
156
|
+
const log = (level: "info" | "warn" | "error", message: string) => {
|
|
157
|
+
if (level !== "info") console.error(`[${PLUGIN_ID}] ${level}: ${message}`);
|
|
158
|
+
try {
|
|
159
|
+
appendFileSync(logFile, `${new Date().toISOString()} ${level} ${message}\n`, { mode: 0o600 });
|
|
160
|
+
} catch {
|
|
161
|
+
// the mirror parent may not exist yet (no session opened) or be unusable
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
// Toasts are mostly status echoes of `/clm` replies the user already sees; only problems are logged.
|
|
165
|
+
const toast = (message: string, variant: ToastVariant = "info") => {
|
|
166
|
+
if (variant === "error" || variant === "warning") log(variant === "error" ? "error" : "warn", message);
|
|
167
|
+
};
|
|
168
|
+
if (settings.compaction === "off") {
|
|
169
|
+
log("warn", `"compaction: off" has no effect on OpenCode 2 (plugins cannot change its compaction settings); set "compaction": { "auto": false } in opencode.json instead.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const record = async (sessionID: string): Promise<SessionRecord | undefined> => {
|
|
173
|
+
const info = await ctx.session.get({ sessionID });
|
|
174
|
+
if (!info) throw new Error("no data");
|
|
175
|
+
const metadata = info.metadata;
|
|
176
|
+
return {
|
|
177
|
+
metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {},
|
|
178
|
+
child: typeof info.parentID === "string" && info.parentID !== "",
|
|
179
|
+
...(typeof info.time?.created === "number" ? { created: info.time.created } : {}),
|
|
180
|
+
...(typeof info.fork?.sessionID === "string" ? { forkOf: info.fork.sessionID } : {}),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
const updateMetadata = async (sessionID: string, metadata: Record<string, unknown>) => {
|
|
184
|
+
await ctx.session.update({ sessionID, metadata });
|
|
185
|
+
};
|
|
186
|
+
const host = createHost({ settings, steering, directory, toast, log, record, updateMetadata });
|
|
187
|
+
|
|
188
|
+
/** Provider-reported tokens of the session's last assistant message (`session.step.ended`). */
|
|
189
|
+
const usage = new Map<string, { id: string; tokens: V2Tokens }>();
|
|
190
|
+
const openFailures = new Set<string>();
|
|
191
|
+
const toolCalls = new ToolCallCounter();
|
|
192
|
+
|
|
193
|
+
const openSession = async (sessionID: string): Promise<ClmSession | undefined> => {
|
|
194
|
+
try {
|
|
195
|
+
return await host.session(sessionID);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// The request goes out with the raw history; say so once per session, not per step.
|
|
198
|
+
if (!openFailures.has(sessionID)) {
|
|
199
|
+
openFailures.add(sessionID);
|
|
200
|
+
log("error", `not active for session ${sessionID}, requests carry the raw history: ${describe(error)}`);
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Model limits and the system prompt section: what `experimental.chat.system.transform` did. */
|
|
207
|
+
const prepare = async (clm: ClmSession, event: ContextEvent) => {
|
|
208
|
+
await clm.refreshSettings();
|
|
209
|
+
const list = await ctx.model.list().catch(() => undefined);
|
|
210
|
+
const limit = list?.data?.find((model) => model.providerID === event.model.providerID && model.id === event.model.id)?.limit;
|
|
211
|
+
if (limit?.context) {
|
|
212
|
+
// pi-clm's `compaction: auto` (the paper's default): OpenCode's threshold compaction must
|
|
213
|
+
// not fire while the guard keeps the context within budget. OpenCode 2 compacts once its
|
|
214
|
+
// measure of the sent context reaches window − reserve (session/compaction.ts
|
|
215
|
+
// `calculateCeiling`, no `compaction.buffer` set), so the core gets that reserve as its
|
|
216
|
+
// output share: a `window` budget, and the guard, then end below the threshold. Recovery
|
|
217
|
+
// from a provider overflow stays OpenCode's. `on` leaves OpenCode's threshold in play.
|
|
218
|
+
const window = limit.input || limit.context;
|
|
219
|
+
const reserve = Math.max(Math.floor(window * 0.15), window >= 32_000 ? 16_000 : 0);
|
|
220
|
+
const output = clm.settings.compaction === "on" ? limit.output : Math.max(limit.output ?? 0, limit.context - window + reserve);
|
|
221
|
+
clm.limits = { context: limit.context, output: output || undefined, ...(limit.input ? { input: limit.input } : {}) };
|
|
222
|
+
clm.limitsKnown = true;
|
|
223
|
+
}
|
|
224
|
+
let toolSize = 0;
|
|
225
|
+
for (const [name, definition] of Object.entries(event.tools ?? {})) {
|
|
226
|
+
toolSize += Math.ceil(((name.length + (definition.description?.length ?? 0) + JSON.stringify(definition.input ?? {}).length) / 4) * settings.estimateFactor);
|
|
227
|
+
}
|
|
228
|
+
clm.scope = { ...clm.scope, toolTokens: toolSize };
|
|
229
|
+
// Without a mirror the model gets no protocol text (pi-clm's before_agent_start).
|
|
230
|
+
if (!clm.enabled || clm.mirrorUnavailable !== undefined) return;
|
|
231
|
+
// notices-only: no protocol section (there is no mirror); the steering document stays.
|
|
232
|
+
const sections = clm.noticesOnly ? [] : [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
|
|
233
|
+
if (clm.steering) sections.push(steeringPromptSection(clm.steering));
|
|
234
|
+
if (sections.length > 0) event.system.push({ type: "text", text: sections.join("\n\n") });
|
|
235
|
+
clm.scope = { ...clm.scope, systemTokens: clm.textTokens(event.system.map((part) => part.text).join("\n")) };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** The core's transform over the event's messages, written back in place. */
|
|
239
|
+
const transform = async (clm: ClmSession, event: ContextEvent) => {
|
|
240
|
+
const observed = usage.get(event.sessionID);
|
|
241
|
+
const view = fromV2(event.messages, event.sessionID, (id) => (observed?.id === id ? observed.tokens : undefined));
|
|
242
|
+
const result = await clm.transform(view.messages);
|
|
243
|
+
event.messages.splice(0, event.messages.length, ...toV2(view, result.messages));
|
|
244
|
+
if (clm.annotationsImported) {
|
|
245
|
+
clm.annotationsImported = false;
|
|
246
|
+
await host.persistAnnotationsOf(event.sessionID);
|
|
247
|
+
}
|
|
248
|
+
if (result.alert) log("warn", result.alert);
|
|
249
|
+
for (const error of result.errors ?? []) log("error", `${event.sessionID}: ${error}`);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const holdsPrompt = (messages: readonly V2Message[], prompt: string) =>
|
|
253
|
+
messages.some((message) => message.role === "user" && message.content.some((part) => part.type === "text" && part.text === prompt));
|
|
254
|
+
|
|
255
|
+
await ctx.session.hook("context", async (event) => {
|
|
256
|
+
const sessionID = event.sessionID;
|
|
257
|
+
const pending = host.compactPending.get(sessionID)?.prompt;
|
|
258
|
+
if (pending !== undefined && holdsPrompt(event.messages, pending)) host.compactPending.delete(sessionID);
|
|
259
|
+
// A new model request: `one-tool` counts its tool calls from zero.
|
|
260
|
+
toolCalls.reset(sessionID);
|
|
261
|
+
const clm = await openSession(sessionID);
|
|
262
|
+
if (!clm) return;
|
|
263
|
+
try {
|
|
264
|
+
// Stamp (and detect a fork) once per process, only while CLM edits this session.
|
|
265
|
+
if (!host.linked.has(sessionID)) {
|
|
266
|
+
await clm.refreshSettings().catch(() => undefined);
|
|
267
|
+
if (clm.enabled) {
|
|
268
|
+
host.linked.add(sessionID);
|
|
269
|
+
await host.linkFork(clm);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
await prepare(clm, event);
|
|
273
|
+
await transform(clm, event);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
// Fail open: the request goes out with the raw history.
|
|
276
|
+
await clm.log({ event: "error", message: describe(error), stack: error instanceof Error ? error.stack : undefined });
|
|
277
|
+
log("error", `transform failed: ${describe(error)}`);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await ctx.session.hook("compaction", async (event) => {
|
|
282
|
+
const clm = await host.opened(event.sessionID);
|
|
283
|
+
if (!clm) return;
|
|
284
|
+
try {
|
|
285
|
+
await clm.commitBeforeCompaction();
|
|
286
|
+
} catch (error) {
|
|
287
|
+
await clm.log({ event: "error", message: `commit before compaction: ${describe(error)}` });
|
|
288
|
+
}
|
|
289
|
+
// OpenCode 2 starts the follow-up request before `session.compaction.ended` reaches the
|
|
290
|
+
// event stream, so the rebase signal is set here. A compaction that fails leaves the
|
|
291
|
+
// history unchanged: the projection stays valid and no rebase happens.
|
|
292
|
+
clm.compacted = true;
|
|
293
|
+
try {
|
|
294
|
+
const context = await clm.compactionContext();
|
|
295
|
+
if (context) event.system.push({ type: "text", text: context });
|
|
296
|
+
clm.compacting = true;
|
|
297
|
+
await transform(clm, event);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
clm.compacting = false;
|
|
300
|
+
await clm.log({ event: "error", message: describe(error) });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await ctx.tool.hook("execute.before", async (event) => {
|
|
305
|
+
// Counted before any await: parallel calls of one response arrive concurrently.
|
|
306
|
+
const position = toolCalls.next(event.sessionID);
|
|
307
|
+
if (position < 2) return;
|
|
308
|
+
const clm = await host.opened(event.sessionID);
|
|
309
|
+
if (!clm || !clm.settings.enabled || !clm.enabled || !clm.settings.oneTool) return;
|
|
310
|
+
await clm.log({ event: "tool-blocked", tool: event.tool, position });
|
|
311
|
+
throw new Error(oneToolText(event.tool, position));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await ctx.tool.hook("execute.after", async (event) => {
|
|
315
|
+
if (event.status !== "completed") return;
|
|
316
|
+
const clm = await host.opened(event.sessionID);
|
|
317
|
+
if (!clm) return;
|
|
318
|
+
const input = event.input && typeof event.input === "object" ? (event.input as Record<string, unknown>) : {};
|
|
319
|
+
let receipt: string | undefined;
|
|
320
|
+
try {
|
|
321
|
+
receipt = clm.receipt(event.tool, input, directory);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
await clm.log({ event: "receipt-error", tool: event.tool, message: describe(error) });
|
|
324
|
+
}
|
|
325
|
+
let content = event.result.content;
|
|
326
|
+
if (receipt) content = appendContent(content, receipt);
|
|
327
|
+
// Last, so it measures the result as the model gets it.
|
|
328
|
+
const trailer = clm.sizeTrailer(contentText(content));
|
|
329
|
+
if (trailer) content = typeof content === "string" ? `${content}${trailer}` : appendContent(content, trailer.trimStart());
|
|
330
|
+
if (content !== event.result.content) event.result = { ...event.result, content };
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Posts `text` to the session: shown at once, with no model turn (`resume: false` defers
|
|
335
|
+
* only the model's reading to its next turn). OpenCode 2 draws a synthetic message only
|
|
336
|
+
* when it carries a `description`, and its desktop drops that row past 1024 characters.
|
|
337
|
+
*/
|
|
338
|
+
const display = async (sessionID: string, text: string) => {
|
|
339
|
+
const description = text.length <= DESCRIPTION_MAX ? text : `${text.slice(0, DESCRIPTION_MAX - 1)}…`;
|
|
340
|
+
await ctx.session.synthetic({ sessionID, text, description, resume: false });
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (settings.commands) {
|
|
344
|
+
await ctx.command.transform((editor) => {
|
|
345
|
+
editor.add({
|
|
346
|
+
name: STATUS_COMMAND,
|
|
347
|
+
description: "CLM: status of the editable context; /clm path | on | off | reset | config | budget",
|
|
348
|
+
execute: async ({ sessionID, prompt }) => {
|
|
349
|
+
let text: string;
|
|
350
|
+
try {
|
|
351
|
+
text = await host.statusCommand(await host.session(checkSessionID(sessionID)), (prompt.text ?? "").trim());
|
|
352
|
+
} catch (error) {
|
|
353
|
+
text = `[CLM] /${STATUS_COMMAND} failed: ${describe(error)}`;
|
|
354
|
+
log("error", text);
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
await display(sessionID, text);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
log("error", `/${STATUS_COMMAND}: could not post the reply: ${describe(error)}`);
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
editor.add({
|
|
364
|
+
name: COMPACT_COMMAND,
|
|
365
|
+
description: "CLM: ask the model to compact its context by editing the mirror",
|
|
366
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
367
|
+
try {
|
|
368
|
+
const clm = await host.session(checkSessionID(sessionID));
|
|
369
|
+
// OpenCode 2 has no history in the core's form outside a request: the last request's size stands in.
|
|
370
|
+
const result = await host.compactCommand(clm, sessionID, (prompt.text ?? "").trim(), async () => ({
|
|
371
|
+
current: clm.lastReading?.estimated,
|
|
372
|
+
busy: false,
|
|
373
|
+
}));
|
|
374
|
+
if ("prompt" in result) await ctx.session.prompt({ sessionID, text: result.prompt, delivery });
|
|
375
|
+
else await display(sessionID, result.refused);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
const text = `[CLM] /${COMPACT_COMMAND} failed: ${describe(error)}`;
|
|
378
|
+
log("error", text);
|
|
379
|
+
await display(sessionID, text);
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (settings.skill) {
|
|
387
|
+
const skill = bundledSkill();
|
|
388
|
+
if (skill) {
|
|
389
|
+
await ctx.skill.transform((editor) => {
|
|
390
|
+
if (!editor.get(String(skill.id))) editor.add(skill);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const tools = continuityTools({
|
|
396
|
+
schema: tool.schema,
|
|
397
|
+
store: async (sessionID) => (await host.session(sessionID)).annotations,
|
|
398
|
+
block: async (sessionID, blockId) => (await host.session(sessionID)).blockSource(blockId),
|
|
399
|
+
onChange: host.persistAnnotationsOf,
|
|
400
|
+
});
|
|
401
|
+
const z = tool.schema;
|
|
402
|
+
await ctx.tool.transform((editor) => {
|
|
403
|
+
for (const [name, description, args] of [
|
|
404
|
+
[ANNOTATE_TOOL, ANNOTATE_DESCRIPTION, annotateArgs(z)],
|
|
405
|
+
[RECALL_TOOL, RECALL_DESCRIPTION, recallArgs(z)],
|
|
406
|
+
] as const) {
|
|
407
|
+
editor.add({
|
|
408
|
+
name,
|
|
409
|
+
description,
|
|
410
|
+
input: z.object(args),
|
|
411
|
+
execute: async (input: Record<string, unknown>, context: V2Session) => {
|
|
412
|
+
const output = await tools[name]!.execute(input as never, { sessionID: context.sessionID } as never);
|
|
413
|
+
return { content: typeof output === "string" ? output : output.output };
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
/** `session.compaction.started` reason per session, read when the compaction ends. */
|
|
420
|
+
const compactionReasons = new Map<string, "auto" | "manual">();
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
void (async () => {
|
|
423
|
+
try {
|
|
424
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
425
|
+
const data = event?.data ?? {};
|
|
426
|
+
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
|
|
427
|
+
switch (event?.type) {
|
|
428
|
+
case "session.step.ended":
|
|
429
|
+
if (sessionID && typeof data.assistantMessageID === "string" && data.tokens) usage.set(sessionID, { id: data.assistantMessageID, tokens: data.tokens });
|
|
430
|
+
break;
|
|
431
|
+
case "session.compaction.started":
|
|
432
|
+
if (sessionID) compactionReasons.set(sessionID, data.reason === "manual" ? "manual" : "auto");
|
|
433
|
+
break;
|
|
434
|
+
case "session.compaction.ended": {
|
|
435
|
+
if (!sessionID) break;
|
|
436
|
+
const reason = compactionReasons.get(sessionID) ?? "auto";
|
|
437
|
+
compactionReasons.delete(sessionID);
|
|
438
|
+
log("info", `OpenCode compacted session ${sessionID} (${reason === "auto" ? "automatic" : "/compact"})`);
|
|
439
|
+
const clm = await host.opened(sessionID);
|
|
440
|
+
if (!clm) break;
|
|
441
|
+
clm.compacted = true;
|
|
442
|
+
await clm.noteCompaction(reason, typeof event.created === "number" ? event.created : Date.now());
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
case "session.execution.succeeded":
|
|
446
|
+
case "session.execution.failed":
|
|
447
|
+
case "session.execution.interrupted":
|
|
448
|
+
if (sessionID) host.compactPending.delete(sessionID);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (!controller.signal.aborted) log("warn", `event stream ended: ${describe(error)}`);
|
|
454
|
+
}
|
|
455
|
+
})();
|
|
456
|
+
return () => controller.abort();
|
|
457
|
+
}
|
package/tui.ts
CHANGED
|
@@ -4,4 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { tui } from "./src/tui/plugin.ts";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// OpenCode 2 validates TUI modules as {id, setup} and calls setup; the panel is not ported yet
|
|
8
|
+
// (PORTING.md), so setup is a no-op there. OpenCode 1 ignores it and calls `tui`.
|
|
9
|
+
export default { id: "opencode-clm", tui, setup: async () => {} };
|