@vikrant82/opencode-cache-keepalive 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +582 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/auth.d.ts +11 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/config.d.ts +31 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/control.d.ts +8 -0
- package/dist/lib/control.d.ts.map +1 -0
- package/dist/lib/keepalive.d.ts +42 -0
- package/dist/lib/keepalive.d.ts.map +1 -0
- package/dist/lib/logger.d.ts +9 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/model.d.ts +8 -0
- package/dist/lib/model.d.ts.map +1 -0
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/state.d.ts +74 -0
- package/dist/lib/state.d.ts.map +1 -0
- package/dist/lib/system.d.ts +11 -0
- package/dist/lib/system.d.ts.map +1 -0
- package/dist/lib/tui/commands.d.ts +3 -0
- package/dist/lib/tui/commands.d.ts.map +1 -0
- package/dist/lib/tui/footer.d.ts +14 -0
- package/dist/lib/tui/footer.d.ts.map +1 -0
- package/dist/lib/tui/format.d.ts +3 -0
- package/dist/lib/tui/format.d.ts.map +1 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.d.ts.map +1 -0
- package/lib/auth.ts +33 -0
- package/lib/config.ts +101 -0
- package/lib/control.ts +37 -0
- package/lib/keepalive.ts +345 -0
- package/lib/logger.ts +21 -0
- package/lib/model.ts +19 -0
- package/lib/paths.ts +31 -0
- package/lib/state.ts +157 -0
- package/lib/system.ts +19 -0
- package/lib/tui/commands.ts +54 -0
- package/lib/tui/footer.tsx +156 -0
- package/lib/tui/format.ts +13 -0
- package/package.json +71 -0
- package/tui.tsx +25 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAWjD,QAAA,MAAM,MAAM,EAAE,MA8Cb,CAAA;AAED,eAAe,MAAM,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
// lib/auth.ts
|
|
2
|
+
function isSecureMode() {
|
|
3
|
+
return !!process.env.OPENCODE_SERVER_PASSWORD;
|
|
4
|
+
}
|
|
5
|
+
function getAuthorizationHeader() {
|
|
6
|
+
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
|
7
|
+
if (!password) return void 0;
|
|
8
|
+
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
|
|
9
|
+
const credentials = Buffer.from(`${username}:${password}`).toString("base64");
|
|
10
|
+
return `Basic ${credentials}`;
|
|
11
|
+
}
|
|
12
|
+
function configureClientAuth(client) {
|
|
13
|
+
const header = getAuthorizationHeader();
|
|
14
|
+
if (!header) return;
|
|
15
|
+
const inner = client?._client ?? client?.client;
|
|
16
|
+
if (!inner?.interceptors?.request) return;
|
|
17
|
+
inner.interceptors.request.use((request) => {
|
|
18
|
+
if (!request.headers.has("Authorization")) {
|
|
19
|
+
request.headers.set("Authorization", header);
|
|
20
|
+
}
|
|
21
|
+
return request;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// lib/config.ts
|
|
26
|
+
var DEFAULTS = {
|
|
27
|
+
enabled: true,
|
|
28
|
+
intervalMs: 27e4,
|
|
29
|
+
// 4.5 min — below the shortest supported cache TTL
|
|
30
|
+
windowMs: 33e5,
|
|
31
|
+
// 55 min — the Copilot Claude write/read break-even
|
|
32
|
+
injectSystemInstruction: true,
|
|
33
|
+
pingToken: "~",
|
|
34
|
+
revertPing: true,
|
|
35
|
+
includeChildSessions: false,
|
|
36
|
+
debug: false,
|
|
37
|
+
providerAllowlist: ["copilot"],
|
|
38
|
+
modelAllowlist: ["claude", "anthropic", "sonnet", "opus", "haiku", "gpt"]
|
|
39
|
+
};
|
|
40
|
+
function getConfig(options) {
|
|
41
|
+
const o = options ?? {};
|
|
42
|
+
const env = process.env;
|
|
43
|
+
return {
|
|
44
|
+
enabled: boolOpt(o.enabled, env.OPENCODE_KEEPALIVE_ENABLED !== "false" && DEFAULTS.enabled),
|
|
45
|
+
intervalMs: durationOpt(
|
|
46
|
+
o.intervalMs,
|
|
47
|
+
o.intervalSeconds,
|
|
48
|
+
numEnv(env.OPENCODE_KEEPALIVE_INTERVAL_MS, DEFAULTS.intervalMs)
|
|
49
|
+
),
|
|
50
|
+
windowMs: durationOpt(
|
|
51
|
+
o.windowMs,
|
|
52
|
+
o.windowMinutes ? Number(o.windowMinutes) * 60 : void 0,
|
|
53
|
+
numEnv(env.OPENCODE_KEEPALIVE_WINDOW_MS, DEFAULTS.windowMs)
|
|
54
|
+
),
|
|
55
|
+
injectSystemInstruction: boolOpt(
|
|
56
|
+
o.injectSystemInstruction,
|
|
57
|
+
DEFAULTS.injectSystemInstruction
|
|
58
|
+
),
|
|
59
|
+
pingToken: typeof o.pingToken === "string" && o.pingToken.length > 0 ? o.pingToken : DEFAULTS.pingToken,
|
|
60
|
+
revertPing: boolOpt(
|
|
61
|
+
o.revertPing,
|
|
62
|
+
env.OPENCODE_KEEPALIVE_REVERT_PING !== "false" && DEFAULTS.revertPing
|
|
63
|
+
),
|
|
64
|
+
includeChildSessions: boolOpt(o.includeChildSessions, DEFAULTS.includeChildSessions),
|
|
65
|
+
debug: boolOpt(o.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true"),
|
|
66
|
+
providerAllowlist: listOpt(o.providerAllowlist, DEFAULTS.providerAllowlist),
|
|
67
|
+
modelAllowlist: listOpt(o.modelAllowlist, DEFAULTS.modelAllowlist)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function boolOpt(value, fallback) {
|
|
71
|
+
return typeof value === "boolean" ? value : fallback;
|
|
72
|
+
}
|
|
73
|
+
function listOpt(value, fallback) {
|
|
74
|
+
if (!Array.isArray(value)) return fallback;
|
|
75
|
+
const filtered = value.filter((item) => typeof item === "string");
|
|
76
|
+
return filtered.length > 0 ? filtered : fallback;
|
|
77
|
+
}
|
|
78
|
+
function durationOpt(ms, seconds, fallback) {
|
|
79
|
+
if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return ms;
|
|
80
|
+
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0)
|
|
81
|
+
return seconds * 1e3;
|
|
82
|
+
return fallback;
|
|
83
|
+
}
|
|
84
|
+
function numEnv(raw, fallback) {
|
|
85
|
+
if (!raw) return fallback;
|
|
86
|
+
const value = Number(raw);
|
|
87
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// lib/control.ts
|
|
91
|
+
import { readFileSync } from "fs";
|
|
92
|
+
import { mkdir, rename, writeFile } from "fs/promises";
|
|
93
|
+
import { dirname } from "path";
|
|
94
|
+
|
|
95
|
+
// lib/paths.ts
|
|
96
|
+
import { createHash } from "crypto";
|
|
97
|
+
import { homedir } from "os";
|
|
98
|
+
import { join } from "path";
|
|
99
|
+
function stateFilePath(directory) {
|
|
100
|
+
const key = createHash("sha256").update(directory).digest("hex").slice(0, 16);
|
|
101
|
+
return join(stateDirectoryPath(), `state-${key}.json`);
|
|
102
|
+
}
|
|
103
|
+
function stateDirectoryPath() {
|
|
104
|
+
return join(
|
|
105
|
+
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
|
106
|
+
"opencode",
|
|
107
|
+
"storage",
|
|
108
|
+
"plugin",
|
|
109
|
+
"keepalive"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
function controlFilePath() {
|
|
113
|
+
return join(stateDirectoryPath(), "control.json");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// lib/control.ts
|
|
117
|
+
function readControl() {
|
|
118
|
+
try {
|
|
119
|
+
const value = JSON.parse(
|
|
120
|
+
readFileSync(controlFilePath(), "utf8")
|
|
121
|
+
);
|
|
122
|
+
if (value.version !== 1 || typeof value.enabled !== "boolean") return void 0;
|
|
123
|
+
if (typeof value.updatedAt !== "number") return void 0;
|
|
124
|
+
return value;
|
|
125
|
+
} catch {
|
|
126
|
+
return void 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// lib/model.ts
|
|
131
|
+
function isEligibleModel(config, providerID, modelID) {
|
|
132
|
+
if (!providerID || !modelID) return false;
|
|
133
|
+
const provider = providerID.toLowerCase();
|
|
134
|
+
const model = modelID.toLowerCase();
|
|
135
|
+
const providerOk = config.providerAllowlist.some((needle) => provider.includes(needle));
|
|
136
|
+
const modelOk = config.modelAllowlist.some((needle) => model.includes(needle));
|
|
137
|
+
return providerOk && modelOk;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// lib/keepalive.ts
|
|
141
|
+
var TICK_MS = 15e3;
|
|
142
|
+
var JITTER_MS = 15e3;
|
|
143
|
+
var CONTROL_POLL_MS = 1e3;
|
|
144
|
+
var STATUS_POLL_MS = 2e3;
|
|
145
|
+
var KeepaliveEngine = class {
|
|
146
|
+
constructor(client, config, store, logger) {
|
|
147
|
+
this.client = client;
|
|
148
|
+
this.config = config;
|
|
149
|
+
this.store = store;
|
|
150
|
+
this.logger = logger;
|
|
151
|
+
const control = readControl();
|
|
152
|
+
this.enabled = control?.enabled ?? config.enabled;
|
|
153
|
+
this.controlUpdatedAt = control?.updatedAt ?? 0;
|
|
154
|
+
this.store.setEnabled(this.enabled);
|
|
155
|
+
}
|
|
156
|
+
client;
|
|
157
|
+
config;
|
|
158
|
+
store;
|
|
159
|
+
logger;
|
|
160
|
+
timer;
|
|
161
|
+
controlTimer;
|
|
162
|
+
statusTimer;
|
|
163
|
+
controlUpdatedAt = 0;
|
|
164
|
+
reconcilingStatus = false;
|
|
165
|
+
enabled;
|
|
166
|
+
start() {
|
|
167
|
+
if (this.timer) return;
|
|
168
|
+
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
|
169
|
+
this.controlTimer = setInterval(() => this.pollControl(), CONTROL_POLL_MS);
|
|
170
|
+
this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS);
|
|
171
|
+
this.store.persist();
|
|
172
|
+
this.logger.info(
|
|
173
|
+
`started (${this.enabled ? "enabled" : "disabled"}, interval=${Math.round(this.config.intervalMs / 1e3)}s, window=${Math.round(this.config.windowMs / 6e4)}m)`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
stop() {
|
|
177
|
+
if (!this.timer) return;
|
|
178
|
+
clearInterval(this.timer);
|
|
179
|
+
this.timer = void 0;
|
|
180
|
+
if (this.controlTimer) clearInterval(this.controlTimer);
|
|
181
|
+
this.controlTimer = void 0;
|
|
182
|
+
if (this.statusTimer) clearInterval(this.statusTimer);
|
|
183
|
+
this.statusTimer = void 0;
|
|
184
|
+
}
|
|
185
|
+
/** True while a ping is in flight — used to block tool calls during a ping. */
|
|
186
|
+
isWarming(sessionID) {
|
|
187
|
+
return !!this.store.get(sessionID)?.warming;
|
|
188
|
+
}
|
|
189
|
+
async onEvent(event) {
|
|
190
|
+
const type = event?.type;
|
|
191
|
+
if (!type) return;
|
|
192
|
+
if (type === "session.deleted") {
|
|
193
|
+
const id = event.properties?.sessionID ?? event.properties?.info?.id;
|
|
194
|
+
if (id) {
|
|
195
|
+
this.store.remove(id);
|
|
196
|
+
this.store.persist();
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (type === "message.updated") {
|
|
201
|
+
const info = event.properties?.info;
|
|
202
|
+
const sessionID = info?.sessionID ?? event.properties?.sessionID;
|
|
203
|
+
if (!sessionID) return;
|
|
204
|
+
const session = this.store.get(sessionID);
|
|
205
|
+
if (session?.warming) return;
|
|
206
|
+
const eventAt = info?.time?.completed ?? info?.time?.created;
|
|
207
|
+
if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
|
|
208
|
+
return;
|
|
209
|
+
if (info?.role === "user") {
|
|
210
|
+
const s = this.store.ensure(sessionID);
|
|
211
|
+
s.busy = true;
|
|
212
|
+
s.active = false;
|
|
213
|
+
this.store.persist();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (info?.role === "assistant" && typeof info?.time?.completed === "number") {
|
|
217
|
+
const s = this.store.ensure(sessionID);
|
|
218
|
+
s.lastResponseAt = info.time.completed;
|
|
219
|
+
this.store.persist();
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (type === "session.status") {
|
|
224
|
+
const sessionID = event.properties?.sessionID;
|
|
225
|
+
const status = event.properties?.status?.type;
|
|
226
|
+
if (!sessionID || !status) return;
|
|
227
|
+
if (this.store.get(sessionID)?.warming) return;
|
|
228
|
+
if (status === "idle") {
|
|
229
|
+
await this.armWindow(sessionID);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const s = this.store.ensure(sessionID);
|
|
233
|
+
s.busy = true;
|
|
234
|
+
s.active = false;
|
|
235
|
+
this.store.persist();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (type === "session.idle") {
|
|
239
|
+
const sessionID = event.properties?.sessionID;
|
|
240
|
+
if (!sessionID) return;
|
|
241
|
+
if (this.store.get(sessionID)?.warming) return;
|
|
242
|
+
await this.armWindow(sessionID);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async armWindow(sessionID) {
|
|
246
|
+
const s = this.store.ensure(sessionID);
|
|
247
|
+
if (s.active && !s.busy) return;
|
|
248
|
+
s.busy = false;
|
|
249
|
+
await this.resolveSession(s);
|
|
250
|
+
if (s.busy || !this.enabled || !s.eligible) {
|
|
251
|
+
s.active = false;
|
|
252
|
+
this.store.persist();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
if (!s.lastResponseAt) s.lastResponseAt = now;
|
|
257
|
+
s.idleSince = now;
|
|
258
|
+
s.windowEndsAt = now + this.config.windowMs;
|
|
259
|
+
s.nextPingAt = now + this.config.intervalMs;
|
|
260
|
+
s.pingsSent = 0;
|
|
261
|
+
s.lastPing = void 0;
|
|
262
|
+
s.active = true;
|
|
263
|
+
this.store.persist();
|
|
264
|
+
this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`);
|
|
265
|
+
}
|
|
266
|
+
async tick() {
|
|
267
|
+
if (!this.enabled) return;
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
for (const s of this.store.all()) {
|
|
270
|
+
if (!s.eligible || !s.active || s.busy || s.warming) continue;
|
|
271
|
+
if (now >= s.windowEndsAt) {
|
|
272
|
+
s.active = false;
|
|
273
|
+
this.store.persist();
|
|
274
|
+
this.logger.dbg(
|
|
275
|
+
`window closed ${short(s.sessionID)} after ${s.pingsSent} ping(s) \u2014 cache may cool`
|
|
276
|
+
);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (now >= s.nextPingAt) void this.firePing(s.sessionID);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async firePing(sessionID) {
|
|
283
|
+
const s = this.store.get(sessionID);
|
|
284
|
+
if (!s || s.warming || s.busy) return;
|
|
285
|
+
s.warming = true;
|
|
286
|
+
this.store.persist();
|
|
287
|
+
const startAt = Date.now();
|
|
288
|
+
try {
|
|
289
|
+
const res = await this.client.session.prompt({
|
|
290
|
+
path: { id: sessionID },
|
|
291
|
+
body: { parts: [{ type: "text", text: this.config.pingToken }] }
|
|
292
|
+
});
|
|
293
|
+
const info = res?.data?.info ?? res?.info;
|
|
294
|
+
const cache = info?.tokens?.cache ?? {};
|
|
295
|
+
const record = {
|
|
296
|
+
at: Date.now(),
|
|
297
|
+
hit: Number(cache.read ?? 0) > 0,
|
|
298
|
+
input: Number(info?.tokens?.input ?? 0),
|
|
299
|
+
cacheRead: Number(cache.read ?? 0),
|
|
300
|
+
cacheWrite: Number(cache.write ?? 0),
|
|
301
|
+
output: Number(info?.tokens?.output ?? 0)
|
|
302
|
+
};
|
|
303
|
+
s.lastPing = record;
|
|
304
|
+
s.lastPingAt = record.at;
|
|
305
|
+
s.pingsSent += 1;
|
|
306
|
+
this.logger.info(
|
|
307
|
+
`ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`
|
|
308
|
+
);
|
|
309
|
+
if (this.config.revertPing) await this.revertPing(sessionID, startAt);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
this.logger.warn(`ping ${short(sessionID)} failed`, errText(err));
|
|
312
|
+
} finally {
|
|
313
|
+
s.warming = false;
|
|
314
|
+
s.nextPingAt = Date.now() + jitter(this.config.intervalMs);
|
|
315
|
+
this.store.persist();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Remove the `~` user + `~` assistant turn so it never enters real context. */
|
|
319
|
+
async revertPing(sessionID, startAt) {
|
|
320
|
+
let messages;
|
|
321
|
+
try {
|
|
322
|
+
const res = await this.client.session.messages({ path: { id: sessionID } });
|
|
323
|
+
messages = res?.data ?? res ?? [];
|
|
324
|
+
} catch (err) {
|
|
325
|
+
this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
let target;
|
|
329
|
+
for (const m of messages) {
|
|
330
|
+
if (m?.info?.role !== "user") continue;
|
|
331
|
+
if (Number(m.info?.time?.created ?? 0) < startAt - 2e3) continue;
|
|
332
|
+
const text = (m.parts ?? []).filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
|
|
333
|
+
if (text === this.config.pingToken) target = m.info.id;
|
|
334
|
+
}
|
|
335
|
+
if (!target) {
|
|
336
|
+
this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
await this.client.session.revert({
|
|
341
|
+
path: { id: sessionID },
|
|
342
|
+
body: { messageID: target }
|
|
343
|
+
});
|
|
344
|
+
} catch (err) {
|
|
345
|
+
this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async resolveSession(s) {
|
|
349
|
+
try {
|
|
350
|
+
const res = await this.client.session.get({ path: { id: s.sessionID } });
|
|
351
|
+
const info = res?.data ?? res;
|
|
352
|
+
if (info?.parentID && !this.config.includeChildSessions) {
|
|
353
|
+
s.eligible = false;
|
|
354
|
+
s.modelLabel = info?.model?.id ?? "child";
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
s.modelLabel = info?.model?.id ?? "unknown";
|
|
358
|
+
s.eligible = isEligibleModel(this.config, info?.model?.providerID, info?.model?.id);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
s.eligible = false;
|
|
361
|
+
this.logger.dbg(`resolve ${short(s.sessionID)} failed`, errText(err));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
pollControl() {
|
|
365
|
+
const control = readControl();
|
|
366
|
+
if (!control || control.updatedAt <= this.controlUpdatedAt) return;
|
|
367
|
+
this.controlUpdatedAt = control.updatedAt;
|
|
368
|
+
if (control.enabled === this.enabled) return;
|
|
369
|
+
this.enabled = control.enabled;
|
|
370
|
+
this.store.setEnabled(control.enabled);
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
for (const session of this.store.all()) {
|
|
373
|
+
if (!control.enabled) {
|
|
374
|
+
session.active = false;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (!session.eligible || session.busy || session.warming) continue;
|
|
378
|
+
if (!session.lastResponseAt) session.lastResponseAt = now;
|
|
379
|
+
session.idleSince = now;
|
|
380
|
+
session.windowEndsAt = now + this.config.windowMs;
|
|
381
|
+
session.nextPingAt = now + this.config.intervalMs;
|
|
382
|
+
session.pingsSent = 0;
|
|
383
|
+
session.lastPing = void 0;
|
|
384
|
+
session.active = true;
|
|
385
|
+
}
|
|
386
|
+
this.store.persist();
|
|
387
|
+
this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`);
|
|
388
|
+
}
|
|
389
|
+
/** Recover when a session.status idle event is missed by reconciling with the API. */
|
|
390
|
+
async reconcileStatus() {
|
|
391
|
+
if (this.reconcilingStatus) return;
|
|
392
|
+
const busy = this.store.all().filter((session) => session.busy && !session.warming);
|
|
393
|
+
if (busy.length === 0) return;
|
|
394
|
+
this.reconcilingStatus = true;
|
|
395
|
+
try {
|
|
396
|
+
const res = await this.client.session.status();
|
|
397
|
+
const statuses = res?.data ?? res ?? {};
|
|
398
|
+
for (const session of busy) {
|
|
399
|
+
const status = statuses?.[session.sessionID]?.type ?? "idle";
|
|
400
|
+
if (status === "idle") await this.armWindow(session.sessionID);
|
|
401
|
+
}
|
|
402
|
+
} catch (err) {
|
|
403
|
+
this.logger.dbg("status reconciliation failed", errText(err));
|
|
404
|
+
} finally {
|
|
405
|
+
this.reconcilingStatus = false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
function short(sessionID) {
|
|
410
|
+
return sessionID.slice(-6);
|
|
411
|
+
}
|
|
412
|
+
function jitter(base) {
|
|
413
|
+
return Math.max(1e3, base + Math.floor((Math.random() * 2 - 1) * JITTER_MS));
|
|
414
|
+
}
|
|
415
|
+
function errText(err) {
|
|
416
|
+
return err instanceof Error ? err.message : String(err);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// lib/logger.ts
|
|
420
|
+
var PREFIX = "[cache-keepalive]";
|
|
421
|
+
var Logger = class {
|
|
422
|
+
constructor(debug) {
|
|
423
|
+
this.debug = debug;
|
|
424
|
+
}
|
|
425
|
+
debug;
|
|
426
|
+
info(message, data) {
|
|
427
|
+
if (this.debug) console.log(`${PREFIX} ${message}`, data ?? "");
|
|
428
|
+
}
|
|
429
|
+
warn(message, data) {
|
|
430
|
+
console.warn(`${PREFIX} ${message}`, data ?? "");
|
|
431
|
+
}
|
|
432
|
+
error(message, data) {
|
|
433
|
+
console.error(`${PREFIX} ${message}`, data ?? "");
|
|
434
|
+
}
|
|
435
|
+
dbg(message, data) {
|
|
436
|
+
if (this.debug) console.log(`${PREFIX}:debug ${message}`, data ?? "");
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// lib/state.ts
|
|
441
|
+
import { mkdir as mkdir2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
442
|
+
import { dirname as dirname2 } from "path";
|
|
443
|
+
var KeepaliveStore = class {
|
|
444
|
+
constructor(config, directory) {
|
|
445
|
+
this.config = config;
|
|
446
|
+
this.directory = directory;
|
|
447
|
+
this.enabled = config.enabled;
|
|
448
|
+
}
|
|
449
|
+
config;
|
|
450
|
+
directory;
|
|
451
|
+
sessions = /* @__PURE__ */ new Map();
|
|
452
|
+
writeQueue = Promise.resolve();
|
|
453
|
+
enabled;
|
|
454
|
+
setEnabled(enabled) {
|
|
455
|
+
this.enabled = enabled;
|
|
456
|
+
}
|
|
457
|
+
get(sessionID) {
|
|
458
|
+
return this.sessions.get(sessionID);
|
|
459
|
+
}
|
|
460
|
+
ensure(sessionID) {
|
|
461
|
+
const existing = this.sessions.get(sessionID);
|
|
462
|
+
if (existing) return existing;
|
|
463
|
+
const created = {
|
|
464
|
+
sessionID,
|
|
465
|
+
eligible: false,
|
|
466
|
+
lastResponseAt: 0,
|
|
467
|
+
idleSince: 0,
|
|
468
|
+
windowEndsAt: 0,
|
|
469
|
+
nextPingAt: 0,
|
|
470
|
+
pingsSent: 0,
|
|
471
|
+
busy: false,
|
|
472
|
+
warming: false,
|
|
473
|
+
active: false
|
|
474
|
+
};
|
|
475
|
+
this.sessions.set(sessionID, created);
|
|
476
|
+
return created;
|
|
477
|
+
}
|
|
478
|
+
remove(sessionID) {
|
|
479
|
+
this.sessions.delete(sessionID);
|
|
480
|
+
}
|
|
481
|
+
all() {
|
|
482
|
+
return [...this.sessions.values()];
|
|
483
|
+
}
|
|
484
|
+
/** Atomically persist a lean snapshot for the TUI. Never throws to callers. */
|
|
485
|
+
persist() {
|
|
486
|
+
const snapshot = {
|
|
487
|
+
version: 1,
|
|
488
|
+
updatedAt: Date.now(),
|
|
489
|
+
enabled: this.enabled,
|
|
490
|
+
intervalMs: this.config.intervalMs,
|
|
491
|
+
windowMs: this.config.windowMs,
|
|
492
|
+
sessions: Object.fromEntries(
|
|
493
|
+
this.all().map((s) => [
|
|
494
|
+
s.sessionID,
|
|
495
|
+
{
|
|
496
|
+
eligible: s.eligible,
|
|
497
|
+
modelLabel: s.modelLabel,
|
|
498
|
+
lastResponseAt: s.lastResponseAt,
|
|
499
|
+
idleSince: s.idleSince,
|
|
500
|
+
windowEndsAt: s.windowEndsAt,
|
|
501
|
+
nextPingAt: s.nextPingAt,
|
|
502
|
+
intervalMs: this.config.intervalMs,
|
|
503
|
+
pingsSent: s.pingsSent,
|
|
504
|
+
busy: s.busy,
|
|
505
|
+
warming: s.warming,
|
|
506
|
+
active: s.active,
|
|
507
|
+
lastPing: s.lastPing
|
|
508
|
+
}
|
|
509
|
+
])
|
|
510
|
+
)
|
|
511
|
+
};
|
|
512
|
+
const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot));
|
|
513
|
+
this.writeQueue = this.writeQueue.then(write, write);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
async function atomicWrite(path, contents) {
|
|
517
|
+
try {
|
|
518
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
519
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
520
|
+
await writeFile2(tmp, `${contents}
|
|
521
|
+
`, "utf8");
|
|
522
|
+
await rename2(tmp, path);
|
|
523
|
+
} catch {
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// lib/system.ts
|
|
528
|
+
function keepaliveInstruction(pingToken) {
|
|
529
|
+
return [
|
|
530
|
+
"<cache_keepalive>",
|
|
531
|
+
`A user message consisting of exactly the single token "${pingToken}" is an`,
|
|
532
|
+
"automated cache-keepalive ping, not a real request. When you receive it,",
|
|
533
|
+
`reply with exactly "${pingToken}" and nothing else. Do not call any tools,`,
|
|
534
|
+
"do not explain, and do not take any action.",
|
|
535
|
+
"</cache_keepalive>"
|
|
536
|
+
].join("\n");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// index.ts
|
|
540
|
+
var server = async (ctx, options) => {
|
|
541
|
+
const config = getConfig(options);
|
|
542
|
+
const logger = new Logger(config.debug);
|
|
543
|
+
if (!config.enabled) {
|
|
544
|
+
logger.info("disabled via config");
|
|
545
|
+
return {};
|
|
546
|
+
}
|
|
547
|
+
if (isSecureMode()) configureClientAuth(ctx.client);
|
|
548
|
+
const store = new KeepaliveStore(config, ctx.directory);
|
|
549
|
+
const engine = new KeepaliveEngine(ctx.client, config, store, logger);
|
|
550
|
+
engine.start();
|
|
551
|
+
const version = true ? "0.1.0" : "dev";
|
|
552
|
+
logger.info(`v${version} ready`);
|
|
553
|
+
const instruction = keepaliveInstruction(config.pingToken);
|
|
554
|
+
return {
|
|
555
|
+
event: async ({ event }) => {
|
|
556
|
+
await engine.onEvent(event);
|
|
557
|
+
},
|
|
558
|
+
// Keep the system prefix identical between real turns and pings, and make
|
|
559
|
+
// the model answer a ping with a single token.
|
|
560
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
561
|
+
if (!config.injectSystemInstruction) return;
|
|
562
|
+
const model = input.model;
|
|
563
|
+
if (!isEligibleModel(config, model?.providerID, model?.id ?? model?.modelID)) return;
|
|
564
|
+
output.system.push(instruction);
|
|
565
|
+
},
|
|
566
|
+
// Backstop: if the model ignores the instruction and tries a tool during a
|
|
567
|
+
// ping, block it — the ping turn is going to be reverted anyway.
|
|
568
|
+
"tool.execute.before": async (input) => {
|
|
569
|
+
if (engine.isWarming(input.sessionID)) {
|
|
570
|
+
throw new Error("cache-keepalive: tools are disabled during a keepalive ping");
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
dispose: async () => {
|
|
574
|
+
engine.stop();
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
};
|
|
578
|
+
var index_default = server;
|
|
579
|
+
export {
|
|
580
|
+
index_default as default
|
|
581
|
+
};
|
|
582
|
+
//# sourceMappingURL=index.js.map
|