flow-tracer 0.2.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 +131 -0
- package/bin/flow-tracer.js +39 -0
- package/package.json +29 -0
- package/src/indexer.js +208 -0
- package/src/llm.js +297 -0
- package/src/mcp.js +229 -0
- package/src/public/index.html +286 -0
- package/src/server.js +275 -0
- package/src/summarizer.js +302 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# FlowTracer
|
|
2
|
+
|
|
3
|
+
Trace code flows across multiple repos. Ask questions in English, get mermaid diagrams + step-by-step explanations.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
You: "How does order creation work?"
|
|
7
|
+
FlowTracer: [mermaid diagram showing Frontend → API → Backend → DB across all repos]
|
|
8
|
+
+ step-by-step explanation with file paths and function names
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Works with **any language** (TypeScript, Haskell, Python, Go, Java, Rust, etc.) and **any number of repos**.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Option A: MCP Server (recommended for Claude Code users)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Add to Claude Code (one-time)
|
|
19
|
+
claude mcp add flow-tracer -- npx flow-tracer
|
|
20
|
+
|
|
21
|
+
# Then in Claude Code, just say:
|
|
22
|
+
# "Register repos /path/to/frontend and /path/to/backend"
|
|
23
|
+
# "How does the checkout flow work?"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Option B: Web UI
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx flow-tracer serve
|
|
30
|
+
# Open http://localhost:3847
|
|
31
|
+
# Enter repo paths, ask questions, see diagrams in browser
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Option C: Clone and run locally
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
git clone <repo-url>
|
|
38
|
+
cd flow-tracer
|
|
39
|
+
npm install
|
|
40
|
+
node bin/flow-tracer.js serve # web UI
|
|
41
|
+
# or
|
|
42
|
+
node bin/flow-tracer.js mcp # MCP server
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Authentication
|
|
46
|
+
|
|
47
|
+
FlowTracer needs access to a Claude model. Two options:
|
|
48
|
+
|
|
49
|
+
### Option 1: Claude Code Pro (no setup needed)
|
|
50
|
+
|
|
51
|
+
If you have a Claude Code subscription, it works automatically — uses the `claude` CLI under the hood.
|
|
52
|
+
|
|
53
|
+
### Option 2: Anthropic API Key
|
|
54
|
+
|
|
55
|
+
1. Go to [console.anthropic.com](https://console.anthropic.com/)
|
|
56
|
+
2. Sign up / log in
|
|
57
|
+
3. Go to **Settings > API Keys**
|
|
58
|
+
4. Click **Create Key**
|
|
59
|
+
5. Copy the key (starts with `sk-ant-api03-...`)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Set the key
|
|
63
|
+
export ANTHROPIC_API_KEY=sk-ant-api03-...
|
|
64
|
+
|
|
65
|
+
# Then run
|
|
66
|
+
npx flow-tracer serve
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
New accounts get **$5 free credits**. Cost per question: ~3.5 cents.
|
|
70
|
+
|
|
71
|
+
**Optional env vars:**
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
ANTHROPIC_API_KEY=sk-ant-... # Required if no Claude CLI
|
|
75
|
+
FILE_SELECT_MODEL=claude-haiku-4-5-20251001 # Cheap model for file picking (default)
|
|
76
|
+
ANALYSIS_MODEL=claude-sonnet-4-20250514 # Quality model for analysis (default)
|
|
77
|
+
PORT=3847 # Web UI port (default)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## How It Works
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
1. REGISTER: You give it repo paths. It scans all files and builds a
|
|
84
|
+
compact manifest (function signatures + imports for each file).
|
|
85
|
+
|
|
86
|
+
2. ASK: You ask a question. Two LLM calls happen:
|
|
87
|
+
Call #1 (fast): LLM reads the manifest and picks 15-25 relevant files
|
|
88
|
+
Call #2 (deep): LLM reads those files and generates diagrams + explanation
|
|
89
|
+
|
|
90
|
+
3. FOLLOW UP: Ask more questions — the conversation context is preserved.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The key insight: instead of keyword-matching file paths (which misses critical files), we let the LLM read function signatures and imports to understand what each file does, then pick the right ones.
|
|
94
|
+
|
|
95
|
+
## MCP Tools
|
|
96
|
+
|
|
97
|
+
When used as an MCP server, FlowTracer exposes:
|
|
98
|
+
|
|
99
|
+
| Tool | Description |
|
|
100
|
+
|------|-------------|
|
|
101
|
+
| `register_repos` | Register repo paths to index. Call this first. |
|
|
102
|
+
| `trace_flow` | Ask a question about code flows. Returns mermaid + explanation. |
|
|
103
|
+
| `follow_up` | Follow-up question using previous conversation context. |
|
|
104
|
+
| `list_repos` | List all registered repo groups. |
|
|
105
|
+
|
|
106
|
+
## Project Structure
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
flow-tracer/
|
|
110
|
+
├── bin/flow-tracer.js # CLI entry point (mcp or serve)
|
|
111
|
+
├── src/
|
|
112
|
+
│ ├── mcp.js # MCP server (for Claude Code/Desktop)
|
|
113
|
+
│ ├── server.js # Express web server (browser UI)
|
|
114
|
+
│ ├── indexer.js # Repo scanner + LLM-guided file selection
|
|
115
|
+
│ ├── llm.js # Claude integration (CLI + API dual mode)
|
|
116
|
+
│ ├── summarizer.js # Builds file manifest (signatures + imports)
|
|
117
|
+
│ └── public/index.html # Chat UI with mermaid rendering
|
|
118
|
+
├── package.json
|
|
119
|
+
└── README.md
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Examples
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
"How does order creation work?"
|
|
126
|
+
"What happens when a user clicks checkout?"
|
|
127
|
+
"Trace the payment flow from frontend to backend"
|
|
128
|
+
"How does the cart sync between Shopify and our backend?"
|
|
129
|
+
"What services are involved in the refund flow?"
|
|
130
|
+
"Show me how authentication works across repos"
|
|
131
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FlowTracer CLI entry point.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* flow-tracer → starts MCP server (default, for Claude Code/Desktop)
|
|
8
|
+
* flow-tracer mcp → starts MCP server (explicit)
|
|
9
|
+
* flow-tracer serve → starts web UI server on port 3847
|
|
10
|
+
* flow-tracer serve 8080 → starts web UI on custom port
|
|
11
|
+
*
|
|
12
|
+
* Supports .env file in the project root for configuration.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from "fs";
|
|
16
|
+
import { fileURLToPath } from "url";
|
|
17
|
+
import { dirname, join } from "path";
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const envPath = join(__dirname, "..", ".env");
|
|
21
|
+
if (existsSync(envPath)) {
|
|
22
|
+
process.loadEnvFile(envPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const [,, command, ...args] = process.argv;
|
|
26
|
+
|
|
27
|
+
switch (command) {
|
|
28
|
+
case "serve": {
|
|
29
|
+
if (args[0]) process.env.PORT = args[0];
|
|
30
|
+
await import("../src/server.js");
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
case "mcp":
|
|
35
|
+
default: {
|
|
36
|
+
await import("../src/mcp.js");
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flow-tracer",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Trace code flows across repos — get mermaid diagrams. Works as MCP server or web UI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"flow-tracer": "bin/flow-tracer.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/flow-tracer.js",
|
|
11
|
+
"serve": "node bin/flow-tracer.js serve",
|
|
12
|
+
"mcp": "node bin/flow-tracer.js mcp"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"code-flow",
|
|
17
|
+
"mermaid",
|
|
18
|
+
"architecture",
|
|
19
|
+
"cross-repo",
|
|
20
|
+
"claude"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
25
|
+
"express": "^5.1.0",
|
|
26
|
+
"glob": "^11.0.1",
|
|
27
|
+
"zod": "^4.3.6"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/indexer.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-agnostic code indexer.
|
|
3
|
+
*
|
|
4
|
+
* ZERO hardcoded repo names, file patterns, or framework-specific logic.
|
|
5
|
+
*
|
|
6
|
+
* Strategy:
|
|
7
|
+
* 1. Index: scan repos, store file paths (no content loaded)
|
|
8
|
+
* 2. At query time: LLM reads a manifest of all files and picks the right ones
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync, statSync } from "fs";
|
|
12
|
+
import { glob } from "glob";
|
|
13
|
+
import { join, extname, basename, dirname } from "path";
|
|
14
|
+
|
|
15
|
+
const CODE_EXTENSIONS = new Set([
|
|
16
|
+
".ts", ".js", ".tsx", ".jsx", ".svelte", ".vue",
|
|
17
|
+
".py", ".go", ".rs", ".hs", ".java", ".kt", ".rb",
|
|
18
|
+
".php", ".cs", ".swift", ".dart", ".scala",
|
|
19
|
+
".json", ".yaml", ".yml", ".toml",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const IGNORE_DIRS = [
|
|
23
|
+
"**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**",
|
|
24
|
+
"**/.svelte-kit/**", "**/target/**", "**/__pycache__/**",
|
|
25
|
+
"**/vendor/**", "**/.git/**", "**/coverage/**", "**/generated/**",
|
|
26
|
+
"**/test/**", "**/tests/**", "**/__tests__/**", "**/playwright*/**",
|
|
27
|
+
"**/*.min.js", "**/*.bundle.js",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const MAX_FILE_SIZE = 50_000;
|
|
31
|
+
|
|
32
|
+
function detectRepoInfo(repoPath) {
|
|
33
|
+
const info = { name: basename(repoPath), languages: [], frameworks: [], type: "unknown" };
|
|
34
|
+
const checks = [
|
|
35
|
+
{ file: "package.json", detect: (c) => {
|
|
36
|
+
const pkg = JSON.parse(c);
|
|
37
|
+
info.languages.push("typescript/javascript");
|
|
38
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
39
|
+
for (const [k] of Object.entries(deps || {})) {
|
|
40
|
+
if (k === "svelte") info.frameworks.push("svelte");
|
|
41
|
+
if (k === "@sveltejs/kit") info.frameworks.push("sveltekit");
|
|
42
|
+
if (k === "react") info.frameworks.push("react");
|
|
43
|
+
if (k === "next") info.frameworks.push("next.js");
|
|
44
|
+
if (k === "express") info.frameworks.push("express");
|
|
45
|
+
if (k === "vue") info.frameworks.push("vue");
|
|
46
|
+
if (k === "@nestjs/core") info.frameworks.push("nestjs");
|
|
47
|
+
}
|
|
48
|
+
}},
|
|
49
|
+
{ file: "go.mod", detect: () => info.languages.push("go") },
|
|
50
|
+
{ file: "Cargo.toml", detect: () => info.languages.push("rust") },
|
|
51
|
+
{ file: "stack.yaml", detect: () => info.languages.push("haskell") },
|
|
52
|
+
{ file: "package.yaml", detect: () => info.languages.push("haskell") },
|
|
53
|
+
{ file: "requirements.txt", detect: () => info.languages.push("python") },
|
|
54
|
+
{ file: "pyproject.toml", detect: () => info.languages.push("python") },
|
|
55
|
+
{ file: "pom.xml", detect: () => info.languages.push("java") },
|
|
56
|
+
{ file: "build.gradle", detect: () => info.languages.push("java/kotlin") },
|
|
57
|
+
{ file: "Gemfile", detect: () => info.languages.push("ruby") },
|
|
58
|
+
{ file: "composer.json", detect: () => info.languages.push("php") },
|
|
59
|
+
];
|
|
60
|
+
for (const { file, detect } of checks) {
|
|
61
|
+
try { if (existsSync(join(repoPath, file))) detect(readFileSync(join(repoPath, file), "utf-8")); } catch {}
|
|
62
|
+
}
|
|
63
|
+
return info;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function indexRepo(repoPath) {
|
|
67
|
+
if (!existsSync(repoPath)) throw new Error(`Repo not found: ${repoPath}`);
|
|
68
|
+
const repoInfo = detectRepoInfo(repoPath);
|
|
69
|
+
const allFiles = glob.sync("**/*", { cwd: repoPath, nodir: true, ignore: IGNORE_DIRS });
|
|
70
|
+
const codeFiles = [];
|
|
71
|
+
for (const file of allFiles) {
|
|
72
|
+
if (!CODE_EXTENSIONS.has(extname(file))) continue;
|
|
73
|
+
const fullPath = join(repoPath, file);
|
|
74
|
+
try {
|
|
75
|
+
const stat = statSync(fullPath);
|
|
76
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
77
|
+
codeFiles.push({ file, size: stat.size, fullPath });
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
return { repo: repoInfo, repoPath, files: codeFiles, stats: { totalFiles: codeFiles.length } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function indexRepos(repoPaths) {
|
|
84
|
+
const results = [];
|
|
85
|
+
for (const p of repoPaths) {
|
|
86
|
+
console.log(`[index] Scanning ${p}...`);
|
|
87
|
+
const r = indexRepo(p);
|
|
88
|
+
console.log(`[index] ${r.repo.name}: ${r.stats.totalFiles} files`);
|
|
89
|
+
results.push(r);
|
|
90
|
+
}
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── File selection (LLM-guided) ─────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Select relevant code files using LLM-guided selection.
|
|
98
|
+
*
|
|
99
|
+
* Step 1: Ask Claude to read the manifest and pick 15-25 files
|
|
100
|
+
* Step 2: Load those files, respecting 120KB budget
|
|
101
|
+
* Step 3: If LLM fails, fall back to keyword matching
|
|
102
|
+
*/
|
|
103
|
+
export async function selectRelevantCode(indexedRepos, question, manifest) {
|
|
104
|
+
// Build lookup map
|
|
105
|
+
const fileMap = new Map();
|
|
106
|
+
for (const repoIndex of indexedRepos) {
|
|
107
|
+
for (const fileInfo of repoIndex.files) {
|
|
108
|
+
fileMap.set(`${repoIndex.repo.name}::${fileInfo.file}`, {
|
|
109
|
+
...fileInfo,
|
|
110
|
+
repo: repoIndex.repo.name,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let selectedPaths = [];
|
|
116
|
+
|
|
117
|
+
// ── Step 1: LLM pre-pass ──
|
|
118
|
+
if (manifest) {
|
|
119
|
+
try {
|
|
120
|
+
const { selectFilesViaLLM } = await import("./llm.js");
|
|
121
|
+
console.log(`[select] Asking LLM to pick files from manifest (${(manifest.length / 1024).toFixed(0)}KB)...`);
|
|
122
|
+
selectedPaths = await selectFilesViaLLM(manifest, question);
|
|
123
|
+
console.log(`[select] LLM selected ${selectedPaths.length} files`);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`[select] LLM pre-pass failed: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Step 2: Load LLM-selected files ──
|
|
130
|
+
if (selectedPaths.length > 0) {
|
|
131
|
+
const selected = [];
|
|
132
|
+
let totalChars = 0;
|
|
133
|
+
|
|
134
|
+
for (const entry of selectedPaths) {
|
|
135
|
+
const key = `${entry.repo}::${entry.file}`;
|
|
136
|
+
let fileInfo = fileMap.get(key);
|
|
137
|
+
|
|
138
|
+
// Fuzzy match if exact key not found
|
|
139
|
+
if (!fileInfo) {
|
|
140
|
+
const fuzzy = [...fileMap.entries()].find(([k]) =>
|
|
141
|
+
k.endsWith(entry.file) || k.includes(entry.file)
|
|
142
|
+
);
|
|
143
|
+
if (fuzzy) fileInfo = fuzzy[1];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!fileInfo) continue;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const content = readFileSync(fileInfo.fullPath, "utf-8");
|
|
150
|
+
if (content.trim().length === 0) continue;
|
|
151
|
+
if (totalChars + content.length > 120_000) continue;
|
|
152
|
+
totalChars += content.length;
|
|
153
|
+
selected.push({ repo: fileInfo.repo, file: fileInfo.file, content, score: 100 - selected.length });
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (selected.length >= 5) {
|
|
158
|
+
const byRepo = {};
|
|
159
|
+
for (const s of selected) byRepo[s.repo] = (byRepo[s.repo] || 0) + 1;
|
|
160
|
+
console.log(`[select] Loaded ${selected.length} files (${(totalChars / 1024).toFixed(0)}KB): ${Object.entries(byRepo).map(([r, c]) => `${r}(${c})`).join(", ")}`);
|
|
161
|
+
return selected;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log(`[select] LLM returned too few (${selected.length}), falling back`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Step 3: Fallback — keyword matching ──
|
|
168
|
+
console.log("[select] Using keyword fallback");
|
|
169
|
+
const stopWords = new Set([
|
|
170
|
+
"how", "does", "the", "work", "works", "for", "what", "show", "give",
|
|
171
|
+
"can", "you", "tell", "explain", "flow", "complete", "from", "frontend",
|
|
172
|
+
"backend", "with", "mermaid", "diagram", "end", "start", "and", "all",
|
|
173
|
+
"me", "please", "this", "that", "are", "is", "a", "an", "to", "in",
|
|
174
|
+
"of", "do", "get", "make", "create", "each", "different", "between",
|
|
175
|
+
]);
|
|
176
|
+
const keywords = question.toLowerCase().replace(/[^a-z0-9\s_-]/g, " ")
|
|
177
|
+
.split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w));
|
|
178
|
+
|
|
179
|
+
const scored = [];
|
|
180
|
+
for (const repoIndex of indexedRepos) {
|
|
181
|
+
for (const fileInfo of repoIndex.files) {
|
|
182
|
+
const pathLower = fileInfo.file.toLowerCase();
|
|
183
|
+
let score = 0;
|
|
184
|
+
for (const kw of keywords) {
|
|
185
|
+
if (pathLower.includes(kw)) score += 10;
|
|
186
|
+
}
|
|
187
|
+
if (score > 0) scored.push({ ...fileInfo, repo: repoIndex.repo.name, score });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
scored.sort((a, b) => b.score - a.score);
|
|
192
|
+
const selected = [];
|
|
193
|
+
let totalChars = 0;
|
|
194
|
+
for (const fileInfo of scored.slice(0, 80)) {
|
|
195
|
+
try {
|
|
196
|
+
const content = readFileSync(fileInfo.fullPath, "utf-8");
|
|
197
|
+
if (content.trim().length === 0) continue;
|
|
198
|
+
if (totalChars + content.length > 120_000) continue;
|
|
199
|
+
totalChars += content.length;
|
|
200
|
+
selected.push({ repo: fileInfo.repo, file: fileInfo.file, content, score: fileInfo.score });
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const byRepo = {};
|
|
205
|
+
for (const s of selected) byRepo[s.repo] = (byRepo[s.repo] || 0) + 1;
|
|
206
|
+
console.log(`[select] Fallback: ${selected.length} files (${(totalChars / 1024).toFixed(0)}KB): ${Object.entries(byRepo).map(([r, c]) => `${r}(${c})`).join(", ")}`);
|
|
207
|
+
return selected;
|
|
208
|
+
}
|