@mxalbert/context-mode 2.0.1 → 2.0.3
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/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +1 -1
- package/build/adapters/opencode/plugin.d.ts +60 -2
- package/build/adapters/opencode/plugin.js +89 -1
- package/build/adapters/opencode/v2.d.ts +11 -0
- package/build/db-base.d.ts +16 -0
- package/build/db-base.js +29 -3
- package/build/server.js +98 -46
- package/build/session/purge.js +2 -1
- package/build/store.d.ts +22 -3
- package/build/store.js +282 -73
- package/cli.bundle.mjs +165 -165
- package/configs/antigravity-cli/plugin.json +1 -1
- package/configs/copilot-cli/.github/plugin/plugin.json +1 -1
- package/hooks/core/routing.mjs +168 -5
- package/hooks/session-db.bundle.mjs +7 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +128 -128
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Claude Code plugins by Mert Koseoğlu",
|
|
9
|
-
"version": "2.0.
|
|
9
|
+
"version": "2.0.3"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "context-mode",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
16
|
-
"version": "2.0.
|
|
16
|
+
"version": "2.0.3",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Mert Koseoğlu"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "Context Mode",
|
|
4
4
|
"kind": "tool",
|
|
5
5
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
6
|
-
"version": "2.0.
|
|
6
|
+
"version": "2.0.3",
|
|
7
7
|
"sandbox": {
|
|
8
8
|
"mode": "permissive",
|
|
9
9
|
"filesystem_access": "full",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mxalbert/context-mode",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
package/README.md
CHANGED
|
@@ -1208,7 +1208,7 @@ Indexed content persists in a per-project SQLite database at `~/.context-mode/co
|
|
|
1208
1208
|
- **Cache hit (within TTL):** Returns a cache hint (~0.3KB) instead of re-fetching (48KB+). Model proceeds to `ctx_search`.
|
|
1209
1209
|
- **Cache miss (TTL expired):** Re-fetches silently. No user action needed.
|
|
1210
1210
|
- **`ttl: 0`** or **`force: true`:** Bypasses cache and re-fetches regardless of freshness.
|
|
1211
|
-
- **14-day cleanup:**
|
|
1211
|
+
- **14-day cleanup:** Sources older than 14 days are removed from the knowledge base on startup. Content DB files themselves are never auto-deleted — an open-but-idle store is indistinguishable from an abandoned one by timestamps alone, and deleting a live store's files causes unrecoverable I/O errors. Use `ctx_purge` to reclaim disk space.
|
|
1212
1212
|
|
|
1213
1213
|
This means `--continue` sessions preserve indexed docs across restarts. No re-fetching, no wasted context tokens.
|
|
1214
1214
|
|
|
@@ -49,8 +49,9 @@
|
|
|
49
49
|
* - No routing file auto-write (avoid dirtying project trees)
|
|
50
50
|
* - Session cleanup happens at plugin init (no SessionStart)
|
|
51
51
|
*/
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
52
|
+
import { SessionDB } from "../../session/db.js";
|
|
53
|
+
import { AdapterPlatformType, OpenCodeAdapter, type PluginGlobalState } from "./index.js";
|
|
54
|
+
import { type PluginClient, type PluginClientAppLogBodyExtra, type V2SetupContext } from "./v2.js";
|
|
54
55
|
type PluginContext = {
|
|
55
56
|
client: PluginClient;
|
|
56
57
|
directory: string;
|
|
@@ -192,6 +193,63 @@ export declare function __resetPluginStateForTests(): void;
|
|
|
192
193
|
export declare function __getPluginGlobalState(): PluginGlobalState;
|
|
193
194
|
/** Test-only: close the cached fd and clear the sink cache so each test gets a fresh sink. */
|
|
194
195
|
export declare function __resetPluginLogSinkForTests(): void;
|
|
196
|
+
type EmitLevel = "info" | "warn" | "error";
|
|
197
|
+
/**
|
|
198
|
+
* Per-plugin-process state shared by the v1 `server()` path and the v2
|
|
199
|
+
* `setup()` path. Both flavors bridge to the SAME handler functions built
|
|
200
|
+
* over this runtime — logic is never duplicated across flavors.
|
|
201
|
+
*/
|
|
202
|
+
interface PluginRuntime {
|
|
203
|
+
ctx: PluginContext;
|
|
204
|
+
platform: AdapterPlatformType;
|
|
205
|
+
adapter: OpenCodeAdapter;
|
|
206
|
+
projectDir: string;
|
|
207
|
+
db: SessionDB;
|
|
208
|
+
routing: {
|
|
209
|
+
routePreToolUse: (...args: unknown[]) => any;
|
|
210
|
+
/** v2-native availability signal (hooks/core/routing.mjs) — optional: older routing copies lack it. */
|
|
211
|
+
setContextModeToolsAvailable?: (available: boolean) => void;
|
|
212
|
+
/**
|
|
213
|
+
* v2 permission-evaluate routing (hooks/core/routing.mjs) — optional:
|
|
214
|
+
* older routing copies lack it, and the permission bridge no-ops (fail
|
|
215
|
+
* open, host decision untouched) when absent.
|
|
216
|
+
*/
|
|
217
|
+
routePermissionEvaluate?: (action: unknown, resources: unknown, projectDir: unknown, platform: unknown) => {
|
|
218
|
+
effect: "allow" | "deny" | "ask";
|
|
219
|
+
message?: string;
|
|
220
|
+
} | null;
|
|
221
|
+
};
|
|
222
|
+
routingBlock: string;
|
|
223
|
+
autoInjectionMod: {
|
|
224
|
+
buildAutoInjection: (events: unknown) => string;
|
|
225
|
+
};
|
|
226
|
+
captureAgentsMd: (sessionId: string) => void;
|
|
227
|
+
buildNativeTools: () => Promise<Record<string, NativeToolDefinition>>;
|
|
228
|
+
logger: (message?: string, extra?: PluginClientAppLogBodyExtra) => Promise<void>;
|
|
229
|
+
safeLog: (message?: string, extra?: PluginClientAppLogBodyExtra) => Promise<void>;
|
|
230
|
+
logHookError: (hookName: string, err: unknown, sessionId?: string) => void;
|
|
231
|
+
logOnce: (key: string, message: string, level?: EmitLevel) => void;
|
|
232
|
+
/**
|
|
233
|
+
* Liveness gate for v2-registered callbacks (defense-in-depth). Set to
|
|
234
|
+
* true by teardownV2 BEFORE any dispose / claim release: hosts whose
|
|
235
|
+
* registration calls succeed but return NO dispose handle cannot be fully
|
|
236
|
+
* unregistered, so their stale callbacks are neutralized instead — every
|
|
237
|
+
* v2 entry point below checks this flag and no-ops (no throw, no DB touch)
|
|
238
|
+
* once the runtime is torn down. The v1 path never tears a runtime down.
|
|
239
|
+
*/
|
|
240
|
+
closed: boolean;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The v2 permission-evaluate bridge handler. Extracted (and exported) so the
|
|
244
|
+
* behavioral contracts are unit-testable: malformed events and a routing
|
|
245
|
+
* throw must leave the host decision untouched (fail-open), and a torn-down
|
|
246
|
+
* runtime must no-op (liveness gate). `route` is pre-bound to the runtime's
|
|
247
|
+
* projectDir/platform by the caller.
|
|
248
|
+
*/
|
|
249
|
+
export declare function createV2PermissionEvaluateHandler(rt: Pick<PluginRuntime, "closed" | "logHookError">, route: (action: string, resources: string[]) => {
|
|
250
|
+
effect: "allow" | "deny" | "ask";
|
|
251
|
+
message?: string;
|
|
252
|
+
} | null): (event: unknown) => Promise<undefined>;
|
|
195
253
|
/**
|
|
196
254
|
* Plugin factory. Called once when a v1 host (KiloCode/OpenCode ≤ v1) loads
|
|
197
255
|
* the plugin. Returns an object mapping hook event names to async handler
|
|
@@ -1283,6 +1283,85 @@ async function registerEventBusV2(ctx, rt, handlers) {
|
|
|
1283
1283
|
dispose: hostDispose ? chainDisposes([abortDispose, hostDispose]) : abortDispose,
|
|
1284
1284
|
};
|
|
1285
1285
|
}
|
|
1286
|
+
/**
|
|
1287
|
+
* OPTIONAL: register the v2 permission "evaluate" hook — the surface that
|
|
1288
|
+
* restores true ask/confirmation semantics on OpenCode v2 (verified against
|
|
1289
|
+
* opencode2 beta-19135 live probe; see .research/opencode-v2-permission-hooks.md).
|
|
1290
|
+
*
|
|
1291
|
+
* The host asserts every core-tool action (shell, read, …) through its
|
|
1292
|
+
* permission system before execution and lets the hook MUTATE the decision
|
|
1293
|
+
* (event.effect / event.message). The bridge routes the event through the
|
|
1294
|
+
* shared routing engine's routePermissionEvaluate and applies its verdict:
|
|
1295
|
+
*
|
|
1296
|
+
* - deny → effect "deny" — blocked with the policy reason (normally
|
|
1297
|
+
* pre-empted by the execute.before throw which fires earlier —
|
|
1298
|
+
* this is defense-in-depth for assert paths execute.before cannot see)
|
|
1299
|
+
* - ask → effect "ask" — an interactive confirmation in TUI runs,
|
|
1300
|
+
* restoring the user's ask intent even when host allow rules would
|
|
1301
|
+
* auto-approve; under `--auto` the host auto-approves the ask too
|
|
1302
|
+
* (explicit opt-in, verified live)
|
|
1303
|
+
* - allow → effect "allow" — skips the host's default-ask prompt for
|
|
1304
|
+
* commands the user's own GLOBAL permission rules pre-approve
|
|
1305
|
+
* - null → untouched — the host decision (its rules / default ask / --auto)
|
|
1306
|
+
* stays exactly as computed
|
|
1307
|
+
*
|
|
1308
|
+
* Plugin-registered tools (the ctx_* tools themselves) are NOT
|
|
1309
|
+
* permission-evaluated (empirically verified — see
|
|
1310
|
+
* .research/opencode-v2-empirical-test-results.md), so their gating stays in
|
|
1311
|
+
* tool.execute.before; this hook only covers host core-tool actions, with
|
|
1312
|
+
* zero overlap or double-handling: execute.before keeps deny/modify/context,
|
|
1313
|
+
* and its Stage-1 ask already falls through (opencode is not in
|
|
1314
|
+
* ASK_CAPABLE_PLATFORMS), so the ask decision is expressed HERE, once.
|
|
1315
|
+
*
|
|
1316
|
+
* Fail-open: a routing throw leaves the event untouched (mirrors the
|
|
1317
|
+
* execute.before catch — a routing failure must never brick tool calls).
|
|
1318
|
+
*/
|
|
1319
|
+
async function registerPermissionHookV2(ctx, rt) {
|
|
1320
|
+
const permissionHook = ctx?.permission && typeof ctx.permission.hook === "function" ? ctx.permission.hook : undefined;
|
|
1321
|
+
if (!permissionHook || !ctx?.permission)
|
|
1322
|
+
return { ok: false };
|
|
1323
|
+
const routePermissionEvaluate = rt.routing.routePermissionEvaluate;
|
|
1324
|
+
const handler = createV2PermissionEvaluateHandler(rt, (action, resources) => {
|
|
1325
|
+
if (typeof routePermissionEvaluate !== "function")
|
|
1326
|
+
return null; // older routing copy — no-op
|
|
1327
|
+
return routePermissionEvaluate(action, resources, rt.projectDir, rt.platform);
|
|
1328
|
+
});
|
|
1329
|
+
return tryRegister(permissionHook, ctx.permission, "evaluate", handler);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* The v2 permission-evaluate bridge handler. Extracted (and exported) so the
|
|
1333
|
+
* behavioral contracts are unit-testable: malformed events and a routing
|
|
1334
|
+
* throw must leave the host decision untouched (fail-open), and a torn-down
|
|
1335
|
+
* runtime must no-op (liveness gate). `route` is pre-bound to the runtime's
|
|
1336
|
+
* projectDir/platform by the caller.
|
|
1337
|
+
*/
|
|
1338
|
+
export function createV2PermissionEvaluateHandler(rt, route) {
|
|
1339
|
+
return async (event) => {
|
|
1340
|
+
if (rt.closed)
|
|
1341
|
+
return undefined; // torn down — silent no-op
|
|
1342
|
+
const ev = (event ?? {});
|
|
1343
|
+
const action = typeof ev.action === "string" ? ev.action : "";
|
|
1344
|
+
const resources = Array.isArray(ev.resources)
|
|
1345
|
+
? ev.resources.filter((r) => typeof r === "string")
|
|
1346
|
+
: [];
|
|
1347
|
+
if (!action || resources.length === 0)
|
|
1348
|
+
return undefined;
|
|
1349
|
+
let routed = null;
|
|
1350
|
+
try {
|
|
1351
|
+
routed = route(action, resources);
|
|
1352
|
+
}
|
|
1353
|
+
catch (err) {
|
|
1354
|
+
rt.logHookError("v2.permission.evaluate", err, v2SessionIdOf(ev));
|
|
1355
|
+
return undefined; // fail-open — host decision untouched
|
|
1356
|
+
}
|
|
1357
|
+
if (!routed)
|
|
1358
|
+
return undefined;
|
|
1359
|
+
ev.effect = routed.effect;
|
|
1360
|
+
if (typeof routed.message === "string")
|
|
1361
|
+
ev.message = routed.message;
|
|
1362
|
+
return undefined; // mutation is the contract — the return value is ignored
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1286
1365
|
/**
|
|
1287
1366
|
* MANDATORY: register the native ctx_* tools via ctx.tool.transform(editor)
|
|
1288
1367
|
* (verified v2 API). The ToolEditor receives one ToolInfo per ctx_* tool:
|
|
@@ -1591,7 +1670,16 @@ async function setupV2(ctx) {
|
|
|
1591
1670
|
missingMessage: "context-mode v2: event bus unavailable (no ctx.event.subscribe) — per-turn token/cost capture inactive",
|
|
1592
1671
|
});
|
|
1593
1672
|
disposes.push(...eventBus.disposes);
|
|
1594
|
-
|
|
1673
|
+
// Optional: permission "evaluate" hook — true ask/allow security
|
|
1674
|
+
// semantics on permission-evaluated actions (see registerPermissionHookV2).
|
|
1675
|
+
const permissionHook = await attemptOptionalV2(activeRt, {
|
|
1676
|
+
surfacePresent: typeof ctx?.permission?.hook === "function",
|
|
1677
|
+
register: () => registerPermissionHookV2(ctx, activeRt),
|
|
1678
|
+
logKeyMissing: "v2-permission-hook-missing",
|
|
1679
|
+
missingMessage: "context-mode v2: permission evaluate hook unavailable (no ctx.permission.hook) — ask/allow security semantics inactive; deny enforcement stays on tool.execute.before",
|
|
1680
|
+
});
|
|
1681
|
+
disposes.push(...permissionHook.disposes);
|
|
1682
|
+
rt.logOnce("v2-setup-complete", `context-mode v2 setup complete: native tools via ctx.tool.transform; tool hooks via ${toolRegs.via}; session context: ${sessionContext.status}; prompt capture: ${promptCapture.status}; event bus: ${eventBus.status}; permission hook: ${permissionHook.status}`, "info");
|
|
1595
1683
|
return async () => {
|
|
1596
1684
|
// Cleanup: unregister everything registered, close the runtime DB
|
|
1597
1685
|
// handle, then release the activation claim so a reload can re-claim.
|
|
@@ -87,6 +87,17 @@ export type V2SetupContext = {
|
|
|
87
87
|
signal?: AbortSignal;
|
|
88
88
|
}) => unknown;
|
|
89
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* v2 permission domain (verified against opencode2 beta-19135 live probe:
|
|
92
|
+
* ctx.permission.hook("evaluate", cb) fires for every core-tool permission
|
|
93
|
+
* assert with a MUTABLE event { action, resources, source?, effect, message? }
|
|
94
|
+
* — hook mutations of effect/message win the decision). Optional: older v2
|
|
95
|
+
* builds and all v1 hosts lack the surface; the plugin degrades to the
|
|
96
|
+
* execute.before throw-based behavior there.
|
|
97
|
+
*/
|
|
98
|
+
permission?: {
|
|
99
|
+
hook?: (name: string, cb: (event: unknown) => unknown) => unknown;
|
|
100
|
+
};
|
|
90
101
|
};
|
|
91
102
|
/**
|
|
92
103
|
* Convert a Zod (v3-classic) schema into a JSON-Schema object — the shared
|
package/build/db-base.d.ts
CHANGED
|
@@ -185,6 +185,22 @@ export declare function renameCorruptDB(dbPath: string): void;
|
|
|
185
185
|
* error) go to stderr via console.error under this prefix.
|
|
186
186
|
*/
|
|
187
187
|
export declare const DB_LOG_PREFIX = "[context-mode:db]";
|
|
188
|
+
/**
|
|
189
|
+
* Extract the SQLite error code (e.g. "SQLITE_IOERR") from an arbitrary
|
|
190
|
+
* thrown value, or "" when absent. better-sqlite3 and node:sqlite set
|
|
191
|
+
* `code` on SqliteError; bun:sqlite encodes it in the message instead.
|
|
192
|
+
*
|
|
193
|
+
* Exported so store.ts can append the same `[<code>]` context to the
|
|
194
|
+
* tool-visible `[context-mode:store] …` error messages without
|
|
195
|
+
* duplicating the throw-shape handling.
|
|
196
|
+
*/
|
|
197
|
+
export declare function extractErrorCode(err: unknown): string;
|
|
198
|
+
/**
|
|
199
|
+
* Normalize the human-readable message of an arbitrary thrown value.
|
|
200
|
+
* Exported alongside extractErrorCode for store.ts's tool-error
|
|
201
|
+
* enrichment (non-Error shapes included).
|
|
202
|
+
*/
|
|
203
|
+
export declare function errorMessage(err: unknown): string;
|
|
188
204
|
/**
|
|
189
205
|
* Log a swallowed DB failure to stderr with the stable
|
|
190
206
|
* `[context-mode:db]` prefix: operation name, error code, error message,
|
package/build/db-base.js
CHANGED
|
@@ -490,6 +490,15 @@ export function withRetry(fn, delays = [100, 500, 2000]) {
|
|
|
490
490
|
throw err;
|
|
491
491
|
}
|
|
492
492
|
lastError = err instanceof Error ? err : new Error(errorSignature(err));
|
|
493
|
+
if (!(err instanceof Error)) {
|
|
494
|
+
// Preserve the SQLite code (e.g. SQLITE_IOERR) on the normalized
|
|
495
|
+
// Error — non-Error throw shapes ({ code } objects, strings) would
|
|
496
|
+
// otherwise lose it, leaving callers unable to classify the failure
|
|
497
|
+
// after retries are exhausted.
|
|
498
|
+
const code = extractErrorCode(err);
|
|
499
|
+
if (code)
|
|
500
|
+
lastError.code = code;
|
|
501
|
+
}
|
|
493
502
|
if (attempt < delays.length) {
|
|
494
503
|
const delay = delays[attempt];
|
|
495
504
|
const start = Date.now();
|
|
@@ -497,8 +506,16 @@ export function withRetry(fn, delays = [100, 500, 2000]) {
|
|
|
497
506
|
}
|
|
498
507
|
}
|
|
499
508
|
}
|
|
500
|
-
|
|
509
|
+
const exhausted = new Error(`SQLITE_BUSY/SQLITE_IOERR: transient SQLite error after ${delays.length} retries. ` +
|
|
501
510
|
`Original error: ${lastError?.message}`);
|
|
511
|
+
// Carry the last error's SQLite code on the wrapper (message alone is
|
|
512
|
+
// ambiguous — it names both SQLITE_BUSY and SQLITE_IOERR). store.ts's
|
|
513
|
+
// `[context-mode:store]` enrichment appends this as ` (<code>)` so the
|
|
514
|
+
// tool-visible error is never a bare "disk I/O error".
|
|
515
|
+
const exhaustedCode = lastError ? extractErrorCode(lastError) : "";
|
|
516
|
+
if (exhaustedCode)
|
|
517
|
+
exhausted.code = exhaustedCode;
|
|
518
|
+
throw exhausted;
|
|
502
519
|
}
|
|
503
520
|
// ─────────────────────────────────────────────────────────
|
|
504
521
|
// Corrupt DB recovery (#244)
|
|
@@ -545,8 +562,12 @@ const _recentDbErrors = new Map();
|
|
|
545
562
|
* Extract the SQLite error code (e.g. "SQLITE_IOERR") from an arbitrary
|
|
546
563
|
* thrown value, or "" when absent. better-sqlite3 and node:sqlite set
|
|
547
564
|
* `code` on SqliteError; bun:sqlite encodes it in the message instead.
|
|
565
|
+
*
|
|
566
|
+
* Exported so store.ts can append the same `[<code>]` context to the
|
|
567
|
+
* tool-visible `[context-mode:store] …` error messages without
|
|
568
|
+
* duplicating the throw-shape handling.
|
|
548
569
|
*/
|
|
549
|
-
function extractErrorCode(err) {
|
|
570
|
+
export function extractErrorCode(err) {
|
|
550
571
|
if (err instanceof Error) {
|
|
551
572
|
const code = err.code;
|
|
552
573
|
return typeof code === "string" ? code : "";
|
|
@@ -557,7 +578,12 @@ function extractErrorCode(err) {
|
|
|
557
578
|
}
|
|
558
579
|
return "";
|
|
559
580
|
}
|
|
560
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Normalize the human-readable message of an arbitrary thrown value.
|
|
583
|
+
* Exported alongside extractErrorCode for store.ts's tool-error
|
|
584
|
+
* enrichment (non-Error shapes included).
|
|
585
|
+
*/
|
|
586
|
+
export function errorMessage(err) {
|
|
561
587
|
if (err instanceof Error)
|
|
562
588
|
return err.message;
|
|
563
589
|
if (typeof err === "string")
|
package/build/server.js
CHANGED
|
@@ -21,7 +21,7 @@ import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime
|
|
|
21
21
|
import { classifyNonZeroExit } from "./exit-classify.js";
|
|
22
22
|
import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
|
|
23
23
|
import { charSafePrefix } from "./truncate.js";
|
|
24
|
-
import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
|
|
24
|
+
import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
|
|
25
25
|
import { purgeSession } from "./session/purge.js";
|
|
26
26
|
import { NPM_LATEST_URL, PACKAGE_NAME, PLUGIN_KEY } from "./package-identity.js";
|
|
27
27
|
import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
|
|
@@ -370,8 +370,19 @@ process.on("exit", () => { try {
|
|
|
370
370
|
unlinkSync(CM_FS_PRELOAD);
|
|
371
371
|
}
|
|
372
372
|
catch { /* best effort */ } });
|
|
373
|
-
// Lazy
|
|
374
|
-
|
|
373
|
+
// Lazy per-project store cache — no DB overhead unless index/search is used.
|
|
374
|
+
// Keyed by resolved dbPath: multi-project hosts (opencode web serves MANY
|
|
375
|
+
// projects inside ONE process, each plugin tool call arriving with its own
|
|
376
|
+
// projectDir via withProjectDirOverride) get one store per project. A single
|
|
377
|
+
// cached instance would funnel every project's reads/writes into whichever
|
|
378
|
+
// project ran the first ctx_* tool — cross-project knowledge-base exposure
|
|
379
|
+
// AND one long-lived connection whose files other processes' startup
|
|
380
|
+
// cleanups may unlink (surfaces as disk I/O error, SQLITE_IOERR_VNODE).
|
|
381
|
+
// Mirrors the #645 SessionDB singleton re-keying pattern applied to the
|
|
382
|
+
// Pi/OMP/OpenClaw plugins.
|
|
383
|
+
const _stores = new Map();
|
|
384
|
+
/** True once the process-wide startup cleanups have run (see getStore). */
|
|
385
|
+
let _startupCleanupsRan = false;
|
|
375
386
|
/**
|
|
376
387
|
* Build the FK-attribution object passed to every ContentStore.index*() call
|
|
377
388
|
* in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
|
|
@@ -434,27 +445,34 @@ export function resolveSessionIdFromSessionDB(opts) {
|
|
|
434
445
|
}
|
|
435
446
|
}
|
|
436
447
|
/**
|
|
437
|
-
* Auto-index session events
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
448
|
+
* Auto-index the CURRENT project's session events file written by the
|
|
449
|
+
* SessionStart hook (same per-project path derivation the hook uses —
|
|
450
|
+
* canonical hash + worktree suffix). The file is consumed (deleted) after
|
|
451
|
+
* indexing to prevent double-indexing. Called on every getStore().
|
|
452
|
+
*
|
|
453
|
+
* Scoped to this project deliberately: the events filename is per-project,
|
|
454
|
+
* and a multi-project host (opencode web) shares one sessions directory
|
|
455
|
+
* across projects. The previous implementation glob-scanned the whole
|
|
456
|
+
* directory, so whichever project ran the first ctx_* tool indexed — and
|
|
457
|
+
* consumed — every OTHER project's pending events into its own store.
|
|
443
458
|
*/
|
|
444
459
|
function maybeIndexSessionEvents(store) {
|
|
445
460
|
try {
|
|
446
461
|
const sessionsDir = getSessionDir();
|
|
447
462
|
if (!existsSync(sessionsDir))
|
|
448
463
|
return;
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
464
|
+
const eventsPath = resolveSessionPath({
|
|
465
|
+
projectDir: getProjectDir(),
|
|
466
|
+
sessionsDir,
|
|
467
|
+
ext: "-events.md",
|
|
468
|
+
});
|
|
469
|
+
if (!existsSync(eventsPath))
|
|
470
|
+
return;
|
|
471
|
+
try {
|
|
472
|
+
store.index({ path: eventsPath, source: "session-events", attribution: currentAttribution() });
|
|
473
|
+
unlinkSync(eventsPath);
|
|
457
474
|
}
|
|
475
|
+
catch { /* best-effort */ }
|
|
458
476
|
}
|
|
459
477
|
catch { /* best-effort — session continuity never blocks tools */ }
|
|
460
478
|
}
|
|
@@ -632,19 +650,27 @@ function getStorePath() {
|
|
|
632
650
|
return resolveContentStorePath({ projectDir: getProjectDir(), contentDir: dir });
|
|
633
651
|
}
|
|
634
652
|
function getStore() {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
653
|
+
// Opens whatever DB exists (or creates a new one). Routine retention
|
|
654
|
+
// never deletes per-platform DB FILES — it is row-level only
|
|
655
|
+
// (cleanupStaleSources below) and on-disk reclamation is ctx_purge's
|
|
656
|
+
// job (rationale in the startup block below). The only file-deleting
|
|
657
|
+
// exception is the constructor's corruption-recovery path.
|
|
658
|
+
const dbPath = getStorePath();
|
|
659
|
+
let store = _stores.get(dbPath);
|
|
660
|
+
if (!store) {
|
|
661
|
+
store = new ContentStore(dbPath);
|
|
662
|
+
_stores.set(dbPath, store);
|
|
640
663
|
// Wire deny-policy hook: store re-checks the Read deny list before
|
|
641
664
|
// re-reading any file_path during auto-refresh. Catches policy edits
|
|
642
|
-
// made after a file was originally indexed. See #442 round-3.
|
|
643
|
-
|
|
665
|
+
// made after a file was originally indexed. See #442 round-3. The
|
|
666
|
+
// project dir is pinned at creation — the store is project-scoped, so
|
|
667
|
+
// its deny policy stays this project's even when other projects make
|
|
668
|
+
// calls into the same process.
|
|
669
|
+
const storeProjectDir = getProjectDir();
|
|
670
|
+
store.setDenyChecker((filePath) => {
|
|
644
671
|
try {
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
|
|
672
|
+
const denyGlobs = readToolDenyPatterns("Read", storeProjectDir);
|
|
673
|
+
const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", storeProjectDir);
|
|
648
674
|
return r.denied;
|
|
649
675
|
}
|
|
650
676
|
catch {
|
|
@@ -652,22 +678,36 @@ function getStore() {
|
|
|
652
678
|
return true;
|
|
653
679
|
}
|
|
654
680
|
});
|
|
655
|
-
//
|
|
681
|
+
// Per-store: drop this project's sources untouched for 14 days.
|
|
656
682
|
try {
|
|
657
|
-
|
|
658
|
-
cleanupStaleContentDBs(contentDir, 14);
|
|
659
|
-
_store.cleanupStaleSources(14);
|
|
660
|
-
// Also clean legacy shared dir from before platform isolation
|
|
661
|
-
const legacyDir = join(homedir(), ".context-mode", "content");
|
|
662
|
-
if (existsSync(legacyDir))
|
|
663
|
-
cleanupStaleContentDBs(legacyDir, 0);
|
|
683
|
+
store.cleanupStaleSources(14);
|
|
664
684
|
}
|
|
665
685
|
catch { /* best-effort */ }
|
|
666
|
-
//
|
|
667
|
-
|
|
686
|
+
// Process-wide, once: sweep the legacy shared content dir (pre-platform-
|
|
687
|
+
// isolation) and orphaned PID-based tmpdir DBs from the migration. Runs
|
|
688
|
+
// on FIRST store creation only — not once per project.
|
|
689
|
+
//
|
|
690
|
+
// The per-platform content dir gets NO automatic file deletion: mtime
|
|
691
|
+
// cannot distinguish a long-idle LIVE connection (a web host with a
|
|
692
|
+
// project open but untouched for weeks) from an abandoned DB, and
|
|
693
|
+
// unlinking an open store's db/-wal/-shm surfaces as unrecoverable
|
|
694
|
+
// disk I/O errors (SQLITE_IOERR_VNODE on macOS). Retention inside
|
|
695
|
+
// each store is handled logically above (cleanupStaleSources);
|
|
696
|
+
// on-disk reclamation is ctx_purge's job.
|
|
697
|
+
if (!_startupCleanupsRan) {
|
|
698
|
+
_startupCleanupsRan = true;
|
|
699
|
+
try {
|
|
700
|
+
const legacyDir = join(homedir(), ".context-mode", "content");
|
|
701
|
+
if (existsSync(legacyDir))
|
|
702
|
+
cleanupStaleContentDBs(legacyDir, 0);
|
|
703
|
+
}
|
|
704
|
+
catch { /* best-effort */ }
|
|
705
|
+
// Also clean old PID-based DBs from migration
|
|
706
|
+
cleanupStaleDBs();
|
|
707
|
+
}
|
|
668
708
|
}
|
|
669
|
-
maybeIndexSessionEvents(
|
|
670
|
-
return
|
|
709
|
+
maybeIndexSessionEvents(store);
|
|
710
|
+
return store;
|
|
671
711
|
}
|
|
672
712
|
// ─────────────────────────────────────────────────────────
|
|
673
713
|
// Session stats — track context consumption per tool
|
|
@@ -4629,12 +4669,17 @@ EXAMPLE: ctx_purge(confirm: true, scope: "project")`,
|
|
|
4629
4669
|
storePathForPurge = getStorePath();
|
|
4630
4670
|
}
|
|
4631
4671
|
catch { /* best effort — store path may be unresolvable on fresh install */ }
|
|
4632
|
-
if (
|
|
4633
|
-
|
|
4634
|
-
|
|
4672
|
+
if (storePathForPurge) {
|
|
4673
|
+
// Close ONLY the calling project's store — other projects' stores in a
|
|
4674
|
+
// multi-project host (opencode web) are neither purged nor disturbed.
|
|
4675
|
+
const openStore = _stores.get(storePathForPurge);
|
|
4676
|
+
if (openStore) {
|
|
4677
|
+
try {
|
|
4678
|
+
openStore.cleanup();
|
|
4679
|
+
}
|
|
4680
|
+
catch { /* best effort */ }
|
|
4681
|
+
_stores.delete(storePathForPurge);
|
|
4635
4682
|
}
|
|
4636
|
-
catch { /* best effort */ }
|
|
4637
|
-
_store = null;
|
|
4638
4683
|
}
|
|
4639
4684
|
// FTS5 store: pass contentDir so purgeSession sweeps BOTH canonical
|
|
4640
4685
|
// and legacy raw-casing variants (dual-hash, mirrors session events).
|
|
@@ -4906,8 +4951,15 @@ async function main() {
|
|
|
4906
4951
|
// Clean up own DB + backgrounded processes + preload script on shutdown
|
|
4907
4952
|
const shutdown = () => {
|
|
4908
4953
|
executor.cleanupBackgrounded();
|
|
4909
|
-
|
|
4910
|
-
|
|
4954
|
+
// Persist every open per-project DB for --continue sessions. Cleared
|
|
4955
|
+
// after close so a repeated shutdown call cannot double-close a store.
|
|
4956
|
+
for (const openStore of _stores.values()) {
|
|
4957
|
+
try {
|
|
4958
|
+
openStore.close();
|
|
4959
|
+
}
|
|
4960
|
+
catch { /* best effort */ }
|
|
4961
|
+
}
|
|
4962
|
+
_stores.clear();
|
|
4911
4963
|
try {
|
|
4912
4964
|
unlinkSync(CM_FS_PRELOAD);
|
|
4913
4965
|
}
|
package/build/session/purge.js
CHANGED
|
@@ -131,7 +131,8 @@ export function purgeSession(opts) {
|
|
|
131
131
|
//
|
|
132
132
|
// Caller is responsible for closing any persistent ContentStore
|
|
133
133
|
// handle BEFORE invoking purgeSession (Windows file lock). The
|
|
134
|
-
// ctx_purge handler does this via
|
|
134
|
+
// ctx_purge handler does this via the per-project store cache's
|
|
135
|
+
// cleanup() before delegating.
|
|
135
136
|
const ftsTargets = [];
|
|
136
137
|
if (storePath && existsSync(storePath))
|
|
137
138
|
ftsTargets.push(storePath);
|
package/build/store.d.ts
CHANGED
|
@@ -19,9 +19,28 @@ export declare function sanitizeTrigramQuery(query: string, mode?: "AND" | "OR")
|
|
|
19
19
|
export declare function cleanupStaleDBs(): number;
|
|
20
20
|
/**
|
|
21
21
|
* Clean up stale per-project content store DBs older than maxAgeDays.
|
|
22
|
-
* Scans the given directory for *.db files and
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Scans the given directory for *.db files and unlinks each (plus its
|
|
23
|
+
* -wal/-shm sidecars) when its EFFECTIVE last-write time — the newer of the
|
|
24
|
+
* main .db mtime and a non-empty -wal mtime — exceeds the cutoff.
|
|
25
|
+
*
|
|
26
|
+
* Why the -wal mtime participates: in WAL mode commits touch only the -wal
|
|
27
|
+
* file; the main .db mtime advances at checkpoint. A main-only check can
|
|
28
|
+
* delete an actively-used store that simply hasn't checkpointed within the
|
|
29
|
+
* window. Conversely, a non-empty -wal older than some threshold is NOT
|
|
30
|
+
* proof the owning process is dead — a live-but-idle connection (a session
|
|
31
|
+
* quiet over lunch) is indistinguishable from a crashed process by mtime
|
|
32
|
+
* alone. The pre-fix heuristic deleted such DBs unconditionally after 1h,
|
|
33
|
+
* which on macOS unlinks the files under an open connection and surfaces
|
|
34
|
+
* as disk I/O error (SQLITE_IOERR_VNODE) on every later write — no amount
|
|
35
|
+
* of retrying recovers an invalidated fd.
|
|
36
|
+
*
|
|
37
|
+
* Contract: a -wal can only EXTEND a DB's life (its mtime counts as the
|
|
38
|
+
* latest write), never shorten it. A WAL never triggers deletion on its
|
|
39
|
+
* own — the way the old 1-hour zombie rule did — and once the whole DB
|
|
40
|
+
* (main + non-empty wal) is beyond maxAgeDays, the caller's retention
|
|
41
|
+
* policy owns the consequences. Callers that cannot tolerate deleting a
|
|
42
|
+
* possibly-live DB (the per-platform content dir — see getStore() in
|
|
43
|
+
* src/server.ts) must not call this with a nonzero maxAgeDays.
|
|
25
44
|
*/
|
|
26
45
|
export declare function cleanupStaleContentDBs(contentDir: string, maxAgeDays: number): number;
|
|
27
46
|
export declare class ContentStore {
|