@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.
Files changed (44) hide show
  1. package/MIGRATION.md +57 -0
  2. package/README.md +75 -51
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1093 -649
  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 -37
  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 -117
  15. package/dist/lib/state.d.ts.map +1 -1
  16. package/dist/lib/tui/commands.d.ts +1 -6
  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.map +1 -1
  21. package/dist/lib/types.d.ts +123 -0
  22. package/dist/lib/types.d.ts.map +1 -0
  23. package/dist/tui.d.ts +3 -0
  24. package/dist/tui.d.ts.map +1 -1
  25. package/lib/config.ts +106 -74
  26. package/lib/control.ts +50 -86
  27. package/lib/paths.ts +12 -14
  28. package/lib/replay-warmer.ts +1200 -0
  29. package/lib/state.ts +171 -181
  30. package/lib/tui/commands.tsx +36 -144
  31. package/lib/tui/footer.tsx +54 -156
  32. package/lib/tui/format.ts +4 -0
  33. package/lib/types.ts +130 -0
  34. package/package.json +3 -2
  35. package/tui.tsx +40 -0
  36. package/dist/lib/keepalive.d.ts +0 -88
  37. package/dist/lib/keepalive.d.ts.map +0 -1
  38. package/dist/lib/model.d.ts +0 -8
  39. package/dist/lib/model.d.ts.map +0 -1
  40. package/dist/lib/system.d.ts +0 -11
  41. package/dist/lib/system.d.ts.map +0 -1
  42. package/lib/keepalive.ts +0 -665
  43. package/lib/model.ts +0 -19
  44. 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
- }