repo-knowledge-graph 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nithin Naubad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ # repo-knowledge-graph
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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)
6
+
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
148
+ ```
149
+
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 ADDED
@@ -0,0 +1,8 @@
1
+ # Security review (alpha)
2
+
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.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
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';
6
+ const [cmd,...args]=process.argv.slice(2),root=process.cwd();
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,43 @@
1
1
  {
2
2
  "name": "repo-knowledge-graph",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.0",
4
+ "type": "module",
5
+ "description": "Repository knowledge graph and CLI",
6
+ "bin": {
7
+ "repo-graph": "bin/repo-graph.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./adapters": "./src/adapters.js",
12
+ "./mcp": "./src/mcp.js",
13
+ "./benchmark": "./src/benchmark.js"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test",
17
+ "check": "node --check src/index.js && node --check src/adapters.js && node --check bin/repo-graph.js && node --check src/mcp.js",
18
+ "prepublishOnly": "npm test && npm run check"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "bin",
26
+ "README.md",
27
+ "LICENSE",
28
+ "SECURITY.md"
29
+ ],
30
+ "license": "MIT",
31
+ "peerDependencies": {
32
+ "typescript": "^5.0.0"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "typescript": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/naubadnithin7/repo-knowledge-graph.git"
42
+ }
43
+ }
@@ -0,0 +1,5 @@
1
+ export async function extractTypeScript(source,filename='source.ts'){let ts;try{ts=await import('typescript');}catch{throw Error('Install typescript to use AST parsing');}const ast=ts.createSourceFile(filename,source,ts.ScriptTarget.Latest,true),symbols=[],imports=[];function visit(n){if(ts.isImportDeclaration(n)&&ts.isStringLiteral(n.moduleSpecifier))imports.push(n.moduleSpecifier.text);if((ts.isFunctionDeclaration(n)||ts.isClassDeclaration(n)||ts.isInterfaceDeclaration(n)||ts.isTypeAliasDeclaration(n))&&n.name)symbols.push({name:n.name.text,line:ast.getLineAndCharacterOfPosition(n.getStart(ast)).line+1});ts.forEachChild(n,visit);}visit(ast);return {symbols,imports};}
2
+ export function createDecisionAdapter({endpoint,model='laya',fetchImpl=fetch,allowedHosts=['localhost','127.0.0.1','::1']}={}){const url=new URL(endpoint);if(!['http:','https:'].includes(url.protocol)||!allowedHosts.includes(url.hostname))throw Error('Endpoint not allowlisted');return async input=>{const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),10000);try{const response=await fetchImpl(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model,messages:[{role:'system',content:'Return JSON {"kind":"model"|"agent","name":"target"}'},{role:'user',content:String(input).slice(0,4000)}],temperature:0}),signal:controller.signal});if(!response.ok)throw Error('Decision HTTP '+response.status);const content=(await response.json())?.choices?.[0]?.message?.content;if(typeof content!=='string'||content.length>2000)throw Error('Invalid decision');const d=JSON.parse(content);if(!['model','agent'].includes(d.kind)||typeof d.name!=='string'||!/^[-\w.]{1,80}$/.test(d.name))throw Error('Invalid route');return d;}finally{clearTimeout(timer);}};}
3
+ export function toGraphify(graph,{createGraph,graphInstance}={}){const target=graphInstance??createGraph?.();if(!target||typeof target.addNode!=='function'||typeof target.addEdge!=='function')throw Error('Graphify adapter requires addNode/addEdge');for(const n of graph.nodes)target.addNode(n.id,{...n});for(const e of graph.edges)target.addEdge(e.from,e.to,{type:e.type});return target;}
4
+ export function toGraphData(graph){return {nodes:graph.nodes.map(({id,...attributes})=>({id,attributes})),edges:graph.edges.map(({from,to,...attributes})=>({source:from,target:to,attributes}))};}
5
+ export async function transformGraph(graph,{transform}={}){if(typeof transform!=='function')throw Error('Provide transform function');return transform(graph);}
@@ -0,0 +1,14 @@
1
+ import {performance} from 'node:perf_hooks';
2
+ /** Retrieval quality benchmark with optional model tokenizer. */
3
+ export async function benchmark(graph,cases,{retrieve,tokenizer,limit=5}={}){
4
+ if(!Array.isArray(cases)||!cases.length||typeof retrieve!=='function')throw Error('Provide cases and retrieve function');
5
+ const count=tokenizer||((s)=>Math.ceil(String(s).length/4)),results=[];
6
+ for(const entry of cases){if(typeof entry.query!=='string'||!Array.isArray(entry.relevantPaths))throw Error('Invalid benchmark case');
7
+ const start=performance.now(),r=await retrieve(graph,entry.query,{limit}),latencyMs=performance.now()-start;
8
+ const selected=Array.isArray(r)?r:r?.selected;if(!Array.isArray(selected))throw Error('Invalid retrieval result');
9
+ const relevant=new Set(entry.relevantPaths),hits=selected.filter(f=>relevant.has(f.path)).length;
10
+ results.push({query:entry.query,precisionAtK:selected.length?hits/selected.length:0,recallAtK:relevant.size?hits/relevant.size:1,selectedTokens:selected.reduce((n,f)=>n+count(f.content||''),0),latencyMs,selectedPaths:selected.map(f=>f.path)});
11
+ }
12
+ const mean=k=>results.reduce((n,r)=>n+r[k],0)/results.length;
13
+ return {cases:results.length,meanPrecisionAtK:mean('precisionAtK'),meanRecallAtK:mean('recallAtK'),meanSelectedTokens:mean('selectedTokens'),meanLatencyMs:mean('latencyMs'),tokenCounting:tokenizer?'supplied tokenizer':'characters/4 estimate',results};
14
+ }
package/src/index.js ADDED
@@ -0,0 +1,66 @@
1
+ import {readdir,readFile,mkdir,writeFile,lstat,realpath} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {extractTypeScript} from './adapters.js';
5
+ const skip=new Set(['.git','node_modules','dist','build','.repo-graph','coverage','.venv','.venv312','.tox','.mypy_cache','.pytest_cache','__pycache__','.next','vendor']);
6
+ const ext=new Set(['.js','.jsx','.ts','.tsx','.mjs','.cjs','.py','.go','.java','.rs','.md','.json']);
7
+ const sensitive=/(^|\/)(\.env(?:\..*)?|id_rsa|id_ed25519|[^/]*\.(?:pem|key|p12)|credentials(?:\..*)?|secrets?(?:\..*)?)$/i;
8
+ const norm=p=>p.split(path.sep).join('/');
9
+ const secretPattern=/(?:-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|(?:ghp_|github_pat_|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}))/;
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/).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;}
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)));}
15
+ export async function scan(root,{maxBytes=256000,previous=null,parser='regex'}={}){
16
+ if(!['regex','typescript'].includes(parser))throw Error('Unknown parser');
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;
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);}}
19
+ await walk(root);files.sort((a,b)=>a.path.localeCompare(b.path));const known=new Set(files.map(f=>f.path));
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);
21
+ if(old?.hash===f.hash&&previous?.parser===parser&&Array.isArray(old.symbols)&&Array.isArray(old.imports)){symbols=old.symbols;imports=old.imports;}
22
+ else if(parser==='typescript'&&/\.[cm]?[jt]sx?$/.test(f.path)){const ast=await extractTypeScript(content,f.path);symbols=ast.symbols;imports=ast.imports;}
23
+ else{symbols=[...content.matchAll(/\b(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|def)\s+([A-Za-z_$][\w$]*)/g)].map(m=>({name:m[1]}));imports=[...content.matchAll(/(?:import\s+(?:[^'"]+?\s+from\s+)?|require\s*\(|from\s+)['"]([^'"]+)['"]/g)].map(m=>m[1]);}
24
+ f.symbols=symbols;f.imports=imports;
25
+ for(const s of symbols){const sid='symbol:'+f.path+':'+s.name;nodes.push({id:sid,type:'symbol',name:s.name,file:f.path,...(s.line?{line:s.line}:{})});edges.push({from:id,to:sid,type:'contains'});}
26
+ for(const spec of imports)edges.push({from:id,to:resolve(spec,f.path,known),type:'imports'});
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)});
29
+ return {schemaVersion:4,root,generatedAt:new Date().toISOString(),nodes,edges,files,reusedFiles,parser};
30
+ }
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
+ }
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;}
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;}
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);}
66
+ export async function routeTask(input,{decide,models={},agents={},fallback='default'}={}){if(typeof decide!=='function')throw Error('Provide decide(input)');const decision=await decide(input),kind=decision?.kind==='agent'?'agent':'model',name=decision?.name||fallback,registry=kind==='agent'?agents:models;if(!Object.hasOwn(registry,name))throw Error('Unregistered '+kind+': '+name);return {kind,name,decision,execute:(...args)=>registry[name](input,...args)};}
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
+ }