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 CHANGED
@@ -22,7 +22,7 @@ Derived from pi-clm (code and model-facing text, adapted for OpenCode):
22
22
  src/steering.ts src/steering.ts
23
23
  src/types.ts src/types.ts
24
24
  steering/house-brief.md steering/house-brief.md (byte-identical copy)
25
- skills/clm-context/SKILL.md src/skills/live-context/SKILL.md
25
+ .agents/skills/clm-context/SKILL.md src/skills/live-context/SKILL.md
26
26
  src/panel/timeline.ts src/timeline.ts (context chart and its zoom levels)
27
27
  src/panel/diff.ts src/diff.ts (side-by-side diff)
28
28
  src/panel/view.ts src/viewer.ts (panel pages)
@@ -45,5 +45,5 @@ Context Language Models
45
45
  Two design choices follow its harness: the fit/shrink edit gate and the default
46
46
  25/50/75% reminder steps. pi-clm's skill guidance on edit cost (batch edits, mind the
47
47
  tail below an edit, write detailed summaries), carried into
48
- skills/clm-context/SKILL.md, covers the same points as the harness prompt in
48
+ .agents/skills/clm-context/SKILL.md, covers the same points as the harness prompt in
49
49
  different words.
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). `opencode plugin`
34
- writes the spec to both files. By hand, add it to both `plugin` lists; options go on the
35
- `opencode.json` entry only, and the TUI plugin reads them from there:
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
- return clm.mirrorUnavailable === undefined ? `CLM mirror: ${clm.mirrorPath}` : `CLM mirror: unavailable (${clm.mirrorUnavailable})`;
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
- const sections = [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
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("The provider rejected the request as too long, and OpenCode's automatic compaction is off. Edit the mirror or run /compact.", "warning");
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 text = await compactCommand(clm, hookInput.sessionID, argument);
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.0",
3
+ "version": "0.4.1",
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",
@@ -31,7 +31,7 @@
31
31
  "index.ts",
32
32
  "tui.ts",
33
33
  "src/**/*.ts",
34
- "skills/**",
34
+ ".agents/skills/clm-context/**",
35
35
  "steering/**",
36
36
  "LICENSE",
37
37
  "NOTICE",
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. `undefined` means "use the model context window".
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
- contextBudget: 32000,
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
- /** Where `budget` came from, for status text. */
74
- source: "config" | "model-window";
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: the configured budget (default 32,000), capped by the model's context
105
- * window minus its output allowance when that is smaller. `undefined` contextBudget means
106
- * "use the window". An output limit that is not finite, <= 0, or >= the window is ignored.
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
- "The overflow guard will hold back most tool results: keep the context mirror short and read files in small parts."
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
- const crossed = tiers.filter((tier) => tokens >= tier.tokens && !this.fired.has(tier.label));
280
- if (crossed.length === 0) return undefined;
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.source === "config" ? "configured" : "model window";
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 ` +