gitnexus 1.1.3 → 1.1.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/ai-context.d.ts +1 -0
- package/dist/cli/ai-context.js +12 -8
- package/dist/cli/analyze.js +13 -0
- package/dist/cli/mcp.js +0 -28
- package/dist/core/ingestion/process-processor.js +8 -2
- package/dist/core/search/bm25-index.js +6 -3
- package/dist/mcp/core/embedder.js +13 -4
- package/dist/mcp/core/kuzu-adapter.js +13 -2
- package/dist/mcp/local/local-backend.js +10 -5
- package/package.json +1 -1
package/dist/cli/ai-context.d.ts
CHANGED
package/dist/cli/ai-context.js
CHANGED
|
@@ -17,6 +17,7 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
|
|
|
17
17
|
* Generate the full GitNexus context content (resources-first approach)
|
|
18
18
|
*/
|
|
19
19
|
function generateGitNexusContent(projectName, stats) {
|
|
20
|
+
const clusterCount = stats.clusters || stats.communities || 0;
|
|
20
21
|
return `${GITNEXUS_START_MARKER}
|
|
21
22
|
# GitNexus MCP
|
|
22
23
|
|
|
@@ -29,9 +30,11 @@ This project is indexed as **${projectName}** by GitNexus, providing AI agents w
|
|
|
29
30
|
| Files | ${stats.files || 0} |
|
|
30
31
|
| Symbols | ${stats.nodes || 0} |
|
|
31
32
|
| Relationships | ${stats.edges || 0} |
|
|
32
|
-
|
|
|
33
|
+
| Clusters | ${clusterCount} |
|
|
33
34
|
| Processes | ${stats.processes || 0} |
|
|
34
35
|
|
|
36
|
+
> **Staleness:** If the index is out of date, run \`gitnexus_analyze({repo: "${projectName}"})\` to refresh. The \`gitnexus://repo/${projectName}/context\` resource will warn you when the index is stale.
|
|
37
|
+
|
|
35
38
|
## Quick Start
|
|
36
39
|
|
|
37
40
|
\`\`\`
|
|
@@ -63,6 +66,7 @@ This project is indexed as **${projectName}** by GitNexus, providing AI agents w
|
|
|
63
66
|
| \`explore\` | Deep dive on symbol/cluster/process | Detailed investigation |
|
|
64
67
|
| \`impact\` | Blast radius analysis | Before making changes |
|
|
65
68
|
| \`cypher\` | Raw graph queries | Complex analysis |
|
|
69
|
+
| \`analyze\` | Re-index repository | When index is stale or after major code changes |
|
|
66
70
|
|
|
67
71
|
> **Multi-repo:** When multiple repos are indexed, pass \`repo: "${projectName}"\` to target this project.
|
|
68
72
|
|
|
@@ -71,17 +75,17 @@ This project is indexed as **${projectName}** by GitNexus, providing AI agents w
|
|
|
71
75
|
### Exploring the Codebase
|
|
72
76
|
\`\`\`
|
|
73
77
|
READ gitnexus://repos → Discover repos
|
|
74
|
-
READ gitnexus://repo/${projectName}/context → Stats and overview
|
|
75
|
-
READ gitnexus://repo/${projectName}/clusters → Find relevant cluster
|
|
76
|
-
READ gitnexus://repo/${projectName}/cluster/
|
|
77
|
-
gitnexus_explore({name: "
|
|
78
|
+
READ gitnexus://repo/${projectName}/context → Stats and overview (check for staleness)
|
|
79
|
+
READ gitnexus://repo/${projectName}/clusters → Find relevant cluster by name
|
|
80
|
+
READ gitnexus://repo/${projectName}/cluster/{name} → See members of that cluster
|
|
81
|
+
gitnexus_explore({name: "<symbol_name>", type: "symbol", repo: "${projectName}"})
|
|
78
82
|
\`\`\`
|
|
79
83
|
|
|
80
84
|
### Planning a Change
|
|
81
85
|
\`\`\`
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
gitnexus_search({query: "<what you want to change>", repo: "${projectName}"})
|
|
87
|
+
gitnexus_impact({target: "<symbol_name>", direction: "upstream", repo: "${projectName}"})
|
|
88
|
+
READ gitnexus://repo/${projectName}/processes → Check affected execution flows
|
|
85
89
|
\`\`\`
|
|
86
90
|
|
|
87
91
|
## Graph Schema
|
package/dist/cli/analyze.js
CHANGED
|
@@ -70,6 +70,7 @@ export const analyzeCommand = async (inputPath, options) => {
|
|
|
70
70
|
await createFTSIndex('Function', 'function_fts', ['name', 'content']);
|
|
71
71
|
await createFTSIndex('Class', 'class_fts', ['name', 'content']);
|
|
72
72
|
await createFTSIndex('Method', 'method_fts', ['name', 'content']);
|
|
73
|
+
await createFTSIndex('Interface', 'interface_fts', ['name', 'content']);
|
|
73
74
|
}
|
|
74
75
|
catch (e) {
|
|
75
76
|
// FTS index creation may fail if tables are empty (no data for that type)
|
|
@@ -103,11 +104,23 @@ export const analyzeCommand = async (inputPath, options) => {
|
|
|
103
104
|
await addToGitignore(repoPath);
|
|
104
105
|
// Generate AI context files
|
|
105
106
|
const projectName = path.basename(repoPath);
|
|
107
|
+
// Compute aggregated cluster count (grouped by heuristicLabel, >=5 symbols)
|
|
108
|
+
// This matches the aggregation logic in local-backend.ts for tool output consistency.
|
|
109
|
+
let aggregatedClusterCount = 0;
|
|
110
|
+
if (pipelineResult.communityResult?.communities) {
|
|
111
|
+
const groups = new Map();
|
|
112
|
+
for (const c of pipelineResult.communityResult.communities) {
|
|
113
|
+
const label = c.heuristicLabel || c.label || 'Unknown';
|
|
114
|
+
groups.set(label, (groups.get(label) || 0) + c.symbolCount);
|
|
115
|
+
}
|
|
116
|
+
aggregatedClusterCount = Array.from(groups.values()).filter(count => count >= 5).length;
|
|
117
|
+
}
|
|
106
118
|
const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, {
|
|
107
119
|
files: pipelineResult.fileContents.size,
|
|
108
120
|
nodes: stats.nodes,
|
|
109
121
|
edges: stats.edges,
|
|
110
122
|
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
|
123
|
+
clusters: aggregatedClusterCount,
|
|
111
124
|
processes: pipelineResult.processResult?.stats.totalProcesses,
|
|
112
125
|
});
|
|
113
126
|
// Close database
|
package/dist/cli/mcp.js
CHANGED
|
@@ -8,35 +8,7 @@
|
|
|
8
8
|
import { startMCPServer } from '../mcp/server.js';
|
|
9
9
|
import { LocalBackend } from '../mcp/local/local-backend.js';
|
|
10
10
|
import { listRegisteredRepos } from '../storage/repo-manager.js';
|
|
11
|
-
/**
|
|
12
|
-
* Protect MCP stdio protocol from library stdout pollution.
|
|
13
|
-
*
|
|
14
|
-
* Libraries like @huggingface/transformers, ONNX Runtime, and kuzu may
|
|
15
|
-
* write progress bars, warnings, or init messages to stdout.
|
|
16
|
-
* MCP uses stdout exclusively for JSON-RPC — any foreign output corrupts
|
|
17
|
-
* the protocol and causes Cursor to kill the connection.
|
|
18
|
-
*
|
|
19
|
-
* This intercept redirects all non-JSON-RPC stdout writes to stderr.
|
|
20
|
-
*/
|
|
21
|
-
function installStdoutGuard() {
|
|
22
|
-
const origWrite = process.stdout.write.bind(process.stdout);
|
|
23
|
-
process.stdout.write = ((chunk, encodingOrCb, cb) => {
|
|
24
|
-
const text = typeof chunk === 'string'
|
|
25
|
-
? chunk
|
|
26
|
-
: Buffer.isBuffer(chunk)
|
|
27
|
-
? chunk.toString('utf-8')
|
|
28
|
-
: '';
|
|
29
|
-
// MCP SDK messages always contain "jsonrpc" — let them through
|
|
30
|
-
if (text.includes('"jsonrpc"')) {
|
|
31
|
-
return origWrite(chunk, encodingOrCb, cb);
|
|
32
|
-
}
|
|
33
|
-
// Redirect everything else to stderr (library noise)
|
|
34
|
-
return process.stderr.write(chunk, encodingOrCb, cb);
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
11
|
export const mcpCommand = async () => {
|
|
38
|
-
// Must be first — before any library can pollute stdout
|
|
39
|
-
installStdoutGuard();
|
|
40
12
|
// Load all registered repos
|
|
41
13
|
const entries = await listRegisteredRepos({ validate: true });
|
|
42
14
|
if (entries.length === 0) {
|
|
@@ -121,10 +121,16 @@ export const processProcesses = async (knowledgeGraph, memberships, onProgress,
|
|
|
121
121
|
},
|
|
122
122
|
};
|
|
123
123
|
};
|
|
124
|
+
/**
|
|
125
|
+
* Minimum edge confidence for process tracing.
|
|
126
|
+
* Filters out ambiguous fuzzy-global matches (0.3) that cause
|
|
127
|
+
* traces to jump across unrelated code areas.
|
|
128
|
+
*/
|
|
129
|
+
const MIN_TRACE_CONFIDENCE = 0.5;
|
|
124
130
|
const buildCallsGraph = (graph) => {
|
|
125
131
|
const adj = new Map();
|
|
126
132
|
graph.relationships.forEach(rel => {
|
|
127
|
-
if (rel.type === 'CALLS') {
|
|
133
|
+
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
|
|
128
134
|
if (!adj.has(rel.sourceId)) {
|
|
129
135
|
adj.set(rel.sourceId, []);
|
|
130
136
|
}
|
|
@@ -136,7 +142,7 @@ const buildCallsGraph = (graph) => {
|
|
|
136
142
|
const buildReverseCallsGraph = (graph) => {
|
|
137
143
|
const adj = new Map();
|
|
138
144
|
graph.relationships.forEach(rel => {
|
|
139
|
-
if (rel.type === 'CALLS') {
|
|
145
|
+
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
|
|
140
146
|
if (!adj.has(rel.targetId)) {
|
|
141
147
|
adj.set(rel.targetId, []);
|
|
142
148
|
}
|
|
@@ -44,25 +44,27 @@ async function queryFTSViaExecutor(executor, tableName, indexName, query, limit)
|
|
|
44
44
|
* @returns Ranked search results from FTS indexes
|
|
45
45
|
*/
|
|
46
46
|
export const searchFTSFromKuzu = async (query, limit = 20, repoId) => {
|
|
47
|
-
let fileResults, functionResults, classResults, methodResults;
|
|
47
|
+
let fileResults, functionResults, classResults, methodResults, interfaceResults;
|
|
48
48
|
if (repoId) {
|
|
49
49
|
// Use MCP connection pool via dynamic import
|
|
50
50
|
const { executeQuery } = await import('../../mcp/core/kuzu-adapter.js');
|
|
51
51
|
const executor = (cypher) => executeQuery(repoId, cypher);
|
|
52
|
-
[fileResults, functionResults, classResults, methodResults] = await Promise.all([
|
|
52
|
+
[fileResults, functionResults, classResults, methodResults, interfaceResults] = await Promise.all([
|
|
53
53
|
queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit),
|
|
54
54
|
queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit),
|
|
55
55
|
queryFTSViaExecutor(executor, 'Class', 'class_fts', query, limit),
|
|
56
56
|
queryFTSViaExecutor(executor, 'Method', 'method_fts', query, limit),
|
|
57
|
+
queryFTSViaExecutor(executor, 'Interface', 'interface_fts', query, limit),
|
|
57
58
|
]);
|
|
58
59
|
}
|
|
59
60
|
else {
|
|
60
61
|
// Use core kuzu adapter (CLI / pipeline context)
|
|
61
|
-
[fileResults, functionResults, classResults, methodResults] = await Promise.all([
|
|
62
|
+
[fileResults, functionResults, classResults, methodResults, interfaceResults] = await Promise.all([
|
|
62
63
|
queryFTS('File', 'file_fts', query, limit, false).catch(() => []),
|
|
63
64
|
queryFTS('Function', 'function_fts', query, limit, false).catch(() => []),
|
|
64
65
|
queryFTS('Class', 'class_fts', query, limit, false).catch(() => []),
|
|
65
66
|
queryFTS('Method', 'method_fts', query, limit, false).catch(() => []),
|
|
67
|
+
queryFTS('Interface', 'interface_fts', query, limit, false).catch(() => []),
|
|
66
68
|
]);
|
|
67
69
|
}
|
|
68
70
|
// Merge results by filePath, summing scores for same file
|
|
@@ -82,6 +84,7 @@ export const searchFTSFromKuzu = async (query, limit = 20, repoId) => {
|
|
|
82
84
|
addResults(functionResults);
|
|
83
85
|
addResults(classResults);
|
|
84
86
|
addResults(methodResults);
|
|
87
|
+
addResults(interfaceResults);
|
|
85
88
|
// Sort by score descending and add rank
|
|
86
89
|
const sorted = Array.from(merged.values())
|
|
87
90
|
.sort((a, b) => b.score - a.score)
|
|
@@ -31,10 +31,19 @@ export const initEmbedder = async () => {
|
|
|
31
31
|
const devicesToTry = ['webgpu', 'cpu'];
|
|
32
32
|
for (const device of devicesToTry) {
|
|
33
33
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
// Silence stdout during model load — ONNX Runtime and transformers.js
|
|
35
|
+
// may write progress/init messages to stdout which corrupts MCP stdio protocol.
|
|
36
|
+
const origWrite = process.stdout.write;
|
|
37
|
+
process.stdout.write = (() => true);
|
|
38
|
+
try {
|
|
39
|
+
embedderInstance = await pipeline('feature-extraction', MODEL_ID, {
|
|
40
|
+
device: device,
|
|
41
|
+
dtype: 'fp32',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
process.stdout.write = origWrite;
|
|
46
|
+
}
|
|
38
47
|
console.error(`GitNexus: Embedding model loaded (${device})`);
|
|
39
48
|
return embedderInstance;
|
|
40
49
|
}
|
|
@@ -82,8 +82,19 @@ export const initKuzu = async (repoId, dbPath) => {
|
|
|
82
82
|
throw new Error(`KuzuDB not found at ${dbPath}. Run: gitnexus analyze`);
|
|
83
83
|
}
|
|
84
84
|
evictLRU();
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// Silence stdout during KuzuDB init — native module may write to stdout
|
|
86
|
+
// which corrupts the MCP stdio protocol.
|
|
87
|
+
const origWrite = process.stdout.write;
|
|
88
|
+
process.stdout.write = (() => true);
|
|
89
|
+
let db;
|
|
90
|
+
let conn;
|
|
91
|
+
try {
|
|
92
|
+
db = new kuzu.Database(dbPath);
|
|
93
|
+
conn = new kuzu.Connection(db);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
process.stdout.write = origWrite;
|
|
97
|
+
}
|
|
87
98
|
pool.set(repoId, { db, conn, lastUsed: Date.now(), dbPath });
|
|
88
99
|
ensureIdleTimer();
|
|
89
100
|
};
|
|
@@ -123,7 +123,8 @@ export class LocalBackend {
|
|
|
123
123
|
}
|
|
124
124
|
// ─── Lazy KuzuDB Init ────────────────────────────────────────────
|
|
125
125
|
async ensureInitialized(repoId) {
|
|
126
|
-
|
|
126
|
+
// Always check the actual pool — the idle timer may have evicted the connection
|
|
127
|
+
if (this.initializedRepos.has(repoId) && isKuzuReady(repoId))
|
|
127
128
|
return;
|
|
128
129
|
const handle = this.repos.get(repoId);
|
|
129
130
|
if (!handle)
|
|
@@ -192,11 +193,13 @@ export class LocalBackend {
|
|
|
192
193
|
this.semanticSearch(repo, query, limit * 2),
|
|
193
194
|
]);
|
|
194
195
|
// Merge and deduplicate results using reciprocal rank fusion
|
|
196
|
+
// Key by nodeId (symbol-level) so semantic precision is preserved.
|
|
197
|
+
// Fall back to filePath for File-level results that lack a nodeId.
|
|
195
198
|
const scoreMap = new Map();
|
|
196
199
|
// BM25 results
|
|
197
200
|
for (let i = 0; i < bm25Results.length; i++) {
|
|
198
201
|
const result = bm25Results[i];
|
|
199
|
-
const key = result.filePath;
|
|
202
|
+
const key = result.nodeId || result.filePath;
|
|
200
203
|
const rrfScore = 1 / (60 + i);
|
|
201
204
|
const existing = scoreMap.get(key);
|
|
202
205
|
if (existing) {
|
|
@@ -210,7 +213,7 @@ export class LocalBackend {
|
|
|
210
213
|
// Semantic results
|
|
211
214
|
for (let i = 0; i < semanticResults.length; i++) {
|
|
212
215
|
const result = semanticResults[i];
|
|
213
|
-
const key = result.filePath;
|
|
216
|
+
const key = result.nodeId || result.filePath;
|
|
214
217
|
const rrfScore = 1 / (60 + i);
|
|
215
218
|
const existing = scoreMap.get(key);
|
|
216
219
|
if (existing) {
|
|
@@ -282,11 +285,11 @@ export class LocalBackend {
|
|
|
282
285
|
const bm25Results = await searchFTSFromKuzu(query, limit, repo.id);
|
|
283
286
|
const results = [];
|
|
284
287
|
for (const bm25Result of bm25Results) {
|
|
285
|
-
const
|
|
288
|
+
const fullPath = bm25Result.filePath;
|
|
286
289
|
try {
|
|
287
290
|
const symbolQuery = `
|
|
288
291
|
MATCH (n)
|
|
289
|
-
WHERE n.filePath
|
|
292
|
+
WHERE n.filePath = '${fullPath.replace(/'/g, "''")}'
|
|
290
293
|
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
|
291
294
|
LIMIT 3
|
|
292
295
|
`;
|
|
@@ -305,6 +308,7 @@ export class LocalBackend {
|
|
|
305
308
|
}
|
|
306
309
|
}
|
|
307
310
|
else {
|
|
311
|
+
const fileName = fullPath.split('/').pop() || fullPath;
|
|
308
312
|
results.push({
|
|
309
313
|
name: fileName,
|
|
310
314
|
type: 'File',
|
|
@@ -314,6 +318,7 @@ export class LocalBackend {
|
|
|
314
318
|
}
|
|
315
319
|
}
|
|
316
320
|
catch {
|
|
321
|
+
const fileName = fullPath.split('/').pop() || fullPath;
|
|
317
322
|
results.push({
|
|
318
323
|
name: fileName,
|
|
319
324
|
type: 'File',
|
package/package.json
CHANGED