pi-l1-cache 1.2.2 → 1.4.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/src/index.ts CHANGED
@@ -1,34 +1,25 @@
1
- // L1 Cache Extension — in-memory response cache for pi
2
- //
3
- // Stoic Unix principle: One thing, done well.
4
- // Optimized for minimal CPU/RAM overhead.
1
+ // L1 Cache Extension — response cache for pi (working implementation)
5
2
  //
6
3
  // Architecture:
7
- // pi → [L1: RAM Map] [L2: Redis via LiteLLM] → Provider
8
- // Lookup: ~0.1ms (vs 150ms Redis, 1-3s API)
4
+ // pi → [L1: RAM Map + disk (~/.pi/agent/cache/l1-cache)] → Provider
5
+ //
6
+ // How it works (requires the fix-l1-cache.js pi patches):
7
+ // - "provider_stream_complete" (new event, patched into sdk.js) delivers the
8
+ // raw OpenAI stream chunks + request params after a successful completion.
9
+ // - On a cache hit, "before_provider_request" returns the original payload
10
+ // with a __piL1Replay marker; the patched pi-ai stream() feeds the cached
11
+ // chunks through the normal consume path and never contacts the provider.
9
12
  //
10
- // Design goals:
11
- // - Fast string hash (FNV-1a) ~100x faster than SHA256
12
- // - Hard memory cap — never lets RAM bloat
13
- // - TTL + LRU eviction
14
- // - CPU-aware graceful degradation
15
- // - Zero dependencies, single file
16
-
17
- import type {
18
- BeforeProviderRequestEvent,
19
- ExtensionAPI,
20
- ExtensionCommandContext,
21
- ExtensionEvent,
22
- } from "@earendil-works/pi-coding-agent"
23
-
24
- type AfterProviderResponseEvent = Extract<ExtensionEvent, { type: "after_provider_response" }>
25
-
26
- // ---------------------------------------------------------------------------
27
- // Types
28
- // ---------------------------------------------------------------------------
13
+ // Key: stable hash of model + messages + tools + sampling fields.
14
+ // Volatile fields (prompt_cache_key, session ids, stream flags) are excluded.
15
+
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
17
+ import fs from "node:fs"
18
+ import path from "node:path"
19
+ import os from "node:os"
29
20
 
30
21
  interface CacheEntry {
31
- response: unknown
22
+ chunks: any[]
32
23
  timestamp: number
33
24
  sizeBytes: number
34
25
  }
@@ -38,111 +29,203 @@ interface Settings {
38
29
  maxEntries: number
39
30
  maxMemoryBytes: number
40
31
  ttlSeconds: number
41
- cpuThreshold: number
32
+ persist: boolean
42
33
  logStats: boolean
43
34
  }
44
35
 
45
- interface Stats {
46
- hits: number
47
- misses: number
48
- evictions: number
49
- cpuSkips: number
50
- }
51
-
52
- // ---------------------------------------------------------------------------
53
- // Configuration
54
- // ---------------------------------------------------------------------------
55
-
56
36
  const DEFAULTS: Settings = {
57
37
  enabled: true,
58
38
  maxEntries: 200,
59
39
  maxMemoryBytes: 20 * 1024 * 1024, // 20MB
60
40
  ttlSeconds: 3600,
61
- cpuThreshold: 95,
41
+ persist: true,
62
42
  logStats: false,
63
43
  }
64
44
 
