@ezmodo/mcp-server 0.13.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.
Files changed (98) hide show
  1. package/README.md +305 -0
  2. package/config/development.js +20 -0
  3. package/config/endpoint-map.js +351 -0
  4. package/config/index.js +34 -0
  5. package/config/production.js +18 -0
  6. package/config/staging.js +18 -0
  7. package/handlers/access.js +141 -0
  8. package/handlers/activity.js +112 -0
  9. package/handlers/agents.js +95 -0
  10. package/handlers/ai-intelligence.js +55 -0
  11. package/handlers/attachments.js +30 -0
  12. package/handlers/catalogs.js +169 -0
  13. package/handlers/components.js +282 -0
  14. package/handlers/context-manifest.js +1150 -0
  15. package/handlers/decisions.js +114 -0
  16. package/handlers/designs.js +118 -0
  17. package/handlers/documents.js +227 -0
  18. package/handlers/entities.js +95 -0
  19. package/handlers/epics.js +190 -0
  20. package/handlers/facts.js +62 -0
  21. package/handlers/feature-flags.js +142 -0
  22. package/handlers/features.js +137 -0
  23. package/handlers/folders.js +127 -0
  24. package/handlers/git-context.js +917 -0
  25. package/handlers/github.js +72 -0
  26. package/handlers/graph.js +23 -0
  27. package/handlers/index.js +205 -0
  28. package/handlers/links.js +156 -0
  29. package/handlers/milestones.js +131 -0
  30. package/handlers/organizations.js +14 -0
  31. package/handlers/projects.js +122 -0
  32. package/handlers/recurring-tasks.js +33 -0
  33. package/handlers/tags.js +124 -0
  34. package/handlers/tasks.js +561 -0
  35. package/handlers/testing.js +116 -0
  36. package/handlers/todos.js +43 -0
  37. package/handlers/watchers.js +54 -0
  38. package/handlers/work-templates.js +32 -0
  39. package/index.js +175 -0
  40. package/lib/active-session.js +86 -0
  41. package/lib/auto-assign.js +93 -0
  42. package/lib/autolink.js +176 -0
  43. package/lib/changed-files.js +22 -0
  44. package/lib/env.js +45 -0
  45. package/lib/git-helpers.js +553 -0
  46. package/lib/git-utils.js +73 -0
  47. package/lib/http-client.js +164 -0
  48. package/lib/links-at-create.js +94 -0
  49. package/lib/local-cache.js +140 -0
  50. package/lib/logger.js +109 -0
  51. package/lib/manifest-loader.js +182 -0
  52. package/lib/manifest-query.js +686 -0
  53. package/lib/repo-config-dir.js +118 -0
  54. package/lib/version.js +10 -0
  55. package/lib/web-url.js +69 -0
  56. package/lib/worktree-tools.js +950 -0
  57. package/package.json +62 -0
  58. package/prompts/ai-workflow-automation.js +96 -0
  59. package/prompts/index.js +39 -0
  60. package/prompts/zephly-usage-guide-content.txt +631 -0
  61. package/prompts/zephly-usage-guide.js +119 -0
  62. package/tools/access-entity-types.js +28 -0
  63. package/tools/access.js +152 -0
  64. package/tools/activity.js +38 -0
  65. package/tools/agents.js +208 -0
  66. package/tools/ai-intelligence.js +111 -0
  67. package/tools/attachments.js +92 -0
  68. package/tools/catalogs.js +341 -0
  69. package/tools/components.js +249 -0
  70. package/tools/context-manifest.js +236 -0
  71. package/tools/decisions.js +168 -0
  72. package/tools/designs.js +222 -0
  73. package/tools/documents.js +287 -0
  74. package/tools/entities.js +223 -0
  75. package/tools/epics.js +267 -0
  76. package/tools/facts.js +70 -0
  77. package/tools/feature-flags.js +300 -0
  78. package/tools/features.js +246 -0
  79. package/tools/folders.js +122 -0
  80. package/tools/git-context.js +109 -0
  81. package/tools/github.js +172 -0
  82. package/tools/graph.js +70 -0
  83. package/tools/index.js +77 -0
  84. package/tools/link-params.js +93 -0
  85. package/tools/linkable-types.js +36 -0
  86. package/tools/links.js +199 -0
  87. package/tools/milestones.js +176 -0
  88. package/tools/organizations.js +23 -0
  89. package/tools/projects.js +172 -0
  90. package/tools/recurring-tasks.js +115 -0
  91. package/tools/tags.js +219 -0
  92. package/tools/task-item-schema.js +57 -0
  93. package/tools/task-type.js +33 -0
  94. package/tools/tasks.js +680 -0
  95. package/tools/testing.js +344 -0
  96. package/tools/todos.js +69 -0
  97. package/tools/watchers.js +81 -0
  98. package/tools/work-templates.js +96 -0
