opencode-clm 0.1.1 → 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/budget.ts CHANGED
@@ -360,7 +360,7 @@ export function budgetNoticeText(
360
360
  * odd sample cannot run away.
361
361
  */
362
362
  export class EstimateCalibrator {
363
- private pending: { estimate: number; rawCount: number } | undefined;
363
+ private pending: { estimate: number; rawCount: number; offset: number } | undefined;
364
364
  private current = 1;
365
365
  private samples = 0;
366
366
 
@@ -390,9 +390,11 @@ export class EstimateCalibrator {
390
390
  * Remember the raw (uncalibrated) estimate of the request about to be sent. The estimate
391
391
  * must cover the same scope as the provider count it will be compared with; an estimate
392
392
  * that omits the system prompt and tool schemas reads their size as undercounting.
393
+ * `offset`: tokens of the provider count the estimate does not cover, already measured
394
+ * (the fixed overhead); they are subtracted from the observation before the comparison.
393
395
  */
394
- record(estimate: number, rawCount: number): void {
395
- if (estimate > 0) this.pending = { estimate, rawCount };
396
+ record(estimate: number, rawCount: number, offset = 0): void {
397
+ if (estimate > 0) this.pending = { estimate, rawCount, offset };
396
398
  }
397
399
 
398
400
  /**
@@ -402,7 +404,7 @@ export class EstimateCalibrator {
402
404
  */
403
405
  observe(observed: { tokens: number; index: number } | undefined): number {
404
406
  if (!observed || !this.pending || observed.index < this.pending.rawCount) return this.current;
405
- const sample = observed.tokens / this.pending.estimate;
407
+ const sample = (observed.tokens - this.pending.offset) / this.pending.estimate;
406
408
  this.pending = undefined;
407
409
  if (!Number.isFinite(sample) || sample <= 0) return this.current;
408
410
  const smoothing = this.options.smoothing ?? 0.5;
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 {
@@ -75,7 +75,11 @@ import {
75
75
  type LiveContextState,
76
76
  } from "./state.ts";
77
77
  import type { SteeringDocument } from "./steering.ts";
78
- 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";
79
83
 
80
84
  export interface ModelLimits {
81
85
  /** Model context window, tokens. */
@@ -213,28 +217,124 @@ export class ClmSession {
213
217
  private saving: Promise<void> = Promise.resolve();
214
218
  private running: Promise<unknown> = Promise.resolve();
215
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
+
216
231
  private constructor(
217
232
  readonly sessionID: string,
218
233
  readonly store: MirrorStore,
219
- readonly settings: ClmSettings,
234
+ readonly baseSettings: ClmSettings,
220
235
  loaded: { state: LiveContextState; warning?: string },
236
+ readonly baseSteering?: SteeringDocument,
221
237
  ) {
238
+ this.settings = baseSettings;
239
+ this.steering = baseSteering;
222
240
  this.state = loaded.state;
223
241
  this.loadWarning = loaded.warning;
224
242
  this.annotations = new AnnotationStore(store.directory, { estimateTokens: (text) => this.textTokens(text) });
225
243
  }
226
244
 
227
- /** Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. */
228
- 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> {
229
250
  if (!SESSION_ID_RE.test(sessionID)) throw new Error(`Invalid session id for CLM: ${JSON.stringify(sessionID)}`);
230
251
  const store = await MirrorStore.create(sessionID, settings.mirrorDir);
231
252
  const loaded = await loadLiveContextState(store.directory);
232
- const session = new ClmSession(sessionID, store, settings, loaded);
253
+ const session = new ClmSession(sessionID, store, settings, loaded, options.steering);
233
254
  if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
234
255
  if (loaded.repaired) await session.log({ event: "state-repaired", message: loaded.repaired });
256
+ await session.refreshSettings();
235
257
  return session;
236
258
  }
237
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
+
238
338
  get mirrorPath(): string {
239
339
  return this.store.filePath;
240
340
  }
@@ -460,7 +560,7 @@ export class ClmSession {
460
560
  * calls that only read it or do not touch it.
461
561
  */
462
562
  receipt(tool: string, args: Record<string, unknown> | undefined, cwd: string): string | undefined {
463
- if (!this.settings.enabled || !this.state.enabled) return undefined;
563
+ if (!this.settings.enabled || !this.enabled) return undefined;
464
564
  if (classifyMirrorToolCall(tool, args ?? {}, cwd, this.mirrorPath) !== "write") return undefined;
465
565
  const check = this.validateMirror(this.store.readSync());
466
566
  if (!check) return undefined;
@@ -545,6 +645,10 @@ export class ClmSession {
545
645
  const revisions = join(this.store.directory, "revisions");
546
646
  await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
547
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
+ }
548
652
  const notes = result.diagnostics.length > 0 ? ` ${result.diagnostics.join(" ")}` : "";
549
653
  this.pendingNotices.push(`[CLM] ${applied}${notes}`);
550
654
  await this.log({
@@ -566,6 +670,101 @@ export class ClmSession {
566
670
  });
567
671
  }
568
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
+
569
768
  // ---- the transform -----------------------------------------------------------------
570
769
 
571
770
  /** Transforms of one session run one at a time. */
@@ -583,7 +782,7 @@ export class ClmSession {
583
782
 
584
783
  /** Text for `experimental.session.compacting`'s `output.context`: the instruction plus the active annotations. */
585
784
  async compactionContext(): Promise<string | undefined> {
586
- if (!this.settings.enabled || !this.state.enabled) return undefined;
785
+ if (!this.settings.enabled || !this.enabled) return undefined;
587
786
  const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: [] });
588
787
  return continuity ? `${COMPACTION_INSTRUCTION}\n\n${continuity}` : COMPACTION_INSTRUCTION;
589
788
  }
@@ -623,7 +822,8 @@ export class ClmSession {
623
822
  }
624
823
 
625
824
  private async transformNow(raw: OcMessage[]): Promise<TransformResult> {
626
- if (!this.settings.enabled || !this.state.enabled) {
825
+ await this.refreshSettings();
826
+ if (!this.settings.enabled || !this.enabled) {
627
827
  this.baseline = undefined;
628
828
  this.compacting = false;
629
829
  return { messages: raw, notices: [] };
@@ -654,6 +854,9 @@ export class ClmSession {
654
854
  // Before the guard and the reading, so a raise applies to this request already.
655
855
  const measured = this.state.budgetCheck?.source === "provider" ? undefined : this.measureOverhead(raw, observed);
656
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();
657
860
 
658
861
  let projection = applyProjection(source, this.state.checkpoint);
659
862
  // A late or repeated compaction signal must not swallow a later revert: rebase only
@@ -693,9 +896,15 @@ export class ClmSession {
693
896
  const resolved = this.resolvedBudget();
694
897
  const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
695
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.
696
902
  const { systemTokens, toolTokens } = this.scope;
697
- const scopeTokens = this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0));
698
- 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 ? [] : [
699
908
  ...(systemTokens === undefined ? ["the system prompt"] : []),
700
909
  ...(toolTokens === undefined ? ["tool schemas"] : []),
701
910
  ];
@@ -786,11 +995,15 @@ export class ClmSession {
786
995
  ];
787
996
 
788
997
  // Calibrate only against a same-scope estimate: the provider count includes the system
789
- // prompt and tool schemas, so both sizes must be known.
790
- if (systemTokens !== undefined && toolTokens !== undefined) {
791
- const conversation = this.rawTokens(flatten(pinnedMessages)) + this.rawTokens(effective) +
792
- (continuity ? this.textTokens(continuity) : 0) + this.noticeTokens(notices);
793
- 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);
794
1007
  }
795
1008
 
796
1009
  this.previousRequest = { rawCount: raw.length, conversation: conversationTokens + this.calibrator.apply(this.noticeTokens(notices)) };
@@ -800,16 +1013,33 @@ export class ClmSession {
800
1013
  await mkdir(directory, { recursive: true, mode: 0o700 }).catch(() => undefined);
801
1014
  await writeFile(join(directory, `n${this.requests}.json`), JSON.stringify(messages, null, 1), { mode: 0o600 }).catch(() => undefined);
802
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
+ }
803
1030
  await this.log({
804
1031
  event: "request",
805
1032
  n: this.requests,
806
1033
  revision: this.state.revision,
1034
+ users: raw.filter((message) => message.info.role === "user").length,
807
1035
  raw: raw.length,
808
1036
  sent: messages.length,
809
1037
  blocks: snapshot.blocks.length,
810
1038
  withheld: withheldCount,
811
1039
  estimated: reading?.estimated ?? requestTokens,
812
1040
  observedPrevious: observed?.tokens,
1041
+ // The assistant message that reported `observedPrevious`; a new id means a new count.
1042
+ observedMessage: observed?.messageID,
813
1043
  calibration: this.calibrator.factor,
814
1044
  notices: notices.map((notice) => notice.slice(0, 80)),
815
1045
  });
@@ -817,9 +1047,13 @@ export class ClmSession {
817
1047
  }
818
1048
 
819
1049
  /** Inputs for presentation.ts `statusText` / `statusLine`. */
820
- status(steering?: SteeringDocument): ClmStatus {
1050
+ status(steering: SteeringDocument | undefined = this.steering): ClmStatus {
821
1051
  const checkpoint = this.state.checkpoint;
1052
+ const { base, effective } = this.settingsValues();
1053
+ const changed = changedSummary(base, effective, { modelWindow: this.limits.context });
822
1054
  return {
1055
+ ...(changed ? { changed } : {}),
1056
+ ...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
823
1057
  sessionID: this.sessionID,
824
1058
  mirrorPath: this.mirrorPath,
825
1059
  revision: this.state.revision,
@@ -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
+ }
@@ -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
  }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Per-session settings changes on disk: `<session dir>/overrides.json` =
3
+ * `{ version: 1, overrides }`, holding only what differs from the base (the plugin options,
4
+ * environment and defaults; for `editing`, state.json's `enabled`). A separate file because
5
+ * state.json belongs to the server's save queue: the TUI writes this file and never state.json.
6
+ *
7
+ * Writers (the TUI panel, typed `/clm config` in the TUI, and the server's `/clm config`)
8
+ * all go through `changeSetting`: parse → merge → drop base-equal keys → stage (resolve the
9
+ * policies, load a steering document or compact prompt strictly) → write atomically. A
10
+ * change that fails any step is reported and not written. The server reads the file before
11
+ * each request (`ClmSession.refreshSettings`). Staging follows pi-clm src/index.ts
12
+ * `changeSetting` / `stageSettings` (MIT, Copyright 2026 Emanuel Casco); written for this package.
13
+ */
14
+ import { readFile, stat } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+
17
+ import { writeJsonAtomic } from "./atomic.ts";
18
+ import { loadCompactPrompt } from "./compact.ts";
19
+ import {
20
+ applyOverrides,
21
+ describeSetting,
22
+ mergeOverrides,
23
+ sanitizeOverrides,
24
+ settingDescriptor,
25
+ unknownSettingText,
26
+ type ClmOverrides,
27
+ type FormatContext,
28
+ type SettingsValues,
29
+ } from "./settings-table.ts";
30
+ import type { ClmSettings } from "./settings.ts";
31
+ import { loadSteeringDocument, type SteeringDocument } from "./steering.ts";
32
+
33
+ export const OVERRIDES_FILE = "overrides.json";
34
+ export const OVERRIDES_VERSION = 1;
35
+
36
+ export interface OverridesRead {
37
+ overrides: ClmOverrides;
38
+ /** Keys dropped as invalid, or a description of why the whole file was unusable. */
39
+ warning?: string;
40
+ }
41
+
42
+ export function overridesPath(sessionDirectory: string): string {
43
+ return join(sessionDirectory, OVERRIDES_FILE);
44
+ }
45
+
46
+ /** Reads and sanitizes overrides.json. Missing → no overrides. Never throws. */
47
+ export async function readOverrides(sessionDirectory: string): Promise<OverridesRead> {
48
+ const path = overridesPath(sessionDirectory);
49
+ let text: string;
50
+ try {
51
+ text = await readFile(path, "utf8");
52
+ } catch (error) {
53
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { overrides: {} };
54
+ return { overrides: {}, warning: `could not read ${path}: ${describe(error)}` };
55
+ }
56
+ return parseOverridesText(text, path);
57
+ }
58
+
59
+ export function parseOverridesText(text: string, path = OVERRIDES_FILE): OverridesRead {
60
+ let parsed: unknown;
61
+ try {
62
+ parsed = JSON.parse(text);
63
+ } catch (error) {
64
+ return { overrides: {}, warning: `${path} is not valid JSON (${describe(error)}); using the defaults` };
65
+ }
66
+ if (!parsed || typeof parsed !== "object" || (parsed as { version?: unknown }).version !== OVERRIDES_VERSION) {
67
+ return { overrides: {}, warning: `${path} has an unknown format; using the defaults` };
68
+ }
69
+ const { overrides, ignored } = sanitizeOverrides((parsed as { overrides?: unknown }).overrides);
70
+ return ignored.length > 0 ? { overrides, warning: `ignored invalid saved settings: ${ignored.join(", ")}` } : { overrides };
71
+ }
72
+
73
+ export async function writeOverrides(sessionDirectory: string, overrides: ClmOverrides): Promise<void> {
74
+ await writeJsonAtomic(overridesPath(sessionDirectory), { version: OVERRIDES_VERSION, overrides });
75
+ }
76
+
77
+ export interface StagedSettings {
78
+ settings: ClmSettings;
79
+ steering?: SteeringDocument;
80
+ /** Set when the steering document could not be loaded (non-strict staging only). */
81
+ steeringError?: string;
82
+ }
83
+
84
+ /**
85
+ * Resolves base + overrides without activating anything. Strict (an interactive change):
86
+ * a steering document or compact prompt that does not load rejects the change. Not strict
87
+ * (reading the file before a request): a steering failure is recorded and the session runs
88
+ * protocol-only. `loaded` reuses an already loaded document for the same path.
89
+ */
90
+ export function stageSettings(
91
+ base: ClmSettings,
92
+ overrides: ClmOverrides,
93
+ options: { strict: boolean; loaded?: SteeringDocument },
94
+ ): StagedSettings {
95
+ const settings = applyOverrides(base, overrides);
96
+ const staged: StagedSettings = { settings };
97
+ const path = settings.steeringPath;
98
+ if (path) {
99
+ if (options.loaded && !options.strict && options.loaded.path === path) {
100
+ staged.steering = options.loaded;
101
+ } else {
102
+ try {
103
+ staged.steering = loadSteeringDocument(path);
104
+ } catch (error) {
105
+ if (options.strict) throw new Error(`steering document not loaded: ${describe(error)}`);
106
+ staged.steeringError = describe(error);
107
+ }
108
+ }
109
+ }
110
+ if (options.strict && settings.compactPromptPath && settings.compactPromptPath !== base.compactPromptPath) {
111
+ loadCompactPrompt(settings.compactPromptPath);
112
+ }
113
+ return staged;
114
+ }
115
+
116
+ export interface ChangeRequest {
117
+ /** The session directory (`<mirrorDir>/clm-<id>`). */
118
+ sessionDirectory: string;
119
+ /** Settings at load (options, environment, defaults). */
120
+ base: ClmSettings;
121
+ /** `editing` base: state.json's `enabled`. */
122
+ baseEditing: boolean;
123
+ /** Relative paths resolve against the project directory. */
124
+ projectDirectory: string;
125
+ format?: FormatContext;
126
+ }
127
+
128
+ export interface ChangeResult {
129
+ overrides: ClmOverrides;
130
+ values: SettingsValues;
131
+ /** `Budget: 20k`. */
132
+ text: string;
133
+ }
134
+
135
+ function valuesOf(base: ClmSettings, baseEditing: boolean, overrides: ClmOverrides, settings?: ClmSettings): SettingsValues {
136
+ return { editing: overrides.editing ?? baseEditing, settings: settings ?? applyOverrides(base, overrides) };
137
+ }
138
+
139
+ /**
140
+ * `/clm config <setting> <value>` and the panel's apply: validate, then write. Throws a
141
+ * user-facing message; nothing is written then.
142
+ */
143
+ export async function changeSetting(request: ChangeRequest, name: string, text: string): Promise<ChangeResult> {
144
+ const descriptor = settingDescriptor(name);
145
+ if (!descriptor) throw new Error(unknownSettingText(name));
146
+ const parsed = descriptor.parse(text, { directory: request.projectDirectory });
147
+ const current = await readOverrides(request.sessionDirectory);
148
+ const baseValues = valuesOf(request.base, request.baseEditing, {}, request.base);
149
+ const overrides = mergeOverrides(baseValues, current.overrides, parsed);
150
+ const staged = stageSettings(request.base, overrides, { strict: true });
151
+ try {
152
+ await writeOverrides(request.sessionDirectory, overrides);
153
+ } catch (error) {
154
+ throw new Error(`Settings unchanged: saving ${overridesPath(request.sessionDirectory)} failed (${describe(error)}).`);
155
+ }
156
+ const values = valuesOf(request.base, request.baseEditing, overrides, staged.settings);
157
+ return { overrides, values, text: `${descriptor.label}: ${descriptor.format(values, request.format)}` };
158
+ }
159
+
160
+ /** `/clm config reset`: drop this session's changes. */
161
+ export async function resetSettings(sessionDirectory: string): Promise<void> {
162
+ try {
163
+ await writeOverrides(sessionDirectory, {});
164
+ } catch (error) {
165
+ throw new Error(`Settings unchanged: saving ${overridesPath(sessionDirectory)} failed (${describe(error)}).`);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Current values of one session, read from its overrides.json (non-strict, never throws for
171
+ * bad files). `mtimeMs`: when overrides.json was last written, if it exists.
172
+ */
173
+ export async function sessionValues(sessionDirectory: string, base: ClmSettings, baseEditing: boolean): Promise<{ values: SettingsValues; warning?: string; overrides: ClmOverrides; mtimeMs?: number }> {
174
+ const read = await readOverrides(sessionDirectory);
175
+ const mtimeMs = await stat(overridesPath(sessionDirectory)).then((info) => info.mtimeMs, () => undefined);
176
+ const at = mtimeMs !== undefined ? { mtimeMs } : {};
177
+ try {
178
+ return { values: valuesOf(base, baseEditing, read.overrides), overrides: read.overrides, ...(read.warning ? { warning: read.warning } : {}), ...at };
179
+ } catch (error) {
180
+ return { values: valuesOf(base, baseEditing, {}, base), overrides: {}, warning: `ignored saved settings: ${describe(error)}`, ...at };
181
+ }
182
+ }
183
+
184
+ /** `/clm config <setting>`: `Label: value — description`. */
185
+ export function showSetting(name: string, values: SettingsValues, format?: FormatContext): string {
186
+ const descriptor = settingDescriptor(name);
187
+ if (!descriptor) throw new Error(unknownSettingText(name));
188
+ return describeSetting(descriptor, values, format);
189
+ }
190
+
191
+ function describe(error: unknown): string {
192
+ return error instanceof Error ? error.message : String(error);
193
+ }