minovative-mind-cli 2.14.1 → 2.14.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.
Files changed (47) hide show
  1. package/README.md +40 -59
  2. package/dist/services/agent/slashCommands.js +144 -13
  3. package/dist/services/agent/toolLoop.js +11 -2
  4. package/dist/services/agent/types.d.ts +9 -2
  5. package/dist/services/agent-tools.d.ts +20 -1
  6. package/dist/services/agent-tools.js +272 -47
  7. package/dist/services/agent.js +43 -18
  8. package/dist/services/ai.d.ts +9 -0
  9. package/dist/services/ai.js +71 -21
  10. package/dist/services/chatHistoryService.d.ts +5 -0
  11. package/dist/services/contextAgent.d.ts +14 -6
  12. package/dist/services/contextAgent.js +36 -10
  13. package/dist/services/orchestration/investigationAgent.js +31 -7
  14. package/dist/services/orchestration/investigationCache.js +19 -9
  15. package/dist/services/orchestration/readCache.d.ts +1 -0
  16. package/dist/services/orchestration/readCache.js +5 -2
  17. package/dist/services/orchestration/scopedTools.js +37 -10
  18. package/dist/services/orchestration/subAgent.js +16 -2
  19. package/dist/services/proxyClient.d.ts +6 -0
  20. package/dist/services/proxyClient.js +24 -10
  21. package/dist/services/sessionSettings.d.ts +66 -0
  22. package/dist/services/sessionSettings.js +126 -0
  23. package/dist/services/userProfileService.d.ts +14 -0
  24. package/dist/services/userProfileService.js +105 -3
  25. package/dist/services/verificationService.js +24 -2
  26. package/dist/utils/analysisRunner.d.ts +120 -8
  27. package/dist/utils/analysisRunner.js +946 -125
  28. package/dist/utils/antiCheatingGuard.d.ts +21 -0
  29. package/dist/utils/antiCheatingGuard.js +554 -0
  30. package/dist/utils/contextPrompts.d.ts +39 -0
  31. package/dist/utils/contextPrompts.js +81 -9
  32. package/dist/utils/contextRanker.d.ts +216 -0
  33. package/dist/utils/contextRanker.js +603 -0
  34. package/dist/utils/dependencyTracer/modules/graph.d.ts +4 -1
  35. package/dist/utils/dependencyTracer/modules/graph.js +11 -0
  36. package/dist/utils/dependencyTracer/modules/types.d.ts +10 -0
  37. package/dist/utils/dependencyTracer.d.ts +40 -2
  38. package/dist/utils/dependencyTracer.js +95 -3
  39. package/dist/utils/fileReadCache.d.ts +58 -0
  40. package/dist/utils/fileReadCache.js +162 -0
  41. package/dist/utils/projectStorage.js +2 -1
  42. package/dist/utils/symbolExtractor.d.ts +12 -0
  43. package/dist/utils/symbolExtractor.js +111 -15
  44. package/dist/utils/systemPrompts.d.ts +4 -3
  45. package/dist/utils/systemPrompts.js +66 -8
  46. package/oclif.manifest.json +1 -1
  47. package/package.json +1 -1
