context-mode 1.0.150 → 1.0.152

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/mcp.json +5 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.openclaw-plugin/openclaw.plugin.json +16 -1
  6. package/.openclaw-plugin/package.json +1 -1
  7. package/README.md +89 -3
  8. package/build/adapters/claude-code/hooks.js +2 -2
  9. package/build/adapters/claude-code/index.js +14 -13
  10. package/build/adapters/client-map.js +3 -0
  11. package/build/adapters/detect.js +13 -1
  12. package/build/adapters/gemini-cli/hooks.d.ts +10 -0
  13. package/build/adapters/gemini-cli/hooks.js +12 -2
  14. package/build/adapters/gemini-cli/index.d.ts +21 -1
  15. package/build/adapters/gemini-cli/index.js +37 -1
  16. package/build/adapters/kimi/config.d.ts +8 -0
  17. package/build/adapters/kimi/config.js +8 -0
  18. package/build/adapters/kimi/hooks.d.ts +28 -0
  19. package/build/adapters/kimi/hooks.js +34 -0
  20. package/build/adapters/kimi/index.d.ts +66 -0
  21. package/build/adapters/kimi/index.js +537 -0
  22. package/build/adapters/kimi/paths.d.ts +1 -0
  23. package/build/adapters/kimi/paths.js +12 -0
  24. package/build/adapters/kiro/hooks.js +2 -2
  25. package/build/adapters/openclaw/plugin.d.ts +14 -13
  26. package/build/adapters/openclaw/plugin.js +140 -40
  27. package/build/adapters/opencode/plugin.js +4 -3
  28. package/build/adapters/opencode/zod3tov4.js +8 -8
  29. package/build/adapters/pi/extension.js +9 -24
  30. package/build/adapters/pi/mcp-bridge.js +37 -0
  31. package/build/adapters/qwen-code/index.js +7 -7
  32. package/build/adapters/types.d.ts +39 -2
  33. package/build/adapters/types.js +55 -2
  34. package/build/adapters/vscode-copilot/index.js +13 -1
  35. package/build/cli.js +433 -25
  36. package/build/executor.js +6 -3
  37. package/build/runtime.d.ts +81 -1
  38. package/build/runtime.js +195 -9
  39. package/build/search/ctx-search-schema.d.ts +90 -0
  40. package/build/search/ctx-search-schema.js +135 -0
  41. package/build/search/unified.d.ts +12 -0
  42. package/build/search/unified.js +17 -2
  43. package/build/server.d.ts +2 -1
  44. package/build/server.js +378 -97
  45. package/build/session/analytics.d.ts +36 -13
  46. package/build/session/analytics.js +123 -26
  47. package/build/session/db.d.ts +24 -0
  48. package/build/session/db.js +41 -0
  49. package/build/session/extract.js +30 -0
  50. package/build/session/snapshot.js +24 -0
  51. package/build/store.d.ts +12 -1
  52. package/build/store.js +72 -20
  53. package/build/types.d.ts +7 -0
  54. package/build/util/project-dir.d.ts +19 -16
  55. package/build/util/project-dir.js +80 -45
  56. package/cli.bundle.mjs +371 -320
  57. package/configs/kimi/hooks.json +54 -0
  58. package/configs/pi/AGENTS.md +3 -85
  59. package/hooks/cache-heal-utils.mjs +148 -0
  60. package/hooks/core/formatters.mjs +26 -0
  61. package/hooks/core/routing.mjs +9 -1
  62. package/hooks/core/stdin.mjs +74 -3
  63. package/hooks/core/tool-naming.mjs +1 -0
  64. package/hooks/heal-partial-install.mjs +712 -0
  65. package/hooks/kimi/platform.mjs +1 -0
  66. package/hooks/kimi/posttooluse.mjs +72 -0
  67. package/hooks/kimi/precompact.mjs +80 -0
  68. package/hooks/kimi/pretooluse.mjs +42 -0
  69. package/hooks/kimi/sessionend.mjs +61 -0
  70. package/hooks/kimi/sessionstart.mjs +113 -0
  71. package/hooks/kimi/stop.mjs +61 -0
  72. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  73. package/hooks/normalize-hooks.mjs +66 -12
  74. package/hooks/routing-block.mjs +8 -2
  75. package/hooks/security.bundle.mjs +1 -1
  76. package/hooks/session-db.bundle.mjs +6 -4
  77. package/hooks/session-extract.bundle.mjs +2 -2
  78. package/hooks/session-helpers.mjs +93 -3
  79. package/hooks/session-snapshot.bundle.mjs +20 -19
  80. package/hooks/sessionstart.mjs +64 -0
  81. package/insight/server.mjs +15 -3
  82. package/openclaw.plugin.json +16 -1
  83. package/package.json +1 -1
  84. package/scripts/heal-installed-plugins.mjs +31 -10
  85. package/scripts/postinstall.mjs +10 -0
  86. package/server.bundle.mjs +206 -157
  87. package/skills/ctx-index/SKILL.md +46 -0
  88. package/skills/ctx-search/SKILL.md +35 -0
  89. package/start.mjs +84 -11
  90. package/build/cache-heal.d.ts +0 -48
  91. package/build/cache-heal.js +0 -150
  92. package/build/concurrency/runPool.d.ts +0 -36
  93. package/build/concurrency/runPool.js +0 -51
  94. package/build/openclaw/mcp-tools.d.ts +0 -54
  95. package/build/openclaw/mcp-tools.js +0 -198
  96. package/build/openclaw/workspace-router.d.ts +0 -29
  97. package/build/openclaw/workspace-router.js +0 -64
  98. package/build/openclaw-plugin.d.ts +0 -130
  99. package/build/openclaw-plugin.js +0 -626
  100. package/build/opencode-plugin.d.ts +0 -122
  101. package/build/opencode-plugin.js +0 -375
  102. package/build/pi-extension.d.ts +0 -14
  103. package/build/pi-extension.js +0 -451
  104. package/build/routing-block.d.ts +0 -8
  105. package/build/routing-block.js +0 -86
  106. package/build/tool-naming.d.ts +0 -4
  107. package/build/tool-naming.js +0 -24
