@jhanink/opencode-context-compaction 0.0.0-stage → 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 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,149 @@
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
+ The unscoped name `opencode-context-compaction` is deprecated and points here.
63
+
64
+ ## Options
65
+
66
+ Set options by loading the plugin from `opencode.json(c)` — and **do not** also
67
+ leave a copy in the auto-discovered plugins directory, or two instances will each
68
+ request compaction:
69
+
70
+ ```jsonc
71
+ {
72
+ "plugins": [
73
+ {
74
+ "package": "@jhanink/opencode-context-compaction",
75
+ "options": {
76
+ "maxTokens": 200000,
77
+ "idleMs": 60000
78
+ }
79
+ }
80
+ ]
81
+ }
82
+ ```
83
+
84
+ Those two are a better starting point than the defaults on a model with a
85
+ 1M-token window such as DeepSeek V4. Its built-in trigger sits out around 900k and
86
+ cache reads are cheap, so there is little reason to compact early; the longer idle
87
+ delay also keeps a short pause mid-task from reading as the end of one. Only the
88
+ keys you set are overridden:
89
+
90
+ | Option | Default | Meaning |
91
+ | --- | --- | --- |
92
+ | `enabled` | `true` | Turn the trigger off without removing the plugin. |
93
+ | `maxTokens` | `150000` | Compact at or above this estimated context size. |
94
+ | `charsPerToken` | `4` | Estimator for a text-heavy transcript; higher means later compaction. |
95
+ | `idleMs` | `45000` | How long a session must be quiet before acting. |
96
+ | `cooldownMs` | `600000` | Minimum gap between compactions of one session. |
97
+ | `dryRun` | `false` | Log and notify instead of compacting. |
98
+
99
+ To watch decisions for a day before letting it rewrite anything, add
100
+ `"dryRun": true` — the toasts then tell you what it *would* have done.
101
+
102
+ ### Sizing `maxTokens`
103
+
104
+ Set it where a session stops being useful to you, not where the provider starts
105
+ refusing it. Per-request cost is dominated by cache reads, which are cheap, so the
106
+ real reasons to compact are transcript drift and the ceiling itself.
107
+
108
+ ## How it works
109
+
110
+ ```
111
+ session.hook("context") → estimate tokens → debounce (idleMs)
112
+ → decide (budget, cooldown, subagent)
113
+ → ctx.session.compact({ delivery: "queue" })
114
+ → RPC event → toast
115
+ ```
116
+
117
+ Estimated size is the serialized system parts and messages divided by
118
+ `charsPerToken`. JSON overhead makes it skew slightly high, which is the safe
119
+ direction for a budget: compact a little early rather than too late. Exact
120
+ tokenization would cost more than the decision is worth.
121
+
122
+ ## Development
123
+
124
+ ```sh
125
+ node --test test/*.test.ts # pure decision logic, no OpenCode needed
126
+ ```
127
+
128
+ `src/lib.ts` holds the rules and is free of OpenCode and Solid imports so it can
129
+ be tested on its own; `src/index.ts` and `src/tui.ts` are thin adapters.
130
+
131
+ OpenCode supplies `@opencode/plugin`, `@opentui/*` and `solid-js` at runtime, so
132
+ they are declared as **optional** peer dependencies and installing this package
133
+ fetches nothing else. (0.1.0 listed `@opencode/plugin` as a normal dependency,
134
+ which pulled 424 packages into every install, including a deprecated `glob`
135
+ with known advisories.)
136
+
137
+ ## Prior art
138
+
139
+ Closest neighbours, both worth reading before adopting this:
140
+
141
+ - [`@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.
142
+ - [`@tarquinen/opencode-dcp`](https://www.npmjs.com/package/@tarquinen/opencode-dcp) (AGPL-3.0) — prunes stale tool output from context instead of compacting.
143
+
144
+ This plugin deliberately does neither: it drives OpenCode's *native* compaction,
145
+ adds no dependencies, and makes no model calls of its own.
146
+
147
+ ## License
148
+
149
+ 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.1",
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,165 @@
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 { decide, estimateTokens, formatTokens, resolveOptions } from "./lib.ts"
22
+ import { ContextCompaction } from "./rpc.ts"
23
+
24
+ type Seen = { estimated: number; compactedAt?: number }
25
+
26
+ export default Plugin.define({
27
+ id: "context-compaction",
28
+
29
+ async setup(ctx: any) {
30
+ const opts = resolveOptions(ctx.options)
31
+ const log = (...args: unknown[]) => console.log("[context-compaction]", ...args)
32
+
33
+ const pending = new Map<string, any>() // sessionID -> debounce timer
34
+ const seen = new Map<string, Seen>() // sessionID -> last observation
35
+
36
+ // Tell the terminal plugin what is happening. Registration can fail (an
37
+ // older server, a conflicting id); the trigger must keep working either way.
38
+ let emit: ((phase: string, sessionID: string, estimated: number) => void) | undefined
39
+ try {
40
+ const registration: any = await ctx.rpc.register(ContextCompaction, {
41
+ status: async () => ({
42
+ enabled: opts.enabled,
43
+ dryRun: opts.dryRun,
44
+ maxTokens: opts.maxTokens,
45
+ charsPerToken: opts.charsPerToken,
46
+ idleMs: opts.idleMs,
47
+ cooldownMs: opts.cooldownMs,
48
+ }),
49
+ })
50
+ emit = (phase, sessionID, estimated) => {
51
+ void Promise.resolve(
52
+ registration.events.emit("decision", {
53
+ sessionID,
54
+ phase,
55
+ estimated,
56
+ maxTokens: opts.maxTokens,
57
+ }),
58
+ ).catch(() => {})
59
+ }
60
+ } catch (error) {
61
+ log("RPC unavailable — the trigger still runs, the TUI just cannot show it:", error)
62
+ }
63
+
64
+ // The cooldown latch lives in plugin storage so a reload mid-session still
65
+ // respects it rather than compacting twice in a row.
66
+ const lastCompactedAt = async (sessionID: string): Promise<number> => {
67
+ try {
68
+ const value = await ctx.storage.get(`last:${sessionID}`)
69
+ if (typeof value === "number") return value
70
+ } catch {
71
+ // fall through to the in-memory record
72
+ }
73
+ return seen.get(sessionID)?.compactedAt ?? 0
74
+ }
75
+
76
+ const markCompacted = async (sessionID: string, now: number) => {
77
+ const record = seen.get(sessionID) ?? { estimated: 0 }
78
+ record.compactedAt = now
79
+ seen.set(sessionID, record)
80
+ try {
81
+ await ctx.storage.set(`last:${sessionID}`, now)
82
+ } catch {
83
+ // in-memory record above still guards the cooldown
84
+ }
85
+ }
86
+
87
+ const act = async (sessionID: string) => {
88
+ pending.delete(sessionID)
89
+ const record = seen.get(sessionID)
90
+ if (!record) return
91
+
92
+ const now = Date.now()
93
+ const info = await ctx.session.get({ sessionID })
94
+ const decision = decide({
95
+ estimated: record.estimated,
96
+ maxTokens: opts.maxTokens,
97
+ enabled: opts.enabled,
98
+ isSubagent: Boolean(info.parentID),
99
+ compactedAt: await lastCompactedAt(sessionID),
100
+ now,
101
+ cooldownMs: opts.cooldownMs,
102
+ })
103
+
104
+ if (decision.action === "skip") {
105
+ if (decision.reason === "cooldown") emit?.("cooldown", sessionID, record.estimated)
106
+ return
107
+ }
108
+
109
+ const size = `~${formatTokens(record.estimated)} tokens over a ${formatTokens(opts.maxTokens)} budget`
110
+ if (opts.dryRun) {
111
+ log(`would compact ${sessionID}: ${size}`)
112
+ emit?.("dry-run", sessionID, record.estimated)
113
+ return
114
+ }
115
+
116
+ log(`compacting ${sessionID}: ${size}`)
117
+ await markCompacted(sessionID, now)
118
+ emit?.("requested", sessionID, record.estimated)
119
+ // "queue" keeps the request behind anything already waiting, so a prompt
120
+ // sent in the meantime is not held up behind the summary.
121
+ await ctx.session.compact({ sessionID, delivery: "queue" })
122
+ }
123
+
124
+ if (opts.enabled) {
125
+ await ctx.session.hook("context", (event: any) => {
126
+ const record = seen.get(event.sessionID) ?? { estimated: 0 }
127
+ record.estimated = estimateTokens(event, opts.charsPerToken)
128
+ seen.set(event.sessionID, record)
129
+
130
+ // Debounce: act once the session stops issuing model calls, not between
131
+ // the calls of a single turn.
132
+ const timer = pending.get(event.sessionID)
133
+ if (timer) clearTimeout(timer)
134
+ pending.set(
135
+ event.sessionID,
136
+ setTimeout(() => {
137
+ act(event.sessionID).catch((error) => log("compaction request failed:", error))
138
+ }, opts.idleMs),
139
+ )
140
+ })
141
+ }
142
+
143
+ // The on-demand counterpart, since the built-in trigger is window-based.
144
+ await ctx.command.transform((editor: any) => {
145
+ editor.add({
146
+ name: "compact-now",
147
+ description: "Compact this session now",
148
+ execute: async ({ sessionID }: any) => {
149
+ await ctx.session.compact({ sessionID, delivery: "queue" })
150
+ },
151
+ })
152
+ })
153
+
154
+ log(
155
+ `loaded — ${opts.enabled ? `compact above ~${formatTokens(opts.maxTokens)} tokens after ${opts.idleMs}ms quiet` : "trigger disabled"}` +
156
+ `${opts.dryRun ? " (dry run)" : ""}`,
157
+ )
158
+
159
+ return () => {
160
+ for (const timer of pending.values()) clearTimeout(timer)
161
+ pending.clear()
162
+ seen.clear()
163
+ }
164
+ },
165
+ })
package/src/lib.ts ADDED
@@ -0,0 +1,96 @@
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
+ }
22
+
23
+ export const DEFAULTS: Options = {
24
+ enabled: true,
25
+ // A budget, not a share of the window: 40% of a 1M-token window is 400k,
26
+ // which real sessions never reach, so a percentage rule never fires.
27
+ maxTokens: 150_000,
28
+ charsPerToken: 4,
29
+ idleMs: 45_000,
30
+ cooldownMs: 10 * 60_000,
31
+ dryRun: false,
32
+ }
33
+
34
+ export function resolveOptions(options: Partial<Options> | undefined): Options {
35
+ return { ...DEFAULTS, ...(options ?? {}) }
36
+ }
37
+
38
+ function lengthOf(value: unknown): number {
39
+ const text = (value as { text?: unknown } | undefined)?.text
40
+ if (typeof text === "string") return text.length
41
+ try {
42
+ return JSON.stringify(value)?.length ?? 0
43
+ } catch {
44
+ return 0
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Rough size of the request about to be assembled: system parts plus messages,
50
+ * serialized and divided by a characters-per-token constant. JSON overhead
51
+ * makes the result skew slightly high, which is the safe direction for a
52
+ * budget — compact a little early rather than too late.
53
+ */
54
+ export function estimateTokens(
55
+ event: { system?: readonly unknown[]; messages?: readonly unknown[] } | undefined,
56
+ charsPerToken: number,
57
+ ): number {
58
+ let chars = 0
59
+ for (const part of event?.system ?? []) chars += lengthOf(part)
60
+ for (const message of event?.messages ?? []) chars += lengthOf(message)
61
+ return Math.round(chars / charsPerToken)
62
+ }
63
+
64
+ export type Decision =
65
+ | { action: "request"; reason: "over-budget" }
66
+ | { action: "skip"; reason: "disabled" | "subagent" | "under-budget" | "cooldown" }
67
+
68
+ export interface DecisionInput {
69
+ estimated: number
70
+ maxTokens: number
71
+ enabled: boolean
72
+ /** Subagent sessions are short-lived; compacting them buys nothing. */
73
+ isSubagent?: boolean
74
+ /** When this session was last compacted, 0 if never. */
75
+ compactedAt?: number
76
+ now: number
77
+ cooldownMs: number
78
+ }
79
+
80
+ export function decide(input: DecisionInput): Decision {
81
+ if (!input.enabled) return { action: "skip", reason: "disabled" }
82
+ if (input.isSubagent) return { action: "skip", reason: "subagent" }
83
+ if (input.estimated < input.maxTokens) return { action: "skip", reason: "under-budget" }
84
+ if (input.compactedAt && input.now - input.compactedAt < input.cooldownMs) {
85
+ return { action: "skip", reason: "cooldown" }
86
+ }
87
+ return { action: "request", reason: "over-budget" }
88
+ }
89
+
90
+ /** 15234 -> "15k", 1500000 -> "1.5M" */
91
+ export function formatTokens(tokens: number): string {
92
+ if (!Number.isFinite(tokens) || tokens <= 0) return "0"
93
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
94
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
95
+ return String(Math.round(tokens))
96
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,54 @@
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
+ },
33
+ required: ["enabled", "dryRun", "maxTokens", "charsPerToken", "idleMs", "cooldownMs"],
34
+ additionalProperties: false,
35
+ },
36
+ },
37
+ },
38
+ events: {
39
+ /** A decision worth telling the user about, per session. */
40
+ decision: {
41
+ schema: {
42
+ type: "object",
43
+ properties: {
44
+ sessionID: { type: "string" },
45
+ phase: { type: "string", enum: ["requested", "dry-run", "cooldown"] },
46
+ estimated: { type: "number" },
47
+ maxTokens: { type: "number" },
48
+ },
49
+ required: ["sessionID", "phase", "estimated", "maxTokens"],
50
+ additionalProperties: false,
51
+ },
52
+ },
53
+ },
54
+ })
package/src/tui.ts ADDED
@@ -0,0 +1,269 @@
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
+ "",
221
+ "/compact-now compacts immediately.",
222
+ ].filter(Boolean)
223
+ await context.ui.dialog.alert({ title: "Context compaction", message: lines.join("\n") })
224
+ }
225
+
226
+ context.keymap.layer(() => ({
227
+ mode: "global",
228
+ priority: 10,
229
+ commands: [
230
+ {
231
+ id: "context-compaction.status",
232
+ title: "Context budget: show status",
233
+ group: "Context",
234
+ palette: true,
235
+ slash: { name: "compact-status" },
236
+ run: () => showStatus(),
237
+ },
238
+ ],
239
+ bindings: ["context-compaction.status"],
240
+ }))
241
+
242
+ const unregisterSlot = context.ui.slot({
243
+ append: "prompt.footer.status",
244
+ render: (props: any) =>
245
+ createComponent(StatusLine, {
246
+ ctx: context,
247
+ get sessionID() {
248
+ return props.sessionID
249
+ },
250
+ get budget() {
251
+ return status()?.maxTokens
252
+ },
253
+ }),
254
+ })
255
+
256
+ return () => {
257
+ try {
258
+ stopDecisions?.()
259
+ } catch {
260
+ // ignore
261
+ }
262
+ try {
263
+ unregisterSlot?.()
264
+ } catch {
265
+ // ignore
266
+ }
267
+ }
268
+ },
269
+ })