opencode-clm 0.4.1 → 0.5.1
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/README.md
CHANGED
|
@@ -42,6 +42,28 @@ options go on the `opencode.json` entry only, and the TUI plugin reads them from
|
|
|
42
42
|
{ "plugin": ["opencode-clm"] }
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
### OpenCode 2
|
|
46
|
+
|
|
47
|
+
The same package runs on OpenCode 2 (2.1+): its default export carries both entry points,
|
|
48
|
+
`server` for OpenCode 1 and `id` + `setup` for OpenCode 2, as ponytail and superpowers do.
|
|
49
|
+
Add it to the server config only (`"plugins"`, or the OpenCode 1 `"plugin"` list that
|
|
50
|
+
OpenCode 2 migrates); options are the same:
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
// opencode.json
|
|
54
|
+
{ "plugins": [{ "package": "opencode-clm", "options": { "budget": "64k" } }] }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
On OpenCode 2, `/clm` never costs a model turn: its reply is posted to the session and
|
|
58
|
+
shown at once (the model reads it at its next turn). It works the same over ACP (editors such as Zed or Devin list
|
|
59
|
+
`/clm` and `/clm-compact`). Compaction behaves as in the paper (`compaction: auto`): OpenCode 2
|
|
60
|
+
compacts on its own at about 85% of the model window, so CLM keeps its budget below that
|
|
61
|
+
point and OpenCode only steps in when the provider rejects a request as too long.
|
|
62
|
+
`compaction: on` lets OpenCode compact at its threshold; `off` is not possible from a plugin
|
|
63
|
+
(set `"compaction": { "auto": false }` in opencode.json). Not available there yet: the `/clm`
|
|
64
|
+
TUI panel and toasts (warnings, errors and OpenCode's compactions go to
|
|
65
|
+
`<mirrorDir>/plugin.log`; `/clm status` shows the last compaction).
|
|
66
|
+
|
|
45
67
|
## Quick start
|
|
46
68
|
|
|
47
69
|
| command | what it does |
|
package/index.ts
CHANGED
|
@@ -27,15 +27,12 @@
|
|
|
27
27
|
* - `tool.definition` carries no session id; it runs per tool each time OpenCode builds the
|
|
28
28
|
* tool set, before the request's `messages.transform`.
|
|
29
29
|
*/
|
|
30
|
-
import {
|
|
31
|
-
import { mkdir, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
30
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
32
31
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
33
32
|
|
|
34
33
|
import { tool, type Hooks, type Plugin, type PluginInput, type PluginOptions } from "@opencode-ai/plugin";
|
|
35
34
|
|
|
36
|
-
import { formatTokens } from "./src/budget.ts";
|
|
37
35
|
import { ClmSession } from "./src/clm.ts";
|
|
38
|
-
import { copyPrivateFile, MirrorDirectoryError, privateTemporaryParent } from "./src/mirror-store.ts";
|
|
39
36
|
import {
|
|
40
37
|
applyCompactionMode,
|
|
41
38
|
compactionAuto,
|
|
@@ -54,22 +51,16 @@ import {
|
|
|
54
51
|
ThresholdWatch,
|
|
55
52
|
ToolCallCounter,
|
|
56
53
|
} from "./src/compaction.ts";
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
54
|
+
import { continuityTools } from "./src/continuity.ts";
|
|
55
|
+
import { checkSessionID, createHost, describe, relay, type SessionRecord, type ToastVariant } from "./src/host.ts";
|
|
59
56
|
import { filterCompacted, replaceInPlace, type OcMessage } from "./src/opencode.ts";
|
|
60
|
-
import {
|
|
57
|
+
import { systemGuidance } from "./src/presentation.ts";
|
|
61
58
|
import { COMPACT_COMMAND, COMPACT_TEMPLATE, STATUS_COMMAND, STATUS_TEMPLATE } from "./src/commands.ts";
|
|
62
|
-
import { changeSetting, OVERRIDES_FILE, resetSettings, showSetting } from "./src/overrides.ts";
|
|
63
|
-
import { STATE_FILE } from "./src/state.ts";
|
|
64
59
|
import { CHANNEL_VERSION, commandOf, decodeRequest, encodeReply, MAX_READ_BYTES, type ChannelReply, type ChannelRequest } from "./src/channel.ts";
|
|
65
|
-
import {
|
|
66
|
-
import {
|
|
67
|
-
import { buildPanelModel } from "./src/panel/model.ts";
|
|
68
|
-
import { panelPageText } from "./src/panel/text.ts";
|
|
69
|
-
import { readSessionDirectory, sessionDirectory } from "./src/session-files.ts";
|
|
70
|
-
import { fallbackBudget, settingsView } from "./src/tui/data.ts";
|
|
71
|
-
import { defaultMirrorDir, resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
|
|
60
|
+
import { sessionDirectory } from "./src/session-files.ts";
|
|
61
|
+
import { resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
|
|
72
62
|
import { loadSteeringDocument, steeringPromptSection, type SteeringDocument } from "./src/steering.ts";
|
|
63
|
+
import { setup } from "./src/v2.ts";
|
|
73
64
|
|
|
74
65
|
export const PLUGIN_ID = "opencode-clm";
|
|
75
66
|
export { COMPACT_COMMAND, STATUS_COMMAND } from "./src/commands.ts";
|
|
@@ -81,26 +72,7 @@ const HELPER_PROMPTS = [
|
|
|
81
72
|
"You are a context summarization agent",
|
|
82
73
|
];
|
|
83
74
|
|
|
84
|
-
|
|
85
|
-
/** A pending `/clm-compact` older than this no longer refuses a new one (its prompt may have failed to send). */
|
|
86
|
-
const COMPACT_PENDING_MS = 5 * 60_000;
|
|
87
|
-
|
|
88
|
-
type ToastVariant = "info" | "success" | "warning" | "error";
|
|
89
|
-
|
|
90
|
-
function describe(error: unknown): string {
|
|
91
|
-
return error instanceof Error ? error.message : String(error);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Session ids name a directory (`<mirrorDir>/clm-<id>`). A separator or `..` would reach
|
|
96
|
-
* outside it, so such ids are refused before anything touches the file system.
|
|
97
|
-
*/
|
|
98
|
-
export function checkSessionID(sessionID: unknown): string {
|
|
99
|
-
if (typeof sessionID !== "string" || sessionID === "" || /[/\\]/.test(sessionID) || sessionID.includes("..")) {
|
|
100
|
-
throw new Error(`CLM refuses session id ${JSON.stringify(sessionID)}: it must not contain "/", "\\" or "..".`);
|
|
101
|
-
}
|
|
102
|
-
return sessionID;
|
|
103
|
-
}
|
|
75
|
+
export { checkSessionID };
|
|
104
76
|
|
|
105
77
|
/** ENOENT/ENOTDIR → undefined (missing); anything else rethrown. */
|
|
106
78
|
function missingAsUndefined(error: unknown): undefined {
|
|
@@ -114,11 +86,6 @@ function isHelperPrompt(system: readonly string[]): boolean {
|
|
|
114
86
|
return HELPER_PROMPTS.some((prompt) => first.startsWith(prompt));
|
|
115
87
|
}
|
|
116
88
|
|
|
117
|
-
/** The text the model receives in place of the `/clm` template. */
|
|
118
|
-
function relay(text: string): string {
|
|
119
|
-
return `${text}\n\nShow the CLM text above to the user exactly as written. Do not edit the mirror or run tools.`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
89
|
export const server: Plugin = async (input: PluginInput, options?: PluginOptions): Promise<Hooks> => {
|
|
123
90
|
const settings: ClmSettings = resolveSettings(options ?? {}, process.env, input.directory);
|
|
124
91
|
if (!settings.enabled) return {};
|
|
@@ -135,7 +102,6 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
135
102
|
}
|
|
136
103
|
}
|
|
137
104
|
|
|
138
|
-
const sessions = new Map<string, Promise<ClmSession>>();
|
|
139
105
|
/** Estimated tokens per tool schema, from `tool.definition` (no session id there). */
|
|
140
106
|
const toolSizes = new Map<string, number>();
|
|
141
107
|
|
|
@@ -221,257 +187,32 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
221
187
|
/**
|
|
222
188
|
* OpenCode's record of a session: its metadata (`Session.Info.metadata`, a free record) and
|
|
223
189
|
* whether it is a child (subagent) session (`parentID`). Undefined when the client is
|
|
224
|
-
* unavailable
|
|
225
|
-
* `metadata`.
|
|
190
|
+
* unavailable. The v1 SDK's `Session` type omits `metadata`.
|
|
226
191
|
*/
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return {
|
|
240
|
-
metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? (metadata as Record<string, unknown>) : {},
|
|
241
|
-
child: typeof data.parentID === "string" && data.parentID !== "",
|
|
242
|
-
...(typeof data.time?.created === "number" ? { created: data.time.created } : {}),
|
|
243
|
-
};
|
|
244
|
-
} catch (error) {
|
|
245
|
-
await clm.log({ event: "stamp-error", stage: "get", error: describe(error) });
|
|
246
|
-
return undefined;
|
|
247
|
-
}
|
|
192
|
+
const record = async (sessionID: string): Promise<SessionRecord | undefined> => {
|
|
193
|
+
if (typeof input.client?.session?.get !== "function") return undefined;
|
|
194
|
+
const result = await input.client.session.get({ path: { id: sessionID } });
|
|
195
|
+
const failed = (result as { error?: unknown } | undefined)?.error;
|
|
196
|
+
const data = (result as { data?: { metadata?: unknown; parentID?: unknown; time?: { created?: unknown } } } | undefined)?.data;
|
|
197
|
+
if (failed !== undefined || !data) throw new Error(JSON.stringify(failed ?? "no data"));
|
|
198
|
+
const metadata = data.metadata;
|
|
199
|
+
return {
|
|
200
|
+
metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? (metadata as Record<string, unknown>) : {},
|
|
201
|
+
child: typeof data.parentID === "string" && data.parentID !== "",
|
|
202
|
+
...(typeof data.time?.created === "number" ? { created: data.time.created } : {}),
|
|
203
|
+
};
|
|
248
204
|
};
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const metadataChains = new Map<string, Promise<unknown>>();
|
|
255
|
-
const inMetadataChain = <T>(sessionID: string, task: () => Promise<T>): Promise<T> => {
|
|
256
|
-
const run = (metadataChains.get(sessionID) ?? Promise.resolve()).then(task, task);
|
|
257
|
-
const tail = run.catch(() => undefined);
|
|
258
|
-
metadataChains.set(sessionID, tail);
|
|
259
|
-
void tail.then(() => {
|
|
260
|
-
if (metadataChains.get(sessionID) === tail) metadataChains.delete(sessionID);
|
|
261
|
-
});
|
|
262
|
-
return run;
|
|
205
|
+
/** `PATCH /session/:id` replaces the whole metadata record. */
|
|
206
|
+
const updateMetadata = async (sessionID: string, metadata: Record<string, unknown>): Promise<void> => {
|
|
207
|
+
const result = await input.client?.session?.update?.({ path: { id: sessionID }, body: { metadata } } as never);
|
|
208
|
+
const error = (result as { error?: unknown } | undefined)?.error;
|
|
209
|
+
if (error) throw new Error(JSON.stringify(error));
|
|
263
210
|
};
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const linked = new Set<string>();
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Fork detection. The plugin stamps `metadata.clm.origin = <own id>` on every session it
|
|
270
|
-
* opens; `Session.fork` copies the metadata into the fork, so a stamp naming another session
|
|
271
|
-
* marks a fork of it. A fresh fork restores the origin's newest matching revision on its
|
|
272
|
-
* first request (clm.ts `restoreFromFork`); the stamp is then rewritten to the fork's id.
|
|
273
|
-
* `PATCH /session/:id` replaces the whole metadata record, so the other keys are copied.
|
|
274
|
-
*/
|
|
275
|
-
const linkFork = (clm: ClmSession): Promise<void> => inMetadataChain(clm.sessionID, async () => {
|
|
276
|
-
const record = await sessionRecord(clm);
|
|
277
|
-
// Child (subagent) sessions are never forked: no stamp, no PATCH bumping their time.
|
|
278
|
-
if (!record || record.child) return;
|
|
279
|
-
const metadata = record.metadata;
|
|
280
|
-
const stamp = metadata.clm && typeof metadata.clm === "object" && !Array.isArray(metadata.clm) ? (metadata.clm as Record<string, unknown>) : {};
|
|
281
|
-
const origin = stamp.origin;
|
|
282
|
-
// `annotations` is the store of a session without a mirror (M1). A session with a mirror
|
|
283
|
-
// drops it, so a later run without one does not restore a stale set; a fork drops the
|
|
284
|
-
// origin's copy and persists its own after its first request.
|
|
285
|
-
const own = origin === clm.sessionID;
|
|
286
|
-
const fork = typeof origin === "string" && !own;
|
|
287
|
-
const dropAnnotations = "annotations" in stamp && (fork || clm.mirrorUnavailable === undefined);
|
|
288
|
-
if (own && !dropAnnotations) return;
|
|
289
|
-
if (!own && typeof origin === "string" && !clm.state.checkpoint && clm.state.revision === 0) {
|
|
290
|
-
try {
|
|
291
|
-
clm.forkOrigin = {
|
|
292
|
-
sessionID: origin,
|
|
293
|
-
directory: sessionDirectory(settings.mirrorDir, checkSessionID(origin)),
|
|
294
|
-
...(record.created !== undefined ? { created: record.created } : {}),
|
|
295
|
-
};
|
|
296
|
-
} catch {
|
|
297
|
-
// a foreign stamp that is not a usable session id: ignore it
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
try {
|
|
301
|
-
const { annotations: _dropped, ...kept } = stamp;
|
|
302
|
-
const next = { ...(dropAnnotations ? kept : stamp), origin: clm.sessionID };
|
|
303
|
-
const result = await input.client?.session?.update?.({ path: { id: clm.sessionID }, body: { metadata: { ...metadata, clm: next } } } as never);
|
|
304
|
-
const error = (result as { error?: unknown } | undefined)?.error;
|
|
305
|
-
if (error) await clm.log({ event: "stamp-error", stage: "update", error: JSON.stringify(error) });
|
|
306
|
-
} catch (error) {
|
|
307
|
-
await clm.log({ event: "stamp-error", stage: "update", error: describe(error) });
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
/** The project's default mirror parent, inside the project (no external-directory prompts). */
|
|
312
|
-
const projectMirrorDir = defaultMirrorDir(input.directory);
|
|
211
|
+
const host = createHost({ settings, steering, directory: input.directory, toast, log, record, updateMetadata });
|
|
212
|
+
const { sessions, session, opened, linked, linkFork, persistAnnotationsOf, setSetting, clearSettings, dropRevision, statusCommand, compactPending } = host;
|
|
313
213
|
/** Sessions already told that CLM could not start for them (one error toast each). */
|
|
314
214
|
const openFailures = new Set<string>();
|
|
315
215
|
|
|
316
|
-
/** Private parent for sessions without a usable mirror directory, made on first use (`openWithoutMirror`). */
|
|
317
|
-
let unmirroredParent: Promise<string> | undefined;
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Opens a session in `mirrorDir`. Only a failure to create or use the session directory
|
|
321
|
-
* (`MirrorDirectoryError`) is handled here; any other error propagates as before.
|
|
322
|
-
* - An explicitly configured `mirrorDir` that fails: the session uses the project default
|
|
323
|
-
* `.opencode/clm` instead, and a toast names both. Inside the project the model's tools
|
|
324
|
-
* reach it without an `external_directory` permission prompt.
|
|
325
|
-
* - The default fails too: the session runs without a mirror (`openWithoutMirror`).
|
|
326
|
-
*/
|
|
327
|
-
const openSession = async (id: string): Promise<ClmSession> => {
|
|
328
|
-
const failures: MirrorDirectoryError[] = [];
|
|
329
|
-
try {
|
|
330
|
-
return await ClmSession.open(id, settings, { steering });
|
|
331
|
-
} catch (error) {
|
|
332
|
-
if (!(error instanceof MirrorDirectoryError)) throw error;
|
|
333
|
-
failures.push(error);
|
|
334
|
-
}
|
|
335
|
-
if (settings.mirrorDir !== projectMirrorDir) {
|
|
336
|
-
try {
|
|
337
|
-
const clm = await ClmSession.open(id, { ...settings, mirrorDir: projectMirrorDir }, { steering });
|
|
338
|
-
const error = failures[0]!;
|
|
339
|
-
const message = `could not use the configured mirrorDir ${settings.mirrorDir} (${describe(error.cause)}); the files of session ${id} are in ${clm.store.directory} instead.`;
|
|
340
|
-
log("warn", message);
|
|
341
|
-
toast(message, "warning");
|
|
342
|
-
await clm.log({ event: "mirror-dir-fallback", configured: settings.mirrorDir, used: projectMirrorDir, reason: describe(error.cause) });
|
|
343
|
-
return clm;
|
|
344
|
-
} catch (error) {
|
|
345
|
-
if (!(error instanceof MirrorDirectoryError)) throw error;
|
|
346
|
-
failures.push(error);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
return await openWithoutMirror(id, failures);
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* pi-clm's degraded mode (src/index.ts `replaceStore` and the `context` handler's `!store`
|
|
354
|
-
* branch): no mirror, no protocol prompt, no edits; requests carry the raw history plus the
|
|
355
|
-
* continuity annotations and their size notice. The session's files go to a private
|
|
356
|
-
* directory under the OS temp directory, made once per process and removed at exit. The
|
|
357
|
-
* model is never pointed there, so no `external_directory` prompt arises. state.json,
|
|
358
|
-
* overrides.json and annotations.jsonl still readable in a failed session directory (ours,
|
|
359
|
-
* not a symlink) are copied in before the session loads them; changes stay in the copy.
|
|
360
|
-
* When even that fails, the open fails: raw history, one "not active" toast naming every
|
|
361
|
-
* reason.
|
|
362
|
-
*/
|
|
363
|
-
const openWithoutMirror = async (id: string, failures: MirrorDirectoryError[]): Promise<ClmSession> => {
|
|
364
|
-
const reason = failures.map((failure) => `${failure.parent}: ${describe(failure.cause)}`).join("; ");
|
|
365
|
-
const copied: string[] = [];
|
|
366
|
-
let copiedFrom: string | undefined;
|
|
367
|
-
let clm: ClmSession;
|
|
368
|
-
try {
|
|
369
|
-
if (!unmirroredParent) {
|
|
370
|
-
unmirroredParent = privateTemporaryParent().then((parent) => {
|
|
371
|
-
process.once("exit", () => rmSync(parent, { recursive: true, force: true }));
|
|
372
|
-
return parent;
|
|
373
|
-
});
|
|
374
|
-
unmirroredParent.catch(() => (unmirroredParent = undefined));
|
|
375
|
-
}
|
|
376
|
-
const parent = await unmirroredParent;
|
|
377
|
-
const directory = sessionDirectory(parent, id);
|
|
378
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
379
|
-
for (const name of [STATE_FILE, OVERRIDES_FILE, ANNOTATIONS_FILE]) {
|
|
380
|
-
for (const failure of failures) {
|
|
381
|
-
const from = sessionDirectory(failure.parent, id);
|
|
382
|
-
if (await copyPrivateFile(from, directory, name)) {
|
|
383
|
-
copied.push(name);
|
|
384
|
-
copiedFrom ??= from;
|
|
385
|
-
break;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
clm = await ClmSession.open(id, { ...settings, mirrorDir: parent }, { steering });
|
|
390
|
-
} catch (error) {
|
|
391
|
-
throw new Error(`no mirror directory could be used (${reason}), and no temporary directory either: ${describe(error)}`);
|
|
392
|
-
}
|
|
393
|
-
clm.mirrorUnavailable = reason;
|
|
394
|
-
await restoreAnnotations(clm);
|
|
395
|
-
const kept = copiedFrom ? `${copied.join(", ")} copied from ${copiedFrom}; changes stay` : "No saved session files found; the session's files are";
|
|
396
|
-
const message = `CLM editing not active for session ${id}: no mirror directory could be used (${reason}). Requests carry the raw history plus the continuity annotations and their size notice. ${kept} in ${clm.store.directory} for this server process only.`;
|
|
397
|
-
log("error", message);
|
|
398
|
-
toast(message, "error");
|
|
399
|
-
await clm.log({ event: "mirror-unavailable", reason, directory: clm.store.directory, ...(copiedFrom ? { copiedFrom, copied } : {}) });
|
|
400
|
-
return clm;
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
/** `metadata.clm` of a session record, or an empty object. */
|
|
404
|
-
const clmStamp = (metadata: Record<string, unknown>): Record<string, unknown> =>
|
|
405
|
-
metadata.clm && typeof metadata.clm === "object" && !Array.isArray(metadata.clm) ? (metadata.clm as Record<string, unknown>) : {};
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* M1: a session without a mirror keeps its files in a per-process temp directory, so its
|
|
409
|
-
* annotations (resolutions made while degraded, and the snapshots copied in) would be lost
|
|
410
|
-
* at restart; pi keeps them in session entries. They go to OpenCode's session metadata
|
|
411
|
-
* (`metadata.clm.annotations`, bounded by PERSISTED_ANNOTATIONS_BYTES) after each change
|
|
412
|
-
* and are restored when the session opens without a mirror again.
|
|
413
|
-
*/
|
|
414
|
-
const restoreAnnotations = async (clm: ClmSession): Promise<void> => {
|
|
415
|
-
const record = await inMetadataChain(clm.sessionID, () => sessionRecord(clm));
|
|
416
|
-
const stamp = record ? clmStamp(record.metadata) : {};
|
|
417
|
-
const persisted = stamp.annotations;
|
|
418
|
-
if (persisted === undefined) return;
|
|
419
|
-
// A fork's metadata is the origin's clone: the fork's first request applies the
|
|
420
|
-
// fork-point cutoff to that set (clm.ts `copyForkAnnotations`) instead.
|
|
421
|
-
if (typeof stamp.origin === "string" && stamp.origin !== clm.sessionID && !record!.child) {
|
|
422
|
-
clm.forkPersisted = restorePersisted([], persisted);
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
try {
|
|
426
|
-
const snapshots = restorePersisted(await clm.annotations.list(), persisted);
|
|
427
|
-
await clm.annotations.appendAll(snapshots);
|
|
428
|
-
if (snapshots.length > 0) await clm.log({ event: "annotations-restored", count: snapshots.length });
|
|
429
|
-
} catch (error) {
|
|
430
|
-
await clm.log({ event: "annotations-restore-error", error: describe(error) });
|
|
431
|
-
}
|
|
432
|
-
};
|
|
433
|
-
const persistAnnotationsOf = async (sessionID: string): Promise<void> => {
|
|
434
|
-
const clm = await opened(sessionID);
|
|
435
|
-
if (!clm || clm.mirrorUnavailable === undefined) return;
|
|
436
|
-
await inMetadataChain(sessionID, () => persistNow(clm));
|
|
437
|
-
};
|
|
438
|
-
const persistNow = async (clm: ClmSession): Promise<void> => {
|
|
439
|
-
const record = await sessionRecord(clm);
|
|
440
|
-
if (!record) return;
|
|
441
|
-
try {
|
|
442
|
-
const annotations = persistAnnotations(await clm.annotations.list());
|
|
443
|
-
const metadata = { ...record.metadata, clm: { ...clmStamp(record.metadata), annotations } };
|
|
444
|
-
const result = await input.client?.session?.update?.({ path: { id: clm.sessionID }, body: { metadata } } as never);
|
|
445
|
-
const error = (result as { error?: unknown } | undefined)?.error;
|
|
446
|
-
if (error) throw new Error(JSON.stringify(error));
|
|
447
|
-
await clm.log({ event: "annotations-persisted", count: annotations.annotations.length, resolved: annotations.resolved.length });
|
|
448
|
-
} catch (error) {
|
|
449
|
-
await clm.log({ event: "annotations-persist-error", error: describe(error) });
|
|
450
|
-
}
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
const session = (sessionID: string): Promise<ClmSession> => {
|
|
454
|
-
const id = checkSessionID(sessionID);
|
|
455
|
-
let existing = sessions.get(id);
|
|
456
|
-
if (!existing) {
|
|
457
|
-
existing = openSession(id).then(async (clm) => {
|
|
458
|
-
if (clm.loadWarning) toast(`state reset for ${id}: ${clm.loadWarning}`, "warning");
|
|
459
|
-
return clm;
|
|
460
|
-
});
|
|
461
|
-
existing.catch(() => sessions.delete(id));
|
|
462
|
-
sessions.set(id, existing);
|
|
463
|
-
}
|
|
464
|
-
return existing;
|
|
465
|
-
};
|
|
466
|
-
/** An already open session, without opening one. */
|
|
467
|
-
const opened = async (sessionID: string): Promise<ClmSession | undefined> => {
|
|
468
|
-
try {
|
|
469
|
-
return await sessions.get(sessionID);
|
|
470
|
-
} catch {
|
|
471
|
-
return undefined;
|
|
472
|
-
}
|
|
473
|
-
};
|
|
474
|
-
|
|
475
216
|
const toolTokens = (): number | undefined => {
|
|
476
217
|
if (toolSizes.size === 0) return undefined;
|
|
477
218
|
let total = 0;
|
|
@@ -479,108 +220,6 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
479
220
|
return total;
|
|
480
221
|
};
|
|
481
222
|
|
|
482
|
-
/** One setting through `changeSetting`, activated at once; returns `Label: value`. */
|
|
483
|
-
async function setSetting(clm: ClmSession, name: string, value: string): Promise<string> {
|
|
484
|
-
const result = await changeSetting(clm.changeRequest(input.directory), name, value);
|
|
485
|
-
await clm.refreshSettings(true);
|
|
486
|
-
return result.text;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async function clearSettings(clm: ClmSession): Promise<void> {
|
|
490
|
-
await resetSettings(clm.store.directory);
|
|
491
|
-
await clm.refreshSettings(true);
|
|
492
|
-
toast("CLM settings reset to the defaults.");
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async function dropRevision(clm: ClmSession): Promise<void> {
|
|
496
|
-
await clm.resetProjection("/clm reset");
|
|
497
|
-
toast("CLM revision dropped");
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* `/clm config …` as text (pi-clm's `/clm config` semantics; the TUI opens the settings
|
|
502
|
-
* page instead). `words` keep their case: values may be paths.
|
|
503
|
-
*/
|
|
504
|
-
async function configCommand(clm: ClmSession, words: string[]): Promise<string> {
|
|
505
|
-
const [name, ...valueWords] = words;
|
|
506
|
-
const { base, effective } = clm.settingsValues();
|
|
507
|
-
const format = { modelWindow: clm.limits.context, modelOutput: clm.limits.output };
|
|
508
|
-
if (!name) return settingsText(base, effective, format, clm.settingsWarning);
|
|
509
|
-
if (name.toLowerCase() === "reset" && valueWords.length === 0) {
|
|
510
|
-
await clearSettings(clm);
|
|
511
|
-
return "CLM settings reset to the defaults for this session.";
|
|
512
|
-
}
|
|
513
|
-
if (valueWords.length === 0) return showSetting(name, effective, format);
|
|
514
|
-
const text = await setSetting(clm, name, valueWords.join(" "));
|
|
515
|
-
toast(`CLM ${text}.`);
|
|
516
|
-
return `CLM ${text}. It applies from the next request.`;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/** `/clm overview|input|edits|settings` outside the TUI: the panel page as plain text. */
|
|
520
|
-
async function pageCommand(clm: ClmSession, page: Page): Promise<string> {
|
|
521
|
-
const files = await readSessionDirectory(clm.store.directory, clm.sessionID);
|
|
522
|
-
const budget = fallbackBudget(clm.settings, clm.limits);
|
|
523
|
-
const model = buildPanelModel(files, budget ? { budget } : {});
|
|
524
|
-
model.enabled = clm.enabled;
|
|
525
|
-
if (clm.noticesOnly) model.noticesOnly = true;
|
|
526
|
-
// No mirror: name why, never the temporary directory (the text reaches the model).
|
|
527
|
-
if (clm.mirrorUnavailable !== undefined) {
|
|
528
|
-
model.mirrorUnavailable = clm.mirrorUnavailable;
|
|
529
|
-
model.mirrorPath = `unavailable (${clm.mirrorUnavailable})`;
|
|
530
|
-
}
|
|
531
|
-
const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}), ...(clm.limits.output ? { modelOutput: clm.limits.output } : {}) };
|
|
532
|
-
model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
|
|
533
|
-
return panelPageText(model, page);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
async function statusCommand(clm: ClmSession, args: string): Promise<string> {
|
|
537
|
-
await clm.refreshSettings();
|
|
538
|
-
const words = args.split(/\s+/).filter(Boolean);
|
|
539
|
-
const argument = (words[0] ?? "").toLowerCase();
|
|
540
|
-
if (argument === "config") return configCommand(clm, words.slice(1));
|
|
541
|
-
// pi's shorthand: `/clm budget <value>` = `/clm config budget <value>`.
|
|
542
|
-
if (argument === "budget") return configCommand(clm, ["budget", ...words.slice(1)]);
|
|
543
|
-
const command = parseClmCommand(`/clm ${args}`);
|
|
544
|
-
if (args !== "" && command?.kind === "open") return pageCommand(clm, command.page);
|
|
545
|
-
if (words.length > 1) return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
|
|
546
|
-
switch (argument) {
|
|
547
|
-
case "":
|
|
548
|
-
case "status": {
|
|
549
|
-
const text = statusText(clm.status());
|
|
550
|
-
toast(text.split("\n").slice(0, 3).join("\n"));
|
|
551
|
-
return text;
|
|
552
|
-
}
|
|
553
|
-
case "path":
|
|
554
|
-
if (clm.mirrorUnavailable !== undefined) return `CLM mirror: unavailable (${clm.mirrorUnavailable})`;
|
|
555
|
-
return clm.noticesOnly ? "CLM mirror: not used (mode notices-only)" : `CLM mirror: ${clm.mirrorPath}`;
|
|
556
|
-
case "on":
|
|
557
|
-
case "off": {
|
|
558
|
-
// The same override the TUI and `/clm config editing` write.
|
|
559
|
-
await setSetting(clm, "editing", argument);
|
|
560
|
-
toast(`CLM ${argument} for this session`);
|
|
561
|
-
if (clm.mirrorUnavailable !== undefined) {
|
|
562
|
-
return argument === "on"
|
|
563
|
-
? "CLM is on for this session. No mirror is available: requests carry the raw history plus the continuity annotations."
|
|
564
|
-
: "CLM is off for this session: requests carry the raw history.";
|
|
565
|
-
}
|
|
566
|
-
if (clm.noticesOnly) {
|
|
567
|
-
return argument === "on"
|
|
568
|
-
? "CLM is on for this session in notices-only mode: requests carry the raw history plus the budget notices; the model cannot edit its context."
|
|
569
|
-
: "CLM is off for this session: requests carry the raw history and no notices.";
|
|
570
|
-
}
|
|
571
|
-
return argument === "on"
|
|
572
|
-
? "CLM is on for this session: the mirror is refreshed before the next request."
|
|
573
|
-
: "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
|
|
574
|
-
}
|
|
575
|
-
case "reset": {
|
|
576
|
-
await dropRevision(clm);
|
|
577
|
-
return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
|
|
578
|
-
}
|
|
579
|
-
default:
|
|
580
|
-
return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
223
|
/** Session ids the server confirmed; a panel reload sends several requests per session. */
|
|
585
224
|
const knownSessions = new Set<string>();
|
|
586
225
|
|
|
@@ -670,82 +309,6 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
670
309
|
.catch(() => undefined);
|
|
671
310
|
};
|
|
672
311
|
|
|
673
|
-
/**
|
|
674
|
-
* `/clm-compact [instructions]`: the compaction prompt, with the size of the context as
|
|
675
|
-
* it would be sent now. pi's checks (pi src/index.ts compactCommand): CLM off and an empty
|
|
676
|
-
* context are refused with a toast; the model then gets a short relay instead of the
|
|
677
|
-
* prompt (the turn cannot be cancelled, see C14). OpenCode does not queue a command behind
|
|
678
|
-
* a busy session: `SessionPrompt.command` fires `command.execute.before` at once and its
|
|
679
|
-
* prompt joins the running loop (session/prompt.ts:1346, 1356-1466). Typed mid-run, the
|
|
680
|
-
* size is therefore measured mid-run, without the rest of the current turn; the toast says
|
|
681
|
-
* so. pi waits for idle instead; this port accepts the lag. A second invocation before the
|
|
682
|
-
* model received the first prompt is refused like pi's (`compactPending`, below).
|
|
683
|
-
*/
|
|
684
|
-
async function compactCommand(clm: ClmSession, sessionID: string, argument: string): Promise<string> {
|
|
685
|
-
const waiting = compactPending.get(sessionID);
|
|
686
|
-
if (waiting && Date.now() - waiting.at < COMPACT_PENDING_MS) {
|
|
687
|
-
const message = "A /clm-compact is already queued and the model has not received it yet.";
|
|
688
|
-
toast(message, "info");
|
|
689
|
-
return relay(`[CLM] ${message}`);
|
|
690
|
-
}
|
|
691
|
-
// Reserved before the first await, so a second invocation in the meantime is refused.
|
|
692
|
-
const entry: { prompt?: string; at: number } = { at: Date.now() };
|
|
693
|
-
compactPending.set(sessionID, entry);
|
|
694
|
-
const refuse = (message: string) => {
|
|
695
|
-
if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
|
|
696
|
-
toast(message, "warning");
|
|
697
|
-
return relay(`[CLM] ${message}`);
|
|
698
|
-
};
|
|
699
|
-
try {
|
|
700
|
-
return await compactPrompt(clm, sessionID, argument, entry, refuse);
|
|
701
|
-
} catch (error) {
|
|
702
|
-
if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
|
|
703
|
-
throw error;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
async function compactPrompt(
|
|
708
|
-
clm: ClmSession,
|
|
709
|
-
sessionID: string,
|
|
710
|
-
argument: string,
|
|
711
|
-
entry: { prompt?: string; at: number },
|
|
712
|
-
refuse: (message: string) => string,
|
|
713
|
-
): Promise<string> {
|
|
714
|
-
await clm.refreshSettings();
|
|
715
|
-
const template = loadCompactPrompt(clm.settings.compactPromptPath);
|
|
716
|
-
if (!clm.enabled) {
|
|
717
|
-
return refuse("CLM is off for this session, so there is no editable context to compact. Turn it on with /clm on.");
|
|
718
|
-
}
|
|
719
|
-
// Without a history (the client cannot list messages) the last request's estimate stands in.
|
|
720
|
-
const history = await sessionMessages(sessionID);
|
|
721
|
-
const current = history ? clm.idleEstimate(filterCompacted(history)) : clm.lastReading?.estimated ?? 0;
|
|
722
|
-
if (current === undefined) return refuse("Nothing to compact yet: the context is empty.");
|
|
723
|
-
const busy = await sessionBusy(sessionID);
|
|
724
|
-
toast(`Asked the model to compact its context (now about ${formatTokens(current)} tokens` +
|
|
725
|
-
`${busy ? ", measured before the current run finishes" : ""}).`);
|
|
726
|
-
const prompt = buildCompactPrompt(template, {
|
|
727
|
-
mirror: clm.mirrorPath,
|
|
728
|
-
current,
|
|
729
|
-
budget: clm.resolvedBudget()?.budget,
|
|
730
|
-
instructions: argument,
|
|
731
|
-
});
|
|
732
|
-
entry.prompt = prompt;
|
|
733
|
-
entry.at = Date.now();
|
|
734
|
-
return prompt;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Sessions with a `/clm-compact` prompt the model has not received yet, and that prompt.
|
|
739
|
-
* pi refuses a second `/clm-compact` while one waits (`compactPending`, pi src/index.ts:235,
|
|
740
|
-
* 997-1000): set when the command is accepted (:1008), cleared once its prompt is sent
|
|
741
|
-
* (:1046). OpenCode sends the prompt itself, into the running loop or a new one, so here
|
|
742
|
-
* pending ends at the first model request whose history holds the prompt (the
|
|
743
|
-
* `messages.transform` hook), or when the session goes idle or fails without one
|
|
744
|
-
* (`session.idle`, session/status.ts:43; `session.error`). If OpenCode's prompt() fails after
|
|
745
|
-
* the hook, neither event may come; an entry older than COMPACT_PENDING_MS no longer refuses.
|
|
746
|
-
*/
|
|
747
|
-
const compactPending = new Map<string, { prompt?: string; at: number }>();
|
|
748
|
-
|
|
749
312
|
const holdsPrompt = (raw: OcMessage[], prompt: string) =>
|
|
750
313
|
raw.some((message) => message.info.role === "user" && message.parts.some((part) => part.type === "text" && part.text === prompt));
|
|
751
314
|
|
|
@@ -1061,18 +624,18 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
1061
624
|
const clm = await session(hookInput.sessionID);
|
|
1062
625
|
const argument = (hookInput.arguments ?? "").trim();
|
|
1063
626
|
if (hookInput.command === STATUS_COMMAND) text = relay(await statusCommand(clm, argument));
|
|
1064
|
-
else
|
|
1065
|
-
//
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
627
|
+
else {
|
|
628
|
+
// OpenCode does not queue a command behind a busy session: `SessionPrompt.command`
|
|
629
|
+
// fires this hook at once and its prompt joins the running loop (session/prompt.ts:
|
|
630
|
+
// 1346, 1356-1466). Typed mid-run, the size is measured mid-run; the toast says so.
|
|
631
|
+
const result = await host.compactCommand(clm, hookInput.sessionID, argument, async () => {
|
|
632
|
+
// Without a history (the client cannot list messages) the last request's estimate stands in.
|
|
633
|
+
const history = await sessionMessages(hookInput.sessionID);
|
|
634
|
+
const current = history ? clm.idleEstimate(filterCompacted(history)) : clm.lastReading?.estimated ?? 0;
|
|
635
|
+
return { current, busy: current !== undefined && await sessionBusy(hookInput.sessionID) };
|
|
636
|
+
});
|
|
637
|
+
// The turn cannot be cancelled (C14): a refusal reaches the model as a relay.
|
|
638
|
+
text = "prompt" in result ? result.prompt : relay(result.refused);
|
|
1076
639
|
}
|
|
1077
640
|
} catch (error) {
|
|
1078
641
|
const message = `/${hookInput.command} failed: ${describe(error)}`;
|
|
@@ -1096,4 +659,5 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
1096
659
|
return hooks;
|
|
1097
660
|
};
|
|
1098
661
|
|
|
1099
|
-
|
|
662
|
+
/** One default export serves both plugin APIs: OpenCode 2 reads `id` + `setup` (src/v2.ts), OpenCode 1 calls `server`. */
|
|
663
|
+
export default { id: PLUGIN_ID, setup, server };
|