opencode-clm 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ // The `/clm` panel model: everything the four pages show, built from one session
2
+ // directory's files (session-files.ts) without touching the host. Pure. The revision view
3
+ // shape follows pi-clm's LiveContextEditRevisionView (src/viewer.ts, MIT, Copyright 2026
4
+ // Emanuel Casco); the rest is written for this package.
5
+
6
+ import { budgetFit } from "../budget.ts";
7
+ import type { LiveContextOutcome, OutcomeKind } from "../state.ts";
8
+ import type { ClmEvent, SessionFiles } from "./files.ts";
9
+ import type { ContextTimeline, MarkerKind, TimelineMarker, TimelinePoint } from "./timeline.ts";
10
+
11
+ export type EditKind = "kept" | "edited" | "removed" | "restored" | "normalized" | "added";
12
+
13
+ /** One message row of the edits page. Indices are one-based for display. */
14
+ export interface EditView {
15
+ kind: EditKind;
16
+ sourceIndex?: number;
17
+ outputIndex?: number;
18
+ beforeRole?: string;
19
+ afterRole?: string;
20
+ beforeTokens?: number;
21
+ afterTokens?: number;
22
+ beforePreview?: string;
23
+ afterPreview?: string;
24
+ /** Full text for the diff (the diff bounds what it shows). */
25
+ beforeText?: string;
26
+ afterText?: string;
27
+ }
28
+
29
+ export interface EditRevision {
30
+ revision: number;
31
+ sourceRevision: number;
32
+ createdAt?: string;
33
+ beforeTokens?: number;
34
+ afterTokens?: number;
35
+ edits: EditView[];
36
+ /** `recorded`: from revisions/rN.json; `unavailable`: only rN.md or the accepted event exist. */
37
+ traceSource: "recorded" | "unavailable";
38
+ /** The accepted mirror text (revisions/rN.md), when no per-message rows exist. */
39
+ mirrorText?: string;
40
+ }
41
+
42
+ export interface InputMessageView {
43
+ index: number;
44
+ role: string;
45
+ tokens: number;
46
+ preview: string;
47
+ }
48
+
49
+ export interface InputView {
50
+ capturedAt: string;
51
+ rawMessages: number;
52
+ sentMessages: number;
53
+ suffix: number;
54
+ rawTokens: number;
55
+ effectiveTokens: number;
56
+ messages: InputMessageView[];
57
+ mirrorPath: string;
58
+ /** Requests ran after the snapshot was written (it is one request behind). */
59
+ stale: boolean;
60
+ }
61
+
62
+ /** One row of the settings page. Filled by the settings table (a later block). */
63
+ export interface SettingRow {
64
+ key: string;
65
+ label: string;
66
+ value: string;
67
+ description?: string;
68
+ changed?: boolean;
69
+ /** Enter cycles through these; without choices Enter opens a text prompt. */
70
+ choices?: string[];
71
+ /** Example value shown in the text prompt. */
72
+ placeholder?: string;
73
+ }
74
+
75
+ export interface PanelSettings {
76
+ rows: SettingRow[];
77
+ summary: string[];
78
+ changed: string[];
79
+ warning?: string;
80
+ }
81
+
82
+ /**
83
+ * Budget arithmetic for the panel, computed with `budgetFit` (src/budget.ts) so the
84
+ * figures equal the server's `/clm status`: `usable` = configured − reserve − overhead,
85
+ * `effectiveUsable` = budget in force − reserve − overhead.
86
+ */
87
+ export interface BudgetView {
88
+ /** Budget in force: `configured`, or raised when it was too small for the overhead. */
89
+ budget?: number;
90
+ /** Budget before any raise. */
91
+ configured?: number;
92
+ reserve?: number;
93
+ /** Overflow-guard limit (budget − reserve), from snapshot.json. */
94
+ limit?: number;
95
+ /** Where the budget came from (`config`, `model-window`). */
96
+ source?: string;
97
+ /** Fixed overhead per request (system prompt + tool schemas), tokens. */
98
+ overhead?: number;
99
+ overheadSource?: "provider" | "estimate";
100
+ usable?: number;
101
+ effectiveUsable?: number;
102
+ raised?: boolean;
103
+ /** The model window stopped the raise short of the minimum. */
104
+ capped?: boolean;
105
+ /** configured − reserve leaves less than the working margin after the overhead. */
106
+ tooSmall: boolean;
107
+ }
108
+
109
+ export interface PanelModel {
110
+ sessionID: string;
111
+ directory: string;
112
+ /** False → "no CLM data for this session". */
113
+ found: boolean;
114
+ enabled: boolean;
115
+ revision: number;
116
+ lastOutcome?: LiveContextOutcome;
117
+ timeline: ContextTimeline;
118
+ input?: InputView;
119
+ revisions: EditRevision[];
120
+ settings: PanelSettings;
121
+ /** Budget line of the overview chart (the budget in force). */
122
+ budget?: number;
123
+ budgetInfo?: BudgetView;
124
+ calibration?: { factor: number; samples: number };
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 };
130
+ mirrorPath: string;
131
+ /** The newest `mirror-unavailable` event's reason: the session runs without a mirror. */
132
+ mirrorUnavailable?: string;
133
+ /** Problems reading the files (corrupt JSON, skipped event lines). */
134
+ warnings: string[];
135
+ }
136
+
137
+ /** Newest provider count the host knows, sized like the server's `observedPrevious`. */
138
+ export interface LatestUsage {
139
+ /** input + cache read + cache write of the assistant message (no output), as clm.ts measures. */
140
+ tokens: number;
141
+ /**
142
+ * When the reply finished (ms or ISO); undefined while it streams. OpenCode creates the
143
+ * assistant message before the request, so completion (not creation) tells whether the
144
+ * reply answered the newest request.
145
+ */
146
+ completedAt?: number | string;
147
+ }
148
+
149
+ export interface PanelExtras {
150
+ /** Sizes the newest request when it is a reply completed after that request was sent. */
151
+ latest?: LatestUsage;
152
+ /**
153
+ * Budget from the resolved settings and the model limits, for sessions without
154
+ * snapshot.json. `cap` is the model window minus its output limit (bounds a raise).
155
+ */
156
+ budget?: { budget: number; reserve: number; source?: string; cap?: number };
157
+ settings?: PanelSettings;
158
+ /**
159
+ * `budget` (the effective settings) wins over snapshot.json: overrides.json changed after
160
+ * the last request, so its budget applies from the next one.
161
+ */
162
+ preferSettings?: boolean;
163
+ }
164
+
165
+ const EDIT_KINDS = new Set<string>(["kept", "edited", "removed", "restored", "normalized", "added"]);
166
+ const OUTCOME_KINDS = new Set<string>(["applied", "rejected", "reset", "compacted"]);
167
+ const PREVIEW_CHARACTERS = 220;
168
+
169
+ function isObject(value: unknown): value is Record<string, unknown> {
170
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
171
+ }
172
+
173
+ function num(value: unknown): number | undefined {
174
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
175
+ }
176
+
177
+ function count(value: unknown): number | undefined {
178
+ return Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
179
+ }
180
+
181
+ function str(value: unknown): string | undefined {
182
+ return typeof value === "string" ? value : undefined;
183
+ }
184
+
185
+ /** Event name: `event` as clm.ts writes it, `type` accepted too. */
186
+ export function eventName(event: ClmEvent): string | undefined {
187
+ return str(event.event) ?? str(event.type);
188
+ }
189
+
190
+ function preview(text: string | undefined): string | undefined {
191
+ if (text === undefined) return undefined;
192
+ const flat = text.replace(/\s+/g, " ").trim();
193
+ return flat.length <= PREVIEW_CHARACTERS ? flat : `${flat.slice(0, PREVIEW_CHARACTERS - 1)}…`;
194
+ }
195
+
196
+ // ---- timeline ------------------------------------------------------------------------
197
+
198
+ function timeOf(value: number | string | undefined): number | undefined {
199
+ if (value === undefined) return undefined;
200
+ const ms = typeof value === "number" ? value : Date.parse(value);
201
+ return Number.isFinite(ms) ? ms : undefined;
202
+ }
203
+
204
+ /**
205
+ * Points from `request` events (requests flagged `compaction` excluded) and markers from
206
+ * outcome events. Every size is the server's measure: input + cache read + cache write of
207
+ * the reply (no output). A request's provider count is the next request event's
208
+ * `observedPrevious`, unless the next event names the same reporting message
209
+ * (`observedMessage`) as this one: the reply failed and the count is the older one again.
210
+ * Events without `observedMessage` fall back to comparing the counts. The newest request takes `latest` when that
211
+ * reply completed after the request was sent; otherwise the logged `estimated`
212
+ * (`measured: false`).
213
+ */
214
+ export function buildTimeline(events: readonly ClmEvent[], latest?: LatestUsage): ContextTimeline {
215
+ const points: TimelinePoint[] = [];
216
+ const markers: TimelineMarker[] = [];
217
+ const turnStarts: number[] = [];
218
+ const requests = events
219
+ .map((event, position) => ({ event, position }))
220
+ .filter(({ event }) => eventName(event) === "request");
221
+ const observedAfter = new Map<number, number>();
222
+ requests.forEach(({ event, position }, index) => {
223
+ const next = requests[index + 1];
224
+ const observed = next ? num(next.event.observedPrevious) : undefined;
225
+ if (observed === undefined || observed <= 0) return;
226
+ // The same reporting message again: the reply to this request failed or reported no
227
+ // usage, so the count is the older one. Without the id (logs before v0.2.0), an equal
228
+ // count stands in for "same message".
229
+ const nextMessage = str(next!.event.observedMessage);
230
+ if (nextMessage !== undefined) {
231
+ if (nextMessage === str(event.observedMessage)) return;
232
+ } else if (observed === num(event.observedPrevious)) {
233
+ return;
234
+ }
235
+ observedAfter.set(position, observed);
236
+ });
237
+ const lastRequest = requests.filter(({ event }) => event.compaction !== true).at(-1);
238
+ const lastAt = timeOf(str(lastRequest?.event.at));
239
+ const latestCompleted = timeOf(latest?.completedAt);
240
+ const latestTokens = latest && latestCompleted !== undefined && latest.tokens > 0 &&
241
+ (lastAt === undefined || latestCompleted >= lastAt)
242
+ ? latest.tokens
243
+ : undefined;
244
+ let users = 0;
245
+
246
+ events.forEach((event, position) => {
247
+ const name = eventName(event);
248
+ const at = str(event.at);
249
+ if (name === "request") {
250
+ if (event.compaction === true) return;
251
+ let tokens = observedAfter.get(position);
252
+ if (tokens === undefined && position === lastRequest?.position) tokens = latestTokens;
253
+ const measured = tokens !== undefined;
254
+ const estimated = num(event.estimated);
255
+ points.push({
256
+ request: points.length + 1,
257
+ ...(at ? { at } : {}),
258
+ tokens: tokens ?? Math.max(0, estimated ?? 0),
259
+ measured,
260
+ ...(measured && estimated !== undefined && estimated > 0 ? { estimated } : {}),
261
+ revision: count(event.revision) ?? 0,
262
+ });
263
+ // `users` counts user messages in the raw history; a rise starts a turn. A drop
264
+ // (compaction rewrote the history) only resets the count.
265
+ const seen = count(event.users);
266
+ if (seen !== undefined) {
267
+ if (seen > users) turnStarts.push(points.length - 1);
268
+ users = seen;
269
+ }
270
+ return;
271
+ }
272
+ const marker = markerOf(name, event, points.length - 1);
273
+ if (marker) markers.push(marker);
274
+ });
275
+ return { points, markers, peakTokens: points.reduce((peak, point) => Math.max(peak, point.tokens), 0), turnStarts };
276
+ }
277
+
278
+ function markerOf(name: string | undefined, event: ClmEvent, afterPoint: number): TimelineMarker | undefined {
279
+ const at = str(event.at);
280
+ const revision = count(event.revision);
281
+ const base = (kind: MarkerKind, markerRevision: number, message: string): TimelineMarker => ({
282
+ kind,
283
+ revision: markerRevision,
284
+ ...(at ? { at } : {}),
285
+ afterPoint,
286
+ message,
287
+ });
288
+ switch (name) {
289
+ case "accepted": {
290
+ if (revision === undefined) return undefined;
291
+ const marker = base("applied", revision, `Applied revision ${revision}.`);
292
+ const before = num(event.before);
293
+ const after = num(event.after);
294
+ if (before !== undefined) marker.beforeTokens = before;
295
+ if (after !== undefined) marker.afterTokens = after;
296
+ return marker;
297
+ }
298
+ case "rejected":
299
+ return base("rejected", revision ?? 0, str(event.reason) ?? "Context edit rejected.");
300
+ case "reset":
301
+ return base("reset", revision ?? 0, str(event.reason) ?? "Reset to the raw context.");
302
+ case "projection-reset": {
303
+ // Logged with the dropped revision; the reset moves one past it.
304
+ const dropped = revision;
305
+ const reason = str(event.reason) ?? "OpenCode's history changed.";
306
+ return base("reset", dropped === undefined ? 0 : dropped + 1, dropped === undefined ? reason : `Revision ${dropped} dropped: ${reason}`);
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
+ }
315
+ case "compacted": {
316
+ const summary = str(event.summary);
317
+ return base("compacted", revision ?? 0, summary ? `Rebased on compaction summary ${summary}.` : "Rebased on a compaction summary.");
318
+ }
319
+ default:
320
+ return undefined;
321
+ }
322
+ }
323
+
324
+ // ---- revisions -----------------------------------------------------------------------
325
+
326
+ function editFromRow(row: unknown): EditView | undefined {
327
+ if (!isObject(row) || typeof row.kind !== "string" || !EDIT_KINDS.has(row.kind)) return undefined;
328
+ const kind = row.kind as EditKind;
329
+ const role = str(row.role);
330
+ const beforeRole = str(row.beforeRole) ?? role;
331
+ const afterRole = str(row.afterRole) ?? role;
332
+ const sourceIndex = count(row.sourceIndex);
333
+ const outputIndex = count(row.outputIndex);
334
+ // `text` stands for both sides of an unchanged row (written since v0.2.0 block 3 fixes).
335
+ const before = kind === "added" ? undefined : str(row.before) ?? str(row.text);
336
+ const after = kind === "removed" ? undefined : str(row.after) ?? str(row.text);
337
+ const view: EditView = { kind };
338
+ if (sourceIndex !== undefined) view.sourceIndex = sourceIndex + 1;
339
+ if (outputIndex !== undefined) view.outputIndex = outputIndex + 1;
340
+ if (beforeRole !== undefined && kind !== "added") view.beforeRole = beforeRole;
341
+ if (afterRole !== undefined && kind !== "removed") view.afterRole = afterRole;
342
+ const beforeTokens = num(row.beforeTokens);
343
+ const afterTokens = num(row.afterTokens);
344
+ if (beforeTokens !== undefined) view.beforeTokens = beforeTokens;
345
+ if (afterTokens !== undefined) view.afterTokens = afterTokens;
346
+ if (before !== undefined) {
347
+ view.beforeText = before;
348
+ view.beforePreview = preview(before);
349
+ }
350
+ if (after !== undefined) {
351
+ view.afterText = after;
352
+ view.afterPreview = preview(after);
353
+ }
354
+ return view;
355
+ }
356
+
357
+ function revisionFromFile(value: unknown, revision: number): EditRevision | undefined {
358
+ if (!isObject(value) || value.version !== 1 || !Array.isArray(value.rows)) return undefined;
359
+ const result: EditRevision = {
360
+ revision: count(value.revision) ?? revision,
361
+ sourceRevision: count(value.sourceRevision) ?? Math.max(0, revision - 1),
362
+ edits: value.rows.map(editFromRow).filter((edit): edit is EditView => edit !== undefined),
363
+ traceSource: "recorded",
364
+ };
365
+ const at = str(value.at);
366
+ if (at) result.createdAt = at;
367
+ const beforeTokens = num(value.beforeTokens);
368
+ const afterTokens = num(value.afterTokens);
369
+ if (beforeTokens !== undefined) result.beforeTokens = beforeTokens;
370
+ if (afterTokens !== undefined) result.afterTokens = afterTokens;
371
+ return result;
372
+ }
373
+
374
+ /**
375
+ * One entry per accepted revision: rN.json when present and valid; otherwise an
376
+ * `unavailable` entry built from rN.md and the `accepted` event, so v0.1 sessions still
377
+ * list their revisions.
378
+ */
379
+ export function buildRevisions(files: Pick<SessionFiles, "events" | "revisions" | "revisionTexts">): EditRevision[] {
380
+ const accepted = new Map<number, ClmEvent>();
381
+ for (const event of files.events) {
382
+ const revision = count(event.revision);
383
+ if (eventName(event) === "accepted" && revision !== undefined) accepted.set(revision, event);
384
+ }
385
+ const numbers = new Set<number>([...files.revisions.keys(), ...files.revisionTexts.keys(), ...accepted.keys()]);
386
+ const result: EditRevision[] = [];
387
+ for (const revision of [...numbers].sort((a, b) => a - b)) {
388
+ const recorded = files.revisions.has(revision) ? revisionFromFile(files.revisions.get(revision), revision) : undefined;
389
+ if (recorded) {
390
+ result.push(recorded);
391
+ continue;
392
+ }
393
+ const event = accepted.get(revision);
394
+ const entry: EditRevision = { revision, sourceRevision: Math.max(0, revision - 1), edits: [], traceSource: "unavailable" };
395
+ const at = event ? str(event.at) : undefined;
396
+ if (at) entry.createdAt = at;
397
+ const before = event ? num(event.before) : undefined;
398
+ const after = event ? num(event.after) : undefined;
399
+ if (before !== undefined) entry.beforeTokens = before;
400
+ if (after !== undefined) entry.afterTokens = after;
401
+ const text = files.revisionTexts.get(revision);
402
+ if (text !== undefined) entry.mirrorText = text;
403
+ result.push(entry);
404
+ }
405
+ return result;
406
+ }
407
+
408
+ // ---- snapshot, state, budget ---------------------------------------------------------
409
+
410
+ function inputFrom(snapshot: Record<string, unknown> | undefined, mirrorPath: string, lastRequestAt: string | undefined): InputView | undefined {
411
+ const input = snapshot?.input;
412
+ if (!isObject(input)) return undefined;
413
+ const messages = Array.isArray(input.messages)
414
+ ? input.messages.flatMap((message): InputMessageView[] => {
415
+ if (!isObject(message)) return [];
416
+ const index = count(message.index);
417
+ const role = str(message.role);
418
+ if (index === undefined || role === undefined) return [];
419
+ return [{ index, role, tokens: num(message.tokens) ?? 0, preview: str(message.preview) ?? "" }];
420
+ })
421
+ : [];
422
+ return {
423
+ capturedAt: str(snapshot?.at) ?? "",
424
+ rawMessages: count(input.raw) ?? 0,
425
+ sentMessages: count(input.sent) ?? 0,
426
+ suffix: count(input.suffix) ?? 0,
427
+ rawTokens: num(input.rawTokens) ?? 0,
428
+ effectiveTokens: num(input.effectiveTokens) ?? 0,
429
+ messages,
430
+ mirrorPath,
431
+ stale: (() => {
432
+ const captured = timeOf(str(snapshot?.at));
433
+ const last = timeOf(lastRequestAt);
434
+ return captured !== undefined && last !== undefined && last > captured;
435
+ })(),
436
+ };
437
+ }
438
+
439
+ function outcomeFrom(value: unknown): LiveContextOutcome | undefined {
440
+ if (!isObject(value) || typeof value.kind !== "string" || !OUTCOME_KINDS.has(value.kind)) return undefined;
441
+ const message = str(value.message);
442
+ const at = str(value.at);
443
+ if (message === undefined || at === undefined) return undefined;
444
+ const outcome: LiveContextOutcome = { kind: value.kind as OutcomeKind, message, at };
445
+ const before = num(value.beforeEstimate);
446
+ const after = num(value.afterEstimate);
447
+ if (before !== undefined) outcome.beforeEstimate = before;
448
+ if (after !== undefined) outcome.afterEstimate = after;
449
+ if (value.estimateUnit === "tokens" || value.estimateUnit === "characters") outcome.estimateUnit = value.estimateUnit;
450
+ return outcome;
451
+ }
452
+
453
+ /**
454
+ * Budget fields. Configured budget and reserve: snapshot.json, else `extras` (the TUI's
455
+ * resolved settings), else the stored check, else the newest check event. Overhead:
456
+ * snapshot.json, else `state.budgetCheck`, else the newest `budget-too-small` /
457
+ * `budget-check` event. The raise is recomputed with `budgetFit`, as the server does on
458
+ * every request, so a stored decision never outlives a config change. With
459
+ * `preferFallback` (overrides.json newer than snapshot.json) the snapshot's budget, reserve
460
+ * and limit are ignored and `fallback` (the effective settings) is used. Event fields are
461
+ * read under the names clm.ts logs (`configured`, `effective`) and the short names
462
+ * (`budget`, `effectiveBudget`); unknown fields are ignored.
463
+ */
464
+ export function buildBudget(
465
+ snapshot: Record<string, unknown> | undefined,
466
+ state: Record<string, unknown> | undefined,
467
+ events: readonly ClmEvent[],
468
+ fallback?: PanelExtras["budget"],
469
+ preferFallback = false,
470
+ ): BudgetView | undefined {
471
+ const snapshotBudget = isObject(snapshot?.budget) && !(preferFallback && fallback) ? snapshot.budget : undefined;
472
+ const check = isObject(state?.budgetCheck) ? state.budgetCheck : undefined;
473
+ let checkEvent: ClmEvent | undefined;
474
+ let tooSmallEvent = false;
475
+ for (const event of events) {
476
+ const name = eventName(event);
477
+ if (name === "budget-too-small" || name === "budget-check") checkEvent = event;
478
+ if (name === "budget-too-small") tooSmallEvent = true;
479
+ }
480
+ const pick = (...values: unknown[]) => values.map(num).find((value) => value !== undefined);
481
+ const overhead = pick(snapshot?.overhead, snapshotBudget?.overhead, check?.overhead, checkEvent?.overhead);
482
+ const configured = pick(snapshotBudget?.budget, fallback?.budget, check?.configured, checkEvent?.configured, checkEvent?.budget);
483
+ const reserve = pick(snapshotBudget?.reserve, fallback?.reserve, check?.reserve, checkEvent?.reserve);
484
+ const limit = pick(snapshotBudget?.limit);
485
+ const overheadSource = str(check?.source) ?? str(checkEvent?.source);
486
+
487
+ const view: BudgetView = { tooSmall: false };
488
+ if (configured !== undefined) {
489
+ const fit = budgetFit(configured, reserve ?? 0, overhead, fallback?.cap);
490
+ view.budget = fit.effective;
491
+ view.configured = fit.configured;
492
+ view.reserve = fit.reserve;
493
+ if (fit.usable !== undefined) view.usable = fit.usable;
494
+ if (fit.effectiveUsable !== undefined) view.effectiveUsable = fit.effectiveUsable;
495
+ view.raised = fit.raised;
496
+ view.capped = fit.capped;
497
+ view.tooSmall = fit.overhead === undefined ? tooSmallEvent : fit.tooSmall;
498
+ } else {
499
+ if (overhead === undefined && !tooSmallEvent && limit === undefined) return undefined;
500
+ view.tooSmall = tooSmallEvent;
501
+ const effective = pick(checkEvent?.effective, checkEvent?.effectiveBudget);
502
+ if (effective !== undefined) view.budget = effective;
503
+ if (reserve !== undefined) view.reserve = reserve;
504
+ }
505
+ if (limit !== undefined) view.limit = limit;
506
+ const source = str(snapshotBudget?.source) ?? (snapshotBudget ? undefined : fallback?.source);
507
+ if (source) view.source = source;
508
+ if (overhead !== undefined) view.overhead = overhead;
509
+ if (overhead !== undefined && (overheadSource === "provider" || overheadSource === "estimate")) view.overheadSource = overheadSource;
510
+ return view;
511
+ }
512
+
513
+ // ---- the model -----------------------------------------------------------------------
514
+
515
+ const EMPTY_SETTINGS: PanelSettings = { rows: [], summary: [], changed: [] };
516
+
517
+ export function buildPanelModel(files: SessionFiles, extras: PanelExtras = {}): PanelModel {
518
+ const snapshot = isObject(files.snapshot) ? files.snapshot : undefined;
519
+ const state = isObject(files.state) ? files.state : undefined;
520
+ const timeline = buildTimeline(files.events, extras.latest);
521
+ // projection-reset logs the dropped revision; the others log the revision in force.
522
+ const eventRevision = files.events.reduce(
523
+ (most, event) => eventName(event) === "projection-reset" ? most : Math.max(most, count(event.revision) ?? 0),
524
+ 0,
525
+ );
526
+ const lastRequestAt = files.events.filter((event) => eventName(event) === "request" && event.compaction !== true).at(-1)?.at;
527
+ const warnings = [...files.warnings];
528
+ if (files.skippedEventLines > 0) {
529
+ warnings.push(`Skipped ${files.skippedEventLines} unreadable line${files.skippedEventLines === 1 ? "" : "s"} in events.jsonl.`);
530
+ }
531
+ const budgetInfo = buildBudget(snapshot, state, files.events, extras.budget, extras.preferSettings === true);
532
+ const model: PanelModel = {
533
+ sessionID: files.sessionID,
534
+ directory: files.directory,
535
+ found: files.found,
536
+ enabled: typeof state?.enabled === "boolean"
537
+ ? state.enabled
538
+ : typeof snapshot?.enabled === "boolean" ? snapshot.enabled : true,
539
+ revision: count(state?.revision) ?? count(snapshot?.revision) ?? eventRevision,
540
+ timeline,
541
+ revisions: buildRevisions(files),
542
+ settings: extras.settings ?? EMPTY_SETTINGS,
543
+ mirrorPath: files.mirrorPath,
544
+ warnings,
545
+ };
546
+ const lastOutcome = outcomeFrom(state?.lastOutcome);
547
+ if (lastOutcome) model.lastOutcome = lastOutcome;
548
+ const input = inputFrom(snapshot, files.mirrorPath, typeof lastRequestAt === "string" ? lastRequestAt : undefined);
549
+ if (input) model.input = input;
550
+ if (budgetInfo) {
551
+ model.budgetInfo = budgetInfo;
552
+ if (budgetInfo.budget !== undefined) model.budget = budgetInfo.budget;
553
+ }
554
+ const calibration = isObject(snapshot?.calibration) ? snapshot.calibration : undefined;
555
+ const factor = num(calibration?.factor);
556
+ if (factor !== undefined) model.calibration = { factor, samples: count(calibration?.samples) ?? 0 };
557
+ if (files.annotations) model.annotations = files.annotations;
558
+ const steering = isObject(snapshot?.steering) ? snapshot.steering : undefined;
559
+ if (steering && typeof steering.name === "string" && typeof steering.hash === "string" && typeof steering.path === "string") {
560
+ model.steering = { name: steering.name, hash: steering.hash, path: steering.path };
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
+ }
568
+ return model;
569
+ }
@@ -0,0 +1,22 @@
1
+ // A panel page as plain text, for the server's `/clm overview|input|edits|settings` (and
2
+ // `opencode run --command clm …`), where no TUI draws the panel: the same model and page
3
+ // renderer, at a fixed width, tones dropped. pi-clm prints its pages the same way outside
4
+ // its TUI (src/index.ts, MIT, Copyright 2026 Emanuel Casco); written for this package.
5
+
6
+ import type { Page } from "./command.ts";
7
+ import { plain } from "./lines.ts";
8
+ import type { PanelModel } from "./model.ts";
9
+ import { initialPanelState, pageLines } from "./view.ts";
10
+
11
+ export const TEXT_WIDTH = 72;
12
+ /** Rows the overview's chart and marker list size themselves against. */
13
+ const TEXT_VIEWPORT = 24;
14
+
15
+ const TITLES: Record<Page, string> = { overview: "overview", input: "input", edits: "edits", settings: "settings" };
16
+
17
+ export function panelPageText(model: PanelModel, page: Page, width = TEXT_WIDTH): string {
18
+ const state = initialPanelState(page);
19
+ const lines = pageLines(model, state, width, TEXT_VIEWPORT).map((line) => plain(line).replace(/\s+$/, ""));
20
+ while (lines.length > 0 && lines.at(-1) === "") lines.pop();
21
+ return [`CLM ${TITLES[page]} · r${model.revision} · session ${model.sessionID}`, "", ...lines].join("\n");
22
+ }