gitnexus 1.2.4 → 1.2.6

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,7 +53,7 @@ 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
59
  .option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
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): ');
@@ -44,6 +44,12 @@ export declare class WikiGenerator {
44
44
  private onProgress;
45
45
  private failedModules;
46
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;
47
53
  /**
48
54
  * Main entry point. Runs the full pipeline or incremental update.
49
55
  */
@@ -41,7 +41,26 @@ export class WikiGenerator {
41
41
  this.llmConfig = llmConfig;
42
42
  this.maxTokensPerModule = options.maxTokensPerModule ?? DEFAULT_MAX_TOKENS_PER_MODULE;
43
43
  this.concurrency = options.concurrency ?? 3;
44
- this.onProgress = onProgress || (() => { });
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
+ };
45
64
  }
46
65
  /**
47
66
  * Main entry point. Runs the full pipeline or incremental update.
@@ -153,8 +172,7 @@ export class WikiGenerator {
153
172
  }
154
173
  catch (err) {
155
174
  this.failedModules.push(node.name);
156
- this.onProgress('modules', 0, `Failed: ${node.name} — ${err.message?.slice(0, 80)}`);
157
- reportProgress(node.name);
175
+ reportProgress(`Failed: ${node.name}`);
158
176
  return 0;
159
177
  }
160
178
  });
@@ -172,8 +190,7 @@ export class WikiGenerator {
172
190
  }
173
191
  catch (err) {
174
192
  this.failedModules.push(node.name);
175
- this.onProgress('modules', 0, `Failed: ${node.name} — ${err.message?.slice(0, 80)}`);
176
- reportProgress(node.name);
193
+ reportProgress(`Failed: ${node.name}`);
177
194
  }
178
195
  }
179
196
  // Phase 3: Generate overview
@@ -216,7 +233,7 @@ export class WikiGenerator {
216
233
  FILE_LIST: fileList,
217
234
  DIRECTORY_TREE: dirTree,
218
235
  });
219
- 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));
220
237
  const grouping = this.parseGroupingResponse(response.content, files);
221
238
  // Convert to tree nodes
222
239
  const tree = [];
@@ -353,7 +370,7 @@ export class WikiGenerator {
353
370
  INCOMING_CALLS: formatCallEdges(interCalls.incoming),
354
371
  PROCESSES: formatProcesses(processes),
355
372
  });
356
- 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));
357
374
  // Write page with front matter
358
375
  const pageContent = `# ${node.name}\n\n${response.content}`;
359
376
  await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
@@ -389,7 +406,7 @@ export class WikiGenerator {
389
406
  CROSS_MODULE_CALLS: formatCallEdges(crossCalls),
390
407
  CROSS_PROCESSES: formatProcesses(processes),
391
408
  });
392
- 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));
393
410
  const pageContent = `# ${node.name}\n\n${response.content}`;
394
411
  await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
395
412
  }
@@ -425,7 +442,7 @@ export class WikiGenerator {
425
442
  MODULE_EDGES: edgesText,
426
443
  TOP_PROCESSES: formatProcesses(topProcesses),
427
444
  });
428
- 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));
429
446
  const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`;
430
447
  await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8');
431
448
  }
@@ -699,7 +716,7 @@ export class WikiGenerator {
699
716
  // On rate limit, reduce concurrency temporarily
700
717
  if (err.message?.includes('429')) {
701
718
  activeConcurrency = Math.max(1, activeConcurrency - 1);
702
- this.onProgress('modules', 0, `Rate limited — reducing concurrency to ${activeConcurrency}`);
719
+ this.onProgress('modules', this.lastPercent, `Rate limited — concurrency ${activeConcurrency}`);
703
720
  // Re-queue the item
704
721
  idx--;
705
722
  setTimeout(next, 5000);
@@ -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,21 +42,25 @@ 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;
60
64
  const MAX_RETRIES = 3;
61
65
  let lastError = null;
62
66
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
@@ -85,6 +89,11 @@ export async function callLLM(prompt, config, systemPrompt) {
85
89
  }
86
90
  throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`);
87
91
  }
92
+ // Streaming path
93
+ if (useStream && response.body) {
94
+ return await readSSEStream(response.body, options.onChunk);
95
+ }
96
+ // Non-streaming path
88
97
  const json = await response.json();
89
98
  const choice = json.choices?.[0];
90
99
  if (!choice?.message?.content) {
@@ -108,6 +117,46 @@ export async function callLLM(prompt, config, systemPrompt) {
108
117
  }
109
118
  throw lastError || new Error('LLM call failed after retries');
110
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
+ }
111
160
  function sleep(ms) {
112
161
  return new Promise(resolve => setTimeout(resolve, ms));
113
162
  }
@@ -1,26 +1,34 @@
1
1
  /**
2
2
  * KuzuDB Adapter (Connection Pool)
3
3
  *
4
- * Manages a pool of KuzuDB connections keyed by repoId.
5
- * Connections are lazily opened on first query and evicted
6
- * after idle timeout or when pool exceeds max size (LRU).
4
+ * Manages a pool of KuzuDB databases keyed by repoId, each with
5
+ * multiple Connection objects for safe concurrent query execution.
6
+ *
7
+ * KuzuDB Connections are NOT thread-safe — a single Connection
8
+ * segfaults if concurrent .query() calls hit it simultaneously.
9
+ * This adapter provides a checkout/return connection pool so each
10
+ * concurrent query gets its own Connection from the same Database.
11
+ *
12
+ * @see https://docs.kuzudb.com/concurrency — multiple Connections
13
+ * from the same Database is the officially supported concurrency pattern.
7
14
  */
