context-mode 1.0.56 → 1.0.58
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +29 -9
- package/build/adapters/codex/index.d.ts +25 -15
- package/build/adapters/codex/index.js +162 -38
- package/build/adapters/cursor/hooks.d.ts +2 -0
- package/build/adapters/cursor/hooks.js +4 -0
- package/build/adapters/cursor/index.d.ts +15 -0
- package/build/adapters/cursor/index.js +48 -0
- package/build/adapters/opencode/index.d.ts +4 -0
- package/build/adapters/opencode/index.js +61 -33
- package/build/cli.js +44 -20
- package/build/db-base.d.ts +7 -6
- package/build/db-base.js +65 -1
- package/build/opencode-plugin.d.ts +1 -1
- package/build/opencode-plugin.js +6 -18
- package/build/server.js +48 -2
- package/build/store.d.ts +2 -0
- package/build/store.js +38 -5
- package/cli.bundle.mjs +102 -102
- package/hooks/routing-block.mjs +2 -0
- package/hooks/session-db.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +13 -0
- package/hooks/sessionstart.mjs +5 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +78 -78
- package/skills/context-mode-ops/SKILL.md +3 -1
- package/skills/context-mode-ops/marketing.md +124 -0
|
@@ -151,8 +151,11 @@ export class OpenCodeAdapter {
|
|
|
151
151
|
if (this.platform === "kilo") {
|
|
152
152
|
return [
|
|
153
153
|
resolve("kilo.json"),
|
|
154
|
-
resolve("
|
|
154
|
+
resolve("kilo.jsonc"),
|
|
155
|
+
resolve(".kilo", "kilo.json"),
|
|
156
|
+
resolve(".kilo", "kilo.jsonc"),
|
|
155
157
|
join(homedir(), ".config", "kilo", "kilo.json"),
|
|
158
|
+
join(homedir(), ".config", "kilo", "kilo.jsonc"),
|
|
156
159
|
];
|
|
157
160
|
}
|
|
158
161
|
return [
|
|
@@ -165,7 +168,14 @@ export class OpenCodeAdapter {
|
|
|
165
168
|
];
|
|
166
169
|
}
|
|
167
170
|
getSessionDir() {
|
|
168
|
-
|
|
171
|
+
let configDir;
|
|
172
|
+
if (process.platform === "win32") {
|
|
173
|
+
configDir = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
177
|
+
}
|
|
178
|
+
const dir = join(configDir, this.platform, "context-mode", "sessions");
|
|
169
179
|
mkdirSync(dir, { recursive: true });
|
|
170
180
|
return dir;
|
|
171
181
|
}
|
|
@@ -224,25 +234,38 @@ export class OpenCodeAdapter {
|
|
|
224
234
|
};
|
|
225
235
|
}
|
|
226
236
|
readSettings() {
|
|
227
|
-
// Try project-local paths first, then global config
|
|
228
|
-
// const paths = this.getConfigFilePaths();
|
|
229
|
-
// for (const configPath of paths) {
|
|
230
237
|
this.settingsPath = undefined;
|
|
231
|
-
|
|
238
|
+
const configPaths = this.paths();
|
|
239
|
+
const globalPaths = new Set(configPaths.filter(p => p.includes(homedir())));
|
|
240
|
+
let firstValidSettings = null;
|
|
241
|
+
let firstValidPath;
|
|
242
|
+
for (const configPath of configPaths) {
|
|
232
243
|
try {
|
|
233
244
|
const raw = readFileSync(configPath, "utf-8");
|
|
234
|
-
this.settingsPath = configPath;
|
|
235
245
|
const text = configPath.endsWith(".jsonc") ? stripJsonComments(raw) : raw;
|
|
236
|
-
|
|
246
|
+
const settings = JSON.parse(text);
|
|
247
|
+
if (!firstValidSettings) {
|
|
248
|
+
firstValidSettings = settings;
|
|
249
|
+
firstValidPath = configPath;
|
|
250
|
+
}
|
|
251
|
+
const isGlobalConfig = globalPaths.has(configPath);
|
|
252
|
+
if (this.hasContextModePlugin(settings) || isGlobalConfig) {
|
|
253
|
+
this.settingsPath = configPath;
|
|
254
|
+
return settings;
|
|
255
|
+
}
|
|
237
256
|
}
|
|
238
257
|
catch {
|
|
239
258
|
continue;
|
|
240
259
|
}
|
|
241
260
|
}
|
|
261
|
+
if (firstValidSettings) {
|
|
262
|
+
this.settingsPath = firstValidPath;
|
|
263
|
+
return firstValidSettings;
|
|
264
|
+
}
|
|
242
265
|
return null;
|
|
243
266
|
}
|
|
244
267
|
writeSettings(settings) {
|
|
245
|
-
// Write to opencode.json/kilo.json in current directory
|
|
268
|
+
// Write to opencode.json(c)/kilo.json(c) in current directory
|
|
246
269
|
writeFileSync(this.getSettingsPath(), JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
247
270
|
}
|
|
248
271
|
// ── Diagnostics (doctor) ─────────────────────────────────
|
|
@@ -259,9 +282,8 @@ export class OpenCodeAdapter {
|
|
|
259
282
|
return results;
|
|
260
283
|
}
|
|
261
284
|
// Check for "context-mode" in plugin array
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
const hasPlugin = plugins.some((p) => p.includes("context-mode"));
|
|
285
|
+
const hasPlugin = this.hasContextModePlugin(settings);
|
|
286
|
+
if (Array.isArray(settings.plugin)) {
|
|
265
287
|
results.push({
|
|
266
288
|
check: "Plugin registration",
|
|
267
289
|
status: hasPlugin ? "pass" : "fail",
|
|
@@ -277,7 +299,7 @@ export class OpenCodeAdapter {
|
|
|
277
299
|
results.push({
|
|
278
300
|
check: "Plugin registration",
|
|
279
301
|
status: "fail",
|
|
280
|
-
message:
|
|
302
|
+
message: `No plugin array found in ${this.platform}.json or ${this.platform}.jsonc`,
|
|
281
303
|
fix: "context-mode upgrade",
|
|
282
304
|
});
|
|
283
305
|
}
|
|
@@ -285,7 +307,7 @@ export class OpenCodeAdapter {
|
|
|
285
307
|
results.push({
|
|
286
308
|
check: "SessionStart hook",
|
|
287
309
|
status: "warn",
|
|
288
|
-
message:
|
|
310
|
+
message: `SessionStart not supported in ${this.name} (see issues #14808, #5409)`,
|
|
289
311
|
});
|
|
290
312
|
return results;
|
|
291
313
|
}
|
|
@@ -298,21 +320,17 @@ export class OpenCodeAdapter {
|
|
|
298
320
|
message: `Could not read ${this.platform}.json or ${this.platform}.jsonc`,
|
|
299
321
|
};
|
|
300
322
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
status: "pass",
|
|
308
|
-
message: "context-mode found in plugin array",
|
|
309
|
-
};
|
|
310
|
-
}
|
|
323
|
+
if (this.hasContextModePlugin(settings)) {
|
|
324
|
+
return {
|
|
325
|
+
check: "Plugin registration",
|
|
326
|
+
status: "pass",
|
|
327
|
+
message: "context-mode found in plugin array",
|
|
328
|
+
};
|
|
311
329
|
}
|
|
312
330
|
return {
|
|
313
331
|
check: "Plugin registration",
|
|
314
332
|
status: "fail",
|
|
315
|
-
message:
|
|
333
|
+
message: `context-mode not found in ${this.platform}.json plugin array`,
|
|
316
334
|
fix: "context-mode upgrade",
|
|
317
335
|
};
|
|
318
336
|
}
|
|
@@ -347,20 +365,23 @@ export class OpenCodeAdapter {
|
|
|
347
365
|
return changes;
|
|
348
366
|
}
|
|
349
367
|
backupSettings() {
|
|
350
|
-
|
|
351
|
-
|
|
368
|
+
const check = this.checkPluginRegistration();
|
|
369
|
+
if (!this.settingsPath)
|
|
370
|
+
return null;
|
|
371
|
+
if (check.status === "pass") {
|
|
372
|
+
return this.settingsPath;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
352
375
|
try {
|
|
353
|
-
accessSync(
|
|
354
|
-
this.settingsPath
|
|
355
|
-
|
|
356
|
-
copyFileSync(configPath, backupPath);
|
|
376
|
+
accessSync(this.settingsPath, constants.R_OK);
|
|
377
|
+
const backupPath = this.settingsPath + ".bak";
|
|
378
|
+
copyFileSync(this.settingsPath, backupPath);
|
|
357
379
|
return backupPath;
|
|
358
380
|
}
|
|
359
381
|
catch {
|
|
360
|
-
|
|
382
|
+
return null;
|
|
361
383
|
}
|
|
362
384
|
}
|
|
363
|
-
return null;
|
|
364
385
|
}
|
|
365
386
|
setHookPermissions(_pluginRoot) {
|
|
366
387
|
// OpenCode uses TS plugin paradigm — no shell scripts to chmod
|
|
@@ -370,6 +391,13 @@ export class OpenCodeAdapter {
|
|
|
370
391
|
// OpenCode manages plugins through npm/opencode.json — no separate registry
|
|
371
392
|
}
|
|
372
393
|
// ── Internal helpers ───────────────────────────────────
|
|
394
|
+
/**
|
|
395
|
+
* Check whether a settings object has the context-mode plugin registered.
|
|
396
|
+
*/
|
|
397
|
+
hasContextModePlugin(settings) {
|
|
398
|
+
const plugins = settings.plugin;
|
|
399
|
+
return Array.isArray(plugins) && plugins.some((p) => typeof p === "string" && p.includes("context-mode"));
|
|
400
|
+
}
|
|
373
401
|
/**
|
|
374
402
|
* Extract session ID from OpenCode hook input.
|
|
375
403
|
* OpenCode uses camelCase sessionID.
|
package/build/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import { execFileSync } from "node:child_process";
|
|
|
17
17
|
import { readFileSync, writeFileSync, cpSync, accessSync, existsSync, rmSync, closeSync, openSync, chmodSync, constants } from "node:fs";
|
|
18
18
|
import { request as httpsRequest } from "node:https";
|
|
19
19
|
import { resolve, dirname, join } from "node:path";
|
|
20
|
-
import { tmpdir, devNull } from "node:os";
|
|
20
|
+
import { tmpdir, devNull, homedir } from "node:os";
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
22
|
import { detectRuntimes, getRuntimeSummary, hasBunRuntime, getAvailableLanguages, } from "./runtime.js";
|
|
23
23
|
// ── Adapter imports ──────────────────────────────────────
|
|
@@ -98,7 +98,7 @@ else {
|
|
|
98
98
|
export function toUnixPath(p) {
|
|
99
99
|
return p.replace(/\\/g, "/");
|
|
100
100
|
}
|
|
101
|
-
function
|
|
101
|
+
function defaultPluginRoot() {
|
|
102
102
|
const __filename = fileURLToPath(import.meta.url);
|
|
103
103
|
const __dirname = dirname(__filename);
|
|
104
104
|
// build/cli.js or src/cli.ts → go up one level; cli.bundle.mjs at project root → stay here
|
|
@@ -108,6 +108,23 @@ function getPluginRoot() {
|
|
|
108
108
|
}
|
|
109
109
|
return __dirname;
|
|
110
110
|
}
|
|
111
|
+
// Opencode/Kilocode install plugins from npm into .cache folder
|
|
112
|
+
function cachePluginRoot(platform) {
|
|
113
|
+
if (process.platform === "win32") {
|
|
114
|
+
const localApp = process.env.LOCALAPPDATA;
|
|
115
|
+
if (localApp)
|
|
116
|
+
return resolve(localApp, platform, "node_modules", "context-mode");
|
|
117
|
+
return resolve(homedir(), "AppData", "Local", platform, "node_modules", "context-mode");
|
|
118
|
+
}
|
|
119
|
+
return resolve(homedir(), ".cache", platform, "node_modules", "context-mode");
|
|
120
|
+
}
|
|
121
|
+
function getPluginRoot() {
|
|
122
|
+
const platform = detectPlatform().platform;
|
|
123
|
+
if (platform === 'opencode' || platform === 'kilo') {
|
|
124
|
+
return cachePluginRoot(platform);
|
|
125
|
+
}
|
|
126
|
+
return defaultPluginRoot();
|
|
127
|
+
}
|
|
111
128
|
function getLocalVersion() {
|
|
112
129
|
try {
|
|
113
130
|
const pkg = JSON.parse(readFileSync(resolve(getPluginRoot(), "package.json"), "utf-8"));
|
|
@@ -358,6 +375,8 @@ async function upgrade() {
|
|
|
358
375
|
const newVersion = newPkg.version ?? "unknown";
|
|
359
376
|
if (newVersion === localVersion) {
|
|
360
377
|
p.log.success(color.green("Already on latest") + ` — v${localVersion}`);
|
|
378
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
379
|
+
return;
|
|
361
380
|
}
|
|
362
381
|
else {
|
|
363
382
|
p.log.info(`Update available: ${color.yellow("v" + localVersion)} → ${color.green("v" + newVersion)}`);
|
|
@@ -412,23 +431,25 @@ async function upgrade() {
|
|
|
412
431
|
timeout: 60000,
|
|
413
432
|
});
|
|
414
433
|
s.stop("Dependencies ready");
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
434
|
+
if (detection.platform !== 'opencode' && detection.platform !== 'kilo') {
|
|
435
|
+
// Rebuild native addons for current Node.js ABI (fixes #131)
|
|
436
|
+
s.start("Rebuilding native addons");
|
|
437
|
+
try {
|
|
438
|
+
execFileSync("npm", ["rebuild", "better-sqlite3"], {
|
|
439
|
+
cwd: pluginRoot,
|
|
440
|
+
stdio: "pipe",
|
|
441
|
+
timeout: 60000,
|
|
442
|
+
});
|
|
443
|
+
s.stop(color.green("Native addons rebuilt"));
|
|
444
|
+
changes.push("Rebuilt better-sqlite3 for current Node.js");
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
448
|
+
s.stop(color.yellow("Native addon rebuild warning"));
|
|
449
|
+
p.log.warn(color.yellow("better-sqlite3 rebuild issue") +
|
|
450
|
+
` — ${message}` +
|
|
451
|
+
color.dim(`\n Try manually: cd "${pluginRoot}" && npm rebuild better-sqlite3`));
|
|
452
|
+
}
|
|
432
453
|
}
|
|
433
454
|
// Update global npm
|
|
434
455
|
s.start("Updating npm global package");
|
|
@@ -465,10 +486,13 @@ async function upgrade() {
|
|
|
465
486
|
// Step 3: Backup settings — adapter-aware
|
|
466
487
|
p.log.step(`Backing up ${adapter.name} settings...`);
|
|
467
488
|
const backupPath = adapter.backupSettings();
|
|
468
|
-
if (backupPath) {
|
|
489
|
+
if (backupPath?.endsWith(".bak")) {
|
|
469
490
|
p.log.success(color.green("Backup created") + color.dim(" -> " + backupPath));
|
|
470
491
|
changes.push("Backed up settings");
|
|
471
492
|
}
|
|
493
|
+
else if (backupPath) {
|
|
494
|
+
p.log.success(color.green("Backup skipped") + color.dim(" — no changes needed"));
|
|
495
|
+
}
|
|
472
496
|
else {
|
|
473
497
|
p.log.warn(color.yellow("No existing settings to backup") +
|
|
474
498
|
" — a new one will be created");
|
package/build/db-base.d.ts
CHANGED
|
@@ -69,13 +69,13 @@ export declare function closeDB(db: DatabaseInstance): void;
|
|
|
69
69
|
*/
|
|
70
70
|
export declare function defaultDBPath(prefix?: string): string;
|
|
71
71
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* The `db` getter exposes the raw `DatabaseInstance` to subclasses only.
|
|
72
|
+
* Retry a DB operation with exponential backoff on SQLITE_BUSY errors.
|
|
73
|
+
* Catches errors containing "SQLITE_BUSY" or "database is locked" and
|
|
74
|
+
* retries up to 3 times with delays: 100ms, 500ms, 2000ms.
|
|
75
|
+
* If all retries fail, throws a descriptive error.
|
|
76
|
+
* Pass custom delays for testing (e.g., [0, 0, 0] to skip waits).
|
|
78
77
|
*/
|
|
78
|
+
export declare function withRetry<T>(fn: () => T, delays?: number[]): T;
|
|
79
79
|
export declare abstract class SQLiteBase {
|
|
80
80
|
#private;
|
|
81
81
|
constructor(dbPath: string);
|
|
@@ -89,6 +89,7 @@ export declare abstract class SQLiteBase {
|
|
|
89
89
|
get dbPath(): string;
|
|
90
90
|
/** Close the database connection without deleting files. */
|
|
91
91
|
close(): void;
|
|
92
|
+
protected withRetry<T>(fn: () => T): T;
|
|
92
93
|
/**
|
|
93
94
|
* Close the connection and delete all associated DB files (main, WAL, SHM).
|
|
94
95
|
* Call on process exit or at end of session lifecycle.
|
package/build/db-base.js
CHANGED
|
@@ -178,6 +178,38 @@ export function defaultDBPath(prefix = "context-mode") {
|
|
|
178
178
|
return join(tmpdir(), `${prefix}-${process.pid}.db`);
|
|
179
179
|
}
|
|
180
180
|
// ─────────────────────────────────────────────────────────
|
|
181
|
+
// Retry helper
|
|
182
|
+
// ─────────────────────────────────────────────────────────
|
|
183
|
+
/**
|
|
184
|
+
* Retry a DB operation with exponential backoff on SQLITE_BUSY errors.
|
|
185
|
+
* Catches errors containing "SQLITE_BUSY" or "database is locked" and
|
|
186
|
+
* retries up to 3 times with delays: 100ms, 500ms, 2000ms.
|
|
187
|
+
* If all retries fail, throws a descriptive error.
|
|
188
|
+
* Pass custom delays for testing (e.g., [0, 0, 0] to skip waits).
|
|
189
|
+
*/
|
|
190
|
+
export function withRetry(fn, delays = [100, 500, 2000]) {
|
|
191
|
+
let lastError;
|
|
192
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
193
|
+
try {
|
|
194
|
+
return fn();
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
198
|
+
if (!msg.includes("SQLITE_BUSY") && !msg.includes("database is locked")) {
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
lastError = err instanceof Error ? err : new Error(msg);
|
|
202
|
+
if (attempt < delays.length) {
|
|
203
|
+
const delay = delays[attempt];
|
|
204
|
+
const start = Date.now();
|
|
205
|
+
while (Date.now() - start < delay) { /* busy-wait for sync retry */ }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`SQLITE_BUSY: database is locked after ${delays.length} retries. ` +
|
|
210
|
+
`Original error: ${lastError?.message}`);
|
|
211
|
+
}
|
|
212
|
+
// ─────────────────────────────────────────────────────────
|
|
181
213
|
// Base class
|
|
182
214
|
// ─────────────────────────────────────────────────────────
|
|
183
215
|
/**
|
|
@@ -188,13 +220,40 @@ export function defaultDBPath(prefix = "context-mode") {
|
|
|
188
220
|
*
|
|
189
221
|
* The `db` getter exposes the raw `DatabaseInstance` to subclasses only.
|
|
190
222
|
*/
|
|
223
|
+
/**
|
|
224
|
+
* Track all live DatabaseInstance objects so we can close them on process exit.
|
|
225
|
+
* Prevents better-sqlite3 segfaults caused by V8 garbage-collecting Database
|
|
226
|
+
* objects after the native addon context is already torn down.
|
|
227
|
+
*
|
|
228
|
+
* Uses a global symbol so the set and exit handler survive vitest's module
|
|
229
|
+
* re-imports within the same fork process (ESM isolate mode clears
|
|
230
|
+
* module-level state but globalThis persists).
|
|
231
|
+
*/
|
|
232
|
+
const _kLiveDBs = Symbol.for("__context_mode_live_dbs__");
|
|
233
|
+
const _liveDBs = (() => {
|
|
234
|
+
const g = globalThis;
|
|
235
|
+
if (!g[_kLiveDBs]) {
|
|
236
|
+
g[_kLiveDBs] = new Set();
|
|
237
|
+
process.on("exit", () => {
|
|
238
|
+
for (const db of g[_kLiveDBs]) {
|
|
239
|
+
try {
|
|
240
|
+
db.close();
|
|
241
|
+
}
|
|
242
|
+
catch { /* already closed */ }
|
|
243
|
+
}
|
|
244
|
+
g[_kLiveDBs].clear();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return g[_kLiveDBs];
|
|
248
|
+
})();
|
|
191
249
|
export class SQLiteBase {
|
|
192
250
|
#dbPath;
|
|
193
251
|
#db;
|
|
194
252
|
constructor(dbPath) {
|
|
195
253
|
const Database = loadDatabase();
|
|
196
254
|
this.#dbPath = dbPath;
|
|
197
|
-
this.#db = new Database(dbPath, { timeout:
|
|
255
|
+
this.#db = new Database(dbPath, { timeout: 30000 });
|
|
256
|
+
_liveDBs.add(this.#db);
|
|
198
257
|
applyWALPragmas(this.#db);
|
|
199
258
|
this.initSchema();
|
|
200
259
|
this.prepareStatements();
|
|
@@ -209,13 +268,18 @@ export class SQLiteBase {
|
|
|
209
268
|
}
|
|
210
269
|
/** Close the database connection without deleting files. */
|
|
211
270
|
close() {
|
|
271
|
+
_liveDBs.delete(this.#db);
|
|
212
272
|
closeDB(this.#db);
|
|
213
273
|
}
|
|
274
|
+
withRetry(fn) {
|
|
275
|
+
return withRetry(fn);
|
|
276
|
+
}
|
|
214
277
|
/**
|
|
215
278
|
* Close the connection and delete all associated DB files (main, WAL, SHM).
|
|
216
279
|
* Call on process exit or at end of session lifecycle.
|
|
217
280
|
*/
|
|
218
281
|
cleanup() {
|
|
282
|
+
_liveDBs.delete(this.#db);
|
|
219
283
|
closeDB(this.#db);
|
|
220
284
|
deleteDBFiles(this.#dbPath);
|
|
221
285
|
}
|
|
@@ -53,7 +53,7 @@ interface CompactingHookOutput {
|
|
|
53
53
|
/**
|
|
54
54
|
* OpenCode plugin factory. Called once when OpenCode loads the plugin.
|
|
55
55
|
* Returns an object mapping hook event names to async handler functions.
|
|
56
|
-
|
|
56
|
+
*/
|
|
57
57
|
export declare const ContextModePlugin: (ctx: PluginContext) => Promise<{
|
|
58
58
|
"tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
|
|
59
59
|
"tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
|
package/build/opencode-plugin.js
CHANGED
|
@@ -14,37 +14,25 @@
|
|
|
14
14
|
* - No routing file auto-write (avoid dirtying project trees)
|
|
15
15
|
* - Session cleanup happens at plugin init (no SessionStart)
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { homedir } from "node:os";
|
|
20
|
-
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { dirname, resolve } from "node:path";
|
|
21
19
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
20
|
import { SessionDB } from "./session/db.js";
|
|
23
21
|
import { extractEvents } from "./session/extract.js";
|
|
24
22
|
import { buildResumeSnapshot } from "./session/snapshot.js";
|
|
23
|
+
import { OpenCodeAdapter } from "./adapters/opencode/index.js";
|
|
25
24
|
// ── Helpers ───────────────────────────────────────────────
|
|
26
25
|
function getPlatform() {
|
|
27
26
|
return process.env.KILO ? "kilo" : "opencode";
|
|
28
27
|
}
|
|
29
|
-
function getSessionDir() {
|
|
30
|
-
const dir = join(homedir(), ".config", getPlatform(), "context-mode", "sessions");
|
|
31
|
-
mkdirSync(dir, { recursive: true });
|
|
32
|
-
return dir;
|
|
33
|
-
}
|
|
34
|
-
function getDBPath(projectDir) {
|
|
35
|
-
const hash = createHash("sha256")
|
|
36
|
-
.update(projectDir)
|
|
37
|
-
.digest("hex")
|
|
38
|
-
.slice(0, 16);
|
|
39
|
-
return join(getSessionDir(), `${hash}.db`);
|
|
40
|
-
}
|
|
41
28
|
// ── Plugin Factory ────────────────────────────────────────
|
|
42
29
|
/**
|
|
43
30
|
* OpenCode plugin factory. Called once when OpenCode loads the plugin.
|
|
44
31
|
* Returns an object mapping hook event names to async handler functions.
|
|
45
|
-
|
|
32
|
+
*/
|
|
46
33
|
export const ContextModePlugin = async (ctx) => {
|
|
47
34
|
// Resolve build dir from compiled JS location
|
|
35
|
+
const adapter = new OpenCodeAdapter(getPlatform());
|
|
48
36
|
const buildDir = dirname(fileURLToPath(import.meta.url));
|
|
49
37
|
// Load routing module (ESM .mjs, lives outside build/ in hooks/)
|
|
50
38
|
const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
|
|
@@ -52,7 +40,7 @@ export const ContextModePlugin = async (ctx) => {
|
|
|
52
40
|
await routing.initSecurity(buildDir);
|
|
53
41
|
// Initialize session
|
|
54
42
|
const projectDir = ctx.directory;
|
|
55
|
-
const db = new SessionDB({ dbPath:
|
|
43
|
+
const db = new SessionDB({ dbPath: adapter.getSessionDBPath(projectDir) });
|
|
56
44
|
const sessionId = randomUUID();
|
|
57
45
|
db.ensureSession(sessionId, projectDir);
|
|
58
46
|
// Clean up old sessions on startup (replaces SessionStart hook)
|
package/build/server.js
CHANGED
|
@@ -127,7 +127,43 @@ const sessionStats = {
|
|
|
127
127
|
cacheBytesSaved: 0, // bytes avoided by TTL cache hits
|
|
128
128
|
sessionStart: Date.now(),
|
|
129
129
|
};
|
|
130
|
+
/**
|
|
131
|
+
* Reset session stats to zero. Called when /clear flag is detected.
|
|
132
|
+
* The SessionStart hook writes a .clear-stats flag file on /clear,
|
|
133
|
+
* and the server checks for it before each tool call.
|
|
134
|
+
*/
|
|
135
|
+
function resetSessionStats() {
|
|
136
|
+
sessionStats.calls = {};
|
|
137
|
+
sessionStats.bytesReturned = {};
|
|
138
|
+
sessionStats.bytesIndexed = 0;
|
|
139
|
+
sessionStats.bytesSandboxed = 0;
|
|
140
|
+
sessionStats.cacheHits = 0;
|
|
141
|
+
sessionStats.cacheBytesSaved = 0;
|
|
142
|
+
sessionStats.sessionStart = Date.now();
|
|
143
|
+
// Also reset FTS5 content store — drop and recreate on next getStore() call
|
|
144
|
+
if (_store) {
|
|
145
|
+
try {
|
|
146
|
+
_store.cleanup();
|
|
147
|
+
}
|
|
148
|
+
catch { /* best effort */ }
|
|
149
|
+
_store = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Check for .clear-stats flag and reset stats if found. */
|
|
153
|
+
function checkClearStatsFlag() {
|
|
154
|
+
const sessDir = join(homedir(), ".claude", "context-mode", "sessions");
|
|
155
|
+
try {
|
|
156
|
+
const flags = readdirSync(sessDir).filter((f) => f.endsWith(".clear-stats"));
|
|
157
|
+
for (const f of flags) {
|
|
158
|
+
unlinkSync(join(sessDir, f));
|
|
159
|
+
}
|
|
160
|
+
if (flags.length > 0)
|
|
161
|
+
resetSessionStats();
|
|
162
|
+
}
|
|
163
|
+
catch { /* best effort */ }
|
|
164
|
+
}
|
|
130
165
|
function trackResponse(toolName, response) {
|
|
166
|
+
checkClearStatsFlag();
|
|
131
167
|
const bytes = response.content.reduce((sum, c) => sum + Buffer.byteLength(c.text), 0);
|
|
132
168
|
sessionStats.calls[toolName] = (sessionStats.calls[toolName] || 0) + 1;
|
|
133
169
|
sessionStats.bytesReturned[toolName] =
|
|
@@ -1370,8 +1406,18 @@ server.registerTool("ctx_stats", {
|
|
|
1370
1406
|
description: "Returns context consumption statistics for the current session. " +
|
|
1371
1407
|
"Shows total bytes returned to context, breakdown by tool, call counts, " +
|
|
1372
1408
|
"estimated token usage, and context savings ratio.",
|
|
1373
|
-
inputSchema: z.object({
|
|
1374
|
-
|
|
1409
|
+
inputSchema: z.object({
|
|
1410
|
+
reset: z.boolean().optional().describe("Reset all stats and FTS5 store to zero. Use after /clear."),
|
|
1411
|
+
}),
|
|
1412
|
+
}, async ({ reset }) => {
|
|
1413
|
+
// Check for clear flag BEFORE reading stats
|
|
1414
|
+
checkClearStatsFlag();
|
|
1415
|
+
if (reset) {
|
|
1416
|
+
resetSessionStats();
|
|
1417
|
+
return trackResponse("ctx_stats", {
|
|
1418
|
+
content: [{ type: "text", text: "Session stats and search index reset." }],
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1375
1421
|
const totalBytesReturned = Object.values(sessionStats.bytesReturned).reduce((sum, b) => sum + b, 0);
|
|
1376
1422
|
const totalCalls = Object.values(sessionStats.calls).reduce((sum, c) => sum + c, 0);
|
|
1377
1423
|
const uptimeMs = Date.now() - sessionStats.sessionStart;
|
package/build/store.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare function cleanupStaleDBs(): number;
|
|
|
17
17
|
/**
|
|
18
18
|
* Clean up stale per-project content store DBs older than maxAgeDays.
|
|
19
19
|
* Scans the given directory for *.db files and checks mtime.
|
|
20
|
+
* Also detects zombie processes holding WAL locks — if a WAL file exists
|
|
21
|
+
* but the owning PID is dead, the DB files are cleaned up regardless of age.
|
|
20
22
|
*/
|
|
21
23
|
export declare function cleanupStaleContentDBs(contentDir: string, maxAgeDays: number): number;
|
|
22
24
|
export declare class ContentStore {
|
package/build/store.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Use for documentation, API references, and any content where
|
|
8
8
|
* you need EXACT text later — not summaries.
|
|
9
9
|
*/
|
|
10
|
-
import { loadDatabase, applyWALPragmas, closeDB } from "./db-base.js";
|
|
10
|
+
import { loadDatabase, applyWALPragmas, closeDB, withRetry } from "./db-base.js";
|
|
11
11
|
import { readFileSync, readdirSync, unlinkSync, existsSync, statSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
@@ -116,9 +116,24 @@ export function cleanupStaleDBs() {
|
|
|
116
116
|
catch { /* ignore readdir errors */ }
|
|
117
117
|
return cleaned;
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Check if a PID is still alive (not a zombie holding a WAL lock).
|
|
121
|
+
* Returns true if the process exists, false if it's dead.
|
|
122
|
+
*/
|
|
123
|
+
function isProcessAlive(pid) {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
119
132
|
/**
|
|
120
133
|
* Clean up stale per-project content store DBs older than maxAgeDays.
|
|
121
134
|
* Scans the given directory for *.db files and checks mtime.
|
|
135
|
+
* Also detects zombie processes holding WAL locks — if a WAL file exists
|
|
136
|
+
* but the owning PID is dead, the DB files are cleaned up regardless of age.
|
|
122
137
|
*/
|
|
123
138
|
export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
|
|
124
139
|
let cleaned = 0;
|
|
@@ -131,7 +146,25 @@ export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
|
|
|
131
146
|
try {
|
|
132
147
|
const filePath = join(contentDir, file);
|
|
133
148
|
const mtime = statSync(filePath).mtimeMs;
|
|
134
|
-
|
|
149
|
+
let shouldClean = mtime < cutoff;
|
|
150
|
+
// Detect zombie processes holding WAL locks:
|
|
151
|
+
// If a WAL file exists, try to read the WAL header to extract the PID.
|
|
152
|
+
// WAL files from dead processes can block new connections.
|
|
153
|
+
if (!shouldClean) {
|
|
154
|
+
const walPath = filePath + "-wal";
|
|
155
|
+
if (existsSync(walPath)) {
|
|
156
|
+
try {
|
|
157
|
+
const walStat = statSync(walPath);
|
|
158
|
+
// If WAL file is non-empty and DB hasn't been modified in >1 hour,
|
|
159
|
+
// the owning process may be dead — check via mtime staleness
|
|
160
|
+
if (walStat.size > 0 && (Date.now() - walStat.mtimeMs) > 3600_000) {
|
|
161
|
+
shouldClean = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch { /* ignore WAL check errors */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (shouldClean) {
|
|
135
168
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
136
169
|
try {
|
|
137
170
|
unlinkSync(filePath + suffix);
|
|
@@ -234,7 +267,7 @@ export class ContentStore {
|
|
|
234
267
|
const Database = loadDatabase();
|
|
235
268
|
this.#dbPath =
|
|
236
269
|
dbPath ?? join(tmpdir(), `context-mode-${process.pid}.db`);
|
|
237
|
-
this.#db = new Database(this.#dbPath, { timeout:
|
|
270
|
+
this.#db = new Database(this.#dbPath, { timeout: 30000 });
|
|
238
271
|
applyWALPragmas(this.#db);
|
|
239
272
|
this.#initSchema();
|
|
240
273
|
this.#prepareStatements();
|
|
@@ -496,7 +529,7 @@ export class ContentStore {
|
|
|
496
529
|
const text = content ?? readFileSync(path, "utf-8");
|
|
497
530
|
const label = source ?? path ?? "untitled";
|
|
498
531
|
const chunks = this.#chunkMarkdown(text);
|
|
499
|
-
return this.#insertChunks(chunks, label, text);
|
|
532
|
+
return withRetry(() => this.#insertChunks(chunks, label, text));
|
|
500
533
|
}
|
|
501
534
|
// ── Index Plain Text ──
|
|
502
535
|
/**
|
|
@@ -613,7 +646,7 @@ export class ContentStore {
|
|
|
613
646
|
stmt = this.#stmtSearchPorter;
|
|
614
647
|
params = [sanitized, limit];
|
|
615
648
|
}
|
|
616
|
-
return this.#mapSearchRows(stmt.all(...params));
|
|
649
|
+
return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
|
|
617
650
|
}
|
|
618
651
|
// ── Trigram Search (Layer 2) ──
|
|
619
652
|
searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
|