opencode-clm 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ // The slash commands the server plugin defines, shared with the TUI so its Enter intercept
2
+ // can tell this package's `/clm` from a user's own. Written for this package.
3
+
4
+ export const STATUS_COMMAND = "clm";
5
+ export const COMPACT_COMMAND = "clm-compact";
6
+ export const STATUS_TEMPLATE = "Show the CLM status. $ARGUMENTS";
7
+ export const COMPACT_TEMPLATE = "Compact your context. $ARGUMENTS";
8
+
9
+ /**
10
+ * True when the config's `command.clm` is not this package's: a user-defined `/clm` keeps
11
+ * its owner, as the server's config hook leaves it alone. Absent → ours (or none yet).
12
+ */
13
+ export function userOwnsStatusCommand(commands: unknown): boolean {
14
+ if (!commands || typeof commands !== "object") return false;
15
+ const entry = (commands as Record<string, unknown>)[STATUS_COMMAND];
16
+ if (entry === undefined) return false;
17
+ return !(entry && typeof entry === "object" && (entry as { template?: unknown }).template === STATUS_TEMPLATE);
18
+ }
@@ -0,0 +1,243 @@
1
+ // The `compaction`, `one-tool` and `trailer` settings: pure decisions for index.ts.
2
+ // Semantics follow pi-clm's `PI_CLM_NATIVE_COMPACTION`, `oneToolPerTurn` and `sizeTrailer`
3
+ // (pi-clm src/index.ts, MIT, Copyright 2026 Emanuel Casco); texts and the OpenCode mapping
4
+ // are this package's.
5
+ //
6
+ // OpenCode 1.18.34 has one flag for automatic compaction, `compaction.auto` in the config.
7
+ // It gates both threshold compaction (session/overflow.ts:28, checked at
8
+ // session/processor.ts:491 after each step and session/prompt.ts:1161 before the next) and
9
+ // recovery from a provider overflow error (session/processor.ts:620-628). Every check reads
10
+ // `config.get()`, the object the plugin's `config` hook received (plugin/index.ts:152,245-252;
11
+ // config/config.ts:620-622), so setting the key on that object takes effect at once, for
12
+ // every session of the instance.
13
+
14
+ import type { CompactionMode } from "./settings.ts";
15
+
16
+ /**
17
+ * The `compaction.auto` value a mode asks for outside a pause; undefined = the user's own
18
+ * config value. pi's `on` is "Pi's default" (its handler leaves compaction alone), so `on`
19
+ * keeps the user's configuration. pi's `auto` cancels threshold compaction while the guard
20
+ * enforces a budget; here `auto` keeps the configuration and index.ts turns the flag off
21
+ * per request only when the threshold can fire (`thresholdReachable`, K1).
22
+ */
23
+ export function compactionAuto(mode: CompactionMode, userAuto: boolean | undefined): boolean | undefined {
24
+ return mode === "off" ? false : userAuto;
25
+ }
26
+
27
+ /** Sets `config.compaction.auto` for `mode`, restoring the user's value (or its absence) for `auto`. */
28
+ export function applyCompactionMode(config: { compaction?: { auto?: boolean } }, mode: CompactionMode, userAuto: boolean | undefined): void {
29
+ const value = compactionAuto(mode, userAuto);
30
+ if (value === undefined) {
31
+ if (config.compaction && "auto" in config.compaction) delete config.compaction.auto;
32
+ return;
33
+ }
34
+ (config.compaction ??= {}).auto = value;
35
+ }
36
+
37
+ /** Toast after OpenCode compacted a session on its own. */
38
+ export function nativeCompactionText(overflow: boolean, mode: CompactionMode): string {
39
+ const why = overflow ? "after the provider reported a context overflow" : "at its token threshold";
40
+ const hint = mode === "off" ? "" : " Set /clm config compaction off to keep the model's own edits instead.";
41
+ return `OpenCode compacted this session ${why}; CLM continues from the summary.${hint}`;
42
+ }
43
+
44
+ /** Model notice when a provider overflow was not compacted because `compaction.auto` was false. */
45
+ export function overflowNotCompactedText(mirrorPath: string): string {
46
+ return `[CLM] The provider rejected the last request as too long, and OpenCode's automatic compaction is off, so nothing was compacted. Shrink the context by editing ${mirrorPath}, or ask the user to run /compact.`;
47
+ }
48
+
49
+ /**
50
+ * Per-session count of tool calls since the session's last model request. `next` must run
51
+ * synchronously at the start of `tool.execute.before`: parallel calls of one response
52
+ * reach the hook concurrently, so "first" means first to arrive, not first in the response.
53
+ */
54
+ export class ToolCallCounter {
55
+ private readonly counts = new Map<string, number>();
56
+
57
+ /** Called for every model request of the session. */
58
+ reset(sessionID: string): void {
59
+ this.counts.delete(sessionID);
60
+ }
61
+
62
+ /** This call's position (1-based) in the current response. */
63
+ next(sessionID: string): number {
64
+ const count = (this.counts.get(sessionID) ?? 0) + 1;
65
+ this.counts.set(sessionID, count);
66
+ return count;
67
+ }
68
+ }
69
+
70
+ /** The error a blocked call returns to the model. */
71
+ export function oneToolText(tool: string, position: number): string {
72
+ return `[CLM] One tool call per response (setting one-tool): this ${tool} call was #${position} in the response and did not run. Call it again on its own in your next response.`;
73
+ }
74
+
75
+ /** `\n[context: ~N of B tokens after this result]`, appended to a tool result. */
76
+ export function sizeTrailer(estimated: number, resultTokens: number, budget: number): string {
77
+ const format = (value: number) => value.toLocaleString("en-US");
78
+ return `\n[context: ~${format(estimated + resultTokens)} of ${format(budget)} tokens after this result]`;
79
+ }
80
+
81
+ // ---- `auto`: pausing threshold compaction while the guard enforces a budget (K1, K5) ----
82
+ //
83
+ // pi's `auto` cancels every threshold compaction while the overflow guard enforces a budget
84
+ // (pi-clm src/index.ts `session_before_compact`) and leaves overflow recovery to Pi. OpenCode
85
+ // gives no cancel hook and one flag for both. So before each request the plugin predicts
86
+ // whether OpenCode's threshold check on that request's count can fire, and sets the flag false
87
+ // only then; below the threshold the user's value (and with it overflow recovery) stays.
88
+ //
89
+ // The threshold check reads the count of one finished request (`tokens.total`, input plus
90
+ // output) after that request's step (session/processor.ts:491) and again before the next
91
+ // step (session/prompt.ts:1161, `lastFinished`); both run after this request's transform and
92
+ // before the next one, so the flag set here governs exactly this request's count.
93
+
94
+ /** OpenCode's default output cap (provider/transform.ts:18 `OUTPUT_TOKEN_MAX`). */
95
+ export const OPENCODE_OUTPUT_TOKEN_MAX = 32_000;
96
+ /** OpenCode's `COMPACTION_BUFFER` (session/overflow.ts:8). */
97
+ const OPENCODE_COMPACTION_BUFFER = 20_000;
98
+
99
+ /** The model limits OpenCode's threshold reads (`Provider.Model.limit`). */
100
+ export interface ThresholdModel {
101
+ context?: number;
102
+ input?: number;
103
+ output?: number;
104
+ }
105
+
106
+ /** `ProviderTransform.maxOutputTokens` (provider/transform.ts:1481-1483). */
107
+ export function openCodeMaxOutput(output: number | undefined, outputTokenMax = OPENCODE_OUTPUT_TOKEN_MAX): number {
108
+ return Math.min(output ?? 0, outputTokenMax) || outputTokenMax;
109
+ }
110
+
111
+ /**
112
+ * The `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` runtime flag (effect/runtime-flags.ts:52), a
113
+ * positive integer; undefined when unset or invalid.
114
+ */
115
+ export function outputTokenMaxFlag(env: Record<string, string | undefined> = process.env): number | undefined {
116
+ const value = Number(env.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX);
117
+ return Number.isInteger(value) && value > 0 ? value : undefined;
118
+ }
119
+
120
+ /**
121
+ * OpenCode's threshold (session/overflow.ts:10-20 `usable`); undefined when the window is
122
+ * unknown or 0 (OpenCode then never compacts at a threshold). processor.ts:491 calls it
123
+ * without the output-cap flag and prompt.ts:1161 with it; the lower of the two is returned.
124
+ */
125
+ export function openCodeUsable(model: ThresholdModel, reserved: number | undefined, outputTokenMax?: number): number | undefined {
126
+ const context = model.context ?? 0;
127
+ if (context <= 0) return undefined;
128
+ const usable = (cap: number) => {
129
+ const maxOutput = openCodeMaxOutput(model.output, cap);
130
+ const reserve = reserved ?? Math.min(OPENCODE_COMPACTION_BUFFER, maxOutput);
131
+ return model.input ? Math.max(0, model.input - reserve) : Math.max(0, context - maxOutput);
132
+ };
133
+ return Math.min(usable(OPENCODE_OUTPUT_TOKEN_MAX), usable(outputTokenMax ?? OPENCODE_OUTPUT_TOKEN_MAX));
134
+ }
135
+
136
+ /** Token fields of an OpenCode assistant message (`SessionV1.Assistant["tokens"]`). */
137
+ export interface StepTokens {
138
+ total?: number;
139
+ input?: number;
140
+ output?: number;
141
+ cache?: { read?: number; write?: number };
142
+ }
143
+
144
+ /** The count OpenCode compares with the threshold (session/overflow.ts:31-32). */
145
+ export function openCodeCount(tokens: StepTokens): number {
146
+ const parts = Number(tokens.input ?? 0) + Number(tokens.output ?? 0) + Number(tokens.cache?.read ?? 0) + Number(tokens.cache?.write ?? 0);
147
+ return Number(tokens.total ?? 0) || parts;
148
+ }
149
+
150
+ /**
151
+ * `auto` with the guard on: whether OpenCode's threshold check can fire on this request, i.e.
152
+ * the request's estimate plus the most it may generate reaches the threshold. The output
153
+ * allowance is OpenCode's own output cap for the request, so the prediction errs toward
154
+ * pausing (pi pauses every threshold compaction).
155
+ */
156
+ export function thresholdReachable(estimated: number, usable: number, maxOutput: number): boolean {
157
+ return estimated + maxOutput >= usable;
158
+ }
159
+
160
+ /**
161
+ * Per-session record of the flag the plugin set for each request and of threshold
162
+ * crossings, for K5: a step whose count reached the threshold while the plugin held the flag
163
+ * false is a threshold compaction the plugin cancelled. Reported once per crossing; a step
164
+ * below the threshold or a compaction re-arms it.
165
+ */
166
+ export class ThresholdWatch {
167
+ private readonly sessions = new Map<string, { by?: "auto" | "off"; checked?: string; crossing: boolean }>();
168
+
169
+ /**
170
+ * The flag this session's request ran under: `by` names the mode that turned it off
171
+ * against the user's value (`auto` paused it, `off` forced it); undefined when it was not.
172
+ */
173
+ set(sessionID: string, by: "auto" | "off" | undefined): void {
174
+ const entry = this.sessions.get(sessionID) ?? { crossing: false };
175
+ entry.by = by;
176
+ this.sessions.set(sessionID, entry);
177
+ }
178
+
179
+ /** The mode that turned the flag off for the session's last request, if any. */
180
+ by(sessionID: string): "auto" | "off" | undefined {
181
+ return this.sessions.get(sessionID)?.by;
182
+ }
183
+
184
+ /** A compaction ran: the next crossing is a new one. */
185
+ reset(sessionID: string): void {
186
+ const entry = this.sessions.get(sessionID);
187
+ if (entry) entry.crossing = false;
188
+ }
189
+
190
+ /**
191
+ * The newest finished step of the session (not a summary), seen at the next transform.
192
+ * Returns its count and the mode when the plugin's flag cancelled a threshold compaction on
193
+ * it and this crossing was not reported yet; undefined otherwise. Each step is judged once.
194
+ */
195
+ observe(sessionID: string, step: { id: string; count: number } | undefined, usable: number | undefined): { count: number; by: "auto" | "off" } | undefined {
196
+ const entry = this.sessions.get(sessionID);
197
+ if (!entry || !step || step.id === entry.checked) return undefined;
198
+ entry.checked = step.id;
199
+ if (usable === undefined || step.count < usable) {
200
+ entry.crossing = false;
201
+ return undefined;
202
+ }
203
+ if (!entry.by || entry.crossing) return undefined;
204
+ entry.crossing = true;
205
+ return { count: step.count, by: entry.by };
206
+ }
207
+ }
208
+
209
+ /** Toast when `auto` paused a threshold compaction (pi: toast only). */
210
+ export function thresholdPausedText(count: number, usable: number, guardLimit: number | undefined): string {
211
+ const format = (value: number) => value.toLocaleString("en-US");
212
+ const guard = guardLimit !== undefined ? `; the overflow guard keeps the context under ~${format(guardLimit)} tokens` : "";
213
+ return `Paused OpenCode's threshold compaction: the last request counted ${format(count)} of OpenCode's ${format(usable)}-token threshold${guard}. Set /clm config compaction on to let OpenCode compact.`;
214
+ }
215
+
216
+ /** Model notice when `off` cancelled a threshold compaction (pi: notice and toast). */
217
+ export function thresholdCancelledNoticeText(mirrorPath: string): string {
218
+ return `[CLM] OpenCode's automatic compaction (threshold) was cancelled: CLM manages this context. Free space by editing ${mirrorPath}.`;
219
+ }
220
+
221
+ /** Toast when `off` cancelled a threshold compaction. */
222
+ export function thresholdCancelledText(count: number, usable: number): string {
223
+ const format = (value: number) => value.toLocaleString("en-US");
224
+ return `Cancelled OpenCode's threshold compaction (setting compaction off): the last request counted ${format(count)} of ${format(usable)} tokens.`;
225
+ }
226
+
227
+ /**
228
+ * The step OpenCode's pre-step check reads (`MessageV2.latest(...).finished`,
229
+ * session/message-v2.ts:586-602): the newest assistant message with `finish` set, by
230
+ * creation time then id. Undefined when there is none or it is a compaction summary
231
+ * (prompt.ts:1163 skips summaries).
232
+ */
233
+ export function lastFinishedStep(raw: readonly { info: { id: string; role: string; [key: string]: any } }[]): { id: string; count: number } | undefined {
234
+ let finished: { id: string; role: string; [key: string]: any } | undefined;
235
+ for (const { info } of raw) {
236
+ if (info.role !== "assistant" || !info.finish) continue;
237
+ const created = Number(info.time?.created ?? 0);
238
+ const newest = Number(finished?.time?.created ?? 0);
239
+ if (!finished || created > newest || (created === newest && info.id > finished.id)) finished = info;
240
+ }
241
+ if (!finished || finished.summary === true || !finished.tokens) return undefined;
242
+ return { id: finished.id, count: openCodeCount(finished.tokens as StepTokens) };
243
+ }
@@ -132,7 +132,8 @@ export function estimateContextCharacters(messages: LiveContextMessage[]): numbe
132
132
  return messages.reduce((total, message) => total + renderMessage(message).length, 0);