@@ -0,0 +1,603 @@
1
+ /**
2
+ * @file src/utils/contextRanker.ts
3
+ * @description Classical BM25 / TF-IDF information retrieval engine with dependency graph
4
+ * centrality scoring and deterministic 3-tier token budgeting.
5
+ *
6
+ * Partitions candidate workspace files into:
7
+ * - Tier 1: Full raw source code for high-relevance edit targets and primary files.
8
+ * - Tier 2: Compact AST declaration skeletons for peripheral hubs and dependent modules.
9
+ * - Tier 3: Omitted overflow files to strictly respect token budget boundaries.
10
+ */
11
+ import path from 'node:path';
12
+ import { extractDeclarationsOutline } from './symbolExtractor.js';
13
+ import { CONTEXT_BUDGET_CONFIG } from './contextPrompts.js';
14
+ // ─── Stop Words ──────────────────────────────────────────────────────
15
+ const COMMON_STOP_WORDS = new Set([
16
+ 'a',
17
+ 'about',
18
+ 'above',
19
+ 'after',
20
+ 'again',
21
+ 'against',
22
+ 'all',
23
+ 'am',
24
+ 'an',
25
+ 'and',
26
+ 'any',
27
+ 'are',
28
+ 'as',
29
+ 'at',
30
+ 'be',
31
+ 'because',
32
+ 'been',
33
+ 'before',
34
+ 'being',
35
+ 'below',
36
+ 'between',
37
+ 'both',
38
+ 'but',
39
+ 'by',
40
+ 'can',
41
+ 'could',
42
+ 'did',
43
+ 'do',
44
+ 'does',
45
+ 'doing',
46
+ 'down',
47
+ 'during',
48
+ 'each',
49
+ 'few',
50
+ 'for',
51
+ 'from',
52
+ 'further',
53
+ 'had',
54
+ 'has',
55
+ 'have',
56
+ 'having',
57
+ 'he',
58
+ 'her',
59
+ 'here',
60
+ 'hers',
61
+ 'herself',
62
+ 'him',
63
+ 'himself',
64
+ 'his',
65
+ 'how',
66
+ 'i',
67
+ 'if',
68
+ 'in',
69
+ 'into',
70
+ 'is',
71
+ 'it',
72
+ 'its',
73
+ 'itself',
74
+ 'just',
75
+ 'me',
76
+ 'more',
77
+ 'most',
78
+ 'my',
79
+ 'myself',
80
+ 'no',
81
+ 'nor',
82
+ 'not',
83
+ 'now',
84
+ 'of',
85
+ 'off',
86
+ 'on',
87
+ 'once',
88
+ 'only',
89
+ 'or',
90
+ 'other',
91
+ 'our',
92
+ 'ours',
93
+ 'ourselves',
94
+ 'out',
95
+ 'over',
96
+ 'own',
97
+ 'same',
98
+ 'should',
99
+ 'so',
100
+ 'some',
101
+ 'such',
102
+ 'than',
103
+ 'that',
104
+ 'the',
105
+ 'their',
106
+ 'theirs',
107
+ 'them',
108
+ 'themselves',
109
+ 'then',
110
+ 'there',
111
+ 'these',
112
+ 'they',
113
+ 'this',
114
+ 'those',
115
+ 'through',
116
+ 'to',
117
+ 'too',
118
+ 'under',
119
+ 'until',
120
+ 'up',
121
+ 'very',
122
+ 'was',
123
+ 'we',
124
+ 'were',
125
+ 'what',
126
+ 'when',
127
+ 'where',
128
+ 'which',
129
+ 'while',
130
+ 'who',
131
+ 'whom',
132
+ 'why',
133
+ 'will',
134
+ 'with',
135
+ 'would',
136
+ 'you',
137
+ 'your',
138
+ 'yours',
139
+ 'yourself',
140
+ 'yourselves',
141
+ ]);
142
+ // ─── Tokenizer & Query Preprocessor ──────────────────────────────────
143
+ /**
144
+ * Tokenizes code and text into normalized keywords.
145
+ * Handles camelCase, PascalCase, snake_case, kebab-case, and dot/slash delimiters.
146
+ *
147
+ * @param text The input string to tokenize
148
+ * @param filterStopWords Whether to filter common English stop words (default: true)
149
+ * @returns Array of lowercase alphanumeric tokens
150
+ */
151
+ export function tokenize(text, filterStopWords = true) {
152
+ if (!text)
153
+ return [];
154
+ // Split on camelCase boundaries (e.g. "contextRanker" -> "context Ranker")
155
+ const splitCamel = text.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
156
+ // Split by non-alphanumeric characters (punctuation, symbols, whitespace)
157
+ const rawTokens = splitCamel.toLowerCase().split(/[^a-z0-9_]+/);
158
+ const tokens = [];
159
+ for (const raw of rawTokens) {
160
+ // Also split any remaining snake_case tokens
161
+ const subTokens = raw.split('_');
162
+ for (const sub of subTokens) {
163
+ const trimmed = sub.trim();
164
+ if (trimmed.length < 2)
165
+ continue;
166
+ if (filterStopWords && COMMON_STOP_WORDS.has(trimmed))
167
+ continue;
168
+ tokens.push(trimmed);
169
+ }
170
+ }
171
+ return tokens;
172
+ }
173
+ /**
174
+ * Extracts unique search terms from a user prompt or query.
175
+ */
176
+ export function extractQueryTerms(prompt) {
177
+ const tokens = tokenize(prompt, true);
178
+ return Array.from(new Set(tokens));
179
+ }
180
+ /**
181
+ * In-memory BM25 index and scoring implementation.
182
+ * Delivers deterministic <5ms search ranking across candidate files.
183
+ */
184
+ export class BM25Engine {
185
+ avgDocLength = 0;
186
+ b;
187
+ docFrequency = new Map();
188
+ documents = [];
189
+ k1;
190
+ totalDocs = 0;
191
+ constructor(options) {
192
+ this.k1 = options?.k1 ?? 1.2;
193
+ this.b = options?.b ?? 0.75;
194
+ }
195
+ /**
196
+ * Adds a document to the BM25 index.
197
+ * Path tokens can be repeated to apply a structural path boost.
198
+ */
199
+ addDocument(id, content, filePath, pathBoostFactor = 3) {
200
+ const contentTokens = tokenize(content, true);
201
+ const pathTokens = tokenize(filePath, true);
202
+ // Apply path boost factor by repeating path tokens
203
+ const boostedTokens = [...contentTokens];
204
+ for (let i = 0; i < pathBoostFactor; i++) {
205
+ boostedTokens.push(...pathTokens);
206
+ }
207
+ const termFreqs = new Map();
208
+ const uniqueTermsInDoc = new Set();
209
+ for (const token of boostedTokens) {
210
+ termFreqs.set(token, (termFreqs.get(token) || 0) + 1);
211
+ uniqueTermsInDoc.add(token);
212
+ }
213
+ for (const term of uniqueTermsInDoc) {
214
+ this.docFrequency.set(term, (this.docFrequency.get(term) || 0) + 1);
215
+ }
216
+ this.documents.push({
217
+ id,
218
+ tokens: boostedTokens,
219
+ termFreqs,
220
+ length: boostedTokens.length,
221
+ });
222
+ }
223
+ /**
224
+ * Finalizes document statistics (average document length).
225
+ */
226
+ build() {
227
+ this.totalDocs = this.documents.length;
228
+ if (this.totalDocs === 0) {
229
+ this.avgDocLength = 0;
230
+ return;
231
+ }
232
+ const totalLength = this.documents.reduce((acc, doc) => acc + doc.length, 0);
233
+ this.avgDocLength = totalLength / this.totalDocs;
234
+ }
235
+ /**
236
+ * Scores all indexed documents against query terms using standard BM25 formula with Robertson-Spärck Jones IDF.
237
+ *
238
+ * @param queryTerms Array of query terms
239
+ * @returns Map of document ID to raw BM25 score
240
+ */
241
+ score(queryTerms) {
242
+ const scores = new Map();
243
+ if (this.totalDocs === 0 || queryTerms.length === 0) {
244
+ for (const doc of this.documents) {
245
+ scores.set(doc.id, 0);
246
+ }
247
+ return scores;
248
+ }
249
+ for (const doc of this.documents) {
250
+ let docScore = 0;
251
+ for (const term of queryTerms) {
252
+ const tf = doc.termFreqs.get(term) || 0;
253
+ if (tf === 0)
254
+ continue;
255
+ const df = this.docFrequency.get(term) || 0;
256
+ // Smoothed BM25 IDF: ln((N - n + 0.5) / (n + 0.5) + 1)
257
+ const idf = Math.log((this.totalDocs - df + 0.5) / (df + 0.5) + 1);
258
+ // BM25 term frequency saturation
259
+ const numerator = tf * (this.k1 + 1);
260
+ const denominator = this.avgDocLength > 0
261
+ ? tf + this.k1 * (1 - this.b + this.b * (doc.length / this.avgDocLength))
262
+ : tf + this.k1;
263
+ docScore += idf * (numerator / denominator);
264
+ }
265
+ scores.set(doc.id, docScore);
266
+ }
267
+ return scores;
268
+ }
269
+ }
270
+ /**
271
+ * Computes BM25 scores for a collection of file candidates.
272
+ *
273
+ * @param candidates List of file candidates
274
+ * @param queryTerms Query terms from prompt
275
+ * @param options BM25 parameters
276
+ * @returns Map of filePath to normalized BM25 score in [0.0, 1.0]
277
+ */
278
+ export function computeBM25Scores(candidates, queryTerms, options) {
279
+ const engine = new BM25Engine(options);
280
+ for (const candidate of candidates) {
281
+ engine.addDocument(candidate.filePath, candidate.content, candidate.filePath);
282
+ }
283
+ engine.build();
284
+ const rawScores = engine.score(queryTerms);
285
+ let maxScore = 0;
286
+ for (const score of rawScores.values()) {
287
+ if (score > maxScore)
288
+ maxScore = score;
289
+ }
290
+ const result = new Map();
291
+ for (const [filePath, raw] of rawScores.entries()) {
292
+ const normalized = maxScore > 0 ? raw / maxScore : 0;
293
+ result.set(filePath, { rawScore: raw, normalizedScore: normalized });
294
+ }
295
+ return result;
296
+ }
297
+ /**
298
+ * Computes TF-IDF scores for a collection of candidates as a lightweight alternative.
299
+ */
300
+ export function computeTfIdfScores(candidates, queryTerms) {
301
+ const totalDocs = candidates.length;
302
+ const docTokensMap = new Map();
303
+ const docFreq = new Map();
304
+ for (const candidate of candidates) {
305
+ const tokens = tokenize(`${candidate.filePath} ${candidate.content}`, true);
306
+ docTokensMap.set(candidate.filePath, tokens);
307
+ const unique = new Set(tokens);
308
+ for (const term of unique) {
309
+ docFreq.set(term, (docFreq.get(term) || 0) + 1);
310
+ }
311
+ }
312
+ const rawScores = new Map();
313
+ let maxScore = 0;
314
+ for (const candidate of candidates) {
315
+ const tokens = docTokensMap.get(candidate.filePath) || [];
316
+ const docLen = tokens.length || 1;
317
+ const tfMap = new Map();
318
+ for (const t of tokens) {
319
+ tfMap.set(t, (tfMap.get(t) || 0) + 1);
320
+ }
321
+ let score = 0;
322
+ for (const query of queryTerms) {
323
+ const tf = (tfMap.get(query) || 0) / docLen;
324
+ const df = docFreq.get(query) || 0;
325
+ if (df > 0) {
326
+ const idf = Math.log(1 + totalDocs / df);
327
+ score += tf * idf;
328
+ }
329
+ }
330
+ rawScores.set(candidate.filePath, score);
331
+ if (score > maxScore)
332
+ maxScore = score;
333
+ }
334
+ const result = new Map();
335
+ for (const [filePath, raw] of rawScores.entries()) {
336
+ const normalized = maxScore > 0 ? raw / maxScore : 0;
337
+ result.set(filePath, { rawScore: raw, normalizedScore: normalized });
338
+ }
339
+ return result;
340
+ }
341
+ // ─── Path & Filename Match Scoring ───────────────────────────────────
342
+ /**
343
+ * Calculates a relevance score for a file path based on matching query terms against
344
+ * the file's basename, directory segments, and file extension.
345
+ *
346
+ * @param filePath File path
347
+ * @param queryTerms Extracted query terms
348
+ * @returns Path match score between 0.0 and 1.0
349
+ */
350
+ export function calculatePathMatchScore(filePath, queryTerms) {
351
+ if (!filePath || queryTerms.length === 0)
352
+ return 0;
353
+ const basename = path.basename(filePath);
354
+ const basenameNoExt = path.parse(filePath).name;
355
+ const baseTokens = new Set(tokenize(basenameNoExt, false));
356
+ const fullPathTokens = new Set(tokenize(filePath, false));
357
+ let matchedBaseTerms = 0;
358
+ let matchedPathTerms = 0;
359
+ for (const term of queryTerms) {
360
+ const termLower = term.toLowerCase();
361
+ // Exact basename match (highest boost)
362
+ if (basenameNoExt.toLowerCase() === termLower || basename.toLowerCase() === termLower) {
363
+ return 1.0;
364
+ }
365
+ if (baseTokens.has(termLower)) {
366
+ matchedBaseTerms++;
367
+ }
368
+ else if (fullPathTokens.has(termLower)) {
369
+ matchedPathTerms++;
370
+ }
371
+ }
372
+ const totalTerms = queryTerms.length;
373
+ const baseScore = (matchedBaseTerms / totalTerms) * 0.8;
374
+ const pathScore = (matchedPathTerms / totalTerms) * 0.4;
375
+ return Math.min(1.0, baseScore + pathScore);
376
+ }
377
+ // ─── Centrality Extraction & Normalization ───────────────────────────
378
+ /**
379
+ * Extracts and normalizes centrality metrics for a list of candidate files.
380
+ *
381
+ * @param candidates List of candidate files
382
+ * @param graph Optional dependency graph
383
+ * @param customCentrality Optional pre-computed centrality map
384
+ * @returns Map of filePath to normalized centrality score in [0.0, 1.0]
385
+ */
386
+ export function computeCandidateCentralityScores(candidates, graph, customCentrality) {
387
+ const result = new Map();
388
+ let maxRawScore = 0;
389
+ for (const candidate of candidates) {
390
+ let metrics = { inDegree: 0, outDegree: 0, totalDegree: 0, score: 0 };
391
+ if (customCentrality?.has(candidate.filePath)) {
392
+ metrics = customCentrality.get(candidate.filePath);
393
+ }
394
+ else if (graph) {
395
+ metrics = graph.getCentrality(candidate.filePath);
396
+ }
397
+ if (metrics.score > maxRawScore) {
398
+ maxRawScore = metrics.score;
399
+ }
400
+ result.set(candidate.filePath, { metrics, normalizedScore: 0 });
401
+ }
402
+ for (const [filePath, entry] of result.entries()) {
403
+ const normalized = maxRawScore > 0 ? entry.metrics.score / maxRawScore : 0;
404
+ result.set(filePath, { metrics: entry.metrics, normalizedScore: normalized });
405
+ }
406
+ return result;
407
+ }
408
+ // ─── Token Estimator ─────────────────────────────────────────────────
409
+ /**
410
+ * Estimates token count from string length using a characters-per-token heuristic.
411
+ */
412
+ export function estimateTokens(text, charsPerToken = CONTEXT_BUDGET_CONFIG.ESTIMATED_CHARS_PER_TOKEN) {
413
+ if (!text)
414
+ return 0;
415
+ return Math.ceil(text.length / charsPerToken);
416
+ }
417
+ // ─── Main Ranking & 3-Tier Budget Allocation Engine ───────────────────
418
+ /**
419
+ * Core Context Ranker:
420
+ * Ranks candidate files via hybrid BM25 + Graph Centrality + Path Match scoring,
421
+ * then partitions them into Tier 1 (full source), Tier 2 (AST outline skeleton),
422
+ * and Tier 3 (omitted) within strict token budgets.
423
+ *
424
+ * @param files Array of candidate file objects or map of filePath -> content
425
+ * @param prompt User prompt or instruction
426
+ * @param options Ranking and budgeting options
427
+ * @returns Partitioned 3-Tier result with complete metadata and statistics
428
+ */
429
+ export function rankFilesForBudget(files, prompt, options) {
430
+ // 1. Normalize input candidates
431
+ let candidates = [];
432
+ if (Array.isArray(files)) {
433
+ candidates = files;
434
+ }
435
+ else if (files instanceof Map) {
436
+ for (const [filePath, content] of files.entries()) {
437
+ candidates.push({ filePath, content });
438
+ }
439
+ }
440
+ else if (typeof files === 'object' && files !== null) {
441
+ for (const [filePath, content] of Object.entries(files)) {
442
+ candidates.push({ filePath, content });
443
+ }
444
+ }
445
+ const charsPerToken = options?.charsPerToken ?? CONTEXT_BUDGET_CONFIG.ESTIMATED_CHARS_PER_TOKEN;
446
+ // Determine budgets
447
+ const defaultMaxChars = CONTEXT_BUDGET_CONFIG.DEFAULT_CONTEXT_MAX_CHARS;
448
+ const charBudget = typeof options?.charBudget === 'number' && options.charBudget > 0
449
+ ? options.charBudget
450
+ : typeof options?.tokenBudget === 'number' && options.tokenBudget > 0
451
+ ? options.tokenBudget * charsPerToken
452
+ : defaultMaxChars;
453
+ const tokenBudget = Math.floor(charBudget / charsPerToken);
454
+ const tier1Ratio = options?.tier1BudgetRatio ?? 0.7;
455
+ const tier2Ratio = options?.tier2BudgetRatio ?? 0.3;
456
+ const tier1CharBudget = Math.floor(charBudget * tier1Ratio);
457
+ const tier2CharBudget = Math.floor(charBudget * tier2Ratio);
458
+ const weights = {
459
+ bm25: options?.weights?.bm25 ?? 0.6,
460
+ centrality: options?.weights?.centrality ?? 0.25,
461
+ pathMatch: options?.weights?.pathMatch ?? 0.15,
462
+ };
463
+ // Normalize weight sum
464
+ const weightSum = weights.bm25 + weights.centrality + weights.pathMatch;
465
+ const normWeights = {
466
+ bm25: weightSum > 0 ? weights.bm25 / weightSum : 0.6,
467
+ centrality: weightSum > 0 ? weights.centrality / weightSum : 0.25,
468
+ pathMatch: weightSum > 0 ? weights.pathMatch / weightSum : 0.15,
469
+ };
470
+ if (candidates.length === 0) {
471
+ return {
472
+ tier1: [],
473
+ tier2: [],
474
+ tier3: [],
475
+ rankedFiles: [],
476
+ totalTokensUsed: 0,
477
+ totalCharsUsed: 0,
478
+ stats: {
479
+ totalCandidates: 0,
480
+ tier1Count: 0,
481
+ tier2Count: 0,
482
+ tier3Count: 0,
483
+ tokenBudget,
484
+ tokensUsed: 0,
485
+ charBudget,
486
+ charsUsed: 0,
487
+ budgetUtilization: 0,
488
+ },
489
+ };
490
+ }
491
+ // 2. Extract query terms from user prompt
492
+ const queryTerms = extractQueryTerms(prompt);
493
+ // 3. Compute BM25 scores
494
+ const bm25Map = computeBM25Scores(candidates, queryTerms, {
495
+ k1: options?.bm25K1,
496
+ b: options?.bm25B,
497
+ });
498
+ // 4. Compute Centrality scores
499
+ const centralityMap = computeCandidateCentralityScores(candidates, options?.graph, options?.centralityMetrics);
500
+ // 5. Precompute AST outlines & composite scores
501
+ const scoredFiles = candidates.map((candidate) => {
502
+ const bm25Info = bm25Map.get(candidate.filePath) || { rawScore: 0, normalizedScore: 0 };
503
+ const centralityInfo = centralityMap.get(candidate.filePath) || {
504
+ metrics: { inDegree: 0, outDegree: 0, totalDegree: 0, score: 0 },
505
+ normalizedScore: 0,
506
+ };
507
+ const pathMatchScore = calculatePathMatchScore(candidate.filePath, queryTerms);
508
+ // Compute AST outline skeleton
509
+ const outline = candidate.outline || extractDeclarationsOutline(candidate.content, candidate.filePath) || '';
510
+ let compositeScore = normWeights.bm25 * bm25Info.normalizedScore +
511
+ normWeights.centrality * centralityInfo.normalizedScore +
512
+ normWeights.pathMatch * pathMatchScore;
513
+ // Explicit targets receive top priority boost
514
+ if (candidate.isExplicitTarget) {
515
+ compositeScore += 10.0;
516
+ }
517
+ return {
518
+ filePath: candidate.filePath,
519
+ content: candidate.content,
520
+ outline,
521
+ tier: 3, // Default to tier 3, will be assigned during budget partitioning
522
+ score: compositeScore,
523
+ bm25Score: bm25Info.normalizedScore,
524
+ centralityScore: centralityInfo.normalizedScore,
525
+ pathMatchScore,
526
+ estimatedTokens: estimateTokens(candidate.content, charsPerToken),
527
+ estimatedChars: candidate.content.length,
528
+ isExplicitTarget: candidate.isExplicitTarget,
529
+ metadata: candidate.metadata,
530
+ };
531
+ });
532
+ // 6. Sort candidate files descending by composite relevance score
533
+ scoredFiles.sort((a, b) => b.score - a.score);
534
+ // 7. Partition into Tier 1 (Full Content), Tier 2 (AST Skeleton), and Tier 3 (Omitted)
535
+ const tier1 = [];
536
+ const tier2 = [];
537
+ const tier3 = [];
538
+ let currentTotalChars = 0;
539
+ let currentTier1Chars = 0;
540
+ let currentTier2Chars = 0;
541
+ for (const file of scoredFiles) {
542
+ const fullContentChars = file.content.length;
543
+ const outlineChars = file.outline.length || Math.min(fullContentChars, 200);
544
+ // Check if score is below minimal cutoff threshold (unless explicit target)
545
+ if (!file.isExplicitTarget && options?.minScoreThreshold && file.score < options.minScoreThreshold) {
546
+ file.tier = 3;
547
+ file.estimatedChars = 0;
548
+ file.estimatedTokens = 0;
549
+ tier3.push(file);
550
+ continue;
551
+ }
552
+ // Attempt Tier 1: Full content
553
+ // Criteria: Explicit targets or fits within remaining Tier 1 budget and overall budget
554
+ const fitsTier1 = file.isExplicitTarget ||
555
+ (currentTier1Chars + fullContentChars <= tier1CharBudget && currentTotalChars + fullContentChars <= charBudget);
556
+ if (fitsTier1 && currentTotalChars + fullContentChars <= charBudget) {
557
+ file.tier = 1;
558
+ file.estimatedChars = fullContentChars;
559
+ file.estimatedTokens = estimateTokens(file.content, charsPerToken);
560
+ tier1.push(file);
561
+ currentTier1Chars += fullContentChars;
562
+ currentTotalChars += fullContentChars;
563
+ continue;
564
+ }
565
+ // Attempt Tier 2: AST Outline Skeleton
566
+ // Criteria: Fits within Tier 2 budget and overall budget
567
+ const fitsTier2 = currentTier2Chars + outlineChars <= tier2CharBudget && currentTotalChars + outlineChars <= charBudget;
568
+ if (fitsTier2) {
569
+ file.tier = 2;
570
+ file.estimatedChars = outlineChars;
571
+ file.estimatedTokens = estimateTokens(file.outline, charsPerToken);
572
+ tier2.push(file);
573
+ currentTier2Chars += outlineChars;
574
+ currentTotalChars += outlineChars;
575
+ continue;
576
+ }
577
+ // If it doesn't fit in Tier 1 or Tier 2, it is assigned to Tier 3 (Omitted)
578
+ file.tier = 3;
579
+ file.estimatedChars = 0;
580
+ file.estimatedTokens = 0;
581
+ tier3.push(file);
582
+ }
583
+ const totalTokensUsed = Math.ceil(currentTotalChars / charsPerToken);
584
+ return {
585
+ tier1,
586
+ tier2,
587
+ tier3,
588
+ rankedFiles: scoredFiles,
589
+ totalTokensUsed,
590
+ totalCharsUsed: currentTotalChars,
591
+ stats: {
592
+ totalCandidates: scoredFiles.length,
593
+ tier1Count: tier1.length,
594
+ tier2Count: tier2.length,
595
+ tier3Count: tier3.length,
596
+ tokenBudget,
597
+ tokensUsed: totalTokensUsed,
598
+ charBudget,
599
+ charsUsed: currentTotalChars,
600
+ budgetUtilization: charBudget > 0 ? Math.min(1.0, currentTotalChars / charBudget) : 0,
601
+ },
602
+ };
603
+ }
@@ -1,9 +1,12 @@
1
- import { DependencyNode } from './types.js';
1
+ import { DependencyNode, CentralityMetrics } from './types.js';
2
2
  export interface DependencyGraph {
3
3
  getImports(filePath: string): string[];
4
4
  getImportedBy(filePath: string): string[];
5
5
  getReverseDependencyTree(filePath: string, maxDepth?: number): string[];
6
6
  getForwardDependencyTree(filePath: string, maxDepth?: number): string[];
7
+ getCentrality(filePath: string): CentralityMetrics;
8
+ getAllCentrality(): Map<string, CentralityMetrics>;
7
9
  readonly nodes: ReadonlyMap<string, DependencyNode>;
8
10
  }
