opencode-clm 0.1.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/CHANGELOG.md +26 -0
- package/LICENSE +35 -0
- package/NOTICE +41 -0
- package/README.md +294 -0
- package/index.ts +332 -0
- package/package.json +28 -0
- package/skills/clm-context/SKILL.md +166 -0
- package/src/budget.ts +304 -0
- package/src/clm.ts +725 -0
- package/src/compact.ts +73 -0
- package/src/context-document.ts +659 -0
- package/src/continuity.ts +722 -0
- package/src/mirror-guard.ts +95 -0
- package/src/mirror-store.ts +154 -0
- package/src/observation.ts +155 -0
- package/src/opencode.ts +339 -0
- package/src/overflow.ts +212 -0
- package/src/policy.ts +60 -0
- package/src/presentation.ts +127 -0
- package/src/projection.ts +146 -0
- package/src/settings.ts +189 -0
- package/src/state.ts +241 -0
- package/src/steering.ts +54 -0
- package/src/types.ts +102 -0
- package/steering/house-brief.md +43 -0
package/src/budget.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget and reminder policy.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from pi-clm src/budget.ts (MIT, Copyright 2026 Emanuel Casco).
|
|
5
|
+
* Default reminder steps (25/50/75 % of the budget) follow the CLM paper harness design;
|
|
6
|
+
* pi-clm uses 50/75/90. Text written for this package.
|
|
7
|
+
*
|
|
8
|
+
* Two measurements are kept apart and always labelled:
|
|
9
|
+
*
|
|
10
|
+
* - `estimated`: the harness estimate of the conversation in the *next* request (pinned
|
|
11
|
+
* task + editable context + notices). Available before every call.
|
|
12
|
+
* - `observed`: the provider-reported input size of the *previous* request (OpenCode's
|
|
13
|
+
* assistant `tokens`). Authoritative but one call late, and it includes the system
|
|
14
|
+
* prompt and tool schemas, which the budget does not cover.
|
|
15
|
+
*
|
|
16
|
+
* The estimate alone governs reminder tiers and the reminder text; `observed` is shown
|
|
17
|
+
* for reference only, because it covers more than the budget does. The harness never
|
|
18
|
+
* blocks a request; the overflow guard (overflow.ts) withholds old tool output when the
|
|
19
|
+
* estimate exceeds budget - reserve.
|
|
20
|
+
*/
|
|
21
|
+
export interface BudgetPolicyConfig {
|
|
22
|
+
/**
|
|
23
|
+
* Context budget in tokens the reminders are measured against. Advisory: requests are
|
|
24
|
+
* never blocked or rolled back. `undefined` means "use the model context window".
|
|
25
|
+
*/
|
|
26
|
+
contextBudget?: number;
|
|
27
|
+
/** Generation headroom reserved below the budget. The final reminder fires at budget - reserve. */
|
|
28
|
+
reserve: number;
|
|
29
|
+
/** Fractions of the budget at which escalating reminders fire (0 < f < 1), ascending. */
|
|
30
|
+
remindAtFractions: readonly number[];
|
|
31
|
+
/** Whether to fire the final "budget - reserve" reminder. */
|
|
32
|
+
remindAtReserve: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_BUDGET_POLICY: BudgetPolicyConfig = {
|
|
36
|
+
contextBudget: 32000,
|
|
37
|
+
reserve: 2048,
|
|
38
|
+
remindAtFractions: [0.25, 0.5, 0.75],
|
|
39
|
+
remindAtReserve: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface BudgetReading {
|
|
43
|
+
/** Effective budget in tokens (config or model window). */
|
|
44
|
+
budget: number;
|
|
45
|
+
reserve: number;
|
|
46
|
+
/**
|
|
47
|
+
* Harness estimate of the conversation in the next request, in tokens: pinned task,
|
|
48
|
+
* editable context, and notices. Excludes the system prompt and tool schemas.
|
|
49
|
+
*/
|
|
50
|
+
estimated: number;
|
|
51
|
+
/** What the estimate knowingly leaves out, named in the notice so the gap is not misread. */
|
|
52
|
+
estimateExcludes?: string;
|
|
53
|
+
/** Calibration factor applied to the raw character estimate (1 = none). */
|
|
54
|
+
calibration?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Provider-reported tokens of the previous request, when known. Includes the system
|
|
57
|
+
* prompt and tool schemas, so it is displayed but never governs reminders.
|
|
58
|
+
*/
|
|
59
|
+
observed?: number;
|
|
60
|
+
/**
|
|
61
|
+
* True when a context edit was accepted after the observed request, so `observed`
|
|
62
|
+
* describes a context that no longer exists. The notice says so.
|
|
63
|
+
*/
|
|
64
|
+
observedStale?: boolean;
|
|
65
|
+
/** Where `budget` came from, for status text. */
|
|
66
|
+
source: "config" | "model-window";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A reminder threshold in absolute tokens plus a stable label for re-arming. */
|
|
70
|
+
export interface BudgetTier {
|
|
71
|
+
label: string;
|
|
72
|
+
tokens: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveBudgetPolicy(overrides: Partial<BudgetPolicyConfig> | undefined): BudgetPolicyConfig {
|
|
76
|
+
const merged: BudgetPolicyConfig = { ...DEFAULT_BUDGET_POLICY, ...(overrides ?? {}) };
|
|
77
|
+
if (merged.contextBudget !== undefined && (!Number.isFinite(merged.contextBudget) || merged.contextBudget <= 0)) {
|
|
78
|
+
throw new Error(`contextBudget must be a positive number of tokens, got ${String(merged.contextBudget)}`);
|
|
79
|
+
}
|
|
80
|
+
if (!Number.isFinite(merged.reserve) || merged.reserve < 0) {
|
|
81
|
+
throw new Error(`reserve must be a nonnegative number of tokens, got ${String(merged.reserve)}`);
|
|
82
|
+
}
|
|
83
|
+
const fractions = [...merged.remindAtFractions];
|
|
84
|
+
for (const fraction of fractions) {
|
|
85
|
+
if (!Number.isFinite(fraction) || fraction <= 0 || fraction >= 1) {
|
|
86
|
+
throw new Error(`remindAtFractions entries must be in (0, 1), got ${String(fraction)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
fractions.sort((left, right) => left - right);
|
|
90
|
+
return { ...merged, remindAtFractions: fractions };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Effective budget: the configured budget (default 32,000), capped by the model's context
|
|
95
|
+
* window minus its output allowance when that is smaller. `undefined` contextBudget means
|
|
96
|
+
* "use the window". An output limit that is not finite, <= 0, or >= the window is ignored.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveBudget(
|
|
99
|
+
policy: BudgetPolicyConfig,
|
|
100
|
+
modelContextWindow: number | undefined,
|
|
101
|
+
modelOutputLimit = 0,
|
|
102
|
+
): Pick<BudgetReading, "budget" | "reserve" | "source"> | undefined {
|
|
103
|
+
const window = modelContextWindow !== undefined && Number.isFinite(modelContextWindow) && modelContextWindow > 0
|
|
104
|
+
? modelContextWindow - (Number.isFinite(modelOutputLimit) && modelOutputLimit > 0 && modelOutputLimit < modelContextWindow ? modelOutputLimit : 0)
|
|
105
|
+
: undefined;
|
|
106
|
+
if (policy.contextBudget !== undefined && (window === undefined || policy.contextBudget <= window)) {
|
|
107
|
+
return { budget: policy.contextBudget, reserve: Math.min(policy.reserve, policy.contextBudget), source: "config" };
|
|
108
|
+
}
|
|
109
|
+
if (window !== undefined) {
|
|
110
|
+
return { budget: window, reserve: Math.min(policy.reserve, window), source: "model-window" };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function budgetTiers(policy: BudgetPolicyConfig, budget: number, reserve: number): BudgetTier[] {
|
|
116
|
+
const tiers: BudgetTier[] = policy.remindAtFractions.map((fraction) => ({
|
|
117
|
+
label: `${Math.round(fraction * 100)}%`,
|
|
118
|
+
tokens: Math.floor(budget * fraction),
|
|
119
|
+
}));
|
|
120
|
+
if (policy.remindAtReserve && reserve > 0 && budget - reserve > 0) {
|
|
121
|
+
tiers.push({ label: "budget-reserve", tokens: budget - reserve });
|
|
122
|
+
}
|
|
123
|
+
tiers.sort((left, right) => left.tokens - right.tokens);
|
|
124
|
+
// Drop tiers that collapse onto the same token count; keep the more urgent label.
|
|
125
|
+
return tiers.filter((tier, index) => index === tiers.length - 1 || tier.tokens !== tiers[index + 1]!.tokens);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The value reminders are judged against: the estimate alone. The provider count also
|
|
130
|
+
* covers the system prompt and tool schemas, which the budget excludes.
|
|
131
|
+
*/
|
|
132
|
+
export function governingTokens(reading: Pick<BudgetReading, "estimated">): number {
|
|
133
|
+
return reading.estimated;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Escalating, re-arming tier tracker over absolute token thresholds. Re-derives its state
|
|
138
|
+
* when the tier set changes (for example after the budget changes).
|
|
139
|
+
*/
|
|
140
|
+
export class BudgetTracker {
|
|
141
|
+
private fired = new Set<string>();
|
|
142
|
+
private tierKey = "";
|
|
143
|
+
|
|
144
|
+
reset(): void {
|
|
145
|
+
this.fired.clear();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Returns the highest newly crossed tier, or undefined when nothing new fired. */
|
|
149
|
+
observe(reading: BudgetReading, tiers: readonly BudgetTier[]): BudgetTier | undefined {
|
|
150
|
+
const key = tiers.map((tier) => `${tier.label}:${tier.tokens}`).join("|");
|
|
151
|
+
if (key !== this.tierKey) {
|
|
152
|
+
this.tierKey = key;
|
|
153
|
+
this.fired.clear();
|
|
154
|
+
}
|
|
155
|
+
const tokens = governingTokens(reading);
|
|
156
|
+
this.fired = new Set([...this.fired].filter((label) => {
|
|
157
|
+
const tier = tiers.find((candidate) => candidate.label === label);
|
|
158
|
+
return tier !== undefined && tokens >= tier.tokens;
|
|
159
|
+
}));
|
|
160
|
+
const crossed = tiers.filter((tier) => tokens >= tier.tokens && !this.fired.has(tier.label));
|
|
161
|
+
if (crossed.length === 0) return undefined;
|
|
162
|
+
const top = crossed[crossed.length - 1]!;
|
|
163
|
+
for (const tier of tiers) if (tier.tokens <= top.tokens) this.fired.add(tier.label);
|
|
164
|
+
return top;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `12345` -> `12,345`. */
|
|
169
|
+
export function formatTokens(value: number): string {
|
|
170
|
+
return value.toLocaleString("en-US");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function calibrated(reading: Pick<BudgetReading, "calibration">): boolean {
|
|
174
|
+
return reading.calibration !== undefined && reading.calibration > 1.005;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One line for status text: budget, estimate, and observation, each labelled. */
|
|
178
|
+
export function budgetSummaryLine(reading: BudgetReading): string {
|
|
179
|
+
const parts = [
|
|
180
|
+
`budget ${formatTokens(reading.budget)} tok (${reading.source === "config" ? "configured" : "model window"}, reserve ${formatTokens(reading.reserve)})`,
|
|
181
|
+
`estimated next request ${formatTokens(reading.estimated)}${calibrated(reading) ? ` (×${reading.calibration!.toFixed(2)} calibrated)` : ""}`,
|
|
182
|
+
];
|
|
183
|
+
parts.push(
|
|
184
|
+
reading.observed === undefined
|
|
185
|
+
? "observed previous request: unknown"
|
|
186
|
+
: `observed previous request ${formatTokens(reading.observed)}${reading.observedStale ? " (before the last accepted edit)" : ""}`,
|
|
187
|
+
);
|
|
188
|
+
return parts.join(" · ");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The model-facing reminder. States the estimate and the provider count separately, the
|
|
193
|
+
* budget and the tokens left, the mirror path, and, from the third tier up, what the
|
|
194
|
+
* overflow guard does above `overflowLimit` (default budget - reserve). Pass `null` when the
|
|
195
|
+
* guard is off: the notice then leaves the guard out.
|
|
196
|
+
*/
|
|
197
|
+
export function budgetNoticeText(
|
|
198
|
+
reading: BudgetReading,
|
|
199
|
+
tier: BudgetTier,
|
|
200
|
+
mirrorPath: string | undefined,
|
|
201
|
+
overflowLimit: number | null = reading.budget - reading.reserve,
|
|
202
|
+
): string {
|
|
203
|
+
const governing = governingTokens(reading);
|
|
204
|
+
const remaining = Math.max(0, reading.budget - governing);
|
|
205
|
+
const where = mirrorPath ?? "the context mirror";
|
|
206
|
+
const calibration = calibrated(reading) ? ` (calibrated ×${reading.calibration!.toFixed(2)} from provider counts)` : "";
|
|
207
|
+
const excludes = `${calibration}${reading.estimateExcludes ? `, excluding ${reading.estimateExcludes}` : ""}`;
|
|
208
|
+
const measurement =
|
|
209
|
+
reading.observed === undefined
|
|
210
|
+
? `estimated ${formatTokens(reading.estimated)} tokens for the next request${excludes}; provider-reported size of the previous request unknown`
|
|
211
|
+
: `estimated ${formatTokens(reading.estimated)} tokens for the next request${excludes}; the provider reported ${formatTokens(reading.observed)} for the previous one, system prompt and tool schemas included${reading.observedStale ? ", before your last accepted edit" : ""}`;
|
|
212
|
+
const overflow = overflowLimit === null
|
|
213
|
+
? ""
|
|
214
|
+
: ` Above ${formatTokens(overflowLimit)} tokens the overflow guard holds back the oldest new tool results and saves them to files.`;
|
|
215
|
+
if (tier.label === "budget-reserve") {
|
|
216
|
+
return (
|
|
217
|
+
`[CLM BUDGET] Context is at ${formatTokens(governing)} of a ${formatTokens(reading.budget)}-token budget ` +
|
|
218
|
+
`(${measurement}). Only ${formatTokens(remaining)} tokens remain, which is inside the ${formatTokens(reading.reserve)}-token generation reserve. ` +
|
|
219
|
+
`Edit ${where} now to free space.${overflow}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const base =
|
|
223
|
+
`[CLM BUDGET] Context crossed ${tier.label} of a ${formatTokens(reading.budget)}-token budget: ${measurement}. ` +
|
|
224
|
+
`${formatTokens(remaining)} tokens remain. You may reorganize ${where} at any time; the final reminder comes at ${formatTokens(reading.budget - reading.reserve)} tokens.`;
|
|
225
|
+
const fraction = tier.tokens / reading.budget;
|
|
226
|
+
if (fraction <= 0.25) return base;
|
|
227
|
+
if (fraction <= 0.5) return `${base} Summaries should keep what you would otherwise have to look up again.`;
|
|
228
|
+
return `${base}${overflow}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Runtime calibration of the character-based estimator against the provider's own counts.
|
|
233
|
+
*
|
|
234
|
+
* chars/4 undercounts many inputs (random strings, code, non-English text tokenize at
|
|
235
|
+
* 2-3 chars per token). Each request carries an estimate; when the provider reports the
|
|
236
|
+
* actual size of that request, the ratio updates a factor that scales future estimates.
|
|
237
|
+
* The factor never drops below 1 (only undercounting is corrected) and is capped so one
|
|
238
|
+
* odd sample cannot run away.
|
|
239
|
+
*/
|
|
240
|
+
export class EstimateCalibrator {
|
|
241
|
+
private pending: { estimate: number; rawCount: number } | undefined;
|
|
242
|
+
private current = 1;
|
|
243
|
+
private samples = 0;
|
|
244
|
+
|
|
245
|
+
constructor(private readonly options: { min?: number; max?: number; smoothing?: number; initial?: number } = {}) {
|
|
246
|
+
const smoothing = options.smoothing;
|
|
247
|
+
if (smoothing !== undefined && (!Number.isFinite(smoothing) || smoothing <= 0 || smoothing > 1)) {
|
|
248
|
+
throw new Error(`smoothing must be in (0, 1], got ${String(smoothing)}`);
|
|
249
|
+
}
|
|
250
|
+
this.current = this.initialFactor();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
get factor(): number {
|
|
254
|
+
return this.current;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get sampleCount(): number {
|
|
258
|
+
return this.samples;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
reset(): void {
|
|
262
|
+
this.pending = undefined;
|
|
263
|
+
this.current = this.initialFactor();
|
|
264
|
+
this.samples = 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Remember the raw (uncalibrated) estimate of the request about to be sent. The estimate
|
|
269
|
+
* must cover the same scope as the provider count it will be compared with; an estimate
|
|
270
|
+
* that omits the system prompt and tool schemas reads their size as undercounting.
|
|
271
|
+
*/
|
|
272
|
+
record(estimate: number, rawCount: number): void {
|
|
273
|
+
if (estimate > 0) this.pending = { estimate, rawCount };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Feed the newest provider-reported request size. `index` is the position of the
|
|
278
|
+
* reporting assistant message in the raw transcript; only a message appended after the
|
|
279
|
+
* recorded request counts as its measurement.
|
|
280
|
+
*/
|
|
281
|
+
observe(observed: { tokens: number; index: number } | undefined): number {
|
|
282
|
+
if (!observed || !this.pending || observed.index < this.pending.rawCount) return this.current;
|
|
283
|
+
const sample = observed.tokens / this.pending.estimate;
|
|
284
|
+
this.pending = undefined;
|
|
285
|
+
if (!Number.isFinite(sample) || sample <= 0) return this.current;
|
|
286
|
+
const smoothing = this.options.smoothing ?? 0.5;
|
|
287
|
+
const next = this.samples === 0 ? sample : this.current * (1 - smoothing) + sample * smoothing;
|
|
288
|
+
this.current = this.clamp(next);
|
|
289
|
+
this.samples += 1;
|
|
290
|
+
return this.current;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
apply(estimate: number): number {
|
|
294
|
+
return Math.ceil(estimate * this.current);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private clamp(value: number): number {
|
|
298
|
+
return Math.min(this.options.max ?? 4, Math.max(this.options.min ?? 1, value));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private initialFactor(): number {
|
|
302
|
+
return this.clamp(this.options.initial ?? 1);
|
|
303
|
+
}
|
|
304
|
+
}
|