8
15
  /**
9
- * Initialize (or reuse) a connection for a specific repo.
16
+ * Initialize (or reuse) a Database + connection pool for a specific repo.
10
17
  * Retries on lock errors (e.g., when `gitnexus analyze` is running).
11
18
  */
12
19
  export declare const initKuzu: (repoId: string, dbPath: string) => Promise<void>;
13
20
  /**
14
- * Execute a query on a specific repo's connection
21
+ * Execute a query on a specific repo's connection pool.
22
+ * Automatically checks out a connection, runs the query, and returns it.
15
23
  */
16
24
  export declare const executeQuery: (repoId: string, cypher: string) => Promise<any[]>;
17
25
  /**
18
- * Close one or all connections.
19
- * If repoId is provided, close only that connection.
20
- * If omitted, close all connections in the pool.
26
+ * Close one or all repo pools.
27
+ * If repoId is provided, close only that repo's connections.
28
+ * If omitted, close all repos.
21
29
  */
22
30
  export declare const closeKuzu: (repoId?: string) => Promise<void>;
23
31
  /**
24
- * Check if a specific repo's connection is active
32
+ * Check if a specific repo's pool is active
25
33
  */
26
34
  export declare const isKuzuReady: (repoId: string) => boolean;
@@ -1,15 +1,28 @@
1
1
  /**
2
2
  * KuzuDB Adapter (Connection Pool)
3
3
  *
4
- * Manages a pool of KuzuDB connections keyed by repoId.
5
- * Connections are lazily opened on first query and evicted
6
- * after idle timeout or when pool exceeds max size (LRU).
4
+ * Manages a pool of KuzuDB databases keyed by repoId, each with
5
+ * multiple Connection objects for safe concurrent query execution.
6
+ *
7
+ * KuzuDB Connections are NOT thread-safe — a single Connection
8
+ * segfaults if concurrent .query() calls hit it simultaneously.
9
+ * This adapter provides a checkout/return connection pool so each
10
+ * concurrent query gets its own Connection from the same Database.
11
+ *
12
+ * @see https://docs.kuzudb.com/concurrency — multiple Connections
13
+ * from the same Database is the officially supported concurrency pattern.
7
14
  */
8
15
  import fs from 'fs/promises';
9
16
  import kuzu from 'kuzu';
10
17
  const pool = new Map();
