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