pi-l1-cache 1.2.1 → 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,27 +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
9
5
  //
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 { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
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.
12
+ //
13
+ // Key: stable hash of model + messages + tools + sampling fields.
14
+ // Volatile fields (prompt_cache_key, session ids, stream flags) are excluded.
18
15
 
19
- // ---------------------------------------------------------------------------
20
- // Types
21
- // ---------------------------------------------------------------------------
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"
22
20
 
23
21
  interface CacheEntry {
24
- response: unknown
22
+ chunks: any[]
25
23
  timestamp: number
26
24
  sizeBytes: number
27
25
  }
@@ -31,105 +29,203 @@ interface Settings {
31
29
  maxEntries: number
32
30
  maxMemoryBytes: number
33
31
  ttlSeconds: number
34
- cpuThreshold: number
32
+ persist: boolean
35
33
  logStats: boolean
36
34
  }
37
35
 
38
- interface Stats {
39
- hits: number
40
- misses: number
41
- evictions: number
42
- cpuSkips: number
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // Configuration
47
- // ---------------------------------------------------------------------------
48
-
49
36
  const DEFAULTS: Settings = {
50
37
  enabled: true,
51
38
  maxEntries: 200,
52
39
  maxMemoryBytes: 20 * 1024 * 1024, // 20MB
53
40
  ttlSeconds: 3600,
54
- cpuThreshold: 95,
41
+ persist: true,
55
42
  logStats: false,
56
43
  }
57
44
 
58
- // 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) */
59
51
  function envSettings(): Partial<Settings> {
60
52
  const out: Partial<Settings> = {}
61
53
  if (process.env.L1_CACHE_ENABLED !== undefined) out.enabled = process.env.L1_CACHE_ENABLED !== "false"
62
54
  if (process.env.L1_CACHE_MAX_ENTRIES) out.maxEntries = parseInt(process.env.L1_CACHE_MAX_ENTRIES, 10) || DEFAULTS.maxEntries
63
55
  if (process.env.L1_CACHE_MAX_MB) out.maxMemoryBytes = (parseInt(process.env.L1_CACHE_MAX_MB, 10) || 20) * 1024 * 1024
64
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"
65
58
  if (process.env.L1_CACHE_LOG) out.logStats = process.env.L1_CACHE_LOG === "true"
66
59
  return out
67
60
  }
68
61
 
69
- const settings: Settings = { ...DEFAULTS, ...envSettings() }
70
-
71
- // ---------------------------------------------------------------------------
72
- // State
73
- // ---------------------------------------------------------------------------
74
-
75
62
  let cache = new Map<string, CacheEntry>()
76
63
  let totalMemory = 0
77
- let initialCpuLoad = 0
78
- let initialCpuStatus: "ok" | "disabled" | "error" = "ok"
79
- let lastCleanup = Date.now()
80
- const stats: Stats = { hits: 0, misses: 0, evictions: 0, cpuSkips: 0 }
64
+ let stats = { hits: 0, misses: 0, writes: 0, evictions: 0, replays: 0 }
65
+ let settings: Settings = { ...DEFAULTS, ...envSettings() }
81
66
 
82
- // ---------------------------------------------------------------------------
83
- // Core helpers
84
- // ---------------------------------------------------------------------------
67
+ // Guard against re-storing a response we just served from cache.
68
+ let lastServed: { key: string; at: number } | null = null
85
69
 
86
- /** Fast string hash (FNV-1a) — ~100x faster than SHA256, enough for cache keys */
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).
87
76
  function fastHash(str: string): string {
88
- let hash = 2166136261
77
+ let h1 = 2166136261
78
+ let h2 = 2166136261
89
79
  for (let i = 0; i < str.length; i++) {
90
- hash ^= str.charCodeAt(i)
91
- 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)
83
+ }
84
+ return (h1 >>> 0).toString(16) + (h2 >>> 0).toString(16)
85
+ }
86
+
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,
92
104
  }
93
- return hash.toString(16)
105
+ return fastHash(JSON.stringify(basis))
94
106
  }
95
107
 
96
- /** Estimate in-memory size of an arbitrary object (UTF-16 overhead ×2) */
97
- function estimateSize(obj: unknown): number {
108
+ function estimateSize(value: unknown): number {
98
109
  try {
99
- const json = JSON.stringify(obj)
100
- if (json === undefined) return 64 // undefined/unserializable primitive
110
+ const json = JSON.stringify(value)
111
+ if (json === undefined) return 4096
101
112
  return json.length * 2
102
113
  } catch {
103
- return 1024 // fallback for non-serializable
114
+ return 4096
104
115
  }
105
116
  }
106
117
 
107
- function log(...args: unknown[]) {
108
- if (settings.logStats) console.log("[l1-cache]", ...args)
109
- }
110
-
111
- // ---------------------------------------------------------------------------
112
- // Eviction
113
- // ---------------------------------------------------------------------------
114
-
115
- function evictOldest(count: number) {
116
- if (count <= 0) return
117
- const sorted = Array.from(cache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)
118
- for (let i = 0; i < Math.min(count, sorted.length); i++) {
119
- const [key, entry] = sorted[i]
120
- totalMemory -= entry.sizeBytes
121
- cache.delete(key)
122
- 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)
123
156
  }
157
+ return out
124
158
  }
