agent-cache-optimizer 0.4.0 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-cache-optimizer",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Content-agnostic KV cache optimizer for LLM CLI agents — boosts prompt cache hit rate by 40-88% through automatic stability tracking and block reordering",
5
5
  "keywords": [
6
6
  "kv-cache",
package/src/core.ts CHANGED
@@ -2,7 +2,12 @@ import { createHash } from "node:crypto"
2
2
  import type { StabilityDB } from "./types"
3
3
 
4
4
  /**
5
- * Core hash-tracking engine — fully CLI-agnostic.
5
+ * Core engine — content-addressed hash tracking (CLI-agnostic).
6
+ *
7
+ * v0.5: Added content-addressed tracking. Instead of tracking which hash
8
+ * appears at which POSITION (which breaks when block count changes across
9
+ * calls), we track by CONTENT identity. The same CLAUDE.md block hash
10
+ * gets counted regardless of whether it appears at index 1, 2, or 3.
6
11
  */
7
12
 
8
13
  // ── Hashing ──────────────────────────────────────────────────────────
@@ -14,7 +19,15 @@ export function hashContent(content: string): string {
14
19
  // ── DB operations ────────────────────────────────────────────────────
15
20
 
16
21
  export function emptyDB(): StabilityDB {
17
- return { positions: {}, scores: {}, observations: 0, updated: 0 }
22
+ return {
23
+ positions: {},
24
+ scores: {},
25
+ contentIndex: {},
26
+ contentScores: {},
27
+ contentObservations: 0,
28
+ observations: 0,
29
+ updated: 0,
30
+ }
18
31
  }
19
32
 
20
33
  export function lookupScore(db: StabilityDB, hash: string): number | null {
@@ -22,7 +35,54 @@ export function lookupScore(db: StabilityDB, hash: string): number | null {
22
35
  return val !== undefined ? val : null
23
36
  }
24
37
 
25
- // ── Stability scoring ────────────────────────────────────────────────
38
+ // ── Content-addressed scoring (primary) ──────────────────────────────
39
+
40
+ /**
41
+ * Look up content-addressed stability score for a block hash.
42
+ * This is position-independent — the same block gets the same score
43
+ * regardless of where it appears in the system prompt.
44
+ */
45
+ export function lookupContentScore(db: StabilityDB, hash: string): number | null {
46
+ const val = db.contentScores[hash]
47
+ return val !== undefined ? val : null
48
+ }
49
+
50
+ /**
51
+ * Update content-addressed tracking.
52
+ *
53
+ * For each block, records its hash in the content index regardless of
54
+ * position. Then recomputes content scores:
55
+ *
56
+ * score = count / observations
57
+ *
58
+ * A block that appears in every call → score → 1.0 (stable)
59
+ * A block that appears once → score → 1/observations (dynamic)
60
+ */
61
+ export function updateContentDB(db: StabilityDB, blocks: string[]): StabilityDB {
62
+ const now = Date.now()
63
+
64
+ for (const block of blocks) {
65
+ const h = hashContent(block)
66
+ const existing = db.contentIndex[h]
67
+ if (existing) {
68
+ existing.lastSeen = now
69
+ existing.count++
70
+ } else {
71
+ db.contentIndex[h] = { hash: h, firstSeen: now, lastSeen: now, count: 1 }
72
+ }
73
+ }
74
+
75
+ // Recompute content scores using contentObservations (not observations)
76
+ db.contentObservations++
77
+ const obs = Math.max(1, db.contentObservations)
78
+ for (const fp of Object.values(db.contentIndex)) {
79
+ db.contentScores[fp.hash] = Math.min(1.0, fp.count / obs)
80
+ }
81
+
82
+ return db
83
+ }
84
+
85
+ // ── Position-based scoring (legacy fallback) ─────────────────────────
26
86
 
27
87
  export function updateDB(db: StabilityDB, blocks: string[]): StabilityDB {
28
88
  const now = Date.now()
@@ -68,27 +128,19 @@ export function isWarm(db: StabilityDB, threshold = 2): boolean {
68
128
 
69
129
  // ── Cache warming ────────────────────────────────────────────────────
70
130
 
71
- /**
72
- * Extract stable hashes from a DB for cache warming.
73
- * A hash is "warmable" if its score >= 0.8 and it has been observed
74
- * at least 3 times at the same position.
75
- */
76
131
  export function extractWarmHashes(db: StabilityDB): Set<string> {
77
132
  const warm = new Set<string>()
78
- for (const fps of Object.values(db.positions)) {
79
- for (const fp of fps) {
80
- const score = db.scores[fp.hash]
81
- if (score !== undefined && score >= 0.8 && fp.count >= 3) {
82
- warm.add(fp.hash)
83
- }
84
- }
133
+ // Primary: content-addressed stable hashes
134
+ for (const [hash, score] of Object.entries(db.contentScores)) {
135
+ if (score >= 0.8) warm.add(hash)
136
+ }
137
+ // Fallback: position-based stable hashes
138
+ for (const [hash, score] of Object.entries(db.scores)) {
139
+ if (score >= 0.8) warm.add(hash)
85
140
  }
86
141
  return warm
87
142
  }
88
143
 
89
- /**
90
- * Check if a block hash is known-stable from cache warming data.
91
- */
92
144
  export function isWarmHash(warmHashes: Set<string> | null, hash: string): boolean {
93
145
  return warmHashes !== null && warmHashes.has(hash)
94
146
  }
@@ -96,13 +148,7 @@ export function isWarmHash(warmHashes: Set<string> | null, hash: string): boolea
96
148
  // ── Cost estimation ──────────────────────────────────────────────────
97
149
 
98
150
  /**
99
- * Estimate cache cost savings based on classification.
100
- *
101
- * DeepSeek v4-pro pricing (per 1M tokens):
102
- * Cache miss (input): $0.435
103
- * Cache hit (input): $0.003625
104
- * Savings: ~$0.431 per 1M cached tokens
105
- *
151
+ * Estimate cache cost savings. DeepSeek v4-pro: $0.435/M miss → $0.003625/M hit.
106
152
  * Rough estimate: 1 token ≈ 4 chars for English text.
107
153
  */
108
154
  export function estimateSavings(
package/src/heuristics.ts CHANGED
@@ -1,64 +1,39 @@
1
1
  import type { StabilityDB, Classified } from "./types"
2
2
  import { splitAll } from "./splitting"
3
- import { hashContent, lookupScore, isWarm } from "./core"
3
+ import { hashContent, lookupScore, lookupContentScore, isWarm } from "./core"
4
4
 
5
5
  /**
6
6
  * Cold-start heuristics — universal position/size/structure signals.
7
7
  *
8
- * These work across ANY agent framework, skill set, or config without
9
- * any content-specific patterns. Principles:
10
- *
11
- * - Position 0 is almost always status/handoff → dynamic
12
- * - Positions 1-7 with substantial content are config → stable
13
- * - Very large blocks (>3KB) are config/definitions → stable
14
- * - Very small blocks (<100B) are status/date → dynamic
15
- * - High date density signals log/diary content → dynamic
16
- * - Structural delimiters ({, [, <, ```) signal config → stable
17
- * - Second-person role assignment → agent prompt → stable
18
- * - Short-line documents (avg < 30 chars) → log/diary → dynamic
19
- * - Tail blocks (last 2) are dynamic UNLESS they look structural
8
+ * v0.5: Content-addressed classification. When content scores are
9
+ * available, they take priority over position-based scores, fixing the
10
+ * "position shift" problem where block count changes bust tracking.
20
11
  */
21
12
 
22
13
  export function coldStartScore(block: string, index: number, total: number): number {
23
14
  let score = 0.5
24
15
 
25
- // ── Position signals ──────────────────────────────────────────
26
-
27
- // Block 0 is status/handoff in virtually every agent framework
28
16
  if (index === 0) score = 0.15
29
-
30
- // Blocks at positions 1-7 with non-trivial content are stable config
31
17
  if (index >= 1 && index <= 7 && block.length > 200) score = 0.8
32
18
 
33
- // Last 2 blocks are usually dynamic, but structured blocks ({, [, <)
34
- // at the tail are probably split artifacts, not real injections.
35
19
  const isStructured = /^[<\{\[]/.test(block.trim())
36
20
  if (index >= total - 2 && !isStructured) score = Math.min(score, 0.25)
37
21
 
38
- // ── Size signals ──────────────────────────────────────────────
39
-
40
22
  if (block.length > 3000) score = Math.max(score, 0.85)
41
23
  if (block.length < 100) score = Math.min(score, 0.2)
42
24
 
43
- // ── Structure signals ─────────────────────────────────────────
44
-
45
- // High density of date stamps → log/diary → dynamic
46
25
  const dateCount = (block.match(/\d{4}-\d{2}-\d{2}/g) || []).length
47
26
  if (dateCount >= 3) score = Math.min(score, 0.25)
48
27
 
49
- // Starts with structural delimiter → JSON, XML, or code fence → config.
50
- // Skip the boost for tail blocks (they're likely <memory> injections).
51
28
  const trimmed = block.trim()
52
29
  if (/^[<\{\[]|^```/.test(trimmed) && index < total - 2) {
53
30
  score = Math.max(score, 0.8)
54
31
  }
55
32
 
56
- // Second-person role assignment → agent system prompt → stable
57
33
  if (/^(You are|Your (job|role|task)|As an? )/m.test(block)) {
58
34
  score = Math.max(score, 0.8)
59
35
  }
60
36
 
61
- // Many very short lines (avg < 30 chars) suggests log/diary → dynamic
62
37
  const lines = block.split("\n")
63
38
  const avgLineLen = block.length / Math.max(1, lines.length)
64
39
  if (lines.length > 15 && avgLineLen < 30) score = Math.min(score, 0.3)
@@ -71,15 +46,17 @@ export function coldStartScore(block: string, index: number, total: number): num
71
46
  /**
72
47
  * Classify blocks into stable / unknown / dynamic.
73
48
  *
74
- * In warm mode (hash-based), uses historical stability scores.
75
- * In cold mode (first few calls per agent), uses position/size heuristics.
49
+ * Scoring priority:
50
+ * 1. Cache warm hash score 0.85 (instant stable)
51
+ * 2. Content-addressed score → score from contentScores (position-independent)
52
+ * 3. Position-based score → score from scores (legacy fallback)
53
+ * 4. Cold-start heuristic → position/size signals
76
54
  */
77
55
  export function classify(
78
56
  blocks: string[],
79
57
  db: StabilityDB,
80
58
  opts?: { warmThreshold?: number; splitThreshold?: number; warmHashes?: Set<string> },
81
59
  ): Classified {
82
- // Split large blocks first
83
60
  const items = splitAll(blocks, opts?.splitThreshold)
84
61
 
85
62
  const result: Classified = { stable: [], unknown: [], dynamic: [] }
@@ -92,14 +69,24 @@ export function classify(
92
69
  if (item === undefined) continue
93
70
 
94
71
  const hash = hashContent(item)
95
- const known = lookupScore(db, hash)
96
- // Cache warming: if hash is in the warm set, treat as stable immediately
97
- const cached = warmSet?.has(hash) ?? false
98
72
 
73
+ // Priority 1: cache-warmed hash
74
+ if (warmSet?.has(hash)) {
75
+ result.stable.push(item)
76
+ continue
77
+ }
78
+
79
+ // Priority 2: content-addressed score (primary)
80
+ const contentScore = lookupContentScore(db, hash)
81
+ if (contentScore !== null && db.contentObservations >= 2) {
82
+ if (contentScore >= 0.7) { result.stable.push(item); continue }
83
+ if (contentScore <= 0.2) { result.dynamic.push(item); continue }
84
+ }
85
+
86
+ // Priority 3: position-based score (fallback)
87
+ const known = lookupScore(db, hash)
99
88
  let score: number
100
- if (cached) {
101
- score = 0.85 // warmed: treat as stable even on cold DB
102
- } else if (known !== null && warm) {
89
+ if (known !== null && warm) {
103
90
  score = known
104
91
  } else {
105
92
  score = coldStartScore(item, i, total)
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ 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, extractWarmHashes, estimateSavings } from "./core"
17
+ import { emptyDB, updateDB, updateContentDB, extractWarmHashes, estimateSavings } from "./core"
18
18
  import { classify } from "./heuristics"
19
19
  import type { StabilityDB } from "./types"
20
20
 
@@ -37,7 +37,31 @@ function warmCachePath(): string {
37
37
 
38
38
  function loadDB(agent: string): StabilityDB {
39
39
  try {
40
- return JSON.parse(readFileSync(dbPath(agent), "utf-8")) as StabilityDB
40
+ const raw = readFileSync(dbPath(agent), "utf-8")
41
+ const db = JSON.parse(raw) as StabilityDB
42
+ // Migrate from pre-0.5.0: rebuild contentIndex from position data
43
+ if (
44
+ (!db.contentIndex || Object.keys(db.contentIndex).length === 0) &&
45
+ db.positions &&
46
+ Object.keys(db.positions).length > 0
47
+ ) {
48
+ db.contentIndex = {}
49
+ for (const fps of Object.values(db.positions)) {
50
+ for (const fp of fps) {
51
+ const existing = db.contentIndex[fp.hash]
52
+ if (existing) {
53
+ existing.count = Math.max(existing.count, fp.count)
54
+ if (fp.lastSeen > existing.lastSeen) existing.lastSeen = fp.lastSeen
55
+ } else {
56
+ db.contentIndex[fp.hash] = { ...fp }
57
+ }
58
+ }
59
+ }
60
+ db.contentScores = {}
61
+ db.contentObservations = 0 // warm from scratch for accurate scores
62
+ saveDB(agent, db)
63
+ }
64
+ return db
41
65
  } catch {
42
66
  return emptyDB()
43
67
  }
@@ -150,13 +174,14 @@ export const CacheOptimizerPlugin: Plugin = async () => {
150
174
  // Reorder: stable → unknown → dynamic
151
175
  output.system = [...classified.stable, ...classified.unknown, ...classified.dynamic]
152
176
 
153
- // Persist
154
- const updated = updateDB(db, output.system)
155
- saveDB(agent, updated)
177
+ // Persist position-based + content-addressed
178
+ updateDB(db, output.system)
179
+ updateContentDB(db, output.system)
180
+ saveDB(agent, db)
156
181
 
157
182
  // Update warm cache every 10 observations
158
- if (updated.observations % 10 === 0) {
159
- saveWarmCache(updated)
183
+ if (db.observations % 10 === 0) {
184
+ saveWarmCache(db)
160
185
  }
161
186
 
162
187
  // Track savings
@@ -173,7 +198,7 @@ export const CacheOptimizerPlugin: Plugin = async () => {
173
198
  agent,
174
199
  `S:${classified.stable.length} U:${classified.unknown.length} ` +
175
200
  `D:${classified.dynamic.length} T:${output.system.length} ` +
176
- `obs:${updated.observations} ` +
201
+ `obs:${db.observations} ` +
177
202
  `stableKB:${(stableBytes / 1024).toFixed(1)} ` +
178
203
  `saved:$${estCallSaving.toFixed(6)} ` +
179
204
  `total:$${savings.estimatedSavingsUSD.toFixed(4)}`,
@@ -208,7 +233,7 @@ export const CacheOptimizerPlugin: Plugin = async () => {
208
233
  }
209
234
 
210
235
  // Re-exports
211
- export { emptyDB, updateDB, hashContent, lookupScore, isWarm, extractWarmHashes, isWarmHash, estimateSavings } from "./core"
236
+ export { emptyDB, updateDB, updateContentDB, hashContent, lookupScore, lookupContentScore, isWarm, extractWarmHashes, isWarmHash, estimateSavings } from "./core"
212
237
  export { coldStartScore, classify } from "./heuristics"
213
238
  export { splitBlock, splitAll } from "./splitting"
214
239
  export type { StabilityDB, Classified, BlockFingerprint, CacheOptimizerOptions } from "./types"
package/src/types.ts CHANGED
@@ -1,27 +1,38 @@
1
1
  /** A fingerprint record for one hash observed at one position */
2
2
  export interface BlockFingerprint {
3
3
  hash: string
4
- /** First time this exact hash was seen (epoch ms) */
5
4
  firstSeen: number
6
- /** Most recent time this hash was seen */
7
5
  lastSeen: number
8
- /** Total observations of this hash at this position */
9
6
  count: number
10
7
  }
11
8
 
12
- /** Stability databasepersisted per-agent to track block stability over time */
9
+ /** Content-addressed fingerprintposition-independent */
10
+ export interface ContentFingerprint {
11
+ hash: string
12
+ firstSeen: number
13
+ lastSeen: number
14
+ count: number
15
+ }
16
+
17
+ /** Stability database — persisted per-agent */
13
18
  export interface StabilityDB {
14
- /** Block position → fingerprints observed at that position */
19
+ /** Position-based fingerprints (legacy, fallback) */
15
20
  positions: Record<number, BlockFingerprint[]>
16
- /** Hash stability score (1.0 = never changes, 0.0 = changes every call) */
21
+ /** Position-based scores */
17
22
  scores: Record<string, number>
18
- /** Total calls observed */
23
+ /** Content-addressed fingerprints (primary) */
24
+ contentIndex: Record<string, ContentFingerprint>
25
+ /** Content-addressed scores */
26
+ contentScores: Record<string, number>
27
+ /** Number of calls that contributed to contentIndex */
28
+ contentObservations: number
29
+ /** Total observations */
19
30
  observations: number
20
31
  /** Last write timestamp */
21
32
  updated: number
22
33
  }
23
34
 
24
- /** Classification result after scoring all blocks */
35
+ /** Classification result */
25
36
  export interface Classified {
26
37
  stable: string[]
27
38
  unknown: string[]
@@ -30,10 +41,7 @@ export interface Classified {
30
41
 
31
42
  /** Options for the cache optimizer plugin */
32
43
  export interface CacheOptimizerOptions {
33
- /** Minimum block size in bytes to attempt splitting (default: 4000) */
34
44
  splitThreshold: number
35
- /** Path to store stability databases and logs */
36
45
  stateDir: string
37
- /** Minimum observations before switching from heuristics to hash-based scoring */
38
46
  warmThreshold: number
39
47
  }