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 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: gpt-4o-mini)')
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
@@ -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
@@ -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 = 'openai/gpt-4o-mini';
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
- 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,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.onProgress = onProgress || (() => { });
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 (bottom-up)
147
+ // Phase 2: Generate module pages (parallel with concurrency limit)
127
148
  const totalModules = this.countModules(moduleTree);
128
149
  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;
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
- for (let i = 0; i < affectedArray.length; i++) {
493
- const modSlug = this.slugify(affectedArray[i]);
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
- await this.generateModulePage(node, () => { });
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
- * Retries once on transient failures (5xx, network errors).
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://api.openai.com/v1',
28
+ || 'https://openrouter.ai/api/v1',
29
29
  model: overrides?.model
30
30
  || process.env.GITNEXUS_MODEL
31
31
  || savedConfig.model
32
- || 'gpt-4o-mini',
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
- * Retries once on transient failures (5xx, network errors).
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 < 2; 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 === 0) {
75
- const retryAfter = parseInt(response.headers.get('retry-after') || '5', 10);
76
- await sleep(retryAfter * 1000);
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 once
80
- if (response.status >= 500 && attempt === 0) {
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 once
100
- if (attempt === 0 && (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.message?.includes('fetch'))) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
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",