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 +36 -78
- package/dist/analysis/deep.js +79 -21
- package/dist/server.js +430 -69
- package/dist/tools/readFiles.js +5 -13
- package/dist/tools/searchAndRead.js +26 -3
- package/package.json +1 -1
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
|
-
- ⚡ **
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
### `
|
|
282
|
-
|
|
283
|
-
- **
|
|
284
|
-
-
|
|
285
|
-
-
|
|
286
|
-
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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.
|
|
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` *(
|
|
378
|
-
|
|
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` *(
|
|
389
|
-
Search + excerpt in one call -
|
|
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:
|
|
398
|
-
- `
|
|
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).
|
package/dist/analysis/deep.js
CHANGED
|
@@ -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
|
|
450
|
-
if (!framework &&
|
|
451
|
-
//
|
|
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 (
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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 (
|
|
475
|
-
|
|
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 = `
|
|
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:
|
|
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
|
-
//
|
|
333
|
-
const lines =
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
-
//
|
|
1710
|
-
if (
|
|
1711
|
-
const
|
|
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
|
-
...
|
|
1714
|
-
surroundingLines:
|
|
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: '**
|
|
1756
|
-
'
|
|
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
|
-
'
|
|
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
|
-
//
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
'
|
|
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: '
|
|
2145
|
-
'
|
|
2146
|
-
'
|
|
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
|
|
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
|
|
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: '
|
|
2640
|
-
'
|
|
2641
|
-
'
|
|
2642
|
-
'
|
|
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.'),
|
package/dist/tools/readFiles.js
CHANGED
|
@@ -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: '
|
|
213
|
-
'
|
|
214
|
-
'
|
|
215
|
-
'
|
|
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;
|