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 +39 -13
- package/index.js +8 -5
- package/lib/commands.js +15 -0
- package/lib/contributors.js +288 -0
- package/lib/limits.js +80 -23
- package/lib/state.js +16 -1
- package/lib/tui-data.d.ts +45 -0
- package/lib/tui-data.js +165 -0
- package/lib/tui-panel.tsx +329 -0
- package/lib/tui-sidebar.tsx +72 -0
- package/lib/tui-theme.js +17 -0
- package/package.json +8 -5
- package/tui.tsx +48 -766
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.
|
|
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.
|
|
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.
|
|
438
|
-
the OpenTUI stack OpenCode 2.0.
|
|
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–
|
|
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
|
|
520
|
-
| (d) unit harness T1–
|
|
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
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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:
|
|
370
|
-
//
|
|
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.
|
|
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
|