opencode-clm 0.1.0 → 0.2.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 CHANGED
@@ -33,16 +33,20 @@
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 {
40
+ budgetFit,
40
41
  budgetNoticeText,
41
42
  budgetTiers,
43
+ budgetTooSmallAlertText,
44
+ budgetTooSmallNoticeText,
42
45
  BudgetTracker,
43
46
  EstimateCalibrator,
44
47
  formatTokens,
45
48
  resolveBudget,
49
+ type BudgetFit,
46
50
  type BudgetReading,
47
51
  } from "./budget.ts";
48
52
  import { applyContextDocument, renderContextDocument, renderMessage } from "./context-document.ts";
@@ -63,13 +67,19 @@ import type { ClmStatus } from "./presentation.ts";
63
67
  import { applyProjection, createProjectionCheckpoint, type ProjectionCheckpoint } from "./projection.ts";
64
68
  import type { ClmSettings } from "./settings.ts";
65
69
  import {
70
+ keptFields,
66
71
  loadLiveContextState,
67
72
  resetProjectionState,
68
73
  saveLiveContextState,
74
+ type BudgetCheck,
69
75
  type LiveContextState,
70
76
  } from "./state.ts";
71
77
  import type { SteeringDocument } from "./steering.ts";
72
- import type { ContextDocumentSnapshot, LiveContextMessage, TurnBaseline } from "./types.ts";
78
+ import { overridesPath, readOverrides, stageSettings, type ChangeRequest, type StagedSettings } from "./overrides.ts";
79
+ import { changedSummary, settingsAsOverrides, type ClmOverrides, type SettingsValues } from "./settings-table.ts";
80
+ import type { ContextDocumentSnapshot, ContextEditTrace, LiveContextMessage, TurnBaseline } from "./types.ts";
81
+ import { writeJsonAtomic } from "./atomic.ts";
82
+ import { SNAPSHOT_FILE, type RevisionFile, type RevisionFileRow, type SnapshotFile } from "./panel/files.ts";
73
83
 
