@jhanink/opencode-context-compaction 0.1.1 → 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/README.md CHANGED
@@ -59,8 +59,6 @@ Or as a package:
59
59
  opencode plugin add @jhanink/opencode-context-compaction
60
60
  ```
61
61
 
62
- The unscoped name `opencode-context-compaction` is deprecated and points here.
63
-
64
62
  ## Options
65
63
 
66
64
  Set options by loading the plugin from `opencode.json(c)` — and **do not** also
@@ -95,6 +93,7 @@ keys you set are overridden:
95
93
  | `idleMs` | `45000` | How long a session must be quiet before acting. |
96
94
  | `cooldownMs` | `600000` | Minimum gap between compactions of one session. |
97
95
  | `dryRun` | `false` | Log and notify instead of compacting. |
96
+ | `history` | `false` | Record decisions and compactions in plugin storage. |
98
97
 
99
98
  To watch decisions for a day before letting it rewrite anything, add
100
99
  `"dryRun": true` — the toasts then tell you what it *would* have done.
@@ -105,13 +104,44 @@ Set it where a session stops being useful to you, not where the provider starts
105
104
  refusing it. Per-request cost is dominated by cache reads, which are cheap, so the
106
105
  real reasons to compact are transcript drift and the ceiling itself.
107
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
+
108
137
  ## How it works
109
138
 
110
139
  ```
