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/clm.ts CHANGED
@@ -7,6 +7,12 @@
7
7
  * during step k is read back and committed at the start of step k+1's transform, right
8
8
  * before the request it affects. As in pi-clm, the file content at that moment wins.
9
9
  *
10
+ * `mode notices-only` (`noticesOnly`) skips steps 1, 2 and 4: the accepted revision, if any,
11
+ * stays in state.json but is not applied, nothing is rendered or committed, and the request
12
+ * carries the raw history (reasoning view, cap and guard still apply) with every notice.
13
+ * Switching back to `edit` applies the kept revision again while it still matches the
14
+ * history; a mismatch then takes the usual restore-or-reset path.
15
+ *
10
16
  * transform(raw) 0. pinned = everything up to and including the first user message;
11
17
  * source = flatten(rest), the raw prefix a checkpoint digests
12
18
  * 1. commit: read the mirror; if it differs from the last render,
@@ -54,17 +60,20 @@ import {
54
60
  AnnotationStore,
55
61
  ContinuitySizeTracker,
56
62
  continuitySizeNoticeText,
63
+ forkAnnotations,
57
64
  formatContinuityMessage,
58
65
  type ContinuityBlockSource,
59
66
  type LiveContextAnnotation,
60
67
  } from "./continuity.ts";
61
68
  import { classifyMirrorToolCall } from "./mirror-guard.ts";
62
- import { MirrorStore } from "./mirror-store.ts";
69
+ import { MirrorDirectoryError, MirrorStore } from "./mirror-store.ts";
63
70
  import { capObservations } from "./observation.ts";
64
71
  import { flatten, noteMessage, unflatten, withoutReasoning, type OcInfo, type OcMessage } from "./opencode.ts";
72
+ import { sizeTrailer } from "./compaction.ts";
65
73
  import { applyOverflowGuard, overflowGuardLimit, overflowNoticeText } from "./overflow.ts";
66
74
  import type { ClmStatus } from "./presentation.ts";
67
- import { applyProjection, createProjectionCheckpoint, type ProjectionCheckpoint } from "./projection.ts";
75
+ import { applyProjection, createProjectionCheckpoint, digestSourceContent, type ProjectionCheckpoint } from "./projection.ts";
76
+ import { clearHistory, loadHistory, remapProjection, saveHistoryEntry, type HistoryEntry } from "./history.ts";
68
77
  import type { ClmSettings } from "./settings.ts";
