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/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<{ values: SettingsValues; warning?: string; overrides: ClmOverrides; mtimeMs?: number }> {
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 };
@@ -19,7 +19,7 @@ export type ClmCommand =
19
19
  | { kind: "server"; args: string }
20
20
  | { kind: "usage"; text: string };
21
21
 
22
- export const CLM_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | config [setting [value]]]";
22
+ export const CLM_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | budget [value] | config [setting [value]]]";
23
23
 
24
24
  /** Page names and the short forms pi accepts. */
25
25
  const PAGE_ALIASES: Record<string, Page> = {
@@ -54,7 +54,13 @@ export function parseClmCommand(text: string): ClmCommand | undefined {
54
54
  case "off":
55
55
  return rest.length === 0 ? { kind: "enable", enabled: word === "on" } : { kind: "usage", text: CLM_USAGE };
56
56
  case "reset":
57
- return { kind: "server", args };
57
+ // Extra words are a typo, not a reset: dropping the revision cannot be undone.
58
+ return rest.length === 0 ? { kind: "server", args } : { kind: "usage", text: CLM_USAGE };
59
+ case "budget": {
60
+ // pi's shorthand for `/clm config budget …`.
61
+ const value = args.replace(/^\S+\s*/, "");
62
+ return value === "" ? { kind: "config-show", setting: "budget" } : { kind: "config-set", setting: "budget", value };
63
+ }
58
64
  case "config": {
59
65
  if (rest.length === 0) return { kind: "open", page: "settings" };
60
66
  const setting = rest[0]!.toLowerCase();
@@ -60,6 +60,8 @@ export interface SnapshotFile {
60
60
  budget: { budget: number; reserve: number; limit: number; source: string };
61
61
  calibration: { factor: number; samples: number };
62
62
  steering?: { name: string; hash: string; path: string };
63
+ /** Why the configured steering document did not load (sessions then run protocol-only). */
64
+ steeringError?: string;
63
65
  sizes: { estimated: number; observedPrevious?: number };
64
66
  input: {
65
67
  raw: number;
@@ -98,6 +100,8 @@ export interface SessionFiles {
98
100
  revisionTexts: Map<number, string>;
99
101
  /** Path of the mirror file, whether or not it exists. */
100
102
  mirrorPath: string;
103
+ /** annotations.jsonl counts: unresolved continuity/pin, unresolved archive, all. */
104
+ annotations?: { active: number; archived: number; total: number };
101
105
  /** Read problems other than missing files. */
102
106
  warnings: string[];
103
107
  }
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 = viewportHeight(size);
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);
@@ -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, num(event.estimated) ?? 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
  }
@@ -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
- text("No input snapshot yet: snapshot.json appears after the next model request.", "muted"),
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 - viewportHeight(size));
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 viewport = viewportHeight(size);
517
- const page = pageLines(model, state, content, viewport);
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
- const visible = page.slice(scroll, scroll + viewport);
520
- while (visible.length < viewport) visible.push([]);
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);
@@ -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
- return `CLM r${status.revision} · ${status.accepted} accepted / ${status.rejected} rejected${size}`;
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[];
@@ -1,7 +1,8 @@
1
1
  // Read-only access to one session's CLM directory (`<mirrorDir>/clm-<session>/`) for the
2
2
  // `/clm` panel: events.jsonl, state.json, snapshot.json and revisions/rN.{json,md}. Missing
3
3
  // files are normal (a v0.1 session has no snapshot or rN.json); torn or corrupt lines and
4
- // files are skipped, never thrown. Written for this package.
4
+ // files are skipped, never thrown. Reads go through a `SessionReader`: the disk, or the
5
+ // server's file API for an attached TUI (src/tui/remote.ts). Written for this package.
5
6
 
6
7
  import { readdir, readFile, stat } from "node:fs/promises";
7
8
  import { join } from "node:path";
@@ -16,6 +17,17 @@ import {
16
17
  type SessionFiles,
17
18
  } from "./panel/files.ts";
18
19
  import { STATE_FILE } from "./state.ts";
20
+ import { activeContinuityAnnotations, ANNOTATIONS_FILE, reconstructAnnotations } from "./continuity.ts";
21
+
22
+ /** Counts of annotations.jsonl (latest snapshot per id, as the server reconstructs them). */
23
+ export function countAnnotations(text: string): { active: number; archived: number; total: number } {
24
+ const annotations = reconstructAnnotations(parseEvents(text).events);
25
+ return {
26
+ active: activeContinuityAnnotations(annotations).length,
27
+ archived: annotations.filter((annotation) => annotation.retention === "archive" && annotation.resolvedAt === undefined).length,
28
+ total: annotations.length,
29
+ };
30
+ }
19
31
 
20
32
  /** `<mirrorDir>/clm-<safe session id>`, the directory MirrorStore creates for the session. */
21
33
  export function sessionDirectory(mirrorDir: string, sessionID: string): string {
@@ -47,40 +59,123 @@ export function parseEvents(text: string): { events: ClmEvent[]; skipped: number
47
59
  }
48
60
 
49
61
  /**
50
- * Revision files keyed by path, kept while their mtime and size are unchanged, so a reload
62
+ * Where a session directory is read from: the local disk, or the server through OpenCode's
63
+ * file API when the TUI runs elsewhere (src/tui/remote.ts). Paths are the server's absolute
64
+ * paths either way.
65
+ */
66
+ export interface SessionReader {
67
+ /** Cache-key prefix, so the same path read two ways does not share entries. */
68
+ readonly name: string;
69
+ /** True for a directory; false when missing. Throws for other errors. */
70
+ isDirectory(path: string): Promise<boolean>;
71
+ /** File text; undefined when missing. Throws for other errors. */
72
+ readText(path: string): Promise<string | undefined>;
73
+ /** Entry names; [] when the directory is missing. Throws for other errors. */
74
+ list(path: string): Promise<string[]>;
75
+ /**
76
+ * Version stamp of a file for the revision cache (mtime and size locally); undefined
77
+ * when missing. A constant stamp caches the file for good (rN files are written once).
78
+ */
79
+ stamp(path: string): Promise<string | undefined>;
80
+ }
81
+
82
+ export const localReader: SessionReader = {
83
+ name: "local",
84
+ async isDirectory(path) {
85
+ try {
86
+ return (await stat(path)).isDirectory();
87
+ } catch (error) {
88
+ if (isMissing(error)) return false;
89
+ throw error;
90
+ }
91
+ },
92
+ async readText(path) {
93
+ try {
94
+ return await readFile(path, "utf8");
95
+ } catch (error) {
96
+ if (isMissing(error)) return undefined;
97
+ throw error;
98
+ }
99
+ },
100
+ async list(path) {
101
+ try {
102
+ return await readdir(path);
103
+ } catch (error) {
104
+ if (isMissing(error)) return [];
105
+ throw error;
106
+ }
107
+ },
108
+ async stamp(path) {
109
+ try {
110
+ const info = await stat(path);
111
+ return `${info.mtimeMs}:${info.size}`;
112
+ } catch (error) {
113
+ if (isMissing(error)) return undefined;
114
+ throw error;
115
+ }
116
+ },
117
+ };
118
+
119
+ /**
120
+ * Revision files keyed by reader and path, kept while their stamp is unchanged, so a reload
51
121
  * on every streamed message reads only new or changed revisions. rN files are written once.
52
122
  */
53
- const revisionCache = new Map<string, { mtimeMs: number; size: number; value: unknown }>();
123
+ const revisionCache = new Map<string, { stamp: string; size: number; value: unknown }>();
54
124
  const REVISION_CACHE_LIMIT = 2_000;
55
- /** Total file bytes kept; the oldest entries go first once it is exceeded. */
125
+ /** Total text kept (characters); the oldest entries go first once it is exceeded. */
56
126
  const REVISION_CACHE_BYTES = 32 * 1024 * 1024;
57
127
  let revisionCacheBytes = 0;
58
128
 
129
+ /** The last `state.json` revision seen per reader and session directory. */
130
+ const lastStateRevision = new Map<string, number>();
131
+
132
+ /**
133
+ * Drop the cached revision files of one session directory when `state.json`'s revision went
134
+ * down since the last read: the session restarted its count (a corrupt state file starts at
135
+ * 0), so its next rN files replace old ones. Needed for readers with a constant stamp (no
136
+ * mtime over the file API or the channel); harmless for the disk.
137
+ */
138
+ function invalidateRevisions(reader: SessionReader, revisionsDirectory: string, stateRevision: number): void {
139
+ const prefix = `${reader.name}:${revisionsDirectory}/`;
140
+ const last = lastStateRevision.get(prefix);
141
+ lastStateRevision.set(prefix, stateRevision);
142
+ if (last === undefined || stateRevision >= last) return;
143
+ for (const [key, entry] of [...revisionCache.entries()]) {
144
+ if (!key.startsWith(prefix)) continue;
145
+ revisionCache.delete(key);
146
+ revisionCacheBytes -= entry.size;
147
+ }
148
+ }
149
+
59
150
  function evictRevision(): void {
60
151
  const [path, entry] = revisionCache.entries().next().value!;
61
152
  revisionCache.delete(path);
62
153
  revisionCacheBytes -= entry.size;
63
154
  }
64
155
 
65
- async function readCached(path: string, kind: "json" | "md", warnings: string[]): Promise<unknown> {
66
- let info;
156
+ async function readCached(reader: SessionReader, path: string, kind: "json" | "md", warnings: string[]): Promise<unknown> {
157
+ let stamp: string | undefined;
67
158
  try {
68
- info = await stat(path);
159
+ stamp = await reader.stamp(path);
69
160
  } catch (error) {
70
- if (!isMissing(error)) warnings.push(`Could not read ${path}: ${describe(error)}`);
161
+ warnings.push(`Could not read ${path}: ${describe(error)}`);
71
162
  return undefined;
72
163
  }
73
- const cached = revisionCache.get(path);
74
- if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.value;
75
- const value = kind === "json" ? await readJson(path, warnings) : await readText(path, warnings);
164
+ if (stamp === undefined) return undefined;
165
+ const key = `${reader.name}:${path}`;
166
+ const cached = revisionCache.get(key);
167
+ if (cached && cached.stamp === stamp) return cached.value;
168
+ const text = await readText(reader, path, warnings);
169
+ if (text === undefined) return undefined;
170
+ const value = kind === "json" ? parseJson(text, path, warnings) : text;
76
171
  if (value !== undefined) {
77
172
  if (cached) {
78
- revisionCache.delete(path);
173
+ revisionCache.delete(key);
79
174
  revisionCacheBytes -= cached.size;
80
175
  }
81
- while (revisionCache.size > 0 && (revisionCache.size >= REVISION_CACHE_LIMIT || revisionCacheBytes + info.size > REVISION_CACHE_BYTES)) evictRevision();
82
- revisionCache.set(path, { mtimeMs: info.mtimeMs, size: info.size, value });
83
- revisionCacheBytes += info.size;
176
+ while (revisionCache.size > 0 && (revisionCache.size >= REVISION_CACHE_LIMIT || revisionCacheBytes + text.length > REVISION_CACHE_BYTES)) evictRevision();
177
+ revisionCache.set(key, { stamp, size: text.length, value });
178
+ revisionCacheBytes += text.length;
84
179
  }
85
180
  return value;
86
181
  }
@@ -99,18 +194,16 @@ function describe(error: unknown): string {
99
194
  return error instanceof Error ? error.message : String(error);
100
195
  }
101
196
 
102
- async function readText(path: string, warnings: string[]): Promise<string | undefined> {
197
+ async function readText(reader: SessionReader, path: string, warnings: string[]): Promise<string | undefined> {
103
198
  try {
104
- return await readFile(path, "utf8");
199
+ return await reader.readText(path);
105
200
  } catch (error) {
106
- if (!isMissing(error)) warnings.push(`Could not read ${path}: ${describe(error)}`);
201
+ warnings.push(`Could not read ${path}: ${describe(error)}`);
107
202
  return undefined;
108
203
  }
109
204
  }
110
205
 
111
- async function readJson(path: string, warnings: string[]): Promise<unknown> {
112
- const text = await readText(path, warnings);
113
- if (text === undefined) return undefined;
206
+ function parseJson(text: string, path: string, warnings: string[]): unknown {
114
207
  try {
115
208
  return JSON.parse(text);
116
209
  } catch (error) {
@@ -119,13 +212,18 @@ async function readJson(path: string, warnings: string[]): Promise<unknown> {
119
212
  }
120
213
  }
121
214
 
215
+ async function readJson(reader: SessionReader, path: string, warnings: string[]): Promise<unknown> {
216
+ const text = await readText(reader, path, warnings);
217
+ return text === undefined ? undefined : parseJson(text, path, warnings);
218
+ }
219
+
122
220
  /** Reads the session directory of `sessionID` under `mirrorDir`. */
123
221
  export function readSessionFiles(mirrorDir: string, sessionID: string): Promise<SessionFiles> {
124
222
  return readSessionDirectory(sessionDirectory(mirrorDir, sessionID), sessionID);
125
223
  }
126
224
 
127
225
  /** Reads a session directory given its path. Never throws for missing or corrupt files. */
128
- export async function readSessionDirectory(directory: string, sessionID: string): Promise<SessionFiles> {
226
+ export async function readSessionDirectory(directory: string, sessionID: string, reader: SessionReader = localReader): Promise<SessionFiles> {
129
227
  const warnings: string[] = [];
130
228
  const files: SessionFiles = {
131
229
  sessionID,
@@ -139,17 +237,19 @@ export async function readSessionDirectory(directory: string, sessionID: string)
139
237
  warnings,
140
238
  };
141
239
  try {
142
- files.found = (await stat(directory)).isDirectory();
240
+ files.found = await reader.isDirectory(directory);
143
241
  } catch (error) {
144
- if (!isMissing(error)) warnings.push(`Could not read ${directory}: ${describe(error)}`);
242
+ warnings.push(`Could not read ${directory}: ${describe(error)}`);
145
243
  }
146
244
  if (!files.found) return files;
147
245
 
148
- const [eventsText, state, snapshot] = await Promise.all([
149
- readText(join(directory, EVENTS_FILE), warnings),
150
- readJson(join(directory, STATE_FILE), warnings),
151
- readJson(join(directory, SNAPSHOT_FILE), warnings),
246
+ const [eventsText, state, snapshot, annotationsText] = await Promise.all([
247
+ readText(reader, join(directory, EVENTS_FILE), warnings),
248
+ readJson(reader, join(directory, STATE_FILE), warnings),
249
+ readJson(reader, join(directory, SNAPSHOT_FILE), warnings),
250
+ readText(reader, join(directory, ANNOTATIONS_FILE), warnings),
152
251
  ]);
252
+ if (annotationsText !== undefined) files.annotations = countAnnotations(annotationsText);
153
253
  if (eventsText !== undefined) {
154
254
  const parsed = parseEvents(eventsText);
155
255
  files.events = parsed.events;
@@ -159,11 +259,13 @@ export async function readSessionDirectory(directory: string, sessionID: string)
159
259
  if (snapshot !== undefined) files.snapshot = snapshot;
160
260
 
161
261
  const revisionsDirectory = join(directory, REVISIONS_DIRECTORY);
262
+ const stateRevision = (state as { revision?: unknown } | undefined)?.revision;
263
+ if (typeof stateRevision === "number" && Number.isInteger(stateRevision)) invalidateRevisions(reader, revisionsDirectory, stateRevision);
162
264
  let names: string[] = [];
163
265
  try {
164
- names = await readdir(revisionsDirectory);
266
+ names = await reader.list(revisionsDirectory);
165
267
  } catch (error) {
166
- if (!isMissing(error)) warnings.push(`Could not list ${revisionsDirectory}: ${describe(error)}`);
268
+ warnings.push(`Could not list ${revisionsDirectory}: ${describe(error)}`);
167
269
  }
168
270
  const jsonRevisions = new Set<number>();
169
271
  const mdRevisions = new Set<number>();
@@ -172,12 +274,12 @@ export async function readSessionDirectory(directory: string, sessionID: string)
172
274
  if (match) (match[2] === "json" ? jsonRevisions : mdRevisions).add(Number(match[1]));
173
275
  }
174
276
  await Promise.all([...jsonRevisions].map(async (revision) => {
175
- const value = await readCached(join(revisionsDirectory, `r${revision}.json`), "json", warnings);
277
+ const value = await readCached(reader, join(revisionsDirectory, `r${revision}.json`), "json", warnings);
176
278
  if (value !== undefined) files.revisions.set(revision, value);
177
279
  }));
178
280
  // rN.md is only shown when rN.json is missing or invalid.
179
281
  await Promise.all([...mdRevisions].filter((revision) => !isRevisionFile(files.revisions.get(revision))).map(async (revision) => {
180
- const text = await readCached(join(revisionsDirectory, `r${revision}.md`), "md", warnings);
282
+ const text = await readCached(reader, join(revisionsDirectory, `r${revision}.md`), "md", warnings);
181
283
  if (typeof text === "string") files.revisionTexts.set(revision, text);
182
284
  }));
183
285
  return files;