pi-l1-cache 1.2.1 → 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 +12 -1
- package/package.json +13 -4
- package/src/index.test.ts +102 -2
- package/src/index.ts +54 -16
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://pi.dev/packages)
|
|
7
|
+
[](https://www.npmjs.com/package/pi-l1-cache)
|
|
8
|
+
[](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
|
|
|
@@ -162,6 +164,15 @@ Measured against the published npm artifact (`pi-l1-cache@1.2.1`) on Node 22, us
|
|
|
162
164
|
|
|
163
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).
|
|
164
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
|
+
|
|
165
176
|
## Related Projects
|
|
166
177
|
|
|
167
178
|
- **[opencode-saia-plugin](https://github.com/tobias-weiss-ai-xr/opencode-saia-plugin)** — SAIA provider for OpenCode
|
|
@@ -174,4 +185,4 @@ MIT — see [LICENSE](LICENSE)
|
|
|
174
185
|
|
|
175
186
|
## Maintainer
|
|
176
187
|
|
|
177
|
-
[Tobias Weiß](https://github.com/tobias-weiss-ai-xr) —
|
|
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.
|
|
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ß <
|
|
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
|
-
"@
|
|
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 {
|
|
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
|
-
|
|
239
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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:
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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
|
|