repo-knowledge-graph 0.5.0 → 0.6.0
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/README.md +168 -8
- package/SECURITY.md +5 -0
- package/bin/repo-graph.js +4 -1
- package/package.json +5 -3
- package/src/index.js +37 -7
- package/src/mcp.js +42 -0
package/README.md
CHANGED
|
@@ -1,13 +1,173 @@
|
|
|
1
1
|
# repo-knowledge-graph
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/repo-knowledge-graph)
|
|
4
|
+
[](https://www.npmjs.com/package/repo-knowledge-graph)
|
|
5
|
+
[](LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
**Turn a source-code repository into a searchable knowledge graph — directly from your terminal.**
|
|
8
|
+
|
|
9
|
+
A lightweight Node.js CLI and library for indexing repository files and code symbols, finding relevant code, assembling focused context, and estimating potential token savings. No hosted service or LLM key required for the core workflow.
|
|
10
|
+
|
|
11
|
+
> **Alpha:** APIs, graph format, and commands may change. Token savings are estimates, not measured API billing reductions.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
**Requirements:** Node.js 20+.
|
|
16
|
+
|
|
17
|
+
From the root of the repository you want to analyze:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install --save-dev repo-knowledge-graph@alpha
|
|
21
|
+
npx repo-graph --help
|
|
22
|
+
npx repo-graph index
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The CLI stores its generated graph in `.repo-graph/graph.json` in the current working directory. To try unreleased v0.6.0 source before npm publication, clone this repository and run `npm link` locally.
|
|
26
|
+
|
|
27
|
+
## CLI showcase
|
|
28
|
+
|
|
29
|
+
| Command | What it does |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `npx repo-graph --help` | Show available commands |
|
|
32
|
+
| `npx repo-graph index` | Scan the current repository and build/update the graph |
|
|
33
|
+
| `npx repo-graph stats` | Display counts of indexed files, nodes, and edges |
|
|
34
|
+
| `npx repo-graph search "embedding"` | Find repository files relevant to a query |
|
|
35
|
+
| `npx repo-graph context "embedding"` | Retrieve focused context for a query |
|
|
36
|
+
| `npx repo-graph savings "embedding"` | Estimate token savings for a query |
|
|
37
|
+
| `npx repo-graph index` | Run again to reuse unchanged file metadata |
|
|
38
|
+
|
|
39
|
+
**Try the complete workflow:**
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 1. Generate or refresh the knowledge graph
|
|
43
|
+
npx repo-graph index
|
|
44
|
+
|
|
45
|
+
# 2. Inspect the graph
|
|
46
|
+
npx repo-graph stats
|
|
47
|
+
|
|
48
|
+
# 3. Search for a concept in your code
|
|
49
|
+
npx repo-graph search "authentication"
|
|
50
|
+
|
|
51
|
+
# 4. Collect context for that concept
|
|
52
|
+
npx repo-graph context "authentication"
|
|
53
|
+
|
|
54
|
+
# 5. Inspect approximate token savings
|
|
55
|
+
npx repo-graph savings "authentication"
|
|
56
|
+
|
|
57
|
+
# 6. Re-index after making changes
|
|
58
|
+
npx repo-graph index
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Important:** `search`, `context`, and `savings` each require a query. Run `index` before using them. Commands operate on the **current working directory**, so run them from the project you want to analyze.
|
|
62
|
+
|
|
63
|
+
### Example: indexing a real repository
|
|
64
|
+
|
|
65
|
+
The following is an **example from an initial run** on a separate development repository, not a performance benchmark or guaranteed result:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
$ npx repo-graph index
|
|
69
|
+
{
|
|
70
|
+
"file": "/path/to/llm_engineering/.repo-graph/graph.json",
|
|
71
|
+
"files": 2173,
|
|
72
|
+
"nodes": 11192,
|
|
73
|
+
"edges": 9025,
|
|
74
|
+
"reusedFiles": 0
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`reusedFiles: 0` is expected on a first scan. Re-running `index` can reuse unchanged file metadata; files are still read and hashed for change detection.
|
|
79
|
+
|
|
80
|
+
### Understanding the commands
|
|
81
|
+
|
|
82
|
+
- **Index:** Collects repository file and symbol information and creates graph relationships.
|
|
83
|
+
- **Stats:** Reports graph size; it does not measure graph quality.
|
|
84
|
+
- **Search:** Finds relevant files using the package's current non-LLM search implementation.
|
|
85
|
+
- **Context:** Builds a focused context response around the supplied query.
|
|
86
|
+
- **Savings:** Reports *estimated* token savings associated with focused context. This is not a measured reduction in actual model usage or cost.
|
|
87
|
+
|
|
88
|
+
## Using it in CI or scripts
|
|
89
|
+
|
|
90
|
+
Install it as a project dependency and invoke the binary from the repository root:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm install --save-dev repo-knowledge-graph@alpha
|
|
94
|
+
npx repo-graph index
|
|
95
|
+
npx repo-graph stats
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
To run it without adding a dependency:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm exec --yes --package=repo-knowledge-graph@alpha -- repo-graph index
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The **npm package name** is `repo-knowledge-graph`; the **CLI executable** is `repo-graph`. `alpha` is a release tag, not part of the package name.
|
|
105
|
+
|
|
106
|
+
## Local MCP integration (v0.6.0 alpha source)
|
|
107
|
+
|
|
108
|
+
After indexing the project, launch the stdio MCP server:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npx repo-graph index
|
|
112
|
+
npx repo-graph serve --mcp
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Register the command with an MCP-compatible coding assistant, setting its working directory to the repository root. The server uses newline-delimited JSON-RPC over stdio; it does **not** print a startup banner to stdout. Tools: `repo_search`, `repo_context` (with `maxTokens`), `repo_stats`, and `repo_refresh`. The server automatically checks for repository changes before each tool call and refreshes the saved graph when files change. `repo_refresh` also forces a refresh. Automatic freshness currently re-reads and hashes eligible files, so very large repositories may see added latency.
|
|
116
|
+
|
|
117
|
+
Example generic MCP client configuration (adapt to your assistant's configuration format):
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"mcpServers": {
|
|
122
|
+
"repo-knowledge-graph": {
|
|
123
|
+
"command": "npx",
|
|
124
|
+
"args": ["repo-graph", "serve", "--mcp"]
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`repo_context` returns line-numbered snippets with a configurable approximate token budget. Snippet extraction is keyword-oriented and may omit relevant code. The server uses the working directory as the repository root; configure your MCP client accordingly.
|
|
131
|
+
|
|
132
|
+
**Note:** The assistant must actually invoke these tools for them to influence context usage. This is not an automatic prompt interceptor. The `savings` command compares against an all-files baseline and does **not** establish actual model-token savings.
|
|
133
|
+
|
|
134
|
+
## Retrieval benchmark
|
|
135
|
+
|
|
136
|
+
Create a `repo-graph-benchmark.json` file containing labeled questions and relevant file paths:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
[
|
|
140
|
+
{"query": "Where is authentication implemented?", "relevantPaths": ["src/auth.js"]}
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Replace `src/auth.js` with an actual indexed path. Then run:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npx repo-graph benchmark repo-graph-benchmark.json
|
|
11
148
|
```
|
|
12
149
|
|
|
13
|
-
|
|
150
|
+
This measures retrieval precision, recall, selected-context token estimates and latency. The benchmark requires ground-truth file labels; it does **not** measure live model billing or guarantee reduced tokens in an assistant.
|
|
151
|
+
|
|
152
|
+
## Advanced / experimental integrations
|
|
153
|
+
|
|
154
|
+
- **Incremental indexing:** Reuses metadata for unchanged files on subsequent scans.
|
|
155
|
+
- **Optional TypeScript AST extraction:** Install the optional `typescript` peer dependency and set `REPO_GRAPH_PARSER=typescript` when indexing.
|
|
156
|
+
- **Graphify-compatible adapter:** `toGraphify(graph, { createGraph })` expects a graph object with `addNode` and `addEdge`. Compatibility with a particular Graphify release has not been verified.
|
|
157
|
+
- **Decision-model integration:** A Laya-compatible local adapter is included as an experimental extension point; no model is bundled.
|
|
158
|
+
|
|
159
|
+
## Safety and limitations
|
|
160
|
+
|
|
161
|
+
Review [SECURITY.md](SECURITY.md) before scanning sensitive repositories. Keep `.repo-graph/` out of version control if the generated graph may contain internal repository details:
|
|
162
|
+
|
|
163
|
+
```gitignore
|
|
164
|
+
.repo-graph/
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The scanner supports common `.gitignore` glob patterns and negations, but it is not a complete implementation of Git's nested ignore semantics. Source files are never executed by the scanner. Generated context includes source snippets and may expose sensitive code to the connected AI assistant; review its privacy policy before use.
|
|
168
|
+
|
|
169
|
+
The CLI runs locally, but graph outputs may reflect private code structure. The current release is an alpha and should be evaluated on representative repositories before production adoption.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT — see [LICENSE](LICENSE).
|
package/SECURITY.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
# Security review (alpha)
|
|
2
2
|
|
|
3
3
|
The scanner does not execute source or persist source content. It excludes common secret filenames and recognizable credential patterns; this is not comprehensive secret detection. Context reads reject symlinks and path escapes. External embedding/model adapters may transmit source. Known limitations include partial gitignore support, TOCTOU race, incomplete secret detection, and no independent penetration test. Never commit `.repo-graph/`.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## MCP security boundaries
|
|
7
|
+
|
|
8
|
+
The MCP server is local and uses stdio, not a network listener. It reads repository files to answer queries and refreshes the on-disk index when files change. Context responses include source snippets; connecting an AI assistant may cause those snippets to be sent to the assistant's model provider. Restrict the working directory to the intended repository, and avoid indexing sensitive source. The server does not execute repository code. Gitignore parsing covers common patterns but is not fully equivalent to Git's nested ignore behavior. Changes between scan and context read are rejected by a content-hash check; this is not a substitute for OS-level access controls.
|
package/bin/repo-graph.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {scan,save,load,searchFiles,context} from '../src/index.js';
|
|
3
|
+
import {startMcp} from '../src/mcp.js';
|
|
4
|
+
import {benchmark} from '../src/benchmark.js';
|
|
5
|
+
import {readFile} from 'node:fs/promises';
|
|
3
6
|
const [cmd,...args]=process.argv.slice(2),root=process.cwd();
|
|
4
|
-
try{if(cmd==='index'){let previous;try{previous=await load(root);}catch{}const g=await scan(root,{previous,parser:process.env.REPO_GRAPH_PARSER||'regex'});console.log(JSON.stringify({file:await save(g),files:g.files.length,nodes:g.nodes.length,edges:g.edges.length,reusedFiles:g.reusedFiles},null,2));}else if(['stats','search','context','savings'].includes(cmd)){const g=await load(root),query=args.join(' ');if(cmd==='stats')console.log(JSON.stringify({files:g.files.length,nodes:g.nodes.length,edges:g.edges.length}));else{if(!query)throw Error('Query required');const r=cmd==='search'?await searchFiles(g,query):await context(g,query);console.log(JSON.stringify(cmd==='savings'?{estimatedTokensSaved:r.estimatedTokensSaved,estimatedSavingsPercent:r.estimatedSavingsPercent,method:r.method}:r,null,2));}}else{console.log('Usage: repo-graph <index|stats|search|context|savings> [query]');if(cmd)process.exitCode=1;}}catch(e){console.error(e.message);process.exitCode=1;}
|
|
7
|
+
try{if(cmd==='serve'&&args.includes('--mcp')){startMcp(root);}else if(cmd==='benchmark'){const cases=JSON.parse(await readFile(args[0]||'repo-graph-benchmark.json','utf8'));const g=await load(root);const r=await benchmark(g,cases,{retrieve:(graph,query,{limit})=>context(graph,query,{limit})});console.log(JSON.stringify(r,null,2));}else if(cmd==='index'){let previous;try{previous=await load(root);}catch{}const g=await scan(root,{previous,parser:process.env.REPO_GRAPH_PARSER||'regex'});console.log(JSON.stringify({file:await save(g),files:g.files.length,nodes:g.nodes.length,edges:g.edges.length,reusedFiles:g.reusedFiles},null,2));}else if(['stats','search','context','savings'].includes(cmd)){const g=await load(root),query=args.join(' ');if(cmd==='stats')console.log(JSON.stringify({files:g.files.length,nodes:g.nodes.length,edges:g.edges.length}));else{if(!query)throw Error('Query required');const r=cmd==='search'?await searchFiles(g,query):await context(g,query);console.log(JSON.stringify(cmd==='savings'?{estimatedTokensSaved:r.estimatedTokensSaved,estimatedSavingsPercent:r.estimatedSavingsPercent,method:r.method}:r,null,2));}}else{console.log('Usage: repo-graph <index|stats|search|context|savings|benchmark [cases.json]|serve --mcp> [query]');if(cmd)process.exitCode=1;}}catch(e){console.error(e.message);process.exitCode=1;}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "repo-knowledge-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Repository knowledge graph and CLI",
|
|
6
6
|
"bin": {
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.js",
|
|
11
|
-
"./adapters": "./src/adapters.js"
|
|
11
|
+
"./adapters": "./src/adapters.js",
|
|
12
|
+
"./mcp": "./src/mcp.js",
|
|
13
|
+
"./benchmark": "./src/benchmark.js"
|
|
12
14
|
},
|
|
13
15
|
"scripts": {
|
|
14
16
|
"test": "node --test",
|
|
15
|
-
"check": "node --check src/index.js && node --check src/adapters.js && node --check bin/repo-graph.js",
|
|
17
|
+
"check": "node --check src/index.js && node --check src/adapters.js && node --check bin/repo-graph.js && node --check src/mcp.js",
|
|
16
18
|
"prepublishOnly": "npm test && npm run check"
|
|
17
19
|
},
|
|
18
20
|
"engines": {
|
package/src/index.js
CHANGED
|
@@ -2,19 +2,20 @@ import {readdir,readFile,mkdir,writeFile,lstat,realpath} from 'node:fs/promises'
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {createHash} from 'node:crypto';
|
|
4
4
|
import {extractTypeScript} from './adapters.js';
|
|
5
|
-
const skip=new Set(['.git','node_modules','dist','build','.repo-graph','coverage','.venv','vendor']);
|
|
5
|
+
const skip=new Set(['.git','node_modules','dist','build','.repo-graph','coverage','.venv','.venv312','.tox','.mypy_cache','.pytest_cache','__pycache__','.next','vendor']);
|
|
6
6
|
const ext=new Set(['.js','.jsx','.ts','.tsx','.mjs','.cjs','.py','.go','.java','.rs','.md','.json']);
|
|
7
7
|
const sensitive=/(^|\/)(\.env(?:\..*)?|id_rsa|id_ed25519|[^/]*\.(?:pem|key|p12)|credentials(?:\..*)?|secrets?(?:\..*)?)$/i;
|
|
8
8
|
const norm=p=>p.split(path.sep).join('/');
|
|
9
9
|
const secretPattern=/(?:-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|(?:ghp_|github_pat_|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}))/;
|
|
10
10
|
export const estimateTokens=s=>Math.ceil(String(s).length/4);
|
|
11
|
-
async function patterns(root){try{return (await readFile(path.join(root,'.gitignore'),'utf8')).split(/\r?\n/).filter(
|
|
12
|
-
function
|
|
11
|
+
async function patterns(root){try{return (await readFile(path.join(root,'.gitignore'),'utf8')).split(/\r?\n/).map(p=>p.trim()).filter(p=>p&&!p.startsWith('#'));}catch{return [];}}
|
|
12
|
+
function globRegex(glob){let out='';for(let i=0;i<glob.length;i++){const c=glob[i];if(c==='*'){if(glob[i+1]==='*'){i++;if(glob[i+1]==='/'){i++;out+='(?:.*/)?';}else out+='.*';}else out+='[^/]*';}else if(c==='?')out+='[^/]';else out+=('.+^$()[]{}|\\'.includes(c)?'\\':'')+c;}return out;}
|
|
13
|
+
function ignored(rel,ps){let matched=false;for(let p of ps){const negate=p.startsWith('!');if(negate)p=p.slice(1);const anchored=p.startsWith('/');if(anchored)p=p.slice(1);const directory=p.endsWith('/');if(directory)p=p.slice(0,-1);const pattern=globRegex(p);const prefix=anchored||p.includes('/')?'^':'^(?:.*/)?';const regex=new RegExp(prefix+pattern+'(?:/.*)?'+String.fromCharCode(36));if(regex.test(rel))matched=!negate;}return matched;}
|
|
13
14
|
function resolve(spec,rel,known){if(!spec.startsWith('.'))return 'module:'+spec;const base=norm(path.posix.normalize(path.posix.join(path.posix.dirname(rel),spec)));return 'file:'+([base,base+'.js',base+'.ts',base+'.tsx',base+'.jsx',base+'/index.js',base+'/index.ts'].find(p=>known.has(p))||'__missing__')==='file:__missing__'?'module:'+spec:'file:'+([base,base+'.js',base+'.ts',base+'.tsx',base+'.jsx',base+'/index.js',base+'/index.ts'].find(p=>known.has(p)));}
|
|
14
15
|
export async function scan(root,{maxBytes=256000,previous=null,parser='regex'}={}){
|
|
15
16
|
if(!['regex','typescript'].includes(parser))throw Error('Unknown parser');
|
|
16
17
|
root=path.resolve(root);const ps=await patterns(root),files=[],nodes=[],edges=[],contents=new Map(),prior=new Map((previous?.files||[]).map(f=>[f.path,f]));let reusedFiles=0;
|
|
17
|
-
async function walk(dir){for(const ent of await readdir(dir,{withFileTypes:true})){if(ent.isSymbolicLink()||skip.has(ent.name))continue;const full=path.join(dir,ent.name),rel=norm(path.relative(root,full));if(ignored(rel,ps)||sensitive.test(rel))continue;if(ent.isDirectory()){await walk(full);continue;}if(!ent.isFile()||!ext.has(path.extname(ent.name)))continue;let content;try{content=await readFile(full,'utf8');}catch{continue;}const bytes=Buffer.byteLength(content);if(bytes>maxBytes||content.includes('\0')||secretPattern.test(content))continue;const hash=createHash('sha256').update(content).digest('hex');if(prior.get(rel)?.hash===hash)reusedFiles++;files.push({path:rel,hash,bytes});contents.set(rel,content);}}
|
|
18
|
+
async function walk(dir){for(const ent of await readdir(dir,{withFileTypes:true})){if(ent.isSymbolicLink()||skip.has(ent.name)||ent.name.startsWith('.venv'))continue;const full=path.join(dir,ent.name),rel=norm(path.relative(root,full));if(ignored(rel,ps)||sensitive.test(rel))continue;if(ent.isDirectory()){await walk(full);continue;}if(!ent.isFile()||!ext.has(path.extname(ent.name)))continue;let content;try{content=await readFile(full,'utf8');}catch{continue;}const bytes=Buffer.byteLength(content);if(bytes>maxBytes||content.includes('\0')||secretPattern.test(content))continue;const hash=createHash('sha256').update(content).digest('hex');if(prior.get(rel)?.hash===hash)reusedFiles++;files.push({path:rel,hash,bytes});contents.set(rel,content);}}
|
|
18
19
|
await walk(root);files.sort((a,b)=>a.path.localeCompare(b.path));const known=new Set(files.map(f=>f.path));
|
|
19
20
|
for(const f of files){const content=contents.get(f.path),id='file:'+f.path;nodes.push({id,type:'file',name:f.path,hash:f.hash});let symbols=[],imports=[];const old=prior.get(f.path);
|
|
20
21
|
if(old?.hash===f.hash&&previous?.parser===parser&&Array.isArray(old.symbols)&&Array.isArray(old.imports)){symbols=old.symbols;imports=old.imports;}
|
|
@@ -27,9 +28,38 @@ export async function scan(root,{maxBytes=256000,previous=null,parser='regex'}={
|
|
|
27
28
|
for(const e of edges.filter(e=>e.to.startsWith('module:')))if(!nodes.some(n=>n.id===e.to))nodes.push({id:e.to,type:'module',name:e.to.slice(7)});
|
|
28
29
|
return {schemaVersion:4,root,generatedAt:new Date().toISOString(),nodes,edges,files,reusedFiles,parser};
|
|
29
30
|
}
|
|
30
|
-
export async function readContextFile(graph,rel){const root=path.resolve(graph.root),file=path.resolve(root,rel);if(!file.startsWith(root+path.sep)||!graph.files.some(f=>f.path===rel))throw Error('Unsafe or unindexed file');const info=await lstat(file);if(!info.isFile()||info.isSymbolicLink())throw Error('Unsafe file type');const actual=await realpath(file),realRoot=await realpath(root);if(!actual.startsWith(realRoot+path.sep))throw Error('File escaped repository');const content=await readFile(actual,'utf8');if(secretPattern.test(content))throw Error('Potential secret detected');return content;}
|
|
31
|
-
export async function searchFiles(graph,query,{limit=10}={}){const terms=String(query).toLowerCase().split(/\s+/).filter(Boolean);if(!terms.length)return [];const found=[];for(const f of graph.files){const body=(await readContextFile(graph,f.path)).toLowerCase(),name=f.path.toLowerCase(),score=terms.reduce((n,t)=>n+(name.includes(t)?5:0)+(body.includes(t)?1:0),0);if(score)found.push({path:f.path,score});}return found.sort((a,b)=>b.score-a.score||a.path.localeCompare(b.path)).slice(0,limit);}
|
|
32
|
-
export async function context(graph,query,{limit=5,maxTokens=4000}={}){
|
|
31
|
+
export async function readContextFile(graph,rel){const root=path.resolve(graph.root),file=path.resolve(root,rel);if(!file.startsWith(root+path.sep)||!graph.files.some(f=>f.path===rel))throw Error('Unsafe or unindexed file');const info=await lstat(file);if(!info.isFile()||info.isSymbolicLink())throw Error('Unsafe file type');const actual=await realpath(file),realRoot=await realpath(root);if(!actual.startsWith(realRoot+path.sep))throw Error('File escaped repository');const content=await readFile(actual,'utf8');if(secretPattern.test(content))throw Error('Potential secret detected');const expected=graph.files.find(f=>f.path===rel)?.hash;if(expected!==createHash('sha256').update(content).digest('hex'))throw Error('Index stale: refresh required');return content;}
|
|
32
|
+
export async function searchFiles(graph,query,{limit=10}={}){const terms=String(query).toLowerCase().split(/\s+/).filter(Boolean);if(!terms.length)return [];const found=[];for(const f of graph.files){const body=(await readContextFile(graph,f.path)).toLowerCase(),name=f.path.toLowerCase(),symbols=(f.symbols||[]).map(s=>s.name.toLowerCase()),score=terms.reduce((n,t)=>n+(name.includes(t)?5:0)+(symbols.some(s=>s.includes(t))?8:0)+(body.includes(t)?1:0),0);if(score)found.push({path:f.path,score});}return found.sort((a,b)=>b.score-a.score||a.path.localeCompare(b.path)).slice(0,limit);}
|
|
33
|
+
export async function context(graph,query,{limit=5,maxTokens=4000}={}){
|
|
34
|
+
let used=0;const selected=[];
|
|
35
|
+
const baseline=graph.files.reduce((sum,f)=>sum+Math.ceil(f.bytes/4),0);
|
|
36
|
+
const terms=String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
37
|
+
for(const f of await searchFiles(graph,query,{limit:graph.files.length})){
|
|
38
|
+
if(selected.length>=limit||used>=maxTokens)break;
|
|
39
|
+
const lines=(await readContextFile(graph,f.path)).split(/\r?\n/);
|
|
40
|
+
const hits=[];for(let i=0;i<lines.length;i++)if(terms.some(t=>lines[i].toLowerCase().includes(t)))hits.push(i);
|
|
41
|
+
if(!hits.length)hits.push(0);
|
|
42
|
+
const windows=[];
|
|
43
|
+
for(const i of hits.slice(0,12)){
|
|
44
|
+
const a=Math.max(0,i-2),b=Math.min(lines.length,i+3);
|
|
45
|
+
if(windows.length&&a<=windows[windows.length-1][1])windows[windows.length-1][1]=Math.max(b,windows[windows.length-1][1]);
|
|
46
|
+
else windows.push([a,b]);
|
|
47
|
+
}
|
|
48
|
+
const snippets=[];
|
|
49
|
+
for(const [a,b] of windows){
|
|
50
|
+
let content=lines.slice(a,b).map((line,j)=>(a+j+1)+': '+line).join('\n');
|
|
51
|
+
const available=maxTokens-used;
|
|
52
|
+
if(available<=0)break;
|
|
53
|
+
if(estimateTokens(content)>available)content=content.slice(0,available*4);
|
|
54
|
+
if(!content)continue;
|
|
55
|
+
const cost=estimateTokens(content);
|
|
56
|
+
snippets.push({startLine:a+1,endLine:b,content,estimatedTokens:cost});
|
|
57
|
+
used+=cost;
|
|
58
|
+
}
|
|
59
|
+
if(snippets.length)selected.push({path:f.path,snippets,content:snippets.map(s=>s.content).join('\n'),estimatedTokens:snippets.reduce((n,s)=>n+s.estimatedTokens,0)});
|
|
60
|
+
}
|
|
61
|
+
return {query,selected,estimatedContextTokens:used,estimatedBaselineTokens:baseline,estimatedTokensSaved:Math.max(0,baseline-used),estimatedSavingsPercent:baseline?Math.round(100*(baseline-used)/baseline):0,method:'characters/4 estimate; full repository theoretical baseline, not actual LLM savings'};
|
|
62
|
+
}
|
|
33
63
|
export async function save(graph,root=graph.root){const dir=path.join(root,'.repo-graph');await mkdir(dir,{recursive:true});const target=path.join(dir,'graph.json');await writeFile(target,JSON.stringify(graph,null,2));return target;}
|
|
34
64
|
export async function load(root){const g=JSON.parse(await readFile(path.join(path.resolve(root),'.repo-graph','graph.json'),'utf8'));if(g.schemaVersion!==4)throw Error('Re-index required');return g;}
|
|
35
65
|
export async function semanticSearch(graph,query,{embed,limit=5}={}){if(typeof embed!=='function')throw Error('Provide embed(text)');const q=await embed(query),norm=v=>Math.sqrt(v.reduce((n,x)=>n+x*x,0)),qn=norm(q),rows=[];for(const f of graph.files){const v=await embed(await readContextFile(graph,f.path));if(v.length!==q.length)throw Error('Embedding dimension mismatch');const vn=norm(v);rows.push({path:f.path,score:qn&&vn?v.reduce((n,x,i)=>n+x*q[i],0)/(qn*vn):0});}return rows.sort((a,b)=>b.score-a.score).slice(0,limit);}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {stdin,stdout,stderr} from 'node:process';
|
|
2
|
+
import {scan,save,load,searchFiles,context} from './index.js';
|
|
3
|
+
|
|
4
|
+
const definitions=[
|
|
5
|
+
{name:'repo_search',description:'Find relevant repository files',inputSchema:{type:'object',properties:{query:{type:'string'},limit:{type:'integer',minimum:1,maximum:30}},required:['query']}},
|
|
6
|
+
{name:'repo_context',description:'Retrieve focused repository context within a token budget',inputSchema:{type:'object',properties:{query:{type:'string'},limit:{type:'integer',minimum:1,maximum:20},maxTokens:{type:'integer',minimum:1,maximum:16000}},required:['query']}},
|
|
7
|
+
{name:'repo_stats',description:'Show indexed repository graph statistics',inputSchema:{type:'object',properties:{}}},
|
|
8
|
+
{name:'repo_refresh',description:'Refresh the repository knowledge graph',inputSchema:{type:'object',properties:{}}}
|
|
9
|
+
];
|
|
10
|
+
const result=(value)=>({content:[{type:'text',text:JSON.stringify(value)}]});
|
|
11
|
+
export async function callTool(root,name,args={}){
|
|
12
|
+
if(name==='repo_refresh'){let previous;try{previous=await load(root);}catch{}const g=await scan(root,{previous,parser:previous?.parser||'regex'});await save(g);return result({files:g.files.length,nodes:g.nodes.length,edges:g.edges.length,reusedFiles:g.reusedFiles});}
|
|
13
|
+
let previous;try{previous=await load(root);}catch{}
|
|
14
|
+
const g=await scan(root,{previous,parser:previous?.parser||'regex'});
|
|
15
|
+
if(!previous||g.files.length!==previous.files.length||g.files.some((f,i)=>f.path!==previous.files[i]?.path||f.hash!==previous.files[i]?.hash))await save(g);
|
|
16
|
+
if(name==='repo_stats')return result({files:g.files.length,nodes:g.nodes.length,edges:g.edges.length});
|
|
17
|
+
if(name==='repo_search')return result(await searchFiles(g,requireQuery(args),{limit:integer(args.limit,10,30)}));
|
|
18
|
+
if(name==='repo_context')return result(await context(g,requireQuery(args),{limit:integer(args.limit,5,20),maxTokens:integer(args.maxTokens,4000,16000)}));
|
|
19
|
+
throw Error('Unknown tool: '+name);
|
|
20
|
+
}
|
|
21
|
+
function integer(value,fallback,max){if(value===undefined)return fallback;if(!Number.isInteger(value)||value<1||value>max)throw Error('Invalid integer argument');return value;}
|
|
22
|
+
function requireQuery(args){if(typeof args.query!=='string'||!args.query.trim()||args.query.length>1000)throw Error('Nonempty query required (max 1000 characters)');return args.query;}
|
|
23
|
+
export function startMcp(root=process.cwd(),{input=stdin,output=stdout}={}){
|
|
24
|
+
let buffer='',queue=Promise.resolve();
|
|
25
|
+
const send=msg=>output.write(JSON.stringify(msg)+'\n');
|
|
26
|
+
const handle=async msg=>{
|
|
27
|
+
if(!msg||msg.jsonrpc!=='2.0'||typeof msg.method!=='string')return;
|
|
28
|
+
if(msg.id===undefined)return;
|
|
29
|
+
try{
|
|
30
|
+
let value;
|
|
31
|
+
if(msg.method==='initialize')value={protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'repo-knowledge-graph',version:'0.6.0'}};
|
|
32
|
+
else if(msg.method==='ping')value={};
|
|
33
|
+
else if(msg.method==='tools/list')value={tools:definitions};
|
|
34
|
+
else if(msg.method==='tools/call')value=await callTool(root,msg.params?.name,msg.params?.arguments??{});
|
|
35
|
+
else {send({jsonrpc:'2.0',id:msg.id,error:{code:-32601,message:'Method not found'}});return;}
|
|
36
|
+
send({jsonrpc:'2.0',id:msg.id,result:value});
|
|
37
|
+
}catch(e){send({jsonrpc:'2.0',id:msg.id,error:{code:-32603,message:e.message}});}
|
|
38
|
+
};
|
|
39
|
+
input.setEncoding('utf8');
|
|
40
|
+
input.on('data',chunk=>{buffer+=chunk;if(buffer.length>1048576){buffer='';stderr.write('MCP message too large\n');return;}let pos;while((pos=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,pos).trim();buffer=buffer.slice(pos+1);if(!line)continue;queue=queue.then(()=>handle(JSON.parse(line))).catch(e=>stderr.write('MCP invalid message: '+e.message+'\n'));}});
|
|
41
|
+
return {close:()=>input.removeAllListeners('data')};
|
|
42
|
+
}
|