ctxman 3.1.1 → 3.2.8

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 (55) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +109 -10
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-count.js +45 -6
  7. package/bin/cm-diagnose.js +199 -0
  8. package/bin/cm-focus.js +322 -0
  9. package/bin/cm-map.js +71 -18
  10. package/bin/cm-pack.js +36 -5
  11. package/bin/cm-policy.js +107 -2
  12. package/bin/cm-pr.js +18 -3
  13. package/bin/cm-search.js +84 -19
  14. package/bin/cm-task.js +192 -0
  15. package/bin/cm-who.js +119 -0
  16. package/bin/cm-why.js +41 -5
  17. package/index.d.ts +940 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/token-calculator.js +76 -4
  20. package/lib/api/mcp/server.js +843 -29
  21. package/lib/cache/CacheManager.js +161 -47
  22. package/lib/core/Analyzer.js +61 -5
  23. package/lib/count/index.js +74 -14
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/policy/audit.js +3 -1
  34. package/lib/policy/index.js +5 -0
  35. package/lib/policy/workspace.js +254 -0
  36. package/lib/pr/pr-context.js +292 -31
  37. package/lib/pr/pr-renderer.js +25 -10
  38. package/lib/rag/EmbeddingProvider.js +139 -2
  39. package/lib/rag/VectorStoreFactory.js +10 -4
  40. package/lib/rag/ask.js +186 -17
  41. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  42. package/lib/search/hybrid.js +324 -0
  43. package/lib/search/symbols.js +75 -11
  44. package/lib/task/index.js +630 -0
  45. package/lib/utils/file-utils.js +1 -0
  46. package/lib/utils/multi-repo-manager.js +4 -1
  47. package/lib/watch/FileWatcher.js +121 -31
  48. package/lib/watch/IncrementalAnalyzer.js +964 -16
  49. package/lib/who/index.js +893 -0
  50. package/lib/why/analyzer-bridge.js +223 -0
  51. package/lib/why/discussions.js +314 -0
  52. package/lib/why/index.js +356 -30
  53. package/lib/why/render.js +154 -9
  54. package/lib/workspace/index.js +1274 -0
  55. package/package.json +1 -1
