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