opencode-clm 0.3.0 → 0.4.1
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 +2 -2
- package/README.md +17 -7
- package/index.ts +29 -10
- package/package.json +2 -2
- package/src/budget.ts +160 -16
- package/src/clm.ts +141 -56
- package/src/compaction.ts +14 -6
- package/src/context-document.ts +4 -1
- package/src/opencode.ts +10 -3
- package/src/overflow.ts +6 -3
- package/src/panel/keys.ts +11 -1
- package/src/panel/model.ts +3 -1
- package/src/panel/view.ts +29 -9
- package/src/presentation.ts +8 -2
- package/src/projection.ts +157 -14
- package/src/settings-table.ts +83 -17
- package/src/settings.ts +70 -3
- package/src/state.ts +50 -1
- package/src/tui/data.ts +8 -2
- package/src/tui/panel.ts +3 -2
- package/src/tui/plugin.ts +3 -2
- package/src/types.ts +2 -0
- /package/{skills → .agents/skills}/clm-context/SKILL.md +0 -0
package/src/settings.ts
CHANGED
|
@@ -47,6 +47,22 @@ export interface ClmSettings {
|
|
|
47
47
|
oneTool: boolean;
|
|
48
48
|
/** Append `[context: ~N of B tokens after this result]` to tool results (paper-harness parity). */
|
|
49
49
|
trailer: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* `edit`: the model edits its context through the mirror. `notices-only`: no mirror, no
|
|
52
|
+
* protocol prompt and no edits; budget notices, reminders, the guard, the cap and the
|
|
53
|
+
* trailer still apply (to compare telling the model its budget with letting it edit).
|
|
54
|
+
*/
|
|
55
|
+
mode: ClmMode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ClmMode = "edit" | "notices-only";
|
|
59
|
+
|
|
60
|
+
/** `edit`, `notices-only`, or `notices` (an alias of `notices-only`). */
|
|
61
|
+
export function parseMode(value: unknown, name = "mode"): ClmMode {
|
|
62
|
+
const text = String(value).trim().toLowerCase();
|
|
63
|
+
if (text === "edit") return "edit";
|
|
64
|
+
if (text === "notices-only" || text === "notices") return "notices-only";
|
|
65
|
+
throw new Error(`${name} must be edit or notices-only, got ${JSON.stringify(value)}`);
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
export type CompactionMode = "auto" | "off" | "on";
|
|
@@ -59,11 +75,12 @@ export function parseCompaction(value: unknown, name = "compaction"): Compaction
|
|
|
59
75
|
|
|
60
76
|
export const PACKAGE_DIR = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
61
77
|
export const HOUSE_STEERING = join(PACKAGE_DIR, "steering", "house-brief.md");
|
|
62
|
-
export const SKILLS_DIR = join(PACKAGE_DIR, "skills");
|
|
78
|
+
export const SKILLS_DIR = join(PACKAGE_DIR, ".agents/skills/clm-context");
|
|
63
79
|
|
|
64
80
|
type Env = Record<string, string | undefined>;
|
|
65
81
|
|
|
66
82
|
const OFF_WORDS = ["none", "off"];
|
|
83
|
+
const BUDGET_FORMS = `a number of tokens (32000, 32k), a percentage of the model window (1% to 100%), or "window"`;
|
|
67
84
|
|
|
68
85
|
function pick(options: Record<string, unknown>, key: string, env: Env, envKey: string): unknown {
|
|
69
86
|
if (options[key] !== undefined) return options[key];
|
|
@@ -85,6 +102,34 @@ export function parseTokens(value: unknown, name: string): number {
|
|
|
85
102
|
return Math.round(Number(match[1]) * scale);
|
|
86
103
|
}
|
|
87
104
|
|
|
105
|
+
export type BudgetValue =
|
|
106
|
+
| { kind: "tokens"; tokens: number }
|
|
107
|
+
| { kind: "fraction"; fraction: number }
|
|
108
|
+
| { kind: "window" };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A budget setting: `32000` / `32k` (tokens), `50%` (of the model window minus its output
|
|
112
|
+
* limit; 1% to 100%), or `window` / `model` (the whole of it).
|
|
113
|
+
*/
|
|
114
|
+
export function parseBudget(value: unknown, name = "budget"): BudgetValue {
|
|
115
|
+
const text = String(value).trim().toLowerCase();
|
|
116
|
+
if (text === "window" || text === "model") return { kind: "window" };
|
|
117
|
+
if (text.endsWith("%")) {
|
|
118
|
+
const number = text.slice(0, -1).trim();
|
|
119
|
+
const percent = /^\d+(?:\.\d+)?$/.test(number) ? Number(number) : Number.NaN;
|
|
120
|
+
if (!(percent >= 1 && percent <= 100)) throw new Error(`${name} must be ${BUDGET_FORMS}, got ${JSON.stringify(value)}`);
|
|
121
|
+
return { kind: "fraction", fraction: percent / 100 };
|
|
122
|
+
}
|
|
123
|
+
let tokens: number;
|
|
124
|
+
try {
|
|
125
|
+
tokens = parseTokens(value, name);
|
|
126
|
+
} catch {
|
|
127
|
+
throw new Error(`${name} must be ${BUDGET_FORMS}, got ${JSON.stringify(value)}`);
|
|
128
|
+
}
|
|
129
|
+
if (tokens <= 0) throw new Error(`${name} must be a positive number of tokens or "window", got ${JSON.stringify(value)}`);
|
|
130
|
+
return { kind: "tokens", tokens };
|
|
131
|
+
}
|
|
132
|
+
|
|
88
133
|
export function parseFlag(value: unknown, name: string): boolean {
|
|
89
134
|
if (typeof value === "boolean") return value;
|
|
90
135
|
const key = String(value).trim().toLowerCase();
|
|
@@ -111,6 +156,23 @@ export function parseFractions(value: unknown, name: string): number[] | "off" {
|
|
|
111
156
|
return [...new Set(fractions)];
|
|
112
157
|
}
|
|
113
158
|
|
|
159
|
+
const COOLDOWN_FORMS = `a share of the budget between 0 and 1, as a percentage (10%) or a fraction (0.1), or off`;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The reminder cooldown: `10%` or `0.1` (a share of the budget, strictly between 0 and 1), or
|
|
163
|
+
* `off` / `none` / `0` for no cooldown (0).
|
|
164
|
+
*/
|
|
165
|
+
export function parseCooldown(value: unknown, name = "reminderCooldown"): number {
|
|
166
|
+
if (value === false) return 0;
|
|
167
|
+
const text = String(value).trim().toLowerCase();
|
|
168
|
+
if (text === "off" || text === "none" || text === "0" || text === "0%") return 0;
|
|
169
|
+
const percent = text.endsWith("%");
|
|
170
|
+
const number = percent ? text.slice(0, -1).trim() : text;
|
|
171
|
+
const parsed = /^\d*\.?\d+$/.test(number) ? Number(number) / (percent ? 100 : 1) : Number.NaN;
|
|
172
|
+
if (!(parsed > 0 && parsed < 1)) throw new Error(`${name} must be ${COOLDOWN_FORMS}, got ${JSON.stringify(value)}`);
|
|
173
|
+
return parsed;
|
|
174
|
+
}
|
|
175
|
+
|
|
114
176
|
function withName<T>(name: string, parse: () => T): T {
|
|
115
177
|
try {
|
|
116
178
|
return parse();
|
|
@@ -132,6 +194,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
|
|
|
132
194
|
const budgetRaw = pick(options, "budget", env, "CLM_BUDGET");
|
|
133
195
|
const reserveRaw = pick(options, "reserve", env, "CLM_RESERVE");
|
|
134
196
|
const remindRaw = pick(options, "remindAt", env, "CLM_REMIND_AT");
|
|
197
|
+
const cooldownRaw = pick(options, "reminderCooldown", env, "CLM_REMINDER_COOLDOWN");
|
|
135
198
|
const gateRaw = pick(options, "gate", env, "CLM_EDIT_GATE");
|
|
136
199
|
const guardRaw = pick(options, "guard", env, "CLM_OVERFLOW");
|
|
137
200
|
const capRaw = pick(options, "observationCap", env, "CLM_OBSERVATION_CAP");
|
|
@@ -144,11 +207,13 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
|
|
|
144
207
|
const compactionRaw = pick(options, "compaction", env, "CLM_NATIVE_COMPACTION");
|
|
145
208
|
const oneToolRaw = pick(options, "oneTool", env, "CLM_ONE_TOOL_PER_TURN");
|
|
146
209
|
const trailerRaw = pick(options, "trailer", env, "CLM_SIZE_TRAILER");
|
|
210
|
+
const modeRaw = pick(options, "mode", env, "CLM_MODE");
|
|
147
211
|
|
|
148
212
|
const overrides: Partial<BudgetPolicyConfig> = {};
|
|
149
213
|
if (budgetRaw !== undefined) {
|
|
150
|
-
|
|
151
|
-
|
|
214
|
+
const parsed = parseBudget(budgetRaw, "budget");
|
|
215
|
+
overrides.contextBudget = parsed.kind === "tokens" ? parsed.tokens : undefined;
|
|
216
|
+
overrides.contextFraction = parsed.kind === "fraction" ? parsed.fraction : undefined;
|
|
152
217
|
}
|
|
153
218
|
if (reserveRaw !== undefined) overrides.reserve = parseTokens(reserveRaw, "reserve");
|
|
154
219
|
if (remindRaw !== undefined) {
|
|
@@ -164,6 +229,7 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
|
|
|
164
229
|
overrides.remindAtFractions = fractions;
|
|
165
230
|
}
|
|
166
231
|
}
|
|
232
|
+
if (cooldownRaw !== undefined) overrides.reminderCooldown = parseCooldown(cooldownRaw, "reminderCooldown");
|
|
167
233
|
const budget = withName("budget", () => resolveBudgetPolicy({ ...DEFAULT_BUDGET_POLICY, ...overrides }));
|
|
168
234
|
|
|
169
235
|
let guard: ClmSettings["guard"] = "withhold";
|
|
@@ -213,5 +279,6 @@ export function resolveSettings(options: Record<string, unknown> = {}, env: Env
|
|
|
213
279
|
compaction: compactionRaw === undefined ? "auto" : parseCompaction(compactionRaw),
|
|
214
280
|
oneTool: oneToolRaw === undefined ? false : parseFlag(oneToolRaw, "oneTool"),
|
|
215
281
|
trailer: trailerRaw === undefined ? false : parseFlag(trailerRaw, "trailer"),
|
|
282
|
+
mode: modeRaw === undefined ? "edit" : parseMode(modeRaw),
|
|
216
283
|
};
|
|
217
284
|
}
|
package/src/state.ts
CHANGED
|
@@ -96,6 +96,23 @@ function isMessageArray(value: unknown): value is LiveContextMessage[] {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Per-message stamps (`projection.ts` SourceStamp): a hex digest and a finite `updatedAt`. */
|
|
100
|
+
function isSourceStamps(value: unknown, count: unknown): boolean {
|
|
101
|
+
return (
|
|
102
|
+
Array.isArray(value) &&
|
|
103
|
+
value.length === count &&
|
|
104
|
+
value.every(
|
|
105
|
+
(stamp) =>
|
|
106
|
+
isObject(stamp) &&
|
|
107
|
+
typeof stamp.digest === "string" &&
|
|
108
|
+
/^[a-f0-9]{64}$/.test(stamp.digest) &&
|
|
109
|
+
Number.isFinite(stamp.updatedAt) &&
|
|
110
|
+
(stamp.strippedDigest === undefined ||
|
|
111
|
+
(typeof stamp.strippedDigest === "string" && /^[a-f0-9]{64}$/.test(stamp.strippedDigest))),
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
function isContextEditTrace(value: unknown): value is ContextEditTrace {
|
|
100
117
|
if (!isObject(value)) return false;
|
|
101
118
|
const trace = value as Partial<ContextEditTrace>;
|
|
@@ -134,6 +151,7 @@ export function isProjectionCheckpoint(value: unknown): value is ProjectionCheck
|
|
|
134
151
|
checkpoint.sourceIds.every((id) => typeof id === "string") &&
|
|
135
152
|
typeof checkpoint.sourceDigest === "string" &&
|
|
136
153
|
/^[a-f0-9]{64}$/.test(checkpoint.sourceDigest) &&
|
|
154
|
+
(checkpoint.sourceStamps === undefined || isSourceStamps(checkpoint.sourceStamps, checkpoint.sourceMessageCount)) &&
|
|
137
155
|
isMessageArray(checkpoint.projectedMessages) &&
|
|
138
156
|
Number.isFinite(checkpoint.beforeEstimate) &&
|
|
139
157
|
Number.isFinite(checkpoint.afterEstimate) &&
|
|
@@ -238,12 +256,43 @@ export async function loadLiveContextState(directory: string): Promise<LoadedSta
|
|
|
238
256
|
delete parsed.budgetCheck;
|
|
239
257
|
repaired = `${path} had an invalid budgetCheck; dropped it, the fixed overhead is measured again.`;
|
|
240
258
|
}
|
|
259
|
+
// A malformed outcome costs only itself: the panel loses one line, the checkpoint stays.
|
|
260
|
+
if (isObject(parsed) && parsed.lastOutcome !== undefined && !isOutcome(parsed.lastOutcome)) {
|
|
261
|
+
delete parsed.lastOutcome;
|
|
262
|
+
repaired = `${path} had an invalid lastOutcome; dropped it, the last outcome is unknown.`;
|
|
263
|
+
}
|
|
264
|
+
// Malformed per-message stamps cost only tail re-anchoring: the checkpoint keeps working
|
|
265
|
+
// with the whole-prefix digest and fails closed on a mismatch, as before.
|
|
266
|
+
if (
|
|
267
|
+
isObject(parsed) &&
|
|
268
|
+
isObject(parsed.checkpoint) &&
|
|
269
|
+
parsed.checkpoint.sourceStamps !== undefined &&
|
|
270
|
+
!isSourceStamps(parsed.checkpoint.sourceStamps, parsed.checkpoint.sourceMessageCount)
|
|
271
|
+
) {
|
|
272
|
+
delete parsed.checkpoint.sourceStamps;
|
|
273
|
+
repaired = `${path} had invalid sourceStamps; dropped them, tail re-anchoring is off for this checkpoint.`;
|
|
274
|
+
}
|
|
241
275
|
if (!isLiveContextState(parsed)) {
|
|
242
|
-
return { state: initialLiveContextState(), warning: `${path} has an invalid shape; starting clean.` };
|
|
276
|
+
return { state: initialLiveContextState(), warning: `${path} has an invalid shape (${stateProblem(parsed)}); starting clean.` };
|
|
243
277
|
}
|
|
244
278
|
return repaired ? { state: parsed, repaired } : { state: parsed };
|
|
245
279
|
}
|
|
246
280
|
|
|
281
|
+
/** Names the first state check `value` fails, so a load warning locates the bad field. */
|
|
282
|
+
function stateProblem(value: unknown): string {
|
|
283
|
+
if (!isObject(value)) return "not an object";
|
|
284
|
+
if (value.version !== STATE_VERSION) return `unsupported version ${JSON.stringify(value.version)}`;
|
|
285
|
+
if (typeof value.enabled !== "boolean") return "enabled is not a boolean";
|
|
286
|
+
if (!isCount(value.revision)) return "revision is not a count";
|
|
287
|
+
if (value.checkpoint !== undefined) {
|
|
288
|
+
if (!isProjectionCheckpoint(value.checkpoint)) return "checkpoint is invalid";
|
|
289
|
+
if (value.checkpoint.revision !== value.revision) return "checkpoint revision does not match the state revision";
|
|
290
|
+
}
|
|
291
|
+
if (value.lastOutcome !== undefined && !isOutcome(value.lastOutcome)) return "lastOutcome is invalid";
|
|
292
|
+
if (value.budgetCheck !== undefined && !isBudgetCheck(value.budgetCheck)) return "budgetCheck is invalid";
|
|
293
|
+
return "unknown";
|
|
294
|
+
}
|
|
295
|
+
|
|
247
296
|
/**
|
|
248
297
|
* Write `state.json` atomically with mode 0600: temp file, fsync, rename, then fsync the
|
|
249
298
|
* directory (best effort). Concurrent saves are not ordered: the last rename wins, so an
|
package/src/tui/data.ts
CHANGED
|
@@ -155,14 +155,15 @@ export function sizeLine(latest: Pick<TimelinePoint, "tokens" | "measured" | "es
|
|
|
155
155
|
* `clm 12k / 32k · r2` (newest size against the budget in force), `clm off · r2`, or
|
|
156
156
|
* undefined before the session has CLM data.
|
|
157
157
|
*/
|
|
158
|
-
export function footerText(model: Pick<PanelModel, "found" | "enabled" | "revision" | "timeline" | "budget">): string | undefined {
|
|
158
|
+
export function footerText(model: Pick<PanelModel, "found" | "enabled" | "revision" | "timeline" | "budget"> & Partial<Pick<PanelModel, "noticesOnly">>): string | undefined {
|
|
159
159
|
if (!model.found) return undefined;
|
|
160
160
|
if (!model.enabled) return `clm off · r${model.revision}`;
|
|
161
|
+
const mode = model.noticesOnly ? " notices-only" : "";
|
|
161
162
|
const latest = model.timeline.points.at(-1);
|
|
162
163
|
const size = latest === undefined
|
|
163
164
|
? ""
|
|
164
165
|
: ` ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}${model.budget !== undefined ? ` / ${formatTokenCount(model.budget)}` : ""}`;
|
|
165
|
-
return `clm${size} · r${model.revision}`;
|
|
166
|
+
return `clm${mode}${size} · r${model.revision}`;
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
/** Key of the settings page's "Reset to defaults" row, and the choice that resets. */
|
|
@@ -199,6 +200,11 @@ export function detailLines(
|
|
|
199
200
|
? "Estimate not calibrated yet (characters ÷ 4 until the provider reports a size)"
|
|
200
201
|
: `Estimate ×${calibration.factor.toFixed(2)}, calibrated from ${calibration.samples} provider count${calibration.samples === 1 ? "" : "s"}`);
|
|
201
202
|
}
|
|
203
|
+
if (effective.settings.mode === "notices-only") {
|
|
204
|
+
lines.push(effective.editing
|
|
205
|
+
? "Mode notices-only: budget notices and the guard run; no mirror, the model cannot edit its context"
|
|
206
|
+
: "Mode notices-only (CLM editing is off, so nothing runs)");
|
|
207
|
+
}
|
|
202
208
|
const limit = model.budgetInfo?.limit;
|
|
203
209
|
lines.push(effective.settings.guard === "off"
|
|
204
210
|
? "Guard off"
|
package/src/tui/panel.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { TextChunk } from "@opentui/core";
|
|
|
9
9
|
import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
|
|
10
10
|
|
|
11
11
|
import type { Page } from "../panel/command.ts";
|
|
12
|
-
import { reduce, type Effect, type Key } from "../panel/keys.ts";
|
|
12
|
+
import { reduce, withMessage, type Effect, type Key } from "../panel/keys.ts";
|
|
13
13
|
import type { Line, Tone } from "../panel/lines.ts";
|
|
14
14
|
import type { PanelModel } from "../panel/model.ts";
|
|
15
15
|
import { initialPanelState, renderPanel, type PanelSize, type PanelState } from "../panel/view.ts";
|
|
@@ -165,7 +165,8 @@ export function createPanel(api: PanelApi, options: PanelOptions): PanelControll
|
|
|
165
165
|
await controller.reload();
|
|
166
166
|
const row = model?.settings.rows.find((candidate) => candidate.key === setting);
|
|
167
167
|
const shown = `${row?.label ?? setting}: ${row?.value ?? value}`;
|
|
168
|
-
|
|
168
|
+
const message = error ? { text: error, warning: true } : { text: shown, warning: false };
|
|
169
|
+
state = model ? withMessage(state, message, model, size()) : { ...state, message };
|
|
169
170
|
render();
|
|
170
171
|
};
|
|
171
172
|
|
package/src/tui/plugin.ts
CHANGED
|
@@ -139,8 +139,9 @@ export const tui: TuiPlugin = async (api, tuiOptions) => {
|
|
|
139
139
|
const preferSettings = overridesNewer(current.overridesAt, files.snapshot);
|
|
140
140
|
const model = buildPanelModel(files, { ...(latest ? { latest } : {}), ...(budget ? { budget } : {}), ...(preferSettings ? { preferSettings } : {}) });
|
|
141
141
|
model.enabled = current.values.effective.editing;
|
|
142
|
+
if (current.values.effective.settings.mode === "notices-only") model.noticesOnly = true;
|
|
142
143
|
model.settings = settingsView(current.values, model, {
|
|
143
|
-
format: { ...(limits.context ? { modelWindow: limits.context } : {}) },
|
|
144
|
+
format: { ...(limits.context ? { modelWindow: limits.context } : {}), ...(limits.output ? { modelOutput: limits.output } : {}) },
|
|
144
145
|
...(current.warning ? { warning: current.warning } : {}),
|
|
145
146
|
});
|
|
146
147
|
model.settings.rows.push(resetRow(model.settings.changed.length));
|
|
@@ -456,7 +457,7 @@ export function statusSummary(model: PanelModel): string {
|
|
|
456
457
|
if (!model.found) return `No CLM data for this session (${model.directory}).`;
|
|
457
458
|
const latest = model.timeline.points.at(-1);
|
|
458
459
|
const parts = [
|
|
459
|
-
`CLM ${model.enabled ? "on" : "off"} · revision ${model.revision}`,
|
|
460
|
+
`CLM ${model.enabled ? "on" : "off"}${model.enabled && model.noticesOnly ? " · mode notices-only" : ""} · revision ${model.revision}`,
|
|
460
461
|
latest ? `last request ${latest.measured ? "" : "~"}${formatTokenCount(latest.tokens)}` : "no requests yet",
|
|
461
462
|
model.budget !== undefined ? `budget ${formatTokenCount(model.budget)}` : undefined,
|
|
462
463
|
model.budgetInfo?.overhead !== undefined ? `fixed overhead ~${formatTokenCount(model.budgetInfo.overhead)}` : undefined,
|
package/src/types.ts
CHANGED
|
File without changes
|