context-mode 1.0.151 → 1.0.153

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 (106) 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/cli.js +433 -25
  35. package/build/executor.js +6 -3
  36. package/build/runtime.d.ts +81 -1
  37. package/build/runtime.js +195 -9
  38. package/build/search/ctx-search-schema.d.ts +90 -0
  39. package/build/search/ctx-search-schema.js +135 -0
  40. package/build/search/unified.d.ts +12 -0
  41. package/build/search/unified.js +17 -2
  42. package/build/server.d.ts +2 -1
  43. package/build/server.js +378 -97
  44. package/build/session/analytics.d.ts +36 -3
  45. package/build/session/analytics.js +88 -26
  46. package/build/session/db.d.ts +24 -0
  47. package/build/session/db.js +41 -0
  48. package/build/session/extract.js +30 -0
  49. package/build/session/snapshot.js +24 -0
  50. package/build/store.d.ts +12 -1
  51. package/build/store.js +72 -20
  52. package/build/types.d.ts +7 -0
  53. package/build/util/project-dir.d.ts +19 -16
  54. package/build/util/project-dir.js +80 -45
  55. package/cli.bundle.mjs +370 -319
  56. package/configs/kimi/hooks.json +54 -0
  57. package/configs/pi/AGENTS.md +3 -85
  58. package/hooks/cache-heal-utils.mjs +148 -0
  59. package/hooks/core/formatters.mjs +26 -0
  60. package/hooks/core/routing.mjs +9 -1
  61. package/hooks/core/stdin.mjs +74 -3
  62. package/hooks/core/tool-naming.mjs +1 -0
  63. package/hooks/heal-partial-install.mjs +712 -0
  64. package/hooks/kimi/platform.mjs +1 -0
  65. package/hooks/kimi/posttooluse.mjs +72 -0
  66. package/hooks/kimi/precompact.mjs +80 -0
  67. package/hooks/kimi/pretooluse.mjs +42 -0
  68. package/hooks/kimi/sessionend.mjs +61 -0
  69. package/hooks/kimi/sessionstart.mjs +113 -0
  70. package/hooks/kimi/stop.mjs +61 -0
  71. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  72. package/hooks/normalize-hooks.mjs +66 -12
  73. package/hooks/routing-block.mjs +8 -2
  74. package/hooks/security.bundle.mjs +1 -1
  75. package/hooks/session-db.bundle.mjs +6 -4
  76. package/hooks/session-extract.bundle.mjs +2 -2
  77. package/hooks/session-helpers.mjs +93 -3
  78. package/hooks/session-snapshot.bundle.mjs +20 -19
  79. package/hooks/sessionstart.mjs +64 -0
  80. package/insight/server.mjs +15 -3
  81. package/openclaw.plugin.json +16 -1
  82. package/package.json +1 -1
  83. package/scripts/heal-installed-plugins.mjs +31 -10
  84. package/scripts/postinstall.mjs +10 -0
  85. package/server.bundle.mjs +206 -157
  86. package/skills/ctx-index/SKILL.md +46 -0
  87. package/skills/ctx-search/SKILL.md +35 -0
  88. package/start.mjs +84 -11
  89. package/build/cache-heal.d.ts +0 -48
  90. package/build/cache-heal.js +0 -150
  91. package/build/concurrency/runPool.d.ts +0 -36
  92. package/build/concurrency/runPool.js +0 -51
  93. package/build/openclaw/mcp-tools.d.ts +0 -54
  94. package/build/openclaw/mcp-tools.js +0 -198
  95. package/build/openclaw/workspace-router.d.ts +0 -29
  96. package/build/openclaw/workspace-router.js +0 -64
  97. package/build/openclaw-plugin.d.ts +0 -130
  98. package/build/openclaw-plugin.js +0 -626
  99. package/build/opencode-plugin.d.ts +0 -122
  100. package/build/opencode-plugin.js +0 -375
  101. package/build/pi-extension.d.ts +0 -14
  102. package/build/pi-extension.js +0 -451
  103. package/build/routing-block.d.ts +0 -8
  104. package/build/routing-block.js +0 -86
  105. package/build/tool-naming.d.ts +0 -4
  106. package/build/tool-naming.js +0 -24
package/build/server.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { createRequire } from "node:module";
5
- import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync } from "node:fs";
5
+ import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
6
6
  import { execSync, spawnSync } from "node:child_process";
7
7
  import { join, dirname, resolve, sep, isAbsolute } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -24,13 +24,14 @@ import { purgeSession } from "./session/purge.js";
24
24
  import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
25
25
  import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
26
26
  import { searchAllSources } from "./search/unified.js";
27
- import { buildNodeCommand } from "./adapters/types.js";
27
+ import { buildCtxSearchInputSchema, CTX_SEARCH_SHARED_MODE, resolveProjectScope, } from "./search/ctx-search-schema.js";
28
+ import { buildNodeCommand, isInProcessPluginPlatform } from "./adapters/types.js";
28
29
  import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
29
30
  import { getHookScriptPaths } from "./util/hook-config.js";
30
31
  import { resolveClaudeConfigDir } from "./util/claude-config.js";
31
32
  import { resolveProjectDir } from "./util/project-dir.js";
32
33
  import { loadDatabase } from "./db-base.js";
33
- import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, OPUS_INPUT_PRICE_PER_TOKEN } from "./session/analytics.js";
34
+ import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, pricePerToken } from "./session/analytics.js";
34
35
  const __pkg_dir = dirname(fileURLToPath(import.meta.url));
