pi-l1-cache 1.2.0 → 1.2.2

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/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Pi Package](https://img.shields.io/badge/pi-package-blue)](https://pi.dev/packages)
7
+ [![npm version](https://img.shields.io/npm/v/pi-l1-cache)](https://www.npmjs.com/package/pi-l1-cache)
8
+ [![CI](https://github.com/tobias-weiss-ai-xr/pi-l1-cache/actions/workflows/ci.yml/badge.svg)](https://github.com/tobias-weiss-ai-xr/pi-l1-cache/actions/workflows/ci.yml)
7
9
 
8
10
  A high-performance, production-ready **L1 (in-memory) cache** extension for the [pi coding agent](https://pi.dev). Designed for stoic Unix simplicity: one file, one purpose, zero dependencies.
9
11
 
@@ -11,10 +13,11 @@ A high-performance, production-ready **L1 (in-memory) cache** extension for the
11
13
 
12
14
  | Feature | Description |
13
15
  |---------|-------------|
14
- | **~0.1ms lookup** | FNV-1a fast hash ~100× faster than SHA256 |
16
+ | **~138µs overhead** | End-to-end per-request cost (measured, incl. key hashing + JSON) |
17
+ | **Fast key hash** | FNV-1a (~1.5µs/op) — no per-request CPU probe on the hot path |
15
18
  | **Memory cap** | Hard limit (20MB default) prevents RAM bloat |
16
19
  | **Auto-eviction** | LRU-style cleanup when limits reached |
17
- | **CPU-aware** | Auto-disables when CPU > 95% |
20
+ | **CPU-aware** | Auto-disables when CPU > 95% (checked once at startup) |
18
21
  | **TTL-based** | 1-hour default expiry for cached entries |
19
22
  | **Atomic cleanup** | Periodic expired entry removal |
20
23
 
@@ -22,7 +25,7 @@ A high-performance, production-ready **L1 (in-memory) cache** extension for the
22
25
 
23
26
  ```
24
27
  pi → [L1: RAM Map] → [L2: Redis via LiteLLM] → Provider
25
- <0.5ms ~150ms 1-3s
28
+ ~138µs ~150ms 1-3s
26
29
  ```
27
30
 
28
31
  ## Installation
@@ -119,7 +122,7 @@ const DEFAULTS: Settings = {
119
122
  - **Zero dependencies** — Single TypeScript file
120
123
 
121
124
  ### Performance Optimizations
122
- 1. **Fast string hashing** (FNV-1a) instead of SHA256
125
+ 1. **Cheap key hashing** (FNV-1a, ~1.5µs/op). Note: Node's native SHA-256 (OpenSSL) is marginally *faster* than a JS FNV loop — hashing is a rounding error next to `JSON.stringify(messages)`, and both are >1000× below provider latency. The real win is *not* a faster hash, it is skipping the provider call.
123
126
  2. **L1 only** — avoid disk I/O in hot path
124
127
  3. **Batch eviction** — remove 10-20% at a time, not one-by-one
125
128
  4. **Periodic cleanup** — async, non-blocking garbage collection
@@ -145,6 +148,31 @@ npx tsc --noEmit
145
148
  - State management & reset
146
149
  - Settings override
147
150
 
151
+ ## Benchmark
152
+
153
+ Measured against the published npm artifact (`pi-l1-cache@1.2.1`) on Node 22, using the package's own test hooks and a mocked `ExtensionAPI` with a realistic 15-message conversation history:
154
+
155
+ | Measurement | Result |
156
+ |---|---|
157
+ | `fastHash` (FNV-1a) standalone | ~670k ops/s — 1.49µs/op |
158
+ | Node native `sha256` (for reference) | ~850k ops/s — 1.18µs/op |
159
+ | Full put: hash + map set + size check | ~249k ops/s — 4.0µs/op |
160
+ | Interceptor path (hit *and* miss, 15-msg history) | ~138µs/request |
161
+ | Cache hit vs uncached provider round-trip (1–3s) | ~7,000–20,000× wall-clock |
162
+ | Key uniqueness over 50k synthetic keys | 50,000/50,000 (no collisions) |
163
+ | Eviction under 200-slot cap, 20k inserts | cap held; 19,800 evicted |
164
+
165
+ > ⚠ **Correction:** earlier versions claimed FNV-1a was "~100× faster than SHA256". That was wrong — Node's native SHA-256 is ~0.8× *faster* in practice. The hash was never the bottleneck: `JSON.stringify` dominates the ~138µs per-request cost. Cache hits are keyed by *byte-identical* requests, so real-world hit rate depends on your workload (best for retries, repeated tool calls and same-prompt reruns).
166
+
167
+ ## pi API compatibility
168
+
169
+ Verified against the **pi 0.84.x** extension API — two runtime facts shape the behaviour:
170
+
171
+ - `before_provider_request` receives the assembled provider request as `event.payload` (model, messages, parameters); cache keys are derived from it. In current pi this hook is a payload *transform*, not a response short-circuit, so a cached response is only ever returned once a response body has actually been captured.
172
+ - `after_provider_response` currently carries only `{ status, headers }` — **no response body**. Until a pi version exposes the body, responses cannot be stored; the extension detects this, logs a one-time note, and keeps `/l1-cache` stats working. It upgrades automatically (no config) on any pi version that exposes the body.
173
+
174
+ On pi 0.84 the extension therefore operates as a request-key instrumentation layer (Hits/Misses/Evictions via `/l1-cache`, CPU guard, TTL bookkeeping) and only short-circuits identical requests when the API contract provides the body it needs.
175
+
148
176
  ## Related Projects
149
177
 
150
178
  - **[opencode-saia-plugin](https://github.com/tobias-weiss-ai-xr/opencode-saia-plugin)** — SAIA provider for OpenCode
@@ -157,4 +185,4 @@ MIT — see [LICENSE](LICENSE)
157
185
 
158
186
  ## Maintainer
159
187
 
160
- [Tobias Weiß](https://github.com/tobias-weiss-ai-xr) — weissto@hrz.uni-marburg.de
188
+ [Tobias Weiß](https://github.com/tobias-weiss-ai-xr) — info@graphwiz.ai
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-l1-cache",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "L1 in-memory cache extension for pi — CPU/RAM optimized, production-ready",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -23,7 +23,7 @@
23
23
  "README.md",
24
24
  "LICENSE"
25
25
  ],
26
- "author": "Tobias Weiß <weissto@hrz.uni-marburg.de>",
26
+ "author": "Tobias Weiß <info@graphwiz.ai>",
27
27
  "license": "MIT",
28
28
  "homepage": "https://github.com/tobias-weiss-ai-xr/pi-l1-cache",
29
29
  "repository": {
@@ -39,9 +39,18 @@
39
39
  },
40
40
  "scripts": {
41
41
  "test": "node --test src/*.test.ts",
42
- "test:watch": "node --watch --test src/*.test.ts"
42
+ "test:watch": "node --watch --test src/*.test.ts",
43
+ "typecheck": "tsc",
44
+ "pack": "npm pack --dry-run",
45
+ "prepack": "npm run typecheck && npm test",
46
+ "prepublishOnly": "npm run typecheck && npm test"
43
47
  },
44
48
  "devDependencies": {
45
- "@types/node": "^20.0.0"
49
+ "@earendil-works/pi-coding-agent": "^0.84.0",
50
+ "@types/node": "^20.0.0",
51
+ "typescript": "^5.8.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@earendil-works/pi-coding-agent": "*"
46
55
  }
47
56
  }
package/src/index.test.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { describe, it, beforeEach } from "node:test"
1
+ import { describe, it, beforeEach, before, after } from "node:test"
2
2
  import assert from "node:assert/strict"
3
- import { _testHooks } from "./index.ts"
3
+ import init, { _testHooks } from "./index.ts"
4
4
 
5
5
  const Test = _testHooks()
6
6
 
@@ -133,4 +133,104 @@ describe("pi-l1-cache", () => {
133
133
  assert.equal(state.settings.maxMemoryBytes, 999999)
134
134
  })
135
135
  })
136
+
137
+ describe("interceptor flow (extension lifecycle)", () => {
138
+ type Handler = (ev?: any, ctx?: any) => any
139
+ const events: Record<string, Handler[]> = {}
140
+ let shutdown: (() => void) | undefined
141
+
142
+ before(async () => {
143
+ const api: any = {
144
+ registerCommand: () => {},
145
+ on: (name: string, cb: Handler) => {
146
+ ;(events[name] ??= []).push(cb)
147
+ },
148
+ }
149
+ await init(api)
150
+ const shutdownCbs = events.session_shutdown ?? []
151
+ shutdown = () => shutdownCbs.forEach((cb) => cb())
152
+ })
153
+
154
+ after(() => {
155
+ shutdown?.()
156
+ })
157
+
158
+ beforeEach(() => {
159
+ Test.reset()
160
+ Test.setSettings({ enabled: true, maxEntries: 50, maxMemoryBytes: 100000, ttlSeconds: 3600 })
161
+ })
162
+
163
+ const payload = () => ({ messages: [{ role: "user", content: "hello cache" }], parameters: {} })
164
+ const event = (): any => ({ type: "before_provider_request", payload: payload() })
165
+ const reqCtx = { model: { id: "test-model" } }
166
+
167
+ it("records a miss and stamps _cacheKey on first request", async () => {
168
+ const ev: any = event()
169
+ const ret = await events.before_provider_request[0](ev, reqCtx)
170
+ assert.equal(ret, undefined)
171
+ assert.ok(ev._cacheKey, "should stamp _cacheKey on miss")
172
+ assert.equal(Test.getStats().misses, 1)
173
+ })
174
+
175
+ it("returns the cached response on a byte-identical repeat when a body is captured", async () => {
176
+ const first: any = event()
177
+ await events.before_provider_request[0](first, reqCtx)
178
+ // Simulate a pi API that exposes the response body
179
+ await events.after_provider_response[0]({
180
+ type: "after_provider_response",
181
+ status: 200,
182
+ headers: {},
183
+ _cacheKey: first._cacheKey,
184
+ response: { content: "cached!" },
185
+ } as any)
186
+
187
+ const second: any = event()
188
+ const out = await events.before_provider_request[0](second, reqCtx)
189
+ assert.ok(out, "identical repeat should short-circuit to cache")
190
+ assert.equal(out.content, "cached!")
191
+ assert.equal(Test.getStats().hits, 1)
192
+ })
193
+
194
+ it("never stores an event envelope when no response body is available", async () => {
195
+ const first: any = event()
196
+ await events.before_provider_request[0](first, reqCtx)
197
+ // pi 0.84 + shape: status + headers only, no body
198
+ await events.after_provider_response[0]({
199
+ type: "after_provider_response",
200
+ status: 200,
201
+ headers: {},
202
+ } as any)
203
+ assert.equal(Test._getCacheSize(), 0, "must not cache the event envelope")
204
+ assert.ok(
205
+ Test.getStats().misses >= 0,
206
+ "after_provider_response without a body must not poison the cache",
207
+ )
208
+ })
209
+
210
+ it("does not touch the cache when disabled", async () => {
211
+ Test.setSettings({ enabled: false })
212
+ const ev: any = event()
213
+ const ret = await events.before_provider_request[0](ev, reqCtx)
214
+ assert.equal(ret, undefined)
215
+ assert.equal(ev._cacheKey, undefined)
216
+ assert.equal(Test.getStats().misses, 0)
217
+ })
218
+
219
+ it("keeps size within cap under interceptor churn", async () => {
220
+ Test.setSettings({ maxEntries: 20 })
221
+ for (let i = 0; i < 50; i++) {
222
+ const ev: any = {
223
+ type: "before_provider_request",
224
+ payload: { messages: [{ role: "user", content: `q-${i}` }], parameters: {} },
225
+ }
226
+ await events.before_provider_request[0](ev, reqCtx)
227
+ await events.after_provider_response[0]({
228
+ _cacheKey: ev._cacheKey,
229
+ response: { content: "a".repeat(50) },
230
+ } as any)
231
+ }
232
+ assert.ok(Test._getCacheSize() <= 20)
233
+ assert.ok(Test.getStats().misses >= 50)
234
+ })
235
+ })
136
236
  })
package/src/index.ts CHANGED
@@ -14,7 +14,14 @@
14
14
  // - CPU-aware graceful degradation
15
15
  // - Zero dependencies, single file
16
16
 
17
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
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" }>
18
25
 
19
26
  // ---------------------------------------------------------------------------
20
27
  // Types
@@ -79,6 +86,12 @@ let initialCpuStatus: "ok" | "disabled" | "error" = "ok"
79
86
  let lastCleanup = Date.now()
80
87
  const stats: Stats = { hits: 0, misses: 0, evictions: 0, cpuSkips: 0 }
81
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
+
82
95
  // ---------------------------------------------------------------------------
83
96
  // Core helpers
84
97
  // ---------------------------------------------------------------------------
@@ -231,12 +244,17 @@ export default async function (pi: ExtensionAPI) {
231
244
  pi.on("session_shutdown", () => clearInterval(cleanupTimer))
232
245
 
233
246
  // Interceptor: cache lookup before provider request
234
- pi.on("before_provider_request", async (event, ctx) => {
247
+ pi.on("before_provider_request", async (event: BeforeProviderRequestEvent, ctx) => {
235
248
  if (!settings.enabled) return
236
249
 
237
250
  const model = ctx.model?.id || "unknown"
238
- const messages = event.messages || []
239
- const params = event.parameters || {}
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 ?? {}
240
258
 
241
259
  // Fast hash; no per-request CPU check to stay on the hot path
242
260
  const key = fastHash(model + JSON.stringify(messages) + JSON.stringify(params))
@@ -244,22 +262,42 @@ export default async function (pi: ExtensionAPI) {
244
262
 
245
263
  if (entry && Date.now() - entry.timestamp <= settings.ttlSeconds * 1000) {
246
264
  stats.hits++
247
- return entry.response as never
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
248
271
  }
249
272
 
250
273
  // Miss — remember the key for after_provider_response
251
- event._cacheKey = key
274
+ ;(event as unknown as { _cacheKey?: string })._cacheKey = key
252
275
  stats.misses++
253
276
  })
254
277
 
255
278
  // Interceptor: store response after provider finishes
256
- pi.on("after_provider_response", async (event) => {
279
+ pi.on("after_provider_response", async (event: AfterProviderResponseEvent) => {
257
280
  if (!settings.enabled) return
258
- if (!event._cacheKey) return
259
281
 
260
- const key = event._cacheKey as string
261
- const response = event.response ?? event.choices ?? event
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
297
+
298
+ const key = e._cacheKey
262
299
  const size = estimateSize(response)
300
+ canServe = true
263
301
 
264
302
  cache.set(key, { response, timestamp: Date.now(), sizeBytes: size })
265
303
  totalMemory += size
@@ -269,7 +307,7 @@ export default async function (pi: ExtensionAPI) {
269
307
  // Commands
270
308
  pi.registerCommand("l1-cache", {
271
309
  description: "Show L1 cache stats, or use 'clear' to reset",
272
- handler: async (args: string, ctx: ExtensionContext) => {
310
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
273
311
  const arg = (args ?? "").trim()
274
312
 
275
313
  if (arg === "clear") {
@@ -278,7 +316,7 @@ export default async function (pi: ExtensionAPI) {
278
316
  stats.hits = 0
279
317
  stats.misses = 0
280
318
  stats.evictions = 0
281
- ctx.ui.notify("L1 cache cleared", "success")
319
+ ctx.ui.notify("L1 cache cleared", "info")
282
320
  return
283
321
  }
284
322
 
@@ -287,6 +325,7 @@ export default async function (pi: ExtensionAPI) {
287
325
  stats.hits + stats.misses > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(1) : "0.0"
288
326
  const lines = [
289
327
  `L1 cache status: ${settings.enabled ? "ENABLED" : "disabled"}`,
328
+ `Response caching: ${canServe ? "active" : "unavailable (no response body in this pi API)"}`,
290
329
  `Entries: ${cache.size} / ${settings.maxEntries}`,
291
330
  `Memory: ${(totalMemory / 1024 / 1024).toFixed(1)}MB / ${(settings.maxMemoryBytes / 1024 / 1024).toFixed(1)}MB`,
292
331
  `TTL: ${settings.ttlSeconds}s | CPU threshold: ${settings.cpuThreshold}%`,
@@ -296,23 +335,22 @@ export default async function (pi: ExtensionAPI) {
296
335
  `Last cleanup: ${new Date(lastCleanup).toISOString()}`,
297
336
  ]
298
337
  ctx.ui.notify(lines.join("\n"), "info")
299
- return ""
338
+ return
300
339
  }
301
340
 
302
341
  if (arg === "enable") {
303
342
  settings.enabled = true
304
- ctx.ui.notify("L1 cache enabled", "success")
343
+ ctx.ui.notify("L1 cache enabled", "info")
305
344
  return
306
345
  }
307
346
 
308
347
  if (arg === "disable") {
309
348
  settings.enabled = false
310
- ctx.ui.notify("L1 cache disabled", "success")
349
+ ctx.ui.notify("L1 cache disabled", "info")
311
350
  return
312
351
  }
313
352
 
314
353
  ctx.ui.notify(`Unknown command: /l1-cache ${arg}\nUsage: /l1-cache [stats|clear|enable|disable]`, "error")
315
- return ""
316
354
  },
317
355
  })
318
356