opencode-clm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/clm.ts ADDED
@@ -0,0 +1,725 @@
1
+ /**
2
+ * The CLM lifecycle for one OpenCode session, independent of the plugin API so it can be
3
+ * unit tested. Ported from pi-clm src/index.ts (`context` and `turn_end` handlers; MIT,
4
+ * Copyright 2026 Emanuel Casco).
5
+ *
6
+ * Timing differs from pi-clm: OpenCode has no `turn_end` hook, so the edit the model made
7
+ * during step k is read back and committed at the start of step k+1's transform, right
8
+ * before the request it affects. As in pi-clm, the file content at that moment wins.
9
+ *
10
+ * transform(raw) 0. pinned = everything up to and including the first user message;
11
+ * source = flatten(rest), the raw prefix a checkpoint digests
12
+ * 1. commit: read the mirror; if it differs from the last render,
13
+ * apply it (edit gate) and save a new checkpoint over that render's source
14
+ * 2. project: checkpoint.projected ++ source suffix; after a compaction,
15
+ * rebase onto the summarized history; on any other mismatch, reset
16
+ * 3. reasoning view, observation cap, overflow guard (new suffix only)
17
+ * 4. render the effective context to the mirror
18
+ * 5. notices: edit outcome, reset, overflow, continuity size, budget tier
19
+ * 6. output: pinned ++ unflatten(effective) ++ continuity ++ notices
20
+ *
21
+ * Anchor: a checkpoint digests the flattened source with tool-result content left out
22
+ * (projection.ts), so OpenCode's prune does not discard it. The same rule means any other
23
+ * change to a stored tool output inside the covered prefix also goes undetected. For a
24
+ * pruned output the accepted revision keeps the text the model saw, in the request too:
25
+ * `unflatten` sends it with the revision's text, so request, mirror and estimate agree.
26
+ * An output rewritten by another plugin or an SDK client is not restored: the request
27
+ * carries the new text while the mirror and estimate keep the revision's.
28
+ *
29
+ * Measurement: one estimator, chars/4 × `estimateFactor`, then × the calibration factor.
30
+ * The edit gate, the overflow guard and the budget reading all use it, in tokens, and all
31
+ * count the system prompt and tool schemas once `scope` holds their sizes. The
32
+ * calibrator learns only from requests whose full scope (system prompt and tool schemas
33
+ * included) was estimated; without those sizes it stays at 1.
34
+ */
35
+ import { randomUUID } from "node:crypto";
36
+ import { appendFile, mkdir, writeFile } from "node:fs/promises";
37
+ import { join } from "node:path";
38
+
39
+ import {
40
+ budgetNoticeText,
41
+ budgetTiers,
42
+ BudgetTracker,
43
+ EstimateCalibrator,
44
+ formatTokens,
45
+ resolveBudget,
46
+ type BudgetReading,
47
+ } from "./budget.ts";
48
+ import { applyContextDocument, renderContextDocument, renderMessage } from "./context-document.ts";
49
+ import {
50
+ AnnotationStore,
51
+ ContinuitySizeTracker,
52
+ continuitySizeNoticeText,
53
+ formatContinuityMessage,
54
+ type ContinuityBlockSource,
55
+ type LiveContextAnnotation,
56
+ } from "./continuity.ts";
57
+ import { classifyMirrorToolCall } from "./mirror-guard.ts";
58
+ import { MirrorStore } from "./mirror-store.ts";
59
+ import { capObservations } from "./observation.ts";
60
+ import { flatten, noteMessage, unflatten, withoutReasoning, type OcInfo, type OcMessage } from "./opencode.ts";
61
+ import { applyOverflowGuard, overflowGuardLimit, overflowNoticeText } from "./overflow.ts";
62
+ import type { ClmStatus } from "./presentation.ts";
63
+ import { applyProjection, createProjectionCheckpoint, type ProjectionCheckpoint } from "./projection.ts";
64
+ import type { ClmSettings } from "./settings.ts";
65
+ import {
66
+ loadLiveContextState,
67
+ resetProjectionState,
68
+ saveLiveContextState,
69
+ type LiveContextState,
70
+ } from "./state.ts";
71
+ import type { SteeringDocument } from "./steering.ts";
72
+ import type { ContextDocumentSnapshot, LiveContextMessage, TurnBaseline } from "./types.ts";
73
+
74
+ export interface ModelLimits {
75
+ /** Model context window, tokens. */
76
+ context?: number;
77
+ /** Model output limit, tokens; subtracted from the window by `resolveBudget`. */
78
+ output?: number;
79
+ }
80
+
81
+ /** Sizes of the request parts the plugin does not see in the messages hook. */
82
+ export interface RequestScope {
83
+ /** Estimated tokens of the system prompt (from `experimental.chat.system.transform`). */
84
+ systemTokens?: number;
85
+ /** Estimated tokens of the tool schemas (from `tool.definition`). */
86
+ toolTokens?: number;
87
+ }
88
+
89
+ export interface TransformResult {
90
+ /** The messages to send; the caller writes them into OpenCode's array in place. */
91
+ messages: OcMessage[];
92
+ /** Notices appended to this request (also inside `messages`, as one user note). */
93
+ notices: string[];
94
+ /** Calibrated estimate of this request, tokens (system prompt and tool schemas included when known); undefined when CLM did not run. */
95
+ estimated?: number;
96
+ reading?: BudgetReading;
97
+ }
98
+
99
+ export interface MirrorCheck {
100
+ changed: boolean;
101
+ accepted: boolean;
102
+ message: string;
103
+ }
104
+
105
+ interface Baseline extends TurnBaseline {
106
+ /** Fit-gate limit for an edit of this render, tokens (guard limit net of the pinned task and continuity). */
107
+ limit?: number;
108
+ }
109
+
110
+ /** OpenCode session ids are `ses_` plus base62; anything else would alias another directory. */
111
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
112
+
113
+ const CHARS_PER_TOKEN = 4;
114
+
115
+ /** Index after the pinned prefix: everything up to and including the first user message. */
116
+ export function pinnedCount(raw: readonly OcMessage[]): number {
117
+ const first = raw.findIndex((message) => message.info.role === "user");
118
+ return first < 0 ? 0 : first + 1;
119
+ }
120
+
121
+ export interface ObservedRequest {
122
+ tokens: number;
123
+ /** Position of the reporting assistant message in the raw OpenCode history. */
124
+ index: number;
125
+ messageID: string;
126
+ }
127
+
128
+ /**
129
+ * Provider-reported input of the newest successful request: the newest assistant message
130
+ * without an error and not a compaction summary, with input + cache read + cache write > 0.
131
+ */
132
+ export function lastProviderReported(raw: readonly OcMessage[]): ObservedRequest | undefined {
133
+ for (let index = raw.length - 1; index >= 0; index--) {
134
+ const info = raw[index]!.info;
135
+ if (info.role !== "assistant" || info.error || info.summary || !info.tokens) continue;
136
+ const tokens = info.tokens;
137
+ const total = Number(tokens.input ?? 0) + Number(tokens.cache?.read ?? 0) + Number(tokens.cache?.write ?? 0);
138
+ if (Number.isFinite(total) && total > 0) return { tokens: total, index, messageID: info.id };
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ /** Id of the newest compaction summary (an assistant message with `summary` set). */
144
+ export function compactionSummaryId(raw: readonly OcMessage[]): string | undefined {
145
+ for (let index = raw.length - 1; index >= 0; index--) {
146
+ const info = raw[index]!.info;
147
+ if (info.role === "assistant" && info.summary && !info.error) return info.id;
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ /** Asks OpenCode's summarizer to carry the plugin's annotations through a compaction. */
153
+ export const COMPACTION_INSTRUCTION =
154
+ "CLM note for the summary: copy every pin and continuity annotation listed below " +
155
+ "(id, title, Why, Next, pinned source text) into the summary word for word. " +
156
+ "Do not shorten, merge or reword them.";
157
+
158
+ function describe(error: unknown): string {
159
+ return error instanceof Error ? error.message : String(error);
160
+ }
161
+
162
+ export class ClmSession {
163
+ readonly tracker = new BudgetTracker();
164
+ readonly calibrator = new EstimateCalibrator();
165
+ readonly continuitySize = new ContinuitySizeTracker();
166
+ readonly annotations: AnnotationStore;
167
+ state: LiveContextState;
168
+ /** Set when `state.json` existed but could not be used (the session then starts clean). */
169
+ readonly loadWarning?: string;
170
+ accepted = 0;
171
+ rejected = 0;
172
+ limits: ModelLimits = {};
173
+ scope: RequestScope = {};
174
+ /** Set by `experimental.session.compacting`: the next transform carries OpenCode's compaction input. */
175
+ compacting = false;
176
+ /**
177
+ * Set when OpenCode reports a finished compaction (`session.compacted` event or the
178
+ * `experimental.compaction.autocontinue` hook); the next transform rebases onto the summary.
179
+ */
180
+ compacted = false;
181
+ /** What the last transform rendered; the next transform commits the mirror against it. */
182
+ baseline?: Baseline;
183
+ /** The snapshot now in the mirror file; continuity tools resolve block ids against it. */
184
+ lastSnapshot?: ContextDocumentSnapshot;
185
+ lastReading?: BudgetReading;
186
+ lastRequest?: { rawMessages: number; sentMessages: number; mirrorBlocks: number };
187
+ requests = 0;
188
+
189
+ private pendingNotices: string[] = [];
190
+ private invalidationStreak = 0;
191
+ /**
192
+ * Per-process seed of the stable document id. Fresh on every open, so a document id never
193
+ * repeats even when a corrupt state file resets the revision counter to 0.
194
+ */
195
+ private readonly nonce = randomUUID();
196
+ private readonly tokenCache = new WeakMap<LiveContextMessage, number>();
197
+ private saving: Promise<void> = Promise.resolve();
198
+ private running: Promise<unknown> = Promise.resolve();
199
+
200
+ private constructor(
201
+ readonly sessionID: string,
202
+ readonly store: MirrorStore,
203
+ readonly settings: ClmSettings,
204
+ loaded: { state: LiveContextState; warning?: string },
205
+ ) {
206
+ this.state = loaded.state;
207
+ this.loadWarning = loaded.warning;
208
+ this.annotations = new AnnotationStore(store.directory, { estimateTokens: (text) => this.textTokens(text) });
209
+ }
210
+
211
+ /** Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. */
212
+ static async open(sessionID: string, settings: ClmSettings): Promise<ClmSession> {
213
+ if (!SESSION_ID_RE.test(sessionID)) throw new Error(`Invalid session id for CLM: ${JSON.stringify(sessionID)}`);
214
+ const store = await MirrorStore.create(sessionID, settings.mirrorDir);
215
+ const loaded = await loadLiveContextState(store.directory);
216
+ const session = new ClmSession(sessionID, store, settings, loaded);
217
+ if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
218
+ return session;
219
+ }
220
+
221
+ get mirrorPath(): string {
222
+ return this.store.filePath;
223
+ }
224
+
225
+ // ---- measurement -------------------------------------------------------------------
226
+
227
+ /** Uncalibrated tokens of a text: chars/4 × estimateFactor. */
228
+ textTokens(text: string): number {
229
+ return Math.ceil((text.length / CHARS_PER_TOKEN) * this.settings.estimateFactor);
230
+ }
231
+
232
+ /** Uncalibrated tokens of messages, cached per message object. */
233
+ rawTokens(messages: readonly LiveContextMessage[]): number {
234
+ let total = 0;
235
+ for (const message of messages) {
236
+ let tokens = this.tokenCache.get(message);
237
+ if (tokens === undefined) {
238
+ tokens = this.textTokens(renderMessage(message));
239
+ this.tokenCache.set(message, tokens);
240
+ }
241
+ total += tokens;
242
+ }
243
+ return total;
244
+ }
245
+
246
+ /** Calibrated tokens: the single estimator for gate, guard and budget. */
247
+ estimate = (messages: LiveContextMessage[]): number => this.calibrator.apply(this.rawTokens(messages));
248
+
249
+ private noticeTokens(notices: readonly string[]): number {
250
+ return notices.reduce((total, notice) => total + this.textTokens(notice), 0);
251
+ }
252
+
253
+ resolvedBudget() {
254
+ return resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
255
+ }
256
+
257
+ /** budget − reserve, or undefined while the budget is unknown. */
258
+ guardLimit(): number | undefined {
259
+ const resolved = this.resolvedBudget();
260
+ return resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
261
+ }
262
+
263
+ // ---- persistence -------------------------------------------------------------------
264
+
265
+ /**
266
+ * Every state change goes through this queue: the next state is computed from the
267
+ * current one, saved, then activated, one change at a time. So the last rename always
268
+ * holds the newest state, and concurrent callers (a command during a transform) never
269
+ * build on a stale state. A failed save throws and leaves the state unchanged, unless
270
+ * `activateOnFailure` is set (outcomes and resets: in-memory state must move on).
271
+ */
272
+ private updateState(
273
+ change: (state: LiveContextState) => LiveContextState,
274
+ activateOnFailure = false,
275
+ ): Promise<LiveContextState> {
276
+ const run = this.saving.then(async () => {
277
+ const next = change(this.state);
278
+ try {
279
+ await saveLiveContextState(this.store.directory, next);
280
+ } catch (error) {
281
+ if (activateOnFailure) this.state = next;
282
+ await this.log({ event: "save-failed", revision: next.revision, error: describe(error) });
283
+ throw error;
284
+ }
285
+ this.state = next;
286
+ return next;
287
+ });
288
+ this.saving = run.then(() => undefined, () => undefined);
289
+ return run;
290
+ }
291
+
292
+ /** One JSON line per event in `<session dir>/events.jsonl`. Best effort. */
293
+ async log(event: Record<string, unknown>): Promise<void> {
294
+ const line = `${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`;
295
+ await appendFile(join(this.store.directory, "events.jsonl"), line, { mode: 0o600 }).catch(() => undefined);
296
+ }
297
+
298
+ async setEnabled(enabled: boolean): Promise<void> {
299
+ await this.updateState((state) => ({ ...state, enabled }));
300
+ this.baseline = undefined;
301
+ if (!enabled) this.continuitySize.observe(0);
302
+ }
303
+
304
+ /** Drop the accepted revision (e.g. `/clm reset`); the next request sends the raw history. */
305
+ async resetProjection(reason: string): Promise<void> {
306
+ const next = await this.updateState((state) => resetProjectionState(state, reason));
307
+ this.baseline = undefined;
308
+ this.tracker.reset();
309
+ this.continuitySize.reset();
310
+ await this.log({ event: "reset", revision: next.revision, reason });
311
+ }
312
+
313
+ // ---- edit validation ---------------------------------------------------------------
314
+
315
+ private applyOptions(baseline: Baseline) {
316
+ return {
317
+ editingMode: "clm" as const,
318
+ gate: this.settings.gate,
319
+ limit: baseline.limit,
320
+ taskPinned: true,
321
+ estimate: this.estimate,
322
+ estimateUnit: "tokens" as const,
323
+ };
324
+ }
325
+
326
+ /** Dry-run validation of mirror text against the last render (receipts). */
327
+ validateMirror(text: string | undefined): MirrorCheck | undefined {
328
+ const baseline = this.baseline;
329
+ if (!baseline || text === undefined) return undefined;
330
+ if (text.trim() === baseline.snapshot.text.trim()) return { changed: false, accepted: true, message: "unchanged" };
331
+ const result = applyContextDocument(text, baseline.snapshot, this.applyOptions(baseline));
332
+ if (!result.accepted) return { changed: true, accepted: false, message: result.reason ?? "refused" };
333
+ const touched = (result.editTrace?.sources ?? [])
334
+ .filter((source) => source.kind !== "kept")
335
+ .map((source) => `${source.sourceIndex + 1} ${baseline.snapshot.blocks[source.sourceIndex]?.role ?? "?"} ${source.kind}`);
336
+ const added = result.editTrace?.additions.length ?? 0;
337
+ const parts = [`about ${formatTokens(result.beforeEstimate)} → ${formatTokens(result.afterEstimate)} tokens`];
338
+ if (touched.length > 0) parts.push(`blocks ${touched.join(", ")}`);
339
+ if (added > 0) parts.push(`${added} new block${added === 1 ? "" : "s"}`);
340
+ const grew = result.afterEstimate > result.beforeEstimate ? " The context grows with this edit." : "";
341
+ const notes = result.diagnostics.length > 0 ? ` ${result.diagnostics.join(" ")}` : "";
342
+ return { changed: true, accepted: true, message: `${parts.join("; ")}.${grew}${notes}` };
343
+ }
344
+
345
+ /**
346
+ * Verdict appended to the result of a tool call that wrote the mirror; undefined for
347
+ * calls that only read it or do not touch it.
348
+ */
349
+ receipt(tool: string, args: Record<string, unknown> | undefined, cwd: string): string | undefined {
350
+ if (!this.settings.enabled || !this.state.enabled) return undefined;
351
+ if (classifyMirrorToolCall(tool, args ?? {}, cwd, this.mirrorPath) !== "write") return undefined;
352
+ const check = this.validateMirror(this.store.readSync());
353
+ if (!check) return undefined;
354
+ if (!check.changed) return "[CLM] Mirror unchanged: the file still matches the last render.";
355
+ return check.accepted
356
+ ? `[CLM] Mirror edit valid: ${check.message} It applies from your next request.`
357
+ : `[CLM] Mirror edit would be refused: ${check.message} Correct the file before this step ends, or the context stays as it is.`;
358
+ }
359
+
360
+ /** The mirror block the model last saw, for continuity annotations. */
361
+ blockSource(blockId: string): ContinuityBlockSource | undefined {
362
+ const snapshot = this.lastSnapshot;
363
+ const block = snapshot?.blocks.find((candidate) => candidate.id === blockId);
364
+ return snapshot && block ? { message: block.source, revision: snapshot.revision } : undefined;
365
+ }
366
+
367
+ /** Step 1: commit the edit made since the last render. */
368
+ private async commit(): Promise<void> {
369
+ const baseline = this.baseline;
370
+ this.baseline = undefined;
371
+ if (!baseline || baseline.snapshot.revision !== this.state.revision) return;
372
+ let text: string | undefined;
373
+ try {
374
+ text = await this.store.read();
375
+ } catch (error) {
376
+ this.pendingNotices.push(`[CLM] Could not read the mirror: ${describe(error)}`);
377
+ return;
378
+ }
379
+ if (text === undefined || text.trim() === baseline.snapshot.text.trim()) return;
380
+ const result = applyContextDocument(text, baseline.snapshot, this.applyOptions(baseline));
381
+ const at = new Date().toISOString();
382
+ const sizes = { beforeEstimate: result.beforeEstimate, afterEstimate: result.afterEstimate, estimateUnit: "tokens" as const };
383
+ const reject = async (reason: string) => {
384
+ this.rejected += 1;
385
+ await this.updateState((state) => ({ ...state, lastOutcome: { kind: "rejected", message: reason, ...sizes, at } }), true)
386
+ .catch(() => undefined);
387
+ this.pendingNotices.push(`[CLM] Edit rejected; the context is unchanged. ${reason}`);
388
+ await this.log({ event: "rejected", revision: this.state.revision, reason });
389
+ };
390
+ if (!result.accepted) return reject(result.reason ?? "Context edit rejected.");
391
+ if (!result.changed) return;
392
+
393
+ const revision = this.state.revision + 1;
394
+ // state.ts requires editTrace.sourceMessageCount === checkpoint.sourceMessageCount.
395
+ // The trace counts rendered blocks (projection + suffix), the checkpoint counts raw
396
+ // source messages; they differ once an earlier revision is active, so the trace is
397
+ // then kept in events.jsonl only.
398
+ const trace = result.editTrace;
399
+ const traceFits = trace !== undefined && trace.sourceMessageCount === baseline.rawMessages.length;
400
+ let checkpoint: ProjectionCheckpoint;
401
+ try {
402
+ checkpoint = createProjectionCheckpoint({
403
+ revision,
404
+ sourceMessages: baseline.rawMessages,
405
+ projectedMessages: result.messages,
406
+ ...sizes,
407
+ createdAt: at,
408
+ ...(traceFits ? { editTrace: trace } : {}),
409
+ });
410
+ } catch (error) {
411
+ return reject(`The edited context cannot be saved: ${describe(error)}`);
412
+ }
413
+ const applied = `Applied revision ${revision}: editable context about ${formatTokens(result.beforeEstimate)} → ${formatTokens(result.afterEstimate)} tokens.`;
414
+ try {
415
+ await this.updateState((state) => {
416
+ if (state.revision + 1 !== revision) throw new Error("the session state changed while the edit was applied");
417
+ return {
418
+ version: 1,
419
+ enabled: state.enabled,
420
+ revision,
421
+ checkpoint,
422
+ lastOutcome: { kind: "applied", message: applied, ...sizes, at },
423
+ };
424
+ });
425
+ } catch (error) {
426
+ // Persist before activate: an unsaved revision would vanish on restart.
427
+ this.pendingNotices.push(`[CLM] Revision ${revision} was not applied: it could not be saved (${describe(error)}); the previous context stays in effect.`);
428
+ return;
429
+ }
430
+ this.accepted += 1;
431
+ const revisions = join(this.store.directory, "revisions");
432
+ await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
433
+ await writeFile(join(revisions, `r${revision}.md`), text, { mode: 0o600 }).catch(() => undefined);
434
+ const notes = result.diagnostics.length > 0 ? ` ${result.diagnostics.join(" ")}` : "";
435
+ this.pendingNotices.push(`[CLM] ${applied}${notes}`);
436
+ await this.log({
437
+ event: "accepted",
438
+ revision,
439
+ before: result.beforeEstimate,
440
+ after: result.afterEstimate,
441
+ trace: trace
442
+ ? {
443
+ sourceCount: trace.sourceMessageCount,
444
+ kept: trace.sources.filter((source) => source.kind === "kept").length,
445
+ edited: trace.sources.filter((source) => source.kind === "edited" || source.kind === "normalized").length,
446
+ removed: trace.sources.filter((source) => source.kind === "removed").length,
447
+ restored: trace.sources.filter((source) => source.kind === "restored").length,
448
+ added: trace.additions.length,
449
+ stored: traceFits,
450
+ }
451
+ : undefined,
452
+ });
453
+ }
454
+
455
+ // ---- the transform -----------------------------------------------------------------
456
+
457
+ /** Transforms of one session run one at a time. */
458
+ transform(raw: OcMessage[]): Promise<TransformResult> {
459
+ const run = this.running.then(() => this.transformNow(raw));
460
+ this.running = run.catch(() => undefined);
461
+ return run;
462
+ }
463
+
464
+ private context(raw: readonly OcMessage[]) {
465
+ const rawById = new Map(raw.map((message) => [message.info.id, message]));
466
+ const template = [...raw].reverse().find((message) => message.info.role === "user")?.info as OcInfo | undefined;
467
+ return { sessionID: this.sessionID, rawById, template };
468
+ }
469
+
470
+ /** Text for `experimental.session.compacting`'s `output.context`: the instruction plus the active annotations. */
471
+ async compactionContext(): Promise<string | undefined> {
472
+ if (!this.settings.enabled || !this.state.enabled) return undefined;
473
+ const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: [] });
474
+ return continuity ? `${COMPACTION_INSTRUCTION}\n\n${continuity}` : COMPACTION_INSTRUCTION;
475
+ }
476
+
477
+ /**
478
+ * After a compaction the history starts at the summary, which the compacting transform
479
+ * built from the accepted revision. Start a fresh baseline there: the revision number
480
+ * moves on, the checkpoint goes, and the model gets no drop note.
481
+ */
482
+ private async rebaseAfterCompaction(summaryId: string): Promise<void> {
483
+ const previous = this.state.checkpoint?.revision;
484
+ const message = `Rebased on compaction summary ${summaryId}.`;
485
+ const at = new Date().toISOString();
486
+ let written = true;
487
+ const next = await this.updateState((state) => ({
488
+ version: 1,
489
+ enabled: state.enabled,
490
+ revision: state.revision + 1,
491
+ lastOutcome: { kind: "compacted", message, at },
492
+ }), true).catch(() => {
493
+ written = false;
494
+ return this.state;
495
+ });
496
+ this.invalidationStreak = 0;
497
+ this.tracker.reset();
498
+ await this.log({ event: "compacted", revision: next.revision, previous, summary: summaryId, written });
499
+ }
500
+
501
+ private async loadAnnotations(): Promise<LiveContextAnnotation[]> {
502
+ try {
503
+ return await this.annotations.list();
504
+ } catch (error) {
505
+ await this.log({ event: "annotations-unreadable", error: describe(error) });
506
+ return [];
507
+ }
508
+ }
509
+
510
+ private async transformNow(raw: OcMessage[]): Promise<TransformResult> {
511
+ if (!this.settings.enabled || !this.state.enabled) {
512
+ this.baseline = undefined;
513
+ this.compacting = false;
514
+ return { messages: raw, notices: [] };
515
+ }
516
+ const pinned = pinnedCount(raw);
517
+ const pinnedMessages = raw.slice(0, pinned);
518
+ const context = this.context(raw);
519
+
520
+ // OpenCode's compaction passes a head slice of the history through this hook. Apply
521
+ // the accepted revision when it still covers that slice; never commit, render,
522
+ // notify or reset (the slice is shorter than the history the checkpoint covers).
523
+ if (this.compacting) {
524
+ this.compacting = false;
525
+ const projection = applyProjection(flatten(raw.slice(pinned)), this.state.checkpoint);
526
+ if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [] };
527
+ return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [] };
528
+ }
529
+
530
+ await this.commit();
531
+ this.requests += 1;
532
+ const compacted = this.compacted;
533
+ this.compacted = false;
534
+
535
+ // The raw source prefix is digested as flattened, before any view, cap or guard.
536
+ const source = flatten(raw.slice(pinned));
537
+ const observed = lastProviderReported(raw);
538
+ this.calibrator.observe(observed);
539
+
540
+ let projection = applyProjection(source, this.state.checkpoint);
541
+ // A late or repeated compaction signal must not swallow a later revert: rebase only
542
+ // onto a summary the active checkpoint does not already cover.
543
+ const summaryId = compacted ? compactionSummaryId(raw) : undefined;
544
+ const rebase = summaryId !== undefined && !projection.valid && this.state.checkpoint !== undefined &&
545
+ !this.state.checkpoint.sourceIds.includes(summaryId);
546
+ if (rebase) {
547
+ await this.rebaseAfterCompaction(summaryId);
548
+ projection = applyProjection(source, undefined);
549
+ } else if (!projection.valid) {
550
+ const dropped = this.state.checkpoint?.revision;
551
+ this.invalidationStreak += 1;
552
+ const reason = projection.reason;
553
+ await this.updateState((state) => resetProjectionState(state, reason), true).catch(() => undefined);
554
+ let notice = `[CLM] Revision ${dropped} was dropped because OpenCode's history changed under it: ${projection.reason} The mirror now shows the stored history.`;
555
+ if (this.invalidationStreak >= 2) {
556
+ notice += " This happened on consecutive requests; edits keep being dropped until the start of the history stops changing.";
557
+ }
558
+ this.pendingNotices.push(notice);
559
+ await this.log({ event: "projection-reset", revision: dropped, reason: projection.reason });
560
+ projection = applyProjection(source, undefined);
561
+ } else if (this.state.checkpoint) {
562
+ this.invalidationStreak = 0;
563
+ }
564
+ const checkpoint = this.state.checkpoint;
565
+ const suffixLength = projection.valid ? projection.suffix.length : 0;
566
+
567
+ let effective = projection.messages;
568
+ if (!this.settings.reasoning) effective = withoutReasoning(effective);
569
+ effective = capObservations(effective, this.settings.observationCap);
570
+
571
+ const annotations = await this.loadAnnotations();
572
+ const continuityText = (messages: LiveContextMessage[]) =>
573
+ formatContinuityMessage({ annotations, effectiveMessages: messages });
574
+ const pinnedTokens = this.estimate(flatten(pinnedMessages));
575
+ const resolved = this.resolvedBudget();
576
+ const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
577
+ // System prompt and tool schemas, when the hooks reported them (one request late).
578
+ const { systemTokens, toolTokens } = this.scope;
579
+ const scopeTokens = this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0));
580
+ const excluded = [
581
+ ...(systemTokens === undefined ? ["the system prompt"] : []),
582
+ ...(toolTokens === undefined ? ["tool schemas"] : []),
583
+ ];
584
+
585
+ let withheldCount = 0;
586
+ if (this.settings.guard === "withhold" && limit !== undefined) {
587
+ const before = continuityText(effective);
588
+ const fixedTokens = pinnedTokens + scopeTokens +
589
+ this.calibrator.apply((before ? this.textTokens(before) : 0) + this.noticeTokens(this.pendingNotices));
590
+ const guarded = applyOverflowGuard(effective, {
591
+ limit,
592
+ fixedTokens,
593
+ estimate: this.estimate,
594
+ saveDirectory: join(this.store.directory, "withheld"),
595
+ // The model's accepted context is respected; only the new raw suffix is withheld.
596
+ protectBefore: checkpoint ? effective.length - suffixLength : 0,
597
+ });
598
+ if (guarded.withheld.length > 0) {
599
+ effective = guarded.messages;
600
+ withheldCount = guarded.withheld.length;
601
+ this.pendingNotices.push(overflowNoticeText(guarded, limit));
602
+ await this.log({
603
+ event: "overflow-guard",
604
+ withheld: guarded.withheld.map((record) => ({ id: record.toolCallId, tokens: record.tokens, file: record.file })),
605
+ });
606
+ }
607
+ }
608
+
609
+ // Continuity sits after the editable context, outside the mirror.
610
+ const continuity = continuityText(effective);
611
+ const continuityTokens = continuity ? this.calibrator.apply(this.textTokens(continuity)) : 0;
612
+ if (this.continuitySize.observe(continuity ? this.textTokens(continuity) : 0)) {
613
+ this.pendingNotices.push(continuitySizeNoticeText(this.textTokens(continuity ?? "")));
614
+ }
615
+
616
+ const snapshot = renderContextDocument(effective, {
617
+ revision: this.state.revision,
618
+ protectedIndexes: new Set<number>(),
619
+ // Constant until the next accepted edit or reset, so header ids read on one call
620
+ // remain valid on the next; bodies are escaped accordingly.
621
+ documentSeed: `${this.sessionID}:${this.nonce}:${checkpoint?.sourceDigest ?? "raw"}`,
622
+ });
623
+ try {
624
+ await this.store.write(snapshot.text);
625
+ this.lastSnapshot = snapshot;
626
+ this.baseline = {
627
+ rawMessages: source,
628
+ effectiveMessages: effective,
629
+ snapshot,
630
+ // The fit gate measures the editable context alone; the system prompt, tool
631
+ // schemas, pinned task and continuity message take their share of budget − reserve first.
632
+ limit: limit === undefined ? undefined : Math.max(1, limit - scopeTokens - pinnedTokens - continuityTokens),
633
+ };
634
+ } catch (error) {
635
+ this.baseline = undefined;
636
+ this.pendingNotices.push(`[CLM] Could not refresh the mirror: ${describe(error)}`);
637
+ }
638
+
639
+ const conversationTokens = pinnedTokens + this.estimate(effective) + continuityTokens;
640
+ const requestTokens = scopeTokens + conversationTokens;
641
+ let reading: BudgetReading | undefined;
642
+ if (resolved) {
643
+ reading = {
644
+ ...resolved,
645
+ estimated: requestTokens + this.calibrator.apply(this.noticeTokens(this.pendingNotices)),
646
+ ...(excluded.length > 0 ? { estimateExcludes: excluded.join(" and ") } : {}),
647
+ calibration: this.calibrator.factor,
648
+ observed: observed?.tokens,
649
+ // The measured request was answered inside the prefix the active revision replaced.
650
+ observedStale: observed !== undefined && checkpoint !== undefined && checkpoint.sourceIds.includes(observed.messageID),
651
+ };
652
+ const tier = this.tracker.observe(reading, budgetTiers(this.settings.budget, resolved.budget, resolved.reserve));
653
+ if (tier) {
654
+ // With the guard off nothing is withheld, so the notice must not describe it.
655
+ this.pendingNotices.push(budgetNoticeText(reading, tier, this.mirrorPath, this.settings.guard === "off" ? null : limit));
656
+ await this.log({ event: "budget-notice", tier: tier.label, estimated: reading.estimated });
657
+ }
658
+ this.lastReading = reading;
659
+ }
660
+
661
+ const notices = this.pendingNotices;
662
+ this.pendingNotices = [];
663
+ const messages = [
664
+ ...pinnedMessages,
665
+ ...unflatten(effective, context),
666
+ ...(continuity ? [noteMessage(continuity, "continuity", context)] : []),
667
+ ...(notices.length > 0 ? [noteMessage(notices.join("\n\n"), `notice:${this.requests}`, context)] : []),
668
+ ];
669
+
670
+ // Calibrate only against a same-scope estimate: the provider count includes the system
671
+ // prompt and tool schemas, so both sizes must be known.
672
+ if (systemTokens !== undefined && toolTokens !== undefined) {
673
+ const conversation = this.rawTokens(flatten(pinnedMessages)) + this.rawTokens(effective) +
674
+ (continuity ? this.textTokens(continuity) : 0) + this.noticeTokens(notices);
675
+ this.calibrator.record(systemTokens + toolTokens + conversation, raw.length);
676
+ }
677
+
678
+ this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: snapshot.blocks.length };
679
+ if (this.settings.dumpRequests) {
680
+ const directory = join(this.store.directory, "requests");
681
+ await mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => undefined);
682
+ await writeFile(join(directory, `n${this.requests}.json`), JSON.stringify(messages, null, 1), { mode: 0o600 }).catch(() => undefined);
683
+ }
684
+ await this.log({
685
+ event: "request",
686
+ n: this.requests,
687
+ revision: this.state.revision,
688
+ raw: raw.length,
689
+ sent: messages.length,
690
+ blocks: snapshot.blocks.length,
691
+ withheld: withheldCount,
692
+ estimated: reading?.estimated ?? requestTokens,
693
+ observedPrevious: observed?.tokens,
694
+ calibration: this.calibrator.factor,
695
+ notices: notices.map((notice) => notice.slice(0, 80)),
696
+ });
697
+ return { messages, notices, estimated: reading?.estimated ?? requestTokens, reading };
698
+ }
699
+
700
+ /** Inputs for presentation.ts `statusText` / `statusLine`. */
701
+ status(steering?: SteeringDocument): ClmStatus {
702
+ const checkpoint = this.state.checkpoint;
703
+ return {
704
+ sessionID: this.sessionID,
705
+ mirrorPath: this.mirrorPath,
706
+ revision: this.state.revision,
707
+ accepted: this.accepted,
708
+ rejected: this.rejected,
709
+ gate: this.settings.gate,
710
+ guard: this.settings.guard,
711
+ reading: this.lastReading,
712
+ modelWindow: this.limits.context,
713
+ checkpoint: checkpoint
714
+ ? {
715
+ revision: checkpoint.revision,
716
+ anchorCount: checkpoint.sourceMessageCount,
717
+ beforeEstimate: checkpoint.beforeEstimate,
718
+ afterEstimate: checkpoint.afterEstimate,
719
+ }
720
+ : undefined,
721
+ lastRequest: this.lastRequest,
722
+ steering,
723
+ };
724
+ }
725
+ }