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.
@@ -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;
@@ -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
- messages.map((message) => {
76
- if (message.role !== "toolResult") return message;
77
- return {
78
- role: message.role,
79
- toolCallId: message.toolCallId,
80
- toolName: message.toolName,
81
- isError: message.isError,
82
- ocMessageID: message.ocMessageID,
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 { valid: false, messages: [...rawMessages], reason: PROJECTION_PREFIX_MISMATCH_REASON };
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
- 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
  }