opencode-clm 0.2.0 → 0.4.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 +87 -66
- package/index.ts +753 -40
- package/package.json +6 -6
- package/src/budget.ts +166 -22
- package/src/channel.ts +187 -0
- package/src/clm.ts +478 -57
- package/src/compaction.ts +251 -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/overflow.ts +6 -3
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/keys.ts +11 -1
- package/src/panel/model.ts +26 -2
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +39 -10
- package/src/presentation.ts +8 -2
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +131 -17
- package/src/settings.ts +100 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +89 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +14 -2
- package/src/tui/plugin.ts +222 -35
- package/src/tui/remote.ts +199 -0
package/src/overrides.ts
CHANGED
|
@@ -170,9 +170,21 @@ export async function resetSettings(sessionDirectory: string): Promise<void> {
|
|
|
170
170
|
* Current values of one session, read from its overrides.json (non-strict, never throws for
|
|
171
171
|
* bad files). `mtimeMs`: when overrides.json was last written, if it exists.
|
|
172
172
|
*/
|
|
173
|
-
export async function sessionValues(sessionDirectory: string, base: ClmSettings, baseEditing: boolean): Promise<
|
|
173
|
+
export async function sessionValues(sessionDirectory: string, base: ClmSettings, baseEditing: boolean): Promise<SessionValues> {
|
|
174
174
|
const read = await readOverrides(sessionDirectory);
|
|
175
175
|
const mtimeMs = await stat(overridesPath(sessionDirectory)).then((info) => info.mtimeMs, () => undefined);
|
|
176
|
+
return overridesValues(read, base, baseEditing, mtimeMs);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface SessionValues {
|
|
180
|
+
values: SettingsValues;
|
|
181
|
+
warning?: string;
|
|
182
|
+
overrides: ClmOverrides;
|
|
183
|
+
mtimeMs?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `sessionValues` for overrides already read (e.g. through the server's file API). */
|
|
187
|
+
export function overridesValues(read: OverridesRead, base: ClmSettings, baseEditing: boolean, mtimeMs?: number): SessionValues {
|
|
176
188
|
const at = mtimeMs !== undefined ? { mtimeMs } : {};
|
|
177
189
|
try {
|
|
178
190
|
return { values: valuesOf(base, baseEditing, read.overrides), overrides: read.overrides, ...(read.warning ? { warning: read.warning } : {}), ...at };
|
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/keys.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
effectiveZoom,
|
|
14
14
|
markerSelection,
|
|
15
15
|
maxScroll,
|
|
16
|
+
pageViewport,
|
|
16
17
|
revisionIndex,
|
|
17
18
|
settingsPage,
|
|
18
19
|
viewportHeight,
|
|
@@ -116,7 +117,7 @@ function clampSetting(model: PanelModel, index: number): number {
|
|
|
116
117
|
|
|
117
118
|
function revealSetting(state: PanelState, model: PanelModel, size: PanelSize): PanelState {
|
|
118
119
|
const { focus } = settingsPage(model, state, contentWidth(size));
|
|
119
|
-
const viewport =
|
|
120
|
+
const viewport = pageViewport(state, size);
|
|
120
121
|
let scroll = state.scroll;
|
|
121
122
|
if (focus < scroll) scroll = focus;
|
|
122
123
|
else if (focus >= scroll + viewport) scroll = focus - viewport + 1;
|
|
@@ -226,6 +227,15 @@ function step(state: PanelState, key: Key, model: PanelModel, size: PanelSize):
|
|
|
226
227
|
return { state };
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The settings page with a result message pinned below it. The message takes body rows, so
|
|
232
|
+
* the selected row is scrolled back into view if the message would cover it.
|
|
233
|
+
*/
|
|
234
|
+
export function withMessage(state: PanelState, message: PanelState["message"], model: PanelModel, size: PanelSize): PanelState {
|
|
235
|
+
const next = { ...state, message };
|
|
236
|
+
return next.page === "settings" ? revealSetting(next, model, size) : next;
|
|
237
|
+
}
|
|
238
|
+
|
|
229
239
|
/** Applies one key. The result's scroll is clamped to the page. */
|
|
230
240
|
export function reduce(state: PanelState, key: Key, model: PanelModel, size: PanelSize): ReduceResult {
|
|
231
241
|
const result = step(state, ALIASES[key] ?? key, model, size);
|
package/src/panel/model.ts
CHANGED
|
@@ -92,7 +92,7 @@ export interface BudgetView {
|
|
|
92
92
|
reserve?: number;
|
|
93
93
|
/** Overflow-guard limit (budget − reserve), from snapshot.json. */
|
|
94
94
|
limit?: number;
|
|
95
|
-
/** Where the budget came from (`config`, `model-window`). */
|
|
95
|
+
/** Where the budget came from (`config`, `model-window`, `window-fraction`, `fallback`). */
|
|
96
96
|
source?: string;
|
|
97
97
|
/** Fixed overhead per request (system prompt + tool schemas), tokens. */
|
|
98
98
|
overhead?: number;
|
|
@@ -123,7 +123,15 @@ 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;
|
|
133
|
+
/** The session runs `mode notices-only` (set by the caller from the settings in force). */
|
|
134
|
+
noticesOnly?: boolean;
|
|
127
135
|
/** Problems reading the files (corrupt JSON, skipped event lines). */
|
|
128
136
|
warnings: string[];
|
|
129
137
|
}
|
|
@@ -245,11 +253,13 @@ export function buildTimeline(events: readonly ClmEvent[], latest?: LatestUsage)
|
|
|
245
253
|
let tokens = observedAfter.get(position);
|
|
246
254
|
if (tokens === undefined && position === lastRequest?.position) tokens = latestTokens;
|
|
247
255
|
const measured = tokens !== undefined;
|
|
256
|
+
const estimated = num(event.estimated);
|
|
248
257
|
points.push({
|
|
249
258
|
request: points.length + 1,
|
|
250
259
|
...(at ? { at } : {}),
|
|
251
|
-
tokens: tokens ?? Math.max(0,
|
|
260
|
+
tokens: tokens ?? Math.max(0, estimated ?? 0),
|
|
252
261
|
measured,
|
|
262
|
+
...(measured && estimated !== undefined && estimated > 0 ? { estimated } : {}),
|
|
253
263
|
revision: count(event.revision) ?? 0,
|
|
254
264
|
});
|
|
255
265
|
// `users` counts user messages in the raw history; a rise starts a turn. A drop
|
|
@@ -297,6 +307,13 @@ function markerOf(name: string | undefined, event: ClmEvent, afterPoint: number)
|
|
|
297
307
|
const reason = str(event.reason) ?? "OpenCode's history changed.";
|
|
298
308
|
return base("reset", dropped === undefined ? 0 : dropped + 1, dropped === undefined ? reason : `Revision ${dropped} dropped: ${reason}`);
|
|
299
309
|
}
|
|
310
|
+
case "restored": {
|
|
311
|
+
// Revert (`dropped`) or fork (`origin`): an older revision became the next one.
|
|
312
|
+
const from = count(event.from);
|
|
313
|
+
const origin = str(event.origin);
|
|
314
|
+
const source = from === undefined ? "an earlier revision" : `r${from}`;
|
|
315
|
+
return base("restored", revision ?? 0, origin ? `restored ${source} of ${origin} (fork)` : `restored ${source} (revert)`);
|
|
316
|
+
}
|
|
300
317
|
case "compacted": {
|
|
301
318
|
const summary = str(event.summary);
|
|
302
319
|
return base("compacted", revision ?? 0, summary ? `Rebased on compaction summary ${summary}.` : "Rebased on a compaction summary.");
|
|
@@ -539,9 +556,16 @@ export function buildPanelModel(files: SessionFiles, extras: PanelExtras = {}):
|
|
|
539
556
|
const calibration = isObject(snapshot?.calibration) ? snapshot.calibration : undefined;
|
|
540
557
|
const factor = num(calibration?.factor);
|
|
541
558
|
if (factor !== undefined) model.calibration = { factor, samples: count(calibration?.samples) ?? 0 };
|
|
559
|
+
if (files.annotations) model.annotations = files.annotations;
|
|
542
560
|
const steering = isObject(snapshot?.steering) ? snapshot.steering : undefined;
|
|
543
561
|
if (steering && typeof steering.name === "string" && typeof steering.hash === "string" && typeof steering.path === "string") {
|
|
544
562
|
model.steering = { name: steering.name, hash: steering.hash, path: steering.path };
|
|
545
563
|
}
|
|
564
|
+
if (typeof snapshot?.steeringError === "string" && snapshot.steeringError !== "") model.steeringError = snapshot.steeringError;
|
|
565
|
+
const unavailable = files.events.filter((event) => eventName(event) === "mirror-unavailable").at(-1)?.reason;
|
|
566
|
+
if (typeof unavailable === "string") {
|
|
567
|
+
model.mirrorUnavailable = unavailable;
|
|
568
|
+
model.mirrorPath = `unavailable (${unavailable})`;
|
|
569
|
+
}
|
|
546
570
|
return model;
|
|
547
571
|
}
|
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,8 @@ 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.";
|
|
55
|
+
const NOTICES_ONLY_TEXT = "Mode notices-only: requests carry the raw history plus the budget notices; no mirror, so the model cannot edit its context (/clm config mode edit).";
|
|
54
56
|
|
|
55
57
|
/** Body rows available for the page. */
|
|
56
58
|
export function viewportHeight(size: PanelSize): number {
|
|
@@ -191,6 +193,10 @@ function overviewLines(model: PanelModel, state: PanelState, width: number, view
|
|
|
191
193
|
const timeline = model.timeline;
|
|
192
194
|
const notice = !model.enabled
|
|
193
195
|
? text("Projection off: the model sees the raw history (/clm on).", "warning")
|
|
196
|
+
: model.mirrorUnavailable !== undefined
|
|
197
|
+
? text(MIRROR_UNAVAILABLE_TEXT, "warning")
|
|
198
|
+
: model.noticesOnly
|
|
199
|
+
? text(NOTICES_ONLY_TEXT, "warning")
|
|
194
200
|
: model.lastOutcome?.kind === "rejected"
|
|
195
201
|
? text(`Last edit rejected: ${model.lastOutcome.message}`, "warning")
|
|
196
202
|
: undefined;
|
|
@@ -271,7 +277,9 @@ function inputLines(model: PanelModel, width: number): Line[] {
|
|
|
271
277
|
return wrapAll([
|
|
272
278
|
title,
|
|
273
279
|
[],
|
|
274
|
-
|
|
280
|
+
model.mirrorUnavailable !== undefined
|
|
281
|
+
? text(`${MIRROR_UNAVAILABLE_TEXT} No input snapshot is kept.`, "warning")
|
|
282
|
+
: text("No input snapshot yet: snapshot.json appears after the next model request.", "muted"),
|
|
275
283
|
[],
|
|
276
284
|
text(`Mirror: ${model.mirrorPath}`, "dim"),
|
|
277
285
|
], width);
|
|
@@ -447,6 +455,10 @@ export function settingsPage(model: PanelModel, state: PanelState, width: number
|
|
|
447
455
|
[span("Settings", "accent", true), span(" · changes apply from the next request and are saved in this session", "muted")],
|
|
448
456
|
...settings.summary.map((line) => text(line, "dim")),
|
|
449
457
|
...(settings.warning ? [text(`⚠ ${settings.warning}`, "warning")] : []),
|
|
458
|
+
// The server's own record, also when the TUI's settings check passed.
|
|
459
|
+
...(model.steeringError && !settings.warning?.includes(model.steeringError)
|
|
460
|
+
? [text(`⚠ steering document not loaded, the session runs without it: ${model.steeringError}`, "warning")]
|
|
461
|
+
: []),
|
|
450
462
|
[],
|
|
451
463
|
], width);
|
|
452
464
|
if (settings.rows.length === 0) {
|
|
@@ -469,12 +481,26 @@ export function settingsPage(model: PanelModel, state: PanelState, width: number
|
|
|
469
481
|
const row = settings.rows[selected]!;
|
|
470
482
|
if (row.description) lines.push([], ...wrapAll([text(row.description, "dim")], width));
|
|
471
483
|
lines.push([], ...wrapAll([text(row.choices?.length ? "Enter: next value" : "Enter: type a value", "dim")], width));
|
|
472
|
-
if (state.message) {
|
|
473
|
-
lines.push(...wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width));
|
|
474
|
-
}
|
|
475
484
|
return { lines, focus };
|
|
476
485
|
}
|
|
477
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Lines pinned below the scrolling page: the settings page's result message ("✓ Budget:
|
|
489
|
+
* 20k"), so it stays on screen when the page is longer than the viewport. At most
|
|
490
|
+
* `viewport` − 1 lines, so one page line always shows.
|
|
491
|
+
*/
|
|
492
|
+
export function pinnedLines(state: PanelState, width: number, viewport: number): Line[] {
|
|
493
|
+
if (state.page !== "settings" || !state.message) return [];
|
|
494
|
+
const message = wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width);
|
|
495
|
+
return message.slice(0, Math.max(0, viewport - 1));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Body rows left for the scrolling page once the pinned lines take theirs. */
|
|
499
|
+
export function pageViewport(state: PanelState, size: PanelSize): number {
|
|
500
|
+
const viewport = viewportHeight(size);
|
|
501
|
+
return Math.max(1, viewport - pinnedLines(state, contentWidth(size), viewport).length);
|
|
502
|
+
}
|
|
503
|
+
|
|
478
504
|
// ---- frame ---------------------------------------------------------------------------
|
|
479
505
|
|
|
480
506
|
/** The whole page at `width` (unclipped; the frame scrolls it). */
|
|
@@ -505,7 +531,7 @@ export function helpText(model: PanelModel, state: PanelState, width: number): s
|
|
|
505
531
|
}
|
|
506
532
|
|
|
507
533
|
export function maxScroll(model: PanelModel, state: PanelState, size: PanelSize): number {
|
|
508
|
-
return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length -
|
|
534
|
+
return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length - pageViewport(state, size));
|
|
509
535
|
}
|
|
510
536
|
|
|
511
537
|
/** The framed panel, exactly `size.height` lines of `size.width` cells. */
|
|
@@ -513,15 +539,18 @@ export function renderPanel(model: PanelModel, state: PanelState, size: PanelSiz
|
|
|
513
539
|
const width = Math.max(8, size.width);
|
|
514
540
|
const inner = width - 2;
|
|
515
541
|
const content = contentWidth({ width, height: size.height });
|
|
516
|
-
const
|
|
517
|
-
const page = pageLines(model, state, content,
|
|
542
|
+
const body = viewportHeight(size);
|
|
543
|
+
const page = pageLines(model, state, content, body);
|
|
544
|
+
const pinned = pinnedLines(state, content, body);
|
|
545
|
+
const viewport = pageViewport(state, size);
|
|
518
546
|
const scroll = Math.max(0, Math.min(state.scroll, page.length - viewport));
|
|
519
|
-
|
|
520
|
-
|
|
547
|
+
// The pinned lines follow the page directly when it fits, else sit at the bottom.
|
|
548
|
+
const visible = [...page.slice(scroll, scroll + viewport), ...pinned];
|
|
549
|
+
while (visible.length < body) visible.push([]);
|
|
521
550
|
|
|
522
551
|
const border = (value: string) => span(value, "border");
|
|
523
552
|
const row = (line: Line): Line => normalize([border("│"), span(" "), ...fit(line, inner - 1), border("│")]);
|
|
524
|
-
const title = ` Live Context Viewer · r${model.revision} `;
|
|
553
|
+
const title = ` Live Context Viewer · r${model.revision}${model.noticesOnly && model.enabled ? " · notices-only" : ""} `;
|
|
525
554
|
const titleText = truncate([span(title)], inner, "").map((part) => part.text).join("");
|
|
526
555
|
const left = Math.max(0, Math.floor((inner - titleText.length) / 2));
|
|
527
556
|
const right = Math.max(0, inner - titleText.length - left);
|
package/src/presentation.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface ClmStatus {
|
|
|
98
98
|
changed?: string;
|
|
99
99
|
/** Saved settings that could not be used. */
|
|
100
100
|
settingsWarning?: string;
|
|
101
|
+
/** Set in `mode notices-only`; absent in `edit`. */
|
|
102
|
+
mode?: "notices-only";
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
/** One line for a toast. */
|
|
@@ -105,7 +107,8 @@ export function statusLine(status: ClmStatus): string {
|
|
|
105
107
|
const size = status.reading
|
|
106
108
|
? ` · ${formatTokens(status.reading.estimated)} of ${formatTokens(status.reading.budget)} tok`
|
|
107
109
|
: "";
|
|
108
|
-
|
|
110
|
+
const mode = status.mode ? ` · mode ${status.mode}` : "";
|
|
111
|
+
return `CLM r${status.revision}${mode} · ${status.accepted} accepted / ${status.rejected} rejected${size}`;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
/** Multi-line status for `/clm`. */
|
|
@@ -113,6 +116,7 @@ export function statusText(status: ClmStatus): string {
|
|
|
113
116
|
const lines = [
|
|
114
117
|
`CLM status for session ${status.sessionID}`,
|
|
115
118
|
`mirror: ${status.mirrorPath}`,
|
|
119
|
+
...(status.mode ? ["mode notices-only: budget notices and the overflow guard run; no mirror and no protocol prompt, so the model cannot edit its context"] : []),
|
|
116
120
|
`revision ${status.revision} · edits accepted ${status.accepted} · rejected ${status.rejected} · gate ${status.gate} · guard ${status.guard}`,
|
|
117
121
|
status.reading
|
|
118
122
|
? `${budgetSummaryLine(status.reading)} · model window ${status.modelWindow ? formatTokens(status.modelWindow) : "unknown"}`
|
|
@@ -121,7 +125,9 @@ export function statusText(status: ClmStatus): string {
|
|
|
121
125
|
if (status.fit) lines.push(budgetFitLine(status.fit));
|
|
122
126
|
const checkpoint = status.checkpoint;
|
|
123
127
|
lines.push(
|
|
124
|
-
checkpoint
|
|
128
|
+
checkpoint && status.mode
|
|
129
|
+
? `revision ${checkpoint.revision} kept but not applied in notices-only mode: the model sees the raw history`
|
|
130
|
+
: checkpoint
|
|
125
131
|
? `active revision ${checkpoint.revision}: covers ${checkpoint.anchorCount} raw messages; ~${formatTokens(checkpoint.beforeEstimate)}→${formatTokens(checkpoint.afterEstimate)} tokens when accepted`
|
|
126
132
|
: "no accepted edit: the model sees the raw history",
|
|
127
133
|
);
|
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;
|