opencode-clm 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/clm.ts CHANGED
@@ -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,
@@ -86,6 +89,8 @@ export interface ModelLimits {
86
89
  context?: number;
87
90
  /** Model output limit, tokens; subtracted from the window by `resolveBudget`. */
88
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;
89
94
  }
90
95
 
91
96
  /** Sizes of the request parts the plugin does not see in the messages hook. */
@@ -96,6 +101,11 @@ export interface RequestScope {
96
101
  toolTokens?: number;
97
102
  }
98
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
+
99
109
  export interface TransformResult {
100
110
  /** The messages to send; the caller writes them into OpenCode's array in place. */
101
111
  messages: OcMessage[];
@@ -104,8 +114,10 @@ export interface TransformResult {
104
114
  /** Calibrated estimate of this request, tokens (system prompt and tool schemas included when known); undefined when CLM did not run. */
105
115
  estimated?: number;
106
116
  reading?: BudgetReading;
107
- /** 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. */
108
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[];
109
121
  }
110
122
 
111
123
  export interface MirrorCheck {
@@ -196,6 +208,23 @@ export class ClmSession {
196
208
  * `experimental.compaction.autocontinue` hook); the next transform rebases onto the summary.
197
209
  */
198
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;
199
228
  /** What the last transform rendered; the next transform commits the mirror against it. */
200
229
  baseline?: Baseline;
201
230
  /** The snapshot now in the mirror file; continuity tools resolve block ids against it. */
@@ -205,9 +234,21 @@ export class ClmSession {
205
234
  requests = 0;
206
235
 
207
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;
208
243
  /** Raw length and conversation estimate (calibrated, tokens) of the last request, for the overhead measurement. */
209
244
  private previousRequest?: { rawCount: number; conversation: number };
210
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;
211
252
  /**
212
253
  * Per-process seed of the stable document id. Fresh on every open, so a document id never
213
254
  * repeats even when a corrupt state file resets the revision counter to 0.
@@ -244,11 +285,14 @@ export class ClmSession {
244
285
 
245
286
  /**
246
287
  * Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. `steering` is the
247
- * document the base settings name, already loaded (the plugin loads it strictly at start).
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`).
248
290
  */
249
291
  static async open(sessionID: string, settings: ClmSettings, options: { steering?: SteeringDocument } = {}): Promise<ClmSession> {
250
292
  if (!SESSION_ID_RE.test(sessionID)) throw new Error(`Invalid session id for CLM: ${JSON.stringify(sessionID)}`);
251
- 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
+ });
252
296
  const loaded = await loadLiveContextState(store.directory);
253
297
  const session = new ClmSession(sessionID, store, settings, loaded, options.steering);
254
298
  if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
@@ -309,11 +353,25 @@ export class ClmSession {
309
353
  } catch (error) {
310
354
  warnings.push(`ignored saved settings: ${describe(error)}`);
311
355
  overrides = {};
312
- staged = { settings: this.baseSettings, ...(this.baseSteering ? { steering: this.baseSteering } : {}) };
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 });
313
358
  }
314
- if (staged.steeringError) warnings.push(`steering document not loaded: ${staged.steeringError}`);
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;
315
368
  await this.activateSettings(staged, overrides);
316
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;
317
375
  }
318
376
 
319
377
  private async activateSettings(staged: StagedSettings, overrides: ClmOverrides): Promise<void> {
@@ -341,6 +399,34 @@ export class ClmSession {
341
399
 
342
400
  // ---- measurement -------------------------------------------------------------------
343
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
+
344
430
  /** Uncalibrated tokens of a text: chars/4 × estimateFactor. */
345
431
  textTokens(text: string): number {
346
432
  return Math.ceil((text.length / CHARS_PER_TOKEN) * this.settings.estimateFactor);
@@ -520,6 +606,9 @@ export class ClmSession {
520
606
  this.baseline = undefined;
521
607
  this.tracker.reset();
522
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));
523
612
  await this.log({ event: "reset", revision: next.revision, reason });
524
613
  }
525
614
 
@@ -570,6 +659,34 @@ export class ClmSession {
570
659
  : `[CLM] Mirror edit would be refused: ${check.message} Correct the file before this step ends, or the context stays as it is.`;
571
660
  }
572
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
+
573
690
  /** The mirror block the model last saw, for continuity annotations. */
574
691
  blockSource(blockId: string): ContinuityBlockSource | undefined {
575
692
  const snapshot = this.lastSnapshot;
@@ -587,6 +704,7 @@ export class ClmSession {
587
704
  text = await this.store.read();
588
705
  } catch (error) {
589
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)}`);
590
708
  return;
591
709
  }
592
710
  if (text === undefined || text.trim() === baseline.snapshot.text.trim()) return;
@@ -639,9 +757,11 @@ export class ClmSession {
639
757
  } catch (error) {
640
758
  // Persist before activate: an unsaved revision would vanish on restart.
641
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)}`);
642
761
  return;
