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/README.md +128 -112
- package/fix-l1-cache.cjs +405 -0
- package/package.json +11 -9
- package/src/index.test.ts +457 -179
- package/src/index.ts +272 -255
package/src/index.ts
CHANGED
|
@@ -1,34 +1,25 @@
|
|
|
1
|
-
// L1 Cache Extension —
|
|
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
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
persist: true,
|
|
62
42
|
logStats: false,
|
|
63
43
|
}
|
|
64
44
|
|
|
65
|
-
|
|
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
|
|
85
|
-
let
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
77
|
+
let h1 = 2166136261
|
|
78
|
+
let h2 = 2166136261
|
|
102
79
|
for (let i = 0; i < str.length; i++) {
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
84
|
+
return (h1 >>> 0).toString(16) + (h2 >>> 0).toString(16)
|
|
107
85
|
}
|
|
108
86
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
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(
|
|
113
|
-
if (json === undefined) return
|
|
110
|
+
const json = JSON.stringify(value)
|
|
111
|
+
if (json === undefined) return 4096
|
|
114
112
|
return json.length * 2
|
|
115
113
|
} catch {
|
|
116
|
-
return
|
|
114
|
+
return 4096
|
|
117
115
|
}
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
//
|
|
247
|
-
pi
|
|
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
|
-
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
//
|
|
279
|
-
|
|
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
|
-
//
|
|
283
|
-
|
|
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
|
|
299
|
-
const
|
|
300
|
-
|
|
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.
|
|
303
|
-
|
|
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: "
|
|
310
|
-
handler: async (args: string, ctx:
|
|
311
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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 }
|