codecortex 0.6.0__tar.gz → 0.8.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.
Potentially problematic release.
This version of codecortex might be problematic. Click here for more details.
- {codecortex-0.6.0/src/codecortex.egg-info → codecortex-0.8.0}/PKG-INFO +2 -1
- {codecortex-0.6.0 → codecortex-0.8.0}/pyproject.toml +6 -1
- {codecortex-0.6.0 → codecortex-0.8.0/src/codecortex.egg-info}/PKG-INFO +2 -1
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/SOURCES.txt +3 -1
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/requires.txt +1 -0
- codecortex-0.8.0/src/codeintel/__init__.py +1 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/config.py +4 -1
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/doctor.py +5 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/indexer.py +158 -10
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/graph.py +3 -1
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/lsp.py +3 -1
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/semantic.py +4 -1
- codecortex-0.8.0/src/codeintel/searcher.py +311 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_chunking.py +13 -6
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_config.py +11 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_doctor.py +12 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_integration.py +11 -5
- codecortex-0.8.0/tests/test_rerank.py +256 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_semantic_provider.py +48 -0
- codecortex-0.8.0/tests/test_treesitter.py +185 -0
- codecortex-0.6.0/src/codeintel/__init__.py +0 -1
- codecortex-0.6.0/src/codeintel/searcher.py +0 -143
- {codecortex-0.6.0 → codecortex-0.8.0}/LICENSE +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/README.md +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/setup.cfg +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/__main__.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/auth.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/cache.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/gateway.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/http_server.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/injector.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/installer.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/mapper.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/metrics.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/policy.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/provider.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/reset.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/server.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/src/codeintel/term.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_cache.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_e2e.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_enterprise.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_gateway.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_graph_provider.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_graph_real.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_hardening.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_http_auth.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_http_server.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_lsp_real.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_mapper.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_never_raise.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_onboarding.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_rbac.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_reindexer.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_reset.py +0 -0
- {codecortex-0.6.0 → codecortex-0.8.0}/tests/test_term.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,6 +26,7 @@ License-File: LICENSE
|
|
|
26
26
|
Requires-Dist: mcp>=1.0
|
|
27
27
|
Requires-Dist: sqlite-vec>=0.1
|
|
28
28
|
Requires-Dist: fastembed>=0.3
|
|
29
|
+
Requires-Dist: tree-sitter-language-pack>=1.0
|
|
29
30
|
Provides-Extra: dev
|
|
30
31
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
31
32
|
Requires-Dist: numpy>=1.24; extra == "dev"
|
|
@@ -31,7 +31,12 @@ classifiers = [
|
|
|
31
31
|
"Topic :: Software Development :: Quality Assurance",
|
|
32
32
|
"Typing :: Typed",
|
|
33
33
|
]
|
|
34
|
-
dependencies = [
|
|
34
|
+
dependencies = [
|
|
35
|
+
"mcp>=1.0",
|
|
36
|
+
"sqlite-vec>=0.1",
|
|
37
|
+
"fastembed>=0.3",
|
|
38
|
+
"tree-sitter-language-pack>=1.0", # def-aligned chunking for non-Python languages (P3)
|
|
39
|
+
]
|
|
35
40
|
|
|
36
41
|
[project.urls]
|
|
37
42
|
Homepage = "https://github.com/hamilton-sky/codeintel"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,6 +26,7 @@ License-File: LICENSE
|
|
|
26
26
|
Requires-Dist: mcp>=1.0
|
|
27
27
|
Requires-Dist: sqlite-vec>=0.1
|
|
28
28
|
Requires-Dist: fastembed>=0.3
|
|
29
|
+
Requires-Dist: tree-sitter-language-pack>=1.0
|
|
29
30
|
Provides-Extra: dev
|
|
30
31
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
31
32
|
Requires-Dist: numpy>=1.24; extra == "dev"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.0"
|
|
@@ -25,6 +25,8 @@ _DEFAULTS: dict = {
|
|
|
25
25
|
"cosine_floor": 0.25,
|
|
26
26
|
"model": "BAAI/bge-small-en-v1.5",
|
|
27
27
|
"chunk_strategy": "syntax", # syntax-aware (def/class boundaries) vs fixed line windows
|
|
28
|
+
"rerank": "on", # hybrid lexical+semantic rerank of search results
|
|
29
|
+
"rerank_candidates": 30, # cosine candidates fused/re-ranked before returning top-k
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
# Values restricted to a fixed set — anything else falls back to the default.
|
|
@@ -33,8 +35,9 @@ _ENUMS: dict = {
|
|
|
33
35
|
"semantic": {"on", "off"},
|
|
34
36
|
"reindex": {"on-demand", "never"},
|
|
35
37
|
"chunk_strategy": {"syntax", "lines"},
|
|
38
|
+
"rerank": {"on", "off"},
|
|
36
39
|
}
|
|
37
|
-
_POSITIVE_INTS = ("window", "stride", "max_chunks", "max_total_chunks")
|
|
40
|
+
_POSITIVE_INTS = ("window", "stride", "max_chunks", "max_total_chunks", "rerank_candidates")
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
def _read_toml(path: pathlib.Path) -> dict:
|
|
@@ -158,4 +158,9 @@ def render_doctor_text(report: dict) -> str:
|
|
|
158
158
|
tail = "" if report.get("deep") else c.dim(" (run with --deep to boot-check serena)")
|
|
159
159
|
out.append("")
|
|
160
160
|
out.append(f" {count} engines ready for this repo.{tail}")
|
|
161
|
+
if healthy is False:
|
|
162
|
+
out.append(" " + c.dim(
|
|
163
|
+
"tip: `codeintel setup --install-uv --install-deps --index` bootstraps the "
|
|
164
|
+
"pip-installable backends; each fix: line above has the per-engine command."
|
|
165
|
+
))
|
|
161
166
|
return "\n".join(out)
|
|
@@ -14,7 +14,10 @@ if TYPE_CHECKING:
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
16
|
_INDEXED_EXTS = frozenset({
|
|
17
|
-
".py", ".
|
|
17
|
+
".py", ".md",
|
|
18
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", # TS/JS variants
|
|
19
|
+
".go", ".rs", ".java",
|
|
20
|
+
".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", # C/C++ variants
|
|
18
21
|
})
|
|
19
22
|
_SKIP_DIRS = frozenset({"__pycache__", ".git", "node_modules"})
|
|
20
23
|
# Vendored / regenerable dirs skipped even without a .gitignore entry.
|
|
@@ -41,6 +44,49 @@ def _pos_int(val: object, default: int) -> int:
|
|
|
41
44
|
return n if n > 0 else default
|
|
42
45
|
|
|
43
46
|
|
|
47
|
+
# ---- tree-sitter chunking (P3): def-aligned chunks for non-Python languages -----------------
|
|
48
|
+
# Extends the P1 chunker interface to TS/JS/Go/Rust/Java/C/C++ via tree-sitter, behind the same
|
|
49
|
+
# _primary_spans → _cover pipeline. The grammar pack is a normal dependency but never a hard
|
|
50
|
+
# requirement: if it (or a grammar) is unavailable, or a file's language config is missing, the
|
|
51
|
+
# file falls back to line windowing — exactly like a Python parse failure.
|
|
52
|
+
|
|
53
|
+
# file extension -> tree_sitter_language_pack language name. Only the *code* exts in
|
|
54
|
+
# _INDEXED_EXTS; .md (and anything unmapped) keeps line-windowing.
|
|
55
|
+
_TS_LANG_BY_EXT = {
|
|
56
|
+
".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "javascript",
|
|
57
|
+
".mjs": "javascript", ".cjs": "javascript",
|
|
58
|
+
".go": "go", ".rs": "rust", ".java": "java",
|
|
59
|
+
".c": "c", ".h": "cpp", ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp",
|
|
60
|
+
".hpp": "cpp", ".hh": "cpp",
|
|
61
|
+
}
|
|
62
|
+
# "Leaf" definition node types — each emitted as one whole chunk (like a top-level Python def).
|
|
63
|
+
_TS_FUNC_TYPES = {
|
|
64
|
+
"typescript": {"function_declaration", "generator_function_declaration", "method_definition",
|
|
65
|
+
"method_signature", "abstract_method_signature", "function_signature"},
|
|
66
|
+
"tsx": {"function_declaration", "generator_function_declaration", "method_definition",
|
|
67
|
+
"method_signature", "abstract_method_signature", "function_signature"},
|
|
68
|
+
"javascript": {"function_declaration", "generator_function_declaration", "method_definition"},
|
|
69
|
+
"go": {"function_declaration", "method_declaration", "type_declaration"},
|
|
70
|
+
"rust": {"function_item", "function_signature_item", "struct_item", "enum_item",
|
|
71
|
+
"union_item", "type_item", "macro_definition"},
|
|
72
|
+
"java": {"method_declaration", "constructor_declaration"},
|
|
73
|
+
"c": {"function_definition", "struct_specifier", "enum_specifier", "union_specifier"},
|
|
74
|
+
"cpp": {"function_definition"},
|
|
75
|
+
}
|
|
76
|
+
# "Container" definition types — emitted as a header chunk + one chunk per nested member, exactly
|
|
77
|
+
# like a Python class (so per-method chunks are never shadowed by a whole-class chunk).
|
|
78
|
+
_TS_CONTAINER_TYPES = {
|
|
79
|
+
"typescript": {"class_declaration", "abstract_class_declaration", "interface_declaration"},
|
|
80
|
+
"tsx": {"class_declaration", "abstract_class_declaration", "interface_declaration"},
|
|
81
|
+
"javascript": {"class_declaration"},
|
|
82
|
+
"go": set(),
|
|
83
|
+
"rust": {"impl_item", "trait_item", "mod_item"},
|
|
84
|
+
"java": {"class_declaration", "interface_declaration", "enum_declaration", "record_declaration"},
|
|
85
|
+
"c": set(),
|
|
86
|
+
"cpp": {"class_specifier", "struct_specifier", "namespace_definition"},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
44
90
|
class Indexer:
|
|
45
91
|
def __init__(
|
|
46
92
|
self,
|
|
@@ -76,6 +122,7 @@ class Indexer:
|
|
|
76
122
|
else 2 * self.window
|
|
77
123
|
)
|
|
78
124
|
self._embedder = None
|
|
125
|
+
self._ts_parsers: dict = {} # per-instance tree-sitter parser cache (lang -> parser|None)
|
|
79
126
|
|
|
80
127
|
def _get_embedder(self):
|
|
81
128
|
if self._embedder is None:
|
|
@@ -249,16 +296,109 @@ class Indexer:
|
|
|
249
296
|
n = len(lines)
|
|
250
297
|
return self._cover(self._primary_spans(tree, n), n)
|
|
251
298
|
|
|
299
|
+
def _get_ts_parser(self, lang: str):
|
|
300
|
+
"""Lazily load + cache (per instance) the tree-sitter parser for a language. Returns None
|
|
301
|
+
when ``tree-sitter-language-pack`` isn't installed or the grammar is unavailable — the
|
|
302
|
+
caller then windows (tree-sitter is a normal dep, never a hard requirement)."""
|
|
303
|
+
cache = self._ts_parsers
|
|
304
|
+
if lang in cache:
|
|
305
|
+
return cache[lang]
|
|
306
|
+
parser = None
|
|
307
|
+
try:
|
|
308
|
+
from tree_sitter_language_pack import get_parser
|
|
309
|
+
parser = get_parser(lang)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
logger.debug("tree-sitter parser for %s unavailable: %s", lang, exc)
|
|
312
|
+
cache[lang] = parser
|
|
313
|
+
return parser
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _ts_end_line(node, n: int) -> int:
|
|
317
|
+
"""0-based half-open end line of a tree-sitter node. ``end_point`` is (row, col); a col of 0
|
|
318
|
+
means the node ends at the start of that row (so the row isn't included). Clamped to
|
|
319
|
+
``[start+1, n]``."""
|
|
320
|
+
row, col = node.end_point
|
|
321
|
+
end = row if col == 0 else row + 1
|
|
322
|
+
return min(n, max(node.start_point[0] + 1, end))
|
|
323
|
+
|
|
324
|
+
def _primary_spans_ts(self, root, lang: str, n: int) -> list[tuple[int, int]]:
|
|
325
|
+
"""Def-aligned 'primary' spans from a tree-sitter tree, mirroring ``_primary_spans``: each
|
|
326
|
+
function/method → one span; each container (class/impl/trait/…) → a header span plus one
|
|
327
|
+
span per nested member. Node types the language config doesn't list are simply not treated
|
|
328
|
+
as defs — they fall into ``_cover``'s window-filled gaps, so an incomplete config degrades
|
|
329
|
+
precision, never correctness."""
|
|
330
|
+
func_types = _TS_FUNC_TYPES.get(lang, set())
|
|
331
|
+
cont_types = _TS_CONTAINER_TYPES.get(lang, set())
|
|
332
|
+
all_types = func_types | cont_types
|
|
333
|
+
if not all_types:
|
|
334
|
+
return [] # unknown language -> caller windows the whole file
|
|
335
|
+
spans: list[tuple[int, int]] = []
|
|
336
|
+
|
|
337
|
+
def nearest_defs(node):
|
|
338
|
+
"""Def-type nodes reachable without crossing another def-type — top-level defs from the
|
|
339
|
+
root, a container's direct members from the container. Iterative, so deep nesting can't
|
|
340
|
+
blow the stack."""
|
|
341
|
+
found = []
|
|
342
|
+
stack = [c for c in reversed(node.children) if c.is_named]
|
|
343
|
+
while stack:
|
|
344
|
+
c = stack.pop()
|
|
345
|
+
if c.type in all_types:
|
|
346
|
+
found.append(c)
|
|
347
|
+
else:
|
|
348
|
+
stack.extend(g for g in reversed(c.children) if g.is_named)
|
|
349
|
+
return found
|
|
350
|
+
|
|
351
|
+
def emit(node):
|
|
352
|
+
s = node.start_point[0]
|
|
353
|
+
e = self._ts_end_line(node, n)
|
|
354
|
+
if node.type in cont_types:
|
|
355
|
+
members = sorted(nearest_defs(node), key=lambda m: m.start_point[0])
|
|
356
|
+
if members:
|
|
357
|
+
header_end = max(s + 1, min(members[0].start_point[0], e))
|
|
358
|
+
spans.append((s, header_end)) # header: class/impl line -> first member
|
|
359
|
+
for m in members:
|
|
360
|
+
emit(m)
|
|
361
|
+
return
|
|
362
|
+
spans.append((s, e))
|
|
363
|
+
|
|
364
|
+
for node in sorted(nearest_defs(root), key=lambda x: x.start_point[0]):
|
|
365
|
+
emit(node)
|
|
366
|
+
return spans
|
|
367
|
+
|
|
368
|
+
def _chunk_treesitter(self, lines: list[str], source: str, lang: str):
|
|
369
|
+
"""Parse ``source`` with tree-sitter → a complete, def-aligned cover, or ``None`` when the
|
|
370
|
+
parser is unavailable (caller windows). tree-sitter is error-tolerant, so a syntactically
|
|
371
|
+
broken file still yields a partial tree (and thus useful spans) rather than raising."""
|
|
372
|
+
parser = self._get_ts_parser(lang)
|
|
373
|
+
if parser is None:
|
|
374
|
+
return None
|
|
375
|
+
tree = parser.parse(source.encode("utf-8", errors="replace"))
|
|
376
|
+
n = len(lines)
|
|
377
|
+
return self._cover(self._primary_spans_ts(tree.root_node, lang, n), n)
|
|
378
|
+
|
|
252
379
|
def _spans_for_file(
|
|
253
380
|
self, filepath: Path, lines: list[str], rel_path: str
|
|
254
381
|
) -> list[tuple[int, int]]:
|
|
255
|
-
"""Choose spans for one file
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
382
|
+
"""Choose spans for one file under the syntax strategy: ``ast`` for ``.py``, tree-sitter for
|
|
383
|
+
the mapped languages (TS/JS/Go/Rust/Java/C/C++), fixed windows for everything else and on
|
|
384
|
+
any parse/grammar failure or when tree-sitter isn't installed."""
|
|
385
|
+
if self.chunk_strategy == "syntax":
|
|
386
|
+
suffix = filepath.suffix.lower()
|
|
387
|
+
if suffix == ".py":
|
|
388
|
+
try:
|
|
389
|
+
return self._chunk_python_ast(lines, "".join(lines))
|
|
390
|
+
except Exception as exc:
|
|
391
|
+
logger.debug("syntax chunking failed for %s (%s) — windowing", rel_path, exc)
|
|
392
|
+
else:
|
|
393
|
+
lang = _TS_LANG_BY_EXT.get(suffix)
|
|
394
|
+
if lang is not None:
|
|
395
|
+
try:
|
|
396
|
+
spans = self._chunk_treesitter(lines, "".join(lines), lang)
|
|
397
|
+
if spans is not None:
|
|
398
|
+
return spans
|
|
399
|
+
except Exception as exc:
|
|
400
|
+
logger.debug("tree-sitter chunking failed for %s (%s) — windowing",
|
|
401
|
+
rel_path, exc)
|
|
262
402
|
return self._window_spans(0, len(lines))
|
|
263
403
|
|
|
264
404
|
# ---- materialisation -------------------------------------------------------------------
|
|
@@ -380,9 +520,17 @@ class Indexer:
|
|
|
380
520
|
try:
|
|
381
521
|
vec = embeddings[j]
|
|
382
522
|
vec_bytes = struct.pack(f"{len(vec)}f", *vec)
|
|
523
|
+
# sqlite-vec's vec0 virtual table does NOT honor INSERT OR REPLACE — it raises
|
|
524
|
+
# UNIQUE on an existing chunk_id instead of replacing. So re-embedding a chunk
|
|
525
|
+
# whose content changed but whose chunk_id (start line) is stable — the common
|
|
526
|
+
# case under syntax chunking, where a def's chunk_id is its def line — would
|
|
527
|
+
# silently fail and keep the STALE vector. DELETE-then-INSERT is the supported
|
|
528
|
+
# upsert for vec0. (chunk_hashes below is a normal table, where REPLACE works.)
|
|
529
|
+
conn.execute(
|
|
530
|
+
"DELETE FROM code_embeddings WHERE chunk_id = ?", (chunk_id,)
|
|
531
|
+
)
|
|
383
532
|
conn.execute(
|
|
384
|
-
"INSERT
|
|
385
|
-
" VALUES (?, ?)",
|
|
533
|
+
"INSERT INTO code_embeddings(chunk_id, embedding) VALUES (?, ?)",
|
|
386
534
|
(chunk_id, vec_bytes),
|
|
387
535
|
)
|
|
388
536
|
conn.execute(
|
|
@@ -157,7 +157,9 @@ class GraphProvider:
|
|
|
157
157
|
return {
|
|
158
158
|
"installed": False, "runnable": False, "repo_indexed": False, "project": None,
|
|
159
159
|
"detail": "codebase-memory-mcp not found on PATH",
|
|
160
|
-
"remediation": "
|
|
160
|
+
"remediation": "put the codebase-memory-mcp binary on PATH — it's an external "
|
|
161
|
+
"native backend (see docs/graph.md); once present it self-updates "
|
|
162
|
+
"via `codebase-memory-mcp update`",
|
|
161
163
|
}
|
|
162
164
|
raw = self._run("list_projects", {}, timeout_ms)
|
|
163
165
|
if raw is None:
|
|
@@ -144,7 +144,9 @@ class LspProvider:
|
|
|
144
144
|
return {
|
|
145
145
|
"installed": False, "runnable": False, "repo_indexed": None,
|
|
146
146
|
"detail": "neither `serena` nor `uvx` found on PATH",
|
|
147
|
-
"remediation": "install uv (provides uvx)
|
|
147
|
+
"remediation": "install uv (provides uvx): `codeintel setup --install-uv` "
|
|
148
|
+
"(or `brew install uv` / `pip install uv`) — serena is then "
|
|
149
|
+
"fetched on first use",
|
|
148
150
|
}
|
|
149
151
|
cmd = self._cmd
|
|
150
152
|
if not deep:
|
|
@@ -130,7 +130,10 @@ class SemanticProvider:
|
|
|
130
130
|
)
|
|
131
131
|
|
|
132
132
|
matches = searcher.search(
|
|
133
|
-
target, project_root,
|
|
133
|
+
target, project_root,
|
|
134
|
+
cosine_floor=float(cfg.get("cosine_floor", 0.25)),
|
|
135
|
+
rerank=str(cfg.get("rerank", "on")),
|
|
136
|
+
rerank_candidates=int(cfg.get("rerank_candidates", 30)),
|
|
134
137
|
)
|
|
135
138
|
if not matches:
|
|
136
139
|
return safe_null_result(op, target, engine="semantic", reason="below-floor")
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import struct
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from codeintel.semantic_db import SemanticDb
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_SNIPPET_LINES = 5
|
|
17
|
+
# Hybrid rerank (0.7.0): re-read up to this many lines per candidate for lexical scoring — a hard
|
|
18
|
+
# per-candidate cap so one huge file can't blow up a query. The read is further bounded at the next
|
|
19
|
+
# stored chunk start (see _rerank); the returned snippet still uses only the first _SNIPPET_LINES.
|
|
20
|
+
_RERANK_READ_LINES = 40
|
|
21
|
+
# Hard ceiling on the candidate set regardless of rerank_candidates, so a misconfigured value can't
|
|
22
|
+
# turn one query into thousands of file reads on the interactive hot path.
|
|
23
|
+
_RERANK_CANDIDATES_CAP = 200
|
|
24
|
+
_RRF_K = 60 # Reciprocal Rank Fusion constant (standard ≈ 60; damps rank differences)
|
|
25
|
+
_SYMBOL_BOOST = 0.1 # additive fusion bonus for an exact def/class-name match (≫ one RRF term)
|
|
26
|
+
|
|
27
|
+
# `\w` is Unicode-aware in Python 3, so a non-ASCII identifier is kept whole; the sub-splitter is
|
|
28
|
+
# Latin-cased on purpose (camelCase only exists there) and just adds bonus pieces for ASCII names.
|
|
29
|
+
_IDENT_RE = re.compile(r"\w+")
|
|
30
|
+
_SUBTOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+")
|
|
31
|
+
_SYMBOL_RE = re.compile(r"[^\W\d]\w*") # a query that is a single identifier (letter/_ then word)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pos_int(val: object, default: int) -> int:
|
|
35
|
+
"""A usable positive int or the default — guards the public Searcher.search against a caller
|
|
36
|
+
passing a non-int/zero/negative k or rerank_candidates (mirrors indexer._pos_int)."""
|
|
37
|
+
try:
|
|
38
|
+
n = int(val) # type: ignore[arg-type]
|
|
39
|
+
except (TypeError, ValueError, OverflowError):
|
|
40
|
+
return default
|
|
41
|
+
return n if n > 0 else default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _tokenize(text: str) -> set[str]:
|
|
45
|
+
"""Lexical tokens for overlap scoring: each whole identifier plus its camel/snake sub-pieces,
|
|
46
|
+
lowercased. Deliberately set-based (presence, not frequency) — cheap and enough for ranking."""
|
|
47
|
+
toks: set[str] = set()
|
|
48
|
+
for ident in _IDENT_RE.findall(text):
|
|
49
|
+
toks.add(ident.lower())
|
|
50
|
+
for sub in _SUBTOKEN_RE.findall(ident):
|
|
51
|
+
toks.add(sub.lower())
|
|
52
|
+
return toks
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Searcher:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
db: SemanticDb,
|
|
59
|
+
model_name: str = "BAAI/bge-small-en-v1.5",
|
|
60
|
+
) -> None:
|
|
61
|
+
self.db = db
|
|
62
|
+
self.model_name = model_name
|
|
63
|
+
self._embedder = None
|
|
64
|
+
|
|
65
|
+
def _get_embedder(self):
|
|
66
|
+
if self._embedder is None:
|
|
67
|
+
from fastembed import TextEmbedding
|
|
68
|
+
self._embedder = TextEmbedding(model_name=self.model_name)
|
|
69
|
+
return self._embedder
|
|
70
|
+
|
|
71
|
+
def _embed_query(self, query: str) -> bytes | None:
|
|
72
|
+
try:
|
|
73
|
+
embedder = self._get_embedder()
|
|
74
|
+
vecs = list(embedder.embed([query]))
|
|
75
|
+
if not vecs:
|
|
76
|
+
return None
|
|
77
|
+
vec = vecs[0]
|
|
78
|
+
return struct.pack(f"{len(vec)}f", *vec)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
logger.warning("query embedding failed: %s", exc)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _row_count(self, project_root_real: str) -> int:
|
|
84
|
+
try:
|
|
85
|
+
conn = self.db.conn()
|
|
86
|
+
row = conn.execute(
|
|
87
|
+
"SELECT COUNT(*) FROM chunk_hashes WHERE project_root = ?",
|
|
88
|
+
(project_root_real,),
|
|
89
|
+
).fetchone()
|
|
90
|
+
return row[0] if row else 0
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
logger.warning("rowcount check failed: %s", exc)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
def has_index(self, project_root: str) -> bool:
|
|
96
|
+
"""True when this project has at least one indexed chunk — lets the provider
|
|
97
|
+
distinguish 'nothing indexed yet' (no-index) from 'matches below floor'."""
|
|
98
|
+
return self._row_count(os.path.realpath(project_root)) > 0
|
|
99
|
+
|
|
100
|
+
def _read_snippet(self, file_path: Path, chunk_start: int) -> str:
|
|
101
|
+
try:
|
|
102
|
+
with open(file_path, encoding="utf-8", errors="replace") as f:
|
|
103
|
+
lines = f.readlines()
|
|
104
|
+
snippet_lines = lines[chunk_start: chunk_start + _SNIPPET_LINES]
|
|
105
|
+
return "".join(snippet_lines).rstrip()
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
return "[file not found]"
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.debug("snippet read failed for %s:%d: %s", file_path, chunk_start, exc)
|
|
110
|
+
return "[file not found]"
|
|
111
|
+
|
|
112
|
+
def _read_chunk(self, file_path: Path, chunk_start: int) -> list[str] | None:
|
|
113
|
+
"""Bounded re-read from ``chunk_start`` for rerank lexical scoring (and the snippet in one
|
|
114
|
+
read). Uses ``islice`` so a huge multi-line file is not fully materialised just to take a
|
|
115
|
+
40-line window. ``None`` on any failure — the caller scores that candidate 0 and shows a
|
|
116
|
+
not-found snippet, never crashing (a missing/edited file must degrade, per never-raise)."""
|
|
117
|
+
try:
|
|
118
|
+
with open(file_path, encoding="utf-8", errors="replace") as f:
|
|
119
|
+
return list(itertools.islice(f, chunk_start, chunk_start + _RERANK_READ_LINES))
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
logger.debug("chunk re-read failed for %s:%d: %s", file_path, chunk_start, exc)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _chunk_starts(self, project_root_real: str, file_path: str) -> list[int]:
|
|
125
|
+
"""All stored chunk start lines for one file, sorted. Used to bound each candidate's rerank
|
|
126
|
+
read at the *next* chunk — the chunk's true end for tiling syntax chunks, its owned region
|
|
127
|
+
for overlapping line windows — so lexical/boost text never bleeds into an unrelated def.
|
|
128
|
+
Uses the (project_root, file_path) composite index. ``[]`` on any error → no bound applied."""
|
|
129
|
+
try:
|
|
130
|
+
rows = self.db.conn().execute(
|
|
131
|
+
"SELECT chunk_start FROM chunk_hashes"
|
|
132
|
+
" WHERE project_root = ? AND file_path = ? ORDER BY chunk_start",
|
|
133
|
+
(project_root_real, file_path),
|
|
134
|
+
).fetchall()
|
|
135
|
+
return [int(r[0]) for r in rows]
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
logger.debug("chunk-start lookup failed for %s: %s", file_path, exc)
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _lexical_score(query_tokens: set[str], chunk_text: str) -> float:
|
|
142
|
+
"""Token overlap in [0, 1]: the fraction of query (sub)tokens present in the chunk."""
|
|
143
|
+
if not query_tokens:
|
|
144
|
+
return 0.0
|
|
145
|
+
return len(query_tokens & _tokenize(chunk_text)) / len(query_tokens)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _symbol_boost(query: str, chunk_text: str) -> float:
|
|
149
|
+
"""Additive fusion bonus for the 'exact symbol' case: when the query is a single identifier
|
|
150
|
+
that appears in the chunk as a ``def``/``class`` name (full boost) or as a standalone word
|
|
151
|
+
(half). Case-insensitive, to match the lexical score. Cosine alone under-ranks these literal
|
|
152
|
+
matches; this is what pulls them to the top. The ``_SYMBOL_RE`` guard runs before any
|
|
153
|
+
``re.escape`` interpolation, so no query character can ever reach the built pattern."""
|
|
154
|
+
q = (query or "").strip()
|
|
155
|
+
if not _SYMBOL_RE.fullmatch(q):
|
|
156
|
+
return 0.0 # multi-word / non-identifier query → no structural signal
|
|
157
|
+
if re.search(rf"\b(?:def|class)\s+{re.escape(q)}\b", chunk_text, re.IGNORECASE):
|
|
158
|
+
return _SYMBOL_BOOST
|
|
159
|
+
if re.search(rf"\b{re.escape(q)}\b", chunk_text, re.IGNORECASE):
|
|
160
|
+
return _SYMBOL_BOOST * 0.5
|
|
161
|
+
return 0.0
|
|
162
|
+
|
|
163
|
+
def _rerank(
|
|
164
|
+
self, query: str, root: Path, project_root_real: str, candidates: list[dict]
|
|
165
|
+
) -> list[dict]:
|
|
166
|
+
"""Reorder floor-gated candidates (given in cosine order) by Reciprocal Rank Fusion over
|
|
167
|
+
the semantic rank and a lexical rank, plus a symbol boost. Reads each candidate's chunk
|
|
168
|
+
once (bounded) — caching its 5-line snippet — so the whole rerank costs ≤ len(candidates)
|
|
169
|
+
reads. When no candidate has any lexical overlap the lexical rank mirrors the semantic
|
|
170
|
+
rank, so the cosine order is returned unchanged (rerank only *reorders* on real signal)."""
|
|
171
|
+
query_tokens = _tokenize(query)
|
|
172
|
+
n = len(candidates)
|
|
173
|
+
# Bound each candidate's lexical/boost text at the NEXT stored chunk start in its file — the
|
|
174
|
+
# chunk's true end for tiling syntax chunks, its owned region for overlapping line windows.
|
|
175
|
+
# A chunk's end line isn't stored, so a fixed 40-line read would otherwise (a) bleed into an
|
|
176
|
+
# unrelated later def, handing this chunk a symbol boost that isn't its own, and/or (b) — if
|
|
177
|
+
# we capped at neighbouring *candidates* — truncate a chunk below its own overlapping span.
|
|
178
|
+
# Bounding at the next real chunk gives each source line to exactly one chunk: the one whose
|
|
179
|
+
# 5-line snippet will actually show it.
|
|
180
|
+
starts_by_file: dict[str, list[int]] = {}
|
|
181
|
+
for path in {c["path"] for c in candidates}:
|
|
182
|
+
starts_by_file[path] = self._chunk_starts(project_root_real, path)
|
|
183
|
+
|
|
184
|
+
lex = [0.0] * n
|
|
185
|
+
boost = [0.0] * n
|
|
186
|
+
for i, c in enumerate(candidates):
|
|
187
|
+
lines = self._read_chunk(root / c["path"], c["line"])
|
|
188
|
+
if lines is None:
|
|
189
|
+
c["snippet"] = "[file not found]"
|
|
190
|
+
text = ""
|
|
191
|
+
else:
|
|
192
|
+
# snippet keeps the existing 5-line preview (bleed-tolerant, unchanged); the
|
|
193
|
+
# lexical/boost text is capped at the next stored chunk start in this file.
|
|
194
|
+
c["snippet"] = "".join(lines[:_SNIPPET_LINES]).rstrip()
|
|
195
|
+
nxt = next((s for s in starts_by_file.get(c["path"], ()) if s > c["line"]), None)
|
|
196
|
+
text = "".join(lines if nxt is None else lines[: max(1, nxt - c["line"])])
|
|
197
|
+
lex[i] = self._lexical_score(query_tokens, text)
|
|
198
|
+
boost[i] = self._symbol_boost(query, text)
|
|
199
|
+
|
|
200
|
+
# lexical rank: highest lexical score first, ties broken by the semantic rank (index i)
|
|
201
|
+
order_by_lex = sorted(range(n), key=lambda i: (-lex[i], i))
|
|
202
|
+
rank_lex = [0] * n
|
|
203
|
+
for pos, i in enumerate(order_by_lex):
|
|
204
|
+
rank_lex[i] = pos
|
|
205
|
+
|
|
206
|
+
# sem rank is the candidate's position i (they arrive in cosine order)
|
|
207
|
+
fused = [1.0 / (_RRF_K + i) + 1.0 / (_RRF_K + rank_lex[i]) + boost[i] for i in range(n)]
|
|
208
|
+
order = sorted(range(n), key=lambda i: (-fused[i], i)) # fused desc, tie → better cosine
|
|
209
|
+
return [candidates[i] for i in order]
|
|
210
|
+
|
|
211
|
+
def search(
|
|
212
|
+
self,
|
|
213
|
+
query: str,
|
|
214
|
+
project_root: str,
|
|
215
|
+
k: int = 10,
|
|
216
|
+
cosine_floor: float = 0.25,
|
|
217
|
+
rerank: str = "on",
|
|
218
|
+
rerank_candidates: int = 30,
|
|
219
|
+
) -> list[dict]:
|
|
220
|
+
if not query or not query.strip():
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
# Guard the public API: a direct caller passing a non-int/zero/negative k or
|
|
224
|
+
# rerank_candidates must degrade, not raise (never-raise). Config callers already pre-cast.
|
|
225
|
+
k = _pos_int(k, 10)
|
|
226
|
+
rerank_candidates = _pos_int(rerank_candidates, 30)
|
|
227
|
+
# Accept the documented "off" plus the obvious falsy spellings (incl. the Python bool
|
|
228
|
+
# ``False`` → ``"false"``), so a direct caller isn't silently left with rerank on.
|
|
229
|
+
do_rerank = str(rerank).strip().lower() not in ("off", "false", "0", "no", "none")
|
|
230
|
+
# With rerank on, retrieve a wider candidate set by cosine, then re-order it; still return
|
|
231
|
+
# the top-k. Cap only the *extra* rerank breadth (rerank_candidates) — the DoS guard — while
|
|
232
|
+
# always honoring k, so enabling rerank never returns fewer results than the pure-cosine
|
|
233
|
+
# path would for the same k (even a large k > cap).
|
|
234
|
+
candidate_limit = max(k, min(rerank_candidates, _RERANK_CANDIDATES_CAP)) if do_rerank else k
|
|
235
|
+
|
|
236
|
+
project_root_real = os.path.realpath(project_root)
|
|
237
|
+
|
|
238
|
+
# Scope the KNN to THIS project — a search in repo B must never surface repo A's
|
|
239
|
+
# chunks (wrong-file, wrong-content hits) from the shared cache.
|
|
240
|
+
if self._row_count(project_root_real) == 0:
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
query_vec = self._embed_query(query)
|
|
244
|
+
if query_vec is None:
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
conn = self.db.conn()
|
|
249
|
+
rows = conn.execute(
|
|
250
|
+
"""
|
|
251
|
+
SELECT
|
|
252
|
+
ce.chunk_id,
|
|
253
|
+
ch.chunk_start,
|
|
254
|
+
ch.file_path,
|
|
255
|
+
vec_distance_cosine(ce.embedding, ?) AS dist
|
|
256
|
+
FROM code_embeddings ce
|
|
257
|
+
JOIN chunk_hashes ch ON ce.chunk_id = ch.chunk_id
|
|
258
|
+
WHERE ch.project_root = ?
|
|
259
|
+
ORDER BY dist
|
|
260
|
+
LIMIT ?
|
|
261
|
+
""",
|
|
262
|
+
(query_vec, project_root_real, candidate_limit),
|
|
263
|
+
).fetchall()
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
logger.warning("KNN query failed: %s", exc)
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
root = Path(project_root)
|
|
269
|
+
|
|
270
|
+
# Floor-gated candidate set, in cosine order. The cosine_floor stays on the *semantic*
|
|
271
|
+
# candidates (not the fused score), so rerank can only re-order what pure cosine already
|
|
272
|
+
# judged good enough — quality can't regress below the pre-0.7 path.
|
|
273
|
+
candidates: list[dict] = []
|
|
274
|
+
for row in rows:
|
|
275
|
+
try:
|
|
276
|
+
score = 1.0 - float(row["dist"])
|
|
277
|
+
if score < cosine_floor:
|
|
278
|
+
continue
|
|
279
|
+
candidates.append({
|
|
280
|
+
"path": str(row["file_path"]),
|
|
281
|
+
"line": int(row["chunk_start"]),
|
|
282
|
+
"score": round(score, 6),
|
|
283
|
+
})
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
logger.debug("candidate row processing failed: %s", exc)
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
if not candidates:
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
# Rerank re-reads chunk text; if anything goes wrong, fall back to the cosine order so a
|
|
292
|
+
# rerank fault can never do worse than today (and never raises).
|
|
293
|
+
if do_rerank and len(candidates) > 1:
|
|
294
|
+
try:
|
|
295
|
+
candidates = self._rerank(query, root, project_root_real, candidates)
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
logger.warning("rerank failed, using cosine order: %s", exc)
|
|
298
|
+
|
|
299
|
+
results: list[dict] = []
|
|
300
|
+
for c in candidates[:k]:
|
|
301
|
+
# _rerank caches the snippet on each candidate it read; fill it in otherwise.
|
|
302
|
+
snippet = c.get("snippet")
|
|
303
|
+
if snippet is None:
|
|
304
|
+
snippet = self._read_snippet(root / c["path"], c["line"])
|
|
305
|
+
results.append({
|
|
306
|
+
"path": c["path"],
|
|
307
|
+
"line": c["line"],
|
|
308
|
+
"snippet": snippet,
|
|
309
|
+
"score": c["score"],
|
|
310
|
+
})
|
|
311
|
+
return results
|