opencode-clm 0.1.1 → 0.3.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/NOTICE +9 -1
- package/README.md +112 -299
- package/index.ts +778 -34
- package/package.json +19 -4
- package/src/atomic.ts +27 -0
- package/src/budget.ts +12 -10
- package/src/channel.ts +187 -0
- package/src/clm.ts +610 -27
- package/src/commands.ts +18 -0
- package/src/compaction.ts +243 -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 +41 -3
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +205 -0
- package/src/panel/command.ts +75 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +107 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +569 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +460 -0
- package/src/panel/view.ts +554 -0
- package/src/presentation.ts +6 -0
- package/src/projection.ts +8 -0
- package/src/session-files.ts +286 -0
- package/src/settings-table.ts +454 -0
- package/src/settings.ts +33 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +241 -0
- package/src/tui/intercept.ts +60 -0
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +205 -0
- package/src/tui/plugin.ts +466 -0
- package/src/tui/remote.ts +199 -0
- package/tui.ts +7 -0
package/src/clm.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* included) was estimated; without those sizes it stays at 1.
|
|
34
34
|
*/
|
|
35
35
|
import { randomUUID } from "node:crypto";
|
|
36
|
-
import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
|
36
|
+
import { appendFile, mkdir, stat, writeFile } from "node:fs/promises";
|
|
37
37
|
import { join } from "node:path";
|
|
38
38
|
|
|
39
39
|
import {
|
|
@@ -54,17 +54,20 @@ import {
|
|
|
54
54
|
AnnotationStore,
|
|
55
55
|
ContinuitySizeTracker,
|
|
56
56
|
continuitySizeNoticeText,
|
|
57
|
+
forkAnnotations,
|
|
57
58
|
formatContinuityMessage,
|
|
58
59
|
type ContinuityBlockSource,
|
|
59
60
|
type LiveContextAnnotation,
|
|
60
61
|
} from "./continuity.ts";
|
|
61
62
|
import { classifyMirrorToolCall } from "./mirror-guard.ts";
|
|
62
|
-
import { MirrorStore } from "./mirror-store.ts";
|
|
63
|
+
import { MirrorDirectoryError, MirrorStore } from "./mirror-store.ts";
|
|
63
64
|
import { capObservations } from "./observation.ts";
|
|
64
65
|
import { flatten, noteMessage, unflatten, withoutReasoning, type OcInfo, type OcMessage } from "./opencode.ts";
|
|
66
|
+
import { sizeTrailer } from "./compaction.ts";
|
|
65
67
|
import { applyOverflowGuard, overflowGuardLimit, overflowNoticeText } from "./overflow.ts";
|
|
66
68
|
import type { ClmStatus } from "./presentation.ts";
|
|
67
|
-
import { applyProjection, createProjectionCheckpoint, type ProjectionCheckpoint } from "./projection.ts";
|
|
69
|
+
import { applyProjection, createProjectionCheckpoint, digestSourceContent, type ProjectionCheckpoint } from "./projection.ts";
|
|
70
|
+
import { clearHistory, loadHistory, remapProjection, saveHistoryEntry, type HistoryEntry } from "./history.ts";
|
|
68
71
|
import type { ClmSettings } from "./settings.ts";
|
|
69
72
|
import {
|
|
70
73
|
keptFields,
|
|
@@ -75,13 +78,19 @@ import {
|
|
|
75
78
|
type LiveContextState,
|
|
76
79
|
} from "./state.ts";
|
|
77
80
|
import type { SteeringDocument } from "./steering.ts";
|
|
78
|
-
import
|
|
81
|
+
import { overridesPath, readOverrides, stageSettings, type ChangeRequest, type StagedSettings } from "./overrides.ts";
|
|
82
|
+
import { changedSummary, settingsAsOverrides, type ClmOverrides, type SettingsValues } from "./settings-table.ts";
|
|
83
|
+
import type { ContextDocumentSnapshot, ContextEditTrace, LiveContextMessage, TurnBaseline } from "./types.ts";
|
|
84
|
+
import { writeJsonAtomic } from "./atomic.ts";
|
|
85
|
+
import { SNAPSHOT_FILE, type RevisionFile, type RevisionFileRow, type SnapshotFile } from "./panel/files.ts";
|
|
79
86
|
|
|
80
87
|
export interface ModelLimits {
|
|
81
88
|
/** Model context window, tokens. */
|
|
82
89
|
context?: number;
|
|
83
90
|
/** Model output limit, tokens; subtracted from the window by `resolveBudget`. */
|
|
84
91
|
output?: number;
|
|
92
|
+
/** Model input limit, tokens, when the provider sets one; only OpenCode's threshold reads it (src/compaction.ts). */
|
|
93
|
+
input?: number;
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
/** Sizes of the request parts the plugin does not see in the messages hook. */
|
|
@@ -92,6 +101,11 @@ export interface RequestScope {
|
|
|
92
101
|
toolTokens?: number;
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
/** Toast when edits keep being dropped (pi `compositionWarningText`, reworded for OpenCode). */
|
|
105
|
+
export const COMPOSITION_WARNING = "CLM edits keep being dropped: OpenCode's history changed between consecutive requests. " +
|
|
106
|
+
"Another plugin that rewrites already-sent messages (experimental.chat.messages.transform) is the usual cause; " +
|
|
107
|
+
"load opencode-clm before it (earlier in the `plugin` list), or turn CLM off for this session with /clm off.";
|
|
108
|
+
|
|
95
109
|
export interface TransformResult {
|
|
96
110
|
/** The messages to send; the caller writes them into OpenCode's array in place. */
|
|
97
111
|
messages: OcMessage[];
|
|
@@ -100,8 +114,10 @@ export interface TransformResult {
|
|
|
100
114
|
/** Calibrated estimate of this request, tokens (system prompt and tool schemas included when known); undefined when CLM did not run. */
|
|
101
115
|
estimated?: number;
|
|
102
116
|
reading?: BudgetReading;
|
|
103
|
-
/** User-facing warning for a toast (
|
|
117
|
+
/** User-facing warning for a toast (budget too small, composition warning, ignored saved settings); set on one request only. */
|
|
104
118
|
alert?: string;
|
|
119
|
+
/** User-facing errors for toasts: the mirror could not be read or refreshed, a revision could not be saved. */
|
|
120
|
+
errors?: string[];
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
export interface MirrorCheck {
|
|
@@ -192,6 +208,23 @@ export class ClmSession {
|
|
|
192
208
|
* `experimental.compaction.autocontinue` hook); the next transform rebases onto the summary.
|
|
193
209
|
*/
|
|
194
210
|
compacted = false;
|
|
211
|
+
/**
|
|
212
|
+
* Set by the plugin when OpenCode's session metadata names another session as the origin
|
|
213
|
+
* (a fork, `Session.fork` copies metadata). The first transform restores the newest of the
|
|
214
|
+
* origin's checkpoints whose source the forked history still contains, then clears it.
|
|
215
|
+
*/
|
|
216
|
+
forkOrigin?: { sessionID: string; directory: string; /** The fork's creation time (ms), when OpenCode reported it. */ created?: number };
|
|
217
|
+
/** The origin's annotations from cloned session metadata (a fork without a mirror); used when the origin's store yields none. */
|
|
218
|
+
forkPersisted?: LiveContextAnnotation[];
|
|
219
|
+
/** Set when a fork's first request copied annotations in; the plugin then persists them (no mirror). */
|
|
220
|
+
annotationsImported = false;
|
|
221
|
+
/**
|
|
222
|
+
* Why no mirror directory could be used, set by the plugin (M1). The store then sits in a
|
|
223
|
+
* private temporary directory the model is never told about; requests carry the raw
|
|
224
|
+
* history plus the continuity annotations and their size notice, as pi-clm does when its
|
|
225
|
+
* mirror store fails to initialize.
|
|
226
|
+
*/
|
|
227
|
+
mirrorUnavailable?: string;
|
|
195
228
|
/** What the last transform rendered; the next transform commits the mirror against it. */
|
|
196
229
|
baseline?: Baseline;
|
|
197
230
|
/** The snapshot now in the mirror file; continuity tools resolve block ids against it. */
|
|
@@ -201,9 +234,21 @@ export class ClmSession {
|
|
|
201
234
|
requests = 0;
|
|
202
235
|
|
|
203
236
|
private pendingNotices: string[] = [];
|
|
237
|
+
/** Errors for the user (toasts), returned with the next transform result. */
|
|
238
|
+
private pendingErrors: string[] = [];
|
|
239
|
+
/** The mirror could not be written last time; its error toast is not repeated until a write succeeds. */
|
|
240
|
+
private refreshFailing = false;
|
|
241
|
+
/** Why the steering document in the settings did not load, for snapshot.json (the panel). */
|
|
242
|
+
steeringError?: string;
|
|
204
243
|
/** Raw length and conversation estimate (calibrated, tokens) of the last request, for the overhead measurement. */
|
|
205
244
|
private previousRequest?: { rawCount: number; conversation: number };
|
|
206
245
|
private invalidationStreak = 0;
|
|
246
|
+
/** The composition warning toast is shown once per session (pi: `compositionWarned`). */
|
|
247
|
+
private compositionWarned = false;
|
|
248
|
+
/** User-facing warnings for the next transform result (`alert`). */
|
|
249
|
+
private pendingAlerts: string[] = [];
|
|
250
|
+
/** The settings warning last shown to the user, so a new or changed one toasts once. */
|
|
251
|
+
private shownSettingsWarning?: string;
|
|
207
252
|
/**
|
|
208
253
|
* Per-process seed of the stable document id. Fresh on every open, so a document id never
|
|
209
254
|
* repeats even when a corrupt state file resets the revision counter to 0.
|
|
@@ -213,34 +258,175 @@ export class ClmSession {
|
|
|
213
258
|
private saving: Promise<void> = Promise.resolve();
|
|
214
259
|
private running: Promise<unknown> = Promise.resolve();
|
|
215
260
|
|
|
261
|
+
/** Settings in force: `baseSettings` with this session's overrides.json applied (`refreshSettings`). */
|
|
262
|
+
settings: ClmSettings;
|
|
263
|
+
/** Overrides in force, as read from overrides.json. */
|
|
264
|
+
overrides: ClmOverrides = {};
|
|
265
|
+
/** The steering document in force for this session; undefined is protocol only. */
|
|
266
|
+
steering?: SteeringDocument;
|
|
267
|
+
/** Why saved settings or the steering document were (partly) ignored; shown by `/clm status`. */
|
|
268
|
+
settingsWarning?: string;
|
|
269
|
+
/** stat key of the overrides.json last read; a change triggers a re-read. */
|
|
270
|
+
private overridesKey = "";
|
|
271
|
+
|
|
216
272
|
private constructor(
|
|
217
273
|
readonly sessionID: string,
|
|
218
274
|
readonly store: MirrorStore,
|
|
219
|
-
readonly
|
|
275
|
+
readonly baseSettings: ClmSettings,
|
|
220
276
|
loaded: { state: LiveContextState; warning?: string },
|
|
277
|
+
readonly baseSteering?: SteeringDocument,
|
|
221
278
|
) {
|
|
279
|
+
this.settings = baseSettings;
|
|
280
|
+
this.steering = baseSteering;
|
|
222
281
|
this.state = loaded.state;
|
|
223
282
|
this.loadWarning = loaded.warning;
|
|
224
283
|
this.annotations = new AnnotationStore(store.directory, { estimateTokens: (text) => this.textTokens(text) });
|
|
225
284
|
}
|
|
226
285
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. `steering` is the
|
|
288
|
+
* document the base settings name, already loaded; undefined when it failed to load at start
|
|
289
|
+
* (each session then retries the path and records the error in `settingsWarning`).
|
|
290
|
+
*/
|
|
291
|
+
static async open(sessionID: string, settings: ClmSettings, options: { steering?: SteeringDocument } = {}): Promise<ClmSession> {
|
|
229
292
|
if (!SESSION_ID_RE.test(sessionID)) throw new Error(`Invalid session id for CLM: ${JSON.stringify(sessionID)}`);
|
|
230
|
-
const store = await MirrorStore.create(sessionID, settings.mirrorDir)
|
|
293
|
+
const store = await MirrorStore.create(sessionID, settings.mirrorDir).catch((error: unknown) => {
|
|
294
|
+
throw new MirrorDirectoryError(settings.mirrorDir, error);
|
|
295
|
+
});
|
|
231
296
|
const loaded = await loadLiveContextState(store.directory);
|
|
232
|
-
const session = new ClmSession(sessionID, store, settings, loaded);
|
|
297
|
+
const session = new ClmSession(sessionID, store, settings, loaded, options.steering);
|
|
233
298
|
if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
|
|
234
299
|
if (loaded.repaired) await session.log({ event: "state-repaired", message: loaded.repaired });
|
|
300
|
+
await session.refreshSettings();
|
|
235
301
|
return session;
|
|
236
302
|
}
|
|
237
303
|
|
|
304
|
+
/** Whether CLM edits apply in this session: the `editing` override, else state.json. */
|
|
305
|
+
get enabled(): boolean {
|
|
306
|
+
return this.overrides.editing ?? this.state.enabled;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Base and effective values, for the settings table. */
|
|
310
|
+
settingsValues(): { base: SettingsValues; effective: SettingsValues } {
|
|
311
|
+
return {
|
|
312
|
+
base: { editing: this.state.enabled, settings: this.baseSettings },
|
|
313
|
+
effective: { editing: this.enabled, settings: this.settings },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** For `changeSetting` (overrides.ts): where and against what a change is validated. */
|
|
318
|
+
changeRequest(projectDirectory: string): ChangeRequest {
|
|
319
|
+
return {
|
|
320
|
+
sessionDirectory: this.store.directory,
|
|
321
|
+
base: this.baseSettings,
|
|
322
|
+
baseEditing: this.state.enabled,
|
|
323
|
+
projectDirectory,
|
|
324
|
+
format: { modelWindow: this.limits.context },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Re-read overrides.json when it changed (stat: mtime, size, inode) and activate it.
|
|
330
|
+
* Runs before every request and every `/clm` command, so a change written by the TUI or
|
|
331
|
+
* the server's `/clm config` applies from the next request. Invalid keys are dropped; a
|
|
332
|
+
* set that does not resolve falls back to the base settings; a steering document that
|
|
333
|
+
* does not load leaves the session protocol-only. Each case is kept in `settingsWarning`.
|
|
334
|
+
* `force` re-reads even when the file looks unchanged.
|
|
335
|
+
*/
|
|
336
|
+
async refreshSettings(force = false): Promise<void> {
|
|
337
|
+
const path = overridesPath(this.store.directory);
|
|
338
|
+
let key = "missing";
|
|
339
|
+
try {
|
|
340
|
+
const info = await stat(path);
|
|
341
|
+
key = `${info.mtimeMs}:${info.size}:${info.ino}`;
|
|
342
|
+
} catch {
|
|
343
|
+
// missing: no overrides
|
|
344
|
+
}
|
|
345
|
+
if (!force && key === this.overridesKey) return;
|
|
346
|
+
this.overridesKey = key;
|
|
347
|
+
const read = key === "missing" ? { overrides: {} as ClmOverrides } : await readOverrides(this.store.directory);
|
|
348
|
+
const warnings: string[] = read.warning ? [read.warning] : [];
|
|
349
|
+
let staged: StagedSettings;
|
|
350
|
+
let overrides = read.overrides;
|
|
351
|
+
try {
|
|
352
|
+
staged = stageSettings(this.baseSettings, overrides, { strict: false, loaded: this.steering ?? this.baseSteering });
|
|
353
|
+
} catch (error) {
|
|
354
|
+
warnings.push(`ignored saved settings: ${describe(error)}`);
|
|
355
|
+
overrides = {};
|
|
356
|
+
// The base settings resolved at load; staging them again keeps a base steering error.
|
|
357
|
+
staged = stageSettings(this.baseSettings, {}, { strict: false, loaded: this.baseSteering });
|
|
358
|
+
}
|
|
359
|
+
// Only warnings about saved settings toast here. The base steering document failing
|
|
360
|
+
// again (no override names another path) already toasted once at load (index.ts).
|
|
361
|
+
const alerting = [...warnings];
|
|
362
|
+
if (staged.steeringError) {
|
|
363
|
+
const warning = `steering document not loaded: ${staged.steeringError}`;
|
|
364
|
+
warnings.push(warning);
|
|
365
|
+
if (staged.settings.steeringPath !== this.baseSettings.steeringPath) alerting.push(warning);
|
|
366
|
+
}
|
|
367
|
+
this.steeringError = staged.steeringError;
|
|
368
|
+
await this.activateSettings(staged, overrides);
|
|
369
|
+
this.settingsWarning = warnings.length > 0 ? warnings.join("; ") : undefined;
|
|
370
|
+
const alert = alerting.length > 0 ? alerting.join("; ") : undefined;
|
|
371
|
+
if (alert && alert !== this.shownSettingsWarning) {
|
|
372
|
+
this.pendingAlerts.push(`saved settings partly ignored: ${alert}`);
|
|
373
|
+
}
|
|
374
|
+
this.shownSettingsWarning = alert;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private async activateSettings(staged: StagedSettings, overrides: ClmOverrides): Promise<void> {
|
|
378
|
+
const before = this.settings.budget;
|
|
379
|
+
const after = staged.settings.budget;
|
|
380
|
+
const wasEnabled = this.enabled;
|
|
381
|
+
const changed = JSON.stringify(this.overrides) !== JSON.stringify(overrides);
|
|
382
|
+
this.settings = staged.settings;
|
|
383
|
+
this.steering = staged.steering;
|
|
384
|
+
this.overrides = overrides;
|
|
385
|
+
if (before.contextBudget !== after.contextBudget || before.reserve !== after.reserve ||
|
|
386
|
+
before.remindAtFractions.join(",") !== after.remindAtFractions.join(",") || before.remindAtReserve !== after.remindAtReserve) {
|
|
387
|
+
this.tracker.reset();
|
|
388
|
+
}
|
|
389
|
+
if (wasEnabled !== this.enabled) {
|
|
390
|
+
this.baseline = undefined;
|
|
391
|
+
if (!this.enabled) this.continuitySize.observe(0);
|
|
392
|
+
}
|
|
393
|
+
if (changed) await this.log({ event: "settings", overrides });
|
|
394
|
+
}
|
|
395
|
+
|
|
238
396
|
get mirrorPath(): string {
|
|
239
397
|
return this.store.filePath;
|
|
240
398
|
}
|
|
241
399
|
|
|
242
400
|
// ---- measurement -------------------------------------------------------------------
|
|
243
401
|
|
|
402
|
+
/**
|
|
403
|
+
* System prompt and tool schemas, when the hooks reported them (one request late).
|
|
404
|
+
* A provider-measured overhead replaces them: it is in real tokens, so it is not
|
|
405
|
+
* calibrated, and it includes the built-in tool schemas the hooks cannot size. The
|
|
406
|
+
* hook sizes stay a floor, since they count text that is certainly sent.
|
|
407
|
+
*/
|
|
408
|
+
private scopeSize(): { measuredOverhead?: number; scopeTokens: number } {
|
|
409
|
+
const { systemTokens, toolTokens } = this.scope;
|
|
410
|
+
const measuredOverhead = this.state.budgetCheck?.source === "provider"
|
|
411
|
+
? Math.max(this.state.budgetCheck.overhead, (systemTokens ?? 0) + (toolTokens ?? 0))
|
|
412
|
+
: undefined;
|
|
413
|
+
return { ...(measuredOverhead !== undefined ? { measuredOverhead } : {}), scopeTokens: measuredOverhead ?? this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0)) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* `/clm-compact`: about how large the next request would be, from the stored history
|
|
418
|
+
* (as OpenCode sends it, see `filterCompacted`) through the accepted revision (continuity and notices not
|
|
419
|
+
* counted). Undefined when the history holds no message to compact.
|
|
420
|
+
*/
|
|
421
|
+
idleEstimate(raw: OcMessage[]): number | undefined {
|
|
422
|
+
const pinned = pinnedCount(raw);
|
|
423
|
+
const source = flatten(raw.slice(pinned));
|
|
424
|
+
if (source.length === 0) return undefined;
|
|
425
|
+
const projection = applyProjection(source, this.state.checkpoint);
|
|
426
|
+
const effective = projection.valid ? projection.messages : source;
|
|
427
|
+
return this.scopeSize().scopeTokens + this.estimate(flatten(raw.slice(0, pinned))) + this.estimate(effective);
|
|
428
|
+
}
|
|
429
|
+
|
|
244
430
|
/** Uncalibrated tokens of a text: chars/4 × estimateFactor. */
|
|
245
431
|
textTokens(text: string): number {
|
|
246
432
|
return Math.ceil((text.length / CHARS_PER_TOKEN) * this.settings.estimateFactor);
|
|
@@ -420,6 +606,9 @@ export class ClmSession {
|
|
|
420
606
|
this.baseline = undefined;
|
|
421
607
|
this.tracker.reset();
|
|
422
608
|
this.continuitySize.reset();
|
|
609
|
+
// The user dropped the edits on purpose: a later revert must not bring one back. Queued
|
|
610
|
+
// behind any history write in flight; writes queued later see no checkpoint and skip.
|
|
611
|
+
await this.historyTask(() => clearHistory(this.store.directory));
|
|
423
612
|
await this.log({ event: "reset", revision: next.revision, reason });
|
|
424
613
|
}
|
|
425
614
|
|
|
@@ -460,7 +649,7 @@ export class ClmSession {
|
|
|
460
649
|
* calls that only read it or do not touch it.
|
|
461
650
|
*/
|
|
462
651
|
receipt(tool: string, args: Record<string, unknown> | undefined, cwd: string): string | undefined {
|
|
463
|
-
if (!this.settings.enabled || !this.
|
|
652
|
+
if (!this.settings.enabled || !this.enabled) return undefined;
|
|
464
653
|
if (classifyMirrorToolCall(tool, args ?? {}, cwd, this.mirrorPath) !== "write") return undefined;
|
|
465
654
|
const check = this.validateMirror(this.store.readSync());
|
|
466
655
|
if (!check) return undefined;
|
|
@@ -470,6 +659,34 @@ export class ClmSession {
|
|
|
470
659
|
: `[CLM] Mirror edit would be refused: ${check.message} Correct the file before this step ends, or the context stays as it is.`;
|
|
471
660
|
}
|
|
472
661
|
|
|
662
|
+
/**
|
|
663
|
+
* `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.
|
|
665
|
+
*/
|
|
666
|
+
sizeTrailer(output: string): string | undefined {
|
|
667
|
+
if (!this.settings.enabled || !this.enabled || !this.settings.trailer || this.mirrorUnavailable !== undefined) return undefined;
|
|
668
|
+
const budget = this.resolvedBudget()?.budget;
|
|
669
|
+
if (!budget) return undefined;
|
|
670
|
+
return sizeTrailer(this.lastReading?.estimated ?? 0, this.calibrator.apply(this.textTokens(output)), budget);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Before an OpenCode compaction (`experimental.session.compacting`): commit the edit the
|
|
675
|
+
* model made in its final step. No request followed that step (e.g. the user typed
|
|
676
|
+
* /compact next), so the commit at the start of a transform has not run; without this the
|
|
677
|
+
* compaction would summarize the pre-edit context and the edit would be lost.
|
|
678
|
+
*/
|
|
679
|
+
async commitBeforeCompaction(): Promise<void> {
|
|
680
|
+
await this.refreshSettings();
|
|
681
|
+
if (!this.settings.enabled || !this.enabled) return;
|
|
682
|
+
await this.commit();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Adds a notice to the next request (e.g. from a bus event). */
|
|
686
|
+
queueNotice(text: string): void {
|
|
687
|
+
this.pendingNotices.push(text);
|
|
688
|
+
}
|
|
689
|
+
|
|
473
690
|
/** The mirror block the model last saw, for continuity annotations. */
|
|
474
691
|
blockSource(blockId: string): ContinuityBlockSource | undefined {
|
|
475
692
|
const snapshot = this.lastSnapshot;
|
|
@@ -487,6 +704,7 @@ export class ClmSession {
|
|
|
487
704
|
text = await this.store.read();
|
|
488
705
|
} catch (error) {
|
|
489
706
|
this.pendingNotices.push(`[CLM] Could not read the mirror: ${describe(error)}`);
|
|
707
|
+
this.pendingErrors.push(`could not read the mirror, the edit was not applied: ${describe(error)}`);
|
|
490
708
|
return;
|
|
491
709
|
}
|
|
492
710
|
if (text === undefined || text.trim() === baseline.snapshot.text.trim()) return;
|
|
@@ -539,12 +757,18 @@ export class ClmSession {
|
|
|
539
757
|
} catch (error) {
|
|
540
758
|
// Persist before activate: an unsaved revision would vanish on restart.
|
|
541
759
|
this.pendingNotices.push(`[CLM] Revision ${revision} was not applied: it could not be saved (${describe(error)}); the previous context stays in effect.`);
|
|
760
|
+
this.pendingErrors.push(`revision ${revision} could not be saved, the previous context stays in effect: ${describe(error)}`);
|
|
542
761
|
return;
|
|
543
762
|
}
|
|
544
763
|
this.accepted += 1;
|
|
764
|
+
await this.remember({ version: 1, checkpoint, contentDigest: digestSourceContent(baseline.rawMessages) });
|
|
545
765
|
const revisions = join(this.store.directory, "revisions");
|
|
546
766
|
await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
|
|
547
767
|
await writeFile(join(revisions, `r${revision}.md`), text, { mode: 0o600 }).catch(() => undefined);
|
|
768
|
+
if (trace) {
|
|
769
|
+
await writeJsonAtomic(join(revisions, `r${revision}.json`), this.revisionFile(revision, at, baseline, result.messages, trace, sizes))
|
|
770
|
+
.catch((error: unknown) => this.log({ event: "revision-file-error", revision, error: describe(error) }));
|
|
771
|
+
}
|
|
548
772
|
const notes = result.diagnostics.length > 0 ? ` ${result.diagnostics.join(" ")}` : "";
|
|
549
773
|
this.pendingNotices.push(`[CLM] ${applied}${notes}`);
|
|
550
774
|
await this.log({
|
|
@@ -566,6 +790,102 @@ export class ClmSession {
|
|
|
566
790
|
});
|
|
567
791
|
}
|
|
568
792
|
|
|
793
|
+
/** `revisions/rN.json` for the panel: every row's full text and role before and after. */
|
|
794
|
+
private revisionFile(
|
|
795
|
+
revision: number,
|
|
796
|
+
at: string,
|
|
797
|
+
baseline: Baseline,
|
|
798
|
+
output: readonly LiveContextMessage[],
|
|
799
|
+
trace: ContextEditTrace,
|
|
800
|
+
sizes: { beforeEstimate: number; afterEstimate: number },
|
|
801
|
+
): RevisionFile {
|
|
802
|
+
const rows: RevisionFileRow[] = [];
|
|
803
|
+
const roleOf = (message: LiveContextMessage | undefined) => (message ? String(message.role) : "?");
|
|
804
|
+
for (const source of trace.sources) {
|
|
805
|
+
const before = baseline.snapshot.blocks[source.sourceIndex]?.source;
|
|
806
|
+
const after = source.outputIndex !== undefined ? output[source.outputIndex] : undefined;
|
|
807
|
+
const row: RevisionFileRow = { kind: source.kind, sourceIndex: source.sourceIndex, role: roleOf(after ?? before) };
|
|
808
|
+
if (source.outputIndex !== undefined) row.outputIndex = source.outputIndex;
|
|
809
|
+
if (before && after && roleOf(before) !== roleOf(after)) {
|
|
810
|
+
row.beforeRole = roleOf(before);
|
|
811
|
+
row.afterRole = roleOf(after);
|
|
812
|
+
}
|
|
813
|
+
if (before) row.beforeTokens = this.estimate([before]);
|
|
814
|
+
if (after && source.kind !== "removed") row.afterTokens = this.estimate([after]);
|
|
815
|
+
const beforeText = before ? renderMessage(before) : undefined;
|
|
816
|
+
const afterText = after && source.kind !== "removed" ? renderMessage(after) : undefined;
|
|
817
|
+
// Unchanged rows (kept, most restored) store their text once.
|
|
818
|
+
if (beforeText !== undefined && beforeText === afterText) row.text = beforeText;
|
|
819
|
+
else {
|
|
820
|
+
if (beforeText !== undefined) row.before = beforeText;
|
|
821
|
+
if (afterText !== undefined) row.after = afterText;
|
|
822
|
+
}
|
|
823
|
+
rows.push(row);
|
|
824
|
+
}
|
|
825
|
+
for (const addition of trace.additions) {
|
|
826
|
+
const after = output[addition.outputIndex];
|
|
827
|
+
rows.push({
|
|
828
|
+
kind: "added",
|
|
829
|
+
outputIndex: addition.outputIndex,
|
|
830
|
+
role: roleOf(after),
|
|
831
|
+
...(after ? { after: renderMessage(after), afterTokens: this.estimate([after]) } : {}),
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
version: 1,
|
|
836
|
+
revision,
|
|
837
|
+
sourceRevision: trace.sourceRevision,
|
|
838
|
+
at,
|
|
839
|
+
beforeTokens: sizes.beforeEstimate,
|
|
840
|
+
afterTokens: sizes.afterEstimate,
|
|
841
|
+
rows,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** `snapshot.json` for the panel: the request just built, replaced atomically. */
|
|
846
|
+
private snapshotFile(input: {
|
|
847
|
+
pinned: readonly LiveContextMessage[];
|
|
848
|
+
effective: readonly LiveContextMessage[];
|
|
849
|
+
sourceTokens: number;
|
|
850
|
+
raw: number;
|
|
851
|
+
sent: number;
|
|
852
|
+
suffix: number;
|
|
853
|
+
estimated: number;
|
|
854
|
+
observed?: number;
|
|
855
|
+
}): SnapshotFile | undefined {
|
|
856
|
+
const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
|
|
857
|
+
const resolved = this.resolvedBudget();
|
|
858
|
+
if (!base || !resolved) return undefined;
|
|
859
|
+
const shown = [...input.pinned, ...input.effective];
|
|
860
|
+
const steering = this.steering;
|
|
861
|
+
return {
|
|
862
|
+
at: new Date().toISOString(),
|
|
863
|
+
request: this.requests,
|
|
864
|
+
revision: this.state.revision,
|
|
865
|
+
enabled: this.enabled,
|
|
866
|
+
budget: { budget: base.budget, reserve: base.reserve, limit: overflowGuardLimit(resolved.budget, resolved.reserve), source: base.source },
|
|
867
|
+
calibration: { factor: this.calibrator.factor, samples: this.calibrator.sampleCount },
|
|
868
|
+
...(steering ? { steering: { name: steering.name, hash: steering.hash, path: steering.path } } : {}),
|
|
869
|
+
...(this.steeringError ? { steeringError: this.steeringError } : {}),
|
|
870
|
+
sizes: { estimated: input.estimated, ...(input.observed !== undefined ? { observedPrevious: input.observed } : {}) },
|
|
871
|
+
input: {
|
|
872
|
+
raw: input.raw,
|
|
873
|
+
sent: input.sent,
|
|
874
|
+
suffix: input.suffix,
|
|
875
|
+
rawTokens: input.sourceTokens,
|
|
876
|
+
effectiveTokens: this.estimate([...shown]),
|
|
877
|
+
messages: shown.map((message, index) => ({
|
|
878
|
+
index: index + 1,
|
|
879
|
+
role: String(message.role),
|
|
880
|
+
tokens: this.estimate([message]),
|
|
881
|
+
preview: renderMessage(message).replace(/\s+/g, " ").trim().slice(0, 120),
|
|
882
|
+
})),
|
|
883
|
+
},
|
|
884
|
+
...(this.state.budgetCheck ? { overhead: this.state.budgetCheck.overhead } : {}),
|
|
885
|
+
base: settingsAsOverrides(this.baseSettings) as Record<string, unknown>,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
569
889
|
// ---- the transform -----------------------------------------------------------------
|
|
570
890
|
|
|
571
891
|
/** Transforms of one session run one at a time. */
|
|
@@ -583,7 +903,7 @@ export class ClmSession {
|
|
|
583
903
|
|
|
584
904
|
/** Text for `experimental.session.compacting`'s `output.context`: the instruction plus the active annotations. */
|
|
585
905
|
async compactionContext(): Promise<string | undefined> {
|
|
586
|
-
if (!this.settings.enabled || !this.
|
|
906
|
+
if (!this.settings.enabled || !this.enabled) return undefined;
|
|
587
907
|
const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: [] });
|
|
588
908
|
return continuity ? `${COMPACTION_INSTRUCTION}\n\n${continuity}` : COMPACTION_INSTRUCTION;
|
|
589
909
|
}
|
|
@@ -613,6 +933,172 @@ export class ClmSession {
|
|
|
613
933
|
await this.log({ event: "compacted", revision: next.revision, previous, summary: summaryId, written });
|
|
614
934
|
}
|
|
615
935
|
|
|
936
|
+
// ---- checkpoint history (revert and fork) ------------------------------------------
|
|
937
|
+
|
|
938
|
+
/** History writes and clears run one at a time (`/clm reset` runs outside the transform queue). */
|
|
939
|
+
private historyQueue: Promise<void> = Promise.resolve();
|
|
940
|
+
|
|
941
|
+
private historyTask(task: () => Promise<void>): Promise<void> {
|
|
942
|
+
const run = this.historyQueue.then(task).catch(() => undefined);
|
|
943
|
+
this.historyQueue = run;
|
|
944
|
+
return run;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Add an accepted checkpoint to `checkpoints/`. Best effort: the revision is already saved.
|
|
949
|
+
* Skipped when the checkpoint is no longer the active one by the time the write runs (a
|
|
950
|
+
* `/clm reset` came in between), so a reset edit never lands in the history.
|
|
951
|
+
*/
|
|
952
|
+
private remember(entry: HistoryEntry): Promise<void> {
|
|
953
|
+
return this.historyTask(async () => {
|
|
954
|
+
if (this.state.checkpoint?.revision !== entry.checkpoint.revision) return;
|
|
955
|
+
await saveHistoryEntry(this.store.directory, entry)
|
|
956
|
+
.catch((error: unknown) => this.log({ event: "history-error", revision: entry.checkpoint.revision, error: describe(error) }));
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Make `entry`'s projection, with `projected` standing for `source`, the next revision.
|
|
962
|
+
* Persisted before it takes effect, like an accepted edit.
|
|
963
|
+
*/
|
|
964
|
+
private async activateRestored(
|
|
965
|
+
entry: HistoryEntry,
|
|
966
|
+
source: readonly LiveContextMessage[],
|
|
967
|
+
projected: readonly LiveContextMessage[],
|
|
968
|
+
describeRestore: (revision: number) => string,
|
|
969
|
+
): Promise<boolean> {
|
|
970
|
+
const at = new Date().toISOString();
|
|
971
|
+
const old = entry.checkpoint;
|
|
972
|
+
const revision = this.state.revision + 1;
|
|
973
|
+
let checkpoint: ProjectionCheckpoint;
|
|
974
|
+
try {
|
|
975
|
+
checkpoint = createProjectionCheckpoint({
|
|
976
|
+
revision,
|
|
977
|
+
sourceMessages: source,
|
|
978
|
+
projectedMessages: projected,
|
|
979
|
+
beforeEstimate: old.beforeEstimate,
|
|
980
|
+
afterEstimate: old.afterEstimate,
|
|
981
|
+
estimateUnit: old.estimateUnit,
|
|
982
|
+
createdAt: at,
|
|
983
|
+
...(old.editTrace ? { editTrace: old.editTrace } : {}),
|
|
984
|
+
});
|
|
985
|
+
} catch {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
const message = describeRestore(revision);
|
|
989
|
+
try {
|
|
990
|
+
await this.updateState((state) => {
|
|
991
|
+
if (state.revision + 1 !== revision) throw new Error("the session state changed while the revision was restored");
|
|
992
|
+
return {
|
|
993
|
+
version: 1,
|
|
994
|
+
enabled: state.enabled,
|
|
995
|
+
revision,
|
|
996
|
+
checkpoint,
|
|
997
|
+
lastOutcome: { kind: "applied", message, beforeEstimate: old.beforeEstimate, afterEstimate: old.afterEstimate, estimateUnit: old.estimateUnit, at },
|
|
998
|
+
...keptFields(state),
|
|
999
|
+
};
|
|
1000
|
+
});
|
|
1001
|
+
} catch {
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
this.tracker.reset();
|
|
1005
|
+
await this.remember({ version: 1, checkpoint, contentDigest: entry.contentDigest });
|
|
1006
|
+
this.pendingNotices.push(`[CLM] ${message} The mirror shows it.`);
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* The active revision no longer fits the history (a revert cut into its prefix): restore
|
|
1012
|
+
* the newest older checkpoint that still fits, as pi-clm restores the checkpoint of the
|
|
1013
|
+
* branch `/tree` selects. False when none fits; the caller then resets.
|
|
1014
|
+
*/
|
|
1015
|
+
private async restoreAfterMismatch(source: readonly LiveContextMessage[], reason: string): Promise<boolean> {
|
|
1016
|
+
const dropped = this.state.checkpoint?.revision;
|
|
1017
|
+
const { entries, ignored } = await loadHistory(this.store.directory);
|
|
1018
|
+
if (ignored.length > 0) await this.log({ event: "history-ignored", files: ignored });
|
|
1019
|
+
for (const entry of entries) {
|
|
1020
|
+
if (entry.checkpoint.revision === dropped) continue;
|
|
1021
|
+
const candidate = applyProjection(source, entry.checkpoint);
|
|
1022
|
+
if (!candidate.valid) continue;
|
|
1023
|
+
const from = entry.checkpoint.revision;
|
|
1024
|
+
const prefix = source.slice(0, entry.checkpoint.sourceMessageCount);
|
|
1025
|
+
const restored = await this.activateRestored(entry, prefix, entry.checkpoint.projectedMessages, (revision) =>
|
|
1026
|
+
`Revision ${dropped} no longer matches OpenCode's history: ${reason.replace(/\.\s*$/, "")}; restored revision ${from}, which still does, as revision ${revision}.`);
|
|
1027
|
+
if (!restored) return false;
|
|
1028
|
+
await this.log({ event: "restored", revision: this.state.revision, from, dropped, reason });
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* First request of a forked session: find the newest origin checkpoint whose source the
|
|
1036
|
+
* fork's history starts with (compared without message ids, which the fork renews) and
|
|
1037
|
+
* restore it with the ids mapped to the fork's.
|
|
1038
|
+
*/
|
|
1039
|
+
private async restoreFromFork(origin: { sessionID: string; directory: string }, source: readonly LiveContextMessage[]): Promise<boolean> {
|
|
1040
|
+
const { entries, ignored } = await loadHistory(origin.directory);
|
|
1041
|
+
if (ignored.length > 0) await this.log({ event: "history-ignored", origin: origin.sessionID, files: ignored });
|
|
1042
|
+
for (const entry of entries) {
|
|
1043
|
+
const count = entry.checkpoint.sourceMessageCount;
|
|
1044
|
+
if (count > source.length) continue;
|
|
1045
|
+
const prefix = source.slice(0, count);
|
|
1046
|
+
if (digestSourceContent(prefix) !== entry.contentDigest) continue;
|
|
1047
|
+
const from = entry.checkpoint.revision;
|
|
1048
|
+
const restored = await this.activateRestored(entry, prefix, remapProjection(entry.checkpoint, prefix), (revision) =>
|
|
1049
|
+
`This session is a fork of ${origin.sessionID}; restored its revision ${from} as revision ${revision}.`);
|
|
1050
|
+
if (!restored) return false;
|
|
1051
|
+
await this.log({ event: "restored", revision: this.state.revision, from, origin: origin.sessionID });
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* First request of a forked session: copy the origin's annotations made up to the fork
|
|
1059
|
+
* point, their sources re-pointed at the fork's messages (`forkAnnotations`). The fork
|
|
1060
|
+
* point is the newest completion among the messages created before the fork itself
|
|
1061
|
+
* (OpenCode keeps message times in a fork); newer messages are the fork's own.
|
|
1062
|
+
*/
|
|
1063
|
+
private async copyForkAnnotations(origin: { sessionID: string; directory: string; created?: number }, raw: readonly OcMessage[], effective: readonly LiveContextMessage[]): Promise<void> {
|
|
1064
|
+
let annotations: LiveContextAnnotation[] = [];
|
|
1065
|
+
try {
|
|
1066
|
+
annotations = await new AnnotationStore(origin.directory).list();
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
await this.log({ event: "annotations-unreadable", origin: origin.sessionID, error: describe(error) });
|
|
1069
|
+
}
|
|
1070
|
+
// Without a mirror the origin's directory is usually out of reach too: fall back to the
|
|
1071
|
+
// set the origin kept in OpenCode's metadata, which the fork cloned (index.ts).
|
|
1072
|
+
if (annotations.length === 0) annotations = this.forkPersisted ?? [];
|
|
1073
|
+
this.forkPersisted = undefined;
|
|
1074
|
+
if (annotations.length === 0) return;
|
|
1075
|
+
let cutoff: number | undefined;
|
|
1076
|
+
for (const { info } of raw) {
|
|
1077
|
+
const created = Number(info.time?.created ?? 0);
|
|
1078
|
+
if (origin.created !== undefined && created >= origin.created) continue;
|
|
1079
|
+
const end = Number(info.time?.completed ?? 0) || created;
|
|
1080
|
+
if (cutoff === undefined || end > cutoff) cutoff = end;
|
|
1081
|
+
}
|
|
1082
|
+
const copied = forkAnnotations(annotations, effective, { sessionId: this.sessionID, ...(cutoff !== undefined ? { cutoff } : {}) });
|
|
1083
|
+
try {
|
|
1084
|
+
const written = await this.annotations.importAll(copied);
|
|
1085
|
+
// Without the fork's creation time the fork's own new messages count too, so origin
|
|
1086
|
+
// annotations made between the fork and its first request are carried as well.
|
|
1087
|
+
if (written > 0) {
|
|
1088
|
+
this.annotationsImported = true;
|
|
1089
|
+
await this.log({
|
|
1090
|
+
event: "annotations-forked",
|
|
1091
|
+
origin: origin.sessionID,
|
|
1092
|
+
count: written,
|
|
1093
|
+
mapped: copied.filter((annotation) => annotation.source.sessionId === this.sessionID).length,
|
|
1094
|
+
cutoff: origin.created === undefined ? "unbounded" : cutoff ?? null,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
await this.log({ event: "annotations-fork-error", origin: origin.sessionID, error: describe(error) });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
616
1102
|
private async loadAnnotations(): Promise<LiveContextAnnotation[]> {
|
|
617
1103
|
try {
|
|
618
1104
|
return await this.annotations.list();
|
|
@@ -622,12 +1108,46 @@ export class ClmSession {
|
|
|
622
1108
|
}
|
|
623
1109
|
}
|
|
624
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* Without a mirror (`mirrorUnavailable`): the raw history, then the continuity message and
|
|
1113
|
+
* its size notice. Nothing is projected, rendered or measured; queued notices name the
|
|
1114
|
+
* mirror, so they are dropped. pi-clm src/index.ts `context` handler, `!store` branch.
|
|
1115
|
+
*/
|
|
1116
|
+
private async transformWithoutMirror(raw: OcMessage[]): Promise<TransformResult> {
|
|
1117
|
+
this.baseline = undefined;
|
|
1118
|
+
this.compacted = false;
|
|
1119
|
+
this.pendingNotices = [];
|
|
1120
|
+
if (this.compacting) {
|
|
1121
|
+
this.compacting = false;
|
|
1122
|
+
return { messages: raw, notices: [] };
|
|
1123
|
+
}
|
|
1124
|
+
this.requests += 1;
|
|
1125
|
+
const origin = this.forkOrigin;
|
|
1126
|
+
this.forkOrigin = undefined;
|
|
1127
|
+
if (origin) await this.copyForkAnnotations(origin, raw, flatten(raw));
|
|
1128
|
+
const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: flatten(raw) });
|
|
1129
|
+
const tokens = continuity ? this.textTokens(continuity) : 0;
|
|
1130
|
+
const notices = this.continuitySize.observe(tokens) ? [continuitySizeNoticeText(tokens)] : [];
|
|
1131
|
+
const context = this.context(raw);
|
|
1132
|
+
const messages = [
|
|
1133
|
+
...raw,
|
|
1134
|
+
...(continuity ? [noteMessage(continuity, "continuity", context)] : []),
|
|
1135
|
+
...(notices.length > 0 ? [noteMessage(notices.join("\n\n"), `notice:${this.requests}`, context)] : []),
|
|
1136
|
+
];
|
|
1137
|
+
this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: 0 };
|
|
1138
|
+
const errors = this.pendingErrors;
|
|
1139
|
+
this.pendingErrors = [];
|
|
1140
|
+
return { messages, notices, ...(errors.length > 0 ? { errors } : {}) };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
625
1143
|
private async transformNow(raw: OcMessage[]): Promise<TransformResult> {
|
|
626
|
-
|
|
1144
|
+
await this.refreshSettings();
|
|
1145
|
+
if (!this.settings.enabled || !this.enabled) {
|
|
627
1146
|
this.baseline = undefined;
|
|
628
1147
|
this.compacting = false;
|
|
629
|
-
return { messages: raw, notices: [] };
|
|
1148
|
+
return { messages: raw, notices: [], ...this.drainAlerts() };
|
|
630
1149
|
}
|
|
1150
|
+
if (this.mirrorUnavailable !== undefined) return await this.transformWithoutMirror(raw);
|
|
631
1151
|
const pinned = pinnedCount(raw);
|
|
632
1152
|
const pinnedMessages = raw.slice(0, pinned);
|
|
633
1153
|
const context = this.context(raw);
|
|
@@ -635,11 +1155,12 @@ export class ClmSession {
|
|
|
635
1155
|
// OpenCode's compaction passes a head slice of the history through this hook. Apply
|
|
636
1156
|
// the accepted revision when it still covers that slice; never commit, render,
|
|
637
1157
|
// notify or reset (the slice is shorter than the history the checkpoint covers).
|
|
1158
|
+
// A pending edit was committed by `commitBeforeCompaction` just before.
|
|
638
1159
|
if (this.compacting) {
|
|
639
1160
|
this.compacting = false;
|
|
640
1161
|
const projection = applyProjection(flatten(raw.slice(pinned)), this.state.checkpoint);
|
|
641
|
-
if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [] };
|
|
642
|
-
return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [] };
|
|
1162
|
+
if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [], ...this.drainAlerts() };
|
|
1163
|
+
return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [], ...this.drainAlerts() };
|
|
643
1164
|
}
|
|
644
1165
|
|
|
645
1166
|
await this.commit();
|
|
@@ -654,6 +1175,9 @@ export class ClmSession {
|
|
|
654
1175
|
// Before the guard and the reading, so a raise applies to this request already.
|
|
655
1176
|
const measured = this.state.budgetCheck?.source === "provider" ? undefined : this.measureOverhead(raw, observed);
|
|
656
1177
|
const alert = measured ? await this.checkBudget(measured) : undefined;
|
|
1178
|
+
// Samples taken before the overhead was measured read the unmeasured part of the
|
|
1179
|
+
// overhead (built-in tool schemas) as undercounting; start calibration over.
|
|
1180
|
+
if (measured?.source === "provider") this.calibrator.reset();
|
|
657
1181
|
|
|
658
1182
|
let projection = applyProjection(source, this.state.checkpoint);
|
|
659
1183
|
// A late or repeated compaction signal must not swallow a later revert: rebase only
|
|
@@ -664,6 +1188,10 @@ export class ClmSession {
|
|
|
664
1188
|
if (rebase) {
|
|
665
1189
|
await this.rebaseAfterCompaction(summaryId);
|
|
666
1190
|
projection = applyProjection(source, undefined);
|
|
1191
|
+
} else if (!projection.valid && (await this.restoreAfterMismatch(source, projection.reason))) {
|
|
1192
|
+
// A revert cut into the active revision's prefix; an older revision still fits.
|
|
1193
|
+
this.invalidationStreak = 0;
|
|
1194
|
+
projection = applyProjection(source, this.state.checkpoint);
|
|
667
1195
|
} else if (!projection.valid) {
|
|
668
1196
|
const dropped = this.state.checkpoint?.revision;
|
|
669
1197
|
this.invalidationStreak += 1;
|
|
@@ -672,6 +1200,10 @@ export class ClmSession {
|
|
|
672
1200
|
let notice = `[CLM] Revision ${dropped} was dropped because OpenCode's history changed under it: ${projection.reason} The mirror now shows the stored history.`;
|
|
673
1201
|
if (this.invalidationStreak >= 2) {
|
|
674
1202
|
notice += " This happened on consecutive requests; edits keep being dropped until the start of the history stops changing.";
|
|
1203
|
+
if (!this.compositionWarned) {
|
|
1204
|
+
this.compositionWarned = true;
|
|
1205
|
+
this.pendingAlerts.push(COMPOSITION_WARNING);
|
|
1206
|
+
}
|
|
675
1207
|
}
|
|
676
1208
|
this.pendingNotices.push(notice);
|
|
677
1209
|
await this.log({ event: "projection-reset", revision: dropped, reason: projection.reason });
|
|
@@ -679,6 +1211,11 @@ export class ClmSession {
|
|
|
679
1211
|
} else if (this.state.checkpoint) {
|
|
680
1212
|
this.invalidationStreak = 0;
|
|
681
1213
|
}
|
|
1214
|
+
const origin = this.forkOrigin;
|
|
1215
|
+
this.forkOrigin = undefined;
|
|
1216
|
+
if (origin && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
|
|
1217
|
+
projection = applyProjection(source, this.state.checkpoint);
|
|
1218
|
+
}
|
|
682
1219
|
const checkpoint = this.state.checkpoint;
|
|
683
1220
|
const suffixLength = projection.valid ? projection.suffix.length : 0;
|
|
684
1221
|
|
|
@@ -686,16 +1223,16 @@ export class ClmSession {
|
|
|
686
1223
|
if (!this.settings.reasoning) effective = withoutReasoning(effective);
|
|
687
1224
|
effective = capObservations(effective, this.settings.observationCap);
|
|
688
1225
|
|
|
1226
|
+
if (origin) await this.copyForkAnnotations(origin, raw, effective);
|
|
689
1227
|
const annotations = await this.loadAnnotations();
|
|
690
1228
|
const continuityText = (messages: LiveContextMessage[]) =>
|
|
691
1229
|
formatContinuityMessage({ annotations, effectiveMessages: messages });
|
|
692
1230
|
const pinnedTokens = this.estimate(flatten(pinnedMessages));
|
|
693
1231
|
const resolved = this.resolvedBudget();
|
|
694
1232
|
const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
|
|
695
|
-
// System prompt and tool schemas, when the hooks reported them (one request late).
|
|
696
1233
|
const { systemTokens, toolTokens } = this.scope;
|
|
697
|
-
const scopeTokens = this.
|
|
698
|
-
const excluded = [
|
|
1234
|
+
const { measuredOverhead, scopeTokens } = this.scopeSize();
|
|
1235
|
+
const excluded = measuredOverhead !== undefined ? [] : [
|
|
699
1236
|
...(systemTokens === undefined ? ["the system prompt"] : []),
|
|
700
1237
|
...(toolTokens === undefined ? ["tool schemas"] : []),
|
|
701
1238
|
];
|
|
@@ -740,6 +1277,7 @@ export class ClmSession {
|
|
|
740
1277
|
});
|
|
741
1278
|
try {
|
|
742
1279
|
await this.store.write(snapshot.text);
|
|
1280
|
+
this.refreshFailing = false;
|
|
743
1281
|
this.lastSnapshot = snapshot;
|
|
744
1282
|
this.baseline = {
|
|
745
1283
|
rawMessages: source,
|
|
@@ -752,6 +1290,8 @@ export class ClmSession {
|
|
|
752
1290
|
} catch (error) {
|
|
753
1291
|
this.baseline = undefined;
|
|
754
1292
|
this.pendingNotices.push(`[CLM] Could not refresh the mirror: ${describe(error)}`);
|
|
1293
|
+
if (!this.refreshFailing) this.pendingErrors.push(`could not refresh the mirror, edits are off until it can be written: ${describe(error)}`);
|
|
1294
|
+
this.refreshFailing = true;
|
|
755
1295
|
}
|
|
756
1296
|
|
|
757
1297
|
const conversationTokens = pinnedTokens + this.estimate(effective) + continuityTokens;
|
|
@@ -786,11 +1326,15 @@ export class ClmSession {
|
|
|
786
1326
|
];
|
|
787
1327
|
|
|
788
1328
|
// Calibrate only against a same-scope estimate: the provider count includes the system
|
|
789
|
-
// prompt and tool schemas
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
this.
|
|
1329
|
+
// prompt and tool schemas. With a measured overhead the calibrator compares the
|
|
1330
|
+
// conversation with the provider count minus that overhead; otherwise both hook sizes
|
|
1331
|
+
// must be known.
|
|
1332
|
+
const conversationRaw = this.rawTokens(flatten(pinnedMessages)) + this.rawTokens(effective) +
|
|
1333
|
+
(continuity ? this.textTokens(continuity) : 0) + this.noticeTokens(notices);
|
|
1334
|
+
if (measuredOverhead !== undefined) {
|
|
1335
|
+
this.calibrator.record(conversationRaw, raw.length, measuredOverhead);
|
|
1336
|
+
} else if (systemTokens !== undefined && toolTokens !== undefined) {
|
|
1337
|
+
this.calibrator.record(systemTokens + toolTokens + conversationRaw, raw.length);
|
|
794
1338
|
}
|
|
795
1339
|
|
|
796
1340
|
this.previousRequest = { rawCount: raw.length, conversation: conversationTokens + this.calibrator.apply(this.noticeTokens(notices)) };
|
|
@@ -800,28 +1344,67 @@ export class ClmSession {
|
|
|
800
1344
|
await mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => undefined);
|
|
801
1345
|
await writeFile(join(directory, `n${this.requests}.json`), JSON.stringify(messages, null, 1), { mode: 0o600 }).catch(() => undefined);
|
|
802
1346
|
}
|
|
1347
|
+
const snapshotFile = this.snapshotFile({
|
|
1348
|
+
pinned: flatten(pinnedMessages),
|
|
1349
|
+
effective,
|
|
1350
|
+
sourceTokens: pinnedTokens + this.estimate(source),
|
|
1351
|
+
raw: raw.length,
|
|
1352
|
+
sent: messages.length,
|
|
1353
|
+
suffix: suffixLength,
|
|
1354
|
+
estimated: reading?.estimated ?? requestTokens,
|
|
1355
|
+
...(observed ? { observed: observed.tokens } : {}),
|
|
1356
|
+
});
|
|
1357
|
+
if (snapshotFile) {
|
|
1358
|
+
await writeJsonAtomic(join(this.store.directory, SNAPSHOT_FILE), snapshotFile)
|
|
1359
|
+
.catch((error: unknown) => this.log({ event: "snapshot-error", error: describe(error) }));
|
|
1360
|
+
}
|
|
803
1361
|
await this.log({
|
|
804
1362
|
event: "request",
|
|
805
1363
|
n: this.requests,
|
|
806
1364
|
revision: this.state.revision,
|
|
1365
|
+
users: raw.filter((message) => message.info.role === "user").length,
|
|
807
1366
|
raw: raw.length,
|
|
808
1367
|
sent: messages.length,
|
|
809
1368
|
blocks: snapshot.blocks.length,
|
|
810
1369
|
withheld: withheldCount,
|
|
811
1370
|
estimated: reading?.estimated ?? requestTokens,
|
|
812
1371
|
observedPrevious: observed?.tokens,
|
|
1372
|
+
// The assistant message that reported `observedPrevious`; a new id means a new count.
|
|
1373
|
+
observedMessage: observed?.messageID,
|
|
813
1374
|
calibration: this.calibrator.factor,
|
|
814
1375
|
notices: notices.map((notice) => notice.slice(0, 80)),
|
|
815
1376
|
});
|
|
816
|
-
return {
|
|
1377
|
+
return {
|
|
1378
|
+
messages,
|
|
1379
|
+
notices,
|
|
1380
|
+
estimated: reading?.estimated ?? requestTokens,
|
|
1381
|
+
reading,
|
|
1382
|
+
...this.drainAlerts(alert),
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** The queued toasts (`alert`, `errors`) for a transform result, cleared once taken. */
|
|
1387
|
+
private drainAlerts(alert?: string): Pick<TransformResult, "alert" | "errors"> {
|
|
1388
|
+
const alerts = [...(alert ? [alert] : []), ...this.pendingAlerts];
|
|
1389
|
+
this.pendingAlerts = [];
|
|
1390
|
+
const errors = this.pendingErrors;
|
|
1391
|
+
this.pendingErrors = [];
|
|
1392
|
+
return {
|
|
1393
|
+
...(alerts.length > 0 ? { alert: alerts.join("\n") } : {}),
|
|
1394
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
1395
|
+
};
|
|
817
1396
|
}
|
|
818
1397
|
|
|
819
1398
|
/** Inputs for presentation.ts `statusText` / `statusLine`. */
|
|
820
|
-
status(steering
|
|
1399
|
+
status(steering: SteeringDocument | undefined = this.steering): ClmStatus {
|
|
821
1400
|
const checkpoint = this.state.checkpoint;
|
|
1401
|
+
const { base, effective } = this.settingsValues();
|
|
1402
|
+
const changed = changedSummary(base, effective, { modelWindow: this.limits.context });
|
|
822
1403
|
return {
|
|
1404
|
+
...(changed ? { changed } : {}),
|
|
1405
|
+
...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
|
|
823
1406
|
sessionID: this.sessionID,
|
|
824
|
-
mirrorPath: this.mirrorPath,
|
|
1407
|
+
mirrorPath: this.mirrorUnavailable === undefined ? this.mirrorPath : `unavailable, requests carry the raw history (${this.mirrorUnavailable})`,
|
|
825
1408
|
revision: this.state.revision,
|
|
826
1409
|
accepted: this.accepted,
|
|
827
1410
|
rejected: this.rejected,
|