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.
Files changed (39) hide show
  1. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/PKG-INFO +35 -9
  2. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/README.md +31 -8
  3. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/PKG-INFO +35 -9
  4. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/SOURCES.txt +4 -1
  5. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/requires.txt +4 -0
  6. codecompass_mcp-5.2.0/graph/vector_store.py +110 -0
  7. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/code_parser.py +6 -1
  8. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/enricher.py +46 -12
  9. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/main.py +72 -16
  10. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/mcp_server.py +62 -9
  11. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/pyproject.toml +5 -1
  12. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_block_hook.py +14 -5
  13. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_enricher.py +32 -0
  14. codecompass_mcp-5.2.0/tests/test_ingest_progress.py +43 -0
  15. codecompass_mcp-5.2.0/tests/test_vector_store.py +65 -0
  16. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/LICENSE +0 -0
  17. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
  18. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
  19. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
  20. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/config.py +0 -0
  21. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/graph/__init__.py +0 -0
  22. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/graph/code_graph_client.py +0 -0
  23. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/graph/code_queries.py +0 -0
  24. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/__init__.py +0 -0
  25. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/code_normalizer.py +0 -0
  26. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/file_watcher.py +0 -0
  27. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/hierarchy_builder.py +0 -0
  28. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/ingestion/source_context.py +0 -0
  29. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/models/__init__.py +0 -0
  30. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/models/code_types.py +0 -0
  31. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/pi_setup.py +0 -0
  32. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/setup.cfg +0 -0
  33. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_accuracy_fixes.py +0 -0
  34. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_code_parser.py +0 -0
  35. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_enrich_preserve.py +0 -0
  36. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_init.py +0 -0
  37. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_lit_css_parser.py +0 -0
  38. {codecompass_mcp-5.0.0 → codecompass_mcp-5.2.0}/tests/test_mcp_server.py +0 -0
  39. {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.0.0
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 database. No cloud. One JSON file per repo. Python, JavaScript/TypeScript, PHP, HTML/CSS.
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. No network, no database, no API keys.
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 the code knowledge graph (auto-generated)
238
- └── AGENTS.md discovery guide for agents (auto-updated)
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 optional** — the parser knows what calls what,
246
- not what it means. `enrich` closes that gap with agent-written descriptions
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 database. No cloud. One JSON file per repo. Python, JavaScript/TypeScript, PHP, HTML/CSS.
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. No network, no database, no API keys.
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 the code knowledge graph (auto-generated)
202
- └── AGENTS.md discovery guide for agents (auto-updated)
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 optional** — the parser knows what calls what,
210
- not what it means. `enrich` closes that gap with agent-written descriptions
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.0.0
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 database. No cloud. One JSON file per repo. Python, JavaScript/TypeScript, PHP, HTML/CSS.
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. No network, no database, no API keys.
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 the code knowledge graph (auto-generated)
238
- └── AGENTS.md discovery guide for agents (auto-updated)
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 optional** — the parser knows what calls what,
246
- not what it means. `enrich` closes that gap with agent-written descriptions
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
@@ -14,3 +14,7 @@ watchdog>=4.0.0
14
14
  [dev]
15
15
  pytest>=7.0.0
16
16
  pytest-asyncio>=0.23.0
17
+
18
+ [search]
19
+ lancedb>=0.13.0
20
+ fastembed>=0.3.0
@@ -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) -> str | None:
184
- """Node id of the single in-project entity called `name`, else None.
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 a CALLS edge the agent spotted in source but the parser missed.
253
- Both ends must resolve unambiguously ambiguous names are skipped, never
254
- guessed. Idempotent: an existing CALLS edge is left alone."""
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
- to_id = _resolve_callee(client, project, callee)
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") == "CALLS"
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="CALLS",
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-]) avoids false positives like `git cat-file`.
198
- _BLOCKED_SHELL_RE = re.compile(r"\b(?:grep|rg|cat)\b(?![\w-])")
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
- const BLOCKED_SHELL_RE = /\b(?:grep|rg|cat)\b(?![\w-])/;
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) -> 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
- raw_triples = parse_directory(repo_path, progress=True)
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
- While reading code you may find entities, calls, or important variables the
684
- parser didn't capture. Record them immediately with the MCP tools
685
- `add_entity(name, kind, file, line, description)` and
686
- `add_call(caller, callee, line)`. Both mark entries `agent_inferred` and skip
687
- anything ambiguous rather than guess. Small opportunistic writes keep the
688
- graph accurate between full `enrich` runs.
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
- from fastmcp import FastMCP
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
- return fetch_fn(*args, repo, os.path.basename(repo), **kwargs)
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, dump_triples: str | None = None) -> dict:
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
- ingest_code(repo, normalize=normalize, dump_triples=dump_triples)
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) -> dict:
290
- """Record a CALLS edge you spotted in source that the parser missed
291
- (dynamic dispatch, callbacks, string-based lookup). Both names must
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
- Idempotent: existing edges are left alone.
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.0.0"
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 cat-file is not a search -> allowed
60
- r = _run(hook, {"tool_name": "Bash",
61
- "tool_input": {"command": "git cat-file -p HEAD"},
62
- "cwd": str(repo)}, env)
63
- assert r.returncode == 0
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