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 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
@@ -9,6 +9,7 @@ export interface WikiCommandOptions {
9
9
  model?: string;
10
10
  baseUrl?: string;
11
11
  apiKey?: string;
12
+ concurrency?: string;
12
13
  gist?: boolean;
13
14
  }
14
15
  export declare const wikiCommand: (inputPath?: string, options?: WikiCommandOptions) => Promise<void>;
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
- bar.update(percent, { phase: detail || phase });
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 (bottom-up)
128
+ // Phase 2: Generate module pages (parallel with concurrency limit)
127
129
  const totalModules = this.countModules(moduleTree);
128
130
  let modulesProcessed = 0;
129
- for (const node of moduleTree) {
130
- const generated = await this.generateModulePage(node, () => {
131
- modulesProcessed++;
132
- const percent = 30 + Math.round((modulesProcessed / totalModules) * 55);
133
- this.onProgress('modules', percent, `${modulesProcessed}/${totalModules} modules`);
134
- });
135
- pagesGenerated += generated;
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
- for (let i = 0; i < affectedArray.length; i++) {
493
- const modSlug = this.slugify(affectedArray[i]);
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
- await this.generateModulePage(node, () => { });
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 < 2; 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 === 0) {
75
- const retryAfter = parseInt(response.headers.get('retry-after') || '5', 10);
76
- await sleep(retryAfter * 1000);
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 once
80
- if (response.status >= 500 && attempt === 0) {
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 once
100
- if (attempt === 0 && (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.message?.includes('fetch'))) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",