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 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
@@ -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 (!llmConfig.apiKey) {
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
- console.log(' Error: No LLM API key found.');
119
- console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,');
120
- console.log(' or pass --api-key <key>.\n');
121
- process.exitCode = 1;
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
- defaultModel = 'gpt-4o-mini';
130
+ // Non-interactive with env var — just use it
145
131
  }
146
132
  else {
147
- baseUrl = 'https://api.openai.com/v1';
148
- defaultModel = 'gpt-4o-mini';
149
- }
150
- // Model
151
- const modelInput = await prompt(` Model (default: ${defaultModel}): `);
152
- const model = modelInput || defaultModel;
153
- // API key
154
- const key = await prompt(' API key: ', true);
155
- if (!key) {
156
- console.log('\n No key provided. Aborting.\n');
157
- process.exitCode = 1;
158
- return;
159
- }
160
- // Save
161
- const save = await prompt('\n Save config to ~/.gitnexus/config.json? (Y/n): ');
162
- if (!save || save.toLowerCase() === 'y' || save.toLowerCase() === 'yes') {
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.\n');
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
- 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 });
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 (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.2",
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",