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/NOTICE +9 -1
- package/README.md +85 -283
- package/index.ts +64 -14
- package/package.json +17 -2
- package/src/atomic.ts +27 -0
- package/src/budget.ts +6 -4
- package/src/clm.ts +251 -17
- package/src/commands.ts +18 -0
- package/src/mirror-store.ts +1 -1
- package/src/overrides.ts +193 -0
- package/src/panel/command.ts +69 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +103 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +547 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +454 -0
- package/src/panel/view.ts +545 -0
- package/src/presentation.ts +6 -0
- package/src/session-files.ts +184 -0
- package/src/settings-table.ts +406 -0
- package/src/settings.ts +2 -2
- package/src/tui/data.ts +161 -0
- package/src/tui/intercept.ts +56 -0
- package/src/tui/panel.ts +194 -0
- package/src/tui/plugin.ts +280 -0
- package/tui.ts +7 -0
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
|
|
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
|
|
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
|
-
/**
|
|
228
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
698
|
-
|
|
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
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
this.
|
|
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
|
|
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,
|
package/src/commands.ts
ADDED
|
@@ -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
|
+
}
|
package/src/mirror-store.ts
CHANGED
|
@@ -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
|
}
|
package/src/overrides.ts
ADDED
|
@@ -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
|
+
}
|