opencode-consistent 0.1.0 → 0.4.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.
Files changed (5) hide show
  1. package/README.md +29 -12
  2. package/freeze.js +110 -13
  3. package/index.js +117 -20
  4. package/package.json +6 -4
  5. package/wait.js +69 -0
package/README.md CHANGED
@@ -1,16 +1,21 @@
1
1
  # opencode-consistent
2
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.
3
+ OpenCode rebuilds the system prompt on every turn. In 1.18.27 the `<env>` block is cwd, worktree, git yes/no, platform, and `Today's date: ${new Date().toDateString()}`. Prefix caches hash from token 0, so when the calendar rolls the whole session misses.
4
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.
5
+ AGENTS.md, MCP instructions, and the skill list are re-read from disk. If you did not edit them, a restart already sends the same bytes. This plugin does **not** freeze those, so a mid-session edit still reaches the model (and correctly misses the old prefix).
6
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.
7
+ The other restart miss is MCP **tools**, which are not in the system string. They sit after the built-in tool schemas. If those servers are still connecting, vLLM hashes builtins only (1,792 tokens = 7×256 blocks) and prefills the rest.
8
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.
9
+ This plugin:
10
+
11
+ 1. **Pins `Today's date`** on the first real turn of a `sessionID` and rewrites just that field later, including after quit/reopen.
12
+ 2. **Waits** in `chat.message` until the same MCP servers are `connected` (or have finished failing) so `SessionTools.resolve` includes the same tool block.
13
+
14
+ Upstream: [#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).
10
15
 
11
16
  ## Install
12
17
 
13
- Put it **last** in the plugin list so other plugins mutate first, then this freezes the result:
18
+ Put it **last** in the plugin list:
14
19
 
15
20
  ```json
16
21
  {
@@ -20,21 +25,33 @@ Put it **last** in the plugin list so other plugins mutate first, then this free
20
25
  }
21
26
  ```
22
27
 
23
- Restart OpenCode. Plugins load at process start.
28
+ Optional wait budget (milliseconds). Default `15000`. `0` polls once and does not block.
29
+
30
+ ```json
31
+ {
32
+ "plugin": [
33
+ ["opencode-consistent", { "mcpWaitMs": 20000 }]
34
+ ]
35
+ }
36
+ ```
37
+
38
+ Same value via `OPENCODE_CONSISTENT_MCP_WAIT_MS`. Restart OpenCode. Plugins load at process start.
24
39
 
25
40
  ## What it freezes
26
41
 
27
- The joined system string OpenCode sends to the model (env + instructions + MCP instructions + skills). Per `sessionID`, in memory, for that process.
42
+ `Today's date` per `sessionID`, on disk under `~/.local/state/opencode/consistent/`.
43
+
44
+ The list of MCP server names that were `connected` when that date was first pinned. Later turns wait until those names are `connected` again.
28
45
 
29
- Title-generation uses the same sessionID with no `<env>` block. Those calls are ignored so they cannot poison the snapshot.
46
+ Title-generation has no date line. Those calls are ignored.
30
47
 
31
48
  ## What it does not freeze
32
49
 
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.
50
+ AGENTS.md, cwd, skills, MCP instruction prose. Change them and the next turn is live and a new prefix hash.
34
51
 
35
- A process restart drops the snapshot. The first turn after a relaunch writes a new prefix (one cold prefill), then it holds again.
52
+ Tool JSON schemas. The plugin cannot inject a missing MCP tool. If a server is still down after `mcpWaitMs`, or it advertises different tools, the hash still misses after the built-in set.
36
53
 
37
- `/compact` still rewrites history. The system prefix stays; the conversation after it is a new hash.
54
+ `/compact` still rewrites history.
38
55
 
39
56
  ## Debug
40
57
 
@@ -42,7 +59,7 @@ A process restart drops the snapshot. The first turn after a relaunch writes a n
42
59
  OPENCODE_CONSISTENT_LOG=1 opencode
43
60
  ```
44
61
 
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.
62
+ Writes `~/.local/state/opencode/opencode-consistent.log` with `wait ok|timeout` and `snapshot` / `restore date=…`. Restore keeps the first-turn date; `sha=` of the full system string is allowed to move when AGENTS.md or skills change.
46
63
 
47
64
  ## License
48
65
 
package/freeze.js CHANGED
@@ -1,38 +1,135 @@
1
+ import fs from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
1
5
  const MAX_SESSIONS = 32
6
+ /** JS Date#toDateString: "Thu Sep 03 2026" */
7
+ export const DATE_LINE = /Today's date:\s+[A-Za-z]{3} [A-Za-z]{3} +\d{1,2} \d{4}/
8
+ const DATE_VALUE = /Today's date:\s+([A-Za-z]{3} [A-Za-z]{3} +\d{1,2} \d{4})/
9
+
10
+ /** @type {Map<string, { date: string, mcp: { connected: string[] } | null, savedAt?: number }>} */
2
11
  const snapshots = new Map()
3
12
 
13
+ export function extractDate(system) {
14
+ const text = Array.isArray(system) ? system.join("\n") : typeof system === "string" ? system : ""
15
+ const match = text.match(DATE_VALUE)
16
+ return match ? match[1] : null
17
+ }
18
+
19
+ export function applyDate(system, date) {
20
+ if (!date || !Array.isArray(system)) return false
21
+ let hit = false
22
+ for (let i = 0; i < system.length; i++) {
23
+ if (DATE_LINE.test(system[i])) {
24
+ system[i] = system[i].replace(DATE_LINE, `Today's date: ${date}`)
25
+ hit = true
26
+ }
27
+ }
28
+ return hit
29
+ }
30
+
4
31
  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:")
