summaryrefslogtreecommitdiffhomepage
path: root/packages/cache-warming/src/pure.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 12:23:06 +0900
committerAdam Malczewski <[email protected]>2026-06-11 12:23:06 +0900
commitc2b4c05d91fa88b8d02c055a0e15c22abd8e21f3 (patch)
tree3f7c2feddbe697a79abd952bb80ed0e01dac0a7a /packages/cache-warming/src/pure.ts
parentf6b45507210e04e9884256b0132900640de4334b (diff)
downloaddispatch-c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3.tar.gz
dispatch-c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3.zip
feat(cache-warming): per-conversation prompt-cache warming + warm() service
Backend-driven warming targeting whatever provider a conversation uses (incl. the external Claude provider-anthropic). Core engine + on/off + last-cache-% done; interval-as-view-control pending a ui-contract NumberField (surface-system gap). Mechanism: - kernel: expose HostAPI.emit (typed bus event emit; counterpart of on) - session-orchestrator: turnStarted/turnSettled event hooks (conversationId/cwd/model); warm() service (cacheWarmHandle) reusing the real-turn assembly (byte-identical prefix, provider-agnostic), refuses mid-turn, never persists/emits, returns Usage - cache-warming (new ext): per-conversation timers (arm on settle, cancel on start, in-flight invalidation), calls warm(), pct=round(clamp(cacheRead/input,0,1)*100), persists {enabled,intervalMs} (default on/240s), registers a controls surface - host-bin: register cache-warming; transport-http: HostAPI stub +emit (fan-out) Honors old-code invariants. 760 vitest + 109 bun = 869 tests; tsc -b EXIT 0; biome clean.
Diffstat (limited to 'packages/cache-warming/src/pure.ts')
-rw-r--r--packages/cache-warming/src/pure.ts95
1 files changed, 95 insertions, 0 deletions
diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts
new file mode 100644
index 0000000..2b00dab
--- /dev/null
+++ b/packages/cache-warming/src/pure.ts
@@ -0,0 +1,95 @@
+/**
+ * Pure core for cache-warming — zero I/O, zero ambient state.
+ * Every function is input → output; testable without mocks.
+ */
+
+// --- Types ---
+
+/** Persisted per-conversation settings (storage-facing). */
+export interface ConversationSettings {
+ readonly enabled: boolean;
+ readonly intervalMs: number;
+}
+
+/** Full per-conversation runtime state (in-memory, not persisted). */
+export interface ConversationState extends ConversationSettings {
+ readonly active: boolean;
+ readonly lastPct: number | null;
+ readonly token: number;
+}
+
+/** Context stored per-conversation from the latest lifecycle event. */
+export interface ConversationContext {
+ readonly cwd?: string;
+ readonly modelName?: string;
+}
+
+export const DEFAULT_INTERVAL_MS = 240_000;
+export const MIN_INTERVAL_MS = 1000;
+
+// --- Pure functions ---
+
+/**
+ * Compute cache-hit percentage from token counts.
+ * Returns an integer in [0, 100]. inputTokens ≤ 0 → 0.
+ */
+export function computeCachePct(inputTokens: number, cacheReadTokens: number): number {
+ if (inputTokens <= 0) return 0;
+ const ratio = cacheReadTokens / inputTokens;
+ const clamped = Math.max(0, Math.min(1, ratio));
+ return Math.round(clamped * 100);
+}
+
+/**
+ * Decide whether a conversation should be warmed right now.
+ * Requires: enabled, idle (not active), and the token is current (not superseded).
+ */
+export function shouldWarm(state: ConversationState, currentToken: number): boolean {
+ return state.enabled && !state.active && state.token === currentToken;
+}
+
+/**
+ * Check whether a token is still current (not superseded by a newer cancel/fire).
+ */
+export function isTokenCurrent(current: number, expected: number): boolean {
+ return current === expected;
+}
+
+const SETTINGS_KEY = "settings";
+
+/**
+ * Parse settings from a raw storage string.
+ * Returns defaults if null or malformed.
+ */
+export function parseSettings(raw: string | null): ConversationSettings {
+ if (raw === null) return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null) {
+ return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ }
+ const obj = parsed as Record<string, unknown>;
+ const enabled = typeof obj.enabled === "boolean" ? obj.enabled : true;
+ const rawInterval = obj.intervalMs;
+ let intervalMs = DEFAULT_INTERVAL_MS;
+ if (typeof rawInterval === "number" && Number.isFinite(rawInterval)) {
+ intervalMs =
+ rawInterval <= 0 ? MIN_INTERVAL_MS : Math.max(MIN_INTERVAL_MS, Math.round(rawInterval));
+ }
+ return { enabled, intervalMs };
+ } catch {
+ return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ }
+}
+
+/**
+ * Serialize settings for storage.
+ */
+export function serializeSettings(settings: ConversationSettings): string {
+ return JSON.stringify(settings);
+}
+
+/** The storage key for a conversation's settings. */
+export function settingsKey(conversationId: string): string {
+ return `${SETTINGS_KEY}:${conversationId}`;
+}