opencode-clm 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/NOTICE +9 -1
- package/README.md +112 -299
- package/index.ts +778 -34
- package/package.json +19 -4
- package/src/atomic.ts +27 -0
- package/src/budget.ts +12 -10
- package/src/channel.ts +187 -0
- package/src/clm.ts +610 -27
- package/src/commands.ts +18 -0
- package/src/compaction.ts +243 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +41 -3
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +205 -0
- package/src/panel/command.ts +75 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +107 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +569 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +460 -0
- package/src/panel/view.ts +554 -0
- package/src/presentation.ts +6 -0
- package/src/projection.ts +8 -0
- package/src/session-files.ts +286 -0
- package/src/settings-table.ts +454 -0
- package/src/settings.ts +33 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +241 -0
- package/src/tui/intercept.ts +60 -0
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +205 -0
- package/src/tui/plugin.ts +466 -0
- package/src/tui/remote.ts +199 -0
- package/tui.ts +7 -0
package/index.ts
CHANGED
|
@@ -27,19 +27,52 @@
|
|
|
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
|
-
import {
|
|
61
|
+
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
|
+
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";
|
|
38
72
|
import { loadSteeringDocument, steeringPromptSection, type SteeringDocument } from "./src/steering.ts";
|
|
39
73
|
|
|
40
74
|
export const PLUGIN_ID = "opencode-clm";
|
|
41
|
-
export
|
|
42
|
-
export const COMPACT_COMMAND = "clm-compact";
|
|
75
|
+
export { COMPACT_COMMAND, STATUS_COMMAND } from "./src/commands.ts";
|
|
43
76
|
|
|
44
77
|
/** Opening words of the system prompts of OpenCode 1.18 helper agents (title, summary, compaction). */
|
|
45
78
|
const HELPER_PROMPTS = [
|
|
@@ -48,7 +81,9 @@ const HELPER_PROMPTS = [
|
|
|
48
81
|
"You are a context summarization agent",
|
|
49
82
|
];
|
|
50
83
|
|
|
51
|
-
const STATUS_USAGE = "Usage: /clm [status | path | on | off | 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;
|
|
52
87
|
|
|
53
88
|
type ToastVariant = "info" | "success" | "warning" | "error";
|
|
54
89
|
|
|
@@ -67,6 +102,13 @@ export function checkSessionID(sessionID: unknown): string {
|
|
|
67
102
|
return sessionID;
|
|
68
103
|
}
|
|
69
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
|
+
|
|
70
112
|
function isHelperPrompt(system: readonly string[]): boolean {
|
|
71
113
|
const first = system[0]?.trimStart() ?? "";
|
|
72
114
|
return HELPER_PROMPTS.some((prompt) => first.startsWith(prompt));
|
|
@@ -80,8 +122,18 @@ function relay(text: string): string {
|
|
|
80
122
|
export const server: Plugin = async (input: PluginInput, options?: PluginOptions): Promise<Hooks> => {
|
|
81
123
|
const settings: ClmSettings = resolveSettings(options ?? {}, process.env, input.directory);
|
|
82
124
|
if (!settings.enabled) return {};
|
|
83
|
-
// Read once at load
|
|
84
|
-
|
|
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
|
+
}
|
|
85
137
|
|
|
86
138
|
const sessions = new Map<string, Promise<ClmSession>>();
|
|
87
139
|
/** Estimated tokens per tool schema, from `tool.definition` (no session id there). */
|
|
@@ -100,14 +152,309 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
100
152
|
.then(() => input.client?.app?.log?.({ body: { service: PLUGIN_ID, level, message } }))
|
|
101
153
|
.catch(() => undefined);
|
|
102
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
|
+
}
|
|
103
218
|
/** Command names this plugin defined; a user's own `clm` / `clm-compact` stays theirs. */
|
|
104
219
|
const ownCommands = new Set<string>();
|
|
105
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
|
+
|
|
106
453
|
const session = (sessionID: string): Promise<ClmSession> => {
|
|
107
454
|
const id = checkSessionID(sessionID);
|
|
108
455
|
let existing = sessions.get(id);
|
|
109
456
|
if (!existing) {
|
|
110
|
-
existing =
|
|
457
|
+
existing = openSession(id).then(async (clm) => {
|
|
111
458
|
if (clm.loadWarning) toast(`state reset for ${id}: ${clm.loadWarning}`, "warning");
|
|
112
459
|
return clm;
|
|
113
460
|
});
|
|
@@ -132,55 +479,313 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
132
479
|
return total;
|
|
133
480
|
};
|
|
134
481
|
|
|
135
|
-
|
|
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 };
|
|
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
|
+
// No mirror: name why, never the temporary directory (the text reaches the model).
|
|
526
|
+
if (clm.mirrorUnavailable !== undefined) {
|
|
527
|
+
model.mirrorUnavailable = clm.mirrorUnavailable;
|
|
528
|
+
model.mirrorPath = `unavailable (${clm.mirrorUnavailable})`;
|
|
529
|
+
}
|
|
530
|
+
const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}) };
|
|
531
|
+
model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
|
|
532
|
+
return panelPageText(model, page);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function statusCommand(clm: ClmSession, args: string): Promise<string> {
|
|
536
|
+
await clm.refreshSettings();
|
|
537
|
+
const words = args.split(/\s+/).filter(Boolean);
|
|
538
|
+
const argument = (words[0] ?? "").toLowerCase();
|
|
539
|
+
if (argument === "config") return configCommand(clm, words.slice(1));
|
|
540
|
+
// pi's shorthand: `/clm budget <value>` = `/clm config budget <value>`.
|
|
541
|
+
if (argument === "budget") return configCommand(clm, ["budget", ...words.slice(1)]);
|
|
542
|
+
const command = parseClmCommand(`/clm ${args}`);
|
|
543
|
+
if (args !== "" && command?.kind === "open") return pageCommand(clm, command.page);
|
|
544
|
+
if (words.length > 1) return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
|
|
136
545
|
switch (argument) {
|
|
137
546
|
case "":
|
|
138
547
|
case "status": {
|
|
139
|
-
const text = statusText(clm.status(
|
|
548
|
+
const text = statusText(clm.status());
|
|
140
549
|
toast(text.split("\n").slice(0, 3).join("\n"));
|
|
141
550
|
return text;
|
|
142
551
|
}
|
|
143
552
|
case "path":
|
|
144
|
-
return `CLM mirror: ${clm.mirrorPath}`;
|
|
553
|
+
return clm.mirrorUnavailable === undefined ? `CLM mirror: ${clm.mirrorPath}` : `CLM mirror: unavailable (${clm.mirrorUnavailable})`;
|
|
145
554
|
case "on":
|
|
146
555
|
case "off": {
|
|
147
|
-
|
|
556
|
+
// The same override the TUI and `/clm config editing` write.
|
|
557
|
+
await setSetting(clm, "editing", argument);
|
|
148
558
|
toast(`CLM ${argument} for this session`);
|
|
559
|
+
if (clm.mirrorUnavailable !== undefined) {
|
|
560
|
+
return argument === "on"
|
|
561
|
+
? "CLM is on for this session. No mirror is available: requests carry the raw history plus the continuity annotations."
|
|
562
|
+
: "CLM is off for this session: requests carry the raw history.";
|
|
563
|
+
}
|
|
149
564
|
return argument === "on"
|
|
150
565
|
? "CLM is on for this session: the mirror is refreshed before the next request."
|
|
151
566
|
: "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
|
|
152
567
|
}
|
|
153
568
|
case "reset": {
|
|
154
|
-
await
|
|
155
|
-
toast("CLM revision dropped");
|
|
569
|
+
await dropRevision(clm);
|
|
156
570
|
return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
|
|
157
571
|
}
|
|
158
572
|
default:
|
|
159
|
-
return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(
|
|
573
|
+
return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Session ids the server confirmed; a panel reload sends several requests per session. */
|
|
578
|
+
const knownSessions = new Set<string>();
|
|
579
|
+
|
|
580
|
+
/** False when the server does not know the session (the request came from elsewhere). */
|
|
581
|
+
async function knownSession(sessionID: string): Promise<boolean> {
|
|
582
|
+
if (knownSessions.has(sessionID) || sessions.has(sessionID)) return true;
|
|
583
|
+
const get = input.client?.session?.get;
|
|
584
|
+
if (typeof get !== "function") return true;
|
|
585
|
+
try {
|
|
586
|
+
const result = await input.client.session.get({ path: { id: sessionID } });
|
|
587
|
+
const known = Boolean(result?.data) && !result?.error;
|
|
588
|
+
if (known) knownSessions.add(sessionID);
|
|
589
|
+
return known;
|
|
590
|
+
} catch {
|
|
591
|
+
return false;
|
|
160
592
|
}
|
|
161
593
|
}
|
|
162
594
|
|
|
163
|
-
|
|
164
|
-
|
|
595
|
+
/** A request from the TUI over the channel (src/channel.ts): apply it, answer with its id. */
|
|
596
|
+
async function channelRequest(request: ChannelRequest): Promise<Omit<ChannelReply, "v" | "id">> {
|
|
597
|
+
const sessionID = checkSessionID(request.session);
|
|
598
|
+
if (!(await knownSession(sessionID))) throw new Error(`no session ${sessionID} on this server`);
|
|
599
|
+
// A session that fell back from the configured mirrorDir (openSession) lives elsewhere.
|
|
600
|
+
const opened = await sessions.get(sessionID)?.catch(() => undefined);
|
|
601
|
+
const directory = opened?.store.directory ?? sessionDirectory(settings.mirrorDir, sessionID);
|
|
602
|
+
switch (request.op) {
|
|
603
|
+
case "locate":
|
|
604
|
+
return { ok: true, text: "", directory, root: input.directory };
|
|
605
|
+
case "read": {
|
|
606
|
+
const path = await insideSession(directory, request.path);
|
|
607
|
+
if (path === undefined) return { ok: true, text: "" };
|
|
608
|
+
const size = (await stat(path).catch(missingAsUndefined))?.size;
|
|
609
|
+
if (size === undefined) return { ok: true, text: "" };
|
|
610
|
+
if (size > MAX_READ_BYTES) {
|
|
611
|
+
return { ok: false, text: `${request.path} is ${size} bytes, more than the ${MAX_READ_BYTES} a channel reply carries` };
|
|
612
|
+
}
|
|
613
|
+
const content = await readFile(path, "utf8").catch(missingAsUndefined);
|
|
614
|
+
return { ok: true, text: "", ...(content !== undefined ? { content } : {}) };
|
|
615
|
+
}
|
|
616
|
+
case "list": {
|
|
617
|
+
const path = await insideSession(directory, request.path);
|
|
618
|
+
if (path === undefined) return { ok: true, text: "" };
|
|
619
|
+
const names = await readdir(path).catch(missingAsUndefined);
|
|
620
|
+
return { ok: true, text: "", ...(names !== undefined ? { names } : {}) };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const clm = await session(sessionID);
|
|
624
|
+
await clm.refreshSettings();
|
|
625
|
+
switch (request.op) {
|
|
626
|
+
case "set": {
|
|
627
|
+
const text = await setSetting(clm, request.setting, request.value);
|
|
628
|
+
toast(`CLM ${text}.`);
|
|
629
|
+
return { ok: true, text };
|
|
630
|
+
}
|
|
631
|
+
case "settings-reset":
|
|
632
|
+
await clearSettings(clm);
|
|
633
|
+
return { ok: true, text: "CLM settings reset to the defaults." };
|
|
634
|
+
case "reset":
|
|
635
|
+
await dropRevision(clm);
|
|
636
|
+
return { ok: true, text: "CLM revision dropped" };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* `path` inside the session directory, else a refusal; undefined when it does not exist.
|
|
642
|
+
* Checked twice: lexically, then with symlinks resolved on both sides, as OpenCode's own
|
|
643
|
+
* file API does (packages/core/src/filesystem.ts:66-71), so a link the model created in
|
|
644
|
+
* the mirror directory cannot point a `read` elsewhere.
|
|
645
|
+
*/
|
|
646
|
+
async function insideSession(directory: string, path: string): Promise<string | undefined> {
|
|
647
|
+
const outside = (root: string, target: string) => {
|
|
648
|
+
const rel = relative(root, target);
|
|
649
|
+
return isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`);
|
|
650
|
+
};
|
|
651
|
+
const target = resolve(directory, path);
|
|
652
|
+
if (outside(directory, target)) throw new Error(`${path} lies outside the session directory`);
|
|
653
|
+
const realRoot = await realpath(directory).catch(missingAsUndefined);
|
|
654
|
+
const realTarget = await realpath(target).catch(missingAsUndefined);
|
|
655
|
+
if (realRoot === undefined || realTarget === undefined) return undefined;
|
|
656
|
+
if (outside(realRoot, realTarget)) throw new Error(`${path} lies outside the session directory`);
|
|
657
|
+
return realTarget;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const reply = (value: ChannelReply) => {
|
|
661
|
+
void Promise.resolve()
|
|
662
|
+
.then(() => input.client?.tui?.publish?.({ body: { type: "tui.command.execute", properties: { command: encodeReply(value) } } as never }))
|
|
663
|
+
.catch(() => undefined);
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* `/clm-compact [instructions]`: the compaction prompt, with the size of the context as
|
|
668
|
+
* it would be sent now. pi's checks (pi src/index.ts compactCommand): CLM off and an empty
|
|
669
|
+
* context are refused with a toast; the model then gets a short relay instead of the
|
|
670
|
+
* prompt (the turn cannot be cancelled, see C14). OpenCode does not queue a command behind
|
|
671
|
+
* a busy session: `SessionPrompt.command` fires `command.execute.before` at once and its
|
|
672
|
+
* prompt joins the running loop (session/prompt.ts:1346, 1356-1466). Typed mid-run, the
|
|
673
|
+
* size is therefore measured mid-run, without the rest of the current turn; the toast says
|
|
674
|
+
* so. pi waits for idle instead; this port accepts the lag. A second invocation before the
|
|
675
|
+
* model received the first prompt is refused like pi's (`compactPending`, below).
|
|
676
|
+
*/
|
|
677
|
+
async function compactCommand(clm: ClmSession, sessionID: string, argument: string): Promise<string> {
|
|
678
|
+
const waiting = compactPending.get(sessionID);
|
|
679
|
+
if (waiting && Date.now() - waiting.at < COMPACT_PENDING_MS) {
|
|
680
|
+
const message = "A /clm-compact is already queued and the model has not received it yet.";
|
|
681
|
+
toast(message, "info");
|
|
682
|
+
return relay(`[CLM] ${message}`);
|
|
683
|
+
}
|
|
684
|
+
// Reserved before the first await, so a second invocation in the meantime is refused.
|
|
685
|
+
const entry: { prompt?: string; at: number } = { at: Date.now() };
|
|
686
|
+
compactPending.set(sessionID, entry);
|
|
687
|
+
const refuse = (message: string) => {
|
|
688
|
+
if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
|
|
689
|
+
toast(message, "warning");
|
|
690
|
+
return relay(`[CLM] ${message}`);
|
|
691
|
+
};
|
|
692
|
+
try {
|
|
693
|
+
return await compactPrompt(clm, sessionID, argument, entry, refuse);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
if (compactPending.get(sessionID) === entry) compactPending.delete(sessionID);
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function compactPrompt(
|
|
701
|
+
clm: ClmSession,
|
|
702
|
+
sessionID: string,
|
|
703
|
+
argument: string,
|
|
704
|
+
entry: { prompt?: string; at: number },
|
|
705
|
+
refuse: (message: string) => string,
|
|
706
|
+
): Promise<string> {
|
|
707
|
+
await clm.refreshSettings();
|
|
708
|
+
const template = loadCompactPrompt(clm.settings.compactPromptPath);
|
|
709
|
+
if (!clm.enabled) {
|
|
710
|
+
return refuse("CLM is off for this session, so there is no editable context to compact. Turn it on with /clm on.");
|
|
711
|
+
}
|
|
712
|
+
// Without a history (the client cannot list messages) the last request's estimate stands in.
|
|
713
|
+
const history = await sessionMessages(sessionID);
|
|
714
|
+
const current = history ? clm.idleEstimate(filterCompacted(history)) : clm.lastReading?.estimated ?? 0;
|
|
715
|
+
if (current === undefined) return refuse("Nothing to compact yet: the context is empty.");
|
|
716
|
+
const busy = await sessionBusy(sessionID);
|
|
717
|
+
toast(`Asked the model to compact its context (now about ${formatTokens(current)} tokens` +
|
|
718
|
+
`${busy ? ", measured before the current run finishes" : ""}).`);
|
|
719
|
+
const prompt = buildCompactPrompt(template, {
|
|
165
720
|
mirror: clm.mirrorPath,
|
|
166
|
-
current
|
|
721
|
+
current,
|
|
167
722
|
budget: clm.resolvedBudget()?.budget,
|
|
168
723
|
instructions: argument,
|
|
169
724
|
});
|
|
725
|
+
entry.prompt = prompt;
|
|
726
|
+
entry.at = Date.now();
|
|
727
|
+
return prompt;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Sessions with a `/clm-compact` prompt the model has not received yet, and that prompt.
|
|
732
|
+
* pi refuses a second `/clm-compact` while one waits (`compactPending`, pi src/index.ts:235,
|
|
733
|
+
* 997-1000): set when the command is accepted (:1008), cleared once its prompt is sent
|
|
734
|
+
* (:1046). OpenCode sends the prompt itself, into the running loop or a new one, so here
|
|
735
|
+
* pending ends at the first model request whose history holds the prompt (the
|
|
736
|
+
* `messages.transform` hook), or when the session goes idle or fails without one
|
|
737
|
+
* (`session.idle`, session/status.ts:43; `session.error`). If OpenCode's prompt() fails after
|
|
738
|
+
* the hook, neither event may come; an entry older than COMPACT_PENDING_MS no longer refuses.
|
|
739
|
+
*/
|
|
740
|
+
const compactPending = new Map<string, { prompt?: string; at: number }>();
|
|
741
|
+
|
|
742
|
+
const holdsPrompt = (raw: OcMessage[], prompt: string) =>
|
|
743
|
+
raw.some((message) => message.info.role === "user" && message.parts.some((part) => part.type === "text" && part.text === prompt));
|
|
744
|
+
|
|
745
|
+
/** Whether OpenCode reports the session busy or retrying; false when the client cannot tell. */
|
|
746
|
+
async function sessionBusy(sessionID: string): Promise<boolean> {
|
|
747
|
+
const status = input.client?.session?.status;
|
|
748
|
+
if (typeof status !== "function") return false;
|
|
749
|
+
try {
|
|
750
|
+
const result = await input.client.session.status();
|
|
751
|
+
const entry = (result?.data as Record<string, { type?: string }> | undefined)?.[sessionID];
|
|
752
|
+
return entry?.type === "busy" || entry?.type === "retry";
|
|
753
|
+
} catch {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** The session's stored messages, or undefined when the client cannot list them. */
|
|
759
|
+
async function sessionMessages(sessionID: string): Promise<OcMessage[] | undefined> {
|
|
760
|
+
const list = input.client?.session?.messages;
|
|
761
|
+
if (typeof list !== "function") return undefined;
|
|
762
|
+
try {
|
|
763
|
+
const result = await input.client.session.messages({ path: { id: sessionID } });
|
|
764
|
+
return Array.isArray(result?.data) ? (result.data as unknown as OcMessage[]) : undefined;
|
|
765
|
+
} catch {
|
|
766
|
+
return undefined;
|
|
767
|
+
}
|
|
170
768
|
}
|
|
171
769
|
|
|
172
770
|
const hooks: Hooks = {
|
|
173
771
|
async config(config) {
|
|
772
|
+
liveConfig = config as { compaction?: { auto?: boolean; reserved?: number } };
|
|
773
|
+
const auto = liveConfig.compaction?.auto;
|
|
774
|
+
userAutoCompaction = typeof auto === "boolean" ? auto : undefined;
|
|
775
|
+
userHadCompaction = liveConfig.compaction !== undefined;
|
|
776
|
+
// `enabled: false` returns no hooks at all (above), so this runs only with CLM enabled;
|
|
777
|
+
// the guard keeps it that way if that early return ever moves.
|
|
778
|
+
if (settings.enabled && settings.compaction !== "auto") applyCompactionMode(liveConfig, settings.compaction, userAutoCompaction);
|
|
174
779
|
if (settings.commands) {
|
|
175
780
|
const commands = (config.command ??= {});
|
|
176
781
|
const ours = {
|
|
177
782
|
[STATUS_COMMAND]: {
|
|
178
783
|
description: "CLM: status of the editable context; /clm path | on | off | reset",
|
|
179
|
-
template:
|
|
784
|
+
template: STATUS_TEMPLATE,
|
|
180
785
|
},
|
|
181
786
|
[COMPACT_COMMAND]: {
|
|
182
787
|
description: "CLM: ask the model to compact its context by editing the mirror",
|
|
183
|
-
template:
|
|
788
|
+
template: COMPACT_TEMPLATE,
|
|
184
789
|
},
|
|
185
790
|
};
|
|
186
791
|
for (const [name, definition] of Object.entries(ours)) {
|
|
@@ -213,11 +818,13 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
213
818
|
} catch {
|
|
214
819
|
return;
|
|
215
820
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (
|
|
821
|
+
await clm.refreshSettings();
|
|
822
|
+
// Without a mirror the model gets no protocol text (pi-clm's before_agent_start).
|
|
823
|
+
if (!clm.enabled || clm.mirrorUnavailable !== undefined) return;
|
|
824
|
+
const limit = (hookInput.model as { limit?: { context?: number; input?: number; output?: number } } | undefined)?.limit;
|
|
825
|
+
if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined, ...(limit.input ? { input: limit.input } : {}) };
|
|
219
826
|
const sections = [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
|
|
220
|
-
if (steering) sections.push(steeringPromptSection(steering));
|
|
827
|
+
if (clm.steering) sections.push(steeringPromptSection(clm.steering));
|
|
221
828
|
output.system.push(sections.join("\n\n"));
|
|
222
829
|
clm.scope = { ...clm.scope, systemTokens: clm.textTokens(output.system.join("\n")) };
|
|
223
830
|
},
|
|
@@ -226,24 +833,87 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
226
833
|
const raw = output.messages as unknown as OcMessage[];
|
|
227
834
|
const sessionID = raw[0]?.info?.sessionID;
|
|
228
835
|
if (!sessionID) return;
|
|
836
|
+
const pendingPrompt = compactPending.get(sessionID)?.prompt;
|
|
837
|
+
if (pendingPrompt !== undefined && holdsPrompt(raw, pendingPrompt)) compactPending.delete(sessionID);
|
|
838
|
+
// A new model request: `one-tool` counts its tool calls from zero.
|
|
839
|
+
toolCalls.reset(sessionID);
|
|
229
840
|
let clm: ClmSession;
|
|
230
841
|
try {
|
|
231
842
|
clm = await session(sessionID);
|
|
232
843
|
} catch (error) {
|
|
233
|
-
|
|
844
|
+
// The request goes out with the raw history; say so once per session, not per step.
|
|
845
|
+
if (!openFailures.has(sessionID)) {
|
|
846
|
+
openFailures.add(sessionID);
|
|
847
|
+
toast(`not active for session ${sessionID}, requests carry the raw history: ${describe(error)}`, "error");
|
|
848
|
+
log("error", `not active for session ${sessionID}: ${describe(error)}`);
|
|
849
|
+
}
|
|
850
|
+
releaseFlag(sessionID);
|
|
234
851
|
return;
|
|
235
852
|
}
|
|
853
|
+
// Stamp (and detect a fork) once per process, only while CLM edits this session:
|
|
854
|
+
// a session with CLM off is left untouched in OpenCode's store.
|
|
855
|
+
if (!linked.has(sessionID)) {
|
|
856
|
+
await clm.refreshSettings().catch(() => undefined);
|
|
857
|
+
if (clm.enabled) {
|
|
858
|
+
linked.add(sessionID);
|
|
859
|
+
await linkFork(clm);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
236
862
|
const tools = toolTokens();
|
|
237
863
|
if (tools !== undefined) clm.scope = { ...clm.scope, toolTokens: tools };
|
|
864
|
+
const compaction = clm.compacting;
|
|
865
|
+
if (compaction) compactionRequests.add(sessionID);
|
|
866
|
+
else compactionRequests.delete(sessionID);
|
|
867
|
+
if (!compaction) {
|
|
868
|
+
// K5: the step before this request reached OpenCode's threshold while the flag this
|
|
869
|
+
// session set was false, so OpenCode skipped a threshold compaction (prompt.ts:1161).
|
|
870
|
+
const usable = thresholdOf(clm);
|
|
871
|
+
const cancelled = thresholds.observe(sessionID, lastFinishedStep(raw), usable);
|
|
872
|
+
if (cancelled && usable !== undefined) {
|
|
873
|
+
await clm.log({ event: "compaction-cancelled", reason: "threshold", setting: cancelled.by, count: cancelled.count, usable });
|
|
874
|
+
if (cancelled.by === "off") {
|
|
875
|
+
clm.queueNotice(thresholdCancelledNoticeText(clm.mirrorPath));
|
|
876
|
+
toast(thresholdCancelledText(cancelled.count, usable));
|
|
877
|
+
} else toast(thresholdPausedText(cancelled.count, usable, clm.guardLimit()));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
238
880
|
try {
|
|
239
881
|
const result = await clm.transform(raw);
|
|
240
882
|
replaceInPlace(raw, result.messages);
|
|
883
|
+
if (clm.annotationsImported) {
|
|
884
|
+
clm.annotationsImported = false;
|
|
885
|
+
await persistAnnotationsOf(sessionID);
|
|
886
|
+
}
|
|
887
|
+
// Instance-wide flag, set per request from this session's setting: OpenCode reads
|
|
888
|
+
// it after this step and before the next one (src/compaction.ts). Sessions running
|
|
889
|
+
// concurrently in one server overwrite each other's value.
|
|
890
|
+
if (!compaction && liveConfig) {
|
|
891
|
+
// Without a mirror, OpenCode's compaction is the only way to shrink: leave it as configured.
|
|
892
|
+
const active = clm.settings.enabled && clm.enabled && clm.mirrorUnavailable === undefined;
|
|
893
|
+
const mode = active ? clm.settings.compaction : "auto";
|
|
894
|
+
// K1, pi's `auto`: while the guard enforces a budget, pause threshold compaction for
|
|
895
|
+
// a request whose count can reach OpenCode's threshold (src/compaction.ts).
|
|
896
|
+
const usable = thresholdOf(clm);
|
|
897
|
+
const pause = active && mode === "auto" && userAutoCompaction !== false && clm.settings.guard !== "off" &&
|
|
898
|
+
clm.guardLimit() !== undefined && result.estimated !== undefined && usable !== undefined &&
|
|
899
|
+
thresholdReachable(result.estimated, usable, openCodeMaxOutput(clm.limits.output, Math.max(OPENCODE_OUTPUT_TOKEN_MAX, outputTokenMaxFlag() ?? 0)));
|
|
900
|
+
decideFlag(sessionID, pause ? false : compactionAuto(mode, userAutoCompaction));
|
|
901
|
+
if (pause !== (thresholds.by(sessionID) === "auto")) {
|
|
902
|
+
await clm.log({ event: "compaction-pause", paused: pause, estimated: result.estimated, usable });
|
|
903
|
+
}
|
|
904
|
+
thresholds.set(sessionID, pause ? "auto" : active && mode === "off" && userAutoCompaction !== false ? "off" : undefined);
|
|
905
|
+
}
|
|
241
906
|
if (result.alert) {
|
|
242
907
|
toast(result.alert, "warning");
|
|
243
908
|
log("warn", result.alert);
|
|
244
909
|
}
|
|
910
|
+
for (const error of result.errors ?? []) {
|
|
911
|
+
toast(error, "error");
|
|
912
|
+
log("error", `${sessionID}: ${error}`);
|
|
913
|
+
}
|
|
245
914
|
} catch (error) {
|
|
246
|
-
// Fail open: the request goes out with the raw history.
|
|
915
|
+
// Fail open: the request goes out with the raw history, under the user's flag.
|
|
916
|
+
if (!compaction) releaseFlag(sessionID);
|
|
247
917
|
await clm.log({ event: "error", message: describe(error), stack: error instanceof Error ? error.stack : undefined });
|
|
248
918
|
toast(`transform failed: ${describe(error)}`, "error");
|
|
249
919
|
}
|
|
@@ -252,6 +922,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
252
922
|
async "experimental.session.compacting"(hookInput, output) {
|
|
253
923
|
const clm = await opened(hookInput.sessionID);
|
|
254
924
|
if (!clm) return;
|
|
925
|
+
try {
|
|
926
|
+
await clm.commitBeforeCompaction();
|
|
927
|
+
} catch (error) {
|
|
928
|
+
await clm.log({ event: "error", message: `commit before compaction: ${describe(error)}` });
|
|
929
|
+
}
|
|
255
930
|
clm.compacting = true;
|
|
256
931
|
try {
|
|
257
932
|
const context = await clm.compactionContext();
|
|
@@ -263,13 +938,63 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
263
938
|
|
|
264
939
|
async "experimental.compaction.autocontinue"(hookInput) {
|
|
265
940
|
const clm = await opened(hookInput.sessionID);
|
|
266
|
-
if (clm)
|
|
941
|
+
if (!clm) return;
|
|
942
|
+
clm.compacted = true;
|
|
943
|
+
thresholds.reset(hookInput.sessionID);
|
|
944
|
+
if (!clm.settings.enabled || !clm.enabled) return;
|
|
945
|
+
// Automatic compaction only (manual /compact does not reach this hook).
|
|
946
|
+
const overflow = hookInput.overflow === true;
|
|
947
|
+
await clm.log({ event: "native-compaction", reason: overflow ? "overflow" : "threshold", setting: clm.settings.compaction });
|
|
948
|
+
toast(nativeCompactionText(overflow, clm.settings.compaction));
|
|
267
949
|
},
|
|
268
950
|
|
|
269
951
|
async event({ event }) {
|
|
952
|
+
const command = commandOf(event);
|
|
953
|
+
if (command !== undefined) {
|
|
954
|
+
const request = decodeRequest(command);
|
|
955
|
+
if (!request) return;
|
|
956
|
+
let answer: Omit<ChannelReply, "v" | "id">;
|
|
957
|
+
try {
|
|
958
|
+
answer = await channelRequest(request);
|
|
959
|
+
} catch (error) {
|
|
960
|
+
answer = { ok: false, text: describe(error) };
|
|
961
|
+
log("warn", `channel ${request.op} for ${request.session}: ${answer.text}`);
|
|
962
|
+
}
|
|
963
|
+
reply({ ...answer, v: CHANNEL_VERSION, id: request.id });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (event.type === "session.idle" || event.type === "session.error") {
|
|
967
|
+
const ended = (event.properties as { sessionID?: string }).sessionID;
|
|
968
|
+
if (ended) {
|
|
969
|
+
compactPending.delete(ended);
|
|
970
|
+
// Not on session.error: the overflow check below reads the flag OpenCode read.
|
|
971
|
+
if (event.type === "session.idle") endFlag(ended);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (event.type === "session.error") {
|
|
975
|
+
const { sessionID, error } = event.properties as { sessionID?: string; error?: { name?: string } };
|
|
976
|
+
if (!sessionID || error?.name !== "ContextOverflowError") return;
|
|
977
|
+
const clm = await opened(sessionID);
|
|
978
|
+
if (!clm || !clm.settings.enabled || !clm.enabled) return;
|
|
979
|
+
// OpenCode surfaced the overflow instead of compacting when the flag it read was
|
|
980
|
+
// false (processor.ts:620-628). The event follows that read synchronously, so the
|
|
981
|
+
// live flag, not this session's setting, says what happened (another session may
|
|
982
|
+
// have set it). An overflow of the compaction request itself is not this case.
|
|
983
|
+
if (liveConfig?.compaction?.auto !== false || compactionRequests.has(sessionID)) return;
|
|
984
|
+
if (clm.mirrorUnavailable !== undefined) {
|
|
985
|
+
await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
|
|
986
|
+
toast("The provider rejected the request as too long, and OpenCode's automatic compaction is off. Run /compact.", "warning");
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
clm.queueNotice(overflowNotCompactedText(clm.mirrorPath));
|
|
990
|
+
await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
|
|
991
|
+
toast("The provider rejected the request as too long, and OpenCode's automatic compaction is off. Edit the mirror or run /compact.", "warning");
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
270
994
|
if (event.type !== "session.compacted") return;
|
|
271
995
|
const clm = await opened(event.properties.sessionID);
|
|
272
996
|
if (clm) clm.compacted = true;
|
|
997
|
+
thresholds.reset(event.properties.sessionID);
|
|
273
998
|
},
|
|
274
999
|
|
|
275
1000
|
async "tool.definition"(hookInput, output) {
|
|
@@ -287,7 +1012,20 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
287
1012
|
toolSizes.set(hookInput.toolID, Math.ceil((size / 4) * settings.estimateFactor));
|
|
288
1013
|
},
|
|
289
1014
|
|
|
1015
|
+
async "tool.execute.before"(hookInput) {
|
|
1016
|
+
// Counted before any await: parallel calls of one response arrive concurrently.
|
|
1017
|
+
const position = toolCalls.next(hookInput.sessionID);
|
|
1018
|
+
if (position < 2) return;
|
|
1019
|
+
const clm = await opened(hookInput.sessionID);
|
|
1020
|
+
if (!clm || !clm.settings.enabled || !clm.enabled || !clm.settings.oneTool) return;
|
|
1021
|
+
await clm.log({ event: "tool-blocked", tool: hookInput.tool, position });
|
|
1022
|
+
// OpenCode turns the throw into the call's error result, which the model sees.
|
|
1023
|
+
throw new Error(oneToolText(hookInput.tool, position));
|
|
1024
|
+
},
|
|
1025
|
+
|
|
290
1026
|
async "tool.execute.after"(hookInput, output) {
|
|
1027
|
+
// Before any await: the session's own flag back after a subagent's requests.
|
|
1028
|
+
if (flagDecisions.has(hookInput.sessionID)) applyFlag(flagDecisions.get(hookInput.sessionID));
|
|
291
1029
|
const clm = await opened(hookInput.sessionID);
|
|
292
1030
|
if (!clm || typeof output.output !== "string") return;
|
|
293
1031
|
// Runs inside the tool's own call: a throw here would fail the model's command.
|
|
@@ -296,10 +1034,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
296
1034
|
receipt = clm.receipt(hookInput.tool, hookInput.args, input.directory);
|
|
297
1035
|
} catch (error) {
|
|
298
1036
|
await clm.log({ event: "receipt-error", tool: hookInput.tool, message: describe(error) });
|
|
299
|
-
return;
|
|
300
1037
|
}
|
|
301
|
-
if (
|
|
302
|
-
|
|
1038
|
+
if (receipt) output.output = output.output ? `${output.output}${output.output.endsWith("\n") ? "\n" : "\n\n"}${receipt}` : receipt;
|
|
1039
|
+
// Last, so it measures the result as the model gets it. Not for failed calls:
|
|
1040
|
+
// OpenCode runs this hook only after a successful execute (session/tools.ts:105-123).
|
|
1041
|
+
const trailer = clm.sizeTrailer(output.output);
|
|
1042
|
+
if (trailer) output.output = `${output.output}${trailer}`;
|
|
303
1043
|
},
|
|
304
1044
|
|
|
305
1045
|
async "command.execute.before"(hookInput, output) {
|
|
@@ -309,9 +1049,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
309
1049
|
try {
|
|
310
1050
|
const clm = await session(hookInput.sessionID);
|
|
311
1051
|
const argument = (hookInput.arguments ?? "").trim();
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
:
|
|
1052
|
+
if (hookInput.command === STATUS_COMMAND) text = relay(await statusCommand(clm, argument));
|
|
1053
|
+
else if (clm.mirrorUnavailable !== undefined) {
|
|
1054
|
+
// pi-clm's compactCommand: nothing for the model to edit, so nothing is asked of it.
|
|
1055
|
+
toast(`The context mirror is unavailable, so the model cannot edit its context (${clm.mirrorUnavailable}).`, "warning");
|
|
1056
|
+
text = relay("[CLM] The context mirror is unavailable, so the model cannot edit its context.");
|
|
1057
|
+
} else text = await compactCommand(clm, hookInput.sessionID, argument);
|
|
315
1058
|
} catch (error) {
|
|
316
1059
|
const message = `/${hookInput.command} failed: ${describe(error)}`;
|
|
317
1060
|
toast(message, "error");
|
|
@@ -328,6 +1071,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
328
1071
|
schema: tool.schema,
|
|
329
1072
|
store: async (sessionID) => (await session(sessionID)).annotations,
|
|
330
1073
|
block: async (sessionID, blockId) => (await session(sessionID)).blockSource(blockId),
|
|
1074
|
+
onChange: persistAnnotationsOf,
|
|
331
1075
|
}),
|
|
332
1076
|
};
|
|
333
1077
|
return hooks;
|