codecompass-mcp 5.0.0__tar.gz → 5.2.0__tar.gz
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.
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/PKG-INFO +35 -9
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/README.md +31 -8
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/PKG-INFO +35 -9
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/SOURCES.txt +4 -1
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/requires.txt +4 -0
- codecompass_mcp-5.2.0/graph/vector_store.py +110 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/code_parser.py +6 -1
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/enricher.py +46 -12
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/main.py +72 -16
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/mcp_server.py +62 -9
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/pyproject.toml +5 -1
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_block_hook.py +14 -5
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_enricher.py +32 -0
- codecompass_mcp-5.2.0/tests/test_ingest_progress.py +43 -0
- codecompass_mcp-5.2.0/tests/test_vector_store.py +65 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/LICENSE +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/config.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/graph/__init__.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/graph/code_graph_client.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/graph/code_queries.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/__init__.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/code_normalizer.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/file_watcher.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/hierarchy_builder.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/source_context.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/models/__init__.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/models/code_types.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/pi_setup.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/setup.cfg +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_accuracy_fixes.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_code_parser.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_enrich_preserve.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_init.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_lit_css_parser.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_mcp_server.py +0 -0
- {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_php_parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecompass-mcp
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
|
|
5
5
|
Author: CodeCompass contributors
|
|
6
6
|
License: MIT
|
|
@@ -32,13 +32,16 @@ Requires-Dist: watchdog>=4.0.0
|
|
|
32
32
|
Provides-Extra: dev
|
|
33
33
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
34
34
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
35
|
+
Provides-Extra: search
|
|
36
|
+
Requires-Dist: lancedb>=0.13.0; extra == "search"
|
|
37
|
+
Requires-Dist: fastembed>=0.3.0; extra == "search"
|
|
35
38
|
Dynamic: license-file
|
|
36
39
|
|
|
37
40
|
# CodeCompass
|
|
38
41
|
|
|
39
|
-
A local code knowledge graph that gives AI agents a map of your codebase — so they navigate by structure instead of grepping blind, and know what's connected before they edit.
|
|
42
|
+
A local code knowledge graph that gives AI agents a map of your codebase — so they navigate by structure instead of grepping blind, and know what's connected before they edit. It learns as agents use it: parser misses they record are preserved across re-indexes, and an optional local vector index adds semantic search over everything in the graph.
|
|
40
43
|
|
|
41
|
-
No
|
|
44
|
+
No cloud. No API keys for core queries. One JSON graph per repo, plus an optional LanceDB vector index. Python, JavaScript/TypeScript, PHP, HTML/CSS.
|
|
42
45
|
|
|
43
46
|
---
|
|
44
47
|
|
|
@@ -68,6 +71,7 @@ The graph turns navigation into a cheap, deterministic loop:
|
|
|
68
71
|
| You have… | Use |
|
|
69
72
|
|---|---|
|
|
70
73
|
| a concept, name, or pattern | `grep` (regex over graph entities) |
|
|
74
|
+
| an idea, not a name ("where does caching go?") | `search` (semantic vector search) |
|
|
71
75
|
| the full layout | `tree` |
|
|
72
76
|
|
|
73
77
|
2. **Trace** — a relationship around a known symbol/file:
|
|
@@ -137,6 +141,21 @@ no agent-facing query CLI. `grep`, `impact`, `blast_radius`, `deps`, `flow`,
|
|
|
137
141
|
`flow_summary`, `dead_code`, `tree` and friends are all MCP tools; pass
|
|
138
142
|
`hops` for traversal depth (start at 1 and follow the one path you need).
|
|
139
143
|
|
|
144
|
+
### Semantic search
|
|
145
|
+
|
|
146
|
+
`grep` finds symbols when you know the name; `search` finds them when you only
|
|
147
|
+
have the idea. It embeds every entity's name/kind/file/description into a local
|
|
148
|
+
LanceDB index (`.codecompass/vectors.lance`) using fastembed's BGE-small model
|
|
149
|
+
— ONNX, CPU-only, no API keys. Opt in with:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install 'codecompass-mcp[search]'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The index follows the graph's lifecycle: wiped and rebuilt at the end of every
|
|
156
|
+
`ingest`, so parser nodes *and* agent-recorded ones are searchable. Without the
|
|
157
|
+
extra, ingest simply skips the vector step.
|
|
158
|
+
|
|
140
159
|
### Enrichment (agent-in-the-loop)
|
|
141
160
|
|
|
142
161
|
Via the MCP tools: `enrich` stages entities for an agent swarm (one-line
|
|
@@ -161,6 +180,7 @@ with use. Ambiguous call targets are skipped, never guessed.
|
|
|
161
180
|
| Tool | Returns |
|
|
162
181
|
|---|---|
|
|
163
182
|
| `grep(pattern, field, ignore_case)` | Regex search over graph entities |
|
|
183
|
+
| `search(query, limit)` | Semantic vector search over entity names/kinds/files/descriptions |
|
|
164
184
|
| `impact(symbol, hops)` | Callers/importers, disambiguated, with `resolved` + `line` |
|
|
165
185
|
| `blast_radius(target, hops)` | Files reachable from a file or symbol |
|
|
166
186
|
| `batch_impact(targets, hops)` | Union of blast radii for a multi-file change |
|
|
@@ -226,25 +246,31 @@ Source files
|
|
|
226
246
|
▼ enricher agent-in-the-loop: enrich batches (descriptions +
|
|
227
247
|
missing calls) and opportunistic add_entity/add_call
|
|
228
248
|
writes — agent_inferred, preserved across re-ingest
|
|
249
|
+
▼ vector_store optional: entity embeddings in vectors.lance (LanceDB
|
|
250
|
+
+ fastembed), wiped & rebuilt on every ingest
|
|
229
251
|
```
|
|
230
252
|
|
|
231
|
-
Everything runs locally, in-process.
|
|
253
|
+
Everything runs locally, in-process. Core queries need no network, no database,
|
|
254
|
+
no API keys; semantic search adds a local vector DB and a one-time model
|
|
255
|
+
download.
|
|
232
256
|
|
|
233
257
|
Inside each indexed project:
|
|
234
258
|
|
|
235
259
|
```
|
|
236
260
|
your-project/
|
|
237
|
-
├── .codecompass/graph.json
|
|
238
|
-
|
|
261
|
+
├── .codecompass/graph.json the code knowledge graph (auto-generated)
|
|
262
|
+
├── .codecompass/vectors.lance/ semantic search index (optional, rebuilt on ingest)
|
|
263
|
+
└── AGENTS.md discovery guide for agents (auto-updated)
|
|
239
264
|
```
|
|
240
265
|
|
|
241
266
|
---
|
|
242
267
|
|
|
243
268
|
## Limitations
|
|
244
269
|
|
|
245
|
-
- **Structure first, semantics
|
|
246
|
-
not what it means.
|
|
247
|
-
and missed edges, marked `agent_inferred
|
|
270
|
+
- **Structure first, semantics layered on** — the parser knows what calls what,
|
|
271
|
+
not what it means. Two optional layers close that gap: `enrich` (agent-written
|
|
272
|
+
descriptions and missed edges, marked `agent_inferred`) and `search`
|
|
273
|
+
(semantic vector search over those descriptions and names).
|
|
248
274
|
- **Static analysis** — dynamic dispatch, reflection, and string-based invocation
|
|
249
275
|
can't be fully resolved. `impact` surfaces those flagged `resolved: false`, and
|
|
250
276
|
`dead_code` results are always candidates to verify.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# CodeCompass
|
|
2
2
|
|
|
3
|
-
A local code knowledge graph that gives AI agents a map of your codebase — so they navigate by structure instead of grepping blind, and know what's connected before they edit.
|
|
3
|
+
A local code knowledge graph that gives AI agents a map of your codebase — so they navigate by structure instead of grepping blind, and know what's connected before they edit. It learns as agents use it: parser misses they record are preserved across re-indexes, and an optional local vector index adds semantic search over everything in the graph.
|
|
4
4
|
|
|
5
|
-
No
|
|
5
|
+
No cloud. No API keys for core queries. One JSON graph per repo, plus an optional LanceDB vector index. Python, JavaScript/TypeScript, PHP, HTML/CSS.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -32,6 +32,7 @@ The graph turns navigation into a cheap, deterministic loop:
|
|
|
32
32
|
| You have… | Use |
|
|
33
33
|
|---|---|
|
|
34
34
|
| a concept, name, or pattern | `grep` (regex over graph entities) |
|
|
35
|
+
| an idea, not a name ("where does caching go?") | `search` (semantic vector search) |
|
|
35
36
|
| the full layout | `tree` |
|
|
36
37
|
|
|
37
38
|
2. **Trace** — a relationship around a known symbol/file:
|
|
@@ -101,6 +102,21 @@ no agent-facing query CLI. `grep`, `impact`, `blast_radius`, `deps`, `flow`,
|
|
|
101
102
|
`flow_summary`, `dead_code`, `tree` and friends are all MCP tools; pass
|
|
102
103
|
`hops` for traversal depth (start at 1 and follow the one path you need).
|
|
103
104
|
|
|
105
|
+
### Semantic search
|
|
106
|
+
|
|
107
|
+
`grep` finds symbols when you know the name; `search` finds them when you only
|
|
108
|
+
have the idea. It embeds every entity's name/kind/file/description into a local
|
|
109
|
+
LanceDB index (`.codecompass/vectors.lance`) using fastembed's BGE-small model
|
|
110
|
+
— ONNX, CPU-only, no API keys. Opt in with:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install 'codecompass-mcp[search]'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The index follows the graph's lifecycle: wiped and rebuilt at the end of every
|
|
117
|
+
`ingest`, so parser nodes *and* agent-recorded ones are searchable. Without the
|
|
118
|
+
extra, ingest simply skips the vector step.
|
|
119
|
+
|
|
104
120
|
### Enrichment (agent-in-the-loop)
|
|
105
121
|
|
|
106
122
|
Via the MCP tools: `enrich` stages entities for an agent swarm (one-line
|
|
@@ -125,6 +141,7 @@ with use. Ambiguous call targets are skipped, never guessed.
|
|
|
125
141
|
| Tool | Returns |
|
|
126
142
|
|---|---|
|
|
127
143
|
| `grep(pattern, field, ignore_case)` | Regex search over graph entities |
|
|
144
|
+
| `search(query, limit)` | Semantic vector search over entity names/kinds/files/descriptions |
|
|
128
145
|
| `impact(symbol, hops)` | Callers/importers, disambiguated, with `resolved` + `line` |
|
|
129
146
|
| `blast_radius(target, hops)` | Files reachable from a file or symbol |
|
|
130
147
|
| `batch_impact(targets, hops)` | Union of blast radii for a multi-file change |
|
|
@@ -190,25 +207,31 @@ Source files
|
|
|
190
207
|
▼ enricher agent-in-the-loop: enrich batches (descriptions +
|
|
191
208
|
missing calls) and opportunistic add_entity/add_call
|
|
192
209
|
writes — agent_inferred, preserved across re-ingest
|
|
210
|
+
▼ vector_store optional: entity embeddings in vectors.lance (LanceDB
|
|
211
|
+
+ fastembed), wiped & rebuilt on every ingest
|
|
193
212
|
```
|
|
194
213
|
|
|
195
|
-
Everything runs locally, in-process.
|
|
214
|
+
Everything runs locally, in-process. Core queries need no network, no database,
|
|
215
|
+
no API keys; semantic search adds a local vector DB and a one-time model
|
|
216
|
+
download.
|
|
196
217
|
|
|
197
218
|
Inside each indexed project:
|
|
198
219
|
|
|
199
220
|
```
|
|
200
221
|
your-project/
|
|
201
|
-
├── .codecompass/graph.json
|
|
202
|
-
|
|
222
|
+
├── .codecompass/graph.json the code knowledge graph (auto-generated)
|
|
223
|
+
├── .codecompass/vectors.lance/ semantic search index (optional, rebuilt on ingest)
|
|
224
|
+
└── AGENTS.md discovery guide for agents (auto-updated)
|
|
203
225
|
```
|
|
204
226
|
|
|
205
227
|
---
|
|
206
228
|
|
|
207
229
|
## Limitations
|
|
208
230
|
|
|
209
|
-
- **Structure first, semantics
|
|
210
|
-
not what it means.
|
|
211
|
-
and missed edges, marked `agent_inferred
|
|
231
|
+
- **Structure first, semantics layered on** — the parser knows what calls what,
|
|
232
|
+
not what it means. Two optional layers close that gap: `enrich` (agent-written
|
|
233
|
+
descriptions and missed edges, marked `agent_inferred`) and `search`
|
|
234
|
+
(semantic vector search over those descriptions and names).
|
|
212
235
|
- **Static analysis** — dynamic dispatch, reflection, and string-based invocation
|
|
213
236
|
can't be fully resolved. `impact` surfaces those flagged `resolved: false`, and
|
|
214
237
|
`dead_code` results are always candidates to verify.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecompass-mcp
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
|
|
5
5
|
Author: CodeCompass contributors
|
|
6
6
|
License: MIT
|
|
@@ -32,13 +32,16 @@ Requires-Dist: watchdog>=4.0.0
|
|
|
32
32
|
Provides-Extra: dev
|
|
33
33
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
34
34
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
35
|
+
Provides-Extra: search
|
|
36
|
+
Requires-Dist: lancedb>=0.13.0; extra == "search"
|
|
37
|
+
Requires-Dist: fastembed>=0.3.0; extra == "search"
|
|
35
38
|
Dynamic: license-file
|
|
36
39
|
|
|
37
40
|
# CodeCompass
|
|
38
41
|
|
|
39
|
-
A local code knowledge graph that gives AI agents a map of your codebase — so they navigate by structure instead of grepping blind, and know what's connected before they edit.
|
|
42
|
+
A local code knowledge graph that gives AI agents a map of your codebase — so they navigate by structure instead of grepping blind, and know what's connected before they edit. It learns as agents use it: parser misses they record are preserved across re-indexes, and an optional local vector index adds semantic search over everything in the graph.
|
|
40
43
|
|
|
41
|
-
No
|
|
44
|
+
No cloud. No API keys for core queries. One JSON graph per repo, plus an optional LanceDB vector index. Python, JavaScript/TypeScript, PHP, HTML/CSS.
|
|
42
45
|
|
|
43
46
|
---
|
|
44
47
|
|
|
@@ -68,6 +71,7 @@ The graph turns navigation into a cheap, deterministic loop:
|
|
|
68
71
|
| You have… | Use |
|
|
69
72
|
|---|---|
|
|
70
73
|
| a concept, name, or pattern | `grep` (regex over graph entities) |
|
|
74
|
+
| an idea, not a name ("where does caching go?") | `search` (semantic vector search) |
|
|
71
75
|
| the full layout | `tree` |
|
|
72
76
|
|
|
73
77
|
2. **Trace** — a relationship around a known symbol/file:
|
|
@@ -137,6 +141,21 @@ no agent-facing query CLI. `grep`, `impact`, `blast_radius`, `deps`, `flow`,
|
|
|
137
141
|
`flow_summary`, `dead_code`, `tree` and friends are all MCP tools; pass
|
|
138
142
|
`hops` for traversal depth (start at 1 and follow the one path you need).
|
|
139
143
|
|
|
144
|
+
### Semantic search
|
|
145
|
+
|
|
146
|
+
`grep` finds symbols when you know the name; `search` finds them when you only
|
|
147
|
+
have the idea. It embeds every entity's name/kind/file/description into a local
|
|
148
|
+
LanceDB index (`.codecompass/vectors.lance`) using fastembed's BGE-small model
|
|
149
|
+
— ONNX, CPU-only, no API keys. Opt in with:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install 'codecompass-mcp[search]'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The index follows the graph's lifecycle: wiped and rebuilt at the end of every
|
|
156
|
+
`ingest`, so parser nodes *and* agent-recorded ones are searchable. Without the
|
|
157
|
+
extra, ingest simply skips the vector step.
|
|
158
|
+
|
|
140
159
|
### Enrichment (agent-in-the-loop)
|
|
141
160
|
|
|
142
161
|
Via the MCP tools: `enrich` stages entities for an agent swarm (one-line
|
|
@@ -161,6 +180,7 @@ with use. Ambiguous call targets are skipped, never guessed.
|
|
|
161
180
|
| Tool | Returns |
|
|
162
181
|
|---|---|
|
|
163
182
|
| `grep(pattern, field, ignore_case)` | Regex search over graph entities |
|
|
183
|
+
| `search(query, limit)` | Semantic vector search over entity names/kinds/files/descriptions |
|
|
164
184
|
| `impact(symbol, hops)` | Callers/importers, disambiguated, with `resolved` + `line` |
|
|
165
185
|
| `blast_radius(target, hops)` | Files reachable from a file or symbol |
|
|
166
186
|
| `batch_impact(targets, hops)` | Union of blast radii for a multi-file change |
|
|
@@ -226,25 +246,31 @@ Source files
|
|
|
226
246
|
▼ enricher agent-in-the-loop: enrich batches (descriptions +
|
|
227
247
|
missing calls) and opportunistic add_entity/add_call
|
|
228
248
|
writes — agent_inferred, preserved across re-ingest
|
|
249
|
+
▼ vector_store optional: entity embeddings in vectors.lance (LanceDB
|
|
250
|
+
+ fastembed), wiped & rebuilt on every ingest
|
|
229
251
|
```
|
|
230
252
|
|
|
231
|
-
Everything runs locally, in-process.
|
|
253
|
+
Everything runs locally, in-process. Core queries need no network, no database,
|
|
254
|
+
no API keys; semantic search adds a local vector DB and a one-time model
|
|
255
|
+
download.
|
|
232
256
|
|
|
233
257
|
Inside each indexed project:
|
|
234
258
|
|
|
235
259
|
```
|
|
236
260
|
your-project/
|
|
237
|
-
├── .codecompass/graph.json
|
|
238
|
-
|
|
261
|
+
├── .codecompass/graph.json the code knowledge graph (auto-generated)
|
|
262
|
+
├── .codecompass/vectors.lance/ semantic search index (optional, rebuilt on ingest)
|
|
263
|
+
└── AGENTS.md discovery guide for agents (auto-updated)
|
|
239
264
|
```
|
|
240
265
|
|
|
241
266
|
---
|
|
242
267
|
|
|
243
268
|
## Limitations
|
|
244
269
|
|
|
245
|
-
- **Structure first, semantics
|
|
246
|
-
not what it means.
|
|
247
|
-
and missed edges, marked `agent_inferred
|
|
270
|
+
- **Structure first, semantics layered on** — the parser knows what calls what,
|
|
271
|
+
not what it means. Two optional layers close that gap: `enrich` (agent-written
|
|
272
|
+
descriptions and missed edges, marked `agent_inferred`) and `search`
|
|
273
|
+
(semantic vector search over those descriptions and names).
|
|
248
274
|
- **Static analysis** — dynamic dispatch, reflection, and string-based invocation
|
|
249
275
|
can't be fully resolved. `impact` surfaces those flagged `resolved: false`, and
|
|
250
276
|
`dead_code` results are always candidates to verify.
|
|
@@ -14,6 +14,7 @@ codecompass_mcp.egg-info/top_level.txt
|
|
|
14
14
|
graph/__init__.py
|
|
15
15
|
graph/code_graph_client.py
|
|
16
16
|
graph/code_queries.py
|
|
17
|
+
graph/vector_store.py
|
|
17
18
|
ingestion/__init__.py
|
|
18
19
|
ingestion/code_normalizer.py
|
|
19
20
|
ingestion/code_parser.py
|
|
@@ -28,7 +29,9 @@ tests/test_block_hook.py
|
|
|
28
29
|
tests/test_code_parser.py
|
|
29
30
|
tests/test_enrich_preserve.py
|
|
30
31
|
tests/test_enricher.py
|
|
32
|
+
tests/test_ingest_progress.py
|
|
31
33
|
tests/test_init.py
|
|
32
34
|
tests/test_lit_css_parser.py
|
|
33
35
|
tests/test_mcp_server.py
|
|
34
|
-
tests/test_php_parser.py
|
|
36
|
+
tests/test_php_parser.py
|
|
37
|
+
tests/test_vector_store.py
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Local vector search over graph entities — LanceDB + fastembed.
|
|
2
|
+
|
|
3
|
+
Optional feature: needs `pip install codecompass-mcp[search]`.
|
|
4
|
+
The index lives at `.codecompass/vectors.lance` and follows the graph's
|
|
5
|
+
lifecycle: wiped and rebuilt wholesale at the end of every ingest, from
|
|
6
|
+
whatever the graph contains at that point (parser + agent-inferred nodes).
|
|
7
|
+
|
|
8
|
+
ponytail: full rebuild each ingest (no per-file incremental updates) — a few
|
|
9
|
+
thousand short embeddings take seconds; add incremental indexing only if
|
|
10
|
+
ingest time actually hurts.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
|
|
19
|
+
DB_DIRNAME = "vectors.lance"
|
|
20
|
+
TABLE = "entities"
|
|
21
|
+
MODEL = "BAAI/bge-small-en-v1.5" # 384-dim, ONNX, CPU — no torch, no network calls
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VectorDepsMissing(RuntimeError):
|
|
25
|
+
"""Raised when lancedb/fastembed aren't installed."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _deps():
|
|
29
|
+
try:
|
|
30
|
+
import lancedb
|
|
31
|
+
from fastembed import TextEmbedding
|
|
32
|
+
return lancedb, TextEmbedding
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise VectorDepsMissing(
|
|
35
|
+
"Vector search needs the optional deps: "
|
|
36
|
+
"pip install 'codecompass-mcp[search]'"
|
|
37
|
+
) from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@lru_cache(maxsize=1)
|
|
41
|
+
def _embedder():
|
|
42
|
+
_, TextEmbedding = _deps()
|
|
43
|
+
return TextEmbedding(model_name=MODEL)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _db_path(repo_path: str) -> str:
|
|
47
|
+
return os.path.join(repo_path, ".codecompass", DB_DIRNAME)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _entity_text(a: dict) -> str:
|
|
51
|
+
"""The string that gets embedded for one entity node."""
|
|
52
|
+
parts = [a.get("kind") or "", a.get("name") or "", a.get("file") or "",
|
|
53
|
+
a.get("description") or ""]
|
|
54
|
+
return " ".join(p for p in parts if p)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def index_entities(repo_path: str) -> int:
|
|
58
|
+
"""Wipe and rebuild the vector index from graph.json. Returns rows indexed."""
|
|
59
|
+
lancedb, _ = _deps()
|
|
60
|
+
graph_path = os.path.join(repo_path, ".codecompass", "graph.json")
|
|
61
|
+
with open(graph_path) as f:
|
|
62
|
+
nodes = json.load(f).get("nodes", [])
|
|
63
|
+
|
|
64
|
+
rows = []
|
|
65
|
+
for a in nodes:
|
|
66
|
+
if a.get("type") != "Entity":
|
|
67
|
+
continue
|
|
68
|
+
rows.append({
|
|
69
|
+
"id": a.get("id") or "",
|
|
70
|
+
"name": a.get("name") or "",
|
|
71
|
+
"kind": a.get("kind") or "",
|
|
72
|
+
"file": a.get("file") or "",
|
|
73
|
+
"line": a.get("line") or 0,
|
|
74
|
+
"description": a.get("description") or "",
|
|
75
|
+
"text": _entity_text(a),
|
|
76
|
+
})
|
|
77
|
+
if not rows:
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
vecs = list(_embedder().embed([r["text"] for r in rows]))
|
|
81
|
+
for r, v in zip(rows, vecs):
|
|
82
|
+
r["vector"] = v
|
|
83
|
+
|
|
84
|
+
db = lancedb.connect(_db_path(repo_path))
|
|
85
|
+
db.create_table(TABLE, rows, mode="overwrite")
|
|
86
|
+
return len(rows)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def search_entities(repo_path: str, query: str, limit: int = 10) -> dict:
|
|
90
|
+
"""Semantic search over entity names/kinds/files/descriptions."""
|
|
91
|
+
lancedb, _ = _deps()
|
|
92
|
+
db_path = _db_path(repo_path)
|
|
93
|
+
if not os.path.exists(db_path):
|
|
94
|
+
return {"query": query, "matches": [], "count": 0,
|
|
95
|
+
"hint": "No vector index yet — run ingest to build it."}
|
|
96
|
+
|
|
97
|
+
vec = list(_embedder().embed([query]))[0]
|
|
98
|
+
db = lancedb.connect(db_path)
|
|
99
|
+
tbl = db.open_table(TABLE)
|
|
100
|
+
hits = tbl.search(vec).limit(limit).to_list()
|
|
101
|
+
|
|
102
|
+
matches = [{
|
|
103
|
+
"name": h["name"],
|
|
104
|
+
"kind": h["kind"],
|
|
105
|
+
"file": h["file"],
|
|
106
|
+
"line": h["line"],
|
|
107
|
+
"description": h["description"],
|
|
108
|
+
"distance": round(h["_distance"], 4),
|
|
109
|
+
} for h in hits]
|
|
110
|
+
return {"query": query, "matches": matches, "count": len(matches)}
|
|
@@ -1753,6 +1753,7 @@ def parse_directory(
|
|
|
1753
1753
|
project_root: str,
|
|
1754
1754
|
skip_dirs: set[str] | None = None,
|
|
1755
1755
|
progress: bool = False,
|
|
1756
|
+
on_progress=None,
|
|
1756
1757
|
) -> list[CodeTriple]:
|
|
1757
1758
|
"""Recursively parse all supported files under project_root.
|
|
1758
1759
|
|
|
@@ -1760,6 +1761,8 @@ def parse_directory(
|
|
|
1760
1761
|
project_root: Absolute path to the repo root.
|
|
1761
1762
|
skip_dirs: Directory names to skip (merged with defaults).
|
|
1762
1763
|
progress: Show a tqdm progress bar while parsing.
|
|
1764
|
+
on_progress: Called as on_progress(files_done, files_total) after each
|
|
1765
|
+
file — for callers with no console (the MCP server).
|
|
1763
1766
|
"""
|
|
1764
1767
|
default_skip = {
|
|
1765
1768
|
".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
@@ -1779,8 +1782,10 @@ def parse_directory(
|
|
|
1779
1782
|
source_files_iter = track(source_files, description="Parsing files") if progress else source_files
|
|
1780
1783
|
|
|
1781
1784
|
triples: list[CodeTriple] = []
|
|
1782
|
-
for full_path in source_files_iter:
|
|
1785
|
+
for done, full_path in enumerate(source_files_iter, 1):
|
|
1783
1786
|
triples.extend(parse_file(full_path, project_root))
|
|
1787
|
+
if on_progress:
|
|
1788
|
+
on_progress(done, len(source_files))
|
|
1784
1789
|
|
|
1785
1790
|
return triples
|
|
1786
1791
|
|
|
@@ -180,19 +180,28 @@ def prepare_enrich_batches(
|
|
|
180
180
|
return result
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def _resolve_callee(client, project: str, name: str
|
|
184
|
-
|
|
183
|
+
def _resolve_callee(client, project: str, name: str,
|
|
184
|
+
allow_external: bool = False) -> str | None:
|
|
185
|
+
"""Node id of the single entity called `name`, else None.
|
|
186
|
+
|
|
187
|
+
In-project entities (those with a file) only, unless allow_external — the
|
|
188
|
+
parser also emits file-less nodes for imported stdlib/third-party modules,
|
|
189
|
+
and an IMPORTS target may legitimately be one of those.
|
|
185
190
|
|
|
186
191
|
Ambiguous names (multiple definitions) are never guessed — the parser's
|
|
187
192
|
whole unresolved-receiver problem lives here, and a wrong edge is worse
|
|
188
193
|
than a missing one.
|
|
189
194
|
"""
|
|
190
|
-
matches =
|
|
195
|
+
matches = _match_ids(client, project, name, allow_external)
|
|
196
|
+
return matches[0] if len(matches) == 1 else None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _match_ids(client, project: str, name: str, allow_external: bool = False) -> list:
|
|
200
|
+
return [
|
|
191
201
|
nid for nid, a in client.graph.nodes(data=True)
|
|
192
202
|
if a.get("type") == "Entity" and a.get("project") == project
|
|
193
|
-
and a.get("file") and a.get("name") == name
|
|
203
|
+
and (a.get("file") or allow_external) and a.get("name") == name
|
|
194
204
|
]
|
|
195
|
-
return matches[0] if len(matches) == 1 else None
|
|
196
205
|
|
|
197
206
|
|
|
198
207
|
def add_entity(repo_path: str, name: str, kind: str = "function",
|
|
@@ -247,30 +256,55 @@ def add_entity(repo_path: str, name: str, kind: str = "function",
|
|
|
247
256
|
client.close()
|
|
248
257
|
|
|
249
258
|
|
|
259
|
+
# Edge types an agent may record. CONTAINS/DEFINED_IN are structural — the
|
|
260
|
+
# parser owns the hierarchy and a guessed one corrupts it.
|
|
261
|
+
AGENT_RELATIONS = ("CALLS", "IMPORTS", "INHERITS")
|
|
262
|
+
|
|
263
|
+
|
|
250
264
|
def add_call(repo_path: str, caller: str, callee: str,
|
|
251
|
-
line: int | None = None) -> dict[str, str]:
|
|
252
|
-
"""Record
|
|
253
|
-
|
|
254
|
-
|
|
265
|
+
line: int | None = None, relation: str = "CALLS") -> dict[str, str]:
|
|
266
|
+
"""Record an edge the agent spotted in source but the parser missed.
|
|
267
|
+
relation is one of AGENT_RELATIONS (CALLS by default). Both ends must
|
|
268
|
+
resolve unambiguously — ambiguous names are skipped, never guessed.
|
|
269
|
+
Idempotent: an existing edge of the same type is left alone."""
|
|
270
|
+
relation = relation.upper()
|
|
271
|
+
if relation not in AGENT_RELATIONS:
|
|
272
|
+
return {"status": "skipped",
|
|
273
|
+
"reason": f"relation {relation!r} not one of {', '.join(AGENT_RELATIONS)}"}
|
|
255
274
|
repo_path = os.path.abspath(repo_path)
|
|
256
275
|
project = os.path.basename(repo_path)
|
|
257
276
|
client = get_client(repo_path)
|
|
258
277
|
try:
|
|
278
|
+
imports = relation == "IMPORTS"
|
|
259
279
|
from_id = _resolve_callee(client, project, caller)
|
|
260
|
-
|
|
280
|
+
# An import target may be a stdlib/third-party module, which lives in
|
|
281
|
+
# the graph as a file-less node — or not at all, the first time anyone
|
|
282
|
+
# records it.
|
|
283
|
+
to_id = _resolve_callee(client, project, callee, allow_external=imports)
|
|
261
284
|
if from_id is None:
|
|
262
285
|
return {"status": "skipped", "reason": f"caller {caller!r} not found or ambiguous"}
|
|
286
|
+
# Only when nothing of that name exists — an ambiguous name still skips.
|
|
287
|
+
if (to_id is None and imports
|
|
288
|
+
and not _match_ids(client, project, callee, allow_external=True)):
|
|
289
|
+
to_id = f"{project}:{callee}"
|
|
290
|
+
language = client.graph.nodes[from_id].get("language", "")
|
|
291
|
+
client.graph.add_node(
|
|
292
|
+
to_id, type="Entity", name=callee, project=project,
|
|
293
|
+
entity_type="module", kind=f"module:{language}" if language else "module",
|
|
294
|
+
language=language, description=f"{language} module".strip(),
|
|
295
|
+
agent_inferred=True, agent_created=True,
|
|
296
|
+
)
|
|
263
297
|
if to_id is None:
|
|
264
298
|
return {"status": "skipped", "reason": f"callee {callee!r} not found or ambiguous"}
|
|
265
299
|
already = any(
|
|
266
|
-
e.get("type") ==
|
|
300
|
+
e.get("type") == relation
|
|
267
301
|
for e in client.graph.get_edge_data(from_id, to_id, default={}).values()
|
|
268
302
|
)
|
|
269
303
|
if already:
|
|
270
304
|
return {"status": "exists", "from": from_id, "to": to_id}
|
|
271
305
|
client.graph.add_edge(
|
|
272
306
|
from_id, to_id,
|
|
273
|
-
type=
|
|
307
|
+
type=relation,
|
|
274
308
|
source_file=client.graph.nodes[from_id].get("file", ""),
|
|
275
309
|
line=line,
|
|
276
310
|
resolved=False,
|
|
@@ -118,6 +118,7 @@ def init_project(repo_path: str) -> None:
|
|
|
118
118
|
|
|
119
119
|
_GITIGNORE_ENTRIES = [
|
|
120
120
|
(".codecompass/enrich/", "# CodeCompass transient enrich-swarm staging dir"),
|
|
121
|
+
(".codecompass/vectors.lance/", "# CodeCompass vector index (rebuilt on ingest)"),
|
|
121
122
|
]
|
|
122
123
|
|
|
123
124
|
|
|
@@ -194,8 +195,14 @@ _REGISTRY = os.environ.get(
|
|
|
194
195
|
_BLOCKED_TOOLS = {"Grep", "Glob"}
|
|
195
196
|
# Word-boundary match anywhere in the command: catches `grep foo`,
|
|
196
197
|
# `git grep foo`, `sudo cat f`, `xargs rg` — not just command position.
|
|
197
|
-
# (?![\w-])
|
|
198
|
-
|
|
198
|
+
# (?![\w-]) keeps the bare-`cat` rule off hyphenated names; git's own
|
|
199
|
+
# subcommands are matched by the `\bgit\b ...` alternatives below.
|
|
200
|
+
_BLOCKED_SHELL_RE = re.compile(
|
|
201
|
+
r"\b(?:grep|rg|cat)\b(?![\w-])"
|
|
202
|
+
# git's own search/dump: `git grep`, `git log -S/-G`, `git ls-files`, `git cat-file`
|
|
203
|
+
r"|\bgit\b[^|;&]*?\s(?:grep|ls-files|cat-file)\b"
|
|
204
|
+
r"|\bgit\b[^|;&]*?\slog\b[^|;&]*?\s-[SG]"
|
|
205
|
+
)
|
|
199
206
|
|
|
200
207
|
|
|
201
208
|
def _repos() -> list:
|
|
@@ -261,11 +268,11 @@ def main() -> None:
|
|
|
261
268
|
saw_path = True
|
|
262
269
|
repo = _repo_containing(p)
|
|
263
270
|
if repo:
|
|
264
|
-
_block("grep/rg/cat")
|
|
271
|
+
_block("grep/rg/cat/git grep")
|
|
265
272
|
if not saw_path: # unparseable — decide by where the agent stands
|
|
266
273
|
repo = _repo_containing(os.path.realpath(cwd))
|
|
267
274
|
if repo:
|
|
268
|
-
_block("grep/rg/cat")
|
|
275
|
+
_block("grep/rg/cat/git grep")
|
|
269
276
|
# every named path is outside all codecompass repos — allow
|
|
270
277
|
|
|
271
278
|
sys.exit(0)
|
|
@@ -374,10 +381,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
374
381
|
// Word-boundary match anywhere in the command: catches `grep foo`,
|
|
375
382
|
// `git grep foo`, `sudo cat f`, `xargs rg` — not just command position.
|
|
376
383
|
// (?![\w-]) avoids false positives like `git cat-file`.
|
|
377
|
-
|
|
384
|
+
// git's own search/dump is blocked too: `git grep`, `git log -S/-G`, `git ls-files`, `git cat-file`.
|
|
385
|
+
const BLOCKED_SHELL_RE =
|
|
386
|
+
/\b(?:grep|rg|cat)\b(?![\w-])|\bgit\b[^|;&]*?\s(?:grep|ls-files|cat-file)\b|\bgit\b[^|;&]*?\slog\b[^|;&]*?\s-[SG]/;
|
|
378
387
|
|
|
379
388
|
const REASON =
|
|
380
|
-
"Don't grep/cat/rg the repo. Discover through the codecompass MCP tools — " +
|
|
389
|
+
"Don't grep/cat/rg (or `git grep`) the repo. Discover through the codecompass MCP tools — " +
|
|
381
390
|
"`grep` to find what's relevant, then `flow`/`impact`/`deps` to trace — " +
|
|
382
391
|
"then read the specific slice with the Read tool (or sed -n/head/tail), " +
|
|
383
392
|
"not a whole-file dump.";
|
|
@@ -436,16 +445,24 @@ def _ensure_pi_agents_md(repo_path: str) -> None:
|
|
|
436
445
|
)
|
|
437
446
|
|
|
438
447
|
|
|
439
|
-
def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | None = None
|
|
448
|
+
def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | None = None,
|
|
449
|
+
on_progress=None) -> None:
|
|
440
450
|
"""Ingest a codebase into the local code knowledge graph.
|
|
441
451
|
|
|
442
452
|
Phase 1: Walk the repo and write the Project → Folder → File skeleton.
|
|
443
453
|
Phase 2: Parse every source file with tree-sitter into CodeTriples.
|
|
444
454
|
Phase 3: Normalize entity names via Haiku (only if --normalize is passed).
|
|
445
455
|
Phase 4: Write all triples to the local graph.json.
|
|
456
|
+
|
|
457
|
+
on_progress: called as on_progress(percent, message) — for callers with no
|
|
458
|
+
console (the MCP server). Percent is 0-100 across all phases.
|
|
446
459
|
"""
|
|
447
460
|
import json
|
|
448
461
|
|
|
462
|
+
def _report(pct: int, message: str) -> None:
|
|
463
|
+
if on_progress:
|
|
464
|
+
on_progress(pct, message)
|
|
465
|
+
|
|
449
466
|
repo_path = os.path.abspath(repo_path)
|
|
450
467
|
project_name = os.path.basename(repo_path)
|
|
451
468
|
console.print(f"[bold blue]Ingesting codebase:[/] {repo_path}")
|
|
@@ -473,11 +490,24 @@ def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | Non
|
|
|
473
490
|
client.graph.clear()
|
|
474
491
|
|
|
475
492
|
console.print("[dim]Phase 1/4 — Building hierarchy…[/]")
|
|
493
|
+
_report(2, "Building hierarchy…")
|
|
476
494
|
file_id_map = build_hierarchy(repo_path, project_name, client)
|
|
477
495
|
console.print(f"[dim] {len(file_id_map)} source files indexed[/]")
|
|
478
496
|
|
|
479
497
|
console.print("[dim]Phase 2/4 — Parsing source files…[/]")
|
|
480
|
-
|
|
498
|
+
# Parsing dominates the wall clock, so it owns 5-70% of the bar.
|
|
499
|
+
_report(5, f"Parsing {len(file_id_map)} source files…")
|
|
500
|
+
last_pct = 5
|
|
501
|
+
|
|
502
|
+
def _parsed(done: int, total: int) -> None:
|
|
503
|
+
nonlocal last_pct
|
|
504
|
+
pct = 5 + int(65 * done / max(total, 1))
|
|
505
|
+
if pct > last_pct: # one notification per percent, not per file
|
|
506
|
+
last_pct = pct
|
|
507
|
+
_report(pct, f"Parsing files ({done}/{total})…")
|
|
508
|
+
|
|
509
|
+
raw_triples = parse_directory(repo_path, progress=True,
|
|
510
|
+
on_progress=_parsed if on_progress else None)
|
|
481
511
|
console.print(f"[dim] {len(raw_triples)} raw triples extracted[/]")
|
|
482
512
|
|
|
483
513
|
if not raw_triples:
|
|
@@ -507,6 +537,7 @@ def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | Non
|
|
|
507
537
|
if normalize:
|
|
508
538
|
from ingestion.code_normalizer import normalize_triples
|
|
509
539
|
console.print("[dim]Phase 3/4 — Normalizing triples via Haiku…[/]")
|
|
540
|
+
_report(72, "Normalizing triples via Haiku…")
|
|
510
541
|
triples = normalize_triples(raw_triples, progress=True)
|
|
511
542
|
console.print(f"[dim] {len(triples)} triples after normalization[/]")
|
|
512
543
|
else:
|
|
@@ -514,6 +545,7 @@ def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | Non
|
|
|
514
545
|
triples = raw_triples
|
|
515
546
|
|
|
516
547
|
console.print("[dim]Phase 4/4 — Writing to local graph…[/]")
|
|
548
|
+
_report(85, f"Writing {len(triples)} triples to the graph…")
|
|
517
549
|
written = client.write_code_triples_batch(triples, file_id_map, project_name)
|
|
518
550
|
|
|
519
551
|
# Restore agent-authored data onto the new graph.
|
|
@@ -537,6 +569,17 @@ def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | Non
|
|
|
537
569
|
total_nodes = client.node_count()
|
|
538
570
|
client.close()
|
|
539
571
|
|
|
572
|
+
# Phase 5 — rebuild the vector index from the final graph (parser nodes
|
|
573
|
+
# plus restored agent-inferred ones). Wipes and rewrites like the graph.
|
|
574
|
+
try:
|
|
575
|
+
from graph.vector_store import index_entities
|
|
576
|
+
_report(95, "Rebuilding vector index…")
|
|
577
|
+
n = index_entities(repo_path)
|
|
578
|
+
console.print(f"[dim]Phase 5/5 — Vector index rebuilt ({n} entities embedded)[/]")
|
|
579
|
+
except Exception as exc:
|
|
580
|
+
console.print(f"[dim]Phase 5/5 — Vector index skipped ({exc})[/]")
|
|
581
|
+
|
|
582
|
+
_report(100, f"Done — {written} triples, {total_nodes} nodes.")
|
|
540
583
|
console.print(
|
|
541
584
|
f"[bold green]Done.[/] Wrote {written} triples. "
|
|
542
585
|
f"Graph now has {total_nodes} nodes."
|
|
@@ -571,6 +614,7 @@ it at another repo.
|
|
|
571
614
|
| You have… | Use | Example |
|
|
572
615
|
|---|---|---|
|
|
573
616
|
| a concept, name, or pattern | `grep` | `grep(pattern="^Session")`, `grep(pattern=".*Adapter$")` |
|
|
617
|
+
| an idea, not a name ("where does caching go?") | `search` | `search(query="session timeout")` — semantic vector search over entity names/kinds/files/descriptions (needs the optional `search` extra + an ingest to build the index) |
|
|
574
618
|
| the full layout | `tree` | (large — read it in slices) |
|
|
575
619
|
|
|
576
620
|
2. **Trace** — understand relationships around a known symbol/file:
|
|
@@ -678,14 +722,26 @@ doc → `memory.md`; code-comment warning to the next person → `learnings.md`.
|
|
|
678
722
|
- After major refactors (moved functions, renamed classes)
|
|
679
723
|
- If query results look stale or incomplete
|
|
680
724
|
|
|
681
|
-
### The graph improves with use — record what it missed
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
graph
|
|
725
|
+
### The graph improves with use — record what it missed (NOT optional)
|
|
726
|
+
|
|
727
|
+
Exploring the code is how you find what the parser missed, so write it back as
|
|
728
|
+
you go — every session, not only when asked:
|
|
729
|
+
|
|
730
|
+
- Read a function/class/constant the graph doesn't have (or `grep`/`search`
|
|
731
|
+
found nothing for) → `add_entity(name, kind, file, line, description)`.
|
|
732
|
+
- Read a call the graph doesn't show — dynamic dispatch, a callback, a handler
|
|
733
|
+
wired up at runtime → `add_call(caller, callee, line)`. Same tool for a
|
|
734
|
+
missed import or base class: `add_call(a, b, relation="IMPORTS")` /
|
|
735
|
+
`relation="INHERITS"`. IMPORTS targets may be stdlib or third-party
|
|
736
|
+
modules (`add_call("main", "pathlib", relation="IMPORTS")`).
|
|
737
|
+
- Worked out what an entity actually does → `add_entity` again with the real
|
|
738
|
+
one-line description; it overwrites the placeholder.
|
|
739
|
+
|
|
740
|
+
Record it in the same turn you learned it, before you answer the user — a fact
|
|
741
|
+
you postpone is a fact the next session re-derives. Both tools mark entries
|
|
742
|
+
`agent_inferred` and skip anything ambiguous rather than guess, so a wrong
|
|
743
|
+
guess costs nothing but a `skipped` status. Small opportunistic writes keep
|
|
744
|
+
the graph accurate between full `enrich` runs.
|
|
689
745
|
|
|
690
746
|
### Enrichment — user-triggered ONLY
|
|
691
747
|
|
|
@@ -15,10 +15,13 @@ agent says "use this repo" or "cd to this repo" to point it elsewhere.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import functools
|
|
18
19
|
import os
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
import anyio.from_thread
|
|
23
|
+
import anyio.to_thread
|
|
24
|
+
from fastmcp import Context, FastMCP
|
|
22
25
|
|
|
23
26
|
from graph.code_queries import (
|
|
24
27
|
DEFAULT_HOPS,
|
|
@@ -95,11 +98,25 @@ def _active_repo() -> str:
|
|
|
95
98
|
return _REPO_PATH
|
|
96
99
|
|
|
97
100
|
|
|
101
|
+
# Attached to every read result: exploring is when the agent learns what the
|
|
102
|
+
# parser missed, so the reminder to write it back rides along with the answer.
|
|
103
|
+
_RECORD_NUDGE = (
|
|
104
|
+
"Before moving on: anything you read that the graph is missing or "
|
|
105
|
+
"describes badly — record it now. add_entity(name, kind, file, line, "
|
|
106
|
+
"description) for entities, add_call(caller, callee, line) for calls. "
|
|
107
|
+
"Ambiguous entries are skipped, not guessed; agent-recorded data survives "
|
|
108
|
+
"re-ingest."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
98
112
|
def _tool(fetch_fn, *args, **kwargs):
|
|
99
113
|
"""Resolve the active repo, ensure it is initialized, and call a fetch helper."""
|
|
100
114
|
repo = _active_repo()
|
|
101
115
|
_ensure_initialized(repo)
|
|
102
|
-
|
|
116
|
+
result = fetch_fn(*args, repo, os.path.basename(repo), **kwargs)
|
|
117
|
+
if isinstance(result, dict):
|
|
118
|
+
result.setdefault("next", _RECORD_NUDGE)
|
|
119
|
+
return result
|
|
103
120
|
|
|
104
121
|
|
|
105
122
|
@mcp.tool()
|
|
@@ -165,6 +182,25 @@ def grep(pattern: str, field: str = "all", ignore_case: bool = True, limit: int
|
|
|
165
182
|
return _tool(fetch_grep, pattern, field=field, ignore_case=ignore_case, limit=limit)
|
|
166
183
|
|
|
167
184
|
|
|
185
|
+
@mcp.tool()
|
|
186
|
+
def search(query: str, limit: int = 10) -> dict:
|
|
187
|
+
"""Semantic vector search over entities — for concepts that aren't symbol names.
|
|
188
|
+
|
|
189
|
+
Matches the query against embedded entity name/kind/file/description, so
|
|
190
|
+
it finds things like "session timeout" or "caching" even when no symbol
|
|
191
|
+
carries those words. Use grep for exact names/patterns; use this when you
|
|
192
|
+
have an idea, not a name. Requires the optional vector deps
|
|
193
|
+
(`pip install 'codecompass-mcp[search]'`) and an ingest to build the index.
|
|
194
|
+
"""
|
|
195
|
+
repo = _active_repo()
|
|
196
|
+
_ensure_initialized(repo)
|
|
197
|
+
from graph.vector_store import search_entities
|
|
198
|
+
result = search_entities(repo, query, limit=limit)
|
|
199
|
+
if isinstance(result, dict):
|
|
200
|
+
result.setdefault("next", _RECORD_NUDGE)
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
168
204
|
@mcp.tool()
|
|
169
205
|
def flow(
|
|
170
206
|
entry_symbol: str,
|
|
@@ -231,7 +267,8 @@ def init() -> dict:
|
|
|
231
267
|
|
|
232
268
|
|
|
233
269
|
@mcp.tool()
|
|
234
|
-
def ingest(normalize: bool = False,
|
|
270
|
+
async def ingest(ctx: Context, normalize: bool = False,
|
|
271
|
+
dump_triples: str | None = None) -> dict:
|
|
235
272
|
"""Re-index the currently configured repo and rebuild the code knowledge graph.
|
|
236
273
|
|
|
237
274
|
normalize: normalize entity names via Haiku (slower, needs an API key).
|
|
@@ -239,7 +276,15 @@ def ingest(normalize: bool = False, dump_triples: str | None = None) -> dict:
|
|
|
239
276
|
loading them into the graph (debugging the parser)."""
|
|
240
277
|
repo = _active_repo()
|
|
241
278
|
_ensure_initialized(repo)
|
|
242
|
-
|
|
279
|
+
|
|
280
|
+
# ingest_code is blocking, so it runs in a worker thread; progress hops back
|
|
281
|
+
# to the event loop from there so notifications flush while it works.
|
|
282
|
+
def on_progress(pct: int, message: str) -> None:
|
|
283
|
+
anyio.from_thread.run(ctx.report_progress, pct, 100, message)
|
|
284
|
+
|
|
285
|
+
await anyio.to_thread.run_sync(
|
|
286
|
+
functools.partial(ingest_code, repo, normalize=normalize,
|
|
287
|
+
dump_triples=dump_triples, on_progress=on_progress))
|
|
243
288
|
return {"status": "ok", "repo": repo, "project": os.path.basename(repo),
|
|
244
289
|
"normalize": normalize, "dump_triples": dump_triples,
|
|
245
290
|
"next": "Record anything the parser missed while you were reading "
|
|
@@ -286,16 +331,24 @@ def add_entity(name: str, kind: str = "function", file: str = "",
|
|
|
286
331
|
|
|
287
332
|
|
|
288
333
|
@mcp.tool()
|
|
289
|
-
def add_call(caller: str, callee: str, line: int | None = None
|
|
290
|
-
|
|
291
|
-
|
|
334
|
+
def add_call(caller: str, callee: str, line: int | None = None,
|
|
335
|
+
relation: str = "CALLS") -> dict:
|
|
336
|
+
"""Record an edge you spotted in source that the parser missed — a call via
|
|
337
|
+
dynamic dispatch, a callback, string-based lookup, a conditional import, a
|
|
338
|
+
runtime-registered base class.
|
|
339
|
+
|
|
340
|
+
relation is CALLS (default), IMPORTS, or INHERITS. Structural edges
|
|
341
|
+
(CONTAINS/DEFINED_IN) are parser-owned and cannot be added. Both names must
|
|
292
342
|
resolve unambiguously — ambiguous targets are skipped, never guessed.
|
|
293
|
-
|
|
343
|
+
IMPORTS may target a stdlib or third-party module (`add_call("main",
|
|
344
|
+
"pathlib", relation="IMPORTS")`); the module node is created if the graph
|
|
345
|
+
has never seen it. Idempotent: existing edges of the same type are left
|
|
346
|
+
alone.
|
|
294
347
|
"""
|
|
295
348
|
repo = _active_repo()
|
|
296
349
|
_ensure_initialized(repo)
|
|
297
350
|
from ingestion.enricher import add_call as _add
|
|
298
|
-
return _add(repo, caller, callee, line=line)
|
|
351
|
+
return _add(repo, caller, callee, line=line, relation=relation)
|
|
299
352
|
|
|
300
353
|
|
|
301
354
|
def main() -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "codecompass-mcp"
|
|
3
|
-
version = "5.
|
|
3
|
+
version = "5.2.0"
|
|
4
4
|
description = "Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -39,6 +39,10 @@ dev = [
|
|
|
39
39
|
"pytest>=7.0.0",
|
|
40
40
|
"pytest-asyncio>=0.23.0",
|
|
41
41
|
]
|
|
42
|
+
search = [
|
|
43
|
+
"lancedb>=0.13.0",
|
|
44
|
+
"fastembed>=0.3.0",
|
|
45
|
+
]
|
|
42
46
|
|
|
43
47
|
[project.scripts]
|
|
44
48
|
codecompass = "main:main"
|
|
@@ -56,11 +56,20 @@ def test_hook_blocks_inside_repo_allows_outside(tmp_path):
|
|
|
56
56
|
"cwd": str(repo)}, env)
|
|
57
57
|
assert r.returncode == 2
|
|
58
58
|
|
|
59
|
-
# git
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
# git's other content searches -> blocked
|
|
60
|
+
for cmd in ("git ls-files", "git log -S needle", "git -C . grep foo",
|
|
61
|
+
"git cat-file -p HEAD"):
|
|
62
|
+
r = _run(hook, {"tool_name": "Bash",
|
|
63
|
+
"tool_input": {"command": cmd},
|
|
64
|
+
"cwd": str(repo)}, env)
|
|
65
|
+
assert r.returncode == 2, cmd
|
|
66
|
+
|
|
67
|
+
# ordinary git history/read commands -> allowed
|
|
68
|
+
for cmd in ("git log --oneline", "git status", "git show HEAD"):
|
|
69
|
+
r = _run(hook, {"tool_name": "Bash",
|
|
70
|
+
"tool_input": {"command": cmd},
|
|
71
|
+
"cwd": str(repo)}, env)
|
|
72
|
+
assert r.returncode == 0, cmd
|
|
64
73
|
|
|
65
74
|
# Grep tool defaulting to cwd (inside the repo) -> blocked
|
|
66
75
|
r = _run(hook, {"tool_name": "Grep",
|
|
@@ -109,12 +109,38 @@ def test_add_entity_and_add_call(tmp_path):
|
|
|
109
109
|
assert add_call(str(repo_path), "caller", "shared")["status"] == "skipped"
|
|
110
110
|
assert add_call(str(repo_path), "ghost", "callee")["status"] == "skipped"
|
|
111
111
|
|
|
112
|
+
# Non-CALLS relations ride the same tool; CALLS on the same pair already
|
|
113
|
+
# exists, so a distinct type must still be added.
|
|
114
|
+
assert add_call(str(repo_path), "caller", "callee",
|
|
115
|
+
relation="imports")["status"] == "added"
|
|
116
|
+
assert add_call(str(repo_path), "caller", "callee",
|
|
117
|
+
relation="IMPORTS")["status"] == "exists"
|
|
118
|
+
# Structural edges stay parser-owned
|
|
119
|
+
assert add_call(str(repo_path), "caller", "callee",
|
|
120
|
+
relation="CONTAINS")["status"] == "skipped"
|
|
121
|
+
|
|
122
|
+
# IMPORTS of a module the graph has never seen creates the external node;
|
|
123
|
+
# a CALLS to the same unknown name still skips (only imports go external).
|
|
124
|
+
assert add_call(str(repo_path), "caller", "pathlib",
|
|
125
|
+
relation="IMPORTS")["status"] == "added"
|
|
126
|
+
assert add_call(str(repo_path), "caller", "pathlib")["status"] == "skipped"
|
|
127
|
+
# Ambiguity still wins over node creation
|
|
128
|
+
assert add_call(str(repo_path), "caller", "shared",
|
|
129
|
+
relation="IMPORTS")["status"] == "skipped"
|
|
130
|
+
|
|
112
131
|
client = get_client(str(repo_path))
|
|
113
132
|
node = next(a for _, a in client.graph.nodes(data=True) if a.get("name") == "helper")
|
|
114
133
|
assert node["description"] == "Async helper." and node["agent_inferred"] is True
|
|
115
134
|
edge = next(e for e in client.graph.get_edge_data(
|
|
116
135
|
f"{project}:caller", f"{project}:callee").values() if e.get("type") == "CALLS")
|
|
117
136
|
assert edge["agent_inferred"] is True
|
|
137
|
+
imports = next(e for e in client.graph.get_edge_data(
|
|
138
|
+
f"{project}:caller", f"{project}:callee").values() if e.get("type") == "IMPORTS")
|
|
139
|
+
assert imports["agent_inferred"] is True
|
|
140
|
+
# The stdlib module node the agent created: file-less, like the parser's own
|
|
141
|
+
stdlib = client.graph.nodes[f"{project}:pathlib"]
|
|
142
|
+
assert stdlib["kind"] == "module:python" and not stdlib.get("file")
|
|
143
|
+
assert stdlib["agent_created"] is True
|
|
118
144
|
client.close()
|
|
119
145
|
|
|
120
146
|
|
|
@@ -136,6 +162,8 @@ def test_agent_data_survives_reingest(tmp_path):
|
|
|
136
162
|
|
|
137
163
|
add_entity(str(repo_path), "helper", file="src/a.py", description="Async helper.")
|
|
138
164
|
assert add_call(str(repo_path), "caller", "target", line=2)["status"] == "added"
|
|
165
|
+
assert add_call(str(repo_path), "caller", "pathlib",
|
|
166
|
+
relation="IMPORTS")["status"] == "added"
|
|
139
167
|
|
|
140
168
|
cc_main.ingest_code(str(repo_path)) # full rebuild
|
|
141
169
|
|
|
@@ -147,4 +175,8 @@ def test_agent_data_survives_reingest(tmp_path):
|
|
|
147
175
|
assert len(agent_edges) == 1
|
|
148
176
|
names = {client.graph.nodes[n].get("name") for n in agent_edges[0]}
|
|
149
177
|
assert names == {"caller", "target"}
|
|
178
|
+
# The file-less stdlib node and its IMPORTS edge survive too
|
|
179
|
+
assert client.graph.nodes[f"{project}:pathlib"]["agent_created"] is True
|
|
180
|
+
assert any(e.get("type") == "IMPORTS" and e.get("agent_inferred")
|
|
181
|
+
for _, _, e in client.graph.edges(data=True))
|
|
150
182
|
client.close()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The MCP ingest tool streams progress notifications while it works."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.anyio
|
|
14
|
+
async def test_ingest_reports_progress(tmp_path, monkeypatch):
|
|
15
|
+
from fastmcp import Client
|
|
16
|
+
|
|
17
|
+
import mcp_server
|
|
18
|
+
|
|
19
|
+
repo = tmp_path / "repo"
|
|
20
|
+
(repo / "pkg").mkdir(parents=True)
|
|
21
|
+
for i in range(5):
|
|
22
|
+
(repo / "pkg" / f"m{i}.py").write_text(f"def f{i}():\n return {i}\n")
|
|
23
|
+
|
|
24
|
+
monkeypatch.setattr(mcp_server, "_REPO_PATH", str(repo))
|
|
25
|
+
seen = []
|
|
26
|
+
|
|
27
|
+
async def handler(progress: float, total: float | None, message: str | None):
|
|
28
|
+
seen.append((progress, total, message))
|
|
29
|
+
|
|
30
|
+
async with Client(mcp_server.mcp, progress_handler=handler) as client:
|
|
31
|
+
result = await client.call_tool("ingest", {})
|
|
32
|
+
|
|
33
|
+
assert result.data["status"] == "ok"
|
|
34
|
+
percents = [p for p, _, _ in seen]
|
|
35
|
+
assert percents == sorted(percents) # monotonic
|
|
36
|
+
assert percents[0] < 100 and percents[-1] == 100
|
|
37
|
+
assert all(t == 100 for _, t, _ in seen)
|
|
38
|
+
assert any("Parsing" in (m or "") for _, _, m in seen)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def anyio_backend():
|
|
43
|
+
return "asyncio"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Vector store: wipe-rebuild on index + semantic search returns the right entity.
|
|
2
|
+
|
|
3
|
+
Uses real LanceDB in a tmp dir; the embedding model is faked with a
|
|
4
|
+
bag-of-words hash so the test needs no download. Skips when the optional
|
|
5
|
+
`search` deps aren't installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
pytest.importorskip("lancedb")
|
|
13
|
+
pytest.importorskip("fastembed")
|
|
14
|
+
|
|
15
|
+
from graph import vector_store
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _FakeEmbedder:
|
|
19
|
+
def embed(self, texts):
|
|
20
|
+
vecs = []
|
|
21
|
+
for t in texts:
|
|
22
|
+
v = [0.0] * 384
|
|
23
|
+
for word in t.lower().split():
|
|
24
|
+
v[hash(word) % 384] += 1.0
|
|
25
|
+
vecs.append(v)
|
|
26
|
+
return iter(vecs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def repo(tmp_path, monkeypatch):
|
|
31
|
+
monkeypatch.setattr(vector_store, "_embedder", lambda: _FakeEmbedder())
|
|
32
|
+
cc = tmp_path / ".codecompass"
|
|
33
|
+
cc.mkdir()
|
|
34
|
+
graph = {
|
|
35
|
+
"nodes": [
|
|
36
|
+
{"type": "Entity", "id": "p:a.py:session_handler", "name": "session_handler",
|
|
37
|
+
"kind": "function:python", "file": "a.py", "line": 3,
|
|
38
|
+
"description": "handles session timeout"},
|
|
39
|
+
{"type": "Entity", "id": "p:b.py:render_button", "name": "render_button",
|
|
40
|
+
"kind": "function:python", "file": "b.py", "line": 1,
|
|
41
|
+
"description": ""},
|
|
42
|
+
{"type": "File", "id": "p:a.py", "path": "a.py"}, # not indexed
|
|
43
|
+
],
|
|
44
|
+
"links": [],
|
|
45
|
+
}
|
|
46
|
+
(cc / "graph.json").write_text(json.dumps(graph))
|
|
47
|
+
return str(tmp_path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_index_skips_non_entities(repo):
|
|
51
|
+
assert vector_store.index_entities(repo) == 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_search_finds_by_description(repo):
|
|
55
|
+
vector_store.index_entities(repo)
|
|
56
|
+
hits = vector_store.search_entities(repo, "session timeout")
|
|
57
|
+
assert hits["count"] >= 1
|
|
58
|
+
assert hits["matches"][0]["name"] == "session_handler"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_search_without_index_reports_hint(tmp_path):
|
|
62
|
+
(tmp_path / ".codecompass").mkdir()
|
|
63
|
+
out = vector_store.search_entities(str(tmp_path), "anything")
|
|
64
|
+
assert out["count"] == 0
|
|
65
|
+
assert "hint" in out
|
|
File without changes
|
{codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|