opencode-clm 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -2
- package/README.md +22 -0
- package/index.ts +42 -478
- package/package.json +7 -7
- package/src/clm.ts +40 -11
- package/src/context-document.ts +4 -1
- package/src/host.ts +512 -0
- package/src/mirror-guard.ts +3 -1
- package/src/opencode-v2.ts +305 -0
- package/src/opencode.ts +10 -3
- package/src/presentation.ts +6 -0
- package/src/projection.ts +157 -14
- package/src/settings.ts +4 -2
- package/src/state.ts +50 -1
- package/src/types.ts +2 -0
- package/src/v2.ts +457 -0
- package/tui.ts +3 -1
- /package/{skills → .agents/skills}/clm-context/SKILL.md +0 -0
package/src/mirror-guard.ts
CHANGED
|
@@ -80,10 +80,12 @@ export function classifyMirrorToolCall(
|
|
|
80
80
|
case "write":
|
|
81
81
|
return isMirror(pathArg(input)) ? "write" : "none";
|
|
82
82
|
case "apply_patch":
|
|
83
|
+
case "patch": // OpenCode 2's name for the same tool and input
|
|
83
84
|
return typeof input.patchText === "string" && patchPaths(input.patchText).some(isMirror) ? "write" : "none";
|
|
84
85
|
case "read":
|
|
85
86
|
return isMirror(pathArg(input)) ? "read" : "none";
|
|
86
|
-
case "bash":
|
|
87
|
+
case "bash":
|
|
88
|
+
case "shell": { // OpenCode 2's name for bash, with the same `command` input
|
|
87
89
|
if (typeof input.command !== "string") return "none";
|
|
88
90
|
const command = input.command;
|
|
89
91
|
if (!referencesMirror(command, mirrorPath)) return "none";
|
|
@@ -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
|
+
}
|
package/src/opencode.ts
CHANGED
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
* "[Tool execution was interrupted]".
|
|
28
28
|
* Flattening never depends on settings: the projection digest is computed from it, and a
|
|
29
29
|
* settings toggle must not discard an accepted revision. Reasoning is always flattened;
|
|
30
|
-
* `withoutReasoning` hides it from the mirror view only.
|
|
30
|
+
* `withoutReasoning` hides it from the mirror view only. Every flat message carries
|
|
31
|
+
* `updatedAt` (`info.time.updated`, else `completed`, else `created`), OpenCode's in-place
|
|
32
|
+
* write marker: digests exclude it (`canonicalMessage`), and the projection reads it only to
|
|
33
|
+
* re-anchor a late rewrite of the tail instead of dropping the revision (projection.ts).
|
|
31
34
|
*
|
|
32
35
|
* Known gap: an assistant message whose only sendable parts are empty text parts has no
|
|
33
36
|
* flat form and is left out of the request. OpenCode sends it as an empty assistant turn.
|
|
@@ -125,9 +128,12 @@ export function flatten(messages: readonly OcMessage[]): LiveContextMessage[] {
|
|
|
125
128
|
if (!sentToModel(message)) continue;
|
|
126
129
|
const id = message.info.id;
|
|
127
130
|
const timestamp = Number(message.info.time?.created ?? 0);
|
|
131
|
+
// OpenCode writes `completed` (and, on some hosts, `updated`); both move when a message
|
|
132
|
+
// is written in place, `created` does not.
|
|
133
|
+
const updatedAt = Number(message.info.time?.updated ?? message.info.time?.completed ?? message.info.time?.created ?? 0);
|
|
128
134
|
if (message.info.role === "user") {
|
|
129
135
|
const content = userContent(message);
|
|
130
|
-
if (content.length > 0) out.push({ role: "user", content, ocMessageID: id, timestamp });
|
|
136
|
+
if (content.length > 0) out.push({ role: "user", content, ocMessageID: id, timestamp, updatedAt });
|
|
131
137
|
continue;
|
|
132
138
|
}
|
|
133
139
|
const content: Record<string, unknown>[] = [];
|
|
@@ -147,11 +153,12 @@ export function flatten(messages: readonly OcMessage[]): LiveContextMessage[] {
|
|
|
147
153
|
isError: output.isError,
|
|
148
154
|
ocMessageID: id,
|
|
149
155
|
timestamp,
|
|
156
|
+
updatedAt,
|
|
150
157
|
});
|
|
151
158
|
}
|
|
152
159
|
}
|
|
153
160
|
if (content.length === 0) continue;
|
|
154
|
-
out.push({ role: "assistant", content, ocMessageID: id, timestamp });
|
|
161
|
+
out.push({ role: "assistant", content, ocMessageID: id, timestamp, updatedAt });
|
|
155
162
|
out.push(...results);
|
|
156
163
|
}
|
|
157
164
|
return out;
|
package/src/presentation.ts
CHANGED
|
@@ -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/projection.ts
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
// whole messages (ids change) or truncates the parts of a kept message (its toolCall and
|
|
14
14
|
// toolResult disappear, so the digest changes); both still fail closed.
|
|
15
15
|
//
|
|
16
|
+
// One mismatch source is OpenCode's own late writes: it keeps updating message objects
|
|
17
|
+
// after `experimental.chat.messages.transform` returns (an assistant message being
|
|
18
|
+
// finalized, turn diffs attached to a user message). A checkpoint taken mid-turn digests
|
|
19
|
+
// those still-mutable tail messages, and the late write flips the digest. `sourceStamps`
|
|
20
|
+
// (per-message digest + captured `time.updated`) lets `applyProjection` attribute such a
|
|
21
|
+
// mismatch to a post-capture OpenCode write and re-anchor the checkpoint to the stable
|
|
22
|
+
// head instead of dropping the revision (see `reanchorTail`); anything not attributable
|
|
23
|
+
// stays fail-closed.
|
|
24
|
+
//
|
|
16
25
|
// pi-clm's `recoverProjectionFromRetryErrors` is not ported. Pi writes failed assistant
|
|
17
26
|
// responses to its session log but drops them from live state, so a resumed log hashes
|
|
18
27
|
// differently. OpenCode keeps the error on the assistant message (`info.error`), and the
|
|
@@ -29,6 +38,19 @@ export const PROJECTION_SHORTER_REASON = "Raw context is shorter than the projec
|
|
|
29
38
|
|
|
30
39
|
export type EstimateUnit = "characters" | "tokens";
|
|
31
40
|
|
|
41
|
+
/** One source message's content digest plus the write time captured with it. */
|
|
42
|
+
export interface SourceStamp {
|
|
43
|
+
digest: string;
|
|
44
|
+
updatedAt: number;
|
|
45
|
+
/**
|
|
46
|
+
* Digest of the same message with OpenCode's `<system-reminder>` blocks removed
|
|
47
|
+
* (whitespace-normalized). OpenCode appends and removes mode-change reminders on user
|
|
48
|
+
* messages between requests without moving any write-time field; a change confined to
|
|
49
|
+
* those blocks is attributable against this fingerprint.
|
|
50
|
+
*/
|
|
51
|
+
strippedDigest?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
export interface ProjectionCheckpoint {
|
|
33
55
|
version: 1;
|
|
34
56
|
revision: number;
|
|
@@ -38,6 +60,12 @@ export interface ProjectionCheckpoint {
|
|
|
38
60
|
sourceIds: string[];
|
|
39
61
|
/** `digestSourcePrefix` of those messages. */
|
|
40
62
|
sourceDigest: string;
|
|
63
|
+
/**
|
|
64
|
+
* Per-message digest and captured write time (length is sourceMessageCount). Lets a
|
|
65
|
+
* mismatch be attributed to specific messages and a late OpenCode write of the tail be
|
|
66
|
+
* re-anchored instead of dropped; absent on checkpoints written by older versions.
|
|
67
|
+
*/
|
|
68
|
+
sourceStamps?: SourceStamp[];
|
|
41
69
|
projectedMessages: LiveContextMessage[];
|
|
42
70
|
beforeEstimate: number;
|
|
43
71
|
afterEstimate: number;
|
|
@@ -47,11 +75,21 @@ export interface ProjectionCheckpoint {
|
|
|
47
75
|
editTrace?: ContextEditTrace;
|
|
48
76
|
}
|
|
49
77
|
|
|
78
|
+
/** The trimmed anchor returned when a late tail rewrite was re-anchored instead of dropped. */
|
|
79
|
+
export interface ProjectionReanchor {
|
|
80
|
+
sourceMessageCount: number;
|
|
81
|
+
sourceIds: string[];
|
|
82
|
+
sourceStamps: SourceStamp[];
|
|
83
|
+
sourceDigest: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
50
86
|
export type ProjectionApplication =
|
|
51
87
|
| {
|
|
52
88
|
valid: true;
|
|
53
89
|
messages: LiveContextMessage[];
|
|
54
90
|
suffix: LiveContextMessage[];
|
|
91
|
+
/** Set when the anchor was trimmed to the stable head (see `reanchorTail`). */
|
|
92
|
+
reanchor?: ProjectionReanchor;
|
|
55
93
|
}
|
|
56
94
|
| {
|
|
57
95
|
valid: false;
|
|
@@ -64,6 +102,60 @@ export function sourceMessageId(message: LiveContextMessage): string {
|
|
|
64
102
|
return typeof message.ocMessageID === "string" ? message.ocMessageID : "";
|
|
65
103
|
}
|
|
66
104
|
|
|
105
|
+
/** The prune-invariant form of one source message (see `digestSourcePrefix`). */
|
|
106
|
+
function sourceDigestForm(message: LiveContextMessage): LiveContextMessage {
|
|
107
|
+
if (message.role !== "toolResult") return message;
|
|
108
|
+
return {
|
|
109
|
+
role: message.role,
|
|
110
|
+
toolCallId: message.toolCallId,
|
|
111
|
+
toolName: message.toolName,
|
|
112
|
+
isError: message.isError,
|
|
113
|
+
ocMessageID: message.ocMessageID,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
118
|
+
|
|
119
|
+
/** Whitespace-normalized text without OpenCode's reminder blocks, for the stripped fingerprint. */
|
|
120
|
+
function normalizeReminderFingerprint(text: string): string {
|
|
121
|
+
return text
|
|
122
|
+
.replace(/\r\n/g, "\n")
|
|
123
|
+
.replace(SYSTEM_REMINDER_RE, "")
|
|
124
|
+
.replace(/\n{2,}/g, "\n\n")
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The message with OpenCode's `<system-reminder>` blocks removed from its text parts; parts
|
|
130
|
+
* that become empty are dropped. OpenCode appends and removes mode-change reminders on user
|
|
131
|
+
* messages between requests without touching any write-time field, so a digest over this
|
|
132
|
+
* form tells a reminder-only edit apart from a real content change.
|
|
133
|
+
*/
|
|
134
|
+
export function stripSystemReminders(message: LiveContextMessage): LiveContextMessage {
|
|
135
|
+
const content = message.content;
|
|
136
|
+
if (!Array.isArray(content)) return message;
|
|
137
|
+
const stripped: unknown[] = [];
|
|
138
|
+
for (const part of content) {
|
|
139
|
+
if (!part || typeof part !== "object") {
|
|
140
|
+
stripped.push(part);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const block = part as Record<string, unknown>;
|
|
144
|
+
if (block.type !== "text" || typeof block.text !== "string") {
|
|
145
|
+
stripped.push(block);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const text = normalizeReminderFingerprint(block.text);
|
|
149
|
+
if (text !== "") stripped.push({ ...block, text });
|
|
150
|
+
}
|
|
151
|
+
return { ...message, content: stripped };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `digestSourceMessage` of the message without OpenCode's system-reminder blocks. */
|
|
155
|
+
export function digestSourceMessageWithoutReminders(message: LiveContextMessage): string {
|
|
156
|
+
return digestMessages([sourceDigestForm(stripSystemReminders(message))]);
|
|
157
|
+
}
|
|
158
|
+
|
|
67
159
|
/**
|
|
68
160
|
* Digest a raw source prefix in a form OpenCode's prune cannot change: every `toolResult`
|
|
69
161
|
* keeps only role, toolCallId, toolName, isError and ocMessageID. Content is dropped for
|
|
@@ -71,18 +163,22 @@ export function sourceMessageId(message: LiveContextMessage): string {
|
|
|
71
163
|
* created and may be cleared when it is validated.
|
|
72
164
|
*/
|
|
73
165
|
export function digestSourcePrefix(messages: readonly LiveContextMessage[]): string {
|
|
74
|
-
return digestMessages(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
166
|
+
return digestMessages(messages.map(sourceDigestForm));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `digestSourcePrefix` of one message: per-message attribution for tail re-anchoring. */
|
|
170
|
+
export function digestSourceMessage(message: LiveContextMessage): string {
|
|
171
|
+
return digestMessages([sourceDigestForm(message)]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A message's stamp: its content digest, the write time OpenCode last wrote, and the
|
|
175
|
+
* reminder-stripped fingerprint. */
|
|
176
|
+
export function sourceStamp(message: LiveContextMessage): SourceStamp {
|
|
177
|
+
return {
|
|
178
|
+
digest: digestSourceMessage(message),
|
|
179
|
+
updatedAt: typeof message.updatedAt === "number" ? message.updatedAt : 0,
|
|
180
|
+
strippedDigest: digestSourceMessageWithoutReminders(message),
|
|
181
|
+
};
|
|
86
182
|
}
|
|
87
183
|
|
|
88
184
|
/**
|
|
@@ -109,6 +205,7 @@ export function createProjectionCheckpoint(options: {
|
|
|
109
205
|
sourceMessageCount: options.sourceMessages.length,
|
|
110
206
|
sourceIds: options.sourceMessages.map(sourceMessageId),
|
|
111
207
|
sourceDigest: digestSourcePrefix(options.sourceMessages),
|
|
208
|
+
sourceStamps: options.sourceMessages.map(sourceStamp),
|
|
112
209
|
projectedMessages: [...options.projectedMessages],
|
|
113
210
|
beforeEstimate: options.beforeEstimate,
|
|
114
211
|
afterEstimate: options.afterEstimate,
|
|
@@ -124,7 +221,11 @@ export function createProjectionCheckpoint(options: {
|
|
|
124
221
|
|
|
125
222
|
/**
|
|
126
223
|
* Replace the checkpoint's raw source prefix and keep every raw message appended after
|
|
127
|
-
* it. A mismatch fails closed and returns a copy of the untouched raw context
|
|
224
|
+
* it. A mismatch fails closed and returns a copy of the untouched raw context — unless the
|
|
225
|
+
* checkpoint has per-message stamps and every changed message was written by OpenCode after
|
|
226
|
+
* it was captured (the current turn's tail still being finalized): then the anchor is
|
|
227
|
+
* trimmed to the stable head, the trimmed tail flows as ordinary suffix, and the caller
|
|
228
|
+
* persists `reanchor` on the checkpoint.
|
|
128
229
|
*/
|
|
129
230
|
export function applyProjection(
|
|
130
231
|
rawMessages: readonly LiveContextMessage[],
|
|
@@ -147,8 +248,50 @@ export function applyProjection(
|
|
|
147
248
|
return { valid: false, messages: [...rawMessages], reason: PROJECTION_SOURCE_IDS_REASON };
|
|
148
249
|
}
|
|
149
250
|
if (digestSourcePrefix(prefix) !== checkpoint.sourceDigest) {
|
|
150
|
-
return
|
|
251
|
+
return reanchorTail(rawMessages, prefix, checkpoint) ??
|
|
252
|
+
{ valid: false, messages: [...rawMessages], reason: PROJECTION_PREFIX_MISMATCH_REASON };
|
|
151
253
|
}
|
|
152
254
|
const suffix = rawMessages.slice(checkpoint.sourceMessageCount);
|
|
153
255
|
return { valid: true, messages: [...checkpoint.projectedMessages, ...suffix], suffix };
|
|
154
256
|
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A mismatch OpenCode itself caused. Every changed message must be explained by an
|
|
260
|
+
* OpenCode write: either its write time (`time.completed`/`updated`, not `created`) moved
|
|
261
|
+
* past the stamp, or the change is confined to OpenCode's `<system-reminder>` mode-change
|
|
262
|
+
* blocks (verified against the reminder-stripped per-message digest). That is the tail of
|
|
263
|
+
* the current turn still being finalized or a mode switch — not a revert, a compaction or
|
|
264
|
+
* a foreign transform, which change ids or content in ways neither check explains and stay
|
|
265
|
+
* fail-closed. The anchor is trimmed to the last stable message; the trimmed tail becomes
|
|
266
|
+
* ordinary suffix, the same contract as pi-clm's rebasing for appended messages.
|
|
267
|
+
*/
|
|
268
|
+
function reanchorTail(
|
|
269
|
+
rawMessages: readonly LiveContextMessage[],
|
|
270
|
+
prefix: readonly LiveContextMessage[],
|
|
271
|
+
checkpoint: ProjectionCheckpoint,
|
|
272
|
+
): ProjectionApplication | undefined {
|
|
273
|
+
const stamps = checkpoint.sourceStamps;
|
|
274
|
+
if (!Array.isArray(stamps) || stamps.length !== prefix.length) return undefined;
|
|
275
|
+
const changed = prefix.map((message, index) => digestSourceMessage(message) !== stamps[index]!.digest);
|
|
276
|
+
const first = changed.indexOf(true);
|
|
277
|
+
if (first <= 0) return undefined;
|
|
278
|
+
const attributable = prefix.every((message, index) => {
|
|
279
|
+
if (!changed[index]) return true;
|
|
280
|
+
const stamp = stamps[index]!;
|
|
281
|
+
if (typeof message.updatedAt === "number" && message.updatedAt > stamp.updatedAt) return true;
|
|
282
|
+
// OpenCode appends and removes `<system-reminder>` blocks (mode changes) on user
|
|
283
|
+
// messages without moving any write time; a change confined to those blocks is still
|
|
284
|
+
// an OpenCode edit. Everything else fails closed.
|
|
285
|
+
return typeof stamp.strippedDigest === "string" && digestSourceMessageWithoutReminders(message) === stamp.strippedDigest;
|
|
286
|
+
});
|
|
287
|
+
if (!attributable) return undefined;
|
|
288
|
+
const stable = prefix.slice(0, first);
|
|
289
|
+
const reanchor: ProjectionReanchor = {
|
|
290
|
+
sourceMessageCount: stable.length,
|
|
291
|
+
sourceIds: stable.map(sourceMessageId),
|
|
292
|
+
sourceStamps: stable.map(sourceStamp),
|
|
293
|
+
sourceDigest: digestSourcePrefix(stable),
|
|
294
|
+
};
|
|
295
|
+
const suffix = rawMessages.slice(stable.length);
|
|
296
|
+
return { valid: true, messages: [...checkpoint.projectedMessages, ...suffix], suffix, reanchor };
|
|
297
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -75,7 +75,7 @@ export function parseCompaction(value: unknown, name = "compaction"): Compaction
|
|
|
75
75
|
|
|
76
76
|
export const PACKAGE_DIR = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
77
77
|
export const HOUSE_STEERING = join(PACKAGE_DIR, "steering", "house-brief.md");
|
|
78
|
-
export const SKILLS_DIR = join(PACKAGE_DIR, "skills");
|
|
78
|
+
export const SKILLS_DIR = join(PACKAGE_DIR, ".agents/skills/clm-context");
|
|
79
79
|
|
|
80
80
|
type Env = Record<string, string | undefined>;
|
|
81
81
|
|
|
@@ -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
|
-
|
|
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
|
}
|