SLOPSHOPPER

cache-guard

Stabilize the prompt-cache prefix, audit compaction fidelity, and report real cache hit numbers per turn.

newprompt
A shopper browsing a rack in a slop shop
README

cache-guard

Claude Code Mod:把 prompt cache 前缀里我们自己造成的抖动抹掉, 把省下来的 miss 变成 hit,并用每轮真实数字证明。

能涨命中的唯一杠杆

服务端按请求前缀精确匹配给缓存,断点由引擎放置——这两样 Mod 都碰不到。 正常多轮会话里追加在末尾的新消息本来就不破坏前缀,命中早该有; 真正的漏发生在前缀中段被改写时:section 重组装混入新时间戳、 context 块措辞漂移、compact 重写历史。cache-guard 0.2.0 干的就是这个:

钩子动作效果
prompt.section仅挥发部分(时间戳、换行符)漂移时钉回上次定稿;真变了才放行并警告重组装不再无故烧缓存
prompt.context同上,按块名钉住同上
prompt.attachmentdropAttachmentTypes 点名的类型直接丢掉(默认空)可选,少一段不稳定输入
session.measure每轮打出 cache read X/Y (Z%),零命中警告证明涨没涨,看数字
turn.complete(主循环)对 transcript 做 append-only 校验:上轮指纹必须按序存活,消失的消息按编号+内容头报警,乱序也报警正常会话丢字当场抓获,不只查 compact
session.compactcompact 前把全量消息快照进 $.store(键 compact-evidence:<时间>,只留最近 2 份;超 4 MiB 上限自动降级为指纹快照),compact 后审计 kept/dropped/added 并存档 10 份compact 是唯一真正改写历史的地方,丢的每个字符在这里都有数

钉住规则(normalizeVolatile):ISO 日期时间只留日期,钟点归零, CRLF 归一为 LF;端口号、版本号、无时间戳文本原样不动。每次钉住和 放行都记日志。

仍然做不到的事

5 分钟缓存 TTL、换模型即失缓存、compact 本体重置——这些在 Mod 之外, 涨不了。也别拿它跟引擎自带的断点机制比上限,它只负责堵住我们自己的漏。

保真度说明

指纹是 FNV-1a + 键排序的规范序列化:同一内容无论键顺序、handle 如何 变化哈希都相同;差一个字符哈希必变。compact 审计的是“引擎实际发出 了什么”,而不是“应该发出什么”。

使用

claude --plugin-dir mods/cache-guard
claude plugin test mods/cache-guard
claude plugin validate mods/cache-guard

一次装全改装聚合包,见仓库根 README.md:

