@duytransipher/gitnexus 1.4.6-sipher.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 (224) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +261 -0
  3. package/dist/cli/ai-context.d.ts +23 -0
  4. package/dist/cli/ai-context.js +265 -0
  5. package/dist/cli/analyze.d.ts +12 -0
  6. package/dist/cli/analyze.js +345 -0
  7. package/dist/cli/augment.d.ts +13 -0
  8. package/dist/cli/augment.js +33 -0
  9. package/dist/cli/clean.d.ts +10 -0
  10. package/dist/cli/clean.js +60 -0
  11. package/dist/cli/eval-server.d.ts +37 -0
  12. package/dist/cli/eval-server.js +389 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +137 -0
  15. package/dist/cli/lazy-action.d.ts +6 -0
  16. package/dist/cli/lazy-action.js +18 -0
  17. package/dist/cli/list.d.ts +6 -0
  18. package/dist/cli/list.js +30 -0
  19. package/dist/cli/mcp.d.ts +8 -0
  20. package/dist/cli/mcp.js +36 -0
  21. package/dist/cli/serve.d.ts +4 -0
  22. package/dist/cli/serve.js +6 -0
  23. package/dist/cli/setup.d.ts +8 -0
  24. package/dist/cli/setup.js +367 -0
  25. package/dist/cli/sipher-patched.d.ts +2 -0
  26. package/dist/cli/sipher-patched.js +77 -0
  27. package/dist/cli/skill-gen.d.ts +26 -0
  28. package/dist/cli/skill-gen.js +549 -0
  29. package/dist/cli/status.d.ts +6 -0
  30. package/dist/cli/status.js +36 -0
  31. package/dist/cli/tool.d.ts +60 -0
  32. package/dist/cli/tool.js +180 -0
  33. package/dist/cli/wiki.d.ts +15 -0
  34. package/dist/cli/wiki.js +365 -0
  35. package/dist/config/ignore-service.d.ts +26 -0
  36. package/dist/config/ignore-service.js +284 -0
  37. package/dist/config/supported-languages.d.ts +15 -0
  38. package/dist/config/supported-languages.js +16 -0
  39. package/dist/core/augmentation/engine.d.ts +26 -0
  40. package/dist/core/augmentation/engine.js +240 -0
  41. package/dist/core/embeddings/embedder.d.ts +60 -0
  42. package/dist/core/embeddings/embedder.js +251 -0
  43. package/dist/core/embeddings/embedding-pipeline.d.ts +51 -0
  44. package/dist/core/embeddings/embedding-pipeline.js +356 -0
  45. package/dist/core/embeddings/index.d.ts +9 -0
  46. package/dist/core/embeddings/index.js +9 -0
  47. package/dist/core/embeddings/text-generator.d.ts +24 -0
  48. package/dist/core/embeddings/text-generator.js +182 -0
  49. package/dist/core/embeddings/types.d.ts +87 -0
  50. package/dist/core/embeddings/types.js +32 -0
  51. package/dist/core/graph/graph.d.ts +2 -0
  52. package/dist/core/graph/graph.js +66 -0
  53. package/dist/core/graph/types.d.ts +66 -0
  54. package/dist/core/graph/types.js +1 -0
  55. package/dist/core/ingestion/ast-cache.d.ts +11 -0
  56. package/dist/core/ingestion/ast-cache.js +35 -0
  57. package/dist/core/ingestion/call-processor.d.ts +23 -0
  58. package/dist/core/ingestion/call-processor.js +793 -0
  59. package/dist/core/ingestion/call-routing.d.ts +68 -0
  60. package/dist/core/ingestion/call-routing.js +129 -0
  61. package/dist/core/ingestion/cluster-enricher.d.ts +38 -0
  62. package/dist/core/ingestion/cluster-enricher.js +170 -0
  63. package/dist/core/ingestion/community-processor.d.ts +39 -0
  64. package/dist/core/ingestion/community-processor.js +312 -0
  65. package/dist/core/ingestion/constants.d.ts +16 -0
  66. package/dist/core/ingestion/constants.js +16 -0
  67. package/dist/core/ingestion/entry-point-scoring.d.ts +40 -0
  68. package/dist/core/ingestion/entry-point-scoring.js +353 -0
  69. package/dist/core/ingestion/export-detection.d.ts +18 -0
  70. package/dist/core/ingestion/export-detection.js +231 -0
  71. package/dist/core/ingestion/filesystem-walker.d.ts +28 -0
  72. package/dist/core/ingestion/filesystem-walker.js +81 -0
  73. package/dist/core/ingestion/framework-detection.d.ts +54 -0
  74. package/dist/core/ingestion/framework-detection.js +411 -0
  75. package/dist/core/ingestion/heritage-processor.d.ts +28 -0
  76. package/dist/core/ingestion/heritage-processor.js +251 -0
  77. package/dist/core/ingestion/import-processor.d.ts +34 -0
  78. package/dist/core/ingestion/import-processor.js +398 -0
  79. package/dist/core/ingestion/language-config.d.ts +46 -0
  80. package/dist/core/ingestion/language-config.js +167 -0
  81. package/dist/core/ingestion/mro-processor.d.ts +45 -0
  82. package/dist/core/ingestion/mro-processor.js +369 -0
  83. package/dist/core/ingestion/named-binding-extraction.d.ts +61 -0
  84. package/dist/core/ingestion/named-binding-extraction.js +363 -0
  85. package/dist/core/ingestion/parsing-processor.d.ts +19 -0
  86. package/dist/core/ingestion/parsing-processor.js +315 -0
  87. package/dist/core/ingestion/pipeline.d.ts +6 -0
  88. package/dist/core/ingestion/pipeline.js +401 -0
  89. package/dist/core/ingestion/process-processor.d.ts +51 -0
  90. package/dist/core/ingestion/process-processor.js +315 -0
  91. package/dist/core/ingestion/resolution-context.d.ts +53 -0
  92. package/dist/core/ingestion/resolution-context.js +132 -0
  93. package/dist/core/ingestion/resolvers/csharp.d.ts +22 -0
  94. package/dist/core/ingestion/resolvers/csharp.js +109 -0
  95. package/dist/core/ingestion/resolvers/go.d.ts +19 -0
  96. package/dist/core/ingestion/resolvers/go.js +42 -0
  97. package/dist/core/ingestion/resolvers/index.d.ts +18 -0
  98. package/dist/core/ingestion/resolvers/index.js +13 -0
  99. package/dist/core/ingestion/resolvers/jvm.d.ts +23 -0
  100. package/dist/core/ingestion/resolvers/jvm.js +87 -0
  101. package/dist/core/ingestion/resolvers/php.d.ts +15 -0
  102. package/dist/core/ingestion/resolvers/php.js +35 -0
  103. package/dist/core/ingestion/resolvers/python.d.ts +19 -0
  104. package/dist/core/ingestion/resolvers/python.js +52 -0
  105. package/dist/core/ingestion/resolvers/ruby.d.ts +12 -0
  106. package/dist/core/ingestion/resolvers/ruby.js +15 -0
  107. package/dist/core/ingestion/resolvers/rust.d.ts +15 -0
  108. package/dist/core/ingestion/resolvers/rust.js +73 -0
  109. package/dist/core/ingestion/resolvers/standard.d.ts +28 -0
  110. package/dist/core/ingestion/resolvers/standard.js +123 -0
  111. package/dist/core/ingestion/resolvers/utils.d.ts +33 -0
  112. package/dist/core/ingestion/resolvers/utils.js +122 -0
  113. package/dist/core/ingestion/structure-processor.d.ts +2 -0
  114. package/dist/core/ingestion/structure-processor.js +36 -0
  115. package/dist/core/ingestion/symbol-table.d.ts +63 -0
  116. package/dist/core/ingestion/symbol-table.js +85 -0
  117. package/dist/core/ingestion/tree-sitter-queries.d.ts +15 -0
  118. package/dist/core/ingestion/tree-sitter-queries.js +888 -0
  119. package/dist/core/ingestion/type-env.d.ts +49 -0
  120. package/dist/core/ingestion/type-env.js +613 -0
  121. package/dist/core/ingestion/type-extractors/c-cpp.d.ts +2 -0
  122. package/dist/core/ingestion/type-extractors/c-cpp.js +385 -0
  123. package/dist/core/ingestion/type-extractors/csharp.d.ts +2 -0
  124. package/dist/core/ingestion/type-extractors/csharp.js +383 -0
  125. package/dist/core/ingestion/type-extractors/go.d.ts +2 -0
  126. package/dist/core/ingestion/type-extractors/go.js +467 -0
  127. package/dist/core/ingestion/type-extractors/index.d.ts +22 -0
  128. package/dist/core/ingestion/type-extractors/index.js +31 -0
  129. package/dist/core/ingestion/type-extractors/jvm.d.ts +3 -0
  130. package/dist/core/ingestion/type-extractors/jvm.js +681 -0
  131. package/dist/core/ingestion/type-extractors/php.d.ts +2 -0
  132. package/dist/core/ingestion/type-extractors/php.js +549 -0
  133. package/dist/core/ingestion/type-extractors/python.d.ts +2 -0
  134. package/dist/core/ingestion/type-extractors/python.js +455 -0
  135. package/dist/core/ingestion/type-extractors/ruby.d.ts +2 -0
  136. package/dist/core/ingestion/type-extractors/ruby.js +389 -0
  137. package/dist/core/ingestion/type-extractors/rust.d.ts +2 -0
  138. package/dist/core/ingestion/type-extractors/rust.js +456 -0
  139. package/dist/core/ingestion/type-extractors/shared.d.ts +145 -0
  140. package/dist/core/ingestion/type-extractors/shared.js +810 -0
  141. package/dist/core/ingestion/type-extractors/swift.d.ts +2 -0
  142. package/dist/core/ingestion/type-extractors/swift.js +137 -0
  143. package/dist/core/ingestion/type-extractors/types.d.ts +127 -0
  144. package/dist/core/ingestion/type-extractors/types.js +1 -0
  145. package/dist/core/ingestion/type-extractors/typescript.d.ts +2 -0
  146. package/dist/core/ingestion/type-extractors/typescript.js +494 -0
  147. package/dist/core/ingestion/utils.d.ts +138 -0
  148. package/dist/core/ingestion/utils.js +1290 -0
  149. package/dist/core/ingestion/workers/parse-worker.d.ts +122 -0
  150. package/dist/core/ingestion/workers/parse-worker.js +1126 -0
  151. package/dist/core/ingestion/workers/worker-pool.d.ts +16 -0
  152. package/dist/core/ingestion/workers/worker-pool.js +128 -0
  153. package/dist/core/lbug/csv-generator.d.ts +33 -0
  154. package/dist/core/lbug/csv-generator.js +366 -0
  155. package/dist/core/lbug/lbug-adapter.d.ts +103 -0
  156. package/dist/core/lbug/lbug-adapter.js +769 -0
  157. package/dist/core/lbug/schema.d.ts +53 -0
  158. package/dist/core/lbug/schema.js +430 -0
  159. package/dist/core/search/bm25-index.d.ts +23 -0
  160. package/dist/core/search/bm25-index.js +96 -0
  161. package/dist/core/search/hybrid-search.d.ts +49 -0
  162. package/dist/core/search/hybrid-search.js +118 -0
  163. package/dist/core/tree-sitter/parser-loader.d.ts +5 -0
  164. package/dist/core/tree-sitter/parser-loader.js +63 -0
  165. package/dist/core/wiki/generator.d.ts +120 -0
  166. package/dist/core/wiki/generator.js +939 -0
  167. package/dist/core/wiki/graph-queries.d.ts +80 -0
  168. package/dist/core/wiki/graph-queries.js +238 -0
  169. package/dist/core/wiki/html-viewer.d.ts +10 -0
  170. package/dist/core/wiki/html-viewer.js +297 -0
  171. package/dist/core/wiki/llm-client.d.ts +43 -0
  172. package/dist/core/wiki/llm-client.js +186 -0
  173. package/dist/core/wiki/prompts.d.ts +53 -0
  174. package/dist/core/wiki/prompts.js +174 -0
  175. package/dist/lib/utils.d.ts +1 -0
  176. package/dist/lib/utils.js +3 -0
  177. package/dist/mcp/compatible-stdio-transport.d.ts +25 -0
  178. package/dist/mcp/compatible-stdio-transport.js +200 -0
  179. package/dist/mcp/core/embedder.d.ts +27 -0
  180. package/dist/mcp/core/embedder.js +108 -0
  181. package/dist/mcp/core/lbug-adapter.d.ts +57 -0
  182. package/dist/mcp/core/lbug-adapter.js +455 -0
  183. package/dist/mcp/local/local-backend.d.ts +181 -0
  184. package/dist/mcp/local/local-backend.js +1722 -0
  185. package/dist/mcp/resources.d.ts +31 -0
  186. package/dist/mcp/resources.js +411 -0
  187. package/dist/mcp/server.d.ts +23 -0
  188. package/dist/mcp/server.js +296 -0
  189. package/dist/mcp/staleness.d.ts +15 -0
  190. package/dist/mcp/staleness.js +29 -0
  191. package/dist/mcp/tools.d.ts +24 -0
  192. package/dist/mcp/tools.js +292 -0
  193. package/dist/server/api.d.ts +10 -0
  194. package/dist/server/api.js +344 -0
  195. package/dist/server/mcp-http.d.ts +13 -0
  196. package/dist/server/mcp-http.js +100 -0
  197. package/dist/storage/git.d.ts +6 -0
  198. package/dist/storage/git.js +35 -0
  199. package/dist/storage/repo-manager.d.ts +138 -0
  200. package/dist/storage/repo-manager.js +299 -0
  201. package/dist/types/pipeline.d.ts +32 -0
  202. package/dist/types/pipeline.js +18 -0
  203. package/dist/unreal/bridge.d.ts +4 -0
  204. package/dist/unreal/bridge.js +113 -0
  205. package/dist/unreal/config.d.ts +6 -0
  206. package/dist/unreal/config.js +55 -0
  207. package/dist/unreal/types.d.ts +105 -0
  208. package/dist/unreal/types.js +1 -0
  209. package/hooks/claude/gitnexus-hook.cjs +238 -0
  210. package/hooks/claude/pre-tool-use.sh +79 -0
  211. package/hooks/claude/session-start.sh +42 -0
  212. package/package.json +100 -0
  213. package/scripts/ensure-cli-executable.cjs +21 -0
  214. package/scripts/patch-tree-sitter-swift.cjs +74 -0
  215. package/scripts/setup-unreal-gitnexus.ps1 +191 -0
  216. package/skills/gitnexus-cli.md +82 -0
  217. package/skills/gitnexus-debugging.md +89 -0
  218. package/skills/gitnexus-exploring.md +78 -0
  219. package/skills/gitnexus-guide.md +64 -0
  220. package/skills/gitnexus-impact-analysis.md +97 -0
  221. package/skills/gitnexus-pr-review.md +163 -0
  222. package/skills/gitnexus-refactoring.md +121 -0
  223. package/vendor/leiden/index.cjs +355 -0
  224. package/vendor/leiden/utils.cjs +392 -0