18
+ /** Max repos in the pool (LRU eviction) */
11
19
  const MAX_POOL_SIZE = 5;
20
+ /** Idle timeout before closing a repo's connections */
12
21
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
22
+ /** Max connections per repo (caps concurrent queries per repo) */
23
+ const MAX_CONNS_PER_REPO = 8;
24
+ /** Connections created eagerly on init */
25
+ const INITIAL_CONNS_PER_REPO = 2;
13
26
  let idleTimer = null;
14
27
  /**
15
28
  * Start the idle cleanup timer (runs every 60s)
@@ -25,13 +38,12 @@ function ensureIdleTimer() {
25
38
  }
26
39
  }
27
40
  }, 60_000);
28
- // Don't keep the process alive just for this timer
29
41
  if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
30
42
  idleTimer.unref();
31
43
  }
32
44
  }
33
45
  /**
34
- * Evict the least-recently-used connection if pool is at capacity
46
+ * Evict the least-recently-used repo if pool is at capacity
35
47
  */
36
48
  function evictLRU() {
37
49
  if (pool.size < MAX_POOL_SIZE)
@@ -49,26 +61,42 @@ function evictLRU() {
49
61
  }
50
62
  }
51
63
  /**
52
- * Close a single pool entry
64
+ * Close all connections for a repo and remove it from the pool
53
65
  */
54
66
  function closeOne(repoId) {
55
67
  const entry = pool.get(repoId);
56
68
  if (!entry)
57
69
  return;
58
- try {
59
- entry.conn.close();
70
+ for (const conn of entry.available) {
71
+ try {
72
+ conn.close();
73
+ }
74
+ catch { }
60
75
  }
61
- catch { }
62
76
  try {
63
77
  entry.db.close();
64
78
  }
65
79
  catch { }
66
80
  pool.delete(repoId);
67
81
  }
82
+ /**
83
+ * Create a new Connection from a repo's Database.
84
+ * Silences stdout to prevent native module output from corrupting MCP stdio.
85
+ */
86
+ function createConnection(db) {
87
+ const origWrite = process.stdout.write;
88
+ process.stdout.write = (() => true);
89
+ try {
90
+ return new kuzu.Connection(db);
91
+ }
92
+ finally {
93
+ process.stdout.write = origWrite;
94
+ }
95
+ }
68
96
  const LOCK_RETRY_ATTEMPTS = 3;
69
97
  const LOCK_RETRY_DELAY_MS = 2000;
70
98
  /**
71
- * Initialize (or reuse) a connection for a specific repo.
99
+ * Initialize (or reuse) a Database + connection pool for a specific repo.
72
100
  * Retries on lock errors (e.g., when `gitnexus analyze` is running).
73
101
  */
74
102
  export const initKuzu = async (repoId, dbPath) => {
@@ -90,17 +118,19 @@ export const initKuzu = async (repoId, dbPath) => {
90
118
  // avoids lock conflicts when `gitnexus analyze` is writing.
91
119
  let lastError = null;
92
120
  for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
93
- // Silence stdout during KuzuDB init — native module may write to stdout
94
- // which corrupts the MCP stdio protocol.
95
121
  const origWrite = process.stdout.write;
96
122
  process.stdout.write = (() => true);
97
123
  try {
98
124
  const db = new kuzu.Database(dbPath, 0, // bufferManagerSize (default)
99
125
  false, // enableCompression (default)
100
126
  true);
101
- const conn = new kuzu.Connection(db);
102
127
  process.stdout.write = origWrite;
103
- pool.set(repoId, { db, conn, lastUsed: Date.now(), dbPath });
128
+ // Pre-create a small pool of connections
129
+ const available = [];
130
+ for (let i = 0; i < INITIAL_CONNS_PER_REPO; i++) {
131
+ available.push(createConnection(db));
132
+ }
133
+ pool.set(repoId, { db, available, checkedOut: 0, waiters: [], lastUsed: Date.now(), dbPath });
104
134
  ensureIdleTimer();
105
135
  return;
106
136
  }
@@ -111,7 +141,6 @@ export const initKuzu = async (repoId, dbPath) => {
111
141
  || lastError.message.includes('lock');
112
142
  if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS)
113
143
  break;
114
- // Wait before retrying — analyze may be mid-rebuild
115
144
  await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt));
116
145
  }
