preflight-mcp 0.1.2 → 0.1.3

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/dist/server.js CHANGED
@@ -3,13 +3,17 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import * as z from 'zod';
5
5
  import { getConfig } from './config.js';
6
- import { bundleExists, checkForUpdates, clearBundleMulti, createBundle, findBundleByInputs, computeCreateInputFingerprint, findBundleStorageDir, getBundlePathsForId, getEffectiveStorageDir, listBundles, repairBundle, updateBundle, } from './bundle/service.js';
6
+ import { checkForUpdates, clearBundleMulti, createBundle, findBundleStorageDir, getBundlePathsForId, getEffectiveStorageDir, listBundles, repairBundle, updateBundle, } from './bundle/service.js';
7
7
  import { readManifest } from './bundle/manifest.js';
8
8
  import { safeJoin, toBundleFileUri } from './mcp/uris.js';
9
9
  import { wrapPreflightError } from './mcp/errorKinds.js';
10
- import { searchIndex, verifyClaimInIndex } from './search/sqliteFts.js';
10
+ import { searchIndex } from './search/sqliteFts.js';
11
+ import { logger } from './logging/logger.js';
11
12
  import { runSearchByTags } from './tools/searchByTags.js';
12
13
  import { cleanupOnStartup, cleanupOrphanBundles } from './bundle/cleanup.js';