643
762
  }
644
763
  this.accepted += 1;
764
+ await this.remember({ version: 1, checkpoint, contentDigest: digestSourceContent(baseline.rawMessages) });
645
765
  const revisions = join(this.store.directory, "revisions");
646
766
  await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
647
767
  await writeFile(join(revisions, `r${revision}.md`), text, { mode: 0o600 }).catch(() => undefined);
@@ -746,6 +866,7 @@ export class ClmSession {
746
866
  budget: { budget: base.budget, reserve: base.reserve, limit: overflowGuardLimit(resolved.budget, resolved.reserve), source: base.source },
747
867
  calibration: { factor: this.calibrator.factor, samples: this.calibrator.sampleCount },
748
868
  ...(steering ? { steering: { name: steering.name, hash: steering.hash, path: steering.path } } : {}),
869
+ ...(this.steeringError ? { steeringError: this.steeringError } : {}),
749
870
  sizes: { estimated: input.estimated, ...(input.observed !== undefined ? { observedPrevious: input.observed } : {}) },
750
871
  input: {
751
872
  raw: input.raw,
@@ -812,6 +933,172 @@ export class ClmSession {
812
933
  await this.log({ event: "compacted", revision: next.revision, previous, summary: summaryId, written });
813
934
  }
814
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
+
815
1102
  private async loadAnnotations(): Promise<LiveContextAnnotation[]> {
816
1103
  try {
817
1104
  return await this.annotations.list();
@@ -821,13 +1108,46 @@ export class ClmSession {
821
1108
  }
822
1109
  }
823
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
+
824
1143
  private async transformNow(raw: OcMessage[]): Promise<TransformResult> {
825
1144
  await this.refreshSettings();
826
1145
  if (!this.settings.enabled || !this.enabled) {
827
1146
  this.baseline = undefined;
828
1147
  this.compacting = false;
829
- return { messages: raw, notices: [] };
1148
+ return { messages: raw, notices: [], ...this.drainAlerts() };
830
1149
  }
1150
+ if (this.mirrorUnavailable !== undefined) return await this.transformWithoutMirror(raw);
831
1151
  const pinned = pinnedCount(raw);
832
1152
  const pinnedMessages = raw.slice(0, pinned);
833
1153
  const context = this.context(raw);
@@ -835,11 +1155,12 @@ export class ClmSession {
835
1155
  // OpenCode's compaction passes a head slice of the history through this hook. Apply
836
1156
  // the accepted revision when it still covers that slice; never commit, render,
837
1157
  // notify or reset (the slice is shorter than the history the checkpoint covers).
1158
+ // A pending edit was committed by `commitBeforeCompaction` just before.
838
1159
  if (this.compacting) {
839
1160
  this.compacting = false;
840
1161
  const projection = applyProjection(flatten(raw.slice(pinned)), this.state.checkpoint);
841
- if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [] };
842
- 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() };
843
1164
  }
844
1165
 
845
1166
  await this.commit();
@@ -867,6 +1188,10 @@ export class ClmSession {
867
1188
  if (rebase) {
868
1189
  await this.rebaseAfterCompaction(summaryId);
869
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);
870
1195
  } else if (!projection.valid) {
871
1196
  const dropped = this.state.checkpoint?.revision;
872
1197
  this.invalidationStreak += 1;
@@ -875,6 +1200,10 @@ export class ClmSession {
875
1200
  let notice = `[CLM] Revision ${dropped} was dropped because OpenCode's history changed under it: ${projection.reason} The mirror now shows the stored history.`;
876
1201
  if (this.invalidationStreak >= 2) {
877
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
+ }
878
1207
  }
879
1208
  this.pendingNotices.push(notice);
880
1209
  await this.log({ event: "projection-reset", revision: dropped, reason: projection.reason });
@@ -882,6 +1211,11 @@ export class ClmSession {
882
1211
  } else if (this.state.checkpoint) {
883
1212
  this.invalidationStreak = 0;
884
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
+ }
885
1219
  const checkpoint = this.state.checkpoint;
886
1220
  const suffixLength = projection.valid ? projection.suffix.length : 0;
887
1221
 
@@ -889,21 +1223,15 @@ export class ClmSession {
889
1223
  if (!this.settings.reasoning) effective = withoutReasoning(effective);
890
1224
  effective = capObservations(effective, this.settings.observationCap);
891
1225
 
1226
+ if (origin) await this.copyForkAnnotations(origin, raw, effective);
892
1227
  const annotations = await this.loadAnnotations();
893
1228
  const continuityText = (messages: LiveContextMessage[]) =>
894
1229
  formatContinuityMessage({ annotations, effectiveMessages: messages });
895
1230
  const pinnedTokens = this.estimate(flatten(pinnedMessages));
896
1231
  const resolved = this.resolvedBudget();
897
1232
  const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
898
- // System prompt and tool schemas, when the hooks reported them (one request late).
899
- // A provider-measured overhead replaces them: it is in real tokens, so it is not
900
- // calibrated, and it includes the built-in tool schemas the hooks cannot size. The
901
- // hook sizes stay a floor, since they count text that is certainly sent.
902
1233
  const { systemTokens, toolTokens } = this.scope;
903
- const measuredOverhead = this.state.budgetCheck?.source === "provider"
904
- ? Math.max(this.state.budgetCheck.overhead, (systemTokens ?? 0) + (toolTokens ?? 0))
905
- : undefined;
906
- const scopeTokens = measuredOverhead ?? this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0));
1234
+ const { measuredOverhead, scopeTokens } = this.scopeSize();
907
1235
  const excluded = measuredOverhead !== undefined ? [] : [
908
1236
  ...(systemTokens === undefined ? ["the system prompt"] : []),
909
1237
  ...(toolTokens === undefined ? ["tool schemas"] : []),
@@ -949,6 +1277,7 @@ export class ClmSession {
949
1277
  });
950
1278
  try {
951
1279
  await this.store.write(snapshot.text);
1280
+ this.refreshFailing = false;
952
1281
  this.lastSnapshot = snapshot;
953
1282
  this.baseline = {
954
1283
  rawMessages: source,
@@ -961,6 +1290,8 @@ export class ClmSession {
961
1290
  } catch (error) {
962
1291
  this.baseline = undefined;
963
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;
964
1295
  }
965
1296
 
966
1297
  const conversationTokens = pinnedTokens + this.estimate(effective) + continuityTokens;
@@ -1043,7 +1374,25 @@ export class ClmSession {
1043
1374
  calibration: this.calibrator.factor,
1044
1375
  notices: notices.map((notice) => notice.slice(0, 80)),
1045
1376
  });
1046
- 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
+ };
1047
1396
  }
1048
1397
 
1049
1398
  /** Inputs for presentation.ts `statusText` / `statusLine`. */
@@ -1055,7 +1404,7 @@ export class ClmSession {
1055
1404
  ...(changed ? { changed } : {}),
1056
1405
  ...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
1057
1406
  sessionID: this.sessionID,
1058
- mirrorPath: this.mirrorPath,
1407
+ mirrorPath: this.mirrorUnavailable === undefined ? this.mirrorPath : `unavailable, requests carry the raw history (${this.mirrorUnavailable})`,
1059
1408
  revision: this.state.revision,
1060
1409
  accepted: this.accepted,
1061
1410
  rejected: this.rejected,