ziprin-context-optimizer 8.0.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 (56) hide show
  1. package/README.md +48 -0
  2. package/bin/ziprin-context-mcp.js +34 -0
  3. package/bin/ziprin-context-setup.js +10 -0
  4. package/install-source.json +5 -0
  5. package/media/context-activity.svg +7 -0
  6. package/package.json +154 -0
  7. package/releases/ziprin-context-optimizer-8.0.0.vsix +0 -0
  8. package/scripts/install.mjs +204 -0
  9. package/scripts/package-vsix.mjs +38 -0
  10. package/scripts/paths.mjs +40 -0
  11. package/scripts/publish.mjs +37 -0
  12. package/scripts/update.mjs +101 -0
  13. package/src/adaptive-budget.js +46 -0
  14. package/src/analyzer.js +456 -0
  15. package/src/audit-history.js +137 -0
  16. package/src/bm25.js +132 -0
  17. package/src/collector.js +343 -0
  18. package/src/compression.js +67 -0
  19. package/src/config.js +28 -0
  20. package/src/context-memory.js +151 -0
  21. package/src/context-profiles.js +124 -0
  22. package/src/dependency-graph.js +90 -0
  23. package/src/estimate.js +109 -0
  24. package/src/eval-harness.js +76 -0
  25. package/src/extension.js +322 -0
  26. package/src/firewall.js +35 -0
  27. package/src/fts-index.js +319 -0
  28. package/src/gateway-api.js +404 -0
  29. package/src/git-recency.js +43 -0
  30. package/src/glob.js +42 -0
  31. package/src/identifier.js +37 -0
  32. package/src/inspector-panel.js +470 -0
  33. package/src/language.js +157 -0
  34. package/src/mcp-event-stream.js +179 -0
  35. package/src/mcp-health-cli.js +34 -0
  36. package/src/mcp-lifecycle-manager.js +427 -0
  37. package/src/mcp-server.js +372 -0
  38. package/src/mmr.js +57 -0
  39. package/src/pagerank.js +176 -0
  40. package/src/profiles.js +74 -0
  41. package/src/pruner.js +130 -0
  42. package/src/quality-guard.js +122 -0
  43. package/src/query-rewrite.js +32 -0
  44. package/src/relevance-scorer.js +197 -0
  45. package/src/repo-map.js +134 -0
  46. package/src/retrieve.js +350 -0
  47. package/src/serena.js +126 -0
  48. package/src/session-ledger.js +83 -0
  49. package/src/session-store.js +78 -0
  50. package/src/skeleton.js +129 -0
  51. package/src/slice-pack.js +70 -0
  52. package/src/supervisor.js +113 -0
  53. package/src/task-analyzer.js +189 -0
  54. package/src/tool-router.js +92 -0
  55. package/src/version.js +5 -0
  56. package/templates/ziprin-context-mcp.mjs +68 -0
