@vikrant82/opencode-cache-keepalive 0.1.0
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/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +582 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/auth.d.ts +11 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/config.d.ts +31 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/control.d.ts +8 -0
- package/dist/lib/control.d.ts.map +1 -0
- package/dist/lib/keepalive.d.ts +42 -0
- package/dist/lib/keepalive.d.ts.map +1 -0
- package/dist/lib/logger.d.ts +9 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/model.d.ts +8 -0
- package/dist/lib/model.d.ts.map +1 -0
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/state.d.ts +74 -0
- package/dist/lib/state.d.ts.map +1 -0
- package/dist/lib/system.d.ts +11 -0
- package/dist/lib/system.d.ts.map +1 -0
- package/dist/lib/tui/commands.d.ts +3 -0
- package/dist/lib/tui/commands.d.ts.map +1 -0
- package/dist/lib/tui/footer.d.ts +14 -0
- package/dist/lib/tui/footer.d.ts.map +1 -0
- package/dist/lib/tui/format.d.ts +3 -0
- package/dist/lib/tui/format.d.ts.map +1 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.d.ts.map +1 -0
- package/lib/auth.ts +33 -0
- package/lib/config.ts +101 -0
- package/lib/control.ts +37 -0
- package/lib/keepalive.ts +345 -0
- package/lib/logger.ts +21 -0
- package/lib/model.ts +19 -0
- package/lib/paths.ts +31 -0
- package/lib/state.ts +157 -0
- package/lib/system.ts +19 -0
- package/lib/tui/commands.ts +54 -0
- package/lib/tui/footer.tsx +156 -0
- package/lib/tui/format.ts +13 -0
- package/package.json +71 -0
- package/tui.tsx +25 -0
package/lib/keepalive.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import type { KeepaliveConfig } from "./config"
|
|
2
|
+
import { readControl } from "./control"
|
|
3
|
+
import { isEligibleModel } from "./model"
|
|
4
|
+
import type { KeepaliveStore, SessionKeepalive } from "./state"
|
|
5
|
+
import type { Logger } from "./logger"
|
|
6
|
+
|
|
7
|
+
const TICK_MS = 15_000
|
|
8
|
+
const JITTER_MS = 15_000
|
|
9
|
+
const CONTROL_POLL_MS = 1_000
|
|
10
|
+
const STATUS_POLL_MS = 2_000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The keepalive engine.
|
|
14
|
+
*
|
|
15
|
+
* Lifecycle per session:
|
|
16
|
+
* 1. A real turn finishes -> `session.idle` -> arm a warm window and schedule pings.
|
|
17
|
+
* 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.
|
|
18
|
+
* 3. `firePing` sends a `~` prompt through opencode (exact cached prefix, reuses
|
|
19
|
+
* the session's model/auth), reads `usage` to confirm a cache hit, then optionally
|
|
20
|
+
* reverts the `~`/`~` turn so it does not pollute the real conversation.
|
|
21
|
+
* 4. A new real user turn or the window closing stops warming.
|
|
22
|
+
*/
|
|
23
|
+
export class KeepaliveEngine {
|
|
24
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
25
|
+
private controlTimer: ReturnType<typeof setInterval> | undefined
|
|
26
|
+
private statusTimer: ReturnType<typeof setInterval> | undefined
|
|
27
|
+
private controlUpdatedAt = 0
|
|
28
|
+
private reconcilingStatus = false
|
|
29
|
+
private enabled: boolean
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly client: any,
|
|
33
|
+
private readonly config: KeepaliveConfig,
|
|
34
|
+
private readonly store: KeepaliveStore,
|
|
35
|
+
private readonly logger: Logger,
|
|
36
|
+
) {
|
|
37
|
+
const control = readControl()
|
|
38
|
+
this.enabled = control?.enabled ?? config.enabled
|
|
39
|
+
this.controlUpdatedAt = control?.updatedAt ?? 0
|
|
40
|
+
this.store.setEnabled(this.enabled)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start(): void {
|
|
44
|
+
if (this.timer) return
|
|
45
|
+
this.timer = setInterval(() => void this.tick(), TICK_MS)
|
|
46
|
+
this.controlTimer = setInterval(() => this.pollControl(), CONTROL_POLL_MS)
|
|
47
|
+
this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS)
|
|
48
|
+
this.store.persist()
|
|
49
|
+
this.logger.info(
|
|
50
|
+
`started (${this.enabled ? "enabled" : "disabled"}, ` +
|
|
51
|
+
`interval=${Math.round(this.config.intervalMs / 1000)}s, ` +
|
|
52
|
+
`window=${Math.round(this.config.windowMs / 60_000)}m)`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stop(): void {
|
|
57
|
+
if (!this.timer) return
|
|
58
|
+
clearInterval(this.timer)
|
|
59
|
+
this.timer = undefined
|
|
60
|
+
if (this.controlTimer) clearInterval(this.controlTimer)
|
|
61
|
+
this.controlTimer = undefined
|
|
62
|
+
if (this.statusTimer) clearInterval(this.statusTimer)
|
|
63
|
+
this.statusTimer = undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True while a ping is in flight — used to block tool calls during a ping. */
|
|
67
|
+
isWarming(sessionID: string): boolean {
|
|
68
|
+
return !!this.store.get(sessionID)?.warming
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async onEvent(event: any): Promise<void> {
|
|
72
|
+
const type: string | undefined = event?.type
|
|
73
|
+
if (!type) return
|
|
74
|
+
|
|
75
|
+
if (type === "session.deleted") {
|
|
76
|
+
const id = event.properties?.sessionID ?? event.properties?.info?.id
|
|
77
|
+
if (id) {
|
|
78
|
+
this.store.remove(id)
|
|
79
|
+
this.store.persist()
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (type === "message.updated") {
|
|
85
|
+
const info = event.properties?.info
|
|
86
|
+
const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID
|
|
87
|
+
if (!sessionID) return
|
|
88
|
+
const session = this.store.get(sessionID)
|
|
89
|
+
// Ignore everything that happens inside our own ping turn.
|
|
90
|
+
if (session?.warming) return
|
|
91
|
+
const eventAt = info?.time?.completed ?? info?.time?.created
|
|
92
|
+
// Events are asynchronous: a completed ping may still emit message updates
|
|
93
|
+
// after `warming` clears. Their timestamps predate the recorded ping.
|
|
94
|
+
if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if (info?.role === "user") {
|
|
98
|
+
const s = this.store.ensure(sessionID)
|
|
99
|
+
s.busy = true
|
|
100
|
+
s.active = false
|
|
101
|
+
this.store.persist()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (info?.role === "assistant" && typeof info?.time?.completed === "number") {
|
|
106
|
+
const s = this.store.ensure(sessionID)
|
|
107
|
+
s.lastResponseAt = info.time.completed
|
|
108
|
+
this.store.persist()
|
|
109
|
+
}
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (type === "session.status") {
|
|
114
|
+
const sessionID: string | undefined = event.properties?.sessionID
|
|
115
|
+
const status: string | undefined = event.properties?.status?.type
|
|
116
|
+
if (!sessionID || !status) return
|
|
117
|
+
|
|
118
|
+
// The ping's own status transitions must not restart its idle window.
|
|
119
|
+
if (this.store.get(sessionID)?.warming) return
|
|
120
|
+
|
|
121
|
+
if (status === "idle") {
|
|
122
|
+
await this.armWindow(sessionID)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const s = this.store.ensure(sessionID)
|
|
127
|
+
s.busy = true
|
|
128
|
+
s.active = false
|
|
129
|
+
this.store.persist()
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Compatibility fallback for opencode versions predating session.status.
|
|
134
|
+
if (type === "session.idle") {
|
|
135
|
+
const sessionID: string | undefined = event.properties?.sessionID
|
|
136
|
+
if (!sessionID) return
|
|
137
|
+
if (this.store.get(sessionID)?.warming) return // idle from our own ping
|
|
138
|
+
await this.armWindow(sessionID)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async armWindow(sessionID: string): Promise<void> {
|
|
143
|
+
const s = this.store.ensure(sessionID)
|
|
144
|
+
// Duplicate idle events (including late events from a ping) must not reset the
|
|
145
|
+
// current idle stretch or extend its warm window.
|
|
146
|
+
if (s.active && !s.busy) return
|
|
147
|
+
s.busy = false
|
|
148
|
+
await this.resolveSession(s)
|
|
149
|
+
|
|
150
|
+
// A new turn may have started while session metadata was being resolved.
|
|
151
|
+
if (s.busy || !this.enabled || !s.eligible) {
|
|
152
|
+
s.active = false
|
|
153
|
+
this.store.persist()
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const now = Date.now()
|
|
158
|
+
if (!s.lastResponseAt) s.lastResponseAt = now
|
|
159
|
+
s.idleSince = now
|
|
160
|
+
s.windowEndsAt = now + this.config.windowMs
|
|
161
|
+
s.nextPingAt = now + this.config.intervalMs
|
|
162
|
+
s.pingsSent = 0
|
|
163
|
+
s.lastPing = undefined
|
|
164
|
+
s.active = true
|
|
165
|
+
this.store.persist()
|
|
166
|
+
this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async tick(): Promise<void> {
|
|
170
|
+
if (!this.enabled) return
|
|
171
|
+
const now = Date.now()
|
|
172
|
+
for (const s of this.store.all()) {
|
|
173
|
+
if (!s.eligible || !s.active || s.busy || s.warming) continue
|
|
174
|
+
|
|
175
|
+
if (now >= s.windowEndsAt) {
|
|
176
|
+
s.active = false
|
|
177
|
+
this.store.persist()
|
|
178
|
+
this.logger.dbg(
|
|
179
|
+
`window closed ${short(s.sessionID)} after ${s.pingsSent} ping(s) — cache may cool`,
|
|
180
|
+
)
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (now >= s.nextPingAt) void this.firePing(s.sessionID)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async firePing(sessionID: string): Promise<void> {
|
|
189
|
+
const s = this.store.get(sessionID)
|
|
190
|
+
if (!s || s.warming || s.busy) return
|
|
191
|
+
|
|
192
|
+
s.warming = true
|
|
193
|
+
this.store.persist()
|
|
194
|
+
const startAt = Date.now()
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const res = await this.client.session.prompt({
|
|
198
|
+
path: { id: sessionID },
|
|
199
|
+
body: { parts: [{ type: "text", text: this.config.pingToken }] },
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const info = res?.data?.info ?? res?.info
|
|
203
|
+
const cache = info?.tokens?.cache ?? {}
|
|
204
|
+
const record = {
|
|
205
|
+
at: Date.now(),
|
|
206
|
+
hit: Number(cache.read ?? 0) > 0,
|
|
207
|
+
input: Number(info?.tokens?.input ?? 0),
|
|
208
|
+
cacheRead: Number(cache.read ?? 0),
|
|
209
|
+
cacheWrite: Number(cache.write ?? 0),
|
|
210
|
+
output: Number(info?.tokens?.output ?? 0),
|
|
211
|
+
}
|
|
212
|
+
s.lastPing = record
|
|
213
|
+
s.lastPingAt = record.at
|
|
214
|
+
s.pingsSent += 1
|
|
215
|
+
this.logger.info(
|
|
216
|
+
`ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} ` +
|
|
217
|
+
`in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if (this.config.revertPing) await this.revertPing(sessionID, startAt)
|
|
221
|
+
} catch (err) {
|
|
222
|
+
this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))
|
|
223
|
+
} finally {
|
|
224
|
+
s.warming = false
|
|
225
|
+
s.nextPingAt = Date.now() + jitter(this.config.intervalMs)
|
|
226
|
+
this.store.persist()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Remove the `~` user + `~` assistant turn so it never enters real context. */
|
|
231
|
+
private async revertPing(sessionID: string, startAt: number): Promise<void> {
|
|
232
|
+
let messages: any[]
|
|
233
|
+
try {
|
|
234
|
+
const res = await this.client.session.messages({ path: { id: sessionID } })
|
|
235
|
+
messages = res?.data ?? res ?? []
|
|
236
|
+
} catch (err) {
|
|
237
|
+
this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err))
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let target: string | undefined
|
|
242
|
+
for (const m of messages) {
|
|
243
|
+
if (m?.info?.role !== "user") continue
|
|
244
|
+
if (Number(m.info?.time?.created ?? 0) < startAt - 2_000) continue
|
|
245
|
+
const text = (m.parts ?? [])
|
|
246
|
+
.filter((p: any) => p?.type === "text")
|
|
247
|
+
.map((p: any) => p?.text ?? "")
|
|
248
|
+
.join("")
|
|
249
|
+
.trim()
|
|
250
|
+
if (text === this.config.pingToken) target = m.info.id
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!target) {
|
|
254
|
+
this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await this.client.session.revert({
|
|
260
|
+
path: { id: sessionID },
|
|
261
|
+
body: { messageID: target },
|
|
262
|
+
})
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// BusyError (a real turn started) or similar — leave the ping visible.
|
|
265
|
+
this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async resolveSession(s: SessionKeepalive): Promise<void> {
|
|
270
|
+
try {
|
|
271
|
+
const res = await this.client.session.get({ path: { id: s.sessionID } })
|
|
272
|
+
const info = res?.data ?? res
|
|
273
|
+
if (info?.parentID && !this.config.includeChildSessions) {
|
|
274
|
+
s.eligible = false
|
|
275
|
+
s.modelLabel = info?.model?.id ?? "child"
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
s.modelLabel = info?.model?.id ?? "unknown"
|
|
279
|
+
s.eligible = isEligibleModel(this.config, info?.model?.providerID, info?.model?.id)
|
|
280
|
+
} catch (err) {
|
|
281
|
+
s.eligible = false
|
|
282
|
+
this.logger.dbg(`resolve ${short(s.sessionID)} failed`, errText(err))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private pollControl(): void {
|
|
287
|
+
const control = readControl()
|
|
288
|
+
if (!control || control.updatedAt <= this.controlUpdatedAt) return
|
|
289
|
+
this.controlUpdatedAt = control.updatedAt
|
|
290
|
+
if (control.enabled === this.enabled) return
|
|
291
|
+
|
|
292
|
+
this.enabled = control.enabled
|
|
293
|
+
this.store.setEnabled(control.enabled)
|
|
294
|
+
const now = Date.now()
|
|
295
|
+
for (const session of this.store.all()) {
|
|
296
|
+
if (!control.enabled) {
|
|
297
|
+
session.active = false
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
if (!session.eligible || session.busy || session.warming) continue
|
|
301
|
+
if (!session.lastResponseAt) session.lastResponseAt = now
|
|
302
|
+
session.idleSince = now
|
|
303
|
+
session.windowEndsAt = now + this.config.windowMs
|
|
304
|
+
session.nextPingAt = now + this.config.intervalMs
|
|
305
|
+
session.pingsSent = 0
|
|
306
|
+
session.lastPing = undefined
|
|
307
|
+
session.active = true
|
|
308
|
+
}
|
|
309
|
+
this.store.persist()
|
|
310
|
+
this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Recover when a session.status idle event is missed by reconciling with the API. */
|
|
314
|
+
private async reconcileStatus(): Promise<void> {
|
|
315
|
+
if (this.reconcilingStatus) return
|
|
316
|
+
const busy = this.store.all().filter((session) => session.busy && !session.warming)
|
|
317
|
+
if (busy.length === 0) return
|
|
318
|
+
|
|
319
|
+
this.reconcilingStatus = true
|
|
320
|
+
try {
|
|
321
|
+
const res = await this.client.session.status()
|
|
322
|
+
const statuses = res?.data ?? res ?? {}
|
|
323
|
+
for (const session of busy) {
|
|
324
|
+
const status = statuses?.[session.sessionID]?.type ?? "idle"
|
|
325
|
+
if (status === "idle") await this.armWindow(session.sessionID)
|
|
326
|
+
}
|
|
327
|
+
} catch (err) {
|
|
328
|
+
this.logger.dbg("status reconciliation failed", errText(err))
|
|
329
|
+
} finally {
|
|
330
|
+
this.reconcilingStatus = false
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function short(sessionID: string): string {
|
|
336
|
+
return sessionID.slice(-6)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function jitter(base: number): number {
|
|
340
|
+
return Math.max(1_000, base + Math.floor((Math.random() * 2 - 1) * JITTER_MS))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function errText(err: unknown): string {
|
|
344
|
+
return err instanceof Error ? err.message : String(err)
|
|
345
|
+
}
|
package/lib/logger.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const PREFIX = "[cache-keepalive]"
|
|
2
|
+
|
|
3
|
+
export class Logger {
|
|
4
|
+
constructor(private readonly debug: boolean) {}
|
|
5
|
+
|
|
6
|
+
info(message: string, data?: unknown) {
|
|
7
|
+
if (this.debug) console.log(`${PREFIX} ${message}`, data ?? "")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
warn(message: string, data?: unknown) {
|
|
11
|
+
console.warn(`${PREFIX} ${message}`, data ?? "")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
error(message: string, data?: unknown) {
|
|
15
|
+
console.error(`${PREFIX} ${message}`, data ?? "")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dbg(message: string, data?: unknown) {
|
|
19
|
+
if (this.debug) console.log(`${PREFIX}:debug ${message}`, data ?? "")
|
|
20
|
+
}
|
|
21
|
+
}
|
package/lib/model.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { KeepaliveConfig } from "./config"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warming only helps on providers that do prefix caching we can refresh by
|
|
5
|
+
* replaying the prefix. Supported defaults include Claude and GPT models served
|
|
6
|
+
* through GitHub Copilot. Everything else is skipped.
|
|
7
|
+
*/
|
|
8
|
+
export function isEligibleModel(
|
|
9
|
+
config: KeepaliveConfig,
|
|
10
|
+
providerID: string | undefined,
|
|
11
|
+
modelID: string | undefined,
|
|
12
|
+
): boolean {
|
|
13
|
+
if (!providerID || !modelID) return false
|
|
14
|
+
const provider = providerID.toLowerCase()
|
|
15
|
+
const model = modelID.toLowerCase()
|
|
16
|
+
const providerOk = config.providerAllowlist.some((needle) => provider.includes(needle))
|
|
17
|
+
const modelOk = config.modelAllowlist.some((needle) => model.includes(needle))
|
|
18
|
+
return providerOk && modelOk
|
|
19
|
+
}
|
package/lib/paths.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Absolute path to the JSON state file shared between the server plugin (writer)
|
|
7
|
+
* and the TUI plugin (reader). Both processes run on the same machine, so a file
|
|
8
|
+
* under opencode's plugin storage dir is the simplest cross-process channel.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the storage layout used by other opencode plugins:
|
|
11
|
+
* <XDG_DATA_HOME | ~/.local/share>/opencode/storage/plugin/keepalive/state.json
|
|
12
|
+
*/
|
|
13
|
+
export function stateFilePath(directory: string): string {
|
|
14
|
+
const key = createHash("sha256").update(directory).digest("hex").slice(0, 16)
|
|
15
|
+
return join(stateDirectoryPath(), `state-${key}.json`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stateDirectoryPath(): string {
|
|
19
|
+
return join(
|
|
20
|
+
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
|
21
|
+
"opencode",
|
|
22
|
+
"storage",
|
|
23
|
+
"plugin",
|
|
24
|
+
"keepalive",
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runtime on/off control written by the TUI and read by the server plugin. */
|
|
29
|
+
export function controlFilePath(): string {
|
|
30
|
+
return join(stateDirectoryPath(), "control.json")
|
|
31
|
+
}
|
package/lib/state.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import type { KeepaliveConfig } from "./config"
|
|
4
|
+
import { stateFilePath } from "./paths"
|
|
5
|
+
|
|
6
|
+
export type PingRecord = {
|
|
7
|
+
at: number
|
|
8
|
+
hit: boolean
|
|
9
|
+
input: number
|
|
10
|
+
cacheRead: number
|
|
11
|
+
cacheWrite: number
|
|
12
|
+
output: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Live per-session bookkeeping held by the server plugin. */
|
|
16
|
+
export type SessionKeepalive = {
|
|
17
|
+
sessionID: string
|
|
18
|
+
/** Whether the session's provider/model pair supports cache warming. */
|
|
19
|
+
eligible: boolean
|
|
20
|
+
modelLabel?: string
|
|
21
|
+
/** Epoch ms of the last real assistant response (excludes ping replies). */
|
|
22
|
+
lastResponseAt: number
|
|
23
|
+
/** Epoch ms the current idle stretch began. */
|
|
24
|
+
idleSince: number
|
|
25
|
+
/** Epoch ms after which warming stops and the cache is allowed to go cold. */
|
|
26
|
+
windowEndsAt: number
|
|
27
|
+
/** Epoch ms the next ping is due. */
|
|
28
|
+
nextPingAt: number
|
|
29
|
+
/** Pings sent during the current idle stretch. */
|
|
30
|
+
pingsSent: number
|
|
31
|
+
/** A real turn is currently running. */
|
|
32
|
+
busy: boolean
|
|
33
|
+
/** A keepalive ping is currently in flight. */
|
|
34
|
+
warming: boolean
|
|
35
|
+
/** Within the warm window and actively scheduling pings. */
|
|
36
|
+
active: boolean
|
|
37
|
+
/** Epoch ms of the last ping; retained only to ignore its late events. */
|
|
38
|
+
lastPingAt?: number
|
|
39
|
+
lastPing?: PingRecord
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Lean projection persisted to disk for the TUI reader. */
|
|
43
|
+
export type PersistedSession = {
|
|
44
|
+
eligible: boolean
|
|
45
|
+
modelLabel?: string
|
|
46
|
+
lastResponseAt: number
|
|
47
|
+
idleSince: number
|
|
48
|
+
windowEndsAt: number
|
|
49
|
+
nextPingAt: number
|
|
50
|
+
intervalMs: number
|
|
51
|
+
pingsSent: number
|
|
52
|
+
busy: boolean
|
|
53
|
+
warming: boolean
|
|
54
|
+
active: boolean
|
|
55
|
+
lastPing?: PingRecord
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PersistedState = {
|
|
59
|
+
version: 1
|
|
60
|
+
updatedAt: number
|
|
61
|
+
enabled: boolean
|
|
62
|
+
intervalMs: number
|
|
63
|
+
windowMs: number
|
|
64
|
+
sessions: Record<string, PersistedSession>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class KeepaliveStore {
|
|
68
|
+
private readonly sessions = new Map<string, SessionKeepalive>()
|
|
69
|
+
private writeQueue: Promise<void> = Promise.resolve()
|
|
70
|
+
private enabled: boolean
|
|
71
|
+
|
|
72
|
+
constructor(
|
|
73
|
+
private readonly config: KeepaliveConfig,
|
|
74
|
+
private readonly directory: string,
|
|
75
|
+
) {
|
|
76
|
+
this.enabled = config.enabled
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setEnabled(enabled: boolean): void {
|
|
80
|
+
this.enabled = enabled
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get(sessionID: string): SessionKeepalive | undefined {
|
|
84
|
+
return this.sessions.get(sessionID)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ensure(sessionID: string): SessionKeepalive {
|
|
88
|
+
const existing = this.sessions.get(sessionID)
|
|
89
|
+
if (existing) return existing
|
|
90
|
+
const created: SessionKeepalive = {
|
|
91
|
+
sessionID,
|
|
92
|
+
eligible: false,
|
|
93
|
+
lastResponseAt: 0,
|
|
94
|
+
idleSince: 0,
|
|
95
|
+
windowEndsAt: 0,
|
|
96
|
+
nextPingAt: 0,
|
|
97
|
+
pingsSent: 0,
|
|
98
|
+
busy: false,
|
|
99
|
+
warming: false,
|
|
100
|
+
active: false,
|
|
101
|
+
}
|
|
102
|
+
this.sessions.set(sessionID, created)
|
|
103
|
+
return created
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
remove(sessionID: string): void {
|
|
107
|
+
this.sessions.delete(sessionID)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
all(): SessionKeepalive[] {
|
|
111
|
+
return [...this.sessions.values()]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Atomically persist a lean snapshot for the TUI. Never throws to callers. */
|
|
115
|
+
persist(): void {
|
|
116
|
+
const snapshot: PersistedState = {
|
|
117
|
+
version: 1,
|
|
118
|
+
updatedAt: Date.now(),
|
|
119
|
+
enabled: this.enabled,
|
|
120
|
+
intervalMs: this.config.intervalMs,
|
|
121
|
+
windowMs: this.config.windowMs,
|
|
122
|
+
sessions: Object.fromEntries(
|
|
123
|
+
this.all().map((s) => [
|
|
124
|
+
s.sessionID,
|
|
125
|
+
{
|
|
126
|
+
eligible: s.eligible,
|
|
127
|
+
modelLabel: s.modelLabel,
|
|
128
|
+
lastResponseAt: s.lastResponseAt,
|
|
129
|
+
idleSince: s.idleSince,
|
|
130
|
+
windowEndsAt: s.windowEndsAt,
|
|
131
|
+
nextPingAt: s.nextPingAt,
|
|
132
|
+
intervalMs: this.config.intervalMs,
|
|
133
|
+
pingsSent: s.pingsSent,
|
|
134
|
+
busy: s.busy,
|
|
135
|
+
warming: s.warming,
|
|
136
|
+
active: s.active,
|
|
137
|
+
lastPing: s.lastPing,
|
|
138
|
+
} satisfies PersistedSession,
|
|
139
|
+
]),
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))
|
|
144
|
+
this.writeQueue = this.writeQueue.then(write, write)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function atomicWrite(path: string, contents: string): Promise<void> {
|
|
149
|
+
try {
|
|
150
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
151
|
+
await mkdir(dirname(path), { recursive: true })
|
|
152
|
+
await writeFile(tmp, `${contents}\n`, "utf8")
|
|
153
|
+
await rename(tmp, path)
|
|
154
|
+
} catch {
|
|
155
|
+
// Persistence is best-effort; the TUI readout simply falls behind.
|
|
156
|
+
}
|
|
157
|
+
}
|
package/lib/system.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable keepalive instruction appended to the system prompt.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: this text must be byte-stable and applied to *every* request for an
|
|
5
|
+
* eligible model (real turns and pings alike). If it were only added on pings, the
|
|
6
|
+
* system prefix would differ from real turns and the ping would miss the cache.
|
|
7
|
+
* Adding it consistently keeps the cached prefix identical and lets the model
|
|
8
|
+
* answer a ping with a single token instead of a paragraph or a tool call.
|
|
9
|
+
*/
|
|
10
|
+
export function keepaliveInstruction(pingToken: string): string {
|
|
11
|
+
return [
|
|
12
|
+
"<cache_keepalive>",
|
|
13
|
+
`A user message consisting of exactly the single token "${pingToken}" is an`,
|
|
14
|
+
"automated cache-keepalive ping, not a real request. When you receive it,",
|
|
15
|
+
`reply with exactly "${pingToken}" and nothing else. Do not call any tools,`,
|
|
16
|
+
"do not explain, and do not take any action.",
|
|
17
|
+
"</cache_keepalive>",
|
|
18
|
+
].join("\n")
|
|
19
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import { readControl, writeControl } from "../control"
|
|
3
|
+
|
|
4
|
+
export function registerKeepaliveCommands(api: TuiPluginApi): void {
|
|
5
|
+
const setEnabled = async (enabled: boolean) => {
|
|
6
|
+
try {
|
|
7
|
+
await writeControl(enabled)
|
|
8
|
+
api.ui.toast({
|
|
9
|
+
variant: enabled ? "success" : "warning",
|
|
10
|
+
title: "Cache keepalive",
|
|
11
|
+
message: enabled ? "Enabled" : "Disabled",
|
|
12
|
+
})
|
|
13
|
+
} catch (error) {
|
|
14
|
+
api.ui.toast({
|
|
15
|
+
variant: "error",
|
|
16
|
+
title: "Cache keepalive",
|
|
17
|
+
message: error instanceof Error ? error.message : String(error),
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
api.keymap.registerLayer({
|
|
23
|
+
commands: [
|
|
24
|
+
{
|
|
25
|
+
name: "keepalive.toggle",
|
|
26
|
+
title: "Toggle cache keepalive",
|
|
27
|
+
category: "Keepalive",
|
|
28
|
+
namespace: "palette",
|
|
29
|
+
slashName: "keepalive-toggle",
|
|
30
|
+
run: () => setEnabled(!currentEnabled()),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "keepalive.on",
|
|
34
|
+
title: "Enable cache keepalive",
|
|
35
|
+
category: "Keepalive",
|
|
36
|
+
namespace: "palette",
|
|
37
|
+
slashName: "keepalive-on",
|
|
38
|
+
run: () => setEnabled(true),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "keepalive.off",
|
|
42
|
+
title: "Disable cache keepalive",
|
|
43
|
+
category: "Keepalive",
|
|
44
|
+
namespace: "palette",
|
|
45
|
+
slashName: "keepalive-off",
|
|
46
|
+
run: () => setEnabled(false),
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function currentEnabled(): boolean {
|
|
53
|
+
return readControl()?.enabled ?? true
|
|
54
|
+
}
|