opencode-context-indicator 1.2.0 → 1.2.2

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 CHANGED
@@ -10,7 +10,7 @@ arguments and the residual "other" bucket.
10
10
  The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
11
11
  generations) plus an optional TUI module (`./tui` entry) that renders a **live
12
12
  sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
13
- and a redacted export. The V2 path is **tested against opencode v2.0.22**; the
13
+ and a redacted export. The V2 path is **tested against opencode v2.0.25**; the
14
14
  V1 path against 1.18.29+.
15
15
 
16
16
  > **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
@@ -50,6 +50,14 @@ For every served request the plugin estimates how the context window is split:
50
50
  * `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
51
51
  `input + output + cache.read + cache.write`. Percentages are **not clamped**
52
52
  at 100% — exceeding the window stays visible.
53
+ * The denominator is the **opencode v2.0.23 compaction ceiling**: `window =
54
+ limit.input || limit.context`; `reserve = max(floor(window * 0.1), window >=
55
+ 32_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. When opencode
56
+ carries a `compaction.buffer` configured in its own settings, the plugin
57
+ cannot read that value (it is not exposed to plugins), so the default
58
+ reserve formula is used — set `OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER`
59
+ to override the reserve for testing or custom setups (non-negative integer <=
60
+ 1e9; invalid values are silently ignored).
53
61
 
54
62
  ### Toast (V1 only)
55
63
 
@@ -296,7 +304,7 @@ and `$TMPDIR` (usually `/tmp`) elsewhere:
296
304
  | File | Purpose |
297
305
  | --- | --- |
298
306
  | `context-breakdown.log` | Human-readable live snapshot + bounded final summaries |
299
- | `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel |
307
+ | `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel. Fields per entry: `sessionID`, `parentID`, `role`, `agent`, `model`, `providerID`, `ctx`, `input`, `usable` (compaction ceiling), `reserve` (tokens reserved for compaction), `limit` (context window), `reasoning`, `categories{user,assistant,reasoning,toolArgs,system,toolSchemas,other}`, `updatedAt`. |
300
308
  | `context-events.log` | Raw event tap — **only** when `DEBUG_EVENTS` is flipped to `true` in the source (off by default) |
301
309
 
302
310
  The live `context-breakdown.log` snapshot is **rewritten** (never grows); final
@@ -429,13 +437,13 @@ the sidebar instead.
429
437
  gracefully (the breakdown falls back to throttled `session.messages` fetches
430
438
  and toasts keep working).
431
439
  * **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
432
- **2.0.22**).
440
+ **2.0.25**).
433
441
  * **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
434
442
  ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
435
443
  dependencies resolved by OpenCode at runtime; the main plugin installs and runs
436
444
  fine without them (the sidebar simply is not available). `@opentui/solid` is
437
- instead shipped as a **pinned direct dependency** (exact `0.5.14`, matching
438
- the OpenTUI stack OpenCode 2.0.22 ships): OpenCode's
445
+ instead shipped as a **pinned direct dependency** (exact `0.5.16`, matching
446
+ the OpenTUI stack OpenCode 2.0.25 ships): OpenCode's
439
447
  TUI loader does not expose a host instance of it, so for npm-installed plugins
440
448
  the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