35
36
  const VERSION = (() => {
36
37
  for (const rel of ["../package.json", "./package.json"]) {
@@ -611,6 +612,7 @@ const sessionStats = {
611
612
  bytesIndexed: 0,
612
613
  bytesSandboxed: 0, // network I/O consumed inside sandbox (never enters context)
613
614
  cacheHits: 0,
615
+ cacheMisses: 0, // ctx_fetch_and_index calls that bypassed the TTL cache
614
616
  cacheBytesSaved: 0, // bytes avoided by TTL cache hits
615
617
  sessionStart: Date.now(),
616
618
  };
@@ -804,7 +806,7 @@ const STATS_PERSIST_THROTTLE_MS = 500;
804
806
  // rendering missing fields (PR #401 architect review P1.3).
805
807
  // v2: added tokens_saved_lifetime + dollars_saved_lifetime.
806
808
  const STATS_SCHEMA_VERSION = 2;
807
- // OPUS_INPUT_PRICE_PER_TOKEN intentionally NOT defined here — single source in
809
+ // pricePerToken() intentionally NOT defined here — single source in
808
810
  // src/session/analytics.ts re-exported above. (P1.1 — pricing constant dedup,
809
811
  // PR #401 architect + ops 2-vote convergence.)
810
812
  const LIFETIME_REFRESH_MS = 30_000;
@@ -820,8 +822,21 @@ let _lifetimeCache;
820
822
  * (`pid-<parent pid>`), so a status line script can derive
821
823
  * the same id from `$PPID` without coupling to MCP.
822
824
  */
825
+ // CLAUDE_SESSION_ID flows from the hosting process (Claude Code, pi, etc.)
826
+ // straight into a path.join, and path.join collapses ".." into the result,
827
+ // so a host env CLAUDE_SESSION_ID=../../evil writes "stats-evil.json" two
828
+ // levels above statsDir. The env var is not under direct MCP-tool-caller
829
+ // control, but in CI / multi-tenant contexts where the host env is partly
830
+ // influenceable this is an arbitrary-write primitive within the MCP server
831
+ // process's filesystem permissions. Constrain to a UUID-shaped charset
832
+ // before splicing into the stats filename.
833
+ const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
834
+ function sanitizeSessionId(raw) {
835
+ return SESSION_ID_RE.test(raw) ? raw : `pid-${process.ppid}`;
836
+ }
823
837
  function getStatsFilePath() {
824
- const sessionId = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
838
+ const raw = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
839
+ const sessionId = sanitizeSessionId(raw);
825
840
  const statsDir = ensureWritableStorageDir(resolveStatsStorageDir(getDefaultSessionDir));
826
841
  return join(statsDir, `stats-${sessionId}.json`);
827
842
  }
@@ -872,12 +887,13 @@ function persistStats() {
872
887
  total_processed: totalProcessed,
873
888
  reduction_pct: reductionPct,
874
889
  tokens_saved: tokensSaved,
875
- // statusline-facing $ values — pre-computed at Opus input rate so the
876
- // statusline doesn't have to know pricing. Lets us evolve pricing in
877
- // one place without touching consumers.
878
- dollars_saved_session: +(tokensSaved * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2),
890
+ // statusline-facing $ values — pre-computed at the current per-token
891
+ // rate (dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set by a
892
+ // Pi host; Opus $15/1M otherwise). Resolved on every persist via
893
+ // pricePerToken() so the env override picks up without an MCP restart.
894
+ dollars_saved_session: +(tokensSaved * pricePerToken()).toFixed(2),
879
895
  tokens_saved_lifetime: lifetimeTokens,
880
- dollars_saved_lifetime: +(lifetimeTokens * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2),
896
+ dollars_saved_lifetime: +(lifetimeTokens * pricePerToken()).toFixed(2),
881
897
  by_tool: Object.fromEntries(Object.keys({ ...sessionStats.calls, ...sessionStats.bytesReturned }).map((t) => [
882
898
  t,
883
899
  {
@@ -1080,15 +1096,20 @@ export function extractSnippet(content, query, maxLen = 1500, highlighted) {
1080
1096
  }
1081
1097
  return parts.join("\n\n");
1082
1098
  }
1083
- export function formatBatchQueryResults(store, queries, source, maxOutput = 80 * 1024) {
1099
+ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 * 1024, scope = "batch") {
1084
1100
  const sections = [];
1085
1101
  let outputSize = 0;
1102
+ // When scope is "global", searchWithFallback receives `undefined` for the
1103
+ // source filter, which makes it query the entire persistent index instead
1104
+ // of only the chunks just produced by this batch's commands. Default
1105
+ // remains "batch" to preserve the historical behavior.
1106
+ const searchSource = scope === "global" ? undefined : source;
1086
1107
  for (const query of queries) {
1087
1108
  if (outputSize > maxOutput) {
1088
1109
  sections.push(`## ${query}\n(output cap reached — use ctx_search(queries: ["${query}"]) for details)\n`);
1089
1110
  continue;
1090
1111
  }
1091
- const results = store.searchWithFallback(query, 3, source, undefined, "exact");
1112
+ const results = store.searchWithFallback(query, 3, searchSource, undefined, "exact");
1092
1113
  sections.push(`## ${query}`);
1093
1114
  sections.push("");
1094
1115
  if (results.length > 0) {
@@ -1104,7 +1125,12 @@ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 *
1104
1125
  sections.push("No matching sections found.");
1105
1126
  sections.push("");
1106
1127
  }
1107
- sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\`.`);
1128
+ if (scope === "global") {
1129
+ sections.push(`\n> **Scope:** Queries searched the entire persistent index (query_scope: "global").`);
1130
+ }
1131
+ else {
1132
+ sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\` or call ctx_batch_execute with \`query_scope: "global"\`.`);
1133
+ }
1108
1134
  return sections;
1109
1135
  }
1110
1136
  function quotePosixSingle(value) {
@@ -1125,7 +1151,42 @@ export function buildBatchNodeOptionsPrefix(shellPath, preloadPath) {
1125
1151
  }
1126
1152
  return `NODE_OPTIONS=${quotePosixSingle(option)} `;
1127
1153
  }
1128
- function formatCommandOutput(label, raw, onFsBytes) {
1154
+ /**
1155
+ * Per-section budget for the echoed `$ <command>` line so a 50KB heredoc
1156
+ * payload cannot dominate the response body. The full command always reaches
1157
+ * the executor — only the echo is clipped (Issues #717 + #736).
1158
+ */
1159
+ const COMMAND_ECHO_MAX = 500;
1160
+ function truncateCommandForEcho(command) {
1161
+ const cleaned = command.replace(/\s+/g, " ").trim();
1162
+ if (cleaned.length <= COMMAND_ECHO_MAX)
1163
+ return cleaned;
1164
+ return cleaned.slice(0, COMMAND_ECHO_MAX) + "…";
1165
+ }
1166
+ /**
1167
+ * Per-call budget for the source-code echo prepended by `ctx_execute` and
1168
+ * `ctx_execute_file` (Issues #717 + #736). The full code always reaches the
1169
+ * sandbox — only the echo is clipped so massive payloads don't dominate
1170
+ * the response. Multi-line preserved (unlike command echo) so the user
1171
+ * sees the actual program shape.
1172
+ */
1173
+ const CODE_ECHO_MAX = 2000;
1174
+ function truncateCodeForEcho(code) {
1175
+ if (code.length <= CODE_ECHO_MAX)
1176
+ return code;
1177
+ return code.slice(0, CODE_ECHO_MAX) + "\n… (truncated)";
1178
+ }
1179
+ /**
1180
+ * Build the source-code preamble surfaced before tool stdout. Provenance
1181
+ * survives in indexed chunks (FTS5 sees the fenced block) so later
1182
+ * ctx_search hits remember what ran.
1183
+ */
1184
+ function buildExecuteEcho(language, code, path) {
1185
+ const header = path ? `path=${path}\n` : "";
1186
+ const fenced = `\`\`\`${language}\n${truncateCodeForEcho(code)}\n\`\`\``;
1187
+ return `${header}${fenced}\n\n`;
1188
+ }
1189
+ function formatCommandOutput(label, command, raw, onFsBytes) {
1129
1190
  let output = raw || "(no output)";
1130
1191
  const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
1131
1192
  let cmdFsBytes = 0;
@@ -1135,7 +1196,11 @@ function formatCommandOutput(label, raw, onFsBytes) {
1135
1196
  onFsBytes?.(cmdFsBytes);
1136
1197
  output = output.replace(/__CM_FS__:\d+\n?/g, "");
1137
1198
  }
1138
- return `# ${label}\n\n${output}\n`;
1199
+ // Echo the executed command below the section heading so per-chunk
1200
+ // indexed content retains provenance for later ctx_search hits
1201
+ // (Issues #717 + #736).
1202
+ const echoed = truncateCommandForEcho(command);
1203
+ return `# ${label}\n\n$ ${echoed}\n\n${output}\n`;
1139
1204
  }
1140
1205
  function combineExecOutput(result) {
1141
1206
  const stdout = result.stdout || "";
@@ -1180,7 +1245,7 @@ export async function runBatchCommands(commands, opts, executor) {
1180
1245
  code: `${nodeOptsPrefix}${cmd.command}`,
1181
1246
  timeout: perCmdTimeout,
1182
1247
  });
1183
- outputs.push(formatCommandOutput(cmd.label, combineExecOutput(result), onFsBytes));
1248
+ outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes));
1184
1249
  if (result.timedOut) {
1185
1250
  timedOut = true;
1186
1251
  for (let j = i + 1; j < commands.length; j++) {
@@ -1203,7 +1268,7 @@ export async function runBatchCommands(commands, opts, executor) {
1203
1268
  });
1204
1269
  // Always route partial output through formatCommandOutput so __CM_FS__
1205
1270
  // markers are stripped + counted, even when the command timed out.
1206
- const formatted = formatCommandOutput(cmd.label, combineExecOutput(result), onFsBytes);
1271
+ const formatted = formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes);
1207
1272
  const output = result.timedOut
1208
1273
  ? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
1209
1274
  : formatted;
@@ -1388,6 +1453,10 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1388
1453
  })(typeof require!=='undefined'?require:null);`;
1389
1454
  }
1390
1455
  const result = await executor.execute({ language, code: instrumentedCode, timeout, background });
1456
+ // Echo the executed source code before stdout so users can audit
1457
+ // and tooling can block command patterns (Issues #717 + #736).
1458
+ // Built from the user-supplied `code`, NOT the instrumented variant.
1459
+ const echo = buildExecuteEcho(language, code);
1391
1460
  // Parse sandbox network metrics from stderr
1392
1461
  const netMatch = result.stderr?.match(/__CM_NET__:(\d+)/);
1393
1462
  if (netMatch) {
@@ -1409,7 +1478,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1409
1478
  content: [
1410
1479
  {
1411
1480
  type: "text",
1412
- text: `${partialOutput}\n\n_(process backgrounded after ${timeout}ms — still running)_`,
1481
+ text: `${echo}${partialOutput}\n\n_(process backgrounded after ${timeout}ms — still running)_`,
1413
1482
  },
1414
1483
  ],
1415
1484
  });
@@ -1420,7 +1489,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1420
1489
  content: [
1421
1490
  {
1422
1491
  type: "text",
1423
- text: `${partialOutput}\n\n_(timed out after ${timeout}ms — partial output shown above)_`,
1492
+ text: `${echo}${partialOutput}\n\n_(timed out after ${timeout}ms — partial output shown above)_`,
1424
1493
  },
1425
1494
  ],
1426
1495
  });
@@ -1429,7 +1498,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1429
1498
  content: [
1430
1499
  {
1431
1500
  type: "text",
1432
- text: `Execution timed out after ${timeout}ms\n\nstderr:\n${result.stderr}`,
1501
+ text: `${echo}Execution timed out after ${timeout}ms\n\nstderr:\n${result.stderr}`,
1433
1502
  },
1434
1503
  ],
1435
1504
  isError: true,
@@ -1443,7 +1512,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1443
1512
  trackIndexed(Buffer.byteLength(output));
1444
1513
  return trackResponse("ctx_execute", {
1445
1514
  content: [
1446
- { type: "text", text: intentSearch(output, intent, isError ? `execute:${language}:error` : `execute:${language}`) },
1515
+ { type: "text", text: `${echo}${intentSearch(output, intent, isError ? `execute:${language}:error` : `execute:${language}`)}` },
1447
1516
  ],
1448
1517
  isError,
1449
1518
  });
@@ -1453,14 +1522,14 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1453
1522
  trackIndexed(Buffer.byteLength(output));
1454
1523
  return trackResponse("ctx_execute", {
1455
1524
  content: [
1456
- { type: "text", text: intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`) },
1525
+ { type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`)}` },
1457
1526
  ],
