@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/README.md +4 -1
- package/dist/index.js +313 -75
- package/dist/index.js.map +1 -1
- package/dist/lib/control.d.ts +34 -1
- package/dist/lib/control.d.ts.map +1 -1
- package/dist/lib/keepalive.d.ts +53 -8
- package/dist/lib/keepalive.d.ts.map +1 -1
- package/dist/lib/state.d.ts +48 -5
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +7 -0
- package/dist/lib/tui/commands.d.ts.map +1 -1
- package/dist/lib/tui/footer.d.ts.map +1 -1
- package/dist/lib/tui/format.d.ts +8 -0
- package/dist/lib/tui/format.d.ts.map +1 -1
- package/lib/control.ts +96 -11
- package/lib/keepalive.ts +364 -89
- package/lib/state.ts +57 -7
- package/lib/tui/commands.tsx +184 -0
- package/lib/tui/footer.tsx +33 -6
- package/lib/tui/format.ts +36 -0
- package/package.json +3 -1
- package/lib/tui/commands.ts +0 -59
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Keep LLM prompt caches warm during idle periods with invisible pings and a live
|
|
|
18
18
|
- **Live TUI footer**: idle time, pings sent/left, last ping cache hit/miss with token counts
|
|
19
19
|
- **Synthetic ping reversion**: removes `~`/`~` turns from conversation history
|
|
20
20
|
- **Runtime toggle**: `/keepalive-toggle`, `/keepalive-on`, `/keepalive-off` slash commands
|
|
21
|
+
- **Runtime interval override**: set a per-project ping interval with presets, custom durations, or reset to the plugin default
|
|
21
22
|
- **Secure mode support**: works with `OPENCODE_SERVER_PASSWORD`
|
|
22
23
|
- **Configurable provider/model allowlists**
|
|
23
24
|
|
|
@@ -85,7 +86,7 @@ All options can be set via plugin config in `opencode.json` or environment varia
|
|
|
85
86
|
The plugin registers a sidebar footer showing live keepalive status:
|
|
86
87
|
|
|
87
88
|
```
|
|
88
|
-
keepalive armed
|
|
89
|
+
keepalive armed every 4m 30s
|
|
89
90
|
idle 03:42 · sent 2 · left 10
|
|
90
91
|
✓ hit input 12.4k · read 11.8k · write 0
|
|
91
92
|
```
|
|
@@ -93,6 +94,7 @@ idle 03:42 · sent 2 · left 10
|
|
|
93
94
|
Metrics:
|
|
94
95
|
|
|
95
96
|
- **idle**: Time since last real assistant response
|
|
97
|
+
- **every**: Effective ping interval, including any runtime project override
|
|
96
98
|
- **sent**: Ping attempts in current warm window (includes failures)
|
|
97
99
|
- **left**: Estimated pings remaining before window expires
|
|
98
100
|
- **hit/miss**: Cache hit status of last ping with token breakdown
|
|
@@ -102,6 +104,7 @@ Slash commands (available in TUI palette):
|
|
|
102
104
|
- `/keepalive-toggle` — Toggle keepalive on/off at runtime
|
|
103
105
|
- `/keepalive-on` — Enable keepalive
|
|
104
106
|
- `/keepalive-off` — Disable keepalive
|
|
107
|
+
- `/keepalive-interval` — Set this project's ping interval at runtime (presets for 5-minute and 30-minute caches, custom durations, or reset to the plugin default); persists per project until changed
|
|
105
108
|
|
|
106
109
|
## How It Works
|
|
107
110
|
|
package/dist/index.js
CHANGED
|
@@ -88,6 +88,7 @@ function numEnv(raw, fallback) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
// lib/control.ts
|
|
91
|
+
import { randomUUID } from "crypto";
|
|
91
92
|
import { readFileSync } from "fs";
|
|
92
93
|
import { mkdir, rename, writeFile } from "fs/promises";
|
|
93
94
|
import { dirname } from "path";
|
|
@@ -118,18 +119,27 @@ function logFilePath() {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
// lib/control.ts
|
|
122
|
+
var MIN_INTERVAL_MS = 6e4;
|
|
123
|
+
var MAX_INTERVAL_MS = 24 * 36e5;
|
|
124
|
+
function isValidInterval(ms) {
|
|
125
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= MIN_INTERVAL_MS && ms <= MAX_INTERVAL_MS;
|
|
126
|
+
}
|
|
121
127
|
function readControl(directory) {
|
|
122
128
|
try {
|
|
123
|
-
const value = JSON.parse(
|
|
124
|
-
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
const value = JSON.parse(readFileSync(controlFilePath(directory), "utf8"));
|
|
130
|
+
if (value?.version !== 1 || typeof value.updatedAt !== "number") return void 0;
|
|
131
|
+
if (value.enabled !== void 0 && typeof value.enabled !== "boolean") return void 0;
|
|
132
|
+
return {
|
|
133
|
+
version: 1,
|
|
134
|
+
updatedAt: value.updatedAt,
|
|
135
|
+
...typeof value.enabled === "boolean" ? { enabled: value.enabled } : {},
|
|
136
|
+
...isValidInterval(value.intervalMs) ? { intervalMs: value.intervalMs } : {}
|
|
137
|
+
};
|
|
129
138
|
} catch {
|
|
130
139
|
return void 0;
|
|
131
140
|
}
|
|
132
141
|
}
|
|
142
|
+
var updateQueue = Promise.resolve();
|
|
133
143
|
|
|
134
144
|
// lib/model.ts
|
|
135
145
|
function isEligibleModel(config, providerID, modelID) {
|
|
@@ -147,7 +157,8 @@ var JITTER_MS = 15e3;
|
|
|
147
157
|
var CONTROL_POLL_MS = 1e3;
|
|
148
158
|
var STATUS_POLL_MS = 2e3;
|
|
149
159
|
var PING_STATUS_GRACE_MS = 5e3;
|
|
150
|
-
var
|
|
160
|
+
var COLD_AFTER_INTERVALS = 2;
|
|
161
|
+
var MESSAGE_LOOKUP_LIMIT = 50;
|
|
151
162
|
var KeepaliveEngine = class {
|
|
152
163
|
constructor(client, config, store, logger, directory) {
|
|
153
164
|
this.client = client;
|
|
@@ -157,8 +168,10 @@ var KeepaliveEngine = class {
|
|
|
157
168
|
this.directory = directory;
|
|
158
169
|
const control = readControl(directory);
|
|
159
170
|
this.enabled = control?.enabled ?? config.enabled;
|
|
171
|
+
this.intervalOverride = control?.intervalMs;
|
|
160
172
|
this.controlUpdatedAt = control?.updatedAt ?? 0;
|
|
161
173
|
this.store.setEnabled(this.enabled);
|
|
174
|
+
this.store.setIntervalMs(this.intervalMs);
|
|
162
175
|
}
|
|
163
176
|
client;
|
|
164
177
|
config;
|
|
@@ -171,6 +184,11 @@ var KeepaliveEngine = class {
|
|
|
171
184
|
controlUpdatedAt = 0;
|
|
172
185
|
reconcilingStatus = false;
|
|
173
186
|
enabled;
|
|
187
|
+
intervalOverride;
|
|
188
|
+
/** Effective ping interval: the runtime override from the control file, else config. */
|
|
189
|
+
get intervalMs() {
|
|
190
|
+
return this.intervalOverride ?? this.config.intervalMs;
|
|
191
|
+
}
|
|
174
192
|
start() {
|
|
175
193
|
if (this.timer) return;
|
|
176
194
|
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
|
@@ -178,7 +196,7 @@ var KeepaliveEngine = class {
|
|
|
178
196
|
this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS);
|
|
179
197
|
this.store.persist();
|
|
180
198
|
this.logger.info(
|
|
181
|
-
`started (${this.enabled ? "enabled" : "disabled"}, interval=${Math.round(this.
|
|
199
|
+
`started (${this.enabled ? "enabled" : "disabled"}, interval=${Math.round(this.intervalMs / 1e3)}s, window=${Math.round(this.config.windowMs / 6e4)}m)`
|
|
182
200
|
);
|
|
183
201
|
}
|
|
184
202
|
stop() {
|
|
@@ -190,9 +208,14 @@ var KeepaliveEngine = class {
|
|
|
190
208
|
if (this.statusTimer) clearInterval(this.statusTimer);
|
|
191
209
|
this.statusTimer = void 0;
|
|
192
210
|
}
|
|
193
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
211
|
+
/**
|
|
212
|
+
* True while a ping turn owns the session, so tool calls must be blocked. Becomes
|
|
213
|
+
* false as soon as a real user message joins the in-flight ping run: the rest of
|
|
214
|
+
* that run is real work and needs its tools.
|
|
215
|
+
*/
|
|
216
|
+
shouldBlockTools(sessionID) {
|
|
217
|
+
const s = this.store.get(sessionID);
|
|
218
|
+
return !!s?.warming && !s.ping?.interrupted;
|
|
196
219
|
}
|
|
197
220
|
async onEvent(event) {
|
|
198
221
|
const type = event?.type;
|
|
@@ -205,19 +228,31 @@ var KeepaliveEngine = class {
|
|
|
205
228
|
}
|
|
206
229
|
return;
|
|
207
230
|
}
|
|
231
|
+
if (type === "message.part.updated") {
|
|
232
|
+
const part = event.properties?.part;
|
|
233
|
+
const sessionID = part?.sessionID ?? event.properties?.sessionID;
|
|
234
|
+
if (!sessionID) return;
|
|
235
|
+
const session = this.store.get(sessionID);
|
|
236
|
+
if (session?.warming) this.notePingRunPart(session, part);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
208
239
|
if (type === "message.updated") {
|
|
209
240
|
const info = event.properties?.info;
|
|
210
241
|
const sessionID = info?.sessionID ?? event.properties?.sessionID;
|
|
211
242
|
if (!sessionID) return;
|
|
212
243
|
const session = this.store.get(sessionID);
|
|
213
|
-
if (session?.warming)
|
|
244
|
+
if (session?.warming) {
|
|
245
|
+
if (info?.role === "user") this.notePingRunUser(session, info);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
214
248
|
const eventAt = info?.time?.completed ?? info?.time?.created;
|
|
215
249
|
if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
|
|
216
250
|
return;
|
|
217
251
|
if (info?.role === "user") {
|
|
218
252
|
const s = this.store.ensure(sessionID);
|
|
253
|
+
if (!isNewUserMessage(s, info)) return;
|
|
254
|
+
noteUserMessage(s, info);
|
|
219
255
|
s.busy = true;
|
|
220
|
-
s.busySince = Date.now();
|
|
221
256
|
s.active = false;
|
|
222
257
|
this.store.persist();
|
|
223
258
|
return;
|
|
@@ -242,7 +277,6 @@ var KeepaliveEngine = class {
|
|
|
242
277
|
}
|
|
243
278
|
const s = this.store.ensure(sessionID);
|
|
244
279
|
s.busy = true;
|
|
245
|
-
s.busySince = Date.now();
|
|
246
280
|
s.active = false;
|
|
247
281
|
this.store.persist();
|
|
248
282
|
return;
|
|
@@ -254,6 +288,55 @@ var KeepaliveEngine = class {
|
|
|
254
288
|
await this.armWindow(sessionID);
|
|
255
289
|
}
|
|
256
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Track a new user message observed while a ping is in flight. Event order alone
|
|
293
|
+
* cannot tell the ping's own message from a real prompt racing it, so messages are
|
|
294
|
+
* held until their text part arrives (see `notePingRunPart`). Any message seen
|
|
295
|
+
* before the ping is sent, or a second new message, is necessarily a real turn.
|
|
296
|
+
*/
|
|
297
|
+
notePingRunUser(s, info) {
|
|
298
|
+
const ping = s.ping;
|
|
299
|
+
if (!ping || typeof info?.id !== "string") return;
|
|
300
|
+
const created = info?.time?.created;
|
|
301
|
+
if (typeof created === "number" && created < ping.startedAt) return;
|
|
302
|
+
if (info.id === ping.messageID || ping.pending.has(info.id)) return;
|
|
303
|
+
if (!ping.sent || ping.messageID) {
|
|
304
|
+
this.interruptPing(s, info);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
ping.pending.set(info.id, info);
|
|
308
|
+
if (ping.pending.size > 1) this.interruptPing(s);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Classify a pending user message by its first text part: the ping token marks
|
|
312
|
+
* the ping's own message; any other text is a real turn joining the ping run.
|
|
313
|
+
* opencode publishes a user message's parts right after the message itself, before
|
|
314
|
+
* the run reaches any tool call.
|
|
315
|
+
*/
|
|
316
|
+
notePingRunPart(s, part) {
|
|
317
|
+
const ping = s.ping;
|
|
318
|
+
if (!ping || part?.type !== "text" || part?.synthetic) return;
|
|
319
|
+
const id = part?.messageID;
|
|
320
|
+
if (typeof id !== "string" || !ping.pending.has(id)) return;
|
|
321
|
+
const info = ping.pending.get(id);
|
|
322
|
+
ping.pending.delete(id);
|
|
323
|
+
if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
|
|
324
|
+
ping.messageID = id;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.interruptPing(s, info);
|
|
328
|
+
}
|
|
329
|
+
/** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
|
|
330
|
+
interruptPing(s, info) {
|
|
331
|
+
const ping = s.ping;
|
|
332
|
+
if (!ping) return;
|
|
333
|
+
if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`);
|
|
334
|
+
ping.interrupted = true;
|
|
335
|
+
if (info) noteUserMessage(s, info);
|
|
336
|
+
s.busy = true;
|
|
337
|
+
s.active = false;
|
|
338
|
+
this.store.persist();
|
|
339
|
+
}
|
|
257
340
|
async armWindow(sessionID) {
|
|
258
341
|
const s = this.store.ensure(sessionID);
|
|
259
342
|
if (s.active && !s.busy) return;
|
|
@@ -261,7 +344,6 @@ var KeepaliveEngine = class {
|
|
|
261
344
|
s.arming = true;
|
|
262
345
|
try {
|
|
263
346
|
s.busy = false;
|
|
264
|
-
s.busySince = void 0;
|
|
265
347
|
await this.resolveSession(s);
|
|
266
348
|
if (s.busy || !this.enabled || !s.eligible) {
|
|
267
349
|
s.active = false;
|
|
@@ -269,30 +351,37 @@ var KeepaliveEngine = class {
|
|
|
269
351
|
return;
|
|
270
352
|
}
|
|
271
353
|
const now = Date.now();
|
|
272
|
-
if (!s.lastResponseAt
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
s
|
|
354
|
+
if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
|
|
355
|
+
s.active = false;
|
|
356
|
+
this.store.persist();
|
|
357
|
+
this.logger.dbg(`not armed ${short(sessionID)} \u2014 no recent real response`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
this.openWindow(s, now);
|
|
279
361
|
this.store.persist();
|
|
280
362
|
this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`);
|
|
281
363
|
} finally {
|
|
282
364
|
s.arming = false;
|
|
283
365
|
}
|
|
284
366
|
}
|
|
367
|
+
/** Start a warm window anchored on the last real response and the last cache touch. */
|
|
368
|
+
openWindow(s, now) {
|
|
369
|
+
s.idleSince = now;
|
|
370
|
+
s.windowEndsAt = s.lastResponseAt + this.config.windowMs;
|
|
371
|
+
s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.intervalMs);
|
|
372
|
+
s.pingsSent = 0;
|
|
373
|
+
s.lastPing = void 0;
|
|
374
|
+
s.active = true;
|
|
375
|
+
}
|
|
376
|
+
/** False once the cache has gone too long without a request to still be trusted warm. */
|
|
377
|
+
cacheMayBeWarm(s, now) {
|
|
378
|
+
const touch = lastCacheTouch(s);
|
|
379
|
+
return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.intervalMs;
|
|
380
|
+
}
|
|
285
381
|
async tick() {
|
|
286
382
|
if (!this.enabled) return;
|
|
287
383
|
const now = Date.now();
|
|
288
384
|
for (const s of this.store.all()) {
|
|
289
|
-
if (s.busy && s.busySince && now - s.busySince > BUSY_TIMEOUT_MS) {
|
|
290
|
-
this.logger.dbg(`busy timeout ${short(s.sessionID)} \u2014 clearing stuck flag`);
|
|
291
|
-
s.busy = false;
|
|
292
|
-
s.busySince = void 0;
|
|
293
|
-
void this.armWindow(s.sessionID);
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
385
|
if (!s.eligible || !s.active || s.busy || s.warming) continue;
|
|
297
386
|
if (now >= s.windowEndsAt) {
|
|
298
387
|
s.active = false;
|
|
@@ -302,22 +391,65 @@ var KeepaliveEngine = class {
|
|
|
302
391
|
);
|
|
303
392
|
continue;
|
|
304
393
|
}
|
|
305
|
-
if (now
|
|
394
|
+
if (now < s.nextPingAt) continue;
|
|
395
|
+
if (!this.cacheMayBeWarm(s, now)) {
|
|
396
|
+
s.active = false;
|
|
397
|
+
this.store.persist();
|
|
398
|
+
this.logger.dbg(
|
|
399
|
+
`window closed ${short(s.sessionID)} \u2014 ping overdue, cache presumed cold`
|
|
400
|
+
);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
void this.firePing(s.sessionID);
|
|
306
404
|
}
|
|
307
405
|
}
|
|
308
406
|
async firePing(sessionID) {
|
|
309
407
|
const s = this.store.get(sessionID);
|
|
310
|
-
if (!s || s.warming || s.busy) return;
|
|
408
|
+
if (!s || s.warming || s.busy || !s.active) return;
|
|
409
|
+
const ping = {
|
|
410
|
+
startedAt: Date.now(),
|
|
411
|
+
sent: false,
|
|
412
|
+
pending: /* @__PURE__ */ new Map(),
|
|
413
|
+
interrupted: false
|
|
414
|
+
};
|
|
311
415
|
s.warming = true;
|
|
312
|
-
s.
|
|
416
|
+
s.ping = ping;
|
|
313
417
|
this.store.persist();
|
|
314
|
-
const startAt = Date.now();
|
|
315
418
|
try {
|
|
419
|
+
if (!await this.isIdleOnServer(sessionID)) {
|
|
420
|
+
s.busy = true;
|
|
421
|
+
s.active = false;
|
|
422
|
+
this.logger.dbg(`ping ${short(sessionID)} skipped \u2014 session busy`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const request = s.request ?? await this.lookupRequest(s);
|
|
426
|
+
if (!request) {
|
|
427
|
+
s.active = false;
|
|
428
|
+
this.logger.dbg(`ping ${short(sessionID)} skipped \u2014 last real request unknown`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (ping.interrupted || s.busy || !s.active || !this.enabled) return;
|
|
432
|
+
ping.sent = true;
|
|
433
|
+
s.pingsSent += 1;
|
|
434
|
+
this.store.persist();
|
|
316
435
|
const res = await this.client.session.prompt({
|
|
317
436
|
path: { id: sessionID },
|
|
318
|
-
body: {
|
|
437
|
+
body: {
|
|
438
|
+
parts: [{ type: "text", text: this.config.pingToken }],
|
|
439
|
+
...request.agent ? { agent: request.agent } : {},
|
|
440
|
+
model: { providerID: request.providerID, modelID: request.modelID },
|
|
441
|
+
...request.variant ? { variant: request.variant } : {}
|
|
442
|
+
}
|
|
319
443
|
});
|
|
320
444
|
const info = res?.data?.info ?? res?.info;
|
|
445
|
+
if (ping.interrupted) {
|
|
446
|
+
const completed = info?.time?.completed;
|
|
447
|
+
if (typeof completed === "number") s.lastResponseAt = completed;
|
|
448
|
+
this.logger.dbg(
|
|
449
|
+
`ping ${short(sessionID)} absorbed a real turn \u2014 kept, not reverted`
|
|
450
|
+
);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
321
453
|
const cache = info?.tokens?.cache ?? {};
|
|
322
454
|
const record = {
|
|
323
455
|
at: Date.now(),
|
|
@@ -331,40 +463,94 @@ var KeepaliveEngine = class {
|
|
|
331
463
|
this.logger.dbg(
|
|
332
464
|
`ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`
|
|
333
465
|
);
|
|
334
|
-
if (this.config.revertPing) await this.revertPing(sessionID,
|
|
466
|
+
if (this.config.revertPing) await this.revertPing(sessionID, ping);
|
|
335
467
|
} catch (err) {
|
|
336
468
|
this.logger.warn(`ping ${short(sessionID)} failed`, errText(err));
|
|
337
469
|
} finally {
|
|
338
470
|
s.warming = false;
|
|
339
|
-
s.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
s.
|
|
343
|
-
|
|
471
|
+
s.ping = void 0;
|
|
472
|
+
const now = Date.now();
|
|
473
|
+
if (ping.sent) {
|
|
474
|
+
s.lastPingAt = now;
|
|
475
|
+
const next = s.nextPingAt + jitter(this.intervalMs);
|
|
476
|
+
s.nextPingAt = next > now ? next : now + jitter(this.intervalMs);
|
|
477
|
+
}
|
|
344
478
|
this.store.persist();
|
|
479
|
+
if (ping.sent && ping.interrupted) {
|
|
480
|
+
s.busy = false;
|
|
481
|
+
s.active = false;
|
|
482
|
+
void this.armWindow(sessionID);
|
|
483
|
+
}
|
|
345
484
|
}
|
|
346
485
|
}
|
|
347
|
-
/**
|
|
348
|
-
async
|
|
349
|
-
let messages;
|
|
486
|
+
/** True only when the server positively reports the session idle. */
|
|
487
|
+
async isIdleOnServer(sessionID) {
|
|
350
488
|
try {
|
|
351
|
-
const res = await this.client.session.
|
|
352
|
-
|
|
489
|
+
const res = await this.client.session.status();
|
|
490
|
+
const statuses = res?.data ?? res ?? {};
|
|
491
|
+
return (statuses?.[sessionID]?.type ?? "idle") === "idle";
|
|
353
492
|
} catch (err) {
|
|
354
|
-
this.logger.
|
|
355
|
-
return;
|
|
493
|
+
this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err));
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/** Recover the last real turn's request settings when no user event was observed. */
|
|
498
|
+
async lookupRequest(s) {
|
|
499
|
+
const messages = await this.recentMessages(s.sessionID);
|
|
500
|
+
if (!messages) return void 0;
|
|
501
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
502
|
+
const m = messages[i];
|
|
503
|
+
if (m?.info?.role !== "user" || this.isPingMessage(m)) continue;
|
|
504
|
+
const request = requestFrom(m.info);
|
|
505
|
+
if (request) {
|
|
506
|
+
s.request = request;
|
|
507
|
+
return request;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return void 0;
|
|
511
|
+
}
|
|
512
|
+
async recentMessages(sessionID) {
|
|
513
|
+
try {
|
|
514
|
+
const res = await this.client.session.messages({
|
|
515
|
+
path: { id: sessionID },
|
|
516
|
+
query: { limit: MESSAGE_LOOKUP_LIMIT }
|
|
517
|
+
});
|
|
518
|
+
const messages = res?.data ?? res;
|
|
519
|
+
return Array.isArray(messages) ? messages : void 0;
|
|
520
|
+
} catch (err) {
|
|
521
|
+
this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err));
|
|
522
|
+
return void 0;
|
|
356
523
|
}
|
|
357
|
-
|
|
358
|
-
|
|
524
|
+
}
|
|
525
|
+
isPingMessage(m) {
|
|
526
|
+
const text = (m?.parts ?? []).filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
|
|
527
|
+
return text === this.config.pingToken;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Remove the `~` user + `~` assistant turn so it never enters real context.
|
|
531
|
+
* Revert drops the target and everything after it (and rolls back file
|
|
532
|
+
* snapshots), so it is skipped unless only the ping's own replies follow.
|
|
533
|
+
*/
|
|
534
|
+
async revertPing(sessionID, ping) {
|
|
535
|
+
const messages = await this.recentMessages(sessionID);
|
|
536
|
+
if (!messages) return;
|
|
537
|
+
let index = -1;
|
|
538
|
+
for (let i = 0; i < messages.length; i++) {
|
|
539
|
+
const m = messages[i];
|
|
359
540
|
if (m?.info?.role !== "user") continue;
|
|
360
|
-
if (
|
|
361
|
-
|
|
362
|
-
if (text === this.config.pingToken) target = m.info.id;
|
|
541
|
+
if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
|
|
542
|
+
index = i;
|
|
363
543
|
}
|
|
364
|
-
if (
|
|
544
|
+
if (index < 0) {
|
|
365
545
|
this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`);
|
|
366
546
|
return;
|
|
367
547
|
}
|
|
548
|
+
const target = messages[index].info.id;
|
|
549
|
+
const foreign = messages.slice(index + 1).some((m) => m?.info?.role !== "assistant" || m?.info?.parentID !== target);
|
|
550
|
+
if (foreign) {
|
|
551
|
+
this.logger.warn(`ping ${short(sessionID)} not reverted \u2014 other messages followed it`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
368
554
|
try {
|
|
369
555
|
await this.client.session.revert({
|
|
370
556
|
path: { id: sessionID },
|
|
@@ -374,6 +560,9 @@ var KeepaliveEngine = class {
|
|
|
374
560
|
this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err));
|
|
375
561
|
}
|
|
376
562
|
}
|
|
563
|
+
isRecentPing(m, ping) {
|
|
564
|
+
return Number(m?.info?.time?.created ?? 0) >= ping.startedAt - 2e3 && this.isPingMessage(m);
|
|
565
|
+
}
|
|
377
566
|
async resolveSession(s) {
|
|
378
567
|
try {
|
|
379
568
|
const res = await this.client.session.get({ path: { id: s.sessionID } });
|
|
@@ -394,26 +583,37 @@ var KeepaliveEngine = class {
|
|
|
394
583
|
const control = readControl(this.directory);
|
|
395
584
|
if (!control || control.updatedAt <= this.controlUpdatedAt) return;
|
|
396
585
|
this.controlUpdatedAt = control.updatedAt;
|
|
397
|
-
if (control.enabled === this.enabled) return;
|
|
398
|
-
this.enabled = control.enabled;
|
|
399
|
-
this.store.setEnabled(control.enabled);
|
|
400
586
|
const now = Date.now();
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
587
|
+
let changed = false;
|
|
588
|
+
if (control.intervalMs !== this.intervalOverride) {
|
|
589
|
+
this.intervalOverride = control.intervalMs;
|
|
590
|
+
this.store.setIntervalMs(this.intervalMs);
|
|
591
|
+
for (const session of this.store.all()) {
|
|
592
|
+
if (session.active && !session.warming)
|
|
593
|
+
session.nextPingAt = Math.max(now, lastCacheTouch(session) + this.intervalMs);
|
|
405
594
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
session.nextPingAt = now + this.config.intervalMs;
|
|
411
|
-
session.pingsSent = 0;
|
|
412
|
-
session.lastPing = void 0;
|
|
413
|
-
session.active = true;
|
|
595
|
+
this.logger.info(
|
|
596
|
+
`runtime interval ${Math.round(this.intervalMs / 1e3)}s` + (control.intervalMs === void 0 ? " (config)" : "")
|
|
597
|
+
);
|
|
598
|
+
changed = true;
|
|
414
599
|
}
|
|
415
|
-
this.
|
|
416
|
-
|
|
600
|
+
const enabled = control.enabled ?? this.config.enabled;
|
|
601
|
+
if (enabled !== this.enabled) {
|
|
602
|
+
this.enabled = enabled;
|
|
603
|
+
this.store.setEnabled(enabled);
|
|
604
|
+
for (const session of this.store.all()) {
|
|
605
|
+
if (!enabled) {
|
|
606
|
+
session.active = false;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (!session.eligible || session.busy || session.warming) continue;
|
|
610
|
+
if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue;
|
|
611
|
+
this.openWindow(session, now);
|
|
612
|
+
}
|
|
613
|
+
this.logger.info(`runtime ${enabled ? "enabled" : "disabled"}`);
|
|
614
|
+
changed = true;
|
|
615
|
+
}
|
|
616
|
+
if (changed) this.store.persist();
|
|
417
617
|
}
|
|
418
618
|
/** Recover when a session.status idle event is missed by reconciling with the API. */
|
|
419
619
|
async reconcileStatus() {
|
|
@@ -435,6 +635,34 @@ var KeepaliveEngine = class {
|
|
|
435
635
|
}
|
|
436
636
|
}
|
|
437
637
|
};
|
|
638
|
+
function lastCacheTouch(s) {
|
|
639
|
+
return Math.max(s.lastResponseAt, s.lastPingAt ?? 0);
|
|
640
|
+
}
|
|
641
|
+
function isNewUserMessage(s, info) {
|
|
642
|
+
if (typeof info?.id === "string" && info.id === s.lastUserID) return false;
|
|
643
|
+
const created = info?.time?.created;
|
|
644
|
+
return typeof created !== "number" || created >= (s.lastUserAt ?? 0);
|
|
645
|
+
}
|
|
646
|
+
function noteUserMessage(s, info) {
|
|
647
|
+
if (typeof info?.id === "string") s.lastUserID = info.id;
|
|
648
|
+
const created = info?.time?.created;
|
|
649
|
+
if (typeof created === "number") s.lastUserAt = created;
|
|
650
|
+
const request = requestFrom(info);
|
|
651
|
+
if (request) s.request = request;
|
|
652
|
+
}
|
|
653
|
+
function requestFrom(info) {
|
|
654
|
+
const model = info?.model;
|
|
655
|
+
const providerID = model?.providerID;
|
|
656
|
+
const modelID = model?.modelID ?? model?.id;
|
|
657
|
+
if (typeof providerID !== "string" || typeof modelID !== "string") return void 0;
|
|
658
|
+
const variant = model?.variant ?? info?.variant;
|
|
659
|
+
return {
|
|
660
|
+
agent: typeof info?.agent === "string" ? info.agent : void 0,
|
|
661
|
+
providerID,
|
|
662
|
+
modelID,
|
|
663
|
+
variant: typeof variant === "string" && variant !== "default" ? variant : void 0
|
|
664
|
+
};
|
|
665
|
+
}
|
|
438
666
|
function short(sessionID) {
|
|
439
667
|
return sessionID.slice(-6);
|
|
440
668
|
}
|
|
@@ -493,15 +721,21 @@ var KeepaliveStore = class {
|
|
|
493
721
|
this.config = config;
|
|
494
722
|
this.directory = directory;
|
|
495
723
|
this.enabled = config.enabled;
|
|
724
|
+
this.intervalMs = config.intervalMs;
|
|
496
725
|
}
|
|
497
726
|
config;
|
|
498
727
|
directory;
|
|
499
728
|
sessions = /* @__PURE__ */ new Map();
|
|
500
729
|
writeQueue = Promise.resolve();
|
|
501
730
|
enabled;
|
|
731
|
+
intervalMs;
|
|
502
732
|
setEnabled(enabled) {
|
|
503
733
|
this.enabled = enabled;
|
|
504
734
|
}
|
|
735
|
+
/** Effective ping interval (runtime override or config) reported to the TUI. */
|
|
736
|
+
setIntervalMs(intervalMs) {
|
|
737
|
+
this.intervalMs = intervalMs;
|
|
738
|
+
}
|
|
505
739
|
get(sessionID) {
|
|
506
740
|
return this.sessions.get(sessionID);
|
|
507
741
|
}
|
|
@@ -529,13 +763,16 @@ var KeepaliveStore = class {
|
|
|
529
763
|
all() {
|
|
530
764
|
return [...this.sessions.values()];
|
|
531
765
|
}
|
|
532
|
-
/**
|
|
766
|
+
/**
|
|
767
|
+
* Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
|
|
768
|
+
* returned promise resolves once this snapshot and every earlier one are written.
|
|
769
|
+
*/
|
|
533
770
|
persist() {
|
|
534
771
|
const snapshot = {
|
|
535
772
|
version: 1,
|
|
536
773
|
updatedAt: Date.now(),
|
|
537
774
|
enabled: this.enabled,
|
|
538
|
-
intervalMs: this.
|
|
775
|
+
intervalMs: this.intervalMs,
|
|
539
776
|
windowMs: this.config.windowMs,
|
|
540
777
|
sessions: Object.fromEntries(
|
|
541
778
|
this.all().map((s) => [
|
|
@@ -547,7 +784,7 @@ var KeepaliveStore = class {
|
|
|
547
784
|
idleSince: s.idleSince,
|
|
548
785
|
windowEndsAt: s.windowEndsAt,
|
|
549
786
|
nextPingAt: s.nextPingAt,
|
|
550
|
-
intervalMs: this.
|
|
787
|
+
intervalMs: this.intervalMs,
|
|
551
788
|
pingsSent: s.pingsSent,
|
|
552
789
|
busy: s.busy,
|
|
553
790
|
warming: s.warming,
|
|
@@ -559,6 +796,7 @@ var KeepaliveStore = class {
|
|
|
559
796
|
};
|
|
560
797
|
const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot));
|
|
561
798
|
this.writeQueue = this.writeQueue.then(write, write);
|
|
799
|
+
return this.writeQueue;
|
|
562
800
|
}
|
|
563
801
|
};
|
|
564
802
|
async function atomicWrite(path, contents) {
|
|
@@ -596,7 +834,7 @@ var server = async (ctx, options) => {
|
|
|
596
834
|
const store = new KeepaliveStore(config, ctx.directory);
|
|
597
835
|
const engine = new KeepaliveEngine(ctx.client, config, store, logger, ctx.directory);
|
|
598
836
|
engine.start();
|
|
599
|
-
const version = true ? "0.1.
|
|
837
|
+
const version = true ? "0.1.6" : "dev";
|
|
600
838
|
logger.info(`v${version} ready`);
|
|
601
839
|
const instruction = keepaliveInstruction(config.pingToken);
|
|
602
840
|
return {
|
|
@@ -614,7 +852,7 @@ var server = async (ctx, options) => {
|
|
|
614
852
|
// Backstop: if the model ignores the instruction and tries a tool during a
|
|
615
853
|
// ping, block it — the ping turn is going to be reverted anyway.
|
|
616
854
|
"tool.execute.before": async (input) => {
|
|
617
|
-
if (engine.
|
|
855
|
+
if (engine.shouldBlockTools(input.sessionID)) {
|
|
618
856
|
throw new Error("cache-keepalive: tools are disabled during a keepalive ping");
|
|
619
857
|
}
|
|
620
858
|
},
|