74
84
  export interface ModelLimits {
75
85
  /** Model context window, tokens. */
@@ -94,6 +104,8 @@ export interface TransformResult {
94
104
  /** Calibrated estimate of this request, tokens (system prompt and tool schemas included when known); undefined when CLM did not run. */
95
105
  estimated?: number;
96
106
  reading?: BudgetReading;
107
+ /** User-facing warning for a toast (the budget-too-small check); set on one request only. */
108
+ alert?: string;
97
109
  }
98
110
 
99
111
  export interface MirrorCheck {
@@ -112,6 +124,12 @@ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
112
124
 
113
125
  const CHARS_PER_TOKEN = 4;
114
126
 
127
+ /**
128
+ * Largest share of the provider count the conversation estimate may hold for an overhead
129
+ * measurement; above it the estimate's error would dominate the result.
130
+ */
131
+ const MAX_CONVERSATION_SHARE = 0.25;
132
+
115
133
  /** Index after the pinned prefix: everything up to and including the first user message. */
116
134
  export function pinnedCount(raw: readonly OcMessage[]): number {
117
135
  const first = raw.findIndex((message) => message.info.role === "user");
@@ -187,6 +205,8 @@ export class ClmSession {
187
205
  requests = 0;
188
206
 
189
207
  private pendingNotices: string[] = [];
208
+ /** Raw length and conversation estimate (calibrated, tokens) of the last request, for the overhead measurement. */
209
+ private previousRequest?: { rawCount: number; conversation: number };
190
210
  private invalidationStreak = 0;
191
211
  /**
192
212
  * Per-process seed of the stable document id. Fresh on every open, so a document id never
@@ -197,27 +217,124 @@ export class ClmSession {
197
217
  private saving: Promise<void> = Promise.resolve();
198
218
  private running: Promise<unknown> = Promise.resolve();
199
219
 
220
+ /** Settings in force: `baseSettings` with this session's overrides.json applied (`refreshSettings`). */
221
+ settings: ClmSettings;
222
+ /** Overrides in force, as read from overrides.json. */
223
+ overrides: ClmOverrides = {};
224
+ /** The steering document in force for this session; undefined is protocol only. */
225
+ steering?: SteeringDocument;
226
+ /** Why saved settings or the steering document were (partly) ignored; shown by `/clm status`. */
227
+ settingsWarning?: string;
228
+ /** stat key of the overrides.json last read; a change triggers a re-read. */
229
+ private overridesKey = "";
230
+
200
231
  private constructor(
201
232
  readonly sessionID: string,
202
233
  readonly store: MirrorStore,
203
- readonly settings: ClmSettings,
234
+ readonly baseSettings: ClmSettings,
204
235
  loaded: { state: LiveContextState; warning?: string },
236
+ readonly baseSteering?: SteeringDocument,
205
237
  ) {
238
+ this.settings = baseSettings;
239
+ this.steering = baseSteering;
206
240
  this.state = loaded.state;
207
241
  this.loadWarning = loaded.warning;
208
242
  this.annotations = new AnnotationStore(store.directory, { estimateTokens: (text) => this.textTokens(text) });
209
243
  }
210
244
 
211
- /** Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. */
212
- static async open(sessionID: string, settings: ClmSettings): Promise<ClmSession> {
245
+ /**
246
+ * Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. `steering` is the
247
+ * document the base settings name, already loaded (the plugin loads it strictly at start).
248
+ */
249
+ static async open(sessionID: string, settings: ClmSettings, options: { steering?: SteeringDocument } = {}): Promise<ClmSession> {
213
250
  if (!SESSION_ID_RE.test(sessionID)) throw new Error(`Invalid session id for CLM: ${JSON.stringify(sessionID)}`);
214
251
  const store = await MirrorStore.create(sessionID, settings.mirrorDir);
215
252
  const loaded = await loadLiveContextState(store.directory);
216
- const session = new ClmSession(sessionID, store, settings, loaded);
253
+ const session = new ClmSession(sessionID, store, settings, loaded, options.steering);
217
254
  if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
255
+ if (loaded.repaired) await session.log({ event: "state-repaired", message: loaded.repaired });
256
+ await session.refreshSettings();
218
257
  return session;
219
258
  }
220
259
 
260
+ /** Whether CLM edits apply in this session: the `editing` override, else state.json. */
261
+ get enabled(): boolean {
262
+ return this.overrides.editing ?? this.state.enabled;
263
+ }
264
+
265
+ /** Base and effective values, for the settings table. */
266
+ settingsValues(): { base: SettingsValues; effective: SettingsValues } {
267
+ return {
268
+ base: { editing: this.state.enabled, settings: this.baseSettings },
269
+ effective: { editing: this.enabled, settings: this.settings },
270
+ };
271
+ }
272
+
273
+ /** For `changeSetting` (overrides.ts): where and against what a change is validated. */
274
+ changeRequest(projectDirectory: string): ChangeRequest {
275
+ return {
276
+ sessionDirectory: this.store.directory,
277
+ base: this.baseSettings,
278
+ baseEditing: this.state.enabled,
279
+ projectDirectory,
280
+ format: { modelWindow: this.limits.context },
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Re-read overrides.json when it changed (stat: mtime, size, inode) and activate it.
286
+ * Runs before every request and every `/clm` command, so a change written by the TUI or
287
+ * the server's `/clm config` applies from the next request. Invalid keys are dropped; a
288
+ * set that does not resolve falls back to the base settings; a steering document that
289
+ * does not load leaves the session protocol-only. Each case is kept in `settingsWarning`.
290
+ * `force` re-reads even when the file looks unchanged.
291
+ */
292
+ async refreshSettings(force = false): Promise<void> {
293
+ const path = overridesPath(this.store.directory);
294
+ let key = "missing";
295
+ try {
296
+ const info = await stat(path);
297
+ key = `${info.mtimeMs}:${info.size}:${info.ino}`;
298
+ } catch {
299
+ // missing: no overrides
300
+ }
301
+ if (!force && key === this.overridesKey) return;
302
+ this.overridesKey = key;
303
+ const read = key === "missing" ? { overrides: {} as ClmOverrides } : await readOverrides(this.store.directory);
304
+ const warnings: string[] = read.warning ? [read.warning] : [];
305
+ let staged: StagedSettings;
306
+ let overrides = read.overrides;
307
+ try {
308
+ staged = stageSettings(this.baseSettings, overrides, { strict: false, loaded: this.steering ?? this.baseSteering });
309
+ } catch (error) {
310
+ warnings.push(`ignored saved settings: ${describe(error)}`);
311
+ overrides = {};
312
+ staged = { settings: this.baseSettings, ...(this.baseSteering ? { steering: this.baseSteering } : {}) };
313
+ }
314
+ if (staged.steeringError) warnings.push(`steering document not loaded: ${staged.steeringError}`);
315
+ await this.activateSettings(staged, overrides);
316
+ this.settingsWarning = warnings.length > 0 ? warnings.join("; ") : undefined;
317
+ }
318
+
319
+ private async activateSettings(staged: StagedSettings, overrides: ClmOverrides): Promise<void> {
320
+ const before = this.settings.budget;
321
+ const after = staged.settings.budget;
322
+ const wasEnabled = this.enabled;
323
+ const changed = JSON.stringify(this.overrides) !== JSON.stringify(overrides);
324
+ this.settings = staged.settings;
325
+ this.steering = staged.steering;
326
+ this.overrides = overrides;
327
+ if (before.contextBudget !== after.contextBudget || before.reserve !== after.reserve ||
328
+ before.remindAtFractions.join(",") !== after.remindAtFractions.join(",") || before.remindAtReserve !== after.remindAtReserve) {
329
+ this.tracker.reset();
330
+ }
331
+ if (wasEnabled !== this.enabled) {
332
+ this.baseline = undefined;
333
+ if (!this.enabled) this.continuitySize.observe(0);
334
+ }
335
+ if (changed) await this.log({ event: "settings", overrides });
336
+ }
337
+
221
338
  get mirrorPath(): string {
222
339
  return this.store.filePath;
223
340
  }
@@ -250,8 +367,29 @@ export class ClmSession {
250
367
  return notices.reduce((total, notice) => total + this.textTokens(notice), 0);
251
368
  }
252
369
 
253
- resolvedBudget() {
254
- return resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
370
+ /**
371
+ * The budget in force: the configured one (or the model window), raised when the
372
+ * session's budget check found it too small for OpenCode's fixed overhead (`budgetFit`).
373
+ */
374
+ resolvedBudget(): Pick<BudgetReading, "budget" | "reserve" | "source" | "raisedFrom"> | undefined {
375
+ const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
376
+ if (!base) return undefined;
377
+ const fit = budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap());
378
+ return fit.raised ? { ...base, budget: fit.effective, raisedFrom: base.budget } : base;
379
+ }
380
+
381
+ /** Model window minus its output limit, when known: the ceiling for a raised budget. */
382
+ private windowCap(): number | undefined {
383
+ return resolveBudget({ ...this.settings.budget, contextBudget: undefined }, this.limits.context, this.limits.output)?.budget;
384
+ }
385
+
386
+ /**
387
+ * Budget arithmetic for `/clm` and a TUI panel: fixed overhead, usable budget, effective
388
+ * budget. Undefined while the budget is unknown; `overhead` is unset until measured.
389
+ */
390
+ budgetFit(): BudgetFit | undefined {
391
+ const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
392
+ return base ? budgetFit(base.budget, base.reserve, this.state.budgetCheck?.overhead, this.windowCap()) : undefined;
255
393
  }
256
394
 
257
395
  /** budget − reserve, or undefined while the budget is unknown. */
@@ -260,6 +398,81 @@ export class ClmSession {
260
398
  return resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
261
399
  }
262
400
 
401
+ /**
402
+ * Fixed overhead of a request (system prompt + tool schemas), measured once per session.
403
+ *
404
+ * Request 1 cannot measure it: OpenCode calls `experimental.chat.system.transform` after
405
+ * this hook, and the provider count arrives with the reply. So a later transform measures
406
+ * it: provider-reported input of the previous request minus that request's conversation
407
+ * estimate. The estimate's error lands in the overhead, so the measurement waits for a
408
+ * request whose conversation is at most MAX_CONVERSATION_SHARE of the provider count
409
+ * (a restarted or upgraded session with a long history is measured after the model
410
+ * shrinks its context, or not at all).
411
+ *
412
+ * Fallback: the chars/4 hook sizes, only when the provider answered without reporting
413
+ * usage. They miss OpenCode's built-in tool schemas (Effect schemas, not measured;
414
+ * several thousand tokens low), so a fallback result is replaced by the first provider
415
+ * measurement.
416
+ */
417
+ private measureOverhead(raw: readonly OcMessage[], observed: ObservedRequest | undefined): Pick<BudgetCheck, "overhead" | "source"> | undefined {
418
+ const previous = this.previousRequest;
419
+ if (!previous) return undefined;
420
+ if (observed && observed.index >= previous.rawCount) {
421
+ if (previous.conversation > observed.tokens * MAX_CONVERSATION_SHARE) return undefined;
422
+ return { overhead: Math.round(observed.tokens - previous.conversation), source: "provider" };
423
+ }
424
+ if (this.state.budgetCheck) return undefined; // an estimate never replaces a measurement
425
+ const answered = raw.slice(previous.rawCount)
426
+ .some((message) => message.info.role === "assistant" && !message.info.error && !message.info.summary);
427
+ const { systemTokens, toolTokens } = this.scope;
428
+ if (answered && systemTokens !== undefined && toolTokens !== undefined) {
429
+ return { overhead: this.calibrator.apply(systemTokens + toolTokens), source: "estimate" };
430
+ }
431
+ return undefined;
432
+ }
433
+
434
+ /**
435
+ * The budget-too-small check, once per session (plus once more when a provider count
436
+ * replaces a fallback estimate). Decision: raise the effective budget to overhead +
437
+ * WORKING_MARGIN + reserve (capped by the model window) rather than switch the overflow
438
+ * guard off. With the guard off, one large tool result can push the request past the
439
+ * model window; with the budget raised, reminders, the fit gate and the guard keep
440
+ * working, measured against a budget the conversation can actually use. The overhead is
441
+ * persisted in `state.budgetCheck` and the raise is derived from it on every request, so
442
+ * it survives a restart, and the event, toast and notice do not repeat.
443
+ */
444
+ private async checkBudget(measured: Pick<BudgetCheck, "overhead" | "source">): Promise<string | undefined> {
445
+ const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
446
+ if (!base) return undefined;
447
+ const previous = this.state.budgetCheck;
448
+ const fit = budgetFit(base.budget, base.reserve, measured.overhead, this.windowCap());
449
+ const check: BudgetCheck = {
450
+ ...measured,
451
+ configured: base.budget,
452
+ reserve: base.reserve,
453
+ effective: fit.effective,
454
+ raised: fit.raised,
455
+ at: new Date().toISOString(),
456
+ };
457
+ // activateOnFailure: a failed save still applies the raise in this process.
458
+ await this.updateState((state) => ({ ...state, budgetCheck: check }), true).catch(() => undefined);
459
+ // A replaced estimate that already reported "too small" does not warn again.
460
+ const warnedBefore = previous !== undefined && budgetFit(previous.configured, previous.reserve, previous.overhead).tooSmall;
461
+ const warn = fit.tooSmall && !warnedBefore;
462
+ await this.log({
463
+ event: warn ? "budget-too-small" : "budget-check",
464
+ ...check,
465
+ usable: fit.usable,
466
+ margin: fit.margin,
467
+ minimum: fit.minimum,
468
+ capped: fit.capped,
469
+ ...(previous ? { replaces: previous.source, previousOverhead: previous.overhead } : {}),
470
+ });
471
+ if (!warn) return undefined;
472
+ this.pendingNotices.push(budgetTooSmallNoticeText(fit));
473
+ return budgetTooSmallAlertText(fit);
474
+ }
475
+
263
476
  // ---- persistence -------------------------------------------------------------------
264
477
 
265
478
  /**
@@ -347,7 +560,7 @@ export class ClmSession {
347
560
  * calls that only read it or do not touch it.
348
561
  */
349
562
  receipt(tool: string, args: Record<string, unknown> | undefined, cwd: string): string | undefined {
350
- if (!this.settings.enabled || !this.state.enabled) return undefined;
563
+ if (!this.settings.enabled || !this.enabled) return undefined;
351
564
  if (classifyMirrorToolCall(tool, args ?? {}, cwd, this.mirrorPath) !== "write") return undefined;
352
565
  const check = this.validateMirror(this.store.readSync());
353
566
  if (!check) return undefined;
@@ -420,6 +633,7 @@ export class ClmSession {
420
633
  revision,
421
634
  checkpoint,
422
635
  lastOutcome: { kind: "applied", message: applied, ...sizes, at },
636
+ ...keptFields(state),
423
637
  };
424
638
  });
425
639
  } catch (error) {
@@ -431,6 +645,10 @@ export class ClmSession {
431
645
  const revisions = join(this.store.directory, "revisions");
432
646
  await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
433
647
  await writeFile(join(revisions, `r${revision}.md`), text, { mode: 0o600 }).catch(() => undefined);
648
+ if (trace) {
649
+ await writeJsonAtomic(join(revisions, `r${revision}.json`), this.revisionFile(revision, at, baseline, result.messages, trace, sizes))
650
+ .catch((error: unknown) => this.log({ event: "revision-file-error", revision, error: describe(error) }));
651
+ }
434
652
  const notes = result.diagnostics.length > 0 ? ` ${result.diagnostics.join(" ")}` : "";
435
653
  this.pendingNotices.push(`[CLM] ${applied}${notes}`);
436
654
  await this.log({
@@ -452,6 +670,101 @@ export class ClmSession {
452
670
  });
453
671
  }
454
672
 
673
+ /** `revisions/rN.json` for the panel: every row's full text and role before and after. */
674
+ private revisionFile(
675
+ revision: number,
676
+ at: string,
677
+ baseline: Baseline,
678
+ output: readonly LiveContextMessage[],
679
+ trace: ContextEditTrace,
680
+ sizes: { beforeEstimate: number; afterEstimate: number },
681
+ ): RevisionFile {
682
+ const rows: RevisionFileRow[] = [];
683
+ const roleOf = (message: LiveContextMessage | undefined) => (message ? String(message.role) : "?");
684
+ for (const source of trace.sources) {
685
+ const before = baseline.snapshot.blocks[source.sourceIndex]?.source;
686
+ const after = source.outputIndex !== undefined ? output[source.outputIndex] : undefined;
687
+ const row: RevisionFileRow = { kind: source.kind, sourceIndex: source.sourceIndex, role: roleOf(after ?? before) };
688
+ if (source.outputIndex !== undefined) row.outputIndex = source.outputIndex;
689
+ if (before && after && roleOf(before) !== roleOf(after)) {
690
+ row.beforeRole = roleOf(before);
691
+ row.afterRole = roleOf(after);
692
+ }
693
+ if (before) row.beforeTokens = this.estimate([before]);
694
+ if (after && source.kind !== "removed") row.afterTokens = this.estimate([after]);
695
+ const beforeText = before ? renderMessage(before) : undefined;
696
+ const afterText = after && source.kind !== "removed" ? renderMessage(after) : undefined;
697
+ // Unchanged rows (kept, most restored) store their text once.
698
+ if (beforeText !== undefined && beforeText === afterText) row.text = beforeText;
699
+ else {
700
+ if (beforeText !== undefined) row.before = beforeText;
701
+ if (afterText !== undefined) row.after = afterText;
702
+ }
703
+ rows.push(row);
704
+ }
705
+ for (const addition of trace.additions) {
706
+ const after = output[addition.outputIndex];
707
+ rows.push({
708
+ kind: "added",
709
+ outputIndex: addition.outputIndex,
710
+ role: roleOf(after),
711
+ ...(after ? { after: renderMessage(after), afterTokens: this.estimate([after]) } : {}),
712
+ });
713
+ }
714
+ return {
715
+ version: 1,
716
+ revision,
717
+ sourceRevision: trace.sourceRevision,
718
+ at,
719
+ beforeTokens: sizes.beforeEstimate,
720
+ afterTokens: sizes.afterEstimate,
721
+ rows,
722
+ };
723
+ }
724
+
725
+ /** `snapshot.json` for the panel: the request just built, replaced atomically. */
726
+ private snapshotFile(input: {
727
+ pinned: readonly LiveContextMessage[];
728
+ effective: readonly LiveContextMessage[];
729
+ sourceTokens: number;
730
+ raw: number;
731
+ sent: number;
732
+ suffix: number;
733
+ estimated: number;
734
+ observed?: number;
735
+ }): SnapshotFile | undefined {
736
+ const base = resolveBudget(this.settings.budget, this.limits.context, this.limits.output);
737
+ const resolved = this.resolvedBudget();
738
+ if (!base || !resolved) return undefined;
739
+ const shown = [...input.pinned, ...input.effective];
740
+ const steering = this.steering;
741
+ return {
742
+ at: new Date().toISOString(),
743
+ request: this.requests,
744
+ revision: this.state.revision,
745
+ enabled: this.enabled,
746
+ budget: { budget: base.budget, reserve: base.reserve, limit: overflowGuardLimit(resolved.budget, resolved.reserve), source: base.source },
747
+ calibration: { factor: this.calibrator.factor, samples: this.calibrator.sampleCount },
748
+ ...(steering ? { steering: { name: steering.name, hash: steering.hash, path: steering.path } } : {}),
749
+ sizes: { estimated: input.estimated, ...(input.observed !== undefined ? { observedPrevious: input.observed } : {}) },
750
+ input: {
751
+ raw: input.raw,
752
+ sent: input.sent,
753
+ suffix: input.suffix,
754
+ rawTokens: input.sourceTokens,
755
+ effectiveTokens: this.estimate([...shown]),
756
+ messages: shown.map((message, index) => ({
757
+ index: index + 1,
758
+ role: String(message.role),
759
+ tokens: this.estimate([message]),
760
+ preview: renderMessage(message).replace(/\s+/g, " ").trim().slice(0, 120),
761
+ })),
762
+ },
763
+ ...(this.state.budgetCheck ? { overhead: this.state.budgetCheck.overhead } : {}),
764
+ base: settingsAsOverrides(this.baseSettings) as Record<string, unknown>,
765
+ };
766
+ }
767
+
455
768
  // ---- the transform -----------------------------------------------------------------
456
769
 
457
770
  /** Transforms of one session run one at a time. */
@@ -469,7 +782,7 @@ export class ClmSession {
469
782
 
470
783
  /** Text for `experimental.session.compacting`'s `output.context`: the instruction plus the active annotations. */
471
784
  async compactionContext(): Promise<string | undefined> {
472
- if (!this.settings.enabled || !this.state.enabled) return undefined;
785
+ if (!this.settings.enabled || !this.enabled) return undefined;
473
786
  const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: [] });
474
787
  return continuity ? `${COMPACTION_INSTRUCTION}\n\n${continuity}` : COMPACTION_INSTRUCTION;
475
788
  }
@@ -489,6 +802,7 @@ export class ClmSession {
489
802
  enabled: state.enabled,
490
803
  revision: state.revision + 1,
491
804
  lastOutcome: { kind: "compacted", message, at },
805
+ ...keptFields(state),
492
806
  }), true).catch(() => {
493
807
  written = false;
494
808
  return this.state;
@@ -508,7 +822,8 @@ export class ClmSession {
508
822
  }
509
823
 
510
824
  private async transformNow(raw: OcMessage[]): Promise<TransformResult> {
511
- if (!this.settings.enabled || !this.state.enabled) {
825
+ await this.refreshSettings();
826
+ if (!this.settings.enabled || !this.enabled) {
512
827
  this.baseline = undefined;
513
828
  this.compacting = false;
514
829
  return { messages: raw, notices: [] };
@@ -536,6 +851,12 @@ export class ClmSession {
536
851
  const source = flatten(raw.slice(pinned));
537
852
  const observed = lastProviderReported(raw);
538
853
  this.calibrator.observe(observed);
854
+ // Before the guard and the reading, so a raise applies to this request already.
855
+ const measured = this.state.budgetCheck?.source === "provider" ? undefined : this.measureOverhead(raw, observed);
856
+ const alert = measured ? await this.checkBudget(measured) : undefined;
857
+ // Samples taken before the overhead was measured read the unmeasured part of the
858
+ // overhead (built-in tool schemas) as undercounting; start calibration over.
859
+ if (measured?.source === "provider") this.calibrator.reset();
539
860
 
540
861
  let projection = applyProjection(source, this.state.checkpoint);
541
862
  // A late or repeated compaction signal must not swallow a later revert: rebase only
@@ -575,9 +896,15 @@ export class ClmSession {
575
896
  const resolved = this.resolvedBudget();
576
897
  const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
577
898
  // System prompt and tool schemas, when the hooks reported them (one request late).
899
+ // A provider-measured overhead replaces them: it is in real tokens, so it is not
900
+ // calibrated, and it includes the built-in tool schemas the hooks cannot size. The
901
+ // hook sizes stay a floor, since they count text that is certainly sent.
578
902
  const { systemTokens, toolTokens } = this.scope;
579
- const scopeTokens = this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0));
580
- const excluded = [
903
+ const measuredOverhead = this.state.budgetCheck?.source === "provider"
904
+ ? Math.max(this.state.budgetCheck.overhead, (systemTokens ?? 0) + (toolTokens ?? 0))
905
+ : undefined;
906
+ const scopeTokens = measuredOverhead ?? this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0));
907
+ const excluded = measuredOverhead !== undefined ? [] : [
581
908
  ...(systemTokens === undefined ? ["the system prompt"] : []),
582
909
  ...(toolTokens === undefined ? ["tool schemas"] : []),
583
910
  ];
@@ -668,39 +995,65 @@ export class ClmSession {
668
995
  ];
669
996
 
670
997
  // 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);
998
+ // prompt and tool schemas. With a measured overhead the calibrator compares the
999
+ // conversation with the provider count minus that overhead; otherwise both hook sizes
1000
+ // must be known.
1001
+ const conversationRaw = this.rawTokens(flatten(pinnedMessages)) + this.rawTokens(effective) +
1002
+ (continuity ? this.textTokens(continuity) : 0) + this.noticeTokens(notices);
1003
+ if (measuredOverhead !== undefined) {
1004
+ this.calibrator.record(conversationRaw, raw.length, measuredOverhead);
1005
+ } else if (systemTokens !== undefined && toolTokens !== undefined) {
1006
+ this.calibrator.record(systemTokens + toolTokens + conversationRaw, raw.length);
676
1007
  }
677
1008
 
1009
+ this.previousRequest = { rawCount: raw.length, conversation: conversationTokens + this.calibrator.apply(this.noticeTokens(notices)) };
678
1010
  this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: snapshot.blocks.length };
679
1011
  if (this.settings.dumpRequests) {
680
1012
  const directory = join(this.store.directory, "requests");
681
1013
  await mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => undefined);
682
1014
  await writeFile(join(directory, `n${this.requests}.json`), JSON.stringify(messages, null, 1), { mode: 0o600 }).catch(() => undefined);
683
1015
  }
1016
+ const snapshotFile = this.snapshotFile({
1017
+ pinned: flatten(pinnedMessages),
1018
+ effective,
1019
+ sourceTokens: pinnedTokens + this.estimate(source),
1020
+ raw: raw.length,
1021
+ sent: messages.length,
1022
+ suffix: suffixLength,
1023
+ estimated: reading?.estimated ?? requestTokens,
1024
+ ...(observed ? { observed: observed.tokens } : {}),
1025
+ });
1026
+ if (snapshotFile) {
1027
+ await writeJsonAtomic(join(this.store.directory, SNAPSHOT_FILE), snapshotFile)
1028
+ .catch((error: unknown) => this.log({ event: "snapshot-error", error: describe(error) }));
1029
+ }
684
1030
  await this.log({
685
1031
  event: "request",
686
1032
  n: this.requests,
687
1033
  revision: this.state.revision,
1034
+ users: raw.filter((message) => message.info.role === "user").length,
688
1035
  raw: raw.length,
689
1036
  sent: messages.length,
690
1037
  blocks: snapshot.blocks.length,
691
1038
  withheld: withheldCount,
692
1039
  estimated: reading?.estimated ?? requestTokens,
693
1040
  observedPrevious: observed?.tokens,
1041
+ // The assistant message that reported `observedPrevious`; a new id means a new count.
1042
+ observedMessage: observed?.messageID,
694
1043
  calibration: this.calibrator.factor,
695
1044
  notices: notices.map((notice) => notice.slice(0, 80)),
696
1045
  });
697
- return { messages, notices, estimated: reading?.estimated ?? requestTokens, reading };
1046
+ return { messages, notices, estimated: reading?.estimated ?? requestTokens, reading, ...(alert ? { alert } : {}) };
698
1047
  }
699
1048
 
700
1049
  /** Inputs for presentation.ts `statusText` / `statusLine`. */
701
- status(steering?: SteeringDocument): ClmStatus {
1050
+ status(steering: SteeringDocument | undefined = this.steering): ClmStatus {
702
1051
  const checkpoint = this.state.checkpoint;
1052
+ const { base, effective } = this.settingsValues();
1053
+ const changed = changedSummary(base, effective, { modelWindow: this.limits.context });
703
1054
  return {
1055
+ ...(changed ? { changed } : {}),
1056
+ ...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
704
1057
  sessionID: this.sessionID,
705
1058
  mirrorPath: this.mirrorPath,
706
1059
  revision: this.state.revision,
@@ -709,6 +1062,7 @@ export class ClmSession {
709
1062
  gate: this.settings.gate,
710
1063
  guard: this.settings.guard,
711
1064
  reading: this.lastReading,
1065
+ fit: this.budgetFit(),
712
1066
  modelWindow: this.limits.context,
713
1067
  checkpoint: checkpoint
714
1068
  ? {
@@ -0,0 +1,18 @@
1
+ // The slash commands the server plugin defines, shared with the TUI so its Enter intercept
2
+ // can tell this package's `/clm` from a user's own. Written for this package.
3
+
4
+ export const STATUS_COMMAND = "clm";
5
+ export const COMPACT_COMMAND = "clm-compact";
6
+ export const STATUS_TEMPLATE = "Show the CLM status. $ARGUMENTS";
7
+ export const COMPACT_TEMPLATE = "Compact your context. $ARGUMENTS";
8
+
9
+ /**
10
+ * True when the config's `command.clm` is not this package's: a user-defined `/clm` keeps
11
+ * its owner, as the server's config hook leaves it alone. Absent → ours (or none yet).
12
+ */
13
+ export function userOwnsStatusCommand(commands: unknown): boolean {
14
+ if (!commands || typeof commands !== "object") return false;
15
+ const entry = (commands as Record<string, unknown>)[STATUS_COMMAND];
16
+ if (entry === undefined) return false;
17
+ return !(entry && typeof entry === "object" && (entry as { template?: unknown }).template === STATUS_TEMPLATE);
18
+ }
@@ -525,13 +525,40 @@ export function applyContextDocument(
525
525
 
526
526
  const parsed = parseDocument(editedText, snapshot);
527
527
  const metadataLine = snapshot.text.split("\n")[0] ?? "";
528
- const staleMetadata = () =>
529
- rejected(
528
+ // States the current revision, what is wrong with the file, and the exact line to
529
+ // write, so the model can fix the file in one step.
530
+ const staleMetadata = () => {
531
+ const current = `The mirror is at revision ${snapshot.revision}.`;
532
+ let found: string;
533
+ let cause: string;
534
+ if (parsed.revision === undefined) {
535
+ found = "your file has no [[LIVE_CONTEXT ...]] line 1";
536
+ cause = parsed.blocks.length === 0 && /^\s*\[\[CTX_TURN /m.test(editedText)
537
+ ? `${current} Its block headers come from an older copy of the mirror: read the file again and apply your change to its current text.`
538
+ : `${current} Put the line below back as line 1 and keep the rest of your edit.`;
539
+ } else if (parsed.revision !== snapshot.revision) {
540
+ found = `your file says revision ${parsed.revision}`;
541
+ cause = `${current} It was rewritten after the copy you edited was taken: read the file again and apply your change to its current text, leaving line 1 as it is.`;
542
+ } else {
543
+ const fields = [
544
+ ...(parsed.version !== snapshot.version ? ["version"] : []),
545
+ ...(parsed.documentId !== snapshot.documentId ? ["document id"] : []),
546
+ ...(!snapshot.stableDocument && parsed.baselineDigest !== snapshot.baselineDigest ? ["baseline digest"] : []),
547
+ ];
548
+ found = fields.length > 0
549
+ ? `your file says revision ${parsed.revision}, but its ${fields.join(" and ")} differ${fields.length === 1 ? "s" : ""}`
550
+ : `your file's line 1 is current, but its block headers belong to another copy of the mirror`;
551
+ cause = fields.length > 0
552
+ ? `${current} Line 1 was changed or copied from another mirror: restore it exactly and keep the rest of your edit.`
553
+ : `${current} Read the file again and apply your change to its current text.`;
554
+ }
555
+ return rejected(
530
556
  snapshot,
531
557
  beforeEstimate,
532
- `Mirror metadata does not match the current context revision (expected revision ${snapshot.revision}, document ${snapshot.documentId.slice(0, 12)}…). ` +
533
- `The first line must be exactly: ${metadataLine}`,
558
+ `Mirror metadata does not match the current context revision (expected revision ${snapshot.revision}, document ${snapshot.documentId.slice(0, 12)}…; ${found}). ` +
559
+ `${cause} The first line must be exactly: ${metadataLine}`,
534
560
  );
561
+ };
535
562
  // A file with no current block headers but with framing lines from another document
536
563
  // (or a stale revision) is an old mirror copied back, not a summary: reject it.
537
564
  if (parsed.blocks.length === 0 && hasForeignFraming(editedText, snapshot)) return staleMetadata();
@@ -14,7 +14,7 @@ export function defaultMirrorParent(): string {
14
14
  return join(stateHome, "opencode-clm", "mirrors");
15
15
  }
16
16
 
17
- function safeSessionId(sessionId: string): string {
17
+ export function safeSessionId(sessionId: string): string {
18
18
  const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64);
19
19
  return safe || "session";
20
20
  }