gitnexus 1.2.3 → 1.2.5
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 +2 -1
- package/dist/cli/wiki.d.ts +1 -0
- package/dist/cli/wiki.js +22 -3
- package/dist/core/wiki/generator.d.ts +18 -5
- package/dist/core/wiki/generator.js +171 -68
- package/dist/core/wiki/llm-client.d.ts +6 -2
- package/dist/core/wiki/llm-client.js +66 -15
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -53,9 +53,10 @@ program
|
|
|
53
53
|
.command('wiki [path]')
|
|
54
54
|
.description('Generate repository wiki from knowledge graph')
|
|
55
55
|
.option('-f, --force', 'Force full regeneration even if up to date')
|
|
56
|
-
.option('--model <model>', 'LLM model name (default:
|
|
56
|
+
.option('--model <model>', 'LLM model name (default: minimax/minimax-m2.5)')
|
|
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
|
@@ -141,7 +141,7 @@ export const wikiCommand = async (inputPath, options) => {
|
|
|
141
141
|
let defaultModel;
|
|
142
142
|
if (choice === '2') {
|
|
143
143
|
baseUrl = 'https://openrouter.ai/api/v1';
|
|
144
|
-
defaultModel = '
|
|
144
|
+
defaultModel = 'minimax/minimax-m2.5';
|
|
145
145
|
}
|
|
146
146
|
else if (choice === '3') {
|
|
147
147
|
baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): ');
|
|
@@ -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,10 +39,17 @@ 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;
|
|
44
46
|
constructor(repoPath: string, storagePath: string, kuzuPath: string, llmConfig: LLMConfig, options?: WikiOptions, onProgress?: ProgressCallback);
|
|
47
|
+
private lastPercent;
|
|
48
|
+
/**
|
|
49
|
+
* Create streaming options that report LLM progress to the progress bar.
|
|
50
|
+
* Uses the last known percent so streaming doesn't reset the bar backwards.
|
|
51
|
+
*/
|
|
52
|
+
private streamOpts;
|
|
45
53
|
/**
|
|
46
54
|
* Main entry point. Runs the full pipeline or incremental update.
|
|
47
55
|
*/
|
|
@@ -65,11 +73,6 @@ export declare class WikiGenerator {
|
|
|
65
73
|
* Split a large module into sub-modules by subdirectory.
|
|
66
74
|
*/
|
|
67
75
|
private splitBySubdirectory;
|
|
68
|
-
/**
|
|
69
|
-
* Recursively generate pages for a module tree node.
|
|
70
|
-
* Returns count of pages generated.
|
|
71
|
-
*/
|
|
72
|
-
private generateModulePage;
|
|
73
76
|
/**
|
|
74
77
|
* Generate a leaf module page from source code + graph data.
|
|
75
78
|
*/
|
|
@@ -88,6 +91,16 @@ export declare class WikiGenerator {
|
|
|
88
91
|
private readProjectInfo;
|
|
89
92
|
private extractModuleFiles;
|
|
90
93
|
private countModules;
|
|
94
|
+
/**
|
|
95
|
+
* Flatten the module tree into leaf nodes and parent nodes.
|
|
96
|
+
* Leaves can be processed in parallel; parents must wait for children.
|
|
97
|
+
*/
|
|
98
|
+
private flattenModuleTree;
|
|
99
|
+
/**
|
|
100
|
+
* Run async tasks in parallel with a concurrency limit and adaptive rate limiting.
|
|
101
|
+
* If a 429 rate limit is hit, concurrency is temporarily reduced.
|
|
102
|
+
*/
|
|
103
|
+
private runParallel;
|
|
91
104
|
private findNodeBySlug;
|
|
92
105
|
private slugify;
|
|
93
106
|
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,7 +40,27 @@ export class WikiGenerator {
|
|
|
39
40
|
this.options = options;
|
|
40
41
|
this.llmConfig = llmConfig;
|
|
41
42
|
this.maxTokensPerModule = options.maxTokensPerModule ?? DEFAULT_MAX_TOKENS_PER_MODULE;
|
|
42
|
-
this.
|
|
43
|
+
this.concurrency = options.concurrency ?? 3;
|
|
44
|
+
const progressFn = onProgress || (() => { });
|
|
45
|
+
this.onProgress = (phase, percent, detail) => {
|
|
46
|
+
if (percent > 0)
|
|
47
|
+
this.lastPercent = percent;
|
|
48
|
+
progressFn(phase, percent, detail);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
lastPercent = 0;
|
|
52
|
+
/**
|
|
53
|
+
* Create streaming options that report LLM progress to the progress bar.
|
|
54
|
+
* Uses the last known percent so streaming doesn't reset the bar backwards.
|
|
55
|
+
*/
|
|
56
|
+
streamOpts(label, fixedPercent) {
|
|
57
|
+
return {
|
|
58
|
+
onChunk: (chars) => {
|
|
59
|
+
const tokens = Math.round(chars / 4);
|
|
60
|
+
const pct = fixedPercent ?? this.lastPercent;
|
|
61
|
+
this.onProgress('stream', pct, `${label} (${tokens} tok)`);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
43
64
|
}
|
|
44
65
|
/**
|
|
45
66
|
* Main entry point. Runs the full pipeline or incremental update.
|
|
@@ -123,16 +144,54 @@ export class WikiGenerator {
|
|
|
123
144
|
// Phase 1: Build module tree
|
|
124
145
|
const moduleTree = await this.buildModuleTree(enrichedFiles);
|
|
125
146
|
pagesGenerated = 0;
|
|
126
|
-
// Phase 2: Generate module pages (
|
|
147
|
+
// Phase 2: Generate module pages (parallel with concurrency limit)
|
|
127
148
|
const totalModules = this.countModules(moduleTree);
|
|
128
149
|
let modulesProcessed = 0;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
150
|
+
const reportProgress = (moduleName) => {
|
|
151
|
+
modulesProcessed++;
|
|
152
|
+
const percent = 30 + Math.round((modulesProcessed / totalModules) * 55);
|
|
153
|
+
const detail = moduleName
|
|
154
|
+
? `${modulesProcessed}/${totalModules} — ${moduleName}`
|
|
155
|
+
: `${modulesProcessed}/${totalModules} modules`;
|
|
156
|
+
this.onProgress('modules', percent, detail);
|
|
157
|
+
};
|
|
158
|
+
// Flatten tree into layers: leaves first, then parents
|
|
159
|
+
// Leaves can run in parallel; parents must wait for their children
|
|
160
|
+
const { leaves, parents } = this.flattenModuleTree(moduleTree);
|
|
161
|
+
// Process all leaf modules in parallel
|
|
162
|
+
pagesGenerated += await this.runParallel(leaves, async (node) => {
|
|
163
|
+
const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
|
|
164
|
+
if (await this.fileExists(pagePath)) {
|
|
165
|
+
reportProgress(node.name);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await this.generateLeafPage(node);
|
|
170
|
+
reportProgress(node.name);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
this.failedModules.push(node.name);
|
|
175
|
+
reportProgress(`Failed: ${node.name}`);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
// Process parent modules sequentially (they depend on child docs)
|
|
180
|
+
for (const node of parents) {
|
|
181
|
+
const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
|
|
182
|
+
if (await this.fileExists(pagePath)) {
|
|
183
|
+
reportProgress(node.name);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
await this.generateParentPage(node);
|
|
188
|
+
pagesGenerated++;
|
|
189
|
+
reportProgress(node.name);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
this.failedModules.push(node.name);
|
|
193
|
+
reportProgress(`Failed: ${node.name}`);
|
|
194
|
+
}
|
|
136
195
|
}
|
|
137
196
|
// Phase 3: Generate overview
|
|
138
197
|
this.onProgress('overview', 88, 'Generating overview page...');
|
|
@@ -174,7 +233,7 @@ export class WikiGenerator {
|
|
|
174
233
|
FILE_LIST: fileList,
|
|
175
234
|
DIRECTORY_TREE: dirTree,
|
|
176
235
|
});
|
|
177
|
-
const response = await callLLM(prompt, this.llmConfig, GROUPING_SYSTEM_PROMPT);
|
|
236
|
+
const response = await callLLM(prompt, this.llmConfig, GROUPING_SYSTEM_PROMPT, this.streamOpts('Grouping files', 15));
|
|
178
237
|
const grouping = this.parseGroupingResponse(response.content, files);
|
|
179
238
|
// Convert to tree nodes
|
|
180
239
|
const tree = [];
|
|
@@ -284,53 +343,6 @@ export class WikiGenerator {
|
|
|
284
343
|
}));
|
|
285
344
|
}
|
|
286
345
|
// ─── 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
346
|
/**
|
|
335
347
|
* Generate a leaf module page from source code + graph data.
|
|
336
348
|
*/
|
|
@@ -358,7 +370,7 @@ export class WikiGenerator {
|
|
|
358
370
|
INCOMING_CALLS: formatCallEdges(interCalls.incoming),
|
|
359
371
|
PROCESSES: formatProcesses(processes),
|
|
360
372
|
});
|
|
361
|
-
const response = await callLLM(prompt, this.llmConfig, MODULE_SYSTEM_PROMPT);
|
|
373
|
+
const response = await callLLM(prompt, this.llmConfig, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
|
|
362
374
|
// Write page with front matter
|
|
363
375
|
const pageContent = `# ${node.name}\n\n${response.content}`;
|
|
364
376
|
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
|
@@ -394,7 +406,7 @@ export class WikiGenerator {
|
|
|
394
406
|
CROSS_MODULE_CALLS: formatCallEdges(crossCalls),
|
|
395
407
|
CROSS_PROCESSES: formatProcesses(processes),
|
|
396
408
|
});
|
|
397
|
-
const response = await callLLM(prompt, this.llmConfig, PARENT_SYSTEM_PROMPT);
|
|
409
|
+
const response = await callLLM(prompt, this.llmConfig, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
|
|
398
410
|
const pageContent = `# ${node.name}\n\n${response.content}`;
|
|
399
411
|
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
|
400
412
|
}
|
|
@@ -430,7 +442,7 @@ export class WikiGenerator {
|
|
|
430
442
|
MODULE_EDGES: edgesText,
|
|
431
443
|
TOP_PROCESSES: formatProcesses(topProcesses),
|
|
432
444
|
});
|
|
433
|
-
const response = await callLLM(prompt, this.llmConfig, OVERVIEW_SYSTEM_PROMPT);
|
|
445
|
+
const response = await callLLM(prompt, this.llmConfig, OVERVIEW_SYSTEM_PROMPT, this.streamOpts('Generating overview', 88));
|
|
434
446
|
const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`;
|
|
435
447
|
await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8');
|
|
436
448
|
}
|
|
@@ -484,26 +496,43 @@ export class WikiGenerator {
|
|
|
484
496
|
existingMeta.moduleFiles['Other'].push(...newFiles);
|
|
485
497
|
affectedModules.add('Other');
|
|
486
498
|
}
|
|
487
|
-
// Regenerate affected module pages
|
|
499
|
+
// Regenerate affected module pages (parallel)
|
|
488
500
|
let pagesGenerated = 0;
|
|
489
501
|
const moduleTree = existingMeta.moduleTree;
|
|
490
502
|
const affectedArray = Array.from(affectedModules);
|
|
491
503
|
this.onProgress('incremental', 20, `Regenerating ${affectedArray.length} module(s)...`);
|
|
492
|
-
|
|
493
|
-
|
|
504
|
+
const affectedNodes = [];
|
|
505
|
+
for (const mod of affectedArray) {
|
|
506
|
+
const modSlug = this.slugify(mod);
|
|
494
507
|
const node = this.findNodeBySlug(moduleTree, modSlug);
|
|
495
508
|
if (node) {
|
|
496
|
-
// Delete existing page to force re-generation
|
|
497
509
|
try {
|
|
498
510
|
await fs.unlink(path.join(this.wikiDir, `${node.slug}.md`));
|
|
499
511
|
}
|
|
500
512
|
catch { }
|
|
501
|
-
|
|
502
|
-
pagesGenerated++;
|
|
513
|
+
affectedNodes.push(node);
|
|
503
514
|
}
|
|
504
|
-
const percent = 20 + Math.round(((i + 1) / affectedArray.length) * 60);
|
|
505
|
-
this.onProgress('incremental', percent, `${i + 1}/${affectedArray.length} modules`);
|
|
506
515
|
}
|
|
516
|
+
let incProcessed = 0;
|
|
517
|
+
pagesGenerated += await this.runParallel(affectedNodes, async (node) => {
|
|
518
|
+
try {
|
|
519
|
+
if (node.children && node.children.length > 0) {
|
|
520
|
+
await this.generateParentPage(node);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
await this.generateLeafPage(node);
|
|
524
|
+
}
|
|
525
|
+
incProcessed++;
|
|
526
|
+
const percent = 20 + Math.round((incProcessed / affectedNodes.length) * 60);
|
|
527
|
+
this.onProgress('incremental', percent, `${incProcessed}/${affectedNodes.length} — ${node.name}`);
|
|
528
|
+
return 1;
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
this.failedModules.push(node.name);
|
|
532
|
+
incProcessed++;
|
|
533
|
+
return 0;
|
|
534
|
+
}
|
|
535
|
+
});
|
|
507
536
|
// Regenerate overview if any pages changed
|
|
508
537
|
if (pagesGenerated > 0) {
|
|
509
538
|
this.onProgress('incremental', 85, 'Updating overview...');
|
|
@@ -637,6 +666,80 @@ export class WikiGenerator {
|
|
|
637
666
|
}
|
|
638
667
|
return count;
|
|
639
668
|
}
|
|
669
|
+
/**
|
|
670
|
+
* Flatten the module tree into leaf nodes and parent nodes.
|
|
671
|
+
* Leaves can be processed in parallel; parents must wait for children.
|
|
672
|
+
*/
|
|
673
|
+
flattenModuleTree(tree) {
|
|
674
|
+
const leaves = [];
|
|
675
|
+
const parents = [];
|
|
676
|
+
for (const node of tree) {
|
|
677
|
+
if (node.children && node.children.length > 0) {
|
|
678
|
+
for (const child of node.children) {
|
|
679
|
+
leaves.push(child);
|
|
680
|
+
}
|
|
681
|
+
parents.push(node);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
leaves.push(node);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return { leaves, parents };
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Run async tasks in parallel with a concurrency limit and adaptive rate limiting.
|
|
691
|
+
* If a 429 rate limit is hit, concurrency is temporarily reduced.
|
|
692
|
+
*/
|
|
693
|
+
async runParallel(items, fn) {
|
|
694
|
+
let total = 0;
|
|
695
|
+
let activeConcurrency = this.concurrency;
|
|
696
|
+
let running = 0;
|
|
697
|
+
let idx = 0;
|
|
698
|
+
return new Promise((resolve, reject) => {
|
|
699
|
+
const next = () => {
|
|
700
|
+
while (running < activeConcurrency && idx < items.length) {
|
|
701
|
+
const item = items[idx++];
|
|
702
|
+
running++;
|
|
703
|
+
fn(item)
|
|
704
|
+
.then((count) => {
|
|
705
|
+
total += count;
|
|
706
|
+
running--;
|
|
707
|
+
if (idx >= items.length && running === 0) {
|
|
708
|
+
resolve(total);
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
next();
|
|
712
|
+
}
|
|
713
|
+
})
|
|
714
|
+
.catch((err) => {
|
|
715
|
+
running--;
|
|
716
|
+
// On rate limit, reduce concurrency temporarily
|
|
717
|
+
if (err.message?.includes('429')) {
|
|
718
|
+
activeConcurrency = Math.max(1, activeConcurrency - 1);
|
|
719
|
+
this.onProgress('modules', this.lastPercent, `Rate limited — concurrency → ${activeConcurrency}`);
|
|
720
|
+
// Re-queue the item
|
|
721
|
+
idx--;
|
|
722
|
+
setTimeout(next, 5000);
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
if (idx >= items.length && running === 0) {
|
|
726
|
+
resolve(total);
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
next();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
if (items.length === 0) {
|
|
736
|
+
resolve(0);
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
next();
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
}
|
|
640
743
|
findNodeBySlug(tree, slug) {
|
|
641
744
|
for (const node of tree) {
|
|
642
745
|
if (node.slug === slug)
|
|
@@ -29,8 +29,12 @@ export declare function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promis
|
|
|
29
29
|
* Estimate token count from text (rough heuristic: ~4 chars per token).
|
|
30
30
|
*/
|
|
31
31
|
export declare function estimateTokens(text: string): number;
|
|
32
|
+
export interface CallLLMOptions {
|
|
33
|
+
onChunk?: (charsReceived: number) => void;
|
|
34
|
+
}
|
|
32
35
|
/**
|
|
33
36
|
* Call an OpenAI-compatible LLM API.
|
|
34
|
-
*
|
|
37
|
+
* Uses streaming when onChunk callback is provided for real-time progress.
|
|
38
|
+
* Retries up to 3 times on transient failures (429, 5xx, network errors).
|
|
35
39
|
*/
|
|
36
|
-
export declare function callLLM(prompt: string, config: LLMConfig, systemPrompt?: string): Promise<LLMResponse>;
|
|
40
|
+
export declare function callLLM(prompt: string, config: LLMConfig, systemPrompt?: string, options?: CallLLMOptions): Promise<LLMResponse>;
|
|
@@ -25,11 +25,11 @@ export async function resolveLLMConfig(overrides) {
|
|
|
25
25
|
baseUrl: overrides?.baseUrl
|
|
26
26
|
|| process.env.GITNEXUS_LLM_BASE_URL
|
|
27
27
|
|| savedConfig.baseUrl
|
|
28
|
-
|| 'https://
|
|
28
|
+
|| 'https://openrouter.ai/api/v1',
|
|
29
29
|
model: overrides?.model
|
|
30
30
|
|| process.env.GITNEXUS_MODEL
|
|
31
31
|
|| savedConfig.model
|
|
32
|
-
|| '
|
|
32
|
+
|| 'minimax/minimax-m2.5',
|
|
33
33
|
maxTokens: overrides?.maxTokens ?? 16_384,
|
|
34
34
|
temperature: overrides?.temperature ?? 0,
|
|
35
35
|
};
|
|
@@ -42,23 +42,28 @@ export function estimateTokens(text) {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Call an OpenAI-compatible LLM API.
|
|
45
|
-
*
|
|
45
|
+
* Uses streaming when onChunk callback is provided for real-time progress.
|
|
46
|
+
* Retries up to 3 times on transient failures (429, 5xx, network errors).
|
|
46
47
|
*/
|
|
47
|
-
export async function callLLM(prompt, config, systemPrompt) {
|
|
48
|
+
export async function callLLM(prompt, config, systemPrompt, options) {
|
|
48
49
|
const messages = [];
|
|
49
50
|
if (systemPrompt) {
|
|
50
51
|
messages.push({ role: 'system', content: systemPrompt });
|
|
51
52
|
}
|
|
52
53
|
messages.push({ role: 'user', content: prompt });
|
|
53
54
|
const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
55
|
+
const useStream = !!options?.onChunk;
|
|
54
56
|
const body = {
|
|
55
57
|
model: config.model,
|
|
56
58
|
messages,
|
|
57
59
|
max_tokens: config.maxTokens,
|
|
58
60
|
temperature: config.temperature,
|
|
59
61
|
};
|
|
62
|
+
if (useStream)
|
|
63
|
+
body.stream = true;
|
|
64
|
+
const MAX_RETRIES = 3;
|
|
60
65
|
let lastError = null;
|
|
61
|
-
for (let attempt = 0; attempt <
|
|
66
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
62
67
|
try {
|
|
63
68
|
const response = await fetch(url, {
|
|
64
69
|
method: 'POST',
|
|
@@ -70,19 +75,25 @@ export async function callLLM(prompt, config, systemPrompt) {
|
|
|
70
75
|
});
|
|
71
76
|
if (!response.ok) {
|
|
72
77
|
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
|
-
|
|
78
|
+
// Rate limit — wait with exponential backoff and retry
|
|
79
|
+
if (response.status === 429 && attempt < MAX_RETRIES - 1) {
|
|
80
|
+
const retryAfter = parseInt(response.headers.get('retry-after') || '0', 10);
|
|
81
|
+
const delay = retryAfter > 0 ? retryAfter * 1000 : (2 ** attempt) * 3000;
|
|
82
|
+
await sleep(delay);
|
|
77
83
|
continue;
|
|
78
84
|
}
|
|
79
|
-
// Server error — retry
|
|
80
|
-
if (response.status >= 500 && attempt
|
|
81
|
-
await sleep(2000);
|
|
85
|
+
// Server error — retry with backoff
|
|
86
|
+
if (response.status >= 500 && attempt < MAX_RETRIES - 1) {
|
|
87
|
+
await sleep((attempt + 1) * 2000);
|
|
82
88
|
continue;
|
|
83
89
|
}
|
|
84
90
|
throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`);
|
|
85
91
|
}
|
|
92
|
+
// Streaming path
|
|
93
|
+
if (useStream && response.body) {
|
|
94
|
+
return await readSSEStream(response.body, options.onChunk);
|
|
95
|
+
}
|
|
96
|
+
// Non-streaming path
|
|
86
97
|
const json = await response.json();
|
|
87
98
|
const choice = json.choices?.[0];
|
|
88
99
|
if (!choice?.message?.content) {
|
|
@@ -96,9 +107,9 @@ export async function callLLM(prompt, config, systemPrompt) {
|
|
|
96
107
|
}
|
|
97
108
|
catch (err) {
|
|
98
109
|
lastError = err;
|
|
99
|
-
// Network error — retry
|
|
100
|
-
if (attempt
|
|
101
|
-
await sleep(3000);
|
|
110
|
+
// Network error — retry with backoff
|
|
111
|
+
if (attempt < MAX_RETRIES - 1 && (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.message?.includes('fetch'))) {
|
|
112
|
+
await sleep((attempt + 1) * 3000);
|
|
102
113
|
continue;
|
|
103
114
|
}
|
|
104
115
|
throw err;
|
|
@@ -106,6 +117,46 @@ export async function callLLM(prompt, config, systemPrompt) {
|
|
|
106
117
|
}
|
|
107
118
|
throw lastError || new Error('LLM call failed after retries');
|
|
108
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Read an SSE stream from an OpenAI-compatible streaming response.
|
|
122
|
+
*/
|
|
123
|
+
async function readSSEStream(body, onChunk) {
|
|
124
|
+
const decoder = new TextDecoder();
|
|
125
|
+
const reader = body.getReader();
|
|
126
|
+
let content = '';
|
|
127
|
+
let buffer = '';
|
|
128
|
+
while (true) {
|
|
129
|
+
const { done, value } = await reader.read();
|
|
130
|
+
if (done)
|
|
131
|
+
break;
|
|
132
|
+
buffer += decoder.decode(value, { stream: true });
|
|
133
|
+
const lines = buffer.split('\n');
|
|
134
|
+
buffer = lines.pop() || '';
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const trimmed = line.trim();
|
|
137
|
+
if (!trimmed || !trimmed.startsWith('data: '))
|
|
138
|
+
continue;
|
|
139
|
+
const data = trimmed.slice(6);
|
|
140
|
+
if (data === '[DONE]')
|
|
141
|
+
continue;
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(data);
|
|
144
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
145
|
+
if (delta) {
|
|
146
|
+
content += delta;
|
|
147
|
+
onChunk(content.length);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Skip malformed SSE chunks
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!content) {
|
|
156
|
+
throw new Error('LLM returned empty streaming response');
|
|
157
|
+
}
|
|
158
|
+
return { content };
|
|
159
|
+
}
|
|
109
160
|
function sleep(ms) {
|
|
110
161
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
111
162
|
}
|
package/package.json
CHANGED