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 CHANGED
@@ -1,13 +1,173 @@
1
1
  # repo-knowledge-graph
2
2
 
3
- Early alpha CLI and npm library. Node.js 20+. **Not published to npm.**
3
+ [![npm version](https://img.shields.io/npm/v/repo-knowledge-graph?label=npm)](https://www.npmjs.com/package/repo-knowledge-graph)
4
+ [![npm alpha](https://img.shields.io/npm/v/repo-knowledge-graph/alpha?label=alpha)](https://www.npmjs.com/package/repo-knowledge-graph)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
6
 
5
- ```sh
6
- npm install github:naubadnithin7/repo-knowledge-graph
7
- repo-graph index
8
- repo-graph search authentication
9
- repo-graph context authentication
10
- repo-graph savings authentication
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
- Incremental symbol parsing reuses unchanged file metadata; files are still read and hashed. Optional TypeScript AST extraction requires `typescript` and `REPO_GRAPH_PARSER=typescript`. Graphify-compatible `toGraphify(graph,{createGraph})` requires a graph object exposing `addNode`/`addEdge`; integration with a specific Graphify release is not verified. Laya-compatible local decision adapter is available but no model is bundled. Token savings are estimates, not billing measurements. See SECURITY.md.
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.5.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(x=>x&&!x.startsWith('#'));}catch{return [];}}
12
- function ignored(rel,ps){return ps.some(p=>{p=p.replace(/^\//,'');if(p.endsWith('/'))return rel.startsWith(p)||rel.includes('/'+p);return rel===p||rel.endsWith('/'+p)||(p.startsWith('*.')&&rel.endsWith(p.slice(1)));});}
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}={}){let used=0,baseline=0;const selected=[];for(const f of graph.files)baseline+=estimateTokens(await readContextFile(graph,f.path));for(const f of await searchFiles(graph,query,{limit:graph.files.length})){if(selected.length>=limit)break;const content=await readContextFile(graph,f.path),cost=estimateTokens(content);if(used+cost>maxTokens)continue;selected.push({path:f.path,content,estimatedTokens:cost});used+=cost;}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 heuristic'};}
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
+ }