@@ -0,0 +1,939 @@
1
+ /**
2
+ * Wiki Generator
3
+ *
4
+ * Orchestrates the full wiki generation pipeline:
5
+ * Phase 0: Validate prerequisites + gather graph structure
6
+ * Phase 1: Build module tree (one LLM call)
7
+ * Phase 2: Generate module pages (one LLM call per module, bottom-up)
8
+ * Phase 3: Generate overview page
9
+ *
10
+ * Supports incremental updates via git diff + module-file mapping.
11
+ */
12
+ import fs from 'fs/promises';
13
+ import path from 'path';
14
+ import { execSync, execFileSync } from 'child_process';
15
+ import { initWikiDb, closeWikiDb, getFilesWithExports, getAllFiles, getIntraModuleCallEdges, getInterModuleCallEdges, getProcessesForFiles, getAllProcesses, getInterModuleEdgesForOverview, } from './graph-queries.js';
16
+ import { generateHTMLViewer } from './html-viewer.js';
17
+ import { callLLM, estimateTokens, } from './llm-client.js';
18
+ import { GROUPING_SYSTEM_PROMPT, GROUPING_USER_PROMPT, MODULE_SYSTEM_PROMPT, MODULE_USER_PROMPT, PARENT_SYSTEM_PROMPT, PARENT_USER_PROMPT, OVERVIEW_SYSTEM_PROMPT, OVERVIEW_USER_PROMPT, fillTemplate, formatFileListForGrouping, formatDirectoryTree, formatCallEdges, formatProcesses, } from './prompts.js';
19
+ import { shouldIgnorePath } from '../../config/ignore-service.js';
20
+ // ─── Constants ────────────────────────────────────────────────────────
21
+ const DEFAULT_MAX_TOKENS_PER_MODULE = 30_000;
22
+ const WIKI_DIR = 'wiki';
23
+ export const MAX_WIKI_PROMPT_TOKENS = 120_000;
24
+ const LARGE_REPO_GROUPING_FILE_THRESHOLD = 1_500;
25
+ const MAX_FILES_PER_DETERMINISTIC_GROUP = 250;
26
+ const MAX_GROUPING_BATCH_TOKENS = 40_000;
27
+ const MAX_LEAF_SOURCE_TOKENS = 70_000;
28
+ const MAX_EDGE_SECTION_TOKENS = 10_000;
29
+ const MAX_PROCESS_SECTION_TOKENS = 10_000;
30
+ const MAX_PARENT_CHILD_DOC_TOKENS = 80_000;
31
+ const MAX_OVERVIEW_SUMMARY_TOKENS = 80_000;
32
+ const MAX_PROJECT_INFO_TOKENS = 10_000;
33
+ const WIKI_CODE_SUFFIXES = [
34
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
35
+ '.py', '.java', '.kt', '.kts', '.cs',
36
+ '.go', '.rs', '.php', '.rb', '.swift',
37
+ '.c', '.cc', '.cpp', '.cxx', '.h', '.hh', '.hpp', '.hxx',
38
+ '.m', '.mm',
39
+ '.usf', '.ush', '.glsl', '.hlsl',
40
+ ];
41
+ export function isWikiPromptExcludedPath(filePath) {
42
+ const normalized = filePath.replace(/\\/g, '/').toLowerCase();
43
+ return normalized.endsWith('.uasset') || normalized.endsWith('.umap');
44
+ }
45
+ export function isWikiCodeFilePath(filePath) {
46
+ const normalized = filePath.replace(/\\/g, '/').toLowerCase();
47
+ return WIKI_CODE_SUFFIXES.some(suffix => normalized.endsWith(suffix));
48
+ }
49
+ export function filterWikiPromptPaths(filePaths) {
50
+ return filePaths.filter(filePath => !isWikiPromptExcludedPath(filePath) && isWikiCodeFilePath(filePath));
51
+ }
52
+ export function estimateGroupingPromptTokens(files) {
53
+ const fileList = formatFileListForGrouping(files);
54
+ const dirTree = formatDirectoryTree(files.map(f => f.filePath));
55
+ const prompt = fillTemplate(GROUPING_USER_PROMPT, {
56
+ FILE_LIST: fileList,
57
+ DIRECTORY_TREE: dirTree,
58
+ });
59
+ return estimateTokens(prompt);
60
+ }
61
+ export function shouldUseDeterministicGrouping(fileCount, promptTokens) {
62
+ return fileCount > LARGE_REPO_GROUPING_FILE_THRESHOLD || promptTokens > MAX_WIKI_PROMPT_TOKENS;
63
+ }
64
+ function truncateTextToTokenBudget(text, maxTokens, notice) {
65
+ if (maxTokens <= 0)
66
+ return notice.trim();
67
+ if (estimateTokens(text) <= maxTokens)
68
+ return text;
69
+ const maxChars = Math.max(0, (maxTokens * 4) - notice.length);
70
+ return text.slice(0, maxChars) + notice;
71
+ }
72
+ // ─── Generator Class ──────────────────────────────────────────────────
73
+ export class WikiGenerator {
74
+ repoPath;
75
+ storagePath;
76
+ wikiDir;
77
+ lbugPath;
78
+ llmConfig;
79
+ maxTokensPerModule;
80
+ concurrency;
81
+ options;
82
+ onProgress;
83
+ failedModules = [];
84
+ constructor(repoPath, storagePath, lbugPath, llmConfig, options = {}, onProgress) {
85
+ this.repoPath = repoPath;
86
+ this.storagePath = storagePath;
87
+ this.wikiDir = path.join(storagePath, WIKI_DIR);
88
+ this.lbugPath = lbugPath;
89
+ this.options = options;
90
+ this.llmConfig = llmConfig;
91
+ this.maxTokensPerModule = options.maxTokensPerModule ?? DEFAULT_MAX_TOKENS_PER_MODULE;
92
+ this.concurrency = options.concurrency ?? 3;
93
+ const progressFn = onProgress || (() => { });
94
+ this.onProgress = (phase, percent, detail) => {
95
+ if (percent > 0)
96
+ this.lastPercent = percent;
97
+ progressFn(phase, percent, detail);
98
+ };
99
+ }
100
+ lastPercent = 0;
101
+ /**
102
+ * Create streaming options that report LLM progress to the progress bar.
103
+ * Uses the last known percent so streaming doesn't reset the bar backwards.
104
+ */
105
+ streamOpts(label, fixedPercent) {
106
+ return {
107
+ onChunk: (chars) => {
108
+ const tokens = Math.round(chars / 4);
109
+ const pct = fixedPercent ?? this.lastPercent;
110
+ this.onProgress('stream', pct, `${label} (${tokens} tok)`);
111
+ },
112
+ };
113
+ }
114
+ /**
115
+ * Main entry point. Runs the full pipeline or incremental update.
116
+ */
117
+ async run() {
118
+ await fs.mkdir(this.wikiDir, { recursive: true });
119
+ const existingMeta = await this.loadWikiMeta();
120
+ const currentCommit = this.getCurrentCommit();
121
+ const forceMode = this.options.force;
122
+ // Up-to-date check (skip if --force)
123
+ if (!forceMode && existingMeta && existingMeta.fromCommit === currentCommit) {
124
+ // Still regenerate the HTML viewer in case it's missing
125
+ await this.ensureHTMLViewer();
126
+ return { pagesGenerated: 0, mode: 'up-to-date', failedModules: [] };
127
+ }
128
+ // Force mode: delete snapshot to force full re-grouping
129
+ if (forceMode) {
130
+ try {
131
+ await fs.unlink(path.join(this.wikiDir, 'first_module_tree.json'));
132
+ }
133
+ catch { }
134
+ // Delete existing module pages so they get regenerated
135
+ const existingFiles = await fs.readdir(this.wikiDir).catch(() => []);
136
+ for (const f of existingFiles) {
137
+ if (f.endsWith('.md')) {
138
+ try {
139
+ await fs.unlink(path.join(this.wikiDir, f));
140
+ }
141
+ catch { }
142
+ }
143
+ }
144
+ }
145
+ // Init graph
146
+ this.onProgress('init', 2, 'Connecting to knowledge graph...');
147
+ await initWikiDb(this.lbugPath);
148
+ let result;
149
+ try {
150
+ if (!forceMode && existingMeta && existingMeta.fromCommit) {
151
+ result = await this.incrementalUpdate(existingMeta, currentCommit);
152
+ }
153
+ else {
154
+ result = await this.fullGeneration(currentCommit);
155
+ }
156
+ }
157
+ finally {
158
+ await closeWikiDb();
159
+ }
160
+ // Always generate the HTML viewer after wiki content changes
161
+ await this.ensureHTMLViewer();
162
+ return result;
163
+ }
164
+ // ─── HTML Viewer ─────────────────────────────────────────────────────
165
+ async ensureHTMLViewer() {
166
+ // Only generate if there are markdown pages to bundle
167
+ const dirEntries = await fs.readdir(this.wikiDir).catch(() => []);
168
+ const hasMd = dirEntries.some(f => f.endsWith('.md'));
169
+ if (!hasMd)
170
+ return;
171
+ this.onProgress('html', 98, 'Building HTML viewer...');
172
+ const repoName = path.basename(this.repoPath);
173
+ await generateHTMLViewer(this.wikiDir, repoName);
174
+ }
175
+ // ─── Full Generation ────────────────────────────────────────────────
176
+ async fullGeneration(currentCommit) {
177
+ let pagesGenerated = 0;
178
+ // Phase 0: Gather structure
179
+ this.onProgress('gather', 5, 'Querying graph for file structure...');
180
+ const filesWithExports = await getFilesWithExports();
181
+ const allFiles = await getAllFiles();
182
+ // Filter to source files only
183
+ const sourceFiles = allFiles.filter(f => !shouldIgnorePath(f) && !isWikiPromptExcludedPath(f) && isWikiCodeFilePath(f));
184
+ if (sourceFiles.length === 0) {
185
+ throw new Error('No source files found in the knowledge graph. Nothing to document.');
186
+ }
187
+ // Build enriched file list (merge exports into all source files)
188
+ const exportMap = new Map(filesWithExports.map(f => [f.filePath, f]));
189
+ const enrichedFiles = sourceFiles.map(fp => {
190
+ return exportMap.get(fp) || { filePath: fp, symbols: [] };
191
+ });
192
+ this.onProgress('gather', 10, `Found ${sourceFiles.length} source files`);
193
+ // Phase 1: Build module tree
194
+ const moduleTree = await this.buildModuleTree(enrichedFiles);
195
+ pagesGenerated = 0;
196
+ // Phase 2: Generate module pages (parallel with concurrency limit)
197
+ const totalModules = this.countModules(moduleTree);
198
+ let modulesProcessed = 0;
199
+ const reportProgress = (moduleName) => {
200
+ modulesProcessed++;
201
+ const percent = 30 + Math.round((modulesProcessed / totalModules) * 55);
202
+ const detail = moduleName
203
+ ? `${modulesProcessed}/${totalModules} — ${moduleName}`
204
+ : `${modulesProcessed}/${totalModules} modules`;
205
+ this.onProgress('modules', percent, detail);
206
+ };
207
+ // Flatten tree into layers: leaves first, then parents
208
+ // Leaves can run in parallel; parents must wait for their children
209
+ const { leaves, parents } = this.flattenModuleTree(moduleTree);
210
+ // Process all leaf modules in parallel
211
+ pagesGenerated += await this.runParallel(leaves, async (node) => {
212
+ const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
213
+ if (await this.fileExists(pagePath)) {
214
+ reportProgress(node.name);
215
+ return 0;
216
+ }
217
+ try {
218
+ await this.generateLeafPage(node);
219
+ reportProgress(node.name);
220
+ return 1;
221
+ }
222
+ catch (err) {
223
+ this.failedModules.push(node.name);
224
+ reportProgress(`Failed: ${node.name}`);
225
+ return 0;
226
+ }
227
+ });
228
+ // Process parent modules sequentially (they depend on child docs)
229
+ for (const node of parents) {
230
+ const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
231
+ if (await this.fileExists(pagePath)) {
232
+ reportProgress(node.name);
233
+ continue;
234
+ }
235
+ try {
236
+ await this.generateParentPage(node);
237
+ pagesGenerated++;
238
+ reportProgress(node.name);
239
+ }
240
+ catch (err) {
241
+ this.failedModules.push(node.name);
242
+ reportProgress(`Failed: ${node.name}`);
243
+ }
244
+ }
245
+ // Phase 3: Generate overview
246
+ this.onProgress('overview', 88, 'Generating overview page...');
247
+ await this.generateOverview(moduleTree);
248
+ pagesGenerated++;
249
+ // Save metadata
250
+ this.onProgress('finalize', 95, 'Saving metadata...');
251
+ const moduleFiles = this.extractModuleFiles(moduleTree);
252
+ await this.saveModuleTree(moduleTree);
253
+ await this.saveWikiMeta({
254
+ fromCommit: currentCommit,
255
+ generatedAt: new Date().toISOString(),
256
+ model: this.llmConfig.model,
257
+ moduleFiles,
258
+ moduleTree,
259
+ });
260
+ this.onProgress('done', 100, 'Wiki generation complete');
261
+ return { pagesGenerated, mode: 'full', failedModules: [...this.failedModules] };
262
+ }
263
+ // ─── Phase 1: Build Module Tree ────────────────────────────────────
264
+ async buildModuleTree(files) {
265
+ // Check for existing immutable snapshot (resumability)
266
+ const snapshotPath = path.join(this.wikiDir, 'first_module_tree.json');
267
+ try {
268
+ const existing = await fs.readFile(snapshotPath, 'utf-8');
269
+ const parsed = JSON.parse(existing);
270
+ if (Array.isArray(parsed) && parsed.length > 0) {
271
+ this.onProgress('grouping', 25, 'Using existing module tree (resuming)');
272
+ return parsed;
273
+ }
274
+ }
275
+ catch {
276
+ // No snapshot, generate new
277
+ }
278
+ const groupingPromptTokens = estimateGroupingPromptTokens(files);
279
+ let tree;
280
+ if (shouldUseDeterministicGrouping(files.length, groupingPromptTokens)) {
281
+ this.onProgress('grouping', 15, `Large repo detected (${files.length} files, ${groupingPromptTokens} tok) — using deterministic grouping`);
282
+ tree = this.buildDeterministicModuleTree(files);
283
+ }
284
+ else {
285
+ this.onProgress('grouping', 15, 'Grouping files into modules (LLM)...');
286
+ const fileList = formatFileListForGrouping(files);
287
+ const dirTree = formatDirectoryTree(files.map(f => f.filePath));
288
+ const prompt = fillTemplate(GROUPING_USER_PROMPT, {
289
+ FILE_LIST: fileList,
290
+ DIRECTORY_TREE: dirTree,
291
+ });
292
+ this.assertPromptWithinBudget(prompt, 'Grouping');
293
+ const response = await callLLM(prompt, this.llmConfig, GROUPING_SYSTEM_PROMPT, this.streamOpts('Grouping files', 15));
294
+ const grouping = this.parseGroupingResponse(response.content, files);
295
+ tree = [];
296
+ for (const [moduleName, modulePaths] of Object.entries(grouping)) {
297
+ const slug = this.slugify(moduleName);
298
+ const node = { name: moduleName, slug, files: modulePaths };
299
+ const totalTokens = await this.estimateModuleTokens(modulePaths);
300
+ if (totalTokens > this.maxTokensPerModule && modulePaths.length > 3) {
301
+ node.children = this.splitBySubdirectory(moduleName, modulePaths);
302
+ node.files = [];
303
+ }
304
+ tree.push(node);
305
+ }
306
+ }
307
+ // Save immutable snapshot for resumability
308
+ await fs.writeFile(snapshotPath, JSON.stringify(tree, null, 2), 'utf-8');
309
+ this.onProgress('grouping', 28, `Created ${tree.length} modules`);
310
+ return tree;
311
+ }
312
+ /**
313
+ * Parse LLM grouping response. Validates all files are assigned.
314
+ */
315
+ parseGroupingResponse(content, files) {
316
+ // Extract JSON from response (handle markdown fences)
317
+ let jsonStr = content.trim();
318
+ const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
319
+ if (fenceMatch) {
320
+ jsonStr = fenceMatch[1].trim();
321
+ }
322
+ let parsed;
323
+ try {
324
+ parsed = JSON.parse(jsonStr);
325
+ }
326
+ catch {
327
+ // Fallback: group by top-level directory
328
+ return this.fallbackGrouping(files);
329
+ }
330
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
331
+ return this.fallbackGrouping(files);
332
+ }
333
+ // Validate — ensure all files are assigned
334
+ const allFilePaths = new Set(files.map(f => f.filePath));
335
+ const assignedFiles = new Set();
336
+ const validGrouping = {};
337
+ for (const [mod, paths] of Object.entries(parsed)) {
338
+ if (!Array.isArray(paths))
339
+ continue;
340
+ const validPaths = paths.filter(p => {
341
+ if (allFilePaths.has(p) && !assignedFiles.has(p)) {
342
+ assignedFiles.add(p);
343
+ return true;
344
+ }
345
+ return false;
346
+ });
347
+ if (validPaths.length > 0) {
348
+ validGrouping[mod] = validPaths;
349
+ }
350
+ }
351
+ // Assign unassigned files to a "Miscellaneous" module
352
+ const unassigned = files
353
+ .map(f => f.filePath)
354
+ .filter(fp => !assignedFiles.has(fp));
355
+ if (unassigned.length > 0) {
356
+ validGrouping['Other'] = unassigned;
357
+ }
358
+ return Object.keys(validGrouping).length > 0
359
+ ? validGrouping
360
+ : this.fallbackGrouping(files);
361
+ }
362
+ /**
363
+ * Fallback grouping by top-level directory when LLM parsing fails.
364
+ */
365
+ fallbackGrouping(files) {
366
+ const groups = new Map();
367
+ for (const f of files) {
368
+ const parts = f.filePath.replace(/\\/g, '/').split('/');
369
+ const topDir = parts.length > 1 ? parts[0] : 'Root';
370
+ let group = groups.get(topDir);
371
+ if (!group) {
372
+ group = [];
373
+ groups.set(topDir, group);
374
+ }
375
+ group.push(f.filePath);
376
+ }
377
+ return Object.fromEntries(groups);
378
+ }
379
+ /**
380
+ * Split a large module into sub-modules by subdirectory.
381
+ */
382
+ splitBySubdirectory(moduleName, files) {
383
+ const subGroups = new Map();
384
+ for (const fp of files) {
385
+ const parts = fp.replace(/\\/g, '/').split('/');
386
+ // Use the deepest common-ish directory
387
+ const subDir = parts.length > 2 ? parts.slice(0, 2).join('/') : parts[0];
388
+ let group = subGroups.get(subDir);
389
+ if (!group) {
390
+ group = [];
391
+ subGroups.set(subDir, group);
392
+ }
393
+ group.push(fp);
394
+ }
395
+ const children = [];
396
+ for (const [subDir, subFiles] of Array.from(subGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
397
+ const chunked = this.chunkFiles(subFiles.sort());
398
+ chunked.forEach((chunk, index) => {
399
+ const suffix = chunked.length > 1 ? ` ${index + 1}` : '';
400
+ const childName = `${moduleName} — ${path.basename(subDir)}${suffix}`;
401
+ children.push({
402
+ name: childName,
403
+ slug: this.slugify(`${moduleName}-${path.basename(subDir)}-${index + 1}`),
404
+ files: chunk,
405
+ });
406
+ });
407
+ }
408
+ return children;
409
+ }
410
+ // ─── Phase 2: Generate Module Pages ─────────────────────────────────
411
+ /**
412
+ * Generate a leaf module page from source code + graph data.
413
+ */
414
+ async generateLeafPage(node) {
415
+ const filePaths = node.files;
416
+ // Read source files from disk
417
+ const sourceCode = await this.readSourceFiles(filePaths);
418
+ const finalSourceCode = truncateTextToTokenBudget(sourceCode, Math.min(this.maxTokensPerModule, MAX_LEAF_SOURCE_TOKENS), '\n\n... (source truncated for context window limits)');
419
+ // Get graph data
420
+ const [intraCalls, interCalls, processes] = await Promise.all([
421
+ getIntraModuleCallEdges(filePaths),
422
+ getInterModuleCallEdges(filePaths),
423
+ getProcessesForFiles(filePaths, 5),
424
+ ]);
425
+ const intraCallsText = truncateTextToTokenBudget(formatCallEdges(intraCalls), MAX_EDGE_SECTION_TOKENS, '\n... (internal calls truncated)');
426
+ const outgoingCallsText = truncateTextToTokenBudget(formatCallEdges(interCalls.outgoing), MAX_EDGE_SECTION_TOKENS, '\n... (outgoing calls truncated)');
427
+ const incomingCallsText = truncateTextToTokenBudget(formatCallEdges(interCalls.incoming), MAX_EDGE_SECTION_TOKENS, '\n... (incoming calls truncated)');
428
+ const processesText = truncateTextToTokenBudget(formatProcesses(processes), MAX_PROCESS_SECTION_TOKENS, '\n... (processes truncated)');
429
+ const prompt = fillTemplate(MODULE_USER_PROMPT, {
430
+ MODULE_NAME: node.name,
431
+ SOURCE_CODE: finalSourceCode,
432
+ INTRA_CALLS: intraCallsText,
433
+ OUTGOING_CALLS: outgoingCallsText,
434
+ INCOMING_CALLS: incomingCallsText,
435
+ PROCESSES: processesText,
436
+ });
437
+ this.assertPromptWithinBudget(prompt, `Leaf module ${node.name}`);
438
+ const response = await callLLM(prompt, this.llmConfig, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
439
+ // Write page with front matter
440
+ const pageContent = `# ${node.name}\n\n${response.content}`;
441
+ await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
442
+ }
443
+ /**
444
+ * Generate a parent module page from children's documentation.
445
+ */
446
+ async generateParentPage(node) {
447
+ if (!node.children || node.children.length === 0)
448
+ return;
449
+ // Read children's overview sections
450
+ const childDocs = [];
451
+ for (const child of node.children) {
452
+ const childPage = path.join(this.wikiDir, `${child.slug}.md`);
453
+ try {
454
+ const content = await fs.readFile(childPage, 'utf-8');
455
+ // Extract overview section (first ~500 chars or up to "### Architecture")
456
+ const overviewEnd = content.indexOf('### Architecture');
457
+ const overview = overviewEnd > 0 ? content.slice(0, overviewEnd).trim() : content.slice(0, 800).trim();
458
+ childDocs.push(`#### ${child.name}\n${overview}`);
459
+ }
460
+ catch {
461
+ childDocs.push(`#### ${child.name}\n(Documentation not yet generated)`);
462
+ }
463
+ }
464
+ // Get cross-child call edges
465
+ const allChildFiles = node.children.flatMap(c => c.files);
466
+ const crossCalls = await getIntraModuleCallEdges(allChildFiles);
467
+ const processes = await getProcessesForFiles(allChildFiles, 3);
468
+ const childDocsText = truncateTextToTokenBudget(childDocs.join('\n\n'), MAX_PARENT_CHILD_DOC_TOKENS, '\n\n... (child documentation truncated)');
469
+ const crossCallsText = truncateTextToTokenBudget(formatCallEdges(crossCalls), MAX_EDGE_SECTION_TOKENS, '\n... (cross-module calls truncated)');
470
+ const crossProcessesText = truncateTextToTokenBudget(formatProcesses(processes), MAX_PROCESS_SECTION_TOKENS, '\n... (shared processes truncated)');
471
+ const prompt = fillTemplate(PARENT_USER_PROMPT, {
472
+ MODULE_NAME: node.name,
473
+ CHILDREN_DOCS: childDocsText,
474
+ CROSS_MODULE_CALLS: crossCallsText,
475
+ CROSS_PROCESSES: crossProcessesText,
476
+ });
477
+ this.assertPromptWithinBudget(prompt, `Parent module ${node.name}`);
478
+ const response = await callLLM(prompt, this.llmConfig, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
479
+ const pageContent = `# ${node.name}\n\n${response.content}`;
480
+ await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
481
+ }
482
+ // ─── Phase 3: Generate Overview ─────────────────────────────────────
483
+ async generateOverview(moduleTree) {
484
+ // Read module overview sections
485
+ const moduleSummaries = [];
486
+ for (const node of moduleTree) {
487
+ const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
488
+ try {
489
+ const content = await fs.readFile(pagePath, 'utf-8');
490
+ const overviewEnd = content.indexOf('### Architecture');
491
+ const overview = overviewEnd > 0 ? content.slice(0, overviewEnd).trim() : content.slice(0, 600).trim();
492
+ moduleSummaries.push(`#### ${node.name}\n${overview}`);
493
+ }
494
+ catch {
495
+ moduleSummaries.push(`#### ${node.name}\n(Documentation pending)`);
496
+ }
497
+ }
498
+ // Get inter-module edges for architecture diagram
499
+ const moduleFiles = this.extractModuleFiles(moduleTree);
500
+ const moduleEdges = await getInterModuleEdgesForOverview(moduleFiles);
501
+ // Get top processes for key workflows
502
+ const topProcesses = await getAllProcesses(5);
503
+ // Read project config
504
+ const projectInfo = await this.readProjectInfo();
505
+ const edgesText = moduleEdges.length > 0
506
+ ? moduleEdges.map(e => `${e.from} → ${e.to} (${e.count} calls)`).join('\n')
507
+ : 'No inter-module call edges detected';
508
+ const trimmedModuleSummaries = truncateTextToTokenBudget(moduleSummaries.join('\n\n'), MAX_OVERVIEW_SUMMARY_TOKENS, '\n\n... (module summaries truncated)');
509
+ const trimmedEdgesText = truncateTextToTokenBudget(edgesText, MAX_EDGE_SECTION_TOKENS, '\n... (inter-module edges truncated)');
510
+ const trimmedProcessesText = truncateTextToTokenBudget(formatProcesses(topProcesses), MAX_PROCESS_SECTION_TOKENS, '\n... (top processes truncated)');
511
+ const trimmedProjectInfo = truncateTextToTokenBudget(projectInfo, MAX_PROJECT_INFO_TOKENS, '\n... (project info truncated)');
512
+ const prompt = fillTemplate(OVERVIEW_USER_PROMPT, {
513
+ PROJECT_INFO: trimmedProjectInfo,
514
+ MODULE_SUMMARIES: trimmedModuleSummaries,
515
+ MODULE_EDGES: trimmedEdgesText,
516
+ TOP_PROCESSES: trimmedProcessesText,
517
+ });
518
+ this.assertPromptWithinBudget(prompt, 'Overview');
519
+ const response = await callLLM(prompt, this.llmConfig, OVERVIEW_SYSTEM_PROMPT, this.streamOpts('Generating overview', 88));
520
+ const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`;
521
+ await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8');
522
+ }
523
+ // ─── Incremental Updates ────────────────────────────────────────────
524
+ async incrementalUpdate(existingMeta, currentCommit) {
525
+ this.onProgress('incremental', 5, 'Detecting changes...');
526
+ // Get changed files since last generation
527
+ const changedFiles = this.getChangedFiles(existingMeta.fromCommit, currentCommit);
528
+ if (changedFiles.length === 0) {
529
+ // No file changes but commit differs (e.g. merge commit)
530
+ await this.saveWikiMeta({
531
+ ...existingMeta,
532
+ fromCommit: currentCommit,
533
+ generatedAt: new Date().toISOString(),
534
+ });
535
+ return { pagesGenerated: 0, mode: 'incremental', failedModules: [] };
536
+ }
537
+ this.onProgress('incremental', 10, `${changedFiles.length} files changed`);
538
+ // Determine affected modules
539
+ const affectedModules = new Set();
540
+ const newFiles = [];
541
+ for (const fp of changedFiles) {
542
+ let found = false;
543
+ for (const [mod, files] of Object.entries(existingMeta.moduleFiles)) {
544
+ if (files.includes(fp)) {
545
+ affectedModules.add(mod);
546
+ found = true;
547
+ break;
548
+ }
549
+ }
550
+ if (!found && !shouldIgnorePath(fp) && !isWikiPromptExcludedPath(fp) && isWikiCodeFilePath(fp)) {
551
+ newFiles.push(fp);
552
+ }
553
+ }
554
+ // If significant new files exist, re-run full grouping
555
+ if (newFiles.length > 5) {
556
+ this.onProgress('incremental', 15, 'Significant new files detected, running full generation...');
557
+ // Delete old snapshot to force re-grouping
558
+ try {
559
+ await fs.unlink(path.join(this.wikiDir, 'first_module_tree.json'));
560
+ }
561
+ catch { }
562
+ const fullResult = await this.fullGeneration(currentCommit);
563
+ return { ...fullResult, mode: 'incremental' };
564
+ }
565
+ // Add new files to nearest module or "Other"
566
+ if (newFiles.length > 0) {
567
+ if (!existingMeta.moduleFiles['Other']) {
568
+ existingMeta.moduleFiles['Other'] = [];
569
+ }
570
+ existingMeta.moduleFiles['Other'].push(...newFiles);
571
+ affectedModules.add('Other');
572
+ }
573
+ // Regenerate affected module pages (parallel)
574
+ let pagesGenerated = 0;
575
+ const moduleTree = existingMeta.moduleTree;
576
+ const affectedArray = Array.from(affectedModules);
577
+ this.onProgress('incremental', 20, `Regenerating ${affectedArray.length} module(s)...`);
578
+ const affectedNodes = [];
579
+ for (const mod of affectedArray) {
580
+ const modSlug = this.slugify(mod);
581
+ const node = this.findNodeBySlug(moduleTree, modSlug);
582
+ if (node) {
583
+ try {
584
+ await fs.unlink(path.join(this.wikiDir, `${node.slug}.md`));
585
+ }
586
+ catch { }
587
+ affectedNodes.push(node);
588
+ }
589
+ }
590
+ let incProcessed = 0;
591
+ pagesGenerated += await this.runParallel(affectedNodes, async (node) => {
592
+ try {
593
+ if (node.children && node.children.length > 0) {
594
+ await this.generateParentPage(node);
595
+ }
596
+ else {
597
+ await this.generateLeafPage(node);
598
+ }
599
+ incProcessed++;
600
+ const percent = 20 + Math.round((incProcessed / affectedNodes.length) * 60);
601
+ this.onProgress('incremental', percent, `${incProcessed}/${affectedNodes.length} — ${node.name}`);
602
+ return 1;
603
+ }
604
+ catch (err) {
605
+ this.failedModules.push(node.name);
606
+ incProcessed++;
607
+ return 0;
608
+ }
609
+ });
610
+ // Regenerate overview if any pages changed
611
+ if (pagesGenerated > 0) {
612
+ this.onProgress('incremental', 85, 'Updating overview...');
613
+ await this.generateOverview(moduleTree);
614
+ pagesGenerated++;
615
+ }
616
+ // Save updated metadata
617
+ this.onProgress('incremental', 95, 'Saving metadata...');
618
+ await this.saveWikiMeta({
619
+ ...existingMeta,
620
+ fromCommit: currentCommit,
621
+ generatedAt: new Date().toISOString(),
622
+ model: this.llmConfig.model,
623
+ });
624
+ this.onProgress('done', 100, 'Incremental update complete');
625
+ return { pagesGenerated, mode: 'incremental', failedModules: [...this.failedModules] };
626
+ }
627
+ // ─── Helpers ────────────────────────────────────────────────────────
628
+ getCurrentCommit() {
629
+ try {
630
+ return execSync('git rev-parse HEAD', { cwd: this.repoPath }).toString().trim();
631
+ }
632
+ catch {
633
+ return '';
634
+ }
635
+ }
636
+ getChangedFiles(fromCommit, toCommit) {
637
+ try {
638
+ const output = execFileSync('git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'], { cwd: this.repoPath }).toString().trim();
639
+ return output ? output.split('\n').filter(Boolean) : [];
640
+ }
641
+ catch {
642
+ return [];
643
+ }
644
+ }
645
+ async readSourceFiles(filePaths) {
646
+ const parts = [];
647
+ for (const fp of filterWikiPromptPaths(filePaths)) {
648
+ const fullPath = path.join(this.repoPath, fp);
649
+ try {
650
+ const content = await fs.readFile(fullPath, 'utf-8');
651
+ parts.push(`\n--- ${fp} ---\n${content}`);
652
+ }
653
+ catch {
654
+ parts.push(`\n--- ${fp} ---\n(file not readable)`);
655
+ }
656
+ }
657
+ return parts.join('\n');
658
+ }
659
+ truncateSource(source, maxTokens) {
660
+ // Rough truncation: keep first maxTokens*4 chars and add notice
661
+ const maxChars = maxTokens * 4;
662
+ if (source.length <= maxChars)
663
+ return source;
664
+ return source.slice(0, maxChars) + '\n\n... (source truncated for context window limits)';
665
+ }
666
+ async estimateModuleTokens(filePaths) {
667
+ let total = 0;
668
+ for (const fp of filterWikiPromptPaths(filePaths)) {
669
+ try {
670
+ const content = await fs.readFile(path.join(this.repoPath, fp), 'utf-8');
671
+ total += estimateTokens(content);
672
+ }
673
+ catch {
674
+ // File not readable, skip
675
+ }
676
+ }
677
+ return total;
678
+ }
679
+ async readProjectInfo() {
680
+ const candidates = ['package.json', 'Cargo.toml', 'pyproject.toml', 'go.mod', 'pom.xml', 'build.gradle'];
681
+ const lines = [`Project: ${path.basename(this.repoPath)}`];
682
+ for (const file of candidates) {
683
+ const fullPath = path.join(this.repoPath, file);
684
+ try {
685
+ const content = await fs.readFile(fullPath, 'utf-8');
686
+ if (file === 'package.json') {
687
+ const pkg = JSON.parse(content);
688
+ if (pkg.name)
689
+ lines.push(`Name: ${pkg.name}`);
690
+ if (pkg.description)
691
+ lines.push(`Description: ${pkg.description}`);
692
+ if (pkg.scripts)
693
+ lines.push(`Scripts: ${Object.keys(pkg.scripts).join(', ')}`);
694
+ }
695
+ else {
696
+ // Include first 500 chars of other config files
697
+ lines.push(`\n${file}:\n${content.slice(0, 500)}`);
698
+ }
699
+ break; // Use first config found
700
+ }
701
+ catch {
702
+ continue;
703
+ }
704
+ }
705
+ // Read README excerpt
706
+ for (const readme of ['README.md', 'readme.md', 'README.txt']) {
707
+ try {
708
+ const content = await fs.readFile(path.join(this.repoPath, readme), 'utf-8');
709
+ lines.push(`\nREADME excerpt:\n${content.slice(0, 1000)}`);
710
+ break;
711
+ }
712
+ catch {
713
+ continue;
714
+ }
715
+ }
716
+ return lines.join('\n');
717
+ }
718
+ assertPromptWithinBudget(prompt, label) {
719
+ const promptTokens = estimateTokens(prompt);
720
+ if (promptTokens > MAX_WIKI_PROMPT_TOKENS) {
721
+ throw new Error(`${label} prompt exceeds safe budget (${promptTokens} tokens)`);
722
+ }
723
+ }
724
+ buildDeterministicModuleTree(files) {
725
+ const topGroups = new Map();
726
+ for (const file of files) {
727
+ const normalized = file.filePath.replace(/\\/g, '/');
728
+ const topLevel = normalized.split('/')[0] || 'Root';
729
+ const group = topGroups.get(topLevel) || [];
730
+ group.push(file.filePath);
731
+ topGroups.set(topLevel, group);
732
+ }
733
+ const tree = [];
734
+ for (const [topLevel, topFiles] of Array.from(topGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
735
+ const sortedTopFiles = topFiles.sort();
736
+ const subGroups = new Map();
737
+ for (const filePath of sortedTopFiles) {
738
+ const normalized = filePath.replace(/\\/g, '/');
739
+ const parts = normalized.split('/');
740
+ const subKey = parts.length > 2 ? parts.slice(0, 2).join('/') : topLevel;
741
+ const group = subGroups.get(subKey) || [];
742
+ group.push(filePath);
743
+ subGroups.set(subKey, group);
744
+ }
745
+ const children = [];
746
+ for (const [subKey, subFiles] of Array.from(subGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
747
+ const chunked = this.chunkFiles(subFiles.sort());
748
+ chunked.forEach((chunk, index) => {
749
+ const baseName = subKey === topLevel ? topLevel : path.basename(subKey);
750
+ const suffix = chunked.length > 1 ? ` ${index + 1}` : '';
751
+ children.push({
752
+ name: `${topLevel} — ${baseName}${suffix}`,
753
+ slug: this.slugify(`${topLevel}-${baseName}-${index + 1}`),
754
+ files: chunk,
755
+ });
756
+ });
757
+ }
758
+ if (children.length <= 1 && sortedTopFiles.length <= MAX_FILES_PER_DETERMINISTIC_GROUP) {
759
+ tree.push({
760
+ name: topLevel,
761
+ slug: this.slugify(topLevel),
762
+ files: sortedTopFiles,
763
+ });
764
+ continue;
765
+ }
766
+ tree.push({
767
+ name: topLevel,
768
+ slug: this.slugify(topLevel),
769
+ files: [],
770
+ children,
771
+ });
772
+ }
773
+ return tree;
774
+ }
775
+ chunkFiles(files) {
776
+ const chunks = [];
777
+ let currentChunk = [];
778
+ let currentPromptTokens = 0;
779
+ for (const file of files) {
780
+ const filePromptTokens = estimateTokens(`- ${file}: no exports\n`);
781
+ const wouldExceedFileLimit = currentChunk.length >= MAX_FILES_PER_DETERMINISTIC_GROUP;
782
+ const wouldExceedPromptBudget = currentChunk.length > 0
783
+ && currentPromptTokens + filePromptTokens > MAX_GROUPING_BATCH_TOKENS;
784
+ if (wouldExceedFileLimit || wouldExceedPromptBudget) {
785
+ chunks.push(currentChunk);
786
+ currentChunk = [];
787
+ currentPromptTokens = 0;
788
+ }
789
+ currentChunk.push(file);
790
+ currentPromptTokens += filePromptTokens;
791
+ }
792
+ if (currentChunk.length > 0) {
793
+ chunks.push(currentChunk);
794
+ }
795
+ return chunks;
796
+ }
797
+ extractModuleFiles(tree) {
798
+ const result = {};
799
+ for (const node of tree) {
800
+ if (node.children && node.children.length > 0) {
801
+ result[node.name] = node.children.flatMap(c => c.files);
802
+ for (const child of node.children) {
803
+ result[child.name] = child.files;
804
+ }
805
+ }
806
+ else {
807
+ result[node.name] = node.files;
808
+ }
809
+ }
810
+ return result;
811
+ }
812
+ countModules(tree) {
813
+ let count = 0;
814
+ for (const node of tree) {
815
+ count++;
816
+ if (node.children) {
817
+ count += node.children.length;
818
+ }
819
+ }
820
+ return count;
821
+ }
822
+ /**
823
+ * Flatten the module tree into leaf nodes and parent nodes.
824
+ * Leaves can be processed in parallel; parents must wait for children.
825
+ */
826
+ flattenModuleTree(tree) {
827
+ const leaves = [];
828
+ const parents = [];
829
+ for (const node of tree) {
830
+ if (node.children && node.children.length > 0) {
831
+ for (const child of node.children) {
832
+ leaves.push(child);
833
+ }
834
+ parents.push(node);
835
+ }
836
+ else {
837
+ leaves.push(node);
838
+ }
839
+ }
840
+ return { leaves, parents };
841
+ }
842
+ /**
843
+ * Run async tasks in parallel with a concurrency limit and adaptive rate limiting.
844
+ * If a 429 rate limit is hit, concurrency is temporarily reduced.
845
+ */
846
+ async runParallel(items, fn) {
847
+ let total = 0;
848
+ let activeConcurrency = this.concurrency;
849
+ let running = 0;
850
+ let idx = 0;
851
+ return new Promise((resolve, reject) => {
852
+ const next = () => {
853
+ while (running < activeConcurrency && idx < items.length) {
854
+ const item = items[idx++];
855
+ running++;
856
+ fn(item)
857
+ .then((count) => {
858
+ total += count;
859
+ running--;
860
+ if (idx >= items.length && running === 0) {
861
+ resolve(total);
862
+ }
863
+ else {
864
+ next();
865
+ }
866
+ })
867
+ .catch((err) => {
868
+ running--;
869
+ // On rate limit, reduce concurrency temporarily
870
+ if (err.message?.includes('429')) {
871
+ activeConcurrency = Math.max(1, activeConcurrency - 1);
872
+ this.onProgress('modules', this.lastPercent, `Rate limited — concurrency → ${activeConcurrency}`);
873
+ // Re-queue the item
874
+ idx--;
875
+ setTimeout(next, 5000);
876
+ }
877
+ else {
878
+ if (idx >= items.length && running === 0) {
879
+ resolve(total);
880
+ }
881
+ else {
882
+ next();
883
+ }
884
+ }
885
+ });
886
+ }
887
+ };
888
+ if (items.length === 0) {
889
+ resolve(0);
890
+ }
891
+ else {
892
+ next();
893
+ }
894
+ });
895
+ }
896
+ findNodeBySlug(tree, slug) {
897
+ for (const node of tree) {
898
+ if (node.slug === slug)
899
+ return node;
900
+ if (node.children) {
901
+ const found = this.findNodeBySlug(node.children, slug);
902
+ if (found)
903
+ return found;
904
+ }
905
+ }
906
+ return null;
907
+ }
908
+ slugify(name) {
909
+ return name
910
+ .toLowerCase()
911
+ .replace(/[^a-z0-9]+/g, '-')
912
+ .replace(/^-+|-+$/g, '')
913
+ .slice(0, 60);
914
+ }
915
+ async fileExists(fp) {
916
+ try {
917
+ await fs.access(fp);
918
+ return true;
919
+ }
920
+ catch {
921
+ return false;
922
+ }
923
+ }
924
+ async loadWikiMeta() {
925
+ try {
926
+ const raw = await fs.readFile(path.join(this.wikiDir, 'meta.json'), 'utf-8');
927
+ return JSON.parse(raw);
928
+ }
929
+ catch {
930
+ return null;
931
+ }
932
+ }
933
+ async saveWikiMeta(meta) {
934
+ await fs.writeFile(path.join(this.wikiDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
935
+ }
936
+ async saveModuleTree(tree) {
937
+ await fs.writeFile(path.join(this.wikiDir, 'module_tree.json'), JSON.stringify(tree, null, 2), 'utf-8');
938
+ }
939
+ }