@vikrant82/opencode-cache-keepalive 0.1.6 → 0.2.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/MIGRATION.md +57 -0
- package/README.md +75 -51
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1093 -649
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +8 -25
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/control.d.ts +10 -37
- package/dist/lib/control.d.ts.map +1 -1
- package/dist/lib/paths.d.ts +5 -12
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/replay-warmer.d.ts +95 -0
- package/dist/lib/replay-warmer.d.ts.map +1 -0
- package/dist/lib/state.d.ts +15 -117
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +1 -6
- package/dist/lib/tui/commands.d.ts.map +1 -1
- package/dist/lib/tui/footer.d.ts +5 -5
- package/dist/lib/tui/footer.d.ts.map +1 -1
- package/dist/lib/tui/format.d.ts.map +1 -1
- package/dist/lib/types.d.ts +123 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.d.ts.map +1 -1
- package/lib/config.ts +106 -74
- package/lib/control.ts +50 -86
- package/lib/paths.ts +12 -14
- package/lib/replay-warmer.ts +1200 -0
- package/lib/state.ts +171 -181
- package/lib/tui/commands.tsx +36 -144
- package/lib/tui/footer.tsx +54 -156
- package/lib/tui/format.ts +4 -0
- package/lib/types.ts +130 -0
- package/package.json +3 -2
- package/tui.tsx +40 -0
- package/dist/lib/keepalive.d.ts +0 -88
- package/dist/lib/keepalive.d.ts.map +0 -1
- package/dist/lib/model.d.ts +0 -8
- package/dist/lib/model.d.ts.map +0 -1
- package/dist/lib/system.d.ts +0 -11
- package/dist/lib/system.d.ts.map +0 -1
- package/lib/keepalive.ts +0 -665
- package/lib/model.ts +0 -19
- package/lib/system.ts +0 -19
package/lib/keepalive.ts
DELETED
|
@@ -1,665 +0,0 @@
|
|
|
1
|
-
import type { KeepaliveConfig } from "./config"
|
|
2
|
-
import { readControl } from "./control"
|
|
3
|
-
import { isEligibleModel } from "./model"
|
|
4
|
-
import type { InflightPing, KeepaliveStore, PingRequest, 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
|
-
const PING_STATUS_GRACE_MS = 5_000
|
|
12
|
-
/**
|
|
13
|
-
* A cache last touched more than this many intervals ago is presumed cold. Pinging
|
|
14
|
-
* it would pay a full cache write for a session nobody is using, so the window
|
|
15
|
-
* stays closed until the next real turn re-arms it.
|
|
16
|
-
*/
|
|
17
|
-
const COLD_AFTER_INTERVALS = 2
|
|
18
|
-
/** Recent messages scanned for ping reverts and request-setting recovery. */
|
|
19
|
-
const MESSAGE_LOOKUP_LIMIT = 50
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The keepalive engine.
|
|
23
|
-
*
|
|
24
|
-
* Lifecycle per session:
|
|
25
|
-
* 1. A real turn finishes -> `session.status` idle -> arm a warm window anchored on
|
|
26
|
-
* the real response and schedule pings.
|
|
27
|
-
* 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.
|
|
28
|
-
* 3. `firePing` confirms with the server that the session is idle, sends a `~`
|
|
29
|
-
* prompt with the last real turn's agent/model/variant (exact cached prefix),
|
|
30
|
-
* reads `usage` to confirm a cache hit, then optionally reverts the `~`/`~` turn.
|
|
31
|
-
* 4. A new real user turn, the window closing, or the cache presumed cold stops warming.
|
|
32
|
-
*
|
|
33
|
-
* Threading: all state is mutated on the single plugin event loop. opencode invokes
|
|
34
|
-
* the `event` hook without awaiting it, so async handlers interleave; per-session
|
|
35
|
-
* `arming` / `warming` flags serialize the async sections.
|
|
36
|
-
*
|
|
37
|
-
* Errors: network/API failures are logged and never thrown to opencode.
|
|
38
|
-
*/
|
|
39
|
-
export class KeepaliveEngine {
|
|
40
|
-
private timer: ReturnType<typeof setInterval> | undefined
|
|
41
|
-
private controlTimer: ReturnType<typeof setInterval> | undefined
|
|
42
|
-
private statusTimer: ReturnType<typeof setInterval> | undefined
|
|
43
|
-
private controlUpdatedAt = 0
|
|
44
|
-
private reconcilingStatus = false
|
|
45
|
-
private enabled: boolean
|
|
46
|
-
private intervalOverride: number | undefined
|
|
47
|
-
|
|
48
|
-
constructor(
|
|
49
|
-
private readonly client: any,
|
|
50
|
-
private readonly config: KeepaliveConfig,
|
|
51
|
-
private readonly store: KeepaliveStore,
|
|
52
|
-
private readonly logger: Logger,
|
|
53
|
-
private readonly directory: string,
|
|
54
|
-
) {
|
|
55
|
-
const control = readControl(directory)
|
|
56
|
-
this.enabled = control?.enabled ?? config.enabled
|
|
57
|
-
this.intervalOverride = control?.intervalMs
|
|
58
|
-
this.controlUpdatedAt = control?.updatedAt ?? 0
|
|
59
|
-
this.store.setEnabled(this.enabled)
|
|
60
|
-
this.store.setIntervalMs(this.intervalMs)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Effective ping interval: the runtime override from the control file, else config. */
|
|
64
|
-
private get intervalMs(): number {
|
|
65
|
-
return this.intervalOverride ?? this.config.intervalMs
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
start(): void {
|
|
69
|
-
if (this.timer) return
|
|
70
|
-
this.timer = setInterval(() => void this.tick(), TICK_MS)
|
|
71
|
-
this.controlTimer = setInterval(() => this.pollControl(), CONTROL_POLL_MS)
|
|
72
|
-
this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS)
|
|
73
|
-
this.store.persist()
|
|
74
|
-
this.logger.info(
|
|
75
|
-
`started (${this.enabled ? "enabled" : "disabled"}, ` +
|
|
76
|
-
`interval=${Math.round(this.intervalMs / 1000)}s, ` +
|
|
77
|
-
`window=${Math.round(this.config.windowMs / 60_000)}m)`,
|
|
78
|
-
)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
stop(): void {
|
|
82
|
-
if (!this.timer) return
|
|
83
|
-
clearInterval(this.timer)
|
|
84
|
-
this.timer = undefined
|
|
85
|
-
if (this.controlTimer) clearInterval(this.controlTimer)
|
|
86
|
-
this.controlTimer = undefined
|
|
87
|
-
if (this.statusTimer) clearInterval(this.statusTimer)
|
|
88
|
-
this.statusTimer = undefined
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* True while a ping turn owns the session, so tool calls must be blocked. Becomes
|
|
93
|
-
* false as soon as a real user message joins the in-flight ping run: the rest of
|
|
94
|
-
* that run is real work and needs its tools.
|
|
95
|
-
*/
|
|
96
|
-
shouldBlockTools(sessionID: string): boolean {
|
|
97
|
-
const s = this.store.get(sessionID)
|
|
98
|
-
return !!s?.warming && !s.ping?.interrupted
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async onEvent(event: any): Promise<void> {
|
|
102
|
-
const type: string | undefined = event?.type
|
|
103
|
-
if (!type) return
|
|
104
|
-
|
|
105
|
-
if (type === "session.deleted") {
|
|
106
|
-
const id = event.properties?.sessionID ?? event.properties?.info?.id
|
|
107
|
-
if (id) {
|
|
108
|
-
this.store.remove(id)
|
|
109
|
-
this.store.persist()
|
|
110
|
-
}
|
|
111
|
-
return
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (type === "message.part.updated") {
|
|
115
|
-
const part = event.properties?.part
|
|
116
|
-
const sessionID: string | undefined = part?.sessionID ?? event.properties?.sessionID
|
|
117
|
-
if (!sessionID) return
|
|
118
|
-
const session = this.store.get(sessionID)
|
|
119
|
-
if (session?.warming) this.notePingRunPart(session, part)
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (type === "message.updated") {
|
|
124
|
-
const info = event.properties?.info
|
|
125
|
-
const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID
|
|
126
|
-
if (!sessionID) return
|
|
127
|
-
const session = this.store.get(sessionID)
|
|
128
|
-
// Everything inside our own ping turn is ignored — except a real user
|
|
129
|
-
// message, which opencode joins to the running ping loop.
|
|
130
|
-
if (session?.warming) {
|
|
131
|
-
if (info?.role === "user") this.notePingRunUser(session, info)
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
const eventAt = info?.time?.completed ?? info?.time?.created
|
|
135
|
-
// Events are asynchronous: a completed ping may still emit message updates
|
|
136
|
-
// after `warming` clears. Their timestamps predate the recorded ping.
|
|
137
|
-
if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
|
|
138
|
-
return
|
|
139
|
-
|
|
140
|
-
if (info?.role === "user") {
|
|
141
|
-
const s = this.store.ensure(sessionID)
|
|
142
|
-
// opencode re-emits older user messages (e.g. summary updates after a
|
|
143
|
-
// turn); only a newly created message starts a turn.
|
|
144
|
-
if (!isNewUserMessage(s, info)) return
|
|
145
|
-
noteUserMessage(s, info)
|
|
146
|
-
s.busy = true
|
|
147
|
-
s.active = false
|
|
148
|
-
this.store.persist()
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (info?.role === "assistant" && typeof info?.time?.completed === "number") {
|
|
153
|
-
const s = this.store.ensure(sessionID)
|
|
154
|
-
s.lastResponseAt = info.time.completed
|
|
155
|
-
this.store.persist()
|
|
156
|
-
}
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (type === "session.status") {
|
|
161
|
-
const sessionID: string | undefined = event.properties?.sessionID
|
|
162
|
-
const status: string | undefined = event.properties?.status?.type
|
|
163
|
-
if (!sessionID || !status) return
|
|
164
|
-
|
|
165
|
-
// The ping and its optional revert emit status transitions after their
|
|
166
|
-
// requests resolve. Ignore them briefly so they cannot re-arm/reset the
|
|
167
|
-
// active warm window. Real user-message events still stop warming now.
|
|
168
|
-
const current = this.store.get(sessionID)
|
|
169
|
-
if (
|
|
170
|
-
current?.warming ||
|
|
171
|
-
(status !== "idle" &&
|
|
172
|
-
current?.lastPingAt &&
|
|
173
|
-
Date.now() - current.lastPingAt < PING_STATUS_GRACE_MS)
|
|
174
|
-
)
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
if (status === "idle") {
|
|
178
|
-
await this.armWindow(sessionID)
|
|
179
|
-
return
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const s = this.store.ensure(sessionID)
|
|
183
|
-
s.busy = true
|
|
184
|
-
s.active = false
|
|
185
|
-
this.store.persist()
|
|
186
|
-
return
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Compatibility fallback for opencode versions predating session.status.
|
|
190
|
-
if (type === "session.idle") {
|
|
191
|
-
const sessionID: string | undefined = event.properties?.sessionID
|
|
192
|
-
if (!sessionID) return
|
|
193
|
-
if (this.store.get(sessionID)?.warming) return // idle from our own ping
|
|
194
|
-
await this.armWindow(sessionID)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Track a new user message observed while a ping is in flight. Event order alone
|
|
200
|
-
* cannot tell the ping's own message from a real prompt racing it, so messages are
|
|
201
|
-
* held until their text part arrives (see `notePingRunPart`). Any message seen
|
|
202
|
-
* before the ping is sent, or a second new message, is necessarily a real turn.
|
|
203
|
-
*/
|
|
204
|
-
private notePingRunUser(s: SessionKeepalive, info: any): void {
|
|
205
|
-
const ping = s.ping
|
|
206
|
-
if (!ping || typeof info?.id !== "string") return
|
|
207
|
-
const created = info?.time?.created
|
|
208
|
-
if (typeof created === "number" && created < ping.startedAt) return // re-emitted old message
|
|
209
|
-
if (info.id === ping.messageID || ping.pending.has(info.id)) return
|
|
210
|
-
if (!ping.sent || ping.messageID) {
|
|
211
|
-
this.interruptPing(s, info)
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
ping.pending.set(info.id, info)
|
|
215
|
-
if (ping.pending.size > 1) this.interruptPing(s)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Classify a pending user message by its first text part: the ping token marks
|
|
220
|
-
* the ping's own message; any other text is a real turn joining the ping run.
|
|
221
|
-
* opencode publishes a user message's parts right after the message itself, before
|
|
222
|
-
* the run reaches any tool call.
|
|
223
|
-
*/
|
|
224
|
-
private notePingRunPart(s: SessionKeepalive, part: any): void {
|
|
225
|
-
const ping = s.ping
|
|
226
|
-
if (!ping || part?.type !== "text" || part?.synthetic) return
|
|
227
|
-
const id = part?.messageID
|
|
228
|
-
if (typeof id !== "string" || !ping.pending.has(id)) return
|
|
229
|
-
const info = ping.pending.get(id)
|
|
230
|
-
ping.pending.delete(id)
|
|
231
|
-
if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
|
|
232
|
-
ping.messageID = id
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
this.interruptPing(s, info)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
|
|
239
|
-
private interruptPing(s: SessionKeepalive, info?: unknown): void {
|
|
240
|
-
const ping = s.ping
|
|
241
|
-
if (!ping) return
|
|
242
|
-
if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`)
|
|
243
|
-
ping.interrupted = true
|
|
244
|
-
if (info) noteUserMessage(s, info)
|
|
245
|
-
s.busy = true
|
|
246
|
-
s.active = false
|
|
247
|
-
this.store.persist()
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
private async armWindow(sessionID: string): Promise<void> {
|
|
251
|
-
const s = this.store.ensure(sessionID)
|
|
252
|
-
// Duplicate idle events (including late events from a ping) must not reset the
|
|
253
|
-
// current idle stretch or extend its warm window.
|
|
254
|
-
if (s.active && !s.busy) return
|
|
255
|
-
// Serialize: only one armWindow may resolve metadata at a time per session.
|
|
256
|
-
// This prevents the session.status + session.idle double-event race from
|
|
257
|
-
// producing two concurrent resolveSession calls that can clobber each other.
|
|
258
|
-
if (s.arming) return
|
|
259
|
-
s.arming = true
|
|
260
|
-
try {
|
|
261
|
-
s.busy = false
|
|
262
|
-
await this.resolveSession(s)
|
|
263
|
-
|
|
264
|
-
// A new turn may have started while session metadata was being resolved.
|
|
265
|
-
if (s.busy || !this.enabled || !s.eligible) {
|
|
266
|
-
s.active = false
|
|
267
|
-
this.store.persist()
|
|
268
|
-
return
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Only a recent real response leaves a cache worth warming. Idle events can
|
|
272
|
-
// also fire for sessions untouched for hours (restarts, re-emitted messages).
|
|
273
|
-
const now = Date.now()
|
|
274
|
-
if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
|
|
275
|
-
s.active = false
|
|
276
|
-
this.store.persist()
|
|
277
|
-
this.logger.dbg(`not armed ${short(sessionID)} — no recent real response`)
|
|
278
|
-
return
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
this.openWindow(s, now)
|
|
282
|
-
this.store.persist()
|
|
283
|
-
this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
|
|
284
|
-
} finally {
|
|
285
|
-
s.arming = false
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** Start a warm window anchored on the last real response and the last cache touch. */
|
|
290
|
-
private openWindow(s: SessionKeepalive, now: number): void {
|
|
291
|
-
s.idleSince = now
|
|
292
|
-
s.windowEndsAt = s.lastResponseAt + this.config.windowMs
|
|
293
|
-
s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.intervalMs)
|
|
294
|
-
s.pingsSent = 0
|
|
295
|
-
s.lastPing = undefined
|
|
296
|
-
s.active = true
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** False once the cache has gone too long without a request to still be trusted warm. */
|
|
300
|
-
private cacheMayBeWarm(s: SessionKeepalive, now: number): boolean {
|
|
301
|
-
const touch = lastCacheTouch(s)
|
|
302
|
-
return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.intervalMs
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
private async tick(): Promise<void> {
|
|
306
|
-
if (!this.enabled) return
|
|
307
|
-
const now = Date.now()
|
|
308
|
-
for (const s of this.store.all()) {
|
|
309
|
-
if (!s.eligible || !s.active || s.busy || s.warming) continue
|
|
310
|
-
|
|
311
|
-
if (now >= s.windowEndsAt) {
|
|
312
|
-
s.active = false
|
|
313
|
-
this.store.persist()
|
|
314
|
-
this.logger.dbg(
|
|
315
|
-
`window closed ${short(s.sessionID)} after ${s.pingsSent} ping(s) — cache may cool`,
|
|
316
|
-
)
|
|
317
|
-
continue
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (now < s.nextPingAt) continue
|
|
321
|
-
|
|
322
|
-
// Far overdue (host sleep, stalled event loop): the cache is presumed cold.
|
|
323
|
-
// Stop rather than pay a cold write or fire a catch-up burst.
|
|
324
|
-
if (!this.cacheMayBeWarm(s, now)) {
|
|
325
|
-
s.active = false
|
|
326
|
-
this.store.persist()
|
|
327
|
-
this.logger.dbg(
|
|
328
|
-
`window closed ${short(s.sessionID)} — ping overdue, cache presumed cold`,
|
|
329
|
-
)
|
|
330
|
-
continue
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
void this.firePing(s.sessionID)
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private async firePing(sessionID: string): Promise<void> {
|
|
338
|
-
const s = this.store.get(sessionID)
|
|
339
|
-
if (!s || s.warming || s.busy || !s.active) return
|
|
340
|
-
|
|
341
|
-
const ping: InflightPing = {
|
|
342
|
-
startedAt: Date.now(),
|
|
343
|
-
sent: false,
|
|
344
|
-
pending: new Map(),
|
|
345
|
-
interrupted: false,
|
|
346
|
-
}
|
|
347
|
-
s.warming = true
|
|
348
|
-
s.ping = ping
|
|
349
|
-
this.store.persist()
|
|
350
|
-
|
|
351
|
-
try {
|
|
352
|
-
// opencode joins a prompt to a running turn instead of rejecting it, so a
|
|
353
|
-
// missed busy signal would inject the ping into real work. Ask the server.
|
|
354
|
-
if (!(await this.isIdleOnServer(sessionID))) {
|
|
355
|
-
s.busy = true
|
|
356
|
-
s.active = false
|
|
357
|
-
this.logger.dbg(`ping ${short(sessionID)} skipped — session busy`)
|
|
358
|
-
return
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const request = s.request ?? (await this.lookupRequest(s))
|
|
362
|
-
if (!request) {
|
|
363
|
-
s.active = false
|
|
364
|
-
this.logger.dbg(`ping ${short(sessionID)} skipped — last real request unknown`)
|
|
365
|
-
return
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// A real turn may have started (or keepalive been disabled) while checking.
|
|
369
|
-
if (ping.interrupted || s.busy || !s.active || !this.enabled) return
|
|
370
|
-
|
|
371
|
-
ping.sent = true
|
|
372
|
-
// Count every request attempt. A transport/provider failure still consumed a
|
|
373
|
-
// scheduled ping slot and must remain visible in the footer.
|
|
374
|
-
s.pingsSent += 1
|
|
375
|
-
this.store.persist()
|
|
376
|
-
|
|
377
|
-
const res = await this.client.session.prompt({
|
|
378
|
-
path: { id: sessionID },
|
|
379
|
-
body: {
|
|
380
|
-
parts: [{ type: "text", text: this.config.pingToken }],
|
|
381
|
-
...(request.agent ? { agent: request.agent } : {}),
|
|
382
|
-
model: { providerID: request.providerID, modelID: request.modelID },
|
|
383
|
-
...(request.variant ? { variant: request.variant } : {}),
|
|
384
|
-
},
|
|
385
|
-
})
|
|
386
|
-
const info = res?.data?.info ?? res?.info
|
|
387
|
-
|
|
388
|
-
if (ping.interrupted) {
|
|
389
|
-
// A real turn ran inside this request: its reply is real work. Keep it,
|
|
390
|
-
// do not score it as a ping, and re-arm from its completion.
|
|
391
|
-
const completed = info?.time?.completed
|
|
392
|
-
if (typeof completed === "number") s.lastResponseAt = completed
|
|
393
|
-
this.logger.dbg(
|
|
394
|
-
`ping ${short(sessionID)} absorbed a real turn — kept, not reverted`,
|
|
395
|
-
)
|
|
396
|
-
return
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const cache = info?.tokens?.cache ?? {}
|
|
400
|
-
const record = {
|
|
401
|
-
at: Date.now(),
|
|
402
|
-
hit: Number(cache.read ?? 0) > 0,
|
|
403
|
-
input: Number(info?.tokens?.input ?? 0),
|
|
404
|
-
cacheRead: Number(cache.read ?? 0),
|
|
405
|
-
cacheWrite: Number(cache.write ?? 0),
|
|
406
|
-
output: Number(info?.tokens?.output ?? 0),
|
|
407
|
-
}
|
|
408
|
-
s.lastPing = record
|
|
409
|
-
this.logger.dbg(
|
|
410
|
-
`ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} ` +
|
|
411
|
-
`in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,
|
|
412
|
-
)
|
|
413
|
-
|
|
414
|
-
if (this.config.revertPing) await this.revertPing(sessionID, ping)
|
|
415
|
-
} catch (err) {
|
|
416
|
-
this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))
|
|
417
|
-
} finally {
|
|
418
|
-
s.warming = false
|
|
419
|
-
s.ping = undefined
|
|
420
|
-
const now = Date.now()
|
|
421
|
-
if (ping.sent) {
|
|
422
|
-
s.lastPingAt = now
|
|
423
|
-
// Schedule relative to the intended time so ping duration doesn't
|
|
424
|
-
// compound drift, but never into the past: that would fire catch-up
|
|
425
|
-
// pings on consecutive ticks.
|
|
426
|
-
const next = s.nextPingAt + jitter(this.intervalMs)
|
|
427
|
-
s.nextPingAt = next > now ? next : now + jitter(this.intervalMs)
|
|
428
|
-
}
|
|
429
|
-
this.store.persist()
|
|
430
|
-
if (ping.sent && ping.interrupted) {
|
|
431
|
-
// The joined real turn has finished with this request; start its window.
|
|
432
|
-
s.busy = false
|
|
433
|
-
s.active = false
|
|
434
|
-
void this.armWindow(sessionID)
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** True only when the server positively reports the session idle. */
|
|
440
|
-
private async isIdleOnServer(sessionID: string): Promise<boolean> {
|
|
441
|
-
try {
|
|
442
|
-
const res = await this.client.session.status()
|
|
443
|
-
const statuses = res?.data ?? res ?? {}
|
|
444
|
-
return (statuses?.[sessionID]?.type ?? "idle") === "idle"
|
|
445
|
-
} catch (err) {
|
|
446
|
-
this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err))
|
|
447
|
-
return false
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/** Recover the last real turn's request settings when no user event was observed. */
|
|
452
|
-
private async lookupRequest(s: SessionKeepalive): Promise<PingRequest | undefined> {
|
|
453
|
-
const messages = await this.recentMessages(s.sessionID)
|
|
454
|
-
if (!messages) return undefined
|
|
455
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
456
|
-
const m = messages[i]
|
|
457
|
-
if (m?.info?.role !== "user" || this.isPingMessage(m)) continue
|
|
458
|
-
const request = requestFrom(m.info)
|
|
459
|
-
if (request) {
|
|
460
|
-
s.request = request
|
|
461
|
-
return request
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
return undefined
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
private async recentMessages(sessionID: string): Promise<any[] | undefined> {
|
|
468
|
-
try {
|
|
469
|
-
const res = await this.client.session.messages({
|
|
470
|
-
path: { id: sessionID },
|
|
471
|
-
query: { limit: MESSAGE_LOOKUP_LIMIT },
|
|
472
|
-
})
|
|
473
|
-
const messages = res?.data ?? res
|
|
474
|
-
return Array.isArray(messages) ? messages : undefined
|
|
475
|
-
} catch (err) {
|
|
476
|
-
this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err))
|
|
477
|
-
return undefined
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
private isPingMessage(m: any): boolean {
|
|
482
|
-
const text = (m?.parts ?? [])
|
|
483
|
-
.filter((p: any) => p?.type === "text")
|
|
484
|
-
.map((p: any) => p?.text ?? "")
|
|
485
|
-
.join("")
|
|
486
|
-
.trim()
|
|
487
|
-
return text === this.config.pingToken
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Remove the `~` user + `~` assistant turn so it never enters real context.
|
|
492
|
-
* Revert drops the target and everything after it (and rolls back file
|
|
493
|
-
* snapshots), so it is skipped unless only the ping's own replies follow.
|
|
494
|
-
*/
|
|
495
|
-
private async revertPing(sessionID: string, ping: InflightPing): Promise<void> {
|
|
496
|
-
const messages = await this.recentMessages(sessionID)
|
|
497
|
-
if (!messages) return
|
|
498
|
-
|
|
499
|
-
let index = -1
|
|
500
|
-
for (let i = 0; i < messages.length; i++) {
|
|
501
|
-
const m = messages[i]
|
|
502
|
-
if (m?.info?.role !== "user") continue
|
|
503
|
-
if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
|
|
504
|
-
index = i
|
|
505
|
-
}
|
|
506
|
-
if (index < 0) {
|
|
507
|
-
this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)
|
|
508
|
-
return
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const target: string = messages[index].info.id
|
|
512
|
-
const foreign = messages
|
|
513
|
-
.slice(index + 1)
|
|
514
|
-
.some((m: any) => m?.info?.role !== "assistant" || m?.info?.parentID !== target)
|
|
515
|
-
if (foreign) {
|
|
516
|
-
this.logger.warn(`ping ${short(sessionID)} not reverted — other messages followed it`)
|
|
517
|
-
return
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
try {
|
|
521
|
-
await this.client.session.revert({
|
|
522
|
-
path: { id: sessionID },
|
|
523
|
-
body: { messageID: target },
|
|
524
|
-
})
|
|
525
|
-
} catch (err) {
|
|
526
|
-
// BusyError (a real turn started) or similar — leave the ping visible.
|
|
527
|
-
this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err))
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
private isRecentPing(m: any, ping: InflightPing): boolean {
|
|
532
|
-
return (
|
|
533
|
-
Number(m?.info?.time?.created ?? 0) >= ping.startedAt - 2_000 && this.isPingMessage(m)
|
|
534
|
-
)
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
private async resolveSession(s: SessionKeepalive): Promise<void> {
|
|
538
|
-
try {
|
|
539
|
-
const res = await this.client.session.get({ path: { id: s.sessionID } })
|
|
540
|
-
const info = res?.data ?? res
|
|
541
|
-
if (info?.parentID && !this.config.includeChildSessions) {
|
|
542
|
-
s.eligible = false
|
|
543
|
-
s.modelLabel = info?.model?.id ?? "child"
|
|
544
|
-
return
|
|
545
|
-
}
|
|
546
|
-
s.modelLabel = info?.model?.id ?? "unknown"
|
|
547
|
-
s.eligible = isEligibleModel(this.config, info?.model?.providerID, info?.model?.id)
|
|
548
|
-
} catch (err) {
|
|
549
|
-
s.eligible = false
|
|
550
|
-
this.logger.dbg(`resolve ${short(s.sessionID)} failed`, errText(err))
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
private pollControl(): void {
|
|
555
|
-
const control = readControl(this.directory)
|
|
556
|
-
if (!control || control.updatedAt <= this.controlUpdatedAt) return
|
|
557
|
-
this.controlUpdatedAt = control.updatedAt
|
|
558
|
-
const now = Date.now()
|
|
559
|
-
let changed = false
|
|
560
|
-
|
|
561
|
-
if (control.intervalMs !== this.intervalOverride) {
|
|
562
|
-
this.intervalOverride = control.intervalMs
|
|
563
|
-
this.store.setIntervalMs(this.intervalMs)
|
|
564
|
-
// Re-anchor pending pings on the new cadence. An in-flight ping schedules
|
|
565
|
-
// its successor with the new interval when it finishes.
|
|
566
|
-
for (const session of this.store.all()) {
|
|
567
|
-
if (session.active && !session.warming)
|
|
568
|
-
session.nextPingAt = Math.max(now, lastCacheTouch(session) + this.intervalMs)
|
|
569
|
-
}
|
|
570
|
-
this.logger.info(
|
|
571
|
-
`runtime interval ${Math.round(this.intervalMs / 1000)}s` +
|
|
572
|
-
(control.intervalMs === undefined ? " (config)" : ""),
|
|
573
|
-
)
|
|
574
|
-
changed = true
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
const enabled = control.enabled ?? this.config.enabled
|
|
578
|
-
if (enabled !== this.enabled) {
|
|
579
|
-
this.enabled = enabled
|
|
580
|
-
this.store.setEnabled(enabled)
|
|
581
|
-
for (const session of this.store.all()) {
|
|
582
|
-
if (!enabled) {
|
|
583
|
-
session.active = false
|
|
584
|
-
continue
|
|
585
|
-
}
|
|
586
|
-
// Only re-arm sessions with a real response whose cache may still be warm,
|
|
587
|
-
// and that are not currently occupied.
|
|
588
|
-
if (!session.eligible || session.busy || session.warming) continue
|
|
589
|
-
if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue
|
|
590
|
-
this.openWindow(session, now)
|
|
591
|
-
}
|
|
592
|
-
this.logger.info(`runtime ${enabled ? "enabled" : "disabled"}`)
|
|
593
|
-
changed = true
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
if (changed) this.store.persist()
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/** Recover when a session.status idle event is missed by reconciling with the API. */
|
|
600
|
-
private async reconcileStatus(): Promise<void> {
|
|
601
|
-
if (this.reconcilingStatus) return
|
|
602
|
-
const busy = this.store.all().filter((session) => session.busy && !session.warming)
|
|
603
|
-
if (busy.length === 0) return
|
|
604
|
-
|
|
605
|
-
this.reconcilingStatus = true
|
|
606
|
-
try {
|
|
607
|
-
const res = await this.client.session.status()
|
|
608
|
-
const statuses = res?.data ?? res ?? {}
|
|
609
|
-
for (const session of busy) {
|
|
610
|
-
const status = statuses?.[session.sessionID]?.type ?? "idle"
|
|
611
|
-
if (status === "idle") await this.armWindow(session.sessionID)
|
|
612
|
-
}
|
|
613
|
-
} catch (err) {
|
|
614
|
-
this.logger.dbg("status reconciliation failed", errText(err))
|
|
615
|
-
} finally {
|
|
616
|
-
this.reconcilingStatus = false
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/** Last time the session's prompt cache was known to be used (real response or ping). */
|
|
622
|
-
function lastCacheTouch(s: SessionKeepalive): number {
|
|
623
|
-
return Math.max(s.lastResponseAt, s.lastPingAt ?? 0)
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function isNewUserMessage(s: SessionKeepalive, info: any): boolean {
|
|
627
|
-
if (typeof info?.id === "string" && info.id === s.lastUserID) return false
|
|
628
|
-
const created = info?.time?.created
|
|
629
|
-
return typeof created !== "number" || created >= (s.lastUserAt ?? 0)
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function noteUserMessage(s: SessionKeepalive, info: any): void {
|
|
633
|
-
if (typeof info?.id === "string") s.lastUserID = info.id
|
|
634
|
-
const created = info?.time?.created
|
|
635
|
-
if (typeof created === "number") s.lastUserAt = created
|
|
636
|
-
const request = requestFrom(info)
|
|
637
|
-
if (request) s.request = request
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/** Extract replayable request settings from a user message's info. */
|
|
641
|
-
function requestFrom(info: any): PingRequest | undefined {
|
|
642
|
-
const model = info?.model
|
|
643
|
-
const providerID = model?.providerID
|
|
644
|
-
const modelID = model?.modelID ?? model?.id
|
|
645
|
-
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
|
646
|
-
const variant = model?.variant ?? info?.variant
|
|
647
|
-
return {
|
|
648
|
-
agent: typeof info?.agent === "string" ? info.agent : undefined,
|
|
649
|
-
providerID,
|
|
650
|
-
modelID,
|
|
651
|
-
variant: typeof variant === "string" && variant !== "default" ? variant : undefined,
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function short(sessionID: string): string {
|
|
656
|
-
return sessionID.slice(-6)
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function jitter(base: number): number {
|
|
660
|
-
return Math.max(1_000, base + Math.floor((Math.random() * 2 - 1) * JITTER_MS))
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function errText(err: unknown): string {
|
|
664
|
-
return err instanceof Error ? err.message : String(err)
|
|
665
|
-
}
|
package/lib/model.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
}
|