@vikrant82/opencode-cache-keepalive 0.1.4 → 0.1.6

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/lib/keepalive.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { KeepaliveConfig } from "./config"
2
2
  import { readControl } from "./control"
3
3
  import { isEligibleModel } from "./model"
4
- import type { KeepaliveStore, SessionKeepalive } from "./state"
4
+ import type { InflightPing, KeepaliveStore, PingRequest, SessionKeepalive } from "./state"
5
5
  import type { Logger } from "./logger"
6
6
 
7
7
  const TICK_MS = 15_000
@@ -9,19 +9,32 @@ const JITTER_MS = 15_000
9
9
  const CONTROL_POLL_MS = 1_000
10
10
  const STATUS_POLL_MS = 2_000
11
11
  const PING_STATUS_GRACE_MS = 5_000
12
- /** Backstop: clear a stuck `busy` flag after this long without an idle event. */
13
- const BUSY_TIMEOUT_MS = 300_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
14
20
 
15
21
  /**
16
22
  * The keepalive engine.
17
23
  *
18
24
  * Lifecycle per session:
19
- * 1. A real turn finishes -> `session.idle` -> arm a warm window and schedule pings.
25
+ * 1. A real turn finishes -> `session.status` idle -> arm a warm window anchored on
26
+ * the real response and schedule pings.
20
27
  * 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.
21
- * 3. `firePing` sends a `~` prompt through opencode (exact cached prefix, reuses
22
- * the session's model/auth), reads `usage` to confirm a cache hit, then optionally
23
- * reverts the `~`/`~` turn so it does not pollute the real conversation.
24
- * 4. A new real user turn or the window closing stops warming.
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.
25
38
  */
