preflight-mcp 0.5.4 → 0.6.0

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/README.md CHANGED
@@ -51,8 +51,9 @@ Preflight: 🔗 Trace links:
51
51
  - 📖 **Auto-generated guides** — `START_HERE.md`, `AGENTS.md`, `OVERVIEW.md`
52
52
  - ☁️ **Cloud sync** — Multi-path mirror backup for redundancy
53
53
  - 🧠 **EDDA (Evidence-Driven Deep Analysis)** — Auto-generate auditable claims with evidence
54
- - ⚡ **21 MCP tools + 5 prompts** — Complete toolkit for code exploration
54
+ - ⚡ **17 MCP tools + 5 prompts** — Streamlined toolkit optimized for LLM use
55
55
  - 📄 **Cursor pagination** — Handle large result sets efficiently (RFC v2)
56
+ - 🧠 **LLM-friendly** — Auto-compressed outputs, unified interfaces (v0.6.0)
56
57
 
57
58
  <details>
58
59
  <summary><b>All Features (click to expand)</b></summary>
@@ -161,13 +162,20 @@ Run end-to-end smoke test:
161
162
  npm run smoke
162
163
  ```
163
164
 
164
- ## Tools (21 total)
165
+ ## Tools (17 active + 5 deprecated)
165
166
 
166
167
  ### `preflight_list_bundles`
167
168
  List bundle IDs in storage.
169
+ - **Markdown output** (v0.6.0): LLM-friendly structured format
168
170
  - **Cursor pagination** (v0.5.0): Use `cursor` parameter for large bundle lists
169
171
  - Triggers: "show bundles", "查看bundle", "有哪些bundle"
170
172
 
173
+ ### `preflight_get_overview` *(NEW v0.6.0)*
174
+ ⭐ **START HERE** - Get project overview in one call.
175
+ - Returns: OVERVIEW.md + START_HERE.md + AGENTS.md
176
+ - Simplest entry point for exploring any bundle
177
+ - Triggers: "了解项目", "项目概览", "what is this project", "show overview"
178
+
171
179
  ### `preflight_create_bundle`
172
180
  Create a new bundle from GitHub repos or local directories.
173
181
  - Triggers: "index this repo", "学习这个项目", "创建bundle"
@@ -240,28 +248,10 @@ Offline repair for a bundle (no fetching): rebuild missing/empty derived artifac
240
248
  - Rebuilds `indexes/search.sqlite3`, `START_HERE.md`, `AGENTS.md`, `OVERVIEW.md` when missing/empty.
241
249
  - Use when: search fails due to index corruption, bundle files were partially deleted, etc.
242
250
 
243
- ### `preflight_search_bundle`
244
- Full-text search across ingested docs/code (line-based SQLite FTS5).
245
- - Triggers: "搜索bundle", "在仓库中查找", "搜代码"
246
-
247
- Important: **this tool is strictly read-only**.
248
- - To update: call `preflight_update_bundle`, then search again.
249
- - To repair: call `preflight_repair_bundle`, then search again.
250
-
251
- **New filtering options** (v0.3.1):
252
- - `excludePatterns`: Filter out paths matching patterns (e.g., `["**/tests/**", "**/__pycache__/**"]`)
253
- - `maxSnippetLength`: Limit snippet length per result (50-500 chars) to reduce token consumption
251
+ ### `preflight_search_bundle` *(DEPRECATED)*
252
+ ⚠️ **Use `preflight_search_and_read` instead** with `readContent: false` for index-only searches.
254
253
 
255
- **EDDA enhancements** (v0.4.0):
256
- - `groupByFile`: Group hits by file, returns `{path, hitCount, topSnippet}` - significantly reduces tokens
257
- - `fileTypeFilters`: Filter by extension (e.g., `[".py", ".ts"]`)
258
- - `includeScore`: Include BM25 relevance score in results
259
-
260
- **Cursor pagination** (v0.5.0):
261
- - `cursor`: Pagination cursor from previous call for fetching next page
262
- - Response includes `truncation.nextCursor` when more results available
263
-
264
- **Deprecated parameters**: `ensureFresh`, `autoRepairIndex`, `maxAgeHours` are deprecated and will return warnings instead of errors.
254
+ Full-text search across ingested docs/code (line-based SQLite FTS5).
265
255
 
266
256
  ### `preflight_search_by_tags`
267
257
  Search across multiple bundles filtered by tags (line-based SQLite FTS5).
@@ -278,25 +268,17 @@ Optional parameters:
278
268
  - `limit`: Max total hits across all bundles
279
269
  - `cursor`: Pagination cursor for fetching next page
280
270
 
281
- ### `preflight_evidence_dependency_graph`
282
- Generate an evidence-based dependency graph. Two modes:
283
- - **Target mode** (provide `target.file`): Analyze a specific file's imports and references
284
- - **Global mode** (omit `target`): Generate project-wide import graph of all code files
285
- - Deterministic output with source ranges for edges.
286
- - Uses Tree-sitter parsing when `PREFLIGHT_AST_ENGINE=wasm`; falls back to regex extraction otherwise.
287
-
288
- **Edge types** (v0.2.7+):
289
- - `edgeTypes: "imports"` (default): Only AST-based import edges (high confidence, recommended)
290
- - `edgeTypes: "all"`: Include FTS-based reference edges (name matching, may have false positives)
271
+ ### `preflight_dependency_graph` *(UNIFIED v0.6.0)*
272
+ Get or generate dependency graph for a bundle.
273
+ - **Auto-generates** if not cached, returns cached version if available
274
+ - `scope: "global"` (default): Project-wide dependency graph
275
+ - `scope: "target"` with `targetFile`: Dependencies for a specific file
276
+ - `format: "summary"` (default): Top nodes, aggregated by directory
277
+ - `format: "full"`: Complete graph data with coverage report
278
+ - Triggers: "show dependencies", "看依赖图", "import graph"
291
279
 
292
- **Cache transparency** (v0.2.7+):
293
- - Response includes `meta.cacheInfo` with `fromCache`, `generatedAt`, `cacheAgeMs`
294
- - Use `force: true` to regenerate cached global graphs
295
-
296
- **Large file handling**:
297
- - `options.maxFileSizeBytes` (default: 1MB): Skip files larger than this
298
- - `options.largeFileStrategy`: `"skip"` (default) or `"truncate"`
299
- - `options.excludeExtensions`: Filter out non-code files from reference search (default: `.json`, `.md`, `.txt`, `.yml`, etc.)
280
+ ### `preflight_evidence_dependency_graph` *(DEPRECATED)*
281
+ ⚠️ **Use `preflight_dependency_graph` instead** - simpler interface with same functionality.
300
282
 
301
283
  ### `preflight_trace_upsert`
302
284
  Create or update traceability links (code↔test, code↔doc, file↔requirement).
@@ -311,27 +293,16 @@ Query traceability links (code↔test, code↔doc, commit↔ticket).
311
293
  - Returns `reason` and `nextSteps` when no edges found (helps LLM decide next action)
312
294
  - Fast when `bundleId` is provided; can scan across bundles when omitted.
313
295
 
314
- ### `preflight_trace_export`
315
- Export trace links to `trace/trace.json` for direct LLM reading.
316
- - Note: Auto-exported after each `trace_upsert`, so only needed to manually refresh
317
- - Triggers: "export trace", "refresh trace.json", "导出trace"
318
-
319
- ### `preflight_suggest_traces` *(NEW v0.4.0)*
320
- Automatically suggest trace links based on file naming patterns.
321
- - **MVP**: Only supports `tested_by` edge type (code↔test relationships)
322
- - Scans for patterns: `test_*.py`, `*_test.py`, `*.test.ts`, `*.spec.ts`, `*_test.go`
323
- - Returns ready-to-use `upsertPayload` for `preflight_trace_upsert`
324
- - Triggers: "suggest test links", "find test coverage", "发现测试关系"
296
+ ### `preflight_trace_export` *(DEPRECATED)*
297
+ ⚠️ trace.json is auto-exported after each `trace_upsert`. Use `preflight_read_file` with `file: "trace/trace.json"` to read.
325
298
 
326
- Parameters:
327
- - `edge_type`: `"tested_by"` (MVP only)
328
- - `scope`: `"repo"` | `"dir"` | `"file"`
329
- - `min_confidence`: 0-1 (default: 0.85)
330
- - `limit`: Max suggestions (default: 50)
299
+ ### `preflight_suggest_traces` *(DEPRECATED)*
300
+ ⚠️ Use `preflight_deep_analyze_bundle` for test detection, then `preflight_trace_upsert` manually.
331
301
 
332
- ### `preflight_deep_analyze_bundle` *(Enhanced v0.5.1)*
302
+ ### `preflight_deep_analyze_bundle` *(Enhanced v0.6.0)*
333
303
  One-call deep analysis aggregating tree, search, deps, traces, **overview content**, and **test detection**.
334
304
  - Returns unified evidence pack with LLM-friendly summary
305
+ - **Summary now includes OVERVIEW.md excerpt** (v0.6.0) - one call for complete context
335
306
  - **Now includes OVERVIEW.md, START_HERE.md, AGENTS.md, README content** (v0.5.1)
336
307
  - **Auto-detects test frameworks** (jest, vitest, pytest, go, mocha) (v0.5.1)
337
308
  - **Generates copyable `nextCommands`** for follow-up actions (v0.5.1)
@@ -374,36 +345,23 @@ Parameters:
374
345
  - `verifyFileExists`: Check evidence files exist (default: true)
375
346
  - `strictMode`: Treat warnings as errors (default: false)
376
347
 
377
- ### `preflight_read_files` *(NEW v0.5.0)*
378
- Batch read multiple files from a bundle in a single call.
379
- - Reduces round-trips for evidence gathering
380
- - **RFC v2 unified envelope**: Returns `ok`, `meta`, `data`, `evidence[]`
381
- - Triggers: "read these files", "get content of", "批量读取"
382
-
383
- Parameters:
384
- - `bundleId`: Bundle ID
385
- - `files[]`: Array of `{path, ranges?, withLineNumbers?}`
386
- - `format`: `"json"` (default) or `"text"`
348
+ ### `preflight_read_files` *(DEPRECATED)*
349
+ ⚠️ Use multiple `preflight_read_file` calls, or use `preflight_search_and_read`.
387
350
 
388
- ### `preflight_search_and_read` *(NEW v0.5.0)*
389
- Search + excerpt in one call - finds relevant code and returns context.
351
+ ### `preflight_search_and_read` *(Enhanced v0.6.0)*
352
+ Search + excerpt in one call - the **primary search tool**.
390
353
  - Combines search with automatic context extraction
354
+ - **`readContent: false`** (v0.6.0): Index-only search (replaces `preflight_search_bundle`)
391
355
  - **RFC v2 unified envelope**: Returns `ok`, `meta`, `data`, `evidence[]`
392
356
  - Triggers: "search and show code", "find and read", "搜索并读取"
393
357
 
394
358
  Parameters:
395
359
  - `bundleId`: Bundle ID
396
360
  - `query`: Search query
397
- - `contextLines`: Lines of context around matches (default: 5)
398
- - `maxFiles`: Max files to read (default: 5)
361
+ - `contextLines`: Lines of context around matches (default: 30)
362
+ - `readContent`: If false, return metadata only without reading files (default: true)
399
363
  - `format`: `"json"` (default) or `"text"`
400
364
 
401
- ### `preflight_get_dependency_graph` *(Simplified wrapper)*
402
- Simplified dependency graph query.
403
- - `scope: "global"` (default): Project-wide graph
404
- - `scope: "target"` with `targetFile`: Single file dependencies
405
- - `format: "summary"` (default): Aggregated view
406
- - `format: "full"`: Raw graph data
407
365
 
408
366
  ### `preflight_cleanup_orphans`
409
367
  Remove incomplete or corrupted bundles (bundles without valid manifest.json).
@@ -23,6 +23,31 @@ export function buildDeepAnalysis(bundleId, components) {
23
23
  }
24
24
  // Build summary text
25
25
  const summaryParts = [];
26
+ // ✅ Enhancement: Include OVERVIEW.md summary at the top for one-call context
27
+ if (overviewContent?.overview) {
28
+ summaryParts.push(`## Project Overview`);
29
+ // Extract first meaningful paragraph (skip title lines starting with #)
30
+ const lines = overviewContent.overview.split('\n');
31
+ const contentLines = [];
32
+ let charCount = 0;
33
+ const MAX_CHARS = 800; // Limit to ~200 tokens
34
+ for (const line of lines) {
35
+ if (charCount >= MAX_CHARS)
36
+ break;
37
+ // Skip empty lines at start and title lines
38
+ if (contentLines.length === 0 && (line.trim() === '' || line.startsWith('#')))
39
+ continue;
40
+ contentLines.push(line);
41
+ charCount += line.length + 1;
42
+ }
43
+ if (contentLines.length > 0) {
44
+ summaryParts.push(contentLines.join('\n'));
45
+ if (charCount >= MAX_CHARS) {
46
+ summaryParts.push('...(truncated, see OVERVIEW.md for full content)');
47
+ }
48
+ }
49
+ summaryParts.push('');
50
+ }
26
51
  if (focusPath || focusQuery) {
27
52
  summaryParts.push(`## Analysis Focus`);
28
53
  if (focusPath)
@@ -403,12 +428,23 @@ export function buildDeepAnalysis(bundleId, components) {
403
428
  nextCommands,
404
429
  };
405
430
  }