@@ -0,0 +1,1274 @@
1
+ /**
2
+ * Multi-Repository Workspace Context (`--repo <dir>`)
3
+ *
4
+ * Aggregates multiple repositories under `<repoAlias>/<relativePath>` namespaces while
5
+ * sharing a single global token budget across:
6
+ * - `buildWorkspaceGraph(repos, options)` -> unified cross-repo dependency & AST symbol call graph
7
+ * - `mapWorkspace(repos, options)` -> unified ranked repository map
8
+ * - `searchWorkspace(repos, query, options)` -> unified BM25 chunk search
9
+ * - `packWorkspace(repos, options)` -> unified packed LLM document (xml/markdown/plain/json)
10
+ *
11
+ * Every repository's own `.gitignore`, `.contextignore`, `.contextinclude`, and
12
+ * `.ctxman/policy.json` rules are enforced independently before merging.
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import MethodAnalyzer from '../analyzers/method-analyzer.js';
18
+ import { Scanner } from '../core/Scanner.js';
19
+ import TokenUtils from '../utils/token-utils.js';
20
+ import { LLMDetector } from '../utils/llm-detector.js';
21
+ import { redactSecrets } from '../utils/secret-redactor.js';
22
+ import { loadPolicy } from '../policy/index.js';
23
+ import { enforceExport } from '../policy/enforce.js';
24
+ import {
25
+ rankFiles,
26
+ resolveFocus,
27
+ renderBlock,
28
+ renderGraph,
29
+ normalizeMapOptions,
30
+ } from '../map/index.js';
31
+ import { signatureAt, mineHistory, DEFAULT_HISTORY, DEFAULT_MAX_FILE_BYTES } from '../map/graph.js';
32
+ import { languageOf, extractImports, extractDeclarations } from '../map/imports.js';
33
+ import { ImportResolver } from '../map/resolver.js';
34
+ import { extractFileCallSites, buildCallGraph } from '../map/call-graph.js';
35
+ import {
36
+ BM25Index,
37
+ tokenize,
38
+ getProjectIndex,
39
+ sliceLines,
40
+ fitToTokenBudget,
41
+ compareStrings,
42
+ DEFAULT_LIMIT,
43
+ DEFAULT_MAX_TOKENS,
44
+ WINDOW_LINES,
45
+ } from '../search/bm25.js';
46
+ import { selectFiles, PackError } from '../pack/select.js';
47
+ import { compressCode } from '../pack/compress.js';
48
+ import { buildTree } from '../pack/tree.js';
49
+ import { rankEntries, fitToBudget } from '../pack/budget.js';
50
+ import { splitIntoParts } from '../pack/split.js';
51
+ import { getRenderer, STYLES as PACK_STYLES } from '../pack/renderers/index.js';
52
+ import { addLineNumbers, DEFAULT_MAX_FILE_SIZE } from '../pack/index.js';
53
+
54
+ const toPosix = (p) => String(p).split(path.sep).join('/');
55
+ const countMapTokens = (text) => (text ? TokenUtils.calculate(text, 'map.txt') : 0);
56
+ const round = (value, digits) => Number(value.toFixed(digits));
57
+
58
+ const MARKDOWN_MAP_TITLE = '# Workspace repository map';
59
+ const MIN_BLOCK_TOKENS = 4;
60
+ const CHARS_PER_TOKEN_CEILING = 10;
61
+ const OMITTED_LISTED = 20;
62
+ const BINARY_SNIFF_BYTES = 8000;
63
+ const MINIFIED_LINE_LENGTH = 300;
64
+ const IDENTIFIER = /[A-Za-z_$][\w$]*/g;
65
+
66
+ function sanitizeAlias(raw) {
67
+ const cleaned = String(raw || 'repo')
68
+ .trim()
69
+ .replace(/[\\/]+/g, '-')
70
+ .replace(/^\.+/, '');
71
+ return cleaned || 'repo';
72
+ }
73
+
74
+ function isPreResolvedRepos(repos) {
75
+ return (
76
+ Array.isArray(repos) &&
77
+ repos.length > 0 &&
78
+ repos.every(
79
+ (r) =>
80
+ r &&
81
+ typeof r === 'object' &&
82
+ typeof r.alias === 'string' &&
83
+ typeof r.root === 'string' &&
84
+ typeof r.realRoot === 'string' &&
85
+ 'policy' in r
86
+ )
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Resolve, validate, deduplicate, and assign unique aliases to workspace repositories.
92
+ * Loads each repository's export policy immediately so invalid policy configs fail fast.
93
+ *
94
+ * @param {Array<string|{path: string, alias?: string}>} repos
95
+ * @returns {Array<{alias: string, root: string, realRoot: string, policy: import('../policy/policy.js').Policy}>}
96
+ */
97
+ export function resolveWorkspaceRepos(repos) {
98
+ if (isPreResolvedRepos(repos)) {
99
+ return repos;
100
+ }
101
+ if (!Array.isArray(repos) || repos.length === 0) {
102
+ throw new Error('At least one repository path is required');
103
+ }
104
+
105
+ const rawEntries = [];
106
+ const seenRealRoots = new Set();
107
+
108
+ for (const item of repos) {
109
+ const rawPath = typeof item === 'string' ? item : item?.path;
110
+ const explicitAlias = typeof item === 'object' && item !== null ? item.alias : undefined;
111
+ if (typeof rawPath !== 'string' || !rawPath.trim()) {
112
+ throw new Error('Repository path must be a non-empty string');
113
+ }
114
+ const resolved = path.resolve(rawPath);
115
+ if (!fs.statSync(resolved, { throwIfNoEntry: false })?.isDirectory()) {
116
+ throw new Error(`Not a directory: ${rawPath}`);
117
+ }
118
+ const realRoot = fs.realpathSync(resolved);
119
+ if (seenRealRoots.has(realRoot)) continue;
120
+ seenRealRoots.add(realRoot);
121
+
122
+ const policy = loadPolicy(resolved);
123
+ rawEntries.push({
124
+ root: resolved,
125
+ realRoot,
126
+ explicitAlias: explicitAlias ? sanitizeAlias(explicitAlias) : null,
127
+ baseName: sanitizeAlias(path.basename(resolved) || path.basename(realRoot) || 'repo'),
128
+ parentName: sanitizeAlias(path.basename(path.dirname(resolved))),
129
+ policy,
130
+ });
131
+ }
132
+
133
+ // Count basename collisions among entries without explicit aliases
134
+ const baseCounts = new Map();
135
+ for (const entry of rawEntries) {
136
+ const candidate = entry.explicitAlias ?? entry.baseName;
137
+ baseCounts.set(candidate, (baseCounts.get(candidate) ?? 0) + 1);
138
+ }
139
+
140
+ const usedAliases = new Set();
141
+ return rawEntries.map((entry) => {
142
+ let candidate = entry.explicitAlias ?? entry.baseName;
143
+ if (!entry.explicitAlias && (baseCounts.get(candidate) ?? 0) > 1 && entry.parentName) {
144
+ candidate = `${entry.parentName}-${entry.baseName}`;
145
+ }
146
+ let unique = candidate;
147
+ let suffix = 2;
148
+ while (usedAliases.has(unique)) {
149
+ unique = `${candidate}-${suffix++}`;
150
+ }
151
+ usedAliases.add(unique);
152
+ return {
153
+ alias: unique,
154
+ root: entry.root,
155
+ realRoot: entry.realRoot,
156
+ policy: entry.policy,
157
+ };
158
+ });
159
+ }
160
+
161
+ export function enforceWorkspacePolicies(
162
+ resolvedRepos,
163
+ { filesByRepo, tokens, redact, targetModel }
164
+ ) {
165
+ for (const repo of resolvedRepos) {
166
+ if (!repo.policy?.active) continue;
167
+ enforceExport(repo.policy, {
168
+ files: filesByRepo?.get(repo.alias) ?? [],
169
+ tokens,
170
+ redact,
171
+ targetModel: targetModel ?? null,
172
+ });
173
+ }
174
+ }
175
+
176
+ // ============================================================================
177
+ // 0. Unified Cross-Repo Workspace Graph (`buildWorkspaceGraph`)
178
+ // ============================================================================
179
+
180
+ function extractWorkspaceMethods(analyzer, content, lines, filePath, redactions) {
181
+ const found = analyzer
182
+ .extractMethods(content, filePath)
183
+ .sort((a, b) => a.line - b.line || compareStrings(a.name, b.name));
184
+ return found.map((method) => {
185
+ let signature = signatureAt(lines, method.line);
186
+ if (redactions) {
187
+ const result = redactSecrets(signature);
188
+ signature = result.text;
189
+ for (const type of result.findings) redactions[type] = (redactions[type] || 0) + 1;
190
+ }
191
+ return { name: method.name, line: method.line, signature };
192
+ });
193
+ }
194
+
195
+ function countWorkspaceSymbolReferences(nodes, identifiers) {
196
+ const definedNames = nodes.map(
197
+ (node) =>
198
+ new Set(
199
+ (node.methods || [])
200
+ .map((method) => method.name.split(' ').pop())
201
+ .filter((name) => name && name.length > 2)
202
+ )
203
+ );
204
+ const definers = new Map();
205
+ for (const names of definedNames) {
206
+ for (const name of names) definers.set(name, (definers.get(name) || 0) + 1);
207
+ }
208
+ const mentions = new Map();
209
+ for (const ids of identifiers) {
210
+ for (const id of ids) {
211
+ if (definers.has(id)) mentions.set(id, (mentions.get(id) || 0) + 1);
212
+ }
213
+ }
214
+ nodes.forEach((node, i) => {
215
+ let score = 0;
216
+ for (const name of definedNames[i]) {
217
+ const defining = definers.get(name);
218
+ score += Math.max(0, (mentions.get(name) || 0) - defining) / defining;
219
+ }
220
+ node.symbolRefs = score;
221
+ });
222
+ }
223
+
224
+ function readPackageName(repoRoot) {
225
+ try {
226
+ const pkgPath = path.join(repoRoot, 'package.json');
227
+ if (!fs.existsSync(pkgPath)) return null;
228
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
229
+ return typeof pkg?.name === 'string' && pkg.name.trim() ? pkg.name.trim() : null;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Resolve cross-repository import specifiers across workspace repositories:
237
+ * - Relative specifiers crossing into another repository on disk (`../../other-repo/src/foo.js`)
238
+ * - Alias or package-name specifiers (`<alias>/...`, `@<alias>/...`, `@<scope>/<alias>/...`, `<pkgName>/...`)
239
+ * - Bare `<alias>`, `@<alias>`, `<pkgName>` (resolves index or src/index)
240
+ */
241
+ function resolveCrossRepoSpec(
242
+ fromRepo,
243
+ fromRepoRelPath,
244
+ spec,
245
+ resolvedRepos,
246
+ resolversByAlias,
247
+ pkgNamesByAlias
248
+ ) {
249
+ if (!spec || typeof spec !== 'string') return null;
250
+ const cleanSpec = spec.trim().replace(/\\/g, '/');
251
+ if (!cleanSpec) return null;
252
+
253
+ // 1. Relative specifier crossing out of fromRepo into a sibling repo on disk
254
+ if (cleanSpec.startsWith('.')) {
255
+ const absFromDir = path.dirname(path.join(fromRepo.root, fromRepoRelPath));
256
+ const absTargetBase = path.resolve(absFromDir, cleanSpec);
257
+ for (const targetRepo of resolvedRepos) {
258
+ if (targetRepo.alias === fromRepo.alias) continue;
259
+ let relInTarget = path.relative(targetRepo.root, absTargetBase);
260
+ if (!relInTarget || relInTarget.startsWith('..') || path.isAbsolute(relInTarget)) {
261
+ if (targetRepo.realRoot && targetRepo.realRoot !== targetRepo.root) {
262
+ relInTarget = path.relative(targetRepo.realRoot, absTargetBase);
263
+ }
264
+ }
265
+ if (relInTarget && !relInTarget.startsWith('..') && !path.isAbsolute(relInTarget)) {
266
+ const targetResolver = resolversByAlias.get(targetRepo.alias);
267
+ if (!targetResolver) continue;
268
+ const resolvedRel = targetResolver.resolveJs('', `./${toPosix(relInTarget)}`);
269
+ if (resolvedRel) {
270
+ return `${targetRepo.alias}/${resolvedRel}`;
271
+ }
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+
277
+ // 2. Alias or package-name specifier
278
+ const normalized = cleanSpec.replace(/^@[^/]+\//, (match) => {
279
+ const bare = match.slice(1, -1);
280
+ if (resolvedRepos.some((r) => r.alias === bare)) {
281
+ return `${bare}/`;
282
+ }
283
+ return '';
284
+ });
285
+ const bareAt = cleanSpec.startsWith('@') ? cleanSpec.slice(1) : cleanSpec;
286
+
287
+ for (const targetRepo of resolvedRepos) {
288
+ if (!targetRepo.alias || targetRepo.alias === fromRepo.alias) continue;
289
+ const alias = targetRepo.alias;
290
+ const pkgName = pkgNamesByAlias.get(alias);
291
+ const targetResolver = resolversByAlias.get(alias);
292
+ if (!targetResolver) continue;
293
+
294
+ let subpath = null;
295
+ if (
296
+ normalized === alias ||
297
+ bareAt === alias ||
298
+ (pkgName && (cleanSpec === pkgName || bareAt === pkgName))
299
+ ) {
300
+ subpath = 'index';
301
+ } else if (normalized.startsWith(`${alias}/`)) {
302
+ subpath = normalized.slice(alias.length + 1);
303
+ } else if (bareAt.startsWith(`${alias}/`)) {
304
+ subpath = bareAt.slice(alias.length + 1);
305
+ } else if (pkgName && cleanSpec.startsWith(`${pkgName}/`)) {
306
+ subpath = cleanSpec.slice(pkgName.length + 1);
307
+ }
308
+
309
+ if (subpath) {
310
+ const candidates =
311
+ subpath === 'index'
312
+ ? ['./index', './src/index', './lib/index', './main', './src/main']
313
+ : [`./${subpath}`, `./src/${subpath}`, `./lib/${subpath}`];
314
+ for (const cand of candidates) {
315
+ const resolvedRel = targetResolver.resolveJs('', cand);
316
+ if (resolvedRel) {
317
+ return `${alias}/${resolvedRel}`;
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ return null;
324
+ }
325
+
326
+ /**
327
+ * Scan multiple repositories and build a unified cross-repository dependency and AST symbol
328
+ * call graph with `<alias>/<relativePath>` node paths.
329
+ *
330
+ * @param {Array<string|{path: string, alias?: string}>} repos
331
+ * @param {object} [options]
332
+ * @param {number} [options.history=300]
333
+ * @param {boolean} [options.redact=true]
334
+ * @param {number} [options.maxFileBytes=524288]
335
+ * @returns {object}
336
+ */
337
+ export function buildWorkspaceGraph(repos, options = {}) {
338
+ const history = options.history ?? DEFAULT_HISTORY;
339
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
340
+ const redact = options.redact !== false;
341
+ if (!Number.isInteger(history) || history < 0) {
342
+ throw new Error(`history must be a non-negative integer, got ${JSON.stringify(history)}`);
343
+ }
344
+
345
+ const startedTotal = performance.now();
346
+ const resolvedRepos = resolveWorkspaceRepos(repos);
347
+ const analyzer = new MethodAnalyzer();
348
+
349
+ const allNodes = [];
350
+ const allIdentifiers = [];
351
+ const callersByFile = new Map();
352
+ const nodesByAlias = new Map();
353
+ const resolversByAlias = new Map();
354
+ const pkgNamesByAlias = new Map();
355
+ const redactions = {};
356
+ const skippedFiles = [];
357
+ const workspaceHistoryCounts = {};
358
+ let totalScannedFiles = 0;
359
+ let totalCommits = 0;
360
+ let anyHistoryAvailable = false;
361
+
362
+ for (const repo of resolvedRepos) {
363
+ const pkgName = readPackageName(repo.root);
364
+ if (pkgName) pkgNamesByAlias.set(repo.alias, pkgName);
365
+
366
+ const scanned = new Scanner(repo.root).scan();
367
+ totalScannedFiles += scanned.length;
368
+
369
+ const candidates = scanned
370
+ .map((file) => ({ file, relative: toPosix(file.relativePath) }))
371
+ .filter(({ relative }) => languageOf(relative))
372
+ .sort((a, b) => compareStrings(a.relative, b.relative));
373
+
374
+ const repoEntries = [];
375
+ for (const { file, relative } of candidates) {
376
+ const prefixedPath = `${repo.alias}/${relative}`;
377
+ if (file.size > maxFileBytes) {
378
+ skippedFiles.push({ path: prefixedPath, reason: 'too large' });
379
+ continue;
380
+ }
381
+ let content;
382
+ try {
383
+ content = fs.readFileSync(file.path, 'utf-8');
384
+ } catch (_error) {
385
+ skippedFiles.push({ path: prefixedPath, reason: 'unreadable' });
386
+ continue;
387
+ }
388
+ const lines = content.split('\n');
389
+ if (content.length > 4096 && content.length / lines.length > MINIFIED_LINE_LENGTH) {
390
+ skippedFiles.push({ path: prefixedPath, reason: 'minified' });
391
+ continue;
392
+ }
393
+
394
+ const language = languageOf(relative);
395
+ const methods = extractWorkspaceMethods(
396
+ analyzer,
397
+ content,
398
+ lines,
399
+ file.path,
400
+ redact ? redactions : null
401
+ );
402
+ const { methodCalls, callers } = extractFileCallSites(content, methods, file.path, analyzer);
403
+ for (const method of methods) {
404
+ method.calls = methodCalls.get(`${method.name}:${method.line}`) || [];
405
+ }
406
+ callersByFile.set(prefixedPath, callers);
407
+
408
+ const imports = extractImports(language, content);
409
+ const declarations = extractDeclarations(language, content);
410
+
411
+ const strippedNode = {
412
+ path: relative,
413
+ language,
414
+ bytes: file.size,
415
+ methods,
416
+ symbolRefs: 0,
417
+ unresolvedImports: 0,
418
+ imports,
419
+ declarations,
420
+ };
421
+ const workspaceNode = {
422
+ path: prefixedPath,
423
+ repo: repo.alias,
424
+ relativePath: relative,
425
+ absolutePath: file.path,
426
+ language,
427
+ bytes: file.size,
428
+ methods,
429
+ symbolRefs: 0,
430
+ unresolvedImports: 0,
431
+ imports,
432
+ };
433
+
434
+ repoEntries.push({ strippedNode, workspaceNode });
435
+ allNodes.push(workspaceNode);
436
+ allIdentifiers.push(new Set(content.match(IDENTIFIER)));
437
+ }
438
+
439
+ nodesByAlias.set(repo.alias, repoEntries);
440
+ resolversByAlias.set(
441
+ repo.alias,
442
+ new ImportResolver(
443
+ repo.root,
444
+ repoEntries.map((e) => e.strippedNode)
445
+ )
446
+ );
447
+
448
+ const mined = mineHistory(repo.root, history);
449
+ if (mined.available) {
450
+ anyHistoryAvailable = true;
451
+ totalCommits += mined.commits;
452
+ const known = new Set(repoEntries.map((e) => e.strippedNode.path));
453
+ for (const [file, count] of Object.entries(mined.counts)) {
454
+ if (known.has(file)) {
455
+ workspaceHistoryCounts[`${repo.alias}/${file}`] = count;
456
+ }
457
+ }
458
+ }
459
+ }
460
+
461
+ const allEdges = [];
462
+ for (const repo of resolvedRepos) {
463
+ const repoEntries = nodesByAlias.get(repo.alias) || [];
464
+ const resolver = resolversByAlias.get(repo.alias);
465
+
466
+ for (const { strippedNode, workspaceNode } of repoEntries) {
467
+ const { edges: intraTargets, unresolved: intraUnresolved } = resolver.resolve(strippedNode);
468
+ const edgeWeights = new Map();
469
+
470
+ for (const { to, weight } of intraTargets) {
471
+ edgeWeights.set(`${repo.alias}/${to}`, weight);
472
+ }
473
+
474
+ let crossResolvedCount = 0;
475
+ for (const imp of workspaceNode.imports || []) {
476
+ if (!imp?.spec) continue;
477
+ const crossTarget = resolveCrossRepoSpec(
478
+ repo,
479
+ strippedNode.path,
480
+ imp.spec,
481
+ resolvedRepos,
482
+ resolversByAlias,
483
+ pkgNamesByAlias
484
+ );
485
+ if (crossTarget && crossTarget !== workspaceNode.path) {
486
+ edgeWeights.set(crossTarget, (edgeWeights.get(crossTarget) || 0) + 1);
487
+ crossResolvedCount++;
488
+ }
489
+ }
490
+
491
+ workspaceNode.unresolvedImports = Math.max(0, intraUnresolved - crossResolvedCount);
492
+ delete workspaceNode.imports;
493
+
494
+ for (const [to, weight] of edgeWeights) {
495
+ allEdges.push({
496
+ from: workspaceNode.path,
497
+ to,
498
+ weight,
499
+ repo: repo.alias,
500
+ });
501
+ }
502
+ }
503
+ }
504
+
505
+ allEdges.sort((a, b) => compareStrings(a.from, b.from) || compareStrings(a.to, b.to));
506
+ countWorkspaceSymbolReferences(allNodes, allIdentifiers);
507
+
508
+ const rawCallEdges = buildCallGraph(allNodes, allEdges, callersByFile);
509
+ const allCallEdges = rawCallEdges.map((ce) => ({
510
+ ...ce,
511
+ repo: ce.from.split('/')[0],
512
+ }));
513
+
514
+ const unresolvedImports = allNodes.reduce((sum, node) => sum + node.unresolvedImports, 0);
515
+
516
+ return {
517
+ root: resolvedRepos.map((r) => r.root).join(path.delimiter),
518
+ primaryRoot: resolvedRepos[0].root,
519
+ repos: resolvedRepos.map(({ alias, root }) => ({ alias, root })),
520
+ nodes: allNodes,
521
+ edges: allEdges,
522
+ callEdges: allCallEdges,
523
+ history: {
524
+ requested: history,
525
+ commits: totalCommits,
526
+ available: anyHistoryAvailable,
527
+ counts: workspaceHistoryCounts,
528
+ },
529
+ stats: {
530
+ scannedFiles: totalScannedFiles,
531
+ files: allNodes.length,
532
+ edges: allEdges.length,
533
+ callEdges: allCallEdges.length,
534
+ unresolvedImports,
535
+ skipped: skippedFiles,
536
+ redactions,
537
+ timings: { total: performance.now() - startedTotal },
538
+ },
539
+ };
540
+ }
541
+
542
+ // ============================================================================
543
+ // 1. Multi-Repo Map (`mapWorkspace`)
544
+ // ============================================================================
545
+
546
+ function splitRepoFocus(resolvedRepos, focusList) {
547
+ const perRepo = new Map(resolvedRepos.map((r) => [r.alias, []]));
548
+ const unmatched = [];
549
+
550
+ for (const raw of focusList ?? []) {
551
+ const posix = toPosix(raw).replace(/^\/+/, '');
552
+ let matchedAny = false;
553
+
554
+ for (const repo of resolvedRepos) {
555
+ if (posix === repo.alias) {
556
+ // Focusing an entire repo alias boosts all top-level entries in that repo
557
+ perRepo.get(repo.alias).push('.');
558
+ matchedAny = true;
559
+ } else if (posix.startsWith(`${repo.alias}/`)) {
560
+ const sub = posix.slice(repo.alias.length + 1);
561
+ if (sub) {
562
+ perRepo.get(repo.alias).push(sub);
563
+ matchedAny = true;
564
+ }
565
+ } else if (fs.existsSync(path.resolve(repo.root, raw))) {
566
+ perRepo.get(repo.alias).push(raw);
567
+ matchedAny = true;
568
+ }
569
+ }
570
+
571
+ if (!matchedAny) unmatched.push(raw);
572
+ }
573
+
574
+ return { perRepo, unmatched };
575
+ }
576
+
577
+ function describeWorkspaceFile(node, row, analyzer) {
578
+ let content = '';
579
+ try {
580
+ content = fs.readFileSync(node.absolutePath, 'utf-8');
581
+ } catch (_error) {
582
+ // File removed after scan
583
+ }
584
+ const sources = content
585
+ ? analyzer.extractMethodSources(content, node.methods, node.absolutePath)
586
+ : [];
587
+ return {
588
+ path: node.path,
589
+ repo: node.repo,
590
+ rank: row.rank,
591
+ score: row.score,
592
+ tokens: content ? TokenUtils.calculate(content, node.absolutePath) : 0,
593
+ methods: node.methods.map((method, i) => ({
594
+ line: method.line,
595
+ name: method.name,
596
+ signature: method.signature,
597
+ tokens: sources[i] ? TokenUtils.calculate(sources[i], node.absolutePath) : 0,
598
+ })),
599
+ };
600
+ }
601
+
602
+ function displayMapOrder(chosen, opts) {
603
+ if (opts.order === 'path') {
604
+ return [...chosen].sort((a, b) => compareStrings(a.entry.path, b.entry.path));
605
+ }
606
+ return opts.importantLast ? [...chosen].reverse() : [...chosen];
607
+ }
608
+
609
+ function selectWorkspaceMapFiles(nodesByPath, ranked, opts) {
610
+ const style = opts.style === 'markdown' ? 'markdown' : 'text';
611
+ const analyzer = new MethodAnalyzer();
612
+ const separator = style === 'markdown' ? '\n\n' : '\n';
613
+ const chosen = [];
614
+ let used = style === 'markdown' ? countMapTokens(`${MARKDOWN_MAP_TITLE}${separator}`) : 0;
615
+ let skipped = 0;
616
+
617
+ for (const row of ranked) {
618
+ const remaining = opts.tokens - used;
619
+ if (remaining < MIN_BLOCK_TOKENS) {
620
+ skipped += ranked.length - row.rank + 1;
621
+ break;
622
+ }
623
+ const node = nodesByPath.get(row.path);
624
+ const characters = node.methods.reduce(
625
+ (sum, method) => sum + method.signature.length,
626
+ row.path.length
627
+ );
628
+ if (characters / CHARS_PER_TOKEN_CEILING > remaining) {
629
+ skipped++;
630
+ continue;
631
+ }
632
+ const skeleton = renderBlock(style, {
633
+ path: row.path,
634
+ score: row.score,
635
+ tokens: 0,
636
+ methods: node.methods.map((method) => ({
637
+ line: method.line,
638
+ name: method.name,
639
+ signature: method.signature,
640
+ tokens: 0,
641
+ })),
642
+ });
643
+ if (countMapTokens(skeleton + separator) > remaining) {
644
+ skipped++;
645
+ continue;
646
+ }
647
+ const entry = describeWorkspaceFile(node, row, analyzer);
648
+ const block = renderBlock(style, entry);
649
+ const cost = countMapTokens(block + separator);
650
+ if (cost > remaining) {
651
+ skipped++;
652
+ continue;
653
+ }
654
+ chosen.push({ entry, block });
655
+ used += cost;
656
+ }
657
+
658
+ for (;;) {
659
+ const ordered = displayMapOrder(chosen, opts);
660
+ const blocks = ordered.map((item) => item.block);
661
+ if (style === 'markdown' && blocks.length > 0) blocks.unshift(MARKDOWN_MAP_TITLE);
662
+ const output = blocks.length > 0 ? `${blocks.join(separator)}\n` : '';
663
+ const tokens = countMapTokens(output);
664
+ if (tokens <= opts.tokens || chosen.length === 0) {
665
+ return { files: ordered.map((item) => item.entry), output, tokens, skipped };
666
+ }
667
+ chosen.pop();
668
+ skipped++;
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Build, rank, and render a unified repository map across multiple repositories under a
674
+ * single shared token budget.
675
+ *
676
+ * @param {Array<string|{path: string, alias?: string}>} repos
677
+ * @param {object} [options] - Same options as `renderRepoMap`
678
+ * @returns {object}
679
+ */
680
+ export function mapWorkspace(repos, options = {}) {
681
+ const started = performance.now();
682
+ const resolvedRepos = resolveWorkspaceRepos(repos);
683
+ const opts = normalizeMapOptions(options);
684
+
685
+ // Enforce redaction policy upfront if --no-redact was requested
686
+ enforceWorkspacePolicies(resolvedRepos, {
687
+ filesByRepo: new Map(),
688
+ tokens: 0,
689
+ redact: opts.redact,
690
+ });
691
+
692
+ const warnings = [];
693
+ const { perRepo: focusPerRepo, unmatched } = splitRepoFocus(resolvedRepos, opts.focus);
694
+ for (const entry of unmatched) {
695
+ warnings.push(`focus path matches no mapped file: ${entry}`);
696
+ }
697
+
698
+ const workspaceGraph = buildWorkspaceGraph(resolvedRepos, {
699
+ history: opts.history,
700
+ redact: opts.redact,
701
+ maxFileBytes: opts.maxFileBytes,
702
+ });
703
+
704
+ const matchedFocusPaths = [];
705
+ for (const repo of resolvedRepos) {
706
+ const repoFocusInputs = focusPerRepo.get(repo.alias) ?? [];
707
+ if (repoFocusInputs.length === 0) continue;
708
+ const repoSubGraph = {
709
+ root: repo.root,
710
+ nodes: workspaceGraph.nodes
711
+ .filter((n) => n.repo === repo.alias)
712
+ .map((n) => ({ ...n, path: n.relativePath })),
713
+ };
714
+ const focus = repoFocusInputs.includes('.')
715
+ ? { files: repoSubGraph.nodes.map((n) => n.path), unmatched: [] }
716
+ : resolveFocus(repoSubGraph, repoFocusInputs);
717
+ for (const u of focus.unmatched) {
718
+ warnings.push(`focus path matches no mapped file in ${repo.alias}: ${u}`);
719
+ }
720
+ for (const f of focus.files) {
721
+ matchedFocusPaths.push(`${repo.alias}/${f}`);
722
+ }
723
+ }
724
+
725
+ const nodesByPath = new Map(workspaceGraph.nodes.map((n) => [n.path, n]));
726
+ const rawRanked = rankFiles(
727
+ { ...workspaceGraph, root: resolvedRepos[0].root },
728
+ {
729
+ focus: matchedFocusPaths,
730
+ callGraph: opts.callGraph,
731
+ weights: opts.weights,
732
+ damping: opts.damping,
733
+ }
734
+ );
735
+ const mergedRanked = rawRanked.map((row) => {
736
+ const node = nodesByPath.get(row.path);
737
+ return {
738
+ ...row,
739
+ repo: node?.repo ?? row.path.split('/')[0],
740
+ relativePath: node?.relativePath ?? row.path.split('/').slice(1).join('/'),
741
+ };
742
+ });
743
+
744
+ let output;
745
+ let tokens;
746
+ let files = [];
747
+ let skipped = 0;
748
+
749
+ if (opts.graph || opts.style === 'dot') {
750
+ output = renderGraph(workspaceGraph, mergedRanked, opts.style, {
751
+ callGraph: opts.callGraph,
752
+ });
753
+ tokens = countMapTokens(output);
754
+ } else {
755
+ const selection = selectWorkspaceMapFiles(nodesByPath, mergedRanked, opts);
756
+ files = selection.files;
757
+ skipped = selection.skipped;
758
+ tokens = selection.tokens;
759
+ if (opts.style === 'json') {
760
+ const included = new Set(files.map((f) => f.path));
761
+ output = `${JSON.stringify(
762
+ {
763
+ repos: workspaceGraph.repos,
764
+ budget: opts.tokens,
765
+ tokens,
766
+ order: opts.order,
767
+ importantLast: opts.importantLast,
768
+ files: files.map((f) => ({ ...f, score: round(f.score, 6) })),
769
+ nodes: mergedRanked.map((row) => ({
770
+ path: row.path,
771
+ repo: row.repo,
772
+ rank: row.rank,
773
+ score: round(row.score, 6),
774
+ included: included.has(row.path),
775
+ })),
776
+ edges: workspaceGraph.edges.map((e) => ({ ...e, weight: round(e.weight, 6) })),
777
+ callEdges: workspaceGraph.callEdges.map((e) => ({ ...e, weight: round(e.weight, 6) })),
778
+ stats: {
779
+ files: workspaceGraph.stats.files,
780
+ edges: workspaceGraph.stats.edges,
781
+ callEdges: workspaceGraph.stats.callEdges,
782
+ unresolvedImports: workspaceGraph.stats.unresolvedImports,
783
+ skippedFiles: workspaceGraph.stats.skipped,
784
+ },
785
+ },
786
+ null,
787
+ 2
788
+ )}\n`;
789
+ } else {
790
+ output = selection.output;
791
+ }
792
+ if (files.length === 0 && mergedRanked.length > 0) {
793
+ warnings.push(`no file fits in ${opts.tokens} tokens`);
794
+ }
795
+ }
796
+
797
+ workspaceGraph.stats.timings.total = performance.now() - started;
798
+
799
+ return {
800
+ output,
801
+ style: opts.style,
802
+ budget: opts.tokens,
803
+ tokens,
804
+ repos: workspaceGraph.repos,
805
+ files,
806
+ ranked: mergedRanked,
807
+ graph: workspaceGraph,
808
+ skipped,
809
+ warnings,
810
+ stats: workspaceGraph.stats,
811
+ };
812
+ }
813
+
814
+ // ============================================================================
815
+ // 2. Multi-Repo Search (`searchWorkspace`)
816
+ // ============================================================================
817
+
818
+ /**
819
+ * Ranked BM25 search across multiple repositories under a shared token budget.
820
+ *
821
+ * @param {Array<string|{path: string, alias?: string}>} repos
822
+ * @param {string} query
823
+ * @param {object} [options]
824
+ * @param {number} [options.limit=10]
825
+ * @param {number} [options.maxTokens=4000]
826
+ * @param {boolean} [options.redact=true]
827
+ * @param {number} [options.windowLines=40]
828
+ * @returns {object}
829
+ */
830
+ export function searchWorkspace(
831
+ repos,
832
+ query,
833
+ {
834
+ limit = DEFAULT_LIMIT,
835
+ maxTokens = DEFAULT_MAX_TOKENS,
836
+ redact = true,
837
+ windowLines = WINDOW_LINES,
838
+ } = {}
839
+ ) {
840
+ if (typeof query !== 'string' || !query.trim()) {
841
+ throw new Error('The search query is empty');
842
+ }
843
+ const resolvedRepos = resolveWorkspaceRepos(repos);
844
+ enforceWorkspacePolicies(resolvedRepos, {
845
+ filesByRepo: new Map(),
846
+ tokens: 0,
847
+ redact,
848
+ });
849
+
850
+ const unifiedIndex = new BM25Index();
851
+ let totalFiles = 0;
852
+
853
+ for (const repo of resolvedRepos) {
854
+ const project = getProjectIndex(repo.root, { windowLines });
855
+ totalFiles += project.files.size;
856
+
857
+ for (const entry of project.files.values()) {
858
+ const prefixedPath = `${repo.alias}/${toPosix(entry.relativePath)}`;
859
+ const pathTerms = tokenize(prefixedPath);
860
+ const lines = entry.content.split('\n');
861
+ for (const chunk of entry.chunks) {
862
+ const text = redactSecrets(lines.slice(chunk.startLine - 1, chunk.endLine).join('\n')).text;
863
+ const nameTerms = chunk.name ? tokenize(chunk.name) : [];
864
+ unifiedIndex.add({ repo: repo.alias, prefixedPath, entry, chunk }, [
865
+ ...tokenize(text),
866
+ ...nameTerms,
867
+ ...nameTerms,
868
+ ...nameTerms,
869
+ ...pathTerms,
870
+ ]);
871
+ }
872
+ }
873
+ }
874
+
875
+ const hits = unifiedIndex.search(query, { limit });
876
+ let used = 0;
877
+ const results = hits.map(({ doc: { repo, prefixedPath, entry, chunk }, score }) => {
878
+ const raw = sliceLines(entry.content, chunk.startLine, chunk.endLine);
879
+ let text = redact ? redactSecrets(raw).text : raw;
880
+ let tokens = TokenUtils.calculate(text, entry.path);
881
+
882
+ const item = {
883
+ repo,
884
+ file: prefixedPath,
885
+ name: chunk.name,
886
+ kind: chunk.kind,
887
+ ...(chunk.parent ? { parent: chunk.parent } : {}),
888
+ startLine: chunk.startLine,
889
+ endLine: chunk.endLine,
890
+ score: Math.round(score * 10000) / 10000,
891
+ };
892
+
893
+ if (used + tokens > maxTokens) {
894
+ text = fitToTokenBudget(text, maxTokens - used, entry.path);
895
+ tokens = text ? TokenUtils.calculate(text, entry.path) : 0;
896
+ item.truncated = true;
897
+ }
898
+ used += tokens;
899
+ return { ...item, tokens, text: text || null };
900
+ });
901
+
902
+ return {
903
+ query,
904
+ root: resolvedRepos.map((r) => `${r.alias}=${r.root}`).join(', '),
905
+ repos: resolvedRepos.map(({ alias, root }) => ({ alias, root })),
906
+ files: totalFiles,
907
+ chunks: unifiedIndex.size,
908
+ tokens: used,
909
+ results,
910
+ };
911
+ }
912
+
913
+ // ============================================================================
914
+ // 3. Multi-Repo Pack (`packWorkspace`)
915
+ // ============================================================================
916
+
917
+ function positiveInteger(value, name) {
918
+ if (value === undefined || value === null) return null;
919
+ const number = Number(value);
920
+ if (!Number.isInteger(number) || number <= 0) {
921
+ throw new PackError('INVALID_OPTION', `${name} must be a positive integer, got ${value}`);
922
+ }
923
+ return number;
924
+ }
925
+
926
+ function stringList(value, name) {
927
+ if (value === undefined || value === null) return [];
928
+ const list = Array.isArray(value) ? value : [value];
929
+ if (!list.every((item) => typeof item === 'string')) {
930
+ throw new PackError('INVALID_OPTION', `${name} must be a list of strings`);
931
+ }
932
+ return list;
933
+ }
934
+
935
+ function resolveTargetProfile(model) {
936
+ if (model === undefined || model === null || model === '') return null;
937
+ const id = LLMDetector.resolveModelId(String(model));
938
+ if (!id) {
939
+ throw new PackError(
940
+ 'UNKNOWN_MODEL',
941
+ `Unknown model "${model}" (ctxman --list-llms lists the known ids)`
942
+ );
943
+ }
944
+ return LLMDetector.getProfile(id);
945
+ }
946
+
947
+ function prepareWorkspaceFile(file, repoAlias, opts) {
948
+ let buffer;
949
+ try {
950
+ buffer = fs.readFileSync(file.absolutePath);
951
+ } catch (_error) {
952
+ return { skipped: 'unreadable' };
953
+ }
954
+ if (buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { skipped: 'binary' };
955
+
956
+ let content = buffer.toString('utf8');
957
+ if (content.charCodeAt(0) === 0xfeff) content = content.slice(1);
958
+ const totalLines = content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
959
+ const count = (text) => TokenUtils.calculate(text, file.path, opts.encoding);
960
+
961
+ let lineNumbers = null;
962
+ let compression = null;
963
+ if (opts.compress) {
964
+ const result = compressCode(content, file.path);
965
+ if (result.elided > 0) {
966
+ compression = {
967
+ bodies: result.elided,
968
+ originalTokens: count(content),
969
+ compressedTokens: count(result.text),
970
+ };
971
+ content = result.text;
972
+ lineNumbers = result.lineMap;
973
+ }
974
+ }
975
+
976
+ if (opts.lineNumbers) content = addLineNumbers(content, lineNumbers);
977
+
978
+ let findings = [];
979
+ if (opts.redact) {
980
+ const redacted = redactSecrets(content);
981
+ if (redacted.findings.length) {
982
+ if (redacted.text.split('\n').length !== content.split('\n').length) lineNumbers = null;
983
+ content = redacted.text;
984
+ findings = redacted.findings;
985
+ }
986
+ }
987
+
988
+ return {
989
+ repo: repoAlias,
990
+ relativePath: file.path,
991
+ path: `${repoAlias}/${file.path}`,
992
+ absolutePath: file.absolutePath,
993
+ modified: file.modified,
994
+ content,
995
+ tokens: count(content),
996
+ totalLines,
997
+ lineNumbers,
998
+ compression,
999
+ findings,
1000
+ };
1001
+ }
1002
+
1003
+ function omittedSection(omitted) {
1004
+ if (!omitted.length) return null;
1005
+ const rest = omitted.slice(OMITTED_LISTED);
1006
+ const sum = (entries) => entries.reduce((total, entry) => total + entry.tokens, 0);
1007
+ return {
1008
+ files: omitted.slice(0, OMITTED_LISTED).map(({ path: p, tokens }) => ({ path: p, tokens })),
1009
+ count: omitted.length,
1010
+ tokens: sum(omitted),
1011
+ more: rest.length,
1012
+ moreTokens: sum(rest),
1013
+ };
1014
+ }
1015
+
1016
+ function modelFit(profile, tokens, encoding) {
1017
+ if (!profile) return null;
1018
+ return {
1019
+ id: profile.id,
1020
+ name: profile.name,
1021
+ vendor: profile.vendor,
1022
+ encoding: TokenUtils.isExact() ? encoding : 'estimate',
1023
+ exact: TokenUtils.isExact() && profile.tokenizer?.exact === true,
1024
+ contextWindow: profile.contextWindow,
1025
+ maxRecommendedInput: profile.maxRecommendedInput ?? null,
1026
+ fits: tokens <= profile.contextWindow,
1027
+ percentOfWindow: (tokens / profile.contextWindow) * 100,
1028
+ };
1029
+ }
1030
+
1031
+ /**
1032
+ * Pack files from multiple repositories into a single LLM-ready document under one shared
1033
+ * token budget, with `<repoAlias>/<path>` prefixes and per-repo policy enforcement.
1034
+ *
1035
+ * @param {Array<string|{path: string, alias?: string}>} repos
1036
+ * @param {object} [options] - Same options as `packContext`
1037
+ * @returns {Promise<object>}
1038
+ */
1039
+ export async function packWorkspace(repos, options = {}) {
1040
+ const resolvedRepos = resolveWorkspaceRepos(repos);
1041
+ const style = options.style ?? 'xml';
1042
+ if (!PACK_STYLES.includes(style)) {
1043
+ throw new PackError(
1044
+ 'INVALID_OPTION',
1045
+ `Unknown style "${style}" (use ${PACK_STYLES.join(', ')})`
1046
+ );
1047
+ }
1048
+ const profile = resolveTargetProfile(options.targetModel);
1049
+ const opts = {
1050
+ style,
1051
+ include: stringList(options.include, 'include'),
1052
+ exclude: stringList(options.exclude, 'exclude'),
1053
+ files: options.files ? stringList(options.files, 'files') : null,
1054
+ rankOrder: options.rankOrder ? stringList(options.rankOrder, 'rankOrder') : null,
1055
+ excludeFiles: stringList(options.excludeFiles, 'excludeFiles'),
1056
+ maxFileSize: positiveInteger(options.maxFileSize, 'maxFileSize') ?? DEFAULT_MAX_FILE_SIZE,
1057
+ tree: options.tree !== false,
1058
+ header: options.header ? String(options.header).trimEnd() : null,
1059
+ instructions: options.instructions ? String(options.instructions).trimEnd() : null,
1060
+ lineNumbers: Boolean(options.lineNumbers),
1061
+ compress: Boolean(options.compress),
1062
+ redact: options.redact !== false,
1063
+ budget: positiveInteger(options.budget, 'budget'),
1064
+ split: positiveInteger(options.split, 'split'),
1065
+ profile,
1066
+ encoding: TokenUtils.encodingFor(profile?.id),
1067
+ };
1068
+
1069
+ const renderer = getRenderer(opts.style);
1070
+ const count = (text) => TokenUtils.calculate(text, `pack${renderer.extension}`, opts.encoding);
1071
+
1072
+ const entries = [];
1073
+ const skipped = [];
1074
+ const globalScores = new Map();
1075
+ const repoByAlias = new Map(resolvedRepos.map((r) => [r.alias, r]));
1076
+
1077
+ if (opts.files) {
1078
+ // Explicit workspace file list (`<alias>/<path>`) in the requested order
1079
+ for (const rawFile of opts.files) {
1080
+ const posix = toPosix(rawFile).replace(/^\/+/, '');
1081
+ const slashIdx = posix.indexOf('/');
1082
+ const alias = slashIdx > 0 ? posix.slice(0, slashIdx) : null;
1083
+ const repo = alias ? repoByAlias.get(alias) : null;
1084
+ if (!repo) {
1085
+ skipped.push({ path: posix, reason: 'missing' });
1086
+ continue;
1087
+ }
1088
+ const repoRel = posix.slice(alias.length + 1);
1089
+ const selection = selectFiles(repo.root, {
1090
+ files: [repoRel],
1091
+ cwd: repo.root,
1092
+ include: opts.include,
1093
+ exclude: opts.exclude,
1094
+ excludeFiles: opts.excludeFiles,
1095
+ maxFileSize: opts.maxFileSize,
1096
+ });
1097
+ for (const s of selection.skipped) {
1098
+ skipped.push({ path: `${repo.alias}/${s.path}`, reason: s.reason });
1099
+ }
1100
+ for (const file of selection.files) {
1101
+ const prepared = prepareWorkspaceFile(file, repo.alias, opts);
1102
+ if (prepared.skipped) {
1103
+ skipped.push({ path: `${repo.alias}/${file.path}`, reason: prepared.skipped });
1104
+ } else {
1105
+ entries.push(prepared);
1106
+ }
1107
+ }
1108
+ }
1109
+ } else {
1110
+ for (const repo of resolvedRepos) {
1111
+ const selection = selectFiles(repo.root, {
1112
+ include: opts.include,
1113
+ exclude: opts.exclude,
1114
+ excludeFiles: opts.excludeFiles,
1115
+ maxFileSize: opts.maxFileSize,
1116
+ });
1117
+ for (const s of selection.skipped) {
1118
+ skipped.push({ path: `${repo.alias}/${s.path}`, reason: s.reason });
1119
+ }
1120
+
1121
+ const repoEntries = [];
1122
+ for (const file of selection.files) {
1123
+ const prepared = prepareWorkspaceFile(file, repo.alias, opts);
1124
+ if (prepared.skipped) {
1125
+ skipped.push({ path: `${repo.alias}/${file.path}`, reason: prepared.skipped });
1126
+ } else {
1127
+ repoEntries.push(prepared);
1128
+ entries.push(prepared);
1129
+ }
1130
+ }
1131
+
1132
+ if (opts.budget && repoEntries.length > 0 && !opts.rankOrder) {
1133
+ // Rank inside each repo using relativePath, then lift scores to `<alias>/<path>`
1134
+ const localProxies = repoEntries.map((entry) => ({ path: entry.relativePath, entry }));
1135
+ const { ranked, scores } = rankEntries(repo.root, localProxies);
1136
+ ranked.forEach((proxy, idx) => {
1137
+ const mapScore = scores.get(proxy.path);
1138
+ // Non-graph files (docs/configs) get a small positive tail score preserving rank order
1139
+ const effectiveScore = mapScore !== undefined ? mapScore + 0.0001 : 0.00005 / (idx + 1);
1140
+ globalScores.set(proxy.entry.path, effectiveScore);
1141
+ });
1142
+ }
1143
+ }
1144
+ }
1145
+
1146
+ const textFindings = [];
1147
+ const redactText = (text) => {
1148
+ if (!text || !opts.redact) return text;
1149
+ const redacted = redactSecrets(text);
1150
+ textFindings.push(...redacted.findings);
1151
+ return redacted.text;
1152
+ };
1153
+ const header = redactText(opts.header);
1154
+ const instructions = redactText(opts.instructions);
1155
+
1156
+ const docFor = (packed, omitted) => ({
1157
+ header,
1158
+ partLabel: null,
1159
+ part: null,
1160
+ tree: opts.tree ? buildTree(packed.map((entry) => entry.path)) : null,
1161
+ files: packed,
1162
+ firstIndex: 1,
1163
+ omitted: omittedSection(omitted),
1164
+ instructions,
1165
+ });
1166
+ const measure = (packed, omitted) => {
1167
+ const text = renderer.render(docFor(packed, omitted));
1168
+ return { text, tokens: count(text) };
1169
+ };
1170
+
1171
+ let packed = entries;
1172
+ let omitted = [];
1173
+ let rendered;
1174
+ if (opts.budget) {
1175
+ let ranked;
1176
+ if (opts.rankOrder && opts.rankOrder.length > 0) {
1177
+ const priority = new Map(opts.rankOrder.map((p, idx) => [p, idx]));
1178
+ ranked = [...entries].sort(
1179
+ (a, b) =>
1180
+ (priority.get(a.path) ?? Number.MAX_SAFE_INTEGER) -
1181
+ (priority.get(b.path) ?? Number.MAX_SAFE_INTEGER) || compareStrings(a.path, b.path)
1182
+ );
1183
+ } else {
1184
+ ranked = [...entries].sort(
1185
+ (a, b) =>
1186
+ (globalScores.get(b.path) ?? 0) - (globalScores.get(a.path) ?? 0) ||
1187
+ compareStrings(a.path, b.path)
1188
+ );
1189
+ }
1190
+ const separator = count('\n\n');
1191
+ const wrapperCost = (entry) =>
1192
+ opts.style === 'json'
1193
+ ? count(renderer.renderFile(entry, entries.length))
1194
+ : entry.tokens + count(renderer.renderFile({ ...entry, content: '' }, entries.length));
1195
+ const cost = (entry) =>
1196
+ (entry.cost ??= wrapperCost(entry) + separator + (opts.tree ? count(entry.path) + 1 : 0));
1197
+ ({ packed, omitted, ...rendered } = fitToBudget(entries, ranked, opts.budget, {
1198
+ measure,
1199
+ cost,
1200
+ }));
1201
+ } else {
1202
+ rendered = measure(entries, []);
1203
+ }
1204
+
1205
+ // Enforce every repository's export policy against the final packed output
1206
+ const filesByRepo = new Map(resolvedRepos.map((r) => [r.alias, []]));
1207
+ for (const entry of packed) {
1208
+ filesByRepo.get(entry.repo)?.push({
1209
+ path: entry.relativePath,
1210
+ tokens: entry.tokens,
1211
+ });
1212
+ }
1213
+ enforceWorkspacePolicies(resolvedRepos, {
1214
+ filesByRepo,
1215
+ tokens: rendered.tokens,
1216
+ redact: opts.redact,
1217
+ targetModel: options.targetModel ?? null,
1218
+ });
1219
+
1220
+ let parts = null;
1221
+ let splitFiles = [];
1222
+ if (opts.split) {
1223
+ const doc = docFor(packed, omitted);
1224
+ ({ parts, splitFiles } = splitIntoParts(packed, opts.split, { renderer, count, doc }));
1225
+ }
1226
+
1227
+ const redactionCounts = {};
1228
+ const findings = [...textFindings, ...packed.flatMap((entry) => entry.findings)];
1229
+ for (const type of findings) redactionCounts[type] = (redactionCounts[type] ?? 0) + 1;
1230
+
1231
+ const compressed = packed.filter((entry) => entry.compression);
1232
+ const sum = (key) => compressed.reduce((total, entry) => total + entry.compression[key], 0);
1233
+ const compression = opts.compress
1234
+ ? {
1235
+ files: compressed.length,
1236
+ bodies: sum('bodies'),
1237
+ originalTokens: sum('originalTokens'),
1238
+ compressedTokens: sum('compressedTokens'),
1239
+ savedTokens: sum('originalTokens') - sum('compressedTokens'),
1240
+ }
1241
+ : null;
1242
+
1243
+ return {
1244
+ text: rendered.text,
1245
+ style: opts.style,
1246
+ encoding: TokenUtils.isExact() ? opts.encoding : 'estimate',
1247
+ totalTokens: rendered.tokens,
1248
+ repos: resolvedRepos.map(({ alias, root }) => ({ alias, root })),
1249
+ files: packed.map((entry) => ({
1250
+ repo: entry.repo,
1251
+ path: entry.path,
1252
+ tokens: entry.tokens,
1253
+ lines: entry.totalLines,
1254
+ compressed: Boolean(entry.compression),
1255
+ })),
1256
+ omitted: omitted.map(({ path: p, tokens }) => ({ path: p, tokens })),
1257
+ skipped,
1258
+ redactions: { total: findings.length, byType: redactionCounts },
1259
+ compression,
1260
+ budget: opts.budget ? { limit: opts.budget, used: rendered.tokens } : null,
1261
+ model: modelFit(opts.profile, rendered.tokens, opts.encoding),
1262
+ parts,
1263
+ splitFiles,
1264
+ };
1265
+ }
1266
+
1267
+ export default {
1268
+ resolveWorkspaceRepos,
1269
+ enforceWorkspacePolicies,
1270
+ buildWorkspaceGraph,
1271
+ mapWorkspace,
1272
+ searchWorkspace,
1273
+ packWorkspace,
1274
+ };