opencode-consistent 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nguyen Phan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # opencode-consistent
2
+
3
+ OpenCode rebuilds the system prompt on every turn. `Today's date`, cwd, git, AGENTS.md, MCP instructions, and the skill list all go back in from live disk. Prefix caches hash from token 0, so one changed byte after the tools block misses the whole session.
4
+
5
+ This plugin snapshots that system prefix on the first real turn of a session and puts the same bytes back on every later turn. Time can land at session start. It does not move while you work.
6
+
7
+ Measured on a homelab vLLM pair: a 186k OpenCode resume after 60 minutes idle came back with 1,792 cached tokens (the built-in tool schemas) and prefilling the rest. Sibling sessions on the same key were at 99.9%. The GPU still had the old prefix. OpenCode had sent a different one.
8
+
9
+ Upstream knows. [#32622](https://github.com/anomalyco/opencode/issues/32622) (date in the cached prefix, closed not-planned), [#29672](https://github.com/anomalyco/opencode/issues/29672), [PR #29949](https://github.com/anomalyco/opencode/pull/29949) (move env to the tail, not default as of 1.18.27). `OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION` did not ship in 1.18.x runtime flags.
10
+
11
+ ## Install
12
+
13
+ Put it **last** in the plugin list so other plugins mutate first, then this freezes the result:
14
+
15
+ ```json
16
+ {
17
+ "plugin": [
18
+ "opencode-consistent"
19
+ ]
20
+ }
21
+ ```
22
+
23
+ Restart OpenCode. Plugins load at process start.
24
+
25
+ ## What it freezes
26
+
27
+ The joined system string OpenCode sends to the model (env + instructions + MCP instructions + skills). Per `sessionID`, in memory, for that process.
28
+
29
+ Title-generation uses the same sessionID with no `<env>` block. Those calls are ignored so they cannot poison the snapshot.
30
+
31
+ ## What it does not freeze
32
+
33
+ The MCP **tool list**. If a server appears or disappears mid-session, tool schemas after the built-in set can still break the hash. Env/date/AGENTS.md will not.
34
+
35
+ A process restart drops the snapshot. The first turn after a relaunch writes a new prefix (one cold prefill), then it holds again.
36
+
37
+ `/compact` still rewrites history. The system prefix stays; the conversation after it is a new hash.
38
+
39
+ ## Debug
40
+
41
+ ```bash
42
+ OPENCODE_CONSISTENT_LOG=1 opencode
43
+ ```
44
+
45
+ Writes `~/.local/state/opencode/opencode-consistent.log` with `snapshot` / `restore` and a 12-char sha of the frozen bytes. Same sha on restore means it held.
46
+
47
+ ## License
48
+
49
+ MIT
package/freeze.js ADDED
@@ -0,0 +1,40 @@
1
+ const MAX_SESSIONS = 32
2
+ const snapshots = new Map()
3
+
4
+ export function isEnvBearing(system) {
5
+ if (!Array.isArray(system) || system.length === 0) return false
6
+ const text = system.join("\n")
7
+ return text.includes("<env>") || text.includes("Today's date:")
8
+ }
9
+
10
+ function remember(sessionID, system) {
11
+ if (snapshots.has(sessionID)) snapshots.delete(sessionID)
12
+ snapshots.set(sessionID, system.slice())
13
+ while (snapshots.size > MAX_SESSIONS) {
14
+ const oldest = snapshots.keys().next().value
15
+ snapshots.delete(oldest)
16
+ }
17
+ }
18
+
19
+ /** @returns {"snapshot"|"restore"|"skip"} */
20
+ export function freezeSessionSystem(sessionID, system) {
21
+ if (!sessionID || !Array.isArray(system) || !isEnvBearing(system)) return "skip"
22
+ const prev = snapshots.get(sessionID)
23
+ if (!prev) {
24
+ remember(sessionID, system)
25
+ return "snapshot"
26
+ }
27
+ system.length = 0
28
+ for (const part of prev) system.push(part)
29
+ snapshots.delete(sessionID)
30
+ snapshots.set(sessionID, prev)
31
+ return "restore"
32
+ }
33
+
34
+ export function forgetSession(sessionID) {
35
+ if (sessionID) snapshots.delete(sessionID)
36
+ }
37
+
38
+ export function resetSnapshots() {
39
+ snapshots.clear()
40
+ }
package/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import { createHash } from "node:crypto"
2
+ import fs from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { freezeSessionSystem, forgetSession } from "./freeze.js"
6
+
7
+ function digest(system) {
8
+ return createHash("sha256").update(system.join("\n")).digest("hex").slice(0, 12)
9
+ }
10
+
11
+ function logLine(kind, sessionID, system) {
12
+ if (process.env.OPENCODE_CONSISTENT_LOG !== "1") return
13
+ try {
14
+ const dir = path.join(os.homedir(), ".local", "state", "opencode")
15
+ fs.mkdirSync(dir, { recursive: true })
16
+ const file = path.join(dir, "opencode-consistent.log")
17
+ const line = `${new Date().toISOString()} ${kind} session=${sessionID.slice(0, 12)} sha=${digest(system)} parts=${system.length} bytes=${system.join("\n").length}\n`
18
+ fs.appendFileSync(file, line)
19
+ } catch {
20
+ /* logging must never break a turn */
21
+ }
22
+ }
23
+
24
+ export const OpencodeConsistentPlugin = async () => ({
25
+ "experimental.chat.system.transform": async (input, output) => {
26
+ const sessionID = input?.sessionID
27
+ const system = output?.system
28
+ const kind = freezeSessionSystem(sessionID, system)
29
+ if (kind !== "skip") logLine(kind, sessionID, system)
30
+ },
31
+ event: async ({ event }) => {
32
+ const type = event?.type
33
+ const id = event?.properties?.sessionID ?? event?.properties?.info?.id
34
+ if (typeof id === "string" && type === "session.deleted") forgetSession(id)
35
+ },
36
+ })
37
+
38
+ export default OpencodeConsistentPlugin
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "opencode-consistent",
3
+ "version": "0.1.0",
4
+ "description": "Freeze OpenCode's system prefix after the first turn of a session so prefix caches (vLLM, Anthropic, OpenAI) actually hit.",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "freeze.js",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "opencode",
18
+ "plugin",
19
+ "prefix-cache",
20
+ "vllm",
21
+ "kv-cache",
22
+ "prompt-cache"
23
+ ],
24
+ "author": "Nguyen Phan <nguyen@nguyenphan.org>",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/NguyenPhan2810/opencode-consistent.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/NguyenPhan2810/opencode-consistent/issues"
32
+ },
33
+ "homepage": "https://github.com/NguyenPhan2810/opencode-consistent#readme",
34
+ "engines": {
35
+ "opencode": ">=1.18.0"
36
+ },
37
+ "peerDependencies": {
38
+ "@opencode-ai/plugin": ">=1.18.0"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "@opencode-ai/plugin": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "scripts": {
46
+ "test": "node freeze.test.js"
47
+ }
48
+ }