opencode-clm 0.3.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,
@@ -200,6 +206,12 @@ export class ClmSession {
200
206
  accepted = 0;
201
207
  rejected = 0;
202
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;
203
215
  scope: RequestScope = {};
204
216
  /** Set by `experimental.session.compacting`: the next transform carries OpenCode's compaction input. */
205
217
  compacting = false;
@@ -306,6 +318,11 @@ export class ClmSession {
306
318
  return this.overrides.editing ?? this.state.enabled;
307
319
  }
308
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
+
309
326
  /** Base and effective values, for the settings table. */
310
327
  settingsValues(): { base: SettingsValues; effective: SettingsValues } {
311
328
  return {
@@ -321,7 +338,7 @@ export class ClmSession {
321
338
  base: this.baseSettings,
322
339
  baseEditing: this.state.enabled,
323
340
  projectDirectory,
324
- format: { modelWindow: this.limits.context },
341
+ format: { modelWindow: this.limits.context, modelOutput: this.limits.output },
325
342
  };
326
343
  }
327
344
 
@@ -378,18 +395,26 @@ export class ClmSession {
378
395
  const before = this.settings.budget;
379
396
  const after = staged.settings.budget;
380
397
  const wasEnabled = this.enabled;
398
+ const wasMode = this.settings.mode;
381
399
  const changed = JSON.stringify(this.overrides) !== JSON.stringify(overrides);
382
400
  this.settings = staged.settings;
383
401
  this.steering = staged.steering;
384
402
  this.overrides = overrides;
385
- if (before.contextBudget !== after.contextBudget || before.reserve !== after.reserve ||
403
+ if (before.contextBudget !== after.contextBudget || before.contextFraction !== after.contextFraction || before.reserve !== after.reserve ||
386
404
  before.remindAtFractions.join(",") !== after.remindAtFractions.join(",") || before.remindAtReserve !== after.remindAtReserve) {
387
405
  this.tracker.reset();
388
406
  }
389
407
  if (wasEnabled !== this.enabled) {
390
408
  this.baseline = undefined;
409
+ this.tracker.clearCooldown();
391
410
  if (!this.enabled) this.continuitySize.observe(0);
392
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
+ }
393
418
  if (changed) await this.log({ event: "settings", overrides });
394
419
  }
395
420
 
@@ -454,10 +479,10 @@ export class ClmSession {
454
479
  }
455
480
 
456
481
  /**
457
- * 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
458
483
  * session's budget check found it too small for OpenCode's fixed overhead (`budgetFit`).
459
484
  */
460
- resolvedBudget(): Pick<BudgetReading, "budget" | "reserve" | "source" | "raisedFrom"> | undefined {
485
+ resolvedBudget(): Pick<BudgetReading, "budget" | "reserve" | "source" | "fraction" | "raisedFrom"> | undefined {
461
486
  const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
462
487
  if (!base) return undefined;
463
488
  const fit = budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap());
@@ -466,7 +491,7 @@ export class ClmSession {
466
491
 
467
492
  /** Model window minus its output limit, when known: the ceiling for a raised budget. */
468
493
  private windowCap(): number | undefined {
469
- 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;
470
495
  }
471
496
 
472
497
  /**
@@ -478,10 +503,19 @@ export class ClmSession {
478
503
  return base ? budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap()) : undefined;
479
504
  }
480
505
 
481
- /** 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
+
482
516
  guardLimit(): number | undefined {
483
517
  const resolved = this.resolvedBudget();
484
- return resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
518
+ return resolved && !this.provisional(resolved) ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
485
519
  }
486
520
 
487
521
  /**
@@ -555,7 +589,7 @@ export class ClmSession {
555
589
  ...(previous ? { replaces: previous.source, previousOverhead: previous.overhead } : {}),
556
590
  });
557
591
  if (!warn) return undefined;
558
- this.pendingNotices.push(budgetTooSmallNoticeText(fit));
592
+ this.pendingNotices.push(budgetTooSmallNoticeText(fit, { noticesOnly: this.noticesOnly }));
559
593
  return budgetTooSmallAlertText(fit);
560
594
  }
561
595
 
@@ -597,6 +631,7 @@ export class ClmSession {
597
631
  async setEnabled(enabled: boolean): Promise<void> {
598
632
  await this.updateState((state) => ({ ...state, enabled }));
599
633
  this.baseline = undefined;
634
+ this.tracker.clearCooldown();
600
635
  if (!enabled) this.continuitySize.observe(0);
601
636
  }
602
637
 
@@ -649,7 +684,7 @@ export class ClmSession {
649
684
  * calls that only read it or do not touch it.
650
685
  */
651
686
  receipt(tool: string, args: Record<string, unknown> | undefined, cwd: string): string | undefined {
652
- if (!this.settings.enabled || !this.enabled) return undefined;
687
+ if (!this.settings.enabled || !this.enabled || this.noticesOnly) return undefined;
653
688
  if (classifyMirrorToolCall(tool, args ?? {}, cwd, this.mirrorPath) !== "write") return undefined;
654
689
  const check = this.validateMirror(this.store.readSync());
655
690
  if (!check) return undefined;
@@ -661,13 +696,15 @@ export class ClmSession {
661
696
 
662
697
  /**
663
698
  * `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.
699
+ * result's calibrated estimate, against the budget in force); undefined when off or while
700
+ * the budget is unknown or a provisional fallback.
665
701
  */
666
702
  sizeTrailer(output: string): string | undefined {
667
703
  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);
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);
671
708
  }
672
709
 
673
710
  /**
@@ -678,7 +715,7 @@ export class ClmSession {
678
715
  */
679
716
  async commitBeforeCompaction(): Promise<void> {
680
717
  await this.refreshSettings();
681
- if (!this.settings.enabled || !this.enabled) return;
718
+ if (!this.settings.enabled || !this.enabled || this.noticesOnly) return;
682
719
  await this.commit();
683
720
  }
684
721
 
@@ -689,6 +726,7 @@ export class ClmSession {
689
726
 
690
727
  /** The mirror block the model last saw, for continuity annotations. */
691
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.");
692
730
  const snapshot = this.lastSnapshot;
693
731
  const block = snapshot?.blocks.find((candidate) => candidate.id === blockId);
694
732
  return snapshot && block ? { message: block.source, revision: snapshot.revision } : undefined;
@@ -761,6 +799,7 @@ export class ClmSession {
761
799
  return;
762
800
  }
763
801
  this.accepted += 1;
802
+ this.tracker.editAccepted();
764
803
  await this.remember({ version: 1, checkpoint, contentDigest: digestSourceContent(baseline.rawMessages) });
765
804
  const revisions = join(this.store.directory, "revisions");
766
805
  await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
@@ -1148,6 +1187,7 @@ export class ClmSession {
1148
1187
  return { messages: raw, notices: [], ...this.drainAlerts() };
1149
1188
  }
1150
1189
  if (this.mirrorUnavailable !== undefined) return await this.transformWithoutMirror(raw);
1190
+ const noticesOnly = this.noticesOnly;
1151
1191
  const pinned = pinnedCount(raw);
1152
1192
  const pinnedMessages = raw.slice(0, pinned);
1153
1193
  const context = this.context(raw);
@@ -1158,15 +1198,23 @@ export class ClmSession {
1158
1198
  // A pending edit was committed by `commitBeforeCompaction` just before.
1159
1199
  if (this.compacting) {
1160
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() };
1161
1203
  const projection = applyProjection(flatten(raw.slice(pinned)), this.state.checkpoint);
1162
1204
  if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [], ...this.drainAlerts() };
1163
1205
  return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [], ...this.drainAlerts() };
1164
1206
  }
1165
1207
 
1166
- 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();
1167
1213
  this.requests += 1;
1168
- const compacted = this.compacted;
1169
- 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;
1170
1218
 
1171
1219
  // The raw source prefix is digested as flattened, before any view, cap or guard.
1172
1220
  const source = flatten(raw.slice(pinned));
@@ -1179,13 +1227,16 @@ export class ClmSession {
1179
1227
  // overhead (built-in tool schemas) as undercounting; start calibration over.
1180
1228
  if (measured?.source === "provider") this.calibrator.reset();
1181
1229
 
1182
- 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);
1183
1232
  // A late or repeated compaction signal must not swallow a later revert: rebase only
1184
1233
  // onto a summary the active checkpoint does not already cover.
1185
1234
  const summaryId = compacted ? compactionSummaryId(raw) : undefined;
1186
1235
  const rebase = summaryId !== undefined && !projection.valid && this.state.checkpoint !== undefined &&
1187
1236
  !this.state.checkpoint.sourceIds.includes(summaryId);
1188
- if (rebase) {
1237
+ if (noticesOnly) {
1238
+ // Raw history: nothing to rebase, restore or reset.
1239
+ } else if (rebase) {
1189
1240
  await this.rebaseAfterCompaction(summaryId);
1190
1241
  projection = applyProjection(source, undefined);
1191
1242
  } else if (!projection.valid && (await this.restoreAfterMismatch(source, projection.reason))) {
@@ -1194,6 +1245,8 @@ export class ClmSession {
1194
1245
  projection = applyProjection(source, this.state.checkpoint);
1195
1246
  } else if (!projection.valid) {
1196
1247
  const dropped = this.state.checkpoint?.revision;
1248
+ // The edit the cooldown measures from no longer applies.
1249
+ this.tracker.clearCooldown();
1197
1250
  this.invalidationStreak += 1;
1198
1251
  const reason = projection.reason;
1199
1252
  await this.updateState((state) => resetProjectionState(state, reason), true).catch(() => undefined);
@@ -1213,10 +1266,10 @@ export class ClmSession {
1213
1266
  }
1214
1267
  const origin = this.forkOrigin;
1215
1268
  this.forkOrigin = undefined;
1216
- if (origin && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
1269
+ if (origin && !noticesOnly && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
1217
1270
  projection = applyProjection(source, this.state.checkpoint);
1218
1271
  }
1219
- const checkpoint = this.state.checkpoint;
1272
+ const checkpoint = noticesOnly ? undefined : this.state.checkpoint;
1220
1273
  const suffixLength = projection.valid ? projection.suffix.length : 0;
1221
1274
 
1222
1275
  let effective = projection.messages;
@@ -1229,7 +1282,13 @@ export class ClmSession {
1229
1282
  formatContinuityMessage({ annotations, effectiveMessages: messages });
1230
1283
  const pinnedTokens = this.estimate(flatten(pinnedMessages));
1231
1284
  const resolved = this.resolvedBudget();
1232
- const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
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);
1233
1292
  const { systemTokens, toolTokens } = this.scope;
1234
1293
  const { measuredOverhead, scopeTokens } = this.scopeSize();
1235
1294
  const excluded = measuredOverhead !== undefined ? [] : [
@@ -1253,7 +1312,7 @@ export class ClmSession {
1253
1312
  if (guarded.withheld.length > 0) {
1254
1313
  effective = guarded.messages;
1255
1314
  withheldCount = guarded.withheld.length;
1256
- this.pendingNotices.push(overflowNoticeText(guarded, limit));
1315
+ this.pendingNotices.push(overflowNoticeText(guarded, limit, { noticesOnly }));
1257
1316
  await this.log({
1258
1317
  event: "overflow-guard",
1259
1318
  withheld: guarded.withheld.map((record) => ({ id: record.toolCallId, tokens: record.tokens, file: record.file })),
@@ -1268,30 +1327,33 @@ export class ClmSession {
1268
1327
  this.pendingNotices.push(continuitySizeNoticeText(this.textTokens(continuity ?? "")));
1269
1328
  }
1270
1329
 
1271
- 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, {
1272
1332
  revision: this.state.revision,
1273
1333
  protectedIndexes: new Set<number>(),
1274
1334
  // Constant until the next accepted edit or reset, so header ids read on one call
1275
1335
  // remain valid on the next; bodies are escaped accordingly.
1276
1336
  documentSeed: `${this.sessionID}:${this.nonce}:${checkpoint?.sourceDigest ?? "raw"}`,
1277
1337
  });
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;
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
+ }
1295
1357
  }
1296
1358
 
1297
1359
  const conversationTokens = pinnedTokens + this.estimate(effective) + continuityTokens;
@@ -1307,10 +1369,16 @@ export class ClmSession {
1307
1369
  // The measured request was answered inside the prefix the active revision replaced.
1308
1370
  observedStale: observed !== undefined && checkpoint !== undefined && checkpoint.sourceIds.includes(observed.messageID),
1309
1371
  };
1310
- 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
+ }
1311
1379
  if (tier) {
1312
1380
  // 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));
1381
+ this.pendingNotices.push(budgetNoticeText(reading, tier, noticesOnly ? undefined : this.mirrorPath, this.settings.guard === "off" ? null : limit, { noticesOnly }));
1314
1382
  await this.log({ event: "budget-notice", tier: tier.label, estimated: reading.estimated });
1315
1383
  }
1316
1384
  this.lastReading = reading;
@@ -1338,7 +1406,7 @@ export class ClmSession {
1338
1406
  }
1339
1407
 
1340
1408
  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 };
1409
+ this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: snapshot?.blocks.length ?? 0 };
1342
1410
  if (this.settings.dumpRequests) {
1343
1411
  const directory = join(this.store.directory, "requests");
1344
1412
  await mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => undefined);
@@ -1365,7 +1433,8 @@ export class ClmSession {
1365
1433
  users: raw.filter((message) => message.info.role === "user").length,
1366
1434
  raw: raw.length,
1367
1435
  sent: messages.length,
1368
- blocks: snapshot.blocks.length,
1436
+ blocks: snapshot?.blocks.length ?? 0,
1437
+ ...(noticesOnly ? { mode: "notices-only" } : {}),
1369
1438
  withheld: withheldCount,
1370
1439
  estimated: reading?.estimated ?? requestTokens,
1371
1440
  observedPrevious: observed?.tokens,
@@ -1399,12 +1468,15 @@ export class ClmSession {
1399
1468
  status(steering: SteeringDocument | undefined = this.steering): ClmStatus {
1400
1469
  const checkpoint = this.state.checkpoint;
1401
1470
  const { base, effective } = this.settingsValues();
1402
- const changed = changedSummary(base, effective, { modelWindow: this.limits.context });
1471
+ const changed = changedSummary(base, effective, { modelWindow: this.limits.context, modelOutput: this.limits.output });
1403
1472
  return {
1404
1473
  ...(changed ? { changed } : {}),
1405
1474
  ...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
1406
1475
  sessionID: this.sessionID,
1407
- mirrorPath: this.mirrorUnavailable === undefined ? this.mirrorPath : `unavailable, requests carry the raw history (${this.mirrorUnavailable})`,
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 } : {}),
1408
1480
  revision: this.state.revision,
1409
1481
  accepted: this.accepted,
1410
1482
  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. */
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
  }
package/src/panel/view.ts CHANGED
@@ -52,6 +52,7 @@ const EDIT_TONE: Tone = "edit";
52
52
  const KEPT_PREVIEW_CHARACTERS = 2_000;
53
53
  const MIRROR_TEXT_MAX_LINES = 200;
54
54
  const MIRROR_UNAVAILABLE_TEXT = "No mirror: requests carry the raw history plus the continuity annotations; edits are off.";
55
+ const NOTICES_ONLY_TEXT = "Mode notices-only: requests carry the raw history plus the budget notices; no mirror, so the model cannot edit its context (/clm config mode edit).";
55
56
 
56
57
  /** Body rows available for the page. */
57
58
  export function viewportHeight(size: PanelSize): number {
@@ -194,6 +195,8 @@ function overviewLines(model: PanelModel, state: PanelState, width: number, view
194
195
  ? text("Projection off: the model sees the raw history (/clm on).", "warning")
195
196
  : model.mirrorUnavailable !== undefined
196
197
  ? text(MIRROR_UNAVAILABLE_TEXT, "warning")
198
+ : model.noticesOnly
199
+ ? text(NOTICES_ONLY_TEXT, "warning")
197
200
  : model.lastOutcome?.kind === "rejected"
198
201
  ? text(`Last edit rejected: ${model.lastOutcome.message}`, "warning")
199
202
  : undefined;
@@ -478,12 +481,26 @@ export function settingsPage(model: PanelModel, state: PanelState, width: number
478
481
  const row = settings.rows[selected]!;
479
482
  if (row.description) lines.push([], ...wrapAll([text(row.description, "dim")], width));
480
483
  lines.push([], ...wrapAll([text(row.choices?.length ? "Enter: next value" : "Enter: type a value", "dim")], width));
481
- if (state.message) {
482
- lines.push(...wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width));
483
- }
484
484
  return { lines, focus };
485
485
  }
486
486
 
487
+ /**
488
+ * Lines pinned below the scrolling page: the settings page's result message ("✓ Budget:
489
+ * 20k"), so it stays on screen when the page is longer than the viewport. At most
490
+ * `viewport` − 1 lines, so one page line always shows.
491
+ */
492
+ export function pinnedLines(state: PanelState, width: number, viewport: number): Line[] {
493
+ if (state.page !== "settings" || !state.message) return [];
494
+ const message = wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width);
495
+ return message.slice(0, Math.max(0, viewport - 1));
496
+ }
497
+
498
+ /** Body rows left for the scrolling page once the pinned lines take theirs. */
499
+ export function pageViewport(state: PanelState, size: PanelSize): number {
500
+ const viewport = viewportHeight(size);
501
+ return Math.max(1, viewport - pinnedLines(state, contentWidth(size), viewport).length);
502
+ }
503
+
487
504
  // ---- frame ---------------------------------------------------------------------------
488
505
 
489
506
  /** The whole page at `width` (unclipped; the frame scrolls it). */
@@ -514,7 +531,7 @@ export function helpText(model: PanelModel, state: PanelState, width: number): s
514
531
  }
515
532
 
516
533
  export function maxScroll(model: PanelModel, state: PanelState, size: PanelSize): number {
517
- return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length - viewportHeight(size));
534
+ return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length - pageViewport(state, size));
518
535
  }
519
536
 
520
537
  /** The framed panel, exactly `size.height` lines of `size.width` cells. */
@@ -522,15 +539,18 @@ export function renderPanel(model: PanelModel, state: PanelState, size: PanelSiz
522
539
  const width = Math.max(8, size.width);
523
540
  const inner = width - 2;
524
541
  const content = contentWidth({ width, height: size.height });
525
- const viewport = viewportHeight(size);
526
- const page = pageLines(model, state, content, viewport);
542
+ const body = viewportHeight(size);
543
+ const page = pageLines(model, state, content, body);
544
+ const pinned = pinnedLines(state, content, body);
545
+ const viewport = pageViewport(state, size);
527
546
  const scroll = Math.max(0, Math.min(state.scroll, page.length - viewport));
528
- const visible = page.slice(scroll, scroll + viewport);
529
- while (visible.length < viewport) visible.push([]);
547
+ // The pinned lines follow the page directly when it fits, else sit at the bottom.
548
+ const visible = [...page.slice(scroll, scroll + viewport), ...pinned];
549
+ while (visible.length < body) visible.push([]);
530
550
 
531
551
  const border = (value: string) => span(value, "border");
532
552
  const row = (line: Line): Line => normalize([border("│"), span(" "), ...fit(line, inner - 1), border("│")]);
533
- const title = ` Live Context Viewer · r${model.revision} `;
553
+ const title = ` Live Context Viewer · r${model.revision}${model.noticesOnly && model.enabled ? " · notices-only" : ""} `;
534
554
  const titleText = truncate([span(title)], inner, "").map((part) => part.text).join("");
535
555
  const left = Math.max(0, Math.floor((inner - titleText.length) / 2));
536
556
  const right = Math.max(0, inner - titleText.length - left);
@@ -98,6 +98,8 @@ export interface ClmStatus {
98
98
  changed?: string;
99
99
  /** Saved settings that could not be used. */
100
100
  settingsWarning?: string;
101
+ /** Set in `mode notices-only`; absent in `edit`. */
102
+ mode?: "notices-only";
101
103
  }
102
104
 
103
105
  /** One line for a toast. */
@@ -105,7 +107,8 @@ export function statusLine(status: ClmStatus): string {
105
107
  const size = status.reading
106
108
  ? ` · ${formatTokens(status.reading.estimated)} of ${formatTokens(status.reading.budget)} tok`
107
109
  : "";
108
- return `CLM r${status.revision} · ${status.accepted} accepted / ${status.rejected} rejected${size}`;
110
+ const mode = status.mode ? ` · mode ${status.mode}` : "";
111
+ return `CLM r${status.revision}${mode} · ${status.accepted} accepted / ${status.rejected} rejected${size}`;
109
112
  }
110
113
 
111
114
  /** Multi-line status for `/clm`. */
@@ -113,6 +116,7 @@ export function statusText(status: ClmStatus): string {
113
116
  const lines = [
114
117
  `CLM status for session ${status.sessionID}`,
115
118
  `mirror: ${status.mirrorPath}`,
119
+ ...(status.mode ? ["mode notices-only: budget notices and the overflow guard run; no mirror and no protocol prompt, so the model cannot edit its context"] : []),
116
120
  `revision ${status.revision} · edits accepted ${status.accepted} · rejected ${status.rejected} · gate ${status.gate} · guard ${status.guard}`,
117
121
  status.reading
118
122
  ? `${budgetSummaryLine(status.reading)} · model window ${status.modelWindow ? formatTokens(status.modelWindow) : "unknown"}`
@@ -121,7 +125,9 @@ export function statusText(status: ClmStatus): string {
121
125
  if (status.fit) lines.push(budgetFitLine(status.fit));
122
126
  const checkpoint = status.checkpoint;
123
127
  lines.push(
124
- checkpoint
128
+ checkpoint && status.mode
129
+ ? `revision ${checkpoint.revision} kept but not applied in notices-only mode: the model sees the raw history`
130
+ : checkpoint
125
131
  ? `active revision ${checkpoint.revision}: covers ${checkpoint.anchorCount} raw messages; ~${formatTokens(checkpoint.beforeEstimate)}→${formatTokens(checkpoint.afterEstimate)} tokens when accepted`
126
132
  : "no accepted edit: the model sees the raw history",
127
133
  );