gitnexus 1.2.3 → 1.2.4
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.
- package/dist/cli/index.js +1 -0
- package/dist/cli/wiki.d.ts +1 -0
- package/dist/cli/wiki.js +21 -2
- package/dist/core/wiki/generator.d.ts +12 -5
- package/dist/core/wiki/generator.js +149 -63
- package/dist/core/wiki/llm-client.js +13 -11
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -56,6 +56,7 @@ program
|
|
|
56
56
|
.option('--model <model>', 'LLM model name (default: gpt-4o-mini)')
|
|
57
57
|
.option('--base-url <url>', 'LLM API base URL (default: OpenAI)')
|
|
58
58
|
.option('--api-key <key>', 'LLM API key (saved to ~/.gitnexus/config.json)')
|
|
59
|
+
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
|
59
60
|
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
|
|
60
61
|
.action(wikiCommand);
|
|
61
62
|
program
|
package/dist/cli/wiki.d.ts
CHANGED
package/dist/cli/wiki.js
CHANGED
|
@@ -186,7 +186,7 @@ export const wikiCommand = async (inputPath, options) => {
|
|
|
186
186
|
llmConfig = { ...llmConfig, apiKey: key, baseUrl, model };
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
-
// ── Setup progress bar
|
|
189
|
+
// ── Setup progress bar with elapsed timer ──────────────────────────
|
|
190
190
|
const bar = new cliProgress.SingleBar({
|
|
191
191
|
format: ' {bar} {percentage}% | {phase}',
|
|
192
192
|
barCompleteChar: '\u2588',
|
|
@@ -199,17 +199,35 @@ export const wikiCommand = async (inputPath, options) => {
|
|
|
199
199
|
}, cliProgress.Presets.shades_grey);
|
|
200
200
|
bar.start(100, 0, { phase: 'Initializing...' });
|
|
201
201
|
const t0 = Date.now();
|
|
202
|
+
let lastPhase = '';
|
|
203
|
+
let phaseStart = t0;
|
|
204
|
+
// Tick elapsed time every second while stuck on the same phase
|
|
205
|
+
const elapsedTimer = setInterval(() => {
|
|
206
|
+
if (lastPhase) {
|
|
207
|
+
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
|
208
|
+
if (elapsed >= 3) {
|
|
209
|
+
bar.update({ phase: `${lastPhase} (${elapsed}s)` });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}, 1000);
|
|
202
213
|
// ── Run generator ───────────────────────────────────────────────────
|
|
203
214
|
const wikiOptions = {
|
|
204
215
|
force: options?.force,
|
|
205
216
|
model: options?.model,
|
|
206
217
|
baseUrl: options?.baseUrl,
|
|
218
|
+
concurrency: options?.concurrency ? parseInt(options.concurrency, 10) : undefined,
|
|
207
219
|
};
|
|
208
220
|
const generator = new WikiGenerator(repoPath, storagePath, kuzuPath, llmConfig, wikiOptions, (phase, percent, detail) => {
|
|
209
|
-
|
|
221
|
+
const label = detail || phase;
|
|
222
|
+
if (label !== lastPhase) {
|
|
223
|
+
lastPhase = label;
|
|
224
|
+
phaseStart = Date.now();
|
|
225
|
+
}
|
|
226
|
+
bar.update(percent, { phase: label });
|
|
210
227
|
});
|
|
211
228
|
try {
|
|
212
229
|
const result = await generator.run();
|
|
230
|
+
clearInterval(elapsedTimer);
|
|
213
231
|
bar.update(100, { phase: 'Done' });
|
|
214
232
|
bar.stop();
|
|
215
233
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
@@ -237,6 +255,7 @@ export const wikiCommand = async (inputPath, options) => {
|
|
|
237
255
|
await maybePublishGist(viewerPath, options?.gist);
|
|
238
256
|
}
|
|
239
257
|
catch (err) {
|
|
258
|
+
clearInterval(elapsedTimer);
|
|
240
259
|
bar.stop();
|
|
241
260
|
if (err.message?.includes('No source files')) {
|
|
242
261
|
console.log(`\n ${err.message}\n`);
|
|
@@ -16,6 +16,7 @@ export interface WikiOptions {
|
|
|
16
16
|
baseUrl?: string;
|
|
17
17
|
apiKey?: string;
|
|
18
18
|
maxTokensPerModule?: number;
|
|
19
|
+
concurrency?: number;
|
|
19
20
|
}
|
|
20
21
|
export interface WikiMeta {
|
|
21
22
|
fromCommit: string;
|
|
@@ -38,6 +39,7 @@ export declare class WikiGenerator {
|
|
|
38
39
|
private kuzuPath;
|
|
39
40
|
private llmConfig;
|
|
40
41
|
private maxTokensPerModule;
|
|
42
|
+
private concurrency;
|
|
41
43
|
private options;
|
|
42
44
|
private onProgress;
|
|
43
45
|
private failedModules;
|
|
@@ -65,11 +67,6 @@ export declare class WikiGenerator {
|
|
|
65
67
|
* Split a large module into sub-modules by subdirectory.
|
|
66
68
|
*/
|
|
67
69
|
private splitBySubdirectory;
|
|
68
|
-
/**
|
|
69
|
-
* Recursively generate pages for a module tree node.
|
|
70
|
-
* Returns count of pages generated.
|
|
71
|
-
*/
|
|
72
|
-
private generateModulePage;
|
|
73
70
|
/**
|
|
74
71
|
* Generate a leaf module page from source code + graph data.
|
|
75
72
|
*/
|
|
@@ -88,6 +85,16 @@ export declare class WikiGenerator {
|
|
|
88
85
|
private readProjectInfo;
|
|
89
86
|
private extractModuleFiles;
|
|
90
87
|
private countModules;
|
|
88
|
+
/**
|
|
89
|
+
* Flatten the module tree into leaf nodes and parent nodes.
|
|
90
|
+
* Leaves can be processed in parallel; parents must wait for children.
|
|
91
|
+
*/
|
|
92
|
+
private flattenModuleTree;
|
|
93
|
+
/**
|
|
94
|
+
* Run async tasks in parallel with a concurrency limit and adaptive rate limiting.
|
|
95
|
+
* If a 429 rate limit is hit, concurrency is temporarily reduced.
|
|
96
|
+
*/
|
|
97
|
+
private runParallel;
|
|
91
98
|
private findNodeBySlug;
|
|
92
99
|
private slugify;
|
|
93
100
|
private fileExists;
|
|
@@ -28,6 +28,7 @@ export class WikiGenerator {
|
|
|
28
28
|
kuzuPath;
|
|
29
29
|
llmConfig;
|
|
30
30
|
maxTokensPerModule;
|
|
31
|
+
concurrency;
|
|
31
32
|
options;
|
|
32
33
|
onProgress;
|
|
33
34
|
failedModules = [];
|
|
@@ -39,6 +40,7 @@ export class WikiGenerator {
|
|
|
39
40
|
this.options = options;
|
|
40
41
|
this.llmConfig = llmConfig;
|
|
41
42
|
this.maxTokensPerModule = options.maxTokensPerModule ?? DEFAULT_MAX_TOKENS_PER_MODULE;
|
|
43
|
+
this.concurrency = options.concurrency ?? 3;
|
|
42
44
|
this.onProgress = onProgress || (() => { });
|
|
43
45
|
}
|
|
44
46
|
/**
|
|
@@ -123,16 +125,56 @@ export class WikiGenerator {
|
|
|
123
125
|
// Phase 1: Build module tree
|
|
124
126
|
const moduleTree = await this.buildModuleTree(enrichedFiles);
|
|
125
127
|
pagesGenerated = 0;
|
|
126
|
-
// Phase 2: Generate module pages (
|
|
128
|
+
// Phase 2: Generate module pages (parallel with concurrency limit)
|
|
127
129
|
const totalModules = this.countModules(moduleTree);
|
|
128
130
|
let modulesProcessed = 0;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
const reportProgress = (moduleName) => {
|
|
132
|
+
modulesProcessed++;
|
|
133
|
+
const percent = 30 + Math.round((modulesProcessed / totalModules) * 55);
|
|
134
|
+
const detail = moduleName
|
|
135
|
+
? `${modulesProcessed}/${totalModules} — ${moduleName}`
|
|
136
|
+
: `${modulesProcessed}/${totalModules} modules`;
|
|
137
|
+
this.onProgress('modules', percent, detail);
|
|
138
|
+
};
|
|
139
|
+
// Flatten tree into layers: leaves first, then parents
|
|
140
|
+
// Leaves can run in parallel; parents must wait for their children
|
|
141
|
+
const { leaves, parents } = this.flattenModuleTree(moduleTree);
|
|
142
|
+
// Process all leaf modules in parallel
|
|
143
|
+
pagesGenerated += await this.runParallel(leaves, async (node) => {
|
|
144
|
+
const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
|
|
145
|
+
if (await this.fileExists(pagePath)) {
|
|
146
|
+
reportProgress(node.name);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await this.generateLeafPage(node);
|
|
151
|
+
reportProgress(node.name);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
this.failedModules.push(node.name);
|
|
156
|
+
this.onProgress('modules', 0, `Failed: ${node.name} — ${err.message?.slice(0, 80)}`);
|
|
157
|
+
reportProgress(node.name);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
// Process parent modules sequentially (they depend on child docs)
|
|
162
|
+
for (const node of parents) {
|
|
163
|
+
const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
|
|
164
|
+
if (await this.fileExists(pagePath)) {
|
|
165
|
+
reportProgress(node.name);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await this.generateParentPage(node);
|
|
170
|
+
pagesGenerated++;
|
|
171
|
+
reportProgress(node.name);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
this.failedModules.push(node.name);
|
|
175
|
+
this.onProgress('modules', 0, `Failed: ${node.name} — ${err.message?.slice(0, 80)}`);
|
|
176
|
+
reportProgress(node.name);
|
|
177
|
+
}
|
|
136
178
|
}
|
|
137
179
|
// Phase 3: Generate overview
|
|
138
180
|
this.onProgress('overview', 88, 'Generating overview page...');
|
|
@@ -284,53 +326,6 @@ export class WikiGenerator {
|
|
|
284
326
|
}));
|
|
285
327
|
}
|
|
286
328
|
// ─── Phase 2: Generate Module Pages ─────────────────────────────────
|
|
287
|
-
/**
|
|
288
|
-
* Recursively generate pages for a module tree node.
|
|
289
|
-
* Returns count of pages generated.
|
|
290
|
-
*/
|
|
291
|
-
async generateModulePage(node, onPageDone) {
|
|
292
|
-
let count = 0;
|
|
293
|
-
// If node has children, generate children first (bottom-up)
|
|
294
|
-
if (node.children && node.children.length > 0) {
|
|
295
|
-
for (const child of node.children) {
|
|
296
|
-
count += await this.generateModulePage(child, onPageDone);
|
|
297
|
-
}
|
|
298
|
-
// Then generate parent page from children docs
|
|
299
|
-
const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
|
|
300
|
-
// Resumability: skip if page already exists
|
|
301
|
-
if (await this.fileExists(pagePath)) {
|
|
302
|
-
onPageDone();
|
|
303
|
-
return count;
|
|
304
|
-
}
|
|
305
|
-
try {
|
|
306
|
-
await this.generateParentPage(node);
|
|
307
|
-
count++;
|
|
308
|
-
}
|
|
309
|
-
catch (err) {
|
|
310
|
-
this.failedModules.push(node.name);
|
|
311
|
-
this.onProgress('modules', 0, `Failed: ${node.name} — ${err.message?.slice(0, 80)}`);
|
|
312
|
-
}
|
|
313
|
-
onPageDone();
|
|
314
|
-
return count;
|
|
315
|
-
}
|
|
316
|
-
// Leaf module — generate from source code
|
|
317
|
-
const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
|
|
318
|
-
// Resumability: skip if page already exists
|
|
319
|
-
if (await this.fileExists(pagePath)) {
|
|
320
|
-
onPageDone();
|
|
321
|
-
return count;
|
|
322
|
-
}
|
|
323
|
-
try {
|
|
324
|
-
await this.generateLeafPage(node);
|
|
325
|
-
count++;
|
|
326
|
-
}
|
|
327
|
-
catch (err) {
|
|
328
|
-
this.failedModules.push(node.name);
|
|
329
|
-
this.onProgress('modules', 0, `Failed: ${node.name} — ${err.message?.slice(0, 80)}`);
|
|
330
|
-
}
|
|
331
|
-
onPageDone();
|
|
332
|
-
return count;
|
|
333
|
-
}
|
|
334
329
|
/**
|
|
335
330
|
* Generate a leaf module page from source code + graph data.
|
|
336
331
|
*/
|
|
@@ -484,26 +479,43 @@ export class WikiGenerator {
|
|
|
484
479
|
existingMeta.moduleFiles['Other'].push(...newFiles);
|
|
485
480
|
affectedModules.add('Other');
|
|
486
481
|
}
|
|
487
|
-
// Regenerate affected module pages
|
|
482
|
+
// Regenerate affected module pages (parallel)
|
|
488
483
|
let pagesGenerated = 0;
|
|
489
484
|
const moduleTree = existingMeta.moduleTree;
|
|
490
485
|
const affectedArray = Array.from(affectedModules);
|
|
491
486
|
this.onProgress('incremental', 20, `Regenerating ${affectedArray.length} module(s)...`);
|
|
492
|
-
|
|
493
|
-
|
|
487
|
+
const affectedNodes = [];
|
|
488
|
+
for (const mod of affectedArray) {
|
|
489
|
+
const modSlug = this.slugify(mod);
|
|
494
490
|
const node = this.findNodeBySlug(moduleTree, modSlug);
|
|
495
491
|
if (node) {
|
|
496
|
-
// Delete existing page to force re-generation
|
|
497
492
|
try {
|
|
498
493
|
await fs.unlink(path.join(this.wikiDir, `${node.slug}.md`));
|
|
499
494
|
}
|
|
500
495
|
catch { }
|
|
501
|
-
|
|
502
|
-
pagesGenerated++;
|
|
496
|
+
affectedNodes.push(node);
|
|
503
497
|
}
|
|
504
|
-
const percent = 20 + Math.round(((i + 1) / affectedArray.length) * 60);
|
|
505
|
-
this.onProgress('incremental', percent, `${i + 1}/${affectedArray.length} modules`);
|
|
506
498
|
}
|
|
499
|
+
let incProcessed = 0;
|
|
500
|
+
pagesGenerated += await this.runParallel(affectedNodes, async (node) => {
|
|
501
|
+
try {
|
|
502
|
+
if (node.children && node.children.length > 0) {
|
|
503
|
+
await this.generateParentPage(node);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
await this.generateLeafPage(node);
|
|
507
|
+
}
|
|
508
|
+
incProcessed++;
|
|
509
|
+
const percent = 20 + Math.round((incProcessed / affectedNodes.length) * 60);
|
|
510
|
+
this.onProgress('incremental', percent, `${incProcessed}/${affectedNodes.length} — ${node.name}`);
|
|
511
|
+
return 1;
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
this.failedModules.push(node.name);
|
|
515
|
+
incProcessed++;
|
|
516
|
+
return 0;
|
|
517
|
+
}
|
|
518
|
+
});
|
|
507
519
|
// Regenerate overview if any pages changed
|
|
508
520
|
if (pagesGenerated > 0) {
|
|
509
521
|
this.onProgress('incremental', 85, 'Updating overview...');
|
|
@@ -637,6 +649,80 @@ export class WikiGenerator {
|
|
|
637
649
|
}
|
|
638
650
|
return count;
|
|
639
651
|
}
|
|
652
|
+
/**
|
|
653
|
+
* Flatten the module tree into leaf nodes and parent nodes.
|
|
654
|
+
* Leaves can be processed in parallel; parents must wait for children.
|
|
655
|
+
*/
|
|
656
|
+
flattenModuleTree(tree) {
|
|
657
|
+
const leaves = [];
|
|
658
|
+
const parents = [];
|
|
659
|
+
for (const node of tree) {
|
|
660
|
+
if (node.children && node.children.length > 0) {
|
|
661
|
+
for (const child of node.children) {
|
|
662
|
+
leaves.push(child);
|
|
663
|
+
}
|
|
664
|
+
parents.push(node);
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
leaves.push(node);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return { leaves, parents };
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Run async tasks in parallel with a concurrency limit and adaptive rate limiting.
|
|
674
|
+
* If a 429 rate limit is hit, concurrency is temporarily reduced.
|
|
675
|
+
*/
|
|
676
|
+
async runParallel(items, fn) {
|
|
677
|
+
let total = 0;
|
|
678
|
+
let activeConcurrency = this.concurrency;
|
|
679
|
+
let running = 0;
|
|
680
|
+
let idx = 0;
|
|
681
|
+
return new Promise((resolve, reject) => {
|
|
682
|
+
const next = () => {
|
|
683
|
+
while (running < activeConcurrency && idx < items.length) {
|
|
684
|
+
const item = items[idx++];
|
|
685
|
+
running++;
|
|
686
|
+
fn(item)
|
|
687
|
+
.then((count) => {
|
|
688
|
+
total += count;
|
|
689
|
+
running--;
|
|
690
|
+
if (idx >= items.length && running === 0) {
|
|
691
|
+
resolve(total);
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
next();
|
|
695
|
+
}
|
|
696
|
+
})
|
|
697
|
+
.catch((err) => {
|
|
698
|
+
running--;
|
|
699
|
+
// On rate limit, reduce concurrency temporarily
|
|
700
|
+
if (err.message?.includes('429')) {
|
|
701
|
+
activeConcurrency = Math.max(1, activeConcurrency - 1);
|
|
702
|
+
this.onProgress('modules', 0, `Rate limited — reducing concurrency to ${activeConcurrency}`);
|
|
703
|
+
// Re-queue the item
|
|
704
|
+
idx--;
|
|
705
|
+
setTimeout(next, 5000);
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
if (idx >= items.length && running === 0) {
|
|
709
|
+
resolve(total);
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
next();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
if (items.length === 0) {
|
|
719
|
+
resolve(0);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
next();
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
}
|
|
640
726
|
findNodeBySlug(tree, slug) {
|
|
641
727
|
for (const node of tree) {
|
|
642
728
|
if (node.slug === slug)
|
|
@@ -57,8 +57,9 @@ export async function callLLM(prompt, config, systemPrompt) {
|
|
|
57
57
|
max_tokens: config.maxTokens,
|
|
58
58
|
temperature: config.temperature,
|
|
59
59
|
};
|
|
60
|
+
const MAX_RETRIES = 3;
|
|
60
61
|
let lastError = null;
|
|
61
|
-
for (let attempt = 0; attempt <
|
|
62
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
62
63
|
try {
|
|
63
64
|
const response = await fetch(url, {
|
|
64
65
|
method: 'POST',
|
|
@@ -70,15 +71,16 @@ export async function callLLM(prompt, config, systemPrompt) {
|
|
|
70
71
|
});
|
|
71
72
|
if (!response.ok) {
|
|
72
73
|
const errorText = await response.text().catch(() => 'unknown error');
|
|
73
|
-
// Rate limit — wait and retry
|
|
74
|
-
if (response.status === 429 && attempt
|
|
75
|
-
const retryAfter = parseInt(response.headers.get('retry-after') || '
|
|
76
|
-
|
|
74
|
+
// Rate limit — wait with exponential backoff and retry
|
|
75
|
+
if (response.status === 429 && attempt < MAX_RETRIES - 1) {
|
|
76
|
+
const retryAfter = parseInt(response.headers.get('retry-after') || '0', 10);
|
|
77
|
+
const delay = retryAfter > 0 ? retryAfter * 1000 : (2 ** attempt) * 3000;
|
|
78
|
+
await sleep(delay);
|
|
77
79
|
continue;
|
|
78
80
|
}
|
|
79
|
-
// Server error — retry
|
|
80
|
-
if (response.status >= 500 && attempt
|
|
81
|
-
await sleep(2000);
|
|
81
|
+
// Server error — retry with backoff
|
|
82
|
+
if (response.status >= 500 && attempt < MAX_RETRIES - 1) {
|
|
83
|
+
await sleep((attempt + 1) * 2000);
|
|
82
84
|
continue;
|
|
83
85
|
}
|
|
84
86
|
throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`);
|
|
@@ -96,9 +98,9 @@ export async function callLLM(prompt, config, systemPrompt) {
|
|
|
96
98
|
}
|
|
97
99
|
catch (err) {
|
|
98
100
|
lastError = err;
|
|
99
|
-
// Network error — retry
|
|
100
|
-
if (attempt
|
|
101
|
-
await sleep(3000);
|
|
101
|
+
// Network error — retry with backoff
|
|
102
|
+
if (attempt < MAX_RETRIES - 1 && (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.message?.includes('fetch'))) {
|
|
103
|
+
await sleep((attempt + 1) * 3000);
|
|
102
104
|
continue;
|
|
103
105
|
}
|
|
104
106
|
throw err;
|
package/package.json
CHANGED