What happened?
After enabling @prevalentware/opencode-goal-plugin, our LLM prompt-cache hit rate dropped from ~85%+ to ~40% for any session that has an active goal. The drop is systematic (every turn) and starts the moment a goal becomes active; it disappears when no goal is set or after the goal is closed.
The cause: the plugin injects a per-turn mutating budget/status block into the cached system prompt, breaking the prefix stability that prompt caching relies on.
Expected behavior
The goal-mode system reminder should not destabilize the cacheable system-prompt prefix. Either it should be cache-stable (no values that change every turn), or it should live outside the cached prefix (e.g. appended after the cache breakpoint) so the static system prompt stays cacheable.
Steps to reproduce
opencode plugin @prevalentware/opencode-goal-plugin
- Start a session with a caching-capable provider (e.g.
anthropic/claude-sonnet-5).
/goal implement X — goal becomes active.
- Send several follow-up turns and observe the provider-side cache-read stats collapse per turn (input tokens billed as non-cached instead of cache-read).
/goal complete ... (or clear) — cache rate recovers once no active goal reminder is injected.
Root cause (analysis)
experimental.chat.system.transform runs on every turn and merges a reminder into the first system block — the same block the cache breakpoint sits on:
// src/server.ts
async "experimental.chat.system.transform"(input, output) {
const goal = await getGoal(input.sessionID) // calls snapshot()
mergeSystemReminder(output, systemReminder(goal, ...)) // appends to output.system[0]
}
function mergeSystemReminder(output, reminder) {
// ...
output.system[0] = `${output.system[0]}\n\n${reminder}` // mutates the cached prefix
}
getGoal → snapshot() recomputes live wall-clock time on every call:
// src/state.ts
const sampledAt = nowSeconds() // changes every second
const activeSeconds = sampledAt - goal.lastAccountedAt
const timeUsedSeconds = goal.timeUsedSeconds + activeSeconds // changes every turn
That snapshot flows into continuationPrompt → budgetLines (src/prompts.ts), which contains values that change turn-over-turn:
- Time spent pursuing goal: ${goal.timeUsedSeconds} seconds // wall-clock, changes each turn
- Tokens used: ${goal.tokensUsed} // monotonically increases
- Tokens remaining: ${goal.remainingTokens} // monotonically decreases
- Auto-continues used: ${goal.autoTurns} // increments
formatGoal() adds more mutating fields (Latest checkpoint, Last status, Tokens used: x/y, etc.).
Because the entire block is appended to output.system[0] — the cached prefix — a single differing character (e.g. 85 seconds → 86 seconds) invalidates the cache from that offset onward, every turn. That matches the observed 85%+ → ~40% drop.
Suggested fixes
In order of impact:
- Move the reminder out of the cached prefix. Push the reminder into a non-cached system block (after the cache breakpoint) or into the trailing user/system turn position, so the static system prompt stays byte-identical across turns.
- Drop the live counters from the system reminder.
timeUsedSeconds, tokensUsed, remainingTokens, and autoTurns are the cache-killers. Keep only stable content (objective + fixed behavior instructions) in the per-turn system block. Budget progress can still be surfaced — but only inside continuationPrompt, which is injected as a separate user message (already a non-cached-prefix path) during auto-continue, not on every chat turn.
- Only inject the reminder on continuation turns, not on ordinary
chat.message turns.
A minimal mitigation combining 2 + 3 (strip live values from budgetLines/formatGoal used by systemReminder; keep them only in continuationPrompt) should restore cache stability for normal turns while preserving the auto-continue budget signal.
Plugin version
0.1.1
OpenCode version
Behavior is version-independent (stems from plugin source, not the OpenCode runtime). Observed on a recent OpenCode build.
Operating system
macOS 15
Model/provider in use
anthropic (prompt-caching provider). Any provider with prefix caching is affected.
Plugin options
Default options (auto_continue enabled, no custom budget).
Relevant goal state or logs
N/A — root cause is determinable from source. Field references above point to the exact mutation sites.
What happened?
After enabling
@prevalentware/opencode-goal-plugin, our LLM prompt-cache hit rate dropped from ~85%+ to ~40% for any session that has an active goal. The drop is systematic (every turn) and starts the moment a goal becomes active; it disappears when no goal is set or after the goal is closed.The cause: the plugin injects a per-turn mutating budget/status block into the cached system prompt, breaking the prefix stability that prompt caching relies on.
Expected behavior
The goal-mode system reminder should not destabilize the cacheable system-prompt prefix. Either it should be cache-stable (no values that change every turn), or it should live outside the cached prefix (e.g. appended after the cache breakpoint) so the static system prompt stays cacheable.
Steps to reproduce
opencode plugin @prevalentware/opencode-goal-pluginanthropic/claude-sonnet-5)./goal implement X— goal becomesactive./goal complete ...(or clear) — cache rate recovers once no active goal reminder is injected.Root cause (analysis)
experimental.chat.system.transformruns on every turn and merges a reminder into the first system block — the same block the cache breakpoint sits on:getGoal→snapshot()recomputes live wall-clock time on every call:That snapshot flows into
continuationPrompt→budgetLines(src/prompts.ts), which contains values that change turn-over-turn:formatGoal()adds more mutating fields (Latest checkpoint,Last status,Tokens used: x/y, etc.).Because the entire block is appended to
output.system[0]— the cached prefix — a single differing character (e.g.85 seconds→86 seconds) invalidates the cache from that offset onward, every turn. That matches the observed 85%+ → ~40% drop.Suggested fixes
In order of impact:
timeUsedSeconds,tokensUsed,remainingTokens, andautoTurnsare the cache-killers. Keep only stable content (objective + fixed behavior instructions) in the per-turn system block. Budget progress can still be surfaced — but only insidecontinuationPrompt, which is injected as a separate user message (already a non-cached-prefix path) during auto-continue, not on every chat turn.chat.messageturns.A minimal mitigation combining 2 + 3 (strip live values from
budgetLines/formatGoalused bysystemReminder; keep them only incontinuationPrompt) should restore cache stability for normal turns while preserving the auto-continue budget signal.Plugin version
0.1.1
OpenCode version
Behavior is version-independent (stems from plugin source, not the OpenCode runtime). Observed on a recent OpenCode build.
Operating system
macOS 15
Model/provider in use
anthropic (prompt-caching provider). Any provider with prefix caching is affected.
Plugin options
Default options (
auto_continueenabled, no custom budget).Relevant goal state or logs
N/A — root cause is determinable from source. Field references above point to the exact mutation sites.