@@ -0,0 +1,686 @@
1
+ /**
2
+ * Context Manifest Query Engine
3
+ * Port of scripts/context-manifest/query.ts for use in the MCP server.
4
+ * Provides search, related files, project overview, and formatting utilities.
5
+ */
6
+
7
+ // ============================================================================
8
+ // Stop Words
9
+ // ============================================================================
10
+
11
+ const STOP_WORDS = new Set([
12
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
13
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
14
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'must',
15
+ 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'it',
16
+ 'they', 'them', 'this', 'that', 'these', 'those', 'what', 'which',
17
+ 'who', 'whom', 'where', 'when', 'why', 'how',
18
+ 'and', 'but', 'or', 'nor', 'not', 'no', 'so', 'if', 'then',
19
+ 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up',
20
+ 'about', 'into', 'through', 'during', 'before', 'after', 'above',
21
+ 'below', 'between', 'out', 'off', 'over', 'under', 'again',
22
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other',
23
+ 'some', 'such', 'only', 'own', 'same', 'than', 'too', 'very',
24
+ 'just', 'also', 'new', 'add', 'make', 'get', 'set', 'use',
25
+ 'file', 'files', 'code', 'change', 'update', 'create',
26
+ ]);
27
+
28
+ // ============================================================================
29
+ // getContextForTags
30
+ // ============================================================================
31
+
32
+ /**
33
+ * Returns all manifest entries matching ANY of the given tags.
34
+ * Results sorted by number of matching tags (most relevant first).
35
+ *
36
+ * @param {object} manifest - Parsed context manifest
37
+ * @param {string[]} tags - Tags to search for
38
+ * @returns {Array<{entry: object, score: number}>} Scored entries
39
+ */
40
+ export function getContextForTags(manifest, tags) {
41
+ if (!tags || tags.length === 0) return [];
42
+
43
+ const lowerTags = tags.map((t) => t.toLowerCase());
44
+ const scored = [];
45
+
46
+ for (const entry of manifest.entries) {
47
+ let score = 0;
48
+ for (const entryTag of entry.tags) {
49
+ const lowerEntryTag = entryTag.toLowerCase();
50
+ for (const searchTag of lowerTags) {
51
+ if (lowerEntryTag === searchTag) {
52
+ score += 2;
53
+ } else if (lowerEntryTag.startsWith(searchTag) || searchTag.startsWith(lowerEntryTag)) {
54
+ score += 1;
55
+ }
56
+ }
57
+ }
58
+
59
+ if (score > 0) {
60
+ scored.push({ entry, score });
61
+ }
62
+ }
63
+
64
+ return scored.sort((a, b) => b.score - a.score);
65
+ }
66
+
67
+ // ============================================================================
68
+ // getContextForTask
69
+ // ============================================================================
70
+
71
+ /**
72
+ * Extract keywords from a natural language description.
73
+ * Removes stop words, special chars, and deduplicates.
74
+ */
75
+ function extractKeywords(text) {
76
+ return text
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9\s\-_]/g, ' ')
79
+ .split(/\s+/)
80
+ .filter((word) => word.length >= 2 && !STOP_WORDS.has(word))
81
+ .filter((word, i, arr) => arr.indexOf(word) === i);
82
+ }
83
+
84
+ /**
85
+ * Score a manifest entry against a set of keywords.
86
+ */
87
+ function scoreEntry(entry, keywords) {
88
+ let score = 0;
89
+
90
+ for (const keyword of keywords) {
91
+ // Tags: highest weight (3 points)
92
+ for (const tag of entry.tags) {
93
+ if (tag.toLowerCase() === keyword) score += 3;
94
+ else if (tag.toLowerCase().includes(keyword)) score += 1;
95
+ }
96
+
97
+ // Domain: high weight (3 points)
98
+ if (entry.domain?.toLowerCase() === keyword) score += 3;
99
+
100
+ // Summary: medium weight (2 points)
101
+ if (entry.summary.toLowerCase().includes(keyword)) score += 2;
102
+
103
+ // Path: low weight (1 point)
104
+ if (entry.path.toLowerCase().includes(keyword)) score += 1;
105
+
106
+ // Exports: medium weight (2 points)
107
+ if (entry.exports) {
108
+ for (const exp of entry.exports) {
109
+ if (exp.toLowerCase().includes(keyword)) {
110
+ score += 2;
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ return score;
118
+ }
119
+
120
+ /**
121
+ * Given a natural language task description, returns the most relevant manifest entries.
122
+ * Uses keyword matching against tags, summaries, paths, and domains.
123
+ *
124
+ * @param {object} manifest - Parsed context manifest
125
+ * @param {string} taskDescription - Natural language description
126
+ * @param {number} limit - Maximum results to return (default 20)
127
+ * @returns {Array<{entry: object, score: number}>} Scored entries
128
+ */
129
+ export function getContextForTask(manifest, taskDescription, limit = 20) {
130
+ if (!taskDescription || taskDescription.trim().length === 0) return [];
131
+
132
+ const keywords = extractKeywords(taskDescription);
133
+ if (keywords.length === 0) return [];
134
+
135
+ const scored = [];
136
+
137
+ for (const entry of manifest.entries) {
138
+ const score = scoreEntry(entry, keywords);
139
+ if (score > 0) {
140
+ scored.push({ entry, score });
141
+ }
142
+ }
143
+
144
+ return scored
145
+ .sort((a, b) => b.score - a.score)
146
+ .slice(0, limit);
147
+ }
148
+
149
+ // ============================================================================
150
+ // getRelatedFiles
151
+ // ============================================================================
152
+
153
+ /**
154
+ * Returns manifest entries related to a given file via the dependency graph.
155
+ * Includes both direct dependencies and reverse dependencies.
156
+ *
157
+ * @param {object} manifest - Parsed context manifest
158
+ * @param {string} filePath - Relative file path from project root
159
+ * @param {number} depth - How many levels deep to traverse (default 1)
160
+ * @param {string} direction - "both", "forward", or "reverse" (default "both")
161
+ * @returns {object[]} Related manifest entries with relationship info
162
+ */
163
+ export function getRelatedFiles(manifest, filePath, depth = 1, direction = 'both') {
164
+ if (!filePath) return [];
165
+
166
+ // Build lookup maps
167
+ const entryMap = new Map();
168
+ const reverseDeps = new Map();
169
+
170
+ for (const entry of manifest.entries) {
171
+ entryMap.set(entry.path, entry);
172
+ for (const dep of entry.dependencies) {
173
+ if (!reverseDeps.has(dep)) reverseDeps.set(dep, []);
174
+ reverseDeps.get(dep).push(entry.path);
175
+ }
176
+ }
177
+
178
+ const sourceEntry = entryMap.get(filePath);
179
+ if (!sourceEntry) return [];
180
+
181
+ // BFS to collect related files at each depth level
182
+ const visited = new Set([filePath]);
183
+ let currentLevel = new Set([filePath]);
184
+
185
+ for (let d = 0; d < depth; d++) {
186
+ const nextLevel = new Set();
187
+
188
+ for (const current of currentLevel) {
189
+ const entry = entryMap.get(current);
190
+ if (!entry) continue;
191
+
192
+ // Forward dependencies
193
+ if (direction === 'both' || direction === 'forward') {
194
+ for (const dep of entry.dependencies) {
195
+ if (!visited.has(dep)) {
196
+ visited.add(dep);
197
+ nextLevel.add(dep);
198
+ }
199
+ }
200
+ }
201
+
202
+ // Reverse dependencies
203
+ if (direction === 'both' || direction === 'reverse') {
204
+ const revDeps = reverseDeps.get(current) || [];
205
+ for (const revDep of revDeps) {
206
+ if (!visited.has(revDep)) {
207
+ visited.add(revDep);
208
+ nextLevel.add(revDep);
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ currentLevel = nextLevel;
215
+ }
216
+
217
+ // Remove the source file itself
218
+ visited.delete(filePath);
219
+
220
+ // Sort: direct deps first, then reverse deps, then deeper
221
+ const directDeps = new Set(sourceEntry.dependencies);
222
+ const directRevDeps = new Set(reverseDeps.get(filePath) || []);
223
+
224
+ return Array.from(visited)
225
+ .map((p) => entryMap.get(p))
226
+ .filter((e) => e !== undefined)
227
+ .sort((a, b) => {
228
+ const aIsDirect = directDeps.has(a.path) ? 1 : 0;
229
+ const bIsDirect = directDeps.has(b.path) ? 1 : 0;
230
+ if (aIsDirect !== bIsDirect) return bIsDirect - aIsDirect;
231
+
232
+ const aIsReverse = directRevDeps.has(a.path) ? 1 : 0;
233
+ const bIsReverse = directRevDeps.has(b.path) ? 1 : 0;
234
+ if (aIsReverse !== bIsReverse) return bIsReverse - aIsReverse;
235
+
236
+ return a.path.localeCompare(b.path);
237
+ });
238
+ }
239
+
240
+ // ============================================================================
241
+ // getProjectOverview
242
+ // ============================================================================
243
+
244
+ /**
245
+ * Aggregate manifest metadata into a high-level project overview.
246
+ * Provides domain breakdown, file counts by type/layer, and key entry points.
247
+ *
248
+ * @param {object} manifest - Parsed context manifest
249
+ * @param {object} options - Optional filters
250
+ * @param {string} options.domain - Filter to a specific domain
251
+ * @param {string} options.layer - Filter to a specific layer
252
+ * @returns {object} Project overview
253
+ */
254
+ export function getProjectOverview(manifest, options = {}) {
255
+ const { domain, layer } = options;
256
+
257
+ let entries = manifest.entries;
258
+
259
+ // Apply filters
260
+ if (domain) {
261
+ entries = entries.filter((e) => e.domain === domain);
262
+ }
263
+ if (layer) {
264
+ entries = entries.filter((e) => e.layer === layer);
265
+ }
266
+
267
+ // Aggregate by type
268
+ const byType = {};
269
+ for (const entry of entries) {
270
+ byType[entry.type] = (byType[entry.type] || 0) + 1;
271
+ }
272
+
273
+ // Aggregate by layer
274
+ const byLayer = {};
275
+ for (const entry of entries) {
276
+ if (entry.layer) {
277
+ byLayer[entry.layer] = (byLayer[entry.layer] || 0) + 1;
278
+ }
279
+ }
280
+
281
+ // Aggregate by domain
282
+ const byDomain = {};
283
+ for (const entry of entries) {
284
+ if (entry.domain) {
285
+ byDomain[entry.domain] = (byDomain[entry.domain] || 0) + 1;
286
+ }
287
+ }
288
+
289
+ // Aggregate by language
290
+ const byLanguage = {};
291
+ for (const entry of entries) {
292
+ if (entry.language) {
293
+ byLanguage[entry.language] = (byLanguage[entry.language] || 0) + 1;
294
+ }
295
+ }
296
+
297
+ // Find key entry points per domain (handlers, services, pages)
298
+ const entryPoints = {};
299
+ const entryPointLayers = new Set(['handler', 'service', 'page', 'route']);
300
+ for (const entry of entries) {
301
+ if (entry.layer && entryPointLayers.has(entry.layer) && entry.domain) {
302
+ if (!entryPoints[entry.domain]) entryPoints[entry.domain] = [];
303
+ entryPoints[entry.domain].push({
304
+ path: entry.path,
305
+ layer: entry.layer,
306
+ summary: entry.summary,
307
+ });
308
+ }
309
+ }
310
+
311
+ // Sort entry points — limit to top 5 per domain
312
+ for (const d of Object.keys(entryPoints)) {
313
+ entryPoints[d] = entryPoints[d].slice(0, 5);
314
+ }
315
+
316
+ // Dependency statistics
317
+ let totalDeps = 0;
318
+ let maxDeps = 0;
319
+ let maxDepsFile = null;
320
+ const reverseDeps = {};
321
+
322
+ for (const entry of entries) {
323
+ totalDeps += entry.dependencies.length;
324
+ if (entry.dependencies.length > maxDeps) {
325
+ maxDeps = entry.dependencies.length;
326
+ maxDepsFile = entry.path;
327
+ }
328
+ for (const dep of entry.dependencies) {
329
+ reverseDeps[dep] = (reverseDeps[dep] || 0) + 1;
330
+ }
331
+ }
332
+
333
+ // Find most-depended-on files
334
+ const mostDependedOn = Object.entries(reverseDeps)
335
+ .sort(([, a], [, b]) => b - a)
336
+ .slice(0, 10)
337
+ .map(([path, count]) => ({ path, dependedOnBy: count }));
338
+
339
+ return {
340
+ totalFiles: entries.length,
341
+ byType,
342
+ byLayer,
343
+ byDomain,
344
+ byLanguage,
345
+ entryPoints,
346
+ dependencyStats: {
347
+ totalRelationships: totalDeps,
348
+ mostDependencies: maxDepsFile ? { path: maxDepsFile, count: maxDeps } : null,
349
+ mostDependedOn,
350
+ },
351
+ ...(domain && { filteredByDomain: domain }),
352
+ ...(layer && { filteredByLayer: layer }),
353
+ };
354
+ }
355
+
356
+ // ============================================================================
357
+ // formatContextForPrompt
358
+ // ============================================================================
359
+
360
+ /**
361
+ * Format manifest entries into a prompt-ready context block.
362
+ *
363
+ * @param {object[]} entries - Manifest entries to format
364
+ * @param {object} options - Formatting options
365
+ * @param {"compact"|"detailed"|"structured"} options.mode - Output format (default: "compact")
366
+ * @param {number} options.maxLength - Max output length in chars (0 = unlimited)
367
+ * @param {"type"|"domain"|"none"} options.groupBy - Grouping strategy (default: "none")
368
+ * @returns {string} Formatted context
369
+ */
370
+ export function formatContextForPrompt(entries, options = {}) {
371
+ const { mode = 'compact', maxLength = 0, groupBy = 'none' } = options;
372
+
373
+ if (entries.length === 0) return 'No relevant context found.';
374
+
375
+ let output;
376
+
377
+ if (mode === 'structured') {
378
+ output = JSON.stringify(
379
+ entries.map((e) => ({
380
+ path: e.path,
381
+ type: e.type,
382
+ tags: e.tags,
383
+ summary: e.summary,
384
+ dependencies: e.dependencies,
385
+ ...(e.layer && { layer: e.layer }),
386
+ ...(e.domain && { domain: e.domain }),
387
+ ...(e.exports && { exports: e.exports }),
388
+ })),
389
+ null,
390
+ 2
391
+ );
392
+ } else if (groupBy !== 'none') {
393
+ output = formatGrouped(entries, mode, groupBy);
394
+ } else {
395
+ output = formatFlat(entries, mode);
396
+ }
397
+
398
+ // Apply max length
399
+ if (maxLength > 0 && output.length > maxLength) {
400
+ output = output.substring(0, maxLength - 50) + '\n\n... (truncated, ' + entries.length + ' total entries)';
401
+ }
402
+
403
+ return output;
404
+ }
405
+
406
+ function formatFlat(entries, mode) {
407
+ const lines = [];
408
+ lines.push(`## Relevant Context (${entries.length} files)\n`);
409
+
410
+ for (const entry of entries) {
411
+ if (mode === 'compact') {
412
+ lines.push(`- **${entry.path}**: ${entry.summary}`);
413
+ } else {
414
+ lines.push(`### ${entry.path}`);
415
+ lines.push(`- **Type**: ${entry.type}`);
416
+ lines.push(`- **Summary**: ${entry.summary}`);
417
+ lines.push(`- **Tags**: ${entry.tags.join(', ')}`);
418
+ if (entry.layer) lines.push(`- **Layer**: ${entry.layer}`);
419
+ if (entry.domain) lines.push(`- **Domain**: ${entry.domain}`);
420
+ if (entry.exports && entry.exports.length > 0) {
421
+ lines.push(`- **Exports**: ${entry.exports.slice(0, 10).join(', ')}`);
422
+ }
423
+ if (entry.dependencies.length > 0) {
424
+ const depList = entry.dependencies.slice(0, 5).join(', ');
425
+ const extra = entry.dependencies.length > 5
426
+ ? ` (+${entry.dependencies.length - 5} more)` : '';
427
+ lines.push(`- **Dependencies**: ${depList}${extra}`);
428
+ }
429
+ lines.push('');
430
+ }
431
+ }
432
+
433
+ return lines.join('\n');
434
+ }
435
+
436
+ function formatGrouped(entries, mode, groupBy) {
437
+ const groups = new Map();
438
+
439
+ for (const entry of entries) {
440
+ const key = groupBy === 'type' ? entry.type : (entry.domain || 'other');
441
+ if (!groups.has(key)) groups.set(key, []);
442
+ groups.get(key).push(entry);
443
+ }
444
+
445
+ const lines = [];
446
+ lines.push(`## Relevant Context (${entries.length} files)\n`);
447
+
448
+ for (const [group, groupEntries] of groups) {
449
+ lines.push(`### ${group} (${groupEntries.length} files)\n`);
450
+
451
+ for (const entry of groupEntries) {
452
+ if (mode === 'compact') {
453
+ lines.push(`- **${entry.path}**: ${entry.summary}`);
454
+ } else {
455
+ lines.push(`- **${entry.path}**`);
456
+ lines.push(` - Summary: ${entry.summary}`);
457
+ lines.push(` - Tags: ${entry.tags.join(', ')}`);
458
+ if (entry.exports && entry.exports.length > 0) {
459
+ lines.push(` - Exports: ${entry.exports.slice(0, 10).join(', ')}`);
460
+ }
461
+ }
462
+ }
463
+
464
+ lines.push('');
465
+ }
466
+
467
+ return lines.join('\n');
468
+ }
469
+
470
+ // ============================================================================
471
+ // getCriticalFiles
472
+ // ============================================================================
473
+
474
+ /** Default directories that indicate critical/core files */
475
+ const KEY_DIRECTORIES = [
476
+ 'services/',
477
+ 'handlers/',
478
+ 'core/',
479
+ 'middleware/',
480
+ 'contexts/',
481
+ 'internal/core/',
482
+ 'internal/api/handlers/',
483
+ 'internal/api/middleware/',
484
+ ];
485
+
486
+ /**
487
+ * Identify critical files that would benefit from LLM-enriched summaries.
488
+ * A file is critical if it has high inbound dependencies, spans multiple
489
+ * domains, or lives in a key directory.
490
+ *
491
+ * @param {object} manifest - Parsed context manifest
492
+ * @param {object} options
493
+ * @param {number} options.threshold - Min inbound deps to qualify (default: 5)
494
+ * @param {number} options.limit - Max files to return (default: 50)
495
+ * @param {boolean} options.unenrichedOnly - Only return files not yet
496
+ * enriched (default: true)
497
+ * @returns {object[]} Critical file entries with scores and signals
498
+ */
499
+ export function getCriticalFiles(manifest, options = {}) {
500
+ const {
501
+ threshold = 5,
502
+ limit = 50,
503
+ unenrichedOnly = true,
504
+ } = options;
505
+
506
+ // Build inbound dependency counts
507
+ const inboundCounts = new Map();
508
+ for (const entry of manifest.entries) {
509
+ for (const dep of entry.dependencies) {
510
+ inboundCounts.set(dep, (inboundCounts.get(dep) || 0) + 1);
511
+ }
512
+ }
513
+
514
+ // Build domain map — count domains each file touches via its deps
515
+ const entryMap = new Map();
516
+ for (const entry of manifest.entries) {
517
+ entryMap.set(entry.path, entry);
518
+ }
519
+
520
+ const results = [];
521
+
522
+ for (const entry of manifest.entries) {
523
+ // Skip already-enriched files if requested
524
+ if (unenrichedOnly && entry.enrichment_method === 'llm') continue;
525
+
526
+ // Skip non-source files (no point enriching configs, assets, etc.)
527
+ if (entry.type !== 'source') continue;
528
+
529
+ const signals = [];
530
+ let rawScore = 0;
531
+
532
+ // Signal 1: High inbound dependency count
533
+ const inbound = inboundCounts.get(entry.path) || 0;
534
+ if (inbound >= threshold) {
535
+ signals.push(`high_dependency_count:${inbound}`);
536
+ rawScore += Math.min(inbound / 20, 1);
537
+ }
538
+
539
+ // Signal 2: Multi-domain (cross-cutting)
540
+ const domains = new Set();
541
+ if (entry.domain) domains.add(entry.domain);
542
+ for (const dep of entry.dependencies) {
543
+ const depEntry = entryMap.get(dep);
544
+ if (depEntry?.domain) domains.add(depEntry.domain);
545
+ }
546
+ if (domains.size >= 3) {
547
+ signals.push(`multi_domain:${domains.size}`);
548
+ rawScore += 0.3;
549
+ }
550
+
551
+ // Signal 3: Key directory
552
+ for (const dir of KEY_DIRECTORIES) {
553
+ if (entry.path.includes(dir)) {
554
+ signals.push(`key_directory:${dir.replace(/\/$/, '')}`);
555
+ rawScore += 0.2;
556
+ break;
557
+ }
558
+ }
559
+
560
+ if (signals.length === 0) continue;
561
+
562
+ results.push({
563
+ path: entry.path,
564
+ score: Math.min(rawScore, 1),
565
+ signals,
566
+ currentSummary: entry.summary,
567
+ enrichmentMethod: entry.enrichment_method || 'static',
568
+ domain: entry.domain || null,
569
+ layer: entry.layer || null,
570
+ language: entry.language || null,
571
+ inboundDeps: inbound,
572
+ dependencies: entry.dependencies,
573
+ });
574
+ }
575
+
576
+ return results
577
+ .sort((a, b) => b.score - a.score)
578
+ .slice(0, limit);
579
+ }
580
+
581
+ /**
582
+ * Update manifest entries with new enriched summaries.
583
+ * Modifies the manifest in-place (caller must save to disk).
584
+ *
585
+ * @param {object} manifest - Parsed context manifest (will be mutated)
586
+ * @param {Array<{path: string, summary: string}>} updates - Entries to update
587
+ * @returns {object} Summary of what was updated
588
+ */
589
+ export function updateManifestEntries(manifest, updates) {
590
+ const entryMap = new Map();
591
+ for (const entry of manifest.entries) {
592
+ entryMap.set(entry.path, entry);
593
+ }
594
+
595
+ const updated = [];
596
+ const notFound = [];
597
+ const now = new Date().toISOString();
598
+
599
+ for (const update of updates) {
600
+ const entry = entryMap.get(update.path);
601
+ if (!entry) {
602
+ notFound.push(update.path);
603
+ continue;
604
+ }
605
+
606
+ entry.summary = update.summary;
607
+ entry.enrichment_method = 'llm';
608
+ entry.lastAnalyzed = now;
609
+
610
+ if (update.criticality_score !== undefined) {
611
+ entry.criticality_score = update.criticality_score;
612
+ }
613
+ if (update.criticality_signals) {
614
+ entry.criticality_signals = update.criticality_signals;
615
+ }
616
+
617
+ updated.push(update.path);
618
+ }
619
+
620
+ return { updated, notFound, timestamp: now };
621
+ }
622
+
623
+ // ============================================================================
624
+ // Utility: Suggest similar paths
625
+ // ============================================================================
626
+
627
+ /**
628
+ * Suggest similar file paths when an exact match isn't found.
629
+ * Uses simple substring matching on path segments.
630
+ *
631
+ * @param {object} manifest - Parsed context manifest
632
+ * @param {string} filePath - Path that wasn't found
633
+ * @param {number} limit - Max suggestions (default 5)
634
+ * @returns {string[]} Similar paths
635
+ */
636
+ export function suggestSimilarPaths(manifest, filePath, limit = 5) {
637
+ const segments = filePath.toLowerCase().split('/').filter(Boolean);
638
+ const basename = segments[segments.length - 1] || '';
639
+
640
+ const scored = [];
641
+
642
+ for (const entry of manifest.entries) {
643
+ let score = 0;
644
+ const entryLower = entry.path.toLowerCase();
645
+
646
+ // Exact basename match
647
+ if (entryLower.endsWith(basename)) score += 3;
648
+
649
+ // Segment overlap
650
+ for (const seg of segments) {
651
+ if (entryLower.includes(seg)) score += 1;
652
+ }
653
+
654
+ if (score > 0) {
655
+ scored.push({ path: entry.path, score });
656
+ }
657
+ }
658
+
659
+ return scored
660
+ .sort((a, b) => b.score - a.score)
661
+ .slice(0, limit)
662
+ .map((s) => s.path);
663
+ }
664
+
665
+ // ============================================================================
666
+ // Utility: Create _meta field
667
+ // ============================================================================
668
+
669
+ /**
670
+ * Create a _meta field for tool responses.
671
+ *
672
+ * @param {object} manifest - Parsed manifest (for version info)
673
+ * @param {number} resultCount - Number of results returned
674
+ * @param {number} startTime - process.hrtime.bigint() start
675
+ * @returns {object} Meta object
676
+ */
677
+ export function createMeta(manifest, resultCount, startTime) {
678
+ const elapsed = Number(process.hrtime.bigint() - startTime) / 1_000_000;
679
+ return {
680
+ resultCount,
681
+ durationMs: Math.round(elapsed * 100) / 100,
682
+ manifestVersion: manifest?.metadata?.version || null,
683
+ manifestGeneratedAt: manifest?.metadata?.generatedAt || null,
684
+ totalManifestEntries: manifest?.metadata?.entryCount || 0,
685
+ };
686
+ }