@stupidloud/codegraph 0.7.20 → 0.9.5

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 (155) hide show
  1. package/README.md +127 -106
  2. package/dist/bin/codegraph.d.ts +4 -0
  3. package/dist/bin/codegraph.d.ts.map +1 -1
  4. package/dist/bin/codegraph.js +327 -8
  5. package/dist/bin/codegraph.js.map +1 -1
  6. package/dist/bin/node-version-check.d.ts +17 -0
  7. package/dist/bin/node-version-check.d.ts.map +1 -1
  8. package/dist/bin/node-version-check.js +37 -0
  9. package/dist/bin/node-version-check.js.map +1 -1
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +1 -11
  12. package/dist/config.js.map +1 -1
  13. package/dist/db/index.d.ts +30 -1
  14. package/dist/db/index.d.ts.map +1 -1
  15. package/dist/db/index.js +75 -25
  16. package/dist/db/index.js.map +1 -1
  17. package/dist/db/queries.d.ts +16 -0
  18. package/dist/db/queries.d.ts.map +1 -1
  19. package/dist/db/queries.js +80 -27
  20. package/dist/db/queries.js.map +1 -1
  21. package/dist/db/sqlite-adapter.d.ts +17 -23
  22. package/dist/db/sqlite-adapter.d.ts.map +1 -1
  23. package/dist/db/sqlite-adapter.js +51 -174
  24. package/dist/db/sqlite-adapter.js.map +1 -1
  25. package/dist/extraction/grammars.d.ts +7 -1
  26. package/dist/extraction/grammars.d.ts.map +1 -1
  27. package/dist/extraction/grammars.js +42 -2
  28. package/dist/extraction/grammars.js.map +1 -1
  29. package/dist/extraction/index.d.ts +9 -14
  30. package/dist/extraction/index.d.ts.map +1 -1
  31. package/dist/extraction/index.js +131 -124
  32. package/dist/extraction/index.js.map +1 -1
  33. package/dist/extraction/languages/index.d.ts.map +1 -1
  34. package/dist/extraction/languages/index.js +4 -0
  35. package/dist/extraction/languages/index.js.map +1 -1
  36. package/dist/extraction/languages/lua.d.ts +3 -0
  37. package/dist/extraction/languages/lua.d.ts.map +1 -0
  38. package/dist/extraction/languages/lua.js +150 -0
  39. package/dist/extraction/languages/lua.js.map +1 -0
  40. package/dist/extraction/languages/luau.d.ts +3 -0
  41. package/dist/extraction/languages/luau.d.ts.map +1 -0
  42. package/dist/extraction/languages/luau.js +37 -0
  43. package/dist/extraction/languages/luau.js.map +1 -0
  44. package/dist/extraction/tree-sitter.d.ts.map +1 -1
  45. package/dist/extraction/tree-sitter.js +38 -0
  46. package/dist/extraction/tree-sitter.js.map +1 -1
  47. package/dist/extraction/wasm/tree-sitter-lua.wasm +0 -0
  48. package/dist/extraction/wasm/tree-sitter-luau.wasm +0 -0
  49. package/dist/extraction/wasm-runtime-flags.d.ts +38 -0
  50. package/dist/extraction/wasm-runtime-flags.d.ts.map +1 -0
  51. package/dist/extraction/wasm-runtime-flags.js +105 -0
  52. package/dist/extraction/wasm-runtime-flags.js.map +1 -0
  53. package/dist/graph/traversal.d.ts.map +1 -1
  54. package/dist/graph/traversal.js +71 -36
  55. package/dist/graph/traversal.js.map +1 -1
  56. package/dist/index.d.ts +11 -5
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +28 -18
  59. package/dist/index.js.map +1 -1
  60. package/dist/installer/config-writer.d.ts.map +1 -1
  61. package/dist/installer/config-writer.js +3 -1
  62. package/dist/installer/config-writer.js.map +1 -1
  63. package/dist/installer/index.d.ts +66 -2
  64. package/dist/installer/index.d.ts.map +1 -1
  65. package/dist/installer/index.js +195 -5
  66. package/dist/installer/index.js.map +1 -1
  67. package/dist/installer/instructions-template.d.ts +2 -2
  68. package/dist/installer/instructions-template.d.ts.map +1 -1
  69. package/dist/installer/instructions-template.js +4 -2
  70. package/dist/installer/instructions-template.js.map +1 -1
  71. package/dist/installer/targets/claude.d.ts +26 -6
  72. package/dist/installer/targets/claude.d.ts.map +1 -1
  73. package/dist/installer/targets/claude.js +165 -10
  74. package/dist/installer/targets/claude.js.map +1 -1
  75. package/dist/installer/targets/cursor.d.ts.map +1 -1
  76. package/dist/installer/targets/cursor.js +57 -3
  77. package/dist/installer/targets/cursor.js.map +1 -1
  78. package/dist/installer/targets/hermes.d.ts +18 -0
  79. package/dist/installer/targets/hermes.d.ts.map +1 -0
  80. package/dist/installer/targets/hermes.js +305 -0
  81. package/dist/installer/targets/hermes.js.map +1 -0
  82. package/dist/installer/targets/registry.d.ts.map +1 -1
  83. package/dist/installer/targets/registry.js +2 -0
  84. package/dist/installer/targets/registry.js.map +1 -1
  85. package/dist/installer/targets/types.d.ts +1 -1
  86. package/dist/installer/targets/types.d.ts.map +1 -1
  87. package/dist/mcp/index.d.ts +12 -0
  88. package/dist/mcp/index.d.ts.map +1 -1
  89. package/dist/mcp/index.js +213 -18
  90. package/dist/mcp/index.js.map +1 -1
  91. package/dist/mcp/server-instructions.d.ts +1 -1
  92. package/dist/mcp/server-instructions.d.ts.map +1 -1
  93. package/dist/mcp/server-instructions.js +15 -0
  94. package/dist/mcp/server-instructions.js.map +1 -1
  95. package/dist/mcp/tools.d.ts +25 -1
  96. package/dist/mcp/tools.d.ts.map +1 -1
  97. package/dist/mcp/tools.js +221 -30
  98. package/dist/mcp/tools.js.map +1 -1
  99. package/dist/mcp/transport.d.ts +17 -0
  100. package/dist/mcp/transport.d.ts.map +1 -1
  101. package/dist/mcp/transport.js +63 -0
  102. package/dist/mcp/transport.js.map +1 -1
  103. package/dist/resolution/frameworks/drupal.d.ts +51 -0
  104. package/dist/resolution/frameworks/drupal.d.ts.map +1 -0
  105. package/dist/resolution/frameworks/drupal.js +335 -0
  106. package/dist/resolution/frameworks/drupal.js.map +1 -0
  107. package/dist/resolution/frameworks/index.d.ts +2 -0
  108. package/dist/resolution/frameworks/index.d.ts.map +1 -1
  109. package/dist/resolution/frameworks/index.js +9 -1
  110. package/dist/resolution/frameworks/index.js.map +1 -1
  111. package/dist/resolution/frameworks/nestjs.d.ts +26 -0
  112. package/dist/resolution/frameworks/nestjs.d.ts.map +1 -0
  113. package/dist/resolution/frameworks/nestjs.js +374 -0
  114. package/dist/resolution/frameworks/nestjs.js.map +1 -0
  115. package/dist/resolution/index.d.ts.map +1 -1
  116. package/dist/resolution/index.js +40 -7
  117. package/dist/resolution/index.js.map +1 -1
  118. package/dist/resolution/lru-cache.d.ts +24 -0
  119. package/dist/resolution/lru-cache.d.ts.map +1 -0
  120. package/dist/resolution/lru-cache.js +62 -0
  121. package/dist/resolution/lru-cache.js.map +1 -0
  122. package/dist/sync/git-hooks.d.ts +45 -0
  123. package/dist/sync/git-hooks.d.ts.map +1 -0
  124. package/dist/sync/git-hooks.js +223 -0
  125. package/dist/sync/git-hooks.js.map +1 -0
  126. package/dist/sync/index.d.ts +4 -0
  127. package/dist/sync/index.d.ts.map +1 -1
  128. package/dist/sync/index.js +12 -1
  129. package/dist/sync/index.js.map +1 -1
  130. package/dist/sync/watch-policy.d.ts +48 -0
  131. package/dist/sync/watch-policy.d.ts.map +1 -0
  132. package/dist/sync/watch-policy.js +124 -0
  133. package/dist/sync/watch-policy.js.map +1 -0
  134. package/dist/sync/watcher.d.ts +2 -4
  135. package/dist/sync/watcher.d.ts.map +1 -1
  136. package/dist/sync/watcher.js +14 -6
  137. package/dist/sync/watcher.js.map +1 -1
  138. package/dist/types.d.ts +1 -1
  139. package/dist/types.d.ts.map +1 -1
  140. package/dist/types.js +11 -0
  141. package/dist/types.js.map +1 -1
  142. package/dist/utils.js +1 -1
  143. package/package.json +4 -4
  144. package/scripts/add-lang/bench.sh +60 -0
  145. package/scripts/add-lang/check-grammar.mjs +75 -0
  146. package/scripts/add-lang/dump-ast.mjs +103 -0
  147. package/scripts/add-lang/verify-extraction.mjs +70 -0
  148. package/scripts/agent-eval/audit.sh +68 -0
  149. package/scripts/agent-eval/itrun.sh +1 -1
  150. package/scripts/agent-eval/run-all.sh +67 -0
  151. package/scripts/build-bundle.sh +118 -0
  152. package/scripts/npm-shim.js +246 -0
  153. package/scripts/pack-npm.sh +95 -0
  154. package/scripts/patch-tree-sitter-dart.js +0 -112
  155. package/scripts/release.sh +0 -68