65
- // Users can override via environment variables (highest priority)
45
+ /** Cache directory (overridable via L1_CACHE_DIR env for tests) */
46
+ function cacheDir(): string {
47
+ return process.env.L1_CACHE_DIR || path.join(os.homedir(), ".pi", "agent", "cache", "l1-cache")
48
+ }
49
+
50
+ /** Users can override via environment variables (highest priority) */
66
51
  function envSettings(): Partial<Settings> {
67
52
  const out: Partial<Settings> = {}
68
53
  if (process.env.L1_CACHE_ENABLED !== undefined) out.enabled = process.env.L1_CACHE_ENABLED !== "false"
69
54
  if (process.env.L1_CACHE_MAX_ENTRIES) out.maxEntries = parseInt(process.env.L1_CACHE_MAX_ENTRIES, 10) || DEFAULTS.maxEntries
70
55
  if (process.env.L1_CACHE_MAX_MB) out.maxMemoryBytes = (parseInt(process.env.L1_CACHE_MAX_MB, 10) || 20) * 1024 * 1024
71
56
  if (process.env.L1_CACHE_TTL) out.ttlSeconds = parseInt(process.env.L1_CACHE_TTL, 10) || DEFAULTS.ttlSeconds
57
+ if (process.env.L1_CACHE_PERSIST !== undefined) out.persist = process.env.L1_CACHE_PERSIST !== "false"
72
58
  if (process.env.L1_CACHE_LOG) out.logStats = process.env.L1_CACHE_LOG === "true"
73
59
  return out
74
60
  }
75
61
 
76
- const settings: Settings = { ...DEFAULTS, ...envSettings() }
77
-
78
- // ---------------------------------------------------------------------------
79
- // State
80
- // ---------------------------------------------------------------------------
81
-
82
62
  let cache = new Map<string, CacheEntry>()
83
63
  let totalMemory = 0
84
- let initialCpuLoad = 0
85
- let initialCpuStatus: "ok" | "disabled" | "error" = "ok"
86
- let lastCleanup = Date.now()
87
- const stats: Stats = { hits: 0, misses: 0, evictions: 0, cpuSkips: 0 }
88
-
89
- // Capability flags (learned at runtime, never assumed):
90
- // `canServe` turns true only after we capture a real response body, and gates
91
- // short-circuiting so we never replace the outgoing request payload with garbage.
92
- let canServe = false
93
- let bodyWarningShown = false
94
-
95
- // ---------------------------------------------------------------------------
96
- // Core helpers
97
- // ---------------------------------------------------------------------------
98
-
99
- /** Fast string hash (FNV-1a) — ~100x faster than SHA256, enough for cache keys */
64
+ let stats = { hits: 0, misses: 0, writes: 0, evictions: 0, replays: 0 }
65
+ let settings: Settings = { ...DEFAULTS, ...envSettings() }
66
+
67
+ // Guard against re-storing a response we just served from cache.
68
+ let lastServed: { key: string; at: number } | null = null
69
+
70
+ function log(...args: any[]) {
71
+ if (settings.logStats) console.log("[l1-cache]", ...args)
72
+ }
73
+
74
+ // FNV-1a — fast, good enough for exact-match keys (collision => 1-in-billions
75
+ // and even then only identical-shape requests after JSON canonicalization).
100
76
  function fastHash(str: string): string {
101
- let hash = 2166136261
77
+ let h1 = 2166136261
78
+ let h2 = 2166136261
102
79
  for (let i = 0; i < str.length; i++) {
103
- hash ^= str.charCodeAt(i)
104
- hash = Math.imul(hash, 16777619)
80
+ const c = str.charCodeAt(i)
81
+ h1 = Math.imul(h1 ^ c, 16777619)
82
+ h2 = Math.imul(h2 ^ (c + i), 16777619)
105
83
  }
106
- return hash.toString(16)
84
+ return (h1 >>> 0).toString(16) + (h2 >>> 0).toString(16)
107
85
  }
108
86
 
