opencode-clm 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * OpenCode 2 adapter: conversion between the provider-level messages of OpenCode 2's
3
+ * `session.hook("context")` and the `{ info, parts }` messages the core works on
4
+ * (src/opencode.ts, as delivered by OpenCode 1's `experimental.chat.messages.transform`).
5
+ *
6
+ * OpenCode 2 hands the hook the request it is about to send (packages/core/src/session/
7
+ * runner/to-llm-message.ts, 2.1.1): `user` and `assistant` messages carry the stored session
8
+ * message id; an assistant message is followed by one `role: "tool"` message per tool result
9
+ * (no id, paired by call id); a hosted tool's result sits inline in the assistant content.
10
+ * `system` messages (mid-conversation instructions, reasoning-effort markers), messages
11
+ * without an id (native compaction windows) and assistant messages with content OpenCode 1
12
+ * has no part for (media, provider compaction) have no `{ info, parts }` form: they are
13
+ * kept out of the core's view and put back next to the message they followed.
14
+ *
15
+ * Identity rule, as in `unflatten`: a message the core returns untouched (the same object
16
+ * `fromV2` made) maps back to the exact OpenCode 2 messages it stands for, provider metadata
17
+ * included, so an unedited context reaches the provider byte for byte. A rewritten message
18
+ * is rebuilt from its parts, reusing every original part that did not change.
19
+ */
20
+ import type { OcMessage, OcPart } from "./opencode.ts";
21
+
22
+ /** One content part of an OpenCode 2 message (`@opencode/ai` `ContentPart`). */
23
+ export type V2Part = { type: string; [key: string]: any };
24
+
25
+ /** One message of the OpenCode 2 `context` hook (`@opencode/ai` `Message`). */
26
+ export interface V2Message {
27
+ id?: string;
28
+ role: "system" | "user" | "assistant" | "tool";
29
+ content: V2Part[];
30
+ metadata?: Record<string, unknown>;
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ /** Provider-reported usage of one assistant message (`session.step.ended` `tokens`). */
35
+ export interface V2Tokens {
36
+ input?: number;
37
+ output?: number;
38
+ reasoning?: number;
39
+ cache?: { read?: number; write?: number };
40
+ }
41
+
42
+ /** A message with no `{ info, parts }` form, and where it stood. */
43
+ interface Opaque {
44
+ message: V2Message;
45
+ /** Id of the converted message it followed; undefined when it came first. */
46
+ after?: string;
47
+ /** Id of the converted message that followed it; undefined when it came last. */
48
+ before?: string;
49
+ }
50
+
51
+ /** The core's view of one OpenCode 2 request, and what is needed to map it back. */
52
+ export interface V2View {
53
+ sessionID: string;
54
+ /** `{ info, parts }` messages, in request order. */
55
+ messages: OcMessage[];
56
+ /** The OpenCode 2 messages each converted message stands for (assistant: plus its tool messages). */
57
+ origin: Map<OcMessage, V2Message[]>;
58
+ /** The same, by message id, for messages the core rewrote. */
59
+ byId: Map<string, V2Message[]>;
60
+ /** Original OpenCode 2 part of each converted part. */
61
+ parts: Map<OcPart, V2Part>;
62
+ opaque: Opaque[];
63
+ }
64
+
65
+ /** The text of a tool result value (`@opencode/ai` `ToolResultValue`). */
66
+ function resultText(result: { type?: string; value?: unknown } | undefined): string {
67
+ if (!result) return "";
68
+ const value = result.value;
69
+ if (result.type === "content" && Array.isArray(value)) {
70
+ return value.filter((item) => item?.type === "text").map((item) => String(item.text ?? "")).join("\n");
71
+ }
72
+ if (result.type === "error") {
73
+ const error = (value as { error?: unknown } | undefined)?.error ?? value;
74
+ const message = typeof error === "string" ? error : (error as { message?: unknown } | undefined)?.message;
75
+ const content = (value as { content?: unknown } | undefined)?.content;
76
+ const detail = Array.isArray(content) ? content.filter((item) => item?.type === "text").map((item) => String(item.text ?? "")).join("\n") : "";
77
+ const head = typeof message === "string" ? message : stringify(error);
78
+ return detail ? `${head}\n${detail}` : head;
79
+ }
80
+ return typeof value === "string" ? value : stringify(value);
81
+ }
82
+
83
+ function stringify(value: unknown): string {
84
+ try {
85
+ return JSON.stringify(value) ?? String(value);
86
+ } catch {
87
+ return String(value);
88
+ }
89
+ }
90
+
91
+ /** Tool-result file items as OpenCode 1 attachments (`fileBlock` reads `mime` and `filename`). */
92
+ function attachments(result: { type?: string; value?: unknown } | undefined): Array<{ mime: string; filename?: string }> {
93
+ if (result?.type !== "content" || !Array.isArray(result.value)) return [];
94
+ return result.value
95
+ .filter((item) => item?.type === "file" && typeof item.mime === "string")
96
+ .map((item) => ({ mime: String(item.mime), ...(typeof item.name === "string" ? { filename: item.name } : {}) }));
97
+ }
98
+
99
+ /** The OpenCode 1 tool state that `toolPartOutput` reads, from a call and its result. */
100
+ function toolState(call: V2Part, result: V2Part | undefined): Record<string, unknown> {
101
+ const input = call.input && typeof call.input === "object" ? call.input : {};
102
+ if (!result) return { status: "pending", input };
103
+ const value = result.result as { type?: string; value?: unknown } | undefined;
104
+ if (value?.type === "error") return { status: "error", input, error: resultText(value), time: { start: 0, end: 0 } };
105
+ const files = attachments(value);
106
+ return {
107
+ status: "completed",
108
+ input,
109
+ output: resultText(value),
110
+ title: "",
111
+ metadata: {},
112
+ time: { start: 0, end: 0 },
113
+ ...(files.length > 0 ? { attachments: files } : {}),
114
+ };
115
+ }
116
+
117
+ /** Opening line of the user message OpenCode 2 sends in place of a summarized history. */
118
+ export const CHECKPOINT_OPEN = "<conversation-checkpoint>";
119
+
120
+ /** Part types of an assistant message that have an OpenCode 1 counterpart. */
121
+ const ASSISTANT_PARTS = new Set(["text", "reasoning", "tool-call", "tool-result"]);
122
+
123
+ function convertible(message: V2Message): boolean {
124
+ if (typeof message.id !== "string" || message.id === "") return false;
125
+ if (message.role === "user") return message.content.every((part) => part.type === "text" || part.type === "media");
126
+ if (message.role === "assistant") return message.content.every((part) => ASSISTANT_PARTS.has(part.type));
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * OpenCode 2 request messages → `{ info, parts }` messages. `usage` supplies the provider
132
+ * count of an assistant message (from `session.step.ended`), which the core's budget
133
+ * calibration reads as `info.tokens`.
134
+ */
135
+ export function fromV2(messages: readonly V2Message[], sessionID: string, usage?: (messageID: string) => V2Tokens | undefined): V2View {
136
+ const view: V2View = { sessionID, messages: [], origin: new Map(), byId: new Map(), parts: new Map(), opaque: [] };
137
+ const pending: Opaque[] = [];
138
+ let last: string | undefined;
139
+ const add = (oc: OcMessage, group: V2Message[]) => {
140
+ for (const item of pending.splice(0)) item.before = oc.info.id;
141
+ view.messages.push(oc);
142
+ view.origin.set(oc, group);
143
+ view.byId.set(oc.info.id, group);
144
+ last = oc.info.id;
145
+ };
146
+ for (let index = 0; index < messages.length; index++) {
147
+ const message = messages[index]!;
148
+ if (!convertible(message)) {
149
+ const item: Opaque = { message, ...(last !== undefined ? { after: last } : {}) };
150
+ view.opaque.push(item);
151
+ pending.push(item);
152
+ continue;
153
+ }
154
+ const id = message.id!;
155
+ const info: OcMessage["info"] = { id, sessionID, role: message.role as "user" | "assistant", time: { created: 0 } };
156
+ if (message.role === "user") {
157
+ // OpenCode 2 sends a summary compaction as one user message (to-llm-message.ts
158
+ // `compaction`); marked like OpenCode 1's summary, so the core rebases onto it.
159
+ if (String(message.content[0]?.text ?? "").startsWith(CHECKPOINT_OPEN)) info.summary = true;
160
+ const parts = message.content.map((part, at): OcPart => {
161
+ const converted: OcPart = part.type === "text"
162
+ ? { id: `${id}:${at}`, type: "text", text: String(part.text ?? "") }
163
+ : { id: `${id}:${at}`, type: "file", mime: String(part.media?.mime ?? part.media?.mimeType ?? "application/octet-stream"), filename: part.filename };
164
+ view.parts.set(converted, part);
165
+ return converted;
166
+ });
167
+ add({ info, parts }, [message]);
168
+ continue;
169
+ }
170
+ // An assistant message and the tool messages that follow it.
171
+ const group = [message];
172
+ while (index + 1 < messages.length && messages[index + 1]!.role === "tool" && messages[index + 1]!.id === undefined) {
173
+ group.push(messages[++index]!);
174
+ }
175
+ const results = new Map<string, V2Part>();
176
+ for (const item of group) {
177
+ for (const part of item.content) if (part.type === "tool-result") results.set(String(part.id), part);
178
+ }
179
+ const parts: OcPart[] = [];
180
+ message.content.forEach((part, at) => {
181
+ let converted: OcPart | undefined;
182
+ if (part.type === "text") converted = { id: `${id}:${at}`, type: "text", text: String(part.text ?? "") };
183
+ else if (part.type === "reasoning") converted = { id: `${id}:${at}`, type: "reasoning", text: String(part.text ?? "") };
184
+ else if (part.type === "tool-call") {
185
+ converted = { id: `${id}:${at}`, type: "tool", callID: String(part.id), tool: String(part.name), state: toolState(part, results.get(String(part.id))) };
186
+ }
187
+ if (!converted) return;
188
+ view.parts.set(converted, part);
189
+ parts.push(converted);
190
+ });
191
+ const tokens = usage?.(id);
192
+ add({ info: { ...info, ...(tokens ? { tokens } : {}) }, parts }, group);
193
+ }
194
+ return view;
195
+ }
196
+
197
+ /** A core note or notice (no OpenCode 2 source): plain user text. */
198
+ function noteToV2(message: OcMessage): V2Message {
199
+ const text = message.parts.filter((part) => part.type === "text").map((part) => String(part.text ?? "")).join("\n");
200
+ return { role: "user", content: [{ type: "text", text }] };
201
+ }
202
+
203
+ /** A user message the core rewrote: its text parts, original parts reused where unchanged. */
204
+ function userToV2(message: OcMessage, original: V2Message, view: V2View): V2Message {
205
+ const content = message.parts.flatMap((part): V2Part[] => {
206
+ const source = view.parts.get(part);
207
+ if (source) return [source];
208
+ return part.type === "text" ? [{ type: "text", text: String(part.text ?? "") }] : [];
209
+ });
210
+ return { ...original, content };
211
+ }
212
+
213
+ /**
214
+ * An assistant message the core rewrote (an edited body, or tool results edited, withheld or
215
+ * capped): untouched parts keep their original form, a changed result becomes a text result
216
+ * in the original place (inline for a hosted tool, else its own `tool` message).
217
+ */
218
+ function assistantToV2(message: OcMessage, group: V2Message[], view: V2View): V2Message[] {
219
+ const [original, ...tools] = group as [V2Message, ...V2Message[]];
220
+ const calls = new Map<string, V2Part>();
221
+ for (const part of original.content) if (part.type === "tool-call") calls.set(String(part.id), part);
222
+ const inline = new Map<string, V2Part>();
223
+ for (const part of original.content) if (part.type === "tool-result") inline.set(String(part.id), part);
224
+ const separate = new Map<string, V2Message>();
225
+ for (const tool of tools) {
226
+ const result = tool.content.find((part) => part.type === "tool-result");
227
+ if (result) separate.set(String(result.id), tool);
228
+ }
229
+ const content: V2Part[] = [];
230
+ const results: V2Message[] = [];
231
+ for (const part of message.parts) {
232
+ if (part.type !== "tool") {
233
+ const source = view.parts.get(part);
234
+ if (source) content.push(source);
235
+ else if (part.type === "text") content.push({ type: "text", text: String(part.text ?? "") });
236
+ else if (part.type === "reasoning") content.push({ type: "reasoning", text: String(part.text ?? "") });
237
+ continue;
238
+ }
239
+ const callID = String(part.callID);
240
+ const call = calls.get(callID);
241
+ if (!call) continue;
242
+ content.push(call);
243
+ const untouched = view.parts.has(part);
244
+ const hosted = inline.get(callID);
245
+ if (hosted) {
246
+ content.push(untouched ? hosted : { ...hosted, result: { type: "text", value: String(part.state?.output ?? "") }, providerMetadata: undefined });
247
+ continue;
248
+ }
249
+ const tool = separate.get(callID);
250
+ if (untouched) {
251
+ if (tool) results.push(tool);
252
+ continue;
253
+ }
254
+ const source = tool?.content.find((item) => item.type === "tool-result");
255
+ results.push({
256
+ role: "tool",
257
+ content: [{
258
+ type: "tool-result",
259
+ id: callID,
260
+ name: String(part.tool),
261
+ result: { type: "text", value: String(part.state?.output ?? "") },
262
+ ...(source?.providerExecuted !== undefined ? { providerExecuted: source.providerExecuted } : {}),
263
+ }],
264
+ });
265
+ }
266
+ // An edited body drops the replay data of the text it replaced.
267
+ const edited = content.some((item) => !original.content.includes(item));
268
+ const rebuilt: V2Message = { ...original, content };
269
+ if (edited) delete rebuilt.providerMetadata;
270
+ return content.length > 0 ? [rebuilt, ...results] : results;
271
+ }
272
+
273
+ /**
274
+ * `{ info, parts }` messages the core returned → OpenCode 2 request messages, with the
275
+ * messages that had no `{ info, parts }` form put back after the message they followed (or,
276
+ * when that one is gone, before the message that followed them).
277
+ */
278
+ export function toV2(view: V2View, result: readonly OcMessage[]): V2Message[] {
279
+ const present = new Set(result.map((message) => message.info.id));
280
+ const placed = new Set<Opaque>();
281
+ const out: V2Message[] = [];
282
+ const emit = (item: Opaque) => {
283
+ if (placed.has(item)) return;
284
+ placed.add(item);
285
+ out.push(item.message);
286
+ };
287
+ for (const item of view.opaque) if (item.after === undefined) emit(item);
288
+ for (const message of result) {
289
+ const id = message.info.id;
290
+ for (const item of view.opaque) {
291
+ if (item.before === id && (item.after === undefined || !present.has(item.after))) emit(item);
292
+ }
293
+ const group = view.origin.get(message);
294
+ if (group) out.push(...group);
295
+ else {
296
+ const original = view.byId.get(id);
297
+ if (!original) out.push(noteToV2(message));
298
+ else if (original[0]!.role === "user") out.push(userToV2(message, original[0]!, view));
299
+ else out.push(...assistantToV2(message, original, view));
300
+ }
301
+ for (const item of view.opaque) if (item.after === id) emit(item);
302
+ }
303
+ for (const item of view.opaque) emit(item);
304
+ return out;
305
+ }
@@ -93,6 +93,8 @@ export interface ClmStatus {
93
93
  /** The accepted revision now in effect, if any. */
94
94
  checkpoint?: { revision: number; anchorCount: number; beforeEstimate: number; afterEstimate: number };
95
95
  lastRequest?: { rawMessages: number; sentMessages: number; mirrorBlocks: number };
96
+ /** OpenCode's newest compaction of the session in this process. */
97
+ lastCompaction?: { reason: "auto" | "manual"; at: number };
96
98
  steering?: SteeringDocument;
97
99
  /** Settings changed for this session (`budget 20k, guard off`); absent when none. */
98
100
  changed?: string;
@@ -135,6 +137,10 @@ export function statusText(status: ClmStatus): string {
135
137
  const request = status.lastRequest;
136
138
  lines.push(`last request: ${request.rawMessages} raw messages → ${request.sentMessages} sent, ${request.mirrorBlocks} mirror blocks`);
137
139
  }
140
+ if (status.lastCompaction) {
141
+ const time = new Date(status.lastCompaction.at).toTimeString().slice(0, 5);
142
+ lines.push(`last compaction: OpenCode, ${status.lastCompaction.reason === "auto" ? "automatic" : "/compact"}, at ${time}`);
143
+ }
138
144
  lines.push(steeringStatusLine(status.steering));
139
145
  if (status.changed) lines.push(`Changed: ${status.changed}`);
140
146
  if (status.settingsWarning) lines.push(`settings warning: ${status.settingsWarning}`);
package/src/settings.ts CHANGED
@@ -168,7 +168,9 @@ export function parseCooldown(value: unknown, name = "reminderCooldown"): number
168
168
  if (text === "off" || text === "none" || text === "0" || text === "0%") return 0;
169
169
  const percent = text.endsWith("%");
170
170
  const number = percent ? text.slice(0, -1).trim() : text;
171
- const parsed = /^\d*\.?\d+$/.test(number) ? Number(number) / (percent ? 100 : 1) : Number.NaN;
171
+ // Bounded, and unambiguous: `\d*\.?\d+` backtracks polynomially on a long digit string (CodeQL).
172
+ const parsed =
173
+ number.length <= 32 && /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(number) ? Number(number) / (percent ? 100 : 1) : Number.NaN;
172
174
  if (!(parsed > 0 && parsed < 1)) throw new Error(`${name} must be ${COOLDOWN_FORMS}, got ${JSON.stringify(value)}`);
173
175
  return parsed;
174
176
  }