opencode-clm 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/README.md +87 -66
- package/index.ts +753 -40
- package/package.json +6 -6
- package/src/budget.ts +166 -22
- package/src/channel.ts +187 -0
- package/src/clm.ts +478 -57
- package/src/compaction.ts +251 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +40 -2
- package/src/opencode.ts +59 -0
- package/src/overflow.ts +6 -3
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/keys.ts +11 -1
- package/src/panel/model.ts +26 -2
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +39 -10
- package/src/presentation.ts +8 -2
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +131 -17
- package/src/settings.ts +100 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +89 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +14 -2
- package/src/tui/plugin.ts +222 -35
- package/src/tui/remote.ts +199 -0
package/index.ts
CHANGED
|
@@ -27,22 +27,48 @@
|
|
|
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";
|
|
32
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
33
|
+
|
|
30
34
|
import { tool, type Hooks, type Plugin, type PluginInput, type PluginOptions } from "@opencode-ai/plugin";
|
|
31
35
|
|
|
36
|
+
import { formatTokens } from "./src/budget.ts";
|
|
32
37
|
import { ClmSession } from "./src/clm.ts";
|
|
38
|
+
import { copyPrivateFile, MirrorDirectoryError, privateTemporaryParent } from "./src/mirror-store.ts";
|
|
39
|
+
import {
|
|
40
|
+
applyCompactionMode,
|
|
41
|
+
compactionAuto,
|
|
42
|
+
lastFinishedStep,
|
|
43
|
+
nativeCompactionText,
|
|
44
|
+
oneToolText,
|
|
45
|
+
openCodeMaxOutput,
|
|
46
|
+
openCodeUsable,
|
|
47
|
+
OPENCODE_OUTPUT_TOKEN_MAX,
|
|
48
|
+
outputTokenMaxFlag,
|
|
49
|
+
overflowNotCompactedText,
|
|
50
|
+
thresholdCancelledNoticeText,
|
|
51
|
+
thresholdCancelledText,
|
|
52
|
+
thresholdPausedText,
|
|
53
|
+
thresholdReachable,
|
|
54
|
+
ThresholdWatch,
|
|
55
|
+
ToolCallCounter,
|
|
56
|
+
} from "./src/compaction.ts";
|
|
33
57
|
import { buildCompactPrompt, loadCompactPrompt } from "./src/compact.ts";
|
|
34
|
-
import { continuityTools } from "./src/continuity.ts";
|
|
35
|
-
import { replaceInPlace, type OcMessage } from "./src/opencode.ts";
|
|
58
|
+
import { ANNOTATIONS_FILE, continuityTools, persistAnnotations, restorePersisted } from "./src/continuity.ts";
|
|
59
|
+
import { filterCompacted, replaceInPlace, type OcMessage } from "./src/opencode.ts";
|
|
36
60
|
import { statusText, systemGuidance } from "./src/presentation.ts";
|
|
37
61
|
import { COMPACT_COMMAND, COMPACT_TEMPLATE, STATUS_COMMAND, STATUS_TEMPLATE } from "./src/commands.ts";
|
|
38
|
-
import { changeSetting, resetSettings, showSetting } from "./src/overrides.ts";
|
|
62
|
+
import { changeSetting, OVERRIDES_FILE, resetSettings, showSetting } from "./src/overrides.ts";
|
|
63
|
+
import { STATE_FILE } from "./src/state.ts";
|
|
64
|
+
import { CHANNEL_VERSION, commandOf, decodeRequest, encodeReply, MAX_READ_BYTES, type ChannelReply, type ChannelRequest } from "./src/channel.ts";
|
|
39
65
|
import { settingsText } from "./src/settings-table.ts";
|
|
40
66
|
import { parseClmCommand, type Page } from "./src/panel/command.ts";
|
|
41
67
|
import { buildPanelModel } from "./src/panel/model.ts";
|
|
42
68
|
import { panelPageText } from "./src/panel/text.ts";
|
|
43
|
-
import { readSessionDirectory } from "./src/session-files.ts";
|
|
69
|
+
import { readSessionDirectory, sessionDirectory } from "./src/session-files.ts";
|
|
44
70
|
import { fallbackBudget, settingsView } from "./src/tui/data.ts";
|
|
45
|
-
import { resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
|
|
71
|
+
import { defaultMirrorDir, resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
|
|
46
72
|
import { loadSteeringDocument, steeringPromptSection, type SteeringDocument } from "./src/steering.ts";
|
|
47
73
|
|
|
48
74
|
export const PLUGIN_ID = "opencode-clm";
|
|
@@ -55,7 +81,9 @@ const HELPER_PROMPTS = [
|
|
|
55
81
|
"You are a context summarization agent",
|
|
56
82
|
];
|
|
57
83
|
|
|
58
|
-
const STATUS_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | config [setting [value] | reset]]";
|
|
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;
|
|
59
87
|
|
|
60
88
|
type ToastVariant = "info" | "success" | "warning" | "error";
|
|
61
89
|
|
|
@@ -74,6 +102,13 @@ export function checkSessionID(sessionID: unknown): string {
|
|
|
74
102
|
return sessionID;
|
|
75
103
|
}
|
|
76
104
|
|
|
105
|
+
/** ENOENT/ENOTDIR → undefined (missing); anything else rethrown. */
|
|
106
|
+
function missingAsUndefined(error: unknown): undefined {
|
|
107
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
108
|
+
if (code === "ENOENT" || code === "ENOTDIR") return undefined;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
77
112
|
function isHelperPrompt(system: readonly string[]): boolean {
|
|
78
113
|
const first = system[0]?.trimStart() ?? "";
|
|
79
114
|
return HELPER_PROMPTS.some((prompt) => first.startsWith(prompt));
|
|
@@ -87,8 +122,18 @@ function relay(text: string): string {
|
|
|
87
122
|
export const server: Plugin = async (input: PluginInput, options?: PluginOptions): Promise<Hooks> => {
|
|
88
123
|
const settings: ClmSettings = resolveSettings(options ?? {}, process.env, input.directory);
|
|
89
124
|
if (!settings.enabled) return {};
|
|
90
|
-
// Read once at load
|
|
91
|
-
|
|
125
|
+
// Read once at load. A missing or empty steering file does not stop the plugin (OpenCode
|
|
126
|
+
// would drop it silently, plugin/index.ts:230-241): sessions run protocol-only, each one
|
|
127
|
+
// records the error in its settings warning (`/clm status`), and a toast says so.
|
|
128
|
+
let steering: SteeringDocument | undefined;
|
|
129
|
+
let steeringError: string | undefined;
|
|
130
|
+
if (settings.steeringPath) {
|
|
131
|
+
try {
|
|
132
|
+
steering = loadSteeringDocument(settings.steeringPath);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
steeringError = describe(error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
92
137
|
|
|
93
138
|
const sessions = new Map<string, Promise<ClmSession>>();
|
|
94
139
|
/** Estimated tokens per tool schema, from `tool.definition` (no session id there). */
|
|
@@ -107,14 +152,309 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
107
152
|
.then(() => input.client?.app?.log?.({ body: { service: PLUGIN_ID, level, message } }))
|
|
108
153
|
.catch(() => undefined);
|
|
109
154
|
};
|
|
155
|
+
/**
|
|
156
|
+
* The live config object from the `config` hook, and the user's own `compaction.auto`
|
|
157
|
+
* (undefined = not set) for the `compaction` setting. See src/compaction.ts.
|
|
158
|
+
*/
|
|
159
|
+
let liveConfig: { compaction?: { auto?: boolean; reserved?: number } } | undefined;
|
|
160
|
+
let userAutoCompaction: boolean | undefined;
|
|
161
|
+
/** Which requests ran with the flag turned off by the plugin, and crossings reported (K1, K5). */
|
|
162
|
+
const thresholds = new ThresholdWatch();
|
|
163
|
+
/** Whether the user's config had a `compaction` object; one the plugin added is removed again. */
|
|
164
|
+
let userHadCompaction = false;
|
|
165
|
+
/**
|
|
166
|
+
* The `compaction.auto` value each session's newest request decided (undefined = the user's
|
|
167
|
+
* value). Re-applied after each of the session's tool calls: a subagent (`task` tool) runs
|
|
168
|
+
* its own requests inside the parent's step and overwrites the instance-wide flag before
|
|
169
|
+
* OpenCode's finish-step check of the parent reads it (session/processor.ts:491).
|
|
170
|
+
*/
|
|
171
|
+
const flagDecisions = new Map<string, boolean | undefined>();
|
|
172
|
+
const applyFlag = (value: boolean | undefined) => {
|
|
173
|
+
if (!liveConfig) return;
|
|
174
|
+
if (value !== undefined) {
|
|
175
|
+
(liveConfig.compaction ??= {}).auto = value;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
applyCompactionMode(liveConfig, "auto", userAutoCompaction);
|
|
179
|
+
if (!userHadCompaction && liveConfig.compaction && Object.keys(liveConfig.compaction).length === 0) delete liveConfig.compaction;
|
|
180
|
+
};
|
|
181
|
+
const decideFlag = (sessionID: string, value: boolean | undefined) => {
|
|
182
|
+
if (!liveConfig) return;
|
|
183
|
+
// Re-inserted, so the map's order is the order of the newest decisions.
|
|
184
|
+
flagDecisions.delete(sessionID);
|
|
185
|
+
flagDecisions.set(sessionID, value);
|
|
186
|
+
applyFlag(value);
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* A session went idle (`session.idle`, session/status.ts:41-44; e.g. a subagent finished,
|
|
190
|
+
* also when the parent's `task` call then threw, which skips `tool.execute.after`,
|
|
191
|
+
* session/tools.ts:111): its decision no longer governs anything, so
|
|
192
|
+
* the newest decision of another session takes the flag back. With no other session the
|
|
193
|
+
* flag stays: the idle session's own next prompt is checked against it first
|
|
194
|
+
* (prompt.ts:1161). Best effort: the event reaches the plugin through the bus and can
|
|
195
|
+
* arrive after the parent's finish-step check.
|
|
196
|
+
*/
|
|
197
|
+
const endFlag = (sessionID: string) => {
|
|
198
|
+
if (!flagDecisions.delete(sessionID) || flagDecisions.size === 0) return;
|
|
199
|
+
applyFlag([...flagDecisions.values()].at(-1));
|
|
200
|
+
};
|
|
201
|
+
/** A request that goes out without CLM (fail-open): the user's flag, no pause left behind. */
|
|
202
|
+
const releaseFlag = (sessionID: string) => {
|
|
203
|
+
decideFlag(sessionID, undefined);
|
|
204
|
+
thresholds.set(sessionID, undefined);
|
|
205
|
+
};
|
|
206
|
+
/** OpenCode's threshold for the session's model (src/compaction.ts `openCodeUsable`). */
|
|
207
|
+
const thresholdOf = (clm: ClmSession) => openCodeUsable(clm.limits, liveConfig?.compaction?.reserved, outputTokenMaxFlag());
|
|
208
|
+
/** Sessions whose newest transform was OpenCode's compaction request (no overflow notice for it). */
|
|
209
|
+
const compactionRequests = new Set<string>();
|
|
210
|
+
/** Tool calls per session since its last model request (`one-tool`). */
|
|
211
|
+
const toolCalls = new ToolCallCounter();
|
|
212
|
+
|
|
213
|
+
if (steeringError) {
|
|
214
|
+
const message = `steering document not loaded, sessions run without it: ${steeringError}`;
|
|
215
|
+
log("error", message);
|
|
216
|
+
toast(message, "error");
|
|
217
|
+
}
|
|
110
218
|
/** Command names this plugin defined; a user's own `clm` / `clm-compact` stays theirs. */
|
|
111
219
|
const ownCommands = new Set<string>();
|
|
112
220
|
|
|
221
|
+
/**
|
|
222
|
+
* OpenCode's record of a session: its metadata (`Session.Info.metadata`, a free record) and
|
|
223
|
+
* 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`.
|
|
226
|
+
*/
|
|
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
|
+
}
|
|
248
|
+
};
|
|
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;
|
|
263
|
+
};
|
|
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);
|
|
313
|
+
/** Sessions already told that CLM could not start for them (one error toast each). */
|
|
314
|
+
const openFailures = new Set<string>();
|
|
315
|
+
|
|
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
|
+
|
|
113
453
|
const session = (sessionID: string): Promise<ClmSession> => {
|
|
114
454
|
const id = checkSessionID(sessionID);
|
|
115
455
|
let existing = sessions.get(id);
|
|
116
456
|
if (!existing) {
|
|
117
|
-
existing =
|
|
457
|
+
existing = openSession(id).then(async (clm) => {
|
|
118
458
|
if (clm.loadWarning) toast(`state reset for ${id}: ${clm.loadWarning}`, "warning");
|
|
119
459
|
return clm;
|
|
120
460
|
});
|
|
@@ -139,6 +479,24 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
139
479
|
return total;
|
|
140
480
|
};
|
|
141
481
|
|
|
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
|
+
|
|
142
500
|
/**
|
|
143
501
|
* `/clm config …` as text (pi-clm's `/clm config` semantics; the TUI opens the settings
|
|
144
502
|
* page instead). `words` keep their case: values may be paths.
|
|
@@ -146,19 +504,16 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
146
504
|
async function configCommand(clm: ClmSession, words: string[]): Promise<string> {
|
|
147
505
|
const [name, ...valueWords] = words;
|
|
148
506
|
const { base, effective } = clm.settingsValues();
|
|
149
|
-
const format = { modelWindow: clm.limits.context };
|
|
507
|
+
const format = { modelWindow: clm.limits.context, modelOutput: clm.limits.output };
|
|
150
508
|
if (!name) return settingsText(base, effective, format, clm.settingsWarning);
|
|
151
509
|
if (name.toLowerCase() === "reset" && valueWords.length === 0) {
|
|
152
|
-
await
|
|
153
|
-
await clm.refreshSettings(true);
|
|
154
|
-
toast("CLM settings reset to the defaults.");
|
|
510
|
+
await clearSettings(clm);
|
|
155
511
|
return "CLM settings reset to the defaults for this session.";
|
|
156
512
|
}
|
|
157
513
|
if (valueWords.length === 0) return showSetting(name, effective, format);
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return `CLM ${result.text}. It applies from the next request.`;
|
|
514
|
+
const text = await setSetting(clm, name, valueWords.join(" "));
|
|
515
|
+
toast(`CLM ${text}.`);
|
|
516
|
+
return `CLM ${text}. It applies from the next request.`;
|
|
162
517
|
}
|
|
163
518
|
|
|
164
519
|
/** `/clm overview|input|edits|settings` outside the TUI: the panel page as plain text. */
|
|
@@ -167,7 +522,13 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
167
522
|
const budget = fallbackBudget(clm.settings, clm.limits);
|
|
168
523
|
const model = buildPanelModel(files, budget ? { budget } : {});
|
|
169
524
|
model.enabled = clm.enabled;
|
|
170
|
-
|
|
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 } : {}) };
|
|
171
532
|
model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
|
|
172
533
|
return panelPageText(model, page);
|
|
173
534
|
}
|
|
@@ -177,6 +538,8 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
177
538
|
const words = args.split(/\s+/).filter(Boolean);
|
|
178
539
|
const argument = (words[0] ?? "").toLowerCase();
|
|
179
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)]);
|
|
180
543
|
const command = parseClmCommand(`/clm ${args}`);
|
|
181
544
|
if (args !== "" && command?.kind === "open") return pageCommand(clm, command.page);
|
|
182
545
|
if (words.length > 1) return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
|
|
@@ -188,20 +551,29 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
188
551
|
return text;
|
|
189
552
|
}
|
|
190
553
|
case "path":
|
|
191
|
-
return `CLM mirror: ${clm.
|
|
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}`;
|
|
192
556
|
case "on":
|
|
193
557
|
case "off": {
|
|
194
558
|
// The same override the TUI and `/clm config editing` write.
|
|
195
|
-
await
|
|
196
|
-
await clm.refreshSettings(true);
|
|
559
|
+
await setSetting(clm, "editing", argument);
|
|
197
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
|
+
}
|
|
198
571
|
return argument === "on"
|
|
199
572
|
? "CLM is on for this session: the mirror is refreshed before the next request."
|
|
200
573
|
: "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
|
|
201
574
|
}
|
|
202
575
|
case "reset": {
|
|
203
|
-
await
|
|
204
|
-
toast("CLM revision dropped");
|
|
576
|
+
await dropRevision(clm);
|
|
205
577
|
return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
|
|
206
578
|
}
|
|
207
579
|
default:
|
|
@@ -209,17 +581,208 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
209
581
|
}
|
|
210
582
|
}
|
|
211
583
|
|
|
212
|
-
|
|
213
|
-
|
|
584
|
+
/** Session ids the server confirmed; a panel reload sends several requests per session. */
|
|
585
|
+
const knownSessions = new Set<string>();
|
|
586
|
+
|
|
587
|
+
/** False when the server does not know the session (the request came from elsewhere). */
|
|
588
|
+
async function knownSession(sessionID: string): Promise<boolean> {
|
|
589
|
+
if (knownSessions.has(sessionID) || sessions.has(sessionID)) return true;
|
|
590
|
+
const get = input.client?.session?.get;
|
|
591
|
+
if (typeof get !== "function") return true;
|
|
592
|
+
try {
|
|
593
|
+
const result = await input.client.session.get({ path: { id: sessionID } });
|
|
594
|
+
const known = Boolean(result?.data) && !result?.error;
|
|
595
|
+
if (known) knownSessions.add(sessionID);
|
|
596
|
+
return known;
|
|
597
|
+
} catch {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** A request from the TUI over the channel (src/channel.ts): apply it, answer with its id. */
|
|
603
|
+
async function channelRequest(request: ChannelRequest): Promise<Omit<ChannelReply, "v" | "id">> {
|
|
604
|
+
const sessionID = checkSessionID(request.session);
|
|
605
|
+
if (!(await knownSession(sessionID))) throw new Error(`no session ${sessionID} on this server`);
|
|
606
|
+
// A session that fell back from the configured mirrorDir (openSession) lives elsewhere.
|
|
607
|
+
const opened = await sessions.get(sessionID)?.catch(() => undefined);
|
|
608
|
+
const directory = opened?.store.directory ?? sessionDirectory(settings.mirrorDir, sessionID);
|
|
609
|
+
switch (request.op) {
|
|
610
|
+
case "locate":
|
|
611
|
+
return { ok: true, text: "", directory, root: input.directory };
|
|
612
|
+
case "read": {
|
|
613
|
+
const path = await insideSession(directory, request.path);
|
|
614
|
+
if (path === undefined) return { ok: true, text: "" };
|
|
615
|
+
const size = (await stat(path).catch(missingAsUndefined))?.size;
|
|
616
|
+
if (size === undefined) return { ok: true, text: "" };
|
|
617
|
+
if (size > MAX_READ_BYTES) {
|
|
618
|
+
return { ok: false, text: `${request.path} is ${size} bytes, more than the ${MAX_READ_BYTES} a channel reply carries` };
|
|
619
|
+
}
|
|
620
|
+
const content = await readFile(path, "utf8").catch(missingAsUndefined);
|
|
621
|
+
return { ok: true, text: "", ...(content !== undefined ? { content } : {}) };
|
|
622
|
+
}
|
|
623
|
+
case "list": {
|
|
624
|
+
const path = await insideSession(directory, request.path);
|
|
625
|
+
if (path === undefined) return { ok: true, text: "" };
|
|
626
|
+
const names = await readdir(path).catch(missingAsUndefined);
|
|
627
|
+
return { ok: true, text: "", ...(names !== undefined ? { names } : {}) };
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const clm = await session(sessionID);
|
|
631
|
+
await clm.refreshSettings();
|
|
632
|
+
switch (request.op) {
|
|
633
|
+
case "set": {
|
|
634
|
+
const text = await setSetting(clm, request.setting, request.value);
|
|
635
|
+
toast(`CLM ${text}.`);
|
|
636
|
+
return { ok: true, text };
|
|
637
|
+
}
|
|
638
|
+
case "settings-reset":
|
|
639
|
+
await clearSettings(clm);
|
|
640
|
+
return { ok: true, text: "CLM settings reset to the defaults." };
|
|
641
|
+
case "reset":
|
|
642
|
+
await dropRevision(clm);
|
|
643
|
+
return { ok: true, text: "CLM revision dropped" };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* `path` inside the session directory, else a refusal; undefined when it does not exist.
|
|
649
|
+
* Checked twice: lexically, then with symlinks resolved on both sides, as OpenCode's own
|
|
650
|
+
* file API does (packages/core/src/filesystem.ts:66-71), so a link the model created in
|
|
651
|
+
* the mirror directory cannot point a `read` elsewhere.
|
|
652
|
+
*/
|
|
653
|
+
async function insideSession(directory: string, path: string): Promise<string | undefined> {
|
|
654
|
+
const outside = (root: string, target: string) => {
|
|
655
|
+
const rel = relative(root, target);
|
|
656
|
+
return isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`);
|
|
657
|
+
};
|
|
658
|
+
const target = resolve(directory, path);
|
|
659
|
+
if (outside(directory, target)) throw new Error(`${path} lies outside the session directory`);
|
|
660
|
+
const realRoot = await realpath(directory).catch(missingAsUndefined);
|
|
661
|
+
const realTarget = await realpath(target).catch(missingAsUndefined);
|
|
662
|
+
if (realRoot === undefined || realTarget === undefined) return undefined;
|
|
663
|
+
if (outside(realRoot, realTarget)) throw new Error(`${path} lies outside the session directory`);
|
|
664
|
+
return realTarget;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const reply = (value: ChannelReply) => {
|
|
668
|
+
void Promise.resolve()
|
|
669
|
+
.then(() => input.client?.tui?.publish?.({ body: { type: "tui.command.execute", properties: { command: encodeReply(value) } } as never }))
|
|
670
|
+
.catch(() => undefined);
|
|
671
|
+
};
|
|
672
|
+
|
|
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, {
|
|
214
727
|
mirror: clm.mirrorPath,
|
|
215
|
-
current
|
|
728
|
+
current,
|
|
216
729
|
budget: clm.resolvedBudget()?.budget,
|
|
217
730
|
instructions: argument,
|
|
218
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
|
+
const holdsPrompt = (raw: OcMessage[], prompt: string) =>
|
|
750
|
+
raw.some((message) => message.info.role === "user" && message.parts.some((part) => part.type === "text" && part.text === prompt));
|
|
751
|
+
|
|
752
|
+
/** Whether OpenCode reports the session busy or retrying; false when the client cannot tell. */
|
|
753
|
+
async function sessionBusy(sessionID: string): Promise<boolean> {
|
|
754
|
+
const status = input.client?.session?.status;
|
|
755
|
+
if (typeof status !== "function") return false;
|
|
756
|
+
try {
|
|
757
|
+
const result = await input.client.session.status();
|
|
758
|
+
const entry = (result?.data as Record<string, { type?: string }> | undefined)?.[sessionID];
|
|
759
|
+
return entry?.type === "busy" || entry?.type === "retry";
|
|
760
|
+
} catch {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** The session's stored messages, or undefined when the client cannot list them. */
|
|
766
|
+
async function sessionMessages(sessionID: string): Promise<OcMessage[] | undefined> {
|
|
767
|
+
const list = input.client?.session?.messages;
|
|
768
|
+
if (typeof list !== "function") return undefined;
|
|
769
|
+
try {
|
|
770
|
+
const result = await input.client.session.messages({ path: { id: sessionID } });
|
|
771
|
+
return Array.isArray(result?.data) ? (result.data as unknown as OcMessage[]) : undefined;
|
|
772
|
+
} catch {
|
|
773
|
+
return undefined;
|
|
774
|
+
}
|
|
219
775
|
}
|
|
220
776
|
|
|
221
777
|
const hooks: Hooks = {
|
|
222
778
|
async config(config) {
|
|
779
|
+
liveConfig = config as { compaction?: { auto?: boolean; reserved?: number } };
|
|
780
|
+
const auto = liveConfig.compaction?.auto;
|
|
781
|
+
userAutoCompaction = typeof auto === "boolean" ? auto : undefined;
|
|
782
|
+
userHadCompaction = liveConfig.compaction !== undefined;
|
|
783
|
+
// `enabled: false` returns no hooks at all (above), so this runs only with CLM enabled;
|
|
784
|
+
// the guard keeps it that way if that early return ever moves.
|
|
785
|
+
if (settings.enabled && settings.compaction !== "auto") applyCompactionMode(liveConfig, settings.compaction, userAutoCompaction);
|
|
223
786
|
if (settings.commands) {
|
|
224
787
|
const commands = (config.command ??= {});
|
|
225
788
|
const ours = {
|
|
@@ -263,12 +826,15 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
263
826
|
return;
|
|
264
827
|
}
|
|
265
828
|
await clm.refreshSettings();
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
829
|
+
// Without a mirror the model gets no protocol text (pi-clm's before_agent_start).
|
|
830
|
+
if (!clm.enabled || clm.mirrorUnavailable !== undefined) return;
|
|
831
|
+
const limit = (hookInput.model as { limit?: { context?: number; input?: number; output?: number } } | undefined)?.limit;
|
|
832
|
+
if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined, ...(limit.input ? { input: limit.input } : {}) };
|
|
833
|
+
if (hookInput.model) clm.limitsKnown = true;
|
|
834
|
+
// notices-only: no protocol section (there is no mirror); the steering document stays.
|
|
835
|
+
const sections = clm.noticesOnly ? [] : [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
|
|
270
836
|
if (clm.steering) sections.push(steeringPromptSection(clm.steering));
|
|
271
|
-
output.system.push(sections.join("\n\n"));
|
|
837
|
+
if (sections.length > 0) output.system.push(sections.join("\n\n"));
|
|
272
838
|
clm.scope = { ...clm.scope, systemTokens: clm.textTokens(output.system.join("\n")) };
|
|
273
839
|
},
|
|
274
840
|
|
|
@@ -276,24 +842,87 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
276
842
|
const raw = output.messages as unknown as OcMessage[];
|
|
277
843
|
const sessionID = raw[0]?.info?.sessionID;
|
|
278
844
|
if (!sessionID) return;
|
|
845
|
+
const pendingPrompt = compactPending.get(sessionID)?.prompt;
|
|
846
|
+
if (pendingPrompt !== undefined && holdsPrompt(raw, pendingPrompt)) compactPending.delete(sessionID);
|
|
847
|
+
// A new model request: `one-tool` counts its tool calls from zero.
|
|
848
|
+
toolCalls.reset(sessionID);
|
|
279
849
|
let clm: ClmSession;
|
|
280
850
|
try {
|
|
281
851
|
clm = await session(sessionID);
|
|
282
852
|
} catch (error) {
|
|
283
|
-
|
|
853
|
+
// The request goes out with the raw history; say so once per session, not per step.
|
|
854
|
+
if (!openFailures.has(sessionID)) {
|
|
855
|
+
openFailures.add(sessionID);
|
|
856
|
+
toast(`not active for session ${sessionID}, requests carry the raw history: ${describe(error)}`, "error");
|
|
857
|
+
log("error", `not active for session ${sessionID}: ${describe(error)}`);
|
|
858
|
+
}
|
|
859
|
+
releaseFlag(sessionID);
|
|
284
860
|
return;
|
|
285
861
|
}
|
|
862
|
+
// Stamp (and detect a fork) once per process, only while CLM edits this session:
|
|
863
|
+
// a session with CLM off is left untouched in OpenCode's store.
|
|
864
|
+
if (!linked.has(sessionID)) {
|
|
865
|
+
await clm.refreshSettings().catch(() => undefined);
|
|
866
|
+
if (clm.enabled) {
|
|
867
|
+
linked.add(sessionID);
|
|
868
|
+
await linkFork(clm);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
286
871
|
const tools = toolTokens();
|
|
287
872
|
if (tools !== undefined) clm.scope = { ...clm.scope, toolTokens: tools };
|
|
873
|
+
const compaction = clm.compacting;
|
|
874
|
+
if (compaction) compactionRequests.add(sessionID);
|
|
875
|
+
else compactionRequests.delete(sessionID);
|
|
876
|
+
if (!compaction) {
|
|
877
|
+
// K5: the step before this request reached OpenCode's threshold while the flag this
|
|
878
|
+
// session set was false, so OpenCode skipped a threshold compaction (prompt.ts:1161).
|
|
879
|
+
const usable = thresholdOf(clm);
|
|
880
|
+
const cancelled = thresholds.observe(sessionID, lastFinishedStep(raw), usable);
|
|
881
|
+
if (cancelled && usable !== undefined) {
|
|
882
|
+
await clm.log({ event: "compaction-cancelled", reason: "threshold", setting: cancelled.by, count: cancelled.count, usable });
|
|
883
|
+
if (cancelled.by === "off") {
|
|
884
|
+
clm.queueNotice(thresholdCancelledNoticeText(clm.noticesOnly ? undefined : clm.mirrorPath));
|
|
885
|
+
toast(thresholdCancelledText(cancelled.count, usable));
|
|
886
|
+
} else toast(thresholdPausedText(cancelled.count, usable, clm.guardLimit()));
|
|
887
|
+
}
|
|
888
|
+
}
|
|
288
889
|
try {
|
|
289
890
|
const result = await clm.transform(raw);
|
|
290
891
|
replaceInPlace(raw, result.messages);
|
|
892
|
+
if (clm.annotationsImported) {
|
|
893
|
+
clm.annotationsImported = false;
|
|
894
|
+
await persistAnnotationsOf(sessionID);
|
|
895
|
+
}
|
|
896
|
+
// Instance-wide flag, set per request from this session's setting: OpenCode reads
|
|
897
|
+
// it after this step and before the next one (src/compaction.ts). Sessions running
|
|
898
|
+
// concurrently in one server overwrite each other's value.
|
|
899
|
+
if (!compaction && liveConfig) {
|
|
900
|
+
// Without a mirror, OpenCode's compaction is the only way to shrink: leave it as configured.
|
|
901
|
+
const active = clm.settings.enabled && clm.enabled && clm.mirrorUnavailable === undefined;
|
|
902
|
+
const mode = active ? clm.settings.compaction : "auto";
|
|
903
|
+
// K1, pi's `auto`: while the guard enforces a budget, pause threshold compaction for
|
|
904
|
+
// a request whose count can reach OpenCode's threshold (src/compaction.ts).
|
|
905
|
+
const usable = thresholdOf(clm);
|
|
906
|
+
const pause = active && mode === "auto" && userAutoCompaction !== false && clm.settings.guard !== "off" &&
|
|
907
|
+
clm.guardLimit() !== undefined && result.estimated !== undefined && usable !== undefined &&
|
|
908
|
+
thresholdReachable(result.estimated, usable, openCodeMaxOutput(clm.limits.output, Math.max(OPENCODE_OUTPUT_TOKEN_MAX, outputTokenMaxFlag() ?? 0)));
|
|
909
|
+
decideFlag(sessionID, pause ? false : compactionAuto(mode, userAutoCompaction));
|
|
910
|
+
if (pause !== (thresholds.by(sessionID) === "auto")) {
|
|
911
|
+
await clm.log({ event: "compaction-pause", paused: pause, estimated: result.estimated, usable });
|
|
912
|
+
}
|
|
913
|
+
thresholds.set(sessionID, pause ? "auto" : active && mode === "off" && userAutoCompaction !== false ? "off" : undefined);
|
|
914
|
+
}
|
|
291
915
|
if (result.alert) {
|
|
292
916
|
toast(result.alert, "warning");
|
|
293
917
|
log("warn", result.alert);
|
|
294
918
|
}
|
|
919
|
+
for (const error of result.errors ?? []) {
|
|
920
|
+
toast(error, "error");
|
|
921
|
+
log("error", `${sessionID}: ${error}`);
|
|
922
|
+
}
|
|
295
923
|
} catch (error) {
|
|
296
|
-
// Fail open: the request goes out with the raw history.
|
|
924
|
+
// Fail open: the request goes out with the raw history, under the user's flag.
|
|
925
|
+
if (!compaction) releaseFlag(sessionID);
|
|
297
926
|
await clm.log({ event: "error", message: describe(error), stack: error instanceof Error ? error.stack : undefined });
|
|
298
927
|
toast(`transform failed: ${describe(error)}`, "error");
|
|
299
928
|
}
|
|
@@ -302,6 +931,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
302
931
|
async "experimental.session.compacting"(hookInput, output) {
|
|
303
932
|
const clm = await opened(hookInput.sessionID);
|
|
304
933
|
if (!clm) return;
|
|
934
|
+
try {
|
|
935
|
+
await clm.commitBeforeCompaction();
|
|
936
|
+
} catch (error) {
|
|
937
|
+
await clm.log({ event: "error", message: `commit before compaction: ${describe(error)}` });
|
|
938
|
+
}
|
|
305
939
|
clm.compacting = true;
|
|
306
940
|
try {
|
|
307
941
|
const context = await clm.compactionContext();
|
|
@@ -313,13 +947,65 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
313
947
|
|
|
314
948
|
async "experimental.compaction.autocontinue"(hookInput) {
|
|
315
949
|
const clm = await opened(hookInput.sessionID);
|
|
316
|
-
if (clm)
|
|
950
|
+
if (!clm) return;
|
|
951
|
+
clm.compacted = true;
|
|
952
|
+
thresholds.reset(hookInput.sessionID);
|
|
953
|
+
if (!clm.settings.enabled || !clm.enabled) return;
|
|
954
|
+
// Automatic compaction only (manual /compact does not reach this hook).
|
|
955
|
+
const overflow = hookInput.overflow === true;
|
|
956
|
+
await clm.log({ event: "native-compaction", reason: overflow ? "overflow" : "threshold", setting: clm.settings.compaction });
|
|
957
|
+
toast(nativeCompactionText(overflow, clm.settings.compaction, clm.noticesOnly));
|
|
317
958
|
},
|
|
318
959
|
|
|
319
960
|
async event({ event }) {
|
|
961
|
+
const command = commandOf(event);
|
|
962
|
+
if (command !== undefined) {
|
|
963
|
+
const request = decodeRequest(command);
|
|
964
|
+
if (!request) return;
|
|
965
|
+
let answer: Omit<ChannelReply, "v" | "id">;
|
|
966
|
+
try {
|
|
967
|
+
answer = await channelRequest(request);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
answer = { ok: false, text: describe(error) };
|
|
970
|
+
log("warn", `channel ${request.op} for ${request.session}: ${answer.text}`);
|
|
971
|
+
}
|
|
972
|
+
reply({ ...answer, v: CHANNEL_VERSION, id: request.id });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (event.type === "session.idle" || event.type === "session.error") {
|
|
976
|
+
const ended = (event.properties as { sessionID?: string }).sessionID;
|
|
977
|
+
if (ended) {
|
|
978
|
+
compactPending.delete(ended);
|
|
979
|
+
// Not on session.error: the overflow check below reads the flag OpenCode read.
|
|
980
|
+
if (event.type === "session.idle") endFlag(ended);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (event.type === "session.error") {
|
|
984
|
+
const { sessionID, error } = event.properties as { sessionID?: string; error?: { name?: string } };
|
|
985
|
+
if (!sessionID || error?.name !== "ContextOverflowError") return;
|
|
986
|
+
const clm = await opened(sessionID);
|
|
987
|
+
if (!clm || !clm.settings.enabled || !clm.enabled) return;
|
|
988
|
+
// OpenCode surfaced the overflow instead of compacting when the flag it read was
|
|
989
|
+
// false (processor.ts:620-628). The event follows that read synchronously, so the
|
|
990
|
+
// live flag, not this session's setting, says what happened (another session may
|
|
991
|
+
// have set it). An overflow of the compaction request itself is not this case.
|
|
992
|
+
if (liveConfig?.compaction?.auto !== false || compactionRequests.has(sessionID)) return;
|
|
993
|
+
if (clm.mirrorUnavailable !== undefined) {
|
|
994
|
+
await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
|
|
995
|
+
toast("The provider rejected the request as too long, and OpenCode's automatic compaction is off. Run /compact.", "warning");
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
clm.queueNotice(overflowNotCompactedText(clm.noticesOnly ? undefined : clm.mirrorPath));
|
|
999
|
+
await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
|
|
1000
|
+
toast(clm.noticesOnly
|
|
1001
|
+
? "The provider rejected the request as too long, and OpenCode's automatic compaction is off. Run /compact."
|
|
1002
|
+
: "The provider rejected the request as too long, and OpenCode's automatic compaction is off. Edit the mirror or run /compact.", "warning");
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
320
1005
|
if (event.type !== "session.compacted") return;
|
|
321
1006
|
const clm = await opened(event.properties.sessionID);
|
|
322
1007
|
if (clm) clm.compacted = true;
|
|
1008
|
+
thresholds.reset(event.properties.sessionID);
|
|
323
1009
|
},
|
|
324
1010
|
|
|
325
1011
|
async "tool.definition"(hookInput, output) {
|
|
@@ -337,7 +1023,20 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
337
1023
|
toolSizes.set(hookInput.toolID, Math.ceil((size / 4) * settings.estimateFactor));
|
|
338
1024
|
},
|
|
339
1025
|
|
|
1026
|
+
async "tool.execute.before"(hookInput) {
|
|
1027
|
+
// Counted before any await: parallel calls of one response arrive concurrently.
|
|
1028
|
+
const position = toolCalls.next(hookInput.sessionID);
|
|
1029
|
+
if (position < 2) return;
|
|
1030
|
+
const clm = await opened(hookInput.sessionID);
|
|
1031
|
+
if (!clm || !clm.settings.enabled || !clm.enabled || !clm.settings.oneTool) return;
|
|
1032
|
+
await clm.log({ event: "tool-blocked", tool: hookInput.tool, position });
|
|
1033
|
+
// OpenCode turns the throw into the call's error result, which the model sees.
|
|
1034
|
+
throw new Error(oneToolText(hookInput.tool, position));
|
|
1035
|
+
},
|
|
1036
|
+
|
|
340
1037
|
async "tool.execute.after"(hookInput, output) {
|
|
1038
|
+
// Before any await: the session's own flag back after a subagent's requests.
|
|
1039
|
+
if (flagDecisions.has(hookInput.sessionID)) applyFlag(flagDecisions.get(hookInput.sessionID));
|
|
341
1040
|
const clm = await opened(hookInput.sessionID);
|
|
342
1041
|
if (!clm || typeof output.output !== "string") return;
|
|
343
1042
|
// Runs inside the tool's own call: a throw here would fail the model's command.
|
|
@@ -346,10 +1045,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
346
1045
|
receipt = clm.receipt(hookInput.tool, hookInput.args, input.directory);
|
|
347
1046
|
} catch (error) {
|
|
348
1047
|
await clm.log({ event: "receipt-error", tool: hookInput.tool, message: describe(error) });
|
|
349
|
-
return;
|
|
350
1048
|
}
|
|
351
|
-
if (
|
|
352
|
-
|
|
1049
|
+
if (receipt) output.output = output.output ? `${output.output}${output.output.endsWith("\n") ? "\n" : "\n\n"}${receipt}` : receipt;
|
|
1050
|
+
// Last, so it measures the result as the model gets it. Not for failed calls:
|
|
1051
|
+
// OpenCode runs this hook only after a successful execute (session/tools.ts:105-123).
|
|
1052
|
+
const trailer = clm.sizeTrailer(output.output);
|
|
1053
|
+
if (trailer) output.output = `${output.output}${trailer}`;
|
|
353
1054
|
},
|
|
354
1055
|
|
|
355
1056
|
async "command.execute.before"(hookInput, output) {
|
|
@@ -359,9 +1060,20 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
359
1060
|
try {
|
|
360
1061
|
const clm = await session(hookInput.sessionID);
|
|
361
1062
|
const argument = (hookInput.arguments ?? "").trim();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
:
|
|
1063
|
+
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);
|
|
1076
|
+
}
|
|
365
1077
|
} catch (error) {
|
|
366
1078
|
const message = `/${hookInput.command} failed: ${describe(error)}`;
|
|
367
1079
|
toast(message, "error");
|
|
@@ -378,6 +1090,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
378
1090
|
schema: tool.schema,
|
|
379
1091
|
store: async (sessionID) => (await session(sessionID)).annotations,
|
|
380
1092
|
block: async (sessionID, blockId) => (await session(sessionID)).blockSource(blockId),
|
|
1093
|
+
onChange: persistAnnotationsOf,
|
|
381
1094
|
}),
|
|
382
1095
|
};
|
|
383
1096
|
return hooks;
|