@jimmyyen/opencode-context-cache 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/index.mjs +337 -0
  4. package/package.json +51 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JackDrogon
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,68 @@
1
+ # @jimmyyen/opencode-context-cache
2
+
3
+ OpenCode plugin for stable, privacy-preserving prompt cache key and sticky session management.
4
+
5
+ Forked from [JackDrogon/opencode-context-cache](https://github.com/JackDrogon/opencode-context-cache).
6
+
7
+ ## Why
8
+
9
+ OpenCode sessions can lose cache efficiency when session identifiers vary between providers, environments, or runs. This plugin standardizes cache key generation with predictable precedence and sends only a SHA256 digest upstream, so the AI provider sees a stable cache identity.
10
+
11
+ Observed result from a real run: input cache hit rate improved from a near-zero baseline to **97.99%** (`164736 / 168112`).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @jimmyyen/opencode-context-cache
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ Add to your `opencode.jsonc` plugin array. The package is resolved via Node module resolution, so you reference it by name:
22
+
23
+ ```jsonc
24
+ {
25
+ "$schema": "https://opencode.ai/config.json",
26
+ "plugin": [
27
+ "@jimmyyen/opencode-context-cache"
28
+ ],
29
+ "provider": {
30
+ "openai": {
31
+ "options": {
32
+ "setCacheKey": true
33
+ }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ The plugin auto-registers on the `chat.params` hook at import time. No additional setup needed after listing it in `plugin`.
40
+
41
+ ## Cache key precedence
42
+
43
+ 1. `OPENCODE_PROMPT_CACHE_KEY` env var
44
+ 2. `OPENCODE_STICKY_SESSION_ID` env var
45
+ 3. Auto-generated `user@host:<absolute_cwd>`
46
+ 4. Existing model headers (`x-session-id`, `conversation_id`, `session_id`)
47
+ 5. OpenCode `sessionID`
48
+
49
+ The key is SHA256-hashed before being sent to the server. Already-hashed values (64-char hex) are detected and passed through without re-hashing.
50
+
51
+ ## Debug logging
52
+
53
+ ```bash
54
+ export OPENCODE_CONTEXT_CACHE_DEBUG=1
55
+ ```
56
+
57
+ Logs are written to `context-cache.log` next to the plugin file with timestamps, PID, and key resolution details.
58
+
59
+ ## Exports
60
+
61
+ | Export | Type | Description |
62
+ |--------|------|-------------|
63
+ | `OpenCodeContextCachePlugin` (default) | async factory | Main plugin factory |
64
+ | `EnhancedCachePlugin` | alias | Backward-compatible alias |
65
+
66
+ ## License
67
+
68
+ MIT. See `LICENSE`.
package/index.mjs ADDED
@@ -0,0 +1,337 @@
1
+ /**
2
+ * opencode plugin: OpenCode Context Cache
3
+ *
4
+ * Features:
5
+ * - Per-project cache isolation using absolute path with user@host prefix
6
+ * - Support for ALL providers (not just specific ones)
7
+ * - Debug logging to file (same directory as plugin)
8
+ * - Smart cache key generation with multiple fallbacks
9
+ * - Unified session header and cache key management
10
+ * - SHA256 hashed cache key for privacy (server sees only hash)
11
+ *
12
+ * Cache Key Format (raw): {user}@{host}:{directory}
13
+ * Cache Key Format (sent to server): SHA256(raw)
14
+ * Example: c@my-laptop:revm -> sha256:abc123...
15
+ *
16
+ * Cache Key Precedence:
17
+ * 1. OPENCODE_PROMPT_CACHE_KEY env var (manual override)
18
+ * 2. OPENCODE_STICKY_SESSION_ID env var (manual override)
19
+ * 3. User@Host:Directory (auto-generated)
20
+ * 4. Model headers (x-session-id / conversation_id / session_id)
21
+ * 5. opencode sessionID (fallback)
22
+ */
23
+
24
+ import { hostname, userInfo } from "os";
25
+ import { dirname, join } from "path";
26
+ import { appendFileSync, existsSync, mkdirSync } from "fs";
27
+ import { fileURLToPath } from "url";
28
+ import { createHash } from "crypto";
29
+
30
+ const SESSION_ID_HEADER_NAMES = ["x-session-id", "conversation_id", "session_id"];
31
+ const PROMPT_CACHE_KEY_ENV_VAR = "OPENCODE_PROMPT_CACHE_KEY";
32
+ const STICKY_SESSION_ID_ENV_VAR = "OPENCODE_STICKY_SESSION_ID";
33
+ const CACHE_DEBUG_ENV_VAR = "OPENCODE_CONTEXT_CACHE_DEBUG";
34
+
35
+ // Get plugin directory (where this file is located)
36
+ const __filename = fileURLToPath(import.meta.url);
37
+ const __dirname = dirname(__filename);
38
+ const LOG_FILE_PATH = join(__dirname, "context-cache.log");
39
+
40
+ class DebugLogger {
41
+ constructor(logFilePath) {
42
+ this.logFilePath = logFilePath;
43
+ this.debugEnabled = null;
44
+ this.loggedInputStructure = false;
45
+ this.ensureLogDirectory();
46
+ }
47
+
48
+ ensureLogDirectory() {
49
+ try {
50
+ const logDir = dirname(this.logFilePath);
51
+ if (!existsSync(logDir)) {
52
+ mkdirSync(logDir, { recursive: true });
53
+ }
54
+ } catch {
55
+ // Ignore errors, fallback will use console.
56
+ }
57
+ }
58
+
59
+ isEnabled() {
60
+ if (this.debugEnabled === null) {
61
+ this.debugEnabled =
62
+ process?.env?.[CACHE_DEBUG_ENV_VAR] === "1" ||
63
+ process?.env?.[CACHE_DEBUG_ENV_VAR] === "true";
64
+ }
65
+ return this.debugEnabled;
66
+ }
67
+
68
+ toLogString(value) {
69
+ if (typeof value !== "object" || value === null) {
70
+ return String(value);
71
+ }
72
+
73
+ try {
74
+ return JSON.stringify(value);
75
+ } catch {
76
+ return String(value);
77
+ }
78
+ }
79
+
80
+ log(...args) {
81
+ if (!this.isEnabled()) return;
82
+
83
+ const timestamp = new Date().toISOString();
84
+ const pid = process.pid;
85
+ const message = args.map((arg) => this.toLogString(arg)).join(" ");
86
+
87
+ // Keep each log entry on a single physical line.
88
+ const safeMessage = message.replace(/\n/g, "\\n").replace(/\r/g, "\\r");
89
+ const logLine = `[${timestamp}] [pid:${pid}] [context-cache] ${safeMessage}\n`;
90
+
91
+ try {
92
+ // O_APPEND keeps each append atomic on POSIX filesystems.
93
+ appendFileSync(this.logFilePath, logLine, "utf8");
94
+ } catch {
95
+ // Fallback to stderr when file append fails.
96
+ console.error(`[pid:${pid}] [context-cache]`, ...args);
97
+ }
98
+ }
99
+
100
+ logInputStructureOnce(input) {
101
+ if (this.loggedInputStructure) return;
102
+ this.loggedInputStructure = true;
103
+
104
+ const safeInput = {
105
+ hasProvider: !!input?.provider,
106
+ providerKeys: input?.provider ? Object.keys(input.provider) : [],
107
+ hasModel: !!input?.model,
108
+ modelKeys: input?.model ? Object.keys(input.model) : [],
109
+ hasSessionID: !!input?.sessionID,
110
+ };
111
+ this.log("Input structure:", safeInput);
112
+ }
113
+ }
114
+
115
+ class CacheKeyResolver {
116
+ constructor(logger) {
117
+ this.logger = logger;
118
+ }
119
+
120
+ sha256(input) {
121
+ return createHash("sha256").update(input, "utf8").digest("hex");
122
+ }
123
+
124
+ isSha256Hex(value) {
125
+ if (typeof value !== "string") return false;
126
+ const v = value.trim();
127
+ if (v.length !== 64) return false;
128
+ return /^[a-fA-F0-9]{64}$/.test(v);
129
+ }
130
+
131
+ getTrimmedEnv(name) {
132
+ const value = process?.env?.[name];
133
+ return typeof value === "string" ? value.trim() : "";
134
+ }
135
+
136
+ getUsername() {
137
+ try {
138
+ const ui = userInfo();
139
+ if (ui && ui.username) {
140
+ return ui.username;
141
+ }
142
+ } catch {
143
+ // userInfo may fail in restricted environments.
144
+ }
145
+
146
+ return (
147
+ process?.env?.USER ||
148
+ process?.env?.USERNAME ||
149
+ process?.env?.LOGNAME ||
150
+ "unknown"
151
+ );
152
+ }
153
+
154
+ getUserHostDirectoryKey() {
155
+ try {
156
+ const user = this.getUsername();
157
+ const host = hostname();
158
+ const cwd = process.cwd();
159
+ return `${user}@${host}:${cwd}`;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ getSessionIdFromHeaders(input) {
166
+ const headers =
167
+ input?.model?.headers && typeof input.model.headers === "object"
168
+ ? input.model.headers
169
+ : {};
170
+
171
+ const value = SESSION_ID_HEADER_NAMES.map((key) => headers[key])
172
+ .find((v) => typeof v === "string" && v.trim())
173
+ ?.trim?.();
174
+
175
+ return value || null;
176
+ }
177
+
178
+ resolveCacheKey(input) {
179
+ let rawKey = null;
180
+ let source = null;
181
+ let alreadyHashed = false;
182
+
183
+ // 1) Explicit env override.
184
+ const promptCacheKey = this.getTrimmedEnv(PROMPT_CACHE_KEY_ENV_VAR);
185
+ if (promptCacheKey) {
186
+ rawKey = promptCacheKey;
187
+ source = PROMPT_CACHE_KEY_ENV_VAR;
188
+ }
189
+
190
+ // 2) Secondary env override.
191
+ if (!rawKey) {
192
+ const stickySessionKey = this.getTrimmedEnv(STICKY_SESSION_ID_ENV_VAR);
193
+ if (stickySessionKey) {
194
+ rawKey = stickySessionKey;
195
+ source = STICKY_SESSION_ID_ENV_VAR;
196
+ }
197
+ }
198
+
199
+ // 3) Preferred stable default.
200
+ if (!rawKey) {
201
+ const userHostDirKey = this.getUserHostDirectoryKey();
202
+ if (userHostDirKey) {
203
+ rawKey = userHostDirKey;
204
+ source = "user@host:directory";
205
+ }
206
+ }
207
+
208
+ // 4) Existing model headers only when no stable default exists.
209
+ if (!rawKey) {
210
+ const headerValue = this.getSessionIdFromHeaders(input);
211
+ if (headerValue) {
212
+ rawKey = headerValue;
213
+ source = "model headers";
214
+ alreadyHashed = this.isSha256Hex(rawKey);
215
+ }
216
+ }
217
+
218
+ // 5) OpenCode session fallback.
219
+ if (!rawKey) {
220
+ const sessionID = typeof input?.sessionID === "string" ? input.sessionID : "";
221
+ if (sessionID) {
222
+ rawKey = sessionID;
223
+ source = "opencode sessionID";
224
+ }
225
+ }
226
+
227
+ if (!rawKey) {
228
+ this.logger.log("No stable cache key found");
229
+ return null;
230
+ }
231
+
232
+ const hashedKey = alreadyHashed ? rawKey : this.sha256(rawKey);
233
+
234
+ if (alreadyHashed) {
235
+ this.logger.log("Cache key already looks hashed; skipping sha256");
236
+ }
237
+
238
+ this.logger.log(`Using cache key from ${source}`);
239
+ this.logger.log(` Raw: ${rawKey}`);
240
+ this.logger.log(` Hash: ${hashedKey}`);
241
+
242
+ return { raw: rawKey, hashed: hashedKey };
243
+ }
244
+ }
245
+
246
+ class CacheKeyApplier {
247
+ constructor(logger) {
248
+ this.logger = logger;
249
+ }
250
+
251
+ applyPromptCacheKey(output, cacheKey) {
252
+ const existingOutputOptions =
253
+ output?.options && typeof output.options === "object" ? output.options : {};
254
+
255
+ output.options = {
256
+ ...existingOutputOptions,
257
+ promptCacheKey: cacheKey,
258
+ };
259
+ }
260
+
261
+ applySessionHeaders(input, cacheKey) {
262
+ if (input?.model && typeof input.model === "object") {
263
+ const headers =
264
+ input.model.headers && typeof input.model.headers === "object"
265
+ ? input.model.headers
266
+ : (input.model.headers = {});
267
+
268
+ for (const headerKey of SESSION_ID_HEADER_NAMES) {
269
+ headers[headerKey] = cacheKey;
270
+ }
271
+
272
+ if (this.logger.isEnabled()) {
273
+ headers["x-cache-debug"] = "1";
274
+ }
275
+
276
+ this.logger.log("Set final cache key (hashed):", cacheKey);
277
+ return;
278
+ }
279
+
280
+ this.logger.log("Input model is missing or not an object, cannot set session headers");
281
+ }
282
+
283
+ apply(input, output, cacheKey) {
284
+ this.applyPromptCacheKey(output, cacheKey);
285
+ this.applySessionHeaders(input, cacheKey);
286
+ }
287
+ }
288
+
289
+ class ContextCachePluginRuntime {
290
+ constructor({ logger, keyResolver, keyApplier }) {
291
+ this.logger = logger;
292
+ this.keyResolver = keyResolver;
293
+ this.keyApplier = keyApplier;
294
+ }
295
+
296
+ initialize() {
297
+ this.logger.log("Plugin initialized");
298
+ this.logger.log("Log file location:", this.logger.logFilePath);
299
+ }
300
+
301
+ handleChatParams(input, output) {
302
+ this.logger.logInputStructureOnce(input);
303
+ this.logger.log("Processing provider");
304
+
305
+ const cacheKeyInfo = this.keyResolver.resolveCacheKey(input);
306
+ if (!cacheKeyInfo) {
307
+ this.logger.log("No cache key available");
308
+ return;
309
+ }
310
+
311
+ this.keyApplier.apply(input, output, cacheKeyInfo.hashed);
312
+ }
313
+ }
314
+
315
+ const logger = new DebugLogger(LOG_FILE_PATH);
316
+ const keyResolver = new CacheKeyResolver(logger);
317
+ const keyApplier = new CacheKeyApplier(logger);
318
+ const runtime = new ContextCachePluginRuntime({
319
+ logger,
320
+ keyResolver,
321
+ keyApplier,
322
+ });
323
+
324
+ export const OpenCodeContextCachePlugin = async () => {
325
+ runtime.initialize();
326
+
327
+ return {
328
+ "chat.params": async (input, output) => {
329
+ runtime.handleChatParams(input, output);
330
+ },
331
+ };
332
+ };
333
+
334
+ // Backward-compatible export alias.
335
+ export const EnhancedCachePlugin = OpenCodeContextCachePlugin;
336
+
337
+ export default OpenCodeContextCachePlugin;
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@jimmyyen/opencode-context-cache",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin for stable, privacy-preserving prompt cache key and sticky session management. Fork of JackDrogon/opencode-context-cache.",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./index.mjs"
10
+ }
11
+ },
12
+ "files": [
13
+ "index.mjs",
14
+ "LICENSE",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --check index.mjs"
19
+ },
20
+ "keywords": [
21
+ "opencode",
22
+ "plugin",
23
+ "prompt-cache",
24
+ "context-cache",
25
+ "session",
26
+ "ai"
27
+ ],
28
+ "author": "jimmyyen",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/cawa0505/opencode-context-cache.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/cawa0505/opencode-context-cache/issues"
36
+ },
37
+ "homepage": "https://github.com/cawa0505/opencode-context-cache#readme",
38
+ "contributors": [
39
+ {
40
+ "name": "Jack Drogon",
41
+ "email": "jack.xsuperman@gmail.com",
42
+ "url": "https://github.com/JackDrogon"
43
+ }
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ }
51
+ }