431
+ /**
432
+ * Test file naming patterns (language-agnostic)
433
+ */
434
+ const TEST_FILE_PATTERNS = [
435
+ /\.test\.(ts|tsx|js|jsx|mjs|cjs)$/i, // *.test.ts, *.test.js
436
+ /\.spec\.(ts|tsx|js|jsx|mjs|cjs)$/i, // *.spec.ts, *.spec.js
437
+ /_test\.(py|go)$/i, // *_test.py, *_test.go
438
+ /^test_.*\.py$/i, // test_*.py (pytest convention)
439
+ /_test\.rs$/i, // *_test.rs (Rust)
440
+ ];
406
441
  /**
407
442
  * Detect test setup from file tree statistics.
408
- * Scans for test directories, test files, and framework config files.
443
+ * Scans for test directories, test files by naming pattern, and framework config files.
409
444
  */
410
445
  export function detectTestInfo(stats, filesFound) {
411
446
  const testDirs = [];
447
+ const testFiles = [];
412
448
  let testFileCount = 0;
413
449
  const configFiles = [];
414
450
  let framework = null;
@@ -424,6 +460,17 @@ export function detectTestInfo(stats, filesFound) {
424
460
  }
425
461
  }
426
462
  }
463
+ // **CRITICAL FIX**: Detect test files by naming pattern (*.test.ts, *.spec.ts, etc.)
464
+ // This catches test files that live alongside source files (e.g., src/foo.test.ts)
465
+ if (filesFound) {
466
+ for (const file of filesFound) {
467
+ const isTestFile = TEST_FILE_PATTERNS.some(pattern => pattern.test(file.name));
468
+ if (isTestFile) {
469
+ testFiles.push(file.path);
470
+ testFileCount++;
471
+ }
472
+ }
473
+ }
427
474
  // Framework detection from config files (if filesFound provided)