14
+ import { startHttpServer } from './http/server.js';
15
+ import { DependencyGraphInputSchema, generateDependencyGraph } from './evidence/dependencyGraph.js';
16
+ import { TraceQueryInputSchema, TraceUpsertInputSchema, traceQuery, traceUpsert } from './trace/service.js';
13
17
  const CreateRepoInputSchema = z.union([
14
18
  z.object({
15
19
  kind: z.literal('github'),
@@ -24,10 +28,6 @@ const CreateRepoInputSchema = z.union([
24
28
  path: z.string().describe('Local directory path containing the repository files.'),
25
29
  ref: z.string().optional().describe('Optional label/ref for the local snapshot.'),
26
30
  }),
27
- z.object({
28
- kind: z.literal('deepwiki'),
29
- url: z.string().url().describe('DeepWiki URL (https://deepwiki.com/owner/repo).'),
30
- }),
31
31
  ]);
32
32
  const CreateBundleInputSchema = {
33
33
  repos: z.array(CreateRepoInputSchema).min(1).describe('Repositories to ingest into the bundle.'),
@@ -43,12 +43,6 @@ const UpdateBundleInputSchema = {
43
43
  checkOnly: z.boolean().optional().describe('If true, only check if updates are available without applying them.'),
44
44
  force: z.boolean().optional().describe('If true, force rebuild index even if no changes detected.'),
45
45
  };
46
- const UpdateAllBundlesInputSchema = {
47
- bundleIds: z
48
- .array(z.string())
49
- .optional()
50
- .describe('Optional bundle IDs to update. If omitted, updates all bundles in storage.'),
51
- };
52
46
  const SearchBundleInputSchema = {
53
47
  bundleId: z.string().describe('Bundle ID to search.'),
54
48
  query: z.string().describe('Search query. Prefix with fts: to use raw FTS syntax.'),
@@ -74,25 +68,6 @@ const SearchByTagsInputSchema = {
74
68
  scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
75
69
  limit: z.number().int().min(1).max(200).default(50).describe('Max total hits across all bundles.'),
76
70
  };
77
- const VerifyClaimInputSchema = {
78
- bundleId: z.string().describe('Bundle ID to verify against.'),
79
- claim: z.string().describe('A claim to look for evidence for (best-effort).'),
80
- scope: z.enum(['docs', 'code', 'all']).default('all').describe('Search scope.'),
81
- limit: z.number().int().min(1).max(50).default(8).describe('Max number of evidence hits.'),
82
- // Deprecated (kept for backward compatibility): this tool is strictly read-only.
83
- ensureFresh: z
84
- .boolean()
85
- .optional()
86
- .describe('DEPRECATED. This tool is strictly read-only and will not auto-update. Use preflight_update_bundle, then call verify again.'),
87
- maxAgeHours: z
88
- .number()
89
- .optional()
90
- .describe('DEPRECATED. Only used with ensureFresh (which is deprecated).'),
91
- autoRepairIndex: z
92
- .boolean()
93
- .optional()
94
- .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair. Use preflight_repair_bundle, then call verify again.'),
95
- };
96
71
  const ListBundlesInputSchema = {
97
72
  // keep open for future filters
98
73
  };
@@ -106,9 +81,6 @@ const RepairBundleInputSchema = {
106
81
  rebuildGuides: z.boolean().optional().describe('If true, rebuild START_HERE.md and AGENTS.md when missing/empty.'),
107
82
  rebuildOverview: z.boolean().optional().describe('If true, rebuild OVERVIEW.md when missing/empty.'),
108
83
  };
109
- const BundleInfoInputSchema = {
110
- bundleId: z.string().describe('Bundle ID to get info for.'),
111
- };
112
84
  const ReadFileInputSchema = {
113
85
  bundleId: z.string().describe('Bundle ID.'),
114
86
  file: z.string().default('OVERVIEW.md').describe('File path relative to bundle root. Common files: OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json, or any repo file like repos/owner/repo/norm/README.md'),
@@ -116,12 +88,14 @@ const ReadFileInputSchema = {
116
88
  export async function startServer() {
117
89
  const cfg = getConfig();
118
90
  // Run orphan bundle cleanup on startup (non-blocking, best-effort)
119
- cleanupOnStartup(cfg).catch(() => {
120
- // Errors already logged, don't block server startup
91
+ cleanupOnStartup(cfg).catch((err) => {
92
+ logger.debug('Startup cleanup failed (non-critical)', err instanceof Error ? err : undefined);
121
93
  });
94
+ // Start built-in REST API (best-effort). This must not interfere with MCP stdio transport.
95
+ startHttpServer(cfg);
122
96
  const server = new McpServer({
123
97
  name: 'preflight-mcp',
124
- version: '0.1.2',
98
+ version: '0.1.3',
125
99
  description: 'Create evidence-based preflight bundles for repositories (docs + code) with SQLite FTS search.',
126
100
  }, {
127
101
  capabilities: {
@@ -281,7 +255,7 @@ export async function startServer() {
281
255
  });
282
256
  server.registerTool('preflight_read_file', {
283
257
  title: 'Read bundle file',
284
- description: 'Read a file from bundle. Use when: "show overview", "read file", "查看概览", "项目概览", "看README", "查看文档". Common files: OVERVIEW.md, START_HERE.md, AGENTS.md.',
258
+ description: 'Read a file from bundle. Use when: "show overview", "read file", "查看概览", "项目概览", "看README", "查看文档", "bundle详情", "bundle状态", "仓库信息". Common files: OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json (for bundle metadata/status).',
285
259
  inputSchema: ReadFileInputSchema,
286
260
  outputSchema: {
287
261
  bundleId: z.string(),
@@ -342,131 +316,9 @@ export async function startServer() {
342
316
  throw wrapPreflightError(err);
343
317
  }
344
318
  });
345
- server.registerTool('preflight_bundle_info', {
346
- title: 'Bundle info',
347
- description: 'Get bundle details: repos, update time, stats. Use when: "bundle info", "show bundle details", "what\'s in this bundle", "bundle状态", "查看bundle详情", "仓库信息".',
348
- inputSchema: BundleInfoInputSchema,
349
- outputSchema: {
350
- bundleId: z.string(),
351
- createdAt: z.string(),
352
- updatedAt: z.string(),
353
- repos: z.array(z.object({
354
- kind: z.enum(['github', 'local', 'deepwiki']),
355
- id: z.string(),
356
- source: z.enum(['git', 'archive', 'local', 'deepwiki']).optional(),
357
- headSha: z.string().optional(),
358
- fetchedAt: z.string().optional(),
359
- notes: z.array(z.string()).optional(),
360
- })),
361
- libraries: z
362
- .array(z.object({
363
- kind: z.literal('context7'),
364
- input: z.string(),
365
- id: z.string().optional(),
366
- fetchedAt: z.string(),
367
- notes: z.array(z.string()).optional(),
368
- files: z.array(z.string()).optional(),
369
- }))
370
- .optional(),
371
- index: z.object({
372
- backend: z.string(),
373
- includeDocs: z.boolean(),
374
- includeCode: z.boolean(),
375
- }),
376
- resources: z.object({
377
- startHere: z.string(),
378
- agents: z.string(),
379
- overview: z.string(),
380
- manifest: z.string(),
381
- }),
382
- },
383
- annotations: {
384
- readOnlyHint: true,
385
- },
386
- }, async (args) => {
387
- try {
388
- const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
389
- if (!storageDir) {
390
- throw new Error(`Bundle not found: ${args.bundleId}`);
391
- }
392
- const paths = getBundlePathsForId(storageDir, args.bundleId);
393
- const manifest = await readManifest(paths.manifestPath);
394
- const resources = {
395
- startHere: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'START_HERE.md' }),
396
- agents: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'AGENTS.md' }),
397
- overview: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'OVERVIEW.md' }),
398
- manifest: toBundleFileUri({ bundleId: args.bundleId, relativePath: 'manifest.json' }),
399
- };
400
- const out = {
401
- bundleId: manifest.bundleId,
402
- createdAt: manifest.createdAt,
403
- updatedAt: manifest.updatedAt,
404
- repos: manifest.repos,
405
- libraries: manifest.libraries,
406
- index: manifest.index,
407
- resources,
408
- };
409
- return {
410
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
411
- structuredContent: out,
412
- };
413
- }
414
- catch (err) {
415
- throw wrapPreflightError(err);
416
- }
417
- });
418
- server.registerTool('preflight_find_bundle', {
419
- title: 'Find existing bundle',
420
- description: 'Check whether a bundle already exists for the given inputs (no fetching, no changes). Use when: "does this repo already exist", "have I indexed this", "find bundle for", "这个项目是否已索引".',
421
- inputSchema: {
422
- repos: z.array(CreateRepoInputSchema).min(1),
423
- libraries: z.array(z.string()).optional(),
424
- topics: z.array(z.string()).optional(),
425
- },
426
- outputSchema: {
427
- found: z.boolean(),
428
- bundleId: z.string().optional(),
429
- fingerprint: z.string(),
430
- },
431
- annotations: {
432
- readOnlyHint: true,
433
- },
434
- }, async (args) => {
435
- try {
436
- const fingerprint = computeCreateInputFingerprint({
437
- repos: args.repos,
438
- libraries: args.libraries,
439
- topics: args.topics,
440
- });
441
- const bundleId = await findBundleByInputs(cfg, {
442
- repos: args.repos,
443
- libraries: args.libraries,
444
- topics: args.topics,
445
- });
446
- const out = {
447
- found: !!bundleId,
448
- bundleId: bundleId ?? undefined,
449
- fingerprint,
450
- };
451
- return {
452
- content: [
453
- {
454
- type: 'text',
455
- text: out.found
456
- ? `FOUND ${out.bundleId} (fingerprint=${out.fingerprint})`
457
- : `NOT_FOUND (fingerprint=${out.fingerprint})`,
458
- },
459
- ],
460
- structuredContent: out,
461
- };
462
- }
463
- catch (err) {
464
- throw wrapPreflightError(err);
465
- }
466
- });
467
319
  server.registerTool('preflight_create_bundle', {
468
320
  title: 'Create bundle',
469
- description: 'Create a new bundle from GitHub repos or DeepWiki (or update an existing one if ifExists=updateExisting). Use when: "index this repo", "create bundle for", "add repo to preflight", "索引这个仓库", "创建bundle", "添加GitHub项目", "学习这个项目".',
321
+ description: 'Create a new bundle from GitHub repos or local directories (or update an existing one if ifExists=updateExisting). Use when: "index this repo", "create bundle for", "add repo to preflight", "索引这个仓库", "创建bundle", "添加GitHub项目", "学习这个项目". NOTE: If the bundle contains code files, consider asking user if they want to generate dependency graph (preflight_evidence_dependency_graph) or establish trace links (preflight_trace_upsert).',
470
322
  inputSchema: CreateBundleInputSchema,
471
323
  outputSchema: {
472
324
  bundleId: z.string(),
@@ -479,9 +331,9 @@ export async function startServer() {
479
331
  manifest: z.string(),
480
332
  }),
481
333
  repos: z.array(z.object({
482
- kind: z.enum(['github', 'local', 'deepwiki']),
334
+ kind: z.enum(['github', 'local']),
483
335
  id: z.string(),
484
- source: z.enum(['git', 'archive', 'local', 'deepwiki']).optional(),
336
+ source: z.enum(['git', 'archive', 'local']).optional(),
485
337
  headSha: z.string().optional(),
486
338
  notes: z.array(z.string()).optional(),
487
339
  })),
@@ -655,52 +507,6 @@ export async function startServer() {
655
507
  throw wrapPreflightError(err);
656
508
  }
657
509
  });
658
- server.registerTool('preflight_update_all_bundles', {
659
- title: 'Update all bundles',
660
- description: 'Batch update all bundles at once. Use when: "update all bundles", "refresh everything", "sync all", "批量更新", "全部刷新", "更新所有bundle".',
661
- inputSchema: UpdateAllBundlesInputSchema,
662
- outputSchema: {
663
- total: z.number().int(),
664
- ok: z.number().int(),
665
- results: z.array(z.object({
666
- bundleId: z.string(),
667
- changed: z.boolean().optional(),
668
- updatedAt: z.string().optional(),
669
- error: z.string().optional(),
670
- })),
671
- },
672
- annotations: {
673
- openWorldHint: true,
674
- },
675
- }, async (args) => {
676
- const effectiveDir = await getEffectiveStorageDir(cfg);
677
- const ids = args.bundleIds && args.bundleIds.length > 0
678
- ? args.bundleIds
679
- : await listBundles(effectiveDir);
680
- const results = [];
681
- for (const bundleId of ids) {
682
- try {
683
- const exists = await bundleExists(effectiveDir, bundleId);
684
- if (!exists) {
685
- throw new Error(`Bundle not found: ${bundleId}`);
686
- }
687
- const { summary, changed } = await updateBundle(cfg, bundleId);
688
- results.push({ bundleId, changed, updatedAt: summary.updatedAt });
689
- }
690
- catch (err) {
691
- results.push({ bundleId, error: wrapPreflightError(err).message });
692
- }
693
- }
694
- const out = {
695
- total: ids.length,
696
- ok: results.filter((r) => !r.error).length,
697
- results,
698
- };
699
- return {
700
- content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
701
- structuredContent: out,
702
- };
703
- });
704
510
  server.registerTool('preflight_search_by_tags', {
705
511
  title: 'Search by tags',
706
512
  description: 'Search across multiple bundles filtered by tags. Use when: "search in MCP bundles", "find in all agent repos", "search web-scraping tools", "在MCP项目中搜索", "搜索所有agent".',
@@ -749,7 +555,7 @@ export async function startServer() {
749
555
  },
750
556
  searchIndexForBundleId: (bundleId, query, scope, limit) => {
751
557
  const paths = getBundlePathsForId(effectiveDir, bundleId);
752
- return searchIndex(paths.searchDbPath, query, scope, limit);
558
+ return searchIndex(paths.searchDbPath, query, scope, limit, paths.rootDir);
753
559
  },
754
560
  toUri: (bundleId, p) => toBundleFileUri({ bundleId, relativePath: p }),
755
561
  });
@@ -815,7 +621,7 @@ export async function startServer() {
815
621
  'Call preflight_repair_bundle explicitly, then call preflight_search_bundle again.');
816
622
  }
817
623
  const paths = getBundlePathsForId(storageDir, args.bundleId);
818
- const rawHits = searchIndex(paths.searchDbPath, args.query, args.scope, args.limit);
624
+ const rawHits = searchIndex(paths.searchDbPath, args.query, args.scope, args.limit, paths.rootDir);
819
625
  const hits = rawHits.map((h) => ({
820
626
  ...h,
821
627
  uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: h.path }),
@@ -835,98 +641,81 @@ export async function startServer() {
835
641
  throw wrapPreflightError(err);
836
642
  }
837
643
  });
838
- server.registerTool('preflight_verify_claim', {
839
- title: 'Verify claim',
840
- description: 'Verify a claim with evidence classification and confidence scoring (strictly read-only). If you need to update or repair, call preflight_update_bundle or preflight_repair_bundle explicitly, then verify again. Returns supporting/contradicting/related evidence. Use when: "verify this claim", "is this true", "find evidence for", "check if", "验证说法", "找证据", "这个对吗", "有没有依据".',
841
- inputSchema: VerifyClaimInputSchema,
644
+ server.registerTool('preflight_evidence_dependency_graph', {
645
+ title: 'Evidence: dependency graph (callers + imports)',
646
+ description: 'Generate an evidence-based dependency graph for a target file/symbol inside a bundle. Output is deterministic (FTS + regex) and every edge includes traceable sources (file + range). This tool is read-only.',
647
+ inputSchema: DependencyGraphInputSchema,
842
648
  outputSchema: {
843
- bundleId: z.string(),
844
- claim: z.string(),
845
- scope: z.enum(['docs', 'code', 'all']),
846
- found: z.boolean(),
847
- confidence: z.number().describe('Confidence score 0-1'),
848
- confidenceLabel: z.enum(['high', 'medium', 'low', 'none']),
849
- summary: z.string().describe('Human-readable summary of verification'),
850
- supporting: z.array(z.object({
851
- kind: z.enum(['doc', 'code']),
852
- repo: z.string(),
853
- path: z.string(),
854
- lineNo: z.number(),
855
- snippet: z.string(),
856
- uri: z.string(),
857
- evidenceType: z.enum(['supporting', 'contradicting', 'related']),
858
- relevanceScore: z.number(),
859
- })).describe('Evidence supporting the claim'),
860
- contradicting: z.array(z.object({
861
- kind: z.enum(['doc', 'code']),
862
- repo: z.string(),
863
- path: z.string(),
864
- lineNo: z.number(),
865
- snippet: z.string(),
866
- uri: z.string(),
867
- evidenceType: z.enum(['supporting', 'contradicting', 'related']),
868
- relevanceScore: z.number(),
869
- })).describe('Evidence contradicting the claim'),
870
- related: z.array(z.object({
871
- kind: z.enum(['doc', 'code']),
872
- repo: z.string(),
873
- path: z.string(),
874
- lineNo: z.number(),
875
- snippet: z.string(),
876
- uri: z.string(),
877
- evidenceType: z.enum(['supporting', 'contradicting', 'related']),
878
- relevanceScore: z.number(),
879
- })).describe('Related but inconclusive evidence'),
880
- autoUpdated: z
881
- .boolean()
882
- .optional()
883
- .describe('DEPRECATED. This tool is strictly read-only and will not auto-update.'),
884
- autoRepaired: z
885
- .boolean()
886
- .optional()
887
- .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
888
- repairActions: z
889
- .array(z.string())
890
- .optional()
891
- .describe('DEPRECATED. This tool is strictly read-only and will not auto-repair.'),
649
+ meta: z.any(),
650
+ facts: z.any(),
651
+ signals: z.any(),
892
652
  },
893
653
  annotations: {
894
654
  readOnlyHint: true,
895
655
  },
896
656
  }, async (args) => {
897
657
  try {
898
- // Resolve bundle location across storageDirs (more robust than a single effectiveDir).
899
- const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
900
- if (!storageDir) {
901
- throw new Error(`Bundle not found: ${args.bundleId}`);
902
- }
903
- if (args.ensureFresh) {
904
- throw new Error('ensureFresh is deprecated and not supported in this tool. This tool is strictly read-only. ' +
905
- 'Call preflight_update_bundle explicitly, then call preflight_verify_claim again.');
906
- }
907
- if (args.autoRepairIndex) {
908
- throw new Error('autoRepairIndex is deprecated and not supported in this tool. This tool is strictly read-only. ' +
909
- 'Call preflight_repair_bundle explicitly, then call preflight_verify_claim again.');
910
- }
911
- const paths = getBundlePathsForId(storageDir, args.bundleId);
912
- const verification = verifyClaimInIndex(paths.searchDbPath, args.claim, args.scope, args.limit);
913
- // Add URIs to evidence hits
914
- const addUri = (hit) => ({
915
- ...hit,
916
- uri: toBundleFileUri({ bundleId: args.bundleId, relativePath: hit.path }),
917
- });
918
- const out = {
919
- bundleId: args.bundleId,
920
- claim: args.claim,
921
- scope: args.scope,
922
- found: verification.found,
923
- confidence: verification.confidence,
924
- confidenceLabel: verification.confidenceLabel,
925
- summary: verification.summary,
926
- supporting: verification.supporting.map(addUri),
927
- contradicting: verification.contradicting.map(addUri),
928
- related: verification.related.map(addUri),
658
+ const out = await generateDependencyGraph(cfg, args);
659
+ return {
660
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
661
+ structuredContent: out,
929
662
  };
663
+ }
664
+ catch (err) {
665
+ throw wrapPreflightError(err);
666
+ }
667
+ });
668
+ server.registerTool('preflight_trace_upsert', {
669
+ title: 'Trace: upsert links',
670
+ description: 'Upsert traceability links (commit↔ticket, symbol↔test, code↔doc, etc.) for a bundle. Stores trace edges in a per-bundle SQLite database.',
671
+ inputSchema: TraceUpsertInputSchema,
672
+ outputSchema: {
673
+ bundleId: z.string(),
674
+ upserted: z.number().int(),
675
+ ids: z.array(z.string()),
676
+ },
677
+ annotations: {
678
+ openWorldHint: true,
679
+ },
680
+ }, async (args) => {
681
+ try {
682
+ const out = await traceUpsert(cfg, args);
683
+ return {
684
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
685
+ structuredContent: out,
686
+ };
687
+ }
688
+ catch (err) {
689
+ throw wrapPreflightError(err);
690
+ }
691
+ });
692
+ server.registerTool('preflight_trace_query', {
693
+ title: 'Trace: query links',
694
+ description: 'Query traceability links. Provide bundleId for fast queries; if omitted, scans across bundles (capped). This tool is read-only.',
695
+ inputSchema: TraceQueryInputSchema,
696
+ outputSchema: {
697
+ bundleId: z.string().optional(),
698
+ scannedBundles: z.number().int().optional(),
699
+ truncated: z.boolean().optional(),
700
+ edges: z.array(z.object({
701
+ id: z.string(),
702
+ source: z.object({ type: z.string(), id: z.string() }),
703
+ target: z.object({ type: z.string(), id: z.string() }),
704
+ type: z.string(),
705
+ confidence: z.number(),
706
+ method: z.enum(['exact', 'heuristic']),
707
+ sources: z.array(z.any()),
708
+ createdAt: z.string(),
709
+ updatedAt: z.string(),
710
+ bundleId: z.string().optional(),
711
+ })),
712
+ },
713
+ annotations: {
714
+ readOnlyHint: true,
715
+ },
716
+ }, async (args) => {
717
+ try {
718
+ const out = await traceQuery(cfg, args);
930
719
  return {
931
720
  content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
932
721
  structuredContent: out,
@@ -0,0 +1,108 @@
1
+ import path from 'node:path';
2
+ import * as z from 'zod';
3
+ import { findBundleStorageDir, getBundlePathsForId, listBundlesMulti } from '../bundle/service.js';
4
+ import { ensureTraceDb, queryTraceEdges, upsertTraceEdges } from './store.js';
5
+ export const TraceUpsertInputSchema = {
6
+ bundleId: z.string().describe('Bundle ID to attach trace links to.'),
7
+ edges: z
8
+ .array(z.object({
9
+ id: z.string().optional(),
10
+ source: z.object({ type: z.string(), id: z.string() }),
11
+ target: z.object({ type: z.string(), id: z.string() }),
12
+ type: z.string().describe('Edge type, e.g. mentions/tests/implements/relates_to'),
13
+ confidence: z.number().min(0).max(1).optional(),
14
+ method: z.enum(['exact', 'heuristic']).optional(),
15
+ sources: z
16
+ .array(z.object({
17
+ file: z.string().optional(),
18
+ range: z
19
+ .object({
20
+ startLine: z.number().int().min(1),
21
+ startCol: z.number().int().min(1),
22
+ endLine: z.number().int().min(1),
23
+ endCol: z.number().int().min(1),
24
+ })
25
+ .optional(),
26
+ externalUrl: z.string().optional(),
27
+ note: z.string().optional(),
28
+ }))
29
+ .optional(),
30
+ }))
31
+ .min(1)
32
+ .describe('Trace edges to upsert.'),
33
+ };
34
+ export const TraceQueryInputSchema = {
35
+ // When omitted, the query may scan across bundles (best-effort, capped).
36
+ bundleId: z.string().optional().describe('Optional bundleId. If omitted, scans across bundles (capped).'),
37
+ source_type: z.string(),
38
+ source_id: z.string(),
39
+ target_type: z.string().optional(),
40
+ target_id: z.string().optional(),
41
+ edge_type: z.string().optional(),
42
+ limit: z.number().int().min(1).max(500).default(50),
43
+ timeBudgetMs: z.number().int().min(500).max(30_000).default(5_000),
44
+ maxBundles: z.number().int().min(1).max(200).default(50),
45
+ };
46
+ function traceDbPathForBundleRoot(bundleRoot) {
47
+ return path.join(bundleRoot, 'trace', 'trace.sqlite3');
48
+ }
49
+ async function getTraceDbPathForBundleId(cfg, bundleId) {
50
+ const storageDir = await findBundleStorageDir(cfg.storageDirs, bundleId);
51
+ if (!storageDir) {
52
+ throw new Error(`Bundle not found: ${bundleId}`);
53
+ }
54
+ const paths = getBundlePathsForId(storageDir, bundleId);
55
+ const traceDbPath = traceDbPathForBundleRoot(paths.rootDir);
56
+ await ensureTraceDb(traceDbPath);
57
+ return traceDbPath;
58
+ }
59
+ export async function traceUpsert(cfg, rawArgs) {
60
+ const args = z.object(TraceUpsertInputSchema).parse(rawArgs);
61
+ const traceDbPath = await getTraceDbPathForBundleId(cfg, args.bundleId);
62
+ const res = await upsertTraceEdges(traceDbPath, args.edges);
63
+ return { bundleId: args.bundleId, ...res };
64
+ }
65
+ export async function traceQuery(cfg, rawArgs) {
66
+ const args = z.object(TraceQueryInputSchema).parse(rawArgs);
67
+ const source = { type: args.source_type, id: args.source_id };
68
+ const target = args.target_type && args.target_id ? { type: args.target_type, id: args.target_id } : undefined;
69
+ // Fast path: single bundle
70
+ if (args.bundleId) {
71
+ const dbPath = await getTraceDbPathForBundleId(cfg, args.bundleId);
72
+ const rows = queryTraceEdges(dbPath, { source, target, edgeType: args.edge_type, limit: args.limit });
73
+ return { bundleId: args.bundleId, edges: rows };
74
+ }
75
+ // Slow path: scan across bundles (best-effort, capped)
76
+ const startedAt = Date.now();
77
+ const timeLeft = () => args.timeBudgetMs - (Date.now() - startedAt);
78
+ let truncated = false;
79
+ const bundleIds = (await listBundlesMulti(cfg.storageDirs)).slice(0, args.maxBundles);
80
+ const collected = [];
81
+ for (const bundleId of bundleIds) {
82
+ if (timeLeft() <= 0) {
83
+ truncated = true;
84
+ break;
85
+ }
86
+ try {
87
+ const dbPath = await getTraceDbPathForBundleId(cfg, bundleId);
88
+ const rows = queryTraceEdges(dbPath, { source, target, edgeType: args.edge_type, limit: Math.min(50, args.limit) });
89
+ for (const r of rows) {
90
+ collected.push({ ...r, bundleId });
91
+ if (collected.length >= args.limit)
92
+ break;
93
+ }
94
+ }
95
+ catch {
96
+ // ignore bundles without trace
97
+ }
98
+ if (collected.length >= args.limit)
99
+ break;
100
+ }
101
+ // Sort by updatedAt desc across bundles
102
+ collected.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
103
+ return {
104
+ scannedBundles: bundleIds.length,
105
+ truncated: truncated ? true : undefined,
106
+ edges: collected.slice(0, args.limit),
107
+ };
108
+ }