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 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 { rmSync } from "node:fs";
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 { buildCompactPrompt, loadCompactPrompt } from "./src/compact.ts";
58
- import { ANNOTATIONS_FILE, continuityTools, persistAnnotations, restorePersisted } from "./src/continuity.ts";
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 { statusText, systemGuidance } from "./src/presentation.ts";
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 { settingsText } from "./src/settings-table.ts";
66
- import { parseClmCommand, type Page } from "./src/panel/command.ts";
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
- const STATUS_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | budget [value] | config [setting [value] | reset]]";
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; a failed call is logged as `stamp-error`. The v1 SDK's `Session` type omits
225
- * `metadata`.
190
+ * unavailable. The v1 SDK's `Session` type omits `metadata`.
226
191
  */
227
- const sessionRecord = async (clm: ClmSession): Promise<{ metadata: Record<string, unknown>; child: boolean; created?: number } | undefined> => {
228
- const get = input.client?.session?.get;
229
- if (typeof get !== "function") return undefined;
230
- try {
231
- const result = await input.client.session.get({ path: { id: clm.sessionID } });
232
- const failed = (result as { error?: unknown } | undefined)?.error;
233
- const data = (result as { data?: { metadata?: unknown; parentID?: unknown; time?: { created?: unknown } } } | undefined)?.data;
234
- if (failed !== undefined || !data) {
235
- await clm.log({ event: "stamp-error", stage: "get", error: JSON.stringify(failed ?? "no data") });
236
- return undefined;
237
- }
238
- const metadata = data.metadata;
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
- * Read-modify-write of a session's metadata, one at a time per session: `PATCH
251
- * /session/:id` replaces the whole record, so concurrent writers (the fork stamp, two
252
- * annotation changes in one step) must each build their body from the previous result.
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
- /** Sessions whose stamp was handled in this process (`linkFork`). */
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 if (clm.mirrorUnavailable !== undefined) {
1065
- // pi-clm's compactCommand: nothing for the model to edit, so nothing is asked of it.
1066
- toast(`The context mirror is unavailable, so the model cannot edit its context (${clm.mirrorUnavailable}).`, "warning");
1067
- text = relay("[CLM] The context mirror is unavailable, so the model cannot edit its context.");
1068
- } else {
1069
- await clm.refreshSettings();
1070
- if (clm.noticesOnly) {
1071
- // The compaction prompt asks for a mirror edit; notices-only has no mirror.
1072
- const message = "CLM runs in notices-only mode for this session, so the model cannot edit its context. Switch with /clm config mode edit.";
1073
- toast(message, "warning");
1074
- text = relay(`[CLM] ${message}`);
1075
- } else text = await compactCommand(clm, hookInput.sessionID, argument);
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
- export default { id: PLUGIN_ID, server };
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 };