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 +21 -0
- package/README.md +49 -0
- package/freeze.js +40 -0
- package/index.js +38 -0
- package/package.json +48 -0
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
|
+
}
|