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/README.md +131 -104
- package/fix-l1-cache.cjs +405 -0
- package/package.json +18 -7
- package/src/index.test.ts +462 -84
- package/src/index.ts +269 -214
package/src/index.ts
CHANGED
|
@@ -1,27 +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
|
-
// Lookup: ~0.1ms (vs 150ms Redis, 1-3s API)
|
|
4
|
+
// pi → [L1: RAM Map + disk (~/.pi/agent/cache/l1-cache)] → Provider
|
|
9
5
|
//
|
|
10
|
-
//
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
13
|
-
// -
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
persist: true,
|
|
55
42
|
logStats: false,
|
|
56
43
|
}
|
|
57
44
|
|
|
58
|
-
|
|
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
|
|
78
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
77
|
+
let h1 = 2166136261
|
|
78
|
+
let h2 = 2166136261
|
|
89
79
|
for (let i = 0; i < str.length; i++) {
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
105
|
+
return fastHash(JSON.stringify(basis))
|
|
94
106
|
}
|
|
95
107
|
|
|
96
|
-
|
|
97
|
-
function estimateSize(obj: unknown): number {
|
|
108
|
+
function estimateSize(value: unknown): number {
|
|
98
109
|
try {
|
|
99
|
-
const json = JSON.stringify(
|
|
100
|
-
if (json === undefined) return
|
|
110
|
+
const json = JSON.stringify(value)
|
|
111
|
+
if (json === undefined) return 4096
|
|
101
112
|
return json.length * 2
|
|
102
113
|
} catch {
|
|
103
|
-
return
|
|
114
|
+
return 4096
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
//
|
|
234
|
-
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) => {
|
|
235
282
|
if (!settings.enabled) return
|
|
236
283
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
const key =
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
256
|
-
|
|
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
|
-
|
|
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
|
|
261
|
-
const
|
|
262
|
-
|
|
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.
|
|
265
|
-
|
|
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: "
|
|
272
|
-
handler: async (args: string, ctx:
|
|
273
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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 }
|