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/NOTICE +9 -1
- package/README.md +85 -258
- package/index.ts +68 -14
- package/package.json +54 -9
- package/src/atomic.ts +27 -0
- package/src/budget.ts +129 -5
- package/src/clm.ts +374 -20
- package/src/commands.ts +18 -0
- package/src/context-document.ts +31 -4
- 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 +10 -1
- package/src/session-files.ts +184 -0
- package/src/settings-table.ts +406 -0
- package/src/settings.ts +2 -2
- package/src/state.ts +49 -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/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
|
|
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
|
|
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
|
-
/**
|
|
212
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
580
|
-
|
|
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
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
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);
|
|
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
|
|
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
|
? {
|
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/context-document.ts
CHANGED
|
@@ -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
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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();
|
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
|
}
|