1458
1527
  isError,
1459
1528
  });
1460
1529
  }
1461
1530
  return trackResponse("ctx_execute", {
1462
1531
  content: [
1463
- { type: "text", text: output },
1532
+ { type: "text", text: `${echo}${output}` },
1464
1533
  ],
1465
1534
  isError,
1466
1535
  });
@@ -1471,17 +1540,25 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1471
1540
  trackIndexed(Buffer.byteLength(stdout));
1472
1541
  return trackResponse("ctx_execute", {
1473
1542
  content: [
1474
- { type: "text", text: intentSearch(stdout, intent, `execute:${language}`) },
1543
+ { type: "text", text: `${echo}${intentSearch(stdout, intent, `execute:${language}`)}` },
1475
1544
  ],
1476
1545
  });
1477
1546
  }
1478
1547
  // Auto-index large stdout into FTS5 — return pointer, not raw content
1479
1548
  if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
1480
- return trackResponse("ctx_execute", indexStdout(stdout, `execute:${language}`));
1549
+ const indexed = indexStdout(stdout, `execute:${language}`);
1550
+ // Prepend echo to the first text content so provenance still surfaces
1551
+ const echoed = {
1552
+ ...indexed,
1553
+ content: indexed.content.map((c, i) => i === 0 && c.type === "text"
1554
+ ? { ...c, text: `${echo}${c.text}` }
1555
+ : c),
1556
+ };
1557
+ return trackResponse("ctx_execute", echoed);
1481
1558
  }
1482
1559
  return trackResponse("ctx_execute", {
1483
1560
  content: [
1484
- { type: "text", text: stdout },
1561
+ { type: "text", text: `${echo}${stdout}` },
1485
1562
  ],
1486
1563
  });
1487
1564
  }
@@ -1638,12 +1715,15 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
1638
1715
  code,
1639
1716
  timeout,
1640
1717
  });
