opencode-clm 0.2.0 → 0.4.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 +1 -1
- package/README.md +87 -66
- package/index.ts +753 -40
- package/package.json +6 -6
- package/src/budget.ts +166 -22
- package/src/channel.ts +187 -0
- package/src/clm.ts +478 -57
- package/src/compaction.ts +251 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +40 -2
- package/src/opencode.ts +59 -0
- package/src/overflow.ts +6 -3
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/keys.ts +11 -1
- package/src/panel/model.ts +26 -2
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +39 -10
- package/src/presentation.ts +8 -2
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +131 -17
- package/src/settings.ts +100 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +89 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +14 -2
- package/src/tui/plugin.ts +222 -35
- package/src/tui/remote.ts +199 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-clm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "OpenCode plugin for Context Language Models: the model edits a file mirror of its own context, and the edited mirror becomes its next input.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,11 +63,11 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@opentui/core": "0.
|
|
67
|
-
"@opentui/keymap": "0.
|
|
68
|
-
"@opentui/solid": "0.
|
|
69
|
-
"@types/bun": "1.
|
|
66
|
+
"@opentui/core": "0.5.14",
|
|
67
|
+
"@opentui/keymap": "0.5.14",
|
|
68
|
+
"@opentui/solid": "0.5.14",
|
|
69
|
+
"@types/bun": "1.4.2",
|
|
70
70
|
"@xterm/headless": "6.0.0",
|
|
71
|
-
"typescript": "
|
|
71
|
+
"typescript": "7.0.2"
|
|
72
72
|
}
|
|
73
73
|
}
|
package/src/budget.ts
CHANGED
|
@@ -7,36 +7,53 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Two measurements are kept apart and always labelled:
|
|
9
9
|
*
|
|
10
|
-
* - `estimated`: the harness estimate of the
|
|
11
|
-
*
|
|
10
|
+
* - `estimated`: the harness estimate of the *next* request (pinned task + editable
|
|
11
|
+
* context + notices, plus the system prompt and tool schemas once their sizes are
|
|
12
|
+
* known). Available before every call.
|
|
12
13
|
* - `observed`: the provider-reported input size of the *previous* request (OpenCode's
|
|
13
|
-
* assistant `tokens`). Authoritative but one call late
|
|
14
|
-
* prompt and tool schemas, which the budget does not cover.
|
|
14
|
+
* assistant `tokens`). Authoritative but one call late.
|
|
15
15
|
*
|
|
16
|
-
* The estimate alone governs reminder tiers and the reminder text; `observed`
|
|
17
|
-
*
|
|
16
|
+
* The estimate alone governs reminder tiers and the reminder text; `observed` calibrates
|
|
17
|
+
* the estimate and is shown for reference. The harness never
|
|
18
18
|
* blocks a request; the overflow guard (overflow.ts) withholds old tool output when the
|
|
19
19
|
* estimate exceeds budget - reserve.
|
|
20
20
|
*/
|
|
21
21
|
export interface BudgetPolicyConfig {
|
|
22
22
|
/**
|
|
23
23
|
* Context budget in tokens the reminders are measured against. Advisory: requests are
|
|
24
|
-
* never blocked or rolled back.
|
|
24
|
+
* never blocked or rolled back. When undefined, `contextFraction` decides; when both are
|
|
25
|
+
* undefined, the budget is the model context window (the `window` setting).
|
|
25
26
|
*/
|
|
26
27
|
contextBudget?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Budget as a fraction (0 < f <= 1) of the model window minus its output limit, used when
|
|
30
|
+
* `contextBudget` is undefined (the `50%` setting). With the window unknown the budget is
|
|
31
|
+
* `FALLBACK_BUDGET`.
|
|
32
|
+
*/
|
|
33
|
+
contextFraction?: number;
|
|
27
34
|
/** Generation headroom reserved below the budget. The final reminder fires at budget - reserve. */
|
|
28
35
|
reserve: number;
|
|
29
36
|
/** Fractions of the budget at which escalating reminders fire (0 < f < 1), ascending. */
|
|
30
37
|
remindAtFractions: readonly number[];
|
|
31
38
|
/** Whether to fire the final "budget - reserve" reminder. */
|
|
32
39
|
remindAtReserve: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Share of the budget (0 <= f < 1) the context must grow past its size after an accepted
|
|
42
|
+
* edit before percentage reminders fire again; 0 turns the cooldown off. The
|
|
43
|
+
* budget − reserve reminder is exempt.
|
|
44
|
+
*/
|
|
45
|
+
reminderCooldown: number;
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
/** Budget when a percentage budget meets an unknown model window. */
|
|
49
|
+
export const FALLBACK_BUDGET = 32_000;
|
|
50
|
+
|
|
35
51
|
export const DEFAULT_BUDGET_POLICY: BudgetPolicyConfig = {
|
|
36
|
-
|
|
52
|
+
contextFraction: 0.5,
|
|
37
53
|
reserve: 2048,
|
|
38
54
|
remindAtFractions: [0.25, 0.5, 0.75],
|
|
39
55
|
remindAtReserve: true,
|
|
56
|
+
reminderCooldown: 0.1,
|
|
40
57
|
};
|
|
41
58
|
|
|
42
59
|
/**
|
|
@@ -70,23 +87,44 @@ export interface BudgetReading {
|
|
|
70
87
|
* describes a context that no longer exists. The notice says so.
|
|
71
88
|
*/
|
|
72
89
|
observedStale?: boolean;
|
|
73
|
-
/**
|
|
74
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Where `budget` came from, for status text: `config` (a token count), `model-window`
|
|
92
|
+
* (the window, or a token count capped by it), `window-fraction` (`fraction` of the
|
|
93
|
+
* window), `fallback` (`FALLBACK_BUDGET`: a percentage with the window unknown).
|
|
94
|
+
*/
|
|
95
|
+
source: BudgetSource;
|
|
96
|
+
/** The configured fraction, for `window-fraction` and `fallback`. */
|
|
97
|
+
fraction?: number;
|
|
75
98
|
/** Set when `budget` was raised above the configured value to cover the fixed overhead (see `budgetFit`). */
|
|
76
99
|
raisedFrom?: number;
|
|
77
100
|
}
|
|
78
101
|
|
|
102
|
+
export type BudgetSource = "config" | "model-window" | "window-fraction" | "fallback";
|
|
103
|
+
|
|
79
104
|
/** A reminder threshold in absolute tokens plus a stable label for re-arming. */
|
|
80
105
|
export interface BudgetTier {
|
|
81
106
|
label: string;
|
|
82
107
|
tokens: number;
|
|
83
108
|
}
|
|
84
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Defaults plus `overrides`, validated. An own `contextBudget` key without an own
|
|
112
|
+
* `contextFraction` key replaces the default fraction (`{ contextBudget: undefined }` still
|
|
113
|
+
* means "the model window"); a defined `contextBudget` always clears `contextFraction`.
|
|
114
|
+
*/
|
|
85
115
|
export function resolveBudgetPolicy(overrides: Partial<BudgetPolicyConfig> | undefined): BudgetPolicyConfig {
|
|
86
116
|
const merged: BudgetPolicyConfig = { ...DEFAULT_BUDGET_POLICY, ...(overrides ?? {}) };
|
|
117
|
+
const budgetGiven = overrides !== undefined && Object.hasOwn(overrides, "contextBudget");
|
|
118
|
+
const fractionGiven = overrides !== undefined && Object.hasOwn(overrides, "contextFraction");
|
|
119
|
+
if (merged.contextBudget !== undefined || (budgetGiven && !fractionGiven)) delete merged.contextFraction;
|
|
87
120
|
if (merged.contextBudget !== undefined && (!Number.isFinite(merged.contextBudget) || merged.contextBudget <= 0)) {
|
|
88
121
|
throw new Error(`contextBudget must be a positive number of tokens, got ${String(merged.contextBudget)}`);
|
|
89
122
|
}
|
|
123
|
+
if (merged.contextFraction !== undefined && (!Number.isFinite(merged.contextFraction) || merged.contextFraction <= 0 || merged.contextFraction > 1)) {
|
|
124
|
+
throw new Error(`contextFraction must be in (0, 1], got ${String(merged.contextFraction)}`);
|
|
125
|
+
}
|
|
126
|
+
if (merged.contextFraction === undefined) delete merged.contextFraction;
|
|
127
|
+
if (merged.contextBudget === undefined) delete merged.contextBudget;
|
|
90
128
|
if (!Number.isFinite(merged.reserve) || merged.reserve < 0) {
|
|
91
129
|
throw new Error(`reserve must be a nonnegative number of tokens, got ${String(merged.reserve)}`);
|
|
92
130
|
}
|
|
@@ -97,25 +135,35 @@ export function resolveBudgetPolicy(overrides: Partial<BudgetPolicyConfig> | und
|
|
|
97
135
|
}
|
|
98
136
|
}
|
|
99
137
|
fractions.sort((left, right) => left - right);
|
|
138
|
+
if (!Number.isFinite(merged.reminderCooldown) || merged.reminderCooldown < 0 || merged.reminderCooldown >= 1) {
|
|
139
|
+
throw new Error(`reminderCooldown must be in [0, 1) (0 = off), got ${String(merged.reminderCooldown)}`);
|
|
140
|
+
}
|
|
100
141
|
return { ...merged, remindAtFractions: fractions };
|
|
101
142
|
}
|
|
102
143
|
|
|
103
144
|
/**
|
|
104
|
-
* Effective budget
|
|
105
|
-
*
|
|
106
|
-
*
|
|
145
|
+
* Effective budget against the model window minus its output allowance (the base): a token
|
|
146
|
+
* count, capped by the base when that is smaller; else `contextFraction` of the base
|
|
147
|
+
* (default 50%), or `FALLBACK_BUDGET` with the window unknown; else the base itself. An
|
|
148
|
+
* output limit that is not finite, <= 0, or >= the window is ignored. Undefined only for
|
|
149
|
+
* the `window` setting with the window unknown.
|
|
107
150
|
*/
|
|
108
151
|
export function resolveBudget(
|
|
109
152
|
policy: BudgetPolicyConfig,
|
|
110
153
|
modelContextWindow: number | undefined,
|
|
111
154
|
modelOutputLimit = 0,
|
|
112
|
-
): Pick<BudgetReading, "budget" | "reserve" | "source"> | undefined {
|
|
155
|
+
): Pick<BudgetReading, "budget" | "reserve" | "source" | "fraction"> | undefined {
|
|
113
156
|
const window = modelContextWindow !== undefined && Number.isFinite(modelContextWindow) && modelContextWindow > 0
|
|
114
157
|
? modelContextWindow - (Number.isFinite(modelOutputLimit) && modelOutputLimit > 0 && modelOutputLimit < modelContextWindow ? modelOutputLimit : 0)
|
|
115
158
|
: undefined;
|
|
116
159
|
if (policy.contextBudget !== undefined && (window === undefined || policy.contextBudget <= window)) {
|
|
117
160
|
return { budget: policy.contextBudget, reserve: Math.min(policy.reserve, policy.contextBudget), source: "config" };
|
|
118
161
|
}
|
|
162
|
+
const fraction = policy.contextBudget === undefined ? policy.contextFraction : undefined;
|
|
163
|
+
if (fraction !== undefined) {
|
|
164
|
+
const budget = window !== undefined ? Math.max(1, Math.floor(window * fraction)) : FALLBACK_BUDGET;
|
|
165
|
+
return { budget, reserve: Math.min(policy.reserve, budget), source: window !== undefined ? "window-fraction" : "fallback", fraction };
|
|
166
|
+
}
|
|
119
167
|
if (window !== undefined) {
|
|
120
168
|
return { budget: window, reserve: Math.min(policy.reserve, window), source: "model-window" };
|
|
121
169
|
}
|
|
@@ -202,7 +250,7 @@ export function budgetFitLine(fit: BudgetFit): string {
|
|
|
202
250
|
* The one-time model notice for a budget that cannot hold OpenCode's fixed overhead plus a
|
|
203
251
|
* working margin. Text written for this package.
|
|
204
252
|
*/
|
|
205
|
-
export function budgetTooSmallNoticeText(fit: BudgetFit): string {
|
|
253
|
+
export function budgetTooSmallNoticeText(fit: BudgetFit, options: NoticeOptions = {}): string {
|
|
206
254
|
const head =
|
|
207
255
|
`[CLM BUDGET] The configured budget of ${formatTokens(fit.configured)} tokens is too small for this session: ` +
|
|
208
256
|
`OpenCode's system prompt and tool schemas take about ${formatTokens(fit.overhead ?? 0)} tokens of every request, ` +
|
|
@@ -212,7 +260,9 @@ export function budgetTooSmallNoticeText(fit: BudgetFit): string {
|
|
|
212
260
|
const raise = fit.raised ? `CLM raised this session's budget to ${formatTokens(fit.effective)} tokens, the most the model window allows, ` : "The model window allows no larger budget, ";
|
|
213
261
|
return (
|
|
214
262
|
`${head} ${raise}which still leaves only ${left} tokens for the conversation. ` +
|
|
215
|
-
|
|
263
|
+
(options.noticesOnly
|
|
264
|
+
? "The overflow guard will hold back most tool results: read files in small parts."
|
|
265
|
+
: "The overflow guard will hold back most tool results: keep the context mirror short and read files in small parts.")
|
|
216
266
|
);
|
|
217
267
|
}
|
|
218
268
|
return (
|
|
@@ -252,35 +302,93 @@ export function governingTokens(reading: Pick<BudgetReading, "estimated">): numb
|
|
|
252
302
|
return reading.estimated;
|
|
253
303
|
}
|
|
254
304
|
|
|
305
|
+
/** The outcome of one `BudgetTracker.check`. */
|
|
306
|
+
export interface BudgetCheck {
|
|
307
|
+
/** The highest newly crossed tier to announce, if any. */
|
|
308
|
+
tier?: BudgetTier;
|
|
309
|
+
/** Tiers crossed during a reminder cooldown: consumed without a notice. */
|
|
310
|
+
suppressed: BudgetTier[];
|
|
311
|
+
/** Governing size at which the active cooldown ends; undefined without one. */
|
|
312
|
+
cooldownUntil?: number;
|
|
313
|
+
}
|
|
314
|
+
|
|
255
315
|
/**
|
|
256
316
|
* Escalating, re-arming tier tracker over absolute token thresholds. Re-derives its state
|
|
257
317
|
* when the tier set changes (for example after the budget changes).
|
|
318
|
+
*
|
|
319
|
+
* Reminder cooldown: `editAccepted()` marks an accepted context edit. The next check records
|
|
320
|
+
* the governing size E; until the size reaches E + cooldown × budget, crossed percentage tiers
|
|
321
|
+
* are marked fired without a notice (consumed, not deferred). The budget-reserve tier is
|
|
322
|
+
* exempt. A later accepted edit restarts the cooldown; a reset, `clearCooldown` or a tier change ends it.
|
|
258
323
|
*/
|
|
259
324
|
export class BudgetTracker {
|
|
260
325
|
private fired = new Set<string>();
|
|
261
326
|
private tierKey = "";
|
|
327
|
+
/** An edit was accepted and the next check has not yet recorded its size. */
|
|
328
|
+
private cooldownPending = false;
|
|
329
|
+
/** Governing size recorded after the last accepted edit, while the cooldown runs. */
|
|
330
|
+
private cooldownStart: number | undefined;
|
|
262
331
|
|
|
263
332
|
reset(): void {
|
|
264
333
|
this.fired.clear();
|
|
334
|
+
this.clearCooldown();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** End the reminder cooldown; fired tiers stay fired. */
|
|
338
|
+
clearCooldown(): void {
|
|
339
|
+
this.cooldownPending = false;
|
|
340
|
+
this.cooldownStart = undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Start (or restart) the reminder cooldown from the next reading. */
|
|
344
|
+
editAccepted(): void {
|
|
345
|
+
this.cooldownPending = true;
|
|
346
|
+
this.cooldownStart = undefined;
|
|
265
347
|
}
|
|
266
348
|
|
|
267
349
|
/** Returns the highest newly crossed tier, or undefined when nothing new fired. */
|
|
268
|
-
observe(reading: BudgetReading, tiers: readonly BudgetTier[]): BudgetTier | undefined {
|
|
350
|
+
observe(reading: BudgetReading, tiers: readonly BudgetTier[], cooldown = 0): BudgetTier | undefined {
|
|
351
|
+
return this.check(reading, tiers, cooldown).tier;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** `observe` with the tiers a cooldown suppressed. `cooldown`: share of the budget, 0 = off. */
|
|
355
|
+
check(reading: BudgetReading, tiers: readonly BudgetTier[], cooldown = 0): BudgetCheck {
|
|
269
356
|
const key = tiers.map((tier) => `${tier.label}:${tier.tokens}`).join("|");
|
|
270
357
|
if (key !== this.tierKey) {
|
|
271
358
|
this.tierKey = key;
|
|
272
359
|
this.fired.clear();
|
|
360
|
+
this.cooldownStart = undefined;
|
|
273
361
|
}
|
|
274
362
|
const tokens = governingTokens(reading);
|
|
275
363
|
this.fired = new Set([...this.fired].filter((label) => {
|
|
276
364
|
const tier = tiers.find((candidate) => candidate.label === label);
|
|
277
365
|
return tier !== undefined && tokens >= tier.tokens;
|
|
278
366
|
}));
|
|
279
|
-
|
|
280
|
-
|
|
367
|
+
if (this.cooldownPending) {
|
|
368
|
+
this.cooldownPending = false;
|
|
369
|
+
this.cooldownStart = tokens;
|
|
370
|
+
}
|
|
371
|
+
let cooldownUntil: number | undefined;
|
|
372
|
+
if (this.cooldownStart !== undefined) {
|
|
373
|
+
const until = this.cooldownStart + cooldown * reading.budget;
|
|
374
|
+
// cooldown 0 (off, possibly set mid-cooldown) ends it outright.
|
|
375
|
+
if (cooldown > 0 && tokens < until) cooldownUntil = until;
|
|
376
|
+
else this.cooldownStart = undefined;
|
|
377
|
+
}
|
|
378
|
+
let crossed = tiers.filter((tier) => tokens >= tier.tokens && !this.fired.has(tier.label));
|
|
379
|
+
const suppressed: BudgetTier[] = [];
|
|
380
|
+
if (cooldownUntil !== undefined) {
|
|
381
|
+
for (const tier of crossed) {
|
|
382
|
+
if (tier.label === "budget-reserve") continue;
|
|
383
|
+
suppressed.push(tier);
|
|
384
|
+
this.fired.add(tier.label);
|
|
385
|
+
}
|
|
386
|
+
crossed = crossed.filter((tier) => tier.label === "budget-reserve");
|
|
387
|
+
}
|
|
388
|
+
if (crossed.length === 0) return { suppressed, cooldownUntil };
|
|
281
389
|
const top = crossed[crossed.length - 1]!;
|
|
282
390
|
for (const tier of tiers) if (tier.tokens <= top.tokens) this.fired.add(tier.label);
|
|
283
|
-
return top;
|
|
391
|
+
return { tier: top, suppressed, cooldownUntil };
|
|
284
392
|
}
|
|
285
393
|
}
|
|
286
394
|
|
|
@@ -293,11 +401,27 @@ function calibrated(reading: Pick<BudgetReading, "calibration">): boolean {
|
|
|
293
401
|
return reading.calibration !== undefined && reading.calibration > 1.005;
|
|
294
402
|
}
|
|
295
403
|
|
|
404
|
+
/** `0.5` → `50%`, `0.125` → `12.5%`. */
|
|
405
|
+
export function formatPercent(fraction: number): string {
|
|
406
|
+
return `${Number((fraction * 100).toFixed(2))}%`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Short origin label of a budget: `configured`, `model window`, `50% of model window`, … */
|
|
410
|
+
export function budgetOrigin(reading: Pick<BudgetReading, "source" | "fraction">): string {
|
|
411
|
+
const percent = reading.fraction !== undefined ? formatPercent(reading.fraction) : "";
|
|
412
|
+
switch (reading.source) {
|
|
413
|
+
case "config": return "configured";
|
|
414
|
+
case "model-window": return "model window";
|
|
415
|
+
case "window-fraction": return `${percent} of model window`;
|
|
416
|
+
case "fallback": return `fallback, ${percent} of an unknown model window`;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
296
420
|
/** One line for status text: budget, estimate, and observation, each labelled. */
|
|
297
421
|
export function budgetSummaryLine(reading: BudgetReading): string {
|
|
298
422
|
const origin = reading.raisedFrom !== undefined
|
|
299
423
|
? `raised from ${formatTokens(reading.raisedFrom)} to cover the fixed overhead`
|
|
300
|
-
: reading
|
|
424
|
+
: budgetOrigin(reading);
|
|
301
425
|
const parts = [
|
|
302
426
|
`budget ${formatTokens(reading.budget)} tok (${origin}, reserve ${formatTokens(reading.reserve)})`,
|
|
303
427
|
`estimated next request ${formatTokens(reading.estimated)}${calibrated(reading) ? ` (×${reading.calibration!.toFixed(2)} calibrated)` : ""}`,
|
|
@@ -310,17 +434,24 @@ export function budgetSummaryLine(reading: BudgetReading): string {
|
|
|
310
434
|
return parts.join(" · ");
|
|
311
435
|
}
|
|
312
436
|
|
|
437
|
+
/** Model-facing notice variants. `noticesOnly`: the session runs `mode notices-only`, so no text may point the model at a mirror. */
|
|
438
|
+
export interface NoticeOptions {
|
|
439
|
+
noticesOnly?: boolean;
|
|
440
|
+
}
|
|
441
|
+
|
|
313
442
|
/**
|
|
314
443
|
* The model-facing reminder. States the estimate and the provider count separately, the
|
|
315
444
|
* budget and the tokens left, the mirror path, and, from the third tier up, what the
|
|
316
445
|
* overflow guard does above `overflowLimit` (default budget - reserve). Pass `null` when the
|
|
317
|
-
* guard is off: the notice then leaves the guard out.
|
|
446
|
+
* guard is off: the notice then leaves the guard out. `noticesOnly` keeps the measurement,
|
|
447
|
+
* the budget, the tokens left and the guard sentence, and drops every invitation to edit.
|
|
318
448
|
*/
|
|
319
449
|
export function budgetNoticeText(
|
|
320
450
|
reading: BudgetReading,
|
|
321
451
|
tier: BudgetTier,
|
|
322
452
|
mirrorPath: string | undefined,
|
|
323
453
|
overflowLimit: number | null = reading.budget - reading.reserve,
|
|
454
|
+
options: NoticeOptions = {},
|
|
324
455
|
): string {
|
|
325
456
|
const governing = governingTokens(reading);
|
|
326
457
|
const remaining = Math.max(0, reading.budget - governing);
|
|
@@ -334,6 +465,19 @@ export function budgetNoticeText(
|
|
|
334
465
|
const overflow = overflowLimit === null
|
|
335
466
|
? ""
|
|
336
467
|
: ` Above ${formatTokens(overflowLimit)} tokens the overflow guard holds back the oldest new tool results and saves them to files.`;
|
|
468
|
+
if (options.noticesOnly) {
|
|
469
|
+
const fraction = tier.tokens / reading.budget;
|
|
470
|
+
if (tier.label === "budget-reserve") {
|
|
471
|
+
return (
|
|
472
|
+
`[CLM BUDGET] Context is at ${formatTokens(governing)} of a ${formatTokens(reading.budget)}-token budget ` +
|
|
473
|
+
`(${measurement}). Only ${formatTokens(remaining)} tokens remain, which is inside the ${formatTokens(reading.reserve)}-token generation reserve.${overflow}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
const base =
|
|
477
|
+
`[CLM BUDGET] Context crossed ${tier.label} of a ${formatTokens(reading.budget)}-token budget: ${measurement}. ` +
|
|
478
|
+
`${formatTokens(remaining)} tokens remain; the final reminder comes at ${formatTokens(reading.budget - reading.reserve)} tokens.`;
|
|
479
|
+
return fraction <= 0.5 ? base : `${base}${overflow}`;
|
|
480
|
+
}
|
|
337
481
|
if (tier.label === "budget-reserve") {
|
|
338
482
|
return (
|
|
339
483
|
`[CLM BUDGET] Context is at ${formatTokens(governing)} of a ${formatTokens(reading.budget)}-token budget ` +
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The turn-free channel between the TUI plugin and the server plugin. Both sides speak
|
|
2
|
+
// through OpenCode's `tui.command.execute` bus event: `POST /tui/publish` accepts any
|
|
3
|
+
// command string, the server publishes it on the instance bus, and every listener sees it:
|
|
4
|
+
// the server plugin's `event` hook, each TUI's own `api.event` and the TUI app, which
|
|
5
|
+
// dispatches the string as a keymap command (an unknown name is a no-op). Requests and
|
|
6
|
+
// replies are prefixed, base64url-encoded JSON, so they carry no spaces and never collide
|
|
7
|
+
// with a real keymap command. Written for this package. Verified against opencode 1.18.34:
|
|
8
|
+
// publish handler server/routes/instance/httpapi/handlers/tui.ts:84-93; command schema
|
|
9
|
+
// packages/schema/src/tui-event.ts:13-37; plugin `event` hook plugin/index.ts:255-260 (events
|
|
10
|
+
// of the plugin's own directory only); TUI app dispatch packages/tui/src/app.tsx:987-990.
|
|
11
|
+
|
|
12
|
+
/** `tui.command.execute` event type (opencode packages/schema/src/tui-event.ts). */
|
|
13
|
+
export const COMMAND_EVENT = "tui.command.execute";
|
|
14
|
+
export const REQUEST_PREFIX = "opencode-clm.request:";
|
|
15
|
+
export const REPLY_PREFIX = "opencode-clm.reply:";
|
|
16
|
+
export const CHANNEL_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
export type ChannelOperation =
|
|
19
|
+
/** Where the server keeps the session's files. Answer: `directory` and `root`. */
|
|
20
|
+
| { op: "locate" }
|
|
21
|
+
/** `/clm config <setting> <value>`, `/clm on|off` and the panel's apply. */
|
|
22
|
+
| { op: "set"; setting: string; value: string }
|
|
23
|
+
/** `/clm config reset`. */
|
|
24
|
+
| { op: "settings-reset" }
|
|
25
|
+
/** `/clm reset`: drop the accepted revision. */
|
|
26
|
+
| { op: "reset" }
|
|
27
|
+
/** A file of the session directory (`path` relative to it). Answer: `content`, absent when missing. */
|
|
28
|
+
| { op: "read"; path: string }
|
|
29
|
+
/** A directory of the session directory (`""` = itself). Answer: `names`, absent when missing. */
|
|
30
|
+
| { op: "list"; path: string };
|
|
31
|
+
|
|
32
|
+
/** Largest file a `read` reply carries; replies travel on the instance bus to every client. */
|
|
33
|
+
export const MAX_READ_BYTES = 4 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
export type ChannelRequest = ChannelOperation & { v: typeof CHANNEL_VERSION; id: string; session: string };
|
|
36
|
+
|
|
37
|
+
export interface ChannelReply {
|
|
38
|
+
v: typeof CHANNEL_VERSION;
|
|
39
|
+
id: string;
|
|
40
|
+
ok: boolean;
|
|
41
|
+
/** User-facing result or error. */
|
|
42
|
+
text: string;
|
|
43
|
+
/** `locate`: the session directory on the server. */
|
|
44
|
+
directory?: string;
|
|
45
|
+
/** `locate`: the server's instance directory, the root `file.read` paths are relative to. */
|
|
46
|
+
root?: string;
|
|
47
|
+
/** `read`: the file text. */
|
|
48
|
+
content?: string;
|
|
49
|
+
/** `list`: the entry names. */
|
|
50
|
+
names?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function encode(prefix: string, value: unknown): string {
|
|
54
|
+
return prefix + Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function decode(prefix: string, command: unknown): Record<string, unknown> | undefined {
|
|
58
|
+
if (typeof command !== "string" || !command.startsWith(prefix)) return undefined;
|
|
59
|
+
try {
|
|
60
|
+
const value: unknown = JSON.parse(Buffer.from(command.slice(prefix.length), "base64url").toString("utf8"));
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
62
|
+
const record = value as Record<string, unknown>;
|
|
63
|
+
if (record.v !== CHANNEL_VERSION || typeof record.id !== "string" || record.id === "") return undefined;
|
|
64
|
+
return record;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function encodeRequest(request: ChannelRequest): string {
|
|
71
|
+
return encode(REQUEST_PREFIX, request);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The request in a command string; undefined for anything else (foreign or malformed). */
|
|
75
|
+
export function decodeRequest(command: unknown): ChannelRequest | undefined {
|
|
76
|
+
const record = decode(REQUEST_PREFIX, command);
|
|
77
|
+
if (!record || typeof record.session !== "string" || record.session === "") return undefined;
|
|
78
|
+
switch (record.op) {
|
|
79
|
+
case "locate":
|
|
80
|
+
case "settings-reset":
|
|
81
|
+
case "reset":
|
|
82
|
+
return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: record.op };
|
|
83
|
+
case "read":
|
|
84
|
+
case "list":
|
|
85
|
+
if (typeof record.path !== "string") return undefined;
|
|
86
|
+
return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: record.op, path: record.path };
|
|
87
|
+
case "set":
|
|
88
|
+
if (typeof record.setting !== "string" || typeof record.value !== "string") return undefined;
|
|
89
|
+
return { v: CHANNEL_VERSION, id: record.id as string, session: record.session, op: "set", setting: record.setting, value: record.value };
|
|
90
|
+
default:
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function encodeReply(reply: ChannelReply): string {
|
|
96
|
+
return encode(REPLY_PREFIX, reply);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function decodeReply(command: unknown): ChannelReply | undefined {
|
|
100
|
+
const record = decode(REPLY_PREFIX, command);
|
|
101
|
+
if (!record || typeof record.ok !== "boolean" || typeof record.text !== "string") return undefined;
|
|
102
|
+
return {
|
|
103
|
+
v: CHANNEL_VERSION,
|
|
104
|
+
id: record.id as string,
|
|
105
|
+
ok: record.ok,
|
|
106
|
+
text: record.text,
|
|
107
|
+
...(typeof record.directory === "string" ? { directory: record.directory } : {}),
|
|
108
|
+
...(typeof record.root === "string" ? { root: record.root } : {}),
|
|
109
|
+
...(typeof record.content === "string" ? { content: record.content } : {}),
|
|
110
|
+
...(Array.isArray(record.names) && record.names.every((name) => typeof name === "string") ? { names: record.names as string[] } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The command string of a `tui.command.execute` event, if the value is one. */
|
|
115
|
+
export function commandOf(event: unknown): string | undefined {
|
|
116
|
+
if (!event || typeof event !== "object") return undefined;
|
|
117
|
+
const { type, properties } = event as { type?: unknown; properties?: { command?: unknown } };
|
|
118
|
+
if (type !== COMMAND_EVENT) return undefined;
|
|
119
|
+
return typeof properties?.command === "string" ? properties.command : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The TUI side: send a request, wait for the reply with the same id. */
|
|
123
|
+
export interface ChannelClientDeps {
|
|
124
|
+
/** Publishes a `tui.command.execute` event with this command string. */
|
|
125
|
+
publish(command: string): Promise<void>;
|
|
126
|
+
/** Subscribes to `tui.command.execute` command strings; returns the unsubscribe. */
|
|
127
|
+
subscribe(handler: (command: string) => void): () => void;
|
|
128
|
+
timeoutMs?: number;
|
|
129
|
+
/** Request ids; random by default. */
|
|
130
|
+
newId?: () => string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ChannelClient {
|
|
134
|
+
/** `timeoutMs` overrides the client's default for this request. */
|
|
135
|
+
request(session: string, operation: ChannelOperation, timeoutMs?: number): Promise<ChannelReply>;
|
|
136
|
+
dispose(): void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class ChannelTimeout extends Error {
|
|
140
|
+
constructor(readonly timeoutMs: number) {
|
|
141
|
+
super(
|
|
142
|
+
`the opencode-clm server plugin did not answer within ${Math.round(timeoutMs / 100) / 10} s: the server does not load ` +
|
|
143
|
+
"opencode-clm, loads an older version without the TUI channel, or has CLM disabled (enabled: false)",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const CHANNEL_TIMEOUT_MS = 5_000;
|
|
149
|
+
/** `locate` is asked on panel loads; a short wait keeps a missing server plugin from stalling them. */
|
|
150
|
+
export const LOCATE_TIMEOUT_MS = 1_000;
|
|
151
|
+
|
|
152
|
+
export function createChannelClient(deps: ChannelClientDeps): ChannelClient {
|
|
153
|
+
const pending = new Map<string, (reply: ChannelReply) => void>();
|
|
154
|
+
const unsubscribe = deps.subscribe((command) => {
|
|
155
|
+
const reply = decodeReply(command);
|
|
156
|
+
if (!reply) return;
|
|
157
|
+
pending.get(reply.id)?.(reply);
|
|
158
|
+
});
|
|
159
|
+
const newId = deps.newId ?? (() => crypto.randomUUID());
|
|
160
|
+
const defaultTimeoutMs = deps.timeoutMs ?? CHANNEL_TIMEOUT_MS;
|
|
161
|
+
return {
|
|
162
|
+
request(session, operation, timeoutMs = defaultTimeoutMs) {
|
|
163
|
+
const id = newId();
|
|
164
|
+
const request = { ...operation, v: CHANNEL_VERSION, id, session } as ChannelRequest;
|
|
165
|
+
return new Promise<ChannelReply>((resolve, reject) => {
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
pending.delete(id);
|
|
168
|
+
reject(new ChannelTimeout(timeoutMs));
|
|
169
|
+
}, timeoutMs);
|
|
170
|
+
pending.set(id, (reply) => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
pending.delete(id);
|
|
173
|
+
resolve(reply);
|
|
174
|
+
});
|
|
175
|
+
deps.publish(encodeRequest(request)).catch((error: unknown) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
pending.delete(id);
|
|
178
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
dispose() {
|
|
183
|
+
unsubscribe();
|
|
184
|
+
pending.clear();
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|