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 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
+ }