@vikrant82/opencode-cache-keepalive 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/MIGRATION.md +57 -0
  2. package/README.md +75 -48
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1097 -614
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/config.d.ts +8 -25
  7. package/dist/lib/config.d.ts.map +1 -1
  8. package/dist/lib/control.d.ts +10 -4
  9. package/dist/lib/control.d.ts.map +1 -1
  10. package/dist/lib/paths.d.ts +5 -12
  11. package/dist/lib/paths.d.ts.map +1 -1
  12. package/dist/lib/replay-warmer.d.ts +95 -0
  13. package/dist/lib/replay-warmer.d.ts.map +1 -0
  14. package/dist/lib/state.d.ts +15 -114
  15. package/dist/lib/state.d.ts.map +1 -1
  16. package/dist/lib/tui/commands.d.ts +2 -0
  17. package/dist/lib/tui/commands.d.ts.map +1 -1
  18. package/dist/lib/tui/footer.d.ts +5 -5
  19. package/dist/lib/tui/footer.d.ts.map +1 -1
  20. package/dist/lib/tui/format.d.ts +8 -0
  21. package/dist/lib/tui/format.d.ts.map +1 -1
  22. package/dist/lib/types.d.ts +123 -0
  23. package/dist/lib/types.d.ts.map +1 -0
  24. package/dist/tui.d.ts +3 -0
  25. package/dist/tui.d.ts.map +1 -1
  26. package/lib/config.ts +106 -74
  27. package/lib/control.ts +69 -20
  28. package/lib/paths.ts +12 -14
  29. package/lib/replay-warmer.ts +1200 -0
  30. package/lib/state.ts +172 -175
  31. package/lib/tui/commands.tsx +76 -0
  32. package/lib/tui/footer.tsx +55 -130
  33. package/lib/tui/format.ts +40 -0
  34. package/lib/types.ts +130 -0
  35. package/package.json +3 -2
  36. package/tui.tsx +40 -0
  37. package/dist/lib/keepalive.d.ts +0 -85
  38. package/dist/lib/keepalive.d.ts.map +0 -1
  39. package/dist/lib/model.d.ts +0 -8
  40. package/dist/lib/model.d.ts.map +0 -1
  41. package/dist/lib/system.d.ts +0 -11
  42. package/dist/lib/system.d.ts.map +0 -1
  43. package/lib/keepalive.ts +0 -636
  44. package/lib/model.ts +0 -19
  45. package/lib/system.ts +0 -19
  46. package/lib/tui/commands.ts +0 -59
