agent-cache-optimizer 0.5.3 → 0.6.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
@@ -8,14 +8,23 @@
8
8
  * @license MIT
9
9
  */
10
10
 
11
- const VERSION = "0.5.3"
11
+ const VERSION = "0.6.0"
12
12
 
13
13
  import type { Plugin } from "@opencode-ai/plugin"
14
14
  import { join } from "node:path"
15
15
  import { homedir } from "node:os"
16
16
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
17
- import { emptyDB, updateDB, updateContentDB, extractWarmHashes, estimateSavings } from "./core"
17
+ import {
18
+ emptyDB,
19
+ updateDB,
20
+ updateContentDB,
21
+ extractWarmHashes,
22
+ estimateSavings,
23
+ hashContent,
24
+ lookupContentScore,
25
+ } from "./core"
18
26
  import { classify } from "./heuristics"
27
+ import { splitAll } from "./splitting"
19
28
  import type { StabilityDB } from "./types"
20
29
 
21
30
  // ── Persistence ──────────────────────────────────────────────────────
@@ -26,8 +35,28 @@ const STATE_DIR = join(
26
35
  "agent-cache-optimizer",
27
36
  )
28
37
 
29
- function dbPath(agent: string): string {
30
- const safe = agent.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "default"
38
+ interface ModelIdentity {
39
+ id?: string
40
+ modelID?: string
41
+ providerID?: string
42
+ name?: string
43
+ }
44
+
45
+ function scopePart(value: string | undefined, fallback: string): string {
46
+ const trimmed = value?.trim()
47
+ return trimmed && trimmed.length > 0 ? trimmed : fallback
48
+ }
49
+
50
+ function modelScope(model: ModelIdentity | undefined, agent?: string): string {
51
+ const provider = scopePart(model?.providerID, "unknown-provider")
52
+ const modelID = scopePart(model?.id ?? model?.modelID ?? model?.name, "unknown-model")
53
+ if (provider === "unknown-provider" && modelID === "unknown-model") return "default"
54
+ const normalizedAgent = agent?.trim()
55
+ return normalizedAgent ? `${provider}__${modelID}__${normalizedAgent}` : `${provider}__${modelID}`
56
+ }
57
+
58
+ function dbPath(scope: string): string {
59
+ const safe = scope.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "default"
31
60
  return join(STATE_DIR, `stability-${safe}.json`)
32
61
  }
33
62
 
@@ -35,9 +64,17 @@ function warmCachePath(): string {
35
64
  return join(STATE_DIR, "warm-cache.json")
36
65
  }
37
66
 
38
- function loadDB(agent: string): StabilityDB {
67
+ function cacheMetricsPath(): string {
68
+ return join(STATE_DIR, "cache-metrics.json")
69
+ }
70
+
71
+ function eventsPath(): string {
72
+ return join(STATE_DIR, "events.jsonl")
73
+ }
74
+
75
+ function loadDB(scope: string): StabilityDB {
39
76
  try {
40
- const raw = readFileSync(dbPath(agent), "utf-8")
77
+ const raw = readFileSync(dbPath(scope), "utf-8")
41
78
  const db = JSON.parse(raw) as StabilityDB
42
79
  // Migrate from pre-0.5.0: rebuild contentIndex from position data
43
80
  // Migrate from pre-0.5.x: ensure contentObservations exists
@@ -46,7 +83,7 @@ function loadDB(agent: string): StabilityDB {
46
83
  db.contentIndex = {}
47
84
  db.contentScores = {}
48
85
  db.contentObservations = 0
49
- saveDB(agent, db)
86
+ saveDB(scope, db)
50
87
  }
51
88
  return db
52
89
  } catch {
@@ -54,43 +91,143 @@ function loadDB(agent: string): StabilityDB {
54
91
  }
55
92
  }
56
93
 
57
- function saveDB(agent: string, db: StabilityDB): void {
94
+ function saveDB(scope: string, db: StabilityDB): void {
58
95
  try {
59
96
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
60
97
  db.updated = Date.now()
61
- writeFileSync(dbPath(agent), JSON.stringify(db, null, 2))
62
- } catch {
63
- /* best-effort */
98
+ writeFileSync(dbPath(scope), JSON.stringify(db, null, 2))
99
+ } catch (error) {
100
+ logError(scope, "save_db", error)
64
101
  }
65
102
  }
66
103
 
104
+ // ── Session scope tracking ───────────────────────────────────────────
105
+
106
+ interface ScopeContext {
107
+ scope: string
108
+ familyScope: string
109
+ }
110
+
111
+ const sessionScopes = new Map<string, ScopeContext>()
112
+
113
+ function familyScope(model: ModelIdentity | undefined): string {
114
+ return modelScope(model)
115
+ }
116
+
117
+ function scopeContext(model: ModelIdentity | undefined, agent?: string): ScopeContext {
118
+ const scope = modelScope(model, agent)
119
+ const modelFamily = familyScope(model)
120
+ return {
121
+ scope,
122
+ familyScope: modelFamily === "default" ? scope : modelFamily,
123
+ }
124
+ }
125
+
126
+ function rememberSessionScope(
127
+ sessionID: string | undefined,
128
+ model: ModelIdentity | undefined,
129
+ agent?: string,
130
+ ): string {
131
+ const context = scopeContext(model, agent)
132
+ if (sessionID) sessionScopes.set(sessionID, context)
133
+ return context.scope
134
+ }
135
+
136
+ function scopeForSession(sessionID: string | undefined, model: ModelIdentity | undefined): string {
137
+ if (sessionID) {
138
+ const known = sessionScopes.get(sessionID)
139
+ if (known) return known.scope
140
+ }
141
+ return scopeContext(model).scope
142
+ }
143
+
144
+ function familyScopeForSession(
145
+ sessionID: string | undefined,
146
+ model: ModelIdentity | undefined,
147
+ ): string {
148
+ if (sessionID) {
149
+ const known = sessionScopes.get(sessionID)
150
+ if (known) return known.familyScope
151
+ }
152
+ return scopeContext(model).familyScope
153
+ }
154
+
67
155
  // ── Cache warming ────────────────────────────────────────────────────
68
156
 
69
- let warmHashes: Set<string> | null = null
157
+ interface WarmCacheStore {
158
+ version: 2
159
+ global: string[]
160
+ scopes: Record<string, string[]>
161
+ }
162
+
163
+ interface WarmCacheMemory {
164
+ global: Set<string>
165
+ scopes: Map<string, Set<string>>
166
+ }
167
+
168
+ let warmCache: WarmCacheMemory = { global: new Set(), scopes: new Map() }
70
169
  let warmHashesLoaded = false
71
170
 
72
- function loadWarmCache(): Set<string> | null {
73
- if (warmHashesLoaded) return warmHashes
171
+ function loadWarmCache(): WarmCacheMemory {
172
+ if (warmHashesLoaded) return warmCache
74
173
  warmHashesLoaded = true
75
174
  try {
76
175
  const raw = readFileSync(warmCachePath(), "utf-8")
77
- const hashes = JSON.parse(raw) as string[]
78
- warmHashes = new Set(hashes)
79
- return warmHashes
176
+ const parsed = JSON.parse(raw) as string[] | WarmCacheStore
177
+ if (Array.isArray(parsed)) {
178
+ warmCache = { global: new Set(parsed), scopes: new Map() }
179
+ } else {
180
+ warmCache = {
181
+ global: new Set(parsed.global ?? []),
182
+ scopes: new Map(
183
+ Object.entries(parsed.scopes ?? {}).map(([scope, hashes]) => [scope, new Set(hashes)]),
184
+ ),
185
+ }
186
+ }
187
+ return warmCache
80
188
  } catch {
81
- return null
189
+ return warmCache
82
190
  }
83
191
  }
84
192
 
85
- function saveWarmCache(db: StabilityDB): void {
193
+ function warmHashesForScope(scope: string): Set<string> | undefined {
194
+ const scoped = warmCache.scopes.get(scope)
195
+ const hashes = new Set<string>(warmCache.global)
196
+ for (const hash of scoped ?? []) hashes.add(hash)
197
+ return hashes.size > 0 ? hashes : undefined
198
+ }
199
+
200
+ function saveWarmCache(scope: string, db: StabilityDB): void {
86
201
  try {
87
202
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
88
- const hashes = [...extractWarmHashes(db)]
89
- if (hashes.length > 0) {
90
- writeFileSync(warmCachePath(), JSON.stringify(hashes))
203
+ warmCache.scopes.set(scope, extractWarmHashes(db))
204
+
205
+ const counts = new Map<string, number>()
206
+ for (const hashes of warmCache.scopes.values()) {
207
+ for (const hash of hashes) counts.set(hash, (counts.get(hash) ?? 0) + 1)
91
208
  }
92
- } catch {
93
- /* best-effort */
209
+ warmCache.global = new Set(
210
+ [...counts.entries()].filter(([, count]) => count >= 2).map(([hash]) => hash),
211
+ )
212
+
213
+ const store: WarmCacheStore = {
214
+ version: 2,
215
+ global: [...warmCache.global].sort(),
216
+ scopes: Object.fromEntries(
217
+ [...warmCache.scopes.entries()]
218
+ .filter(([, hashes]) => hashes.size > 0)
219
+ .map(([scopeName, hashes]) => [scopeName, [...hashes].sort()]),
220
+ ),
221
+ }
222
+ writeFileSync(warmCachePath(), JSON.stringify(store, null, 2))
223
+ eventLog("warm_cache_update", scope, {
224
+ scopedHashCount: store.scopes[scope]?.length ?? 0,
225
+ globalHashCount: store.global.length,
226
+ scopeCount: Object.keys(store.scopes).length,
227
+ observations: db.observations,
228
+ })
229
+ } catch (error) {
230
+ logError(scope, "save_warm_cache", error)
94
231
  }
95
232
  }
96
233
 
@@ -120,25 +257,457 @@ function saveSavings(data: SavingsData): void {
120
257
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
121
258
  data.updated = Date.now()
122
259
  writeFileSync(savingsPath(), JSON.stringify(data, null, 2))
260
+ } catch (error) {
261
+ logError("global", "save_savings", error)
262
+ }
263
+ }
264
+
265
+ // ── Provider cache metrics ───────────────────────────────────────────
266
+
267
+ interface CacheMetricSnapshot {
268
+ inputTokens: number
269
+ outputTokens: number
270
+ cacheReadTokens: number
271
+ cacheWriteTokens: number
272
+ costUSD: number
273
+ }
274
+
275
+ interface CacheMetricTotals extends CacheMetricSnapshot {
276
+ events: number
277
+ cacheHitRate: number
278
+ }
279
+
280
+ interface CacheMetricsData {
281
+ total: CacheMetricTotals
282
+ scopes: Record<string, CacheMetricTotals>
283
+ snapshots: Record<string, CacheMetricSnapshot>
284
+ updated: number
285
+ }
286
+
287
+ function isMetricSnapshotIDSafe(value: string): boolean {
288
+ return value === "unknown" || /^[a-f0-9]{16}$/.test(value)
289
+ }
290
+
291
+ function metricSnapshotPart(value: unknown): string {
292
+ return hashID(value) ?? "unknown"
293
+ }
294
+
295
+ function metricSnapshotKey(
296
+ source: "message" | "step",
297
+ sessionID: unknown,
298
+ itemID: unknown,
299
+ ): string {
300
+ return `${source}:${metricSnapshotPart(sessionID)}:${metricSnapshotPart(itemID)}`
301
+ }
302
+
303
+ function normalizeMetricSnapshotKey(key: string): string {
304
+ const match = /^(message|step):([^:]+):([^:]+)$/.exec(key)
305
+ if (!match) return key
306
+ const source = match[1] as "message" | "step"
307
+ const sessionID = match[2]!
308
+ const itemID = match[3]!
309
+ const safeSessionID = isMetricSnapshotIDSafe(sessionID) ? sessionID : hashContent(sessionID)
310
+ const safeItemID = isMetricSnapshotIDSafe(itemID) ? itemID : hashContent(itemID)
311
+ return `${source}:${safeSessionID}:${safeItemID}`
312
+ }
313
+
314
+ function normalizeMetricSnapshots(
315
+ snapshots: Record<string, CacheMetricSnapshot> | undefined,
316
+ ): Record<string, CacheMetricSnapshot> {
317
+ const normalized: Record<string, CacheMetricSnapshot> = {}
318
+ for (const [key, snapshot] of Object.entries(snapshots ?? {})) {
319
+ normalized[normalizeMetricSnapshotKey(key)] = snapshot
320
+ }
321
+ return normalized
322
+ }
323
+
324
+ function sameKeys(
325
+ left: Record<string, CacheMetricSnapshot> | undefined,
326
+ right: Record<string, CacheMetricSnapshot>,
327
+ ): boolean {
328
+ const leftKeys = Object.keys(left ?? {}).sort()
329
+ const rightKeys = Object.keys(right).sort()
330
+ return (
331
+ leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
332
+ )
333
+ }
334
+
335
+ function emptyMetricTotals(): CacheMetricTotals {
336
+ return {
337
+ events: 0,
338
+ inputTokens: 0,
339
+ outputTokens: 0,
340
+ cacheReadTokens: 0,
341
+ cacheWriteTokens: 0,
342
+ costUSD: 0,
343
+ cacheHitRate: 0,
344
+ }
345
+ }
346
+
347
+ function computeCacheHitRate(inputTokens: number, cacheReadTokens: number): number {
348
+ const promptTokens = inputTokens + cacheReadTokens
349
+ return promptTokens > 0 ? cacheReadTokens / promptTokens : 0
350
+ }
351
+
352
+ function refreshCacheHitRate(total: CacheMetricTotals): boolean {
353
+ const next = computeCacheHitRate(total.inputTokens, total.cacheReadTokens)
354
+ const changed = total.cacheHitRate !== next
355
+ total.cacheHitRate = next
356
+ return changed
357
+ }
358
+
359
+ function loadCacheMetrics(): CacheMetricsData {
360
+ try {
361
+ const parsed = JSON.parse(readFileSync(cacheMetricsPath(), "utf-8")) as CacheMetricsData
362
+ const snapshots = normalizeMetricSnapshots(parsed.snapshots)
363
+ const data: CacheMetricsData = {
364
+ total: parsed.total ?? emptyMetricTotals(),
365
+ scopes: parsed.scopes ?? {},
366
+ snapshots,
367
+ updated: parsed.updated ?? 0,
368
+ }
369
+ let changed = !sameKeys(parsed.snapshots, snapshots)
370
+ changed = refreshCacheHitRate(data.total) || changed
371
+ for (const total of Object.values(data.scopes)) {
372
+ changed = refreshCacheHitRate(total) || changed
373
+ }
374
+ if (changed) {
375
+ data.updated = Date.now()
376
+ writeFileSync(cacheMetricsPath(), JSON.stringify(data, null, 2))
377
+ }
378
+ return data
123
379
  } catch {
124
- /* best-effort */
380
+ return { total: emptyMetricTotals(), scopes: {}, snapshots: {}, updated: 0 }
381
+ }
382
+ }
383
+
384
+ function applyMetricDelta(total: CacheMetricTotals, delta: CacheMetricSnapshot): void {
385
+ total.events++
386
+ total.inputTokens += delta.inputTokens
387
+ total.outputTokens += delta.outputTokens
388
+ total.cacheReadTokens += delta.cacheReadTokens
389
+ total.cacheWriteTokens += delta.cacheWriteTokens
390
+ total.costUSD += delta.costUSD
391
+ refreshCacheHitRate(total)
392
+ }
393
+
394
+ function positiveDelta(current: number, previous: number | undefined): number {
395
+ return Math.max(0, current - (previous ?? 0))
396
+ }
397
+
398
+ function saveCacheMetrics(
399
+ scope: string,
400
+ key: string,
401
+ current: CacheMetricSnapshot,
402
+ ): CacheMetricsData {
403
+ const data = loadCacheMetrics()
404
+ const previous = data.snapshots[key]
405
+ const delta: CacheMetricSnapshot = {
406
+ inputTokens: positiveDelta(current.inputTokens, previous?.inputTokens),
407
+ outputTokens: positiveDelta(current.outputTokens, previous?.outputTokens),
408
+ cacheReadTokens: positiveDelta(current.cacheReadTokens, previous?.cacheReadTokens),
409
+ cacheWriteTokens: positiveDelta(current.cacheWriteTokens, previous?.cacheWriteTokens),
410
+ costUSD: positiveDelta(current.costUSD, previous?.costUSD),
411
+ }
412
+
413
+ if (
414
+ delta.inputTokens === 0 &&
415
+ delta.outputTokens === 0 &&
416
+ delta.cacheReadTokens === 0 &&
417
+ delta.cacheWriteTokens === 0 &&
418
+ delta.costUSD === 0
419
+ ) {
420
+ return data
125
421
  }
422
+
423
+ data.snapshots[key] = current
424
+ applyMetricDelta(data.total, delta)
425
+ data.scopes[scope] ??= emptyMetricTotals()
426
+ applyMetricDelta(data.scopes[scope], delta)
427
+ data.updated = Date.now()
428
+
429
+ try {
430
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
431
+ writeFileSync(cacheMetricsPath(), JSON.stringify(data, null, 2))
432
+ } catch (error) {
433
+ logError(scope, "save_cache_metrics", error)
434
+ }
435
+ return data
436
+ }
437
+
438
+ function metricSnapshotFromTokens(tokens: any, cost: unknown): CacheMetricSnapshot | null {
439
+ if (!tokens || typeof tokens !== "object") return null
440
+ const inputTokens = Number(tokens.input ?? 0)
441
+ const outputTokens = Number(tokens.output ?? 0)
442
+ const cacheReadTokens = Number(tokens.cache?.read ?? 0)
443
+ const cacheWriteTokens = Number(tokens.cache?.write ?? 0)
444
+ const costUSD = Number(cost ?? 0)
445
+ if (
446
+ inputTokens === 0 &&
447
+ outputTokens === 0 &&
448
+ cacheReadTokens === 0 &&
449
+ cacheWriteTokens === 0 &&
450
+ costUSD === 0
451
+ ) {
452
+ return null
453
+ }
454
+ return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUSD }
455
+ }
456
+
457
+ function recordCacheMetricFromEvent(event: any): void {
458
+ if (event?.type === "session.next.step.started") {
459
+ const props = event.properties
460
+ rememberSessionScope(props?.sessionID, props?.model, props?.agent)
461
+ return
462
+ }
463
+
464
+ if (event?.type === "message.updated") {
465
+ const info = event.properties?.info
466
+ if (info?.role !== "assistant") return
467
+ const snapshot = metricSnapshotFromTokens(info.tokens, info.cost)
468
+ if (!snapshot) return
469
+ const scope =
470
+ info.agent && info.providerID && info.modelID
471
+ ? modelScope({ providerID: info.providerID, modelID: info.modelID }, info.agent)
472
+ : scopeForSession(info.sessionID, { providerID: info.providerID, modelID: info.modelID })
473
+ const key = metricSnapshotKey("message", info.sessionID, info.id)
474
+ const previous = loadCacheMetrics().snapshots[key]
475
+ const data = saveCacheMetrics(scope, key, snapshot)
476
+ const delta = metricDelta(snapshot, previous)
477
+ if (isZeroMetricDelta(delta)) return
478
+ diag(
479
+ scope,
480
+ `metrics input:${data.scopes[scope]?.inputTokens ?? 0} ` +
481
+ `cacheRead:${data.scopes[scope]?.cacheReadTokens ?? 0} ` +
482
+ `cacheWrite:${data.scopes[scope]?.cacheWriteTokens ?? 0} ` +
483
+ `hitRate:${((data.scopes[scope]?.cacheHitRate ?? 0) * 100).toFixed(1)}%`,
484
+ )
485
+ eventLog("metrics", scope, {
486
+ sessionHash: hashID(info.sessionID),
487
+ messageHash: hashID(info.id),
488
+ source: "message.updated",
489
+ delta,
490
+ totals: data.scopes[scope] ?? emptyMetricTotals(),
491
+ })
492
+ return
493
+ }
494
+
495
+ if (event?.type === "session.next.step.ended") {
496
+ const props = event.properties
497
+ const snapshot = metricSnapshotFromTokens(props?.tokens, props?.cost)
498
+ if (!snapshot) return
499
+ const scope = scopeForSession(props?.sessionID, undefined)
500
+ const key = metricSnapshotKey("step", props?.sessionID, props?.assistantMessageID ?? event.id)
501
+ const previous = loadCacheMetrics().snapshots[key]
502
+ const data = saveCacheMetrics(scope, key, snapshot)
503
+ const delta = metricDelta(snapshot, previous)
504
+ if (isZeroMetricDelta(delta)) return
505
+ diag(
506
+ scope,
507
+ `metrics input:${data.scopes[scope]?.inputTokens ?? 0} ` +
508
+ `cacheRead:${data.scopes[scope]?.cacheReadTokens ?? 0} ` +
509
+ `cacheWrite:${data.scopes[scope]?.cacheWriteTokens ?? 0} ` +
510
+ `hitRate:${((data.scopes[scope]?.cacheHitRate ?? 0) * 100).toFixed(1)}%`,
511
+ )
512
+ eventLog("metrics", scope, {
513
+ sessionHash: hashID(props?.sessionID),
514
+ messageHash: hashID(props?.assistantMessageID ?? event.id),
515
+ source: "session.next.step.ended",
516
+ delta,
517
+ totals: data.scopes[scope] ?? emptyMetricTotals(),
518
+ })
519
+ }
520
+ }
521
+
522
+ function metricDelta(
523
+ current: CacheMetricSnapshot,
524
+ previous: CacheMetricSnapshot | undefined,
525
+ ): CacheMetricSnapshot {
526
+ return {
527
+ inputTokens: positiveDelta(current.inputTokens, previous?.inputTokens),
528
+ outputTokens: positiveDelta(current.outputTokens, previous?.outputTokens),
529
+ cacheReadTokens: positiveDelta(current.cacheReadTokens, previous?.cacheReadTokens),
530
+ cacheWriteTokens: positiveDelta(current.cacheWriteTokens, previous?.cacheWriteTokens),
531
+ costUSD: positiveDelta(current.costUSD, previous?.costUSD),
532
+ }
533
+ }
534
+
535
+ function isZeroMetricDelta(delta: CacheMetricSnapshot): boolean {
536
+ return (
537
+ delta.inputTokens === 0 &&
538
+ delta.outputTokens === 0 &&
539
+ delta.cacheReadTokens === 0 &&
540
+ delta.cacheWriteTokens === 0 &&
541
+ delta.costUSD === 0
542
+ )
126
543
  }
127
544
 
128
545
  // ── Diagnostics ──────────────────────────────────────────────────────
129
546
 
130
- let firstCallLogged = false
547
+ const MAX_DIAG_LINES = 1000
548
+ const MAX_DIAG_BYTES = 50 * 1024 // 50KB
549
+ const MAX_EVENT_LINES = 5000
550
+ const MAX_EVENT_BYTES = 512 * 1024 // 512KB
551
+ const DB_PRUNE_INTERVAL = 100 // prune every N observations
552
+ const DB_STALE_DAYS = 7
553
+
554
+ const loadedScopes = new Set<string>()
131
555
 
132
- function diag(agent: string, msg: string): void {
556
+ function diag(scope: string, msg: string): void {
133
557
  try {
134
558
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
135
559
  const ts = new Date().toISOString()
136
- writeFileSync(join(STATE_DIR, "diag.log"), `[${ts}] [${agent}] ${msg}\n`, { flag: "a" })
560
+ writeFileSync(join(STATE_DIR, "diag.log"), `[${ts}] [${scope}] ${msg}\n`, { flag: "a" })
137
561
  } catch {
138
562
  /* silent */
139
563
  }
140
564
  }
141
565
 
566
+ function errorMessage(error: unknown): string {
567
+ return error instanceof Error ? error.message : String(error)
568
+ }
569
+
570
+ function logError(scope: string, operation: string, error: unknown): void {
571
+ eventLog("error", scope, {
572
+ operation,
573
+ message: errorMessage(error),
574
+ })
575
+ }
576
+
577
+ function hashID(value: unknown): string | undefined {
578
+ if (typeof value !== "string" || value.length === 0) return undefined
579
+ return hashContent(value)
580
+ }
581
+
582
+ function eventLog(type: string, scope: string, data: Record<string, unknown> = {}): void {
583
+ try {
584
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
585
+ rotateLineLog(eventsPath(), MAX_EVENT_BYTES, MAX_EVENT_LINES)
586
+ const event = {
587
+ ts: new Date().toISOString(),
588
+ version: VERSION,
589
+ type,
590
+ scope,
591
+ ...data,
592
+ }
593
+ writeFileSync(eventsPath(), `${JSON.stringify(event)}\n`, { flag: "a" })
594
+ } catch {
595
+ /* best-effort */
596
+ }
597
+ }
598
+
599
+ // ── Disk management ──────────────────────────────────────────────────
600
+
601
+ function rotateLineLog(path: string, maxBytes: number, maxLines: number): void {
602
+ try {
603
+ if (!existsSync(path)) return
604
+ const content = readFileSync(path, "utf-8")
605
+ if (content.length < maxBytes) return
606
+ const lines = content.split("\n").filter(Boolean)
607
+ if (lines.length <= maxLines) return
608
+ writeFileSync(path, lines.slice(-maxLines).join("\n") + "\n")
609
+ } catch {
610
+ /* best-effort */
611
+ }
612
+ }
613
+
614
+ function rotateDiagLog(): void {
615
+ rotateLineLog(join(STATE_DIR, "diag.log"), MAX_DIAG_BYTES, MAX_DIAG_LINES)
616
+ }
617
+
618
+ function pruneStaleHashes(db: StabilityDB): void {
619
+ const now = Date.now()
620
+ const staleMs = DB_STALE_DAYS * 24 * 60 * 60 * 1000
621
+ // Prune contentIndex: remove hashes not seen in STALE_DAYS with low count
622
+ for (const [hash, fp] of Object.entries(db.contentIndex)) {
623
+ if (now - fp.lastSeen > staleMs && fp.count <= 2) {
624
+ delete db.contentIndex[hash]
625
+ delete db.contentScores[hash]
626
+ }
627
+ }
628
+ // Prune position hashes similarly
629
+ for (const fps of Object.values(db.positions)) {
630
+ for (let i = fps.length - 1; i >= 0; i--) {
631
+ const fp = fps[i]!
632
+ if (now - fp.lastSeen > staleMs && fp.count <= 2) {
633
+ delete db.scores[fp.hash]
634
+ fps.splice(i, 1)
635
+ }
636
+ }
637
+ }
638
+ }
639
+
640
+ // ── Cross-agent stable prefix ranking ────────────────────────────────
641
+
642
+ interface WarmHashMembership {
643
+ global: Set<string>
644
+ scoped: Set<string>
645
+ family: Set<string>
646
+ }
647
+
648
+ interface StableRanking {
649
+ sharedStable: string[]
650
+ scopedStable: string[]
651
+ coldStable: string[]
652
+ dynamic: string[]
653
+ }
654
+
655
+ function classificationWarmHashes(membership: WarmHashMembership): Set<string> {
656
+ const hashes = new Set<string>(membership.global)
657
+ for (const hash of membership.scoped) hashes.add(hash)
658
+ return hashes
659
+ }
660
+
661
+ function warmMembershipForScope(scope: string, familyDB: StabilityDB): WarmHashMembership {
662
+ const cache = loadWarmCache()
663
+ return {
664
+ global: cache.global,
665
+ scoped: cache.scopes.get(scope) ?? new Set(),
666
+ family: extractWarmHashes(familyDB),
667
+ }
668
+ }
669
+
670
+ function hasStableContentScore(db: StabilityDB, hash: string): boolean {
671
+ const score = lookupContentScore(db, hash)
672
+ return db.contentObservations >= 2 && score !== null && score >= 0.7
673
+ }
674
+
675
+ function rankStableBlocks(
676
+ stableBlocks: string[],
677
+ dynamicBlocks: string[],
678
+ scopeDB: StabilityDB,
679
+ familyDB: StabilityDB,
680
+ warmMembership: WarmHashMembership,
681
+ ): StableRanking {
682
+ const ranking: StableRanking = {
683
+ sharedStable: [],
684
+ scopedStable: [],
685
+ coldStable: [],
686
+ dynamic: dynamicBlocks,
687
+ }
688
+
689
+ for (const block of stableBlocks) {
690
+ const hash = hashContent(block)
691
+ if (
692
+ warmMembership.global.has(hash) ||
693
+ warmMembership.family.has(hash) ||
694
+ hasStableContentScore(familyDB, hash)
695
+ ) {
696
+ ranking.sharedStable.push(block)
697
+ continue
698
+ }
699
+
700
+ if (warmMembership.scoped.has(hash) || hasStableContentScore(scopeDB, hash)) {
701
+ ranking.scopedStable.push(block)
702
+ continue
703
+ }
704
+
705
+ ranking.coldStable.push(block)
706
+ }
707
+
708
+ return ranking
709
+ }
710
+
142
711
  // ── Plugin ───────────────────────────────────────────────────────────
143
712
 
144
713
  export const CacheOptimizerPlugin: Plugin = async () => {
@@ -150,25 +719,75 @@ export const CacheOptimizerPlugin: Plugin = async () => {
150
719
 
151
720
  "experimental.chat.system.transform": async (input, output) => {
152
721
  const rawBlocks = output.system
153
- if (!rawBlocks || rawBlocks.length <= 1) return
154
-
155
- const agent = input.model?.id ?? "default"
156
- const db = loadDB(agent)
157
-
158
- // Pass warm hashes to classifier for cache warming
159
- const classified = classify(rawBlocks, db, { warmHashes: warmHashes ?? undefined })
722
+ const rawBlockCount = rawBlocks?.length ?? 0
723
+ const scope = scopeForSession(input.sessionID, input.model)
724
+ const splitBlocks = rawBlocks ? splitAll(rawBlocks) : []
725
+ const splitBlockCount = splitBlocks.length
726
+ if (splitBlockCount <= 1) {
727
+ eventLog("transform_seen", scope, {
728
+ sessionHash: hashID(input.sessionID),
729
+ rawBlockCount,
730
+ splitBlockCount,
731
+ status: "skipped",
732
+ reason: "insufficient_system_blocks",
733
+ })
734
+ return
735
+ }
736
+ eventLog("transform_seen", scope, {
737
+ sessionHash: hashID(input.sessionID),
738
+ rawBlockCount,
739
+ splitBlockCount,
740
+ status: "received",
741
+ })
742
+
743
+ const family = familyScopeForSession(input.sessionID, input.model)
744
+ const db = loadDB(scope)
745
+ const familyDB = family === scope ? db : loadDB(family)
746
+ const warmMembership = warmMembershipForScope(scope, familyDB)
747
+
748
+ const classified = classify(splitBlocks, db, {
749
+ splitThreshold: Number.MAX_SAFE_INTEGER,
750
+ warmHashes: classificationWarmHashes(warmMembership),
751
+ })
752
+
753
+ const ranked = rankStableBlocks(
754
+ classified.stable,
755
+ [...classified.unknown, ...classified.dynamic],
756
+ db,
757
+ familyDB,
758
+ warmMembership,
759
+ )
160
760
 
161
- // Reorder: stable → unknown → dynamic
162
- output.system = [...classified.stable, ...classified.unknown, ...classified.dynamic]
761
+ output.system = [
762
+ ...ranked.sharedStable,
763
+ ...ranked.scopedStable,
764
+ ...ranked.coldStable,
765
+ ...ranked.dynamic,
766
+ ]
163
767
 
164
768
  // Persist position-based + content-addressed
165
769
  updateDB(db, output.system)
166
770
  updateContentDB(db, output.system)
167
- saveDB(agent, db)
771
+ if (family !== scope) {
772
+ updateDB(familyDB, output.system)
773
+ updateContentDB(familyDB, output.system)
774
+ }
775
+
776
+ // Periodic maintenance
777
+ if (db.observations % DB_PRUNE_INTERVAL === 0) {
778
+ pruneStaleHashes(db)
779
+ }
780
+ if (family !== scope && familyDB.observations % DB_PRUNE_INTERVAL === 0) {
781
+ pruneStaleHashes(familyDB)
782
+ }
783
+
784
+ saveDB(scope, db)
785
+ if (family !== scope) saveDB(family, familyDB)
786
+ rotateDiagLog()
168
787
 
169
788
  // Update warm cache every 10 observations
170
789
  if (db.observations % 10 === 0) {
171
- saveWarmCache(db)
790
+ saveWarmCache(scope, db)
172
791
  }
173
792
 
174
793
  // Track savings
@@ -176,37 +795,83 @@ export const CacheOptimizerPlugin: Plugin = async () => {
176
795
  const savings = loadSavings()
177
796
  savings.totalStableBytes += stableBytes
178
797
  savings.totalObservations++
179
- savings.estimatedSavingsUSD = estimateSavings(savings.totalStableBytes, savings.totalObservations)
798
+ savings.estimatedSavingsUSD = estimateSavings(savings.totalStableBytes, 1)
180
799
  saveSavings(savings)
800
+ const sharedPrefixBytes = ranked.sharedStable.reduce((s, b) => s + b.length, 0)
181
801
 
182
802
  // Diagnostic log with savings
183
803
  const estCallSaving = estimateSavings(stableBytes, 1)
804
+ const warmCount = warmHashesForScope(scope)?.size ?? 0
184
805
  diag(
185
- agent,
806
+ scope,
186
807
  `S:${classified.stable.length} U:${classified.unknown.length} ` +
187
808
  `D:${classified.dynamic.length} T:${output.system.length} ` +
809
+ `SH:${ranked.sharedStable.length} SC:${ranked.scopedStable.length} ` +
810
+ `CS:${ranked.coldStable.length} ` +
188
811
  `obs:${db.observations} ` +
189
812
  `stableKB:${(stableBytes / 1024).toFixed(1)} ` +
813
+ `sharedKB:${(sharedPrefixBytes / 1024).toFixed(1)} ` +
190
814
  `saved:$${estCallSaving.toFixed(6)} ` +
191
815
  `total:$${savings.estimatedSavingsUSD.toFixed(4)}`,
192
816
  )
817
+ eventLog("transform", scope, {
818
+ sessionHash: hashID(input.sessionID),
819
+ family,
820
+ counts: {
821
+ stable: classified.stable.length,
822
+ unknown: classified.unknown.length,
823
+ dynamic: classified.dynamic.length,
824
+ total: output.system.length,
825
+ },
826
+ classifier: {
827
+ unknown: classified.unknown.length,
828
+ warmHashes: warmCount,
829
+ },
830
+ ranking: {
831
+ sharedStable: ranked.sharedStable.length,
832
+ scopedStable: ranked.scopedStable.length,
833
+ coldStable: ranked.coldStable.length,
834
+ dynamic: ranked.dynamic.length,
835
+ sharedPrefixBytes,
836
+ },
837
+ stableBytes,
838
+ estimatedCallSavingsUSD: estCallSaving,
839
+ totalEstimatedSavingsUSD: savings.estimatedSavingsUSD,
840
+ observations: db.observations,
841
+ })
193
842
  },
194
843
 
195
844
  // ── Diagnostic: chat.params (confirms plugin loaded) ──────────
196
845
 
197
846
  "chat.params": async (input, _output) => {
198
- if (!firstCallLogged) {
199
- firstCallLogged = true
200
- const agent = input.agent ?? "unknown"
201
- const warmCount = warmHashes?.size ?? 0
847
+ const scope = rememberSessionScope(input.sessionID, input.model, input.agent)
848
+ if (!loadedScopes.has(scope)) {
849
+ loadedScopes.add(scope)
850
+ const warmCount = warmHashesForScope(scope)?.size ?? 0
202
851
  diag(
203
- agent,
204
- `v${VERSION} loaded agent=${agent} model=${input.model?.id ?? "?"} ` +
852
+ scope,
853
+ `v${VERSION} loaded agent=${input.agent ?? "unknown"} ` +
854
+ `provider=${input.model?.providerID ?? "?"} model=${input.model?.id ?? "?"} ` +
205
855
  `warm=${warmCount}`,
206
856
  )
857
+ eventLog("loaded", scope, {
858
+ sessionHash: hashID(input.sessionID),
859
+ provider: input.model?.providerID ?? "unknown-provider",
860
+ model: input.model?.id ?? "unknown-model",
861
+ agent: input.agent ?? "unknown",
862
+ warmCount,
863
+ })
207
864
  }
208
865
  },
209
866
 
867
+ "chat.message": async (input, _output) => {
868
+ rememberSessionScope(input.sessionID, input.model, input.agent)
869
+ },
870
+
871
+ event: async (input) => {
872
+ recordCacheMetricFromEvent(input.event)
873
+ },
874
+
210
875
  // ── Provider cache headers ────────────────────────────────────
211
876
 
212
877
  "chat.headers": async (input, output) => {
@@ -220,7 +885,18 @@ export const CacheOptimizerPlugin: Plugin = async () => {
220
885
  }
221
886
 
222
887
  // Re-exports
223
- export { emptyDB, updateDB, updateContentDB, hashContent, lookupScore, lookupContentScore, isWarm, extractWarmHashes, isWarmHash, estimateSavings } from "./core"
888
+ export {
889
+ emptyDB,
890
+ updateDB,
891
+ updateContentDB,
892
+ hashContent,
893
+ lookupScore,
894
+ lookupContentScore,
895
+ isWarm,
896
+ extractWarmHashes,
897
+ isWarmHash,
898
+ estimateSavings,
899
+ } from "./core"
224
900
  export { coldStartScore, classify } from "./heuristics"
225
901
  export { splitBlock, splitAll } from "./splitting"
226
902
  export type { StabilityDB, Classified, BlockFingerprint, CacheOptimizerOptions } from "./types"