opencode-clm 0.3.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/README.md +17 -7
- package/index.ts +29 -10
- package/package.json +1 -1
- package/src/budget.ts +160 -16
- package/src/clm.ts +119 -47
- package/src/compaction.ts +14 -6
- 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/settings-table.ts +83 -17
- package/src/settings.ts +69 -2
- package/src/tui/data.ts +8 -2
- package/src/tui/panel.ts +3 -2
- package/src/tui/plugin.ts +3 -2
package/README.md
CHANGED
|
@@ -30,9 +30,10 @@ opencode plugin -g opencode-clm # global: opencode.json(c) and tui.json in ~
|
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
The package holds two plugins: the server plugin (`index.ts`: mirror, edits, budget,
|
|
33
|
-
`/clm`, `/clm-compact`) and the TUI plugin (`tui.ts`: the `/clm` panel).
|
|
34
|
-
|
|
35
|
-
`opencode
|
|
33
|
+
`/clm`, `/clm-compact`) and the TUI plugin (`tui.ts`: the `/clm` panel). Install both:
|
|
34
|
+
without the TUI plugin there is no panel, and each `/clm` command costs a model turn.
|
|
35
|
+
`opencode plugin` writes the spec to both files. By hand, add it to both `plugin` lists;
|
|
36
|
+
options go on the `opencode.json` entry only, and the TUI plugin reads them from there:
|
|
36
37
|
|
|
37
38
|
```jsonc
|
|
38
39
|
// opencode.json
|
|
@@ -47,17 +48,19 @@ writes the spec to both files. By hand, add it to both `plugin` lists; options g
|
|
|
47
48
|
|---------|--------------|
|
|
48
49
|
| `/clm` | open the panel: **overview** (context size per request + every accepted edit) <br><img src="https://raw.githubusercontent.com/bcmyguest/opencode-clm/main/.github/images/overview.png" alt="The overview page: context size per request, with the requests after which the model edited its context" width="720"> <br> **input** (what the next request contains) · **edits** (per-revision side-by-side diff) <br><img src="https://raw.githubusercontent.com/bcmyguest/opencode-clm/main/.github/images/edits.png" alt="The edits page: a tool result before and after the model shortened it" width="720"> |
|
|
49
50
|
| `/clm overview` / `input` / `edits` / `settings` | open the panel on that page (`timeline` and `edit` also work); without the TUI plugin, print it as text |
|
|
50
|
-
| `/clm status` | a toast: on or off, revision, last request size, budget, fixed overhead, last outcome |
|
|
51
|
+
| `/clm status` | a toast: on or off, revision, last request size, budget, fixed overhead, last outcome; `mode notices-only` when set |
|
|
51
52
|
| `/clm config` | open **settings**; `/clm config <setting> <value>` changes one, `/clm config reset` drops this session's changes <br><img src="https://raw.githubusercontent.com/bcmyguest/opencode-clm/main/.github/images/settings.png" alt="The settings page: sizes, files, and every setting" width="720"> |
|
|
52
53
|
| `/clm-compact [instructions]` | ask the model to compact its own context now; anything you add (e.g. what to keep) is passed along. The result shows on the **edits** page |
|
|
53
54
|
| `/clm on` / `off` / `reset` / `path` | enable, use raw context, discard the accepted revision, show the mirror's path |
|
|
54
|
-
| `/clm budget [value]` | show or change the budget, the same as `/clm config budget` |
|
|
55
|
+
| `/clm budget [value]` | show or change the budget, the same as `/clm config budget`: a share of the model window (default `50%`), tokens (`64k`), or `window` |
|
|
56
|
+
| `/clm config mode notices-only` | keep the budget notices, reminders and overflow guard but take away editing: no mirror, no protocol prompt, raw history. For comparing "telling the model its budget" with "letting it edit"; `/clm config mode edit` switches back |
|
|
55
57
|
|
|
56
58
|
With the TUI plugin, typed `/clm …` lines run in the TUI and cost no model turn, and Tab
|
|
57
59
|
completes their subcommands, setting names and values;
|
|
58
60
|
`/clm reset` is handed to the server plugin, also without a turn. The right of the
|
|
59
61
|
session prompt shows `clm <size> / <budget> · r<revision>` (`~` before the size marks an
|
|
60
|
-
estimate the provider has not confirmed; `clm off · r<revision>` while CLM is off
|
|
62
|
+
estimate the provider has not confirmed; `clm off · r<revision>` while CLM is off;
|
|
63
|
+
`clm notices-only <size> / <budget> · r<revision>` in notices-only mode).
|
|
61
64
|
|
|
62
65
|
In the panel: `1–4` or `Tab` switch pages, `← →` step through the overview's markers or
|
|
63
66
|
the edits page's revisions, `z` zooms the chart, `Enter` opens the selection, `r`
|
|
@@ -89,13 +92,20 @@ notes: 49 requests, 16 accepted edits, 0 rejected. The server reused cache after
|
|
|
89
92
|
and prefilled 11,240 tokens in total, where a prefix-only cache would have prefilled
|
|
90
93
|
214,585 (95% less); requests of 20–48k tokens prefilled 34–3,523 tokens each.
|
|
91
94
|
|
|
95
|
+
**Nine agent tasks in OpenCode** (find and fix, opencode-clm 0.3.0, 1 run each), CLM
|
|
96
|
+
at a 32k budget vs CLM off: both solved 9/9; CLM took 55.5 s per task vs 91.3 s and
|
|
97
|
+
prefilled 2,868 tokens vs 7,317 on average. CLM accepted only 1 edit in 9 runs because no
|
|
98
|
+
task came near the budget, so most of the gain looks behavioural: told its budget, the
|
|
99
|
+
model makes fewer tool calls. One CLM run read files outside its workspace and is not
|
|
100
|
+
comparable.
|
|
101
|
+
|
|
92
102
|
Flash-Next was not trained for CLM, so it makes more edit mistakes than the paper's
|
|
93
103
|
trained models. Hosted APIs are untested.
|
|
94
104
|
|
|
95
105
|
## Docs
|
|
96
106
|
|
|
97
107
|
- [How it works](docs/how-it-works.md) — the mirror, what runs without the model, the panel, model and server support, safety.
|
|
98
|
-
- [Configuration](docs/configuration.md) — every setting (editing, budget, reserve, reminders, gate, guard, compaction, cap, steering, one-tool, trailer, compact-prompt, reasoning), plugin options and environment variables.
|
|
108
|
+
- [Configuration](docs/configuration.md) — every setting (editing, budget, reserve, reminders, reminder-cooldown, gate, guard, compaction, cap, steering, mode, one-tool, trailer, compact-prompt, reasoning), plugin options and environment variables.
|
|
99
109
|
- [Architecture](docs/architecture.md) — modules, session files, design notes, known limitations.
|
|
100
110
|
- [Development](docs/development.md) — setup, checks, the integration suites, releasing.
|
|
101
111
|
|
package/index.ts
CHANGED
|
@@ -504,7 +504,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
504
504
|
async function configCommand(clm: ClmSession, words: string[]): Promise<string> {
|
|
505
505
|
const [name, ...valueWords] = words;
|
|
506
506
|
const { base, effective } = clm.settingsValues();
|
|
507
|
-
const format = { modelWindow: clm.limits.context };
|
|
507
|
+
const format = { modelWindow: clm.limits.context, modelOutput: clm.limits.output };
|
|
508
508
|
if (!name) return settingsText(base, effective, format, clm.settingsWarning);
|
|
509
509
|
if (name.toLowerCase() === "reset" && valueWords.length === 0) {
|
|
510
510
|
await clearSettings(clm);
|
|
@@ -522,12 +522,13 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
522
522
|
const budget = fallbackBudget(clm.settings, clm.limits);
|
|
523
523
|
const model = buildPanelModel(files, budget ? { budget } : {});
|
|
524
524
|
model.enabled = clm.enabled;
|
|
525
|
+
if (clm.noticesOnly) model.noticesOnly = true;
|
|
525
526
|
// No mirror: name why, never the temporary directory (the text reaches the model).
|
|
526
527
|
if (clm.mirrorUnavailable !== undefined) {
|
|
527
528
|
model.mirrorUnavailable = clm.mirrorUnavailable;
|
|
528
529
|
model.mirrorPath = `unavailable (${clm.mirrorUnavailable})`;
|
|
529
530
|
}
|
|
530
|
-
const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}) };
|
|
531
|
+
const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}), ...(clm.limits.output ? { modelOutput: clm.limits.output } : {}) };
|
|
531
532
|
model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
|
|
532
533
|
return panelPageText(model, page);
|
|
533
534
|
}
|
|
@@ -550,7 +551,8 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
550
551
|
return text;
|
|
551
552
|
}
|
|
552
553
|
case "path":
|
|
553
|
-
|
|
554
|
+
if (clm.mirrorUnavailable !== undefined) return `CLM mirror: unavailable (${clm.mirrorUnavailable})`;
|
|
555
|
+
return clm.noticesOnly ? "CLM mirror: not used (mode notices-only)" : `CLM mirror: ${clm.mirrorPath}`;
|
|
554
556
|
case "on":
|
|
555
557
|
case "off": {
|
|
556
558
|
// The same override the TUI and `/clm config editing` write.
|
|
@@ -561,6 +563,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
561
563
|
? "CLM is on for this session. No mirror is available: requests carry the raw history plus the continuity annotations."
|
|
562
564
|
: "CLM is off for this session: requests carry the raw history.";
|
|
563
565
|
}
|
|
566
|
+
if (clm.noticesOnly) {
|
|
567
|
+
return argument === "on"
|
|
568
|
+
? "CLM is on for this session in notices-only mode: requests carry the raw history plus the budget notices; the model cannot edit its context."
|
|
569
|
+
: "CLM is off for this session: requests carry the raw history and no notices.";
|
|
570
|
+
}
|
|
564
571
|
return argument === "on"
|
|
565
572
|
? "CLM is on for this session: the mirror is refreshed before the next request."
|
|
566
573
|
: "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
|
|
@@ -823,9 +830,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
823
830
|
if (!clm.enabled || clm.mirrorUnavailable !== undefined) return;
|
|
824
831
|
const limit = (hookInput.model as { limit?: { context?: number; input?: number; output?: number } } | undefined)?.limit;
|
|
825
832
|
if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined, ...(limit.input ? { input: limit.input } : {}) };
|
|
826
|
-
|
|
833
|
+
if (hookInput.model) clm.limitsKnown = true;
|
|
834
|
+
// notices-only: no protocol section (there is no mirror); the steering document stays.
|
|
835
|
+
const sections = clm.noticesOnly ? [] : [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
|
|
827
836
|
if (clm.steering) sections.push(steeringPromptSection(clm.steering));
|
|
828
|
-
output.system.push(sections.join("\n\n"));
|
|
837
|
+
if (sections.length > 0) output.system.push(sections.join("\n\n"));
|
|
829
838
|
clm.scope = { ...clm.scope, systemTokens: clm.textTokens(output.system.join("\n")) };
|
|
830
839
|
},
|
|
831
840
|
|
|
@@ -872,7 +881,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
872
881
|
if (cancelled && usable !== undefined) {
|
|
873
882
|
await clm.log({ event: "compaction-cancelled", reason: "threshold", setting: cancelled.by, count: cancelled.count, usable });
|
|
874
883
|
if (cancelled.by === "off") {
|
|
875
|
-
clm.queueNotice(thresholdCancelledNoticeText(clm.mirrorPath));
|
|
884
|
+
clm.queueNotice(thresholdCancelledNoticeText(clm.noticesOnly ? undefined : clm.mirrorPath));
|
|
876
885
|
toast(thresholdCancelledText(cancelled.count, usable));
|
|
877
886
|
} else toast(thresholdPausedText(cancelled.count, usable, clm.guardLimit()));
|
|
878
887
|
}
|
|
@@ -945,7 +954,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
945
954
|
// Automatic compaction only (manual /compact does not reach this hook).
|
|
946
955
|
const overflow = hookInput.overflow === true;
|
|
947
956
|
await clm.log({ event: "native-compaction", reason: overflow ? "overflow" : "threshold", setting: clm.settings.compaction });
|
|
948
|
-
toast(nativeCompactionText(overflow, clm.settings.compaction));
|
|
957
|
+
toast(nativeCompactionText(overflow, clm.settings.compaction, clm.noticesOnly));
|
|
949
958
|
},
|
|
950
959
|
|
|
951
960
|
async event({ event }) {
|
|
@@ -986,9 +995,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
986
995
|
toast("The provider rejected the request as too long, and OpenCode's automatic compaction is off. Run /compact.", "warning");
|
|
987
996
|
return;
|
|
988
997
|
}
|
|
989
|
-
clm.queueNotice(overflowNotCompactedText(clm.mirrorPath));
|
|
998
|
+
clm.queueNotice(overflowNotCompactedText(clm.noticesOnly ? undefined : clm.mirrorPath));
|
|
990
999
|
await clm.log({ event: "overflow-not-compacted", setting: clm.settings.compaction });
|
|
991
|
-
toast(
|
|
1000
|
+
toast(clm.noticesOnly
|
|
1001
|
+
? "The provider rejected the request as too long, and OpenCode's automatic compaction is off. Run /compact."
|
|
1002
|
+
: "The provider rejected the request as too long, and OpenCode's automatic compaction is off. Edit the mirror or run /compact.", "warning");
|
|
992
1003
|
return;
|
|
993
1004
|
}
|
|
994
1005
|
if (event.type !== "session.compacted") return;
|
|
@@ -1054,7 +1065,15 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
1054
1065
|
// pi-clm's compactCommand: nothing for the model to edit, so nothing is asked of it.
|
|
1055
1066
|
toast(`The context mirror is unavailable, so the model cannot edit its context (${clm.mirrorUnavailable}).`, "warning");
|
|
1056
1067
|
text = relay("[CLM] The context mirror is unavailable, so the model cannot edit its context.");
|
|
1057
|
-
} else
|
|
1068
|
+
} else {
|
|
1069
|
+
await clm.refreshSettings();
|
|
1070
|
+
if (clm.noticesOnly) {
|
|
1071
|
+
// The compaction prompt asks for a mirror edit; notices-only has no mirror.
|
|
1072
|
+
const message = "CLM runs in notices-only mode for this session, so the model cannot edit its context. Switch with /clm config mode edit.";
|
|
1073
|
+
toast(message, "warning");
|
|
1074
|
+
text = relay(`[CLM] ${message}`);
|
|
1075
|
+
} else text = await compactCommand(clm, hookInput.sessionID, argument);
|
|
1076
|
+
}
|
|
1058
1077
|
} catch (error) {
|
|
1059
1078
|
const message = `/${hookInput.command} failed: ${describe(error)}`;
|
|
1060
1079
|
toast(message, "error");
|
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",
|
package/src/budget.ts
CHANGED
|
@@ -21,22 +21,39 @@
|
|
|
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 ` +
|