opencode-clm 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,7 +19,7 @@ export type ClmCommand =
19
19
  | { kind: "server"; args: string }
20
20
  | { kind: "usage"; text: string };
21
21
 
22
- export const CLM_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | config [setting [value]]]";
22
+ export const CLM_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | budget [value] | config [setting [value]]]";
23
23
 
24
24
  /** Page names and the short forms pi accepts. */
25
25
  const PAGE_ALIASES: Record<string, Page> = {
@@ -54,7 +54,13 @@ export function parseClmCommand(text: string): ClmCommand | undefined {
54
54
  case "off":
55
55
  return rest.length === 0 ? { kind: "enable", enabled: word === "on" } : { kind: "usage", text: CLM_USAGE };
56
56
  case "reset":
57
- return { kind: "server", args };
57
+ // Extra words are a typo, not a reset: dropping the revision cannot be undone.
58
+ return rest.length === 0 ? { kind: "server", args } : { kind: "usage", text: CLM_USAGE };
59
+ case "budget": {
60
+ // pi's shorthand for `/clm config budget …`.
61
+ const value = args.replace(/^\S+\s*/, "");
62
+ return value === "" ? { kind: "config-show", setting: "budget" } : { kind: "config-set", setting: "budget", value };
63
+ }
58
64
  case "config": {
59
65
  if (rest.length === 0) return { kind: "open", page: "settings" };
60
66
  const setting = rest[0]!.toLowerCase();
@@ -60,6 +60,8 @@ export interface SnapshotFile {
60
60
  budget: { budget: number; reserve: number; limit: number; source: string };
61
61
  calibration: { factor: number; samples: number };
62
62
  steering?: { name: string; hash: string; path: string };
63
+ /** Why the configured steering document did not load (sessions then run protocol-only). */
64
+ steeringError?: string;
63
65
  sizes: { estimated: number; observedPrevious?: number };
64
66
  input: {
65
67
  raw: number;
@@ -98,6 +100,8 @@ export interface SessionFiles {
98
100
  revisionTexts: Map<number, string>;
99
101
  /** Path of the mirror file, whether or not it exists. */
100
102
  mirrorPath: string;
103
+ /** annotations.jsonl counts: unresolved continuity/pin, unresolved archive, all. */
104
+ annotations?: { active: number; archived: number; total: number };
101
105
  /** Read problems other than missing files. */
102
106
  warnings: string[];
103
107
  }
@@ -123,7 +123,13 @@ export interface PanelModel {
123
123
  budgetInfo?: BudgetView;
124
124
  calibration?: { factor: number; samples: number };
125
125
  steering?: { name: string; hash: string; path: string };
126
+ /** snapshot.json `steeringError`: the configured steering document did not load. */
127
+ steeringError?: string;
128
+ /** annotations.jsonl counts (`countAnnotations`): active continuity/pin, unresolved archive, all. */
129
+ annotations?: { active: number; archived: number; total: number };
126
130
  mirrorPath: string;
131
+ /** The newest `mirror-unavailable` event's reason: the session runs without a mirror. */
132
+ mirrorUnavailable?: string;
127
133
  /** Problems reading the files (corrupt JSON, skipped event lines). */
128
134
  warnings: string[];
129
135
  }
@@ -245,11 +251,13 @@ export function buildTimeline(events: readonly ClmEvent[], latest?: LatestUsage)
245
251
  let tokens = observedAfter.get(position);
246
252
  if (tokens === undefined && position === lastRequest?.position) tokens = latestTokens;
247
253
  const measured = tokens !== undefined;
254
+ const estimated = num(event.estimated);
248
255
  points.push({
249
256
  request: points.length + 1,
250
257
  ...(at ? { at } : {}),
251
- tokens: tokens ?? Math.max(0, num(event.estimated) ?? 0),
258
+ tokens: tokens ?? Math.max(0, estimated ?? 0),
252
259
  measured,
260
+ ...(measured && estimated !== undefined && estimated > 0 ? { estimated } : {}),
253
261
  revision: count(event.revision) ?? 0,
254
262
  });
255
263
  // `users` counts user messages in the raw history; a rise starts a turn. A drop
@@ -297,6 +305,13 @@ function markerOf(name: string | undefined, event: ClmEvent, afterPoint: number)
297
305
  const reason = str(event.reason) ?? "OpenCode's history changed.";
298
306
  return base("reset", dropped === undefined ? 0 : dropped + 1, dropped === undefined ? reason : `Revision ${dropped} dropped: ${reason}`);
299
307
  }
308
+ case "restored": {
309
+ // Revert (`dropped`) or fork (`origin`): an older revision became the next one.
310
+ const from = count(event.from);
311
+ const origin = str(event.origin);
312
+ const source = from === undefined ? "an earlier revision" : `r${from}`;
313
+ return base("restored", revision ?? 0, origin ? `restored ${source} of ${origin} (fork)` : `restored ${source} (revert)`);
314
+ }
300
315
  case "compacted": {
301
316
  const summary = str(event.summary);
302
317
  return base("compacted", revision ?? 0, summary ? `Rebased on compaction summary ${summary}.` : "Rebased on a compaction summary.");
@@ -539,9 +554,16 @@ export function buildPanelModel(files: SessionFiles, extras: PanelExtras = {}):
539
554
  const calibration = isObject(snapshot?.calibration) ? snapshot.calibration : undefined;
540
555
  const factor = num(calibration?.factor);
541
556
  if (factor !== undefined) model.calibration = { factor, samples: count(calibration?.samples) ?? 0 };
557
+ if (files.annotations) model.annotations = files.annotations;
542
558
  const steering = isObject(snapshot?.steering) ? snapshot.steering : undefined;
543
559
  if (steering && typeof steering.name === "string" && typeof steering.hash === "string" && typeof steering.path === "string") {
544
560
  model.steering = { name: steering.name, hash: steering.hash, path: steering.path };
545
561
  }
562
+ if (typeof snapshot?.steeringError === "string" && snapshot.steeringError !== "") model.steeringError = snapshot.steeringError;
563
+ const unavailable = files.events.filter((event) => eventName(event) === "mirror-unavailable").at(-1)?.reason;
564
+ if (typeof unavailable === "string") {
565
+ model.mirrorUnavailable = unavailable;
566
+ model.mirrorPath = `unavailable (${unavailable})`;
567
+ }
546
568
  return model;
547
569
  }
@@ -15,11 +15,13 @@ export interface TimelinePoint {
15
15
  /** Context size of this request: provider-reported when `measured`, else the CLM estimate. */
16
16
  tokens: number;
17
17
  measured: boolean;
18
+ /** The CLM estimate logged for this request, kept beside a provider count (`measured`). */
19
+ estimated?: number;
18
20
  /** Live-context revision active when the request was sent. */
19
21
  revision: number;
20
22
  }
21
23
 
22
- export type MarkerKind = "applied" | "rejected" | "reset" | "compacted";
24
+ export type MarkerKind = "applied" | "rejected" | "reset" | "compacted" | "restored";
23
25
 
24
26
  export interface TimelineMarker {
25
27
  kind: MarkerKind;
@@ -410,6 +412,8 @@ export function formatMarkerCompact(marker: TimelineMarker): string {
410
412
  return `r${marker.revision} ${time} reset to raw context`;
411
413
  case "compacted":
412
414
  return `r${marker.revision} ${time} compacted by OpenCode`;
415
+ case "restored":
416
+ return `r${marker.revision} ${time} ${marker.message}`;
413
417
  case "rejected":
414
418
  return `r${marker.revision} ${time} rejected: ${marker.message}`;
415
419
  case "applied": {
@@ -439,6 +443,8 @@ export function formatMarkerRow(marker: TimelineMarker, timeline: ContextTimelin
439
443
  return `r${marker.revision} ${time} reset to raw context (${where})`;
440
444
  case "compacted":
441
445
  return `r${marker.revision} ${time} compacted by OpenCode (${where})`;
446
+ case "restored":
447
+ return `r${marker.revision} ${time} ${marker.message} (${where})`;
442
448
  case "rejected":
443
449
  return `r${marker.revision} ${time} edit rejected: ${marker.message} (${where})`;
444
450
  }
package/src/panel/view.ts CHANGED
@@ -51,6 +51,7 @@ const ZOOM_LABELS: Record<TimelineZoom, string> = { fit: "all", requests: "detai
51
51
  const EDIT_TONE: Tone = "edit";
52
52
  const KEPT_PREVIEW_CHARACTERS = 2_000;
53
53
  const MIRROR_TEXT_MAX_LINES = 200;
54
+ const MIRROR_UNAVAILABLE_TEXT = "No mirror: requests carry the raw history plus the continuity annotations; edits are off.";
54
55
 
55
56
  /** Body rows available for the page. */
56
57
  export function viewportHeight(size: PanelSize): number {
@@ -191,6 +192,8 @@ function overviewLines(model: PanelModel, state: PanelState, width: number, view
191
192
  const timeline = model.timeline;
192
193
  const notice = !model.enabled
193
194
  ? text("Projection off: the model sees the raw history (/clm on).", "warning")
195
+ : model.mirrorUnavailable !== undefined
196
+ ? text(MIRROR_UNAVAILABLE_TEXT, "warning")
194
197
  : model.lastOutcome?.kind === "rejected"
195
198
  ? text(`Last edit rejected: ${model.lastOutcome.message}`, "warning")
196
199
  : undefined;
@@ -271,7 +274,9 @@ function inputLines(model: PanelModel, width: number): Line[] {
271
274
  return wrapAll([
272
275
  title,
273
276
  [],
274
- text("No input snapshot yet: snapshot.json appears after the next model request.", "muted"),
277
+ model.mirrorUnavailable !== undefined
278
+ ? text(`${MIRROR_UNAVAILABLE_TEXT} No input snapshot is kept.`, "warning")
279
+ : text("No input snapshot yet: snapshot.json appears after the next model request.", "muted"),
275
280
  [],
276
281
  text(`Mirror: ${model.mirrorPath}`, "dim"),
277
282
  ], width);
@@ -447,6 +452,10 @@ export function settingsPage(model: PanelModel, state: PanelState, width: number
447
452
  [span("Settings", "accent", true), span(" · changes apply from the next request and are saved in this session", "muted")],
448
453
  ...settings.summary.map((line) => text(line, "dim")),
449
454
  ...(settings.warning ? [text(`⚠ ${settings.warning}`, "warning")] : []),
455
+ // The server's own record, also when the TUI's settings check passed.
456
+ ...(model.steeringError && !settings.warning?.includes(model.steeringError)
457
+ ? [text(`⚠ steering document not loaded, the session runs without it: ${model.steeringError}`, "warning")]
458
+ : []),
450
459
  [],
451
460
  ], width);
452
461
  if (settings.rows.length === 0) {
package/src/projection.ts CHANGED
@@ -85,6 +85,14 @@ export function digestSourcePrefix(messages: readonly LiveContextMessage[]): str
85
85
  );
86
86
  }
87
87
 
88
+ /**
89
+ * `digestSourcePrefix` without OpenCode message ids. `Session.fork` copies every message
90
+ * under a new id, so the copied history digests the same way as the original.
91
+ */
92
+ export function digestSourceContent(messages: readonly LiveContextMessage[]): string {
93
+ return digestSourcePrefix(messages.map(({ ocMessageID: _id, ...rest }) => rest as LiveContextMessage));
94
+ }
95
+
88
96
  export function createProjectionCheckpoint(options: {
89
97
  revision: number;
90
98
  sourceMessages: readonly LiveContextMessage[];
@@ -1,7 +1,8 @@
1
1
  // Read-only access to one session's CLM directory (`<mirrorDir>/clm-<session>/`) for the
2
2
  // `/clm` panel: events.jsonl, state.json, snapshot.json and revisions/rN.{json,md}. Missing
3
3
  // files are normal (a v0.1 session has no snapshot or rN.json); torn or corrupt lines and
4
- // files are skipped, never thrown. Written for this package.
4
+ // files are skipped, never thrown. Reads go through a `SessionReader`: the disk, or the
5
+ // server's file API for an attached TUI (src/tui/remote.ts). Written for this package.
5
6
 
6
7
  import { readdir, readFile, stat } from "node:fs/promises";
7
8
  import { join } from "node:path";
@@ -16,6 +17,17 @@ import {
16
17
  type SessionFiles,
17
18
  } from "./panel/files.ts";
18
19
  import { STATE_FILE } from "./state.ts";
20
+ import { activeContinuityAnnotations, ANNOTATIONS_FILE, reconstructAnnotations } from "./continuity.ts";
21
+
22
+ /** Counts of annotations.jsonl (latest snapshot per id, as the server reconstructs them). */
23
+ export function countAnnotations(text: string): { active: number; archived: number; total: number } {
24
+ const annotations = reconstructAnnotations(parseEvents(text).events);
25
+ return {
26
+ active: activeContinuityAnnotations(annotations).length,
27
+ archived: annotations.filter((annotation) => annotation.retention === "archive" && annotation.resolvedAt === undefined).length,
28
+ total: annotations.length,
29
+ };
30
+ }
19
31
 
20
32
  /** `<mirrorDir>/clm-<safe session id>`, the directory MirrorStore creates for the session. */
21
33
  export function sessionDirectory(mirrorDir: string, sessionID: string): string {
@@ -47,40 +59,123 @@ export function parseEvents(text: string): { events: ClmEvent[]; skipped: number
47
59
  }
48
60
 
49
61
  /**
50
- * Revision files keyed by path, kept while their mtime and size are unchanged, so a reload
62
+ * Where a session directory is read from: the local disk, or the server through OpenCode's
63
+ * file API when the TUI runs elsewhere (src/tui/remote.ts). Paths are the server's absolute
64
+ * paths either way.
65
+ */
66
+ export interface SessionReader {
67
+ /** Cache-key prefix, so the same path read two ways does not share entries. */
68
+ readonly name: string;
69
+ /** True for a directory; false when missing. Throws for other errors. */
70
+ isDirectory(path: string): Promise<boolean>;
71
+ /** File text; undefined when missing. Throws for other errors. */
72
+ readText(path: string): Promise<string | undefined>;
73
+ /** Entry names; [] when the directory is missing. Throws for other errors. */
74
+ list(path: string): Promise<string[]>;
75
+ /**
76
+ * Version stamp of a file for the revision cache (mtime and size locally); undefined
77
+ * when missing. A constant stamp caches the file for good (rN files are written once).
78
+ */
79
+ stamp(path: string): Promise<string | undefined>;
80
+ }
81
+
82
+ export const localReader: SessionReader = {
83
+ name: "local",
84
+ async isDirectory(path) {
85
+ try {
86
+ return (await stat(path)).isDirectory();
87
+ } catch (error) {
88
+ if (isMissing(error)) return false;
89
+ throw error;
90
+ }
91
+ },
92
+ async readText(path) {
93
+ try {
94
+ return await readFile(path, "utf8");
95
+ } catch (error) {
96
+ if (isMissing(error)) return undefined;
97
+ throw error;
98
+ }
99
+ },
100
+ async list(path) {
101
+ try {
102
+ return await readdir(path);
103
+ } catch (error) {
104
+ if (isMissing(error)) return [];
105
+ throw error;
106
+ }
107
+ },
108
+ async stamp(path) {
109
+ try {
110
+ const info = await stat(path);
111
+ return `${info.mtimeMs}:${info.size}`;
112
+ } catch (error) {
113
+ if (isMissing(error)) return undefined;
114
+ throw error;
115
+ }
116
+ },
117
+ };
118
+
119
+ /**
120
+ * Revision files keyed by reader and path, kept while their stamp is unchanged, so a reload
51
121
  * on every streamed message reads only new or changed revisions. rN files are written once.
52
122
  */
53
- const revisionCache = new Map<string, { mtimeMs: number; size: number; value: unknown }>();
123
+ const revisionCache = new Map<string, { stamp: string; size: number; value: unknown }>();
54
124
  const REVISION_CACHE_LIMIT = 2_000;
55
- /** Total file bytes kept; the oldest entries go first once it is exceeded. */
125
+ /** Total text kept (characters); the oldest entries go first once it is exceeded. */
56
126
  const REVISION_CACHE_BYTES = 32 * 1024 * 1024;
57
127
  let revisionCacheBytes = 0;
58
128
 
129
+ /** The last `state.json` revision seen per reader and session directory. */
130
+ const lastStateRevision = new Map<string, number>();
131
+
132
+ /**
133
+ * Drop the cached revision files of one session directory when `state.json`'s revision went
134
+ * down since the last read: the session restarted its count (a corrupt state file starts at
135
+ * 0), so its next rN files replace old ones. Needed for readers with a constant stamp (no
136
+ * mtime over the file API or the channel); harmless for the disk.
137
+ */
138
+ function invalidateRevisions(reader: SessionReader, revisionsDirectory: string, stateRevision: number): void {
139
+ const prefix = `${reader.name}:${revisionsDirectory}/`;
140
+ const last = lastStateRevision.get(prefix);
141
+ lastStateRevision.set(prefix, stateRevision);
142
+ if (last === undefined || stateRevision >= last) return;
143
+ for (const [key, entry] of [...revisionCache.entries()]) {
144
+ if (!key.startsWith(prefix)) continue;
145
+ revisionCache.delete(key);
146
+ revisionCacheBytes -= entry.size;
147
+ }
148
+ }
149
+
59
150
  function evictRevision(): void {
60
151
  const [path, entry] = revisionCache.entries().next().value!;
61
152
  revisionCache.delete(path);
62
153
  revisionCacheBytes -= entry.size;
63
154
  }
64
155
 
65
- async function readCached(path: string, kind: "json" | "md", warnings: string[]): Promise<unknown> {
66
- let info;
156
+ async function readCached(reader: SessionReader, path: string, kind: "json" | "md", warnings: string[]): Promise<unknown> {
157
+ let stamp: string | undefined;
67
158
  try {
68
- info = await stat(path);
159
+ stamp = await reader.stamp(path);
69
160
  } catch (error) {
70
- if (!isMissing(error)) warnings.push(`Could not read ${path}: ${describe(error)}`);
161
+ warnings.push(`Could not read ${path}: ${describe(error)}`);
71
162
  return undefined;
72
163
  }
73
- const cached = revisionCache.get(path);
74
- if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.value;
75
- const value = kind === "json" ? await readJson(path, warnings) : await readText(path, warnings);
164
+ if (stamp === undefined) return undefined;
165
+ const key = `${reader.name}:${path}`;
166
+ const cached = revisionCache.get(key);
167
+ if (cached && cached.stamp === stamp) return cached.value;
168
+ const text = await readText(reader, path, warnings);
169
+ if (text === undefined) return undefined;
170
+ const value = kind === "json" ? parseJson(text, path, warnings) : text;
76
171
  if (value !== undefined) {
77
172
  if (cached) {
78
- revisionCache.delete(path);
173
+ revisionCache.delete(key);
79
174
  revisionCacheBytes -= cached.size;
80
175
  }
81
- while (revisionCache.size > 0 && (revisionCache.size >= REVISION_CACHE_LIMIT || revisionCacheBytes + info.size > REVISION_CACHE_BYTES)) evictRevision();
82
- revisionCache.set(path, { mtimeMs: info.mtimeMs, size: info.size, value });
83
- revisionCacheBytes += info.size;
176
+ while (revisionCache.size > 0 && (revisionCache.size >= REVISION_CACHE_LIMIT || revisionCacheBytes + text.length > REVISION_CACHE_BYTES)) evictRevision();
177
+ revisionCache.set(key, { stamp, size: text.length, value });
178
+ revisionCacheBytes += text.length;
84
179
  }
85
180
  return value;
86
181
  }
@@ -99,18 +194,16 @@ function describe(error: unknown): string {
99
194
  return error instanceof Error ? error.message : String(error);
100
195
  }
101
196
 
102
- async function readText(path: string, warnings: string[]): Promise<string | undefined> {
197
+ async function readText(reader: SessionReader, path: string, warnings: string[]): Promise<string | undefined> {
103
198
  try {
104
- return await readFile(path, "utf8");
199
+ return await reader.readText(path);
105
200
  } catch (error) {
106
- if (!isMissing(error)) warnings.push(`Could not read ${path}: ${describe(error)}`);
201
+ warnings.push(`Could not read ${path}: ${describe(error)}`);
107
202
  return undefined;
108
203
  }
109
204
  }
110
205
 
111
- async function readJson(path: string, warnings: string[]): Promise<unknown> {
112
- const text = await readText(path, warnings);
113
- if (text === undefined) return undefined;
206
+ function parseJson(text: string, path: string, warnings: string[]): unknown {
114
207
  try {
115
208
  return JSON.parse(text);
116
209
  } catch (error) {
@@ -119,13 +212,18 @@ async function readJson(path: string, warnings: string[]): Promise<unknown> {
119
212
  }
120
213
  }
121
214
 
215
+ async function readJson(reader: SessionReader, path: string, warnings: string[]): Promise<unknown> {
216
+ const text = await readText(reader, path, warnings);
217
+ return text === undefined ? undefined : parseJson(text, path, warnings);
218
+ }
219
+
122
220
  /** Reads the session directory of `sessionID` under `mirrorDir`. */
123
221
  export function readSessionFiles(mirrorDir: string, sessionID: string): Promise<SessionFiles> {
124
222
  return readSessionDirectory(sessionDirectory(mirrorDir, sessionID), sessionID);
125
223
  }
126
224
 
127
225
  /** Reads a session directory given its path. Never throws for missing or corrupt files. */
128
- export async function readSessionDirectory(directory: string, sessionID: string): Promise<SessionFiles> {
226
+ export async function readSessionDirectory(directory: string, sessionID: string, reader: SessionReader = localReader): Promise<SessionFiles> {
129
227
  const warnings: string[] = [];
130
228
  const files: SessionFiles = {
131
229
  sessionID,
@@ -139,17 +237,19 @@ export async function readSessionDirectory(directory: string, sessionID: string)
139
237
  warnings,
140
238
  };
141
239
  try {
142
- files.found = (await stat(directory)).isDirectory();
240
+ files.found = await reader.isDirectory(directory);
143
241
  } catch (error) {
144
- if (!isMissing(error)) warnings.push(`Could not read ${directory}: ${describe(error)}`);
242
+ warnings.push(`Could not read ${directory}: ${describe(error)}`);
145
243
  }
146
244
  if (!files.found) return files;
147
245
 
148
- const [eventsText, state, snapshot] = await Promise.all([
149
- readText(join(directory, EVENTS_FILE), warnings),
150
- readJson(join(directory, STATE_FILE), warnings),
151
- readJson(join(directory, SNAPSHOT_FILE), warnings),
246
+ const [eventsText, state, snapshot, annotationsText] = await Promise.all([
247
+ readText(reader, join(directory, EVENTS_FILE), warnings),
248
+ readJson(reader, join(directory, STATE_FILE), warnings),
249
+ readJson(reader, join(directory, SNAPSHOT_FILE), warnings),
250
+ readText(reader, join(directory, ANNOTATIONS_FILE), warnings),
152
251
  ]);
252
+ if (annotationsText !== undefined) files.annotations = countAnnotations(annotationsText);
153
253
  if (eventsText !== undefined) {
154
254
  const parsed = parseEvents(eventsText);
155
255
  files.events = parsed.events;
@@ -159,11 +259,13 @@ export async function readSessionDirectory(directory: string, sessionID: string)
159
259
  if (snapshot !== undefined) files.snapshot = snapshot;
160
260
 
161
261
  const revisionsDirectory = join(directory, REVISIONS_DIRECTORY);
262
+ const stateRevision = (state as { revision?: unknown } | undefined)?.revision;
263
+ if (typeof stateRevision === "number" && Number.isInteger(stateRevision)) invalidateRevisions(reader, revisionsDirectory, stateRevision);
162
264
  let names: string[] = [];
163
265
  try {
164
- names = await readdir(revisionsDirectory);
266
+ names = await reader.list(revisionsDirectory);
165
267
  } catch (error) {
166
- if (!isMissing(error)) warnings.push(`Could not list ${revisionsDirectory}: ${describe(error)}`);
268
+ warnings.push(`Could not list ${revisionsDirectory}: ${describe(error)}`);
167
269
  }
168
270
  const jsonRevisions = new Set<number>();
169
271
  const mdRevisions = new Set<number>();
@@ -172,12 +274,12 @@ export async function readSessionDirectory(directory: string, sessionID: string)
172
274
  if (match) (match[2] === "json" ? jsonRevisions : mdRevisions).add(Number(match[1]));
173
275
  }
174
276
  await Promise.all([...jsonRevisions].map(async (revision) => {
175
- const value = await readCached(join(revisionsDirectory, `r${revision}.json`), "json", warnings);
277
+ const value = await readCached(reader, join(revisionsDirectory, `r${revision}.json`), "json", warnings);
176
278
  if (value !== undefined) files.revisions.set(revision, value);
177
279
  }));
178
280
  // rN.md is only shown when rN.json is missing or invalid.
179
281
  await Promise.all([...mdRevisions].filter((revision) => !isRevisionFile(files.revisions.get(revision))).map(async (revision) => {
180
- const text = await readCached(join(revisionsDirectory, `r${revision}.md`), "md", warnings);
282
+ const text = await readCached(reader, join(revisionsDirectory, `r${revision}.md`), "md", warnings);
181
283
  if (typeof text === "string") files.revisionTexts.set(revision, text);
182
284
  }));
183
285
  return files;
@@ -13,10 +13,11 @@ import { basename, resolve } from "node:path";
13
13
  import { resolveBudgetPolicy } from "./budget.ts";
14
14
  import { MIN_OBSERVATION_CAP, resolveObservationCap } from "./observation.ts";
15
15
  import { toEditGate, type EditGate } from "./policy.ts";
16
- import { HOUSE_STEERING, parseFlag, parseFractions, parseTokens, type ClmSettings } from "./settings.ts";
16
+ import { HOUSE_STEERING, parseCompaction, parseFlag, parseFractions, parseTokens, type ClmSettings, type CompactionMode } from "./settings.ts";
17
17
 
18
18
  export type SettingKey =
19
- | "editing" | "budget" | "reserve" | "reminders" | "gate" | "guard" | "cap" | "steering" | "compactPrompt" | "reasoning";
19
+ | "editing" | "budget" | "reserve" | "reminders" | "gate" | "guard" | "compaction" | "cap" | "steering" | "oneTool" | "trailer"
20
+ | "compactPrompt" | "reasoning";
20
21
 
21
22
  /**
22
23
  * Per-session changes, as stored in overrides.json. Only keys that differ from the base are
@@ -31,6 +32,7 @@ export interface ClmOverrides {
31
32
  reminders?: number[];
32
33
  gate?: EditGate;
33
34
  guard?: "withhold" | "off";
35
+ compaction?: CompactionMode;
34
36
  cap?: number | null;
35
37
  capHead?: number;
36
38
  /** Absolute path. */
@@ -38,6 +40,8 @@ export interface ClmOverrides {
38
40
  /** Absolute path. */
39
41
  compactPrompt?: string | null;
40
42
  reasoning?: boolean;
43
+ oneTool?: boolean;
44
+ trailer?: boolean;
41
45
  }
42
46
 
43
47
  /** Settings in force for one session: the resolved settings plus whether edits apply. */
@@ -185,6 +189,15 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
185
189
  format: (values) => onOff(values.settings.guard === "withhold"),
186
190
  parse: (text) => ({ guard: parseFlag(text.trim().toLowerCase() === "withhold" ? "on" : text, "guard") ? "withhold" : "off" }),
187
191
  },
192
+ {
193
+ key: "compaction",
194
+ name: "compaction",
195
+ label: "OC compaction",
196
+ description: "OpenCode's automatic compaction: off turns it off (a provider overflow is then reported, not compacted); auto and on leave your config's compaction.auto (under auto the overflow guard keeps requests below OpenCode's threshold). Manual /compact always works. The flag is shared by every session of the server. Env: CLM_NATIVE_COMPACTION.",
197
+ choices: ["auto", "off", "on"],
198
+ format: (values) => values.settings.compaction,
199
+ parse: (text) => ({ compaction: parseCompaction(text) }),
200
+ },
188
201
  {
189
202
  key: "cap",
190
203
  name: "cap",
@@ -210,6 +223,24 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
210
223
  return { steering: parsePathSetting(text, context.directory, ["none", "off", ""]) };
211
224
  },
212
225
  },
226
+ {
227
+ key: "oneTool",
228
+ name: "one-tool",
229
+ label: "One tool per turn",
230
+ description: "Paper-harness parity: run only the first tool call of each model response; later calls in it fail with a note to repeat them. Env: CLM_ONE_TOOL_PER_TURN.",
231
+ choices: ["off", "on"],
232
+ format: (values) => onOff(values.settings.oneTool),
233
+ parse: (text) => ({ oneTool: parseFlag(text, "one-tool") }),
234
+ },
235
+ {
236
+ key: "trailer",
237
+ name: "trailer",
238
+ label: "Size trailer",
239
+ description: "Paper-harness parity: end every successful tool result with \"[context: ~N of B tokens after this result]\". Env: CLM_SIZE_TRAILER.",
240
+ choices: ["off", "on"],
241
+ format: (values) => onOff(values.settings.trailer),
242
+ parse: (text) => ({ trailer: parseFlag(text, "trailer") }),
243
+ },
213
244
  {
214
245
  key: "compactPrompt",
215
246
  name: "compact-prompt",
@@ -243,11 +274,19 @@ const ALIASES: Record<string, SettingKey> = {
243
274
  "edit-gate": "gate",
244
275
  guard: "guard",
245
276
  overflow: "guard",
277
+ compaction: "compaction",
278
+ "native-compaction": "compaction",
246
279
  cap: "cap",
247
280
  observation: "cap",
248
281
  "observation-cap": "cap",
249
282
  observationcap: "cap",
250
283
  steering: "steering",
284
+ "one-tool": "oneTool",
285
+ onetool: "oneTool",
286
+ "one-tool-per-turn": "oneTool",
287
+ trailer: "trailer",
288
+ "size-trailer": "trailer",
289
+ sizetrailer: "trailer",
251
290
  "compact-prompt": "compactPrompt",
252
291
  compactprompt: "compactPrompt",
253
292
  reasoning: "reasoning",
@@ -288,6 +327,9 @@ export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmS
288
327
  budget,
289
328
  gate: overrides.gate ?? base.gate,
290
329
  guard: overrides.guard ?? base.guard,
330
+ compaction: overrides.compaction ?? base.compaction,
331
+ oneTool: overrides.oneTool ?? base.oneTool,
332
+ trailer: overrides.trailer ?? base.trailer,
291
333
  observationCap,
292
334
  steeringPath: overrides.steering === null ? undefined : overrides.steering ?? base.steeringPath,
293
335
  compactPromptPath: overrides.compactPrompt === null ? undefined : overrides.compactPrompt ?? base.compactPromptPath,
@@ -305,6 +347,9 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
305
347
  case "reminders": return settings.budget.remindAtReserve || settings.budget.remindAtFractions.length > 0 ? [...settings.budget.remindAtFractions] : [];
306
348
  case "gate": return settings.gate;
307
349
  case "guard": return settings.guard;
350
+ case "compaction": return settings.compaction;
351
+ case "oneTool": return settings.oneTool;
352
+ case "trailer": return settings.trailer;
308
353
  case "cap": return settings.observationCap.maxCharacters ?? null;
309
354
  case "capHead": return settings.observationCap.headFraction;
310
355
  case "steering": return settings.steeringPath ?? null;
@@ -321,7 +366,7 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
321
366
  */
322
367
  export function settingsAsOverrides(settings: ClmSettings): ClmOverrides {
323
368
  const values: SettingsValues = { editing: true, settings };
324
- const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "gate", "guard", "cap", "capHead", "steering", "compactPrompt", "reasoning"];
369
+ const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "gate", "guard", "compaction", "cap", "capHead", "steering", "oneTool", "trailer", "compactPrompt", "reasoning"];
325
370
  return Object.fromEntries(keys.map((key) => [key, baseValue(key, values)])) as ClmOverrides;
326
371
  }
327
372
 
@@ -345,6 +390,9 @@ const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
345
390
  reminders: (value) => Array.isArray(value) && value.every((fraction) => isNumber(fraction) && fraction > 0 && fraction < 1),
346
391
  gate: (value) => value === "fit" || value === "shrink" || value === "none",
347
392
  guard: (value) => value === "withhold" || value === "off",
393
+ compaction: (value) => value === "auto" || value === "off" || value === "on",
394
+ oneTool: (value) => typeof value === "boolean",
395
+ trailer: (value) => typeof value === "boolean",
348
396
  cap: (value) => value === null || (isNumber(value) && value >= MIN_OBSERVATION_CAP),
349
397
  capHead: (value) => isNumber(value) && value > 0 && value <= 1,
350
398
  steering: isPath,
package/src/settings.ts CHANGED
@@ -5,8 +5,7 @@
5
5
  * instead of running with a guess.
6
6
  *
7
7
  * Setting set adapted from pi-clm src/settings.ts (MIT, Copyright 2026 Emanuel Casco).
8
- * pi-clm's per-session overrides (`/clm config` panel) are not ported: OpenCode commands
9
- * cannot open a panel.
8
+ * Per-session overrides (`/clm config`) live in src/settings-table.ts and src/overrides.ts.
10
9
  */
11
10
  import { join, resolve } from "node:path";
12
11
  import { fileURLToPath } from "node:url";
@@ -38,6 +37,24 @@ export interface ClmSettings {
38
37
  commands: boolean;
39
38
  /** Write every transformed request to `<session dir>/requests/nN.json` (debugging). */
40
39
  dumpRequests: boolean;
40
+ /**
41
+ * OpenCode's automatic compaction (`compaction.auto`): `off` turns it off; `auto` pauses it
42
+ * while the guard enforces a budget (src/compaction.ts); `on` leaves the user's config value
43
+ * (pi's names). Manual /compact always works.
44
+ */
45
+ compaction: CompactionMode;
46
+ /** Run only the first tool call of each model response (paper-harness parity). */
47
+ oneTool: boolean;
48
+ /** Append `[context: ~N of B tokens after this result]` to tool results (paper-harness parity). */
49
+ trailer: boolean;
50
+ }
51
+
52
+ export type CompactionMode = "auto" | "off" | "on";
53
+
54
+ export function parseCompaction(value: unknown, name = "compaction"): CompactionMode {
55
+ const text = String(value).trim().toLowerCase();
56
+ if (text === "auto" || text === "off" || text === "on") return text;
57
+ throw new Error(`${name} must be auto, off or on, got ${JSON.stringify(value)}`);
41
58
  }
42
59
 
43
60
  export const PACKAGE_DIR = resolve(fileURLToPath(new URL("..", import.meta.url)));
@@ -55,6 +72,11 @@ function pick(options: Record<string, unknown>, key: string, env: Env, envKey: s
55
72
  }
56
73
 
57
74
  /** `32000`, `32k`, `1.5m`, `32_000`, `32,000`. */
75
+ /** The default `mirrorDir`: `<project>/.opencode/clm`, inside the project, so the model's tools reach it without an external-directory permission. */
76
+ export function defaultMirrorDir(directory: string): string {
77
+ return join(directory, ".opencode", "clm");
78
+ }
79
+
58
80
  export function parseTokens(value: unknown, name: string): number {
59
81
  const text = String(value).trim().toLowerCase().replace(/[_,]/g, "");
60
82
  const match = /^(\d+(?:\.\d+)?)(k|m)?$/.exec(text);
@@ -119,6 +141,9 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
119
141
  const factorRaw = pick(options, "estimateFactor", env, "CLM_ESTIMATE_FACTOR");
120
142
  const reasoningRaw = pick(options, "reasoning", env, "CLM_REASONING");
121
143
  const dumpRaw = pick(options, "dumpRequests", env, "CLM_DUMP_REQUESTS");
144
+ const compactionRaw = pick(options, "compaction", env, "CLM_NATIVE_COMPACTION");
145
+ const oneToolRaw = pick(options, "oneTool", env, "CLM_ONE_TOOL_PER_TURN");
146
+ const trailerRaw = pick(options, "trailer", env, "CLM_SIZE_TRAILER");
122
147
 
123
148
  const overrides: Partial<BudgetPolicyConfig> = {};
124
149
  if (budgetRaw !== undefined) {
@@ -177,7 +202,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
177
202
  gate: gateRaw === undefined ? "fit" : withName("gate (CLM_EDIT_GATE)", () => toEditGate(gateRaw)),
178
203
  guard,
179
204
  observationCap,
180
- mirrorDir: dirRaw === undefined ? join(directory, ".opencode", "clm") : resolve(directory, String(dirRaw)),
205
+ mirrorDir: dirRaw === undefined ? defaultMirrorDir(directory) : resolve(directory, String(dirRaw)),
181
206
  steeringPath,
182
207
  compactPromptPath,
183
208
  estimateFactor,
@@ -185,5 +210,8 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
185
210
  skill: options.skill === undefined ? true : parseFlag(options.skill, "skill"),
186
211
  commands: options.commands === undefined ? true : parseFlag(options.commands, "commands"),
187
212
  dumpRequests: dumpRaw === undefined ? false : parseFlag(dumpRaw, "dumpRequests"),
213
+ compaction: compactionRaw === undefined ? "auto" : parseCompaction(compactionRaw),
214
+ oneTool: oneToolRaw === undefined ? false : parseFlag(oneToolRaw, "oneTool"),
215
+ trailer: trailerRaw === undefined ? false : parseFlag(trailerRaw, "trailer"),
188
216
  };
189
217
  }