428
475
  const frameworkConfigs = [
429
476
  { pattern: /^jest\.config\.(js|ts|mjs|cjs|json)$/i, framework: 'jest' },
@@ -446,39 +493,49 @@ export function detectTestInfo(stats, filesFound) {
446
493
  }
447
494
  }
448
495
  }
449
- // Infer framework from file extensions if not detected from config
450
- if (!framework && stats.byExtension) {
451
- // Check for test file patterns in extensions
496
+ // Infer framework from test file patterns if not detected from config
497
+ if (!framework && testFiles.length > 0) {
498
+ // Infer from file extensions
499
+ const hasTsTests = testFiles.some(f => /\.(ts|tsx)$/i.test(f));
500
+ const hasPyTests = testFiles.some(f => /\.py$/i.test(f));
501
+ const hasGoTests = testFiles.some(f => /\.go$/i.test(f));
502
+ if (hasGoTests)
503
+ framework = 'go';
504
+ else if (hasPyTests)
505
+ framework = 'pytest';
506
+ else if (hasTsTests)
507
+ framework = 'unknown'; // Could be jest/vitest/mocha
508
+ }
509
+ // Fallback: infer from general extension stats
510
+ if (!framework && testDirs.length > 0 && stats.byExtension) {
452
511
  const hasTs = (stats.byExtension['.ts'] ?? 0) > 0 || (stats.byExtension['.tsx'] ?? 0) > 0;
453
512
  const hasPy = (stats.byExtension['.py'] ?? 0) > 0;
454
513
  const hasGo = (stats.byExtension['.go'] ?? 0) > 0;
455
- if (testDirs.length > 0 || testFileCount > 0) {
456
- if (hasGo)
457
- framework = 'go';
458
- else if (hasPy)
459
- framework = 'pytest';
460
- else if (hasTs)
461
- framework = 'unknown'; // Could be jest/vitest/mocha
462
- }
463
- }
464
- // Count test files by pattern (approximate from extensions)
465
- // This is a heuristic - actual test files may vary
466
- if (testFileCount === 0 && stats.byExtension) {
467
- // If no test directories found, estimate based on common patterns
468
- // This is imprecise but gives a hint
514
+ if (hasGo)
515
+ framework = 'go';
516
+ else if (hasPy)
517
+ framework = 'pytest';
518
+ else if (hasTs)
519
+ framework = 'unknown';
469
520
  }
470
521
  const detected = testDirs.length > 0 || testFileCount > 0 || configFiles.length > 0;
471
522
  // Generate hint based on detection results
472
523
  let hint;
473
524
  if (detected) {
474
- if (testFileCount > 0) {
475
- hint = `Found ${testFileCount} test files. Run preflight_suggest_traces to map code↔test relationships.`;
525
+ if (testFiles.length > 0) {
526
+ // Show sample of detected test files
527
+ const sampleFiles = testFiles.slice(0, 3).map(f => f.split('/').pop()).join(', ');
528
+ const moreCount = testFiles.length > 3 ? ` (+${testFiles.length - 3} more)` : '';
529
+ hint = `Found ${testFileCount} test files (${sampleFiles}${moreCount}). Run preflight_suggest_traces to map code↔test relationships.`;
530
+ }
531
+ else if (testDirs.length > 0) {
532
+ hint = `Test directories found (${testDirs.join(', ')}). Run preflight_suggest_traces to map code↔test relationships.`;
476
533
  }
477
534
  else if (configFiles.length > 0) {
478
535
  hint = `Test config found (${configFiles[0]}). Run preflight_suggest_traces to discover test files.`;
479
536
  }
480
537
  else {
481
- hint = `Test directories found. Run preflight_suggest_traces to map code↔test relationships.`;
538
+ hint = `Tests detected. Run preflight_suggest_traces to map code↔test relationships.`;
482
539
  }
483
540
  }
484
541
  else {
@@ -488,6 +545,7 @@ export function detectTestInfo(stats, filesFound) {
488
545
  detected,
489
546
  framework,
490
547
  testDirs,
548
+ testFiles: testFiles.slice(0, 20), // Limit to 20 for output size
491
549
  testFileCount,
492
550
  configFiles,
493
551
  hint,
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
4
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
6
  import * as z from 'zod';
@@ -138,6 +139,17 @@ const GetTaskStatusInputSchema = {
138
139
  libraries: z.array(z.string()).optional().describe('Libraries for fingerprint computation.'),
139
140
  topics: z.array(z.string()).optional().describe('Topics for fingerprint computation.'),
140
141
  };
142
+ // Read version from package.json at startup
143
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
144
+ const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
145
+ let PACKAGE_VERSION = '0.0.0';
146
+ try {
147
+ const pkgJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
148
+ PACKAGE_VERSION = pkgJson.version ?? '0.0.0';
149
+ }
150
+ catch {
151
+ // Fallback if package.json cannot be read
152
+ }
141
153
  export async function startServer() {
142
154
  const cfg = getConfig();
143
155
  // Run orphan bundle cleanup on startup (non-blocking, best-effort)
@@ -148,7 +160,7 @@ export async function startServer() {
148
160
  startHttpServer(cfg);
149
161
  const server = new McpServer({
150
162
  name: 'preflight-mcp',
151
- version: '0.5.3',
163
+ version: PACKAGE_VERSION,
152
164
  description: 'Create evidence-based preflight bundles for repositories (docs + code) with SQLite FTS search.',
153
165
  }, {
154
166
  capabilities: {
@@ -329,22 +341,117 @@ export async function startServer() {
329
341
  }
330
342
  : { truncated: false, returnedCount: filtered.length, totalCount: allIds.length };
331
343
  const out = { bundles: filtered, truncation };
332
- // Stable human-readable format for UI logs.
333
- const lines = filtered.map((b) => {
334
- const repos = b.repos.length ? b.repos.join(', ') : '(none)';
335
- const tags = b.tags.length ? b.tags.join(', ') : '(none)';
336
- return `${b.bundleId} | ${b.displayName} | repos: ${repos} | tags: ${tags}`;
337
- });
338
- // Add pagination hint to text output
339
- let textOutput = lines.join('\n') || '(no bundles)';
344
+ // LLM-friendly Markdown format (easier to parse than pipe-separated)
345
+ const lines = [];
346
+ lines.push(`## Bundles (${filtered.length}${hasMore ? '+' : ''})`);
347
+ lines.push('');
348
+ for (const b of filtered) {
349
+ lines.push(`### ${b.displayName}`);
350
+ lines.push(`- **ID**: \`${b.bundleId}\``);
351
+ if (b.repos.length > 0) {
352
+ lines.push(`- **Repos**: ${b.repos.join(', ')}`);
353
+ }
354
+ if (b.tags.length > 0) {
355
+ lines.push(`- **Tags**: ${b.tags.join(', ')}`);
356
+ }
357
+ lines.push('');
358
+ }
359
+ // Add pagination hint
340
360
  if (hasMore) {
341
- textOutput += `\n\n📄 More bundles available. Use cursor to fetch next page.`;
361
+ lines.push('---');
362
+ lines.push(`📄 More bundles available (total: ${allIds.length}). Use cursor to fetch next page.`);
342
363
  }
364
+ const textOutput = filtered.length > 0 ? lines.join('\n') : '(no bundles found)';
343
365
  return {
344
366
  content: [{ type: 'text', text: textOutput }],
345
367
  structuredContent: out,
346
368
  };
347
369
  });
370
+ // ==================== NEW: preflight_get_overview ====================
371
+ // Simplified tool for getting project overview - the FIRST step when exploring a bundle
372
+ server.registerTool('preflight_get_overview', {
373
+ title: 'Get bundle overview',
374
+ description: '⭐ **START HERE** - Get project overview in one call. Returns OVERVIEW.md + START_HERE.md + AGENTS.md. ' +
375
+ 'This is the recommended FIRST tool to call when exploring any bundle. ' +
376
+ 'Use when: "了解项目", "项目概览", "what is this project", "show overview", "get started".\n\n' +
377
+ '**Returns:**\n' +
378
+ '- OVERVIEW.md: AI-generated project summary & architecture\n' +
379
+ '- START_HERE.md: Key entry points & critical paths\n' +
380
+ '- AGENTS.md: AI agent usage guide\n\n' +
381
+ '**Next steps after overview:**\n' +
382
+ '1. `preflight_repo_tree` - See file structure\n' +
383
+ '2. `preflight_search` - Find specific code\n' +
384
+ '3. `preflight_read_file` - Read specific files',
385
+ inputSchema: {
386
+ bundleId: z.string().describe('Bundle ID to get overview for.'),
387
+ },
388
+ outputSchema: {
389
+ bundleId: z.string(),
390
+ overview: z.string().nullable().describe('OVERVIEW.md content'),
391
+ startHere: z.string().nullable().describe('START_HERE.md content'),
392
+ agents: z.string().nullable().describe('AGENTS.md content'),
393
+ sections: z.array(z.string()).describe('List of available sections'),
394
+ },
395
+ annotations: {
396
+ readOnlyHint: true,
397
+ },
398
+ }, async (args) => {
399
+ try {
400
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
401
+ if (!storageDir) {
402
+ throw new BundleNotFoundError(args.bundleId);
403
+ }
404
+ const paths = getBundlePathsForId(storageDir, args.bundleId);
405
+ const bundleRoot = paths.rootDir;
406
+ const readFile = async (name) => {
407
+ try {
408
+ return await fs.readFile(safeJoin(bundleRoot, name), 'utf8');
409
+ }
410
+ catch {
411
+ return null;
412
+ }
413
+ };
414
+ const overview = await readFile('OVERVIEW.md');
415
+ const startHere = await readFile('START_HERE.md');
416
+ const agents = await readFile('AGENTS.md');
417
+ const sections = [];
418
+ if (overview)
419
+ sections.push('OVERVIEW.md');
420
+ if (startHere)
421
+ sections.push('START_HERE.md');
422
+ if (agents)
423
+ sections.push('AGENTS.md');
424
+ // Build human-readable text output
425
+ const textParts = [];
426
+ textParts.push(`[Bundle: ${args.bundleId}] Overview (${sections.length} sections)`);
427
+ textParts.push('');
428
+ if (overview) {
429
+ textParts.push('=== OVERVIEW.md ===');
430
+ textParts.push(overview);
431
+ textParts.push('');
432
+ }
433
+ if (startHere) {
434
+ textParts.push('=== START_HERE.md ===');
435
+ textParts.push(startHere);
436
+ textParts.push('');
437
+ }
438
+ if (agents) {
439
+ textParts.push('=== AGENTS.md ===');
440
+ textParts.push(agents);
441
+ }
442
+ if (sections.length === 0) {
443
+ textParts.push('⚠️ No overview files found. Try preflight_repo_tree to explore structure.');
444
+ }
445
+ const out = { bundleId: args.bundleId, overview, startHere, agents, sections };
446
+ return {
447
+ content: [{ type: 'text', text: textParts.join('\n') }],
448
+ structuredContent: out,
449
+ };
450
+ }
451
+ catch (err) {
452
+ throw wrapPreflightError(err);
453
+ }
454
+ });
348
455
  server.registerTool('preflight_read_file', {
349
456
  title: 'Read bundle file(s)',
350
457
  description: 'Read file(s) from bundle. Two modes: ' +
@@ -384,9 +491,16 @@ export async function startServer() {
384
491
  inputSchema: {
385
492
  bundleId: z.string().describe('Bundle ID to read.'),
386
493
  file: z.string().optional().describe('Specific file to read (e.g., "deps/dependency-graph.json"). If omitted, uses mode-based batch reading.'),
387
- mode: z.enum(['light', 'full']).optional().default('light').describe('Batch reading mode (used when file param is omitted). ' +
494
+ mode: z.enum(['light', 'full', 'core']).optional().default('light').describe('Batch reading mode (used when file param is omitted). ' +
388
495
  'light: OVERVIEW + START_HERE + AGENTS + manifest only (recommended, saves tokens). ' +
389
- 'full: includes README and deps graph too.'),
496
+ 'full: includes README and deps graph too. ' +
497
+ 'core: ⭐ NEW - reads core source files (top imported + entry points) with outline and content.'),
498
+ coreOptions: z.object({
499
+ maxFiles: z.number().int().min(1).max(10).default(5).describe('Max core files to read.'),
500
+ includeOutline: z.boolean().default(true).describe('Include symbol outline for each file.'),
501
+ includeContent: z.boolean().default(true).describe('Include full file content.'),
502
+ tokenBudget: z.number().int().optional().describe('Approximate token budget (chars/4). Files exceeding budget return outline only.'),
503
+ }).optional().describe('Options for mode="core". Controls which files and how much content to return.'),
390
504
  includeReadme: z.boolean().optional().default(false).describe('Include repo README files in batch mode (can be large).'),
391
505
  includeDepsGraph: z.boolean().optional().default(false).describe('Include deps/dependency-graph.json in batch mode.'),
392
506
  withLineNumbers: z.boolean().optional().default(false).describe('If true, prefix each line with line number in "N|" format for evidence citation.'),
@@ -426,6 +540,20 @@ export async function startServer() {
426
540
  children: z.array(z.any()).optional(),
427
541
  })).optional().describe('Symbol outline (when outline=true).'),
428
542
  language: z.string().optional().describe('Detected language (when outline=true).'),
543
+ // NEW: core mode output
544
+ coreFiles: z.array(z.object({
545
+ path: z.string(),
546
+ reason: z.string().describe('Why this file is considered core (e.g., "Most imported (5 dependents)")'),
547
+ outline: z.array(z.any()).optional().describe('Symbol outline'),
548
+ content: z.string().optional().describe('Full content (if within token budget)'),
549
+ language: z.string().optional(),
550
+ charCount: z.number().describe('Character count of file'),
551
+ })).optional().describe('Core files with outline and content (when mode="core").'),
552
+ coreStats: z.object({
553
+ totalFiles: z.number(),
554
+ totalChars: z.number(),
555
+ truncatedFiles: z.number().describe('Files where content was omitted due to token budget'),
556
+ }).optional().describe('Statistics for core mode.'),
429
557
  },
430
558
  annotations: {
431
559
  readOnlyHint: true,
@@ -624,6 +752,193 @@ export async function startServer() {
624
752
  }
625
753
  // Batch mode: read key files based on mode
626
754
  const mode = args.mode ?? 'light';
755
+ // ==================== MODE: CORE ====================
756
+ // Read core source files (top imported + entry points) with outline and content
757
+ if (mode === 'core') {
758
+ const coreOpts = (args.coreOptions ?? {});
759
+ const maxFiles = coreOpts.maxFiles ?? 5;
760
+ const includeOutline = coreOpts.includeOutline ?? true;
761
+ const includeContent = coreOpts.includeContent ?? true;
762
+ const tokenBudget = coreOpts.tokenBudget; // chars / 4 ≈ tokens
763
+ const charBudget = tokenBudget ? tokenBudget * 4 : undefined;
764
+ // Step 1: Generate dependency graph to find core files
765
+ let depResult;
766
+ try {
767
+ depResult = await generateDependencyGraph(cfg, {
768
+ bundleId: args.bundleId,
769
+ options: { timeBudgetMs: 10000, maxNodes: 200, maxEdges: 1000 },
770
+ });
771
+ }
772
+ catch {
773
+ depResult = null;
774
+ }
775
+ // Step 2: Identify core files (most imported + entry points)
776
+ const coreFileCandidates = [];
777
+ if (depResult?.facts?.edges) {
778
+ // Count how many times each file is imported
779
+ const importedByCounts = {};
780
+ for (const edge of depResult.facts.edges) {
781
+ if (edge.type === 'imports' || edge.type === 'imports_resolved') {
782
+ const to = typeof edge.to === 'string' ? edge.to.replace(/^(file:|module:)/, '') : '';
783
+ if (to && !to.startsWith('node_modules') && !to.includes('node:')) {
784
+ importedByCounts[to] = (importedByCounts[to] ?? 0) + 1;
785
+ }
786
+ }
787
+ }
788
+ // Sort by import count and add top files
789
+ const sortedByImports = Object.entries(importedByCounts)
790
+ .sort((a, b) => b[1] - a[1])
791
+ .slice(0, maxFiles * 2); // Get more candidates for filtering
792
+ for (const [filePath, count] of sortedByImports) {
793
+ coreFileCandidates.push({
794
+ path: filePath,
795
+ reason: `Most imported (${count} dependents)`,
796
+ score: count * 10,
797
+ });
798
+ }
799
+ }
800
+ // Add entry points (index.ts, main.ts, etc.)
801
+ const entryPointPatterns = [
802
+ { pattern: /\/(index|main)\.(ts|js|tsx|jsx)$/i, reason: 'Entry point', score: 50 },
803
+ { pattern: /\/app\.(ts|js|tsx|jsx)$/i, reason: 'App entry', score: 40 },
804
+ { pattern: /\/server\.(ts|js)$/i, reason: 'Server entry', score: 40 },
805
+ { pattern: /\/types\.(ts|d\.ts)$/i, reason: 'Type definitions', score: 30 },
806
+ ];
807
+ // Scan for entry points in repos directory
808
+ const scanEntryPoints = async (dir, relPath) => {
809
+ try {
810
+ const entries = await fs.readdir(dir, { withFileTypes: true });
811
+ for (const entry of entries) {
812
+ if (entry.name.startsWith('.') || ['node_modules', '__pycache__', 'dist', 'build'].includes(entry.name))
813
+ continue;
814
+ const fullPath = path.join(dir, entry.name);
815
+ const entryRelPath = relPath ? `${relPath}/${entry.name}` : entry.name;
816
+ if (entry.isFile()) {
817
+ for (const ep of entryPointPatterns) {
818
+ if (ep.pattern.test('/' + entryRelPath)) {
819
+ // Check if already in candidates
820
+ const existing = coreFileCandidates.find(c => c.path.endsWith(entry.name) || c.path === entryRelPath);
821
+ if (!existing) {
822
+ coreFileCandidates.push({ path: entryRelPath, reason: ep.reason, score: ep.score });
823
+ }
824
+ }
825
+ }
826
+ }
827
+ else if (entry.isDirectory() && relPath.split('/').length < 6) {
828
+ await scanEntryPoints(fullPath, entryRelPath);
829
+ }
830
+ }
831
+ }
832
+ catch { /* ignore */ }
833
+ };
834
+ await scanEntryPoints(paths.reposDir, 'repos');
835
+ // Sort by score and dedupe
836
+ coreFileCandidates.sort((a, b) => b.score - a.score);
837
+ const seenPaths = new Set();
838
+ const uniqueCandidates = coreFileCandidates.filter(c => {
839
+ const key = c.path.split('/').pop() ?? c.path;
840
+ if (seenPaths.has(key))
841
+ return false;
842
+ seenPaths.add(key);
843
+ return true;
844
+ }).slice(0, maxFiles);
845
+ // Step 3: Read each core file with outline and content
846
+ const coreFilesResult = [];
847
+ let totalChars = 0;
848
+ let truncatedFiles = 0;
849
+ for (const candidate of uniqueCandidates) {
850
+ // Resolve actual file path
851
+ let actualPath = candidate.path;
852
+ let absPath;
853
+ // Try different path resolutions
854
+ const pathsToTry = [
855
+ candidate.path,
856
+ `repos/${candidate.path}`,
857
+ candidate.path.startsWith('repos/') ? candidate.path : null,
858
+ ].filter(Boolean);
859
+ let fileContent = null;
860
+ for (const tryPath of pathsToTry) {
861
+ try {
862
+ absPath = safeJoin(bundleRoot, tryPath);
863
+ fileContent = await fs.readFile(absPath, 'utf8');
864
+ actualPath = tryPath;
865
+ break;
866
+ }
867
+ catch { /* try next */ }
868
+ }
869
+ if (!fileContent)
870
+ continue;
871
+ const charCount = fileContent.length;
872
+ const withinBudget = !charBudget || (totalChars + charCount <= charBudget);
873
+ const result = {
874
+ path: actualPath,
875
+ reason: candidate.reason,
876
+ charCount,
877
+ };
878
+ // Extract outline if requested
879
+ if (includeOutline) {
880
+ const outlineResult = await extractOutlineWasm(actualPath, fileContent);
881
+ if (outlineResult) {
882
+ result.outline = outlineResult.outline;
883
+ result.language = outlineResult.language;
884
+ }
885
+ }
886
+ // Include content if requested and within budget
887
+ if (includeContent && withinBudget) {
888
+ result.content = fileContent;
889
+ totalChars += charCount;
890
+ }
891
+ else if (includeContent && !withinBudget) {
892
+ truncatedFiles++;
893
+ }
894
+ coreFilesResult.push(result);
895
+ }
896
+ // Build text output
897
+ const textParts = [];
898
+ textParts.push(`[Mode: core] ${coreFilesResult.length} core files identified`);
899
+ textParts.push(`Total: ${totalChars} chars (~${Math.round(totalChars / 4)} tokens)`);
900
+ if (truncatedFiles > 0) {
901
+ textParts.push(`⚠️ ${truncatedFiles} file(s) exceeded token budget - showing outline only`);
902
+ }
903
+ textParts.push('');
904
+ for (const cf of coreFilesResult) {
905
+ textParts.push(`=== ${cf.path} (${cf.reason}) ===`);
906
+ // Show outline
907
+ if (cf.outline && cf.outline.length > 0) {
908
+ textParts.push(`[Outline - ${cf.outline.length} symbols]`);
909
+ for (const sym of cf.outline.slice(0, 10)) {
910
+ const exp = sym.exported ? '⚡' : '';
911
+ textParts.push(` ${exp}${sym.kind} ${sym.name}${sym.signature || ''} :${sym.range.startLine}-${sym.range.endLine}`);
912
+ }
913
+ if (cf.outline.length > 10) {
914
+ textParts.push(` ... and ${cf.outline.length - 10} more symbols`);
915
+ }
916
+ }
917
+ // Show content
918
+ if (cf.content) {
919
+ textParts.push(`[Content - ${cf.charCount} chars]`);
920
+ textParts.push('```' + (cf.language || ''));
921
+ textParts.push(cf.content);
922
+ textParts.push('```');
923
+ }
924
+ textParts.push('');
925
+ }
926
+ const out = {
927
+ bundleId: args.bundleId,
928
+ mode: 'core',
929
+ coreFiles: coreFilesResult,
930
+ coreStats: {
931
+ totalFiles: coreFilesResult.length,
932
+ totalChars,
933
+ truncatedFiles,
934
+ },
935
+ };
936
+ return {
937
+ content: [{ type: 'text', text: textParts.join('\n') }],
938
+ structuredContent: out,
939
+ };
940
+ }
941
+ // ==================== MODE: LIGHT / FULL ====================
627
942
  const includeReadme = args.includeReadme ?? (mode === 'full');
628
943
  const includeDepsGraph = args.includeDepsGraph ?? (mode === 'full');
629
944
  // Core files (always included in both modes)
@@ -690,6 +1005,7 @@ export async function startServer() {
690
1005
  textParts.push('💡 To include README: set includeReadme=true');
691
1006
  textParts.push('💡 To include dependency graph: set includeDepsGraph=true');
692
1007
  textParts.push('💡 For all content: set mode="full"');
1008
+ textParts.push('💡 ⭐ For core source code: set mode="core"');
693
1009
  }
694
1010
  const out = { bundleId: args.bundleId, mode, files, sections };
695
1011
  return {
@@ -1460,8 +1776,10 @@ export async function startServer() {
1460
1776
  // End RFC v2 tools
1461
1777
  // ==========================================================================
1462
1778
  server.registerTool('preflight_search_bundle', {
1463
- title: 'Search bundle',
1464
- description: 'Full-text search in bundle docs and code (strictly read-only). If you need to update or repair, call preflight_update_bundle or preflight_repair_bundle explicitly, then search again. Use when: "search in bundle", "find in repo", "look for X in bundle", "搜索bundle", "在仓库中查找", "搜代码", "搜文档".',
1779
+ title: 'Search bundle (DEPRECATED)',
1780
+ description: '⚠️ **DEPRECATED**: Use `preflight_search_and_read` instead. ' +
1781
+ 'For index-only search without reading content, use `preflight_search_and_read` with `readContent: false`.\n\n' +
1782
+ 'Full-text search in bundle docs and code (strictly read-only). If you need to update or repair, call preflight_update_bundle or preflight_repair_bundle explicitly, then search again.',
1465
1783
  inputSchema: SearchBundleInputSchema,
1466
1784
  outputSchema: {
1467
1785
  bundleId: z.string(),
@@ -1638,15 +1956,35 @@ export async function startServer() {
1638
1956
  : g.topSnippet,
1639
1957
  }));
1640
1958
  }
1959
+ // Auto-compress: extract common repo to top-level if all results from same repo
1960
+ let commonRepo;
1961
+ if (grouped && grouped.length > 0) {
1962
+ const repos = new Set(grouped.map(g => g.repo));
1963
+ if (repos.size === 1 && grouped[0]) {
1964
+ commonRepo = grouped[0].repo;
1965
+ }
1966
+ }
1967
+ // Build compressed grouped results (omit repo when extracted to top-level)
1968
+ const compressedGrouped = grouped?.map(g => {
1969
+ const { repo, ...rest } = g;
1970
+ return commonRepo ? rest : g;
1971
+ });
1641
1972
  const out = {
1642
1973
  bundleId: args.bundleId,
1643
1974
  query: args.query,
1644
1975
  scope: args.scope,
1645
- hits: paginatedHits.map(h => ({
1646
- ...h,
1647
- uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: h.path }),
1648
- })),
1649
- grouped,
1976
+ // Auto-compress: omit uri (can be derived from bundleId + path)
1977
+ hits: paginatedHits.map(h => {
1978
+ const { context, ...rest } = h;
1979
+ // Auto-compress: trim surroundingLines to save tokens
1980
+ const compressedContext = context ? {
1981
+ ...context,
1982
+ surroundingLines: context.surroundingLines?.slice(0, 5),
1983
+ } : undefined;
1984
+ return compressedContext ? { ...rest, context: compressedContext } : rest;
1985
+ }),
1986
+ grouped: compressedGrouped,
1987
+ ...(commonRepo && { repo: commonRepo }), // Extracted common repo
1650
1988
  meta: result.meta,
1651
1989
  };
1652
1990
  if (warnings.length > 0) {
@@ -1697,21 +2035,33 @@ export async function startServer() {
1697
2035
  const hasMore = rawHits.length > offset + pageSize;
1698
2036
  // Apply cursor pagination
1699
2037
  rawHits = rawHits.slice(offset, offset + pageSize);
2038
+ // Auto-compress: extract common repo if all hits from same repo
2039
+ let commonRepo;
2040
+ if (rawHits.length > 0 && rawHits[0]) {
2041
+ const repos = new Set(rawHits.map(h => h.repo).filter(Boolean));
2042
+ if (repos.size === 1) {
2043
+ commonRepo = rawHits[0].repo;
2044
+ }
2045
+ }
1700
2046
  const hits = rawHits.map((h) => {
1701
- const hit = {
1702
- ...h,
1703
- uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: h.path }),
1704
- };
2047
+ // Auto-compress: omit uri (derivable from bundleId + path), omit repo when extracted
2048
+ const { uri: _uri, repo, context, ...rest } = h;
2049
+ const hit = commonRepo && repo === commonRepo
2050
+ ? rest // Omit repo when extracted to top-level
2051
+ : { ...rest, ...(repo ? { repo } : {}) };
1705
2052
  // Apply maxSnippetLength truncation
1706
2053
  if (args.maxSnippetLength && h.snippet && h.snippet.length > args.maxSnippetLength) {
1707
2054
  hit.snippet = h.snippet.slice(0, args.maxSnippetLength) + '…';
1708
2055
  }
1709
- // Truncate surroundingLines if maxSnippetLength is set
1710
- if (args.maxSnippetLength && h.context?.surroundingLines) {
1711
- const maxLines = Math.max(3, Math.floor(args.maxSnippetLength / 50));
2056
+ // Auto-compress: limit surroundingLines to save tokens (default 5, or based on maxSnippetLength)
2057
+ if (context?.surroundingLines) {
2058
+ const ctx = context;
2059
+ const maxLines = args.maxSnippetLength
2060
+ ? Math.max(3, Math.floor(args.maxSnippetLength / 50))
2061
+ : 5; // Default auto-compress to 5 lines
1712
2062
  hit.context = {
1713
- ...h.context,
1714
- surroundingLines: h.context.surroundingLines.slice(0, maxLines),
2063
+ ...ctx,
2064
+ surroundingLines: ctx.surroundingLines.slice(0, maxLines),
1715
2065
  };
1716
2066
  }
1717
2067
  return hit;
@@ -1721,6 +2071,7 @@ export async function startServer() {
1721
2071
  query: args.query,
1722
2072
  scope: args.scope,
1723
2073
  hits,
2074
+ ...(commonRepo && { repo: commonRepo }), // Auto-compress: extracted common repo
1724
2075
  };
1725
2076
  if (warnings.length > 0) {
1726
2077
  out.warnings = warnings;
@@ -1751,27 +2102,12 @@ export async function startServer() {
1751
2102
  }
1752
2103
  });
1753
2104
  server.registerTool('preflight_evidence_dependency_graph', {
1754
- title: 'Evidence: dependency graph',
1755
- description: '**Proactive use recommended**: Generate dependency graphs to understand code structure. ' +
1756
- 'Generate an evidence-based dependency graph. IMPORTANT: Before running, ASK the user which bundle and which file/mode they want! ' +
2105
+ title: 'Evidence: dependency graph (DEPRECATED)',
2106
+ description: '⚠️ **DEPRECATED**: Use `preflight_dependency_graph` instead. ' +
2107
+ 'This tool provides the same functionality with a simpler interface.\n\n' +
2108
+ 'Generate an evidence-based dependency graph. ' +
1757
2109
  'Two modes: (1) TARGET MODE: analyze a specific file (provide target.file). (2) GLOBAL MODE: project-wide graph (omit target). ' +
1758
- 'Do NOT automatically choose bundle or mode - confirm with user first! ' +
1759
- 'File path must be bundle-relative: repos/{owner}/{repo}/norm/{path}.\n\n' +
1760
- '📊 **Coverage Report (Global Mode):**\n' +
1761
- 'The response includes a `coverageReport` explaining what was analyzed:\n' +
1762
- '- `scannedFilesCount` / `parsedFilesCount`: Files discovered vs successfully parsed\n' +
1763
- '- `perLanguage`: Statistics per programming language (TypeScript, Python, etc.)\n' +
1764
- '- `perDir`: File counts per top-level directory\n' +
1765
- '- `skippedFiles`: Files that were skipped with reasons (too large, read error, etc.)\n' +
1766
- '- `truncated` / `truncatedReason`: Whether limits were hit\n\n' +
1767
- 'Use this to understand graph completeness and identify gaps.\n\n' +
1768
- '📂 **Large File Handling (LLM Guidance):**\n' +
1769
- '- Default: files >1MB are skipped to avoid timeouts\n' +
1770
- '- If coverageReport.skippedFiles shows important files were skipped:\n' +
1771
- ' 1. Try `largeFileStrategy: "truncate"` to read first 500 lines\n' +
1772
- ' 2. Or increase `maxFileSizeBytes` (e.g., 5000000 for 5MB)\n' +
1773
- '- Options: `{ maxFileSizeBytes: 5000000, largeFileStrategy: "truncate", truncateLines: 1000 }`\n' +
1774
- '- User can override these settings if needed',
2110
+ 'File path must be bundle-relative: repos/{owner}/{repo}/norm/{path}.',
1775
2111
  inputSchema: DependencyGraphInputSchema,
1776
2112
  outputSchema: {
1777
2113
  meta: z.any(),
@@ -1842,18 +2178,19 @@ export async function startServer() {
1842
2178
  throw wrapPreflightError(err);
1843
2179
  }
1844
2180
  });
1845
- // Simplified dependency graph tool for single-point tasks
1846
- server.registerTool('preflight_get_dependency_graph', {
1847
- title: 'Get dependency graph (simplified)',
1848
- description: 'Get dependency graph with minimal parameters. ' +
1849
- 'Use when user asks: "show dependencies", "看依赖图", "import graph", "what does X depend on". ' +
1850
- 'This is a simplified wrapper around preflight_evidence_dependency_graph.\n\n' +
2181
+ // ==================== MAIN: preflight_dependency_graph ====================
2182
+ // Unified tool for dependency graphs (replaces both evidence_dependency_graph and get_dependency_graph)
2183
+ server.registerTool('preflight_dependency_graph', {
2184
+ title: 'Dependency graph',
2185
+ description: 'Get or generate dependency graph for a bundle. ' +
2186
+ 'Auto-generates if not cached, returns cached version if available. ' +
2187
+ 'Use when: "show dependencies", "看依赖图", "import graph", "what does X depend on".\n\n' +
1851
2188
  '**Modes:**\n' +
1852
2189
  '- `scope: "global"` (default): Project-wide dependency graph\n' +
1853
2190
  '- `scope: "target"` with `targetFile`: Dependencies for a specific file\n\n' +
1854
2191
  '**Format:**\n' +
1855
2192
  '- `format: "summary"` (default): Top nodes, aggregated by directory, key edges only\n' +
1856
- '- `format: "full"`: Complete graph data',
2193
+ '- `format: "full"`: Complete graph data with coverage report',
1857
2194
  inputSchema: {
1858
2195
  bundleId: z.string().describe('Bundle ID. Use preflight_list_bundles to find available bundles.'),
1859
2196
  scope: z.enum(['global', 'target']).optional().default('global').describe('global=project-wide, target=single file.'),
@@ -2140,10 +2477,10 @@ export async function startServer() {
2140
2477
  }
2141
2478
  });
2142
2479
  server.registerTool('preflight_trace_export', {
2143
- title: 'Trace: export to JSON',
2144
- description: 'Export trace links to trace/trace.json for direct LLM reading. ' +
2145
- 'Note: trace.json is auto-exported after each trace_upsert, so this tool is only needed to manually refresh or verify the export. ' +
2146
- 'Use when: "export trace", "refresh trace.json", "导出trace", "刷新trace.json".',
2480
+ title: 'Trace: export to JSON (DEPRECATED)',
2481
+ description: '⚠️ **DEPRECATED**: trace.json is auto-exported after each trace_upsert, so this tool is rarely needed. ' +
2482
+ 'Use `preflight_read_file` with `file: "trace/trace.json"` to read exported traces directly.\n\n' +
2483
+ 'Export trace links to trace/trace.json. Only needed to manually refresh or verify the export.',
2147
2484
  inputSchema: {
2148
2485
  bundleId: z.string().describe('Bundle ID to export trace links from.'),
2149
2486
  },
@@ -2250,6 +2587,7 @@ export async function startServer() {
2250
2587
  detected: z.boolean(),
2251
2588
  framework: z.enum(['jest', 'vitest', 'pytest', 'go', 'mocha', 'unknown']).nullable(),
2252
2589
  testDirs: z.array(z.string()),
2590
+ testFiles: z.array(z.string()).describe('Test files detected by naming pattern (*.test.ts, *.spec.ts, etc.)'),
2253
2591
  testFileCount: z.number(),
2254
2592
  configFiles: z.array(z.string()),
2255
2593
  hint: z.string(),
@@ -2488,8 +2826,31 @@ export async function startServer() {
2488
2826
  }
2489
2827
  // 7. Test detection
2490
2828
  if ((opts.includeTests ?? true) && tree) {
2491
- // Collect files for framework detection
2829
+ // Collect files for test detection (config files + source files)
2492
2830
  const filesFound = [];
2831
+ // Helper to recursively scan for files
2832
+ const scanDir = async (dir, relPath, maxDepth) => {
2833
+ if (maxDepth <= 0)
2834
+ return;
2835
+ try {
2836
+ const entries = await fs.readdir(dir, { withFileTypes: true });
2837
+ for (const entry of entries) {
2838
+ if (entry.name.startsWith('.') || ['node_modules', '__pycache__', 'venv', '.venv', 'dist', 'build'].includes(entry.name))
2839
+ continue;
2840
+ const fullPath = safeJoin(dir, entry.name);
2841
+ const entryRelPath = relPath ? `${relPath}/${entry.name}` : entry.name;
2842
+ if (entry.isFile()) {
2843
+ filesFound.push({ path: entryRelPath, name: entry.name });
2844
+ }
2845
+ else if (entry.isDirectory()) {
2846
+ await scanDir(fullPath, entryRelPath, maxDepth - 1);
2847
+ }
2848
+ }
2849
+ }
2850
+ catch {
2851
+ // Ignore directory access errors
2852
+ }
2853
+ };
2493
2854
  try {
2494
2855
  // Scan for config files at bundle root
2495
2856
  const configPatterns = [
@@ -2508,9 +2869,12 @@ export async function startServer() {
2508
2869
  // Config file doesn't exist
2509
2870
  }
2510
2871
  }
2872
+ // **CRITICAL**: Scan repos/ directory to find test files (*.test.ts, *.spec.ts, etc.)
2873
+ // This is essential for detecting tests that live alongside source files
2874
+ await scanDir(paths.reposDir, 'repos', 8); // Scan up to 8 levels deep
2511
2875
  }
2512
2876
  catch {
2513
- // Ignore errors during config scanning
2877
+ // Ignore errors during scanning
2514
2878
  }
2515
2879
  testInfo = detectTestInfo({ byExtension: tree.byExtension, byTopDir: tree.topDirs.reduce((acc, d) => ({ ...acc, [d.path]: d.fileCount }), {}) }, filesFound.length > 0 ? filesFound : undefined);
2516
2880
  }
@@ -2635,14 +2999,11 @@ export async function startServer() {
2635
2999
  }
2636
3000
  });
2637
3001
  server.registerTool('preflight_suggest_traces', {
2638
- title: 'Trace: suggest links',
2639
- description: 'Automatically suggest trace links based on file naming patterns. ' +
2640
- 'MVP: Only supports tested_by edge type (code↔test relationships). ' +
2641
- 'Use to bulk-discover test coverage relationships before reviewing/upserting.\n\n' +
2642
- '**Workflow:**\n' +
2643
- '1. Call with dryRun-style output to preview suggestions\n' +
2644
- '2. Review suggestions (LLM or human)\n' +
2645
- '3. Use trace_upsert with returned upsertPayload to persist approved links',
3002
+ title: 'Trace: suggest links (DEPRECATED)',
3003
+ description: '⚠️ **DEPRECATED**: This tool has limited value. Use `preflight_deep_analyze_bundle` for test detection, ' +
3004
+ 'then manually create trace links with `preflight_trace_upsert` if needed.\n\n' +
3005
+ 'Automatically suggest trace links based on file naming patterns. ' +
3006
+ 'MVP: Only supports tested_by edge type (code↔test relationships).',
2646
3007
  inputSchema: {
2647
3008
  bundleId: z.string().describe('Bundle ID to scan for trace suggestions.'),
2648
3009
  edge_type: z.enum(['tested_by']).default('tested_by').describe('Type of edge to suggest. MVP only supports tested_by.'),
@@ -208,17 +208,9 @@ export function createReadFilesHandler(deps) {
208
208
  * Tool description for MCP registration.
209
209
  */
210
210
  export const readFilesToolDescription = {
211
- title: 'Read multiple files',
212
- description: 'Read multiple files from a bundle in a single call. ' +
213
- 'Reduces round-trips for evidence gathering. ' +
214
- 'Each file can specify optional line ranges and line number formatting.\n\n' +
215
- '**LLM Usage Guide:**\n' +
216
- '- Use when you know which files to read (from search results, tree, or overview)\n' +
217
- '- Specify ranges to read only relevant portions (saves tokens)\n' +
218
- '- Default withLineNumbers=true provides citation-ready format\n' +
219
- '- Max 20 files per call, 1MB total response limit\n\n' +
220
- '**Evidence Citation:**\n' +
221
- '- Response includes evidence[] with path + range for each file read\n' +
222
- '- Use format "path:startLine-endLine" in your citations\n\n' +
223
- 'Triggers: "read these files", "get content of", "批量读取", "读取多个文件"',
211
+ title: 'Read multiple files (DEPRECATED)',
212
+ description: '⚠️ **DEPRECATED**: Use multiple `preflight_read_file` calls, or use `preflight_search_and_read` ' +
213
+ 'which combines search and reading in one call.\n\n' +
214
+ 'Read multiple files from a bundle in a single call. ' +
215
+ 'Each file can specify optional line ranges and line number formatting.',
224
216
  };
@@ -65,6 +65,13 @@ export const SearchAndReadInputSchema = {
65
65
  .enum(['json', 'text'])
66
66
  .default('json')
67
67
  .describe('Response format. json=unified envelope (default).'),
68
+ // NEW: readContent parameter for search-only mode (replaces search_bundle)
69
+ readContent: z
70
+ .boolean()
71
+ .default(true)
72
+ .describe('If true (default), read file excerpts for each hit. ' +
73
+ 'If false, return search metadata only (path, lineNo, score) without reading file content. ' +
74
+ 'Use false for quick index-only searches (like groupByFile in search_bundle).'),
68
75
  };
69
76
  /**
70
77
  * Compute SHA256 hash of content.
@@ -189,15 +196,16 @@ export function createSearchAndReadHandler(deps) {
189
196
  });
190
197
  // Apply pagination offset
191
198
  rawHits = rawHits.slice(offset);
192
- // Build result hits with excerpts
199
+ // Build result hits with excerpts (or metadata-only if readContent=false)
193
200
  const hits = [];
194
201
  let totalBytes = 0;
195
202
  const estimatedTokensPerByte = 0.25; // Rough estimate
203
+ const shouldReadContent = args.readContent ?? true;
196
204
  for (const rawHit of rawHits) {
197
205
  if (hits.length >= limit)
198
206
  break;
199
- // Check token budget
200
- if (tokenBudget && totalBytes * estimatedTokensPerByte > tokenBudget) {
207
+ // Check token budget (only relevant when reading content)
208
+ if (shouldReadContent && tokenBudget && totalBytes * estimatedTokensPerByte > tokenBudget) {
201
209
  addWarning(ctx, WarningCodes.RESULT_TRUNCATED, 'Token budget exceeded', true);
202
210
  setTruncation(ctx, true, {
203
211
  reason: 'Token budget exceeded',
@@ -205,6 +213,21 @@ export function createSearchAndReadHandler(deps) {
205
213
  });
206
214
  break;
207
215
  }
216
+ // readContent=false: return metadata only (index-only search)
217
+ if (!shouldReadContent) {
218
+ const hit = {
219
+ path: rawHit.path,
220
+ repo: rawHit.repo,
221
+ kind: rawHit.kind,
222
+ matchRange: { startLine: rawHit.lineNo, endLine: rawHit.lineNo },
223
+ excerptRange: { startLine: rawHit.lineNo, endLine: rawHit.lineNo },
224
+ excerpt: rawHit.snippet || '', // Use indexed snippet if available
225
+ score: rawHit.score,
226
+ };
227
+ hits.push(hit);
228
+ continue;
229
+ }
230
+ // readContent=true: read full excerpt with context
208
231
  const excerptResult = await readExcerpt(paths.rootDir, rawHit, contextLines, withLineNumbers, maxBytesPerHit);
209
232
  if (!excerptResult)
210
233
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "preflight-mcp",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server that creates evidence-based preflight bundles for GitHub repositories and library docs.",
5
5
  "type": "module",
6
6
  "license": "MIT",