125
159
 
126
- /** Enforce size and memory caps using LRU-style (oldest-first) eviction */
127
160
  function evictIfNeeded() {
128
161
  while (cache.size > settings.maxEntries) {
129
- 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
+ }
130
172
  }
131
173
  while (totalMemory > settings.maxMemoryBytes) {
132
- 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)
133
229
  }
134
230
  }
135
231
 
@@ -145,176 +241,135 @@ function cleanupExpired() {
145
241
  }
146
242
  }
147
243
  if (expired > 0) log(`expired ${expired} entries`)
148
- lastCleanup = now
149
- }
150
-
151
- // ---------------------------------------------------------------------------
152
- // CPU check (once at startup — not per-request, to avoid overhead)
153
- // ---------------------------------------------------------------------------
154
-
155
- async function checkInitialCpuLoad(): Promise<number> {
156
- try {
157
- const { execSync } = await import("child_process")
158
- if (process.platform === "win32") {
159
- const out = execSync("wmic cpu get loadpercentage /value", { encoding: "utf8", timeout: 2000 })
160
- const match = out.match(/LoadPercentage\s*:\s*(\d+)/)
161
- return match ? parseInt(match[1], 10) : 0
162
- }
163
- const out = execSync("cat /proc/loadavg", { encoding: "utf8", timeout: 2000 })
164
- const { cpus } = await import("os")
165
- const cores = cpus().length
166
- const load = parseFloat(out.split(" ")[0])
167
- return Math.min(100, (load / cores) * 100)
168
- } catch {
169
- return 0
170
- }
171
- }
172
-
173
- // ---------------------------------------------------------------------------
174
- // Tests (self-check on load — see also src/index.test.ts)
175
- // ---------------------------------------------------------------------------
176
-
177
- export function _testHooks() {
178
- return {
179
- fastHash,
180
- estimateSize,
181
- evictIfNeeded,
182
- cleanupExpired,
183
- evictOldest,
184
- getStats: () => ({ ...stats }),
185
- getState: () => ({ size: cache.size, totalMemory, settings: { ...settings } }),
186
- reset: () => {
187
- cache = new Map()
188
- totalMemory = 0
189
- stats.hits = 0
190
- stats.misses = 0
191
- stats.evictions = 0
192
- stats.cpuSkips = 0
193
- },
194
- _setCache: (key: string, entry: CacheEntry) => {
195
- cache.set(key, entry)
196
- totalMemory += entry.sizeBytes
197
- },
198
- _getCacheSize: () => cache.size,
199
- _getTotalMemory: () => totalMemory,
200
- setSettings: (patch: Partial<Settings>) => Object.assign(settings, patch),
201
- }
202
244
  }
203
245
 
204
- // ---------------------------------------------------------------------------
205
- // Main plugin
206
- // ---------------------------------------------------------------------------
207
-
208
246
  export default async function (pi: ExtensionAPI) {
209
- // Async init: check CPU once at startup
210
- try {
211
- initialCpuLoad = await checkInitialCpuLoad()
212
- if (initialCpuLoad > settings.cpuThreshold) {
213
- settings.enabled = false
214
- initialCpuStatus = "disabled"
215
- console.log(
216
- `[l1-cache] disabled (CPU ${initialCpuLoad.toFixed(0)}% > ${settings.cpuThreshold}% threshold)`,
217
- )
218
- } else {
219
- initialCpuStatus = "ok"
220
- console.log(
221
- `[l1-cache] enabled (max ${settings.maxEntries} entries, ${(settings.maxMemoryBytes / 1024 / 1024).toFixed(0)}MB, TTL ${settings.ttlSeconds}s)`,
222
- )
223
- }
224
- } catch (err) {
225
- initialCpuStatus = "error"
226
- 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")
227
260
  }
228
261
 
229
- // Periodic cleanup (every 10 minutes)
230
- 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()
231
277
  pi.on("session_shutdown", () => clearInterval(cleanupTimer))
232
278
 
233
- // Interceptor: cache lookup before provider request
234
- pi.on("before_provider_request", async (event, 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) => {
235
282
  if (!settings.enabled) return
236
283
 
237
- const model = ctx.model?.id || "unknown"
238
- const messages = event.messages || []
239
- const params = event.parameters || {}
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
+ }
240
291
 
241
- // Fast hash; no per-request CPU check to stay on the hot path
242
- const key = fastHash(model + JSON.stringify(messages) + JSON.stringify(params))
243
- const entry = cache.get(key)
292
+ const payload = event.payload
293
+ const key = keyForPayload(payload)
294
+ if (!key) return
244
295
 
296
+ const entry = cache.get(key)
245
297
  if (entry && Date.now() - entry.timestamp <= settings.ttlSeconds * 1000) {
246
298
  stats.hits++
247
- return entry.response as never
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 }
248
304
  }
249
305
 
250
- // Miss — remember the key for after_provider_response
251
- event._cacheKey = key
252
306
  stats.misses++
307
+ log("MISS", key)
253
308
  })