111
- session.hook("context") → estimate tokens → debounce (idleMs)
112
- → decide (budget, cooldown, subagent)
113
- → ctx.session.compact({ delivery: "queue" })
114
- → RPC event → toast
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
115
145
  ```
116
146
 
117
147
  Estimated size is the serialized system parts and messages divided by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhanink/opencode-context-compaction",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Compact an OpenCode session on a token budget at idle, with a TUI status line and toasts.",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -18,7 +18,19 @@
18
18
  * the TUI shows is what the trigger uses.
19
19
  */
20
20
  import { Plugin } from "@opencode/plugin"
21
- import { decide, estimateTokens, formatTokens, resolveOptions } from "./lib.ts"
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"
22
34
  import { ContextCompaction } from "./rpc.ts"
23
35
 
24
36
  type Seen = { estimated: number; compactedAt?: number }
@@ -33,19 +45,121 @@ export default Plugin.define({
33
45
  const pending = new Map<string, any>() // sessionID -> debounce timer
34
46
  const seen = new Map<string, Seen>() // sessionID -> last observation
35
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
+
36
117
  // Tell the terminal plugin what is happening. Registration can fail (an
37
118
  // older server, a conflicting id); the trigger must keep working either way.
38
119
  let emit: ((phase: string, sessionID: string, estimated: number) => void) | undefined
39
120
  try {
40
121
  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
- }),
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
+ },
49
163
  })
50
164
  emit = (phase, sessionID, estimated) => {
51
165
  void Promise.resolve(
@@ -91,15 +205,27 @@ export default Plugin.define({
91
205
 
92
206
  const now = Date.now()
93
207
  const info = await ctx.session.get({ sessionID })
208
+ const compactedAt = await lastCompactedAt(sessionID)
94
209
  const decision = decide({
95
210
  estimated: record.estimated,
96
211
  maxTokens: opts.maxTokens,
97
212
  enabled: opts.enabled,
98
213
  isSubagent: Boolean(info.parentID),
99
- compactedAt: await lastCompactedAt(sessionID),
214
+ compactedAt,
100
215
  now,
101
216
  cooldownMs: opts.cooldownMs,
102
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
+ )
103
229
 
104
230
  if (decision.action === "skip") {
105
231
  if (decision.reason === "cooldown") emit?.("cooldown", sessionID, record.estimated)
@@ -116,9 +242,16 @@ export default Plugin.define({
116
242
  log(`compacting ${sessionID}: ${size}`)
117
243
  await markCompacted(sessionID, now)
118
244
  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" })
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
+ }
122
255
  }
123
256
 
124
257
  if (opts.enabled) {
@@ -146,20 +279,30 @@ export default Plugin.define({
146
279
  name: "compact-now",
147
280
  description: "Compact this session now",
148
281
  execute: async ({ sessionID }: any) => {
149
- await ctx.session.compact({ sessionID, delivery: "queue" })
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
+ }
150
291
  },
151
292
  })
152
293
  })
153
294
 
154
295
  log(
155
296
  `loaded — ${opts.enabled ? `compact above ~${formatTokens(opts.maxTokens)} tokens after ${opts.idleMs}ms quiet` : "trigger disabled"}` +
156
- `${opts.dryRun ? " (dry run)" : ""}`,
297
+ `${opts.dryRun ? " (dry run)" : ""}` +
298
+ `${opts.history ? " (history on)" : ""}`,
157
299
  )
158
300
 
159
301
  return () => {
160
302
  for (const timer of pending.values()) clearTimeout(timer)
161
303
  pending.clear()
162
304
  seen.clear()
305
+ requested.clear()
163
306
  }
164
307
  },
165
308
  })
package/src/lib.ts CHANGED
@@ -18,6 +18,8 @@ export interface Options {
18
18
  cooldownMs: number
19
19
  /** Log and notify instead of requesting compaction. */
20
20
  dryRun: boolean
21
+ /** Keep a durable record of decisions and compactions, for later analysis. */
22
+ history: boolean
21
23
  }
22
24
 
23
25
  export const DEFAULTS: Options = {
@@ -29,6 +31,9 @@ export const DEFAULTS: Options = {
29
31
  idleMs: 45_000,
30
32
  cooldownMs: 10 * 60_000,
31
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,
32
37
  }
33
38
 
34
39
  export function resolveOptions(options: Partial<Options> | undefined): Options {
@@ -45,6 +50,20 @@ function lengthOf(value: unknown): number {
45
50
  }
46
51
  }
47
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
+
48
67
  /**
49
68
  * Rough size of the request about to be assembled: system parts plus messages,
50
69
  * serialized and divided by a characters-per-token constant. JSON overhead
@@ -55,10 +74,7 @@ export function estimateTokens(
55
74
  event: { system?: readonly unknown[]; messages?: readonly unknown[] } | undefined,
56
75
  charsPerToken: number,
57
76
  ): 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)
77
+ return Math.round(countChars(event) / charsPerToken)
62
78
  }
63
79
 
64
80
  export type Decision =
@@ -94,3 +110,130 @@ export function formatTokens(tokens: number): string {
94
110
  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
95
111
  return String(Math.round(tokens))
96
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 CHANGED
@@ -29,8 +29,21 @@ export const ContextCompaction = Rpc.define({
29
29
  charsPerToken: { type: "number" },
30
30
  idleMs: { type: "number" },
31
31
  cooldownMs: { type: "number" },
32
+ history: { type: "boolean" },
33
+ historyRecords: { type: "number" },
34
+ historyLastAt: { type: "number" },
32
35
  },
33
- required: ["enabled", "dryRun", "maxTokens", "charsPerToken", "idleMs", "cooldownMs"],
36
+ required: [
37
+ "enabled",
38
+ "dryRun",
39
+ "maxTokens",
40
+ "charsPerToken",
41
+ "idleMs",
42
+ "cooldownMs",
43
+ "history",
44
+ "historyRecords",
45
+ "historyLastAt",
46
+ ],
34
47
  additionalProperties: false,
35
48
  },
36
49
  },
package/src/tui.ts CHANGED
@@ -217,6 +217,11 @@ export default Plugin.define({
217
217
  current ? `idle before acting: ${Math.round(current.idleMs / 1000)}s` : "",
218
218
  current ? `cooldown: ${Math.round(current.cooldownMs / 60_000)} min` : "",
219
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
+ : "",
220
225
  "",
221
226
  "/compact-now compacts immediately.",
222
227
  ].filter(Boolean)