package/dist/mcp/tools.js CHANGED
@@ -47,9 +47,22 @@ const fs_1 = require("fs");
47
47
  const utils_1 = require("../utils");
48
48
  const os_1 = require("os");
49
49
  const path_1 = require("path");
50
- const db_1 = require("../db");
51
50
  /** Maximum output length to prevent context bloat (characters) */
52
51
  const MAX_OUTPUT_LENGTH = 15000;
52
+ /**
53
+ * Maximum length for free-form string inputs (query, task, symbol).
54
+ * Bounds memory and CPU when a buggy or hostile MCP client sends a
55
+ * huge payload — without this an attacker could ship a 100MB string
56
+ * and force a full FTS5 scan / OOM the server. 10 000 characters is
57
+ * far beyond any realistic legitimate query.
58
+ */
59
+ const MAX_INPUT_LENGTH = 10_000;
60
+ /**
61
+ * Maximum length for path-like string inputs (projectPath, path
62
+ * filter, glob pattern). Paths beyond a few thousand chars are
63
+ * never legitimate and signal abuse or a bug upstream.
64
+ */
65
+ const MAX_PATH_LENGTH = 4_096;
53
66
  /**
54
67
  * Rust path roots that have no file-system equivalent — `crate` is the
55
68
  * current crate, `super` is the parent module, `self` is the current
@@ -58,6 +71,15 @@ const MAX_OUTPUT_LENGTH = 15000;
58
71
  * same as `configurator::stage_apply::run`.
59
72
  */