1718
+ // Echo path + executed source code before stdout for audit/debug
1719
+ // (Issues #717 + #736).
1720
+ const echo = buildExecuteEcho(language, code, path);
1641
1721
  if (result.timedOut) {
1642
1722
  return trackResponse("ctx_execute_file", {
1643
1723
  content: [
1644
1724
  {
1645
1725
  type: "text",
1646
- text: `Timed out processing ${path} after ${timeout}ms`,
1726
+ text: `${echo}Timed out processing ${path} after ${timeout}ms`,
1647
1727
  },
1648
1728
  ],
1649
1729
  isError: true,
@@ -1657,7 +1737,7 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
1657
1737
  trackIndexed(Buffer.byteLength(output));
1658
1738
  return trackResponse("ctx_execute_file", {
1659
1739
  content: [
1660
- { type: "text", text: intentSearch(output, intent, isError ? `file:${path}:error` : `file:${path}`) },
1740
+ { type: "text", text: `${echo}${intentSearch(output, intent, isError ? `file:${path}:error` : `file:${path}`)}` },
1661
1741
  ],
1662
1742
  isError,
1663
1743
  });
@@ -1667,14 +1747,14 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
1667
1747
  trackIndexed(Buffer.byteLength(output));
1668
1748
  return trackResponse("ctx_execute_file", {
1669
1749
  content: [
1670
- { type: "text", text: intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`) },
1750
+ { type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`)}` },
1671
1751
  ],
1672
1752
  isError,
1673
1753
  });
1674
1754
  }
1675
1755
  return trackResponse("ctx_execute_file", {
1676
1756
  content: [
1677
- { type: "text", text: output },
1757
+ { type: "text", text: `${echo}${output}` },
1678
1758
  ],
1679
1759
  isError,
1680
1760
  });
@@ -1684,17 +1764,24 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
1684
1764
  trackIndexed(Buffer.byteLength(stdout));
1685
1765
  return trackResponse("ctx_execute_file", {
1686
1766
  content: [
1687
- { type: "text", text: intentSearch(stdout, intent, `file:${path}`) },
1767
+ { type: "text", text: `${echo}${intentSearch(stdout, intent, `file:${path}`)}` },
1688
1768
  ],
1689
1769
  });
1690
1770
  }
1691
1771
  // Auto-index large stdout into FTS5 — return pointer, not raw content
1692
1772
  if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
1693
- return trackResponse("ctx_execute_file", indexStdout(stdout, `file:${path}`));
1773
+ const indexed = indexStdout(stdout, `file:${path}`);
1774
+ const echoed = {
1775
+ ...indexed,
1776
+ content: indexed.content.map((c, i) => i === 0 && c.type === "text"
1777
+ ? { ...c, text: `${echo}${c.text}` }
1778
+ : c),
1779
+ };
1780
+ return trackResponse("ctx_execute_file", echoed);
1694
1781
  }
1695
1782
  return trackResponse("ctx_execute_file", {
1696
1783
  content: [
1697
- { type: "text", text: stdout },
1784
+ { type: "text", text: `${echo}${stdout}` },
1698
1785
  ],
1699
1786
  });
1700
1787
  }
@@ -1780,6 +1867,35 @@ EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
1780
1867
  // resolved path is a directory, walk it bounded and re-enter `index()`
1781
1868
  // per-file so the security gate at store.ts:845 (TOCTOU defense from
1782
1869
  // #442 round-3) keeps running for every file.
1870
+ //
1871
+ // Root-level symlink defense: the deny-glob check above ran on the
1872
+ // user-supplied `path`. If `path` is a symlink whose target lands in
1873
+ // a sensitive directory (e.g. `/tmp/link -> /etc`), statSync would
1874
+ // happily report directory and walkDirectoryDetailed would
1875
+ // realpathSync internally, walking /etc with the user's deny globs
1876
+ // bound to /tmp/link instead of the real target. Detect the symlink
1877
+ // with lstatSync, follow it once, and re-apply the deny check
1878
+ // against the realpath so the user's deny globs see the actual
1879
+ // walk root.
1880
+ if (resolvedPath && existsSync(resolvedPath)) {
1881
+ const lst = lstatSync(resolvedPath);
1882
+ if (lst.isSymbolicLink()) {
1883
+ let realTarget;
1884
+ try {
1885
+ realTarget = realpathSync(resolvedPath);
1886
+ }
1887
+ catch {
1888
+ return trackResponse("ctx_index", {
1889
+ content: [{ type: "text", text: "Error: symlink target could not be resolved." }],
1890
+ });
1891
+ }
1892
+ if (realTarget !== resolvedPath) {
1893
+ const realDenied = checkFilePathDenyPolicy(realTarget, "ctx_index");
1894
+ if (realDenied)
1895
+ return realDenied;
1896
+ }
1897
+ }
1898
+ }
1783
1899
  if (resolvedPath && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
1784
1900
  const store = getStore();
1785
1901
  const projectDir = getProjectDir();
@@ -1858,12 +1974,24 @@ EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
1858
1974
  // ─────────────────────────────────────────────────────────
1859
1975
  // Tool: search — progressive throttling
1860
1976
  // ─────────────────────────────────────────────────────────
1861
- // Track search calls per 60-second window for progressive throttling
1977
+ // Track search calls per N-second window for progressive throttling.
1978
+ // Defaults preserve the historical behavior (60s window, soft-cap at 3
1979
+ // calls, hard-block at 8). All three thresholds are overridable via env
1980
+ // vars so users can loosen or tighten the policy without forking. Invalid
1981
+ // values (non-positive numbers, NaN) fall back to the default to avoid
1982
+ // silently disabling the protection.
1983
+ function readPositiveEnv(name, defaultValue) {
1984
+ const raw = process.env[name];
1985
+ if (!raw)
1986
+ return defaultValue;
1987
+ const parsed = Number(raw);
1988
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
1989
+ }
1862
1990
  let searchCallCount = 0;
1863
1991
  let searchWindowStart = Date.now();
1864
- const SEARCH_WINDOW_MS = 60_000;
1865
- const SEARCH_MAX_RESULTS_AFTER = 3; // after 3 calls: 1 result per query
1866
- const SEARCH_BLOCK_AFTER = 8; // after 8 calls: refuse, demand batching
1992
+ const SEARCH_WINDOW_MS = readPositiveEnv("CONTEXT_MODE_SEARCH_WINDOW_MS", 60_000);
1993
+ const SEARCH_MAX_RESULTS_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER", 3); // after N calls: 1 result per query
1994
+ const SEARCH_BLOCK_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_BLOCK_AFTER", 8); // after N calls: refuse, demand batching
1867
1995
  /**
1868
1996
  * Defensive coercion: parse stringified JSON arrays, AND lift a bare
1869
1997
  * non-empty string into a single-element array.
@@ -1948,45 +2076,17 @@ WHEN NOT:
1948
2076
  - You have one ad-hoc question against data that is not in the knowledge base — answer it inline by running code in the sandbox tool; one round-trip instead of capture-then-query
1949
2077
 
1950
2078
  RETURNS:
1951
- Per-query ranked sections with window-extracted snippets. Use 2-4 specific technical terms per query. Common session-memory source labels: \`decision\` (user corrections / preferences), \`error\` and \`error-resolution\` (past failures + their fixes), \`blocker\`, \`plan\`, \`user-prompt\`, \`rejected-approach\`, \`compaction\` (post-compact session guide). See ctx_stats for live category counts.
2079
+ Per-query ranked sections with window-extracted snippets. Use 2-4 specific technical terms per query. Common session-memory source labels: \`decision\` (user corrections / preferences), \`error\` and \`error-resolution\` (past failures + their fixes), \`blocker\`, \`plan\`, \`user-prompt\`, \`rejected-approach\`, \`compaction\` (post-compact session guide). See ctx_stats for live category counts. Each response carries a throttle counter (call #N/M in the rolling time window); results taper toward the soft cap and calls block after the hard cap. Tune via CONTEXT_MODE_SEARCH_WINDOW_MS, CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER, CONTEXT_MODE_SEARCH_BLOCK_AFTER.
1952
2080
 
1953
2081
  EXAMPLE: ctx_search(queries: ["root cause", "proposed fix", "test coverage"], source: "issue-#683")
1954
2082
  EXAMPLE: ctx_search(queries: ["what did we decide about caching"], source: "decision", sort: "timeline")
1955
2083
  EXAMPLE: ctx_search(queries: ["useEffect cleanup pattern"], source: "react-docs", contentType: "code", limit: 5)
1956
2084
  EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blockers"], sort: "timeline")`,
1957
- inputSchema: z.object({
1958
- queries: z.preprocess(coerceJsonArray, z
1959
- .array(z.string())
1960
- .optional()
1961
- .describe("Array of search queries. Batch ALL questions in one call.")),
1962
- // limit: z.coerce.number() (not z.number()) — OpenCode's native
1963
- // plugin path delivers tool args straight from the LLM provider's
1964
- // tool-call JSON, where several providers stringify primitives
1965
- // (limit:"4" instead of limit:4). Since v1.0.139 / #621 we run
1966
- // inputSchema.parse() on that path, so a plain z.number() rejects
1967
- // "4" with "Expected number, received string". z.coerce mirrors what
1968
- // ctx_batch_execute / ctx_fetch_and_index / ctx_execute already do.
1969
- // Fixes #627.
1970
- limit: z
1971
- .coerce.number()
1972
- .optional()
1973
- .default(3)
1974
- .describe("Results per query (default: 3)"),
1975
- source: z
1976
- .string()
1977
- .optional()
1978
- .describe("Filter to a specific indexed source (partial match)."),
1979
- contentType: z
1980
- .enum(["code", "prose"])
1981
- .optional()
1982
- .describe("Filter results by content type: 'code' or 'prose'."),
1983
- sort: z
1984
- .enum(["relevance", "timeline"])
1985
- .optional()
1986
- .default("relevance")
1987
- .describe("Sort mode. 'relevance' (default): BM25 ranked, current session only. " +
1988
- "'timeline': chronological across current session, prior sessions, and auto-memory."),
1989
- }),
2085
+ // Schema construction is centralised in `src/search/ctx-search-schema.ts`
2086
+ // so the conditional `project` field (only registered when the host runs
2087
+ // in shared-DB mode, `CONTEXT_MODE_PROJECT_DIR` set at module load) is a
2088
+ // hard property of the tool surface — not a runtime hint. Fixes #737.
2089
+ inputSchema: buildCtxSearchInputSchema(CTX_SEARCH_SHARED_MODE),
1990
2090
  }, async (params) => {
1991
2091
  try {
1992
2092
  const store = getStore();
@@ -2023,7 +2123,12 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
2023
2123
  isError: true,
2024
2124
  });
2025
2125
  }
2026
- const { limit = 3, source, contentType } = params;
2126
+ const { limit = 3, source, contentType, project } = params;
2127
+ // Resolve the per-project scope (#737). When shared-DB mode is off the
2128
+ // resolver returns `undefined` and `project` is silently ignored — the
2129
+ // per-project DB is naturally isolated by directory hash, so there is
2130
+ // nothing for an in-process filter to do.
2131
+ const projectScope = resolveProjectScope(project, CTX_SEARCH_SHARED_MODE, () => getProjectDir());
2027
2132
  // Progressive throttling: track calls in time window
2028
2133
  const now = Date.now();
2029
2134
  if (now - searchWindowStart > SEARCH_WINDOW_MS) {
@@ -2050,9 +2155,13 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
2050
2155
  const MAX_TOTAL = 40 * 1024; // 40KB total cap
2051
2156
  let totalSize = 0;
2052
2157
  const sections = [];
2053
- // Open SessionDB once before the loop (Blocker 4: avoid open/close per query)
2158
+ // Open SessionDB once before the loop (Blocker 4: avoid open/close per query).
2159
+ // Issue #737: also open in relevance mode when a string `projectScope`
2160
+ // is in play — the 2-step IN-clause needs SessionDB to translate
2161
+ // `project_dir` → allow-set of session ids for the ContentStore filter.
2054
2162
  let timelineDB = null;
2055
- if (sort === "timeline") {
2163
+ const needsSessionDB = sort === "timeline" || typeof projectScope === "string";
2164
+ if (needsSessionDB) {
2056
2165
  try {
2057
2166
  const sessionsDir = getSessionDir();
2058
2167
  const projectDir = getProjectDir();
@@ -2063,6 +2172,17 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
2063
2172
  }
2064
2173
  catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
2065
2174
  }
2175
+ // Resolve the session-id allow-set once for the relevance-mode path —
2176
+ // searchAllSources resolves its own copy for timeline mode. Empty set
2177
+ // is preserved (means "no events for this project"), which surfaces
2178
+ // only legacy `session_id=''` chunks via the post-filter.
2179
+ let relevanceAllowSet;
2180
+ if (typeof projectScope === "string" && timelineDB) {
2181
+ try {
2182
+ relevanceAllowSet = new Set(timelineDB.getSessionIdsForProject(projectScope));
2183
+ }
2184
+ catch { /* best-effort */ }
2185
+ }
2066
2186
  const configDir = _detectedAdapter?.getConfigDir() ?? resolveClaudeConfigDir();
2067
2187
  try {
2068
2188
  for (const q of queryList) {
@@ -2083,10 +2203,11 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
2083
2203
  projectDir: getProjectDir(),
2084
2204
  configDir,
2085
2205
  adapter: _detectedAdapter ?? undefined,
2206
+ projectScope,
2086
2207
  });
2087
2208
  }
2088
2209
  else {
2089
- results = store.searchWithFallback(q, effectiveLimit, source, contentType);
2210
+ results = store.searchWithFallback(q, effectiveLimit, source, contentType, "like", relevanceAllowSet);
2090
2211
  }
2091
2212
  if (results.length === 0) {
2092
2213
  sections.push(`## ${q}\nNo results found.`);
@@ -2117,12 +2238,22 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
2117
2238
  if (store.lastRefreshCount > 0) {
2118
2239
  output = `> Auto-refreshed ${store.lastRefreshCount} stale source${store.lastRefreshCount > 1 ? "s" : ""} (file changed since indexing).\n\n` + output;
2119
2240
  }
2120
- // Add throttle warning after threshold
2241
+ // Throttle counter always surfaced so agents can pace themselves
2242
+ // proactively instead of discovering the limit only after results are
2243
+ // already truncated. Soft warning after SEARCH_MAX_RESULTS_AFTER calls;
2244
+ // gentle informational line before that.
2245
+ const throttleRemaining = Math.max(0, SEARCH_BLOCK_AFTER - searchCallCount);
2246
+ const softCapRemaining = Math.max(0, SEARCH_MAX_RESULTS_AFTER - searchCallCount);
2121
2247
  if (searchCallCount >= SEARCH_MAX_RESULTS_AFTER) {
2122
2248
  output += `\n\n⚠ search call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
2123
- `Results limited to ${effectiveLimit}/query. ` +
2249
+ `Results limited to ${effectiveLimit}/query. ${throttleRemaining} call(s) remaining before block. ` +
2124
2250
  `Batch queries: ctx_search(queries: ["q1","q2","q3"]) or use ctx_batch_execute.`;
2125
2251
  }
2252
+ else {
2253
+ output += `\n\n> Throttle: call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
2254
+ `${softCapRemaining} call(s) before soft cap. ` +
2255
+ `Prefer ctx_search(queries: [...]) array form for multi-query workloads — it counts as a single call.`;
2256
+ }
2126
2257
  if (output.trim().length === 0) {
2127
2258
  const sources = store.listSources();
2128
2259
  const sourceList = sources.length > 0
@@ -2390,6 +2521,25 @@ async function fetchWithManualRedirect(initialUrl) {
2390
2521
  throw new Error('SSRF blocked: redirect chain exceeded ' + MAX_REDIRECTS + ' hops');
2391
2522
  }
2392
2523
 
2524
+ // Subprocess response-body size cap. A malicious or unexpectedly large
2525
+ // endpoint reachable through ctx_fetch_and_index would otherwise stream
2526
+ // gigabytes into resp.text(), then into outputPath, then into the parent
2527
+ // MCP server's heap via readFileSync. 50 MB is far above typical web
2528
+ // page / API response sizes (~1-5 MB) but bounded enough to keep parent
2529
+ // heap survivable. Cap both early via Content-Length and after the read.
2530
+ const MAX_FETCH_BYTES = 50 * 1024 * 1024;
2531
+ async function safeText(resp) {
2532
+ const cl = parseInt(resp.headers.get('content-length') || '0', 10);
2533
+ if (cl > MAX_FETCH_BYTES) {
2534
+ throw new Error('Response too large: Content-Length ' + cl + ' exceeds ' + MAX_FETCH_BYTES);
2535
+ }
2536
+ const text = await resp.text();
2537
+ if (text.length > MAX_FETCH_BYTES) {
2538
+ throw new Error('Response too large: ' + text.length + ' bytes exceeds ' + MAX_FETCH_BYTES);
2539
+ }
2540
+ return text;
2541
+ }
2542
+
2393
2543
  async function main() {
2394
2544
  const resp = await fetchWithManualRedirect(url);
2395
2545
  if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
@@ -2397,7 +2547,7 @@ async function main() {
2397
2547
 
2398
2548
  // --- JSON responses ---
2399
2549
  if (contentType.includes('application/json') || contentType.includes('+json')) {
2400
- const text = await resp.text();
2550
+ const text = await safeText(resp);
2401
2551
  try {
2402
2552
  const pretty = JSON.stringify(JSON.parse(text), null, 2);
2403
2553
  emit('json', pretty);
@@ -2409,7 +2559,7 @@ async function main() {
2409
2559
 
2410
2560
  // --- HTML responses (default for text/html, application/xhtml+xml) ---
2411
2561
  if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
2412
- const html = await resp.text();
2562
+ const html = await safeText(resp);
2413
2563
  const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
2414
2564
  td.use(gfm);
2415
2565
  td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
@@ -2418,7 +2568,7 @@ async function main() {
2418
2568
  }
2419
2569
 
2420
2570
  // --- Everything else: plain text, CSV, XML, etc. ---
2421
- const text = await resp.text();
2571
+ const text = await safeText(resp);
2422
2572
  emit('text', text);
2423
2573
  }
2424
2574
  main();
@@ -2656,6 +2806,16 @@ async function fetchOneUrl(url, source, force, ttl) {
2656
2806
  const header = (result.stdout || "").trim();
2657
2807
  let markdown;
2658
2808
  try {
2809
+ // Parent-side defense-in-depth on the subprocess output size. The
2810
+ // embedded safeText() in buildFetchCode already caps before writing,
2811
+ // but a torn write (subprocess killed mid-write, fs cache desync,
2812
+ // etc.) could still leave an oversized file. Bail before slurping
2813
+ // multiple gigabytes into the long-running MCP server's heap.
2814
+ const MAX_FETCH_OUTPUT_BYTES = 50 * 1024 * 1024;
2815
+ const fileSize = statSync(outputPath).size;
2816
+ if (fileSize > MAX_FETCH_OUTPUT_BYTES) {
2817
+ return { kind: "fetch_error", url, error: `subprocess output ${fileSize} bytes exceeds cap ${MAX_FETCH_OUTPUT_BYTES}`, reason: "read" };
2818
+ }
2659
2819
  markdown = readFileSync(outputPath, "utf-8").trim();
2660
2820
  }
2661
2821
  catch {
@@ -2832,6 +2992,10 @@ EXAMPLE: ctx_fetch_and_index(
2832
2992
  }
2833
2993
  else {
2834
2994
  // Serial FTS5 write here — no parallel store.index calls.
2995
+ // Cache miss: the URL was not in the TTL window so we paid the
2996
+ // network round-trip + re-indexed. Counted here so ctx_stats can
2997
+ // report nominal cache_hit_rate alongside the existing hit metrics.
2998
+ sessionStats.cacheMisses++;
2835
2999
  finalized.push({ kind: "fetched", indexed: indexFetched(v) });
2836
3000
  }
2837
3001
  }
@@ -2998,8 +3162,18 @@ EXAMPLE: ctx_batch_execute(
2998
3162
  "Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
2999
3163
  ">1 switches to per-command timeouts (no shared budget) and " +
3000
3164
  "individual `(timed out)` blocks instead of cascading skip."),
3165
+ query_scope: z
3166
+ .enum(["batch", "global"])
3167
+ .optional()
3168
+ .default("batch")
3169
+ .describe("Scope for `queries` (default: `batch`). " +
3170
+ "`batch` searches ONLY the chunks produced by this batch's commands " +
3171
+ "— useful when you want answers about the just-fetched output. " +
3172
+ "`global` searches the entire persistent index (same scope as ctx_search) " +
3173
+ "— useful when you want the batch commands to enrich context and " +
3174
+ "the queries to also surface related prior knowledge in one round trip."),
3001
3175
  }),
3002
- }, async ({ commands, queries, timeout, concurrency }) => {
3176
+ }, async ({ commands, queries, timeout, concurrency, query_scope }) => {
3003
3177
  // Security: check each command against deny patterns
3004
3178
  for (const cmd of commands) {
3005
3179
  const denied = checkDenyPolicy(cmd.command, "batch_execute");
@@ -3042,6 +3216,14 @@ EXAMPLE: ctx_batch_execute(
3042
3216
  .join(",")
3043
3217
  .slice(0, 80)}`;
3044
3218
  const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
3219
+ // Commands inventory — list what the agent actually ran so the
3220
+ // response itself documents intent, not just per-section echoes.
3221
+ // Placed before "## Indexed Sections" so it scans top-down with
3222
+ // the human asking "what just happened" (Issues #717 + #736).
3223
+ const commandsInventory = ["## Commands", ""];
3224
+ for (const c of commands) {
3225
+ commandsInventory.push(`- ${c.label}: \`${truncateCommandForEcho(c.command)}\``);
3226
+ }
3045
3227
  // Build section inventory — direct query by source_id (no FTS5 MATCH needed)
3046
3228
  const allSections = store.getChunksBySource(indexed.sourceId);
3047
3229
  const inventory = ["## Indexed Sections", ""];
@@ -3051,9 +3233,11 @@ EXAMPLE: ctx_batch_execute(
3051
3233
  inventory.push(`- ${s.title} (${(bytes / 1024).toFixed(1)}KB)`);
3052
3234
  sectionTitles.push(s.title);
3053
3235
  }
3054
- // Run all search queries — source scoped only.
3055
- // Cross-source search remains available via explicit ctx_search().
3056
- const queryResults = formatBatchQueryResults(store, queries, source);
3236
+ // Run all search queries — default scope is batch-local (legacy behavior).
3237
+ // When the caller passes query_scope: "global", searches reach the entire
3238
+ // persistent index in the same round trip. Cross-source search remains
3239
+ // available via explicit ctx_search() as well.
3240
+ const queryResults = formatBatchQueryResults(store, queries, source, undefined, query_scope);
3057
3241
  // Get searchable terms for edge cases where follow-up is needed
3058
3242
  const distinctiveTerms = store.getDistinctiveTerms
3059
3243
  ? store.getDistinctiveTerms(indexed.sourceId)
@@ -3062,6 +3246,8 @@ EXAMPLE: ctx_batch_execute(
3062
3246
  `Executed ${commands.length} commands (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB). ` +
3063
3247
  `Indexed ${indexed.totalChunks} sections. Searched ${queries.length} queries.`,
3064
3248
  "",
3249
+ ...commandsInventory,
3250
+ "",
3065
3251
  ...inventory,
3066
3252
  "",
3067
3253
  ...queryResults,
@@ -3086,6 +3272,32 @@ EXAMPLE: ctx_batch_execute(
3086
3272
  });
3087
3273
  }
3088
3274
  });
3275
+ /**
3276
+ * Pi byte accounting: patch lifetime.totalEvents from bytes_sandboxed
3277
+ * in stats-*.json files instead of the default events × 256 heuristic.
3278
+ * Only active for Pi adapter — other platforms use getLifetimeStats() as-is.
3279
+ */
3280
+ function patchPiLifetimeFromStatsFiles(lifetime, sessionsDir) {
3281
+ if (!existsSync(sessionsDir))
3282
+ return;
3283
+ let sandboxedBytes = 0;
3284
+ try {
3285
+ for (const f of readdirSync(sessionsDir)) {
3286
+ if (!f.startsWith("stats-") || !f.endsWith(".json"))
3287
+ continue;
3288
+ try {
3289
+ const raw = JSON.parse(readFileSync(join(sessionsDir, f), "utf-8"));
3290
+ sandboxedBytes += (raw?.bytes_sandboxed ?? 0) + (raw?.bytes_indexed ?? 0);
3291
+ }
3292
+ catch { /* corrupt file — skip */ }
3293
+ }
3294
+ }
3295
+ catch { /* never block ctx_stats on stats file I/O */ }
3296
+ if (sandboxedBytes > 0) {
3297
+ const rescueTokens = (lifetime.rescueBytes ?? 0) / 4;
3298
+ lifetime.totalEvents = Math.round((sandboxedBytes / 4 + rescueTokens) / 256);
3299
+ }
3300
+ }
3089
3301
  // ─────────────────────────────────────────────────────────
3090
3302
  // Tool: stats
3091
3303
  // ─────────────────────────────────────────────────────────
@@ -3230,12 +3442,23 @@ server.registerTool("ctx_stats", {
3230
3442
  }
3231
3443
  }
3232
3444
  catch { /* never block ctx_stats */ }
3445
+ // Pi byte accounting: patch lifetime from stats-*.json files
3446
+ // (actual bytes_sandboxed, not events × 256 heuristic).
3447
+ if (_detectedAdapter?.name === "Pi") {
3448
+ patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3449
+ }
3233
3450
  // v1.0.117: pass projectDir as cwd so the narrative renderer's
3234
- // "started in <path>" line matches the user's actual project, not
3235
- // the MCP server's chdir'd plugin install dir. getProjectDir()
3236
- // includes v1.0.115's transcript heuristic which reads the literal
3237
- // cwd from Claude Code's session jsonl.
3238
- text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage, multiAdapter, conversation, realBytes, cwd: projectDir });
3451
+ // "started in <path>" line matches the user's actual project.
3452
+ // Snapshot the persistent store so the renderer can show
3453
+ // total_chunks / last_indexed_at without callers having to query
3454
+ // separately. Best-effort getStore() is process-local and may
3455
+ // be unavailable on cold paths; failures are absorbed.
3456
+ let indexState;
3457
+ try {
3458
+ indexState = getStore().getIndexState();
3459
+ }
3460
+ catch { /* never block ctx_stats */ }
3461
+ text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage, multiAdapter, conversation, realBytes, indexState, cwd: projectDir });
3239
3462
  }
