codecortex 0.8.1__tar.gz → 0.8.3__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 (69) hide show
  1. {codecortex-0.8.1/src/codecortex.egg-info → codecortex-0.8.3}/PKG-INFO +1 -1
  2. {codecortex-0.8.1 → codecortex-0.8.3/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/SOURCES.txt +2 -0
  4. codecortex-0.8.3/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/__main__.py +27 -22
  6. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/indexer.py +5 -0
  7. codecortex-0.8.3/src/codeintel/installer.py +120 -0
  8. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/onboarding.py +1 -1
  9. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/graph.py +8 -0
  10. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/semantic.py +16 -10
  11. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/reindexer.py +3 -3
  12. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/reset.py +37 -9
  13. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/semantic_db.py +84 -14
  14. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/server.py +9 -1
  15. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_doctor.py +3 -3
  16. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_e2e.py +1 -2
  17. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_graph_provider.py +13 -0
  18. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_hardening.py +1 -1
  19. codecortex-0.8.3/tests/test_installer.py +97 -0
  20. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_integration.py +2 -2
  21. codecortex-0.8.3/tests/test_model_dimension.py +148 -0
  22. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_onboarding.py +1 -1
  23. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_semantic_provider.py +4 -4
  24. codecortex-0.8.1/src/codeintel/__init__.py +0 -1
  25. codecortex-0.8.1/src/codeintel/installer.py +0 -113
  26. {codecortex-0.8.1 → codecortex-0.8.3}/LICENSE +0 -0
  27. {codecortex-0.8.1 → codecortex-0.8.3}/README.md +0 -0
  28. {codecortex-0.8.1 → codecortex-0.8.3}/pyproject.toml +0 -0
  29. {codecortex-0.8.1 → codecortex-0.8.3}/setup.cfg +0 -0
  30. {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/dependency_links.txt +0 -0
  31. {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/entry_points.txt +0 -0
  32. {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/requires.txt +0 -0
  33. {codecortex-0.8.1 → codecortex-0.8.3}/src/codecortex.egg-info/top_level.txt +0 -0
  34. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/auth.py +0 -0
  35. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/cache.py +0 -0
  36. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/config.py +0 -0
  37. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/doctor.py +0 -0
  38. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/gateway.py +0 -0
  39. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/http_server.py +0 -0
  40. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/injector.py +0 -0
  41. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/logconfig.py +0 -0
  42. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/mapper.py +0 -0
  43. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/metrics.py +0 -0
  44. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/policy.py +0 -0
  45. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/provider.py +0 -0
  46. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/__init__.py +0 -0
  47. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/lsp.py +0 -0
  48. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/providers/none.py +0 -0
  49. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/searcher.py +0 -0
  50. {codecortex-0.8.1 → codecortex-0.8.3}/src/codeintel/term.py +0 -0
  51. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_cache.py +0 -0
  52. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_chunking.py +0 -0
  53. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_config.py +0 -0
  54. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_enterprise.py +0 -0
  55. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_gateway.py +0 -0
  56. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_graph_real.py +0 -0
  57. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_graph_stdin.py +0 -0
  58. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_http_auth.py +0 -0
  59. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_http_server.py +0 -0
  60. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_lsp_provider.py +0 -0
  61. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_lsp_real.py +0 -0
  62. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_mapper.py +0 -0
  63. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_never_raise.py +0 -0
  64. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_rbac.py +0 -0
  65. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_reindexer.py +0 -0
  66. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_rerank.py +0 -0
  67. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_reset.py +0 -0
  68. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_term.py +0 -0
  69. {codecortex-0.8.1 → codecortex-0.8.3}/tests/test_treesitter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.8.1
3
+ Version: 0.8.3
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.8.1
3
+ Version: 0.8.3
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
@@ -48,10 +48,12 @@ tests/test_graph_stdin.py
48
48
  tests/test_hardening.py
49
49
  tests/test_http_auth.py
50
50
  tests/test_http_server.py
51
+ tests/test_installer.py
51
52
  tests/test_integration.py
52
53
  tests/test_lsp_provider.py
53
54
  tests/test_lsp_real.py
54
55
  tests/test_mapper.py
56
+ tests/test_model_dimension.py
55
57
  tests/test_never_raise.py
56
58
  tests/test_onboarding.py
57
59
  tests/test_rbac.py
@@ -0,0 +1 @@
1
+ __version__ = "0.8.3"
@@ -110,28 +110,32 @@ def main() -> None:
110
110
  from codeintel.semantic_db import SemanticDb, default_db_path
111
111
 
112
112
  project_root = args.project_root or os.getcwd()
113
- cfg = load_config(project_root)
114
-
115
- db_path = default_db_path()
116
- os.makedirs(os.path.dirname(db_path), exist_ok=True)
117
- db = SemanticDb(db_path)
113
+ # Wrap the whole semantic pass so a setup failure (e.g. an unresolvable home dir →
114
+ # Path.home() raising) degrades with a message, like every other subcommand, not a traceback.
118
115
  try:
119
- db.init()
120
- count = Indexer(
121
- db,
122
- model_name=str(cfg.get("model") or "BAAI/bge-small-en-v1.5"),
123
- window=int(cfg.get("window", 20)),
124
- stride=int(cfg.get("stride", 10)),
125
- max_chunks=int(cfg.get("max_chunks", 500)),
126
- max_total_chunks=int(cfg.get("max_total_chunks", 100000)),
127
- chunk_strategy=str(cfg.get("chunk_strategy", "syntax")),
128
- ).index(project_root)
129
- if count > 0:
130
- print(f"Indexed {count} chunks")
131
- else:
132
- print("Nothing new to index")
133
- finally:
134
- db.close()
116
+ cfg = load_config(project_root)
117
+ db_path = default_db_path(str(cfg.get("model") or ""))
118
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
119
+ db = SemanticDb(db_path)
120
+ try:
121
+ db.init()
122
+ count = Indexer(
123
+ db,
124
+ model_name=str(cfg.get("model") or "BAAI/bge-small-en-v1.5"),
125
+ window=int(cfg.get("window", 20)),
126
+ stride=int(cfg.get("stride", 10)),
127
+ max_chunks=int(cfg.get("max_chunks", 500)),
128
+ max_total_chunks=int(cfg.get("max_total_chunks", 100000)),
129
+ chunk_strategy=str(cfg.get("chunk_strategy", "syntax")),
130
+ ).index(project_root)
131
+ if count > 0:
132
+ print(f"Indexed {count} chunks")
133
+ else:
134
+ print("Nothing new to index")
135
+ finally:
136
+ db.close()
137
+ except Exception as exc:
138
+ print(f"index failed: {exc}")
135
139
 
136
140
  # best-effort graph reindex
137
141
  import shutil
@@ -242,8 +246,9 @@ def main() -> None:
242
246
  state = "available" if available else "unavailable"
243
247
  print(f" {engine:<10} {state}")
244
248
 
249
+ from codeintel.config import load_config
245
250
  from codeintel.semantic_db import default_db_path
246
- db_path = default_db_path()
251
+ db_path = default_db_path(str(load_config(project_root).get("model") or ""))
247
252
  if os.path.exists(db_path):
248
253
  import datetime
249
254
  mtime = os.path.getmtime(db_path)
@@ -519,6 +519,11 @@ class Indexer:
519
519
  break
520
520
  try:
521
521
  vec = embeddings[j]
522
+ # Create code_embeddings lazily, sized to this vector (self-dimensioning). A
523
+ # returned dim != len(vec) means the file already holds a different dimension
524
+ # (only reachable on a DEFAULT_MODEL size bump) — skip rather than corrupt/mix.
525
+ if self.db.ensure_embeddings_table(len(vec)) != len(vec):
526
+ continue
522
527
  vec_bytes = struct.pack(f"{len(vec)}f", *vec)
523
528
  # sqlite-vec's vec0 virtual table does NOT honor INSERT OR REPLACE — it raises
524
529
  # UNIQUE on an existing chunk_id instead of replacing. So re-embedding a chunk
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import pathlib
6
+
7
+ _AGENTS = ["claude", "codex", "gemini", "zed"]
8
+
9
+
10
+ def _atomic_write_text(path: pathlib.Path, text: str) -> None:
11
+ """Write via a sibling temp file + os.replace so an interrupted write can never truncate
12
+ the user's existing agent config (which holds unrelated settings) to a partial/empty file.
13
+ The temp lives in the same directory as ``path`` so the replace stays on one filesystem."""
14
+ tmp = path.with_name(path.name + ".codeintel.tmp")
15
+ tmp.write_text(text, encoding="utf-8")
16
+ os.replace(tmp, path)
17
+
18
+
19
+ # Per-agent registration recipe. Most agents take a Claude-style JSON block; Codex is different —
20
+ # its CLI reads MCP servers from ``~/.codex/config.toml`` as ``[mcp_servers.<name>]`` TOML tables,
21
+ # NOT a JSON ``mcpServers`` map in config.json (writing the latter registers nothing).
22
+ _CONFIG: dict[str, dict] = {
23
+ "claude": {
24
+ "format": "json",
25
+ "path": "~/.claude/settings.json",
26
+ "key": ["mcpServers", "codeintel"],
27
+ "value": {"command": "codeintel", "args": ["serve"]},
28
+ },
29
+ "codex": {
30
+ "format": "toml-mcp",
31
+ "path": "~/.codex/config.toml",
32
+ "table": "[mcp_servers.codeintel]",
33
+ "block": '[mcp_servers.codeintel]\ncommand = "codeintel"\nargs = ["serve"]\n',
34
+ },
35
+ "gemini": {
36
+ "format": "json",
37
+ "path": "~/.gemini/settings.json",
38
+ "key": ["mcpServers", "codeintel"],
39
+ "value": {"command": "codeintel", "args": ["serve"]},
40
+ },
41
+ "zed": {
42
+ "format": "json",
43
+ "path": "~/.config/zed/settings.json",
44
+ "key": ["context_servers", "codeintel"],
45
+ "value": {"command": {"path": "codeintel", "args": ["serve"]}},
46
+ },
47
+ }
48
+
49
+
50
+ def _get_nested(data: dict, keys: list[str]):
51
+ node = data
52
+ for k in keys:
53
+ if not isinstance(node, dict) or k not in node:
54
+ return None
55
+ node = node[k]
56
+ return node
57
+
58
+
59
+ def _set_nested(data: dict, keys: list[str], value) -> None:
60
+ node = data
61
+ for k in keys[:-1]:
62
+ if k not in node or not isinstance(node[k], dict):
63
+ node[k] = {}
64
+ node = node[k]
65
+ node[keys[-1]] = value
66
+
67
+
68
+ class Installer:
69
+ def register(self, agent: str) -> dict:
70
+ spec = _CONFIG.get(agent)
71
+ if spec is None:
72
+ return self._result(agent, "", False, "failed", f"unknown agent '{agent}'")
73
+ config_path = pathlib.Path(spec["path"]).expanduser()
74
+ try:
75
+ if spec.get("format") == "toml-mcp":
76
+ return self._register_toml(agent, config_path, spec)
77
+ return self._register_json(agent, config_path, spec)
78
+ except Exception as exc:
79
+ return self._result(agent, str(config_path), False, "failed", str(exc))
80
+
81
+ def _register_json(self, agent: str, config_path: pathlib.Path, spec: dict) -> dict:
82
+ if config_path.exists():
83
+ data = json.loads(config_path.read_text(encoding="utf-8"))
84
+ if not isinstance(data, dict):
85
+ data = {}
86
+ else:
87
+ data = {}
88
+
89
+ if _get_nested(data, spec["key"]) == spec["value"]:
90
+ return self._result(agent, str(config_path), True, "already")
91
+
92
+ config_path.parent.mkdir(parents=True, exist_ok=True)
93
+ _set_nested(data, spec["key"], spec["value"])
94
+ _atomic_write_text(config_path, json.dumps(data, indent=2))
95
+ return self._result(agent, str(config_path), True, "registered")
96
+
97
+ def _register_toml(self, agent: str, config_path: pathlib.Path, spec: dict) -> dict:
98
+ """Codex: append an ``[mcp_servers.codeintel]`` table to config.toml, preserving everything
99
+ already there (other servers, project trust levels, hooks). Text-based on purpose — it must
100
+ not reformat or risk corrupting a config the user hand-edits, and it stays idempotent by
101
+ checking for the table header. If a codeintel entry already exists it is left untouched."""
102
+ existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
103
+ if spec["table"] in existing:
104
+ return self._result(agent, str(config_path), True, "already")
105
+
106
+ config_path.parent.mkdir(parents=True, exist_ok=True)
107
+ if existing == "":
108
+ new_text = spec["block"]
109
+ else:
110
+ prefix = existing if existing.endswith("\n") else existing + "\n"
111
+ new_text = prefix + "\n" + spec["block"] # blank line before the new table
112
+ _atomic_write_text(config_path, new_text)
113
+ return self._result(agent, str(config_path), True, "registered")
114
+
115
+ @staticmethod
116
+ def _result(agent: str, path: str, ok: bool, action: str, reason: str = "") -> dict:
117
+ return {"agent": agent, "path": path, "ok": ok, "action": action, "reason": reason}
118
+
119
+ def register_all(self) -> list[dict]:
120
+ return [self.register(agent) for agent in _AGENTS]
@@ -63,7 +63,7 @@ def _bounded_index(project_root: str, *, timeout_s: float, out) -> dict:
63
63
  def _work() -> None:
64
64
  try:
65
65
  cfg = load_config(project_root)
66
- db_path = default_db_path()
66
+ db_path = default_db_path(str(cfg.get("model") or ""))
67
67
  os.makedirs(os.path.dirname(db_path), exist_ok=True)
68
68
  db = SemanticDb(db_path)
69
69
  try:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  import shutil
5
6
  import subprocess
6
7
  import threading
@@ -107,6 +108,13 @@ class GraphProvider:
107
108
  older/mocked shape — accept both. Prefer an exact ``root_path`` match; otherwise the
108
109
  LONGEST prefix match (so ``.../project/codeintel`` resolves to codeintel, not its
109
110
  parent ``.../project``). Pure + static so ``_resolve_project`` and ``probe`` share it."""
111
+ # Normalize the input to an absolute realpath: the backend stores absolute root_paths, so a
112
+ # relative ``project_root`` (e.g. `codeintel map .` passing ".") would otherwise never match
113
+ # — the bug where the map/query silently reported "not indexed" from inside the repo.
114
+ try:
115
+ project_root = os.path.realpath(project_root)
116
+ except Exception:
117
+ pass
110
118
  entries = raw.get("projects", []) if isinstance(raw, dict) else raw
111
119
  if not isinstance(entries, list):
112
120
  return None
@@ -12,8 +12,6 @@ try:
12
12
  except ImportError:
13
13
  _DEPS_OK = False
14
14
 
15
- _DB_PATH = pathlib.Path.home() / ".codeintel" / "semantic.db"
16
-
17
15
 
18
16
  class SemanticProvider:
19
17
  """Real semantic search provider backed by SemanticDb and Searcher."""
@@ -23,9 +21,10 @@ class SemanticProvider:
23
21
  return _DEPS_OK
24
22
 
25
23
  def probe(self, project_root: str) -> dict:
26
- """Never-raise health check for the doctor. READ-ONLY and MODEL-FREE: it opens the db
27
- read-only and counts this repo's chunks — it must NOT call SemanticDb.init() (a schema
28
- write) or load fastembed. ``repo_indexed`` is project-scoped (mirrors Searcher.has_index)."""
24
+ """Never-raise health check for the doctor. READ-ONLY: it opens the db read-only and counts
25
+ this repo's chunks — it must NOT call SemanticDb.init() (a schema write) or LOAD fastembed.
26
+ It does resolve the project's ``model`` *name* (a cheap config read, no model load) to pick
27
+ the per-model cache file. ``repo_indexed`` is project-scoped (mirrors Searcher.has_index)."""
29
28
  if not self.available:
30
29
  return {
31
30
  "installed": False, "runnable": False, "repo_indexed": False,
@@ -36,8 +35,10 @@ class SemanticProvider:
36
35
  import sqlite3
37
36
 
38
37
  try:
38
+ from codeintel.config import load_config
39
39
  from codeintel.semantic_db import default_db_path
40
- db_path = default_db_path()
40
+ model = str(load_config(project_root).get("model") or "")
41
+ db_path = default_db_path(model)
41
42
  except Exception:
42
43
  db_path = ""
43
44
  if not db_path or not os.path.exists(db_path):
@@ -59,8 +60,8 @@ class SemanticProvider:
59
60
  except Exception as exc:
60
61
  return {
61
62
  "installed": True, "runnable": False, "repo_indexed": False,
62
- "detail": f"semantic.db present but unreadable ({type(exc).__name__})",
63
- "remediation": "rm ~/.codeintel/semantic.db && codeintel index <root>",
63
+ "detail": f"semantic cache present but unreadable ({type(exc).__name__})",
64
+ "remediation": f"codeintel reset {project_root} && codeintel index {project_root}",
64
65
  }
65
66
  if count > 0:
66
67
  return {
@@ -95,11 +96,16 @@ class SemanticProvider:
95
96
  from codeintel.indexer import Indexer
96
97
  from codeintel.searcher import Searcher
97
98
 
99
+ from codeintel.semantic_db import default_db_path
100
+
98
101
  cfg = load_config(project_root)
99
102
  model = str(cfg.get("model") or "BAAI/bge-small-en-v1.5")
100
103
 
101
- _DB_PATH.parent.mkdir(parents=True, exist_ok=True)
102
- db = SemanticDb(str(_DB_PATH))
104
+ # Per-model cache file: index and search for this repo use the SAME model → same file,
105
+ # so a repo configured with a different model can never corrupt or wipe another's rows.
106
+ db_path = default_db_path(model)
107
+ pathlib.Path(db_path).parent.mkdir(parents=True, exist_ok=True)
108
+ db = SemanticDb(db_path)
103
109
  db.init()
104
110
 
105
111
  searcher = Searcher(db, model_name=model)
@@ -102,11 +102,11 @@ class Reindexer:
102
102
  from codeintel.semantic_db import SemanticDb, default_db_path
103
103
  from codeintel.indexer import Indexer
104
104
 
105
- # Same per-machine cache the SemanticProvider reads — index and search must never diverge
105
+ # Same per-model cache file the SemanticProvider reads — index and search must never diverge
106
106
  # onto different files. Honor the project's config so the background pass indexes exactly
107
- # like the inline and CLI paths (same model, window/stride, and chunk ceilings).
107
+ # like the inline and CLI paths (same model → same file, plus window/stride, ceilings).
108
108
  cfg = load_config(project_root)
109
- db_path = default_db_path()
109
+ db_path = default_db_path(str(cfg.get("model") or ""))
110
110
  pathlib.Path(db_path).parent.mkdir(parents=True, exist_ok=True)
111
111
  db = SemanticDb(db_path)
112
112
  try:
@@ -5,6 +5,7 @@ module is pure (dry-run by default via ``apply=False``).
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import glob
8
9
  import os
9
10
  import sqlite3
10
11
 
@@ -13,6 +14,16 @@ import sqlite_vec
13
14
  from codeintel.semantic_db import default_db_path
14
15
 
15
16
 
17
+ def _cache_files() -> list[str]:
18
+ """Every per-model cache file (``semantic.db`` + ``semantic-<hash>.db``). Reset must sweep all
19
+ of them: a repo's rows can live in any model's file, and a model switch leaves orphans behind."""
20
+ base = os.path.dirname(default_db_path())
21
+ try:
22
+ return sorted(glob.glob(os.path.join(base, "semantic*.db")))
23
+ except Exception:
24
+ return []
25
+
26
+
16
27
  def _reset_scoped(project_root: str, path: str, apply: bool) -> dict:
17
28
  real = os.path.realpath(str(project_root))
18
29
  if not os.path.exists(path):
@@ -37,9 +48,12 @@ def _reset_scoped(project_root: str, path: str, apply: bool) -> dict:
37
48
  count = int(row[0]) if row else 0
38
49
 
39
50
  if apply:
40
- conn.execute(
41
- "DELETE FROM code_embeddings WHERE chunk_id IN "
42
- "(SELECT chunk_id FROM chunk_hashes WHERE project_root=?)", (real,))
51
+ try:
52
+ conn.execute(
53
+ "DELETE FROM code_embeddings WHERE chunk_id IN "
54
+ "(SELECT chunk_id FROM chunk_hashes WHERE project_root=?)", (real,))
55
+ except Exception:
56
+ pass # code_embeddings is created lazily at first embed — may not exist yet
43
57
  conn.execute("DELETE FROM chunk_hashes WHERE project_root=?", (real,))
44
58
  conn.commit()
45
59
 
@@ -88,13 +102,27 @@ def run_reset(
88
102
  apply: bool = False,
89
103
  db_path: str | None = None,
90
104
  ) -> dict:
91
- """Drop indexed rows for ``project_root`` (or, with ``all_projects``, remove the whole
92
- cache db file plus its -wal/-shm siblings). ``apply=False`` is a dry-run: count only,
93
- delete nothing. Never raises."""
105
+ """Drop indexed rows for ``project_root`` (or, with ``all_projects``, remove the whole cache —
106
+ every per-model db file plus their -wal/-shm siblings). ``apply=False`` is a dry-run: count
107
+ only, delete nothing. Never raises.
108
+
109
+ An explicit ``db_path`` operates on exactly that one file (the test seam / a targeted reset);
110
+ otherwise reset sweeps EVERY per-model cache file, so a repo's rows are cleared no matter which
111
+ model's file they landed in, and model-switch orphans are reclaimed."""
94
112
  try:
95
- path = db_path if db_path is not None else default_db_path()
113
+ if db_path is not None:
114
+ return _reset_all(db_path, apply) if all_projects else _reset_scoped(project_root, db_path, apply)
115
+
116
+ files = _cache_files() or [default_db_path()]
96
117
  if all_projects:
97
- return _reset_all(path, apply)
98
- return _reset_scoped(project_root, path, apply)
118
+ count = sum(_reset_all(p, apply)["count"] for p in files)
119
+ verb = "removed" if apply else "would remove"
120
+ return {"ok": True, "mode": "all", "target": "ALL", "count": count,
121
+ "applied": bool(apply), "detail": f"{verb} {count} index file(s) across all models"}
122
+ real = os.path.realpath(str(project_root))
123
+ count = sum(_reset_scoped(project_root, p, apply)["count"] for p in files)
124
+ verb = "removed" if apply else "found"
125
+ return {"ok": True, "mode": "scoped", "target": real, "count": count, "applied": bool(apply),
126
+ "detail": f"{verb} {count} indexed chunk(s) for this project"}
99
127
  except Exception as exc:
100
128
  return {"ok": True, "applied": apply, "detail": f"reset-error: {type(exc).__name__}: {exc}"}
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import logging
4
5
  import pathlib
6
+ import re
5
7
  import sqlite3
6
8
 
7
9
  import sqlite_vec
@@ -11,23 +13,45 @@ logger = logging.getLogger(__name__)
11
13
  DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
12
14
 
13
15
 
14
- def default_db_path() -> str:
15
- """The single, per-machine semantic index cache. Every entry point (the
16
- SemanticProvider, the Reindexer, and the CLI) MUST resolve to this one path — rows
17
- are partitioned by ``project_root`` inside it — so ``index`` and ``search`` can never
18
- diverge onto different files for the same repo.
19
- """
20
- return str(pathlib.Path.home() / ".codeintel" / "semantic.db")
16
+ def _base_dir() -> pathlib.Path:
17
+ """The per-machine cache directory. A single seam so tests can redirect every model's db
18
+ file at once (patch this, not each computed path)."""
19
+ return pathlib.Path.home() / ".codeintel"
20
+
21
+
22
+ def _model_slug(model: str) -> str:
23
+ # errors="replace" keeps this total (default_db_path promises it) even for a pathological
24
+ # model string with unpaired surrogates — unreachable via config, but the docstring says total.
25
+ return hashlib.sha256(model.strip().encode("utf-8", "replace")).hexdigest()[:12]
26
+
27
+
28
+ def default_db_path(model: str | None = None) -> str:
29
+ """The per-machine semantic cache file for a given embedding ``model``. A sqlite-vec vec0 table
30
+ is single-dimension and different models' vectors are incompatible, so each model gets its OWN
31
+ file — different-model repos then coexist as separate files and can never corrupt or wipe each
32
+ other. The default model (and ``None``) map to the legacy ``semantic.db`` (zero migration); any
33
+ other model maps to ``semantic-<hash(model)>.db``.
34
+
35
+ Index and search for one repo MUST pass the same model → same file. Rows are still partitioned
36
+ by ``project_root`` WITHIN a shared-model file. Pure + total: any string yields a filename."""
37
+ base = _base_dir()
38
+ m = (model or "").strip()
39
+ if not m or m == DEFAULT_MODEL:
40
+ return str(base / "semantic.db")
41
+ return str(base / f"semantic-{_model_slug(m)}.db")
21
42
 
22
43
 
23
44
  class SemanticDb:
24
45
  """DB layer: opens a SQLite connection, loads sqlite-vec, and owns schema creation."""
25
46
 
26
- dimension: int = 384
47
+ _DIM_RE = re.compile(r"float\s*\[\s*(\d+)\s*\]", re.IGNORECASE)
27
48
 
28
49
  def __init__(self, db_path: str) -> None:
29
50
  self.db_path = db_path
30
51
  self._conn: sqlite3.Connection | None = None
52
+ # The vec0 embedding dimension, discovered lazily from the table / the first real vector
53
+ # (see ensure_embeddings_table) rather than hardcoded — so any model's size just works.
54
+ self.dimension: int | None = None
31
55
 
32
56
  def conn(self) -> sqlite3.Connection:
33
57
  if self._conn is None:
@@ -68,12 +92,10 @@ class SemanticDb:
68
92
  except Exception:
69
93
  pass
70
94
 
71
- c.executescript(f"""
72
- CREATE VIRTUAL TABLE IF NOT EXISTS code_embeddings USING vec0(
73
- chunk_id TEXT PRIMARY KEY,
74
- embedding FLOAT[{self.dimension}]
75
- );
76
-
95
+ # chunk_hashes + indexes are created now; code_embeddings is created LAZILY at the first
96
+ # write, sized to the embedding model's real vector length (ensure_embeddings_table) — a
97
+ # vec0 table is single-dimension, so it can't be created before the model's size is known.
98
+ c.executescript("""
77
99
  CREATE TABLE IF NOT EXISTS chunk_hashes (
78
100
  chunk_id TEXT PRIMARY KEY,
79
101
  project_root TEXT NOT NULL,
@@ -94,6 +116,54 @@ class SemanticDb:
94
116
  """)
95
117
  c.commit()
96
118
 
119
+ def _table_dim(self) -> int | None:
120
+ """The existing code_embeddings vec0 dimension from the live schema (``FLOAT[N]``), or None
121
+ if the table is absent / unparseable."""
122
+ try:
123
+ row = self.conn().execute(
124
+ "SELECT sql FROM sqlite_master WHERE name = 'code_embeddings'"
125
+ ).fetchone()
126
+ if not row or not row[0]:
127
+ return None
128
+ m = self._DIM_RE.search(str(row[0]))
129
+ return int(m.group(1)) if m else None
130
+ except Exception:
131
+ return None
132
+
133
+ def ensure_embeddings_table(self, dim: int) -> int | None:
134
+ """Ensure ``code_embeddings`` exists sized to ``dim`` (the embedding's true length). Returns
135
+ the table dimension (== dim) on success, or ``None`` when it already exists at a DIFFERENT
136
+ dimension — the caller then skips the write, never mixing dimensions and never wiping data.
137
+ The table self-dimensions from the real vector, so any model (incl. future/unknown ones)
138
+ just works. Never raises.
139
+
140
+ A dimension mismatch is only reachable on the default-model file when a release bumps
141
+ ``DEFAULT_MODEL`` to a new-sized model (a non-default file is keyed by model, so its dim is
142
+ fixed); that release directs the user to ``codeintel reset`` once. Non-destructive here."""
143
+ try:
144
+ dim = int(dim)
145
+ if self.dimension is None:
146
+ self.dimension = self._table_dim()
147
+ if self.dimension == dim:
148
+ return dim
149
+ if self.dimension is not None:
150
+ logger.warning(
151
+ "embedding dimension %d != cache dimension %d — skipping write; run "
152
+ "`codeintel reset` to rebuild the semantic index for the new model",
153
+ dim, self.dimension,
154
+ )
155
+ return None
156
+ self.conn().execute(
157
+ "CREATE VIRTUAL TABLE IF NOT EXISTS code_embeddings USING vec0("
158
+ f"chunk_id TEXT PRIMARY KEY, embedding FLOAT[{dim}])"
159
+ )
160
+ self.conn().commit()
161
+ self.dimension = dim
162
+ return dim
163
+ except Exception as exc:
164
+ logger.warning("ensure_embeddings_table failed: %s", exc)
165
+ return None
166
+
97
167
  def delete_file_orphans(
98
168
  self, project_root: str, file_path: str, keep_ids: set[str]
99
169
  ) -> int:
@@ -122,10 +122,18 @@ def code_status_handler(args: dict) -> dict:
122
122
  if semantic_available:
123
123
  model = DEFAULT_MODEL
124
124
  if project_root:
125
+ try: # report THIS repo's configured model, not the machine default
126
+ from codeintel.config import load_config
127
+ model = str(load_config(project_root).get("model") or DEFAULT_MODEL)
128
+ except Exception:
129
+ pass
125
130
  indexed = bool(SemanticProvider().probe(project_root).get("repo_indexed"))
126
131
  else:
132
+ import glob
127
133
  import os
128
- indexed = os.path.exists(default_db_path())
134
+ # any per-model cache file (semantic.db / semantic-<hash>.db) counts as "indexed"
135
+ base = os.path.dirname(default_db_path())
136
+ indexed = bool(glob.glob(os.path.join(base, "semantic*.db")))
129
137
  except Exception:
130
138
  pass
131
139
 
@@ -157,7 +157,7 @@ def test_semantic_probe_real_db_indexed(tmp_path, monkeypatch):
157
157
  repo = tmp_path / "repo"
158
158
  repo.mkdir()
159
159
  _make_db(db_path, os.path.realpath(str(repo)))
160
- monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda: str(db_path))
160
+ monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda *a, **k: str(db_path))
161
161
 
162
162
  r = SemanticProvider().probe(str(repo))
163
163
  assert r["installed"] is True and r["runnable"] is True and r["repo_indexed"] is True
@@ -172,7 +172,7 @@ def test_semantic_probe_real_db_indexed(tmp_path, monkeypatch):
172
172
 
173
173
  def test_semantic_probe_no_db(tmp_path, monkeypatch):
174
174
  monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
175
- monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda: str(tmp_path / "missing.db"))
175
+ monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda *a, **k: str(tmp_path / "missing.db"))
176
176
  r = SemanticProvider().probe(str(tmp_path))
177
177
  assert r["runnable"] is True and r["repo_indexed"] is False
178
178
  assert "codeintel index" in r["remediation"]
@@ -182,7 +182,7 @@ def test_semantic_probe_corrupt_db(tmp_path, monkeypatch):
182
182
  monkeypatch.setattr("codeintel.providers.semantic._DEPS_OK", True)
183
183
  db_path = tmp_path / "semantic.db"
184
184
  db_path.write_bytes(b"this is not a sqlite database")
185
- monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda: str(db_path))
185
+ monkeypatch.setattr("codeintel.semantic_db.default_db_path", lambda *a, **k: str(db_path))
186
186
  r = SemanticProvider().probe(str(tmp_path))
187
187
  assert r["installed"] is True and r["runnable"] is False # unreadable → not runnable
188
188
  assert r["remediation"]
@@ -58,8 +58,7 @@ def fixture_repo(tmp_path):
58
58
 
59
59
 
60
60
  def test_e2e_search_returns_ranked_result(fixture_repo, tmp_path, monkeypatch):
61
- db_path = tmp_path / "e2e_semantic.db"
62
- monkeypatch.setattr(_sem_mod, "_DB_PATH", db_path)
61
+ monkeypatch.setattr("codeintel.semantic_db._base_dir", lambda: tmp_path)
63
62
  monkeypatch.setattr(_sem_mod, "_DEPS_OK", True)
64
63
 
65
64
  with patch("fastembed.TextEmbedding", _FakeTextEmbedding):
@@ -1,12 +1,25 @@
1
1
  """GraphProvider tests: never-raise invariant and key behavioral guarantees."""
2
2
  from __future__ import annotations
3
3
 
4
+ import os
4
5
  import subprocess
5
6
 
6
7
  from codeintel.providers.graph import GraphProvider
7
8
  from codeintel.server import code_status_handler
8
9
 
9
10
 
11
+ def test_match_project_resolves_a_relative_path(tmp_path, monkeypatch):
12
+ # The backend stores absolute root_paths, so a relative project_root (e.g. `codeintel map .`)
13
+ # must be normalized before matching — otherwise resolution fails from inside the repo and the
14
+ # map/graph query silently reports "not indexed" (the map-stub bug).
15
+ real = os.path.realpath(str(tmp_path))
16
+ raw = {"projects": [{"name": "myrepo", "root_path": real}]}
17
+ monkeypatch.chdir(tmp_path)
18
+ assert GraphProvider._match_project(raw, ".") == "myrepo" # relative resolves now
19
+ assert GraphProvider._match_project(raw, real) == "myrepo" # absolute still works
20
+ assert GraphProvider._match_project(raw, os.path.join(real, "src")) == "myrepo" # subdir prefix
21
+
22
+
10
23
  # ---------------------------------------------------------------------------
11
24
  # Group 1 — Never-raise: None args
12
25
  # ---------------------------------------------------------------------------