opencode-jev-compaction 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JLegends
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,25 @@
1
+ # Attribution
2
+
3
+ The compaction strategy implemented here is adapted from
4
+ [fast-jev-compaction](https://github.com/tamaratran/fast-jev-compaction),
5
+ which is MIT licensed, Copyright (c) 2025.
6
+
7
+ Specifically derived from that project:
8
+
9
+ - the graded two-question decision protocol (`keepCall` / `keepResult` as
10
+ `noul` probabilities) and the keep / truncate-result / drop-call actions
11
+ - the staged state-fitting ladder and its constants (tool inputs truncated to
12
+ 1000 / 200 / 60 characters, `TEXT_HEAD` 400, `TEXT_TAIL` 150)
13
+ - the calibrated token estimator (a word per six letters, half a token per
14
+ digit, 0.9 per other symbol)
15
+ - the pinning rule (first message plus the newest N)
16
+ - the state context wording and the batching-under-a-token-budget approach
17
+
18
+ This project is an independent reimplementation for
19
+ [opencode](https://opencode.ai), not a fork. It targets opencode's part model,
20
+ where a single `tool` part carries both the call and its result, so the
21
+ original's orphaned-result invariant is unnecessary. It also runs before every
22
+ model request rather than only at a compaction boundary, never throws (any
23
+ failure leaves the messages untouched), and adds a daily request ceiling.
24
+
25
+ All credit for the underlying idea and the decision protocol belongs upstream.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # opencode-jev-compaction
2
+
3
+ Two [opencode](https://opencode.ai) plugins that replace lossy compaction with
4
+ decisions: ask a fast model which tool calls and results are still needed, drop
5
+ or truncate the ones that aren't, and leave every user and assistant message
6
+ verbatim.
7
+
8
+ - **`./server`** — the pruner. Runs before every model request, and adds a note
9
+ to the compaction prompt so shortened results aren't mistaken for failures.
10
+ - **`./tui`** — a sidebar widget showing how much context the pruner has removed.
11
+
12
+ Strategy adapted from [fast-jev-compaction](https://github.com/tamaratran/fast-jev-compaction) (MIT). See [NOTICE](./NOTICE).
13
+
14
+ ## Why
15
+
16
+ When a context window fills, the usual answer is to summarize old turns. A
17
+ summary is lossy: a file path, an exact error, or a constraint can vanish even
18
+ when it matters later. This never rewrites anything. It only removes what a
19
+ model says is no longer needed, and everything kept stays byte-for-byte.
20
+
21
+ opencode already has a pruner, but its decision is purely recency and size — it
22
+ keeps a fixed window of recent tool output and erases the rest. This one decides
23
+ by relevance.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ opencode plugin opencode-jev-compaction --global
29
+ ```
30
+
31
+ That detects both the `./server` and `./tui` entrypoints and writes each to the
32
+ right config (`opencode.json` for the server plugin, `tui.json` for the widget).
33
+ Restart opencode afterwards.
34
+
35
+ From a checkout instead:
36
+
37
+ ```sh
38
+ opencode plugin github:JLegends/opencode-jev-compaction --global
39
+ ```
40
+
41
+ ## Configure
42
+
43
+ The key comes from the environment, or from the macOS Keychain if you point it at
44
+ one:
45
+
46
+ ```sh
47
+ export TYPESAFE_API_KEY=... # or:
48
+ export JEV_KEYCHAIN_SERVICE=... # keychain service name
49
+ export JEV_KEYCHAIN_ACCOUNT=... # keychain account name
50
+ ```
51
+
52
+ | Variable | Default | Purpose |
53
+ | --- | --- | --- |
54
+ | `TYPESAFE_API_KEY` | — | API key. Required unless the keychain is configured. |
55
+ | `JEV_KEYCHAIN_SERVICE` / `JEV_KEYCHAIN_ACCOUNT` | — | Read the key from the macOS Keychain instead of the environment. |
56
+ | `JEV_COMPACTION` | on | `0` disables everything. |
57
+ | `JEV_COMPACTION_THRESHOLD` | `60000` | Estimated tokens before it engages. Below this it does nothing and costs nothing. |
58
+ | `JEV_KEEP_THRESHOLD` | `0.35` | Minimum probability for a call or result to be kept. Lower keeps more; a call below it is deleted outright, which is irreversible, so this is deliberately conservative. |
59
+ | `JEV_PRESERVE_RECENT` | `6` | Newest messages never touched. Values below `1` are clamped to `1`; setting it to `0` drops the results the model is actively using and causes re-run loops. |
60
+ | `JEV_MAX_STATE_TOKENS` | `25000` | Ceiling for the state sent to Jev. |
61
+ | `JEV_MAX_REQUEST_TOKENS` | `30000` | Ceiling for state plus one batch of questions. |
62
+ | `JEV_TRUNCATE_HEAD` | `300` | Characters of a dropped result kept before its note. |
63
+ | `JEV_SMALL_RESULT_CHARS` | `600` | Results at or below this size are shown to Jev in full instead of as a note. |
64
+ | `JEV_TIMEOUT_MS` | `20000` | Per-request timeout. Failures are skipped silently. |
65
+ | `JEV_DAILY_REQUEST_CAP` | `200` | Hard ceiling on Jev requests per day. |
66
+ | `JEV_MODEL` | `jev-latest` | Model name. |
67
+ | `JEV_BASE_URL` | System One endpoint | Override the endpoint. |
68
+ | `JEV_DEBUG` | off | `1` appends a trace to `~/.local/share/opencode/jev-compaction.log`. |
69
+
70
+ ## Cost
71
+
72
+ Jev is priced per input token with free output. At the default 25k state ceiling
73
+ and the 200-request daily cap, worst-case spend is about **$0.21/day**, and it
74
+ cannot exceed that. It also removes input tokens from every subsequent request,
75
+ which is the point.
76
+
77
+ Set `JEV_DAILY_REQUEST_CAP` lower if you want a tighter bound.
78
+
79
+ ## How it works
80
+
81
+ 1. Every finished `tool` part is a candidate, except those in the first message
82
+ or the newest `JEV_PRESERVE_RECENT` messages, which are pinned.
83
+ 2. The whole conversation is sent as state, oldest first, with tool outputs
84
+ replaced by a short note (`ok, 4213 chars (omitted)`). Tool inputs and all
85
+ text are included. The state is shrunk in stages until it fits
86
+ `JEV_MAX_STATE_TOKENS`: inputs truncated to 1000, then 200, then 60
87
+ characters; long texts abridged head and tail; old messages collapsed;
88
+ old calls reduced to one line each. If it still doesn't fit, the run is
89
+ skipped.
90
+ 3. Jev answers two graded questions per call: should the **call** stay, and
91
+ should the **result** stay verbatim. Questions are split into as many
92
+ requests as needed so state plus questions fits `JEV_MAX_REQUEST_TOKENS`, and
93
+ those requests run concurrently.
94
+ 4. `keepResult >= threshold` keeps both. Otherwise `keepCall >= threshold` keeps
95
+ the call and truncates the result to its first `JEV_TRUNCATE_HEAD`
96
+ characters. Otherwise the call and its result go.
97
+ 5. Decisions are cached per call for the life of the process and are monotonic:
98
+ once dropped, always dropped.
99
+
100
+ Nothing here throws. A missing key, a timeout, a malformed answer, or a history
101
+ too large to fit leaves the messages exactly as they were, so a Jev outage can
102
+ slow nothing down and break nothing.
103
+
104
+ ## Requirements
105
+
106
+ - opencode `>= 1.18.31`
107
+ - A TypeSafe API key with access to Jev
108
+
109
+ ## Not affiliated
110
+
111
+ Not built by, endorsed by, or affiliated with the opencode team or TypeSafe.
112
+ "opencode", "Jev", and "TypeSafe" are used only to describe what this plugs into.
113
+
114
+ ## License
115
+
116
+ MIT. The compaction strategy is adapted from
117
+ [fast-jev-compaction](https://github.com/tamaratran/fast-jev-compaction) (MIT) —
118
+ see [NOTICE](./NOTICE).
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "opencode-jev-compaction",
3
+ "version": "0.1.1",
4
+ "description": "opencode plugins that replace lossy compaction with Jev decisions: score every tool call and result, drop or truncate the stale ones, keep everything else verbatim.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "JLegends",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/JLegends/opencode-jev-compaction.git"
11
+ },
12
+ "homepage": "https://github.com/JLegends/opencode-jev-compaction#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/JLegends/opencode-jev-compaction/issues"
15
+ },
16
+ "exports": {
17
+ "./server": {
18
+ "import": "./src/server.ts"
19
+ },
20
+ "./tui": {
21
+ "import": "./src/tui.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "src",
26
+ "README.md",
27
+ "LICENSE",
28
+ "NOTICE"
29
+ ],
30
+ "keywords": [
31
+ "opencode",
32
+ "opencode-plugin",
33
+ "compaction",
34
+ "context",
35
+ "tokens",
36
+ "jev",
37
+ "typesafe",
38
+ "cost"
39
+ ],
40
+ "engines": {
41
+ "opencode": ">=1.18.31"
42
+ },
43
+ "peerDependencies": {
44
+ "@opentui/solid": ">=0.4.5",
45
+ "solid-js": "^1.9.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@opentui/solid": {
49
+ "optional": true
50
+ },
51
+ "solid-js": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "scripts": {
56
+ "check": "node --check src/tui.js && bun build src/server.ts --target node --outfile .check.js && rm -f .check.js"
57
+ }
58
+ }
package/src/server.ts ADDED
@@ -0,0 +1,617 @@
1
+ // jev-compaction — an opencode server plugin.
2
+ //
3
+ // Strategy adapted from https://github.com/tamaratran/fast-jev-compaction (MIT):
4
+ // never summarize on compaction. Instead ask a fast model, per tool call, whether the
5
+ // call and whether its full output still need to be in context. Drop the ones that
6
+ // don't, truncate the ones where only the fact of the call matters, and leave every
7
+ // user and assistant message verbatim. See NOTICE for the attribution.
8
+ //
9
+ // Adapted to opencode's model: a `tool` part carries both the call (state.input) and
10
+ // its result (state.output) together, so there is no orphaned-result case to guard
11
+ // against the way the original has to.
12
+ //
13
+ // Safety: this runs before every model request. It never throws — any failure leaves
14
+ // the messages exactly as they were.
15
+ //
16
+ // TYPESAFE_API_KEY API key (required unless the keychain is configured)
17
+ // JEV_KEYCHAIN_SERVICE macOS keychain service to read the key from
18
+ // JEV_KEYCHAIN_ACCOUNT macOS keychain account to read the key from
19
+ // JEV_COMPACTION=0 disable entirely
20
+ // JEV_COMPACTION_THRESHOLD estimated tokens before it engages (default 60000)
21
+ // JEV_KEEP_THRESHOLD minimum keep probability (default 0.35)
22
+ // JEV_PRESERVE_RECENT newest messages never touched (default 6, minimum 1)
23
+ // JEV_MAX_STATE_TOKENS ceiling for the state sent to Jev (default 25000)
24
+ // JEV_MAX_REQUEST_TOKENS ceiling for state plus questions (default 30000)
25
+ // JEV_TRUNCATE_HEAD chars of a dropped result retained (default 300)
26
+ // JEV_SMALL_RESULT_CHARS results this size or smaller are shown to Jev in full (default 600)
27
+ // JEV_TIMEOUT_MS per-request timeout (default 20000)
28
+ // JEV_DAILY_REQUEST_CAP hard ceiling on Jev requests per day (default 200)
29
+ // JEV_MODEL model name (default "jev-latest")
30
+ // JEV_BASE_URL endpoint (default the System One endpoint)
31
+ // JEV_DEBUG=1 append a trace to the debug log
32
+
33
+ import { spawnSync } from "node:child_process"
34
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
35
+ import { homedir } from "node:os"
36
+ import { join } from "node:path"
37
+
38
+ const ENDPOINT = process.env.JEV_BASE_URL ?? "https://api.typesafe.ai/v1/systemone"
39
+ const MODEL = process.env.JEV_MODEL ?? "jev-latest"
40
+
41
+ /** Parse a numeric setting, falling back rather than letting NaN disable a guard. */
42
+ function num(value: string | undefined, fallback: number, min = 0): number {
43
+ const parsed = Number(value)
44
+ return Number.isFinite(parsed) && parsed >= min ? parsed : fallback
45
+ }
46
+
47
+ const ENABLED = process.env.JEV_COMPACTION !== "0"
48
+ const THRESHOLD_TOKENS = num(process.env.JEV_COMPACTION_THRESHOLD, 60_000, 1)
49
+ const MAX_STATE_TOKENS = num(process.env.JEV_MAX_STATE_TOKENS, 25_000, 1)
50
+ const MAX_REQUEST_TOKENS = num(process.env.JEV_MAX_REQUEST_TOKENS, 30_000, 1)
51
+ const KEEP_THRESHOLD = num(process.env.JEV_KEEP_THRESHOLD, 0.35)
52
+ const PRESERVE_RECENT = Math.max(1, Math.floor(num(process.env.JEV_PRESERVE_RECENT, 6, 1)))
53
+ const TRUNCATE_HEAD = Math.floor(num(process.env.JEV_TRUNCATE_HEAD, 300))
54
+ const SMALL_RESULT_CHARS = Math.floor(num(process.env.JEV_SMALL_RESULT_CHARS, 600))
55
+ const TIMEOUT_MS = num(process.env.JEV_TIMEOUT_MS, 20_000, 1)
56
+ const DAILY_REQUEST_CAP = Math.floor(num(process.env.JEV_DAILY_REQUEST_CAP, 200))
57
+
58
+ const STATE_DIR = join(homedir(), ".local", "share", "opencode")
59
+ const STATS_FILE = join(STATE_DIR, "jev-compaction.json")
60
+ const CAP_FILE = join(STATE_DIR, "jev-compaction-usage.json")
61
+ const DEBUG_FILE = join(STATE_DIR, "jev-compaction.log")
62
+
63
+ const STATE_CONTEXT =
64
+ "A coding assistant conversation is being compacted to free context. `history` is the whole " +
65
+ "conversation so far, oldest first; tool outputs are replaced by a short `result` note and long " +
66
+ "texts may be abridged. Each question asks whether one tool call, or the full output of that " +
67
+ "call, still needs to stay in the history verbatim. Whatever is not kept is deleted permanently, " +
68
+ "but the assistant can always re-run a tool or re-read a file."
69
+
70
+ // Plugin modules are loaded once per server process, so this state persists across
71
+ // the many transform calls a single session makes. Decisions are monotonic per call:
72
+ // once dropped, always dropped.
73
+ const decided = new Map<string, Action>()
74
+ let cachedKey: string | undefined
75
+ let counted: { day: string; requests: number } | undefined
76
+
77
+ function trace(line: string, extra?: unknown) {
78
+ if (process.env.JEV_DEBUG !== "1") return
79
+ try {
80
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
81
+ appendFileSync(
82
+ DEBUG_FILE,
83
+ `${new Date().toISOString()} ${line}${extra === undefined ? "" : " " + JSON.stringify(extra)}\n`,
84
+ { mode: 0o600 },
85
+ )
86
+ } catch {}
87
+ }
88
+
89
+ // --- token estimate -----------------------------------------------------------
90
+ // A word costs ~1 token per 6 letters, a digit half a token, any other symbol 0.9.
91
+ // Lands slightly above the counts Jev reports, which is the safe direction.
92
+
93
+ const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g
94
+
95
+ function estimateTokens(text: string): number {
96
+ let tokens = 0
97
+ for (const [piece] of text.matchAll(TOKEN_PIECES)) {
98
+ const first = piece.charCodeAt(0)
99
+ if (first >= 48 && first <= 57) tokens += piece.length / 2
100
+ else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) tokens += 1 + Math.floor((piece.length - 1) / 6)
101
+ else tokens += 0.9
102
+ }
103
+ return Math.ceil(tokens)
104
+ }
105
+
106
+ // --- key ----------------------------------------------------------------------
107
+
108
+ function apiKey(): string {
109
+ if (cachedKey !== undefined) return cachedKey
110
+ const env = process.env.TYPESAFE_API_KEY
111
+ if (env && env.trim()) {
112
+ cachedKey = env.trim()
113
+ return cachedKey
114
+ }
115
+ const service = process.env.JEV_KEYCHAIN_SERVICE
116
+ const account = process.env.JEV_KEYCHAIN_ACCOUNT
117
+ if (service && account) {
118
+ // Array args, no shell: env-derived values cannot be interpolated into a command.
119
+ // Timeout so a locked keychain cannot block the pre-request path indefinitely.
120
+ const result = spawnSync(
121
+ "security",
122
+ ["find-generic-password", "-s", service, "-a", account, "-w"],
123
+ { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] },
124
+ )
125
+ cachedKey = result.status === 0 ? (result.stdout ?? "").trim() : ""
126
+ trace("key resolved", { source: "keychain", found: cachedKey.length > 0 })
127
+ return cachedKey
128
+ }
129
+ cachedKey = ""
130
+ return cachedKey
131
+ }
132
+
133
+ // --- spend ceiling -------------------------------------------------------------
134
+
135
+ function today(): string {
136
+ return new Date().toISOString().slice(0, 10)
137
+ }
138
+
139
+ function readUsage(): { day: string; requests: number } {
140
+ try {
141
+ const raw = JSON.parse(readFileSync(CAP_FILE, "utf8"))
142
+ if (raw && raw.day === today()) return { day: raw.day, requests: Number(raw.requests) || 0 }
143
+ } catch {}
144
+ return { day: today(), requests: 0 }
145
+ }
146
+
147
+ /** Process-local counter so concurrent batches cannot lose increments. */
148
+ function dayUsage(): { day: string; requests: number } {
149
+ if (!counted || counted.day !== today()) counted = readUsage()
150
+ return counted
151
+ }
152
+
153
+ function writeUsage(current: { day: string; requests: number }) {
154
+ try {
155
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
156
+ writeFileSync(CAP_FILE, JSON.stringify(current, null, 2), { mode: 0o600 })
157
+ } catch {}
158
+ }
159
+
160
+ // --- asking -------------------------------------------------------------------
161
+
162
+ type Question = { type: "noul"; instructions: string }
163
+ type Answers = Record<string, { noul?: unknown }>
164
+
165
+ async function ask(state: object, questions: Record<string, Question>): Promise<Answers> {
166
+ const key = apiKey()
167
+ if (!key) throw new Error("no Jev key configured (TYPESAFE_API_KEY or JEV_KEYCHAIN_SERVICE/JEV_KEYCHAIN_ACCOUNT)")
168
+
169
+ const controller = new AbortController()
170
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
171
+ try {
172
+ const response = await fetch(ENDPOINT, {
173
+ method: "POST",
174
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
175
+ body: JSON.stringify({ model: MODEL, state, questions }),
176
+ signal: controller.signal,
177
+ })
178
+ if (!response.ok) throw new Error(`jev request failed (${response.status})`)
179
+ const parsed = JSON.parse(await response.text())
180
+ if (!parsed || typeof parsed !== "object" || !("answers" in parsed) || !parsed.answers) {
181
+ throw new Error("jev response missing answers")
182
+ }
183
+ trace("jev usage", {
184
+ input: parsed.usage?.input_tokens,
185
+ output: parsed.usage?.output_tokens,
186
+ answers: Object.keys(parsed.answers ?? {}).length,
187
+ })
188
+ return parsed.answers as Answers
189
+ } finally {
190
+ clearTimeout(timer)
191
+ }
192
+ }
193
+
194
+ function noul(answers: Answers, name: string): number {
195
+ const value = answers?.[name]?.noul
196
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`invalid jev answer for ${name}`)
197
+ return value
198
+ }
199
+
200
+ // --- opencode part handling ---------------------------------------------------
201
+
202
+ type Part = { id?: string; type?: string; tool?: string; callID?: string; state?: any; text?: string; [key: string]: any }
203
+ type Message = { info?: any; parts?: Part[]; [key: string]: any }
204
+ type Call = {
205
+ id: string
206
+ callID: string
207
+ tool: string
208
+ input: Record<string, unknown>
209
+ output: string
210
+ isError: boolean
211
+ messageIndex: number
212
+ /** The part itself, held by reference: dropping one part must not shift the others. */
213
+ part: Part
214
+ pinned: boolean
215
+ }
216
+
217
+ function isFinishedToolPart(part: Part): boolean {
218
+ if (part?.type !== "tool") return false
219
+ return part.state?.status === "completed" || part.state?.status === "error"
220
+ }
221
+
222
+ function outputOf(part: Part): { text: string; isError: boolean } {
223
+ if (part.state?.status === "completed") return { text: String(part.state.output ?? ""), isError: false }
224
+ return { text: String(part.state?.error ?? ""), isError: true }
225
+ }
226
+
227
+ function textOf(message: Message): string {
228
+ return (message.parts ?? [])
229
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
230
+ .map((part) => part.text as string)
231
+ .join("\n")
232
+ .trim()
233
+ }
234
+
235
+ function isPinned(index: number, total: number): boolean {
236
+ return index === 0 || index >= total - PRESERVE_RECENT
237
+ }
238
+
239
+ function collectCalls(messages: Message[]): Call[] {
240
+ const calls: Call[] = []
241
+ messages.forEach((message, messageIndex) => {
242
+ for (const part of message.parts ?? []) {
243
+ if (!isFinishedToolPart(part)) continue
244
+ const { text, isError } = outputOf(part)
245
+ calls.push({
246
+ id: `t${calls.length + 1}`,
247
+ callID: String(part.callID ?? part.id ?? `p${calls.length + 1}`),
248
+ tool: String(part.tool ?? "tool"),
249
+ input: (part.state?.input as Record<string, unknown>) ?? {},
250
+ output: text,
251
+ isError,
252
+ messageIndex,
253
+ part,
254
+ pinned: isPinned(messageIndex, messages.length),
255
+ })
256
+ }
257
+ })
258
+ return calls
259
+ }
260
+
261
+ // --- state fitting ------------------------------------------------------------
262
+
263
+ const INPUT_CHARS = [1000, 200, 60] as const
264
+ const TEXT_HEAD = 400
265
+ const TEXT_TAIL = 150
266
+
267
+ function truncate(text: string, limit: number): string {
268
+ return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`
269
+ }
270
+
271
+ function abridge(text: string, head: number, tail: number): string {
272
+ if (text.length <= head + tail + 40) return text
273
+ return `${text.slice(0, head)}\n[… ${text.length - head - tail} chars omitted …]\n${text.slice(-tail)}`
274
+ }
275
+
276
+ function inputText(input: Record<string, unknown>, limit: number): string {
277
+ try {
278
+ return truncate(JSON.stringify(input), limit)
279
+ } catch {
280
+ return "[unserializable input]"
281
+ }
282
+ }
283
+
284
+ function resultNote(call: Call): string {
285
+ // Small results are sent in full. Replacing every result with a note hides the
286
+ // evidence Jev needs: it cannot tell a throwaway file listing from a short file
287
+ // of hard constraints, so it reasonably guesses "cheap to re-read" and drops
288
+ // both. Showing what a small result actually says is what lets it tell them apart.
289
+ if (call.output.length <= SMALL_RESULT_CHARS) return call.output
290
+ return `${call.isError ? "error" : "ok"}, ${call.output.length} chars (omitted)`
291
+ }
292
+
293
+ function compactCall(call: Call): string {
294
+ const input = Object.entries(call.input)
295
+ .map(([key, value]) => {
296
+ const text = typeof value === "string" ? value : inputText({ [key]: value }, 200)
297
+ return `${key}=${text.replace(/\s+/g, " ")}`
298
+ })
299
+ .join(" ")
300
+ return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${call.isError ? "error" : "ok"} ${call.output.length}ch`
301
+ }
302
+
303
+ type Entry = { i: number; role: string; text: string; tool_calls?: Array<Record<string, string>> | string[] }
304
+
305
+ function buildHistory(messages: Message[], calls: Call[], inputChars: number): Entry[] {
306
+ const byMessage = new Map<number, Call[]>()
307
+ for (const call of calls) {
308
+ const list = byMessage.get(call.messageIndex) ?? []
309
+ list.push(call)
310
+ byMessage.set(call.messageIndex, list)
311
+ }
312
+ const entries: Entry[] = []
313
+ messages.forEach((message, index) => {
314
+ const toolCalls = (byMessage.get(index) ?? []).map((call) => ({
315
+ id: call.id,
316
+ tool: call.tool,
317
+ input: inputText(call.input, inputChars),
318
+ result: resultNote(call),
319
+ }))
320
+ const text = textOf(message)
321
+ if (text.length === 0 && toolCalls.length === 0) return
322
+ const entry: Entry = { i: index, role: String(message.info?.role ?? "user"), text }
323
+ if (toolCalls.length > 0) entry.tool_calls = toolCalls
324
+ entries.push(entry)
325
+ })
326
+ return entries
327
+ }
328
+
329
+ function goalFrom(messages: Message[]): string {
330
+ return messages
331
+ .filter((message) => message.info?.role === "user" && textOf(message).length > 0)
332
+ .slice(-3)
333
+ .map((message) => truncate(textOf(message), 500))
334
+ .join("\n")
335
+ }
336
+
337
+ function fitState(messages: Message[], calls: Call[]): { state: object; tokens: number; stage: string } {
338
+ const goal = goalFrom(messages)
339
+ const stateOf = (history: Entry[]) => ({ context: STATE_CONTEXT, goal, history })
340
+ const tokensOf = (history: Entry[]) =>
341
+ estimateTokens(JSON.stringify(stateOf([]))) +
342
+ history.reduce((sum, entry) => sum + estimateTokens(JSON.stringify(entry)) + 1, 0)
343
+
344
+ for (const limit of INPUT_CHARS) {
345
+ const history = buildHistory(messages, calls, limit)
346
+ const tokens = tokensOf(history)
347
+ if (tokens <= MAX_STATE_TOKENS) return { state: stateOf(history), tokens, stage: `inputs<=${limit}` }
348
+ }
349
+
350
+ const history = buildHistory(messages, calls, INPUT_CHARS[2])
351
+ let tokens = tokensOf(history)
352
+ const pinnedAt = (entry: Entry) => isPinned(entry.i, messages.length)
353
+ const order = [
354
+ ...history.map((_, i) => i).filter((i) => !pinnedAt(history[i]!)),
355
+ ...history.map((_, i) => i).filter((i) => pinnedAt(history[i]!)),
356
+ ]
357
+
358
+ for (const index of order) {
359
+ const entry = history[index]
360
+ if (!entry || entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue
361
+ entry.text = abridge(entry.text, TEXT_HEAD, TEXT_TAIL)
362
+ tokens = tokensOf(history)
363
+ if (tokens <= MAX_STATE_TOKENS) return { state: stateOf(history), tokens, stage: "texts abridged" }
364
+ }
365
+
366
+ for (const index of order) {
367
+ const entry = history[index]
368
+ if (!entry || pinnedAt(entry) || entry.text.length === 0) continue
369
+ const original = textOf(messages[entry.i] ?? {}).length || entry.text.length
370
+ entry.text = `[… ${original} chars omitted …]`
371
+ tokens = tokensOf(history)
372
+ if (tokens <= MAX_STATE_TOKENS) return { state: stateOf(history), tokens, stage: "old messages collapsed" }
373
+ }
374
+
375
+ const byMessage = new Map<number, Call[]>()
376
+ for (const call of calls) {
377
+ const list = byMessage.get(call.messageIndex) ?? []
378
+ list.push(call)
379
+ byMessage.set(call.messageIndex, list)
380
+ }
381
+ for (const index of order) {
382
+ const entry = history[index]
383
+ const own = entry ? byMessage.get(entry.i) : undefined
384
+ if (!entry || pinnedAt(entry) || !own) continue
385
+ entry.tool_calls = own.map(compactCall)
386
+ tokens = tokensOf(history)
387
+ if (tokens <= MAX_STATE_TOKENS) return { state: stateOf(history), tokens, stage: "old calls compacted" }
388
+ }
389
+
390
+ return { state: stateOf(history), tokens, stage: "overflow" }
391
+ }
392
+
393
+ // --- decisions -----------------------------------------------------------------
394
+
395
+ type Action = "keep" | "drop_result" | "drop_call"
396
+
397
+ function questionsFor(call: Call): Record<string, Question> {
398
+ return {
399
+ [`call_${call.id}`]: {
400
+ type: "noul",
401
+ instructions: `Tool call ${call.id} (${call.tool}) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next`,
402
+ },
403
+ [`result_${call.id}`]: {
404
+ type: "noul",
405
+ instructions: `The full output of tool call ${call.id} (${call.tool}, ${call.output.length} chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do`,
406
+ },
407
+ }
408
+ }
409
+
410
+ const REQUEST_OVERHEAD_TOKENS = 20
411
+
412
+ function batch(calls: Call[], stateTokens: number): Call[][] {
413
+ const budget = MAX_REQUEST_TOKENS - stateTokens - REQUEST_OVERHEAD_TOKENS
414
+ const batches: Call[][] = []
415
+ let current: Call[] = []
416
+ let currentTokens = 0
417
+ for (const call of calls) {
418
+ const tokens = estimateTokens(JSON.stringify(questionsFor(call)))
419
+ if (current.length > 0 && currentTokens + tokens > budget) {
420
+ batches.push(current)
421
+ current = []
422
+ currentTokens = 0
423
+ }
424
+ if (current.length === 0 && tokens > budget) throw new Error(`state leaves no room for questions (~${stateTokens} tokens)`)
425
+ current.push(call)
426
+ currentTokens += tokens
427
+ }
428
+ if (current.length > 0) batches.push(current)
429
+ return batches
430
+ }
431
+
432
+ function decide(call: Call, keepCall: number, keepResult: number): Action {
433
+ if (call.pinned) return "keep"
434
+ if (keepResult >= KEEP_THRESHOLD) return "keep"
435
+ if (keepCall >= KEEP_THRESHOLD) return "drop_result"
436
+ return "drop_call"
437
+ }
438
+
439
+ function truncatedOutput(call: Call): string {
440
+ if (call.output.length <= TRUNCATE_HEAD + 120) return call.output
441
+ const head = TRUNCATE_HEAD > 0 ? `${call.output.slice(0, TRUNCATE_HEAD)}\n` : ""
442
+ return `${head}[jev-compaction truncated ${call.output.length - TRUNCATE_HEAD} chars of this tool result${call.isError ? " (error)" : ""}; re-run the tool if needed]`
443
+ }
444
+
445
+ // --- stats ---------------------------------------------------------------------
446
+
447
+ function writeStats(delta: {
448
+ savedChars: number
449
+ calls: number
450
+ dropped: number
451
+ truncated: number
452
+ requests: number
453
+ ms: number
454
+ stage: string
455
+ }) {
456
+ try {
457
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
458
+ let previous: any = {}
459
+ try {
460
+ previous = JSON.parse(readFileSync(STATS_FILE, "utf8"))
461
+ } catch {}
462
+ const next = {
463
+ updated: new Date().toISOString(),
464
+ runs: (Number(previous.runs) || 0) + 1,
465
+ tokensSaved: (Number(previous.tokensSaved) || 0) + Math.round(delta.savedChars / 4),
466
+ callsSeen: (Number(previous.callsSeen) || 0) + delta.calls,
467
+ dropped: (Number(previous.dropped) || 0) + delta.dropped,
468
+ truncated: (Number(previous.truncated) || 0) + delta.truncated,
469
+ last: delta,
470
+ }
471
+ writeFileSync(STATS_FILE, JSON.stringify(next, null, 2), { mode: 0o600 })
472
+ } catch {}
473
+ }
474
+
475
+ // --- the pruner ----------------------------------------------------------------
476
+
477
+ async function prune(messages: Message[], reason: string): Promise<void> {
478
+ if (!ENABLED) return
479
+ try {
480
+ if (!Array.isArray(messages) || messages.length === 0) return
481
+
482
+ const calls = collectCalls(messages)
483
+ if (calls.length === 0) return
484
+
485
+ const estimated = estimateTokens(JSON.stringify(messages))
486
+ if (estimated < THRESHOLD_TOKENS) {
487
+ trace("below threshold", { estimated, threshold: THRESHOLD_TOKENS })
488
+ return
489
+ }
490
+
491
+ const allowed = Math.max(0, DAILY_REQUEST_CAP - dayUsage().requests)
492
+ if (allowed === 0) {
493
+ trace("daily cap reached, skipping", { used: dayUsage().requests, cap: DAILY_REQUEST_CAP })
494
+ return
495
+ }
496
+ if (!apiKey()) {
497
+ trace("no key, skipping")
498
+ return
499
+ }
500
+
501
+ const started = Date.now()
502
+ const candidates = calls.filter((call) => !call.pinned && !decided.has(call.callID))
503
+ const totalBefore = messages.reduce((sum, message) => sum + JSON.stringify(message).length, 0)
504
+
505
+ let requests = 0
506
+ let stage = "cache"
507
+ if (candidates.length > 0) {
508
+ const fitted = fitState(messages, calls)
509
+ stage = fitted.stage
510
+ if (fitted.stage === "overflow") {
511
+ trace("state overflow, skipping", { tokens: fitted.tokens })
512
+ return
513
+ }
514
+ // Reserve against the cap before firing: every request is already in flight by
515
+ // the time the first answer returns, so checking only the total afterwards
516
+ // would let a single run overshoot the ceiling.
517
+ const send = batch(candidates, fitted.tokens).slice(0, allowed)
518
+ if (send.length === 0) {
519
+ trace("no request budget left for a batch", { stateTokens: fitted.tokens })
520
+ return
521
+ }
522
+ dayUsage().requests += send.length
523
+ writeUsage(dayUsage())
524
+
525
+ const answered = await Promise.all(
526
+ send.map(async (group) => {
527
+ const questions = Object.assign({}, ...group.map(questionsFor))
528
+ const answers = await ask(fitted.state, questions)
529
+ requests += 1
530
+ return group.map((call) => ({
531
+ call,
532
+ keepCall: noul(answers, `call_${call.id}`),
533
+ keepResult: noul(answers, `result_${call.id}`),
534
+ }))
535
+ }),
536
+ )
537
+ if (decided.size > 5000) decided.clear()
538
+ for (const group of answered) {
539
+ for (const item of group) {
540
+ const action = decide(item.call, item.keepCall, item.keepResult)
541
+ trace("decision", {
542
+ id: item.call.id,
543
+ tool: item.call.tool,
544
+ keepCall: item.keepCall,
545
+ keepResult: item.keepResult,
546
+ action,
547
+ })
548
+ decided.set(item.call.callID, action)
549
+ }
550
+ }
551
+ }
552
+
553
+ // Apply by part reference. Parts are held directly, so removing one cannot shift
554
+ // the position of another in the same message.
555
+ const drop = new Set<Part>()
556
+ let dropped = 0
557
+ let truncated = 0
558
+ for (const call of calls) {
559
+ const action = decided.get(call.callID)
560
+ if (!action || action === "keep" || call.pinned) continue
561
+ if (action === "drop_call") {
562
+ drop.add(call.part)
563
+ dropped += 1
564
+ continue
565
+ }
566
+ const next = truncatedOutput(call)
567
+ if (next === call.output) continue
568
+ if (call.part.state?.status === "completed") call.part.state.output = next
569
+ else if (call.part.state?.status === "error") call.part.state.error = next
570
+ truncated += 1
571
+ }
572
+
573
+ if (drop.size > 0) {
574
+ for (const message of messages) {
575
+ if (!message.parts || !message.parts.some((part) => drop.has(part))) continue
576
+ message.parts = message.parts.filter((part) => !drop.has(part))
577
+ }
578
+ }
579
+
580
+ const kept = messages.filter((message) => (message.parts ?? []).length > 0)
581
+ messages.length = 0
582
+ messages.push(...kept)
583
+
584
+ const totalAfter = messages.reduce((sum, message) => sum + JSON.stringify(message).length, 0)
585
+ writeStats({
586
+ savedChars: Math.max(0, totalBefore - totalAfter),
587
+ calls: calls.length,
588
+ dropped,
589
+ truncated,
590
+ requests,
591
+ ms: Date.now() - started,
592
+ stage,
593
+ })
594
+ trace("pruned", { reason, estimated, stage, requests, dropped, truncated, savedChars: totalBefore - totalAfter })
595
+ } catch (error) {
596
+ trace("prune failed", { error: String((error as Error)?.message ?? error) })
597
+ }
598
+ }
599
+
600
+ // --- plugin --------------------------------------------------------------------
601
+
602
+ async function server() {
603
+ return {
604
+ "experimental.chat.messages.transform": async (_input: unknown, output: { messages: Message[] }) => {
605
+ await prune(output.messages, "step")
606
+ },
607
+
608
+ "experimental.session.compacting": async (_input: unknown, output: { context: string[]; prompt?: string }) => {
609
+ output.context.push(
610
+ "Tool results marked `[jev-compaction truncated …]` were shortened deliberately: the call is still " +
611
+ "historically accurate but the body was dropped as no longer needed. Do not treat them as tool failures.",
612
+ )
613
+ },
614
+ }
615
+ }
616
+
617
+ export default { id: "jev-compaction", server }
package/src/tui.js ADDED
@@ -0,0 +1,101 @@
1
+ // jev-savings — TUI sidebar widget that reports what the jev-compaction server
2
+ // plugin has removed from context. Reads the stats file the server plugin writes.
3
+ //
4
+ // Plain JS with getters on purpose. opencode loads published plugins from inside
5
+ // node_modules, and its bundled Bun runtime does not apply the JSX transform to
6
+ // files there, so a shipped .tsx never executes. Getters are what a Solid-aware
7
+ // JSX compiler would emit for reactive props, so this is the compiled form.
8
+
9
+ import { createSignal, Show } from "solid-js"
10
+ import { jsx, jsxs } from "@opentui/solid/jsx-runtime"
11
+ import { readFileSync } from "node:fs"
12
+ import { homedir } from "node:os"
13
+ import { join } from "node:path"
14
+
15
+ const id = "jev-savings"
16
+
17
+ const POLL_MS = 3000
18
+
19
+ const STATS_FILE = join(homedir(), ".local", "share", "opencode", "jev-compaction.json")
20
+
21
+ const empty = () => ({ runs: 0, tokensSaved: 0, callsSeen: 0, dropped: 0, truncated: 0 })
22
+
23
+ function read() {
24
+ try {
25
+ const raw = JSON.parse(readFileSync(STATS_FILE, "utf8"))
26
+ return {
27
+ runs: Number(raw.runs) || 0,
28
+ tokensSaved: Number(raw.tokensSaved) || 0,
29
+ callsSeen: Number(raw.callsSeen) || 0,
30
+ dropped: Number(raw.dropped) || 0,
31
+ truncated: Number(raw.truncated) || 0,
32
+ }
33
+ } catch {
34
+ return empty()
35
+ }
36
+ }
37
+
38
+ function compact(n) {
39
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
40
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
41
+ return String(n)
42
+ }
43
+
44
+ const tui = async (api) => {
45
+ const [stats, setStats] = createSignal(empty())
46
+
47
+ setStats(read())
48
+ const timer = setInterval(() => setStats(read()), POLL_MS)
49
+ api.lifecycle.onDispose(() => clearInterval(timer))
50
+
51
+ api.slots.register({
52
+ order: 91,
53
+ slots: {
54
+ sidebar_content() {
55
+ return jsxs(Show, {
56
+ get when() {
57
+ return stats().runs > 0 && stats().tokensSaved > 0
58
+ },
59
+ get children() {
60
+ return jsxs("box", {
61
+ children: [
62
+ jsx("text", {
63
+ get fg() {
64
+ return api.theme.current.text
65
+ },
66
+ get children() {
67
+ return jsx("b", {
68
+ get children() {
69
+ return "Jev savings"
70
+ },
71
+ })
72
+ },
73
+ }),
74
+ jsx("text", {
75
+ get fg() {
76
+ return api.theme.current.textMuted
77
+ },
78
+ get children() {
79
+ return `~${compact(stats().tokensSaved)} tokens saved`
80
+ },
81
+ }),
82
+ jsx("text", {
83
+ get fg() {
84
+ return api.theme.current.textMuted
85
+ },
86
+ get children() {
87
+ const current = stats()
88
+ const plural = current.runs === 1 ? "run" : "runs"
89
+ return `${current.dropped} dropped, ${current.truncated} truncated · ${current.runs} ${plural}`
90
+ },
91
+ }),
92
+ ],
93
+ })
94
+ },
95
+ })
96
+ },
97
+ },
98
+ })
99
+ }
100
+
101
+ export default { id, tui }