3240
3463
  finally {
3241
3464
  sdb.close();
@@ -3247,12 +3470,20 @@ server.registerTool("ctx_stats", {
3247
3470
  const engine = new AnalyticsEngine(createMinimalDb());
3248
3471
  const report = engine.queryAll(sessionStats);
3249
3472
  const lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
3473
+ if (_detectedAdapter?.name === "Pi") {
3474
+ patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3475
+ }
3250
3476
  let multiAdapter;
3251
3477
  try {
3252
3478
  multiAdapter = getMultiAdapterLifetimeStats();
3253
3479
  }
3254
3480
  catch { /* never block ctx_stats */ }
3255
- text = formatReport(report, VERSION, _latestVersion, { lifetime, multiAdapter });
3481
+ let indexState;
3482
+ try {
3483
+ indexState = getStore().getIndexState();
3484
+ }
3485
+ catch { /* never block ctx_stats */ }
3486
+ text = formatReport(report, VERSION, _latestVersion, { lifetime, multiAdapter, indexState });
3256
3487
  }
3257
3488
  }
3258
3489
  catch {
@@ -3264,6 +3495,9 @@ server.registerTool("ctx_stats", {
3264
3495
  lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
3265
3496
  }
3266
3497
  catch { /* never block ctx_stats */ }
3498
+ if (_detectedAdapter?.name === "Pi" && lifetime) {
3499
+ patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3500
+ }
3267
3501
  let multiAdapter;
3268
3502
  try {
3269
3503
  multiAdapter = getMultiAdapterLifetimeStats();
@@ -3422,19 +3656,23 @@ server.registerTool("ctx_upgrade", {
3422
3656
  // and cmd.exe — unlike env-var prefixes). If detection fails we
3423
3657
  // skip the flag and let upgrade()'s own detectPlatform() fall back.
3424
3658
  let platformFlag = "";
3659
+ let nodeOpts = undefined;
3425
3660
  try {
3426
3661
  const { detectPlatform } = await import("./adapters/detect.js");
3427
3662
  const clientInfo = server.server.getClientVersion();
3428
3663
  const signal = detectPlatform(clientInfo ?? undefined);
3429
3664
  platformFlag = ` --platform ${signal.platform}`;
3665
+ nodeOpts = isInProcessPluginPlatform(signal.platform) && runtimes.javascript
3666
+ ? { platform: signal.platform, jsRuntime: runtimes.javascript }
3667
+ : undefined;
3430
3668
  }
3431
3669
  catch { /* best effort — fall back to upgrade()'s own detect */ }
3432
3670
  let cmd;
3433
3671
  if (existsSync(bundlePath)) {
3434
- cmd = `${buildNodeCommand(bundlePath)} upgrade${platformFlag}`;
3672
+ cmd = `${buildNodeCommand(bundlePath, nodeOpts)} upgrade${platformFlag}`;
3435
3673
  }
3436
3674
  else if (existsSync(fallbackPath)) {
3437
- cmd = `${buildNodeCommand(fallbackPath)} upgrade${platformFlag}`;
3675
+ cmd = `${buildNodeCommand(fallbackPath, nodeOpts)} upgrade${platformFlag}`;
3438
3676
  }
3439
3677
  else {
3440
3678
  // Inline fallback: neither CLI file exists (e.g. marketplace installs).
@@ -3444,8 +3682,8 @@ server.registerTool("ctx_upgrade", {
3444
3682
  // across cmd.exe, PowerShell, and bash (node -e '...' breaks on Windows).
3445
3683
  const scriptLines = [
3446
3684
  `import{execFileSync}from"node:child_process";`,
3447
- `import{cpSync,rmSync,existsSync,mkdtempSync,readFileSync,writeFileSync}from"node:fs";`,
3448
- `import{join}from"node:path";`,
3685
+ `import{cpSync,rmSync,existsSync,mkdtempSync,readFileSync,writeFileSync,lstatSync}from"node:fs";`,
3686
+ `import{join,resolve,sep}from"node:path";`,
3449
3687
  `import{tmpdir}from"node:os";`,
3450
3688
  `const P=${JSON.stringify(pluginRoot)};`,
3451
3689
  `const T=mkdtempSync(join(tmpdir(),"ctx-upgrade-"));`,
@@ -3458,7 +3696,18 @@ server.registerTool("ctx_upgrade", {
3458
3696
  `console.log("- [x] Built from source");`,
3459
3697
  `const pkg=JSON.parse(readFileSync(join(T,"package.json"),"utf8"));`,
3460
3698
  `const items=[...(Array.isArray(pkg.files)?pkg.files:[]),"src","package.json"];`,
3461
- `for(const item of items){const from=join(T,item);const to=join(P,item);if(existsSync(from)){rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,force:true});}}`,
3699
+ // Supply-chain containment on items[]. Mirror the cli.ts upgrade()
3700
+ // guard: a compromised upstream package.json with files:["../etc"]
3701
+ // would otherwise let path.join follow ".." out of pluginRoot.
3702
+ // path.resolve normalizes "..", so the lexical startsWith catches
3703
+ // both relative-".." traversal and absolute-path bypass. Plus a
3704
+ // symlink filter so a committed symlink inside the clone can't
3705
+ // plant itself in pluginRoot (cpSync default preserves source
3706
+ // symlinks; a planted symlink in pluginRoot/src then redirects
3707
+ // every subsequent load through to an attacker target).
3708
+ `const PW=resolve(P)+sep;const TW=resolve(T)+sep;`,
3709
+ `const noSymlink=(src)=>{try{return !lstatSync(src).isSymbolicLink()}catch{return false}};`,
3710
+ `for(const item of items){const from=resolve(T,item);const to=resolve(P,item);if(!(to+sep).startsWith(PW))continue;if(!(from+sep).startsWith(TW))continue;if(!noSymlink(from))continue;if(existsSync(from)){rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,force:true,filter:noSymlink});}}`,
3462
3711
  // Issue #609: do NOT write .mcp.json into the cache dir. Claude Code reads
3463
3712
  // .claude-plugin/plugin.json.mcpServers as the canonical MCP source — the
3464
3713
  // per-version .mcp.json file is a stale-write vector. Same architectural
@@ -3478,7 +3727,7 @@ server.registerTool("ctx_upgrade", {
3478
3727
  const tmpScript = resolve(pluginRoot, ".ctx-upgrade-inline.mjs");
3479
3728
  const { writeFileSync: writeTmp } = await import("node:fs");
3480
3729
  writeTmp(tmpScript, scriptLines);
3481
- cmd = buildNodeCommand(tmpScript);
3730
+ cmd = buildNodeCommand(tmpScript, nodeOpts);
3482
3731
  }
3483
3732
  const text = [
3484
3733
  "## ctx-upgrade",
@@ -3837,7 +4086,7 @@ export function killProcessOnPort(port, platform = process.platform, runner = sp
3837
4086
  // ── ctx-insight: analytics dashboard ──────────────────────────────────────────
3838
4087
  server.registerTool("ctx_insight", {
3839
4088
  title: "Open Insight Dashboard",
3840
- description: "Opens the context-mode Insight dashboard in the browser. " +
4089
+ description: "Opens the context-mode Insight dashboard in the browser — a dashboard launcher for session analytics; for natural-language queries over indexed content, use ctx_search. " +
3841
4090
  "Shows personal analytics: session activity, tool usage, error rate, " +
3842
4091
  "parallel work patterns, project focus, and actionable insights. " +
3843
4092
  "First run installs dependencies (~30s). Subsequent runs open instantly. " +
@@ -3864,6 +4113,38 @@ server.registerTool("ctx_insight", {
3864
4113
  const sessDir = explicitSessionDir ? resolve(explicitSessionDir) : getSessionDir();
3865
4114
  const insightContentDirResolved = explicitContentDir ? resolve(explicitContentDir) : join(dirname(sessDir), "content");
3866
4115
  const cacheDir = join(dirname(sessDir), "insight-cache");
4116
+ // Confused-deputy guard on explicit overrides. The spawned insight
4117
+ // server reads every .db file under sessDir and insightContentDir, and
4118
+ // its /api/content DELETE endpoint can rewrite hex-named .db files in
4119
+ // those trees. A prompt-injected caller passing sessionDir="~/.ssh"
4120
+ // or contentDir="~/.gnupg" would otherwise let the dashboard
4121
+ // enumerate (and, for hex-named SQLite files, mutate rows in) those
4122
+ // directories. Contain explicit overrides to the adapter's config
4123
+ // root: broad enough for the documented "multi-install setups or
4124
+ // pointing at a sibling project's data" use case, narrow enough to
4125
+ // block /etc, ~/.ssh, /tmp/<foreign-user>, etc.
4126
+ if (explicitSessionDir || explicitContentDir) {
4127
+ const defaultSessDir = getSessionDir();
4128
+ const containmentRoot = dirname(dirname(defaultSessDir));
4129
+ const containmentRootWithSep = resolve(containmentRoot) + sep;
4130
+ const isContained = (dir) => (resolve(dir) + sep).startsWith(containmentRootWithSep);
4131
+ if (explicitSessionDir && !isContained(sessDir)) {
4132
+ return trackResponse("ctx_insight", {
4133
+ content: [{
4134
+ type: "text",
4135
+ text: `Error: sessionDir must resolve under ${containmentRoot} (got ${sessDir}).`,
4136
+ }],
4137
+ });
4138
+ }
4139
+ if (explicitContentDir && !isContained(insightContentDirResolved)) {
4140
+ return trackResponse("ctx_insight", {
4141
+ content: [{
4142
+ type: "text",
4143
+ text: `Error: contentDir must resolve under ${containmentRoot} (got ${insightContentDirResolved}).`,
4144
+ }],
4145
+ });
4146
+ }
4147
+ }
3867
4148
  // Verify source exists
3868
4149
  if (!existsSync(join(insightSource, "server.mjs"))) {
3869
4150
  return trackResponse("ctx_insight", {