package/lib/tui/format.ts CHANGED
@@ -7,7 +7,47 @@ export function mmss(ms: number): string {
7
7
 
8
8
  export function kfmt(n: number): string {
9
9
  if (!Number.isFinite(n) || n <= 0) return "0"
10
+ if (n >= 1_000_000) {
11
+ const m = n / 1_000_000
12
+ return `${m >= 10 ? Math.round(m) : m.toFixed(1)}M`
13
+ }
10
14
  if (n < 1000) return `${Math.round(n)}`
11
15
  const k = n / 1000
12
16
  return `${k >= 10 ? Math.round(k) : k.toFixed(1)}k`
13
17
  }
18
+
19
+ const UNIT_MS: Record<string, number> = { h: 3_600_000, m: 60_000, s: 1_000 }
20
+
21
+ /**
22
+ * Parse a human duration such as "4m30s", "4.5m", "270s", "1h" or "28m 20s".
23
+ * A bare number means minutes. Returns undefined for anything unparseable;
24
+ * range checks are the caller's concern.
25
+ */
26
+ export function parseDuration(input: string): number | undefined {
27
+ const text = input.trim().toLowerCase()
28
+ if (!text) return undefined
29
+ if (/^\d+(?:\.\d+)?$/.test(text)) return Math.round(Number(text) * UNIT_MS.m)
30
+
31
+ const segment = /(\d+(?:\.\d+)?)\s*(hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\s*/y
32
+ let total = 0
33
+ while (segment.lastIndex < text.length) {
34
+ const match = segment.exec(text)
35
+ if (!match) return undefined
36
+ total += Number(match[1]) * UNIT_MS[match[2][0]]
37
+ }
38
+ return Math.round(total)
39
+ }
40
+
41
+ /** Compact duration label, e.g. 270000 -> "4m 30s", 3600000 -> "1h". */
42
+ export function formatDuration(ms: number): string {
43
+ const total = Math.max(0, Math.round(ms / 1000))
44
+ const hours = Math.floor(total / 3600)
45
+ const minutes = Math.floor((total % 3600) / 60)
46
+ const seconds = total % 60
47
+ const parts = [
48
+ hours ? `${hours}h` : "",
49
+ minutes ? `${minutes}m` : "",
50
+ seconds ? `${seconds}s` : "",
51
+ ].filter(Boolean)
52
+ return parts.length ? parts.join(" ") : "0s"
53
+ }
package/lib/types.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Shared contracts between the server-side replay engine and the state/TUI layer.
3
+ *
4
+ * The engine produces one self-contained {@link SessionWarmEntry} per tracked root
5
+ * session and hands it to a {@link WarmStateSink}; the TUI reads persisted entries
6
+ * back per session. Entries never contain request URLs with queries, headers,
7
+ * bodies, credentials, or conversation text.
8
+ */
9
+
10
+ /** Wire API family of a recorded request, derived from the request pathname. */
11
+ export type WarmApi = "messages" | "responses" | "chat"
12
+
13
+ /**
14
+ * Display/lifecycle state of a session's cache warming.
15
+ * - `active`: a real model request/step is in flight; no replay is sent.
16
+ * - `busy`: the session is blocked on a running tool; replays are scheduled.
17
+ * - `idle`: the session is idle between turns; replays are scheduled.
18
+ * - `stopped`: warming ended for the current gap; see {@link StopReason}.
19
+ * - `off`: warming is disabled by config or runtime control.
20
+ */
21
+ export type WarmState = "active" | "busy" | "idle" | "stopped" | "off"
22
+
23
+ /**
24
+ * Why warming stopped for the current gap.
25
+ * - `cap`: the per-gap replay cap was reached.
26
+ * - `lapsed`: the cache is believed expired (failed retry, or a replay reported a cache write instead of a read).
27
+ * - `rejected-4xx`: the provider rejected the replay with 400/401/403.
28
+ * - `disabled`: runtime control or config turned warming off.
29
+ */
30
+ export type StopReason = "cap" | "lapsed" | "rejected-4xx" | "disabled"
31
+
32
+ /** Outcome of the most recent replay attempt. */
33
+ export interface ReplayResult {
34
+ /** Replay send time, epoch ms. */
35
+ at: number
36
+ /** HTTP status; 0 for network error or timeout. */
37
+ status: number
38
+ /** Milliseconds from issuing the replay fetch to the API-specific abort point. */
39
+ latencyMs: number
40
+ /** Cached prompt tokens reported by the provider, when parseable before abort (messages API). */
41
+ cacheRead?: number
42
+ /** Cache-write prompt tokens reported by the provider, when parseable before abort (messages API). */
43
+ cacheWrite?: number
44
+ }
45
+
46
+ /** Result of the first real model call after a gap that had at least one successful replay. */
47
+ export interface ResumeResult {
48
+ /** step-finish time, epoch ms. */
49
+ at: number
50
+ /** cache.read tokens of that call. */
51
+ read: number
52
+ /** Prompt tokens (input + cache.read + cache.write) of the last call before the gap. */
53
+ promptPrev: number
54
+ /** True when read >= 0.9 * promptPrev. */
55
+ hit: boolean
56
+ /** Successful replays spent in the gap. */
57
+ replays: number
58
+ /**
59
+ * Cache-write tokens avoided for the gap: `promptPrev` on a hit, 0 on a miss.
60
+ * Gross figure shown to users as "saved"; replay cost is reported separately.
61
+ */
62
+ avoidedTokens: number
63
+ /**
64
+ * Cached-read tokens spent by the gap's successful replays (sum of each replay's
65
+ * reported cacheRead, falling back to promptPrev when not parsed). Logged only.
66
+ */
67
+ replayReadTokens: number
68
+ }
69
+
70
+ /**
71
+ * Complete, self-contained display state for one root session. Every field the TUI
72
+ * needs is here so readers never depend on file-level or process-level fields.
73
+ */
74
+ export interface SessionWarmEntry {
75
+ sessionID: string
76
+ /** Last time this entry changed, epoch ms; readers pick the freshest entry per session. */
77
+ updatedAt: number
78
+ /** Effective enablement (config AND runtime control). */
79
+ enabled: boolean
80
+ /** Model id from the recorded request body. */
81
+ model: string
82
+ api: WarmApi
83
+ state: WarmState
84
+ stopReason?: StopReason
85
+ /** HTTP status that caused `rejected-4xx`. */
86
+ stopStatus?: number
87
+ /** Monotonic per-session gap counter; a gap starts at each real request send. */
88
+ gapId: number
89
+ /** Successful replays in the current gap. */
90
+ gapReplays: number
91
+ /** Per-gap replay cap in effect. */
92
+ cap: number
93
+ /** Next scheduled replay time, epoch ms; null when none is scheduled. */
94
+ nextReplayAt: number | null
95
+ /** Successful replays across the session's lifetime in this process. */
96
+ sessionReplays: number
97
+ /** Sum of ResumeResult.avoidedTokens across the session's gaps in this process. */
98
+ avoidedTokens: number
99
+ /** Resumes (first call after a gap with ≥1 successful replay) that hit, in this process. */
100
+ resumeHits: number
101
+ /** All resumes (hits + misses) in this process. */
102
+ resumeCount: number
103
+ lastReplay?: ReplayResult
104
+ resume?: ResumeResult
105
+ }
106
+
107
+ /** Process-wide aggregates, informational only (never used to gate display). */
108
+ export interface ProcessTotals {
109
+ replays: number
110
+ avoidedTokens: number
111
+ replayReadTokens: number
112
+ resumeHits: number
113
+ resumeMisses: number
114
+ }
115
+
116
+ /**
117
+ * Destination for engine state. Implementations persist sanitized entries for the
118
+ * TUI. Calls are synchronous and must never throw; persistence may be debounced.
119
+ * Single-threaded use from the plugin's event loop is assumed.
120
+ */
121
+ export interface WarmStateSink {
122
+ /** Replace the entry for `entry.sessionID`. */
123
+ upsert(entry: SessionWarmEntry): void
124
+ /** Forget a session (e.g. deleted). */
125
+ remove(sessionID: string): void
126
+ /** Replace process totals. */
127
+ setTotals(totals: ProcessTotals): void
128
+ /** Flush pending writes and remove this instance's persisted file. Never rejects. */
129
+ dispose(): Promise<void>
130
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@vikrant82/opencode-cache-keepalive",
4
- "version": "0.1.5",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
- "description": "OpenCode plugin that keeps provider prompt caches warm by sending invisible keepalive pings, with a live TUI readout",
6
+ "description": "OpenCode plugin that uses bounded provider-request replay to keep prompt caches warm, with a live TUI readout",
7
7
  "main": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
9
  "exports": {
@@ -68,6 +68,7 @@
68
68
  "lib/",
69
69
  "tui.tsx",
70
70
  "README.md",
71
+ "MIGRATION.md",
71
72
  "LICENSE"
72
73
  ]
73
74
  }
package/tui.tsx CHANGED
@@ -3,9 +3,49 @@
3
3
  import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
4
4
  import { registerKeepaliveCommands } from "./lib/tui/commands"
5
5
  import { KeepaliveFooter } from "./lib/tui/footer"
6
+ import { readSessionEntry } from "./lib/state"
7
+ import type { SessionWarmEntry } from "./lib/types"
8
+
9
+ /** Route-level owner for deduplicating rejected replay notifications. */
10
+ export function notifyRejectedReplayOnce(
11
+ notified: Set<string>,
12
+ sessionID: string,
13
+ entry: SessionWarmEntry,
14
+ toast: (message: string) => void,
15
+ ): void {
16
+ if (
17
+ entry.state !== "stopped" ||
18
+ entry.stopReason !== "rejected-4xx" ||
19
+ entry.stopStatus === undefined
20
+ )
21
+ return
22
+ const key = `${sessionID}:${entry.gapId}`
23
+ if (notified.has(key)) return
24
+ notified.add(key)
25
+ toast(`Keepalive refresh rejected (${entry.stopStatus}) — stopped for this session`)
26
+ }
6
27
 
7
28
  const tui: TuiPluginModule["tui"] = async (api) => {
8
29
  registerKeepaliveCommands(api)
30
+ const notified = new Set<string>()
31
+ const toastTimer = setInterval(() => {
32
+ const route = api.route.current
33
+ const sessionID =
34
+ route.name === "session" && typeof route.params?.sessionID === "string"
35
+ ? route.params.sessionID
36
+ : undefined
37
+ if (!sessionID) return
38
+ const entry = readSessionEntry(api.state.path.directory, sessionID)
39
+ if (!entry) return
40
+ notifyRejectedReplayOnce(notified, sessionID, entry, (message) =>
41
+ api.ui.toast({
42
+ variant: "error",
43
+ title: "Keepalive refresh rejected",
44
+ message,
45
+ }),
46
+ )
47
+ }, 1000)
48
+ api.lifecycle.onDispose(() => clearInterval(toastTimer))
9
49
  api.slots.register({
10
50
  // The built-in sidebar footer uses order 100. This slot is single-winner,
11
51
  // so a lower order makes the keepalive readout the visible footer.
@@ -1,85 +0,0 @@
1
- import type { KeepaliveConfig } from "./config";
2
- import type { KeepaliveStore } from "./state";
3
- import type { Logger } from "./logger";
4
- /**
5
- * The keepalive engine.
6
- *
7
- * Lifecycle per session:
8
- * 1. A real turn finishes -> `session.status` idle -> arm a warm window anchored on
9
- * the real response and schedule pings.
10
- * 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.
11
- * 3. `firePing` confirms with the server that the session is idle, sends a `~`
12
- * prompt with the last real turn's agent/model/variant (exact cached prefix),
13
- * reads `usage` to confirm a cache hit, then optionally reverts the `~`/`~` turn.
14
- * 4. A new real user turn, the window closing, or the cache presumed cold stops warming.
15
- *
16
- * Threading: all state is mutated on the single plugin event loop. opencode invokes
17
- * the `event` hook without awaiting it, so async handlers interleave; per-session
18
- * `arming` / `warming` flags serialize the async sections.
19
- *
20
- * Errors: network/API failures are logged and never thrown to opencode.
21
- */
22
- export declare class KeepaliveEngine {
23
- private readonly client;
24
- private readonly config;
25
- private readonly store;
26
- private readonly logger;
27
- private readonly directory;
28
- private timer;
29
- private controlTimer;
30
- private statusTimer;
31
- private controlUpdatedAt;
32
- private reconcilingStatus;
33
- private enabled;
34
- constructor(client: any, config: KeepaliveConfig, store: KeepaliveStore, logger: Logger, directory: string);
35
- start(): void;
36
- stop(): void;
37
- /**
38
- * True while a ping turn owns the session, so tool calls must be blocked. Becomes
39
- * false as soon as a real user message joins the in-flight ping run: the rest of
40
- * that run is real work and needs its tools.
41
- */
42
- shouldBlockTools(sessionID: string): boolean;
43
- onEvent(event: any): Promise<void>;
44
- /**
45
- * Track a new user message observed while a ping is in flight. Event order alone
46
- * cannot tell the ping's own message from a real prompt racing it, so messages are
47
- * held until their text part arrives (see `notePingRunPart`). Any message seen
48
- * before the ping is sent, or a second new message, is necessarily a real turn.
49
- */
50
- private notePingRunUser;
51
- /**
52
- * Classify a pending user message by its first text part: the ping token marks
53
- * the ping's own message; any other text is a real turn joining the ping run.
54
- * opencode publishes a user message's parts right after the message itself, before
55
- * the run reaches any tool call.
56
- */
57
- private notePingRunPart;
58
- /** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
59
- private interruptPing;
60
- private armWindow;
61
- /** Start a warm window anchored on the last real response and the last cache touch. */
62
- private openWindow;
63
- /** False once the cache has gone too long without a request to still be trusted warm. */
64
- private cacheMayBeWarm;
65
- private tick;
66
- private firePing;
67
- /** True only when the server positively reports the session idle. */
68
- private isIdleOnServer;
69
- /** Recover the last real turn's request settings when no user event was observed. */
70
- private lookupRequest;
71
- private recentMessages;
72
- private isPingMessage;
73
- /**
74
- * Remove the `~` user + `~` assistant turn so it never enters real context.
75
- * Revert drops the target and everything after it (and rolls back file
76
- * snapshots), so it is skipped unless only the ping's own replies follow.
77
- */
78
- private revertPing;
79
- private isRecentPing;
80
- private resolveSession;
81
- private pollControl;
82
- /** Recover when a session.status idle event is missed by reconciling with the API. */
83
- private reconcileStatus;
84
- }
85
- //# sourceMappingURL=keepalive.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"keepalive.d.ts","sourceRoot":"","sources":["../../lib/keepalive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAG/C,OAAO,KAAK,EAAgB,cAAc,EAAiC,MAAM,SAAS,CAAA;AAC1F,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAgBtC;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,eAAe;IASpB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAZ9B,OAAO,CAAC,KAAK,CAA4C;IACzD,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,WAAW,CAA4C;IAC/D,OAAO,CAAC,gBAAgB,CAAI;IAC5B,OAAO,CAAC,iBAAiB,CAAQ;IACjC,OAAO,CAAC,OAAO,CAAS;gBAGH,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,eAAe,EACvB,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM;IAQtC,KAAK,IAAI,IAAI;IAab,IAAI,IAAI,IAAI;IAUZ;;;;OAIG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAKtC,OAAO,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IAiGxC;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAcvB;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAcvB,0FAA0F;IAC1F,OAAO,CAAC,aAAa;YAWP,SAAS;IAuCvB,uFAAuF;IACvF,OAAO,CAAC,UAAU;IASlB,yFAAyF;IACzF,OAAO,CAAC,cAAc;YAKR,IAAI;YAgCJ,QAAQ;IAsGtB,qEAAqE;YACvD,cAAc;IAW5B,qFAAqF;YACvE,aAAa;YAeb,cAAc;IAc5B,OAAO,CAAC,aAAa;IASrB;;;;OAIG;YACW,UAAU;IAoCxB,OAAO,CAAC,YAAY;YAMN,cAAc;IAiB5B,OAAO,CAAC,WAAW;IAwBnB,sFAAsF;YACxE,eAAe;CAmBhC"}
@@ -1,8 +0,0 @@
1
- import type { KeepaliveConfig } from "./config";
2
- /**
3
- * Warming only helps on providers that do prefix caching we can refresh by
4
- * replaying the prefix. Supported defaults include Claude and GPT models served
5
- * through GitHub Copilot. Everything else is skipped.
6
- */
7
- export declare function isEligibleModel(config: KeepaliveConfig, providerID: string | undefined, modelID: string | undefined): boolean;
8
- //# sourceMappingURL=model.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../../lib/model.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAE/C;;;;GAIG;AACH,wBAAgB,eAAe,CAC3B,MAAM,EAAE,eAAe,EACvB,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,EAAE,MAAM,GAAG,SAAS,GAC5B,OAAO,CAOT"}
@@ -1,11 +0,0 @@
1
- /**
2
- * Stable keepalive instruction appended to the system prompt.
3
- *
4
- * IMPORTANT: this text must be byte-stable and applied to *every* request for an
5
- * eligible model (real turns and pings alike). If it were only added on pings, the
6
- * system prefix would differ from real turns and the ping would miss the cache.
7
- * Adding it consistently keeps the cached prefix identical and lets the model
8
- * answer a ping with a single token instead of a paragraph or a tool call.
9
- */
10
- export declare function keepaliveInstruction(pingToken: string): string;
11
- //# sourceMappingURL=system.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"system.d.ts","sourceRoot":"","sources":["../../lib/system.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAS9D"}