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.
package/src/clm.ts CHANGED
@@ -33,7 +33,7 @@
33
33
  * included) was estimated; without those sizes it stays at 1.
34
34
  */
35
35
  import { randomUUID } from "node:crypto";
36
- import { appendFile, mkdir, writeFile } from "node:fs/promises";
36
+ import { appendFile, mkdir, stat, writeFile } from "node:fs/promises";
37
37
  import { join } from "node:path";
38
38
 
39
39
  import {
@@ -54,17 +54,20 @@ import {
54
54
  AnnotationStore,
55
55
  ContinuitySizeTracker,
56
56
  continuitySizeNoticeText,
57
+ forkAnnotations,
57
58
  formatContinuityMessage,
58
59
  type ContinuityBlockSource,
59
60
  type LiveContextAnnotation,
60
61
  } from "./continuity.ts";
61
62
  import { classifyMirrorToolCall } from "./mirror-guard.ts";
62
- import { MirrorStore } from "./mirror-store.ts";
63
+ import { MirrorDirectoryError, MirrorStore } from "./mirror-store.ts";
63
64
  import { capObservations } from "./observation.ts";
64
65
  import { flatten, noteMessage, unflatten, withoutReasoning, type OcInfo, type OcMessage } from "./opencode.ts";
66
+ import { sizeTrailer } from "./compaction.ts";
65
67
  import { applyOverflowGuard, overflowGuardLimit, overflowNoticeText } from "./overflow.ts";
66
68
  import type { ClmStatus } from "./presentation.ts";
67
- import { applyProjection, createProjectionCheckpoint, type ProjectionCheckpoint } from "./projection.ts";
69
+ import { applyProjection, createProjectionCheckpoint, digestSourceContent, type ProjectionCheckpoint } from "./projection.ts";
70
+ import { clearHistory, loadHistory, remapProjection, saveHistoryEntry, type HistoryEntry } from "./history.ts";
68
71
  import type { ClmSettings } from "./settings.ts";
69
72
  import {
70
73
  keptFields,
@@ -75,13 +78,19 @@ import {
75
78
  type LiveContextState,
76
79
  } from "./state.ts";
77
80
  import type { SteeringDocument } from "./steering.ts";
78
- import type { ContextDocumentSnapshot, LiveContextMessage, TurnBaseline } from "./types.ts";
81
+ import { overridesPath, readOverrides, stageSettings, type ChangeRequest, type StagedSettings } from "./overrides.ts";
82
+ import { changedSummary, settingsAsOverrides, type ClmOverrides, type SettingsValues } from "./settings-table.ts";
83
+ import type { ContextDocumentSnapshot, ContextEditTrace, LiveContextMessage, TurnBaseline } from "./types.ts";
84
+ import { writeJsonAtomic } from "./atomic.ts";
85
+ import { SNAPSHOT_FILE, type RevisionFile, type RevisionFileRow, type SnapshotFile } from "./panel/files.ts";
79
86
 
80
87
  export interface ModelLimits {
81
88
  /** Model context window, tokens. */
82
89
  context?: number;
83
90
  /** Model output limit, tokens; subtracted from the window by `resolveBudget`. */
84
91
  output?: number;
92
+ /** Model input limit, tokens, when the provider sets one; only OpenCode's threshold reads it (src/compaction.ts). */
93
+ input?: number;
85
94
  }
86
95
 
87
96
  /** Sizes of the request parts the plugin does not see in the messages hook. */
@@ -92,6 +101,11 @@ export interface RequestScope {
92
101
  toolTokens?: number;
93
102
  }
94
103
 
104
+ /** Toast when edits keep being dropped (pi `compositionWarningText`, reworded for OpenCode). */
105
+ export const COMPOSITION_WARNING = "CLM edits keep being dropped: OpenCode's history changed between consecutive requests. " +
106
+ "Another plugin that rewrites already-sent messages (experimental.chat.messages.transform) is the usual cause; " +
107
+ "load opencode-clm before it (earlier in the `plugin` list), or turn CLM off for this session with /clm off.";
108
+
95
109
  export interface TransformResult {
96
110
  /** The messages to send; the caller writes them into OpenCode's array in place. */
97
111
  messages: OcMessage[];
@@ -100,8 +114,10 @@ export interface TransformResult {
100
114
  /** Calibrated estimate of this request, tokens (system prompt and tool schemas included when known); undefined when CLM did not run. */
101
115
  estimated?: number;
102
116
  reading?: BudgetReading;
103
- /** User-facing warning for a toast (the budget-too-small check); set on one request only. */
117
+ /** User-facing warning for a toast (budget too small, composition warning, ignored saved settings); set on one request only. */
104
118
  alert?: string;
119
+ /** User-facing errors for toasts: the mirror could not be read or refreshed, a revision could not be saved. */
120
+ errors?: string[];
105
121
  }
106
122
 
107
123
  export interface MirrorCheck {
@@ -192,6 +208,23 @@ export class ClmSession {
192
208
  * `experimental.compaction.autocontinue` hook); the next transform rebases onto the summary.
193
209
  */
194
210
  compacted = false;
211
+ /**
212
+ * Set by the plugin when OpenCode's session metadata names another session as the origin
213
+ * (a fork, `Session.fork` copies metadata). The first transform restores the newest of the
214
+ * origin's checkpoints whose source the forked history still contains, then clears it.
215
+ */
216
+ forkOrigin?: { sessionID: string; directory: string; /** The fork's creation time (ms), when OpenCode reported it. */ created?: number };
217
+ /** The origin's annotations from cloned session metadata (a fork without a mirror); used when the origin's store yields none. */
218
+ forkPersisted?: LiveContextAnnotation[];
219
+ /** Set when a fork's first request copied annotations in; the plugin then persists them (no mirror). */
220
+ annotationsImported = false;
221
+ /**
222
+ * Why no mirror directory could be used, set by the plugin (M1). The store then sits in a
223
+ * private temporary directory the model is never told about; requests carry the raw
224
+ * history plus the continuity annotations and their size notice, as pi-clm does when its
225
+ * mirror store fails to initialize.
226
+ */
227
+ mirrorUnavailable?: string;
195
228
  /** What the last transform rendered; the next transform commits the mirror against it. */
196
229
  baseline?: Baseline;
197
230
  /** The snapshot now in the mirror file; continuity tools resolve block ids against it. */
@@ -201,9 +234,21 @@ export class ClmSession {
201
234
  requests = 0;
202
235
 
203
236
  private pendingNotices: string[] = [];
237
+ /** Errors for the user (toasts), returned with the next transform result. */
238
+ private pendingErrors: string[] = [];
239
+ /** The mirror could not be written last time; its error toast is not repeated until a write succeeds. */
240
+ private refreshFailing = false;
241
+ /** Why the steering document in the settings did not load, for snapshot.json (the panel). */
242
+ steeringError?: string;
204
243
  /** Raw length and conversation estimate (calibrated, tokens) of the last request, for the overhead measurement. */
205
244
  private previousRequest?: { rawCount: number; conversation: number };
206
245
  private invalidationStreak = 0;
246
+ /** The composition warning toast is shown once per session (pi: `compositionWarned`). */
247
+ private compositionWarned = false;
248
+ /** User-facing warnings for the next transform result (`alert`). */
249
+ private pendingAlerts: string[] = [];
250
+ /** The settings warning last shown to the user, so a new or changed one toasts once. */
251
+ private shownSettingsWarning?: string;
207
252
  /**
208
253
  * Per-process seed of the stable document id. Fresh on every open, so a document id never
209
254
  * repeats even when a corrupt state file resets the revision counter to 0.
@@ -213,34 +258,175 @@ export class ClmSession {
213
258
  private saving: Promise<void> = Promise.resolve();
214
259
  private running: Promise<unknown> = Promise.resolve();
215
260
 
261
+ /** Settings in force: `baseSettings` with this session's overrides.json applied (`refreshSettings`). */
262
+ settings: ClmSettings;
263
+ /** Overrides in force, as read from overrides.json. */
264
+ overrides: ClmOverrides = {};
265
+ /** The steering document in force for this session; undefined is protocol only. */
266
+ steering?: SteeringDocument;
267
+ /** Why saved settings or the steering document were (partly) ignored; shown by `/clm status`. */
268
+ settingsWarning?: string;
269
+ /** stat key of the overrides.json last read; a change triggers a re-read. */
270
+ private overridesKey = "";
271
+
216
272
  private constructor(
217
273
  readonly sessionID: string,
218
274
  readonly store: MirrorStore,
219
- readonly settings: ClmSettings,
275
+ readonly baseSettings: ClmSettings,
220
276
  loaded: { state: LiveContextState; warning?: string },
277
+ readonly baseSteering?: SteeringDocument,
221
278
  ) {
279
+ this.settings = baseSettings;
280
+ this.steering = baseSteering;
222
281
  this.state = loaded.state;
223
282
  this.loadWarning = loaded.warning;
224
283
  this.annotations = new AnnotationStore(store.directory, { estimateTokens: (text) => this.textTokens(text) });
225
284
  }
226
285
 
227
- /** Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. */
228
- static async open(sessionID: string, settings: ClmSettings): Promise<ClmSession> {
286
+ /**
287
+ * Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. `steering` is the
288
+ * document the base settings name, already loaded; undefined when it failed to load at start
289
+ * (each session then retries the path and records the error in `settingsWarning`).
290
+ */
291
+ static async open(sessionID: string, settings: ClmSettings, options: { steering?: SteeringDocument } = {}): Promise<ClmSession> {
229
292
  if (!SESSION_ID_RE.test(sessionID)) throw new Error(`Invalid session id for CLM: ${JSON.stringify(sessionID)}`);
230
- const store = await MirrorStore.create(sessionID, settings.mirrorDir);
293
+ const store = await MirrorStore.create(sessionID, settings.mirrorDir).catch((error: unknown) => {
294
+ throw new MirrorDirectoryError(settings.mirrorDir, error);
295
+ });
231
296
  const loaded = await loadLiveContextState(store.directory);
232
- const session = new ClmSession(sessionID, store, settings, loaded);
297
+ const session = new ClmSession(sessionID, store, settings, loaded, options.steering);
233
298
  if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
234
299
  if (loaded.repaired) await session.log({ event: "state-repaired", message: loaded.repaired });
300
+ await session.refreshSettings();
235
301
  return session;
236
302
  }
237
303
 
304
+ /** Whether CLM edits apply in this session: the `editing` override, else state.json. */
305
+ get enabled(): boolean {
306
+ return this.overrides.editing ?? this.state.enabled;
307
+ }
308
+
309
+ /** Base and effective values, for the settings table. */
310
+ settingsValues(): { base: SettingsValues; effective: SettingsValues } {
311
+ return {
312
+ base: { editing: this.state.enabled, settings: this.baseSettings },
313
+ effective: { editing: this.enabled, settings: this.settings },
314
+ };
315
+ }
316
+
317
+ /** For `changeSetting` (overrides.ts): where and against what a change is validated. */
318
+ changeRequest(projectDirectory: string): ChangeRequest {
319
+ return {
320
+ sessionDirectory: this.store.directory,
321
+ base: this.baseSettings,
322
+ baseEditing: this.state.enabled,
323
+ projectDirectory,
324
+ format: { modelWindow: this.limits.context },
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Re-read overrides.json when it changed (stat: mtime, size, inode) and activate it.
330
+ * Runs before every request and every `/clm` command, so a change written by the TUI or
331
+ * the server's `/clm config` applies from the next request. Invalid keys are dropped; a
332
+ * set that does not resolve falls back to the base settings; a steering document that
333
+ * does not load leaves the session protocol-only. Each case is kept in `settingsWarning`.
334
+ * `force` re-reads even when the file looks unchanged.
335
+ */
336
+ async refreshSettings(force = false): Promise<void> {
337
+ const path = overridesPath(this.store.directory);
338
+ let key = "missing";
339
+ try {
340
+ const info = await stat(path);
341
+ key = `${info.mtimeMs}:${info.size}:${info.ino}`;
342
+ } catch {
343
+ // missing: no overrides
344
+ }
345
+ if (!force && key === this.overridesKey) return;
346
+ this.overridesKey = key;
347
+ const read = key === "missing" ? { overrides: {} as ClmOverrides } : await readOverrides(this.store.directory);
348
+ const warnings: string[] = read.warning ? [read.warning] : [];
349
+ let staged: StagedSettings;
350
+ let overrides = read.overrides;
351
+ try {
352
+ staged = stageSettings(this.baseSettings, overrides, { strict: false, loaded: this.steering ?? this.baseSteering });
353
+ } catch (error) {
354
+ warnings.push(`ignored saved settings: ${describe(error)}`);
355
+ overrides = {};
356
+ // The base settings resolved at load; staging them again keeps a base steering error.
357
+ staged = stageSettings(this.baseSettings, {}, { strict: false, loaded: this.baseSteering });
358
+ }
359
+ // Only warnings about saved settings toast here. The base steering document failing
360
+ // again (no override names another path) already toasted once at load (index.ts).
361
+ const alerting = [...warnings];
362
+ if (staged.steeringError) {
363
+ const warning = `steering document not loaded: ${staged.steeringError}`;
364
+ warnings.push(warning);
365
+ if (staged.settings.steeringPath !== this.baseSettings.steeringPath) alerting.push(warning);
366
+ }
367
+ this.steeringError = staged.steeringError;
368
+ await this.activateSettings(staged, overrides);
369
+ this.settingsWarning = warnings.length > 0 ? warnings.join("; ") : undefined;
370
+ const alert = alerting.length > 0 ? alerting.join("; ") : undefined;
371
+ if (alert && alert !== this.shownSettingsWarning) {
372
+ this.pendingAlerts.push(`saved settings partly ignored: ${alert}`);
373
+ }
374
+ this.shownSettingsWarning = alert;
375
+ }
376
+
377
+ private async activateSettings(staged: StagedSettings, overrides: ClmOverrides): Promise<void> {
378
+ const before = this.settings.budget;
379
+ const after = staged.settings.budget;
380
+ const wasEnabled = this.enabled;
381
+ const changed = JSON.stringify(this.overrides) !== JSON.stringify(overrides);
382
+ this.settings = staged.settings;
383
+ this.steering = staged.steering;
384
+ this.overrides = overrides;
385
+ if (before.contextBudget !== after.contextBudget || before.reserve !== after.reserve ||
386
+ before.remindAtFractions.join(",") !== after.remindAtFractions.join(",") || before.remindAtReserve !== after.remindAtReserve) {
387
+ this.tracker.reset();
388
+ }
389
+ if (wasEnabled !== this.enabled) {
390
+ this.baseline = undefined;
391
+ if (!this.enabled) this.continuitySize.observe(0);
392
+ }
393
+ if (changed) await this.log({ event: "settings", overrides });
394
+ }
395
+
238
396
  get mirrorPath(): string {
239
397
  return this.store.filePath;
240
398
  }
241
399
 
242
400
  // ---- measurement -------------------------------------------------------------------
243
401
 
402
+ /**
403
+ * System prompt and tool schemas, when the hooks reported them (one request late).
404
+ * A provider-measured overhead replaces them: it is in real tokens, so it is not
405
+ * calibrated, and it includes the built-in tool schemas the hooks cannot size. The
406
+ * hook sizes stay a floor, since they count text that is certainly sent.
407
+ */
408
+ private scopeSize(): { measuredOverhead?: number; scopeTokens: number } {
409
+ const { systemTokens, toolTokens } = this.scope;
410
+ const measuredOverhead = this.state.budgetCheck?.source === "provider"
411
+ ? Math.max(this.state.budgetCheck.overhead, (systemTokens ?? 0) + (toolTokens ?? 0))
412
+ : undefined;
413
+ return { ...(measuredOverhead !== undefined ? { measuredOverhead } : {}), scopeTokens: measuredOverhead ?? this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0)) };
414
+ }
415
+
416
+ /**
417
+ * `/clm-compact`: about how large the next request would be, from the stored history
418
+ * (as OpenCode sends it, see `filterCompacted`) through the accepted revision (continuity and notices not
419
+ * counted). Undefined when the history holds no message to compact.
420
+ */
421
+ idleEstimate(raw: OcMessage[]): number | undefined {
422
+ const pinned = pinnedCount(raw);
423
+ const source = flatten(raw.slice(pinned));
424
+ if (source.length === 0) return undefined;
425
+ const projection = applyProjection(source, this.state.checkpoint);
426
+ const effective = projection.valid ? projection.messages : source;
427
+ return this.scopeSize().scopeTokens + this.estimate(flatten(raw.slice(0, pinned))) + this.estimate(effective);
428
+ }
429
+
244
430
  /** Uncalibrated tokens of a text: chars/4 × estimateFactor. */
245
431
  textTokens(text: string): number {
246
432
  return Math.ceil((text.length / CHARS_PER_TOKEN) * this.settings.estimateFactor);
@@ -420,6 +606,9 @@ export class ClmSession {
420
606
  this.baseline = undefined;
421
607
  this.tracker.reset();
422
608
  this.continuitySize.reset();
609
+ // The user dropped the edits on purpose: a later revert must not bring one back. Queued
610
+ // behind any history write in flight; writes queued later see no checkpoint and skip.
611
+ await this.historyTask(() => clearHistory(this.store.directory));
423
612
  await this.log({ event: "reset", revision: next.revision, reason });
424
613
  }
425
614
 
@@ -460,7 +649,7 @@ export class ClmSession {
460
649
  * calls that only read it or do not touch it.
461
650
  */
462
651
  receipt(tool: string, args: Record<string, unknown> | undefined, cwd: string): string | undefined {
463
- if (!this.settings.enabled || !this.state.enabled) return undefined;
652
+ if (!this.settings.enabled || !this.enabled) return undefined;
464
653
  if (classifyMirrorToolCall(tool, args ?? {}, cwd, this.mirrorPath) !== "write") return undefined;
465
654
  const check = this.validateMirror(this.store.readSync());
466
655
  if (!check) return undefined;
@@ -470,6 +659,34 @@ export class ClmSession {
470
659
  : `[CLM] Mirror edit would be refused: ${check.message} Correct the file before this step ends, or the context stays as it is.`;
471
660
  }
472
661
 
662
+ /**
663
+ * `setting trailer`: the size trailer for one tool result (the last reading plus the
664
+ * result's calibrated estimate, against the budget in force); undefined when off.
665
+ */
666
+ sizeTrailer(output: string): string | undefined {
667
+ if (!this.settings.enabled || !this.enabled || !this.settings.trailer || this.mirrorUnavailable !== undefined) return undefined;
668
+ const budget = this.resolvedBudget()?.budget;
669
+ if (!budget) return undefined;
670
+ return sizeTrailer(this.lastReading?.estimated ?? 0, this.calibrator.apply(this.textTokens(output)), budget);
671
+ }
672
+
673
+ /**
674
+ * Before an OpenCode compaction (`experimental.session.compacting`): commit the edit the
675
+ * model made in its final step. No request followed that step (e.g. the user typed
676
+ * /compact next), so the commit at the start of a transform has not run; without this the
677
+ * compaction would summarize the pre-edit context and the edit would be lost.
678
+ */
679
+ async commitBeforeCompaction(): Promise<void> {
680
+ await this.refreshSettings();
681
+ if (!this.settings.enabled || !this.enabled) return;
682
+ await this.commit();
683
+ }
684
+
685
+ /** Adds a notice to the next request (e.g. from a bus event). */
686
+ queueNotice(text: string): void {
687
+ this.pendingNotices.push(text);
688
+ }
689
+
473
690
  /** The mirror block the model last saw, for continuity annotations. */
474
691
  blockSource(blockId: string): ContinuityBlockSource | undefined {
475
692
  const snapshot = this.lastSnapshot;
@@ -487,6 +704,7 @@ export class ClmSession {
487
704
  text = await this.store.read();
488
705
  } catch (error) {
489
706
  this.pendingNotices.push(`[CLM] Could not read the mirror: ${describe(error)}`);
707
+ this.pendingErrors.push(`could not read the mirror, the edit was not applied: ${describe(error)}`);
490
708
  return;
491
709
  }
492
710
  if (text === undefined || text.trim() === baseline.snapshot.text.trim()) return;
@@ -539,12 +757,18 @@ export class ClmSession {
539
757
  } catch (error) {
540
758
  // Persist before activate: an unsaved revision would vanish on restart.
541
759
  this.pendingNotices.push(`[CLM] Revision ${revision} was not applied: it could not be saved (${describe(error)}); the previous context stays in effect.`);
760
+ this.pendingErrors.push(`revision ${revision} could not be saved, the previous context stays in effect: ${describe(error)}`);
542
761
  return;
543
762
  }
544
763
  this.accepted += 1;
764
+ await this.remember({ version: 1, checkpoint, contentDigest: digestSourceContent(baseline.rawMessages) });
545
765
  const revisions = join(this.store.directory, "revisions");
546
766
  await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
547
767
  await writeFile(join(revisions, `r${revision}.md`), text, { mode: 0o600 }).catch(() => undefined);
768
+ if (trace) {
769
+ await writeJsonAtomic(join(revisions, `r${revision}.json`), this.revisionFile(revision, at, baseline, result.messages, trace, sizes))
770
+ .catch((error: unknown) => this.log({ event: "revision-file-error", revision, error: describe(error) }));
771
+ }
548
772
  const notes = result.diagnostics.length > 0 ? ` ${result.diagnostics.join(" ")}` : "";
549
773
  this.pendingNotices.push(`[CLM] ${applied}${notes}`);
550
774
  await this.log({
@@ -566,6 +790,102 @@ export class ClmSession {
566
790
  });
567
791
  }
568
792
 
793
+ /** `revisions/rN.json` for the panel: every row's full text and role before and after. */
794
+ private revisionFile(
795
+ revision: number,
796
+ at: string,
797
+ baseline: Baseline,
798
+ output: readonly LiveContextMessage[],
799
+ trace: ContextEditTrace,
800
+ sizes: { beforeEstimate: number; afterEstimate: number },
801
+ ): RevisionFile {
802
+ const rows: RevisionFileRow[] = [];
803
+ const roleOf = (message: LiveContextMessage | undefined) => (message ? String(message.role) : "?");
804
+ for (const source of trace.sources) {
805
+ const before = baseline.snapshot.blocks[source.sourceIndex]?.source;
806
+ const after = source.outputIndex !== undefined ? output[source.outputIndex] : undefined;
807
+ const row: RevisionFileRow = { kind: source.kind, sourceIndex: source.sourceIndex, role: roleOf(after ?? before) };
808
+ if (source.outputIndex !== undefined) row.outputIndex = source.outputIndex;
809
+ if (before && after && roleOf(before) !== roleOf(after)) {
810
+ row.beforeRole = roleOf(before);
811
+ row.afterRole = roleOf(after);
812
+ }
813
+ if (before) row.beforeTokens = this.estimate([before]);
814
+ if (after && source.kind !== "removed") row.afterTokens = this.estimate([after]);
815
+ const beforeText = before ? renderMessage(before) : undefined;
816
+ const afterText = after && source.kind !== "removed" ? renderMessage(after) : undefined;
817
+ // Unchanged rows (kept, most restored) store their text once.
818
+ if (beforeText !== undefined && beforeText === afterText) row.text = beforeText;
819
+ else {
820
+ if (beforeText !== undefined) row.before = beforeText;
821
+ if (afterText !== undefined) row.after = afterText;
822
+ }
823
+ rows.push(row);
824
+ }
825
+ for (const addition of trace.additions) {
826
+ const after = output[addition.outputIndex];
827
+ rows.push({
828
+ kind: "added",
829
+ outputIndex: addition.outputIndex,
830
+ role: roleOf(after),
831
+ ...(after ? { after: renderMessage(after), afterTokens: this.estimate([after]) } : {}),
832
+ });
833
+ }
834
+ return {
835
+ version: 1,
836
+ revision,
837
+ sourceRevision: trace.sourceRevision,
838
+ at,
839
+ beforeTokens: sizes.beforeEstimate,
840
+ afterTokens: sizes.afterEstimate,
841
+ rows,
842
+ };
843
+ }
844
+
845
+ /** `snapshot.json` for the panel: the request just built, replaced atomically. */
846
+ private snapshotFile(input: {
847
+ pinned: readonly LiveContextMessage[];
848
+ effective: readonly LiveContextMessage[];
849
+ sourceTokens: number;
850
+ raw: number;
851
+ sent: number;
852
+ suffix: number;
853
+ estimated: number;
854
+ observed?: number;
855
+ }): SnapshotFile | undefined {
856
+ const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
857
+ const resolved = this.resolvedBudget();
858
+ if (!base || !resolved) return undefined;
859
+ const shown = [...input.pinned, ...input.effective];
860
+ const steering = this.steering;
861
+ return {
862
+ at: new Date().toISOString(),
863
+ request: this.requests,
864
+ revision: this.state.revision,
865
+ enabled: this.enabled,
866
+ budget: { budget: base.budget, reserve: base.reserve, limit: overflowGuardLimit(resolved.budget, resolved.reserve), source: base.source },
867
+ calibration: { factor: this.calibrator.factor, samples: this.calibrator.sampleCount },
868
+ ...(steering ? { steering: { name: steering.name, hash: steering.hash, path: steering.path } } : {}),
869
+ ...(this.steeringError ? { steeringError: this.steeringError } : {}),
870
+ sizes: { estimated: input.estimated, ...(input.observed !== undefined ? { observedPrevious: input.observed } : {}) },
871
+ input: {
872
+ raw: input.raw,
873
+ sent: input.sent,
874
+ suffix: input.suffix,
875
+ rawTokens: input.sourceTokens,
876
+ effectiveTokens: this.estimate([...shown]),
877
+ messages: shown.map((message, index) => ({
878
+ index: index + 1,
879
+ role: String(message.role),
880
+ tokens: this.estimate([message]),
881
+ preview: renderMessage(message).replace(/\s+/g, " ").trim().slice(0, 120),
882
+ })),
883
+ },
884
+ ...(this.state.budgetCheck ? { overhead: this.state.budgetCheck.overhead } : {}),
885
+ base: settingsAsOverrides(this.baseSettings) as Record<string, unknown>,
886
+ };
887
+ }
888
+
569
889
  // ---- the transform -----------------------------------------------------------------
570
890
 
571
891
  /** Transforms of one session run one at a time. */
@@ -583,7 +903,7 @@ export class ClmSession {
583
903
 
584
904
  /** Text for `experimental.session.compacting`'s `output.context`: the instruction plus the active annotations. */
585
905
  async compactionContext(): Promise<string | undefined> {
586
- if (!this.settings.enabled || !this.state.enabled) return undefined;
906
+ if (!this.settings.enabled || !this.enabled) return undefined;
587
907
  const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: [] });
588
908
  return continuity ? `${COMPACTION_INSTRUCTION}\n\n${continuity}` : COMPACTION_INSTRUCTION;
589
909
  }
@@ -613,6 +933,172 @@ export class ClmSession {
613
933
  await this.log({ event: "compacted", revision: next.revision, previous, summary: summaryId, written });
614
934
  }
615
935
 
936
+ // ---- checkpoint history (revert and fork) ------------------------------------------
937
+
938
+ /** History writes and clears run one at a time (`/clm reset` runs outside the transform queue). */
939
+ private historyQueue: Promise<void> = Promise.resolve();
940
+
941
+ private historyTask(task: () => Promise<void>): Promise<void> {
942
+ const run = this.historyQueue.then(task).catch(() => undefined);
943
+ this.historyQueue = run;
944
+ return run;
945
+ }
946
+
947
+ /**
948
+ * Add an accepted checkpoint to `checkpoints/`. Best effort: the revision is already saved.
949
+ * Skipped when the checkpoint is no longer the active one by the time the write runs (a
950
+ * `/clm reset` came in between), so a reset edit never lands in the history.
951
+ */
952
+ private remember(entry: HistoryEntry): Promise<void> {
953
+ return this.historyTask(async () => {
954
+ if (this.state.checkpoint?.revision !== entry.checkpoint.revision) return;
955
+ await saveHistoryEntry(this.store.directory, entry)
956
+ .catch((error: unknown) => this.log({ event: "history-error", revision: entry.checkpoint.revision, error: describe(error) }));
957
+ });
958
+ }
959
+
960
+ /**
961
+ * Make `entry`'s projection, with `projected` standing for `source`, the next revision.
962
+ * Persisted before it takes effect, like an accepted edit.
963
+ */
964
+ private async activateRestored(
965
+ entry: HistoryEntry,
966
+ source: readonly LiveContextMessage[],
967
+ projected: readonly LiveContextMessage[],
968
+ describeRestore: (revision: number) => string,
969
+ ): Promise<boolean> {
970
+ const at = new Date().toISOString();
971
+ const old = entry.checkpoint;
972
+ const revision = this.state.revision + 1;
973
+ let checkpoint: ProjectionCheckpoint;
974
+ try {
975
+ checkpoint = createProjectionCheckpoint({
976
+ revision,
977
+ sourceMessages: source,
978
+ projectedMessages: projected,
979
+ beforeEstimate: old.beforeEstimate,
980
+ afterEstimate: old.afterEstimate,
981
+ estimateUnit: old.estimateUnit,
982
+ createdAt: at,
983
+ ...(old.editTrace ? { editTrace: old.editTrace } : {}),
984
+ });
985
+ } catch {
986
+ return false;
987
+ }
988
+ const message = describeRestore(revision);
989
+ try {
990
+ await this.updateState((state) => {
991
+ if (state.revision + 1 !== revision) throw new Error("the session state changed while the revision was restored");
992
+ return {
993
+ version: 1,
994
+ enabled: state.enabled,
995
+ revision,
996
+ checkpoint,
997
+ lastOutcome: { kind: "applied", message, beforeEstimate: old.beforeEstimate, afterEstimate: old.afterEstimate, estimateUnit: old.estimateUnit, at },
998
+ ...keptFields(state),
999
+ };
1000
+ });
1001
+ } catch {
1002
+ return false;
1003
+ }
1004
+ this.tracker.reset();
1005
+ await this.remember({ version: 1, checkpoint, contentDigest: entry.contentDigest });
1006
+ this.pendingNotices.push(`[CLM] ${message} The mirror shows it.`);
1007
+ return true;
1008
+ }
1009
+
1010
+ /**
1011
+ * The active revision no longer fits the history (a revert cut into its prefix): restore
1012
+ * the newest older checkpoint that still fits, as pi-clm restores the checkpoint of the
1013
+ * branch `/tree` selects. False when none fits; the caller then resets.
1014
+ */
1015
+ private async restoreAfterMismatch(source: readonly LiveContextMessage[], reason: string): Promise<boolean> {
1016
+ const dropped = this.state.checkpoint?.revision;
1017
+ const { entries, ignored } = await loadHistory(this.store.directory);
1018
+ if (ignored.length > 0) await this.log({ event: "history-ignored", files: ignored });
1019
+ for (const entry of entries) {
1020
+ if (entry.checkpoint.revision === dropped) continue;
1021
+ const candidate = applyProjection(source, entry.checkpoint);
1022
+ if (!candidate.valid) continue;
1023
+ const from = entry.checkpoint.revision;
1024
+ const prefix = source.slice(0, entry.checkpoint.sourceMessageCount);
1025
+ const restored = await this.activateRestored(entry, prefix, entry.checkpoint.projectedMessages, (revision) =>
1026
+ `Revision ${dropped} no longer matches OpenCode's history: ${reason.replace(/\.\s*$/, "")}; restored revision ${from}, which still does, as revision ${revision}.`);
1027
+ if (!restored) return false;
1028
+ await this.log({ event: "restored", revision: this.state.revision, from, dropped, reason });
1029
+ return true;
1030
+ }
1031
+ return false;
1032
+ }
1033
+
1034
+ /**
1035
+ * First request of a forked session: find the newest origin checkpoint whose source the
1036
+ * fork's history starts with (compared without message ids, which the fork renews) and
1037
+ * restore it with the ids mapped to the fork's.
1038
+ */
1039
+ private async restoreFromFork(origin: { sessionID: string; directory: string }, source: readonly LiveContextMessage[]): Promise<boolean> {
1040
+ const { entries, ignored } = await loadHistory(origin.directory);
1041
+ if (ignored.length > 0) await this.log({ event: "history-ignored", origin: origin.sessionID, files: ignored });
1042
+ for (const entry of entries) {
1043
+ const count = entry.checkpoint.sourceMessageCount;
1044
+ if (count > source.length) continue;
1045
+ const prefix = source.slice(0, count);
1046
+ if (digestSourceContent(prefix) !== entry.contentDigest) continue;
1047
+ const from = entry.checkpoint.revision;
1048
+ const restored = await this.activateRestored(entry, prefix, remapProjection(entry.checkpoint, prefix), (revision) =>
1049
+ `This session is a fork of ${origin.sessionID}; restored its revision ${from} as revision ${revision}.`);
1050
+ if (!restored) return false;
1051
+ await this.log({ event: "restored", revision: this.state.revision, from, origin: origin.sessionID });
1052
+ return true;
1053
+ }
1054
+ return false;
1055
+ }
1056
+
1057
+ /**
1058
+ * First request of a forked session: copy the origin's annotations made up to the fork
1059
+ * point, their sources re-pointed at the fork's messages (`forkAnnotations`). The fork
1060
+ * point is the newest completion among the messages created before the fork itself
1061
+ * (OpenCode keeps message times in a fork); newer messages are the fork's own.
1062
+ */
1063
+ private async copyForkAnnotations(origin: { sessionID: string; directory: string; created?: number }, raw: readonly OcMessage[], effective: readonly LiveContextMessage[]): Promise<void> {
1064
+ let annotations: LiveContextAnnotation[] = [];
1065
+ try {
1066
+ annotations = await new AnnotationStore(origin.directory).list();
1067
+ } catch (error) {
1068
+ await this.log({ event: "annotations-unreadable", origin: origin.sessionID, error: describe(error) });
1069
+ }
1070
+ // Without a mirror the origin's directory is usually out of reach too: fall back to the
1071
+ // set the origin kept in OpenCode's metadata, which the fork cloned (index.ts).
1072
+ if (annotations.length === 0) annotations = this.forkPersisted ?? [];
1073
+ this.forkPersisted = undefined;
1074
+ if (annotations.length === 0) return;
1075
+ let cutoff: number | undefined;
1076
+ for (const { info } of raw) {
1077
+ const created = Number(info.time?.created ?? 0);
1078
+ if (origin.created !== undefined && created >= origin.created) continue;
1079
+ const end = Number(info.time?.completed ?? 0) || created;
1080
+ if (cutoff === undefined || end > cutoff) cutoff = end;
1081
+ }
1082
+ const copied = forkAnnotations(annotations, effective, { sessionId: this.sessionID, ...(cutoff !== undefined ? { cutoff } : {}) });
1083
+ try {
1084
+ const written = await this.annotations.importAll(copied);
1085
+ // Without the fork's creation time the fork's own new messages count too, so origin
1086
+ // annotations made between the fork and its first request are carried as well.
1087
+ if (written > 0) {
1088
+ this.annotationsImported = true;
1089
+ await this.log({
1090
+ event: "annotations-forked",
1091
+ origin: origin.sessionID,
1092
+ count: written,
1093
+ mapped: copied.filter((annotation) => annotation.source.sessionId === this.sessionID).length,
1094
+ cutoff: origin.created === undefined ? "unbounded" : cutoff ?? null,
1095
+ });
1096
+ }
1097
+ } catch (error) {
1098
+ await this.log({ event: "annotations-fork-error", origin: origin.sessionID, error: describe(error) });
1099
+ }
1100
+ }
1101
+
616
1102
  private async loadAnnotations(): Promise<LiveContextAnnotation[]> {
617
1103
  try {
618
1104
  return await this.annotations.list();
@@ -622,12 +1108,46 @@ export class ClmSession {
622
1108
  }
623
1109
  }
624
1110
 
1111
+ /**
1112
+ * Without a mirror (`mirrorUnavailable`): the raw history, then the continuity message and
1113
+ * its size notice. Nothing is projected, rendered or measured; queued notices name the
1114
+ * mirror, so they are dropped. pi-clm src/index.ts `context` handler, `!store` branch.
1115
+ */
1116
+ private async transformWithoutMirror(raw: OcMessage[]): Promise<TransformResult> {
1117
+ this.baseline = undefined;
1118
+ this.compacted = false;
1119
+ this.pendingNotices = [];
1120
+ if (this.compacting) {
1121
+ this.compacting = false;
1122
+ return { messages: raw, notices: [] };
1123
+ }
1124
+ this.requests += 1;
1125
+ const origin = this.forkOrigin;
1126
+ this.forkOrigin = undefined;
1127
+ if (origin) await this.copyForkAnnotations(origin, raw, flatten(raw));
1128
+ const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: flatten(raw) });
1129
+ const tokens = continuity ? this.textTokens(continuity) : 0;
1130
+ const notices = this.continuitySize.observe(tokens) ? [continuitySizeNoticeText(tokens)] : [];
1131
+ const context = this.context(raw);
1132
+ const messages = [
1133
+ ...raw,
1134
+ ...(continuity ? [noteMessage(continuity, "continuity", context)] : []),
1135
+ ...(notices.length > 0 ? [noteMessage(notices.join("\n\n"), `notice:${this.requests}`, context)] : []),
1136
+ ];
1137
+ this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: 0 };
1138
+ const errors = this.pendingErrors;
1139
+ this.pendingErrors = [];
1140
+ return { messages, notices, ...(errors.length > 0 ? { errors } : {}) };
1141
+ }
1142
+
625
1143
  private async transformNow(raw: OcMessage[]): Promise<TransformResult> {
626
- if (!this.settings.enabled || !this.state.enabled) {
1144
+ await this.refreshSettings();
1145
+ if (!this.settings.enabled || !this.enabled) {
627
1146
  this.baseline = undefined;
628
1147
  this.compacting = false;
629
- return { messages: raw, notices: [] };
1148
+ return { messages: raw, notices: [], ...this.drainAlerts() };
630
1149
  }
1150
+ if (this.mirrorUnavailable !== undefined) return await this.transformWithoutMirror(raw);
631
1151
  const pinned = pinnedCount(raw);
632
1152
  const pinnedMessages = raw.slice(0, pinned);
633
1153
  const context = this.context(raw);
@@ -635,11 +1155,12 @@ export class ClmSession {
635
1155
  // OpenCode's compaction passes a head slice of the history through this hook. Apply
636
1156
  // the accepted revision when it still covers that slice; never commit, render,
637
1157
  // notify or reset (the slice is shorter than the history the checkpoint covers).
1158
+ // A pending edit was committed by `commitBeforeCompaction` just before.
638
1159
  if (this.compacting) {
639
1160
  this.compacting = false;
640
1161
  const projection = applyProjection(flatten(raw.slice(pinned)), this.state.checkpoint);
641
- if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [] };
642
- return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [] };
1162
+ if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [], ...this.drainAlerts() };
1163
+ return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [], ...this.drainAlerts() };
643
1164
  }
644
1165
 
645
1166
  await this.commit();
@@ -654,6 +1175,9 @@ export class ClmSession {
654
1175
  // Before the guard and the reading, so a raise applies to this request already.
655
1176
  const measured = this.state.budgetCheck?.source === "provider" ? undefined : this.measureOverhead(raw, observed);
656
1177
  const alert = measured ? await this.checkBudget(measured) : undefined;
1178
+ // Samples taken before the overhead was measured read the unmeasured part of the
1179
+ // overhead (built-in tool schemas) as undercounting; start calibration over.
1180
+ if (measured?.source === "provider") this.calibrator.reset();
657
1181
 
658
1182
  let projection = applyProjection(source, this.state.checkpoint);
659
1183
  // A late or repeated compaction signal must not swallow a later revert: rebase only
@@ -664,6 +1188,10 @@ export class ClmSession {
664
1188
  if (rebase) {
665
1189
  await this.rebaseAfterCompaction(summaryId);
666
1190
  projection = applyProjection(source, undefined);
1191
+ } else if (!projection.valid && (await this.restoreAfterMismatch(source, projection.reason))) {
1192
+ // A revert cut into the active revision's prefix; an older revision still fits.
1193
+ this.invalidationStreak = 0;
1194
+ projection = applyProjection(source, this.state.checkpoint);
667
1195
  } else if (!projection.valid) {
668
1196
  const dropped = this.state.checkpoint?.revision;
669
1197
  this.invalidationStreak += 1;
@@ -672,6 +1200,10 @@ export class ClmSession {
672
1200
  let notice = `[CLM] Revision ${dropped} was dropped because OpenCode's history changed under it: ${projection.reason} The mirror now shows the stored history.`;
673
1201
  if (this.invalidationStreak >= 2) {
674
1202
  notice += " This happened on consecutive requests; edits keep being dropped until the start of the history stops changing.";
1203
+ if (!this.compositionWarned) {
1204
+ this.compositionWarned = true;
1205
+ this.pendingAlerts.push(COMPOSITION_WARNING);
1206
+ }
675
1207
  }
676
1208
  this.pendingNotices.push(notice);
677
1209
  await this.log({ event: "projection-reset", revision: dropped, reason: projection.reason });
@@ -679,6 +1211,11 @@ export class ClmSession {
679
1211
  } else if (this.state.checkpoint) {
680
1212
  this.invalidationStreak = 0;
681
1213
  }
1214
+ const origin = this.forkOrigin;
1215
+ this.forkOrigin = undefined;
1216
+ if (origin && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
1217
+ projection = applyProjection(source, this.state.checkpoint);
1218
+ }
682
1219
  const checkpoint = this.state.checkpoint;
683
1220
  const suffixLength = projection.valid ? projection.suffix.length : 0;
684
1221
 
@@ -686,16 +1223,16 @@ export class ClmSession {
686
1223
  if (!this.settings.reasoning) effective = withoutReasoning(effective);
687
1224
  effective = capObservations(effective, this.settings.observationCap);
688
1225
 
1226
+ if (origin) await this.copyForkAnnotations(origin, raw, effective);
689
1227
  const annotations = await this.loadAnnotations();
690
1228
  const continuityText = (messages: LiveContextMessage[]) =>
691
1229
  formatContinuityMessage({ annotations, effectiveMessages: messages });
692
1230
  const pinnedTokens = this.estimate(flatten(pinnedMessages));
693
1231
  const resolved = this.resolvedBudget();
694
1232
  const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
695
- // System prompt and tool schemas, when the hooks reported them (one request late).
696
1233
  const { systemTokens, toolTokens } = this.scope;
697
- const scopeTokens = this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0));
698
- const excluded = [
1234
+ const { measuredOverhead, scopeTokens } = this.scopeSize();
1235
+ const excluded = measuredOverhead !== undefined ? [] : [
699
1236
  ...(systemTokens === undefined ? ["the system prompt"] : []),
700
1237
  ...(toolTokens === undefined ? ["tool schemas"] : []),
701
1238
  ];
@@ -740,6 +1277,7 @@ export class ClmSession {
740
1277
  });
741
1278
  try {
742
1279
  await this.store.write(snapshot.text);
1280
+ this.refreshFailing = false;
743
1281
  this.lastSnapshot = snapshot;
744
1282
  this.baseline = {
745
1283
  rawMessages: source,
@@ -752,6 +1290,8 @@ export class ClmSession {
752
1290
  } catch (error) {
753
1291
  this.baseline = undefined;
754
1292
  this.pendingNotices.push(`[CLM] Could not refresh the mirror: ${describe(error)}`);
1293
+ if (!this.refreshFailing) this.pendingErrors.push(`could not refresh the mirror, edits are off until it can be written: ${describe(error)}`);
1294
+ this.refreshFailing = true;
755
1295
  }
756
1296
 
757
1297
  const conversationTokens = pinnedTokens + this.estimate(effective) + continuityTokens;
@@ -786,11 +1326,15 @@ export class ClmSession {
786
1326
  ];
787
1327
 
788
1328
  // Calibrate only against a same-scope estimate: the provider count includes the system
789
- // prompt and tool schemas, so both sizes must be known.
790
- if (systemTokens !== undefined && toolTokens !== undefined) {
791
- const conversation = this.rawTokens(flatten(pinnedMessages)) + this.rawTokens(effective) +
792
- (continuity ? this.textTokens(continuity) : 0) + this.noticeTokens(notices);
793
- this.calibrator.record(systemTokens + toolTokens + conversation, raw.length);
1329
+ // prompt and tool schemas. With a measured overhead the calibrator compares the
1330
+ // conversation with the provider count minus that overhead; otherwise both hook sizes
1331
+ // must be known.
1332
+ const conversationRaw = this.rawTokens(flatten(pinnedMessages)) + this.rawTokens(effective) +
1333
+ (continuity ? this.textTokens(continuity) : 0) + this.noticeTokens(notices);
1334
+ if (measuredOverhead !== undefined) {
1335
+ this.calibrator.record(conversationRaw, raw.length, measuredOverhead);
1336
+ } else if (systemTokens !== undefined && toolTokens !== undefined) {
1337
+ this.calibrator.record(systemTokens + toolTokens + conversationRaw, raw.length);
794
1338
  }
795
1339
 
796
1340
  this.previousRequest = { rawCount: raw.length, conversation: conversationTokens + this.calibrator.apply(this.noticeTokens(notices)) };
@@ -800,28 +1344,67 @@ export class ClmSession {
800
1344
  await mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => undefined);
801
1345
  await writeFile(join(directory, `n${this.requests}.json`), JSON.stringify(messages, null, 1), { mode: 0o600 }).catch(() => undefined);
802
1346
  }
1347
+ const snapshotFile = this.snapshotFile({
1348
+ pinned: flatten(pinnedMessages),
1349
+ effective,
1350
+ sourceTokens: pinnedTokens + this.estimate(source),
1351
+ raw: raw.length,
1352
+ sent: messages.length,
1353
+ suffix: suffixLength,
1354
+ estimated: reading?.estimated ?? requestTokens,
1355
+ ...(observed ? { observed: observed.tokens } : {}),
1356
+ });
1357
+ if (snapshotFile) {
1358
+ await writeJsonAtomic(join(this.store.directory, SNAPSHOT_FILE), snapshotFile)
1359
+ .catch((error: unknown) => this.log({ event: "snapshot-error", error: describe(error) }));
1360
+ }
803
1361
  await this.log({
804
1362
  event: "request",
805
1363
  n: this.requests,
806
1364
  revision: this.state.revision,
1365
+ users: raw.filter((message) => message.info.role === "user").length,
807
1366
  raw: raw.length,
808
1367
  sent: messages.length,
809
1368
  blocks: snapshot.blocks.length,
810
1369
  withheld: withheldCount,
811
1370
  estimated: reading?.estimated ?? requestTokens,
812
1371
  observedPrevious: observed?.tokens,
1372
+ // The assistant message that reported `observedPrevious`; a new id means a new count.
1373
+ observedMessage: observed?.messageID,
813
1374
  calibration: this.calibrator.factor,
814
1375
  notices: notices.map((notice) => notice.slice(0, 80)),
815
1376
  });
816
- return { messages, notices, estimated: reading?.estimated ?? requestTokens, reading, ...(alert ? { alert } : {}) };
1377
+ return {
1378
+ messages,
1379
+ notices,
1380
+ estimated: reading?.estimated ?? requestTokens,
1381
+ reading,
1382
+ ...this.drainAlerts(alert),
1383
+ };
1384
+ }
1385
+
1386
+ /** The queued toasts (`alert`, `errors`) for a transform result, cleared once taken. */
1387
+ private drainAlerts(alert?: string): Pick<TransformResult, "alert" | "errors"> {
1388
+ const alerts = [...(alert ? [alert] : []), ...this.pendingAlerts];
1389
+ this.pendingAlerts = [];
1390
+ const errors = this.pendingErrors;
1391
+ this.pendingErrors = [];
1392
+ return {
1393
+ ...(alerts.length > 0 ? { alert: alerts.join("\n") } : {}),
1394
+ ...(errors.length > 0 ? { errors } : {}),
1395
+ };
817
1396
  }
818
1397
 
819
1398
  /** Inputs for presentation.ts `statusText` / `statusLine`. */
820
- status(steering?: SteeringDocument): ClmStatus {
1399
+ status(steering: SteeringDocument | undefined = this.steering): ClmStatus {
821
1400
  const checkpoint = this.state.checkpoint;
1401
+ const { base, effective } = this.settingsValues();
1402
+ const changed = changedSummary(base, effective, { modelWindow: this.limits.context });
822
1403
  return {
1404
+ ...(changed ? { changed } : {}),
1405
+ ...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
823
1406
  sessionID: this.sessionID,
824
- mirrorPath: this.mirrorPath,
1407
+ mirrorPath: this.mirrorUnavailable === undefined ? this.mirrorPath : `unavailable, requests carry the raw history (${this.mirrorUnavailable})`,
825
1408
  revision: this.state.revision,
826
1409
  accepted: this.accepted,
827
1410
  rejected: this.rejected,