32
+ return extractDate(system) != null
8
33
  }
9
34
 
10
- function remember(sessionID, system) {
35
+ export function stateDir() {
36
+ return process.env.OPENCODE_CONSISTENT_DIR || path.join(os.homedir(), ".local", "state", "opencode", "consistent")
37
+ }
38
+
39
+ function fileFor(sessionID) {
40
+ const safe = String(sessionID).replace(/[^A-Za-z0-9._-]/g, "_")
41
+ return path.join(stateDir(), `${safe}.json`)
42
+ }
43
+
44
+ function cloneRecord(rec) {
45
+ return {
46
+ date: rec.date,
47
+ mcp: rec.mcp && Array.isArray(rec.mcp.connected) ? { connected: rec.mcp.connected.slice() } : null,
48
+ savedAt: rec.savedAt,
49
+ }
50
+ }
51
+
52
+ export function loadRecord(sessionID) {
53
+ if (!sessionID) return null
54
+ try {
55
+ const raw = fs.readFileSync(fileFor(sessionID), "utf8")
56
+ const parsed = JSON.parse(raw)
57
+ let date = typeof parsed.date === "string" ? parsed.date : null
58
+ if (!date && Array.isArray(parsed.system)) date = extractDate(parsed.system)
59
+ if (!date) return null
60
+ const mcp =
61
+ parsed.mcp && Array.isArray(parsed.mcp.connected) ? { connected: parsed.mcp.connected.map(String) } : null
62
+ return { date, mcp, savedAt: parsed.savedAt }
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ export function saveRecord(sessionID, rec) {
69
+ if (!sessionID || !rec?.date) return
70
+ fs.mkdirSync(stateDir(), { recursive: true })
71
+ const body = { date: rec.date, savedAt: rec.savedAt ?? Date.now() }
72
+ if (rec.mcp && Array.isArray(rec.mcp.connected)) {
73
+ body.mcp = { connected: rec.mcp.connected.slice() }
74
+ }
75
+ fs.writeFileSync(fileFor(sessionID), JSON.stringify(body))
76
+ }
77
+
78
+ export function forgetDisk(sessionID) {
79
+ if (!sessionID) return
80
+ try {
81
+ fs.unlinkSync(fileFor(sessionID))
82
+ } catch {
83
+ /* missing is fine */
84
+ }
85
+ }
86
+
87
+ function remember(sessionID, rec) {
11
88
  if (snapshots.has(sessionID)) snapshots.delete(sessionID)
12
- snapshots.set(sessionID, system.slice())
89
+ snapshots.set(sessionID, cloneRecord(rec))
13
90
  while (snapshots.size > MAX_SESSIONS) {
14
91
  const oldest = snapshots.keys().next().value
15
92
  snapshots.delete(oldest)
16
93
  }
17
94
  }
18
95
 
96
+ export function peekRecord(sessionID) {
97
+ if (!sessionID) return null
98
+ const live = snapshots.get(sessionID)
99
+ if (live) return cloneRecord(live)
100
+ const disk = loadRecord(sessionID)
101
+ return disk ? cloneRecord(disk) : null
102
+ }
103
+
19
104
  /** @returns {"snapshot"|"restore"|"skip"} */
20
105
  export function freezeSessionSystem(sessionID, system) {
21
- if (!sessionID || !Array.isArray(system) || !isEnvBearing(system)) return "skip"
22
- const prev = snapshots.get(sessionID)
106
+ if (!sessionID || !Array.isArray(system)) return "skip"
107
+ const date = extractDate(system)
108
+ if (!date) return "skip"
109
+ const prev = snapshots.get(sessionID) || loadRecord(sessionID)
23
110
  if (!prev) {
24
- remember(sessionID, system)
111
+ const rec = { date, mcp: null, savedAt: Date.now() }
112
+ remember(sessionID, rec)
113
+ saveRecord(sessionID, rec)
25
114
  return "snapshot"
26
115
  }
27
- system.length = 0
28
- for (const part of prev) system.push(part)
29
- snapshots.delete(sessionID)
30
- snapshots.set(sessionID, prev)
116
+ applyDate(system, prev.date)
117
+ remember(sessionID, prev)
31
118
  return "restore"
32
119
  }
33
120
 
121
+ export function setMcpConnected(sessionID, connected) {
122
+ const rec = snapshots.get(sessionID) || loadRecord(sessionID)
123
+ if (!rec) return
124
+ rec.mcp = { connected: [...new Set((connected ?? []).map(String))].sort() }
125
+ remember(sessionID, rec)
126
+ saveRecord(sessionID, rec)
127
+ }
128
+
34
129
  export function forgetSession(sessionID) {
35
- if (sessionID) snapshots.delete(sessionID)
130
+ if (!sessionID) return
131
+ snapshots.delete(sessionID)
132
+ forgetDisk(sessionID)
36
133
  }
37
134
 
38
135
  export function resetSnapshots() {
package/index.js CHANGED
@@ -2,37 +2,134 @@ import { createHash } from "node:crypto"
2
2
  import fs from "node:fs"
3
3
  import os from "node:os"
4
4
  import path from "node:path"
5
- import { freezeSessionSystem, forgetSession } from "./freeze.js"
5
+ import { freezeSessionSystem, forgetSession, peekRecord, setMcpConnected } from "./freeze.js"
6
+ import { connectedNames, hasConnected, isSettled, waitMsFrom, waitUntil, wantedServers } from "./wait.js"
6
7
 
7
8
  function digest(system) {
8
9
  return createHash("sha256").update(system.join("\n")).digest("hex").slice(0, 12)
9
10
  }
10
11
 
11
- function logLine(kind, sessionID, system) {
12
+ function logPath() {
13
+ return path.join(os.homedir(), ".local", "state", "opencode", "opencode-consistent.log")
14
+ }
15
+
16
+ function logRaw(line) {
12
17
  if (process.env.OPENCODE_CONSISTENT_LOG !== "1") return
13
18
  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
+ fs.mkdirSync(path.dirname(logPath()), { recursive: true })
20
+ fs.appendFileSync(logPath(), `${new Date().toISOString()} ${line}\n`)
19
21
  } catch {
20
22
  /* logging must never break a turn */
21
23
  }
22
24
  }
23
25
 
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
- })
26
+ function logSystem(kind, sessionID, system) {
27
+ const rec = peekRecord(sessionID)
28
+ const date = rec?.date ?? "none"
29
+ logRaw(
30
+ `${kind} session=${sessionID.slice(0, 12)} date=${date.replaceAll(" ", "_")} sha=${digest(system)} parts=${system.length} bytes=${system.join("\n").length}`,
31
+ )
32
+ }
33
+
34
+ export async function readStatus(client) {
35
+ if (!client?.mcp?.status) return {}
36
+ try {
37
+ const result = await client.mcp.status()
38
+ const data = result?.data
39
+ if (data && typeof data === "object" && !Array.isArray(data)) return data
40
+ return {}
41
+ } catch {
42
+ return {}
43
+ }
44
+ }
45
+
46
+ async function readWanted(client) {
47
+ if (!client?.config?.get) return []
48
+ try {
49
+ const result = await client.config.get()
50
+ const cfg = result?.data ?? result
51
+ return wantedServers(cfg?.mcp)
52
+ } catch {
53
+ return []
54
+ }
55
+ }
56
+
57
+ export const OpencodeConsistentPlugin = async (ctx = {}, options = {}) => {
58
+ const client = ctx.client
59
+ let wanted = []
60
+ /** @type {Map<string, Record<string, { status: string }>>} */
61
+ const lastWait = new Map()
62
+ const timeoutMs = waitMsFrom(options.mcpWaitMs)
63
+ const intervalMs =
64
+ options.mcpWaitIntervalMs !== undefined && Number.isFinite(Number(options.mcpWaitIntervalMs))
65
+ ? Math.max(0, Number(options.mcpWaitIntervalMs))
66
+ : undefined
67
+
68
+ return {
69
+ config: async (cfg) => {
70
+ wanted = wantedServers(cfg?.mcp)
71
+ },
72
+ "chat.message": async (input) => {
73
+ const sessionID = input?.sessionID
74
+ if (!sessionID) return
75
+ try {
76
+ if (wanted.length === 0) wanted = await readWanted(client)
77
+ const rec = peekRecord(sessionID)
78
+ const required = rec?.mcp?.connected
79
+ let mode
80
+ let predicate
81
+ if (Array.isArray(required)) {
82
+ if (required.length === 0) {
83
+ logRaw(`wait skip session=${sessionID.slice(0, 12)} reason=no-mcp`)
84
+ lastWait.set(sessionID, {})
85
+ return
86
+ }
87
+ mode = "restore"
88
+ predicate = (status) => hasConnected(status, required)
89
+ } else {
90
+ if (wanted.length === 0) {
91
+ logRaw(`wait skip session=${sessionID.slice(0, 12)} reason=no-wanted`)
92
+ lastWait.set(sessionID, {})
93
+ return
94
+ }
95
+ mode = rec ? "upgrade" : "first"
96
+ predicate = (status) => isSettled(status, wanted)
97
+ }
98
+ const result = await waitUntil({
99
+ poll: () => readStatus(client),
100
+ ok: predicate,
101
+ timeoutMs,
102
+ intervalMs,
103
+ })
104
+ lastWait.set(sessionID, result.last ?? {})
105
+ const connected = connectedNames(result.last).join(",") || "-"
106
+ logRaw(
107
+ `wait ${result.ok ? "ok" : "timeout"} mode=${mode} session=${sessionID.slice(0, 12)} elapsed=${result.elapsed} connected=${connected}`,
108
+ )
109
+ } catch (err) {
110
+ logRaw(`wait error session=${sessionID.slice(0, 12)} ${err?.message ?? err}`)
111
+ }
112
+ },
113
+ "experimental.chat.system.transform": async (input, output) => {
114
+ const sessionID = input?.sessionID
115
+ const system = output?.system
116
+ const kind = freezeSessionSystem(sessionID, system)
117
+ if (kind === "skip") return
118
+ const rec = peekRecord(sessionID)
119
+ const waited = lastWait.get(sessionID)
120
+ const shouldCapture = waited !== undefined && (kind === "snapshot" || rec?.mcp == null)
121
+ if (shouldCapture) setMcpConnected(sessionID, connectedNames(waited))
122
+ logSystem(kind, sessionID, system)
123
+ },
124
+ event: async ({ event }) => {
125
+ const type = event?.type
126
+ const id = event?.properties?.sessionID ?? event?.properties?.info?.id
127
+ if (typeof id === "string" && type === "session.deleted") {
128
+ lastWait.delete(id)
129
+ forgetSession(id)
130
+ }
131
+ },
132
+ }
133
+ }
37
134
 
38
135
  export default OpencodeConsistentPlugin
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.4.0",
4
+ "description": "Pin Today's date in OpenCode sessions and wait for the same MCP servers so prefix caches actually hit.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "exports": {
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "index.js",
12
12
  "freeze.js",
13
+ "wait.js",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],
@@ -19,7 +20,8 @@
19
20
  "prefix-cache",
20
21
  "vllm",
21
22
  "kv-cache",
22
- "prompt-cache"
23
+ "prompt-cache",
24
+ "mcp"
23
25
  ],
