context-mode 1.0.146 → 1.0.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/server.js CHANGED
@@ -18,14 +18,14 @@ import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readTo
18
18
  import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
19
19
  import { classifyNonZeroExit } from "./exit-classify.js";
20
20
  import { startLifecycleGuard } from "./lifecycle.js";
21
- import { hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveSessionDbPath, SessionDB } from "./session/db.js";
21
+ import { charSafePrefix } from "./truncate.js";
22
+ import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
22
23
  import { purgeSession } from "./session/purge.js";
23
24
  import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
24
25
  import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
25
26
  import { searchAllSources } from "./search/unified.js";
26
27
  import { buildNodeCommand } from "./adapters/types.js";
27
28
  import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
28
- import { resolveCodexConfigDir } from "./adapters/codex/paths.js";
29
29
  import { getHookScriptPaths } from "./util/hook-config.js";
30
30
  import { resolveClaudeConfigDir } from "./util/claude-config.js";
31
31
  import { resolveProjectDir } from "./util/project-dir.js";
@@ -231,9 +231,32 @@ server.registerTool = (...args) => {
231
231
  emitSuppressionDiagnostic();
232
232
  return undefined;
233
233
  }
234
- REGISTERED_CTX_TOOLS.push({ name, config, handler });
234
+ const wrappedHandler = wrapToolHandler(name, handler);
235
+ REGISTERED_CTX_TOOLS.push({ name, config, handler: wrappedHandler });
236
+ args[2] = wrappedHandler;
235
237
  return originalRegisterTool(...args);
236
238
  };
239
+ function wrapToolHandler(name, handler) {
240
+ return async (toolArgs) => {
241
+ try {
242
+ return await handler(toolArgs);
243
+ }
244
+ catch (err) {
245
+ const result = storageErrorResult(err);
246
+ if (result) {
247
+ try {
248
+ return trackResponse(name, result);
249
+ }
250
+ catch (trackErr) {
251
+ if (trackErr instanceof StorageDirectoryError)
252
+ return result;
253
+ throw trackErr;
254
+ }
255
+ }
256
+ throw err;
257
+ }
258
+ };
259
+ }
237
260
  // Issue #637 — when suppression is active, install the empty tools/list handler
238
261
  // once at module-init time so the suppressed MCP child responds with
239
262
  // `{tools: []}` instead of JSON-RPC `-32601 Method not found`. Pair with the
@@ -383,9 +406,6 @@ let _insightChild = null;
383
406
  * Issue #460 round-3: delegates to the canonical util so empty/whitespace
384
407
  * env values fall back instead of poisoning downstream `join()` calls.
385
408
  */