254
309
 
255
- // Interceptor: store response after provider finishes
256
- pi.on("after_provider_response", async (event) => {
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) => {
257
313
  if (!settings.enabled) return
258
- if (!event._cacheKey) return
314
+ const payload = event.payload
315
+ const key = keyForPayload(payload)
316
+ if (!key || !Array.isArray(event.chunks) || event.chunks.length === 0) return
317
+
318
+ // Don't re-store what we just replayed from cache.
319
+ if (lastServed && lastServed.key === key && Date.now() - lastServed.at < 10000) return
259
320
 
260
- const key = event._cacheKey as string
261
- const response = event.response ?? event.choices ?? event
262
- const size = estimateSize(response)
321
+ const chunks = coalesceChunks(event.chunks)
322
+ const sizeBytes = estimateSize(chunks)
323
+ if (sizeBytes > settings.maxMemoryBytes / 4) return // don't cache giant single entries
263
324
 
264
- cache.set(key, { response, timestamp: Date.now(), sizeBytes: size })
265
- 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++
266
329
  evictIfNeeded()
330
+ if (settings.persist) persistEntry(key, cache.get(key)!)
331
+ log("STORED", key, chunks.length, "chunks,", (sizeBytes / 1024).toFixed(1) + "KB")
267
332
  })
268
333
 
269
- // Commands
334
+ // Commands for inspection
270
335
  pi.registerCommand("l1-cache", {
271
- description: "Show L1 cache stats, or use 'clear' to reset",
272
- handler: async (args: string, ctx: ExtensionContext) => {
273
- const arg = (args ?? "").trim()
274
-
275
- if (arg === "clear") {
336
+ description: "L1 cache stats and management",
337
+ handler: async (args: string, ctx: any) => {
338
+ if (args === "clear") {
276
339
  cache.clear()
277
340
  totalMemory = 0
278
- stats.hits = 0
279
- stats.misses = 0
280
- stats.evictions = 0
281
- ctx.ui.notify("L1 cache cleared", "success")
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")
282
347
  return
283
348
  }
284
-
285
- if (arg === "stats" || arg === "") {
286
- const hitRate =
287
- stats.hits + stats.misses > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(1) : "0.0"
288
- const lines = [
289
- `L1 cache status: ${settings.enabled ? "ENABLED" : "disabled"}`,
290
- `Entries: ${cache.size} / ${settings.maxEntries}`,
291
- `Memory: ${(totalMemory / 1024 / 1024).toFixed(1)}MB / ${(settings.maxMemoryBytes / 1024 / 1024).toFixed(1)}MB`,
292
- `TTL: ${settings.ttlSeconds}s | CPU threshold: ${settings.cpuThreshold}%`,
293
- `Hits: ${stats.hits} | Misses: ${stats.misses} | Evictions: ${stats.evictions}`,
294
- `Hit rate: ${hitRate}%`,
295
- `Init CPU: ${initialCpuLoad.toFixed(1)}% (${initialCpuStatus})`,
296
- `Last cleanup: ${new Date(lastCleanup).toISOString()}`,
297
- ]
298
- ctx.ui.notify(lines.join("\n"), "info")
299
- return ""
300
- }
301
-
302
- if (arg === "enable") {
303
- settings.enabled = true
304
- ctx.ui.notify("L1 cache enabled", "success")
305
- return
306
- }
307
-
308
- if (arg === "disable") {
309
- settings.enabled = false
310
- ctx.ui.notify("L1 cache disabled", "success")
311
- return
312
- }
313
-
314
- ctx.ui.notify(`Unknown command: /l1-cache ${arg}\nUsage: /l1-cache [stats|clear|enable|disable]`, "error")
315
- return ""
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
+ )
316
354
  },
317
355
  })
356
+ }
318
357
 
319
- 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())
320
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 }