24
26
  "author": "Nguyen Phan <nguyen@nguyenphan.org>",
25
27
  "license": "MIT",
@@ -43,6 +45,6 @@
43
45
  }
44
46
  },
45
47
  "scripts": {
46
- "test": "node freeze.test.js"
48
+ "test": "node freeze.test.js && node wait.test.js && node plugin.test.js"
47
49
  }
48
50
  }
package/wait.js ADDED
@@ -0,0 +1,69 @@
1
+ export const DEFAULT_WAIT_MS = 15_000
2
+ export const DEFAULT_INTERVAL_MS = 200
3
+
4
+ const TERMINAL = new Set(["connected", "failed", "needs_auth", "needs_client_registration"])
5
+
6
+ export function waitMsFrom(option) {
7
+ if (option !== undefined && option !== null && Number.isFinite(Number(option))) {
8
+ return Math.max(0, Number(option))
9
+ }
10
+ const env = process.env.OPENCODE_CONSISTENT_MCP_WAIT_MS
11
+ if (env !== undefined && env !== "" && Number.isFinite(Number(env))) {
12
+ return Math.max(0, Number(env))
13
+ }
14
+ return DEFAULT_WAIT_MS
15
+ }
16
+
17
+ /** MCP names that OpenCode will try to connect (enabled !== false). */
18
+ export function wantedServers(mcpConfig) {
19
+ if (!mcpConfig || typeof mcpConfig !== "object") return []
20
+ return Object.entries(mcpConfig)
21
+ .filter(([, value]) => value && typeof value === "object" && value.enabled !== false)
22
+ .map(([name]) => name)
23
+ .sort()
24
+ }
25
+
26
+ export function connectedNames(status) {
27
+ if (!status || typeof status !== "object") return []
28
+ return Object.entries(status)
29
+ .filter(([, value]) => value && value.status === "connected")
30
+ .map(([name]) => name)
31
+ .sort()
32
+ }
33
+
34
+ /**
35
+ * Connecting servers report as "disabled" on GET /mcp until they finish.
36
+ * Treat disabled/missing as in-flight for names that are configured and enabled.
37
+ */
38
+ export function isSettled(status, wanted) {
39
+ if (!Array.isArray(wanted) || wanted.length === 0) return true
40
+ return wanted.every((name) => TERMINAL.has(status?.[name]?.status))
41
+ }
42
+
43
+ export function hasConnected(status, required) {
44
+ if (!Array.isArray(required) || required.length === 0) return true
45
+ return required.every((name) => status?.[name]?.status === "connected")
46
+ }
47
+
48
+ /**
49
+ * Poll until `ok(last)` or timeout. First poll is immediate.
50
+ * @returns {{ ok: boolean, elapsed: number, last: any, timedOut: boolean }}
51
+ */
52
+ export async function waitUntil({ poll, ok, timeoutMs, intervalMs, sleep, now }) {
53
+ const t0 = (now ?? Date.now)()
54
+ const timeout = timeoutMs ?? DEFAULT_WAIT_MS
55
+ const step = intervalMs ?? DEFAULT_INTERVAL_MS
56
+ const nap = sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
57
+ const clock = now ?? Date.now
58
+
59
+ let last = await poll()
60
+ if (ok(last)) return { ok: true, elapsed: clock() - t0, last, timedOut: false }
61
+ if (timeout <= 0) return { ok: false, elapsed: clock() - t0, last, timedOut: true }
62
+
63
+ while (clock() - t0 < timeout) {
64
+ await nap(step)
65
+ last = await poll()
66
+ if (ok(last)) return { ok: true, elapsed: clock() - t0, last, timedOut: false }
67
+ }
68
+ return { ok: false, elapsed: clock() - t0, last, timedOut: true }
69
+ }