agent-cache-optimizer 0.5.3 → 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/CHANGELOG.md +85 -22
- package/README.md +55 -33
- package/bin/aco +61 -6
- package/docs/superpowers/plans/2026-06-25-cross-agent-cache-sharing.md +549 -0
- package/docs/superpowers/specs/2026-06-25-cross-agent-cache-hit-design.md +102 -0
- package/package.json +1 -1
- package/src/__tests__/heuristics-splitting.test.ts +287 -0
- package/src/__tests__/plugin.test.ts +620 -0
- package/src/heuristics.ts +43 -6
- package/src/index.ts +724 -48
- package/src/splitting.ts +155 -15
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
# Cross-Agent Cache Sharing Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make shared stable system blocks appear before agent-specific stable blocks after OpenCode switches agents on the same provider/model.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Keep the existing `provider__model__agent` DB for per-agent behavior, and add a `provider__model` family DB used only for cross-agent ranking. Classification remains content-agnostic; the new ranking phase partitions already-stable blocks into shared, scoped, and cold groups before appending dynamic blocks.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, OpenCode plugin hooks, Node fs/path APIs, Vitest.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
- Modify `src/index.ts`: add family scope helpers, stable-block ranking, transform wiring, and diagnostics.
|
|
16
|
+
- Modify `src/__tests__/plugin.test.ts`: add cross-agent ordering and family DB persistence tests.
|
|
17
|
+
- Run existing tests through `npm test` and type checks through `npm run typecheck`.
|
|
18
|
+
|
|
19
|
+
The family DB should be persisted as another `stability-*.json` file using the existing DB helpers. Do not change prompt text. Do not migrate the warm-cache file format. Family warm membership should be derived from the family DB with `extractWarmHashes(familyDB)` so the existing global warm promotion does not count a family DB as a second agent scope. Family warm hashes are used for ranking only; they must not bypass volatile metadata classification.
|
|
20
|
+
|
|
21
|
+
### Task 1: Add Failing Cross-Agent Ordering Test
|
|
22
|
+
|
|
23
|
+
**Files:**
|
|
24
|
+
- Modify: `src/__tests__/plugin.test.ts`
|
|
25
|
+
|
|
26
|
+
- [ ] **Step 1: Add a test showing shared blocks should outrank agent-specific blocks**
|
|
27
|
+
|
|
28
|
+
Append this test inside the existing `describe("CacheOptimizerPlugin provider/model scope", () => { ... })` block, after the warm-cache promotion test:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
it("orders family-stable shared blocks before agent-specific stable blocks after agent switches", async () => {
|
|
32
|
+
await withPlugin(async (hooks, cacheRoot) => {
|
|
33
|
+
const sharedTools = "Shared tool and project instructions stay identical across agents. ".repeat(30)
|
|
34
|
+
const buildPrompt = "You are the build agent with build-only instructions. ".repeat(30)
|
|
35
|
+
const reviewPrompt = "You are the review agent with review-only instructions. ".repeat(30)
|
|
36
|
+
|
|
37
|
+
await hooks["chat.params"](
|
|
38
|
+
{ sessionID: "s-build", agent: "build", model: model("deepseek") },
|
|
39
|
+
{},
|
|
40
|
+
)
|
|
41
|
+
for (let i = 0; i < 3; i++) {
|
|
42
|
+
await hooks["experimental.chat.system.transform"](
|
|
43
|
+
{ sessionID: "s-build", model: model("deepseek") },
|
|
44
|
+
{ system: [`currentDate: 2026-06-25\nsession id: build-${i}`, buildPrompt, sharedTools] },
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await hooks["chat.params"](
|
|
49
|
+
{ sessionID: "s-review", agent: "review", model: model("deepseek") },
|
|
50
|
+
{},
|
|
51
|
+
)
|
|
52
|
+
for (let i = 0; i < 3; i++) {
|
|
53
|
+
await hooks["experimental.chat.system.transform"](
|
|
54
|
+
{ sessionID: "s-review", model: model("deepseek") },
|
|
55
|
+
{
|
|
56
|
+
system: [
|
|
57
|
+
`currentDate: 2026-06-25\nsession id: review-${i}`,
|
|
58
|
+
reviewPrompt,
|
|
59
|
+
sharedTools,
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const output = {
|
|
66
|
+
system: ["currentDate: 2026-06-25\nsession id: review-final", reviewPrompt, sharedTools],
|
|
67
|
+
}
|
|
68
|
+
await hooks["experimental.chat.system.transform"](
|
|
69
|
+
{ sessionID: "s-review", model: model("deepseek") },
|
|
70
|
+
output,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
expect(output.system[0]).toBe(sharedTools)
|
|
74
|
+
expect(output.system[1]).toBe(reviewPrompt)
|
|
75
|
+
expect(output.system[2]).toContain("session id: review-final")
|
|
76
|
+
|
|
77
|
+
const raw = readFileSync(join(stateDir(cacheRoot), "events.jsonl"), "utf-8")
|
|
78
|
+
const events = raw
|
|
79
|
+
.trim()
|
|
80
|
+
.split("\n")
|
|
81
|
+
.map((line) => JSON.parse(line))
|
|
82
|
+
const transform = events.filter((event) => event.type === "transform").at(-1)
|
|
83
|
+
|
|
84
|
+
expect(transform.ranking).toMatchObject({
|
|
85
|
+
sharedStable: 1,
|
|
86
|
+
scopedStable: 1,
|
|
87
|
+
coldStable: 0,
|
|
88
|
+
})
|
|
89
|
+
expect(transform.ranking.sharedPrefixBytes).toBe(sharedTools.length)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- [ ] **Step 2: Run the new test and verify it fails**
|
|
95
|
+
|
|
96
|
+
Run:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npm test -- src/__tests__/plugin.test.ts -t "orders family-stable shared blocks"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Expected: FAIL because current output keeps `reviewPrompt` before `sharedTools`, and the `ranking` diagnostic object does not exist.
|
|
103
|
+
|
|
104
|
+
### Task 2: Add Failing Family DB Persistence Test
|
|
105
|
+
|
|
106
|
+
**Files:**
|
|
107
|
+
- Modify: `src/__tests__/plugin.test.ts`
|
|
108
|
+
|
|
109
|
+
- [ ] **Step 1: Add a test for writing both agent and family stability DBs**
|
|
110
|
+
|
|
111
|
+
Append this test near the existing scope tests:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
it("writes a provider-model family DB alongside agent-scoped DBs", async () => {
|
|
115
|
+
await withPlugin(async (hooks, cacheRoot) => {
|
|
116
|
+
const stable = "Stable shared prompt content for the model family. ".repeat(20)
|
|
117
|
+
const dynamic = "currentDate: 2026-06-25\nsession id: family-db"
|
|
118
|
+
|
|
119
|
+
await hooks["chat.params"](
|
|
120
|
+
{ sessionID: "s-build-family", agent: "build", model: model("deepseek") },
|
|
121
|
+
{},
|
|
122
|
+
)
|
|
123
|
+
await hooks["experimental.chat.system.transform"](
|
|
124
|
+
{ sessionID: "s-build-family", model: model("deepseek") },
|
|
125
|
+
{ system: [dynamic, stable] },
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const files = readdirSync(stateDir(cacheRoot))
|
|
129
|
+
.filter((file) => file.startsWith("stability-"))
|
|
130
|
+
.sort()
|
|
131
|
+
|
|
132
|
+
expect(files).toContain("stability-deepseek__deepseek-chat.json")
|
|
133
|
+
expect(files).toContain("stability-deepseek__deepseek-chat__build.json")
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- [ ] **Step 2: Run the family DB test and verify it fails**
|
|
139
|
+
|
|
140
|
+
Run:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm test -- src/__tests__/plugin.test.ts -t "writes a provider-model family DB"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Expected: FAIL because only `stability-deepseek__deepseek-chat__build.json` is written for an agent session.
|
|
147
|
+
|
|
148
|
+
### Task 3: Add Family Scope Context Helpers
|
|
149
|
+
|
|
150
|
+
**Files:**
|
|
151
|
+
- Modify: `src/index.ts`
|
|
152
|
+
|
|
153
|
+
- [ ] **Step 1: Replace string-only session scope tracking with scope contexts**
|
|
154
|
+
|
|
155
|
+
In `src/index.ts`, replace the current session-scope block with this implementation:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// ── Session scope tracking ───────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
interface ScopeContext {
|
|
161
|
+
scope: string
|
|
162
|
+
familyScope: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sessionScopes = new Map<string, ScopeContext>()
|
|
166
|
+
|
|
167
|
+
function familyScope(model: ModelIdentity | undefined): string {
|
|
168
|
+
return modelScope(model)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function scopeContext(model: ModelIdentity | undefined, agent?: string): ScopeContext {
|
|
172
|
+
const scope = modelScope(model, agent)
|
|
173
|
+
const modelFamily = familyScope(model)
|
|
174
|
+
return {
|
|
175
|
+
scope,
|
|
176
|
+
familyScope: modelFamily === "default" ? scope : modelFamily,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function rememberSessionScope(
|
|
181
|
+
sessionID: string | undefined,
|
|
182
|
+
model: ModelIdentity | undefined,
|
|
183
|
+
agent?: string,
|
|
184
|
+
): string {
|
|
185
|
+
const context = scopeContext(model, agent)
|
|
186
|
+
if (sessionID) sessionScopes.set(sessionID, context)
|
|
187
|
+
return context.scope
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function scopeForSession(sessionID: string | undefined, model: ModelIdentity | undefined): string {
|
|
191
|
+
if (sessionID) {
|
|
192
|
+
const known = sessionScopes.get(sessionID)
|
|
193
|
+
if (known) return known.scope
|
|
194
|
+
}
|
|
195
|
+
return scopeContext(model).scope
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function familyScopeForSession(
|
|
199
|
+
sessionID: string | undefined,
|
|
200
|
+
model: ModelIdentity | undefined,
|
|
201
|
+
): string {
|
|
202
|
+
if (sessionID) {
|
|
203
|
+
const known = sessionScopes.get(sessionID)
|
|
204
|
+
if (known) return known.familyScope
|
|
205
|
+
}
|
|
206
|
+
return scopeContext(model).familyScope
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- [ ] **Step 2: Run existing scope tests**
|
|
211
|
+
|
|
212
|
+
Run:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
npm test -- src/__tests__/plugin.test.ts -t "scope"
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Expected: existing scope tests still PASS. The two new tests still FAIL.
|
|
219
|
+
|
|
220
|
+
### Task 4: Add Stable Block Ranking Helpers
|
|
221
|
+
|
|
222
|
+
**Files:**
|
|
223
|
+
- Modify: `src/index.ts`
|
|
224
|
+
|
|
225
|
+
- [ ] **Step 1: Add `lookupContentScore` to the core imports**
|
|
226
|
+
|
|
227
|
+
Change the import from `./core` so it includes `lookupContentScore`:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import {
|
|
231
|
+
emptyDB,
|
|
232
|
+
updateDB,
|
|
233
|
+
updateContentDB,
|
|
234
|
+
extractWarmHashes,
|
|
235
|
+
estimateSavings,
|
|
236
|
+
hashContent,
|
|
237
|
+
lookupContentScore,
|
|
238
|
+
} from "./core"
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- [ ] **Step 2: Add ranking types and helpers before the plugin definition**
|
|
242
|
+
|
|
243
|
+
Insert this block after `pruneStaleHashes`:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
// ── Cross-agent stable prefix ranking ────────────────────────────────
|
|
247
|
+
|
|
248
|
+
interface WarmHashMembership {
|
|
249
|
+
global: Set<string>
|
|
250
|
+
scoped: Set<string>
|
|
251
|
+
family: Set<string>
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface StableRanking {
|
|
255
|
+
sharedStable: string[]
|
|
256
|
+
scopedStable: string[]
|
|
257
|
+
coldStable: string[]
|
|
258
|
+
dynamic: string[]
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function warmMembershipForScope(scope: string, familyDB: StabilityDB): WarmHashMembership {
|
|
262
|
+
const cache = loadWarmCache()
|
|
263
|
+
return {
|
|
264
|
+
global: cache.global,
|
|
265
|
+
scoped: cache.scopes.get(scope) ?? new Set(),
|
|
266
|
+
family: extractWarmHashes(familyDB),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function hasStableContentScore(db: StabilityDB, hash: string): boolean {
|
|
271
|
+
const score = lookupContentScore(db, hash)
|
|
272
|
+
return db.contentObservations >= 2 && score !== null && score >= 0.7
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function classificationWarmHashes(membership: WarmHashMembership): Set<string> {
|
|
276
|
+
const hashes = new Set<string>(membership.global)
|
|
277
|
+
for (const hash of membership.scoped) hashes.add(hash)
|
|
278
|
+
return hashes
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function rankStableBlocks(
|
|
282
|
+
stableBlocks: string[],
|
|
283
|
+
dynamicBlocks: string[],
|
|
284
|
+
scopeDB: StabilityDB,
|
|
285
|
+
familyDB: StabilityDB,
|
|
286
|
+
warmMembership: WarmHashMembership,
|
|
287
|
+
): StableRanking {
|
|
288
|
+
const ranking: StableRanking = {
|
|
289
|
+
sharedStable: [],
|
|
290
|
+
scopedStable: [],
|
|
291
|
+
coldStable: [],
|
|
292
|
+
dynamic: dynamicBlocks,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const block of stableBlocks) {
|
|
296
|
+
const hash = hashContent(block)
|
|
297
|
+
if (
|
|
298
|
+
warmMembership.global.has(hash) ||
|
|
299
|
+
warmMembership.family.has(hash) ||
|
|
300
|
+
hasStableContentScore(familyDB, hash)
|
|
301
|
+
) {
|
|
302
|
+
ranking.sharedStable.push(block)
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (warmMembership.scoped.has(hash) || hasStableContentScore(scopeDB, hash)) {
|
|
307
|
+
ranking.scopedStable.push(block)
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
ranking.coldStable.push(block)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return ranking
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
- [ ] **Step 3: Run typecheck**
|
|
319
|
+
|
|
320
|
+
Run:
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
npm run typecheck
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Expected: PASS after unused-import and type errors are resolved.
|
|
327
|
+
|
|
328
|
+
### Task 5: Wire Family DB and Ranking Into Transform
|
|
329
|
+
|
|
330
|
+
**Files:**
|
|
331
|
+
- Modify: `src/index.ts`
|
|
332
|
+
|
|
333
|
+
- [ ] **Step 1: Load family DB and membership in the transform hook**
|
|
334
|
+
|
|
335
|
+
Inside `"experimental.chat.system.transform"`, replace:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
const db = loadDB(scope)
|
|
339
|
+
|
|
340
|
+
// Pass warm hashes to classifier for cache warming
|
|
341
|
+
const classified = classify(splitBlocks, db, {
|
|
342
|
+
splitThreshold: Number.MAX_SAFE_INTEGER,
|
|
343
|
+
warmHashes: warmHashesForScope(scope),
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// Reorder: stable → unknown → dynamic
|
|
347
|
+
output.system = [...classified.stable, ...classified.unknown, ...classified.dynamic]
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
with:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
const family = familyScopeForSession(input.sessionID, input.model)
|
|
354
|
+
const db = loadDB(scope)
|
|
355
|
+
const familyDB = family === scope ? db : loadDB(family)
|
|
356
|
+
const warmMembership = warmMembershipForScope(scope, familyDB)
|
|
357
|
+
|
|
358
|
+
const classified = classify(splitBlocks, db, {
|
|
359
|
+
splitThreshold: Number.MAX_SAFE_INTEGER,
|
|
360
|
+
warmHashes: classificationWarmHashes(warmMembership),
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
const ranked = rankStableBlocks(
|
|
364
|
+
classified.stable,
|
|
365
|
+
[...classified.unknown, ...classified.dynamic],
|
|
366
|
+
db,
|
|
367
|
+
familyDB,
|
|
368
|
+
warmMembership,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
output.system = [
|
|
372
|
+
...ranked.sharedStable,
|
|
373
|
+
...ranked.scopedStable,
|
|
374
|
+
...ranked.coldStable,
|
|
375
|
+
...ranked.dynamic,
|
|
376
|
+
]
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
- [ ] **Step 2: Persist both DBs after reorder**
|
|
380
|
+
|
|
381
|
+
Replace:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
updateDB(db, output.system)
|
|
385
|
+
updateContentDB(db, output.system)
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
with:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
updateDB(db, output.system)
|
|
392
|
+
updateContentDB(db, output.system)
|
|
393
|
+
if (family !== scope) {
|
|
394
|
+
updateDB(familyDB, output.system)
|
|
395
|
+
updateContentDB(familyDB, output.system)
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Replace:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
saveDB(scope, db)
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
with:
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
saveDB(scope, db)
|
|
409
|
+
if (family !== scope) saveDB(family, familyDB)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
- [ ] **Step 3: Keep warm-cache writes scoped to agent DBs**
|
|
413
|
+
|
|
414
|
+
Leave this block scoped-only:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
if (db.observations % 10 === 0) {
|
|
418
|
+
saveWarmCache(scope, db)
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Do not call `saveWarmCache(family, familyDB)`.
|
|
423
|
+
|
|
424
|
+
- [ ] **Step 4: Run the new family DB test**
|
|
425
|
+
|
|
426
|
+
Run:
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
npm test -- src/__tests__/plugin.test.ts -t "writes a provider-model family DB"
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Expected: PASS.
|
|
433
|
+
|
|
434
|
+
### Task 6: Add Ranking Diagnostics
|
|
435
|
+
|
|
436
|
+
**Files:**
|
|
437
|
+
- Modify: `src/index.ts`
|
|
438
|
+
|
|
439
|
+
- [ ] **Step 1: Track shared prefix bytes and ranking counts**
|
|
440
|
+
|
|
441
|
+
After savings are calculated, add:
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
const sharedPrefixBytes = ranked.sharedStable.reduce((s, b) => s + b.length, 0)
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Change the diagnostic log call to include ranking counts:
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
diag(
|
|
451
|
+
scope,
|
|
452
|
+
`S:${classified.stable.length} U:${classified.unknown.length} ` +
|
|
453
|
+
`D:${classified.dynamic.length} T:${output.system.length} ` +
|
|
454
|
+
`SH:${ranked.sharedStable.length} SC:${ranked.scopedStable.length} ` +
|
|
455
|
+
`CS:${ranked.coldStable.length} ` +
|
|
456
|
+
`obs:${db.observations} ` +
|
|
457
|
+
`stableKB:${(stableBytes / 1024).toFixed(1)} ` +
|
|
458
|
+
`sharedKB:${(sharedPrefixBytes / 1024).toFixed(1)} ` +
|
|
459
|
+
`saved:$${estCallSaving.toFixed(6)} ` +
|
|
460
|
+
`total:$${savings.estimatedSavingsUSD.toFixed(4)}`,
|
|
461
|
+
)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Add this object to the `eventLog("transform", scope, { ... })` payload:
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
family,
|
|
468
|
+
ranking: {
|
|
469
|
+
sharedStable: ranked.sharedStable.length,
|
|
470
|
+
scopedStable: ranked.scopedStable.length,
|
|
471
|
+
coldStable: ranked.coldStable.length,
|
|
472
|
+
dynamic: ranked.dynamic.length,
|
|
473
|
+
sharedPrefixBytes,
|
|
474
|
+
},
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
- [ ] **Step 2: Run the cross-agent ordering test**
|
|
478
|
+
|
|
479
|
+
Run:
|
|
480
|
+
|
|
481
|
+
```bash
|
|
482
|
+
npm test -- src/__tests__/plugin.test.ts -t "orders family-stable shared blocks"
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Expected: PASS.
|
|
486
|
+
|
|
487
|
+
### Task 7: Full Verification and Commit
|
|
488
|
+
|
|
489
|
+
**Files:**
|
|
490
|
+
- Modify: `src/index.ts`
|
|
491
|
+
- Modify: `src/__tests__/plugin.test.ts`
|
|
492
|
+
|
|
493
|
+
- [ ] **Step 1: Run the focused plugin suite**
|
|
494
|
+
|
|
495
|
+
Run:
|
|
496
|
+
|
|
497
|
+
```bash
|
|
498
|
+
npm test -- src/__tests__/plugin.test.ts
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Expected: PASS.
|
|
502
|
+
|
|
503
|
+
- [ ] **Step 2: Run all tests**
|
|
504
|
+
|
|
505
|
+
Run:
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
npm test
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Expected: PASS.
|
|
512
|
+
|
|
513
|
+
- [ ] **Step 3: Run typecheck**
|
|
514
|
+
|
|
515
|
+
Run:
|
|
516
|
+
|
|
517
|
+
```bash
|
|
518
|
+
npm run typecheck
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Expected: PASS.
|
|
522
|
+
|
|
523
|
+
- [ ] **Step 4: Inspect git diff**
|
|
524
|
+
|
|
525
|
+
Run:
|
|
526
|
+
|
|
527
|
+
```bash
|
|
528
|
+
git diff -- src/index.ts src/__tests__/plugin.test.ts
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Expected: only family-scope ranking, diagnostics, and tests are changed.
|
|
532
|
+
|
|
533
|
+
- [ ] **Step 5: Commit implementation**
|
|
534
|
+
|
|
535
|
+
Run:
|
|
536
|
+
|
|
537
|
+
```bash
|
|
538
|
+
git add src/index.ts src/__tests__/plugin.test.ts
|
|
539
|
+
git commit -m "feat: share stable cache prefix across agents"
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Expected: commit succeeds with the implementation changes only.
|
|
543
|
+
|
|
544
|
+
## Self-Review
|
|
545
|
+
|
|
546
|
+
- Spec coverage: family scope, shared/scoped/cold ranking, diagnostics, fallback behavior, and tests are covered by Tasks 1-7.
|
|
547
|
+
- Placeholder scan: the plan contains no deferred implementation markers.
|
|
548
|
+
- Type consistency: `familyScope`, `familyScopeForSession`, `WarmHashMembership`, `StableRanking`, `rankStableBlocks`, and `sharedPrefixBytes` are consistently named across tasks.
|
|
549
|
+
- Scope check: the plan changes only OpenCode system prompt ordering and does not introduce conversation-log rewriting, prompt mutation, manual pinning, or DeepSeek-specific branches.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Cross-Agent Cache Hit Design
|
|
2
|
+
|
|
3
|
+
Date: 2026-06-25
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Improve DeepSeek prefix-cache hit rate when OpenCode switches between agents such
|
|
8
|
+
as `build`, `review`, and `plan`.
|
|
9
|
+
|
|
10
|
+
The plugin already moves stable system blocks before dynamic blocks. The missing
|
|
11
|
+
piece is that stable blocks are learned and ordered mostly inside
|
|
12
|
+
`provider__model__agent` scopes. After an agent switch, shared blocks can still
|
|
13
|
+
appear after an agent-specific prompt block, shortening the byte-identical prefix
|
|
14
|
+
that DeepSeek can reuse.
|
|
15
|
+
|
|
16
|
+
## Constraints
|
|
17
|
+
|
|
18
|
+
- Keep the plugin content-agnostic: use hashes and structural metadata only.
|
|
19
|
+
- Do not edit prompt text. Only reorder system blocks.
|
|
20
|
+
- Preserve per-agent observability and stability databases.
|
|
21
|
+
- Keep behavior safe for non-DeepSeek providers that also use prefix caching.
|
|
22
|
+
- Avoid configuration requirements for the default path.
|
|
23
|
+
|
|
24
|
+
## Design
|
|
25
|
+
|
|
26
|
+
Add a model-family learning layer alongside the existing per-agent scope.
|
|
27
|
+
|
|
28
|
+
Current scope:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
provider__model__agent
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
New family scope:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
provider__model
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Each transform updates both databases. The agent scope keeps local behavior and
|
|
41
|
+
metrics. The family scope learns which block hashes are stable across multiple
|
|
42
|
+
agents using the same provider/model.
|
|
43
|
+
|
|
44
|
+
The reordered system prompt becomes:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
sharedStable -> scopedStable -> coldStable -> dynamic
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`sharedStable` contains hashes that are stable in the family scope or promoted to
|
|
51
|
+
the global warm cache. These blocks form the cross-agent prefix and should appear
|
|
52
|
+
before agent-specific prompt blocks. `scopedStable` contains hashes stable only
|
|
53
|
+
for the current agent. `coldStable` contains blocks that cold-start heuristics
|
|
54
|
+
consider stable but the family learner has not confirmed yet.
|
|
55
|
+
|
|
56
|
+
## Data Flow
|
|
57
|
+
|
|
58
|
+
1. Resolve the current agent scope from OpenCode session events.
|
|
59
|
+
2. Resolve the family scope from provider and model only.
|
|
60
|
+
3. Split system blocks using the existing splitter.
|
|
61
|
+
4. Load both stability DBs and warm-cache data.
|
|
62
|
+
5. Classify each block using the existing classifier.
|
|
63
|
+
6. Rank stable blocks by shared status first, then scoped status, then current
|
|
64
|
+
order as a deterministic fallback.
|
|
65
|
+
7. Persist both DBs after reorder.
|
|
66
|
+
8. Record diagnostics for shared-prefix size and hash counts.
|
|
67
|
+
|
|
68
|
+
## Components
|
|
69
|
+
|
|
70
|
+
- `familyScope(model)`: returns `provider__model`.
|
|
71
|
+
- `rankStableBlocks(...)`: partitions classified stable blocks into shared,
|
|
72
|
+
scoped, and cold stable groups.
|
|
73
|
+
- Warm cache shape: keep existing v2 format but expose separate `global`,
|
|
74
|
+
`family`, and `scope` membership to ranking code instead of only returning a
|
|
75
|
+
merged set.
|
|
76
|
+
- Diagnostics: add `sharedStable`, `scopedStable`, `coldStable`, and
|
|
77
|
+
`sharedPrefixBytes` to structured transform events.
|
|
78
|
+
|
|
79
|
+
## Error Handling
|
|
80
|
+
|
|
81
|
+
All new storage remains best-effort like the existing DBs. If the family DB or
|
|
82
|
+
warm cache cannot be read, the plugin falls back to the current per-agent
|
|
83
|
+
classification and reorder behavior. Failed family writes should log an error
|
|
84
|
+
event but must not block the chat request.
|
|
85
|
+
|
|
86
|
+
## Testing
|
|
87
|
+
|
|
88
|
+
Add focused Vitest coverage:
|
|
89
|
+
|
|
90
|
+
- Two agents with the same provider/model and shared tool blocks should place
|
|
91
|
+
shared blocks before agent-specific stable blocks after family learning.
|
|
92
|
+
- Per-agent DB files should still be written.
|
|
93
|
+
- Metrics should still aggregate by `provider__model__agent`.
|
|
94
|
+
- Family DB read/write failures should not prevent transform output.
|
|
95
|
+
- Existing warm-cache promotion tests should continue to pass.
|
|
96
|
+
|
|
97
|
+
## Non-Goals
|
|
98
|
+
|
|
99
|
+
- No conversation-log rewrite.
|
|
100
|
+
- No prompt text mutation or marker insertion.
|
|
101
|
+
- No manual pinning UI in this change.
|
|
102
|
+
- No provider-specific DeepSeek branching.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-cache-optimizer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|