386
- function resolveClaudeConfigRoot() {
387
- return resolveClaudeConfigDir();
388
- }
389
409
  async function getDiagnosticAdapter() {
390
410
  if (_detectedAdapter)
391
411
  return _detectedAdapter;
@@ -402,7 +422,7 @@ async function getDiagnosticAdapter() {
402
422
  * Get the platform-specific sessions directory from the detected adapter.
403
423
  * Falls back to the detected platform config root before adapter detection.
404
424
  */
405
- function getSessionDir() {
425
+ function getDefaultSessionDir() {
406
426
  if (_detectedAdapter)
407
427
  return _detectedAdapter.getSessionDir();
408
428
  // Pre-detection path (race window before MCP `initialize` completes):
@@ -415,22 +435,24 @@ function getSessionDir() {
415
435
  const signal = detectPlatform();
416
436
  const segments = getSessionDirSegments(signal.platform);
417
437
  if (segments) {
418
- let root = join(homedir(), ...segments);
419
- if (segments.length === 1 && segments[0] === ".claude") {
420
- root = resolveClaudeConfigRoot();
421
- }
422
- else if (segments.length === 1 && segments[0] === ".codex") {
423
- root = resolveCodexConfigDir();
424
- }
425
- const dir = join(root, "context-mode", "sessions");
426
- mkdirSync(dir, { recursive: true });
427
- return dir;
438
+ return resolveDefaultSessionDir({
439
+ configDir: join(...segments),
440
+ configDirEnv: configDirEnvForSessionSegments(segments),
441
+ });
428
442
  }
429
443
  }
430
444
  catch { /* fall through to claude fallback */ }
431
- const dir = join(resolveClaudeConfigRoot(), "context-mode", "sessions");
432
- mkdirSync(dir, { recursive: true });
433
- return dir;
445
+ return resolveDefaultSessionDir({ configDir: ".claude", configDirEnv: "CLAUDE_CONFIG_DIR" });
446
+ }
447
+ function configDirEnvForSessionSegments(segments) {
448
+ if (segments.length === 1 && segments[0] === ".claude")
449
+ return "CLAUDE_CONFIG_DIR";
450
+ if (segments.length === 1 && segments[0] === ".codex")
451
+ return "CODEX_HOME";
452
+ return undefined;
453
+ }
454
+ function getSessionDir() {
455
+ return ensureWritableStorageDir(resolveSessionStorageDir(getDefaultSessionDir));
434
456
  }
435
457
  /**
436
458
  * Project directory detection across supported platforms.
@@ -526,9 +548,7 @@ function getSessionDbPath() {
526
548
  * ~/.cursor/context-mode/content/87c28c41ddb64d38.db
527
549
  */
528
550
  function getStorePath() {
529
- // Derive content dir from session dir: .../sessions/ → .../content/
530
- const dir = join(dirname(getSessionDir()), "content");
531
- mkdirSync(dir, { recursive: true });
551
+ const dir = ensureWritableStorageDir(resolveContentStorageDir(getDefaultSessionDir));
532
552
  // Delegate to resolveContentStorePath: same case-fold + one-shot legacy
533
553
  // rename behavior as resolveSessionDbPath. On macOS / Windows, an
534
554
  // existing legacy raw-casing FTS5 db (with -wal/-shm sidecars) is
@@ -585,6 +605,14 @@ const sessionStats = {
585
605
  cacheBytesSaved: 0, // bytes avoided by TTL cache hits
586
606
  sessionStart: Date.now(),
587
607
  };
608
+ function storageErrorResult(err) {
609
+ if (!(err instanceof StorageDirectoryError))
610
+ return null;
611
+ return {
612
+ content: [{ type: "text", text: formatStorageDirectoryError(err) }],
613
+ isError: true,
614
+ };
615
+ }
588
616
  // ── Version outdated warning ──────────────────────────────────────────────
589
617
  // Non-blocking npm check at startup. trackResponse prepends warning
590
618
  // using a burst cadence: 3 warnings → 1h silent → 3 warnings → repeat.
@@ -785,7 +813,8 @@ let _lifetimeCache;
785
813
  */
786
814
  function getStatsFilePath() {
787
815
  const sessionId = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
788
- return join(getSessionDir(), `stats-${sessionId}.json`);
816
+ const statsDir = ensureWritableStorageDir(resolveStatsStorageDir(getDefaultSessionDir));
817
+ return join(statsDir, `stats-${sessionId}.json`);
789
818
  }
790
819
  function persistStats() {
791
820
  const now = Date.now();
@@ -1099,6 +1128,15 @@ function formatCommandOutput(label, raw, onFsBytes) {
1099
1128
  }
1100
1129
  return `# ${label}\n\n${output}\n`;
1101
1130
  }
1131
+ function combineExecOutput(result) {
1132
+ const stdout = result.stdout || "";
1133
+ const stderr = result.stderr || "";
1134
+ if (!stderr)
1135
+ return stdout;
1136
+ if (!stdout)
1137
+ return stderr;
1138
+ return `${stdout}${stdout.endsWith("\n") ? "" : "\n"}${stderr}`;
1139
+ }
1102
1140
  /**
1103
1141
  * Execute batch commands. concurrency=1 preserves the legacy serial path
1104
1142
  * (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
@@ -1130,10 +1168,10 @@ export async function runBatchCommands(commands, opts, executor) {
1130
1168
  }
1131
1169
  const result = await executor.execute({
1132
1170
  language: "shell",
1133
- code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
1171
+ code: `${nodeOptsPrefix}${cmd.command}`,
1134
1172
  timeout: perCmdTimeout,
1135
1173
  });
1136
- outputs.push(formatCommandOutput(cmd.label, result.stdout, onFsBytes));
1174
+ outputs.push(formatCommandOutput(cmd.label, combineExecOutput(result), onFsBytes));
1137
1175
  if (result.timedOut) {
1138
1176
  timedOut = true;
1139
1177
  for (let j = i + 1; j < commands.length; j++) {
@@ -1151,12 +1189,12 @@ export async function runBatchCommands(commands, opts, executor) {
1151
1189
  run: async () => {
1152
1190
  const result = await executor.execute({
1153
1191
  language: "shell",
1154
- code: `${nodeOptsPrefix}${cmd.command} 2>&1`,
1192
+ code: `${nodeOptsPrefix}${cmd.command}`,
1155
1193
  timeout,
1156
1194
  });
1157
- // Always route partial stdout through formatCommandOutput so __CM_FS__
1195
+ // Always route partial output through formatCommandOutput so __CM_FS__
1158
1196
  // markers are stripped + counted, even when the command timed out.
1159
- const formatted = formatCommandOutput(cmd.label, result.stdout, onFsBytes);
1197
+ const formatted = formatCommandOutput(cmd.label, combineExecOutput(result), onFsBytes);
1160
1198
  const output = result.timedOut
1161
1199
  ? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
1162
1200
  : formatted;
@@ -1186,7 +1224,38 @@ export async function runBatchCommands(commands, opts, executor) {
1186
1224
  // ─────────────────────────────────────────────────────────
1187
1225
  server.registerTool("ctx_execute", {
1188
1226
  title: "Execute Code",
1189
- description: `MANDATORY: Use for any command where output exceeds 20 lines. Execute code in a sandboxed subprocess. Only stdout enters context — raw data stays in the subprocess.${bunNote} Available: ${langList}.\n\nPREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, pytest), git queries (git log, git diff), data processing, and ANY CLI command that may produce large output. Bash should only be used for file mutations, git writes, and navigation.\n\nTHINK IN CODE: When you need to analyze, count, filter, compare, or process data — write code that does the work and console.log() only the answer. Do NOT read raw data into context to process mentally. Program the analysis, don't compute it in your reasoning. Write robust, pure JavaScript (no npm dependencies). Use only Node.js built-ins (fs, path, child_process). Always wrap in try/catch. Handle null/undefined. Works on both Node.js and Bun.`,
1227
+ description: `Run code in a sandboxed subprocess.${bunNote} Languages: ${langList}.
1228
+
1229
+ Think-in-Code — the core philosophy: the bytes your code processes never enter your conversation memory; only what you console.log() does. Reading a 700 KB log directly means 700 KB of your remaining reasoning capacity gets spent on raw bytes. Running code over that same log in this sandbox and printing a 3 KB summary leaves you with 697 KB of capacity for the actual work.
1230
+
1231
+ Concrete shape — analyze 47 source files without reading any of them:
1232
+ ctx_execute(language: "javascript", code: \`
1233
+ const fs = require('fs');
1234
+ const files = fs.readdirSync('src').filter(f => f.endsWith('.ts'));
1235
+ files.forEach(f => {
1236
+ const lines = fs.readFileSync('src/'+f,'utf8').split('\\\\n').length;
1237
+ console.log(f + ': ' + lines + ' lines');
1238
+ });
1239
+ \`)
1240
+ // 47 files analyzed, 15,314 LoC summarized — output ~3.6 KB instead of 47 Read() calls = ~700 KB.
1241
+
1242
+ WHEN:
1243
+ - You intend to derive an answer FROM data (filter, count, aggregate, parse, compare, transform) — do the derivation in code and print only the answer
1244
+ - Output shape or size cannot be predicted before execution (recursive finds, repo-wide greps, list endpoints, query results, log scans)
1245
+ - You would otherwise read raw output and then mentally compute — that compute belongs here, in code, where its inputs stay out of your conversation
1246
+ - You need to keep a long-running process alive (dev server, watcher, daemon) — pass \`background: true\` to detach on timeout instead of killing the process
1247
+ - The output may legitimately be large but you only want recall-by-topic later — pass an \`intent\` string; outputs over ~5KB are auto-indexed into the knowledge base and only the section titles + previews come back, retrievable via ctx_search
1248
+
1249
+ WHEN NOT:
1250
+ - Single observational command whose entire short output you intend to consume verbatim (whoami, pwd, git status on a clean tree) — Bash is simpler
1251
+ - File mutations (Edit/Write) or navigation (cd/ls) — Bash is the right surface
1252
+ - You already know the output is one short fixed line and you want to read it as-is
1253
+
1254
+ RETURNS:
1255
+ Only what your code prints. Wrap risky calls in try/catch — uncaught errors go to stderr and may leak more than intended. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout; use ctx_search(queries: [...]) to drill into specific sections.
1256
+
1257
+ EXAMPLE: ctx_execute(language: "shell", code: "npm test 2>&1 | grep -E '(FAIL|✗|×|Error:|Tests +.*(failed|passed))' | head -60")
1258
+ EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('gh issue list --json number,title --limit 100', {encoding:'utf8'}); const hooks = JSON.parse(out).filter(i => /hook|routing/i.test(i.title)); console.log(\`\${hooks.length} hook-related issues\`)")`,
1190
1259
  inputSchema: z.object({
1191
1260
  language: z
1192
1261
  .enum([
@@ -1484,7 +1553,26 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
1484
1553
  // ─────────────────────────────────────────────────────────
1485
1554
  server.registerTool("ctx_execute_file", {
1486
1555
  title: "Execute File Processing",
1487
- description: "Read a file and process it without loading contents into context. The file is read into a FILE_CONTENT variable inside the sandbox. Only your printed summary enters context.\n\nPREFER THIS OVER Read/cat for: log files, data files (CSV, JSON, XML), large source files for analysis, and any file where you need to extract specific information rather than read the entire content.\n\nTHINK IN CODE: Write code that processes FILE_CONTENT and console.log() only the answer. Don't read files into context to analyze mentally. Write robust, pure JavaScript no npm deps, try/catch, null-safe. Node.js + Bun compatible.",
1556
+ description: `Read a file into a sandboxed FILE_CONTENT variable and run code over it. Only what you console.log() enters your conversationthe file bytes stay in the sandbox.
1557
+
1558
+ Think-in-Code applied to file-level analysis: Reading the whole file means every byte enters your conversation memory and costs reasoning capacity for the rest of the session. Running code over it here lets you keep the raw bytes out and only the derived answer in. Same principle as ctx_execute, scoped to one named file via the FILE_CONTENT variable.
1559
+
1560
+ WHEN:
1561
+ - You want to KNOW SOMETHING ABOUT a file (line count, matches of a pattern, parsed structure, statistical aggregate) without needing to SEE all of it
1562
+ - The file is structured (CSV, JSON, log, code) and a code-level derivation is cheaper than reading verbatim
1563
+ - The file is large enough that reading the full content would burn meaningful conversation memory you need for the actual work
1564
+ - The derivation may itself produce a large output you want recall-by-topic on later — pass an \`intent\` string; outputs over ~5KB are auto-indexed and only matching sections come back, retrievable via ctx_search
1565
+
1566
+ WHEN NOT:
1567
+ - You intend to EDIT the file — use Read so the subsequent Edit can match the exact text
1568
+ - You only need one specific line and you know its offset — Read with offset/limit is the simplest path
1569
+ - The file is small AND you will consume all of it for understanding/editing — Read directly
1570
+
1571
+ RETURNS:
1572
+ Only what your code prints. The FILE_CONTENT variable holds the raw bytes inside the sandbox; nothing else leaves. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout.
1573
+
1574
+ EXAMPLE: ctx_execute_file(path: "huge.log", language: "javascript", code: "const errs = FILE_CONTENT.split('\\\\n').filter(l => /ERROR|FATAL/.test(l)); console.log(\`\${errs.length} error lines\`); console.log(errs.slice(-5).join('\\\\n'))")
1575
+ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const rows = FILE_CONTENT.split('\\\\n'); console.log(\`rows: \${rows.length - 1}, header: \${rows[0]}\`)")`,
1488
1576
  inputSchema: z.object({
1489
1577
  path: z
1490
1578
  .string()
@@ -1616,19 +1704,25 @@ server.registerTool("ctx_execute_file", {
1616
1704
  // ─────────────────────────────────────────────────────────
1617
1705
  server.registerTool("ctx_index", {
1618
1706
  title: "Index Content",
1619
- description: "Index documentation or knowledge content into a searchable BM25 knowledge base. " +
1620
- "Chunks markdown by headings (keeping code blocks intact) and stores in ephemeral FTS5 database. " +
1621
- "The full content does NOT stay in context — only a brief summary is returned.\n\n" +
1622
- "WHEN TO USE:\n" +
1623
- "- Documentation from Context7, Skills, or MCP tools (API docs, framework guides, code examples)\n" +
1624
- "- API references (endpoint details, parameter specs, response schemas)\n" +
1625
- "- MCP tools/list output (exact tool signatures and descriptions)\n" +
1626
- "- Skill prompts and instructions that are too large for context\n" +
1627
- "- README files, migration guides, changelog entries\n" +
1628
- "- Any content with code examples you may need to reference precisely\n\n" +
1629
- "After indexing, use 'ctx_search' to retrieve specific sections on-demand.\n" +
1630
- "When `path` is provided, a content hash is stored for automatic stale detection in search results.\n" +
1631
- "Do NOT use for: log files, test output, CSV, build output use 'ctx_execute_file' for those.",
1707
+ description: `Store content in a searchable knowledge base (BM25 over FTS5). Splits markdown by headings, keeps code blocks intact, and persists the raw chunks. The full content stays in storage — retrieve any section on-demand via ctx_search; nothing is summarized or truncated.
1708
+
1709
+ WHEN:
1710
+ - Documentation from Context7, Skills, or MCP tools (API docs, framework guides, code examples)
1711
+ - API references (endpoint details, parameter specs, response schemas)
1712
+ - MCP tools/list output (exact tool signatures and descriptions)
1713
+ - Skill prompts and instructions that are too large to keep verbatim in conversation
1714
+ - README files, migration guides, changelog entries
1715
+ - Any content with code examples you may need to reference precisely later
1716
+
1717
+ WHEN NOT:
1718
+ - Log files, test output, CSV, or build output use ctx_execute_file, which processes in-sandbox without persisting bytes
1719
+ - Single-use ephemeral content you will not query laterkeep it inline if it fits, or ctx_execute_file it
1720
+
1721
+ RETURNS:
1722
+ Indexing metadata: chunk counts (total, code-bearing), source label, and the exact ctx_search call shape to query the indexed content. Raw content is NOT echoed back — it lives in storage, retrievable via ctx_search(source: "<label>"). When \`path\` is provided, a content hash is stored so ctx_search results auto-flag staleness on future calls.
1723
+
1724
+ EXAMPLE: ctx_index(content: "# React useEffect\\n\\nThe Effect Hook lets you ...", source: "react-useeffect-docs")
1725
+ EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
1632
1726
  inputSchema: z.object({
1633
1727
  content: z
1634
1728
  .string()
@@ -1774,11 +1868,28 @@ function coerceCommandsArray(val) {
1774
1868
  }
1775
1869
  server.registerTool("ctx_search", {
1776
1870
  title: "Search Indexed Content",
1777
- description: "Search indexed content. Requires prior indexing via ctx_batch_execute, ctx_index, or ctx_fetch_and_index. " +
1778
- "Pass ALL search questions as queries array in ONE call. " +
1779
- "File-backed sources are auto-refreshed when the source file changes.\n\n" +
1780
- "TIPS: 2-4 specific terms per query. Use 'source' to scope results.\n\n" +
1781
- "SESSION STATE: If skills, roles, or decisions were set earlier in this conversation, they are still active. Do not discard or contradict them.",
1871
+ description: `Search a unified knowledge base with a multi-strategy ranking pipeline. Two parallel matchers run on every query: a Porter-stemming matcher ("caching" finds "cached", "caches", "cach") and a trigram-substring matcher ("useEff" finds "useEffect"). Their ranked lists are merged via Reciprocal Rank Fusion, so a document that ranks well in both surfaces above one that wins only on a single strategy. Multi-term queries get an additional proximity-rerank pass that boosts passages where the query terms appear close together. Typos are corrected via Levenshtein distance and re-searched. Result snippets are window-extracted around the matched terms, not blindly truncated.
1872
+
1873
+ The knowledge base is unified: queries reach indexed content you stored (ctx_index, ctx_fetch_and_index, ctx_batch_execute output) AND auto-captured session memory written by hooks (decisions, errors, blockers, plans, user prompts, rejected approaches, tool failures, compaction guides — 26 event categories). File-backed sources carry a content hash and auto-flag staleness when the source file changes.
1874
+
1875
+ WHEN:
1876
+ - You want to recall something that exists in storage (recently indexed content, prior session events, auto-memory) instead of re-reading raw sources
1877
+ - You have multiple related questions about the same body of knowledge — batch every question into one call (the ranking pipeline runs per-query but the round-trip cost is paid once)
1878
+ - You want to scope the query to one labelled source (pass \`source\` — partial match is fine)
1879
+ - You want a chronological view across current session + prior sessions + persistent auto-memory (pass \`sort: "timeline"\` — the default \`relevance\` mode only ranks within the current session)
1880
+ - You want to filter ranked results by content shape (pass \`contentType: "code"\` to surface implementation snippets or \`contentType: "prose"\` to surface explanations)
1881
+
1882
+ WHEN NOT:
1883
+ - The data you want to query has never been stored in the knowledge base AND no session memory has accumulated around it — capture first (run a gather-and-index call), then come back here to query
1884
+ - 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
1885
+
1886
+ RETURNS:
1887
+ 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.
1888
+
1889
+ EXAMPLE: ctx_search(queries: ["root cause", "proposed fix", "test coverage"], source: "issue-#683")
1890
+ EXAMPLE: ctx_search(queries: ["what did we decide about caching"], source: "decision", sort: "timeline")
1891
+ EXAMPLE: ctx_search(queries: ["useEffect cleanup pattern"], source: "react-docs", contentType: "code", limit: 5)
1892
+ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blockers"], sort: "timeline")`,
1782
1893
  inputSchema: z.object({
1783
1894
  queries: z.preprocess(coerceJsonArray, z
1784
1895
  .array(z.string())
@@ -1888,7 +1999,7 @@ server.registerTool("ctx_search", {
1888
1999
  }
1889
2000
  catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
1890
2001
  }
1891
- const configDir = _detectedAdapter?.getConfigDir() ?? resolveClaudeConfigRoot();
2002
+ const configDir = _detectedAdapter?.getConfigDir() ?? resolveClaudeConfigDir();
1892
2003
  try {
1893
2004
  for (const q of queryList) {
1894
2005
  if (totalSize > MAX_TOTAL) {
@@ -2254,6 +2365,20 @@ main();
2254
2365
  // ─────────────────────────────────────────────────────────
2255
2366
  const FETCH_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
2256
2367
  const FETCH_PREVIEW_LIMIT = 3072;
2368
+ function formatFetchTtl(ttlMs) {
2369
+ if (ttlMs === 0)
2370
+ return "0ms";
2371
+ const day = 24 * 60 * 60 * 1000;
2372
+ const hour = 60 * 60 * 1000;
2373
+ const minute = 60 * 1000;
2374
+ if (ttlMs % day === 0)
2375
+ return `${ttlMs / day}d`;
2376
+ if (ttlMs % hour === 0)
2377
+ return `${ttlMs / hour}h`;
2378
+ if (ttlMs % minute === 0)
2379
+ return `${ttlMs / minute}m`;
2380
+ return `${ttlMs}ms`;
2381
+ }
2257
2382
  /**
2258
2383
  * Pure fetch step — TTL cache check + subprocess fetch. SAFE TO RUN IN PARALLEL.
2259
2384
  * Performs zero SQLite writes (only reads source meta). Caller must funnel
@@ -2328,10 +2453,23 @@ async function ssrfGuard(rawUrl) {
2328
2453
  }
2329
2454
  }
2330
2455
  catch (err) {
2456
+ // libuv DNS error codes that typically indicate the resolver itself can't
2457
+ // reach a nameserver — common when the MCP host process is running under
2458
+ // a sandbox that blocks outbound network, OR a transient upstream DNS
2459
+ // hiccup. Append an imperative retry hint so the agent does not capitulate
2460
+ // to training data on the FIRST transient failure (PR #654 substitute —
2461
+ // sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
2462
+ const errCode = err?.code ?? "";
2463
+ const isTransientDns = errCode === "ETIMEOUT" || errCode === "ETIMEDOUT" ||
2464
+ errCode === "EAI_AGAIN" || errCode === "ENETUNREACH" || errCode === "EPERM";
2465
+ const baseMsg = err instanceof Error ? err.message : String(err);
2466
+ const hint = isTransientDns
2467
+ ? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
2468
+ : "";
2331
2469
  return {
2332
2470
  kind: "fetch_error",
2333
2471
  url: rawUrl,
2334
- error: `DNS lookup failed for "${parsed.hostname}": ${err instanceof Error ? err.message : String(err)}`,
2472
+ error: `DNS lookup failed for "${parsed.hostname}": ${baseMsg}${hint}`,
2335
2473
  reason: "exit",
2336
2474
  };
2337
2475
  }
@@ -2401,14 +2539,14 @@ export function classifyIp(rawIp) {
2401
2539
  return "private"; // 192.168.0.0/16
2402
2540
  return "public";
2403
2541
  }
2404
- async function fetchOneUrl(url, source, force) {
2542
+ async function fetchOneUrl(url, source, force, ttl) {
2405
2543
  // SSRF guard — reject file://, javascript:, loopback, RFC1918, IMDS, link-local
2406
2544
  // BEFORE any cache lookup or subprocess spawn. Even cached entries shouldn't
2407
2545
  // serve a previously-poisoned source label.
2408
2546
  const ssrfBlock = await ssrfGuard(url);
2409
2547
  if (ssrfBlock)
2410
2548
  return ssrfBlock;
2411
- if (!force) {
2549
+ if (!force && ttl !== 0) {
2412
2550
  const store = getStore();
2413
2551
  // Cache key composes (source, url) so two distinct URLs sharing the same
2414
2552
  // `source` label do not collide — they each get their own cache slot
@@ -2418,12 +2556,13 @@ async function fetchOneUrl(url, source, force) {
2418
2556
  if (meta) {
2419
2557
  const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
2420
2558
  const ageMs = Date.now() - indexedAt.getTime();
2421
- if (ageMs < FETCH_TTL_MS) {
2559
+ const cacheTtlMs = ttl ?? FETCH_TTL_MS;
2560
+ if (ageMs < cacheTtlMs) {
2422
2561
  const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
2423
2562
  const ageMin = Math.floor(ageMs / (60 * 1000));
2424
2563
  const ageStr = ageHours > 0 ? `${ageHours}h ago` : ageMin > 0 ? `${ageMin}m ago` : "just now";
2425
2564
  const estimatedBytes = meta.chunkCount * 1600; // ~1.6KB/chunk avg
2426
- return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr };
2565
+ return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr, ttlStr: formatFetchTtl(cacheTtlMs) };
2427
2566
  }
2428
2567
  // Stale — fall through to re-fetch silently
2429
2568
  }
@@ -2437,7 +2576,18 @@ async function fetchOneUrl(url, source, force) {
2437
2576
  timeout: 30_000,
2438
2577
  });
2439
2578
  if (result.exitCode !== 0) {
2440
- return { kind: "fetch_error", url, error: result.stderr || result.stdout || "unknown error", reason: "exit" };
2579
+ // Subprocess fetch failure undici / fetch can surface EAI_AGAIN /
2580
+ // ETIMEDOUT / ENETUNREACH in stderr when the resolver is overloaded
2581
+ // or the network is briefly unavailable. Append the same retry hint
2582
+ // ssrfGuard's pre-flight DNS path emits so the agent doesn't capitulate
2583
+ // to training data on the first transient failure (PR #654 substitute —
2584
+ // sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
2585
+ const raw = result.stderr || result.stdout || "unknown error";
2586
+ const isTransientDns = /\b(EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ENETUNREACH|EPERM|getaddrinfo)\b/.test(raw);
2587
+ const hint = isTransientDns
2588
+ ? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
2589
+ : "";
2590
+ return { kind: "fetch_error", url, error: `${raw}${hint}`, reason: "exit" };
2441
2591
  }
2442
2592
  const header = (result.stdout || "").trim();
2443
2593
  let markdown;
@@ -2492,7 +2642,7 @@ function indexFetched(f) {
2492
2642
  // Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
2493
2643
  trackIndexed(Buffer.byteLength(f.markdown));
2494
2644
  const preview = f.markdown.length > FETCH_PREVIEW_LIMIT
2495
- ? f.markdown.slice(0, FETCH_PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
2645
+ ? charSafePrefix(f.markdown, FETCH_PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
2496
2646
  : f.markdown;
2497
2647
  return {
2498
2648
  label: indexed.label,
@@ -2503,15 +2653,27 @@ function indexFetched(f) {
2503
2653
  }
2504
2654
  server.registerTool("ctx_fetch_and_index", {
2505
2655
  title: "Fetch & Index URL(s)",
2506
- description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
2507
- "and returns a ~3KB preview. Full content stays in sandbox — use ctx_search() for deeper lookups.\n\n" +
2508
- "Better than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context.\n\n" +
2509
- "Content-type aware: HTML is converted to markdown, JSON is chunked by key paths, plain text is indexed directly.\n\n" +
2510
- "PARALLELIZE I/O: For multi-URL research (library evaluation, migration scans, doc comparisons), pass `requests: [{url, source}, ...]` with `concurrency: 4-8` — speeds up by 3-5x on real workloads.\n" +
2511
- " Use concurrency: 4-8 for: library docs sweep, multi-changelog scan, competitive pricing pages, multi-region docs, GitHub raw file pulls.\n" +
2512
- " Single URL use the legacy {url, source} shape (concurrency irrelevant).\n" +
2513
- " Example: requests: [{url: 'https://react.dev/...', source: 'react'}, {url: 'https://vuejs.org/...', source: 'vue'}], concurrency: 5.\n" +
2514
- " Fetches parallelize up to your concurrency setting; FTS5 indexing serializes the writes after (SQLite single-writer rule).",
2656
+ description: `Fetches URL content, converts HTML to markdown (JSON is chunked by key paths, plain text indexed directly), persists it in a searchable knowledge base, and returns a small preview window per source. The raw page bytes never enter your conversation — they live in storage and you retrieve any section on-demand via ctx_search.
2657
+
2658
+ Caching: every fetch is cached on disk and reused for repeat calls within the TTL window. The default TTL is 24 hours; override per-call with the \`ttl\` parameter (milliseconds, \`ttl: 0\` bypasses cache like \`force: true\`). Stored content older than 14 days is cleaned up on startup.
2659
+
2660
+ WHEN:
2661
+ - You need web content (docs, changelogs, API references, spec pages) and the raw page bytes should NOT enter your conversation
2662
+ - Multi-URL research (library evaluation, migration scans, doc comparisons): pass the \`requests\` array and a \`concurrency\` value 2-8 for parallel I/O
2663
+ - You want repeat lookups against the same URL to be cheap (TTL cache hits return only a hint, no re-fetch)
2664
+ - You want a long-lived cache window (override \`ttl\` upward for stable specs) or a guaranteed-fresh fetch (\`ttl: 0\` or \`force: true\`)
2665
+
2666
+ WHEN NOT:
2667
+ - You already have the content locally — store it via the inline index tool
2668
+ - The page is SPA-rendered (JavaScript-required to materialize content) — this is a plain HTTP fetch, no headless browser
2669
+
2670
+ RETURNS:
2671
+ Per-source preview windows extracted around indexable headings plus indexing metadata (chunk counts, source labels, cache state). Raw content is NOT echoed back — retrieve any section on-demand via ctx_search(source: "<label>"). Concurrency parallelizes the fetch phase up to your chosen value (capped by the host's logical CPU count); the FTS5 write phase always runs serially because SQLite is a single-writer store. Net latency = max(fetch latency across the pool) + sum(per-source index write time). Cache hits skip both phases and return a small freshness hint instead of re-fetching. Use 4-8 for stable I/O-bound batches; lower the value when the target host enforces a per-IP rate limit you cannot raise.
2672
+
2673
+ EXAMPLE: ctx_fetch_and_index(
2674
+ requests: [{url: "https://react.dev/...", source: "react"}, {url: "https://vuejs.org/...", source: "vue"}],
2675
+ concurrency: 5
2676
+ )`,
2515
2677
  inputSchema: z.object({
2516
2678
  url: z.string().optional().describe("Single URL to fetch and index (legacy single-shape)"),
2517
2679
  source: z
@@ -2519,11 +2681,10 @@ server.registerTool("ctx_fetch_and_index", {
2519
2681
  .optional()
2520
2682
  .describe("Label for the indexed content when using single `url` (e.g., 'React useEffect docs', 'Supabase Auth API'). For batch, put source in each requests entry."),
2521
2683
  requests: z
2522
- .array(z.object({
2684
+ .preprocess(coerceJsonArray, z.array(z.object({
2523
2685
  url: z.string().describe("URL to fetch"),
2524
2686
  source: z.string().optional().describe("Label for this URL's indexed content"),
2525
- }))
2526
- .min(1)
2687
+ })).min(1))
2527
2688
  .optional()
2528
2689
  .describe("Batch shape: array of {url, source?} entries. Use with concurrency>1 for parallel fetch. " +
2529
2690
  "Each request indexed under its own source label. Output preserves input order."),
@@ -2539,11 +2700,18 @@ server.registerTool("ctx_fetch_and_index", {
2539
2700
  "Capped by os.cpus().length on small machines (response notes when capped). " +
2540
2701
  "Indexing is always serial regardless — only fetches race."),
2541
2702
  force: z
2542
- .boolean()
2703
+ .preprocess(coerceBoolean, z.boolean())
2543
2704
  .optional()
2544
2705
  .describe("Skip cache and re-fetch even if content was recently indexed"),
2706
+ ttl: z
2707
+ .coerce.number()
2708
+ .int()
2709
+ .min(0)
2710
+ .optional()
2711
+ .describe("Override the cache freshness window for this call, in milliseconds. " +
2712
+ "`ttl: 0` bypasses the cache like `force: true`; omit to use the default 24h TTL."),
2545
2713
  }),
2546
- }, async ({ url, source, requests, concurrency, force }) => {
2714
+ }, async ({ url, source, requests, concurrency, force, ttl }) => {
2547
2715
  // Normalize input: legacy {url} or new {requests: [...]}.
2548
2716
  // requests wins when both are provided (explicit batch intent).
2549
2717
  const batch = requests
@@ -2565,7 +2733,7 @@ server.registerTool("ctx_fetch_and_index", {
2565
2733
  // Parallel fetch via shared runPool primitive. capByCpuCount only for batch
2566
2734
  // — single-URL doesn't need the cap (only one job, executor is one subprocess).
2567
2735
  const jobs = batch.map((req) => ({
2568
- run: () => fetchOneUrl(req.url, req.source, force),
2736
+ run: () => fetchOneUrl(req.url, req.source, force, ttl),
2569
2737
  }));
2570
2738
  const { settled, effectiveConcurrency, capped } = await runPool(jobs, {
2571
2739
  concurrency: requestedConcurrency,
@@ -2593,7 +2761,7 @@ server.registerTool("ctx_fetch_and_index", {
2593
2761
  source: cachedLabel,
2594
2762
  bytesAvoided: cachedBytes,
2595
2763
  }));
2596
- finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr });
2764
+ finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr, ttlStr: v.ttlStr });
2597
2765
  }
2598
2766
  else if (v.kind === "fetch_error") {
2599
2767
  finalized.push({ kind: "fetch_error", url: v.url, error: v.error, reason: v.reason });
@@ -2610,7 +2778,7 @@ server.registerTool("ctx_fetch_and_index", {
2610
2778
  return trackResponse("ctx_fetch_and_index", {
2611
2779
  content: [{
2612
2780
  type: "text",
2613
- text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL: 24h).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${r.label}")`,
2781
+ text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL: ${r.ttlStr}).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${r.label}")`,
2614
2782
  }],
2615
2783
  });
2616
2784
  }
@@ -2659,7 +2827,7 @@ server.registerTool("ctx_fetch_and_index", {
2659
2827
  for (const r of finalized) {
2660
2828
  if (r.kind === "cached") {
2661
2829
  cachedCount++;
2662
- lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr})`);
2830
+ lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr}, TTL: ${r.ttlStr})`);
2663
2831
  }
2664
2832
  else if (r.kind === "fetched") {
2665
2833
  fetchedCount++;
@@ -2705,17 +2873,32 @@ server.registerTool("ctx_fetch_and_index", {
2705
2873
  // ─────────────────────────────────────────────────────────
2706
2874
  server.registerTool("ctx_batch_execute", {
2707
2875
  title: "Batch Execute & Search",
2708
- description: "Execute multiple commands in ONE call, auto-index all output, and search with multiple queries. " +
2709
- "Returns search results directly — no follow-up calls needed.\n\n" +
2710
- "THIS IS THE PRIMARY TOOL. Use this instead of multiple ctx_execute() calls.\n\n" +
2711
- "One ctx_batch_execute call replaces 30+ ctx_execute calls + 10+ ctx_search calls.\n" +
2712
- "Provide all commands to run and all queries to search — everything happens in one round trip.\n\n" +
2713
- "PARALLELIZE I/O: For I/O-bound batches (network calls, slow API queries, multi-URL fetches), ALWAYS pass concurrency: 4-8 speeds up by 3-5x on real workloads.\n" +
2714
- " Use concurrency: 4-8 for: gh API calls, curl/web fetches, multi-region cloud queries, multi-repo git reads, dig/DNS, docker inspect.\n" +
2715
- " Keep concurrency: 1 for: npm test, build, lint, image processing (CPU-bound), or commands sharing state (ports, lock files, same-repo writes).\n" +
2716
- " Example: [gh issue view 1, gh issue view 2, gh issue view 3] concurrency: 3.\n" +
2717
- " Speedup depends on workload — applies to I/O wait, not CPU work.\n\n" +
2718
- "THINK IN CODE — NON-NEGOTIABLE: When commands produce data you need to analyze, count, filter, compare, or transform — add a processing command that runs JavaScript and console.log() ONLY the answer. NEVER pull raw output into context to reason over. Concurrency parallelizes the FETCH; THINK IN CODE owns the PROCESSING. One programmed analysis replaces ten read-and-reason rounds. Pure JavaScript, Node.js built-ins (fs, path, child_process), try/catch, null-safe.",
2876
+ description: `Run multiple commands in ONE call. Every command's output is auto-indexed into the knowledge base; if you also pass \`queries\`, the matching sections come back in the same round trip so a follow-up search call is not needed.
2877
+
2878
+ Concurrency parallelizes the FETCH phase (run-the-commands). The DERIVATION phase turning raw output into an answer — still belongs in code: add a processing command that consumes the indexed output and prints only the answer, so the raw bytes never enter your conversation (Think-in-Code, same principle as the sandbox tool).
2879
+
2880
+ WHEN:
2881
+ - You have 3+ related commands you would otherwise run sequentially (multi-issue lookups, git log + git diff + git blame, multi-file reads, multi-region cloud queries)
2882
+ - You want to gather AND query in one round trip pass \`queries\` so the matching sections come back inline
2883
+ - You want to parallelize I/O-bound work pass \`concurrency\` 2-8 (network calls, gh CLI, cloud APIs, multi-repo git reads)
2884
+ - The combined output is large enough that piping it through ctx_search later would itself be expensive — let auto-index + inline queries do both in one shot
2885
+
2886
+ WHEN NOT:
2887
+ - Single command with no follow-up query — run it in the sandbox tool directly
2888
+ - CPU-bound or stateful commands — keep concurrency at 1 (npm test, build, lint, port-binding servers, lock-file holders, anything that races on the same resource)
2889
+
2890
+ RETURNS:
2891
+ Auto-indexed section list per command label, plus top matches per query (when \`queries\` is passed). Raw output is NOT echoed in full — only the matched windows. Concurrency>1 switches each command to its own per-command timeout (no shared budget); concurrency=1 preserves the legacy shared-budget cascading-skip-on-timeout path. Use 4-8 for I/O-bound batches; keep at 1 for CPU work or shared-state commands; lower the value when target hosts enforce per-IP rate limits.
2892
+
2893
+ EXAMPLE: ctx_batch_execute(
2894
+ commands: [
2895
+ {label: "issue 1", command: "gh issue view 1"},
2896
+ {label: "issue 2", command: "gh issue view 2"},
2897
+ {label: "summarize", command: "echo done"}
2898
+ ],
2899
+ queries: ["root cause", "proposed fix"],
2900
+ concurrency: 2
2901
+ )`,
2719
2902
  inputSchema: z.object({
2720
2903
  commands: z.preprocess(coerceCommandsArray, z
2721
2904
  .array(z.object({
@@ -3017,6 +3200,12 @@ server.registerTool("ctx_doctor", {
3017
3200
  else {
3018
3201
  lines.push("[WARN] Performance: NORMAL — install Bun for 3-5x speed boost");
3019
3202
  }
3203
+ const sessionStorage = resolveSessionStorageDir(getDefaultSessionDir);
3204
+ const contentStorage = resolveContentStorageDir(getDefaultSessionDir);
3205
+ const statsStorage = resolveStatsStorageDir(getDefaultSessionDir);
3206
+ lines.push(`[OK] Storage sessions: ${sessionStorage.path} (${describeStorageDirectorySource(sessionStorage)})`);
3207
+ lines.push(`[OK] Storage content: ${contentStorage.path} (${describeStorageDirectorySource(contentStorage)})`);
3208
+ lines.push(`[OK] Storage stats: ${statsStorage.path} (${describeStorageDirectorySource(statsStorage)})`);
3020
3209
  // Server test — cleanup executor to prevent resource leaks (#247)
3021
3210
  {
3022
3211
  const testExecutor = new PolyglotExecutor({ runtimes });
@@ -3231,24 +3420,31 @@ server.registerTool("ctx_upgrade", {
3231
3420
  // tool with "input_schema does not support fields". Issue #563.
3232
3421
  server.registerTool("ctx_purge", {
3233
3422
  title: "Purge Knowledge Base",
3234
- description: "DESTRUCTIVE permanently delete indexed content. CANNOT be undone.\n\n" +
3235
- "You MUST specify exactly ONE scope:\n\n" +
3236
- " • { confirm: true, sessionId: \"<uuid>\" }\n" +
3237
- " Deletes ONLY that session's events + per-session FTS5 chunks.\n" +
3238
- " Preserves stats file and ALL other sessions.\n\n" +
3239
- " • { confirm: true, scope: \"project\" }\n" +
3240
- " Wipes the ENTIRE project: FTS5 knowledge base, every session DB row,\n" +
3241
- " events markdown, AND resets the stats file.\n\n" +
3242
- "REFUSAL RULES (tool returns an error):\n" +
3243
- " • confirm: false → 'purge cancelled'\n" +
3244
- " • Both sessionId AND scope:'project' provided → 'ambiguous — pick one'\n" +
3245
- " scope:'session' without sessionId throws (sessionId required)\n" +
3246
- " Neither sessionId NOR scope provided → DEPRECATED: maps to\n" +
3247
- " scope:'project' with a deprecation warning to stderr. Will be a hard\n" +
3248
- " error in a future major.\n\n" +
3249
- "Use sessionId when the user asks to clear a specific conversation's data.\n" +
3250
- "Use scope:'project' ONLY when the user explicitly asks to reset everything.\n" +
3251
- "NEVER call with bare {confirm:true} always specify the scope.",
3423
+ description: `DESTRUCTIVE: permanently delete indexed content. Cannot be undone. Requires confirm:true and exactly one scope.
3424
+
3425
+ WHEN:
3426
+ - User explicitly asks to clear a specific session ('purge this session', 'wipe this conversation')
3427
+ - User explicitly asks to reset the whole project ('reset everything', 'wipe the knowledge base')
3428
+
3429
+ WHEN NOT:
3430
+ - User says 'reset', 'clear', or 'wipe' without naming a scope -> ask which scope before calling
3431
+ - User wants to free memory or improve performance -> recommend ctx_stats first, do not purge
3432
+
3433
+ SCOPES (pass exactly one):
3434
+ - Per-session: ctx_purge(confirm: true, sessionId: "<uuid>") deletes that session's events (auto-captured decisions, errors, plans, user prompts, rejected approaches, etc.) and per-session FTS5 chunks; sibling sessions and stats file are preserved.
3435
+ - Per-project: ctx_purge(confirm: true, scope: "project") wipes FTS5 knowledge base, every session DB row, events markdown, and resets the stats file. Use ctx_stats first to preview category counts before purging.
3436
+
3437
+ CONTRACT:
3438
+ - confirm:true is required; confirm:false returns 'purge cancelled'.
3439
+ - sessionId and scope:'project' together return 'ambiguous - pick one'.
3440
+ - scope:'session' without sessionId throws (sessionId required).
3441
+ - Bare {confirm:true} is deprecated: maps to scope:'project' with a stderr warning; will hard-error in a future major.
3442
+
3443
+ RETURNS:
3444
+ A summary of removed rows + the resolved scope.
3445
+
3446
+ EXAMPLE: ctx_purge(confirm: true, sessionId: "7c8a-1234-5678-9abc-def012345678")
3447
+ EXAMPLE: ctx_purge(confirm: true, scope: "project")`,
3252
3448
  // NOTE: schema MUST be a plain z.object — no .refine()/.transform()/
3253
3449
  // .superRefine() wrapper. See block comment above & issue #563. The
3254
3450
  // cross-field ambiguity check lives in the handler body below.
@@ -3540,7 +3736,11 @@ server.registerTool("ctx_insight", {
3540
3736
  description: "Opens the context-mode Insight dashboard in the browser. " +
3541
3737
  "Shows personal analytics: session activity, tool usage, error rate, " +
3542
3738
  "parallel work patterns, project focus, and actionable insights. " +
3543
- "First run installs dependencies (~30s). Subsequent runs open instantly.",
3739
+ "First run installs dependencies (~30s). Subsequent runs open instantly. " +
3740
+ "Defaults to port 4747; pass `port` to override. " +
3741
+ "`sessionDir` and `contentDir` override the session/content storage roots " +
3742
+ "(env aliases INSIGHT_SESSION_DIR / INSIGHT_CONTENT_DIR) for diagnosing " +
3743
+ "multi-install setups or pointing at a sibling project's data.",
3544
3744
  inputSchema: z.object({
3545
3745
  port: z.coerce.number().int().min(1).max(65535).optional().describe("Port to serve on (default: 4747)"),
3546
3746
  sessionDir: z.string().optional().describe("Override INSIGHT_SESSION_DIR: directory containing context-mode session .db files"),