@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 +21 -0
- package/README.md +178 -2
- package/package.json +53 -4
- package/src/index.ts +308 -0
- package/src/lib.ts +239 -0
- package/src/rpc.ts +67 -0
- package/src/tui.ts +274 -0
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
|
-
#
|
|
1
|
+
# opencode-context-compaction
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
})
|