agent-cache-optimizer 0.1.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.
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { hashContent, emptyDB, updateDB, lookupScore, isWarm } from "../core"
3
+
4
+ describe("hashContent", () => {
5
+ it("produces consistent hashes", () => {
6
+ const a = hashContent("hello")
7
+ const b = hashContent("hello")
8
+ expect(a).toBe(b)
9
+ expect(a.length).toBe(16)
10
+ })
11
+
12
+ it("produces different hashes for different content", () => {
13
+ expect(hashContent("hello")).not.toBe(hashContent("world"))
14
+ })
15
+ })
16
+
17
+ describe("emptyDB", () => {
18
+ it("returns a fresh database", () => {
19
+ const db = emptyDB()
20
+ expect(db.observations).toBe(0)
21
+ expect(db.updated).toBe(0)
22
+ expect(Object.keys(db.positions)).toHaveLength(0)
23
+ expect(Object.keys(db.scores)).toHaveLength(0)
24
+ })
25
+ })
26
+
27
+ describe("updateDB", () => {
28
+ it("tracks fingerprints at positions", () => {
29
+ let db = emptyDB()
30
+ db = updateDB(db, ["block-a", "block-b", "block-c"])
31
+
32
+ expect(db.observations).toBe(1)
33
+ expect(db.positions[0]).toHaveLength(1)
34
+ expect(db.positions[1]).toHaveLength(1)
35
+ expect(db.positions[2]).toHaveLength(1)
36
+ })
37
+
38
+ it("counts repeated hashes at the same position", () => {
39
+ let db = emptyDB()
40
+
41
+ // Session 1
42
+ db = updateDB(db, ["HANDOFF-v1", "CLAUDE-stable", "MEMORY-v1"])
43
+
44
+ // Session 2: same CLAUDE, different HANDOFF and MEMORY
45
+ db = updateDB(db, ["HANDOFF-v2", "CLAUDE-stable", "MEMORY-v2"])
46
+
47
+ // Position 0 has 2 distinct hashes (HANDOFF changed)
48
+ expect(db.positions[0]).toHaveLength(2)
49
+ // Position 1 has 1 hash, count=2 (CLAUDE stable)
50
+ expect(db.positions[1]).toHaveLength(1)
51
+ expect(db.positions[1]?.[0]?.count).toBe(2)
52
+ // Position 2 has 2 distinct hashes (MEMORY changed)
53
+ expect(db.positions[2]).toHaveLength(2)
54
+ })
55
+
56
+ it("assigns high scores to stable blocks", () => {
57
+ let db = emptyDB()
58
+
59
+ // 4 sessions with stable CLAUDE, changing HANDOFF
60
+ for (const v of ["v1", "v2", "v3", "v4"]) {
61
+ db = updateDB(db, [`HANDOFF-${v}`, "CLAUDE-stable"])
62
+ }
63
+
64
+ const claudeHash = hashContent("CLAUDE-stable")
65
+ const claudeScore = lookupScore(db, claudeHash)
66
+ expect(claudeScore).toBeGreaterThan(0.7)
67
+
68
+ const handoffHash = hashContent("HANDOFF-v4")
69
+ const handoffScore = lookupScore(db, handoffHash)
70
+ expect(handoffScore).toBeLessThan(0.5)
71
+ })
72
+
73
+ it("clamps scores to [0, 1]", () => {
74
+ let db = emptyDB()
75
+ for (let i = 0; i < 10; i++) {
76
+ db = updateDB(db, ["stable-block"])
77
+ }
78
+ const hash = hashContent("stable-block")
79
+ const score = lookupScore(db, hash)
80
+ expect(score).not.toBeNull()
81
+ expect(score!).toBeGreaterThanOrEqual(0)
82
+ expect(score!).toBeLessThanOrEqual(1)
83
+ })
84
+ })
85
+
86
+ describe("isWarm", () => {
87
+ it("returns false below threshold", () => {
88
+ const db = emptyDB()
89
+ expect(isWarm(db, 2)).toBe(false)
90
+ })
91
+
92
+ it("returns true at or above threshold", () => {
93
+ let db = emptyDB()
94
+ db.observations = 3
95
+ expect(isWarm(db, 2)).toBe(true)
96
+ })
97
+ })
package/src/core.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { createHash } from "node:crypto"
2
+ import type { StabilityDB } from "./types"
3
+
4
+ /**
5
+ * Core hash-tracking engine — fully CLI-agnostic.
6
+ *
7
+ * Input: string[] of system prompt blocks
8
+ * Output: updated StabilityDB with per-position fingerprints and scores
9
+ *
10
+ * This module has ZERO external dependencies and can be used by any
11
+ * CLI agent adapter (OpenCode, Claude Code, Codex, etc.).
12
+ */
13
+
14
+ // ── Hashing ──────────────────────────────────────────────────────────
15
+
16
+ /** SHA-256 truncated to 16 hex chars — collision-safe for ~10⁵ blocks */
17
+ export function hashContent(content: string): string {
18
+ return createHash("sha256").update(content).digest("hex").slice(0, 16)
19
+ }
20
+
21
+ // ── DB persistence ───────────────────────────────────────────────────
22
+
23
+ export function emptyDB(): StabilityDB {
24
+ return { positions: {}, scores: {}, observations: 0, updated: 0 }
25
+ }
26
+
27
+ // ── Stability scoring ────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Look up the current stability score for a block hash.
31
+ * Returns null if this hash has never been seen.
32
+ */
33
+ export function lookupScore(db: StabilityDB, hash: string): number | null {
34
+ const val = db.scores[hash]
35
+ return val !== undefined ? val : null
36
+ }
37
+
38
+ /**
39
+ * Update the stability database with a new observation.
40
+ *
41
+ * For each block position, records the hash fingerprint. Then recomputes
42
+ * stability scores for all known hashes:
43
+ *
44
+ * score = positionalFidelity × recency × varietyPenalty
45
+ *
46
+ * - positionalFidelity: how often this hash appears at this position
47
+ * - recency: 1.0 if seen in the last 24h, 0.7 otherwise
48
+ * - varietyPenalty: penalizes positions where many different hashes appear
49
+ *
50
+ * All scores are clamped to [0, 1].
51
+ */
52
+ export function updateDB(db: StabilityDB, blocks: string[]): StabilityDB {
53
+ const now = Date.now()
54
+ const hashes = blocks.map(hashContent)
55
+
56
+ // Record fingerprints at each position
57
+ for (let i = 0; i < hashes.length; i++) {
58
+ const h = hashes[i]
59
+ if (h === undefined) continue
60
+ if (!db.positions[i]) db.positions[i] = []
61
+ const fps = db.positions[i]
62
+ if (!fps) continue
63
+ const existing = fps.find((f) => f.hash === h)
64
+ if (existing) {
65
+ existing.lastSeen = now
66
+ existing.count++
67
+ } else {
68
+ fps.push({ hash: h, firstSeen: now, lastSeen: now, count: 1 })
69
+ }
70
+ }
71
+
72
+ // Recompute stability scores
73
+ for (const [posStr, fps] of Object.entries(db.positions)) {
74
+ const pos = Number(posStr)
75
+ for (const fp of fps) {
76
+ const fidelity = fp.count / Math.max(1, db.observations)
77
+ const recency = now - fp.lastSeen < 24 * 60 * 60 * 1000 ? 1.0 : 0.7
78
+ const varietyCount = db.positions[pos]?.length || 1
79
+ const varietyPenalty = 1 / Math.max(1, varietyCount)
80
+
81
+ db.scores[fp.hash] = Math.min(
82
+ 1.0,
83
+ Math.max(0.0, fidelity * recency * (0.5 + 0.5 * varietyPenalty)),
84
+ )
85
+ }
86
+ }
87
+
88
+ db.observations++
89
+ return db
90
+ }
91
+
92
+ /**
93
+ * Check whether the database has enough observations for hash-based
94
+ * (warm) decisions. Below this threshold, cold-start heuristics are used.
95
+ */
96
+ export function isWarm(db: StabilityDB, threshold = 2): boolean {
97
+ return db.observations >= threshold
98
+ }
@@ -0,0 +1,109 @@
1
+ import type { StabilityDB, Classified } from "./types"
2
+ import { splitAll } from "./splitting"
3
+ import { hashContent, lookupScore, isWarm } from "./core"
4
+
5
+ /**
6
+ * Cold-start heuristics — universal position/size/structure signals.
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
20
+ */
21
+
22
+ export function coldStartScore(block: string, index: number, total: number): number {
23
+ let score = 0.5
24
+
25
+ // ── Position signals ──────────────────────────────────────────
26
+
27
+ // Block 0 is status/handoff in virtually every agent framework
28
+ if (index === 0) score = 0.15
29
+
30
+ // Blocks at positions 1-7 with non-trivial content are stable config
31
+ if (index >= 1 && index <= 7 && block.length > 200) score = 0.8
32
+
33
+ // Last 2 blocks are usually dynamic, but structured blocks ({, [, <)
34
+ // at the tail are probably split artifacts, not real injections.
35
+ const isStructured = /^[<\{\[]/.test(block.trim())
36
+ if (index >= total - 2 && !isStructured) score = Math.min(score, 0.25)
37
+
38
+ // ── Size signals ──────────────────────────────────────────────
39
+
40
+ if (block.length > 3000) score = Math.max(score, 0.85)
41
+ if (block.length < 100) score = Math.min(score, 0.2)
42
+
43
+ // ── Structure signals ─────────────────────────────────────────
44
+
45
+ // High density of date stamps → log/diary → dynamic
46
+ const dateCount = (block.match(/\d{4}-\d{2}-\d{2}/g) || []).length
47
+ if (dateCount >= 3) score = Math.min(score, 0.25)
48
+
49
+ // Starts with structural delimiter → JSON, XML, or code fence → config.
50
+ // Skip the boost for tail blocks (they're likely <memory> injections).
51
+ const trimmed = block.trim()
52
+ if (/^[<\{\[]|^```/.test(trimmed) && index < total - 2) {
53
+ score = Math.max(score, 0.8)
54
+ }
55
+
56
+ // Second-person role assignment → agent system prompt → stable
57
+ if (/^(You are|Your (job|role|task)|As an? )/m.test(block)) {
58
+ score = Math.max(score, 0.8)
59
+ }
60
+
61
+ // Many very short lines (avg < 30 chars) suggests log/diary → dynamic
62
+ const lines = block.split("\n")
63
+ const avgLineLen = block.length / Math.max(1, lines.length)
64
+ if (lines.length > 15 && avgLineLen < 30) score = Math.min(score, 0.3)
65
+
66
+ return score
67
+ }
68
+
69
+ // ── Classification ───────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Classify blocks into stable / unknown / dynamic.
73
+ *
74
+ * In warm mode (hash-based), uses historical stability scores.
75
+ * In cold mode (first few calls per agent), uses position/size heuristics.
76
+ */
77
+ export function classify(
78
+ blocks: string[],
79
+ db: StabilityDB,
80
+ opts?: { warmThreshold?: number; splitThreshold?: number },
81
+ ): Classified {
82
+ // Split large blocks first
83
+ const items = splitAll(blocks, opts?.splitThreshold)
84
+
85
+ const result: Classified = { stable: [], unknown: [], dynamic: [] }
86
+ const warm = isWarm(db, opts?.warmThreshold ?? 2)
87
+ const total = items.length
88
+
89
+ for (let i = 0; i < items.length; i++) {
90
+ const item = items[i]
91
+ if (item === undefined) continue
92
+
93
+ const hash = hashContent(item)
94
+ const known = lookupScore(db, hash)
95
+
96
+ let score: number
97
+ if (known !== null && warm) {
98
+ score = known
99
+ } else {
100
+ score = coldStartScore(item, i, total)
101
+ }
102
+
103
+ if (score >= 0.7) result.stable.push(item)
104
+ else if (score <= 0.3) result.dynamic.push(item)
105
+ else result.unknown.push(item)
106
+ }
107
+
108
+ return result
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * agent-cache-optimizer — OpenCode Plugin Entry Point
3
+ *
4
+ * Content-agnostic KV cache optimizer. Reorders system prompt blocks so
5
+ * that stable content (config, agent definitions, tool schemas) comes
6
+ * FIRST and dynamic content (session handoff, memory injections, dates)
7
+ * comes LAST. This maximizes prefix-match cache reuse across sessions.
8
+ *
9
+ * Installation:
10
+ * 1. Add to opencode.json plugins: "agent-cache-optimizer"
11
+ * 2. Or use file:// path for local development
12
+ * 3. Restart OpenCode
13
+ *
14
+ * @license MIT
15
+ */
16
+
17
+ import type { Plugin } from "@opencode-ai/plugin"
18
+ import { join } from "node:path"
19
+ import { homedir } from "node:os"
20
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
21
+ import { emptyDB, updateDB } from "./core"
22
+ import { classify } from "./heuristics"
23
+ import type { StabilityDB } from "./types"
24
+
25
+ // ── Persistence ──────────────────────────────────────────────────────
26
+
27
+ const STATE_DIR = join(
28
+ process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
29
+ "opencode",
30
+ "agent-cache-optimizer",
31
+ )
32
+
33
+ function dbPath(agent: string): string {
34
+ const safe = agent.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "default"
35
+ return join(STATE_DIR, `stability-${safe}.json`)
36
+ }
37
+
38
+ function loadDB(agent: string): StabilityDB {
39
+ try {
40
+ return JSON.parse(readFileSync(dbPath(agent), "utf-8")) as StabilityDB
41
+ } catch {
42
+ return emptyDB()
43
+ }
44
+ }
45
+
46
+ function saveDB(agent: string, db: StabilityDB): void {
47
+ try {
48
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
49
+ db.updated = Date.now()
50
+ writeFileSync(dbPath(agent), JSON.stringify(db, null, 2))
51
+ } catch {
52
+ /* best-effort */
53
+ }
54
+ }
55
+
56
+ // ── Diagnostics ──────────────────────────────────────────────────────
57
+
58
+ let firstCallLogged = false
59
+
60
+ function diag(agent: string, msg: string): void {
61
+ try {
62
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
63
+ const ts = new Date().toISOString()
64
+ writeFileSync(join(STATE_DIR, "diag.log"), `[${ts}] [${agent}] ${msg}\n`, { flag: "a" })
65
+ } catch {
66
+ /* silent */
67
+ }
68
+ }
69
+
70
+ // ── Plugin ───────────────────────────────────────────────────────────
71
+
72
+ export const CacheOptimizerPlugin: Plugin = async () => {
73
+ return {
74
+ // ── Primary hook: system prompt reordering ─────────────────────
75
+
76
+ "experimental.chat.system.transform": async (input, output) => {
77
+ const rawBlocks = output.system
78
+ if (!rawBlocks || rawBlocks.length <= 1) return
79
+
80
+ const agent = input.model?.id ?? "default"
81
+ const db = loadDB(agent)
82
+ const classified = classify(rawBlocks, db)
83
+
84
+ // Reorder: stable → unknown → dynamic
85
+ output.system = [...classified.stable, ...classified.unknown, ...classified.dynamic]
86
+
87
+ // Persist for next call
88
+ const updated = updateDB(db, output.system)
89
+ saveDB(agent, updated)
90
+
91
+ diag(
92
+ agent,
93
+ `S:${classified.stable.length} U:${classified.unknown.length} ` +
94
+ `D:${classified.dynamic.length} T:${output.system.length} ` +
95
+ `obs:${updated.observations}`,
96
+ )
97
+ },
98
+
99
+ // ── Diagnostic: chat.params (confirms plugin loaded) ──────────
100
+
101
+ "chat.params": async (input, _output) => {
102
+ if (!firstCallLogged) {
103
+ firstCallLogged = true
104
+ diag(
105
+ input.agent ?? "unknown",
106
+ `plugin-loaded agent=${input.agent ?? "?"} model=${input.model?.id ?? "?"}`,
107
+ )
108
+ }
109
+ },
110
+
111
+ // ── Provider cache headers ────────────────────────────────────
112
+
113
+ "chat.headers": async (input, output) => {
114
+ if (input.provider?.info?.name?.toLowerCase().includes("anthropic")) {
115
+ if (!output.headers["anthropic-beta"]) {
116
+ output.headers["anthropic-beta"] = "prompt-caching-2024-07-31"
117
+ }
118
+ }
119
+ },
120
+ }
121
+ }
122
+
123
+ // Re-export core for standalone usage
124
+ export { emptyDB, updateDB, hashContent, lookupScore, isWarm } from "./core"
125
+ export { coldStartScore, classify } from "./heuristics"
126
+ export { splitBlock, splitAll } from "./splitting"
127
+ export type { StabilityDB, Classified, BlockFingerprint, CacheOptimizerOptions } from "./types"
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Block splitting — split large prompt blocks at natural boundaries.
3
+ *
4
+ * Large blocks (>4KB) like tool definition arrays or long agent prompts
5
+ * can contain multiple independent items. Splitting them allows individual
6
+ * sub-blocks to be classified independently:
7
+ *
8
+ * - JSON arrays of tool definitions → individual tool objects
9
+ * - Markdown files with ## sections → individual sections
10
+ * - XML/HTML blocks → individual elements
11
+ * - Otherwise → paragraph boundaries (double newline)
12
+ *
13
+ * This is fully content-agnostic: it only looks at structural delimiters,
14
+ * never at specific keywords or names.
15
+ */
16
+
17
+ const DEFAULT_SPLIT_THRESHOLD = 4000
18
+
19
+ /**
20
+ * Split a block into sub-blocks at natural structural boundaries.
21
+ * Returns [block] unchanged if no split is needed or possible.
22
+ */
23
+ export function splitBlock(block: string, threshold = DEFAULT_SPLIT_THRESHOLD): string[] {
24
+ if (block.length <= threshold) return [block]
25
+
26
+ const trimmed = block.trim()
27
+
28
+ // ── JSON object array: {"name": "A", ...}, {"name": "B", ...} ──
29
+ if (trimmed.startsWith("{")) {
30
+ const objects = block.match(/\{[^}{]*"name"\s*:\s*"[^"]+"[^}]*\}/g)
31
+ if (objects && objects.length >= 2) return objects
32
+ }
33
+
34
+ // ── Markdown: split at ## section headers ──────────────────────
35
+ if (block.includes("\n## ")) {
36
+ const sections = block.split(/\n(?=## )/)
37
+ if (sections.length >= 2) return sections
38
+ }
39
+
40
+ // ── XML/HTML: split at top-level closing tags ──────────────────
41
+ if (/^<(\w+)[^>]*>/.test(trimmed)) {
42
+ const tagMatch = trimmed.match(/^<(\w+)[^>]*>/)
43
+ if (tagMatch) {
44
+ const tag = tagMatch[1]
45
+ const parts = block.split(new RegExp(`(?=</?${tag}[>\\s])`))
46
+ if (parts.length >= 2) return parts
47
+ }
48
+ }
49
+
50
+ // ── Fallback: paragraph boundaries ─────────────────────────────
51
+ const paragraphs = block.split(/\n\n+/)
52
+ if (paragraphs.length >= 3) return paragraphs
53
+
54
+ return [block]
55
+ }
56
+
57
+ /**
58
+ * Apply splitting to an array of blocks, returning a flat array.
59
+ */
60
+ export function splitAll(blocks: string[], threshold?: number): string[] {
61
+ const result: string[] = []
62
+ for (const b of blocks) {
63
+ result.push(...splitBlock(b, threshold))
64
+ }
65
+ return result
66
+ }
package/src/types.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** A fingerprint record for one hash observed at one position */
2
+ export interface BlockFingerprint {
3
+ hash: string
4
+ /** First time this exact hash was seen (epoch ms) */
5
+ firstSeen: number
6
+ /** Most recent time this hash was seen */
7
+ lastSeen: number
8
+ /** Total observations of this hash at this position */
9
+ count: number
10
+ }
11
+
12
+ /** Stability database — persisted per-agent to track block stability over time */
13
+ export interface StabilityDB {
14
+ /** Block position → fingerprints observed at that position */
15
+ positions: Record<number, BlockFingerprint[]>
16
+ /** Hash → stability score (1.0 = never changes, 0.0 = changes every call) */
17
+ scores: Record<string, number>
18
+ /** Total calls observed */
19
+ observations: number
20
+ /** Last write timestamp */
21
+ updated: number
22
+ }
23
+
24
+ /** Classification result after scoring all blocks */
25
+ export interface Classified {
26
+ stable: string[]
27
+ unknown: string[]
28
+ dynamic: string[]
29
+ }
30
+
31
+ /** Options for the cache optimizer plugin */
32
+ export interface CacheOptimizerOptions {
33
+ /** Minimum block size in bytes to attempt splitting (default: 4000) */
34
+ splitThreshold: number
35
+ /** Path to store stability databases and logs */
36
+ stateDir: string
37
+ /** Minimum observations before switching from heuristics to hash-based scoring */
38
+ warmThreshold: number
39
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noImplicitReturns": true,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "noUnusedLocals": false,
20
+ "noUnusedParameters": false,
21
+ "types": ["node"]
22
+ },
23
+ "include": ["src/**/*.ts"],
24
+ "exclude": ["node_modules", "dist"]
25
+ }