gitnexus 1.2.2 → 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 +100 -48
- 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
|
@@ -108,67 +108,85 @@ export const wikiCommand = async (inputPath, options) => {
|
|
|
108
108
|
await saveCLIConfig({ ...existing, ...updates });
|
|
109
109
|
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
|
110
110
|
}
|
|
111
|
+
const savedConfig = await loadCLIConfig();
|
|
112
|
+
const hasSavedConfig = !!(savedConfig.apiKey && savedConfig.baseUrl);
|
|
113
|
+
const hasCLIOverrides = !!(options?.apiKey || options?.model || options?.baseUrl);
|
|
111
114
|
let llmConfig = await resolveLLMConfig({
|
|
112
115
|
model: options?.model,
|
|
113
116
|
baseUrl: options?.baseUrl,
|
|
114
117
|
apiKey: options?.apiKey,
|
|
115
118
|
});
|
|
116
|
-
if
|
|
119
|
+
// Run interactive setup if no saved config and no CLI flags provided
|
|
120
|
+
// (even if env vars exist — let user explicitly choose their provider)
|
|
121
|
+
if (!hasSavedConfig && !hasCLIOverrides) {
|
|
117
122
|
if (!process.stdin.isTTY) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
console.log(' No LLM configured. Let\'s set it up.\n');
|
|
125
|
-
console.log(' Supports OpenAI, OpenRouter, or any OpenAI-compatible API.\n');
|
|
126
|
-
// Provider selection
|
|
127
|
-
console.log(' [1] OpenAI (api.openai.com)');
|
|
128
|
-
console.log(' [2] OpenRouter (openrouter.ai)');
|
|
129
|
-
console.log(' [3] Custom endpoint\n');
|
|
130
|
-
const choice = await prompt(' Select provider (1/2/3): ');
|
|
131
|
-
let baseUrl;
|
|
132
|
-
let defaultModel;
|
|
133
|
-
if (choice === '2') {
|
|
134
|
-
baseUrl = 'https://openrouter.ai/api/v1';
|
|
135
|
-
defaultModel = 'openai/gpt-4o-mini';
|
|
136
|
-
}
|
|
137
|
-
else if (choice === '3') {
|
|
138
|
-
baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): ');
|
|
139
|
-
if (!baseUrl) {
|
|
140
|
-
console.log('\n No URL provided. Aborting.\n');
|
|
123
|
+
if (!llmConfig.apiKey) {
|
|
124
|
+
console.log(' Error: No LLM API key found.');
|
|
125
|
+
console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,');
|
|
126
|
+
console.log(' or pass --api-key <key>.\n');
|
|
141
127
|
process.exitCode = 1;
|
|
142
128
|
return;
|
|
143
129
|
}
|
|
144
|
-
|
|
130
|
+
// Non-interactive with env var — just use it
|
|
145
131
|
}
|
|
146
132
|
else {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
133
|
+
console.log(' No LLM configured. Let\'s set it up.\n');
|
|
134
|
+
console.log(' Supports OpenAI, OpenRouter, or any OpenAI-compatible API.\n');
|
|
135
|
+
// Provider selection
|
|
136
|
+
console.log(' [1] OpenAI (api.openai.com)');
|
|
137
|
+
console.log(' [2] OpenRouter (openrouter.ai)');
|
|
138
|
+
console.log(' [3] Custom endpoint\n');
|
|
139
|
+
const choice = await prompt(' Select provider (1/2/3): ');
|
|
140
|
+
let baseUrl;
|
|
141
|
+
let defaultModel;
|
|
142
|
+
if (choice === '2') {
|
|
143
|
+
baseUrl = 'https://openrouter.ai/api/v1';
|
|
144
|
+
defaultModel = 'openai/gpt-4o-mini';
|
|
145
|
+
}
|
|
146
|
+
else if (choice === '3') {
|
|
147
|
+
baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): ');
|
|
148
|
+
if (!baseUrl) {
|
|
149
|
+
console.log('\n No URL provided. Aborting.\n');
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
defaultModel = 'gpt-4o-mini';
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
baseUrl = 'https://api.openai.com/v1';
|
|
157
|
+
defaultModel = 'gpt-4o-mini';
|
|
158
|
+
}
|
|
159
|
+
// Model
|
|
160
|
+
const modelInput = await prompt(` Model (default: ${defaultModel}): `);
|
|
161
|
+
const model = modelInput || defaultModel;
|
|
162
|
+
// API key — pre-fill hint if env var exists
|
|
163
|
+
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
|
|
164
|
+
let key;
|
|
165
|
+
if (envKey) {
|
|
166
|
+
const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4);
|
|
167
|
+
const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `);
|
|
168
|
+
if (!useEnv || useEnv.toLowerCase() === 'y' || useEnv.toLowerCase() === 'yes') {
|
|
169
|
+
key = envKey;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
key = await prompt(' API key: ', true);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
key = await prompt(' API key: ', true);
|
|
177
|
+
}
|
|
178
|
+
if (!key) {
|
|
179
|
+
console.log('\n No key provided. Aborting.\n');
|
|
180
|
+
process.exitCode = 1;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Save
|
|
163
184
|
await saveCLIConfig({ apiKey: key, baseUrl, model });
|
|
164
|
-
console.log(' Config saved
|
|
185
|
+
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
|
186
|
+
llmConfig = { ...llmConfig, apiKey: key, baseUrl, model };
|
|
165
187
|
}
|
|
166
|
-
else {
|
|
167
|
-
console.log(' Config will be used for this session only.\n');
|
|
168
|
-
}
|
|
169
|
-
llmConfig = { ...llmConfig, apiKey: key, baseUrl, model };
|
|
170
188
|
}
|
|
171
|
-
// ── Setup progress bar
|
|
189
|
+
// ── Setup progress bar with elapsed timer ──────────────────────────
|
|
172
190
|
const bar = new cliProgress.SingleBar({
|
|
173
191
|
format: ' {bar} {percentage}% | {phase}',
|
|
174
192
|
barCompleteChar: '\u2588',
|
|
@@ -181,17 +199,35 @@ export const wikiCommand = async (inputPath, options) => {
|
|
|
181
199
|
}, cliProgress.Presets.shades_grey);
|
|
182
200
|
bar.start(100, 0, { phase: 'Initializing...' });
|
|
183
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);
|
|
184
213
|
// ── Run generator ───────────────────────────────────────────────────
|
|
185
214
|
const wikiOptions = {
|
|
186
215
|
force: options?.force,
|
|
187
216
|
model: options?.model,
|
|
188
217
|
baseUrl: options?.baseUrl,
|
|
218
|
+
concurrency: options?.concurrency ? parseInt(options.concurrency, 10) : undefined,
|
|
189
219
|
};
|
|
190
220
|
const generator = new WikiGenerator(repoPath, storagePath, kuzuPath, llmConfig, wikiOptions, (phase, percent, detail) => {
|
|
191
|
-
|
|
221
|
+
const label = detail || phase;
|
|
222
|
+
if (label !== lastPhase) {
|
|
223
|
+
lastPhase = label;
|
|
224
|
+
phaseStart = Date.now();
|
|
225
|
+
}
|
|
226
|
+
bar.update(percent, { phase: label });
|
|
192
227
|
});
|
|
193
228
|
try {
|
|
194
229
|
const result = await generator.run();
|
|
230
|
+
clearInterval(elapsedTimer);
|
|
195
231
|
bar.update(100, { phase: 'Done' });
|
|
196
232
|
bar.stop();
|
|
197
233
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
@@ -219,12 +255,28 @@ export const wikiCommand = async (inputPath, options) => {
|
|
|
219
255
|
await maybePublishGist(viewerPath, options?.gist);
|
|
220
256
|
}
|
|
221
257
|
catch (err) {
|
|
258
|
+
clearInterval(elapsedTimer);
|
|
222
259
|
bar.stop();
|
|
223
260
|
if (err.message?.includes('No source files')) {
|
|
224
261
|
console.log(`\n ${err.message}\n`);
|
|
225
262
|
}
|
|
226
263
|
else if (err.message?.includes('API key') || err.message?.includes('API error')) {
|
|
227
264
|
console.log(`\n LLM Error: ${err.message}\n`);
|
|
265
|
+
// Offer to reconfigure on auth-related failures
|
|
266
|
+
const isAuthError = err.message?.includes('401') || err.message?.includes('403')
|
|
267
|
+
|| err.message?.includes('502') || err.message?.includes('authenticate')
|
|
268
|
+
|| err.message?.includes('Unauthorized');
|
|
269
|
+
if (isAuthError && process.stdin.isTTY) {
|
|
270
|
+
const answer = await new Promise((resolve) => {
|
|
271
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
272
|
+
rl.question(' Reconfigure LLM settings? (Y/n): ', (ans) => { rl.close(); resolve(ans.trim().toLowerCase()); });
|
|
273
|
+
});
|
|
274
|
+
if (!answer || answer === 'y' || answer === 'yes') {
|
|
275
|
+
// Clear saved config so next run triggers interactive setup
|
|
276
|
+
await saveCLIConfig({});
|
|
277
|
+
console.log(' Config cleared. Run `gitnexus wiki` again to reconfigure.\n');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
228
280
|
}
|
|
229
281
|
else {
|
|
230
282
|
console.log(`\n Error: ${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