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/NOTICE +2 -2
- package/README.md +17 -7
- package/index.ts +29 -10
- package/package.json +2 -2
- package/src/budget.ts +160 -16
- package/src/clm.ts +141 -56
- package/src/compaction.ts +14 -6
- package/src/context-document.ts +4 -1
- package/src/opencode.ts +10 -3
- 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/projection.ts +157 -14
- package/src/settings-table.ts +83 -17
- package/src/settings.ts +70 -3
- package/src/state.ts +50 -1
- package/src/tui/data.ts +8 -2
- package/src/tui/panel.ts +3 -2
- package/src/tui/plugin.ts +3 -2
- package/src/types.ts +2 -0
- /package/{skills → .agents/skills}/clm-context/SKILL.md +0 -0
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
|
|
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
|
-
/**
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1275
|
-
// remain valid on the next;
|
|
1276
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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/context-document.ts
CHANGED
|
@@ -54,7 +54,10 @@ function normalizeJson(value: unknown): unknown {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export function canonicalMessage(message: LiveContextMessage): string {
|
|
57
|
-
|
|
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
|
-
|
|
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
|
}
|