133
133
  }
134
134
 
135
- function blockId(message: LiveContextMessage, index: number): string {
135
+ /** The mirror block id of `message` at position `index` of the rendered messages. */
136
+ export function blockId(message: LiveContextMessage, index: number): string {
136
137
  const digest = createHash("sha256").update(canonicalMessage(message)).digest("hex").slice(0, 12);
137
138
  return `${index + 1}-${digest}`;
138
139
  }
package/src/continuity.ts CHANGED
@@ -17,7 +17,7 @@ import { resolve } from "node:path";
17
17
 
18
18
  import type { ToolContext, ToolDefinition, tool } from "@opencode-ai/plugin";
19
19
 
20
- import { digestMessages, renderMessage } from "./context-document.ts";
20
+ import { blockId, digestMessages, renderMessage } from "./context-document.ts";
21
21
  import type { LiveContextMessage } from "./types.ts";
22
22
 
23
23
  export const ANNOTATIONS_FILE = "annotations.jsonl";
@@ -413,6 +413,113 @@ export interface CreateAnnotationInput {
413
413
  */
414
414
  const fileQueues = new Map<string, Promise<unknown>>();
415
415
 
416
+ /**
417
+ * The origin's annotations as a fork of it carries them (pi: a branch carries the annotation
418
+ * entries on its path). OpenCode's `Session.fork` copies messages with new ids
419
+ * (session/session.ts:691-730) and keeps their times, and an annotation's `contentHash` and
420
+ * block id digest the source message with its id. So:
421
+ * - an annotation created after `cutoff` (ms; the fork's newest copied message) belongs to
422
+ * the origin's later history and is left out; one resolved after it is carried unresolved;
423
+ * - a source found in the fork's `messages` by its rendered text (role and text, or the head
424
+ * of a truncated snapshot) is re-pointed at the fork's copy: hash, block id (its position
425
+ * in `messages`) and session; an unmatched source keeps the origin's values and
426
+ * stays recallable from its stored text. Identical messages (a repeated prompt, the same
427
+ * tool output twice) map to the first copy: same text and hash, possibly another block id.
428
+ * Block ids index `messages` as the caller passes them (the effective messages before the
429
+ * overflow guard, which replaces results in place and so keeps positions).
430
+ * Ids, titles, text, creation times and `source.revision` (the origin's revision) are kept.
431
+ */
432
+ export function forkAnnotations(
433
+ annotations: readonly LiveContextAnnotation[],
434
+ messages: readonly LiveContextMessage[],
435
+ options: { sessionId: string; cutoff?: number },
436
+ ): LiveContextAnnotation[] {
437
+ const rendered = messages.map((message) => renderMessage(message));
438
+ const before = (iso: string | undefined) => iso !== undefined && (options.cutoff === undefined || Date.parse(iso) <= options.cutoff);
439
+ const out: LiveContextAnnotation[] = [];
440
+ for (const annotation of annotations) {
441
+ if (!before(annotation.createdAt)) continue;
442
+ const copy: LiveContextAnnotation = { ...annotation, source: { ...annotation.source } };
443
+ if (copy.resolvedAt !== undefined && !before(copy.resolvedAt)) {
444
+ delete copy.resolvedAt;
445
+ delete copy.resolution;
446
+ }
447
+ const { source } = copy;
448
+ const index = messages.findIndex((message, position) =>
449
+ message.role === source.role &&
450
+ (source.truncated ? rendered[position]!.startsWith(source.text) : rendered[position] === source.text));
451
+ if (index >= 0) {
452
+ copy.source = { ...source, sessionId: options.sessionId, contentHash: sourceContentHash(messages[index]!), blockId: blockId(messages[index]!, index) };
453
+ }
454
+ out.push(copy);
455
+ }
456
+ return out;
457
+ }
458
+
459
+ /** Bound on the annotations a session without a mirror keeps in OpenCode's session metadata, bytes of JSON. */
460
+ export const PERSISTED_ANNOTATIONS_BYTES = 64 * 1024;
461
+
462
+ /**
463
+ * Annotations of a session without a mirror, as kept in OpenCode's session metadata
464
+ * (`metadata.clm.annotations`) so they survive a restart; the session's files then live in a
465
+ * per-process temp directory. `resolved` records every resolution (small); `annotations`
466
+ * holds full snapshots, active ones first, as many as fit the byte bound.
467
+ */
468
+ export interface PersistedAnnotations {
469
+ version: 1;
470
+ resolved: Array<{ id: string; resolvedAt: string; resolution?: string }>;
471
+ annotations: LiveContextAnnotation[];
472
+ }
473
+
474
+ /** The record for `metadata.clm.annotations`, at most `maxBytes` of JSON. */
475
+ export function persistAnnotations(annotations: readonly LiveContextAnnotation[], maxBytes = PERSISTED_ANNOTATIONS_BYTES): PersistedAnnotations {
476
+ const out: PersistedAnnotations = { version: 1, resolved: [], annotations: [] };
477
+ let size = Buffer.byteLength(JSON.stringify(out));
478
+ const fits = (value: unknown) => {
479
+ const added = Buffer.byteLength(JSON.stringify(value)) + 1;
480
+ if (size + added > maxBytes) return false;
481
+ size += added;
482
+ return true;
483
+ };
484
+ for (const annotation of annotations) {
485
+ if (annotation.resolvedAt === undefined) continue;
486
+ const stub = { id: annotation.id, resolvedAt: annotation.resolvedAt, ...(annotation.resolution !== undefined ? { resolution: annotation.resolution } : {}) };
487
+ if (fits(stub)) out.resolved.push(stub);
488
+ }
489
+ const rank = (annotation: LiveContextAnnotation) => (annotation.resolvedAt === undefined && annotation.retention !== "archive" ? 0 : 1);
490
+ for (const annotation of [...annotations].sort((left, right) => rank(left) - rank(right))) {
491
+ if (fits(annotation)) out.annotations.push(annotation);
492
+ }
493
+ return out;
494
+ }
495
+
496
+ /**
497
+ * Snapshots to append to a store holding `current` so it reflects `persisted` (a
498
+ * `PersistedAnnotations` read back from metadata; anything else yields nothing): annotations
499
+ * the store lacks, then resolutions of ones it holds unresolved.
500
+ */
501
+ export function restorePersisted(current: readonly LiveContextAnnotation[], persisted: unknown): LiveContextAnnotation[] {
502
+ if (!persisted || typeof persisted !== "object") return [];
503
+ const record = persisted as Partial<PersistedAnnotations>;
504
+ if (record.version !== 1) return [];
505
+ const byId = new Map(current.map((annotation) => [annotation.id, annotation]));
506
+ const out: LiveContextAnnotation[] = [];
507
+ for (const annotation of Array.isArray(record.annotations) ? record.annotations : []) {
508
+ if (!isLiveContextAnnotation(annotation) || byId.has(annotation.id)) continue;
509
+ byId.set(annotation.id, annotation);
510
+ out.push(annotation);
511
+ }
512
+ for (const stub of Array.isArray(record.resolved) ? record.resolved : []) {
513
+ if (!stub || typeof stub !== "object" || typeof stub.id !== "string" || typeof stub.resolvedAt !== "string") continue;
514
+ const annotation = byId.get(stub.id);
515
+ if (!annotation || annotation.resolvedAt !== undefined) continue;
516
+ const resolved = resolveAnnotation(annotation, typeof stub.resolution === "string" ? stub.resolution : undefined, stub.resolvedAt);
517
+ byId.set(stub.id, resolved);
518
+ out.push(resolved);
519
+ }
520
+ return out;
521
+ }
522
+
416
523
  /** Append-only `annotations.jsonl` in one session directory. */
417
524
  export class AnnotationStore {
418
525
  readonly filePath: string;
@@ -492,6 +599,25 @@ export class AnnotationStore {
492
599
  });
493
600
  }
494
601
 
602
+ /** Appends snapshots as they are (`restorePersisted`); the latest per id wins on load. */
603
+ appendAll(annotations: readonly LiveContextAnnotation[]): Promise<void> {
604
+ return this.serialize(async () => {
605
+ for (const annotation of annotations) await this.append(annotation);
606
+ });
607
+ }
608
+
609
+ /**
610
+ * Writes another session's annotations into this store as they are (`forkAnnotations`),
611
+ * only while this store holds none; returns how many were written.
612
+ */
613
+ importAll(annotations: readonly LiveContextAnnotation[]): Promise<number> {
614
+ return this.serialize(async () => {
615
+ if (annotations.length === 0 || (await this.list()).length > 0) return 0;
616
+ for (const annotation of annotations) await this.append(annotation);
617
+ return annotations.length;
618
+ });
619
+ }
620
+
495
621
  async recall(
496
622
  id: string,
497
623
  maxTokens?: number,
package/src/history.ts ADDED
@@ -0,0 +1,109 @@
1
+ // Checkpoint history: every accepted revision's checkpoint, kept in
2
+ // `<session dir>/checkpoints/rN.json` (the newest HISTORY_CAP files). pi-clm rebuilds its
3
+ // history from the session entries on the active branch (pi-clm src/index.ts `restore`,
4
+ // MIT, Copyright 2026 Emanuel Casco) and restores the checkpoint valid on the branch `/tree`
5
+ // selects. OpenCode has no branches; its equivalents are revert
6
+ // (packages/opencode/src/session/revert.ts), which cuts the history, and `Session.fork`
7
+ // (packages/opencode/src/session/session.ts:691-731), which copies it into a new session
8
+ // under new message ids. clm.ts uses this history to restore the newest older revision that
9
+ // still fits after either. Written for this package.
10
+ import { mkdir, readdir, readFile, rm } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+
13
+ import { writeJsonAtomic } from "./atomic.ts";
14
+ import { sourceMessageId, type ProjectionCheckpoint } from "./projection.ts";
15
+ import { isProjectionCheckpoint } from "./state.ts";
16
+ import type { LiveContextMessage } from "./types.ts";
17
+
18
+ export const HISTORY_DIR = "checkpoints";
19
+ /** Checkpoints kept per session; each holds a whole projected context, so the count is small. */
20
+ export const HISTORY_CAP = 8;
21
+
22
+ const FILE_RE = /^r(\d+)\.json$/;
23
+
24
+ export interface HistoryEntry {
25
+ version: 1;
26
+ checkpoint: ProjectionCheckpoint;
27
+ /** `digestSourceContent` of the checkpoint's source: matches a forked copy of it. */
28
+ contentDigest: string;
29
+ }
30
+
31
+ export interface LoadedHistory {
32
+ /** Newest revision first. */
33
+ entries: HistoryEntry[];
34
+ /** Files that were skipped (unreadable, malformed, or naming another revision). */
35
+ ignored: string[];
36
+ }
37
+
38
+ export function historyDirectory(sessionDirectory: string): string {
39
+ return join(sessionDirectory, HISTORY_DIR);
40
+ }
41
+
42
+ function isHistoryEntry(value: unknown): value is HistoryEntry {
43
+ if (!value || typeof value !== "object") return false;
44
+ const entry = value as Partial<HistoryEntry>;
45
+ return entry.version === 1 && isProjectionCheckpoint(entry.checkpoint) &&
46
+ typeof entry.contentDigest === "string" && /^[a-f0-9]{64}$/.test(entry.contentDigest);
47
+ }
48
+
49
+ /** Write one checkpoint, then delete all but the newest `cap` files. */
50
+ export async function saveHistoryEntry(sessionDirectory: string, entry: HistoryEntry, cap = HISTORY_CAP): Promise<void> {
51
+ const directory = historyDirectory(sessionDirectory);
52
+ await mkdir(directory, { recursive: true, mode: 0o700 });
53
+ await writeJsonAtomic(join(directory, `r${entry.checkpoint.revision}.json`), entry);
54
+ const revisions = (await readdir(directory))
55
+ .map((name) => FILE_RE.exec(name))
56
+ .filter((match): match is RegExpExecArray => match !== null)
57
+ .map((match) => Number(match[1]))
58
+ .sort((left, right) => right - left);
59
+ await Promise.all(revisions.slice(Math.max(1, cap)).map((revision) =>
60
+ rm(join(directory, `r${revision}.json`), { force: true })));
61
+ }
62
+
63
+ /** All usable entries, newest first. Never throws: a missing directory is an empty history. */
64
+ export async function loadHistory(sessionDirectory: string): Promise<LoadedHistory> {
65
+ const directory = historyDirectory(sessionDirectory);
66
+ let names: string[];
67
+ try {
68
+ names = await readdir(directory);
69
+ } catch {
70
+ return { entries: [], ignored: [] };
71
+ }
72
+ const entries: HistoryEntry[] = [];
73
+ const ignored: string[] = [];
74
+ for (const name of names) {
75
+ const match = FILE_RE.exec(name);
76
+ if (!match) continue;
77
+ try {
78
+ const parsed: unknown = JSON.parse(await readFile(join(directory, name), "utf8"));
79
+ if (isHistoryEntry(parsed) && parsed.checkpoint.revision === Number(match[1])) entries.push(parsed);
80
+ else ignored.push(name);
81
+ } catch {
82
+ ignored.push(name);
83
+ }
84
+ }
85
+ entries.sort((left, right) => right.checkpoint.revision - left.checkpoint.revision);
86
+ return { entries, ignored };
87
+ }
88
+
89
+ /** Forget every checkpoint (`/clm reset`: the user dropped the edits on purpose). */
90
+ export async function clearHistory(sessionDirectory: string): Promise<void> {
91
+ await rm(historyDirectory(sessionDirectory), { recursive: true, force: true });
92
+ }
93
+
94
+ /**
95
+ * The checkpoint's projected messages with the source session's message ids replaced by
96
+ * the fork's, position by position over the source prefix (`source` is the fork's prefix,
97
+ * same length as `checkpoint.sourceIds`). Ids outside the prefix stay as they are.
98
+ */
99
+ export function remapProjection(checkpoint: ProjectionCheckpoint, source: readonly LiveContextMessage[]): LiveContextMessage[] {
100
+ const ids = new Map<string, string>();
101
+ checkpoint.sourceIds.forEach((id, index) => {
102
+ const next = source[index] ? sourceMessageId(source[index]) : "";
103
+ if (id && next && !ids.has(id)) ids.set(id, next);
104
+ });
105
+ return checkpoint.projectedMessages.map((message) => {
106
+ const id = typeof message.ocMessageID === "string" ? ids.get(message.ocMessageID) : undefined;
107
+ return id === undefined ? message : { ...message, ocMessageID: id };
108
+ });
109
+ }
@@ -1,8 +1,9 @@
1
1
  // Adapted from pi-clm src/mirror-store.ts (MIT, Copyright 2026 Emanuel Casco).
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
- import { chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
- import { homedir } from "node:os";
4
+ import { constants } from "node:fs";
5
+ import { chmod, lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { homedir, tmpdir } from "node:os";
6
7
  import { basename, join } from "node:path";
7
8
  import { randomUUID } from "node:crypto";
8
9
 
@@ -14,7 +15,14 @@ export function defaultMirrorParent(): string {
14
15
  return join(stateHome, "opencode-clm", "mirrors");
15
16
  }
16
17
 
17
- function safeSessionId(sessionId: string): string {
18
+ /** The session directory under a mirror parent could not be created or was refused. */
19
+ export class MirrorDirectoryError extends Error {
20
+ constructor(readonly parent: string, readonly cause: unknown) {
21
+ super(`could not use ${parent}: ${cause instanceof Error ? cause.message : String(cause)}`);
22
+ }
23
+ }
24
+
25
+ export function safeSessionId(sessionId: string): string {
18
26
  const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64);
19
27
  return safe || "session";
20
28
  }
@@ -76,6 +84,36 @@ async function assertPrivateDirectory(directory: string): Promise<void> {
76
84
  }
77
85
  }
78
86
 
87
+ /** A fresh private (0700) directory under the OS temp directory, for sessions without a usable mirror directory. */
88
+ export function privateTemporaryParent(): Promise<string> {
89
+ return mkdtemp(join(tmpdir(), "opencode-clm-"));
90
+ }
91
+
92
+ /**
93
+ * Copies `name` from `fromDirectory` into `toDirectory` (mode 0600), only when the source
94
+ * directory passes the store's own check (a real directory owned by this user) and the file
95
+ * is a regular file. Never overwrites. Returns whether a copy was made; never throws.
96
+ */
97
+ export async function copyPrivateFile(fromDirectory: string, toDirectory: string, name: string): Promise<boolean> {
98
+ try {
99
+ await assertPrivateDirectory(fromDirectory);
100
+ // O_NOFOLLOW and a check on the open handle: a file swapped for a symlink is not followed.
101
+ // O_NONBLOCK: opening a FIFO must not block before the isFile() check.
102
+ const handle = await open(join(fromDirectory, name), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
103
+ let content: Buffer;
104
+ try {
105
+ if (!(await handle.stat()).isFile()) return false;
106
+ content = await handle.readFile();
107
+ } finally {
108
+ await handle.close();
109
+ }
110
+ await writeFile(join(toDirectory, name), content, { mode: 0o600, flag: "wx" });
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
79
117
  export class MirrorStore {
80
118
  readonly directory: string;
81
119
  readonly filePath: string;
package/src/opencode.ts CHANGED
@@ -337,3 +337,62 @@ export function unflatten(messages: readonly LiveContextMessage[], context: Unfl
337
337
  export function replaceInPlace(target: OcMessage[], next: readonly OcMessage[]): void {
338
338
  target.splice(0, target.length, ...next);
339
339
  }
340
+
341
+ /**
342
+ * The part of a stored history OpenCode sends to the model: port of OpenCode 1.18.34
343
+ * `MessageV2.filterCompacted` (packages/opencode/src/session/message-v2.ts:525-576), which
344
+ * the prompt loop applies to the stored stream (session/prompt.ts:1092). `client.session.messages`
345
+ * returns the full history, oldest first (no filter); OpenCode's `stream` walks it newest
346
+ * first, so this walks `messages` backwards. After a completed compaction the result is
347
+ * [compaction user message, summary, retained tail (`tail_start_id`), newer messages];
348
+ * without one it is `messages` unchanged.
349
+ */
350
+ export function filterCompacted(messages: readonly OcMessage[]): OcMessage[] {
351
+ const result: OcMessage[] = [];
352
+ const completed = new Set<string>();
353
+ let retain: string | undefined;
354
+ for (let i = messages.length - 1; i >= 0; i--) {
355
+ const message = messages[i]!;
356
+ result.push(message);
357
+ if (retain) {
358
+ if (message.info.id === retain) break;
359
+ continue;
360
+ }
361
+ if (message.info.role === "user" && completed.has(message.info.id)) {
362
+ const part = message.parts.find((item) => item.type === "compaction");
363
+ if (!part) continue;
364
+ if (!part.tail_start_id) break;
365
+ retain = String(part.tail_start_id);
366
+ if (message.info.id === retain) break;
367
+ continue;
368
+ }
369
+ if (message.info.role === "assistant" && message.info.summary && message.info.finish && !message.info.error) {
370
+ completed.add(message.info.parentID);
371
+ }
372
+ }
373
+ result.reverse();
374
+ const isTailCompaction = (item: OcPart) => item.type === "compaction" && item.tail_start_id !== undefined;
375
+ let compactionIndex = -1;
376
+ for (let i = result.length - 1; i >= 0; i--) {
377
+ const message = result[i]!;
378
+ if (message.info.role === "user" && message.parts.some(isTailCompaction)) {
379
+ compactionIndex = i;
380
+ break;
381
+ }
382
+ }
383
+ const compaction = result[compactionIndex];
384
+ const part = compaction?.parts.find(isTailCompaction);
385
+ const summaryIndex = compaction
386
+ ? result.findIndex((message, index) =>
387
+ index > compactionIndex && message.info.role === "assistant" && message.info.summary && message.info.parentID === compaction.info.id)
388
+ : -1;
389
+ const tailIndex = part?.tail_start_id ? result.findIndex((message) => message.info.id === part.tail_start_id) : -1;
390
+ if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
391
+ return [
392
+ ...result.slice(compactionIndex, summaryIndex + 1),
393
+ ...result.slice(tailIndex, compactionIndex),
394
+ ...result.slice(summaryIndex + 1),
395
+ ];
396
+ }
397
+ return result;
398
+ }