package/src/pruner.js ADDED
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { tokens } = require('./estimate');
5
+ const { getProfile } = require('./profiles');
6
+
7
+ /**
8
+ * Strong context-pruning algorithm for Cursor chat.
9
+ * Scores candidate files by task relevance and fits them under a token budget.
10
+ * Prefer tools (Serena/grep) over dumping; never invent contracts.
11
+ */
12
+
13
+ const TOKEN_BOMBS = [
14
+ /\.cursor\/agents\//,
15
+ /\.cursor\/skills\//,
16
+ /\.cursor\/_tmp/,
17
+ /tooling\/project-index\.json$/,
18
+ /project-must-follow-catalog\.json$/,
19
+ /\/reports\/.*\.json$/,
20
+ /node_modules\//,
21
+ /\.git\//,
22
+ /\.vsix$/,
23
+ ];
24
+
25
+ const HIGH_VALUE = [
26
+ /\/src\//,
27
+ /packages\/(ui|shared)\//,
28
+ /AGENTS\.md$/,
29
+ /ziprin-supervisor\.mdc$/,
30
+ /\.policy\.json$/,
31
+ /auth\/contracts\//,
32
+ ];
33
+
34
+ function isTokenBomb(rel) {
35
+ const r = rel.replace(/\\/g, '/');
36
+ return TOKEN_BOMBS.some((re) => re.test(r));
37
+ }
38
+
39
+ function baseScore(rel, query = '') {
40
+ const r = rel.replace(/\\/g, '/');
41
+ let s = 0;
42
+ if (isTokenBomb(r)) return -1000;
43
+ for (const re of HIGH_VALUE) if (re.test(r)) s += 30;
44
+ if (/\.(ts|tsx|js|jsx)$/.test(r)) s += 10;
45
+ if (/\.(mdc|md)$/.test(r) && !r.includes('.cursor/agents') && !r.includes('.cursor/skills')) s += 5;
46
+ if (query) {
47
+ const q = query.toLowerCase();
48
+ const parts = q.split(/[^a-z0-9_/-]+/).filter(Boolean);
49
+ for (const p of parts) {
50
+ if (p.length < 2) continue;
51
+ if (r.toLowerCase().includes(p)) s += 25;
52
+ }
53
+ }
54
+ // Prefer shallow public APIs
55
+ if (/\/index\.(ts|tsx)$/.test(r)) s += 8;
56
+ if (/\/(test|spec|__tests__)\//.test(r)) s -= 15;
57
+ return s;
58
+ }
59
+
60
+ /**
61
+ * @param {Array<{path:string, bytes:number}>} candidates
62
+ * @param {{query?:string, profileId?:string, maxTokens?:number}} opts
63
+ */
64
+ function pruneCandidates(candidates, opts = {}) {
65
+ const profile = getProfile(opts.profileId || 'standard');
66
+ const budget = opts.maxTokens ?? profile.maxOnDemandTokens;
67
+ const cpt = profile.charsPerToken || 4;
68
+ const query = opts.query || '';
69
+
70
+ const scored = candidates
71
+ .map((c) => {
72
+ const rel = c.path.replace(/\\/g, '/');
73
+ const score = baseScore(rel, query);
74
+ const tok = tokens(c.bytes || 0, cpt);
75
+ return { path: rel, bytes: c.bytes || 0, tokens: tok, score };
76
+ })
77
+ .filter((c) => c.score > -500)
78
+ .sort((a, b) => b.score - a.score || a.tokens - b.tokens);
79
+
80
+ const selected = [];
81
+ let used = 0;
82
+ const rejected = [];
83
+ for (const c of scored) {
84
+ if (c.tokens <= 0) continue;
85
+ if (used + c.tokens > budget) {
86
+ rejected.push({ ...c, reason: 'budget' });
87
+ continue;
88
+ }
89
+ selected.push(c);
90
+ used += c.tokens;
91
+ }
92
+
93
+ return {
94
+ profile: profile.id,
95
+ budget,
96
+ usedTokens: used,
97
+ selected,
98
+ rejected,
99
+ toolsFirst: profile.preferToolsFirst
100
+ ? ['serena.find_symbol', 'grep', 'read_file', 'ziprinSuperGuard.scanCurrentFile']
101
+ : [],
102
+ qualityNote:
103
+ 'Pruner keeps high-score product paths under budget; token bombs stay out. Tools-first avoids dumping folders.',
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Build a chat context plan from a natural-language task.
109
+ */
110
+ function planChatContext(task, candidates, opts = {}) {
111
+ const profile = getProfile(opts.profileId || 'standard');
112
+ const pruned = pruneCandidates(candidates, { ...opts, query: task, profileId: profile.id });
113
+ const steps = [];
114
+ if (profile.preferSerena) {
115
+ steps.push({ action: 'tool', tool: 'serena', why: 'Symbol-level lookup before file dump' });
116
+ }
117
+ if (profile.preferToolsFirst) {
118
+ steps.push({ action: 'tool', tool: 'grep', why: 'Locate exact references without loading whole trees' });
119
+ }
120
+ steps.push({ action: 'read', files: pruned.selected.slice(0, 12).map((s) => s.path), why: 'Budgeted relevant sources' });
121
+ if (!profile.allowAgentsInChat) {
122
+ steps.push({ action: 'skip', what: '.cursor/agents/**', why: 'Agents live in Super Guard pack; not chat context' });
123
+ }
124
+ if (!profile.allowSkillsInChat) {
125
+ steps.push({ action: 'skip', what: '.cursor/skills/**', why: 'Skills invoked on demand via routing, not always-loaded' });
126
+ }
127
+ return { task, profile: profile.id, steps, pruned };
128
+ }
129
+
130
+ module.exports = { isTokenBomb, baseScore, pruneCandidates, planChatContext, TOKEN_BOMBS, HIGH_VALUE };
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Context Quality Guard — prevent aggressive pruning mistakes.
5
+ * Restores explicitly mentioned symbols, paths, and domain-relevant files.
6
+ */
7
+
8
+ function qualityCheck(opts) {
9
+ const {
10
+ taskTokens = [],
11
+ selected = [],
12
+ restored = [],
13
+ scores = [],
14
+ budget,
15
+ usedTokens,
16
+ symbols = [],
17
+ pathHints = [],
18
+ domain = null,
19
+ } = opts;
20
+
21
+ const issues = [];
22
+ const actions = [];
23
+
24
+ // Explicit path mentions should be present
25
+ for (const t of taskTokens) {
26
+ if (!/\.(ts|tsx|js|jsx|mdc|md)$/.test(t) && !t.includes('/')) continue;
27
+ const hit = selected.some((p) => p.includes(t) || t.includes(p));
28
+ if (!hit) {
29
+ const candidate = scores.find((s) => s.path.includes(t));
30
+ if (candidate) {
31
+ issues.push(`Mentioned path missing: ${t}`);
32
+ actions.push({ condition: 'Removing file may reduce answer accuracy', action: 'keep file', path: candidate.path });
33
+ }
34
+ }
35
+ }
36
+
37
+ // Symbol names mentioned in prompt must be in selected files
38
+ for (const sym of symbols) {
39
+ const symLower = sym.toLowerCase();
40
+ const symKebab = sym.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
41
+ const hit = selected.some(
42
+ (p) => p.toLowerCase().includes(symLower) || p.toLowerCase().includes(symKebab),
43
+ );
44
+ if (!hit) {
45
+ const candidate = scores.find(
46
+ (s) => s.path.toLowerCase().includes(symLower) || s.path.toLowerCase().includes(symKebab),
47
+ );
48
+ if (candidate) {
49
+ issues.push(`Mentioned symbol missing: ${sym}`);
50
+ actions.push({ condition: 'Mentioned symbol file excluded', action: 'keep file', path: candidate.path });
51
+ }
52
+ }
53
+ }
54
+
55
+ // Path hints from prompt must be in selected files
56
+ for (const hint of pathHints) {
57
+ const hit = selected.some((p) => p.toLowerCase().includes(hint));
58
+ if (!hit) {
59
+ const candidate = scores.find((s) => s.path.toLowerCase().includes(hint));
60
+ if (candidate) {
61
+ issues.push(`Mentioned feature path missing: ${hint}`);
62
+ actions.push({ condition: 'Feature path excluded', action: 'keep file', path: candidate.path });
63
+ }
64
+ }
65
+ }
66
+
67
+ // Domain mismatch: if domain is set, at least one selected file should be in priority paths
68
+ if (domain && domain.priorityPaths && domain.priorityPaths.length > 0) {
69
+ const domainHit = selected.some((p) =>
70
+ domain.priorityPaths.some((dp) => p.toLowerCase().includes(dp.toLowerCase())),
71
+ );
72
+ if (!domainHit) {
73
+ issues.push(`Task domain mismatch: ${domain.label} files missing`);
74
+ const candidates = scores
75
+ .filter((s) => domain.priorityPaths.some((dp) => s.path.toLowerCase().includes(dp.toLowerCase())))
76
+ .slice(0, 5);
77
+ for (const c of candidates) {
78
+ actions.push({ condition: 'Domain-relevant file excluded', action: 'keep file', path: c.path });
79
+ }
80
+ }
81
+ }
82
+
83
+ if (restored.length) {
84
+ for (const p of restored) {
85
+ actions.push({ condition: 'Required dependency missing', action: 'restore dependency', path: p });
86
+ }
87
+ }
88
+
89
+ if (selected.length === 0) {
90
+ issues.push('No files selected — restoring top scoring sources');
91
+ const top = scores.filter((s) => s.final_score > 0).slice(0, 5).map((s) => s.path);
92
+ actions.push({ condition: 'Empty context', action: 'keep file', paths: top });
93
+ }
94
+
95
+ const tokenPressure = budget ? usedTokens / budget : 0;
96
+ const ok =
97
+ selected.length > 0 &&
98
+ issues.filter((i) => i.startsWith('Mentioned') || i.startsWith('Task domain')).length === 0;
99
+
100
+ return {
101
+ ok,
102
+ issues,
103
+ actions,
104
+ tokenPressure,
105
+ qualityNote: ok
106
+ ? 'Quality guard: selected set can plausibly answer the task (heuristic).'
107
+ : 'Quality guard: weak context — restored dependencies / mentioned paths.',
108
+ };
109
+ }
110
+
111
+ function applyQualityActions(selected, scores, quality) {
112
+ const set = new Set(selected);
113
+ for (const a of quality.actions || []) {
114
+ if (a.path) set.add(a.path);
115
+ if (a.paths) for (const p of a.paths) set.add(p);
116
+ }
117
+ // keep order by score
118
+ const order = new Map(scores.map((s, i) => [s.path, i]));
119
+ return [...set].sort((a, b) => (order.get(a) ?? 999) - (order.get(b) ?? 999));
120
+ }
121
+
122
+ module.exports = { qualityCheck, applyQualityActions };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Rewrite vague FA/EN prompts using last-session domain + synonyms.
5
+ */
6
+
7
+ const VAGUE_RE =
8
+ /^(درستش\s*کن|فیکس\s*کن|باگ\s*داره|خراب\s*شده|اینو\s*درست\s*کن|درستش\s*کن\s*لطفا|fix\s*it|it'?s\s*broken|broken|help|؟+)$/i;
9
+
10
+ function rewriteQuery(prompt, memory = {}) {
11
+ const p = String(prompt || '').trim();
12
+ const vague = VAGUE_RE.test(p);
13
+ if (!vague) return { rewritten: p, vague: false, extra: [] };
14
+
15
+ const extra = [];
16
+ const lastDomain = memory.lastDomain || memory.domainHits && Object.keys(memory.domainHits).sort(
17
+ (a, b) => (memory.domainHits[b] || 0) - (memory.domainHits[a] || 0),
18
+ )[0];
19
+ if (lastDomain === 'marketplace' || /marketplace/.test(lastDomain || '')) extra.push('marketplace', 'cart', 'checkout');
20
+ if (lastDomain === 'bss_vendor' || /vendor/.test(lastDomain || '')) extra.push('vendor', 'add-product', 'product-status');
21
+ if (lastDomain === 'bss_dashboard') extra.push('dashboard', 'bss', 'ticket');
22
+
23
+ const lastFiles = Object.keys(memory.pathBoost || {})
24
+ .sort((a, b) => (memory.pathBoost[b] || 0) - (memory.pathBoost[a] || 0))
25
+ .slice(0, 3);
26
+ extra.push(...lastFiles.map((f) => f.split('/').slice(-2).join(' ')));
27
+
28
+ const rewritten = [p, ...extra.filter(Boolean)].join(' ').trim();
29
+ return { rewritten, vague: true, extra };
30
+ }
31
+
32
+ module.exports = { rewriteQuery, VAGUE_RE };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { isTokenBomb } = require('./pruner');
5
+
6
+ /**
7
+ * Relevance Scoring Engine — confidence scoring, never blind delete.
8
+ * Explicit prompt signals (symbols, paths, domains) get highest priority.
9
+ */
10
+
11
+ function pathKeywords(rel) {
12
+ return String(rel || '')
13
+ .toLowerCase()
14
+ .replace(/\\/g, '/')
15
+ .split(/[\/._-]+/)
16
+ .filter((p) => p.length > 1);
17
+ }
18
+
19
+ function scoreFile(rel, opts = {}) {
20
+ const r = String(rel || '').replace(/\\/g, '/');
21
+ const rLower = r.toLowerCase();
22
+ const taskTokens = opts.taskTokens || [];
23
+ const mentioned = opts.mentionedPaths || [];
24
+ const deps = opts.dependencyBoost || new Set();
25
+ const memoryBoost = opts.memoryBoost || {};
26
+ const recentSet = opts.recentPaths || new Set();
27
+ const bytes = opts.bytes || 0;
28
+ const category = opts.category || 'mixed';
29
+ const domain = opts.domain || null;
30
+ const symbols = opts.symbols || [];
31
+ const pathHints = opts.pathHints || [];
32
+
33
+ const breakdown = {
34
+ task_match: 0,
35
+ explicit_mention: 0,
36
+ symbol_match: 0,
37
+ path_hint_match: 0,
38
+ domain_match: 0,
39
+ import_relation: 0,
40
+ recent_change: 0,
41
+ folder_importance: 0,
42
+ architecture_importance: 0,
43
+ historical_usefulness: 0,
44
+ size_penalty: 0,
45
+ generated_penalty: 0,
46
+ category_fit: 0,
47
+ };
48
+
49
+ if (isTokenBomb(r)) {
50
+ return { path: r, final_score: -1000, breakdown: { ...breakdown, generated_penalty: -1000 }, keep: false };
51
+ }
52
+
53
+ const fileParts = pathKeywords(r);
54
+
55
+ // --- Explicit signal boosts (highest priority) ---
56
+
57
+ // Symbol match: PascalCase symbol names in prompt vs file path/name
58
+ for (const sym of symbols) {
59
+ const symLower = sym.toLowerCase();
60
+ const symKebab = sym.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
61
+ if (rLower.includes(symLower) || rLower.includes(symKebab)) {
62
+ breakdown.symbol_match = 100;
63
+ break;
64
+ }
65
+ }
66
+
67
+ // Path hint match: "product-status", "vendor/product-status" etc.
68
+ const wantsAddProduct = pathHints.includes('add-product');
69
+ const wantsProductStatus = pathHints.includes('product-status');
70
+ for (const hint of pathHints) {
71
+ if (rLower.includes(hint)) {
72
+ breakdown.path_hint_match = Math.max(breakdown.path_hint_match, 90);
73
+ }
74
+ }
75
+ if (wantsAddProduct && !wantsProductStatus && rLower.includes('product-status')) {
76
+ breakdown.path_hint_match = Math.min(breakdown.path_hint_match, -30);
77
+ }
78
+ if (wantsAddProduct && rLower.includes('add-product')) {
79
+ breakdown.path_hint_match = Math.max(breakdown.path_hint_match, 95);
80
+ if (/\.api\.(ts|tsx)$/.test(rLower) || /\.mapper\.(ts|tsx)$/.test(rLower)) {
81
+ breakdown.path_hint_match = Math.max(breakdown.path_hint_match, 98);
82
+ }
83
+ if (/\.types\.(ts|tsx)$/.test(rLower)) {
84
+ breakdown.category_fit -= 15;
85
+ }
86
+ }
87
+
88
+ // Domain match: BSS vs marketplace priority paths
89
+ if (domain && domain.priorityPaths) {
90
+ for (const dp of domain.priorityPaths) {
91
+ if (rLower.startsWith(dp.toLowerCase()) || rLower.includes(dp.toLowerCase())) {
92
+ breakdown.domain_match = 70;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+
98
+ // --- Standard factors ---
99
+
100
+ for (const t of taskTokens) {
101
+ if (t.length < 3) continue;
102
+ if (rLower.includes(t) || fileParts.includes(t)) breakdown.task_match += 8;
103
+ }
104
+ breakdown.task_match = Math.min(40, breakdown.task_match);
105
+
106
+ for (const m of mentioned) {
107
+ if (r.includes(String(m).replace(/\\/g, '/'))) breakdown.explicit_mention = 50;
108
+ }
109
+
110
+ const depHit =
111
+ (deps instanceof Set && (deps.has(r) || [...deps].some((d) => r === d || r.startsWith(String(d) + '/')))) ||
112
+ (deps && typeof deps === 'object' && !Array.isArray(deps) && (deps[r] || Object.keys(deps).some((d) => r === d || r.startsWith(d + '/'))));
113
+ if (depHit) breakdown.import_relation = 30;
114
+ if (recentSet.has(r)) breakdown.recent_change = 20;
115
+
116
+ if (/\/src\//.test(r)) breakdown.folder_importance += 10;
117
+ if (/packages\/(ui|shared)\//.test(r)) breakdown.folder_importance += 8;
118
+ if (/ziprin-supervisor\.mdc$|\.policy\.json$/.test(r)) {
119
+ breakdown.architecture_importance += 12;
120
+ }
121
+ if (/AGENTS\.md$/.test(r)) breakdown.generated_penalty -= 30;
122
+ if (/\/index\.(ts|tsx)$/.test(r) && /packages\//.test(r)) breakdown.size_penalty -= 15;
123
+
124
+ if (category === 'frontend') {
125
+ if (/\.(tsx|css|module\.css)$/.test(r) || /packages\/ui\//.test(r)) breakdown.category_fit += 15;
126
+ if (/\/server\//.test(r) || /migration|database/.test(r)) breakdown.category_fit -= 20;
127
+ }
128
+ if (category === 'backend') {
129
+ if (/\/server\/|service|schema|repository|api/.test(r) || /packages\/shared\//.test(r)) {
130
+ breakdown.category_fit += 15;
131
+ }
132
+ if (/\.tsx$|\.css$/.test(r)) breakdown.category_fit -= 15;
133
+ }
134
+ if (category === 'architecture') {
135
+ if (/\.mdc$|governance|architecture|supervisor/.test(r)) breakdown.category_fit += 20;
136
+ }
137
+
138
+ const mem = memoryBoost[r] || memoryBoost[path.dirname(r)] || 0;
139
+ breakdown.historical_usefulness = Math.min(25, mem);
140
+
141
+ if (bytes > 80_000) breakdown.size_penalty -= 20;
142
+ else if (bytes > 30_000) breakdown.size_penalty -= 10;
143
+
144
+ if (/report|generated|seed|fixture|mock-data|catalog-data/i.test(r)) {
145
+ breakdown.generated_penalty -= 40;
146
+ }
147
+ const action = opts.action || '';
148
+ const promptText = String(opts.prompt || '').toLowerCase();
149
+ if (/\/(test|spec|__tests__)\//.test(r) || /\.(test|spec)\./.test(r)) {
150
+ if (/test|spec|unit|vitest|jest/.test(promptText)) breakdown.category_fit += 8;
151
+ else breakdown.generated_penalty -= 25;
152
+ }
153
+ if (/\/public\//.test(r)) breakdown.generated_penalty -= 50;
154
+
155
+ const final_score = Object.values(breakdown).reduce((a, b) => a + b, 0);
156
+ const confidence = Math.min(100, Math.max(0, Math.round(final_score)));
157
+ const whySelected = explainScore(breakdown, r);
158
+ return {
159
+ path: r,
160
+ final_score,
161
+ breakdown,
162
+ keep: final_score >= 15,
163
+ confidence,
164
+ whySelected,
165
+ };
166
+ }
167
+
168
+ function explainScore(breakdown, rel) {
169
+ const reasons = [];
170
+ if (breakdown.symbol_match) reasons.push('symbol match');
171
+ if (breakdown.path_hint_match) reasons.push('feature path match');
172
+ if (breakdown.domain_match) reasons.push('domain match');
173
+ if (breakdown.explicit_mention) reasons.push('explicit path mention');
174
+ if (breakdown.import_relation) reasons.push('import dependency');
175
+ if (breakdown.task_match) reasons.push('task keyword');
176
+ if (breakdown.historical_usefulness) reasons.push('historical success');
177
+ if (breakdown.folder_importance) reasons.push('source folder');
178
+ if (breakdown.generated_penalty <= -40) reasons.push('generated/noise penalty');
179
+ if (breakdown.size_penalty) reasons.push('large file penalty');
180
+ if (!reasons.length) reasons.push('low signal');
181
+ return `${rel}: ${reasons.join(', ')} (score factors)`;
182
+ }
183
+
184
+ function explainRemoval(breakdown, final_score, reason) {
185
+ if (reason) return reason;
186
+ if (final_score < 15) return 'low relevance score';
187
+ if (breakdown.generated_penalty <= -40) return 'generated or fixture file';
188
+ return 'budget or pruning';
189
+ }
190
+
191
+ function scoreCandidates(files, opts) {
192
+ return files
193
+ .map((f) => scoreFile(f.path, { ...opts, bytes: f.bytes }))
194
+ .sort((a, b) => b.final_score - a.final_score);
195
+ }
196
+
197
+ module.exports = { scoreFile, scoreCandidates, pathKeywords, explainScore, explainRemoval };
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { extractImports } = require('./dependency-graph');
6
+ const { isSourceFile } = require('./collector');
7
+
8
+ const REPO_MAP_REL = '.cursor/context-gateway-repo-map.json';
9
+ const EXPORT_RE = /^export\s+(?:async\s+)?(?:function|class|const|type|interface|enum)\s+([A-Za-z0-9_]+)/gm;
10
+
11
+ const DOMAIN_OWNERSHIP = [
12
+ { id: 'bss_vendor', prefix: 'apps/dashboard/bss/src/portals/vendor' },
13
+ { id: 'bss_dashboard', prefix: 'apps/dashboard/bss/src' },
14
+ { id: 'marketplace', prefix: 'apps/marketplace/src' },
15
+ { id: 'shared', prefix: 'packages/shared/src' },
16
+ { id: 'ui', prefix: 'packages/ui/src' },
17
+ ];
18
+
19
+ function repoMapPath(root) {
20
+ return path.join(root, REPO_MAP_REL);
21
+ }
22
+
23
+ function domainForPath(rel) {
24
+ const r = String(rel || '').replace(/\\/g, '/');
25
+ for (const d of DOMAIN_OWNERSHIP) {
26
+ if (r.startsWith(d.prefix)) return d.id;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function extractSymbolsFromFile(text) {
32
+ const syms = new Set();
33
+ EXPORT_RE.lastIndex = 0;
34
+ let m;
35
+ while ((m = EXPORT_RE.exec(text))) syms.add(m[1]);
36
+ return [...syms];
37
+ }
38
+
39
+ function buildRepoMap(root, seedPaths = [], maxFiles = 120) {
40
+ const entries = {};
41
+ const edges = [];
42
+ const queue = [...new Set(seedPaths)].slice(0, maxFiles);
43
+ const seen = new Set();
44
+
45
+ while (queue.length && seen.size < maxFiles) {
46
+ const rel = queue.shift();
47
+ if (!rel || seen.has(rel)) continue;
48
+ seen.add(rel);
49
+ if (!isSourceFile(rel)) continue;
50
+
51
+ const abs = path.join(root, rel);
52
+ let text = '';
53
+ let mtime = 0;
54
+ try {
55
+ const st = fs.statSync(abs);
56
+ mtime = st.mtimeMs;
57
+ text = fs.readFileSync(abs, 'utf8').slice(0, 80_000);
58
+ } catch {
59
+ continue;
60
+ }
61
+
62
+ const symbols = extractSymbolsFromFile(text);
63
+ const imports = extractImports(text);
64
+ entries[rel] = {
65
+ domain: domainForPath(rel),
66
+ symbols,
67
+ mtime,
68
+ importCount: imports.length,
69
+ };
70
+
71
+ for (const spec of imports.slice(0, 20)) {
72
+ edges.push({ from: rel, spec });
73
+ }
74
+ }
75
+
76
+ return {
77
+ version: 1,
78
+ builtAt: new Date().toISOString(),
79
+ fileCount: Object.keys(entries).length,
80
+ entries,
81
+ edges: edges.slice(0, 200),
82
+ };
83
+ }
84
+
85
+ function loadRepoMap(root) {
86
+ const file = repoMapPath(root);
87
+ if (!fs.existsSync(file)) return null;
88
+ try {
89
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function saveRepoMap(root, map) {
96
+ fs.mkdirSync(path.dirname(repoMapPath(root)), { recursive: true });
97
+ fs.writeFileSync(repoMapPath(root), JSON.stringify(map, null, 2), 'utf8');
98
+ }
99
+
100
+ function getOrBuildRepoMap(root, seedPaths) {
101
+ const cached = loadRepoMap(root);
102
+ const ageMs = cached?.builtAt ? Date.now() - Date.parse(cached.builtAt) : Infinity;
103
+ if (cached && ageMs < 3600_000 && cached.fileCount > 0) return cached;
104
+ const map = buildRepoMap(root, seedPaths);
105
+ saveRepoMap(root, map);
106
+ return map;
107
+ }
108
+
109
+ function symbolPathsFromMap(map, symbol) {
110
+ if (!map?.entries || !symbol) return [];
111
+ const symLower = symbol.toLowerCase();
112
+ const kebab = symbol.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
113
+ return Object.entries(map.entries)
114
+ .filter(([, e]) => e.symbols?.some((s) => s.toLowerCase() === symLower || s === symbol))
115
+ .map(([p]) => p)
116
+ .concat(
117
+ Object.keys(map.entries).filter((p) => {
118
+ const pl = p.toLowerCase();
119
+ return pl.includes(symLower) || pl.includes(kebab);
120
+ }),
121
+ )
122
+ .filter((p, i, a) => a.indexOf(p) === i)
123
+ .slice(0, 10);
124
+ }
125
+
126
+ module.exports = {
127
+ REPO_MAP_REL,
128
+ buildRepoMap,
129
+ loadRepoMap,
130
+ saveRepoMap,
131
+ getOrBuildRepoMap,
132
+ domainForPath,
133
+ symbolPathsFromMap,
134
+ };