69
78
  import {
70
79
  keptFields,
@@ -86,6 +95,8 @@ export interface ModelLimits {
86
95
  context?: number;
87
96
  /** Model output limit, tokens; subtracted from the window by `resolveBudget`. */
88
97
  output?: number;
98
+ /** Model input limit, tokens, when the provider sets one; only OpenCode's threshold reads it (src/compaction.ts). */
99
+ input?: number;
89
100
  }
90
101
 
91
102
  /** Sizes of the request parts the plugin does not see in the messages hook. */
@@ -96,6 +107,11 @@ export interface RequestScope {
96
107
  toolTokens?: number;
97
108
  }
98
109
 
110
+ /** Toast when edits keep being dropped (pi `compositionWarningText`, reworded for OpenCode). */
111
+ export const COMPOSITION_WARNING = "CLM edits keep being dropped: OpenCode's history changed between consecutive requests. " +
112
+ "Another plugin that rewrites already-sent messages (experimental.chat.messages.transform) is the usual cause; " +
113
+ "load opencode-clm before it (earlier in the `plugin` list), or turn CLM off for this session with /clm off.";
114
+
99
115
  export interface TransformResult {
100
116
  /** The messages to send; the caller writes them into OpenCode's array in place. */
101
117
  messages: OcMessage[];
@@ -104,8 +120,10 @@ export interface TransformResult {
104
120
  /** Calibrated estimate of this request, tokens (system prompt and tool schemas included when known); undefined when CLM did not run. */
105
121
  estimated?: number;
106
122
  reading?: BudgetReading;
107
- /** User-facing warning for a toast (the budget-too-small check); set on one request only. */
123
+ /** User-facing warning for a toast (budget too small, composition warning, ignored saved settings); set on one request only. */
108
124
  alert?: string;
125
+ /** User-facing errors for toasts: the mirror could not be read or refreshed, a revision could not be saved. */
126
+ errors?: string[];
109
127
  }
110
128
 
111
129
  export interface MirrorCheck {
@@ -188,6 +206,12 @@ export class ClmSession {
188
206
  accepted = 0;
189
207
  rejected = 0;
190
208
  limits: ModelLimits = {};
209
+ /**
210
+ * Set once the system transform has seen the model, with or without a window. Until then
211
+ * a fallback budget is provisional: the guard, fit gate, reminders and trailer stand down.
212
+ * A model that reports no window keeps the fallback, which then applies in full.
213
+ */
214
+ limitsKnown = false;
191
215
  scope: RequestScope = {};
192
216
  /** Set by `experimental.session.compacting`: the next transform carries OpenCode's compaction input. */
193
217
  compacting = false;
@@ -196,6 +220,23 @@ export class ClmSession {
196
220
  * `experimental.compaction.autocontinue` hook); the next transform rebases onto the summary.
197
221
  */
198
222
  compacted = false;
223
+ /**
224
+ * Set by the plugin when OpenCode's session metadata names another session as the origin
225
+ * (a fork, `Session.fork` copies metadata). The first transform restores the newest of the
226
+ * origin's checkpoints whose source the forked history still contains, then clears it.
227
+ */
228
+ forkOrigin?: { sessionID: string; directory: string; /** The fork's creation time (ms), when OpenCode reported it. */ created?: number };
229
+ /** The origin's annotations from cloned session metadata (a fork without a mirror); used when the origin's store yields none. */
230
+ forkPersisted?: LiveContextAnnotation[];
231
+ /** Set when a fork's first request copied annotations in; the plugin then persists them (no mirror). */
232
+ annotationsImported = false;
233
+ /**
234
+ * Why no mirror directory could be used, set by the plugin (M1). The store then sits in a
235
+ * private temporary directory the model is never told about; requests carry the raw
236
+ * history plus the continuity annotations and their size notice, as pi-clm does when its
237
+ * mirror store fails to initialize.
238
+ */
239
+ mirrorUnavailable?: string;
199
240
  /** What the last transform rendered; the next transform commits the mirror against it. */
200
241
  baseline?: Baseline;
201
242
  /** The snapshot now in the mirror file; continuity tools resolve block ids against it. */
@@ -205,9 +246,21 @@ export class ClmSession {
205
246
  requests = 0;
206
247
 
207
248
  private pendingNotices: string[] = [];
249
+ /** Errors for the user (toasts), returned with the next transform result. */
250
+ private pendingErrors: string[] = [];
251
+ /** The mirror could not be written last time; its error toast is not repeated until a write succeeds. */
252
+ private refreshFailing = false;
253
+ /** Why the steering document in the settings did not load, for snapshot.json (the panel). */
254
+ steeringError?: string;
208
255
  /** Raw length and conversation estimate (calibrated, tokens) of the last request, for the overhead measurement. */
209
256
  private previousRequest?: { rawCount: number; conversation: number };
210
257
  private invalidationStreak = 0;
258
+ /** The composition warning toast is shown once per session (pi: `compositionWarned`). */
259
+ private compositionWarned = false;
260
+ /** User-facing warnings for the next transform result (`alert`). */
261
+ private pendingAlerts: string[] = [];
262
+ /** The settings warning last shown to the user, so a new or changed one toasts once. */
263
+ private shownSettingsWarning?: string;
211
264
  /**
212
265
  * Per-process seed of the stable document id. Fresh on every open, so a document id never
213
266
  * repeats even when a corrupt state file resets the revision counter to 0.
@@ -244,11 +297,14 @@ export class ClmSession {
244
297
 
245
298
  /**
246
299
  * 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).
300
+ * document the base settings name, already loaded; undefined when it failed to load at start
301
+ * (each session then retries the path and records the error in `settingsWarning`).
248
302
  */
249
303
  static async open(sessionID: string, settings: ClmSettings, options: { steering?: SteeringDocument } = {}): Promise<ClmSession> {
250
304
  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);
305
+ const store = await MirrorStore.create(sessionID, settings.mirrorDir).catch((error: unknown) => {
306
+ throw new MirrorDirectoryError(settings.mirrorDir, error);
307
+ });
252
308
  const loaded = await loadLiveContextState(store.directory);
253
309
  const session = new ClmSession(sessionID, store, settings, loaded, options.steering);
254
310
  if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
@@ -262,6 +318,11 @@ export class ClmSession {
262
318
  return this.overrides.editing ?? this.state.enabled;
263
319
  }
264
320
 
321
+ /** `mode notices-only`: notices and the guard run, the model cannot edit its context. */
322
+ get noticesOnly(): boolean {
323
+ return this.settings.mode === "notices-only";
324
+ }
325
+
265
326
  /** Base and effective values, for the settings table. */
266
327
  settingsValues(): { base: SettingsValues; effective: SettingsValues } {
267
328
  return {
@@ -277,7 +338,7 @@ export class ClmSession {
277
338
  base: this.baseSettings,
278
339
  baseEditing: this.state.enabled,
279
340
  projectDirectory,
280
- format: { modelWindow: this.limits.context },
341
+ format: { modelWindow: this.limits.context, modelOutput: this.limits.output },
281
342
  };
282
343
  }
283
344
 
@@ -309,29 +370,51 @@ export class ClmSession {
309
370
  } catch (error) {
310
371
  warnings.push(`ignored saved settings: ${describe(error)}`);
311
372
  overrides = {};
312
- staged = { settings: this.baseSettings, ...(this.baseSteering ? { steering: this.baseSteering } : {}) };
373
+ // The base settings resolved at load; staging them again keeps a base steering error.
374
+ staged = stageSettings(this.baseSettings, {}, { strict: false, loaded: this.baseSteering });
375
+ }
376
+ // Only warnings about saved settings toast here. The base steering document failing
377
+ // again (no override names another path) already toasted once at load (index.ts).
378
+ const alerting = [...warnings];
379
+ if (staged.steeringError) {
380
+ const warning = `steering document not loaded: ${staged.steeringError}`;
381
+ warnings.push(warning);
382
+ if (staged.settings.steeringPath !== this.baseSettings.steeringPath) alerting.push(warning);
313
383
  }
314
- if (staged.steeringError) warnings.push(`steering document not loaded: ${staged.steeringError}`);
384
+ this.steeringError = staged.steeringError;
315
385
  await this.activateSettings(staged, overrides);
316
386
  this.settingsWarning = warnings.length > 0 ? warnings.join("; ") : undefined;
387
+ const alert = alerting.length > 0 ? alerting.join("; ") : undefined;
388
+ if (alert && alert !== this.shownSettingsWarning) {
389
+ this.pendingAlerts.push(`saved settings partly ignored: ${alert}`);
390
+ }
391
+ this.shownSettingsWarning = alert;
317
392
  }
318
393
 
319
394
  private async activateSettings(staged: StagedSettings, overrides: ClmOverrides): Promise<void> {
320
395
  const before = this.settings.budget;
321
396
  const after = staged.settings.budget;
322
397
  const wasEnabled = this.enabled;
398
+ const wasMode = this.settings.mode;
323
399
  const changed = JSON.stringify(this.overrides) !== JSON.stringify(overrides);
324
400
  this.settings = staged.settings;
325
401
  this.steering = staged.steering;
326
402
  this.overrides = overrides;
327
- if (before.contextBudget !== after.contextBudget || before.reserve !== after.reserve ||
403
+ if (before.contextBudget !== after.contextBudget || before.contextFraction !== after.contextFraction || before.reserve !== after.reserve ||
328
404
  before.remindAtFractions.join(",") !== after.remindAtFractions.join(",") || before.remindAtReserve !== after.remindAtReserve) {
329
405
  this.tracker.reset();
330
406
  }
331
407
  if (wasEnabled !== this.enabled) {
332
408
  this.baseline = undefined;
409
+ this.tracker.clearCooldown();
333
410
  if (!this.enabled) this.continuitySize.observe(0);
334
411
  }
412
+ if (wasMode !== this.settings.mode) {
413
+ // The render the baseline holds no longer describes what the model is sent.
414
+ this.baseline = undefined;
415
+ this.lastSnapshot = undefined;
416
+ this.tracker.clearCooldown();
417
+ }
335
418
  if (changed) await this.log({ event: "settings", overrides });
336
419
  }
337
420
 
@@ -341,6 +424,34 @@ export class ClmSession {
341
424
 
342
425
  // ---- measurement -------------------------------------------------------------------
343
426
 
427
+ /**
428
+ * System prompt and tool schemas, when the hooks reported them (one request late).
429
+ * A provider-measured overhead replaces them: it is in real tokens, so it is not
430
+ * calibrated, and it includes the built-in tool schemas the hooks cannot size. The
431
+ * hook sizes stay a floor, since they count text that is certainly sent.
432
+ */
433
+ private scopeSize(): { measuredOverhead?: number; scopeTokens: number } {
434
+ const { systemTokens, toolTokens } = this.scope;
435
+ const measuredOverhead = this.state.budgetCheck?.source === "provider"
436
+ ? Math.max(this.state.budgetCheck.overhead, (systemTokens ?? 0) + (toolTokens ?? 0))
437
+ : undefined;
438
+ return { ...(measuredOverhead !== undefined ? { measuredOverhead } : {}), scopeTokens: measuredOverhead ?? this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0)) };
439
+ }
440
+
441
+ /**
442
+ * `/clm-compact`: about how large the next request would be, from the stored history
443
+ * (as OpenCode sends it, see `filterCompacted`) through the accepted revision (continuity and notices not
444
+ * counted). Undefined when the history holds no message to compact.
445
+ */
446
+ idleEstimate(raw: OcMessage[]): number | undefined {
447
+ const pinned = pinnedCount(raw);
448
+ const source = flatten(raw.slice(pinned));
449
+ if (source.length === 0) return undefined;
450
+ const projection = applyProjection(source, this.state.checkpoint);
451
+ const effective = projection.valid ? projection.messages : source;
452
+ return this.scopeSize().scopeTokens + this.estimate(flatten(raw.slice(0, pinned))) + this.estimate(effective);
453
+ }
454
+
344
455
  /** Uncalibrated tokens of a text: chars/4 × estimateFactor. */
345
456
  textTokens(text: string): number {
346
457
  return Math.ceil((text.length / CHARS_PER_TOKEN) * this.settings.estimateFactor);
@@ -368,10 +479,10 @@ export class ClmSession {
368
479
  }
369
480
 
370
481
  /**
371
- * The budget in force: the configured one (or the model window), raised when the
482
+ * The budget in force: the resolved one (`resolveBudget`), raised when the
372
483
  * session's budget check found it too small for OpenCode's fixed overhead (`budgetFit`).
373
484
  */
374
- resolvedBudget(): Pick<BudgetReading, "budget" | "reserve" | "source" | "raisedFrom"> | undefined {
485
+ resolvedBudget(): Pick<BudgetReading, "budget" | "reserve" | "source" | "fraction" | "raisedFrom"> | undefined {
375
486
  const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
376
487
  if (!base) return undefined;
377
488
  const fit = budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap());
@@ -380,7 +491,7 @@ export class ClmSession {
380
491
 
381
492
  /** Model window minus its output limit, when known: the ceiling for a raised budget. */
382
493
  private windowCap(): number | undefined {
383
- return resolveBudget({ ...this.settings.budget, contextBudget: undefined }, this.limits.context, this.limits.output)?.budget;
494
+ return resolveBudget({ ...this.settings.budget, contextBudget: undefined, contextFraction: undefined }, this.limits.context, this.limits.output)?.budget;
384
495
  }
385
496
 
386
497
  /**
@@ -392,10 +503,19 @@ export class ClmSession {
392
503
  return base ? budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap()) : undefined;
393
504
  }
394
505
 
395
- /** budget − reserve, or undefined while the budget is unknown. */
506
+ /**
507
+ * budget − reserve: the limit the overflow guard enforces. Undefined while the budget is
508
+ * unknown or a fallback (`FALLBACK_BUDGET`, model window not yet known): the guard then
509
+ * withholds nothing.
510
+ */
511
+ /** A fallback budget read before the model's limits arrived (see `limitsKnown`). */
512
+ private provisional(resolved: Pick<BudgetReading, "source">): boolean {
513
+ return resolved.source === "fallback" && !this.limitsKnown;
514
+ }
515
+
396
516
  guardLimit(): number | undefined {
397
517
  const resolved = this.resolvedBudget();
398
- return resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
518
+ return resolved && !this.provisional(resolved) ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
399
519
  }
400
520
 
401
521
  /**
@@ -469,7 +589,7 @@ export class ClmSession {
469
589
  ...(previous ? { replaces: previous.source, previousOverhead: previous.overhead } : {}),
470
590
  });
471
591
  if (!warn) return undefined;
472
- this.pendingNotices.push(budgetTooSmallNoticeText(fit));
592
+ this.pendingNotices.push(budgetTooSmallNoticeText(fit, { noticesOnly: this.noticesOnly }));
473
593
  return budgetTooSmallAlertText(fit);
474
594
  }
475
595
 
@@ -511,6 +631,7 @@ export class ClmSession {
511
631
  async setEnabled(enabled: boolean): Promise<void> {
512
632
  await this.updateState((state) => ({ ...state, enabled }));
513
633
  this.baseline = undefined;
634
+ this.tracker.clearCooldown();
514
635
  if (!enabled) this.continuitySize.observe(0);
515
636
  }
516
637
 
@@ -520,6 +641,9 @@ export class ClmSession {
520
641
  this.baseline = undefined;
521
642
  this.tracker.reset();
522
643
  this.continuitySize.reset();
644
+ // The user dropped the edits on purpose: a later revert must not bring one back. Queued
645
+ // behind any history write in flight; writes queued later see no checkpoint and skip.
646
+ await this.historyTask(() => clearHistory(this.store.directory));
523
647
  await this.log({ event: "reset", revision: next.revision, reason });
524
648
  }
525
649
 
@@ -560,7 +684,7 @@ export class ClmSession {
560
684
  * calls that only read it or do not touch it.
561
685
  */
562
686
  receipt(tool: string, args: Record<string, unknown> | undefined, cwd: string): string | undefined {
563
- if (!this.settings.enabled || !this.enabled) return undefined;
687
+ if (!this.settings.enabled || !this.enabled || this.noticesOnly) return undefined;
564
688
  if (classifyMirrorToolCall(tool, args ?? {}, cwd, this.mirrorPath) !== "write") return undefined;
565
689
  const check = this.validateMirror(this.store.readSync());
566
690
  if (!check) return undefined;
@@ -570,8 +694,39 @@ export class ClmSession {
570
694
  : `[CLM] Mirror edit would be refused: ${check.message} Correct the file before this step ends, or the context stays as it is.`;
571
695
  }
572
696
 
697
+ /**
698
+ * `setting trailer`: the size trailer for one tool result (the last reading plus the
699
+ * result's calibrated estimate, against the budget in force); undefined when off or while
700
+ * the budget is unknown or a provisional fallback.
701
+ */
702
+ sizeTrailer(output: string): string | undefined {
703
+ if (!this.settings.enabled || !this.enabled || !this.settings.trailer || this.mirrorUnavailable !== undefined) return undefined;
704
+ const resolved = this.resolvedBudget();
705
+ // A fallback budget is a placeholder: "~N of 32,000" would report a limit nothing enforces.
706
+ if (!resolved || this.provisional(resolved)) return undefined;
707
+ return sizeTrailer(this.lastReading?.estimated ?? 0, this.calibrator.apply(this.textTokens(output)), resolved.budget);
708
+ }
709
+
710
+ /**
711
+ * Before an OpenCode compaction (`experimental.session.compacting`): commit the edit the
712
+ * model made in its final step. No request followed that step (e.g. the user typed
713
+ * /compact next), so the commit at the start of a transform has not run; without this the
714
+ * compaction would summarize the pre-edit context and the edit would be lost.
715
+ */
716
+ async commitBeforeCompaction(): Promise<void> {
717
+ await this.refreshSettings();
718
+ if (!this.settings.enabled || !this.enabled || this.noticesOnly) return;
719
+ await this.commit();
720
+ }
721
+
722
+ /** Adds a notice to the next request (e.g. from a bus event). */
723
+ queueNotice(text: string): void {
724
+ this.pendingNotices.push(text);
725
+ }
726
+
573
727
  /** The mirror block the model last saw, for continuity annotations. */
574
728
  blockSource(blockId: string): ContinuityBlockSource | undefined {
729
+ if (this.noticesOnly) throw new Error("CLM runs in notices-only mode: there is no context mirror, so no block can be annotated.");
575
730
  const snapshot = this.lastSnapshot;
576
731
  const block = snapshot?.blocks.find((candidate) => candidate.id === blockId);
577
732
  return snapshot && block ? { message: block.source, revision: snapshot.revision } : undefined;
@@ -587,6 +742,7 @@ export class ClmSession {
587
742
  text = await this.store.read();
588
743
  } catch (error) {
589
744
  this.pendingNotices.push(`[CLM] Could not read the mirror: ${describe(error)}`);
745
+ this.pendingErrors.push(`could not read the mirror, the edit was not applied: ${describe(error)}`);
590
746
  return;
591
747
  }
592
748
  if (text === undefined || text.trim() === baseline.snapshot.text.trim()) return;
@@ -639,9 +795,12 @@ export class ClmSession {
639
795
  } catch (error) {
640
796
  // Persist before activate: an unsaved revision would vanish on restart.
641
797
  this.pendingNotices.push(`[CLM] Revision ${revision} was not applied: it could not be saved (${describe(error)}); the previous context stays in effect.`);
798
+ this.pendingErrors.push(`revision ${revision} could not be saved, the previous context stays in effect: ${describe(error)}`);
642
799
  return;
643
800
  }
644
801
  this.accepted += 1;
802
+ this.tracker.editAccepted();
803
+ await this.remember({ version: 1, checkpoint, contentDigest: digestSourceContent(baseline.rawMessages) });
645
804
  const revisions = join(this.store.directory, "revisions");
646
805
  await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
647
806
  await writeFile(join(revisions, `r${revision}.md`), text, { mode: 0o600 }).catch(() => undefined);
@@ -746,6 +905,7 @@ export class ClmSession {
746
905
  budget: { budget: base.budget, reserve: base.reserve, limit: overflowGuardLimit(resolved.budget, resolved.reserve), source: base.source },
747
906
  calibration: { factor: this.calibrator.factor, samples: this.calibrator.sampleCount },
748
907
  ...(steering ? { steering: { name: steering.name, hash: steering.hash, path: steering.path } } : {}),
908
+ ...(this.steeringError ? { steeringError: this.steeringError } : {}),
749
909
  sizes: { estimated: input.estimated, ...(input.observed !== undefined ? { observedPrevious: input.observed } : {}) },
750
910
  input: {
751
911
  raw: input.raw,
@@ -812,6 +972,172 @@ export class ClmSession {
812
972
  await this.log({ event: "compacted", revision: next.revision, previous, summary: summaryId, written });
813
973
  }
814
974
 
975
+ // ---- checkpoint history (revert and fork) ------------------------------------------
976
+
977
+ /** History writes and clears run one at a time (`/clm reset` runs outside the transform queue). */
978
+ private historyQueue: Promise<void> = Promise.resolve();
979
+
980
+ private historyTask(task: () => Promise<void>): Promise<void> {
981
+ const run = this.historyQueue.then(task).catch(() => undefined);
982
+ this.historyQueue = run;
983
+ return run;
984
+ }
985
+
986
+ /**
987
+ * Add an accepted checkpoint to `checkpoints/`. Best effort: the revision is already saved.
988
+ * Skipped when the checkpoint is no longer the active one by the time the write runs (a
989
+ * `/clm reset` came in between), so a reset edit never lands in the history.
990
+ */
991
+ private remember(entry: HistoryEntry): Promise<void> {
992
+ return this.historyTask(async () => {
993
+ if (this.state.checkpoint?.revision !== entry.checkpoint.revision) return;
994
+ await saveHistoryEntry(this.store.directory, entry)
995
+ .catch((error: unknown) => this.log({ event: "history-error", revision: entry.checkpoint.revision, error: describe(error) }));
996
+ });
997
+ }
998
+
999
+ /**
1000
+ * Make `entry`'s projection, with `projected` standing for `source`, the next revision.
1001
+ * Persisted before it takes effect, like an accepted edit.
1002
+ */
1003
+ private async activateRestored(
1004
+ entry: HistoryEntry,
1005
+ source: readonly LiveContextMessage[],
1006
+ projected: readonly LiveContextMessage[],
1007
+ describeRestore: (revision: number) => string,
1008
+ ): Promise<boolean> {
1009
+ const at = new Date().toISOString();
1010
+ const old = entry.checkpoint;
1011
+ const revision = this.state.revision + 1;
1012
+ let checkpoint: ProjectionCheckpoint;
1013
+ try {
1014
+ checkpoint = createProjectionCheckpoint({
1015
+ revision,
1016
+ sourceMessages: source,
1017
+ projectedMessages: projected,
1018
+ beforeEstimate: old.beforeEstimate,
1019
+ afterEstimate: old.afterEstimate,
1020
+ estimateUnit: old.estimateUnit,
1021
+ createdAt: at,
1022
+ ...(old.editTrace ? { editTrace: old.editTrace } : {}),
1023
+ });
1024
+ } catch {
1025
+ return false;
1026
+ }
1027
+ const message = describeRestore(revision);
1028
+ try {
1029
+ await this.updateState((state) => {
1030
+ if (state.revision + 1 !== revision) throw new Error("the session state changed while the revision was restored");
1031
+ return {
1032
+ version: 1,
1033
+ enabled: state.enabled,
1034
+ revision,
1035
+ checkpoint,
1036
+ lastOutcome: { kind: "applied", message, beforeEstimate: old.beforeEstimate, afterEstimate: old.afterEstimate, estimateUnit: old.estimateUnit, at },
1037
+ ...keptFields(state),
1038
+ };
1039
+ });
1040
+ } catch {
1041
+ return false;
1042
+ }
1043
+ this.tracker.reset();
1044
+ await this.remember({ version: 1, checkpoint, contentDigest: entry.contentDigest });
1045
+ this.pendingNotices.push(`[CLM] ${message} The mirror shows it.`);
1046
+ return true;
1047
+ }
1048
+
1049
+ /**
1050
+ * The active revision no longer fits the history (a revert cut into its prefix): restore
1051
+ * the newest older checkpoint that still fits, as pi-clm restores the checkpoint of the
1052
+ * branch `/tree` selects. False when none fits; the caller then resets.
1053
+ */
1054
+ private async restoreAfterMismatch(source: readonly LiveContextMessage[], reason: string): Promise<boolean> {
1055
+ const dropped = this.state.checkpoint?.revision;
1056
+ const { entries, ignored } = await loadHistory(this.store.directory);
1057
+ if (ignored.length > 0) await this.log({ event: "history-ignored", files: ignored });
1058
+ for (const entry of entries) {
1059
+ if (entry.checkpoint.revision === dropped) continue;
1060
+ const candidate = applyProjection(source, entry.checkpoint);
1061
+ if (!candidate.valid) continue;
1062
+ const from = entry.checkpoint.revision;
1063
+ const prefix = source.slice(0, entry.checkpoint.sourceMessageCount);
1064
+ const restored = await this.activateRestored(entry, prefix, entry.checkpoint.projectedMessages, (revision) =>
1065
+ `Revision ${dropped} no longer matches OpenCode's history: ${reason.replace(/\.\s*$/, "")}; restored revision ${from}, which still does, as revision ${revision}.`);
1066
+ if (!restored) return false;
1067
+ await this.log({ event: "restored", revision: this.state.revision, from, dropped, reason });
1068
+ return true;
1069
+ }
1070
+ return false;
1071
+ }
1072
+
1073
+ /**
1074
+ * First request of a forked session: find the newest origin checkpoint whose source the
1075
+ * fork's history starts with (compared without message ids, which the fork renews) and
1076
+ * restore it with the ids mapped to the fork's.
1077
+ */
1078
+ private async restoreFromFork(origin: { sessionID: string; directory: string }, source: readonly LiveContextMessage[]): Promise<boolean> {
1079
+ const { entries, ignored } = await loadHistory(origin.directory);
1080
+ if (ignored.length > 0) await this.log({ event: "history-ignored", origin: origin.sessionID, files: ignored });
1081
+ for (const entry of entries) {
1082
+ const count = entry.checkpoint.sourceMessageCount;
1083
+ if (count > source.length) continue;
1084
+ const prefix = source.slice(0, count);
1085
+ if (digestSourceContent(prefix) !== entry.contentDigest) continue;
1086
+ const from = entry.checkpoint.revision;
1087
+ const restored = await this.activateRestored(entry, prefix, remapProjection(entry.checkpoint, prefix), (revision) =>
1088
+ `This session is a fork of ${origin.sessionID}; restored its revision ${from} as revision ${revision}.`);
1089
+ if (!restored) return false;
1090
+ await this.log({ event: "restored", revision: this.state.revision, from, origin: origin.sessionID });
1091
+ return true;
1092
+ }
1093
+ return false;
1094
+ }
1095
+
1096
+ /**
1097
+ * First request of a forked session: copy the origin's annotations made up to the fork
1098
+ * point, their sources re-pointed at the fork's messages (`forkAnnotations`). The fork
1099
+ * point is the newest completion among the messages created before the fork itself
1100
+ * (OpenCode keeps message times in a fork); newer messages are the fork's own.
1101
+ */
1102
+ private async copyForkAnnotations(origin: { sessionID: string; directory: string; created?: number }, raw: readonly OcMessage[], effective: readonly LiveContextMessage[]): Promise<void> {
1103
+ let annotations: LiveContextAnnotation[] = [];
1104
+ try {
1105
+ annotations = await new AnnotationStore(origin.directory).list();
1106
+ } catch (error) {
1107
+ await this.log({ event: "annotations-unreadable", origin: origin.sessionID, error: describe(error) });
1108
+ }
1109
+ // Without a mirror the origin's directory is usually out of reach too: fall back to the
1110
+ // set the origin kept in OpenCode's metadata, which the fork cloned (index.ts).
1111
+ if (annotations.length === 0) annotations = this.forkPersisted ?? [];
1112
+ this.forkPersisted = undefined;
1113
+ if (annotations.length === 0) return;
1114
+ let cutoff: number | undefined;
1115
+ for (const { info } of raw) {
1116
+ const created = Number(info.time?.created ?? 0);
1117
+ if (origin.created !== undefined && created >= origin.created) continue;
1118
+ const end = Number(info.time?.completed ?? 0) || created;
1119
+ if (cutoff === undefined || end > cutoff) cutoff = end;
1120
+ }
1121
+ const copied = forkAnnotations(annotations, effective, { sessionId: this.sessionID, ...(cutoff !== undefined ? { cutoff } : {}) });
1122
+ try {
1123
+ const written = await this.annotations.importAll(copied);
1124
+ // Without the fork's creation time the fork's own new messages count too, so origin
1125
+ // annotations made between the fork and its first request are carried as well.
1126
+ if (written > 0) {
1127
+ this.annotationsImported = true;
1128
+ await this.log({
1129
+ event: "annotations-forked",
1130
+ origin: origin.sessionID,
1131
+ count: written,
1132
+ mapped: copied.filter((annotation) => annotation.source.sessionId === this.sessionID).length,
1133
+ cutoff: origin.created === undefined ? "unbounded" : cutoff ?? null,
1134
+ });
1135
+ }
1136
+ } catch (error) {
1137
+ await this.log({ event: "annotations-fork-error", origin: origin.sessionID, error: describe(error) });
1138
+ }
1139
+ }
1140
+
815
1141
  private async loadAnnotations(): Promise<LiveContextAnnotation[]> {
816
1142
  try {
817
1143
  return await this.annotations.list();
@@ -821,13 +1147,47 @@ export class ClmSession {
821
1147
  }
822
1148
  }
823
1149
 
1150
+ /**
1151
+ * Without a mirror (`mirrorUnavailable`): the raw history, then the continuity message and
1152
+ * its size notice. Nothing is projected, rendered or measured; queued notices name the
1153
+ * mirror, so they are dropped. pi-clm src/index.ts `context` handler, `!store` branch.
1154
+ */
1155
+ private async transformWithoutMirror(raw: OcMessage[]): Promise<TransformResult> {
1156
+ this.baseline = undefined;
1157
+ this.compacted = false;
1158
+ this.pendingNotices = [];
1159
+ if (this.compacting) {
1160
+ this.compacting = false;
1161
+ return { messages: raw, notices: [] };
1162
+ }
1163
+ this.requests += 1;
1164
+ const origin = this.forkOrigin;
1165
+ this.forkOrigin = undefined;
1166
+ if (origin) await this.copyForkAnnotations(origin, raw, flatten(raw));
1167
+ const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: flatten(raw) });
1168
+ const tokens = continuity ? this.textTokens(continuity) : 0;
1169
+ const notices = this.continuitySize.observe(tokens) ? [continuitySizeNoticeText(tokens)] : [];
1170
+ const context = this.context(raw);
1171
+ const messages = [
1172
+ ...raw,
1173
+ ...(continuity ? [noteMessage(continuity, "continuity", context)] : []),
1174
+ ...(notices.length > 0 ? [noteMessage(notices.join("\n\n"), `notice:${this.requests}`, context)] : []),
1175
+ ];
1176
+ this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: 0 };
1177
+ const errors = this.pendingErrors;
1178
+ this.pendingErrors = [];
1179
+ return { messages, notices, ...(errors.length > 0 ? { errors } : {}) };
1180
+ }
1181
+
824
1182
  private async transformNow(raw: OcMessage[]): Promise<TransformResult> {
825
1183
  await this.refreshSettings();
826
1184
  if (!this.settings.enabled || !this.enabled) {
827
1185
  this.baseline = undefined;
828
1186
  this.compacting = false;
829
- return { messages: raw, notices: [] };
1187
+ return { messages: raw, notices: [], ...this.drainAlerts() };
830
1188
  }
1189
+ if (this.mirrorUnavailable !== undefined) return await this.transformWithoutMirror(raw);
1190
+ const noticesOnly = this.noticesOnly;
831
1191
  const pinned = pinnedCount(raw);
832
1192
  const pinnedMessages = raw.slice(0, pinned);
833
1193
  const context = this.context(raw);
@@ -835,17 +1195,26 @@ export class ClmSession {
835
1195
  // OpenCode's compaction passes a head slice of the history through this hook. Apply
836
1196
  // the accepted revision when it still covers that slice; never commit, render,
837
1197
  // notify or reset (the slice is shorter than the history the checkpoint covers).
1198
+ // A pending edit was committed by `commitBeforeCompaction` just before.
838
1199
  if (this.compacting) {
839
1200
  this.compacting = false;
1201
+ // notices-only: the summary is made from the history the model was sent, the raw one.
1202
+ if (noticesOnly) return { messages: raw, notices: [], ...this.drainAlerts() };
840
1203
  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: [] };
1204
+ if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [], ...this.drainAlerts() };
1205
+ return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [], ...this.drainAlerts() };
843
1206
  }
844
1207
 
845
- await this.commit();
1208
+ if (noticesOnly) {
1209
+ // No render to commit against; a mirror written meanwhile is never applied.
1210
+ this.baseline = undefined;
1211
+ this.lastSnapshot = undefined;
1212
+ } else await this.commit();
846
1213
  this.requests += 1;
847
- const compacted = this.compacted;
848
- this.compacted = false;
1214
+ // notices-only leaves a compaction signal pending: back in `edit`, the kept revision
1215
+ // rebases onto the summary as it would have at the time.
1216
+ const compacted = noticesOnly ? false : this.compacted;
1217
+ if (!noticesOnly) this.compacted = false;
849
1218
 
850
1219
  // The raw source prefix is digested as flattened, before any view, cap or guard.
851
1220
  const source = flatten(raw.slice(pinned));
@@ -858,23 +1227,36 @@ export class ClmSession {
858
1227
  // overhead (built-in tool schemas) as undercounting; start calibration over.
859
1228
  if (measured?.source === "provider") this.calibrator.reset();
860
1229
 
861
- let projection = applyProjection(source, this.state.checkpoint);
1230
+ // notices-only: the kept revision is ignored, not dropped (see the header comment).
1231
+ let projection = applyProjection(source, noticesOnly ? undefined : this.state.checkpoint);
862
1232
  // A late or repeated compaction signal must not swallow a later revert: rebase only
863
1233
  // onto a summary the active checkpoint does not already cover.
864
1234
  const summaryId = compacted ? compactionSummaryId(raw) : undefined;
865
1235
  const rebase = summaryId !== undefined && !projection.valid && this.state.checkpoint !== undefined &&
866
1236
  !this.state.checkpoint.sourceIds.includes(summaryId);
867
- if (rebase) {
1237
+ if (noticesOnly) {
1238
+ // Raw history: nothing to rebase, restore or reset.
1239
+ } else if (rebase) {
868
1240
  await this.rebaseAfterCompaction(summaryId);
869
1241
  projection = applyProjection(source, undefined);
1242
+ } else if (!projection.valid && (await this.restoreAfterMismatch(source, projection.reason))) {
1243
+ // A revert cut into the active revision's prefix; an older revision still fits.
1244
+ this.invalidationStreak = 0;
1245
+ projection = applyProjection(source, this.state.checkpoint);
870
1246
  } else if (!projection.valid) {
871
1247
  const dropped = this.state.checkpoint?.revision;
1248
+ // The edit the cooldown measures from no longer applies.
1249
+ this.tracker.clearCooldown();
872
1250
  this.invalidationStreak += 1;
873
1251
  const reason = projection.reason;
874
1252
  await this.updateState((state) => resetProjectionState(state, reason), true).catch(() => undefined);
875
1253
  let notice = `[CLM] Revision ${dropped} was dropped because OpenCode's history changed under it: ${projection.reason} The mirror now shows the stored history.`;
876
1254
  if (this.invalidationStreak >= 2) {
877
1255
  notice += " This happened on consecutive requests; edits keep being dropped until the start of the history stops changing.";
1256
+ if (!this.compositionWarned) {
1257
+ this.compositionWarned = true;
1258
+ this.pendingAlerts.push(COMPOSITION_WARNING);
1259
+ }
878
1260
  }
879
1261
  this.pendingNotices.push(notice);
880
1262
  await this.log({ event: "projection-reset", revision: dropped, reason: projection.reason });
@@ -882,28 +1264,33 @@ export class ClmSession {
882
1264
  } else if (this.state.checkpoint) {
883
1265
  this.invalidationStreak = 0;
884
1266
  }
885
- const checkpoint = this.state.checkpoint;
1267
+ const origin = this.forkOrigin;
1268
+ this.forkOrigin = undefined;
1269
+ if (origin && !noticesOnly && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
1270
+ projection = applyProjection(source, this.state.checkpoint);
1271
+ }
1272
+ const checkpoint = noticesOnly ? undefined : this.state.checkpoint;
886
1273
  const suffixLength = projection.valid ? projection.suffix.length : 0;
887
1274
 
888
1275
  let effective = projection.messages;
889
1276
  if (!this.settings.reasoning) effective = withoutReasoning(effective);
890
1277
  effective = capObservations(effective, this.settings.observationCap);
891
1278
 
1279
+ if (origin) await this.copyForkAnnotations(origin, raw, effective);
892
1280
  const annotations = await this.loadAnnotations();
893
1281
  const continuityText = (messages: LiveContextMessage[]) =>
894
1282
  formatContinuityMessage({ annotations, effectiveMessages: messages });
895
1283
  const pinnedTokens = this.estimate(flatten(pinnedMessages));
896
1284
  const resolved = this.resolvedBudget();
897
- 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.
1285
+ // Model limits arrive one request late, so the first request of a process reads the
1286
+ // FALLBACK_BUDGET placeholder. Withholding, reminding or refusing an edit against it is
1287
+ // undone on the next request (a prompt-cache miss, a spurious notice, an edit the real
1288
+ // budget would accept), so none acts on a fallback: `guardLimit()` is undefined, which
1289
+ // also leaves the fit gate unlimited, as with no budget at all.
1290
+ const limit = this.guardLimit();
1291
+ const provisional = resolved !== undefined && this.provisional(resolved);
902
1292
  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));
1293
+ const { measuredOverhead, scopeTokens } = this.scopeSize();
907
1294
  const excluded = measuredOverhead !== undefined ? [] : [
908
1295
  ...(systemTokens === undefined ? ["the system prompt"] : []),
909
1296
  ...(toolTokens === undefined ? ["tool schemas"] : []),
@@ -925,7 +1312,7 @@ export class ClmSession {
925
1312
  if (guarded.withheld.length > 0) {
926
1313
  effective = guarded.messages;
927
1314
  withheldCount = guarded.withheld.length;
928
- this.pendingNotices.push(overflowNoticeText(guarded, limit));
1315
+ this.pendingNotices.push(overflowNoticeText(guarded, limit, { noticesOnly }));
929
1316
  await this.log({
930
1317
  event: "overflow-guard",
931
1318
  withheld: guarded.withheld.map((record) => ({ id: record.toolCallId, tokens: record.tokens, file: record.file })),
@@ -940,27 +1327,33 @@ export class ClmSession {
940
1327
  this.pendingNotices.push(continuitySizeNoticeText(this.textTokens(continuity ?? "")));
941
1328
  }
942
1329
 
943
- const snapshot = renderContextDocument(effective, {
1330
+ // notices-only: no mirror is rendered or written, so no edit can be validated or applied.
1331
+ const snapshot = noticesOnly ? undefined : renderContextDocument(effective, {
944
1332
  revision: this.state.revision,
945
1333
  protectedIndexes: new Set<number>(),
946
1334
  // Constant until the next accepted edit or reset, so header ids read on one call
947
1335
  // remain valid on the next; bodies are escaped accordingly.
948
1336
  documentSeed: `${this.sessionID}:${this.nonce}:${checkpoint?.sourceDigest ?? "raw"}`,
949
1337
  });
950
- try {
951
- await this.store.write(snapshot.text);
952
- this.lastSnapshot = snapshot;
953
- this.baseline = {
954
- rawMessages: source,
955
- effectiveMessages: effective,
956
- snapshot,
957
- // The fit gate measures the editable context alone; the system prompt, tool
958
- // schemas, pinned task and continuity message take their share of budget − reserve first.
959
- limit: limit === undefined ? undefined : Math.max(1, limit - scopeTokens - pinnedTokens - continuityTokens),
960
- };
961
- } catch (error) {
962
- this.baseline = undefined;
963
- this.pendingNotices.push(`[CLM] Could not refresh the mirror: ${describe(error)}`);
1338
+ if (snapshot) {
1339
+ try {
1340
+ await this.store.write(snapshot.text);
1341
+ this.refreshFailing = false;
1342
+ this.lastSnapshot = snapshot;
1343
+ this.baseline = {
1344
+ rawMessages: source,
1345
+ effectiveMessages: effective,
1346
+ snapshot,
1347
+ // The fit gate measures the editable context alone; the system prompt, tool
1348
+ // schemas, pinned task and continuity message take their share of budget − reserve first.
1349
+ limit: limit === undefined ? undefined : Math.max(1, limit - scopeTokens - pinnedTokens - continuityTokens),
1350
+ };
1351
+ } catch (error) {
1352
+ this.baseline = undefined;
1353
+ this.pendingNotices.push(`[CLM] Could not refresh the mirror: ${describe(error)}`);
1354
+ if (!this.refreshFailing) this.pendingErrors.push(`could not refresh the mirror, edits are off until it can be written: ${describe(error)}`);
1355
+ this.refreshFailing = true;
1356
+ }
964
1357
  }
965
1358
 
966
1359
  const conversationTokens = pinnedTokens + this.estimate(effective) + continuityTokens;
@@ -976,10 +1369,16 @@ export class ClmSession {
976
1369
  // The measured request was answered inside the prefix the active revision replaced.
977
1370
  observedStale: observed !== undefined && checkpoint !== undefined && checkpoint.sourceIds.includes(observed.messageID),
978
1371
  };
979
- const tier = this.tracker.observe(reading, budgetTiers(this.settings.budget, resolved.budget, resolved.reserve));
1372
+ // A fallback reading skips the tracker: no tier fires or is marked fired, and a pending
1373
+ // cooldown waits, so reminders start from the real budget once the window is known.
1374
+ const { tier, suppressed, cooldownUntil } = provisional ? { tier: undefined, suppressed: [] } : this.tracker.check(
1375
+ reading, budgetTiers(this.settings.budget, resolved.budget, resolved.reserve), this.settings.budget.reminderCooldown);
1376
+ for (const skipped of suppressed) {
1377
+ await this.log({ event: "budget-notice-suppressed", tier: skipped.label, estimated: reading.estimated, cooldownUntil });
1378
+ }
980
1379
  if (tier) {
981
1380
  // With the guard off nothing is withheld, so the notice must not describe it.
982
- this.pendingNotices.push(budgetNoticeText(reading, tier, this.mirrorPath, this.settings.guard === "off" ? null : limit));
1381
+ this.pendingNotices.push(budgetNoticeText(reading, tier, noticesOnly ? undefined : this.mirrorPath, this.settings.guard === "off" ? null : limit, { noticesOnly }));
983
1382
  await this.log({ event: "budget-notice", tier: tier.label, estimated: reading.estimated });
984
1383
  }
985
1384
  this.lastReading = reading;
@@ -1007,7 +1406,7 @@ export class ClmSession {
1007
1406
  }
1008
1407
 
1009
1408
  this.previousRequest = { rawCount: raw.length, conversation: conversationTokens + this.calibrator.apply(this.noticeTokens(notices)) };
1010
- this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: snapshot.blocks.length };
1409
+ this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: snapshot?.blocks.length ?? 0 };
1011
1410
  if (this.settings.dumpRequests) {
1012
1411
  const directory = join(this.store.directory, "requests");
1013
1412
  await mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => undefined);
@@ -1034,7 +1433,8 @@ export class ClmSession {
1034
1433
  users: raw.filter((message) => message.info.role === "user").length,
1035
1434
  raw: raw.length,
1036
1435
  sent: messages.length,
1037
- blocks: snapshot.blocks.length,
1436
+ blocks: snapshot?.blocks.length ?? 0,
1437
+ ...(noticesOnly ? { mode: "notices-only" } : {}),
1038
1438
  withheld: withheldCount,
1039
1439
  estimated: reading?.estimated ?? requestTokens,
1040
1440
  observedPrevious: observed?.tokens,
@@ -1043,19 +1443,40 @@ export class ClmSession {
1043
1443
  calibration: this.calibrator.factor,
1044
1444
  notices: notices.map((notice) => notice.slice(0, 80)),
1045
1445
  });
1046
- return { messages, notices, estimated: reading?.estimated ?? requestTokens, reading, ...(alert ? { alert } : {}) };
1446
+ return {
1447
+ messages,
1448
+ notices,
1449
+ estimated: reading?.estimated ?? requestTokens,
1450
+ reading,
1451
+ ...this.drainAlerts(alert),
1452
+ };
1453
+ }
1454
+
1455
+ /** The queued toasts (`alert`, `errors`) for a transform result, cleared once taken. */
1456
+ private drainAlerts(alert?: string): Pick<TransformResult, "alert" | "errors"> {
1457
+ const alerts = [...(alert ? [alert] : []), ...this.pendingAlerts];
1458
+ this.pendingAlerts = [];
1459
+ const errors = this.pendingErrors;
1460
+ this.pendingErrors = [];
1461
+ return {
1462
+ ...(alerts.length > 0 ? { alert: alerts.join("\n") } : {}),
1463
+ ...(errors.length > 0 ? { errors } : {}),
1464
+ };
1047
1465
  }
1048
1466
 
1049
1467
  /** Inputs for presentation.ts `statusText` / `statusLine`. */
1050
1468
  status(steering: SteeringDocument | undefined = this.steering): ClmStatus {
1051
1469
  const checkpoint = this.state.checkpoint;
1052
1470
  const { base, effective } = this.settingsValues();
1053
- const changed = changedSummary(base, effective, { modelWindow: this.limits.context });
1471
+ const changed = changedSummary(base, effective, { modelWindow: this.limits.context, modelOutput: this.limits.output });
1054
1472
  return {
1055
1473
  ...(changed ? { changed } : {}),
1056
1474
  ...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
1057
1475
  sessionID: this.sessionID,
1058
- mirrorPath: this.mirrorPath,
1476
+ mirrorPath: this.mirrorUnavailable !== undefined
1477
+ ? `unavailable, requests carry the raw history (${this.mirrorUnavailable})`
1478
+ : this.noticesOnly ? "not used (mode notices-only)" : this.mirrorPath,
1479
+ ...(this.noticesOnly ? { mode: "notices-only" as const } : {}),
1059
1480
  revision: this.state.revision,
1060
1481
  accepted: this.accepted,
1061
1482
  rejected: this.rejected,