60
73
  const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
74
+ /**
75
+ * Node kinds that contain other symbols. For these, `codegraph_node` with
76
+ * `includeCode=true` returns a structural outline (member names + signatures
77
+ * + line numbers) instead of the full body, which for a large class is a
78
+ * multi-thousand-character wall of source that bloats the agent's context.
79
+ */
80
+ const CONTAINER_NODE_KINDS = new Set([
81
+ 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
82
+ ]);
61
83
  /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
62
84
  function lastQualifierPart(symbol) {
63
85
  const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
@@ -96,12 +118,12 @@ function getExploreOutputBudget(fileCount) {
96
118
  }
97
119
  if (fileCount < 5000) {
98
120
  return {
99
- maxOutputChars: 28000,
100
- defaultMaxFiles: 9,
101
- maxCharsPerFile: 5000,
102
- gapThreshold: 12,
103
- maxSymbolsInFileHeader: 10,
104
- maxEdgesPerRelationshipKind: 10,
121
+ maxOutputChars: 13000,
122
+ defaultMaxFiles: 6,
123
+ maxCharsPerFile: 2500,
124
+ gapThreshold: 10,
125
+ maxSymbolsInFileHeader: 8,
126
+ maxEdgesPerRelationshipKind: 8,
105
127
  includeRelationships: true,
106
128
  includeAdditionalFiles: true,
107
129
  includeCompletenessSignal: true,
@@ -168,15 +190,50 @@ function numberSourceLines(slice, firstLineNumber) {
168
190
  /**
169
191
  * Mark a Claude session as having consulted MCP tools.
170
192
  * This enables Grep/Glob/Bash commands that would otherwise be blocked.
193
+ *
194
+ * Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
195
+ * tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
196
+ * machine any other local user can pre-create `codegraph-consulted-<hash>` as
197
+ * a symlink pointing at a file the victim owns. The old `writeFileSync` would
198
+ * happily follow that link and overwrite the target's contents with the ISO
199
+ * timestamp string (CWE-59). The session-id hash provides the predictability
200
+ * gate, but it's defense-in-depth: if a session id ever surfaces in logs,
201
+ * argv, or telemetry the attack becomes trivial, and the right fix is to not
202
+ * follow links from /tmp paths in the first place.
171
203
  */
172
204
  function markSessionConsulted(sessionId) {
173
205
  try {
174
206
  const hash = (0, crypto_1.createHash)('md5').update(sessionId).digest('hex').slice(0, 16);
175
207
  const markerPath = (0, path_1.join)((0, os_1.tmpdir)(), `codegraph-consulted-${hash}`);
176
- (0, fs_1.writeFileSync)(markerPath, new Date().toISOString(), 'utf8');
208
+ // Refuse to follow a pre-planted symlink at the marker path (CWE-59).
209
+ // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
210
+ // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
211
+ // drops it and openSync would follow the link. This lstat check closes that
212
+ // gap cross-platform; ENOENT (path is free) falls through to create it.
213
+ try {
214
+ if ((0, fs_1.lstatSync)(markerPath).isSymbolicLink())
215
+ return;
216
+ }
217
+ catch {
218
+ // No existing entry (or stat failed) — nothing to refuse; proceed.
219
+ }
220
+ // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
221
+ // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
222
+ // mode 0o600 prevents readback by other local users (the marker payload is
223
+ // benign, but narrowing the exposure costs nothing).
224
+ const flags = fs_1.constants.O_WRONLY | fs_1.constants.O_CREAT | fs_1.constants.O_TRUNC | fs_1.constants.O_NOFOLLOW;
225
+ const fd = (0, fs_1.openSync)(markerPath, flags, 0o600);
226
+ try {
227
+ (0, fs_1.writeSync)(fd, new Date().toISOString());
228
+ }
229
+ finally {
230
+ (0, fs_1.closeSync)(fd);
231
+ }
177
232
  }
178
233
  catch {
179
- // Silently fail - don't break MCP on marker write failure
234
+ // Silently fail - don't break MCP on marker write failure. ELOOP from a
235
+ // planted symlink lands here too, which is the intended behavior: refuse
236
+ // to write rather than overwrite an attacker-chosen target.
180
237
  }
181
238
  }
182
239
  /**
@@ -222,7 +279,7 @@ exports.tools = [
222
279
  },
223
280
  {
224
281
  name: 'codegraph_context',
225
- description: 'PRIMARY TOOL: Build comprehensive context for a task. Returns entry points, related symbols, and key code - often enough to understand the codebase without additional tool calls. NOTE: This provides CODE context, not product requirements. For new features, still clarify UX/behavior questions with the user before implementing.',
282
+ description: 'PRIMARY TOOL call this FIRST for any "how does X work", architecture, feature, or bug-context question. Composes search + node + callers + callees and returns entry points, related symbols, and key code in ONE call — usually enough to answer with no further search/Read/Grep. Prefer this over chaining codegraph_search + codegraph_node, and over codegraph_explore. NOTE: provides CODE context, not product requirements; for new features still clarify UX/edge cases with the user.',
226
283
  inputSchema: {
227
284
  type: 'object',
228
285
  properties: {
@@ -307,7 +364,7 @@ exports.tools = [
307
364
  },
308
365
  {
309
366
  name: 'codegraph_node',
310
- description: 'Get detailed information about a specific code symbol. Use includeCode=true only when you need the full source code - otherwise just get location and signature to minimize context usage.',
367
+ description: 'Get detailed info about ONE symbol (location, signature, docstring). Pass includeCode=true for source: a function/method returns its body; a class/interface/struct/enum returns a compact member OUTLINE (fields + method signatures + line numbers), not every method body — Read or codegraph_node a specific member for its body. Keep includeCode=false to minimize context. For SEVERAL related symbols, make ONE codegraph_explore (or codegraph_context) call instead of many node calls — repeated node calls each re-read the whole context and cost far more.',
311
368
  inputSchema: {
312
369
  type: 'object',
313
370
  properties: {
@@ -327,7 +384,7 @@ exports.tools = [
327
384
  },
328
385
  {
329
386
  name: 'codegraph_explore',
330
- description: 'PRIMARY TOOL for understanding questions "how does X work", "trace X end to end", "explain the Y system", architecture/onboarding. Returns comprehensive context in a SINGLE call: relevant source grouped by file (contiguous, line-numbered sections, not snippets) + a relationship map + deep graph traversal. It REPLACES the grep+Read exploration loop: feed it the key symbol/file names and read its output — do NOT Read the files one by one. It works best when your query names the relevant symbols (e.g. "readAgentsFromDirectory createClaudeSession chat-manager agents.ts"); if the question is a plain sentence that names nothing concrete, do ONE quick codegraph_search or codegraph_context to surface the names, then call this with them. After exploring, use codegraph_node / Read only to fill specific gaps it did not cover. Prefer codegraph_search over this only for a pinpoint "where is X defined" lookup.',
387
+ description: 'PRIMARY TOOL for understanding questions and inspecting SEVERAL related symbols in ONE capped call. Returns relevant source grouped by file (contiguous, line-numbered sections) plus a relationship map. Use it after codegraph_context when you need actual source for the surfaced symbols, or query it directly with specific symbol/file/code terms. Strongly prefer it over many codegraph_node or Read calls. For a pinpoint "where is X defined" lookup, use codegraph_search instead.',
331
388
  inputSchema: {
332
389
  type: 'object',
333
390
  properties: {
@@ -399,6 +456,9 @@ class ToolHandler {
399
456
  cg;
400
457
  // Cache of opened CodeGraph instances for cross-project queries
401
458
  projectCache = new Map();
459
+ // The directory the server last searched for a default project. Surfaced in
460
+ // the "not initialized" error so users can see why detection missed.
461
+ defaultProjectHint = null;
402
462
  constructor(cg) {
403
463
  this.cg = cg;
404
464
  }
@@ -408,6 +468,13 @@ class ToolHandler {
408
468
  setDefaultCodeGraph(cg) {
409
469
  this.cg = cg;
410
470
  }
471
+ /**
472
+ * Record the directory the server tried to resolve the default project from.
473
+ * Used only to make the "no default project" error actionable.
474
+ */
475
+ setDefaultProjectHint(searchedPath) {
476
+ this.defaultProjectHint = searchedPath;
477
+ }
411
478
  /**
412
479
  * Whether a default CodeGraph instance is available
413
480
  */
@@ -451,7 +518,14 @@ class ToolHandler {
451
518
  getCodeGraph(projectPath) {
452
519
  if (!projectPath) {
453
520
  if (!this.cg) {
454
- throw new Error('CodeGraph not initialized for this project. Run \'codegraph init\' first.');
521
+ const searched = this.defaultProjectHint ?? process.cwd();
522
+ throw new Error('No CodeGraph project is loaded for this session.\n' +
523
+ `Searched for a .codegraph/ directory starting from: ${searched}\n` +
524
+ 'The index is likely fine — this is a working-directory detection issue: ' +
525
+ "the MCP client launched the server outside your project and didn't report the " +
526
+ 'workspace root. Fix it either way:\n' +
527
+ ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
528
+ ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]');
455
529
  }
456
530
  return this.cg;
457
531
  }
@@ -459,11 +533,32 @@ class ToolHandler {
459
533
  if (this.projectCache.has(projectPath)) {
460
534
  return this.projectCache.get(projectPath);
461
535
  }
536
+ // Reject sensitive system directories before opening. Only validate a
537
+ // path that actually exists — a nested or not-yet-created sub-path of a
538
+ // real project must still be allowed to resolve UP to its .codegraph/
539
+ // root below (issue #238), so we don't run the existence-checking
540
+ // validator on paths that are meant to walk up.
541
+ if ((0, fs_1.existsSync)(projectPath)) {
542
+ const pathError = (0, utils_1.validateProjectPath)(projectPath);
543
+ if (pathError) {
544
+ throw new Error(pathError);
545
+ }
546
+ }
462
547
  // Walk up parent directories to find nearest .codegraph/
463
548
  const resolvedRoot = (0, index_1.findNearestCodeGraphRoot)(projectPath);
464
549
  if (!resolvedRoot) {
465
550
  throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
466
551
  }
552
+ // If the path resolves to the default project, reuse the already-open
553
+ // default instance rather than opening a SECOND connection to the same DB.
554
+ // A duplicate connection serializes reads against the watcher's auto-sync
555
+ // writes; on the wasm backend (no WAL) that surfaces as intermittent
556
+ // "database is locked" on concurrent tool calls. See issue #238. Deliberately
557
+ // not cached under projectPath — the server owns and closes the default
558
+ // instance, so routing it through projectCache.closeAll() would double-close it.
559
+ if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
560
+ return this.cg;
561
+ }
467
562
  // Check if we already have this resolved root cached (different path, same project)
468
563
  if (this.projectCache.has(resolvedRoot)) {
469
564
  const cg = this.projectCache.get(resolvedRoot);
@@ -489,12 +584,35 @@ class ToolHandler {
489
584
  this.projectCache.clear();
490
585
  }
491
586
  /**
492
- * Validate that a value is a non-empty string
587
+ * Validate that a value is a non-empty string within length bounds.
588
+ *
589
+ * The `maxLength` cap protects against MCP clients that ship huge
590
+ * payloads (10MB+ query strings either by accident or maliciously).
591
+ * Without this, a single oversized input can pin the FTS5 index or
592
+ * exhaust memory before any real work runs.
493
593
  */
494
- validateString(value, name) {
594
+ validateString(value, name, maxLength = MAX_INPUT_LENGTH) {
495
595
  if (typeof value !== 'string' || value.length === 0) {
496
596
  return this.errorResult(`${name} must be a non-empty string`);
497
597
  }
598
+ if (value.length > maxLength) {
599
+ return this.errorResult(`${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`);
600
+ }
601
+ return value;
602
+ }
603
+ /**
604
+ * Validate an optional path-like string input. Returns the value if
605
+ * valid (or undefined), or a ToolResult with the error.
606
+ */
607
+ validateOptionalPath(value, name) {
608
+ if (value === undefined || value === null)
609
+ return undefined;
610
+ if (typeof value !== 'string') {
611
+ return this.errorResult(`${name} must be a string`);
612
+ }
613
+ if (value.length > MAX_PATH_LENGTH) {
614
+ return this.errorResult(`${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`);
615
+ }
498
616
  return value;
499
617
  }
500
618
  /**
@@ -502,6 +620,26 @@ class ToolHandler {
502
620
  */
503
621
  async execute(toolName, args) {
504
622
  try {
623
+ // Cross-cutting input validation. All tools accept an optional
624
+ // `projectPath` and most accept either `query`, `task`, or
625
+ // `symbol` — bound their lengths centrally so individual handlers
626
+ // can stay focused on tool-specific logic.
627
+ const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
628
+ if (typeof pathCheck === 'object' && pathCheck !== undefined) {
629
+ return pathCheck;
630
+ }
631
+ // The `path` and `pattern` properties used by codegraph_files are
632
+ // also path-shaped — apply the same cap.
633
+ if (args.path !== undefined) {
634
+ const check = this.validateOptionalPath(args.path, 'path');
635
+ if (typeof check === 'object' && check !== undefined)
636
+ return check;
637
+ }
638
+ if (args.pattern !== undefined) {
639
+ const check = this.validateOptionalPath(args.pattern, 'pattern');
640
+ if (typeof check === 'object' && check !== undefined)
641
+ return check;
642
+ }
505
643
  switch (toolName) {
506
644
  case 'codegraph_search':
507
645
  return await this.handleSearch(args);
@@ -577,10 +715,10 @@ class ToolHandler {
577
715
  : '';
578
716
  // buildContext returns string when format is 'markdown'
579
717
  if (typeof context === 'string') {
580
- return this.textResult(context + reminder);
718
+ return this.textResult(this.truncateOutput(context + reminder));
581
719
  }
582
720
  // If it returns TaskContext, format it
583
- return this.textResult(this.formatTaskContext(context) + reminder);
721
+ return this.textResult(this.truncateOutput(this.formatTaskContext(context) + reminder));
584
722
  }
585
723
  /**
586
724
  * Heuristic to detect if a query looks like a feature request
@@ -1123,7 +1261,20 @@ class ToolHandler {
1123
1261
  // Stats unavailable — skip budget note
1124
1262
  }
1125
1263
  }
1126
- return this.textResult(lines.join('\n'));
1264
+ // Hard-cap to the adaptive budget. The per-file loop bounds the source
1265
+ // sections, but the relationship map, additional-files list, and
1266
+ // completeness/budget notes can still push the assembled output past
1267
+ // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
1268
+ // payload persists in the agent's context and is re-read as cache-input
1269
+ // on every subsequent turn, so the overrun is paid many times over.
1270
+ const output = lines.join('\n');
1271
+ if (output.length > budget.maxOutputChars) {
1272
+ const cut = output.slice(0, budget.maxOutputChars);
1273
+ const lastNewline = cut.lastIndexOf('\n');
1274
+ const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
1275
+ return this.textResult(safe + '\n\n... (explore output truncated to budget — use codegraph_node or Read for more)');
1276
+ }
1277
+ return this.textResult(output);
1127
1278
  }
1128
1279
  /**
1129
1280
  * Handle codegraph_node
@@ -1140,10 +1291,22 @@ class ToolHandler {
1140
1291
  return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1141
1292
  }
1142
1293
  let code = null;
1294
+ let outline = null;
1143
1295
  if (includeCode) {
1144
- code = await cg.getCode(match.node.id);
1296
+ // For container symbols (class/interface/struct/…), the full body is the
1297
+ // sum of every method body — a wall of source (e.g. a 10k-char class)
1298
+ // that bloats context and is rarely needed in full. Return a structural
1299
+ // outline (members + signatures + line numbers) instead; the agent can
1300
+ // Read or codegraph_node a specific method for its body. Leaf symbols
1301
+ // (function/method/etc.) return their full body as before.
1302
+ if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
1303
+ outline = this.buildContainerOutline(cg, match.node);
1304
+ }
1305
+ if (!outline) {
1306
+ code = await cg.getCode(match.node.id);
1307
+ }
1145
1308
  }
1146
- const formatted = this.formatNodeDetails(match.node, code) + match.note;
1309
+ const formatted = this.formatNodeDetails(match.node, code, outline) + match.note;
1147
1310
  return this.textResult(this.truncateOutput(formatted));
1148
1311
  }
1149
1312
  /**
@@ -1160,16 +1323,20 @@ class ToolHandler {
1160
1323
  `**Total edges:** ${stats.edgeCount}`,
1161
1324
  `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
1162
1325
  ];
1163
- // Surface the active SQLite backend. Without this, users on the
1164
- // silent WASM fallback (better-sqlite3 install failed) see "slow"
1165
- // indexing and DB-lock errors with no signal of why.
1166
- const backend = cg.getBackend();
1167
- if (backend === 'native') {
1168
- lines.push(`**Backend:** native (better-sqlite3)`);
1326
+ // Surface the active SQLite backend (node:sqlite, Node's built-in real
1327
+ // SQLite full WAL + FTS5, no native build).
1328
+ lines.push(`**Backend:** node:sqlite (Node built-in) full WAL + FTS5`);
1329
+ // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
1330
+ // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
1331
+ // everywhere, so a non-wal mode means the filesystem can't (network/
1332
+ // virtualized mounts, WSL2 /mnt). See issue #238.
1333
+ const journalMode = cg.getJournalMode();
1334
+ if (journalMode === 'wal') {
1335
+ lines.push(`**Journal mode:** wal (concurrent reads safe)`);
1169
1336
  }
1170
1337
  else {
1171
- lines.push(`**Backend:** ⚠ wasm (better-sqlite3 unavailable) — ` +
1172
- `5-10x slower than native. Fix: ${db_1.WASM_FALLBACK_FIX_RECIPE}`);
1338
+ lines.push(`**Journal mode:** ⚠ ${journalMode || 'unknown'}WAL not active, so reads ` +
1339
+ `can block on a concurrent write (WAL appears unsupported on this filesystem)`);
1173
1340
  }
1174
1341
  lines.push('', '### Nodes by Kind:');
1175
1342
  for (const [kind, count] of Object.entries(stats.nodesByKind)) {
@@ -1520,7 +1687,28 @@ class ToolHandler {
1520
1687
  }
1521
1688
  return lines.join('\n');
1522
1689
  }
1523
- formatNodeDetails(node, code) {
1690
+ /**
1691
+ * Build a compact structural outline of a container symbol from its
1692
+ * indexed children (methods, fields, properties, …) — name, kind,
1693
+ * line number, and signature — so the agent gets the shape of a class
1694
+ * without the full source of every method. Returns '' when the container
1695
+ * has no indexed children, so the caller can fall back to full source.
1696
+ */
1697
+ buildContainerOutline(cg, node) {
1698
+ const children = cg.getChildren(node.id)
1699
+ .filter(c => c.kind !== 'import' && c.kind !== 'export')
1700
+ .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
1701
+ if (children.length === 0)
1702
+ return '';
1703
+ const lines = [`**Members (${children.length}):**`, ''];
1704
+ for (const c of children) {
1705
+ const loc = c.startLine ? `:${c.startLine}` : '';
1706
+ const sig = c.signature ? ` — \`${c.signature}\`` : '';
1707
+ lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
1708
+ }
1709
+ return lines.join('\n');
1710
+ }
1711
+ formatNodeDetails(node, code, outline) {
1524
1712
  const location = node.startLine ? `:${node.startLine}` : '';
1525
1713
  const lines = [
1526
1714
  `## ${node.name} (${node.kind})`,
@@ -1534,7 +1722,10 @@ class ToolHandler {
1534
1722
  if (node.docstring && node.docstring.length < 200) {
1535
1723
  lines.push('', node.docstring);
1536
1724
  }
1537
- if (code) {
1725
+ if (outline) {
1726
+ lines.push('', outline, '', `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
1727
+ }
1728
+ else if (code) {
1538
1729
  lines.push('', '```' + node.language, code, '```');
1539
1730
  }
1540
1731
  return lines.join('\n');