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/README.md +17 -7
- package/index.ts +29 -10
- package/package.json +1 -1
- package/src/budget.ts +160 -16
- package/src/clm.ts +119 -47
- package/src/compaction.ts +14 -6
- package/src/overflow.ts +6 -3
- package/src/panel/keys.ts +11 -1
- package/src/panel/model.ts +3 -1
- package/src/panel/view.ts +29 -9
- package/src/presentation.ts +8 -2
- package/src/settings-table.ts +83 -17
- package/src/settings.ts +69 -2
- package/src/tui/data.ts +8 -2
- package/src/tui/panel.ts +3 -2
- package/src/tui/plugin.ts +3 -2
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
|
|
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
|
-
/**
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|
package/src/panel/model.ts
CHANGED
|
@@ -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 -
|
|
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
|
|
526
|
-
const page = pageLines(model, state, content,
|
|
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
|
-
|
|
529
|
-
|
|
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);
|
package/src/presentation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
);
|