context-mode 1.0.144 → 1.0.145

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.144"
9
+ "version": "1.0.145"
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": "1.0.144",
16
+ "version": "1.0.145",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.144",
3
+ "version": "1.0.145",
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": "1.0.144",
3
+ "version": "1.0.145",
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": "1.0.144",
6
+ "version": "1.0.145",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.144",
3
+ "version": "1.0.145",
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
@@ -1387,17 +1387,7 @@ export CTX_FETCH_STRICT=1
1387
1387
 
1388
1388
  That blocks loopback + RFC1918 + ULA in addition to the always-blocked ranges. Useful when context-mode runs as a shared service, not on a developer's own machine.
1389
1389
 
1390
- `tool_input` for any `mcp__*` tool call is also redacted before persistence — keys matching `authorization`, `token`, `secret`, `password`, `api_key`, `cookie`, `signature`, `private_key` get masked to `[REDACTED]` so credentials in MCP arguments don't end up in the session DB.
1391
-
1392
- ### Lifecycle environment variables
1393
-
1394
- One runtime knob controls MCP sibling cleanup. Idle self-shutdown was removed after [#592](https://github.com/mksglu/context-mode/issues/592): hosts can keep registered tool handles after a clean MCP exit, making a timer-driven exit unsafe.
1395
-
1396
- | Variable | Default | Purpose |
1397
- |---|---|---|
1398
- | `CONTEXT_MODE_STARTUP_SWEEP` | `1` (enabled) | At boot, a newly-spawned MCP child reaps any other context-mode MCP server pids that share its parent process (`sameParentOnly: true` — never touches MCP children of a different host). This reclaims accumulated siblings immediately instead of waiting for each idle timer to fire. Set to `0` or `false` to disable (useful when you intentionally want multiple concurrent MCP children under the same host, e.g. multi-tenant test runners). |
1399
-
1400
- `CONTEXT_MODE_STARTUP_SWEEP` is read fresh at MCP server start — no restart of the host CLI is required, just spawn a new MCP child (open a new session) for changes to take effect. Unrecognized values fall back to enabled.
1390
+ `tool_input` for any `mcp__*` tool call is also redacted before persistence — the regex matcher in `hooks/posttooluse.mjs` masks `authorization`, `auth_token`, `access_token`, `refresh_token`, `bearer`, `token`, `secret`, `password`, `passwd`, `pwd`, `api_key` / `apikey` / `x_api_key`, `cookie` / `set-cookie`, `signature`, `private_key`, and `client_secret` (case-insensitive, hyphen/underscore-insensitive) to `[REDACTED]` so credentials in MCP arguments don't end up in the session DB.
1401
1391
 
1402
1392
  ### Routing-guidance environment variables
1403
1393
 
@@ -20,7 +20,30 @@
20
20
  * `resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() })`
21
21
  * in `src/session/db.ts`. Adapters expose only `getSessionDir()` for
22
22
  * storage-related path concerns.
23
+ *
24
+ * Issue #649 — `CONTEXT_MODE_DATA_DIR` universal storage override. Many
25
+ * adapters (Pi, OMP, Gemini CLI, Codex, Cursor, …) had storage hardcoded to
26
+ * `~/.<platform>/context-mode/sessions/` with no env-var escape hatch. CI
27
+ * runners on NFS homes, dev containers, and shared-workspace setups need to
28
+ * point context-mode storage at a writable volume without patching source or
29
+ * abusing the host platform's own config-dir variable. The override applies
30
+ * only to context-mode-owned state (`getSessionDir`, `getMemoryDir`) — never
31
+ * to platform-native config (`getConfigDir`, `getSettingsPath`), which must
32
+ * stay where the host platform's own tooling expects it. Adapters that
33
+ * override `getSessionDir`/`getMemoryDir` directly (claude-code, codex,
34
+ * opencode, vscode-copilot) honor the override by routing through
35
+ * `resolveContextModeDataRoot()` at the top of their override.
36
+ */
37
+ /**
38
+ * Universal storage-root override. Returns the resolved absolute path when
39
+ * `CONTEXT_MODE_DATA_DIR` is set to a non-blank value, otherwise `null` so
40
+ * callers fall back to their platform-native default.
41
+ *
42
+ * Mirrors the `resolveClaudeConfigDir` contract for env-var handling
43
+ * (whitespace guard, tilde expansion, relative-path resolution) so users
44
+ * get one consistent set of rules across every override site.
23
45
  */
46
+ export declare function resolveContextModeDataRoot(env?: NodeJS.ProcessEnv): string | null;
24
47
  export declare abstract class BaseAdapter {
25
48
  protected readonly sessionDirSegments: string[];
26
49
  constructor(sessionDirSegments: string[]);
@@ -33,6 +56,13 @@ export declare abstract class BaseAdapter {
33
56
  * openclaw, opencode) override this and resolve their segments against
34
57
  * `projectDir` (or `process.cwd()` when omitted).
35
58
  *
59
+ * NOT relocated by `CONTEXT_MODE_DATA_DIR` (#649). The platform owns its
60
+ * settings.json / hooks.json / config.toml location — relocating that
61
+ * would silently fork platform behaviour from the platform's own tooling.
62
+ * Use `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, etc. to move
63
+ * platform-native config; use `CONTEXT_MODE_DATA_DIR` to move context-mode
64
+ * storage independently.
65
+ *
36
66
  * @param _projectDir Unused by the home-rooted default — accepted so
37
67
  * project-scoped overrides honor the same signature.
38
68
  */
@@ -46,6 +76,10 @@ export declare abstract class BaseAdapter {
46
76
  * Default: <configDir>/memory. Always absolute (configDir is absolute by
47
77
  * contract). Adapters with a different memory dir name (e.g., codex uses
48
78
  * "memories" plural) override this.
79
+ *
80
+ * Issue #649: when `CONTEXT_MODE_DATA_DIR` is set, memory follows storage
81
+ * to `<DATA_DIR>/context-mode/memory/` since persistent memory is
82
+ * context-mode-owned state, not platform-native config.
49
83
  */
50
84
  getMemoryDir(): string;
51
85
  backupSettings(): string | null;
@@ -20,17 +20,51 @@
20
20
  * `resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() })`
21
21
  * in `src/session/db.ts`. Adapters expose only `getSessionDir()` for
22
22
  * storage-related path concerns.
23
+ *
24
+ * Issue #649 — `CONTEXT_MODE_DATA_DIR` universal storage override. Many
25
+ * adapters (Pi, OMP, Gemini CLI, Codex, Cursor, …) had storage hardcoded to
26
+ * `~/.<platform>/context-mode/sessions/` with no env-var escape hatch. CI
27
+ * runners on NFS homes, dev containers, and shared-workspace setups need to
28
+ * point context-mode storage at a writable volume without patching source or
29
+ * abusing the host platform's own config-dir variable. The override applies
30
+ * only to context-mode-owned state (`getSessionDir`, `getMemoryDir`) — never
31
+ * to platform-native config (`getConfigDir`, `getSettingsPath`), which must
32
+ * stay where the host platform's own tooling expects it. Adapters that
33
+ * override `getSessionDir`/`getMemoryDir` directly (claude-code, codex,
34
+ * opencode, vscode-copilot) honor the override by routing through
35
+ * `resolveContextModeDataRoot()` at the top of their override.
23
36
  */
24
- import { join } from "node:path";
37
+ import { join, resolve } from "node:path";
25
38
  import { accessSync, copyFileSync, constants, mkdirSync } from "node:fs";
26
39
  import { homedir } from "node:os";
40
+ /**
41
+ * Universal storage-root override. Returns the resolved absolute path when
42
+ * `CONTEXT_MODE_DATA_DIR` is set to a non-blank value, otherwise `null` so
43
+ * callers fall back to their platform-native default.
44
+ *
45
+ * Mirrors the `resolveClaudeConfigDir` contract for env-var handling
46
+ * (whitespace guard, tilde expansion, relative-path resolution) so users
47
+ * get one consistent set of rules across every override site.
48
+ */
49
+ export function resolveContextModeDataRoot(env = process.env) {
50
+ const raw = env.CONTEXT_MODE_DATA_DIR;
51
+ if (!raw || raw.trim() === "")
52
+ return null;
53
+ if (raw.startsWith("~")) {
54
+ return resolve(homedir(), raw.replace(/^~[/\\]?/, ""));
55
+ }
56
+ return resolve(raw);
57
+ }
27
58
  export class BaseAdapter {
28
59
  sessionDirSegments;
29
60
  constructor(sessionDirSegments) {
30
61
  this.sessionDirSegments = sessionDirSegments;
31
62
  }
32
63
  getSessionDir() {
33
- const dir = join(homedir(), ...this.sessionDirSegments, "context-mode", "sessions");
64
+ const override = resolveContextModeDataRoot();
65
+ const dir = override
66
+ ? join(override, "context-mode", "sessions")
67
+ : join(homedir(), ...this.sessionDirSegments, "context-mode", "sessions");
34
68
  mkdirSync(dir, { recursive: true });
35
69
  return dir;
36
70
  }
@@ -42,6 +76,13 @@ export class BaseAdapter {
42
76
  * openclaw, opencode) override this and resolve their segments against
43
77
  * `projectDir` (or `process.cwd()` when omitted).
44
78
  *
79
+ * NOT relocated by `CONTEXT_MODE_DATA_DIR` (#649). The platform owns its
80
+ * settings.json / hooks.json / config.toml location — relocating that
81
+ * would silently fork platform behaviour from the platform's own tooling.
82
+ * Use `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, etc. to move
83
+ * platform-native config; use `CONTEXT_MODE_DATA_DIR` to move context-mode
84
+ * storage independently.
85
+ *
45
86
  * @param _projectDir Unused by the home-rooted default — accepted so
46
87
  * project-scoped overrides honor the same signature.
47
88
  */
@@ -59,8 +100,15 @@ export class BaseAdapter {
59
100
  * Default: <configDir>/memory. Always absolute (configDir is absolute by
60
101
  * contract). Adapters with a different memory dir name (e.g., codex uses
61
102
  * "memories" plural) override this.
103
+ *
104
+ * Issue #649: when `CONTEXT_MODE_DATA_DIR` is set, memory follows storage
105
+ * to `<DATA_DIR>/context-mode/memory/` since persistent memory is
106
+ * context-mode-owned state, not platform-native config.
62
107
  */
63
108
  getMemoryDir() {
109
+ const override = resolveContextModeDataRoot();
110
+ if (override)
111
+ return join(override, "context-mode", "memory");
64
112
  return join(this.getConfigDir(), "memory");
65
113
  }
66
114
  backupSettings() {
@@ -15,6 +15,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, chmodSync, access
15
15
  import { resolve, join } from "node:path";
16
16
  import { homedir } from "node:os";
17
17
  import { ClaudeCodeBaseAdapter } from "../claude-code-base.js";
18
+ import { resolveContextModeDataRoot } from "../base.js";
18
19
  import { resolveClaudeConfigDir } from "../../util/claude-config.js";
19
20
  import { checkPluginCacheIntegritySync } from "../../util/plugin-cache-integrity.js";
20
21
  import { buildNodeCommand, } from "../types.js";
@@ -61,7 +62,14 @@ export class ClaudeCodeAdapter extends ClaudeCodeBaseAdapter {
61
62
  return resolveClaudeConfigDir();
62
63
  }
63
64
  getSessionDir() {
64
- const dir = join(this.getConfigDir(), "context-mode", "sessions");
65
+ // Issue #649: honor CONTEXT_MODE_DATA_DIR universal storage override
66
+ // before falling back to the Claude-rooted default. The override moves
67
+ // ONLY context-mode-owned state; settings.json + CLAUDE_CONFIG_DIR stay
68
+ // intact below.
69
+ const override = resolveContextModeDataRoot();
70
+ const dir = override
71
+ ? join(override, "context-mode", "sessions")
72
+ : join(this.getConfigDir(), "context-mode", "sessions");
65
73
  mkdirSync(dir, { recursive: true });
66
74
  return dir;
67
75
  }
@@ -16,7 +16,7 @@
16
16
  import { readFileSync, writeFileSync, accessSync, copyFileSync, constants, mkdirSync, } from "node:fs";
17
17
  import { resolve, dirname, join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
- import { BaseAdapter } from "../base.js";
19
+ import { BaseAdapter, resolveContextModeDataRoot } from "../base.js";
20
20
  import { resolveCodexConfigDir } from "./paths.js";
21
21
  // PreToolUse matcher: canonical Codex tool names + context-mode bare MCP tool
22
22
  // names + external MCP catch-all literal (#529, #547 hotfix).
@@ -236,7 +236,14 @@ export class CodexAdapter extends BaseAdapter {
236
236
  return join(this.getConfigDir(), "config.toml");
237
237
  }
238
238
  getSessionDir() {
239
- const dir = join(this.getConfigDir(), "context-mode", "sessions");
239
+ // Issue #649: honor CONTEXT_MODE_DATA_DIR universal storage override
240
+ // before falling back to the $CODEX_HOME-rooted default. Settings.toml
241
+ // and hooks.json continue to live under getConfigDir() so the Codex CLI
242
+ // sees its own config in the expected place.
243
+ const override = resolveContextModeDataRoot();
244
+ const dir = override
245
+ ? join(override, "context-mode", "sessions")
246
+ : join(this.getConfigDir(), "context-mode", "sessions");
240
247
  mkdirSync(dir, { recursive: true });
241
248
  return dir;
242
249
  }
@@ -252,6 +259,13 @@ export class CodexAdapter extends BaseAdapter {
252
259
  }
253
260
  getMemoryDir() {
254
261
  // Codex uses "memories" (plural), not the default "memory".
262
+ // Issue #649: honor CONTEXT_MODE_DATA_DIR for context-mode-owned
263
+ // persistent memory while preserving the platform-native plural folder
264
+ // name so legacy Codex tooling continues to find it when DATA_DIR is
265
+ // unset. Under the override, layout is `<DATA_DIR>/context-mode/memories`.
266
+ const override = resolveContextModeDataRoot();
267
+ if (override)
268
+ return join(override, "context-mode", "memories");
255
269
  return join(this.getConfigDir(), "memories");
256
270
  }
257
271
  generateHookConfig(_pluginRoot) {
@@ -25,8 +25,7 @@
25
25
  */
26
26
  import { createHash } from "node:crypto";
27
27
  import { mkdirSync } from "node:fs";
28
- import { join } from "node:path";
29
- import { SessionDB } from "../../session/db.js";
28
+ import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
30
29
  import { extractEvents } from "../../session/extract.js";
31
30
  import { buildResumeSnapshot } from "../../session/snapshot.js";
32
31
  import { OMPAdapter } from "./index.js";
@@ -62,6 +61,7 @@ const BLOCKED_BASH_PATTERNS = [
62
61
  // session_start so multi-session reuse within a long-lived plugin
63
62
  // process keeps event attribution correct.
64
63
  let _db = null;
64
+ let _dbPath = "";
65
65
  let _sessionId = "";
66
66
  const _ompAdapter = new OMPAdapter();
67
67
  function getSessionDir() {
@@ -69,12 +69,31 @@ function getSessionDir() {
69
69
  mkdirSync(dir, { recursive: true });
70
70
  return dir;
71
71
  }
72
- function getDBPath() {
73
- return join(getSessionDir(), "context-mode.db");
72
+ // Issue #645 — route through the canonical per-project resolver the MCP
73
+ // server uses (src/server.ts ctx_stats / ctx_search timeline). The
74
+ // previous shared `context-mode.db` literal was a different file from
75
+ // the `<canonical-hash>.db` the server reads, so every OMP user's
76
+ // `ctx_stats` reported zero history and `ctx_search(sort: "timeline")`
77
+ // silently dropped the sort. Mirrors the matching Pi fix and the
78
+ // opencode plugin pattern (src/adapters/opencode/plugin.ts:307).
79
+ function getDBPath(projectDir) {
80
+ return resolveSessionDbPath({ projectDir, sessionsDir: getSessionDir() });
74
81
  }
75
- function getOrCreateDB() {
76
- if (!_db) {
77
- _db = new SessionDB({ dbPath: getDBPath() });
82
+ function getOrCreateDB(projectDir) {
83
+ // Reopen the singleton if the resolved DB path changes. See the
84
+ // matching Pi extension comment defensive re-keying on projectDir
85
+ // hash keeps tests deterministic and stops a stale singleton from
86
+ // pointing at an earlier projectDir's `<hash>.db`. (#645)
87
+ const dbPath = getDBPath(projectDir);
88
+ if (!_db || _dbPath !== dbPath) {
89
+ if (_db) {
90
+ try {
91
+ _db.close();
92
+ }
93
+ catch { /* best effort */ }
94
+ }
95
+ _db = new SessionDB({ dbPath });
96
+ _dbPath = dbPath;
78
97
  }
79
98
  return _db;
80
99
  }
@@ -102,7 +121,14 @@ function deriveSessionId(ctx) {
102
121
  // The plugin's default export is the OMP factory; this helper is only
103
122
  // imported by tests to clear singletons between cases.
104
123
  export function _resetOmpPluginStateForTests() {
124
+ if (_db) {
125
+ try {
126
+ _db.close();
127
+ }
128
+ catch { /* best effort */ }
129
+ }
105
130
  _db = null;
131
+ _dbPath = "";
106
132
  _sessionId = "";
107
133
  }
108
134
  /**
@@ -129,7 +155,7 @@ export default function ompPlugin(pi) {
129
155
  // earlier `OMP_PROJECT_DIR` read was an EM mistake — no upstream code
130
156
  // ever sets it. Drop it; fall through PI_PROJECT_DIR → cwd().
131
157
  const projectDir = process.env.PI_PROJECT_DIR || process.cwd();
132
- const db = getOrCreateDB();
158
+ const db = getOrCreateDB(projectDir);
133
159
  // ── 1. session_start — initialize session row ─────────
134
160
  pi.on("session_start", (_event, ctx) => {
135
161
  try {
@@ -33,6 +33,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
33
33
  import { homedir } from "node:os";
34
34
  import { dirname, join, resolve } from "node:path";
35
35
  import { fileURLToPath, pathToFileURL } from "node:url";
36
+ import { resolveContextModeDataRoot } from "../base.js";
36
37
  import { OpenClawSessionDB } from "./session-db.js";
37
38
  import { extractEvents, extractUserEvents } from "../../session/extract.js";
38
39
  import { buildResumeSnapshot } from "../../session/snapshot.js";
@@ -70,7 +71,15 @@ const configSchema = {
70
71
  };
71
72
  // ── Helpers ───────────────────────────────────────────────
72
73
  function getSessionDir() {
73
- const dir = join(homedir(), ".openclaw", "context-mode", "sessions");
74
+ // Issue #649: honor CONTEXT_MODE_DATA_DIR universal storage override
75
+ // ahead of the hardcoded ~/.openclaw root so dev-container/CI/NFS-home
76
+ // users can relocate context-mode storage without patching the source.
77
+ // Kept in sync with OpenClawAdapter.getSessionDir() (inherited from
78
+ // BaseAdapter) — both call sites must agree byte-for-byte.
79
+ const override = resolveContextModeDataRoot();
80
+ const dir = override
81
+ ? join(override, "context-mode", "sessions")
82
+ : join(homedir(), ".openclaw", "context-mode", "sessions");
74
83
  mkdirSync(dir, { recursive: true });
75
84
  return dir;
76
85
  }
@@ -25,7 +25,7 @@ function stripJsonComments(str) {
25
25
  import { readFileSync, writeFileSync, mkdirSync, copyFileSync, accessSync, constants, } from "node:fs";
26
26
  import { resolve, join } from "node:path";
27
27
  import { homedir } from "node:os";
28
- import { BaseAdapter } from "../base.js";
28
+ import { BaseAdapter, resolveContextModeDataRoot } from "../base.js";
29
29
  // ─────────────────────────────────────────────────────────
30
30
  // Hook constants (re-exported from hooks.ts)
31
31
  // ─────────────────────────────────────────────────────────
@@ -177,7 +177,14 @@ export class OpenCodeAdapter extends BaseAdapter {
177
177
  ];
178
178
  }
179
179
  getSessionDir() {
180
- const dir = join(this.getConfigDir(), "context-mode", "sessions");
180
+ // Issue #649: honor CONTEXT_MODE_DATA_DIR universal storage override
181
+ // ahead of OpenCode/Kilo's XDG-rooted default. opencode.json + plugin
182
+ // discovery stay under getConfigDir() so OpenCode itself sees its own
183
+ // config in the expected location.
184
+ const override = resolveContextModeDataRoot();
185
+ const dir = override
186
+ ? join(override, "context-mode", "sessions")
187
+ : join(this.getConfigDir(), "context-mode", "sessions");
181
188
  mkdirSync(dir, { recursive: true });
182
189
  return dir;
183
190
  }
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { join, resolve, dirname } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
- import { SessionDB } from "../../session/db.js";
18
+ import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
19
19
  import { extractEvents, extractUserEvents } from "../../session/extract.js";
20
20
  import { buildResumeSnapshot } from "../../session/snapshot.js";
21
21
  import { bootstrapMCPTools } from "./mcp-bridge.js";
@@ -121,6 +121,7 @@ export function isSafeCurlWget(segment) {
121
121
  }
122
122
  // ── Module-level DB singleton ────────────────────────────
123
123
  let _db = null;
124
+ let _dbPath = "";
124
125
  let _sessionId = "";
125
126
  // MCP bridge handle. The bridge spawns server.bundle.mjs once and
126
127
  // registers each MCP tool through pi.registerTool() so the Pi LLM can
@@ -181,12 +182,38 @@ function getSessionDir() {
181
182
  mkdirSync(dir, { recursive: true });
182
183
  return dir;
183
184
  }
184
- function getDBPath() {
185
- return join(getSessionDir(), "context-mode.db");
185
+ // Issue #645 — the MCP server (src/server.ts ctx_stats / ctx_search
186
+ // timeline) resolves the SessionDB filename via
187
+ // `resolveSessionDbPath({ projectDir, sessionsDir })`, which produces a
188
+ // per-project canonical `<16-hex-hash>.db` (case-folded on darwin/win32,
189
+ // suffixed for non-main worktrees). The Pi extension previously wrote
190
+ // every session to a shared `context-mode.db` literal — a different
191
+ // file the server never reads. The result was silent degradation of
192
+ // `ctx_stats` (zero history) and `ctx_search(sort: "timeline")` (sort
193
+ // dropped) for every Pi user. Routing through the same helper keeps the
194
+ // extension-side writes and the server-side reads aligned across
195
+ // case-fold migrations, worktree suffixes, and any future change to the
196
+ // canonical filename contract.
197
+ function getDBPath(projectDir) {
198
+ return resolveSessionDbPath({ projectDir, sessionsDir: getSessionDir() });
186
199
  }
187
- function getOrCreateDB() {
188
- if (!_db) {
189
- _db = new SessionDB({ dbPath: getDBPath() });
200
+ function getOrCreateDB(projectDir) {
201
+ // Reopen the singleton if the resolved DB path changes. Production code
202
+ // normally loads the extension once per process with a single workspace,
203
+ // but defensive re-keying on path keeps the contract honest if a host
204
+ // ever calls piExtension(pi) twice with different projectDirs, and
205
+ // removes a subtle test-isolation foot-gun where stale singletons
206
+ // pointed at a prior test's `<hash>.db`. (#645)
207
+ const dbPath = getDBPath(projectDir);
208
+ if (!_db || _dbPath !== dbPath) {
209
+ if (_db) {
210
+ try {
211
+ _db.close();
212
+ }
213
+ catch { /* best effort */ }
214
+ }
215
+ _db = new SessionDB({ dbPath });
216
+ _dbPath = dbPath;
190
217
  }
191
218
  return _db;
192
219
  }
@@ -368,7 +395,7 @@ export default function piExtension(pi) {
368
395
  // events default to project_dir="" which causes cross-project data leakage
369
396
  // in shared SessionDB instances.
370
397
  const _attribution = { projectDir, source: "workspace_root", confidence: 0.98 };
371
- const db = getOrCreateDB();
398
+ const db = getOrCreateDB(projectDir);
372
399
  // ── 1. session_start — Initialize session ──────────────
373
400
  pi.on("session_start", (_event, ctx) => {
374
401
  try {
@@ -658,6 +685,7 @@ export default function piExtension(pi) {
658
685
  _db.cleanupOldSessions(7);
659
686
  }
660
687
  _db = null;
688
+ _dbPath = "";
661
689
  _sessionId = "";
662
690
  }
663
691
  catch {
@@ -704,7 +732,7 @@ export default function piExtension(pi) {
704
732
  description: "Run context-mode diagnostics",
705
733
  handler: async (argsOrCtx, maybeCtx) => {
706
734
  const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
707
- const dbPath = getDBPath();
735
+ const dbPath = getDBPath(projectDir);
708
736
  const dbExists = existsSync(dbPath);
709
737
  const lines = [
710
738
  "## ctx-doctor (Pi)",
@@ -102,6 +102,25 @@ export function resolveJsRuntimeForBridge(deps = {}) {
102
102
  // layer (per-tool timeout / background mode / Pi-level cancel), not
103
103
  // to the transport.
104
104
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
105
+ // Retry budget for the bridge bootstrap `initialize` handshake (#647).
106
+ //
107
+ // On cold NFS home dirs, first JIT compile of server.bundle.mjs, or
108
+ // constrained CI runners, the first `initialize` can exceed the 60s
109
+ // ceiling above. Before this fix, bootstrapMCPTools propagated the
110
+ // rejection up to extension.ts, which logged once and continued with
111
+ // NO ctx_* tools registered — silently degrading the session for its
112
+ // entire lifetime while the routing block kept emitting ~2.5K tokens
113
+ // of dead instructions per turn.
114
+ //
115
+ // Retry pattern mirrors the existing #583 single-flight respawn shape:
116
+ // on failure, shut the prior child cleanly, sleep a short backoff so
117
+ // the OS reclaims fds, then start + initialize again. After the budget
118
+ // is exhausted we re-throw and the existing extension.ts handler runs
119
+ // the degrade-and-log path — preserving the contract for genuinely
120
+ // broken servers (binary missing, runtime crash, etc.) while
121
+ // self-healing the transient warm-up case.
122
+ const MAX_INIT_RETRIES = 2;
123
+ const INIT_RETRY_DELAY_MS = 1_000;
105
124
  class PiTextComponent {
106
125
  text;
107
126
  constructor(text = "") {
@@ -566,8 +585,45 @@ export async function bootstrapMCPTools(pi, serverScript, options = {}) {
566
585
  return skippedBridge();
567
586
  }
568
587
  const client = new MCPStdioClient(serverScript, env, runtime);
569
- client.start();
570
- await client.initialize();
588
+ // Retry-on-slow-initialize (#647).
589
+ //
590
+ // Each attempt is independently bounded by DEFAULT_REQUEST_TIMEOUT_MS
591
+ // (60s) inside request(). On failure we shutdown the child to release
592
+ // its fds before respawning — this is the same sequencing the #583
593
+ // respawn path uses, just hoisted into the bootstrap layer where the
594
+ // failure happens before any tool was registered. Final attempt's
595
+ // rejection is re-thrown so extension.ts's existing then/onRejected
596
+ // handler runs the degrade-and-log path for genuinely broken servers.
597
+ let lastError;
598
+ for (let attempt = 0; attempt <= MAX_INIT_RETRIES; attempt++) {
599
+ try {
600
+ client.start();
601
+ await client.initialize();
602
+ lastError = undefined;
603
+ break;
604
+ }
605
+ catch (err) {
606
+ lastError = err;
607
+ if (attempt === MAX_INIT_RETRIES)
608
+ break;
609
+ const msg = err instanceof Error ? err.message : String(err);
610
+ process.stderr.write(`[context-mode] WARNING: MCP bridge initialize failed ` +
611
+ `(attempt ${attempt + 1}/${MAX_INIT_RETRIES + 1}): ${msg}. Retrying…\n`);
612
+ // Reclaim the failed child's fds before respawning. shutdown() is
613
+ // idempotent and bounded by a 5s SIGKILL fallback (#472 round-3),
614
+ // so a child stuck in an uninterruptible syscall cannot block the
615
+ // retry loop indefinitely.
616
+ try {
617
+ client.shutdown();
618
+ }
619
+ catch {
620
+ // best effort — we are already on the failure path
621
+ }
622
+ await new Promise((resolve) => setTimeout(resolve, INIT_RETRY_DELAY_MS));
623
+ }
624
+ }
625
+ if (lastError !== undefined)
626
+ throw lastError;
571
627
  const tools = await client.listTools();
572
628
  const registered = [];
573
629
  for (const tool of tools) {
@@ -13,6 +13,7 @@ import { readFileSync, mkdirSync, accessSync, existsSync, constants, } from "nod
13
13
  import { resolve, join } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { CopilotBaseAdapter } from "../copilot-base.js";
16
+ import { resolveContextModeDataRoot } from "../base.js";
16
17
  // ─────────────────────────────────────────────────────────
17
18
  // Hook constants (re-exported from hooks.ts)
18
19
  // ─────────────────────────────────────────────────────────
@@ -45,6 +46,16 @@ export class VSCodeCopilotAdapter extends CopilotBaseAdapter {
45
46
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
46
47
  }
47
48
  getSessionDir() {
49
+ // Issue #649: CONTEXT_MODE_DATA_DIR wins over both the .github project
50
+ // dir and the ~/.vscode fallback so dev-container/CI users can pin
51
+ // storage to a writable volume regardless of whether a .github tree
52
+ // happens to exist in cwd.
53
+ const override = resolveContextModeDataRoot();
54
+ if (override) {
55
+ const overrideDir = join(override, "context-mode", "sessions");
56
+ mkdirSync(overrideDir, { recursive: true });
57
+ return overrideDir;
58
+ }
48
59
  // Prefer .github/context-mode/sessions/ if .github exists,
49
60
  // otherwise fall back to ~/.vscode/context-mode/sessions/
50
61
  const githubDir = resolve(".github", "context-mode", "sessions");
package/build/store.js CHANGED
@@ -524,7 +524,7 @@ export class ContentStore {
524
524
  highlight(chunks, 1, char(2), char(3)) AS highlighted
525
525
  FROM chunks
526
526
  JOIN sources ON sources.id = chunks.source_id
527
- WHERE chunks MATCH ? AND sources.label LIKE ?
527
+ WHERE chunks MATCH ? AND sources.label LIKE ? ESCAPE '\\'
528
528
  ORDER BY rank
529
529
  LIMIT ?
530
530
  `);
@@ -569,7 +569,7 @@ export class ContentStore {
569
569
  highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
570
570
  FROM chunks_trigram
571
571
  JOIN sources ON sources.id = chunks_trigram.source_id
572
- WHERE chunks_trigram MATCH ? AND sources.label LIKE ?
572
+ WHERE chunks_trigram MATCH ? AND sources.label LIKE ? ESCAPE '\\'
573
573
  ORDER BY rank
574
574
  LIMIT ?
575
575
  `);
@@ -615,7 +615,7 @@ export class ContentStore {
615
615
  highlight(chunks, 1, char(2), char(3)) AS highlighted
616
616
  FROM chunks
617
617
  JOIN sources ON sources.id = chunks.source_id
618
- WHERE chunks MATCH ? AND sources.label LIKE ? AND chunks.content_type = ?
618
+ WHERE chunks MATCH ? AND sources.label LIKE ? ESCAPE '\\' AND chunks.content_type = ?
619
619
  ORDER BY rank
620
620
  LIMIT ?
621
621
  `);
@@ -660,7 +660,7 @@ export class ContentStore {
660
660
  highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
661
661
  FROM chunks_trigram
662
662
  JOIN sources ON sources.id = chunks_trigram.source_id
663
- WHERE chunks_trigram MATCH ? AND sources.label LIKE ? AND chunks_trigram.content_type = ?
663
+ WHERE chunks_trigram MATCH ? AND sources.label LIKE ? ESCAPE '\\' AND chunks_trigram.content_type = ?
664
664
  ORDER BY rank
665
665
  LIMIT ?
666
666
  `);
@@ -859,7 +859,19 @@ export class ContentStore {
859
859
  }));
860
860
  }
861
861
  #sourceFilterParam(source, sourceMatchMode) {
862
- return sourceMatchMode === "exact" ? source : `%${source}%`;
862
+ if (sourceMatchMode === "exact")
863
+ return source;
864
+ // Escape SQLite LIKE metacharacters so user-supplied source labels
865
+ // containing `_`, `%`, or `\` are matched literally rather than as
866
+ // wildcards. Backslash must be replaced first (otherwise subsequent
867
+ // escapes would themselves be re-escaped). Paired with `ESCAPE '\'`
868
+ // in the four prepared LIKE statements (#stmtSearchPorter*,
869
+ // #stmtSearchTrigram*). Regression: #646.
870
+ const escaped = source
871
+ .replace(/\\/g, "\\\\")
872
+ .replace(/%/g, "\\%")
873
+ .replace(/_/g, "\\_");
874
+ return `%${escaped}%`;
863
875
  }
864
876
  search(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
865
877
  const sanitized = sanitizeQuery(query, mode);