opencode-clm 0.3.0 → 0.4.1

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,
@@ -32,7 +38,6 @@
32
38
  * calibrator learns only from requests whose full scope (system prompt and tool schemas
33
39
  * included) was estimated; without those sizes it stays at 1.
34
40
  */
35
- import { randomUUID } from "node:crypto";
36
41
  import { appendFile, mkdir, stat, writeFile } from "node:fs/promises";
37
42
  import { join } from "node:path";
38
43
 
@@ -200,6 +205,12 @@ export class ClmSession {
200
205
  accepted = 0;
201
206
  rejected = 0;
202
207
  limits: ModelLimits = {};
208
+ /**
209
+ * Set once the system transform has seen the model, with or without a window. Until then
210
+ * a fallback budget is provisional: the guard, fit gate, reminders and trailer stand down.
211
+ * A model that reports no window keeps the fallback, which then applies in full.
212
+ */
213
+ limitsKnown = false;
203
214
  scope: RequestScope = {};
204
215
  /** Set by `experimental.session.compacting`: the next transform carries OpenCode's compaction input. */
205
216
  compacting = false;
@@ -249,11 +260,6 @@ export class ClmSession {
249
260
  private pendingAlerts: string[] = [];
250
261
  /** The settings warning last shown to the user, so a new or changed one toasts once. */
251
262
  private shownSettingsWarning?: string;
252
- /**
253
- * Per-process seed of the stable document id. Fresh on every open, so a document id never
254
- * repeats even when a corrupt state file resets the revision counter to 0.
255
- */
256
- private readonly nonce = randomUUID();
257
263
  private readonly tokenCache = new WeakMap<LiveContextMessage, number>();
258
264
  private saving: Promise<void> = Promise.resolve();
259
265
  private running: Promise<unknown> = Promise.resolve();
@@ -306,6 +312,11 @@ export class ClmSession {
306
312
  return this.overrides.editing ?? this.state.enabled;
307
313
  }
308
314
 
315
+ /** `mode notices-only`: notices and the guard run, the model cannot edit its context. */
316
+ get noticesOnly(): boolean {
317
+ return this.settings.mode === "notices-only";
318
+ }
319
+
309
320
  /** Base and effective values, for the settings table. */
310
321
  settingsValues(): { base: SettingsValues; effective: SettingsValues } {
311
322
  return {
@@ -321,7 +332,7 @@ export class ClmSession {
321
332
  base: this.baseSettings,
322
333
  baseEditing: this.state.enabled,
323
334
  projectDirectory,
324
- format: { modelWindow: this.limits.context },
335
+ format: { modelWindow: this.limits.context, modelOutput: this.limits.output },
325
336
  };
326
337
  }
327
338
 
@@ -378,18 +389,26 @@ export class ClmSession {
378
389
  const before = this.settings.budget;
379
390
  const after = staged.settings.budget;
380
391
  const wasEnabled = this.enabled;
392
+ const wasMode = this.settings.mode;
381
393
  const changed = JSON.stringify(this.overrides) !== JSON.stringify(overrides);
382
394
  this.settings = staged.settings;
383
395
  this.steering = staged.steering;
384
396
  this.overrides = overrides;
385
- if (before.contextBudget !== after.contextBudget || before.reserve !== after.reserve ||
397
+ if (before.contextBudget !== after.contextBudget || before.contextFraction !== after.contextFraction || before.reserve !== after.reserve ||
386
398
  before.remindAtFractions.join(",") !== after.remindAtFractions.join(",") || before.remindAtReserve !== after.remindAtReserve) {
387
399
  this.tracker.reset();
388
400
  }
389
401
  if (wasEnabled !== this.enabled) {
390
402
  this.baseline = undefined;
403
+ this.tracker.clearCooldown();
391
404
  if (!this.enabled) this.continuitySize.observe(0);
392
405
  }
406
+ if (wasMode !== this.settings.mode) {
407
+ // The render the baseline holds no longer describes what the model is sent.
408
+ this.baseline = undefined;
409
+ this.lastSnapshot = undefined;
410
+ this.tracker.clearCooldown();
411
+ }
393
412
  if (changed) await this.log({ event: "settings", overrides });
394
413
  }
395
414
 
@@ -454,10 +473,10 @@ export class ClmSession {
454
473
  }
455
474
 
456
475
  /**
457
- * The budget in force: the configured one (or the model window), raised when the
476
+ * The budget in force: the resolved one (`resolveBudget`), raised when the
458
477
  * session's budget check found it too small for OpenCode's fixed overhead (`budgetFit`).
459
478
  */
460
- resolvedBudget(): Pick<BudgetReading, "budget" | "reserve" | "source" | "raisedFrom"> | undefined {
479
+ resolvedBudget(): Pick<BudgetReading, "budget" | "reserve" | "source" | "fraction" | "raisedFrom"> | undefined {
461
480
  const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
462
481
  if (!base) return undefined;
463
482
  const fit = budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap());
@@ -466,7 +485,7 @@ export class ClmSession {
466
485
 
467
486
  /** Model window minus its output limit, when known: the ceiling for a raised budget. */
468
487
  private windowCap(): number | undefined {
469
- return resolveBudget({ ...this.settings.budget, contextBudget: undefined }, this.limits.context, this.limits.output)?.budget;
488
+ return resolveBudget({ ...this.settings.budget, contextBudget: undefined, contextFraction: undefined }, this.limits.context, this.limits.output)?.budget;
470
489
  }
471
490
 
472
491
  /**
@@ -478,10 +497,19 @@ export class ClmSession {
478
497
  return base ? budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap()) : undefined;
479
498
  }
480
499
 
481
- /** budget − reserve, or undefined while the budget is unknown. */
500
+ /**
501
+ * budget − reserve: the limit the overflow guard enforces. Undefined while the budget is
502
+ * unknown or a fallback (`FALLBACK_BUDGET`, model window not yet known): the guard then
503
+ * withholds nothing.
504
+ */
505
+ /** A fallback budget read before the model's limits arrived (see `limitsKnown`). */
506
+ private provisional(resolved: Pick<BudgetReading, "source">): boolean {
507
+ return resolved.source === "fallback" && !this.limitsKnown;
508
+ }
509
+
482
510
  guardLimit(): number | undefined {
483
511
  const resolved = this.resolvedBudget();
484
- return resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
512
+ return resolved && !this.provisional(resolved) ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
485
513
  }
486
514
 
487
515
  /**
@@ -555,7 +583,7 @@ export class ClmSession {
555
583
  ...(previous ? { replaces: previous.source, previousOverhead: previous.overhead } : {}),
556
584
  });
557
585
  if (!warn) return undefined;
558
- this.pendingNotices.push(budgetTooSmallNoticeText(fit));
586
+ this.pendingNotices.push(budgetTooSmallNoticeText(fit, { noticesOnly: this.noticesOnly }));
559
587
  return budgetTooSmallAlertText(fit);
560
588
  }
561
589
 
@@ -597,6 +625,7 @@ export class ClmSession {
597
625
  async setEnabled(enabled: boolean): Promise<void> {
598
626
  await this.updateState((state) => ({ ...state, enabled }));
599
627
  this.baseline = undefined;
628
+ this.tracker.clearCooldown();
600
629
  if (!enabled) this.continuitySize.observe(0);
601
630
  }
602
631
 
@@ -649,7 +678,7 @@ export class ClmSession {
649
678
  * calls that only read it or do not touch it.
650
679
  */
651
680
  receipt(tool: string, args: Record<string, unknown> | undefined, cwd: string): string | undefined {
652
- if (!this.settings.enabled || !this.enabled) return undefined;
681
+ if (!this.settings.enabled || !this.enabled || this.noticesOnly) return undefined;
653
682
  if (classifyMirrorToolCall(tool, args ?? {}, cwd, this.mirrorPath) !== "write") return undefined;
654
683
  const check = this.validateMirror(this.store.readSync());
655
684
  if (!check) return undefined;
@@ -661,13 +690,15 @@ export class ClmSession {
661
690
 
662
691
  /**
663
692
  * `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.
693
+ * result's calibrated estimate, against the budget in force); undefined when off or while
694
+ * the budget is unknown or a provisional fallback.
665
695
  */
666
696
  sizeTrailer(output: string): string | undefined {
667
697
  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);
698
+ const resolved = this.resolvedBudget();
699
+ // A fallback budget is a placeholder: "~N of 32,000" would report a limit nothing enforces.
700
+ if (!resolved || this.provisional(resolved)) return undefined;
701
+ return sizeTrailer(this.lastReading?.estimated ?? 0, this.calibrator.apply(this.textTokens(output)), resolved.budget);
671
702
  }
672
703
 
673
704
  /**
@@ -678,7 +709,7 @@ export class ClmSession {
678
709
  */
679
710
  async commitBeforeCompaction(): Promise<void> {
680
711
  await this.refreshSettings();
681
- if (!this.settings.enabled || !this.enabled) return;
712
+ if (!this.settings.enabled || !this.enabled || this.noticesOnly) return;
682
713
  await this.commit();
683
714
  }
684
715
 
@@ -689,6 +720,7 @@ export class ClmSession {
689
720
 
690
721
  /** The mirror block the model last saw, for continuity annotations. */
691
722
  blockSource(blockId: string): ContinuityBlockSource | undefined {
723
+ if (this.noticesOnly) throw new Error("CLM runs in notices-only mode: there is no context mirror, so no block can be annotated.");
692
724
  const snapshot = this.lastSnapshot;
693
725
  const block = snapshot?.blocks.find((candidate) => candidate.id === blockId);
694
726
  return snapshot && block ? { message: block.source, revision: snapshot.revision } : undefined;
@@ -761,6 +793,7 @@ export class ClmSession {
761
793
  return;
762
794
  }
763
795
  this.accepted += 1;
796
+ this.tracker.editAccepted();
764
797
  await this.remember({ version: 1, checkpoint, contentDigest: digestSourceContent(baseline.rawMessages) });
765
798
  const revisions = join(this.store.directory, "revisions");
766
799
  await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
@@ -1148,6 +1181,7 @@ export class ClmSession {
1148
1181
  return { messages: raw, notices: [], ...this.drainAlerts() };
1149
1182
  }
1150
1183
  if (this.mirrorUnavailable !== undefined) return await this.transformWithoutMirror(raw);
1184
+ const noticesOnly = this.noticesOnly;
1151
1185
  const pinned = pinnedCount(raw);
1152
1186
  const pinnedMessages = raw.slice(0, pinned);
1153
1187
  const context = this.context(raw);
@@ -1158,15 +1192,23 @@ export class ClmSession {
1158
1192
  // A pending edit was committed by `commitBeforeCompaction` just before.
1159
1193
  if (this.compacting) {
1160
1194
  this.compacting = false;
1195
+ // notices-only: the summary is made from the history the model was sent, the raw one.
1196
+ if (noticesOnly) return { messages: raw, notices: [], ...this.drainAlerts() };
1161
1197
  const projection = applyProjection(flatten(raw.slice(pinned)), this.state.checkpoint);
1162
1198
  if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [], ...this.drainAlerts() };
1163
1199
  return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [], ...this.drainAlerts() };
1164
1200
  }
1165
1201
 
1166
- await this.commit();
1202
+ if (noticesOnly) {
1203
+ // No render to commit against; a mirror written meanwhile is never applied.
1204
+ this.baseline = undefined;
1205
+ this.lastSnapshot = undefined;
1206
+ } else await this.commit();
1167
1207
  this.requests += 1;
1168
- const compacted = this.compacted;
1169
- this.compacted = false;
1208
+ // notices-only leaves a compaction signal pending: back in `edit`, the kept revision
1209
+ // rebases onto the summary as it would have at the time.
1210
+ const compacted = noticesOnly ? false : this.compacted;
1211
+ if (!noticesOnly) this.compacted = false;
1170
1212
 
1171
1213
  // The raw source prefix is digested as flattened, before any view, cap or guard.
1172
1214
  const source = flatten(raw.slice(pinned));
@@ -1179,13 +1221,16 @@ export class ClmSession {
1179
1221
  // overhead (built-in tool schemas) as undercounting; start calibration over.
1180
1222
  if (measured?.source === "provider") this.calibrator.reset();
1181
1223
 
1182
- let projection = applyProjection(source, this.state.checkpoint);
1224
+ // notices-only: the kept revision is ignored, not dropped (see the header comment).
1225
+ let projection = applyProjection(source, noticesOnly ? undefined : this.state.checkpoint);
1183
1226
  // A late or repeated compaction signal must not swallow a later revert: rebase only
1184
1227
  // onto a summary the active checkpoint does not already cover.
1185
1228
  const summaryId = compacted ? compactionSummaryId(raw) : undefined;
1186
1229
  const rebase = summaryId !== undefined && !projection.valid && this.state.checkpoint !== undefined &&
1187
1230
  !this.state.checkpoint.sourceIds.includes(summaryId);
1188
- if (rebase) {
1231
+ if (noticesOnly) {
1232
+ // Raw history: nothing to rebase, restore or reset.
1233
+ } else if (rebase) {
1189
1234
  await this.rebaseAfterCompaction(summaryId);
1190
1235
  projection = applyProjection(source, undefined);
1191
1236
  } else if (!projection.valid && (await this.restoreAfterMismatch(source, projection.reason))) {
@@ -1194,6 +1239,8 @@ export class ClmSession {
1194
1239
  projection = applyProjection(source, this.state.checkpoint);
1195
1240
  } else if (!projection.valid) {
1196
1241
  const dropped = this.state.checkpoint?.revision;
1242
+ // The edit the cooldown measures from no longer applies.
1243
+ this.tracker.clearCooldown();
1197
1244
  this.invalidationStreak += 1;
1198
1245
  const reason = projection.reason;
1199
1246
  await this.updateState((state) => resetProjectionState(state, reason), true).catch(() => undefined);
@@ -1211,12 +1258,30 @@ export class ClmSession {
1211
1258
  } else if (this.state.checkpoint) {
1212
1259
  this.invalidationStreak = 0;
1213
1260
  }
1261
+ if (projection.valid && projection.reanchor) {
1262
+ // OpenCode rewrote messages of the current turn after the checkpoint was captured
1263
+ // (an assistant message still being finalized). Trim the anchor to the stable head
1264
+ // instead of dropping the revision: the trimmed tail becomes ordinary suffix,
1265
+ // exactly as if the edit had been committed a moment later. Silent by design;
1266
+ // `reanchored` in events.jsonl records it for the panel and for debugging.
1267
+ this.invalidationStreak = 0;
1268
+ const reanchor = projection.reanchor;
1269
+ const revision = this.state.revision;
1270
+ await this.updateState(
1271
+ (state) =>
1272
+ state.checkpoint && state.revision === revision
1273
+ ? { ...state, checkpoint: { ...state.checkpoint, ...reanchor } }
1274
+ : state,
1275
+ true,
1276
+ ).catch(() => undefined);
1277
+ await this.log({ event: "reanchored", revision, sourceMessageCount: reanchor.sourceMessageCount });
1278
+ }
1214
1279
  const origin = this.forkOrigin;
1215
1280
  this.forkOrigin = undefined;
1216
- if (origin && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
1281
+ if (origin && !noticesOnly && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
1217
1282
  projection = applyProjection(source, this.state.checkpoint);
1218
1283
  }
1219
- const checkpoint = this.state.checkpoint;
1284
+ const checkpoint = noticesOnly ? undefined : this.state.checkpoint;
1220
1285
  const suffixLength = projection.valid ? projection.suffix.length : 0;
1221
1286
 
1222
1287
  let effective = projection.messages;
@@ -1229,7 +1294,13 @@ export class ClmSession {
1229
1294
  formatContinuityMessage({ annotations, effectiveMessages: messages });
1230
1295
  const pinnedTokens = this.estimate(flatten(pinnedMessages));
1231
1296
  const resolved = this.resolvedBudget();
1232
- const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
1297
+ // Model limits arrive one request late, so the first request of a process reads the
1298
+ // FALLBACK_BUDGET placeholder. Withholding, reminding or refusing an edit against it is
1299
+ // undone on the next request (a prompt-cache miss, a spurious notice, an edit the real
1300
+ // budget would accept), so none acts on a fallback: `guardLimit()` is undefined, which
1301
+ // also leaves the fit gate unlimited, as with no budget at all.
1302
+ const limit = this.guardLimit();
1303
+ const provisional = resolved !== undefined && this.provisional(resolved);
1233
1304
  const { systemTokens, toolTokens } = this.scope;
1234
1305
  const { measuredOverhead, scopeTokens } = this.scopeSize();
1235
1306
  const excluded = measuredOverhead !== undefined ? [] : [
@@ -1253,7 +1324,7 @@ export class ClmSession {
1253
1324
  if (guarded.withheld.length > 0) {
1254
1325
  effective = guarded.messages;
1255
1326
  withheldCount = guarded.withheld.length;
1256
- this.pendingNotices.push(overflowNoticeText(guarded, limit));
1327
+ this.pendingNotices.push(overflowNoticeText(guarded, limit, { noticesOnly }));
1257
1328
  await this.log({
1258
1329
  event: "overflow-guard",
1259
1330
  withheld: guarded.withheld.map((record) => ({ id: record.toolCallId, tokens: record.tokens, file: record.file })),
@@ -1268,30 +1339,34 @@ export class ClmSession {
1268
1339
  this.pendingNotices.push(continuitySizeNoticeText(this.textTokens(continuity ?? "")));
1269
1340
  }
1270
1341
 
1271
- const snapshot = renderContextDocument(effective, {
1342
+ // notices-only: no mirror is rendered or written, so no edit can be validated or applied.
1343
+ const snapshot = noticesOnly ? undefined : renderContextDocument(effective, {
1272
1344
  revision: this.state.revision,
1273
1345
  protectedIndexes: new Set<number>(),
1274
- // Constant until the next accepted edit or reset, so header ids read on one call
1275
- // remain valid on the next; bodies are escaped accordingly.
1276
- documentSeed: `${this.sessionID}:${this.nonce}:${checkpoint?.sourceDigest ?? "raw"}`,
1346
+ // Constant until the next accepted edit or reset — and across process restarts,
1347
+ // as pi-clm's is — so header ids read on one call remain valid on the next;
1348
+ // bodies are escaped accordingly.
1349
+ documentSeed: `${this.sessionID}:${checkpoint?.sourceDigest ?? "raw"}`,
1277
1350
  });
1278
- try {
1279
- await this.store.write(snapshot.text);
1280
- this.refreshFailing = false;
1281
- this.lastSnapshot = snapshot;
1282
- this.baseline = {
1283
- rawMessages: source,
1284
- effectiveMessages: effective,
1285
- snapshot,
1286
- // The fit gate measures the editable context alone; the system prompt, tool
1287
- // schemas, pinned task and continuity message take their share of budget − reserve first.
1288
- limit: limit === undefined ? undefined : Math.max(1, limit - scopeTokens - pinnedTokens - continuityTokens),
1289
- };
1290
- } catch (error) {
1291
- this.baseline = undefined;
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;
1351
+ if (snapshot) {
1352
+ try {
1353
+ await this.store.write(snapshot.text);
1354
+ this.refreshFailing = false;
1355
+ this.lastSnapshot = snapshot;
1356
+ this.baseline = {
1357
+ rawMessages: source,
1358
+ effectiveMessages: effective,
1359
+ snapshot,
1360
+ // The fit gate measures the editable context alone; the system prompt, tool
1361
+ // schemas, pinned task and continuity message take their share of budget − reserve first.
1362
+ limit: limit === undefined ? undefined : Math.max(1, limit - scopeTokens - pinnedTokens - continuityTokens),
1363
+ };
1364
+ } catch (error) {
1365
+ this.baseline = undefined;
1366
+ this.pendingNotices.push(`[CLM] Could not refresh the mirror: ${describe(error)}`);
1367
+ if (!this.refreshFailing) this.pendingErrors.push(`could not refresh the mirror, edits are off until it can be written: ${describe(error)}`);
1368
+ this.refreshFailing = true;
1369
+ }
1295
1370
  }
1296
1371
 
1297
1372
  const conversationTokens = pinnedTokens + this.estimate(effective) + continuityTokens;
@@ -1307,10 +1382,16 @@ export class ClmSession {
1307
1382
  // The measured request was answered inside the prefix the active revision replaced.
1308
1383
  observedStale: observed !== undefined && checkpoint !== undefined && checkpoint.sourceIds.includes(observed.messageID),
1309
1384
  };
1310
- const tier = this.tracker.observe(reading, budgetTiers(this.settings.budget, resolved.budget, resolved.reserve));
1385
+ // A fallback reading skips the tracker: no tier fires or is marked fired, and a pending
1386
+ // cooldown waits, so reminders start from the real budget once the window is known.
1387
+ const { tier, suppressed, cooldownUntil } = provisional ? { tier: undefined, suppressed: [] } : this.tracker.check(
1388
+ reading, budgetTiers(this.settings.budget, resolved.budget, resolved.reserve), this.settings.budget.reminderCooldown);
1389
+ for (const skipped of suppressed) {
1390
+ await this.log({ event: "budget-notice-suppressed", tier: skipped.label, estimated: reading.estimated, cooldownUntil });
1391
+ }
1311
1392
  if (tier) {
1312
1393
  // With the guard off nothing is withheld, so the notice must not describe it.
1313
- this.pendingNotices.push(budgetNoticeText(reading, tier, this.mirrorPath, this.settings.guard === "off" ? null : limit));
1394
+ this.pendingNotices.push(budgetNoticeText(reading, tier, noticesOnly ? undefined : this.mirrorPath, this.settings.guard === "off" ? null : limit, { noticesOnly }));
1314
1395
  await this.log({ event: "budget-notice", tier: tier.label, estimated: reading.estimated });
1315
1396
  }
1316
1397
  this.lastReading = reading;
@@ -1338,7 +1419,7 @@ export class ClmSession {
1338
1419
  }
1339
1420
 
1340
1421
  this.previousRequest = { rawCount: raw.length, conversation: conversationTokens + this.calibrator.apply(this.noticeTokens(notices)) };
1341
- this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: snapshot.blocks.length };
1422
+ this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: snapshot?.blocks.length ?? 0 };
1342
1423
  if (this.settings.dumpRequests) {
1343
1424
  const directory = join(this.store.directory, "requests");
1344
1425
  await mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => undefined);
@@ -1365,7 +1446,8 @@ export class ClmSession {
1365
1446
  users: raw.filter((message) => message.info.role === "user").length,
1366
1447
  raw: raw.length,
1367
1448
  sent: messages.length,
1368
- blocks: snapshot.blocks.length,
1449
+ blocks: snapshot?.blocks.length ?? 0,
1450
+ ...(noticesOnly ? { mode: "notices-only" } : {}),
1369
1451
  withheld: withheldCount,
1370
1452
  estimated: reading?.estimated ?? requestTokens,
1371
1453
  observedPrevious: observed?.tokens,
@@ -1399,12 +1481,15 @@ export class ClmSession {
1399
1481
  status(steering: SteeringDocument | undefined = this.steering): ClmStatus {
1400
1482
  const checkpoint = this.state.checkpoint;
1401
1483
  const { base, effective } = this.settingsValues();
1402
- const changed = changedSummary(base, effective, { modelWindow: this.limits.context });
1484
+ const changed = changedSummary(base, effective, { modelWindow: this.limits.context, modelOutput: this.limits.output });
1403
1485
  return {
1404
1486
  ...(changed ? { changed } : {}),
1405
1487
  ...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
1406
1488
  sessionID: this.sessionID,
1407
- mirrorPath: this.mirrorUnavailable === undefined ? this.mirrorPath : `unavailable, requests carry the raw history (${this.mirrorUnavailable})`,
1489
+ mirrorPath: this.mirrorUnavailable !== undefined
1490
+ ? `unavailable, requests carry the raw history (${this.mirrorUnavailable})`
1491
+ : this.noticesOnly ? "not used (mode notices-only)" : this.mirrorPath,
1492
+ ...(this.noticesOnly ? { mode: "notices-only" as const } : {}),
1408
1493
  revision: this.state.revision,
1409
1494
  accepted: this.accepted,
1410
1495
  rejected: this.rejected,
package/src/compaction.ts CHANGED
@@ -35,15 +35,20 @@ export function applyCompactionMode(config: { compaction?: { auto?: boolean } },
35
35
  }
36
36
 
37
37
  /** Toast after OpenCode compacted a session on its own. */
38
- export function nativeCompactionText(overflow: boolean, mode: CompactionMode): string {
38
+ export function nativeCompactionText(overflow: boolean, mode: CompactionMode, noticesOnly = false): string {
39
39
  const why = overflow ? "after the provider reported a context overflow" : "at its token threshold";
40
- const hint = mode === "off" ? "" : " Set /clm config compaction off to keep the model's own edits instead.";
40
+ // In notices-only the model makes no edits a compaction could replace.
41
+ const hint = mode === "off" || noticesOnly ? "" : " Set /clm config compaction off to keep the model's own edits instead.";
41
42
  return `OpenCode compacted this session ${why}; CLM continues from the summary.${hint}`;
42
43
  }
43
44
 
44
45
  /** Model notice when a provider overflow was not compacted because `compaction.auto` was false. */
45
- export function overflowNotCompactedText(mirrorPath: string): string {
46
- return `[CLM] The provider rejected the last request as too long, and OpenCode's automatic compaction is off, so nothing was compacted. Shrink the context by editing ${mirrorPath}, or ask the user to run /compact.`;
46
+ export function overflowNotCompactedText(mirrorPath: string | undefined): string {
47
+ const head = "[CLM] The provider rejected the last request as too long, and OpenCode's automatic compaction is off, so nothing was compacted.";
48
+ // undefined: `mode notices-only`, no mirror to edit.
49
+ return mirrorPath === undefined
50
+ ? `${head} Ask the user to run /compact.`
51
+ : `${head} Shrink the context by editing ${mirrorPath}, or ask the user to run /compact.`;
47
52
  }
48
53
 
49
54
  /**
@@ -214,8 +219,11 @@ export function thresholdPausedText(count: number, usable: number, guardLimit: n
214
219
  }
215
220
 
216
221
  /** Model notice when `off` cancelled a threshold compaction (pi: notice and toast). */
217
- export function thresholdCancelledNoticeText(mirrorPath: string): string {
218
- return `[CLM] OpenCode's automatic compaction (threshold) was cancelled: CLM manages this context. Free space by editing ${mirrorPath}.`;
222
+ export function thresholdCancelledNoticeText(mirrorPath: string | undefined): string {
223
+ // undefined: `mode notices-only`, no mirror to edit.
224
+ return mirrorPath === undefined
225
+ ? "[CLM] OpenCode's automatic compaction (threshold) was cancelled: CLM's compaction setting is off."
226
+ : `[CLM] OpenCode's automatic compaction (threshold) was cancelled: CLM manages this context. Free space by editing ${mirrorPath}.`;
219
227
  }
220
228
 
221
229
  /** Toast when `off` cancelled a threshold compaction. */
@@ -54,7 +54,10 @@ function normalizeJson(value: unknown): unknown {
54
54
  }
55
55
 
56
56
  export function canonicalMessage(message: LiveContextMessage): string {
57
- return JSON.stringify(normalizeJson(message));
57
+ // `updatedAt` is OpenCode write metadata, not content: OpenCode rewrites it in place while
58
+ // a message is still being finalized, so it must not reach any digest or block id.
59
+ const { updatedAt: _updatedAt, ...rest } = message;
60
+ return JSON.stringify(normalizeJson(rest));
58
61
  }
59
62
 
60
63
  export function digestMessages(messages: LiveContextMessage[]): string {
package/src/opencode.ts CHANGED
@@ -27,7 +27,10 @@
27
27
  * "[Tool execution was interrupted]".
28
28
  * Flattening never depends on settings: the projection digest is computed from it, and a
29
29
  * settings toggle must not discard an accepted revision. Reasoning is always flattened;
30
- * `withoutReasoning` hides it from the mirror view only.
30
+ * `withoutReasoning` hides it from the mirror view only. Every flat message carries
31
+ * `updatedAt` (`info.time.updated`, else `completed`, else `created`), OpenCode's in-place
32
+ * write marker: digests exclude it (`canonicalMessage`), and the projection reads it only to
33
+ * re-anchor a late rewrite of the tail instead of dropping the revision (projection.ts).
31
34
  *
32
35
  * Known gap: an assistant message whose only sendable parts are empty text parts has no
33
36
  * flat form and is left out of the request. OpenCode sends it as an empty assistant turn.
@@ -125,9 +128,12 @@ export function flatten(messages: readonly OcMessage[]): LiveContextMessage[] {
125
128
  if (!sentToModel(message)) continue;
126
129
  const id = message.info.id;
127
130
  const timestamp = Number(message.info.time?.created ?? 0);
131
+ // OpenCode writes `completed` (and, on some hosts, `updated`); both move when a message
132
+ // is written in place, `created` does not.
133
+ const updatedAt = Number(message.info.time?.updated ?? message.info.time?.completed ?? message.info.time?.created ?? 0);
128
134
  if (message.info.role === "user") {
129
135
  const content = userContent(message);
130
- if (content.length > 0) out.push({ role: "user", content, ocMessageID: id, timestamp });
136
+ if (content.length > 0) out.push({ role: "user", content, ocMessageID: id, timestamp, updatedAt });
131
137
  continue;
132
138
  }
133
139
  const content: Record<string, unknown>[] = [];
@@ -147,11 +153,12 @@ export function flatten(messages: readonly OcMessage[]): LiveContextMessage[] {
147
153
  isError: output.isError,
148
154
  ocMessageID: id,
149
155
  timestamp,
156
+ updatedAt,
150
157
  });
151
158
  }
152
159
  }
153
160
  if (content.length === 0) continue;
154
- out.push({ role: "assistant", content, ocMessageID: id, timestamp });
161
+ out.push({ role: "assistant", content, ocMessageID: id, timestamp, updatedAt });
155
162
  out.push(...results);
156
163
  }
157
164
  return out;
package/src/overflow.ts CHANGED
@@ -195,7 +195,8 @@ export function applyOverflowGuard(messages: LiveContextMessage[], options: Appl
195
195
  return { messages: current, withheld, estimated };
196
196
  }
197
197
 
198
- export function overflowNoticeText(result: OverflowGuardResult, limit: number): string {
198
+ /** `noticesOnly` (`mode notices-only`): no mirror, so the text names no edit. */
199
+ export function overflowNoticeText(result: OverflowGuardResult, limit: number, options: { noticesOnly?: boolean } = {}): string {
199
200
  const count = result.withheld.length;
200
201
  const files = result.withheld.filter((record) => record.file).length;
201
202
  const list = result.withheld
@@ -205,8 +206,10 @@ export function overflowNoticeText(result: OverflowGuardResult, limit: number):
205
206
  return (
206
207
  `[CLM BUDGET] Overflow guard: the request would have exceeded the limit of ${limit.toLocaleString("en-US")} tokens, so ${count} tool result${count === 1 ? "" : "s"} ` +
207
208
  `${count === 1 ? "was" : "were"} withheld from your context and replaced by notes${files > 0 ? " with file paths" : ""}: ${list}. ` +
208
- `Estimated request is now ${result.estimated.toLocaleString("en-US")} tokens${fits ? "" : " and still over the limit; edit your context now"}. ` +
209
+ `Estimated request is now ${result.estimated.toLocaleString("en-US")} tokens${fits ? "" : options.noticesOnly ? " and still over the limit" : " and still over the limit; edit your context now"}. ` +
209
210
  "Nothing was re-run; the full outputs are in the files named in the notes and in the session history. " +
210
- "Free space by editing the context mirror, then re-read only the parts you need, e.g. with sed -n or grep."
211
+ (options.noticesOnly
212
+ ? "Re-read only the parts you need, e.g. with sed -n or grep."
213
+ : "Free space by editing the context mirror, then re-read only the parts you need, e.g. with sed -n or grep.")
211
214
  );
212
215
  }
package/src/panel/keys.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  effectiveZoom,
14
14
  markerSelection,
15
15
  maxScroll,
16
+ pageViewport,
16
17
  revisionIndex,
17
18
  settingsPage,
18
19
  viewportHeight,
@@ -116,7 +117,7 @@ function clampSetting(model: PanelModel, index: number): number {
116
117
 
117
118
  function revealSetting(state: PanelState, model: PanelModel, size: PanelSize): PanelState {
118
119
  const { focus } = settingsPage(model, state, contentWidth(size));
119
- const viewport = viewportHeight(size);
120
+ const viewport = pageViewport(state, size);
120
121
  let scroll = state.scroll;
121
122
  if (focus < scroll) scroll = focus;
122
123
  else if (focus >= scroll + viewport) scroll = focus - viewport + 1;
@@ -226,6 +227,15 @@ function step(state: PanelState, key: Key, model: PanelModel, size: PanelSize):
226
227
  return { state };
227
228
  }
228
229
 
230
+ /**
231
+ * The settings page with a result message pinned below it. The message takes body rows, so
232
+ * the selected row is scrolled back into view if the message would cover it.
233
+ */
234
+ export function withMessage(state: PanelState, message: PanelState["message"], model: PanelModel, size: PanelSize): PanelState {
235
+ const next = { ...state, message };
236
+ return next.page === "settings" ? revealSetting(next, model, size) : next;
237
+ }
238
+
229
239
  /** Applies one key. The result's scroll is clamped to the page. */
230
240
  export function reduce(state: PanelState, key: Key, model: PanelModel, size: PanelSize): ReduceResult {
231
241
  const result = step(state, ALIASES[key] ?? key, model, size);
@@ -92,7 +92,7 @@ export interface BudgetView {
92
92
  reserve?: number;
93
93
  /** Overflow-guard limit (budget − reserve), from snapshot.json. */
94
94
  limit?: number;
95
- /** Where the budget came from (`config`, `model-window`). */
95
+ /** Where the budget came from (`config`, `model-window`, `window-fraction`, `fallback`). */
96
96
  source?: string;
97
97
  /** Fixed overhead per request (system prompt + tool schemas), tokens. */
98
98
  overhead?: number;
@@ -130,6 +130,8 @@ export interface PanelModel {
130
130
  mirrorPath: string;
131
131
  /** The newest `mirror-unavailable` event's reason: the session runs without a mirror. */
132
132
  mirrorUnavailable?: string;
133
+ /** The session runs `mode notices-only` (set by the caller from the settings in force). */
134
+ noticesOnly?: boolean;
133
135
  /** Problems reading the files (corrupt JSON, skipped event lines). */
134
136
  warnings: string[];
135
137
  }