@@ -7,7 +7,7 @@ export interface RuntimeInfo {
7
7
  preferred: boolean;
8
8
  }
9
9
  export interface RuntimeMap {
10
- javascript: string;
10
+ javascript: string | null;
11
11
  typescript: string | null;
12
12
  python: string | null;
13
13
  shell: string;
@@ -20,8 +20,88 @@ export interface RuntimeMap {
20
20
  elixir: string | null;
21
21
  csharp: string | null;
22
22
  }
23
+ /**
24
+ * Resolve the JavaScript runtime used by PolyglotExecutor.
25
+ *
26
+ * PR #190 (f69b0d2) made `process.execPath` the default so snap-Node
27
+ * envs would not re-invoke the snap wrapper via PATH. That assumed
28
+ * `process.execPath` always points at a JS runtime — true on Node,
29
+ * tsx, and snap-Node, but FALSE when context-mode runs in-process
30
+ * inside a bun-compiled self-contained binary (OpenCode, Kilo, …).
31
+ * In those hosts, `process.execPath` resolves to `opencode.exe` /
32
+ * `opencode` (NOT node), and spawning that with a `.js` argument
33
+ * triggers the yargs "Failed to change directory" error (#731).
34
+ *
35
+ * Fix: gate `process.execPath` on the existing `JS_RUNTIMES`
36
+ * allowlist (single source of truth — same set used by
37
+ * `buildNodeCommand()` in src/adapters/types.ts since PR #708). When
38
+ * the execPath basename is not a known JS runtime, fall back to a
39
+ * PATH-resolved `node`. If neither is reachable, return `null` and
40
+ * let ctx_doctor surface an actionable error.
41
+ *
42
+ * The cross-OS guard is the allowlist itself — NOT a `win32` check.
43
+ * OpenCode ships self-contained binaries on macOS and Linux too,
44
+ * and the bug reproduces identically there.
45
+ */
46
+ export declare function resolveJavascriptRuntime(bun: string | null, deps?: {
47
+ execPath?: string;
48
+ commandExists?: (cmd: string) => boolean;
49
+ }): string | null;
23
50
  export declare function detectRuntimes(): RuntimeMap;
24
51
  export declare function hasBunRuntime(): boolean;
52
+ /**
53
+ * Resolved JS runtime for hook spawn commands. `path` is the absolute (or
54
+ * bare-name on POSIX where PATH resolution is reliable) binary path.
55
+ * `isBun` is true only when we successfully probed a Bun ≥1.0 install.
56
+ */
57
+ export interface HookRuntime {
58
+ readonly path: string;
59
+ readonly isBun: boolean;
60
+ }
61
+ /**
62
+ * Reset the hook-runtime resolution cache. Test-only — production code
63
+ * should never call this. Vitest mocks `node:child_process`/`node:fs`
64
+ * per-test, so the per-process cache from a previous test would otherwise
65
+ * mask the mock and yield the host's real bun/node detection result.
66
+ */
67
+ export declare function resetHookRuntimeCache(): void;
68
+ /**
69
+ * Resolve the JS runtime to use for spawning hook scripts (issue #738).
70
+ *
71
+ * Returns Bun when:
72
+ * - a bun binary is located via {@link bunCommand} (already handles the
73
+ * Windows .cmd shim trap from #506 + absolute path fallbacks), AND
74
+ * - `bun --version` exits 0 within the probe timeout, AND
75
+ * - the reported semver major is ≥1.
76
+ *
77
+ * Returns Node (`process.execPath`) on every other path — missing bun,
78
+ * version probe failure, version <1, malformed version banner. Silent
79
+ * fallback: never throws, never logs to stderr (a noisy log would clutter
80
+ * the same MCP boot output that #719 tightened up).
81
+ *
82
+ * Result is cached at module load so the cost is amortised across every
83
+ * hook command emission for the lifetime of the process. The cache also
84
+ * keeps the behaviour deterministic — if the user `brew uninstall bun`
85
+ * mid-session, the cached resolution stays valid for that session and the
86
+ * next MCP boot re-detects.
87
+ *
88
+ * Why bun ≥1.0 instead of "any bun":
89
+ * - Bun 0.x had multiple ESM/module-resolution regressions that broke
90
+ * dynamic `import()` inside hooks (and our hooks do ~7 dynamic imports
91
+ * in `pretooluse.mjs`).
92
+ * - 1.0 ships stable npm-compat that our better-sqlite3-adjacent code
93
+ * relies on indirectly (hooks share `ensure-deps.mjs` which is
94
+ * bun-safe past 1.0 but not 0.x).
95
+ *
96
+ * NOT used by:
97
+ * - `buildNodeCommand` — kept on `process.execPath` for openclaw doctor /
98
+ * upgrade hints which must invoke the better-sqlite3-loading CLI on
99
+ * Node (#543: bun cannot dlopen better-sqlite3's prebuilt .node).
100
+ * - `ensure-deps.mjs` — separate path, must stay on Node for the same
101
+ * reason.
102
+ * - `ctx_upgrade` — separate path, must stay on Node for the same reason.
103
+ */
104
+ export declare function resolveHookRuntime(): HookRuntime;
25
105
  export declare function getRuntimeSummary(runtimes: RuntimeMap): string;
26
106
  export declare function getAvailableLanguages(runtimes: RuntimeMap): Language[];
27
107
  export declare function buildCommand(runtimes: RuntimeMap, language: Language, filePath: string): string[];
package/build/runtime.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { JS_RUNTIMES } from "./adapters/types.js";
3
4
  /**
4
5
  * Allowlist for SHELL env override. Only POSIX shells + Windows shells permit
5
6
  * arbitrary command interpretation; anything else (e.g., /usr/bin/python set
@@ -21,6 +22,11 @@ export function isAllowlistedShell(shellPath) {
21
22
  // Cross-OS basename: split on either separator, take the last segment.
22
23
  return ALLOWED_SHELL_BASENAMES.test(runtimeBasename(shellPath));
23
24
  }
25
+ function isWindowsWslBash(shellPath) {
26
+ const lower = shellPath.toLowerCase().replace(/\//g, "\\");
27
+ return /\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(lower) ||
28
+ /\\microsoft\\windowsapps\\bash\.exe$/.test(lower);
29
+ }
24
30
  const isWindows = process.platform === "win32";
25
31
  function commandExists(cmd) {
26
32
  try {
@@ -189,24 +195,71 @@ function getVersion(cmd, args = ["--version"]) {
189
195
  return "unknown";
190
196
  }
191
197
  }
198
+ /**
199
+ * Resolve the JavaScript runtime used by PolyglotExecutor.
200
+ *
201
+ * PR #190 (f69b0d2) made `process.execPath` the default so snap-Node
202
+ * envs would not re-invoke the snap wrapper via PATH. That assumed
203
+ * `process.execPath` always points at a JS runtime — true on Node,
204
+ * tsx, and snap-Node, but FALSE when context-mode runs in-process
205
+ * inside a bun-compiled self-contained binary (OpenCode, Kilo, …).
206
+ * In those hosts, `process.execPath` resolves to `opencode.exe` /
207
+ * `opencode` (NOT node), and spawning that with a `.js` argument
208
+ * triggers the yargs "Failed to change directory" error (#731).
209
+ *
210
+ * Fix: gate `process.execPath` on the existing `JS_RUNTIMES`
211
+ * allowlist (single source of truth — same set used by
212
+ * `buildNodeCommand()` in src/adapters/types.ts since PR #708). When
213
+ * the execPath basename is not a known JS runtime, fall back to a
214
+ * PATH-resolved `node`. If neither is reachable, return `null` and
215
+ * let ctx_doctor surface an actionable error.
216
+ *
217
+ * The cross-OS guard is the allowlist itself — NOT a `win32` check.
218
+ * OpenCode ships self-contained binaries on macOS and Linux too,
219
+ * and the bug reproduces identically there.
220
+ */
221
+ export function resolveJavascriptRuntime(bun, deps = {}) {
222
+ if (bun)
223
+ return bun;
224
+ const execPath = deps.execPath ?? process.execPath;
225
+ const cmdExists = deps.commandExists ?? commandExists;
226
+ // Cross-OS basename: split on either separator, strip optional `.exe`.
227
+ const base = execPath
228
+ .split(/[\\/]/)
229
+ .pop()
230
+ .replace(/\.exe$/i, "");
231
+ if (JS_RUNTIMES.has(base)) {
232
+ // Real JS runtime (node, bun, deno) — preserves #190 snap-Node fix
233
+ // because the snap wrapper's binary is literally named `node`.
234
+ return execPath;
235
+ }
236
+ // Host binary (opencode/kilo/etc.) — fall back to node on PATH.
237
+ if (cmdExists("node"))
238
+ return "node";
239
+ // No usable runtime — doctor + summary must handle null gracefully.
240
+ return null;
241
+ }
192
242
  export function detectRuntimes() {
193
243
  const hasBun = bunExists();
194
244
  const bun = hasBun ? bunCommand() : null;
195
245
  // Honor SHELL env var when it points at a real binary AND the basename is
196
- // an allowlisted shell. Lets users with non-standard setups (WSL, custom
197
- // bash, msys2) pin context-mode to their preferred shell.
246
+ // an allowlisted shell. Lets users with non-standard setups (custom bash,
247
+ // msys2, pwsh) pin context-mode to their preferred shell.
198
248
  //
199
249
  // Allowlist (PR #401 ops review): basename must match
200
- // /^(bash|sh|zsh|dash|pwsh|cmd)(\.exe)?$/. Without this guard, an attacker
250
+ // /^(bash|sh|zsh|dash|pwsh|powershell|cmd)(\.exe)?$/. Without this guard, an attacker
201
251
  // who controls SHELL (e.g., supply-chain compromise of a profile script)
202
252
  // could redirect the executor to /usr/bin/python or any arbitrary binary.
203
253
  const userShell = process.env.SHELL;
204
- const shellOverride = userShell && existsSync(userShell) && isAllowlistedShell(userShell)
254
+ const isWin = process.platform === "win32";
255
+ const shellOverride = userShell &&
256
+ existsSync(userShell) &&
257
+ isAllowlistedShell(userShell) &&
258
+ !(isWin && isWindowsWslBash(userShell))
205
259
  ? userShell
206
260
  : null;
207
- const isWin = process.platform === "win32";
208
261
  return {
209
- javascript: bun ?? process.execPath,
262
+ javascript: resolveJavascriptRuntime(bun),
210
263
  typescript: bun
211
264
  ? bun
212
265
  : commandExists("tsx")
@@ -241,10 +294,130 @@ export function detectRuntimes() {
241
294
  export function hasBunRuntime() {
242
295
  return bunExists();
243
296
  }
297
+ /**
298
+ * Cached result of {@link resolveHookRuntime}. Populated on first call so the
299
+ * relatively expensive `bun --version` probe runs at most once per process.
300
+ * Reset via {@link resetHookRuntimeCache} (test-only).
301
+ */
302
+ let _hookRuntimeCache = null;
303
+ /**
304
+ * Reset the hook-runtime resolution cache. Test-only — production code
305
+ * should never call this. Vitest mocks `node:child_process`/`node:fs`
306
+ * per-test, so the per-process cache from a previous test would otherwise
307
+ * mask the mock and yield the host's real bun/node detection result.
308
+ */
309
+ export function resetHookRuntimeCache() {
310
+ _hookRuntimeCache = null;
311
+ }
312
+ /**
313
+ * Parse a `bun --version` stdout string and return true when the version is
314
+ * ≥1.0.0. Anything that doesn't match `MAJOR.MINOR.PATCH` (with optional
315
+ * pre-release suffix) returns false — we refuse to trust runtimes whose
316
+ * version we can't read because the failure mode is silent miscompare
317
+ * (e.g. a banner line getting interpreted as "0.0.0").
318
+ */
319
+ function bunVersionAtLeast1(versionOutput) {
320
+ const trimmed = versionOutput.trim();
321
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(trimmed);
322
+ if (!m)
323
+ return false;
324
+ const major = Number(m[1]);
325
+ return Number.isFinite(major) && major >= 1;
326
+ }
327
+ /**
328
+ * Resolve the JS runtime to use for spawning hook scripts (issue #738).
329
+ *
330
+ * Returns Bun when:
331
+ * - a bun binary is located via {@link bunCommand} (already handles the
332
+ * Windows .cmd shim trap from #506 + absolute path fallbacks), AND
333
+ * - `bun --version` exits 0 within the probe timeout, AND
334
+ * - the reported semver major is ≥1.
335
+ *
336
+ * Returns Node (`process.execPath`) on every other path — missing bun,
337
+ * version probe failure, version <1, malformed version banner. Silent
338
+ * fallback: never throws, never logs to stderr (a noisy log would clutter
339
+ * the same MCP boot output that #719 tightened up).
340
+ *
341
+ * Result is cached at module load so the cost is amortised across every
342
+ * hook command emission for the lifetime of the process. The cache also
343
+ * keeps the behaviour deterministic — if the user `brew uninstall bun`
344
+ * mid-session, the cached resolution stays valid for that session and the
345
+ * next MCP boot re-detects.
346
+ *
347
+ * Why bun ≥1.0 instead of "any bun":
348
+ * - Bun 0.x had multiple ESM/module-resolution regressions that broke
349
+ * dynamic `import()` inside hooks (and our hooks do ~7 dynamic imports
350
+ * in `pretooluse.mjs`).
351
+ * - 1.0 ships stable npm-compat that our better-sqlite3-adjacent code
352
+ * relies on indirectly (hooks share `ensure-deps.mjs` which is
353
+ * bun-safe past 1.0 but not 0.x).
354
+ *
355
+ * NOT used by:
356
+ * - `buildNodeCommand` — kept on `process.execPath` for openclaw doctor /
357
+ * upgrade hints which must invoke the better-sqlite3-loading CLI on
358
+ * Node (#543: bun cannot dlopen better-sqlite3's prebuilt .node).
359
+ * - `ensure-deps.mjs` — separate path, must stay on Node for the same
360
+ * reason.
361
+ * - `ctx_upgrade` — separate path, must stay on Node for the same reason.
362
+ */
363
+ export function resolveHookRuntime() {
364
+ if (_hookRuntimeCache)
365
+ return _hookRuntimeCache;
366
+ const nodeFallback = { path: process.execPath, isBun: false };
367
+ try {
368
+ if (!bunExists()) {
369
+ _hookRuntimeCache = nodeFallback;
370
+ return _hookRuntimeCache;
371
+ }
372
+ const bun = bunCommand();
373
+ // Re-use the same probe shape as getVersion (POSIX execFile, Windows
374
+ // execSync quoted string for DEP0190 compliance).
375
+ let versionOutput;
376
+ try {
377
+ if (process.platform === "win32") {
378
+ const out = execSync(`"${bun}" --version`, {
379
+ encoding: "utf-8",
380
+ stdio: ["pipe", "pipe", "pipe"],
381
+ timeout: 5000,
382
+ });
383
+ versionOutput = String(out);
384
+ }
385
+ else {
386
+ const out = execFileSync(bun, ["--version"], {
387
+ encoding: "utf-8",
388
+ stdio: ["pipe", "pipe", "pipe"],
389
+ timeout: 5000,
390
+ });
391
+ versionOutput = String(out);
392
+ }
393
+ }
394
+ catch {
395
+ _hookRuntimeCache = nodeFallback;
396
+ return _hookRuntimeCache;
397
+ }
398
+ if (!bunVersionAtLeast1(versionOutput)) {
399
+ _hookRuntimeCache = nodeFallback;
400
+ return _hookRuntimeCache;
401
+ }
402
+ _hookRuntimeCache = { path: bun, isBun: true };
403
+ return _hookRuntimeCache;
404
+ }
405
+ catch {
406
+ _hookRuntimeCache = nodeFallback;
407
+ return _hookRuntimeCache;
408
+ }
409
+ }
244
410
  export function getRuntimeSummary(runtimes) {
245
411
  const lines = [];
246
412
  const bunPreferred = runtimes.javascript?.endsWith("bun") ?? false;
247
- lines.push(` JavaScript: ${runtimes.javascript} (${getVersion(runtimes.javascript)})${bunPreferred ? " ⚡" : ""}`);
413
+ if (runtimes.javascript) {
414
+ lines.push(` JavaScript: ${runtimes.javascript} (${getVersion(runtimes.javascript)})${bunPreferred ? " ⚡" : ""}`);
415
+ }
416
+ else {
417
+ // #731: host binary (opencode/kilo) AND no PATH-resolvable node.
418
+ // Surface actionable guidance instead of rendering literal `null`.
419
+ lines.push(` JavaScript: not available (install node or bun — host process is not a JS runtime)`);
420
+ }
248
421
  if (runtimes.typescript) {
249
422
  lines.push(` TypeScript: ${runtimes.typescript} (${getVersion(runtimes.typescript)})`);
250
423
  }
@@ -308,6 +481,12 @@ export function getAvailableLanguages(runtimes) {
308
481
  export function buildCommand(runtimes, language, filePath) {
309
482
  switch (language) {
310
483
  case "javascript":
484
+ if (!runtimes.javascript) {
485
+ // #731: in-process plugin host (opencode/kilo binary) AND no
486
+ // PATH-resolvable node. Refuse early with an actionable error
487
+ // instead of spawning the host binary (the original bug shape).
488
+ throw new Error("No JavaScript runtime available. Install Node.js or Bun on PATH (the host process is not itself a JS runtime).");
489
+ }
311
490
  return BUN_BASENAME.test(runtimeBasename(runtimes.javascript))
312
491
  ? [runtimes.javascript, "run", filePath]
313
492
  : [runtimes.javascript, filePath];
@@ -341,9 +520,16 @@ export function buildCommand(runtimes, language, filePath) {
341
520
  return [runtimes.shell, "-c", `source '${escaped}'`];
342
521
  }
343
522
  if (shellName.includes("powershell") || shellName.includes("pwsh")) {
344
- return [runtimes.shell, "-File", filePath];
523
+ // Windows PowerShell defaults to Restricted when no execution policy
524
+ // is configured. Use process-scoped Bypass so generated temp scripts
525
+ // run without changing machine/user policy.
526
+ return [runtimes.shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", filePath];
527
+ }
528
+ const shellBase = shellName.split(/[\\/]/).pop() ?? shellName;
529
+ if (shellBase === "cmd" || shellBase === "cmd.exe") {
530
+ return [runtimes.shell, "/d", "/s", "/c", filePath];
345
531
  }
346
- // cmd.exe and others: direct file (cmd reads .cmd association safely).
532
+ // Other Windows shells: direct file.
347
533
  }
348
534
  return [runtimes.shell, filePath];
349
535
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ctx_search input-schema builder and project-scope resolver.
3
+ *
4
+ * Issue #737 introduces the optional `project:` parameter used by callers
5
+ * running in the shared-DB mode (`CONTEXT_MODE_PROJECT_DIR` is set). The
6
+ * field is registered conditionally so that in the default per-project DB
7
+ * mode the LLM physically cannot pass it — the parameter does not exist
8
+ * in the tool schema at all, which is a stronger guarantee than runtime
9
+ * validation that depends on the model honouring documentation.
10
+ *
11
+ * The handler in `src/server.ts` consumes both exports:
12
+ * - {@link buildCtxSearchInputSchema} composes the Zod object used at
13
+ * `registerTool` time, spreading the conditional `project` field only
14
+ * when `isSharedMode` is true.
15
+ * - {@link resolveProjectScope} normalises the raw param into the
16
+ * three-state contract consumed by `searchAllSources`:
17
+ * undefined → no filter
18
+ * null → explicit cross-project recall (no filter)
19
+ * string → restrict to that project directory
20
+ */
21
+ import { z } from "zod";
22
+ /**
23
+ * Build the Zod object passed to `server.registerTool("ctx_search", …)`.
24
+ *
25
+ * The base fields (`queries`, `limit`, `source`, `contentType`, `sort`)
26
+ * are always present and mirror today's contract exactly. The `project`
27
+ * field is only spread in when `isSharedMode` is true. When the host runs
28
+ * with the default per-project DB layout the schema does not expose the
29
+ * field at all, which keeps the tool surface honest about what is
30
+ * actionable in that mode.
31
+ */
32
+ export declare function buildCtxSearchInputSchema(isSharedMode: boolean): z.ZodObject<{
33
+ queries: z.ZodEffects<z.ZodOptional<z.ZodArray<z.ZodString, "many">>, string[] | undefined, unknown>;
34
+ limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
35
+ source: z.ZodOptional<z.ZodString>;
36
+ contentType: z.ZodOptional<z.ZodEnum<["code", "prose"]>>;
37
+ sort: z.ZodDefault<z.ZodOptional<z.ZodEnum<["relevance", "timeline"]>>>;
38
+ } | {
39
+ project: z.ZodOptional<z.ZodString>;
40
+ queries: z.ZodEffects<z.ZodOptional<z.ZodArray<z.ZodString, "many">>, string[] | undefined, unknown>;
41
+ limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
42
+ source: z.ZodOptional<z.ZodString>;
43
+ contentType: z.ZodOptional<z.ZodEnum<["code", "prose"]>>;
44
+ sort: z.ZodDefault<z.ZodOptional<z.ZodEnum<["relevance", "timeline"]>>>;
45
+ }, "strip", z.ZodTypeAny, {
46
+ sort: "relevance" | "timeline";
47
+ limit: number;
48
+ source?: string | undefined;
49
+ contentType?: "code" | "prose" | undefined;
50
+ queries?: string[] | undefined;
51
+ } | {
52
+ sort: "relevance" | "timeline";
53
+ limit: number;
54
+ source?: string | undefined;
55
+ contentType?: "code" | "prose" | undefined;
56
+ queries?: string[] | undefined;
57
+ project?: unknown;
58
+ }, {
59
+ sort?: "relevance" | "timeline" | undefined;
60
+ source?: string | undefined;
61
+ limit?: number | undefined;
62
+ contentType?: "code" | "prose" | undefined;
63
+ queries?: unknown;
64
+ } | {
65
+ sort?: "relevance" | "timeline" | undefined;
66
+ source?: string | undefined;
67
+ limit?: number | undefined;
68
+ contentType?: "code" | "prose" | undefined;
69
+ queries?: unknown;
70
+ project?: unknown;
71
+ }>;
72
+ /**
73
+ * Normalise the raw `project` value into the three-state contract consumed
74
+ * by {@link searchAllSources}.
75
+ *
76
+ * - shared mode OFF → `undefined` (param ignored)
77
+ * - shared mode ON, param `undefined` → current project (`getProjectDirFn()`)
78
+ * - shared mode ON, param `"global"` → `null` (no filter — cross-project)
79
+ * - shared mode ON, param `<string>` → that string verbatim
80
+ *
81
+ * The function is pure so it stays trivially testable without spinning up
82
+ * the MCP server.
83
+ */
84
+ export declare function resolveProjectScope(raw: string | undefined, isSharedMode: boolean, getProjectDirFn: () => string): string | null | undefined;
85
+ /**
86
+ * Module-load snapshot of `CONTEXT_MODE_PROJECT_DIR`. Captured once so the
87
+ * tool schema registered with `server.registerTool` reflects the launch
88
+ * environment — the LLM-visible surface should never flip mid-session.
89
+ */
90
+ export declare const CTX_SEARCH_SHARED_MODE: boolean;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * ctx_search input-schema builder and project-scope resolver.
3
+ *
4
+ * Issue #737 introduces the optional `project:` parameter used by callers
5
+ * running in the shared-DB mode (`CONTEXT_MODE_PROJECT_DIR` is set). The
6
+ * field is registered conditionally so that in the default per-project DB
7
+ * mode the LLM physically cannot pass it — the parameter does not exist
8
+ * in the tool schema at all, which is a stronger guarantee than runtime
9
+ * validation that depends on the model honouring documentation.
10
+ *
11
+ * The handler in `src/server.ts` consumes both exports:
12
+ * - {@link buildCtxSearchInputSchema} composes the Zod object used at
13
+ * `registerTool` time, spreading the conditional `project` field only
14
+ * when `isSharedMode` is true.
15
+ * - {@link resolveProjectScope} normalises the raw param into the
16
+ * three-state contract consumed by `searchAllSources`:
17
+ * undefined → no filter
18
+ * null → explicit cross-project recall (no filter)
19
+ * string → restrict to that project directory
20
+ */
21
+ import { z } from "zod";
22
+ /**
23
+ * Helper that mirrors the Zod coercer used elsewhere in the server for
24
+ * array-shaped tool args. Kept inline so this module has no runtime
25
+ * dependency on `server.ts` (which would create a cycle).
26
+ *
27
+ * Behaviour mirrors `coerceJsonArray` in `server.ts`:
28
+ * 1. Empty / whitespace string → returned untouched so Zod surfaces the
29
+ * "non-empty" error rather than masquerading as `[""]`.
30
+ * 2. Valid JSON array string → parsed and returned.
31
+ * 3. Any other plain string (a bare single query) → lifted to a
32
+ * single-element array. Fixes #627 for the native OpenCode plugin
33
+ * path where some providers deliver `queries: "search term"`.
34
+ */
35
+ function coerceJsonArray(val) {
36
+ if (typeof val === "string") {
37
+ const trimmed = val.trim();
38
+ if (trimmed.length === 0)
39
+ return val;
40
+ try {
41
+ const parsed = JSON.parse(val);
42
+ if (Array.isArray(parsed))
43
+ return parsed;
44
+ }
45
+ catch {
46
+ /* fall through — not JSON, treat as bare-string lift */
47
+ }
48
+ return [val];
49
+ }
50
+ return val;
51
+ }
52
+ /**
53
+ * Build the Zod object passed to `server.registerTool("ctx_search", …)`.
54
+ *
55
+ * The base fields (`queries`, `limit`, `source`, `contentType`, `sort`)
56
+ * are always present and mirror today's contract exactly. The `project`
57
+ * field is only spread in when `isSharedMode` is true. When the host runs
58
+ * with the default per-project DB layout the schema does not expose the
59
+ * field at all, which keeps the tool surface honest about what is
60
+ * actionable in that mode.
61
+ */
62
+ export function buildCtxSearchInputSchema(isSharedMode) {
63
+ const projectField = isSharedMode
64
+ ? {
65
+ project: z
66
+ .string()
67
+ .optional()
68
+ .describe("Project scope. " +
69
+ "Default (omit): this session's project — auto-resolved from the host adapter. " +
70
+ "'global': span every project in the shared store (cross-project recall). " +
71
+ "<absolute-path>: scope to that specific project directory."),
72
+ }
73
+ : {};
74
+ return z.object({
75
+ queries: z.preprocess(coerceJsonArray, z
76
+ .array(z.string())
77
+ .optional()
78
+ .describe("Array of search queries. Batch ALL questions in one call.")),
79
+ // limit: z.coerce.number() (not z.number()) — OpenCode's native
80
+ // plugin path delivers tool args straight from the LLM provider's
81
+ // tool-call JSON, where several providers stringify primitives
82
+ // (limit:"4" instead of limit:4). Since v1.0.139 / #621 we run
83
+ // inputSchema.parse() on that path, so a plain z.number() rejects
84
+ // "4" with "Expected number, received string". z.coerce mirrors what
85
+ // ctx_batch_execute / ctx_fetch_and_index / ctx_execute already do.
86
+ // Fixes #627.
87
+ limit: z
88
+ .coerce.number()
89
+ .optional()
90
+ .default(3)
91
+ .describe("Results per query (default: 3)"),
92
+ source: z
93
+ .string()
94
+ .optional()
95
+ .describe("Filter to a specific indexed source (partial match)."),
96
+ contentType: z
97
+ .enum(["code", "prose"])
98
+ .optional()
99
+ .describe("Filter results by content type: 'code' or 'prose'."),
100
+ sort: z
101
+ .enum(["relevance", "timeline"])
102
+ .optional()
103
+ .default("relevance")
104
+ .describe("Sort mode. 'relevance' (default): BM25 ranked, current session only. " +
105
+ "'timeline': chronological across current session, prior sessions, and auto-memory."),
106
+ ...projectField,
107
+ });
108
+ }
109
+ /**
110
+ * Normalise the raw `project` value into the three-state contract consumed
111
+ * by {@link searchAllSources}.
112
+ *
113
+ * - shared mode OFF → `undefined` (param ignored)
114
+ * - shared mode ON, param `undefined` → current project (`getProjectDirFn()`)
115
+ * - shared mode ON, param `"global"` → `null` (no filter — cross-project)
116
+ * - shared mode ON, param `<string>` → that string verbatim
117
+ *
118
+ * The function is pure so it stays trivially testable without spinning up
119
+ * the MCP server.
120
+ */
121
+ export function resolveProjectScope(raw, isSharedMode, getProjectDirFn) {
122
+ if (!isSharedMode)
123
+ return undefined;
124
+ if (raw === undefined)
125
+ return getProjectDirFn();
126
+ if (raw === "global")
127
+ return null;
128
+ return raw;
129
+ }
130
+ /**
131
+ * Module-load snapshot of `CONTEXT_MODE_PROJECT_DIR`. Captured once so the
132
+ * tool schema registered with `server.registerTool` reflects the launch
133
+ * environment — the LLM-visible surface should never flip mid-session.
134
+ */
135
+ export const CTX_SEARCH_SHARED_MODE = !!process.env.CONTEXT_MODE_PROJECT_DIR;
@@ -31,6 +31,18 @@ export interface SearchAllSourcesOpts {
31
31
  configDir?: string;
32
32
  /** Detected platform adapter — used for adapter-aware auto-memory. */
33
33
  adapter?: AutoMemoryAdapter;
34
+ /**
35
+ * Per-project scope for the ContentStore filter (#737). Only honoured
36
+ * when a `sessionDB` is also supplied (the 2-step IN-clause needs the
37
+ * SessionDB to translate `project_dir` → list of session ids).
38
+ *
39
+ * - `undefined` — no project filter, today's behaviour.
40
+ * - `null` — cross-project recall in shared-DB mode (also no filter).
41
+ * - `string` — restrict ContentStore results to chunks attributed to
42
+ * session ids whose events match this `project_dir`,
43
+ * plus legacy `session_id=''` chunks (public surface).
44
+ */
45
+ projectScope?: string | null;
34
46
  }
35
47
  /**
36
48
  * Search across all available sources.
@@ -20,14 +20,29 @@ const DEBUG = process.env.DEBUG?.includes("context-mode");
20
20
  * are always returned.
21
21
  */
22
22
  export function searchAllSources(opts) {
23
- const { query, limit, store, sort = "relevance", source, contentType, sessionDB, projectDir, configDir, adapter, } = opts;
23
+ const { query, limit, store, sort = "relevance", source, contentType, sessionDB, projectDir, configDir, adapter, projectScope, } = opts;
24
24
  const results = [];
25
25
  // Capture session start time once — used as proxy for ContentStore items
26
26
  // (we don't know exact indexing time, but all content is from current session)
27
27
  const sessionStartTime = new Date().toISOString();
28
+ // ── Project scope (#737) ──
29
+ // Resolve the per-project session-id allow-set ONCE, before the
30
+ // ContentStore call. `projectScope === null` means cross-project recall —
31
+ // an explicit "no filter" choice surfaced by the ctx_search caller — and
32
+ // `undefined` falls back to today's unfiltered behaviour.
33
+ let sessionIdAllowSet;
34
+ if (typeof projectScope === "string" && sessionDB) {
35
+ try {
36
+ sessionIdAllowSet = new Set(sessionDB.getSessionIdsForProject(projectScope));
37
+ }
38
+ catch (e) {
39
+ if (DEBUG)
40
+ process.stderr.write(`[ctx] getSessionIdsForProject failed: ${e}\n`);
41
+ }
42
+ }
28
43
  // ── Source 1: ContentStore (always, both modes) ──
29
44
  try {
30
- const storeResults = store.searchWithFallback(query, limit, source, contentType);
45
+ const storeResults = store.searchWithFallback(query, limit, source, contentType, "like", sessionIdAllowSet);
31
46
  results.push(...storeResults.map((r) => ({
32
47
  title: r.title,
33
48
  content: r.content,
package/build/server.d.ts CHANGED
@@ -97,7 +97,8 @@ export declare function getProjectDir(): string;
97
97
  */
98
98
  export declare function positionsFromHighlight(highlighted: string): number[];
99
99
  export declare function extractSnippet(content: string, query: string, maxLen?: number, highlighted?: string): string;
100
- export declare function formatBatchQueryResults(store: ContentStore, queries: string[], source: string, maxOutput?: number): string[];
100
+ export type BatchQueryScope = "batch" | "global";
101
+ export declare function formatBatchQueryResults(store: ContentStore, queries: string[], source: string, maxOutput?: number, scope?: BatchQueryScope): string[];
101
102
  export interface BatchCommand {
102
103
  label: string;
103
104
  command: string;