opencode-clm 0.2.0 → 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/README.md +77 -66
- package/index.ts +730 -36
- package/package.json +6 -6
- package/src/budget.ts +6 -6
- package/src/channel.ts +187 -0
- package/src/clm.ts +369 -20
- 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 +40 -2
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/model.ts +23 -1
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +10 -1
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +51 -3
- package/src/settings.ts +31 -3
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +83 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +11 -0
- package/src/tui/plugin.ts +219 -33
- 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.
|
|
@@ -149,16 +507,13 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
149
507
|
const format = { modelWindow: clm.limits.context };
|
|
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,6 +522,11 @@ 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;
|
|
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
|
+
}
|
|
170
530
|
const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}) };
|
|
171
531
|
model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
|
|
172
532
|
return panelPageText(model, page);
|
|
@@ -177,6 +537,8 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
177
537
|
const words = args.split(/\s+/).filter(Boolean);
|
|
178
538
|
const argument = (words[0] ?? "").toLowerCase();
|
|
179
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)]);
|
|
180
542
|
const command = parseClmCommand(`/clm ${args}`);
|
|
181
543
|
if (args !== "" && command?.kind === "open") return pageCommand(clm, command.page);
|
|
182
544
|
if (words.length > 1) return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
|
|
@@ -188,20 +550,23 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
188
550
|
return text;
|
|
189
551
|
}
|
|
190
552
|
case "path":
|
|
191
|
-
return `CLM mirror: ${clm.mirrorPath}`;
|
|
553
|
+
return clm.mirrorUnavailable === undefined ? `CLM mirror: ${clm.mirrorPath}` : `CLM mirror: unavailable (${clm.mirrorUnavailable})`;
|
|
192
554
|
case "on":
|
|
193
555
|
case "off": {
|
|
194
556
|
// The same override the TUI and `/clm config editing` write.
|
|
195
|
-
await
|
|
196
|
-
await clm.refreshSettings(true);
|
|
557
|
+
await setSetting(clm, "editing", argument);
|
|
197
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
|
+
}
|
|
198
564
|
return argument === "on"
|
|
199
565
|
? "CLM is on for this session: the mirror is refreshed before the next request."
|
|
200
566
|
: "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
|
|
201
567
|
}
|
|
202
568
|
case "reset": {
|
|
203
|
-
await
|
|
204
|
-
toast("CLM revision dropped");
|
|
569
|
+
await dropRevision(clm);
|
|
205
570
|
return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
|
|
206
571
|
}
|
|
207
572
|
default:
|
|
@@ -209,17 +574,208 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
209
574
|
}
|
|
210
575
|
}
|
|
211
576
|
|
|
212
|
-
|
|
213
|
-
|
|
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;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
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, {
|
|
214
720
|
mirror: clm.mirrorPath,
|
|
215
|
-
current
|
|
721
|
+
current,
|
|
216
722
|
budget: clm.resolvedBudget()?.budget,
|
|
217
723
|
instructions: argument,
|
|
218
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
|
+
}
|
|
219
768
|
}
|
|
220
769
|
|
|
221
770
|
const hooks: Hooks = {
|
|
222
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);
|
|
223
779
|
if (settings.commands) {
|
|
224
780
|
const commands = (config.command ??= {});
|
|
225
781
|
const ours = {
|
|
@@ -263,9 +819,10 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
263
819
|
return;
|
|
264
820
|
}
|
|
265
821
|
await clm.refreshSettings();
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 } : {}) };
|
|
269
826
|
const sections = [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
|
|
270
827
|
if (clm.steering) sections.push(steeringPromptSection(clm.steering));
|
|
271
828
|
output.system.push(sections.join("\n\n"));
|
|
@@ -276,24 +833,87 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
276
833
|
const raw = output.messages as unknown as OcMessage[];
|
|
277
834
|
const sessionID = raw[0]?.info?.sessionID;
|
|
278
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);
|
|
279
840
|
let clm: ClmSession;
|
|
280
841
|
try {
|
|
281
842
|
clm = await session(sessionID);
|
|
282
843
|
} catch (error) {
|
|
283
|
-
|
|
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);
|
|
284
851
|
return;
|
|
285
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
|
+
}
|
|
286
862
|
const tools = toolTokens();
|
|
287
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
|
+
}
|
|
288
880
|
try {
|
|
289
881
|
const result = await clm.transform(raw);
|
|
290
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
|
+
}
|
|
291
906
|
if (result.alert) {
|
|
292
907
|
toast(result.alert, "warning");
|
|
293
908
|
log("warn", result.alert);
|
|
294
909
|
}
|
|
910
|
+
for (const error of result.errors ?? []) {
|
|
911
|
+
toast(error, "error");
|
|
912
|
+
log("error", `${sessionID}: ${error}`);
|
|
913
|
+
}
|
|
295
914
|
} catch (error) {
|
|
296
|
-
// 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);
|
|
297
917
|
await clm.log({ event: "error", message: describe(error), stack: error instanceof Error ? error.stack : undefined });
|
|
298
918
|
toast(`transform failed: ${describe(error)}`, "error");
|
|
299
919
|
}
|
|
@@ -302,6 +922,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
302
922
|
async "experimental.session.compacting"(hookInput, output) {
|
|
303
923
|
const clm = await opened(hookInput.sessionID);
|
|
304
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
|
+
}
|
|
305
930
|
clm.compacting = true;
|
|
306
931
|
try {
|
|
307
932
|
const context = await clm.compactionContext();
|
|
@@ -313,13 +938,63 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
313
938
|
|
|
314
939
|
async "experimental.compaction.autocontinue"(hookInput) {
|
|
315
940
|
const clm = await opened(hookInput.sessionID);
|
|
316
|
-
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));
|
|
317
949
|
},
|
|
318
950
|
|
|
319
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
|
+
}
|
|
320
994
|
if (event.type !== "session.compacted") return;
|
|
321
995
|
const clm = await opened(event.properties.sessionID);
|
|
322
996
|
if (clm) clm.compacted = true;
|
|
997
|
+
thresholds.reset(event.properties.sessionID);
|
|
323
998
|
},
|
|
324
999
|
|
|
325
1000
|
async "tool.definition"(hookInput, output) {
|
|
@@ -337,7 +1012,20 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
337
1012
|
toolSizes.set(hookInput.toolID, Math.ceil((size / 4) * settings.estimateFactor));
|
|
338
1013
|
},
|
|
339
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
|
+
|
|
340
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));
|
|
341
1029
|
const clm = await opened(hookInput.sessionID);
|
|
342
1030
|
if (!clm || typeof output.output !== "string") return;
|
|
343
1031
|
// Runs inside the tool's own call: a throw here would fail the model's command.
|
|
@@ -346,10 +1034,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
346
1034
|
receipt = clm.receipt(hookInput.tool, hookInput.args, input.directory);
|
|
347
1035
|
} catch (error) {
|
|
348
1036
|
await clm.log({ event: "receipt-error", tool: hookInput.tool, message: describe(error) });
|
|
349
|
-
return;
|
|
350
1037
|
}
|
|
351
|
-
if (
|
|
352
|
-
|
|
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}`;
|
|
353
1043
|
},
|
|
354
1044
|
|
|
355
1045
|
async "command.execute.before"(hookInput, output) {
|
|
@@ -359,9 +1049,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
359
1049
|
try {
|
|
360
1050
|
const clm = await session(hookInput.sessionID);
|
|
361
1051
|
const argument = (hookInput.arguments ?? "").trim();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
:
|
|
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);
|
|
365
1058
|
} catch (error) {
|
|
366
1059
|
const message = `/${hookInput.command} failed: ${describe(error)}`;
|
|
367
1060
|
toast(message, "error");
|
|
@@ -378,6 +1071,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
378
1071
|
schema: tool.schema,
|
|
379
1072
|
store: async (sessionID) => (await session(sessionID)).annotations,
|
|
380
1073
|
block: async (sessionID, blockId) => (await session(sessionID)).blockSource(blockId),
|
|
1074
|
+
onChange: persistAnnotationsOf,
|
|
381
1075
|
}),
|
|
382
1076
|
};
|
|
383
1077
|
return hooks;
|