11
+ export declare function computeGraphCentrality(nodes: ReadonlyMap<string, DependencyNode> | Map<string, DependencyNode>): Map<string, CentralityMetrics>;
9
12
  export declare function bfsTraverse(nodes: Map<string, DependencyNode>, startFile: string, direction: 'imports' | 'importedBy', maxDepth: number): string[];
@@ -1,3 +1,14 @@
1
+ export function computeGraphCentrality(nodes) {
2
+ const result = new Map();
3
+ for (const [file, node] of nodes.entries()) {
4
+ const inDegree = node.importedBy.size;
5
+ const outDegree = node.imports.size;
6
+ const totalDegree = inDegree + outDegree;
7
+ const score = inDegree * 1.5 + outDegree * 0.5;
8
+ result.set(file, { inDegree, outDegree, totalDegree, score });
9
+ }
10
+ return result;
11
+ }
1
12
  export function bfsTraverse(nodes, startFile, direction, maxDepth) {
2
13
  const visited = new Set();
3
14
  visited.add(startFile);
@@ -2,3 +2,13 @@ export interface DependencyNode {
2
2
  imports: Set<string>;
3
3
  importedBy: Set<string>;
4
4
  }
5
+ export interface CentralityMetrics {
6
+ /** Number of files that directly import this file (hub dependency) */
7
+ inDegree: number;
8
+ /** Number of files this file directly imports */
9
+ outDegree: number;
10
+ /** Total direct connections (inDegree + outDegree) */
11
+ totalDegree: number;
12
+ /** Centrality score (weighted: inDegree * 1.5 + outDegree * 0.5) */
13
+ score: number;
14
+ }