agent-cache-optimizer 0.4.0 → 0.5.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/package.json +1 -1
- package/src/core.ts +68 -25
- package/src/heuristics.ts +25 -38
- package/src/index.ts +9 -8
- package/src/types.ts +17 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-cache-optimizer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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
|
|
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,14 @@ export function hashContent(content: string): string {
|
|
|
14
19
|
// ── DB operations ────────────────────────────────────────────────────
|
|
15
20
|
|
|
16
21
|
export function emptyDB(): StabilityDB {
|
|
17
|
-
return {
|
|
22
|
+
return {
|
|
23
|
+
positions: {},
|
|
24
|
+
scores: {},
|
|
25
|
+
contentIndex: {},
|
|
26
|
+
contentScores: {},
|
|
27
|
+
observations: 0,
|
|
28
|
+
updated: 0,
|
|
29
|
+
}
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
export function lookupScore(db: StabilityDB, hash: string): number | null {
|
|
@@ -22,7 +34,52 @@ export function lookupScore(db: StabilityDB, hash: string): number | null {
|
|
|
22
34
|
return val !== undefined ? val : null
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
// ──
|
|
37
|
+
// ── Content-addressed scoring (primary) ──────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Look up content-addressed stability score for a block hash.
|
|
41
|
+
* This is position-independent — the same block gets the same score
|
|
42
|
+
* regardless of where it appears in the system prompt.
|
|
43
|
+
*/
|
|
44
|
+
export function lookupContentScore(db: StabilityDB, hash: string): number | null {
|
|
45
|
+
const val = db.contentScores[hash]
|
|
46
|
+
return val !== undefined ? val : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Update content-addressed tracking.
|
|
51
|
+
*
|
|
52
|
+
* For each block, records its hash in the content index regardless of
|
|
53
|
+
* position. Then recomputes content scores:
|
|
54
|
+
*
|
|
55
|
+
* score = count / observations
|
|
56
|
+
*
|
|
57
|
+
* A block that appears in every call → score → 1.0 (stable)
|
|
58
|
+
* A block that appears once → score → 1/observations (dynamic)
|
|
59
|
+
*/
|
|
60
|
+
export function updateContentDB(db: StabilityDB, blocks: string[]): StabilityDB {
|
|
61
|
+
const now = Date.now()
|
|
62
|
+
|
|
63
|
+
for (const block of blocks) {
|
|
64
|
+
const h = hashContent(block)
|
|
65
|
+
const existing = db.contentIndex[h]
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.lastSeen = now
|
|
68
|
+
existing.count++
|
|
69
|
+
} else {
|
|
70
|
+
db.contentIndex[h] = { hash: h, firstSeen: now, lastSeen: now, count: 1 }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Recompute content scores
|
|
75
|
+
for (const fp of Object.values(db.contentIndex)) {
|
|
76
|
+
db.contentScores[fp.hash] = Math.min(1.0, fp.count / Math.max(1, db.observations))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return db
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Position-based scoring (legacy fallback) ─────────────────────────
|
|
26
83
|
|
|
27
84
|
export function updateDB(db: StabilityDB, blocks: string[]): StabilityDB {
|
|
28
85
|
const now = Date.now()
|
|
@@ -68,27 +125,19 @@ export function isWarm(db: StabilityDB, threshold = 2): boolean {
|
|
|
68
125
|
|
|
69
126
|
// ── Cache warming ────────────────────────────────────────────────────
|
|
70
127
|
|
|
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
128
|
export function extractWarmHashes(db: StabilityDB): Set<string> {
|
|
77
129
|
const warm = new Set<string>()
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
130
|
+
// Primary: content-addressed stable hashes
|
|
131
|
+
for (const [hash, score] of Object.entries(db.contentScores)) {
|
|
132
|
+
if (score >= 0.8) warm.add(hash)
|
|
133
|
+
}
|
|
134
|
+
// Fallback: position-based stable hashes
|
|
135
|
+
for (const [hash, score] of Object.entries(db.scores)) {
|
|
136
|
+
if (score >= 0.8) warm.add(hash)
|
|
85
137
|
}
|
|
86
138
|
return warm
|
|
87
139
|
}
|
|
88
140
|
|
|
89
|
-
/**
|
|
90
|
-
* Check if a block hash is known-stable from cache warming data.
|
|
91
|
-
*/
|
|
92
141
|
export function isWarmHash(warmHashes: Set<string> | null, hash: string): boolean {
|
|
93
142
|
return warmHashes !== null && warmHashes.has(hash)
|
|
94
143
|
}
|
|
@@ -96,13 +145,7 @@ export function isWarmHash(warmHashes: Set<string> | null, hash: string): boolea
|
|
|
96
145
|
// ── Cost estimation ──────────────────────────────────────────────────
|
|
97
146
|
|
|
98
147
|
/**
|
|
99
|
-
* Estimate cache cost savings
|
|
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
|
-
*
|
|
148
|
+
* Estimate cache cost savings. DeepSeek v4-pro: $0.435/M miss → $0.003625/M hit.
|
|
106
149
|
* Rough estimate: 1 token ≈ 4 chars for English text.
|
|
107
150
|
*/
|
|
108
151
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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.observations >= 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 (
|
|
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
|
|
|
@@ -150,13 +150,14 @@ export const CacheOptimizerPlugin: Plugin = async () => {
|
|
|
150
150
|
// Reorder: stable → unknown → dynamic
|
|
151
151
|
output.system = [...classified.stable, ...classified.unknown, ...classified.dynamic]
|
|
152
152
|
|
|
153
|
-
// Persist
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
// Persist position-based + content-addressed
|
|
154
|
+
updateDB(db, output.system)
|
|
155
|
+
updateContentDB(db, output.system)
|
|
156
|
+
saveDB(agent, db)
|
|
156
157
|
|
|
157
158
|
// Update warm cache every 10 observations
|
|
158
|
-
if (
|
|
159
|
-
saveWarmCache(
|
|
159
|
+
if (db.observations % 10 === 0) {
|
|
160
|
+
saveWarmCache(db)
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
// Track savings
|
|
@@ -173,7 +174,7 @@ export const CacheOptimizerPlugin: Plugin = async () => {
|
|
|
173
174
|
agent,
|
|
174
175
|
`S:${classified.stable.length} U:${classified.unknown.length} ` +
|
|
175
176
|
`D:${classified.dynamic.length} T:${output.system.length} ` +
|
|
176
|
-
`obs:${
|
|
177
|
+
`obs:${db.observations} ` +
|
|
177
178
|
`stableKB:${(stableBytes / 1024).toFixed(1)} ` +
|
|
178
179
|
`saved:$${estCallSaving.toFixed(6)} ` +
|
|
179
180
|
`total:$${savings.estimatedSavingsUSD.toFixed(4)}`,
|
|
@@ -208,7 +209,7 @@ export const CacheOptimizerPlugin: Plugin = async () => {
|
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
// Re-exports
|
|
211
|
-
export { emptyDB, updateDB, hashContent, lookupScore, isWarm, extractWarmHashes, isWarmHash, estimateSavings } from "./core"
|
|
212
|
+
export { emptyDB, updateDB, updateContentDB, hashContent, lookupScore, lookupContentScore, isWarm, extractWarmHashes, isWarmHash, estimateSavings } from "./core"
|
|
212
213
|
export { coldStartScore, classify } from "./heuristics"
|
|
213
214
|
export { splitBlock, splitAll } from "./splitting"
|
|
214
215
|
export type { StabilityDB, Classified, BlockFingerprint, CacheOptimizerOptions } from "./types"
|
package/src/types.ts
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
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
|
-
/**
|
|
9
|
+
/** Content-addressed fingerprint — position-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
|
-
/**
|
|
19
|
+
/** Position-based fingerprints (legacy, fallback) */
|
|
15
20
|
positions: Record<number, BlockFingerprint[]>
|
|
16
|
-
/**
|
|
21
|
+
/** Position-based scores */
|
|
17
22
|
scores: Record<string, number>
|
|
18
|
-
/**
|
|
23
|
+
/** Content-addressed fingerprints (primary) */
|
|
24
|
+
contentIndex: Record<string, ContentFingerprint>
|
|
25
|
+
/** Content-addressed scores */
|
|
26
|
+
contentScores: Record<string, number>
|
|
27
|
+
/** Total observations */
|
|
19
28
|
observations: number
|
|
20
29
|
/** Last write timestamp */
|
|
21
30
|
updated: number
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
/** Classification result
|
|
33
|
+
/** Classification result */
|
|
25
34
|
export interface Classified {
|
|
26
35
|
stable: string[]
|
|
27
36
|
unknown: string[]
|
|
@@ -30,10 +39,7 @@ export interface Classified {
|
|
|
30
39
|
|
|
31
40
|
/** Options for the cache optimizer plugin */
|
|
32
41
|
export interface CacheOptimizerOptions {
|
|
33
|
-
/** Minimum block size in bytes to attempt splitting (default: 4000) */
|
|
34
42
|
splitThreshold: number
|
|
35
|
-
/** Path to store stability databases and logs */
|
|
36
43
|
stateDir: string
|
|
37
|
-
/** Minimum observations before switching from heuristics to hash-based scoring */
|
|
38
44
|
warmThreshold: number
|
|
39
45
|
}
|