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.
- package/CHANGELOG.md +1 -1
- package/README.md +77 -66
- package/index.ts +730 -36
- package/package.json +6 -6
- package/src/budget.ts +6 -6
- package/src/channel.ts +187 -0
- package/src/clm.ts +369 -20
- package/src/compaction.ts +243 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +40 -2
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/model.ts +23 -1
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +10 -1
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +51 -3
- package/src/settings.ts +31 -3
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +83 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +11 -0
- package/src/tui/plugin.ts +219 -33
- package/src/tui/remote.ts +199 -0
package/src/panel/command.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/panel/files.ts
CHANGED
|
@@ -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
|
}
|
package/src/panel/model.ts
CHANGED
|
@@ -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,
|
|
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
|
}
|
package/src/panel/timeline.ts
CHANGED
|
@@ -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
|
-
|
|
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[];
|
package/src/session-files.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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, {
|
|
123
|
+
const revisionCache = new Map<string, { stamp: string; size: number; value: unknown }>();
|
|
54
124
|
const REVISION_CACHE_LIMIT = 2_000;
|
|
55
|
-
/** Total
|
|
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
|
|
156
|
+
async function readCached(reader: SessionReader, path: string, kind: "json" | "md", warnings: string[]): Promise<unknown> {
|
|
157
|
+
let stamp: string | undefined;
|
|
67
158
|
try {
|
|
68
|
-
|
|
159
|
+
stamp = await reader.stamp(path);
|
|
69
160
|
} catch (error) {
|
|
70
|
-
|
|
161
|
+
warnings.push(`Could not read ${path}: ${describe(error)}`);
|
|
71
162
|
return undefined;
|
|
72
163
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
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(
|
|
173
|
+
revisionCache.delete(key);
|
|
79
174
|
revisionCacheBytes -= cached.size;
|
|
80
175
|
}
|
|
81
|
-
while (revisionCache.size > 0 && (revisionCache.size >= REVISION_CACHE_LIMIT || revisionCacheBytes +
|
|
82
|
-
revisionCache.set(
|
|
83
|
-
revisionCacheBytes +=
|
|
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
|
|
199
|
+
return await reader.readText(path);
|
|
105
200
|
} catch (error) {
|
|
106
|
-
|
|
201
|
+
warnings.push(`Could not read ${path}: ${describe(error)}`);
|
|
107
202
|
return undefined;
|
|
108
203
|
}
|
|
109
204
|
}
|
|
110
205
|
|
|
111
|
-
|
|
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 =
|
|
240
|
+
files.found = await reader.isDirectory(directory);
|
|
143
241
|
} catch (error) {
|
|
144
|
-
|
|
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
|
|
266
|
+
names = await reader.list(revisionsDirectory);
|
|
165
267
|
} catch (error) {
|
|
166
|
-
|
|
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;
|
package/src/settings-table.ts
CHANGED
|
@@ -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" | "
|
|
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
|
-
*
|
|
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 ?
|
|
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
|
}
|