@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 +36 -6
- package/package.json +1 -1
- package/src/index.ts +158 -15
- package/src/lib.ts +147 -4
- package/src/rpc.ts +14 -1
- package/src/tui.ts +5 -0
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")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
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 {
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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)
|