26
39
  export class KeepaliveEngine {
27
40
  private timer: ReturnType<typeof setInterval> | undefined
@@ -30,6 +43,7 @@ export class KeepaliveEngine {
30
43
  private controlUpdatedAt = 0
31
44
  private reconcilingStatus = false
32
45
  private enabled: boolean
46
+ private intervalOverride: number | undefined
33
47
 
34
48
  constructor(
35
49
  private readonly client: any,
@@ -40,8 +54,15 @@ export class KeepaliveEngine {
40
54
  ) {
41
55
  const control = readControl(directory)
42
56
  this.enabled = control?.enabled ?? config.enabled
57
+ this.intervalOverride = control?.intervalMs
43
58
  this.controlUpdatedAt = control?.updatedAt ?? 0
44
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
45
66
  }
46
67
 
47
68
  start(): void {
@@ -52,7 +73,7 @@ export class KeepaliveEngine {
52
73
  this.store.persist()
53
74
  this.logger.info(
54
75
  `started (${this.enabled ? "enabled" : "disabled"}, ` +
55
- `interval=${Math.round(this.config.intervalMs / 1000)}s, ` +
76
+ `interval=${Math.round(this.intervalMs / 1000)}s, ` +
56
77
  `window=${Math.round(this.config.windowMs / 60_000)}m)`,
57
78
  )
58
79
  }
@@ -67,9 +88,14 @@ export class KeepaliveEngine {
67
88
  this.statusTimer = undefined
68
89
  }
69
90
 
70
- /** True while a ping is in flight — used to block tool calls during a ping. */
71
- isWarming(sessionID: string): boolean {
72
- return !!this.store.get(sessionID)?.warming
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
73
99
  }
74
100
 
75
101
  async onEvent(event: any): Promise<void> {
@@ -85,13 +111,26 @@ export class KeepaliveEngine {
85
111
  return
86
112
  }
87
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
+
88
123
  if (type === "message.updated") {
89
124
  const info = event.properties?.info
90
125
  const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID
91
126
  if (!sessionID) return
92
127
  const session = this.store.get(sessionID)
93
- // Ignore everything that happens inside our own ping turn.
94
- if (session?.warming) return
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
+ }
95
134
  const eventAt = info?.time?.completed ?? info?.time?.created
96
135
  // Events are asynchronous: a completed ping may still emit message updates
97
136
  // after `warming` clears. Their timestamps predate the recorded ping.
@@ -100,8 +139,11 @@ export class KeepaliveEngine {
100
139
 
101
140
  if (info?.role === "user") {
102
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)
103
146
  s.busy = true
104
- s.busySince = Date.now()
105
147
  s.active = false
106
148
  this.store.persist()
107
149
  return
@@ -139,7 +181,6 @@ export class KeepaliveEngine {
139
181
 
140
182
  const s = this.store.ensure(sessionID)
141
183
  s.busy = true
142
- s.busySince = Date.now()
143
184
  s.active = false
144
185
  this.store.persist()
145
186
  return
@@ -154,6 +195,58 @@ export class KeepaliveEngine {
154
195
  }
155
196
  }
156
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
+
157
250
  private async armWindow(sessionID: string): Promise<void> {
158
251
  const s = this.store.ensure(sessionID)
159
252
  // Duplicate idle events (including late events from a ping) must not reset the
@@ -166,7 +259,6 @@ export class KeepaliveEngine {
166
259
  s.arming = true
167
260
  try {
168
261
  s.busy = false
169
- s.busySince = undefined
170
262
  await this.resolveSession(s)
171
263
 
172
264
  // A new turn may have started while session metadata was being resolved.
@@ -176,14 +268,17 @@ export class KeepaliveEngine {
176
268
  return
177
269
  }
178
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).
179
273
  const now = Date.now()
180
- if (!s.lastResponseAt) s.lastResponseAt = now
181
- s.idleSince = now
182
- s.windowEndsAt = now + this.config.windowMs
183
- s.nextPingAt = now + this.config.intervalMs
184
- s.pingsSent = 0
185
- s.lastPing = undefined
186
- s.active = true
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)
187
282
  this.store.persist()
188
283
  this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
189
284
  } finally {
@@ -191,20 +286,26 @@ export class KeepaliveEngine {
191
286
  }
192
287
  }
193
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
+
194
305
  private async tick(): Promise<void> {
195
306
  if (!this.enabled) return
196
307
  const now = Date.now()
197
308
  for (const s of this.store.all()) {
198
- // Backstop: if busy is stuck (missed idle event + reconcile failure),
199
- // clear it after BUSY_TIMEOUT_MS so the session can re-arm.
200
- if (s.busy && s.busySince && now - s.busySince > BUSY_TIMEOUT_MS) {
201
- this.logger.dbg(`busy timeout ${short(s.sessionID)} — clearing stuck flag`)
202
- s.busy = false
203
- s.busySince = undefined
204
- void this.armWindow(s.sessionID)
205
- continue
206
- }
207
-
208
309
  if (!s.eligible || !s.active || s.busy || s.warming) continue
209
310
 
210
311
  if (now >= s.windowEndsAt) {
@@ -216,28 +317,85 @@ export class KeepaliveEngine {
216
317
  continue
217
318
  }
218
319
 
219
- if (now >= s.nextPingAt) void this.firePing(s.sessionID)
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)
220
334
  }
221
335
  }
222
336
 
223
337
  private async firePing(sessionID: string): Promise<void> {
224
338
  const s = this.store.get(sessionID)
225
- if (!s || s.warming || s.busy) return
339
+ if (!s || s.warming || s.busy || !s.active) return
226
340
 
341
+ const ping: InflightPing = {
342
+ startedAt: Date.now(),
343
+ sent: false,
344
+ pending: new Map(),
345
+ interrupted: false,
346
+ }
227
347
  s.warming = true
228
- // Count every request attempt. A transport/provider failure still consumed a
229
- // scheduled ping slot and must remain visible in the footer.
230
- s.pingsSent += 1
348
+ s.ping = ping
231
349
  this.store.persist()
232
- const startAt = Date.now()
233
350
 
234
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
+
235
377
  const res = await this.client.session.prompt({
236
378
  path: { id: sessionID },
237
- body: { parts: [{ type: "text", text: this.config.pingToken }] },
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
+ },
238
385
  })
239
-
240
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
+
241
399
  const cache = info?.tokens?.cache ?? {}
242
400
  const record = {
243
401
  at: Date.now(),
@@ -253,51 +411,112 @@ export class KeepaliveEngine {
253
411
  `in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,
254
412
  )
255
413
 
256
- if (this.config.revertPing) await this.revertPing(sessionID, startAt)
414
+ if (this.config.revertPing) await this.revertPing(sessionID, ping)
257
415
  } catch (err) {
258
416
  this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))
259
417
  } finally {
260
418
  s.warming = false
261
- s.lastPingAt = Date.now()
262
- // Schedule relative to the intended time so ping duration doesn't
263
- // compound drift. Floor at now+1s to avoid immediate re-fire if the
264
- // ping took longer than the interval.
265
- s.nextPingAt = Math.max(
266
- Date.now() + 1_000,
267
- s.nextPingAt + jitter(this.config.intervalMs),
268
- )
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
+ }
269
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
+ }
270
436
  }
271
437
  }
272
438
 
273
- /** Remove the `~` user + `~` assistant turn so it never enters real context. */
274
- private async revertPing(sessionID: string, startAt: number): Promise<void> {
275
- let messages: any[]
439
+ /** True only when the server positively reports the session idle. */
440
+ private async isIdleOnServer(sessionID: string): Promise<boolean> {
276
441
  try {
277
- const res = await this.client.session.messages({ path: { id: sessionID } })
278
- messages = res?.data ?? res ?? []
442
+ const res = await this.client.session.status()
443
+ const statuses = res?.data ?? res ?? {}
444
+ return (statuses?.[sessionID]?.type ?? "idle") === "idle"
279
445
  } catch (err) {
280
- this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err))
281
- return
446
+ this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err))
447
+ return false
282
448
  }
449
+ }
283
450
 
284
- let target: string | undefined
285
- for (const m of messages) {
286
- if (m?.info?.role !== "user") continue
287
- if (Number(m.info?.time?.created ?? 0) < startAt - 2_000) continue
288
- const text = (m.parts ?? [])
289
- .filter((p: any) => p?.type === "text")
290
- .map((p: any) => p?.text ?? "")
291
- .join("")
292
- .trim()
293
- if (text === this.config.pingToken) target = m.info.id
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
294
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
+ }
295
489
 
296
- if (!target) {
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) {
297
507
  this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)
298
508
  return
299
509
  }
300
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
+
301
520
  try {
302
521
  await this.client.session.revert({
303
522
  path: { id: sessionID },
@@ -309,6 +528,12 @@ export class KeepaliveEngine {
309
528
  }
310
529
  }
311
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
+
312
537
  private async resolveSession(s: SessionKeepalive): Promise<void> {
313
538
  try {
314
539
  const res = await this.client.session.get({ path: { id: s.sessionID } })
@@ -330,29 +555,45 @@ export class KeepaliveEngine {
330
555
  const control = readControl(this.directory)
331
556
  if (!control || control.updatedAt <= this.controlUpdatedAt) return
332
557
  this.controlUpdatedAt = control.updatedAt
333
- if (control.enabled === this.enabled) return
334
-
335
- this.enabled = control.enabled
336
- this.store.setEnabled(control.enabled)
337
558
  const now = Date.now()
338
- for (const session of this.store.all()) {
339
- if (!control.enabled) {
340
- session.active = false
341
- continue
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)
342
591
  }
343
- // Only re-arm sessions that have had a real turn (cache to warm) and
344
- // are not currently occupied.
345
- if (!session.eligible || session.busy || session.warming) continue
346
- if (!session.lastResponseAt) continue
347
- session.idleSince = now
348
- session.windowEndsAt = now + this.config.windowMs
349
- session.nextPingAt = now + this.config.intervalMs
350
- session.pingsSent = 0
351
- session.lastPing = undefined
352
- session.active = true
592
+ this.logger.info(`runtime ${enabled ? "enabled" : "disabled"}`)
593
+ changed = true
353
594
  }
354
- this.store.persist()
355
- this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`)
595
+
596
+ if (changed) this.store.persist()
356
597
  }
357
598
 
358
599
  /** Recover when a session.status idle event is missed by reconciling with the API. */
@@ -377,6 +618,40 @@ export class KeepaliveEngine {
377
618
  }
378
619
  }
379
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
+
380
655
  function short(sessionID: string): string {
381
656
  return sessionID.slice(-6)
382
657
  }