@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.
- package/MIGRATION.md +57 -0
- package/README.md +75 -51
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1093 -649
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +8 -25
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/control.d.ts +10 -37
- package/dist/lib/control.d.ts.map +1 -1
- package/dist/lib/paths.d.ts +5 -12
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/replay-warmer.d.ts +95 -0
- package/dist/lib/replay-warmer.d.ts.map +1 -0
- package/dist/lib/state.d.ts +15 -117
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +1 -6
- package/dist/lib/tui/commands.d.ts.map +1 -1
- package/dist/lib/tui/footer.d.ts +5 -5
- package/dist/lib/tui/footer.d.ts.map +1 -1
- package/dist/lib/tui/format.d.ts.map +1 -1
- package/dist/lib/types.d.ts +123 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.d.ts.map +1 -1
- package/lib/config.ts +106 -74
- package/lib/control.ts +50 -86
- package/lib/paths.ts +12 -14
- package/lib/replay-warmer.ts +1200 -0
- package/lib/state.ts +171 -181
- package/lib/tui/commands.tsx +36 -144
- package/lib/tui/footer.tsx +54 -156
- package/lib/tui/format.ts +4 -0
- package/lib/types.ts +130 -0
- package/package.json +3 -2
- package/tui.tsx +40 -0
- package/dist/lib/keepalive.d.ts +0 -88
- package/dist/lib/keepalive.d.ts.map +0 -1
- package/dist/lib/model.d.ts +0 -8
- package/dist/lib/model.d.ts.map +0 -1
- package/dist/lib/system.d.ts +0 -11
- package/dist/lib/system.d.ts.map +0 -1
- package/lib/keepalive.ts +0 -665
- package/lib/model.ts +0 -19
- package/lib/system.ts +0 -19
package/lib/config.ts
CHANGED
|
@@ -1,101 +1,133 @@
|
|
|
1
1
|
export type KeepaliveConfig = {
|
|
2
|
-
/** Master switch. When false the plugin registers no timers or hooks. */
|
|
3
2
|
enabled: boolean
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* write/read break-even; providers with automatic caching may be cheaper.
|
|
10
|
-
*/
|
|
11
|
-
windowMs: number
|
|
12
|
-
/**
|
|
13
|
-
* Append a stable keepalive instruction to the system prompt so the model
|
|
14
|
-
* answers a `~` ping with a single `~` token and never calls tools. The
|
|
15
|
-
* instruction is added to real turns too, so the cached prefix stays identical.
|
|
16
|
-
*/
|
|
17
|
-
injectSystemInstruction: boolean
|
|
18
|
-
/** The single character/token used as the keepalive suffix. */
|
|
19
|
-
pingToken: string
|
|
20
|
-
/** Remove synthetic ping turns after measuring their cache usage. */
|
|
21
|
-
revertPing: boolean
|
|
22
|
-
/** Warm subagent/child sessions too. Off by default to avoid wasted pings. */
|
|
3
|
+
intervals: Record<string, number>
|
|
4
|
+
hosts: string[]
|
|
5
|
+
cacheReadFactor: number
|
|
6
|
+
missFactor: number
|
|
7
|
+
maxReplaysPerGap: "auto" | number
|
|
23
8
|
includeChildSessions: boolean
|
|
9
|
+
replayTimeoutMs: number
|
|
10
|
+
maxStoredBytes: number
|
|
24
11
|
debug: boolean
|
|
25
|
-
/** providerID substrings eligible for warming (cache-controlled providers). */
|
|
26
|
-
providerAllowlist: string[]
|
|
27
|
-
/** modelID substrings eligible for warming. */
|
|
28
|
-
modelAllowlist: string[]
|
|
29
12
|
}
|
|
30
13
|
|
|
31
14
|
const DEFAULTS: KeepaliveConfig = {
|
|
32
15
|
enabled: true,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
16
|
+
intervals: { claude: 285_000, gpt: 1_680_000 },
|
|
17
|
+
hosts: ["githubcopilot.com"],
|
|
18
|
+
cacheReadFactor: 0.1,
|
|
19
|
+
missFactor: 1,
|
|
20
|
+
maxReplaysPerGap: "auto",
|
|
38
21
|
includeChildSessions: false,
|
|
22
|
+
replayTimeoutMs: 60_000,
|
|
23
|
+
maxStoredBytes: 67_108_864,
|
|
39
24
|
debug: false,
|
|
40
|
-
providerAllowlist: ["copilot"],
|
|
41
|
-
modelAllowlist: ["claude", "anthropic", "sonnet", "opus", "haiku", "gpt"],
|
|
42
25
|
}
|
|
43
26
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
27
|
+
const DEPRECATED = [
|
|
28
|
+
"intervalMs",
|
|
29
|
+
"intervalSeconds",
|
|
30
|
+
"windowMs",
|
|
31
|
+
"windowMinutes",
|
|
32
|
+
"revertPing",
|
|
33
|
+
"pingToken",
|
|
34
|
+
"injectSystemInstruction",
|
|
35
|
+
"providerAllowlist",
|
|
36
|
+
"modelAllowlist",
|
|
37
|
+
"claudeBusyWarm",
|
|
38
|
+
"claudeBusyWarmIntervalMs",
|
|
39
|
+
"claudeBusyWarmWindowMs",
|
|
40
|
+
]
|
|
41
|
+
const warnedDeprecated = new Set<string>()
|
|
47
42
|
|
|
43
|
+
export function getConfig(
|
|
44
|
+
options: Record<string, unknown> | undefined,
|
|
45
|
+
warn: (message: string) => void = () => {},
|
|
46
|
+
): KeepaliveConfig {
|
|
47
|
+
const option = options ?? {}
|
|
48
|
+
const env = process.env
|
|
49
|
+
for (const key of DEPRECATED) {
|
|
50
|
+
if (key in option && !warnedDeprecated.has(key)) {
|
|
51
|
+
warnedDeprecated.add(key)
|
|
52
|
+
warn(`deprecated option ${key} is ignored`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const parseJson = (raw: string | undefined): unknown => {
|
|
56
|
+
if (!raw) return undefined
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(raw)
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const intervals = objectOption(
|
|
64
|
+
option.intervals ?? parseJson(env.OPENCODE_KEEPALIVE_INTERVALS),
|
|
65
|
+
DEFAULTS.intervals,
|
|
66
|
+
)
|
|
67
|
+
const hosts = listOption(
|
|
68
|
+
option.hosts ?? parseJson(env.OPENCODE_KEEPALIVE_HOSTS),
|
|
69
|
+
DEFAULTS.hosts,
|
|
70
|
+
)
|
|
71
|
+
const maxRaw = option.maxReplaysPerGap ?? env.OPENCODE_KEEPALIVE_MAX_REPLAYS_PER_GAP
|
|
72
|
+
const maxReplaysPerGap =
|
|
73
|
+
maxRaw === "auto"
|
|
74
|
+
? "auto"
|
|
75
|
+
: positiveNumber(maxRaw) !== undefined
|
|
76
|
+
? positiveNumber(maxRaw)!
|
|
77
|
+
: DEFAULTS.maxReplaysPerGap
|
|
48
78
|
return {
|
|
49
|
-
enabled:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
79
|
+
enabled: boolOption(
|
|
80
|
+
option.enabled,
|
|
81
|
+
env.OPENCODE_KEEPALIVE_ENABLED === undefined
|
|
82
|
+
? DEFAULTS.enabled
|
|
83
|
+
: env.OPENCODE_KEEPALIVE_ENABLED !== "false",
|
|
54
84
|
),
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
intervals,
|
|
86
|
+
hosts,
|
|
87
|
+
cacheReadFactor:
|
|
88
|
+
positiveNumber(option.cacheReadFactor ?? env.OPENCODE_KEEPALIVE_CACHE_READ_FACTOR) ??
|
|
89
|
+
DEFAULTS.cacheReadFactor,
|
|
90
|
+
missFactor:
|
|
91
|
+
positiveNumber(option.missFactor ?? env.OPENCODE_KEEPALIVE_MISS_FACTOR) ??
|
|
92
|
+
DEFAULTS.missFactor,
|
|
93
|
+
maxReplaysPerGap,
|
|
94
|
+
includeChildSessions: boolOption(
|
|
95
|
+
option.includeChildSessions,
|
|
96
|
+
env.OPENCODE_KEEPALIVE_INCLUDE_CHILD_SESSIONS === "true" ||
|
|
97
|
+
DEFAULTS.includeChildSessions,
|
|
59
98
|
),
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
DEFAULTS.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
: DEFAULTS.pingToken,
|
|
68
|
-
revertPing: boolOpt(
|
|
69
|
-
o.revertPing,
|
|
70
|
-
env.OPENCODE_KEEPALIVE_REVERT_PING !== "false" && DEFAULTS.revertPing,
|
|
71
|
-
),
|
|
72
|
-
includeChildSessions: boolOpt(o.includeChildSessions, DEFAULTS.includeChildSessions),
|
|
73
|
-
debug: boolOpt(o.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true"),
|
|
74
|
-
providerAllowlist: listOpt(o.providerAllowlist, DEFAULTS.providerAllowlist),
|
|
75
|
-
modelAllowlist: listOpt(o.modelAllowlist, DEFAULTS.modelAllowlist),
|
|
99
|
+
replayTimeoutMs:
|
|
100
|
+
positiveNumber(option.replayTimeoutMs ?? env.OPENCODE_KEEPALIVE_REPLAY_TIMEOUT_MS) ??
|
|
101
|
+
DEFAULTS.replayTimeoutMs,
|
|
102
|
+
maxStoredBytes:
|
|
103
|
+
positiveNumber(option.maxStoredBytes ?? env.OPENCODE_KEEPALIVE_MAX_STORED_BYTES) ??
|
|
104
|
+
DEFAULTS.maxStoredBytes,
|
|
105
|
+
debug: boolOption(option.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true"),
|
|
76
106
|
}
|
|
77
107
|
}
|
|
78
108
|
|
|
79
|
-
function
|
|
109
|
+
function boolOption(value: unknown, fallback: boolean): boolean {
|
|
80
110
|
return typeof value === "boolean" ? value : fallback
|
|
81
111
|
}
|
|
82
112
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return filtered.length > 0 ? filtered : fallback
|
|
113
|
+
function positiveNumber(value: unknown): number | undefined {
|
|
114
|
+
const parsed = typeof value === "string" ? Number(value) : value
|
|
115
|
+
return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
|
87
116
|
}
|
|
88
117
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return
|
|
94
|
-
|
|
118
|
+
function objectOption(value: unknown, fallback: Record<string, number>): Record<string, number> {
|
|
119
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback
|
|
120
|
+
const entries = Object.entries(value).flatMap(([key, interval]) => {
|
|
121
|
+
const parsed = positiveNumber(interval)
|
|
122
|
+
return parsed === undefined ? [] : [[key.toLowerCase(), parsed] as const]
|
|
123
|
+
})
|
|
124
|
+
return entries.length ? Object.fromEntries(entries) : fallback
|
|
95
125
|
}
|
|
96
126
|
|
|
97
|
-
function
|
|
98
|
-
if (!
|
|
99
|
-
const
|
|
100
|
-
|
|
127
|
+
function listOption(value: unknown, fallback: string[]): string[] {
|
|
128
|
+
if (!Array.isArray(value)) return fallback
|
|
129
|
+
const values = value.filter(
|
|
130
|
+
(item): item is string => typeof item === "string" && item.length > 0,
|
|
131
|
+
)
|
|
132
|
+
return values.length ? values : fallback
|
|
101
133
|
}
|
package/lib/control.ts
CHANGED
|
@@ -4,90 +4,57 @@ import { mkdir, rename, writeFile } from "node:fs/promises"
|
|
|
4
4
|
import { dirname } from "node:path"
|
|
5
5
|
import { controlFilePath } from "./paths"
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
/** Largest accepted runtime ping interval (sanity bound; no provider cache outlives it). */
|
|
10
|
-
export const MAX_INTERVAL_MS = 24 * 3_600_000
|
|
7
|
+
const VERSION = 2
|
|
8
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* Absent fields mean "use the plugin configuration". Readers ignore unknown fields,
|
|
15
|
-
* so older servers still honour `enabled` and ignore `intervalMs`.
|
|
16
|
-
*/
|
|
17
|
-
export type KeepaliveControl = {
|
|
18
|
-
version: 1
|
|
19
|
-
/** Runtime on/off override. */
|
|
20
|
-
enabled?: boolean
|
|
21
|
-
/** Runtime ping interval override in milliseconds. */
|
|
22
|
-
intervalMs?: number
|
|
23
|
-
/** Epoch ms of the last write; strictly increasing so every write is observed. */
|
|
24
|
-
updatedAt: number
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Changes to apply to the control file; `intervalMs: null` clears the override. */
|
|
28
|
-
export type ControlPatch = {
|
|
29
|
-
enabled?: boolean
|
|
30
|
-
intervalMs?: number | null
|
|
31
|
-
}
|
|
10
|
+
export type SessionControl = { enabled: boolean; updatedAt: number }
|
|
11
|
+
export type SessionControlFile = { version: 2; sessions: Record<string, SessionControl> }
|
|
32
12
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
typeof ms === "number" &&
|
|
36
|
-
Number.isFinite(ms) &&
|
|
37
|
-
ms >= MIN_INTERVAL_MS &&
|
|
38
|
-
ms <= MAX_INTERVAL_MS
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Read the directory's control file. Returns undefined when it is missing or
|
|
44
|
-
* malformed; an out-of-range interval override is dropped rather than rejecting
|
|
45
|
-
* the whole file. Never throws.
|
|
46
|
-
*/
|
|
47
|
-
export function readControl(directory: string): KeepaliveControl | undefined {
|
|
13
|
+
/** Read current session overrides; old folder-level controls are intentionally ignored. */
|
|
14
|
+
export function readSessionControls(directory: string, now = Date.now()): SessionControlFile {
|
|
48
15
|
try {
|
|
49
16
|
const value = JSON.parse(readFileSync(controlFilePath(directory), "utf8")) as Record<
|
|
50
17
|
string,
|
|
51
18
|
unknown
|
|
52
19
|
>
|
|
53
|
-
if (value
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
20
|
+
if (value.version !== VERSION || !value.sessions || typeof value.sessions !== "object")
|
|
21
|
+
return { version: VERSION, sessions: {} }
|
|
22
|
+
const sessions: Record<string, SessionControl> = {}
|
|
23
|
+
for (const [id, raw] of Object.entries(value.sessions)) {
|
|
24
|
+
if (
|
|
25
|
+
!raw ||
|
|
26
|
+
typeof raw !== "object" ||
|
|
27
|
+
typeof (raw as SessionControl).enabled !== "boolean" ||
|
|
28
|
+
typeof (raw as SessionControl).updatedAt !== "number" ||
|
|
29
|
+
(raw as SessionControl).updatedAt < now - MAX_AGE_MS
|
|
30
|
+
)
|
|
31
|
+
continue
|
|
32
|
+
sessions[id] = {
|
|
33
|
+
enabled: (raw as SessionControl).enabled,
|
|
34
|
+
updatedAt: (raw as SessionControl).updatedAt,
|
|
35
|
+
}
|
|
60
36
|
}
|
|
37
|
+
return { version: VERSION, sessions }
|
|
61
38
|
} catch {
|
|
62
|
-
return
|
|
39
|
+
return { version: VERSION, sessions: {} }
|
|
63
40
|
}
|
|
64
41
|
}
|
|
65
42
|
|
|
66
|
-
|
|
67
|
-
* Merge `patch` into the directory's control file and write it atomically. Fields
|
|
68
|
-
* not in the patch keep their current value.
|
|
69
|
-
*
|
|
70
|
-
* @throws RangeError when `patch.intervalMs` is outside
|
|
71
|
-
* [MIN_INTERVAL_MS, MAX_INTERVAL_MS]; filesystem errors propagate.
|
|
72
|
-
*/
|
|
73
|
-
export async function updateControl(
|
|
43
|
+
export function readSessionControl(
|
|
74
44
|
directory: string,
|
|
75
|
-
|
|
76
|
-
):
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
patch.intervalMs !== null &&
|
|
80
|
-
!isValidInterval(patch.intervalMs)
|
|
81
|
-
) {
|
|
82
|
-
throw new RangeError(
|
|
83
|
-
`Ping interval must be between ${MIN_INTERVAL_MS / 1000}s and ${MAX_INTERVAL_MS / 3_600_000}h`,
|
|
84
|
-
)
|
|
85
|
-
}
|
|
45
|
+
sessionID: string,
|
|
46
|
+
): SessionControl | undefined {
|
|
47
|
+
return readSessionControls(directory).sessions[sessionID]
|
|
48
|
+
}
|
|
86
49
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
50
|
+
/** Atomically update only this session key and prune expired overrides. */
|
|
51
|
+
export async function setSessionEnabled(
|
|
52
|
+
directory: string,
|
|
53
|
+
sessionID: string,
|
|
54
|
+
enabled: boolean,
|
|
55
|
+
now = Date.now(),
|
|
56
|
+
): Promise<SessionControlFile> {
|
|
57
|
+
const run = updateQueue.then(() => writeSessionControl(directory, sessionID, enabled, now))
|
|
91
58
|
updateQueue = run.then(
|
|
92
59
|
() => undefined,
|
|
93
60
|
() => undefined,
|
|
@@ -97,17 +64,19 @@ export async function updateControl(
|
|
|
97
64
|
|
|
98
65
|
let updateQueue: Promise<void> = Promise.resolve()
|
|
99
66
|
|
|
100
|
-
async function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
67
|
+
async function writeSessionControl(
|
|
68
|
+
directory: string,
|
|
69
|
+
sessionID: string,
|
|
70
|
+
enabled: boolean,
|
|
71
|
+
now: number,
|
|
72
|
+
): Promise<SessionControlFile> {
|
|
73
|
+
const current = readSessionControls(directory, now)
|
|
74
|
+
const cutoff = now - MAX_AGE_MS
|
|
75
|
+
const sessions = Object.fromEntries(
|
|
76
|
+
Object.entries(current.sessions).filter(([, value]) => value.updatedAt >= cutoff),
|
|
77
|
+
)
|
|
78
|
+
sessions[sessionID] = { enabled, updatedAt: now }
|
|
79
|
+
const value: SessionControlFile = { version: VERSION, sessions }
|
|
111
80
|
const path = controlFilePath(directory)
|
|
112
81
|
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
113
82
|
await mkdir(dirname(path), { recursive: true })
|
|
@@ -115,8 +84,3 @@ async function writeMerged(directory: string, patch: ControlPatch): Promise<Keep
|
|
|
115
84
|
await rename(tmp, path)
|
|
116
85
|
return value
|
|
117
86
|
}
|
|
118
|
-
|
|
119
|
-
/** Set the runtime on/off override, preserving any interval override. */
|
|
120
|
-
export function writeControl(enabled: boolean, directory: string): Promise<KeepaliveControl> {
|
|
121
|
-
return updateControl(directory, { enabled })
|
|
122
|
-
}
|
package/lib/paths.ts
CHANGED
|
@@ -2,17 +2,17 @@ import { createHash } from "node:crypto"
|
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
/** Stable short directory key shared by that directory's state-file readers/writers. */
|
|
6
|
+
export function stateDirectoryKey(directory: string): string {
|
|
7
|
+
return createHash("sha256").update(directory).digest("hex").slice(0, 16)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Filename for one process/plugin-instance state snapshot. */
|
|
11
|
+
export function instanceStateFilePath(directory: string, pid: number, instanceId: string): string {
|
|
12
|
+
return join(
|
|
13
|
+
stateDirectoryPath(),
|
|
14
|
+
`state-v2-${stateDirectoryKey(directory)}-${pid}-${instanceId}.json`,
|
|
15
|
+
)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function stateDirectoryPath(): string {
|
|
@@ -26,9 +26,7 @@ export function stateDirectoryPath(): string {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
30
|
-
* Scoped per directory (like the state file) so toggling keepalive in one
|
|
31
|
-
* project does not affect every other open opencode session.
|
|
29
|
+
* Session runtime controls are stored by directory, with entries keyed by session ID.
|
|
32
30
|
*/
|
|
33
31
|
export function controlFilePath(directory: string): string {
|
|
34
32
|
const key = createHash("sha256").update(directory).digest("hex").slice(0, 16)
|