claude plugin install plus@claudecode-plus-mod --scope user
Source 2 files
hooks/register.ts 202 lines
1import type { On } from 'claude-code'
2
3import {
4  auditCompact,
5  auditCustody,
6  auditKey,
7  cacheLine,
8  evidenceKey,
9  fingerprintMessages,
10  isColdMiss,
11  previewMessage,
12  pruneKeys,
13  stabilize,
14} from './fingerprint'
15
16type GuardOptions = {
17  verbose?: boolean
18  forbidAutoCompact?: boolean
19  dropAttachmentTypes?: string[]
20}
21
22function report($: unknown, message: string, verbose: boolean): void {
23  const ui = ($ as { ui: { log: (text: string, opts?: object) => void } }).ui
24  if (verbose) {
25    ui.log(message)
26  } else {
27    ui.log(message, { to: 'debug' })
28  }
29}
30
31/**
32 * Registers cache-prefix stabilization. The provider matches on exact prefix
33 * and the engine places every cache mark itself, so nothing here forces a
34 * hit. What it does is erase the misses we cause ourselves: volatile-only
35 * drift in recomposed sections and context blocks is pinned back to the
36 * last settled text, real content changes pass through with a warning, and
37 * per-turn numbers prove the delta.
38 *
39 * @param on the engine's registrar
40 * @param options `verbose` surfaces each line in the transcript;
41 * `forbidAutoCompact` vetoes threshold-triggered compaction;
42 * `dropAttachmentTypes` leaves named attachment types out of the request
43 */
44export function register(on: On, options: GuardOptions = {}): void {
45  const verbose = options.verbose === true
46  const settledSections = new Map<string, string>()
47  const settledBlocks = new Map<string, string>()
48  const dropped = new Set(options.dropAttachmentTypes ?? [])
49  let lastChain: string[] | undefined
50  let lastPreview: string[] = []
51
52  on('session.start', ($, e, next) => {
53    report($, '[cache-guard] active, watching session.measure, prompt.section, prompt.context, turn.complete, session.compact', verbose)
54    return next(e)
55  })
56
57  on('session.measure', async ($, e, next) => {
58    const result = await next(e)
59    try {
60      const usage = await $.session.usage({ breakdown: 'summary' })
61      const api = usage.breakdown?.apiUsage
62      if (api !== undefined && api !== null) {
63        report($, `[cache-guard] ${cacheLine(api)}`, verbose)
64        if (isColdMiss(api)) {
65          report($, '[cache-guard] cold miss: nothing served from cache this turn, the request prefix changed', verbose)
66        }
67      }
68    } catch {
69      report($, '[cache-guard] usage unavailable, skipping cache report', verbose)
70    }
71    return result
72  })
73
74  on('prompt.section', async ($, e, next) => {
75    const out = await next(e)
76    if (out.text === null) {
77      return out
78    }
79    const stable = stabilize(settledSections.get(e.name), out.text)
80    settledSections.set(e.name, stable.text)
81    if (stable.pinned) {
82      report($, `[cache-guard] section "${e.name}" pinned to last settled text, volatile-only drift erased`, verbose)
83    } else if (stable.drifted) {
84      report($, `[cache-guard] section "${e.name}" content changed, next request prefix will miss cache`, verbose)
85    }
86    return { text: stable.text }
87  })
88
89  on('prompt.context', async ($, e, next) => {
90    const out = await next(e)
91    const blocks = out.blocks.map((block) => {
92      const stable = stabilize(settledBlocks.get(block.name), block.text)
93      settledBlocks.set(block.name, stable.text)
94      if (stable.pinned) {
95        report($, `[cache-guard] context block "${block.name}" pinned to last settled text`, verbose)
96      } else if (stable.drifted) {
97        report($, `[cache-guard] context block "${block.name}" content changed`, verbose)
98      }
99      return { ...block, text: stable.text }
100    })
101    return { ...out, blocks }
102  })
103
104  on('prompt.attachment', async ($, e, next) => {
105    if (dropped.has(e.type)) {
106      report($, `[cache-guard] attachment "${e.type}" dropped by dropAttachmentTypes`, verbose)
107      return { text: null }
108    }
109    return next(e)
110  })
111
112  on('turn.complete', async ($, e, next) => {
113    const result = await next(e)
114    if (e.agentId !== undefined) {
115      return result
116    }
117    try {
118      const messages = await $.session.messages()
119      const curr = fingerprintMessages(messages)
120      const preview = messages.map(previewMessage)
121      if (lastChain === undefined) {
122        report($, `[cache-guard] custody baseline: ${curr.length} messages`, verbose)
123      } else {
124        const audit = auditCustody(lastChain, curr)
125        if (audit.dropped.length > 0 || audit.moved) {
126          const ui = ($ as { ui: { log: (text: string) => void } }).ui
127          for (const index of audit.dropped) {
128            ui.log(`[cache-guard] custody violation: message #${index} vanished between turns: ${lastPreview[index] ?? ''}`)
129          }
130          if (audit.moved) {
131            ui.log('[cache-guard] custody violation: message order changed between turns')
132          }
133        } else {
134          report($, `[cache-guard] custody ok: ${curr.length} messages, +${audit.appended} appended, none lost`, verbose)
135        }
136      }
137      lastChain = curr
138      lastPreview = preview
139    } catch {
140      report($, '[cache-guard] custody check skipped, messages unreadable', verbose)
141    }
142    return result
143  })
144
145  if (options.forbidAutoCompact === true) {
146    on('session.compact', { trigger: 'auto' }, () => ({
147      skip: '[cache-guard] auto-compact vetoed by forbidAutoCompact; run /compact manually when ready',
148    }))
149  }
150
151  on('session.compact', async ($, e, next) => {
152    const before = fingerprintMessages(e.messages)
153    let at: number
154    try {
155      at = await $.clock.now()
156    } catch {
157      at = Date.now()
158    }
159
160    let evidence = 'store-unavailable'
161    try {
162      await $.store.set(evidenceKey(at), { trigger: e.trigger, at, messages: e.messages })
163      const keys = await $.store.keys()
164      for (const key of pruneKeys(keys, 'compact-evidence:', 2)) {
165        await $.store.delete(key)
166      }
167      evidence = `full snapshot of ${e.messages.length} messages`
168    } catch {
169      try {
170        await $.store.set(evidenceKey(at), { trigger: e.trigger, at, fingerprints: before })
171        evidence = `fingerprints only, full text over store limit`
172      } catch {
173        evidence = 'store-unavailable'
174      }
175    }
176
177    const result = await next(e)
178    if (!('messages' in result) || result.messages === undefined) {
179      report($, `[cache-guard] compact (${e.trigger}) skipped`, verbose)
180      return result
181    }
182    const after = fingerprintMessages(result.messages)
183    const audit = auditCompact(before, after)
184    try {
185      await $.store.set(auditKey(at), { trigger: e.trigger, at, before: before.length, after: after.length, audit })
186      const keys = await $.store.keys()
187      for (const key of pruneKeys(keys, 'compact-audit:', 10)) {
188        await $.store.delete(key)
189      }
190    } catch {
191      report($, '[cache-guard] audit record not stored', verbose)
192    }
193    report(
194      $,
195      `[cache-guard] compact (${e.trigger}): ${before.length} -> ${after.length} messages, ` +
196        `kept ${audit.kept}, dropped ${audit.dropped}, added ${audit.added}; evidence locked (${evidence}); prefix cache resets after compact`,
197      verbose,
198    )
199    return result
200  })
201}
202
hooks/fingerprint.ts 223 lines
1/**
2 * Byte-stability helpers: the provider's prompt cache hits only on an exact
3 * prefix match, so any drift in what the engine sends is a guaranteed miss.
4 * These functions detect that drift; they send nothing and change nothing.
5 */
6
7/**
8 * @param value any JSON-like value
9 * @returns canonical string with sorted keys; the engine's `handle` dropped so
10 * identity metadata never counts as content drift
11 */
12export function stableStringify(value: unknown): string {
13  const seen = new Set<object>()
14
15  function encode(node: unknown): string {
16    if (node === null || typeof node !== 'object') {
17      const text = JSON.stringify(node)
18      return text === undefined ? 'undefined' : text
19    }
20    if (seen.has(node)) {
21      return '"[circular]"'
22    }
23    seen.add(node)
24    if (Array.isArray(node)) {
25      return `[${node.map(encode).join(',')}]`
26    }
27    const record = node as Record<string, unknown>
28    const keys = Object.keys(record)
29      .filter((key) => key !== 'handle')
30      .sort()
31    return `{${keys.map((key) => `${JSON.stringify(key)}:${encode(record[key])}`).join(',')}}`
32  }
33
34  try {
35    return encode(value)
36  } catch {
37    return String(value)
38  }
39}
40
41/**
42 * @param text canonical text
43 * @returns 8-hex-digit FNV-1a over UTF-16 units; sync and dependency-free on
44 * purpose, so hooks never wait on crypto for a change-detection hash
45 */
46export function fnv1a(text: string): string {
47  let hash = 0x811c9dc5
48  for (let i = 0; i < text.length; i++) {
49    hash ^= text.charCodeAt(i)
50    hash = Math.imul(hash, 0x01000193)
51  }
52  return (hash >>> 0).toString(16).padStart(8, '0')
53}
54
55/**
56 * @param messages transcript messages in `$.session.messages()` shape
57 * @returns one content hash per message, order preserved
58 */
59export function fingerprintMessages(messages: readonly unknown[]): string[] {
60  return messages.map((message) => fnv1a(stableStringify(message)))
61}
62
63export type CompactAudit = {
64  kept: number
65  dropped: number
66  added: number
67}
68
69/**
70 * @param before content hashes going into compaction
71 * @param after content hashes coming out of it
72 * @returns multiset diff: kept, dropped, added
73 */
74export function auditCompact(before: readonly string[], after: readonly string[]): CompactAudit {
75  const remaining = new Map<string, number>()
76  for (const hash of after) {
77    remaining.set(hash, (remaining.get(hash) ?? 0) + 1)
78  }
79  let kept = 0
80  for (const hash of before) {
81    const count = remaining.get(hash) ?? 0
82    if (count > 0) {
83      kept++
84      remaining.set(hash, count - 1)
85    }
86  }
87  let added = 0
88  for (const count of remaining.values()) {
89    added += count
90  }
91  return { kept, dropped: before.length - kept, added }
92}
93
94export function evidenceKey(at: number): string {
95  return `compact-evidence:${at}`
96}
97
98export function auditKey(at: number): string {
99  return `compact-audit:${at}`
100}
101
102/**
103 * @param keys every key in the plugin store, insertion ordered
104 * @param prefix only keys under this prefix are managed, foreign keys untouched
105 * @param keep how many newest snapshots survive
106 * @returns keys to delete, oldest first
107 */
108export function pruneKeys(keys: readonly string[], prefix: string, keep: number): string[] {
109  const owned = keys.filter((key) => key.startsWith(prefix))
110  const stamped = owned
111    .map((key) => ({ key, at: Number(key.slice(prefix.length)) }))
112    .filter((entry) => Number.isFinite(entry.at))
113    .sort((a, b) => b.at - a.at)
114  return stamped.slice(keep).map((entry) => entry.key)
115}
116
117export type CustodyAudit = {
118  appended: number
119  dropped: number[]
120  moved: boolean
121}
122
123/**
124 * @param prev content hashes at the end of the previous turn
125 * @param curr content hashes now
126 * @returns append-only check: previously seen messages must survive in order.
127 * `dropped` names previous indexes that vanished, `moved` flags a reorder,
128 * `appended` counts genuinely new tail messages.
129 */
130export function auditCustody(prev: readonly string[], curr: readonly string[]): CustodyAudit {
131  const dropped: number[] = []
132  let cursor = 0
133  for (let i = 0; i < prev.length; i++) {
134    const found = curr.indexOf(prev[i] as string, cursor)
135    if (found === -1) {
136      dropped.push(i)
137    } else {
138      cursor = found + 1
139    }
140  }
141  const matched = prev.length - dropped.length
142  const isPrefix = matched === prev.length && curr.slice(0, prev.length).every((hash, i) => hash === prev[i])
143  return { appended: curr.length - matched, dropped, moved: matched > 0 && !isPrefix }
144}
145
146/**
147 * @param message one transcript message
148 * @returns short `[role] head...` preview for violation logs; never throws,
149 * never dumps full tool results into the transcript
150 */
151export function previewMessage(message: unknown): string {
152  if (typeof message === 'object' && message !== null) {
153    const record = message as Record<string, unknown>
154    const role = typeof record.role === 'string' ? record.role : '?'
155    const raw = typeof record.text === 'string' ? record.text : stableStringify(record.text)
156    const flat = raw.replace(/\s+/g, ' ')
157    const head = flat.length > 80 ? `${flat.slice(0, 80)}...` : flat
158    return `[${role}] ${head}`
159  }
160  return stableStringify(message).slice(0, 80)
161}
162
163export type ApiUsage = {
164  input_tokens: number
165  cache_creation_input_tokens: number
166  cache_read_input_tokens: number
167  output_tokens: number
168}
169
170/**
171 * @param text section or context text as the engine composed it
172 * @returns text with the volatile parts normalized: ISO datetimes collapse to
173 * their date, clock times to 00:00, CRLF to LF. Dates stay (useful), the
174 * ever-ticking clock goes (pure cache poison). Code-shaped text without
175 * timestamps passes through untouched.
176 */
177export function normalizeVolatile(text: string): string {
178  return text
179    .replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, (match) => match.slice(0, 10))
180    .replace(/\b\d{2}:\d{2}(?::\d{2})?\b/g, '00:00')
181    .replace(/\r\n/g, '\n')
182}
183
184export type Stabilized = {
185  text: string
186  pinned: boolean
187  drifted: boolean
188}
189
190/**
191 * @param previous the text this key last settled on, absent on first sight
192 * @param current the text the engine composed just now
193 * @returns pinned text when only volatile parts moved (a free hit saved),
194 * current text with drift flagged when content really changed
195 */
196export function stabilize(previous: string | undefined, current: string): Stabilized {
197  if (previous === undefined || previous === current) {
198    return { text: current, pinned: false, drifted: false }
199  }
200  if (normalizeVolatile(previous) === normalizeVolatile(current)) {
201    return { text: previous, pinned: true, drifted: false }
202  }
203  return { text: current, pinned: false, drifted: true }
204}
205
206/**
207 * @param api the last response's token counts as the API reported them
208 * @returns one log line, e.g. `cache read 18000/20000 input (90%), created 0, output 320`
209 */
210export function cacheLine(api: ApiUsage): string {
211  const total = api.input_tokens + api.cache_creation_input_tokens + api.cache_read_input_tokens
212  const pct = total === 0 ? 100 : Math.round((api.cache_read_input_tokens / total) * 100)
213  return `cache read ${api.cache_read_input_tokens}/${total} input (${pct}%), created ${api.cache_creation_input_tokens}, output ${api.output_tokens}`
214}
215
216/**
217 * @param api the last response's token counts
218 * @returns true when nothing was served from cache despite a non-empty request
219 */
220export function isColdMiss(api: ApiUsage): boolean {
221  return api.cache_read_input_tokens === 0 && api.input_tokens + api.cache_creation_input_tokens > 0
222}
223