109
- /** Estimate in-memory size of an arbitrary object (UTF-16 overhead ×2) */
110
- function estimateSize(obj: unknown): number {
87
+ /**
88
+ * Stable cache key from the real request payload. Only fields that change the
89
+ * model's output are included; transport/volatile fields are excluded.
90
+ */
91
+ function keyForPayload(payload: any): string | null {
92
+ if (!payload || !Array.isArray(payload.messages)) return null
93
+ const basis = {
94
+ model: payload.model,
95
+ messages: payload.messages,
96
+ tools: payload.tools,
97
+ tool_choice: payload.tool_choice,
98
+ temperature: payload.temperature,
99
+ top_p: payload.top_p,
100
+ reasoning_effort: payload.reasoning_effort,
101
+ thinking: payload.thinking,
102
+ max_completion_tokens: payload.max_completion_tokens,
103
+ max_tokens: payload.max_tokens,
104
+ }
105
+ return fastHash(JSON.stringify(basis))
106
+ }
107
+
108
+ function estimateSize(value: unknown): number {
111
109
  try {
112
- const json = JSON.stringify(obj)
113
- if (json === undefined) return 64 // undefined/unserializable primitive
110
+ const json = JSON.stringify(value)
111
+ if (json === undefined) return 4096
114
112
  return json.length * 2
115
113
  } catch {
116
- return 1024 // fallback for non-serializable
114
+ return 4096
117
115
  }
118
116
  }
119
117
 
120
- function log(...args: unknown[]) {
121
- if (settings.logStats) console.log("[l1-cache]", ...args)
122
- }
123
-
124
- // ---------------------------------------------------------------------------
125
- // Eviction
126
- // ---------------------------------------------------------------------------
127
-
128
- function evictOldest(count: number) {
129
- if (count <= 0) return
130
- const sorted = Array.from(cache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)
131
- for (let i = 0; i < Math.min(count, sorted.length); i++) {
132
- const [key, entry] = sorted[i]
133
- totalMemory -= entry.sizeBytes
134
- cache.delete(key)
135
- stats.evictions++
118
+ /** Merge adjacent pure-delta chunks (content / reasoning_content) to shrink storage. */
119
+ function coalesceChunks(chunks: any[]): any[] {
120
+ const COALESCEABLE = new Set(["content", "reasoning_content", "reasoning"])
121
+ const out: any[] = []
122
+ for (const chunk of chunks) {
123
+ const prev = out[out.length - 1]
124
+ const choice = chunk?.choices?.[0]
125
+ const prevChoice = prev?.choices?.[0]
126
+ if (
127
+ prev &&
128
+ choice &&
129
+ prevChoice &&
130
+ choice.delta &&
131
+ prevChoice.delta &&
132
+ !choice.finish_reason &&
133
+ !prevChoice.finish_reason &&
134
+ !chunk.usage &&
135
+ !prev.usage &&
136
+ !choice.delta.tool_calls &&
137
+ !prevChoice.delta.tool_calls
138
+ ) {
139
+ const dKeys = Object.keys(choice.delta).filter((k) => choice.delta[k] !== undefined)
140
+ const pKeys = Object.keys(prevChoice.delta).filter((k) => prevChoice.delta[k] !== undefined)
141
+ // each delta must be a single coalesceable string field (role allowed on the first)
142
+ const dField = dKeys.find((k) => COALESCEABLE.has(k))
143
+ const pField = pKeys.find((k) => COALESCEABLE.has(k))
144
+ if (dField === undefined || pField === undefined) {
145
+ out.push(chunk)
146
+ continue
147
+ }
148
+ const dOk = dKeys.every((k) => k === dField || k === "role") && typeof choice.delta[dField] === "string"
149
+ const pOk = pKeys.every((k) => k === pField || k === "role") && typeof prevChoice.delta[pField] === "string"
150
+ if (dOk && pOk && dField === pField) {
151
+ prevChoice.delta[pField] += choice.delta[dField]
152
+ continue
153
+ }
154
+ }
155
+ out.push(chunk)
136
156
  }
157
+ return out
137
158
  }
138
159
 
139
- /** Enforce size and memory caps using LRU-style (oldest-first) eviction */
140
160
  function evictIfNeeded() {
141
161
  while (cache.size > settings.maxEntries) {
142
- evictOldest(Math.max(1, Math.ceil(settings.maxEntries * 0.1)))
162
+ const oldest = Array.from(cache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0]
163
+ if (!oldest) break
164
+ totalMemory -= oldest[1].sizeBytes
165
+ cache.delete(oldest[0])
166
+ stats.evictions++
167
+ if (settings.persist) {
168
+ try {
169
+ fs.unlinkSync(path.join(cacheDir(), oldest[0] + ".json"))
170
+ } catch {}
171
+ }
143
172
  }
144
173
  while (totalMemory > settings.maxMemoryBytes) {
145
- evictOldest(Math.max(1, Math.ceil(settings.maxEntries * 0.2)))
174
+ const oldest = Array.from(cache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0]
175
+ if (!oldest) break
176
+ totalMemory -= oldest[1].sizeBytes
177
+ cache.delete(oldest[0])
178
+ stats.evictions++
179
+ if (settings.persist) {
180
+ try {
181
+ fs.unlinkSync(path.join(cacheDir(), oldest[0] + ".json"))
182
+ } catch {}
183
+ }
184
+ }
185
+ }
186
+
187
+ function persistEntry(key: string, entry: CacheEntry) {
188
+ try {
189
+ fs.mkdirSync(cacheDir(), { recursive: true })
190
+ fs.writeFileSync(
191
+ path.join(cacheDir(), key + ".json"),
192
+ JSON.stringify({ chunks: entry.chunks, timestamp: entry.timestamp })
193
+ )
194
+ } catch (err) {
195
+ log("persist failed:", (err as Error).message)
196
+ }
197
+ }
198
+
199
+ function loadPersisted() {
200
+ if (!settings.persist) return
201
+ try {
202
+ if (!fs.existsSync(cacheDir())) return
203
+ const now = Date.now()
204
+ for (const file of fs.readdirSync(cacheDir())) {
205
+ if (!file.endsWith(".json")) continue
206
+ try {
207
+ const raw = JSON.parse(fs.readFileSync(path.join(cacheDir(), file), "utf8"))
208
+ if (!Array.isArray(raw.chunks) || raw.chunks.length === 0) {
209
+ fs.unlinkSync(path.join(cacheDir(), file))
210
+ continue
211
+ }
212
+ if (now - (raw.timestamp || 0) > settings.ttlSeconds * 1000) {
213
+ fs.unlinkSync(path.join(cacheDir(), file))
214
+ continue
215
+ }
216
+ const key = file.replace(/\.json$/, "")
217
+ const sizeBytes = estimateSize(raw.chunks)
218
+ cache.set(key, { chunks: raw.chunks, timestamp: raw.timestamp || now, sizeBytes })
219
+ totalMemory += sizeBytes
220
+ } catch {
221
+ try {
222
+ fs.unlinkSync(path.join(cacheDir(), file))
223
+ } catch {}
224
+ }
225
+ }
226
+ evictIfNeeded()
227
+ } catch (err) {
228
+ log("load failed:", (err as Error).message)
146
229
  }
147
230
  }
148
231
 
@@ -158,201 +241,135 @@ function cleanupExpired() {
158
241
  }
159
242
  }
160
243
  if (expired > 0) log(`expired ${expired} entries`)
161
- lastCleanup = now
162
244
  }
163
245
 
164
- // ---------------------------------------------------------------------------
165
- // CPU check (once at startup — not per-request, to avoid overhead)
166
- // ---------------------------------------------------------------------------
167
-
168
- async function checkInitialCpuLoad(): Promise<number> {
169
- try {
170
- const { execSync } = await import("child_process")
171
- if (process.platform === "win32") {
172
- const out = execSync("wmic cpu get loadpercentage /value", { encoding: "utf8", timeout: 2000 })
173
- const match = out.match(/LoadPercentage\s*:\s*(\d+)/)
174
- return match ? parseInt(match[1], 10) : 0
175
- }
176
- const out = execSync("cat /proc/loadavg", { encoding: "utf8", timeout: 2000 })
177
- const { cpus } = await import("os")
178
- const cores = cpus().length
179
- const load = parseFloat(out.split(" ")[0])
180
- return Math.min(100, (load / cores) * 100)
181
- } catch {
182
- return 0
183
- }
184
- }
185
-
186
- // ---------------------------------------------------------------------------
187
- // Tests (self-check on load — see also src/index.test.ts)
188
- // ---------------------------------------------------------------------------
189
-
190
- export function _testHooks() {
191
- return {
192
- fastHash,
193
- estimateSize,
194
- evictIfNeeded,
195
- cleanupExpired,
196
- evictOldest,
197
- getStats: () => ({ ...stats }),
198
- getState: () => ({ size: cache.size, totalMemory, settings: { ...settings } }),
199
- reset: () => {
200
- cache = new Map()
201
- totalMemory = 0
202
- stats.hits = 0
203
- stats.misses = 0
204
- stats.evictions = 0
205
- stats.cpuSkips = 0
206
- },
207
- _setCache: (key: string, entry: CacheEntry) => {
208
- cache.set(key, entry)
209
- totalMemory += entry.sizeBytes
210
- },
211
- _getCacheSize: () => cache.size,
212
- _getTotalMemory: () => totalMemory,
213
- setSettings: (patch: Partial<Settings>) => Object.assign(settings, patch),
214
- }
215
- }
216
-
217
- // ---------------------------------------------------------------------------
218
- // Main plugin
219
- // ---------------------------------------------------------------------------
220
-
221
246
  export default async function (pi: ExtensionAPI) {
222
- // Async init: check CPU once at startup
223
- try {
224
- initialCpuLoad = await checkInitialCpuLoad()
225
- if (initialCpuLoad > settings.cpuThreshold) {
226
- settings.enabled = false
227
- initialCpuStatus = "disabled"
228
- console.log(
229
- `[l1-cache] disabled (CPU ${initialCpuLoad.toFixed(0)}% > ${settings.cpuThreshold}% threshold)`,
230
- )
231
- } else {
232
- initialCpuStatus = "ok"
233
- console.log(
234
- `[l1-cache] enabled (max ${settings.maxEntries} entries, ${(settings.maxMemoryBytes / 1024 / 1024).toFixed(0)}MB, TTL ${settings.ttlSeconds}s)`,
235
- )
236
- }
237
- } catch (err) {
238
- initialCpuStatus = "error"
239
- console.log(`[l1-cache] CPU check failed (${err}); continuing enabled`)
247
+ if (settings.enabled) {
248
+ console.log(
249
+ "[l1-cache] enabled (max",
250
+ settings.maxEntries,
251
+ "entries,",
252
+ (settings.maxMemoryBytes / 1024 / 1024).toFixed(0) + "MB, TTL",
253
+ settings.ttlSeconds + "s,",
254
+ settings.persist ? "persisted)" : "memory-only)"
255
+ )
256
+ loadPersisted()
257
+ if (cache.size > 0) log("loaded", cache.size, "entries from disk")
258
+ } else {
259
+ console.log("[l1-cache] disabled")
240
260
  }
241
261
 
242
- // Periodic cleanup (every 10 minutes)
243
- const cleanupTimer = setInterval(cleanupExpired, 10 * 60 * 1000)
262
+ const cleanupTimer = setInterval(() => {
263
+ const now = Date.now()
264
+ for (const [key, entry] of cache.entries()) {
265
+ if (now - entry.timestamp > settings.ttlSeconds * 1000) {
266
+ totalMemory -= entry.sizeBytes
267
+ cache.delete(key)
268
+ if (settings.persist) {
269
+ try {
270
+ fs.unlinkSync(path.join(cacheDir(), key + ".json"))
271
+ } catch {}
272
+ }
273
+ }
274
+ }
275
+ }, 10 * 60 * 1000)
276
+ if (typeof cleanupTimer.unref === "function") cleanupTimer.unref()
244
277
  pi.on("session_shutdown", () => clearInterval(cleanupTimer))
245
278
 
246
- // Interceptor: cache lookup before provider request
247
- pi.on("before_provider_request", async (event: BeforeProviderRequestEvent, ctx) => {
279
+ // HIT path: serve cached chunks by replaying them through the patched
280
+ // pi-ai stream() the provider is never contacted.
281
+ pi.on("before_provider_request", async (event: any, ctx) => {
248
282
  if (!settings.enabled) return
249
283
 
250
- const model = ctx.model?.id || "unknown"
251
- // pi passes the assembled provider request as `event.payload`
252
- const payload = event.payload as
253
- | { messages?: unknown[]; parameters?: unknown }
254
- | null
255
- | undefined
256
- const messages = payload?.messages ?? []
257
- const params = payload?.parameters ?? {}
258
-
259
- // Fast hash; no per-request CPU check to stay on the hot path
260
- const key = fastHash(model + JSON.stringify(messages) + JSON.stringify(params))
261
- const entry = cache.get(key)
284
+ // The runner can be stale if a provider stream outlives a session
285
+ // replacement; skip instead of crashing on ctx access.
286
+ try {
287
+ if (ctx.model?.id === undefined && event.payload?.model === undefined) return
288
+ } catch {
289
+ return
290
+ }
262
291
 
292
+ const payload = event.payload
293
+ const key = keyForPayload(payload)
294
+ if (!key) return
295
+
296
+ const entry = cache.get(key)
263
297
  if (entry && Date.now() - entry.timestamp <= settings.ttlSeconds * 1000) {
264
298
  stats.hits++
265
- // Only replace the payload when we have actually captured a response body.
266
- // Without one (pi 0.84 +) a return value would overwrite the outgoing
267
- // request payload instead of short-circuiting — so we count the hit and
268
- // leave the request untouched.
269
- if (canServe) return entry.response
270
- return
299
+ stats.replays++
300
+ lastServed = { key, at: Date.now() }
301
+ entry.timestamp = Date.now() // LRU touch
302
+ log("HIT", key, "(" + entry.chunks.length + " chunks)")
303
+ return { ...payload, __piL1Replay: entry.chunks }
271
304
  }
272
305
 
273
- // Miss — remember the key for after_provider_response
274
- ;(event as unknown as { _cacheKey?: string })._cacheKey = key
275
306
  stats.misses++
307
+ log("MISS", key)
276
308
  })
277
309
 
278
- // Interceptor: store response after provider finishes
279
- pi.on("after_provider_response", async (event: AfterProviderResponseEvent) => {
310
+ // WRITE path: patched sdk.js emits this after a successful completion with
311
+ // the raw stream chunks + the exact request params.
312
+ ;(pi as any).on("provider_stream_complete", async (event: any) => {
280
313
  if (!settings.enabled) return
314
+ const payload = event.payload
315
+ const key = keyForPayload(payload)
316
+ if (!key || !Array.isArray(event.chunks) || event.chunks.length === 0) return
281
317
 
282
- // pi 0.84 + exposes only { status, headers } here — no response body.
283
- // Guard so we never cache the event envelope as if it were a response.
284
- const e = event as unknown as { _cacheKey?: string; response?: unknown; choices?: unknown }
285
- const response = e.response ?? e.choices
286
- if (response === undefined) {
287
- if (!bodyWarningShown) {
288
- bodyWarningShown = true
289
- console.log(
290
- "[l1-cache] note: this pi version does not expose a response body in 'after_provider_response'; " +
291
- "responses cannot be cached. Stats remain available via /l1-cache.",
292
- )
293
- }
294
- return
295
- }
296
- if (!e._cacheKey) return
318
+ // Don't re-store what we just replayed from cache.
319
+ if (lastServed && lastServed.key === key && Date.now() - lastServed.at < 10000) return
297
320
 
298
- const key = e._cacheKey
299
- const size = estimateSize(response)
300
- canServe = true
321
+ const chunks = coalesceChunks(event.chunks)
322
+ const sizeBytes = estimateSize(chunks)
323
+ if (sizeBytes > settings.maxMemoryBytes / 4) return // don't cache giant single entries
301
324
 
302
- cache.set(key, { response, timestamp: Date.now(), sizeBytes: size })
303
- totalMemory += size
325
+ if (cache.has(key)) totalMemory -= cache.get(key)!.sizeBytes
326
+ cache.set(key, { chunks, timestamp: Date.now(), sizeBytes })
327
+ totalMemory += sizeBytes
328
+ stats.writes++
304
329
  evictIfNeeded()
330
+ if (settings.persist) persistEntry(key, cache.get(key)!)
331
+ log("STORED", key, chunks.length, "chunks,", (sizeBytes / 1024).toFixed(1) + "KB")
305
332
  })
306
333
 
307
- // Commands
334
+ // Commands for inspection
308
335
  pi.registerCommand("l1-cache", {
309
- description: "Show L1 cache stats, or use 'clear' to reset",
310
- handler: async (args: string, ctx: ExtensionCommandContext) => {
311
- const arg = (args ?? "").trim()
312
-
313
- if (arg === "clear") {
336
+ description: "L1 cache stats and management",
337
+ handler: async (args: string, ctx: any) => {
338
+ if (args === "clear") {
314
339
  cache.clear()
315
340
  totalMemory = 0
316
- stats.hits = 0
317
- stats.misses = 0
318
- stats.evictions = 0
319
- ctx.ui.notify("L1 cache cleared", "info")
341
+ lastServed = null
342
+ try {
343
+ if (fs.existsSync(cacheDir()))
344
+ for (const f of fs.readdirSync(cacheDir())) fs.unlinkSync(path.join(cacheDir(), f))
345
+ } catch {}
346
+ ctx.ui.notify("L1 cache cleared (memory + disk)", "success")
320
347
  return
321
348
  }
322
-
323
- if (arg === "stats" || arg === "") {
324
- const hitRate =
325
- stats.hits + stats.misses > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(1) : "0.0"
326
- const lines = [
327
- `L1 cache status: ${settings.enabled ? "ENABLED" : "disabled"}`,
328
- `Response caching: ${canServe ? "active" : "unavailable (no response body in this pi API)"}`,
329
- `Entries: ${cache.size} / ${settings.maxEntries}`,
330
- `Memory: ${(totalMemory / 1024 / 1024).toFixed(1)}MB / ${(settings.maxMemoryBytes / 1024 / 1024).toFixed(1)}MB`,
331
- `TTL: ${settings.ttlSeconds}s | CPU threshold: ${settings.cpuThreshold}%`,
332
- `Hits: ${stats.hits} | Misses: ${stats.misses} | Evictions: ${stats.evictions}`,
333
- `Hit rate: ${hitRate}%`,
334
- `Init CPU: ${initialCpuLoad.toFixed(1)}% (${initialCpuStatus})`,
335
- `Last cleanup: ${new Date(lastCleanup).toISOString()}`,
336
- ]
337
- ctx.ui.notify(lines.join("\n"), "info")
338
- return
339
- }
340
-
341
- if (arg === "enable") {
342
- settings.enabled = true
343
- ctx.ui.notify("L1 cache enabled", "info")
344
- return
345
- }
346
-
347
- if (arg === "disable") {
348
- settings.enabled = false
349
- ctx.ui.notify("L1 cache disabled", "info")
350
- return
351
- }
352
-
353
- ctx.ui.notify(`Unknown command: /l1-cache ${arg}\nUsage: /l1-cache [stats|clear|enable|disable]`, "error")
349
+ const kb = (totalMemory / 1024).toFixed(1)
350
+ ctx.ui.notify(
351
+ `L1 cache: ${cache.size} entries, ${kb}KB | hits ${stats.hits} (replays ${stats.replays}), misses ${stats.misses}, writes ${stats.writes}, evictions ${stats.evictions} | dir: ${cacheDir()}`,
352
+ "info"
353
+ )
354
354
  },
355
355
  })
356
+ }
356
357
 
357
- console.log("[l1-cache] ready. /l1-cache for stats.")
358
+ /** Reset all module state (for tests) */
359
+ export function resetForTests() {
360
+ cache.clear()
361
+ totalMemory = 0
362
+ lastServed = null
363
+ stats.hits = 0
364
+ stats.misses = 0
365
+ stats.writes = 0
366
+ stats.evictions = 0
367
+ stats.replays = 0
368
+ Object.assign(settings, DEFAULTS, envSettings())
358
369
  }
370
+
371
+ // Export internal state for testing
372
+ export { cache, totalMemory, stats, settings, cacheDir, lastServed }
373
+
374
+ // Export internal functions for testing
375
+ export { fastHash, estimateSize, coalesceChunks, evictIfNeeded, cleanupExpired, keyForPayload, persistEntry, loadPersisted }