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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/README.zh-CN.md +200 -0
- package/adapters/claude-code.md +119 -0
- package/docs/cross-cli.md +89 -0
- package/docs/upstream.md +65 -0
- package/package.json +70 -0
- package/scripts/cache-status.sh +170 -0
- package/scripts/check-cache-friendly.sh +122 -0
- package/skills/cache-status/SKILL.md +81 -0
- package/src/__tests__/core.test.ts +97 -0
- package/src/core.ts +98 -0
- package/src/heuristics.ts +109 -0
- package/src/index.ts +127 -0
- package/src/splitting.ts +66 -0
- package/src/types.ts +39 -0
- package/tsconfig.json +25 -0
|
@@ -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"
|
package/src/splitting.ts
ADDED
|
@@ -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
|
+
}
|