117
146
  }
@@ -119,7 +148,47 @@ export const initKuzu = async (repoId, dbPath) => {
119
148
  `Retry later. (${lastError?.message || 'unknown error'})`);
120
149
  };
121
150
  /**
122
- * Execute a query on a specific repo's connection
151
+ * Checkout a connection from the pool.
152
+ * Returns an available connection, or creates a new one if under the cap.
153
+ * If all connections are busy and at cap, queues the caller until one is returned.
154
+ */
155
+ function checkout(entry) {
156
+ // Fast path: grab an available connection
157
+ if (entry.available.length > 0) {
158
+ entry.checkedOut++;
159
+ return Promise.resolve(entry.available.pop());
160
+ }
161
+ // Grow the pool if under the cap
162
+ const totalConns = entry.available.length + entry.checkedOut;
163
+ if (totalConns < MAX_CONNS_PER_REPO) {
164
+ entry.checkedOut++;
165
+ return Promise.resolve(createConnection(entry.db));
166
+ }
167
+ // At capacity — queue the caller. checkin() will resolve this when
168
+ // a connection is returned, handing it directly to the next waiter.
169
+ return new Promise(resolve => {
170
+ entry.waiters.push(resolve);
171
+ });
172
+ }
173
+ /**
174
+ * Return a connection to the pool after use.
175
+ * If there are queued waiters, hand the connection directly to the next one
176
+ * instead of putting it back in the available array (avoids race conditions).
177
+ */
178
+ function checkin(entry, conn) {
179
+ if (entry.waiters.length > 0) {
180
+ // Hand directly to the next waiter — no intermediate available state
181
+ const waiter = entry.waiters.shift();
182
+ waiter(conn);
183
+ }
184
+ else {
185
+ entry.checkedOut--;
186
+ entry.available.push(conn);
187
+ }
188
+ }
189
+ /**
190
+ * Execute a query on a specific repo's connection pool.
191
+ * Automatically checks out a connection, runs the query, and returns it.
123
192
  */
124
193
  export const executeQuery = async (repoId, cypher) => {
125
194
  const entry = pool.get(repoId);
@@ -127,22 +196,27 @@ export const executeQuery = async (repoId, cypher) => {
127
196
  throw new Error(`KuzuDB not initialized for repo "${repoId}". Call initKuzu first.`);
128
197
  }
129
198
  entry.lastUsed = Date.now();
130
- const queryResult = await entry.conn.query(cypher);
131
- const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
132
- const rows = await result.getAll();
133
- return rows;
199
+ const conn = await checkout(entry);
200
+ try {
201
+ const queryResult = await conn.query(cypher);
202
+ const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
203
+ const rows = await result.getAll();
204
+ return rows;
205
+ }
206
+ finally {
207
+ checkin(entry, conn);
208
+ }
134
209
  };
135
210
  /**
136
- * Close one or all connections.
137
- * If repoId is provided, close only that connection.
138
- * If omitted, close all connections in the pool.
211
+ * Close one or all repo pools.
212
+ * If repoId is provided, close only that repo's connections.
213
+ * If omitted, close all repos.
139
214
  */
140
215
  export const closeKuzu = async (repoId) => {
141
216
  if (repoId) {
142
217
  closeOne(repoId);
143
218
  return;
144
219
  }
145
- // Close all
146
220
  for (const id of [...pool.keys()]) {
147
221
  closeOne(id);
148
222
  }
@@ -152,6 +226,6 @@ export const closeKuzu = async (repoId) => {
152
226
  }
153
227
  };
154
228
  /**
155
- * Check if a specific repo's connection is active
229
+ * Check if a specific repo's pool is active
156
230
  */
157
231
  export const isKuzuReady = (repoId) => pool.has(repoId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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",