441
449
  (upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
@@ -497,9 +505,10 @@ caller.
497
505
  ### Running tests
498
506
 
499
507
  ```bash
500
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T12 unit
508
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T16i unit checks
501
509
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
502
510
  npm run test:e2e:quick # E2E load-only, no LLM call
511
+ npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
503
512
  ```
504
513
 
505
514
  `prepublishOnly` runs **both** gates automatically on `npm publish`
@@ -516,8 +525,8 @@ STEP 80 s, ~3 min total.
516
525
  |---|---|---|
517
526
  | (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically) | no test/node_modules/tsconfig in the tarball |
518
527
  | (b) pragma regression | esbuild `--jsx=automatic` → `@opentui/solid/jsx-runtime` present, `react/jsx-runtime` absent | a removed/broken pragma kills the sidebar on npm install |
519
- | (c) resolve chain | replica install → `import.meta.resolve` for all tui.tsx imports | broken peer/optional dep silently breaks the sidebar |
520
- | (d) unit harness T1–T12 | all limit/agent/state fix assertions | regressions in modelLimits, writeStateFile, hydrate |
528
+ | (c) resolve chain | replica install: host-bridged + peer packages resolve; local tui.tsx relative imports resolve via node from the entry (resolve-bridge.cjs) | broken peer/optional dep or a bad local import silently breaks the sidebar |
529
+ | (d) unit harness T1–T16i | limit/agent/state fix assertions plus measureContributors and redacted-export checks (34 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
521
530
  | (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
522
531
  | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
523
532
 
@@ -525,7 +534,7 @@ STEP 80 s, ~3 min total.
525
534
 
526
535
  | Check | What | Why |
527
536
  |---|---|---|
528
- | A — plugin load | spins a private `opencode --standalone` PTY, asserts `msg="loading plugin" → opencode-context-indicator` and that the `file:///` entrypoint is **this checkout** | catches a plugin that no longer imports cleanly, or a config pointing at the published npm copy |
537
+ | A — plugin load | spins a private `opencode --standalone` PTY against an **isolated global config** (`OPENCODE_CONFIG_DIR` → `%TEMP%/oci-e2e-config`, sole plugin entry = this checkout's `file://` URL), then asserts **from the host opencode log** (atomic lines, immune to PTY interleaving) that the plugin loaded and its `entrypoint` is **this checkout's `index.js`** — both fail-closed | catches a plugin that no longer imports cleanly, or an isolated config the host did not honor |
529
538
  | B — live update | one `opencode run "Say OK" --standalone` round-trip; asserts the LLM answered **and** the isolated `state.json` gained a session with `ctx > 0` | catches a regression in the `session.idle` → `writeStateFile` path |
530
539
 
531
540
  The E2E is fully **non-invasive** to your running OpenCode Desktop:
@@ -534,7 +543,14 @@ The E2E is fully **non-invasive** to your running OpenCode Desktop:
534
543
  - `OPENCODE_DB` points at a throwaway DB in `%TEMP%` (your sessions are untouched);
535
544
  - `OPENCODE_CONTEXT_INDICATOR_STATE_FILE` points at a throwaway state file in `%TEMP%`
536
545
  — the live `opencode-context-indicator-state.json` is never read or written;
546
+ - `OPENCODE_CONFIG_DIR` points at a throwaway global config written by the script
547
+ (plugins: this checkout; `update: "disable"`) **for Check A only** — the load
548
+ source is deterministic and fail-closed instead of following your live config;
549
+ Check B uses your real global config read-only (the round-trip needs the
550
+ provider/model defined there). Your real config is never modified;
537
551
  - `OPENCODE_DISABLE_PROJECT_CONFIG=1` avoids project-level config;
552
+ - any `opencode-cli` instance the test itself spawned is force-killed after each
553
+ check (`serve --service` — your Desktop background service — is never touched);
538
554
  - exactly **one** minimal LLM call (`Say OK`) is made, in the isolated session.
539
555
 
540
556
  The script prints `[e2e] running against ISOLATED opencode instance — your running
@@ -547,10 +563,20 @@ model.
547
563
 
548
564
  ### CI
549
565
 
550
- `.github/workflows/ci.yml` runs `test/prepublish.mjs` on every push/PR and additionally
551
- does a standalone esbuild transpile check (pragmas, no React). The Node-pty E2E is not
552
- run in CI (requires the OpenCode Desktop binary) and is skipped gracefully when the CLI
553
- is absent (`[SKIP] opencode CLI not found`).
566
+ `.github/workflows/ci.yml` runs on every push/PR to `main`, on `workflow_dispatch`,
567
+ and on a daily schedule (GitHub auto-parks scheduled workflows after 60 days of
568
+ repo inactivity — a push or manual run re-enables them):
569
+
570
+ 1. `npx --no-install tsc --noEmit` — typecheck of the TUI entry (`tui.tsx` + its
571
+ `lib/*` imports);
572
+ 2. `test/prepublish.mjs` — the full fast gate (a)–(f), including the standalone
573
+ esbuild transpile check (pragmas, no React);
574
+ 3. `test/drift-watch.mjs` — the OpenTUI pin must match the OpenTUI catalog of the
575
+ **latest released** opencode; red CI means "bump the pin before the next
576
+ release".
577
+
578
+ The Node-pty E2E is not run in CI (requires the OpenCode binary) and is skipped
579
+ gracefully when the CLI is absent (`[SKIP] opencode CLI not found`).
554
580
 
555
581
  ---
556
582
 
package/index.js CHANGED
@@ -112,8 +112,8 @@
112
112
  * Every live/final log write ALSO rewrites
113
113
  * %TEMP%\opencode-context-indicator-state.json (os.tmpdir())
114
114
  * — a machine-readable { sessionID: { model, providerID, ctx, usable,
115
- * limit, categories{...}, updatedAt } } snapshot consumed by the optional
116
- * TUI sidebar in ./tui.tsx (package entry "./tui"). Written atomically
115
+ * reserve, limit, categories{...}, updatedAt } } snapshot consumed by the
116
+ * optional TUI sidebar in ./tui.tsx (package entry "./tui"). Written atomically
117
117
  * (temp + rename), keyed by session, bounded to MAX_TRACKED_SESSIONS, and
118
118
  * strictly additive: any failure is swallowed and never affects the log.
119
119
  *
@@ -123,9 +123,10 @@
123
123
  *
124
124
  * - Metric: the native opencode overflow count — tokens.total when present,
125
125
  * else input + output + cache.read + cache.write — compared against the
126
- * model's usable window (limit.input − reserved, else limit.context −
127
- * maxOutput). Percentages are NOT clamped at 100% — exceeding the window
128
- * must stay visible.
126
+ * model's compaction ceiling (opencode v2.0.23 calculateCeiling): window =
127
+ * limit.input || limit.context, reserve = max(10% of window, 16k when
128
+ * window >= 32k), usable = window - reserve. Percentages are NOT
129
+ * clamped at 100% - exceeding the ceiling must stay visible.
129
130
  * - Category estimates use a unicode-aware heuristic (see estimateTokens):
130
131
  * Cyrillic ~2.5 chars/token, CJK ~1.5, latin/ASCII ~4. These are ESTIMATES,
131
132
  * not a tokenizer; "other" is the residual input − sum of estimates.
@@ -165,6 +166,7 @@ import {
165
166
  getUsableContext,
166
167
  hydrateModelLimitsFromState,
167
168
  modelLimits,
169
+ reserveFor,
168
170
  } from "./lib/limits.js";
169
171
  import { sessionAgentCache, sessionParentCache } from "./lib/cache.js";
170
172
  import {
@@ -189,6 +191,7 @@ export {
189
191
  hydrateModelLimitsFromState,
190
192
  getUsableContext,
191
193
  getModelLimit,
194
+ reserveFor,
192
195
  collectSessionReport,
193
196
  renderSessionRow,
194
197
  shortSummaryText,
package/lib/commands.js CHANGED
@@ -272,6 +272,12 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
272
272
  const usableInfo = getUsableContext(providerID, modelID);
273
273
  const usable =
274
274
  usableInfo?.usable ?? entry?.usable ?? getModelLimit(providerID, modelID) ?? null;
275
+ const reserve =
276
+ usableInfo?.reserved != null
277
+ ? usableInfo.reserved
278
+ : Number.isFinite(entry?.reserve) && entry.reserve > 0
279
+ ? entry.reserve
280
+ : null;
275
281
  const parentID = entry?.parentID ?? null;
276
282
  const updatedAt =
277
283
  entry?.updatedAt ||
@@ -284,6 +290,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
284
290
  providerID,
285
291
  ctxTokens,
286
292
  usable,
293
+ reserve,
287
294
  categories,
288
295
  source,
289
296
  updatedAt,
@@ -384,6 +391,14 @@ function shortSummaryText(r) {
384
391
  } else {
385
392
  lines.push("_category breakdown unavailable (no cache/state entry yet)_");
386
393
  }
394
+ // Compact-at line: show the new ceiling and, when known, the reserve.
395
+ if (r.usable && r.usable > 0) {
396
+ let compact = `compact at ${fmt(r.usable)}`;
397
+ if (r.reserve != null && r.reserve > 0) {
398
+ compact += ` (${fmt(r.reserve)} reserve)`;
399
+ }
400
+ lines.push(compact);
401
+ }
387
402
  lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
388
403
  return lines.join("\n");
389
404
  }
@@ -0,0 +1,288 @@
1
+ // lib/contributors.js
2
+ //
3
+ // Pure contributor-measurement helpers extracted from tui.tsx for unit testing.
4
+ // All functions are stateless transforms of their arguments.
5
+ //
6
+ // Extracted 1:1 (behaviour + WHY comments, stripped of TS syntax):
7
+ // estimateTokensLocal, asRecord, safeText, measureContributors,
8
+ // contributorLine, exportFileName, buildRedactedPayload, panelDenom, panelRole.
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Token estimation (mirrors lib/estimate.js but self-contained — tui.tsx
12
+ // loads standalone and must NOT import lib/*).
13
+ // ---------------------------------------------------------------------------
14
+
15
+ // Unicode heuristic — estimate, not a tokenizer. Cyrillic ~2.5, CJK ~1.5,
16
+ // latin/ASCII ~4 chars/token.
17
+ function estimateTokensLocal(s) {
18
+ let other = 0;
19
+ let cyr = 0;
20
+ let cjk = 0;
21
+ for (let i = 0; i < s.length; i++) {
22
+ const cp = s.codePointAt(i);
23
+ if (cp > 0xffff) i++; // astral pair — consume the low surrogate
24
+ if (cp < 0x80) other++;
25
+ else if (
26
+ (cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
27
+ (cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
28
+ (cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
29
+ (cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
30
+ (cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
31
+ )
32
+ cyr++;
33
+ else if (
34
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
35
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
36
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
37
+ (cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
38
+ (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
39
+ (cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
40
+ (cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
41
+ )
42
+ cjk++;
43
+ else other++;
44
+ }
45
+ return Math.round(other / 4 + cyr / 2.5 + cjk / 1.5);
46
+ }
47
+
48
+ // Live message data is untrusted: coerce every field defensively, never throw.
49
+ function asRecord(v) {
50
+ return v && typeof v === "object" ? v : null;
51
+ }
52
+
53
+ // One-line sanitisation for untrusted labels (agent / model). Drops zero-width
54
+ // + bidi control chars, collapses C0/C1 controls, caps at 32 code points
55
+ // (surrogate-safe). Only labels are ever rendered — never message content.
56
+ function safeText(v) {
57
+ try {
58
+ const s = v == null ? "" : String(v);
59
+ const cleaned = s
60
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
61
+ .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, " ");
62
+ const cps = Array.from(cleaned);
63
+ return cps.length > 32 ? cps.slice(0, 31).join("") + "~" : cleaned;
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Contributor measurement
71
+ // ---------------------------------------------------------------------------
72
+
73
+ // Char counts per (message, bucket). `asst.text` = assistant text,
74
+ // `asst.rsn` = reasoning, `asst.tool` = tool CALL arguments (streaming raw
75
+ // string or JSON.stringify of the input object), `asst.result` = tool RESULT
76
+ // outputs (text content of completed/error tool states). Sorted descending by
77
+ // estimated tokens.
78
+ //
79
+ // Returns an array of plain objects (no class/prototype dependency):
80
+ // { kind, ref, chars, tokensEstimate, tool, preview }
81
+ function measureContributors(messages) {
82
+ const out = [];
83
+ // Bounded preview: control chars collapse to spaces, bidi/zero-width stripped,
84
+ // capped at 26 code points with a "~" tail. Local rendering only.
85
+ const previewOf = (sample) => {
86
+ const cleaned = sample
87
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
88
+ .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
89
+ .trim();
90
+ const cps = Array.from(cleaned);
91
+ return cps.length > 26 ? cps.slice(0, 25).join("") + "~" : cleaned;
92
+ };
93
+ const add = (kind, ref, sample, tool, previewOverride) => {
94
+ if (sample.length === 0) return;
95
+ out.push({
96
+ kind,
97
+ ref,
98
+ chars: sample.length,
99
+ tokensEstimate: estimateTokensLocal(sample),
100
+ tool: tool || "",
101
+ preview: previewOverride !== undefined ? previewOverride : previewOf(sample),
102
+ });
103
+ };
104
+ // Human clock ref: "21:04" from the message's own creation time — far more
105
+ // actionable than an array ordinal. Falls back to "#N" when time is absent.
106
+ const hhmm = (ms) => {
107
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return "";
108
+ try {
109
+ const d = new Date(ms);
110
+ if (Number.isNaN(d.getTime())) return "";
111
+ const pad = (x) => String(x).padStart(2, "0");
112
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
113
+ } catch {
114
+ return "";
115
+ }
116
+ };
117
+ for (let i = 0; i < messages.length; i++) {
118
+ const m = asRecord(messages[i]);
119
+ if (!m) continue;
120
+ const timeRec = asRecord(m.time);
121
+ const ref = hhmm(timeRec && timeRec.created) || `#${i + 1}`;
122
+ const type = typeof m.type === "string" ? m.type : "";
123
+ if (type === "user") {
124
+ if (typeof m.text === "string") add("user.text", ref, m.text);
125
+ continue;
126
+ }
127
+ if (type !== "assistant") continue;
128
+ const content = Array.isArray(m.content) ? m.content : [];
129
+ let text = "";
130
+ let rsn = "";
131
+ for (const rawPart of content) {
132
+ const part = asRecord(rawPart);
133
+ if (!part) continue;
134
+ const pt = typeof part.type === "string" ? part.type : "";
135
+ if (pt === "text") {
136
+ if (typeof part.text === "string") text += part.text;
137
+ } else if (pt === "reasoning") {
138
+ if (typeof part.text === "string") rsn += part.text;
139
+ } else if (pt === "tool") {
140
+ const state = asRecord(part.state);
141
+ if (!state) continue;
142
+ // Per-tool-part contributors: each tool call carries its own name and
143
+ // measured size (one message can hold several tool calls). The raw
144
+ // args JSON is measured but NOT previewed — the tool name is the
145
+ // useful identifier; result text previews fine.
146
+ const toolName =
147
+ typeof part.name === "string" && part.name ? safeText(part.name) : "";
148
+ let toolArgs = "";
149
+ let result = "";
150
+ if (typeof state.input === "string") {
151
+ toolArgs += state.input;
152
+ } else if (state.input != null) {
153
+ try {
154
+ toolArgs += JSON.stringify(state.input) || "";
155
+ } catch {
156
+ /* unstringifiable input: skip its chars */
157
+ }
158
+ }
159
+ const c = state.content;
160
+ if (Array.isArray(c)) {
161
+ for (const rawItem of c) {
162
+ const item = asRecord(rawItem);
163
+ if (item && item.type === "text" && typeof item.text === "string")
164
+ result += item.text;
165
+ }
166
+ }
167
+ add("asst.tool", ref, toolArgs, toolName, ""); // raw args: no preview
168
+ add("asst.result", ref, result, toolName);
169
+ }
170
+ }
171
+ add("asst.text", ref, text);
172
+ add("asst.rsn", ref, rsn);
173
+ }
174
+ return out.sort((a, b) => b.tokensEstimate - a.tokensEstimate);
175
+ }
176
+
177
+ // Human line for one measured contributor: role in full words, the message's
178
+ // own clock time, the bucket (with the tool name for tool buckets), the token
179
+ // estimate, and a bounded single-line preview. Never contains a full session id
180
+ // or file paths.
181
+ function contributorLine(c, rank) {
182
+ const dot = c.kind.indexOf(".");
183
+ const who = dot >= 0 ? c.kind.slice(0, dot) : c.kind;
184
+ const bucket = dot >= 0 ? c.kind.slice(dot + 1) : "";
185
+ const role = who === "asst" ? "assistant" : who;
186
+ const what =
187
+ bucket === "rsn"
188
+ ? "reasoning"
189
+ : bucket === "tool"
190
+ ? "tool args"
191
+ : bucket === "result"
192
+ ? "tool result"
193
+ : "";
194
+ const tool = c.tool ? ` (${c.tool})` : "";
195
+ const whatFull = what ? `${what}${tool}` : "";
196
+ const head = `${rank}. ${role} ${c.ref}${whatFull ? ` ${whatFull}` : ""} \u2014 ${c.tokensEstimate}`;
197
+ return c.preview ? `${head} "${c.preview}"` : head;
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Redacted export helpers (pure)
202
+ // ---------------------------------------------------------------------------
203
+
204
+ // Filename for a redacted export snapshot:
205
+ // context-<id8>-<yyyymmdd-HHMMSSmmm>.json
206
+ function exportFileName(sessionID, now) {
207
+ const id8 = String(sessionID).slice(0, 8);
208
+ const pad = (n) => String(n).padStart(2, "0");
209
+ const stamp =
210
+ `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
211
+ `-${pad(now.getHours())}${pad(now.getMinutes())}` +
212
+ `${pad(now.getSeconds())}${String(now.getMilliseconds()).padStart(3, "0")}`;
213
+ return `context-${id8}-${stamp}.json`;
214
+ }
215
+
216
+ // Build the redacted export payload. All IO (mkdirSync/writeFileSync) and
217
+ // toasts stay in tui.tsx as the thin exportRedacted() wrapper.
218
+ //
219
+ // Fields: generatedAt, session{id8-prefixed id, model, providerID},
220
+ // family[role/model/ctx/usable/limit/pct/updated], contributors[kind/tool/ref/
221
+ // chars/tokensEstimate]. NO message text, NO full session id, NO tool output
222
+ // content, NO contributor preview text.
223
+ //
224
+ // panelDenom / panelRole are passed as parameters so this file stays pure and
225
+ // tui.tsx controls their source.
226
+ function buildRedactedPayload({ sessionID, root, rows, contributors, now, panelDenom, panelRole, num }) {
227
+ const id8 = String(sessionID).slice(0, 8);
228
+ return {
229
+ generatedAt: now.toISOString(),
230
+ session: {
231
+ id: `${id8}...`,
232
+ model: safeText(root && root.model != null ? root.model : ""),
233
+ providerID: safeText(root && root.providerID != null ? root.providerID : ""),
234
+ },
235
+ family: rows.map((e, i) => {
236
+ const d = panelDenom(e);
237
+ const ctxVal = num(e && e.ctx);
238
+ return {
239
+ role: panelRole(e, i === 0),
240
+ model: safeText(e && e.model != null ? e.model : ""),
241
+ ctx: ctxVal,
242
+ usable: e && e.usable != null ? num(e.usable) : null,
243
+ limit: e && e.limit != null ? num(e.limit) : null,
244
+ pct: d > 0 ? Math.round((ctxVal / d) * 100) : null,
245
+ updated: safeText(e && e.updatedAt != null ? e.updatedAt : ""),
246
+ };
247
+ }),
248
+ contributors: contributors.map((c) => ({
249
+ kind: c.kind,
250
+ tool: c.tool || null,
251
+ ref: c.ref,
252
+ chars: c.chars,
253
+ tokensEstimate: c.tokensEstimate,
254
+ })),
255
+ };
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Panel helpers (pure, shared with tui.tsx)
260
+ // ---------------------------------------------------------------------------
261
+
262
+ // Per-row denominator with the same fallback cascade as the server table:
263
+ // usable, else limit, else 0.
264
+ function panelDenom(e) {
265
+ const u = e && typeof e.usable === "number" && e.usable > 0 ? e.usable : 0;
266
+ if (u > 0) return u;
267
+ const l = e && typeof e.limit === "number" && e.limit > 0 ? e.limit : 0;
268
+ return l;
269
+ }
270
+
271
+ // Role label for a state entry: "main" for root, "sub" or "sub:<agent>" for
272
+ // descendants.
273
+ function panelRole(e, isMain) {
274
+ if (isMain) return "main";
275
+ return e && e.agent ? `sub:${safeText(e.agent)}` : "sub";
276
+ }
277
+
278
+ export {
279
+ estimateTokensLocal,
280
+ asRecord,
281
+ safeText,
282
+ measureContributors,
283
+ contributorLine,
284
+ exportFileName,
285
+ buildRedactedPayload,
286
+ panelDenom,
287
+ panelRole,
288
+ };
package/lib/limits.js CHANGED
@@ -6,6 +6,16 @@
6
6
  // client.config.providers(), ensureModelLimits for V2 via ctx.model.list()).
7
7
  // hydrateModelLimitsFromState() seeds the maps from the shared state.json
8
8
  // snapshot via ./state.js. Extracted 1:1 (code + WHY comments) from index.js.
9
+ //
10
+ // Compaction ceiling (v2.0.23): opencode's core calculateCeiling() in
11
+ // packages/core/src/session/compaction.ts derives the auto-compaction trigger
12
+ // ceiling as window - reserve, where window = limit.input || limit.context
13
+ // (null/zero falls through to the context window) and
14
+ // reserve = max(floor(window * 0.1), window >= 32_000 ? 16_000 : 0)
15
+ // A compaction.buffer configured in opencode itself is NOT visible to the
16
+ // plugin (the plugin cannot read opencode's internal compaction config); the
17
+ // env var OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER overrides the reserve for
18
+ // testing and power users only (positive finite integer <= 1e9, ignore invalid).
9
19
 
10
20
  import { finitePos, withTimeout } from "./estimate.js";
11
21
  import { logErr, readStateSnapshot } from "./state.js";
@@ -67,32 +77,73 @@ function getModelLimit(providerID, modelID) {
67
77
  return typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
68
78
  }
69
79
 
70
- // Native opencode usable window (see packages/opencode/src/session/overflow.ts):
71
- // usable = limit.input ? limit.input − reserved : limit.context − maxOutput
72
- // reserved = compaction.reserved ?? min(20_000, maxOutput)
73
- // maxOutput = model.limit.output
74
- // Returns { usable, limit, maxOutput, reserved } or null when not derivable
75
- // (callers then degrade to count / limit.context, or to no percentage at all).
76
- function getUsableContext(providerID, modelID) {
80
+ // Compute the reserve for a given window, matching opencode v2.0.23
81
+ // packages/core/src/session/compaction.ts calculateCeiling(). Returns null when
82
+ // window is not a finite positive number.
83
+ function reserveFor(window, bufferOverride) {
84
+ if (
85
+ typeof window !== "number" ||
86
+ !Number.isFinite(window) ||
87
+ window <= 0
88
+ ) return null;
89
+ // bufferOverride: accept only finite, non-negative integers <= 1e9.
90
+ if (
91
+ typeof bufferOverride === "number" &&
92
+ Number.isFinite(bufferOverride) &&
93
+ bufferOverride >= 0 &&
94
+ bufferOverride <= 1e9
95
+ ) {
96
+ return bufferOverride;
97
+ }
98
+ // Default reserve formula (mirror of opencode core):
99
+ // max(floor(window * 0.1), window >= 32_000 ? 16_000 : 0)
100
+ return Math.max(
101
+ Math.floor(window * 0.1),
102
+ window >= 32_000 ? 16_000 : 0,
103
+ );
104
+ }
105
+
106
+ // Native opencode compaction ceiling (v2.0.23 calculateCeiling):
107
+ // window = limit.input || limit.context (zero/null falls through to context)
108
+ // reserve = reserveFor(window) [or bufferOverride when provided]
109
+ // usable = window - reserve
110
+ // Returns { usable, limit: window, maxOutput, reserved } or null when not
111
+ // derivable (callers then degrade to count / limit.context, or no percentage).
112
+ // The per-model compaction.reserved and the old min(20000, maxOutput) no longer
113
+ // participate in this calculation; opencode's internal buffer is not visible.
114
+ function getUsableContext(providerID, modelID, bufferOverride) {
77
115
  const rec = getModelLimitRecord(providerID, modelID);
78
116
  if (!rec) return null;
79
117
  const context =
80
118
  typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
81
119
  const input =
82
120
  typeof rec.input === "number" && rec.input > 0 ? rec.input : null;
121
+ if (input == null && context == null) return null;
122
+ // Mirror core's limit.input || limit.context (zero/null falls through to context).
123
+ const window = input != null && input > 0 ? input : context;
124
+ if (window == null || !(window > 0)) return null;
125
+
126
+ // When bufferOverride is omitted, read it from the env var once per call.
127
+ let resolvedBuffer = bufferOverride;
128
+ if (resolvedBuffer === undefined) {
129
+ const raw = process.env.OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER;
130
+ // Strict digits-only: reject "5000abc", "5e3", " 5000", "", "-1" and other
131
+ // partial parses (parseInt/parseFloat stop at the first non-digit).
132
+ if (typeof raw === "string" && /^\d+$/.test(raw)) {
133
+ const parsed = parseInt(raw, 10);
134
+ if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1e9) {
135
+ resolvedBuffer = parsed;
136
+ }
137
+ }
138
+ }
139
+
140
+ const reserve = reserveFor(window, resolvedBuffer);
141
+ if (reserve == null) return null;
142
+ const usable = window - reserve;
143
+ if (usable <= 0) return null;
83
144
  const maxOutput =
84
145
  typeof rec.output === "number" && rec.output > 0 ? rec.output : 0;
85
- if (input == null && context == null) return null;
86
- // Mirrors opencode overflow.ts: reserved = min(20000, maxOutput), i.e. 0 when
87
- // the model declares no output limit (NOT a 20000 default — that would
88
- // understate the usable window).
89
- const reserved =
90
- typeof rec.reserved === "number" && rec.reserved >= 0
91
- ? rec.reserved
92
- : Math.min(20000, maxOutput);
93
- const usable = input != null ? input - reserved : context - maxOutput;
94
- if (!(usable > 0)) return null;
95
- return { usable, limit: context, maxOutput, reserved };
146
+ return { usable, limit: window, maxOutput, reserved: reserve };
96
147
  }
97
148
 
98
149
  // Store a model limit record under `<providerID>:<modelID>`. Written by
@@ -366,13 +417,18 @@ function hydrateModelLimitsFromState() {
366
417
  }
367
418
  }
368
419
 
369
- // Build the record: precise usable-as-input when known (so getUsableContext
370
- // re-derives EXACTLY the persisted window), else the raw context window.
420
+ // Build the record: seed the raw window (context, preferring limit when
421
+ // present) so the NEW formula derives the ceiling fresh on every call.
422
+ // The old precise-usable-as-input seeding (which caused double-reserve
423
+ // under the new formula) is retired: getUsableContext is now a pure
424
+ // function of the window and needs no pre-derived state.
371
425
  let rec = null;
372
- if (best.usable != null) {
373
- rec = { context: best.limit, input: best.usable, output: null, reserved: 0 };
374
- } else if (best.limit != null) {
426
+ if (best.limit != null) {
375
427
  rec = { context: best.limit, input: null, output: null, reserved: null };
428
+ } else if (best.usable != null) {
429
+ // No limit in snapshot: fall back to persisted usable as a best-effort
430
+ // window (it was the ceiling before the formula change, close enough).
431
+ rec = { context: best.usable, input: null, output: null, reserved: null };
376
432
  }
377
433
  if (rec == null) continue;
378
434
  modelLimits.set(key, rec);
@@ -396,6 +452,7 @@ export {
396
452
  modelKey,
397
453
  getModelLimit,
398
454
  getUsableContext,
455
+ reserveFor,
399
456
  setModelLimit,
400
457
  ensureProviderLimits,
401
458
  ensureModelLimits,
package/lib/state.js CHANGED
@@ -331,14 +331,23 @@ function writeStateFile(
331
331
  }
332
332
  try {
333
333
  const bd = breakdownCache.get(sessionID) || null;
334
- const limit = getModelLimit(providerID, modelID);
335
334
  const usableInfo = getUsableContext(providerID, modelID);
335
+ // Persist the EFFECTIVE window (limit.input || limit.context — the same
336
+ // window the ceiling was derived from), not the raw context window: on a
337
+ // cold instance hydrateModelLimitsFromState seeds context = limit, so a
338
+ // mismatched limit would re-derive a WRONG (too large) ceiling for models
339
+ // where limit.input < limit.context.
340
+ const limit =
341
+ usableInfo != null && usableInfo.limit != null
342
+ ? usableInfo.limit
343
+ : getModelLimit(providerID, modelID);
336
344
  const denom =
337
345
  usableInfo != null
338
346
  ? usableInfo.usable
339
347
  : limit != null && limit > 0
340
348
  ? limit
341
349
  : null;
350
+ const reserve = usableInfo != null ? usableInfo.reserved : null;
342
351
  const total =
343
352
  typeof ctxTokens === "number" && ctxTokens > 0
344
353
  ? ctxTokens
@@ -433,6 +442,12 @@ function writeStateFile(
433
442
  : sameModelAsPrev && finitePos(prevEntry.usable)
434
443
  ? prevEntry.usable
435
444
  : null,
445
+ reserve:
446
+ reserve != null
447
+ ? reserve
448
+ : sameModelAsPrev && finitePos(prevEntry.reserve)
449
+ ? prevEntry.reserve
450
+ : null,
436
451
  limit:
437
452
  limit != null
438
453
  ? limit