@jhanink/opencode-context-compaction 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jhanink
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/README.md CHANGED
@@ -1,3 +1,179 @@
1
- # Temporary Holding Version
1
+ # opencode-context-compaction
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Compact an OpenCode session on a **token budget**, at an idle moment, instead of
4
+ waiting for the model's context ceiling.
5
+
6
+ ## Why
7
+
8
+ OpenCode compacts automatically near the window limit (`limit − compaction.buffer`).
9
+ On a 1M-token model that is roughly 900k, so a session can sit at 150–250k all
10
+ afternoon and never compact: every request keeps re-reading a large transcript,
11
+ and the conversation keeps drifting away from what it actually needed.
12
+
13
+ The obvious fix — "compact at 40% of the window" — does not work for the same
14
+ reason: 40% of 1M is 400k, which real sessions never reach. A budget in absolute
15
+ tokens is the rule that actually bounds context, so that is what this uses.
16
+
17
+ ## What it does
18
+
19
+ - Watches the context assembled for every model call and estimates its size.
20
+ - When a session goes quiet for `idleMs` **and** its context is over `maxTokens`,
21
+ it requests a normal OpenCode compaction (`delivery: "queue"`, so a prompt you
22
+ send in the meantime is not stuck behind the summary).
23
+ - Adds no model calls of its own. The compaction it requests costs one summary
24
+ call plus a prompt-cache miss on the next request, because the conversation is
25
+ rewritten — the same cost the built-in trigger pays, just at a moment you chose.
26
+ - Leaves subagent sessions alone, and will not compact the same session twice
27
+ inside `cooldownMs` (the latch is durable, so a reload does not double-fire).
28
+
29
+ ## Terminal integration
30
+
31
+ The server plugin and the terminal plugin are separate halves that talk over
32
+ [RPC](https://opencode.ai/v2/docs/build/plugins/rpc), so the numbers on screen are
33
+ the numbers the trigger uses.
34
+
35
+ - **Status line** under the composer: `ctx 44k/150k 29%`, muted while comfortable,
36
+ attention-coloured once the session is past budget (`ctx 160k/150k 100% · compacts at idle`).
37
+ - **Toasts** when a compaction is requested, and again when the session's history
38
+ actually shrinks.
39
+ - **`/compact-status`** (also in the command palette) for budget, current usage,
40
+ idle delay, cooldown, and whether a dry run is on.
41
+ - **`/compact-now`** to compact immediately.
42
+
43
+ ## Install
44
+
45
+ Local plugin directories are picked up automatically, and a directory plugin's
46
+ `tui.ts` is loaded by the terminal client:
47
+
48
+ ```sh
49
+ git clone https://github.com/jhanink/opencode-context-compaction
50
+ cp -R opencode-context-compaction/src ~/.config/opencode/plugins/context-compaction
51
+ ```
52
+
53
+ Restart the terminal UI if the status line does not appear; the server half
54
+ reloads on its own.
55
+
56
+ Or as a package:
57
+
58
+ ```sh
59
+ opencode plugin add @jhanink/opencode-context-compaction
60
+ ```
61
+
62
+ ## Options
63
+
64
+ Set options by loading the plugin from `opencode.json(c)` — and **do not** also
65
+ leave a copy in the auto-discovered plugins directory, or two instances will each
66
+ request compaction:
67
+
68
+ ```jsonc
69
+ {
70
+ "plugins": [
71
+ {
72
+ "package": "@jhanink/opencode-context-compaction",
73
+ "options": {
74
+ "maxTokens": 200000,
75
+ "idleMs": 60000
76
+ }
77
+ }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ Those two are a better starting point than the defaults on a model with a
83
+ 1M-token window such as DeepSeek V4. Its built-in trigger sits out around 900k and
84
+ cache reads are cheap, so there is little reason to compact early; the longer idle
85
+ delay also keeps a short pause mid-task from reading as the end of one. Only the
86
+ keys you set are overridden:
87
+
88
+ | Option | Default | Meaning |
89
+ | --- | --- | --- |
90
+ | `enabled` | `true` | Turn the trigger off without removing the plugin. |
91
+ | `maxTokens` | `150000` | Compact at or above this estimated context size. |
92
+ | `charsPerToken` | `4` | Estimator for a text-heavy transcript; higher means later compaction. |
93
+ | `idleMs` | `45000` | How long a session must be quiet before acting. |
94
+ | `cooldownMs` | `600000` | Minimum gap between compactions of one session. |
95
+ | `dryRun` | `false` | Log and notify instead of compacting. |
96
+ | `history` | `false` | Record decisions and compactions in plugin storage. |
97
+
98
+ To watch decisions for a day before letting it rewrite anything, add
99
+ `"dryRun": true` — the toasts then tell you what it *would* have done.
100
+
101
+ ### Sizing `maxTokens`
102
+
103
+ Set it where a session stops being useful to you, not where the provider starts
104
+ refusing it. Per-request cost is dominated by cache reads, which are cheap, so the
105
+ real reasons to compact are transcript drift and the ceiling itself.
106
+
107
+ ## History
108
+
109
+ Off by default. Records are written to plugin storage — which is OpenCode's
110
+ database — so keeping them is a choice rather than a side effect. Set
111
+ `"history": true` to record what the trigger decided, and every compaction that
112
+ actually happened, including the ones OpenCode starts on its own.
113
+
114
+ Both record types carry sizes and counts, never message content:
115
+
116
+ | Record | Written when | Carries |
117
+ | --- | --- | --- |
118
+ | `decision` | the trigger evaluates a session | action, reason, estimated tokens, budget, dry run, gap since the last compaction |
119
+ | `compaction` | a compaction begins | origin, estimated tokens, characters, message count, model |
120
+
121
+ `origin` is the field worth having. OpenCode records *every* compaction in its
122
+ own database as `reason: manual` — whether you asked for it, the built-in trigger
123
+ did, or this plugin did — so without this the three are indistinguishable, and
124
+ there is no way to tell whether a budget fires when it should.
125
+
126
+ The records stay small because they do not duplicate what OpenCode already
127
+ stores: the per-request token counts you would want alongside them are already in
128
+ `opencode.db`. The newest 2,000 are kept, under the `history/` key prefix.
129
+
130
+ To read them:
131
+
132
+ ```sh
133
+ sqlite3 ~/.local/share/opencode/opencode.db \
134
+ "select key, value from kv where key like 'plugin:%history/%' order by key;"
135
+ ```
136
+
137
+ ## How it works
138
+
139
+ ```
140
+ session.hook("context") → estimate tokens → debounce (idleMs)
141
+ → decide (budget, cooldown, subagent)
142
+ → ctx.session.compact({ delivery: "queue" })
143
+ → RPC event → toast
144
+ session.hook("compaction") → attribute (rule | command | external) → history
145
+ ```
146
+
147
+ Estimated size is the serialized system parts and messages divided by
148
+ `charsPerToken`. JSON overhead makes it skew slightly high, which is the safe
149
+ direction for a budget: compact a little early rather than too late. Exact
150
+ tokenization would cost more than the decision is worth.
151
+
152
+ ## Development
153
+
154
+ ```sh
155
+ node --test test/*.test.ts # pure decision logic, no OpenCode needed
156
+ ```
157
+
158
+ `src/lib.ts` holds the rules and is free of OpenCode and Solid imports so it can
159
+ be tested on its own; `src/index.ts` and `src/tui.ts` are thin adapters.
160
+
161
+ OpenCode supplies `@opencode/plugin`, `@opentui/*` and `solid-js` at runtime, so
162
+ they are declared as **optional** peer dependencies and installing this package
163
+ fetches nothing else. (0.1.0 listed `@opencode/plugin` as a normal dependency,
164
+ which pulled 424 packages into every install, including a deprecated `glob`
165
+ with known advisories.)
166
+
167
+ ## Prior art
168
+
169
+ Closest neighbours, both worth reading before adopting this:
170
+
171
+ - [`@cortexkit/opencode-magic-context`](https://www.npmjs.com/package/@cortexkit/opencode-magic-context) (MIT) — background compression of old conversation, cross-session memory, cache-aware deferred operations. Solves the same problem with a parallel summarizer.
172
+ - [`@tarquinen/opencode-dcp`](https://www.npmjs.com/package/@tarquinen/opencode-dcp) (AGPL-3.0) — prunes stale tool output from context instead of compacting.
173
+
174
+ This plugin deliberately does neither: it drives OpenCode's *native* compaction,
175
+ adds no dependencies, and makes no model calls of its own.
176
+
177
+ ## License
178
+
179
+ MIT. See [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@jhanink/opencode-context-compaction",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "type": "module",
5
+ "description": "Compact an OpenCode session on a token budget at idle, with a TUI status line and toasts.",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./tui": "./src/tui.ts",
10
+ "./rpc": "./src/rpc.ts"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "compaction",
21
+ "context",
22
+ "tokens"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test test/*.test.ts"
26
+ },
27
+ "peerDependencies": {
28
+ "@opencode/plugin": ">=2",
29
+ "@opentui/core": "*",
30
+ "@opentui/solid": "*",
31
+ "solid-js": ">=1.9"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@opencode/plugin": {
35
+ "optional": true
36
+ },
37
+ "@opentui/core": {
38
+ "optional": true
39
+ },
40
+ "@opentui/solid": {
41
+ "optional": true
42
+ },
43
+ "solid-js": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/jhanink/opencode-context-compaction.git"
53
+ },
54
+ "author": "jhanink"
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * context-compaction — server side.
3
+ *
4
+ * OpenCode's built-in trigger fires near the window ceiling (limit minus
5
+ * `compaction.buffer`), which on a 1M-token model means roughly 900k. A long
6
+ * session can sit at 150-250k all afternoon and never compact. This plugin
7
+ * watches the context assembled for each model call and, once a session has
8
+ * gone quiet and its context passes a budget, asks OpenCode to compact — the
9
+ * same compaction the built-in trigger uses, requested at an idle moment
10
+ * instead of mid-task at the ceiling.
11
+ *
12
+ * Cost shape: no model calls of its own. The compaction it requests costs one
13
+ * summary call plus a prompt-cache miss on the next request, because the
14
+ * conversation is rewritten. That cost exists because a compaction happened;
15
+ * the plugin decides when, not whether.
16
+ *
17
+ * The terminal side lives in ./tui.ts and reads its numbers over RPC, so what
18
+ * the TUI shows is what the trigger uses.
19
+ */
20
+ import { Plugin } from "@opencode/plugin"
21
+ import {
22
+ HISTORY_LIMIT,
23
+ HISTORY_PREFIX,
24
+ compactionEntry,
25
+ decide,
26
+ decisionEntry,
27
+ estimateTokens,
28
+ formatTokens,
29
+ historyKey,
30
+ resolveOptions,
31
+ takeOrigin,
32
+ type HistoryEntry,
33
+ } from "./lib.ts"
34
+ import { ContextCompaction } from "./rpc.ts"
35
+
36
+ type Seen = { estimated: number; compactedAt?: number }
37
+
38
+ export default Plugin.define({
39
+ id: "context-compaction",
40
+
41
+ async setup(ctx: any) {
42
+ const opts = resolveOptions(ctx.options)
43
+ const log = (...args: unknown[]) => console.log("[context-compaction]", ...args)
44
+
45
+ const pending = new Map<string, any>() // sessionID -> debounce timer
46
+ const seen = new Map<string, Seen>() // sessionID -> last observation
47
+
48
+ // Compactions this plugin has asked for that have not landed yet. OpenCode
49
+ // admits the request and compacts at the next step boundary, so the request
50
+ // and the compaction are separated in time; this marker is what lets the
51
+ // history say whether a compaction was ours or OpenCode's own.
52
+ const requested = new Map<string, { at: number; origin: "rule" | "command" }>()
53
+
54
+ // Optional history. Deliberately low volume — one record per decision, one
55
+ // per compaction — because the per-request token counts an analysis of the
56
+ // budget actually needs are already in OpenCode's own database, and are not
57
+ // worth duplicating. See the "History" section of the README.
58
+ let seq = 0
59
+ let writes = 0
60
+ const PRUNE_EVERY = 100
61
+
62
+ const prune = async (): Promise<void> => {
63
+ // Keys come back oldest-first, so anything over the cap is at the front.
64
+ const keys: string[] = []
65
+ let after: string | undefined
66
+ do {
67
+ const page: any = await ctx.storage.scan({ prefix: HISTORY_PREFIX, after, limit: 500 })
68
+ for (const found of page?.entries ?? []) keys.push(found.key)
69
+ after = page?.next
70
+ } while (after)
71
+ for (let index = 0; index < keys.length - HISTORY_LIMIT; index += 1) {
72
+ await ctx.storage.remove(keys[index])
73
+ }
74
+ }
75
+
76
+ // Fire and forget: a compaction must never wait on our bookkeeping.
77
+ const write = (entry: HistoryEntry): void => {
78
+ if (!opts.history) return
79
+ const key = historyKey(entry.at, seq)
80
+ seq += 1
81
+ void (async () => {
82
+ try {
83
+ await ctx.storage.set(key, entry)
84
+ if (writes++ % PRUNE_EVERY === 0) await prune()
85
+ } catch (error) {
86
+ log("history write failed:", error)
87
+ }
88
+ })()
89
+ }
90
+
91
+ // Observe every compaction, including the ones OpenCode starts on its own —
92
+ // which is the comparison worth having. Strictly an observer: setting
93
+ // `result` on this event would replace the summary OpenCode was going to
94
+ // generate, so this hook never touches anything but its own record.
95
+ if (opts.history) {
96
+ try {
97
+ await ctx.session.hook("compaction", (event: any) => {
98
+ const at = Date.now()
99
+ write(
100
+ compactionEntry({
101
+ at,
102
+ sessionID: event.sessionID,
103
+ origin: takeOrigin(requested, event.sessionID, at),
104
+ event,
105
+ charsPerToken: opts.charsPerToken,
106
+ agent: event.agent,
107
+ model: event.model?.id,
108
+ providerID: event.model?.providerID,
109
+ }),
110
+ )
111
+ })
112
+ } catch (error) {
113
+ log("history hook unavailable — compactions will not be attributed:", error)
114
+ }
115
+ }
116
+
117
+ // Tell the terminal plugin what is happening. Registration can fail (an
118
+ // older server, a conflicting id); the trigger must keep working either way.
119
+ let emit: ((phase: string, sessionID: string, estimated: number) => void) | undefined
120
+ try {
121
+ const registration: any = await ctx.rpc.register(ContextCompaction, {
122
+ status: async () => {
123
+ // Counted from storage, not from memory. The plugin is instantiated
124
+ // per location, so the instance answering this call is frequently
125
+ // not the one whose hook wrote the records — an in-memory counter
126
+ // reports zero there while records plainly exist. Storage is shared.
127
+ let records = 0
128
+ let lastWriteAt = 0
129
+ if (opts.history) {
130
+ try {
131
+ let after: string | undefined
132
+ do {
133
+ const page: any = await ctx.storage.scan({ prefix: HISTORY_PREFIX, after, limit: 500 })
134
+ for (const found of page?.entries ?? []) {
135
+ records += 1
136
+ // Keys lead with a zero-padded millisecond stamp.
137
+ const stamped = Number(
138
+ String(found.key).slice(HISTORY_PREFIX.length, HISTORY_PREFIX.length + 16),
139
+ )
140
+ if (Number.isFinite(stamped)) lastWriteAt = Math.max(lastWriteAt, stamped)
141
+ }
142
+ after = page?.next
143
+ } while (after)
144
+ } catch {
145
+ // -1 rather than 0: an unreadable history must not be
146
+ // indistinguishable from an empty one, which is the exact
147
+ // failure this field exists to catch.
148
+ records = -1
149
+ }
150
+ }
151
+ return {
152
+ enabled: opts.enabled,
153
+ dryRun: opts.dryRun,
154
+ maxTokens: opts.maxTokens,
155
+ charsPerToken: opts.charsPerToken,
156
+ idleMs: opts.idleMs,
157
+ cooldownMs: opts.cooldownMs,
158
+ history: opts.history,
159
+ historyRecords: records,
160
+ historyLastAt: lastWriteAt,
161
+ }
162
+ },
163
+ })
164
+ emit = (phase, sessionID, estimated) => {
165
+ void Promise.resolve(
166
+ registration.events.emit("decision", {
167
+ sessionID,
168
+ phase,
169
+ estimated,
170
+ maxTokens: opts.maxTokens,
171
+ }),
172
+ ).catch(() => {})
173
+ }
174
+ } catch (error) {
175
+ log("RPC unavailable — the trigger still runs, the TUI just cannot show it:", error)
176
+ }
177
+
178
+ // The cooldown latch lives in plugin storage so a reload mid-session still
179
+ // respects it rather than compacting twice in a row.
180
+ const lastCompactedAt = async (sessionID: string): Promise<number> => {
181
+ try {
182
+ const value = await ctx.storage.get(`last:${sessionID}`)
183
+ if (typeof value === "number") return value
184
+ } catch {
185
+ // fall through to the in-memory record
186
+ }
187
+ return seen.get(sessionID)?.compactedAt ?? 0
188
+ }
189
+
190
+ const markCompacted = async (sessionID: string, now: number) => {
191
+ const record = seen.get(sessionID) ?? { estimated: 0 }
192
+ record.compactedAt = now
193
+ seen.set(sessionID, record)
194
+ try {
195
+ await ctx.storage.set(`last:${sessionID}`, now)
196
+ } catch {
197
+ // in-memory record above still guards the cooldown
198
+ }
199
+ }
200
+
201
+ const act = async (sessionID: string) => {
202
+ pending.delete(sessionID)
203
+ const record = seen.get(sessionID)
204
+ if (!record) return
205
+
206
+ const now = Date.now()
207
+ const info = await ctx.session.get({ sessionID })
208
+ const compactedAt = await lastCompactedAt(sessionID)
209
+ const decision = decide({
210
+ estimated: record.estimated,
211
+ maxTokens: opts.maxTokens,
212
+ enabled: opts.enabled,
213
+ isSubagent: Boolean(info.parentID),
214
+ compactedAt,
215
+ now,
216
+ cooldownMs: opts.cooldownMs,
217
+ })
218
+ write(
219
+ decisionEntry({
220
+ at: now,
221
+ sessionID,
222
+ decision,
223
+ estimated: record.estimated,
224
+ maxTokens: opts.maxTokens,
225
+ dryRun: opts.dryRun,
226
+ compactedAt,
227
+ }),
228
+ )
229
+
230
+ if (decision.action === "skip") {
231
+ if (decision.reason === "cooldown") emit?.("cooldown", sessionID, record.estimated)
232
+ return
233
+ }
234
+
235
+ const size = `~${formatTokens(record.estimated)} tokens over a ${formatTokens(opts.maxTokens)} budget`
236
+ if (opts.dryRun) {
237
+ log(`would compact ${sessionID}: ${size}`)
238
+ emit?.("dry-run", sessionID, record.estimated)
239
+ return
240
+ }
241
+
242
+ log(`compacting ${sessionID}: ${size}`)
243
+ await markCompacted(sessionID, now)
244
+ emit?.("requested", sessionID, record.estimated)
245
+ requested.set(sessionID, { at: now, origin: "rule" })
246
+ try {
247
+ // "queue" keeps the request behind anything already waiting, so a prompt
248
+ // sent in the meantime is not held up behind the summary.
249
+ await ctx.session.compact({ sessionID, delivery: "queue" })
250
+ } catch (error) {
251
+ // The request never landed, so it must not claim a later compaction.
252
+ requested.delete(sessionID)
253
+ throw error
254
+ }
255
+ }
256
+
257
+ if (opts.enabled) {
258
+ await ctx.session.hook("context", (event: any) => {
259
+ const record = seen.get(event.sessionID) ?? { estimated: 0 }
260
+ record.estimated = estimateTokens(event, opts.charsPerToken)
261
+ seen.set(event.sessionID, record)
262
+
263
+ // Debounce: act once the session stops issuing model calls, not between
264
+ // the calls of a single turn.
265
+ const timer = pending.get(event.sessionID)
266
+ if (timer) clearTimeout(timer)
267
+ pending.set(
268
+ event.sessionID,
269
+ setTimeout(() => {
270
+ act(event.sessionID).catch((error) => log("compaction request failed:", error))
271
+ }, opts.idleMs),
272
+ )
273
+ })
274
+ }
275
+
276
+ // The on-demand counterpart, since the built-in trigger is window-based.
277
+ await ctx.command.transform((editor: any) => {
278
+ editor.add({
279
+ name: "compact-now",
280
+ description: "Compact this session now",
281
+ execute: async ({ sessionID }: any) => {
282
+ // Marked for the same reason the trigger marks its own request: the
283
+ // history should record that a compaction was asked for, not infer it.
284
+ requested.set(sessionID, { at: Date.now(), origin: "command" })
285
+ try {
286
+ await ctx.session.compact({ sessionID, delivery: "queue" })
287
+ } catch (error) {
288
+ requested.delete(sessionID)
289
+ throw error
290
+ }
291
+ },
292
+ })
293
+ })
294
+
295
+ log(
296
+ `loaded — ${opts.enabled ? `compact above ~${formatTokens(opts.maxTokens)} tokens after ${opts.idleMs}ms quiet` : "trigger disabled"}` +
297
+ `${opts.dryRun ? " (dry run)" : ""}` +
298
+ `${opts.history ? " (history on)" : ""}`,
299
+ )
300
+
301
+ return () => {
302
+ for (const timer of pending.values()) clearTimeout(timer)
303
+ pending.clear()
304
+ seen.clear()
305
+ requested.clear()
306
+ }
307
+ },
308
+ })
package/src/lib.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Pure decision logic for context-compaction.
3
+ *
4
+ * Nothing here imports OpenCode or Solid, so the rules can be unit-tested on
5
+ * their own (see test/lib.test.ts) with a plain TypeScript runtime.
6
+ */
7
+
8
+ export interface Options {
9
+ /** Hold the trigger off entirely without removing the plugin. */
10
+ enabled: boolean
11
+ /** Compact when the estimated context is at or above this many tokens. */
12
+ maxTokens: number
13
+ /** Estimate for a text-heavy transcript; high estimates compact earlier. */
14
+ charsPerToken: number
15
+ /** How long a session must be quiet before a compaction is requested. */
16
+ idleMs: number
17
+ /** Minimum gap between compactions of the same session. */
18
+ cooldownMs: number
19
+ /** Log and notify instead of requesting compaction. */
20
+ dryRun: boolean
21
+ /** Keep a durable record of decisions and compactions, for later analysis. */
22
+ history: boolean
23
+ }
24
+
25
+ export const DEFAULTS: Options = {
26
+ enabled: true,
27
+ // A budget, not a share of the window: 40% of a 1M-token window is 400k,
28
+ // which real sessions never reach, so a percentage rule never fires.
29
+ maxTokens: 150_000,
30
+ charsPerToken: 4,
31
+ idleMs: 45_000,
32
+ cooldownMs: 10 * 60_000,
33
+ dryRun: false,
34
+ // Off by default: records land in OpenCode's own database, which is the
35
+ // user's, so writing there is something they opt into rather than inherit.
36
+ history: false,
37
+ }
38
+
39
+ export function resolveOptions(options: Partial<Options> | undefined): Options {
40
+ return { ...DEFAULTS, ...(options ?? {}) }
41
+ }
42
+
43
+ function lengthOf(value: unknown): number {
44
+ const text = (value as { text?: unknown } | undefined)?.text
45
+ if (typeof text === "string") return text.length
46
+ try {
47
+ return JSON.stringify(value)?.length ?? 0
48
+ } catch {
49
+ return 0
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Characters in the request about to be assembled: system parts plus messages.
55
+ * Every size estimate is this number over a per-token constant, so the raw
56
+ * count is worth keeping for the history — it can be re-divided later.
57
+ */
58
+ export function countChars(
59
+ event: { system?: readonly unknown[]; messages?: readonly unknown[] } | undefined,
60
+ ): number {
61
+ let chars = 0
62
+ for (const part of event?.system ?? []) chars += lengthOf(part)
63
+ for (const message of event?.messages ?? []) chars += lengthOf(message)
64
+ return chars
65
+ }
66
+
67
+ /**
68
+ * Rough size of the request about to be assembled: system parts plus messages,
69
+ * serialized and divided by a characters-per-token constant. JSON overhead
70
+ * makes the result skew slightly high, which is the safe direction for a
71
+ * budget — compact a little early rather than too late.
72
+ */
73
+ export function estimateTokens(
74
+ event: { system?: readonly unknown[]; messages?: readonly unknown[] } | undefined,
75
+ charsPerToken: number,
76
+ ): number {
77
+ return Math.round(countChars(event) / charsPerToken)
78
+ }
79
+
80
+ export type Decision =
81
+ | { action: "request"; reason: "over-budget" }
82
+ | { action: "skip"; reason: "disabled" | "subagent" | "under-budget" | "cooldown" }
83
+
84
+ export interface DecisionInput {
85
+ estimated: number
86
+ maxTokens: number
87
+ enabled: boolean
88
+ /** Subagent sessions are short-lived; compacting them buys nothing. */
89
+ isSubagent?: boolean
90
+ /** When this session was last compacted, 0 if never. */
91
+ compactedAt?: number
92
+ now: number
93
+ cooldownMs: number
94
+ }
95
+
96
+ export function decide(input: DecisionInput): Decision {
97
+ if (!input.enabled) return { action: "skip", reason: "disabled" }
98
+ if (input.isSubagent) return { action: "skip", reason: "subagent" }
99
+ if (input.estimated < input.maxTokens) return { action: "skip", reason: "under-budget" }
100
+ if (input.compactedAt && input.now - input.compactedAt < input.cooldownMs) {
101
+ return { action: "skip", reason: "cooldown" }
102
+ }
103
+ return { action: "request", reason: "over-budget" }
104
+ }
105
+
106
+ /** 15234 -> "15k", 1500000 -> "1.5M" */
107
+ export function formatTokens(tokens: number): string {
108
+ if (!Number.isFinite(tokens) || tokens <= 0) return "0"
109
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
110
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
111
+ return String(Math.round(tokens))
112
+ }
113
+
114
+ /**
115
+ * Optional history.
116
+ *
117
+ * Off unless asked for. It exists to answer what the trigger cannot answer
118
+ * about itself: whether a given compaction was ours or OpenCode's, and how far
119
+ * the `charsPerToken` estimate sits from the token count the provider actually
120
+ * reported. Records hold sizes and counts only — never message content — and
121
+ * they live in plugin storage, which is the user's database, so the list is
122
+ * capped rather than left to grow.
123
+ */
124
+ export type Origin = "rule" | "command" | "external"
125
+
126
+ export interface DecisionRecord {
127
+ kind: "decision"
128
+ at: number
129
+ sessionID: string
130
+ action: Decision["action"]
131
+ reason: Decision["reason"]
132
+ estimated: number
133
+ maxTokens: number
134
+ dryRun: boolean
135
+ /** Milliseconds since this session last compacted; 0 if it never has. */
136
+ sinceCompactionMs: number
137
+ }
138
+
139
+ export interface CompactionRecord {
140
+ kind: "compaction"
141
+ at: number
142
+ sessionID: string
143
+ origin: Origin
144
+ /** The same estimator the trigger uses, over the transcript being summarized. */
145
+ estimated: number
146
+ chars: number
147
+ messages: number
148
+ agent?: string
149
+ model?: string
150
+ providerID?: string
151
+ }
152
+
153
+ export type HistoryEntry = DecisionRecord | CompactionRecord
154
+
155
+ /** Newest records kept; the oldest are pruned as new ones arrive. */
156
+ export const HISTORY_LIMIT = 2_000
157
+
158
+ /** Storage key prefix, so this never collides with a plugin's other keys. */
159
+ export const HISTORY_PREFIX = "history/"
160
+
161
+ /**
162
+ * How long a requested compaction stays attributable to the plugin. OpenCode
163
+ * admits the request and compacts at the next step boundary, so the gap is
164
+ * normally seconds; a request that never lands must not go on to claim a
165
+ * compaction OpenCode started on its own.
166
+ */
167
+ export const PENDING_TTL_MS = 30 * 60_000
168
+
169
+ /**
170
+ * Keys carry a zero-padded timestamp, so a prefix scan returns records in the
171
+ * order they were written and pruning can drop from the front. The sequence
172
+ * only breaks ties within the same millisecond.
173
+ */
174
+ export function historyKey(at: number, seq: number): string {
175
+ return `${HISTORY_PREFIX}${String(at).padStart(16, "0")}-${String(seq).padStart(6, "0")}`
176
+ }
177
+
178
+ export function decisionEntry(input: {
179
+ at: number
180
+ sessionID: string
181
+ decision: Decision
182
+ estimated: number
183
+ maxTokens: number
184
+ dryRun: boolean
185
+ compactedAt?: number
186
+ }): DecisionRecord {
187
+ return {
188
+ kind: "decision",
189
+ at: input.at,
190
+ sessionID: input.sessionID,
191
+ action: input.decision.action,
192
+ reason: input.decision.reason,
193
+ estimated: input.estimated,
194
+ maxTokens: input.maxTokens,
195
+ dryRun: input.dryRun,
196
+ sinceCompactionMs: input.compactedAt ? Math.max(0, input.at - input.compactedAt) : 0,
197
+ }
198
+ }
199
+
200
+ export function compactionEntry(input: {
201
+ at: number
202
+ sessionID: string
203
+ origin: Origin
204
+ event?: { system?: readonly unknown[]; messages?: readonly unknown[] }
205
+ charsPerToken: number
206
+ agent?: string
207
+ model?: string
208
+ providerID?: string
209
+ }): CompactionRecord {
210
+ return {
211
+ kind: "compaction",
212
+ at: input.at,
213
+ sessionID: input.sessionID,
214
+ origin: input.origin,
215
+ estimated: estimateTokens(input.event, input.charsPerToken),
216
+ chars: countChars(input.event),
217
+ messages: input.event?.messages?.length ?? 0,
218
+ agent: input.agent,
219
+ model: input.model,
220
+ providerID: input.providerID,
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Who a compaction that is starting now belongs to. Consumes the session's
226
+ * pending marker, because one request can only explain one compaction.
227
+ * Mutates `pending`.
228
+ */
229
+ export function takeOrigin(
230
+ pending: Map<string, { at: number; origin: "rule" | "command" }>,
231
+ sessionID: string,
232
+ now: number,
233
+ ttlMs: number = PENDING_TTL_MS,
234
+ ): Origin {
235
+ const held = pending.get(sessionID)
236
+ if (!held) return "external"
237
+ pending.delete(sessionID)
238
+ return now - held.at > ttlMs ? "external" : held.origin
239
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The contract between the server plugin and the terminal plugin.
3
+ *
4
+ * The server side owns the budget and knows when a compaction is requested; the
5
+ * terminal side renders it. Sharing the contract keeps the budget in one place
6
+ * instead of duplicating a constant that can drift.
7
+ */
8
+ import { Rpc } from "@opencode/plugin/rpc"
9
+
10
+ export const ContextCompaction = Rpc.define({
11
+ id: "context-compaction",
12
+ methods: {
13
+ /** Current settings, so the terminal shows the numbers actually in use. */
14
+ status: {
15
+ // Deliberately an empty object schema rather than an omitted `input`:
16
+ // this server version dereferences the input schema even for
17
+ // argument-less methods, and a missing one throws internally.
18
+ input: {
19
+ type: "object",
20
+ properties: {},
21
+ additionalProperties: false,
22
+ },
23
+ output: {
24
+ type: "object",
25
+ properties: {
26
+ enabled: { type: "boolean" },
27
+ dryRun: { type: "boolean" },
28
+ maxTokens: { type: "number" },
29
+ charsPerToken: { type: "number" },
30
+ idleMs: { type: "number" },
31
+ cooldownMs: { type: "number" },
32
+ history: { type: "boolean" },
33
+ historyRecords: { type: "number" },
34
+ historyLastAt: { type: "number" },
35
+ },
36
+ required: [
37
+ "enabled",
38
+ "dryRun",
39
+ "maxTokens",
40
+ "charsPerToken",
41
+ "idleMs",
42
+ "cooldownMs",
43
+ "history",
44
+ "historyRecords",
45
+ "historyLastAt",
46
+ ],
47
+ additionalProperties: false,
48
+ },
49
+ },
50
+ },
51
+ events: {
52
+ /** A decision worth telling the user about, per session. */
53
+ decision: {
54
+ schema: {
55
+ type: "object",
56
+ properties: {
57
+ sessionID: { type: "string" },
58
+ phase: { type: "string", enum: ["requested", "dry-run", "cooldown"] },
59
+ estimated: { type: "number" },
60
+ maxTokens: { type: "number" },
61
+ },
62
+ required: ["sessionID", "phase", "estimated", "maxTokens"],
63
+ additionalProperties: false,
64
+ },
65
+ },
66
+ },
67
+ })
package/src/tui.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * context-compaction — terminal side.
3
+ *
4
+ * Shows what the server-side trigger is doing, in the terminal:
5
+ *
6
+ * - a status line under the composer: `ctx 44k/150k 29%`, turning
7
+ * attention-coloured once the session is past the budget;
8
+ * - a toast when a compaction is requested, and another when the session's
9
+ * history actually shrinks;
10
+ * - `/compact-status` (and a palette command) for the full numbers.
11
+ *
12
+ * It never decides anything: the budget and the decisions come from the server
13
+ * plugin over RPC, so the TUI cannot disagree with the trigger.
14
+ *
15
+ * Built without JSX, matching the element tree a Solid transform would emit, so
16
+ * the file loads under the plugin runtime as-is.
17
+ */
18
+ import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
19
+ import { createComponent, createElement, effect, insert, setProp } from "@opentui/solid"
20
+ import { Plugin } from "@opencode/plugin/tui"
21
+ import { formatTokens } from "./lib.ts"
22
+ import { ContextCompaction } from "./rpc.ts"
23
+
24
+ // Events drive updates; this interval is only a low-frequency backstop.
25
+ const REFRESH_MS = 10_000
26
+
27
+ // What the status line last saw, so the command can report the same numbers.
28
+ const observed: { sessionID?: string; used: number } = { used: 0 }
29
+
30
+ function info(message: any) {
31
+ return message?.info ?? message ?? {}
32
+ }
33
+
34
+ /** Tokens the session is carrying: the newest request's input and cache reads. */
35
+ function contextTokens(messages: any[]): number {
36
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
37
+ const tokens = info(messages[index]).tokens
38
+ if (!tokens) continue
39
+ const cache = tokens.cache ?? {}
40
+ const total = (tokens.input ?? 0) + (cache.read ?? 0) + (cache.write ?? 0)
41
+ if (total > 0) return total
42
+ }
43
+ return 0
44
+ }
45
+
46
+ function latestCompaction(messages: any[]): any {
47
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
48
+ const message = info(messages[index])
49
+ if (message.type === "compaction") return message
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ function StatusLine(props: any) {
55
+ const ctx = props.ctx
56
+ const [usage, setUsage] = createSignal(0)
57
+ const adopted = new Set<string>()
58
+ let lastCompaction: string | undefined
59
+
60
+ // Read defensively: the cache can throw if the session is torn down mid-read,
61
+ // and this runs inside event callbacks where an exception would be unhandled.
62
+ const read = () => {
63
+ const sessionID = props.sessionID
64
+ if (!sessionID) return undefined
65
+ try {
66
+ const messages = ctx.data.session.message.list(sessionID) ?? []
67
+ return { used: contextTokens(messages), compaction: latestCompaction(messages) }
68
+ } catch {
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ const refresh = () => {
74
+ const current = read()
75
+ if (!current) return
76
+ observed.sessionID = props.sessionID
77
+ observed.used = current.used
78
+ setUsage(current.used)
79
+
80
+ const compaction = current.compaction
81
+ if (!compaction) return
82
+ // A compaction already in history when we start watching is adopted, not
83
+ // announced; only one that lands while watching is worth a toast.
84
+ if (!adopted.has(props.sessionID)) {
85
+ adopted.add(props.sessionID)
86
+ lastCompaction = compaction.id
87
+ return
88
+ }
89
+ if (compaction.id === lastCompaction) return
90
+ lastCompaction = compaction.id
91
+ ctx.ui.toast.show({
92
+ title: "Context compacted",
93
+ message: `session context is now ~${formatTokens(current.used)} tokens`,
94
+ duration: 6000,
95
+ })
96
+ }
97
+
98
+ const resync = () => {
99
+ refresh()
100
+ const sessionID = props.sessionID
101
+ if (!sessionID) return
102
+ try {
103
+ void Promise.resolve(ctx.data.session.message.sync(sessionID)).then(refresh, refresh)
104
+ } catch {
105
+ // best effort: the interval backstop covers failures
106
+ }
107
+ }
108
+
109
+ // The slot may stay mounted while the session changes underneath it.
110
+ createEffect(() => {
111
+ void props.sessionID
112
+ refresh()
113
+ const recovery = [setTimeout(refresh, 400), setTimeout(refresh, 1500), setTimeout(refresh, 4000)]
114
+ onCleanup(() => {
115
+ for (const timer of recovery) clearTimeout(timer)
116
+ })
117
+ })
118
+
119
+ onMount(() => {
120
+ resync()
121
+ const stops = [
122
+ ctx.data.on("message.updated", resync),
123
+ ctx.data.on("message.removed", resync),
124
+ ctx.data.on("session.updated", resync),
125
+ ctx.data.on("session.idle", resync),
126
+ ]
127
+ const interval = setInterval(resync, REFRESH_MS)
128
+ onCleanup(() => {
129
+ for (const stop of stops) {
130
+ try {
131
+ stop()
132
+ } catch {
133
+ // ignore
134
+ }
135
+ }
136
+ clearInterval(interval)
137
+ })
138
+ })
139
+
140
+ const budget = () => props.budget
141
+
142
+ const label = () => {
143
+ const used = usage()
144
+ if (!used) return ""
145
+ const limit = budget()
146
+ if (!limit) return `ctx ~${formatTokens(used)}`
147
+ const share = Math.round((used / limit) * 100)
148
+ const over = used >= limit ? " · compacts at idle" : ""
149
+ return `ctx ${formatTokens(used)}/${formatTokens(limit)} ${share}%${over}`
150
+ }
151
+
152
+ const color = () => {
153
+ const limit = budget()
154
+ const theme = ctx.theme ?? {}
155
+ if (limit && usage() >= limit) return theme.text?.warn ?? theme.text?.base
156
+ return theme.text?.muted ?? theme.text?.base
157
+ }
158
+
159
+ const text = createElement("text")
160
+ setProp(text, "wrapMode", "none")
161
+ insert(text, () => label())
162
+ effect((previous: any) => setProp(text, "fg", color(), previous))
163
+ return text
164
+ }
165
+
166
+ export default Plugin.define({
167
+ id: "opencode-context-compaction",
168
+
169
+ async setup(context: any) {
170
+ const [status, setStatus] = createSignal<any>(undefined)
171
+ const compaction = context.client.rpc(ContextCompaction)
172
+
173
+ // Ask the server plugin for the settings in use, rather than duplicating
174
+ // them here where they would drift.
175
+ const loadStatus = async () => {
176
+ try {
177
+ setStatus(await compaction.status({}))
178
+ } catch {
179
+ // Server plugin absent or still starting: show usage without a budget.
180
+ }
181
+ }
182
+ await loadStatus()
183
+
184
+ const stopDecisions = compaction.events.on("decision", (event: any) => {
185
+ const data = event?.data ?? {}
186
+ const estimated = formatTokens(data.estimated ?? 0)
187
+ const limit = formatTokens(data.maxTokens ?? 0)
188
+ if (data.phase === "requested") {
189
+ context.ui.toast.show({
190
+ title: "Context compaction",
191
+ message: `~${estimated} tokens over a ${limit} budget — compacting now`,
192
+ duration: 6000,
193
+ })
194
+ } else if (data.phase === "dry-run") {
195
+ context.ui.toast.show({
196
+ title: "Context compaction (dry run)",
197
+ message: `would compact ~${estimated} tokens against a ${limit} budget`,
198
+ duration: 6000,
199
+ })
200
+ } else if (data.phase === "cooldown") {
201
+ context.ui.toast.show({
202
+ title: "Context compaction",
203
+ message: `~${estimated} tokens over budget, but inside the cooldown window`,
204
+ duration: 5000,
205
+ })
206
+ }
207
+ })
208
+
209
+ const showStatus = async () => {
210
+ const current = status()
211
+ const used = observed.used
212
+ const limit = current?.maxTokens
213
+ const lines = [
214
+ current?.enabled === false ? "trigger: disabled" : "trigger: armed",
215
+ limit ? `budget: ~${formatTokens(limit)} tokens` : "budget: unknown (server plugin not answering)",
216
+ used ? `this session: ~${formatTokens(used)} tokens${limit ? ` (${Math.round((used / limit) * 100)}%)` : ""}` : "this session: no request seen yet",
217
+ current ? `idle before acting: ${Math.round(current.idleMs / 1000)}s` : "",
218
+ current ? `cooldown: ${Math.round(current.cooldownMs / 60_000)} min` : "",
219
+ current?.dryRun ? "dry run: on (nothing is compacted)" : "",
220
+ current?.history
221
+ ? current.historyRecords === -1
222
+ ? "history: on (unreadable)"
223
+ : `history: on (${current.historyRecords ?? 0} recorded this run)`
224
+ : "",
225
+ "",
226
+ "/compact-now compacts immediately.",
227
+ ].filter(Boolean)
228
+ await context.ui.dialog.alert({ title: "Context compaction", message: lines.join("\n") })
229
+ }
230
+
231
+ context.keymap.layer(() => ({
232
+ mode: "global",
233
+ priority: 10,
234
+ commands: [
235
+ {
236
+ id: "context-compaction.status",
237
+ title: "Context budget: show status",
238
+ group: "Context",
239
+ palette: true,
240
+ slash: { name: "compact-status" },
241
+ run: () => showStatus(),
242
+ },
243
+ ],
244
+ bindings: ["context-compaction.status"],
245
+ }))
246
+
247
+ const unregisterSlot = context.ui.slot({
248
+ append: "prompt.footer.status",
249
+ render: (props: any) =>
250
+ createComponent(StatusLine, {
251
+ ctx: context,
252
+ get sessionID() {
253
+ return props.sessionID
254
+ },
255
+ get budget() {
256
+ return status()?.maxTokens
257
+ },
258
+ }),
259
+ })
260
+
261
+ return () => {
262
+ try {
263
+ stopDecisions?.()
264
+ } catch {
265
+ // ignore
266
+ }
267
+ try {
268
+ unregisterSlot?.()
269
+ } catch {
270
+ // ignore
271
+ }
272
+ }
273
+ },
274
+ })