agent-cache-optimizer 0.5.4 → 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.4"
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,46 +257,364 @@ 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),
125
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
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
547
  const MAX_DIAG_LINES = 1000
131
548
  const MAX_DIAG_BYTES = 50 * 1024 // 50KB
549
+ const MAX_EVENT_LINES = 5000
550
+ const MAX_EVENT_BYTES = 512 * 1024 // 512KB
132
551
  const DB_PRUNE_INTERVAL = 100 // prune every N observations
133
552
  const DB_STALE_DAYS = 7
134
553
 
135
- let firstCallLogged = false
554
+ const loadedScopes = new Set<string>()
136
555
 
137
- function diag(agent: string, msg: string): void {
556
+ function diag(scope: string, msg: string): void {
138
557
  try {
139
558
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
140
559
  const ts = new Date().toISOString()
141
- 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" })
142
561
  } catch {
143
562
  /* silent */
144
563
  }
145
564
  }
146
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
+
147
599
  // ── Disk management ──────────────────────────────────────────────────
148
600
 
149
- function rotateDiagLog(): void {
601
+ function rotateLineLog(path: string, maxBytes: number, maxLines: number): void {
150
602
  try {
151
- const path = join(STATE_DIR, "diag.log")
152
603
  if (!existsSync(path)) return
153
- const stat = existsSync(path) ? readFileSync(path, "utf-8").length : 0
154
- if (stat < MAX_DIAG_BYTES) return
155
- const lines = readFileSync(path, "utf-8").split("\n").filter(Boolean)
156
- if (lines.length <= MAX_DIAG_LINES) return
157
- writeFileSync(path, lines.slice(-MAX_DIAG_LINES).join("\n") + "\n")
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")
158
609
  } catch {
159
610
  /* best-effort */
160
611
  }
161
612
  }
162
613
 
614
+ function rotateDiagLog(): void {
615
+ rotateLineLog(join(STATE_DIR, "diag.log"), MAX_DIAG_BYTES, MAX_DIAG_LINES)
616
+ }
617
+
163
618
  function pruneStaleHashes(db: StabilityDB): void {
164
619
  const now = Date.now()
165
620
  const staleMs = DB_STALE_DAYS * 24 * 60 * 60 * 1000
@@ -182,6 +637,77 @@ function pruneStaleHashes(db: StabilityDB): void {
182
637
  }
183
638
  }
184
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
+
185
711
  // ── Plugin ───────────────────────────────────────────────────────────
186
712
 
187
713
  export const CacheOptimizerPlugin: Plugin = async () => {
@@ -193,32 +719,75 @@ export const CacheOptimizerPlugin: Plugin = async () => {
193
719
 
194
720
  "experimental.chat.system.transform": async (input, output) => {
195
721
  const rawBlocks = output.system
196
- if (!rawBlocks || rawBlocks.length <= 1) return
197
-
198
- const agent = input.model?.id ?? "default"
199
- const db = loadDB(agent)
200
-
201
- // Pass warm hashes to classifier for cache warming
202
- 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
+ )
203
760
 
204
- // Reorder: stable → unknown → dynamic
205
- output.system = [...classified.stable, ...classified.unknown, ...classified.dynamic]
761
+ output.system = [
762
+ ...ranked.sharedStable,
763
+ ...ranked.scopedStable,
764
+ ...ranked.coldStable,
765
+ ...ranked.dynamic,
766
+ ]
206
767
 
207
768
  // Persist position-based + content-addressed
208
769
  updateDB(db, output.system)
209
770
  updateContentDB(db, output.system)
771
+ if (family !== scope) {
772
+ updateDB(familyDB, output.system)
773
+ updateContentDB(familyDB, output.system)
774
+ }
210
775
 
211
776
  // Periodic maintenance
212
777
  if (db.observations % DB_PRUNE_INTERVAL === 0) {
213
778
  pruneStaleHashes(db)
214
779
  }
780
+ if (family !== scope && familyDB.observations % DB_PRUNE_INTERVAL === 0) {
781
+ pruneStaleHashes(familyDB)
782
+ }
215
783
 
216
- saveDB(agent, db)
784
+ saveDB(scope, db)
785
+ if (family !== scope) saveDB(family, familyDB)
217
786
  rotateDiagLog()
218
787
 
219
788
  // Update warm cache every 10 observations
220
789
  if (db.observations % 10 === 0) {
221
- saveWarmCache(db)
790
+ saveWarmCache(scope, db)
222
791
  }
223
792
 
224
793
  // Track savings
@@ -226,37 +795,83 @@ export const CacheOptimizerPlugin: Plugin = async () => {
226
795
  const savings = loadSavings()
227
796
  savings.totalStableBytes += stableBytes
228
797
  savings.totalObservations++
229
- savings.estimatedSavingsUSD = estimateSavings(savings.totalStableBytes, savings.totalObservations)
798
+ savings.estimatedSavingsUSD = estimateSavings(savings.totalStableBytes, 1)
230
799
  saveSavings(savings)
800
+ const sharedPrefixBytes = ranked.sharedStable.reduce((s, b) => s + b.length, 0)
231
801
 
232
802
  // Diagnostic log with savings
233
803
  const estCallSaving = estimateSavings(stableBytes, 1)
804
+ const warmCount = warmHashesForScope(scope)?.size ?? 0
234
805
  diag(
235
- agent,
806
+ scope,
236
807
  `S:${classified.stable.length} U:${classified.unknown.length} ` +
237
808
  `D:${classified.dynamic.length} T:${output.system.length} ` +
809
+ `SH:${ranked.sharedStable.length} SC:${ranked.scopedStable.length} ` +
810
+ `CS:${ranked.coldStable.length} ` +
238
811
  `obs:${db.observations} ` +
239
812
  `stableKB:${(stableBytes / 1024).toFixed(1)} ` +
813
+ `sharedKB:${(sharedPrefixBytes / 1024).toFixed(1)} ` +
240
814
  `saved:$${estCallSaving.toFixed(6)} ` +
241
815
  `total:$${savings.estimatedSavingsUSD.toFixed(4)}`,
242
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
+ })
243
842
  },
244
843
 
245
844
  // ── Diagnostic: chat.params (confirms plugin loaded) ──────────
246
845
 
247
846
  "chat.params": async (input, _output) => {
248
- if (!firstCallLogged) {
249
- firstCallLogged = true
250
- const agent = input.agent ?? "unknown"
251
- 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
252
851
  diag(
253
- agent,
254
- `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 ?? "?"} ` +
255
855
  `warm=${warmCount}`,
256
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
+ })
257
864
  }
258
865
  },
259
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
+
260
875
  // ── Provider cache headers ────────────────────────────────────
261
876
 
262
877
  "chat.headers": async (input, output) => {
@@ -270,7 +885,18 @@ export const CacheOptimizerPlugin: Plugin = async () => {
270
885
  }
271
886
 
272
887
  // Re-exports
273
- 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"
274
900
  export { coldStartScore, classify } from "./heuristics"
275
901
  export { splitBlock, splitAll } from "./splitting"
276
902
  export type { StabilityDB, Classified, BlockFingerprint, CacheOptimizerOptions } from "./types"