cortex-local 2.0.10__tar.gz → 2.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortex-local
3
- Version: 2.0.10
3
+ Version: 2.1.0
4
4
  Summary: Local semantic search MCP server — notes + code, embedded Qdrant, fastembed
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: click>=8.0.0
@@ -0,0 +1,141 @@
1
+ """cortex CLI — index <path> and serve (stdio MCP)."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from .config import (
11
+ WORKSPACES_DIR, get_active_workspace, set_active_workspace, get_workspace_dir,
12
+ )
13
+
14
+
15
+ @click.group()
16
+ def cli() -> None:
17
+ """cortex: local semantic search MCP server."""
18
+
19
+
20
+ @cli.command()
21
+ @click.argument("path", type=click.Path(exists=True, file_okay=False, resolve_path=True))
22
+ def index(path: str) -> None:
23
+ """Index PATH (notes and/or code) into ~/.cortex."""
24
+ from .indexer import index_path
25
+ index_path(Path(path))
26
+
27
+
28
+ @cli.command()
29
+ def serve() -> None:
30
+ """Run cortex MCP server on stdio."""
31
+ from .mcp_server import run
32
+ run()
33
+
34
+
35
+ @cli.command()
36
+ def pull_models() -> None:
37
+ """Pre-download fastembed models."""
38
+ from .embedder import pull_models as _pull
39
+ _pull()
40
+
41
+
42
+ @cli.command()
43
+ def stats() -> None:
44
+ """Show cortex efficiency stats."""
45
+ from .state import _stats
46
+ from .mcp_server import _fmt_stats
47
+ from .config import VERSION
48
+ click.echo(_fmt_stats(VERSION, _stats, current=True))
49
+
50
+
51
+ @cli.group()
52
+ def workspace() -> None:
53
+ """Manage cortex workspaces."""
54
+
55
+
56
+ @workspace.command("list")
57
+ def workspace_list() -> None:
58
+ """List all workspaces and their status."""
59
+ active = get_active_workspace()
60
+ if not WORKSPACES_DIR.exists():
61
+ click.echo(f"No workspaces found. Active: '{active}' (not yet indexed).")
62
+ return
63
+ workspaces = sorted([d.name for d in WORKSPACES_DIR.iterdir() if d.is_dir()])
64
+ if not workspaces:
65
+ click.echo(f"No workspaces found. Active: '{active}'.")
66
+ return
67
+ click.echo(f"Active workspace: {active}\n")
68
+ for ws in workspaces:
69
+ marker = "* " if ws == active else " "
70
+ has_index = (WORKSPACES_DIR / ws / "qdrant").exists()
71
+ click.echo(f"{marker}{ws} ({'indexed' if has_index else 'empty'})")
72
+
73
+
74
+ @workspace.command("create")
75
+ @click.argument("name")
76
+ def workspace_create(name: str) -> None:
77
+ """Create a new workspace."""
78
+ ws_dir = get_workspace_dir(name)
79
+ if ws_dir.exists():
80
+ click.echo(f"Workspace '{name}' already exists.")
81
+ return
82
+ ws_dir.mkdir(parents=True, exist_ok=True)
83
+ click.echo(f"Created workspace '{name}'. Switch with: cortex workspace switch {name}")
84
+
85
+
86
+ @workspace.command("switch")
87
+ @click.argument("name")
88
+ def workspace_switch(name: str) -> None:
89
+ """Switch the active workspace."""
90
+ ws_dir = get_workspace_dir(name)
91
+ if not ws_dir.exists():
92
+ click.echo(f"Workspace '{name}' does not exist. Create it with: cortex workspace create {name}")
93
+ return
94
+ set_active_workspace(name)
95
+ click.echo(f"Switched to workspace '{name}'.")
96
+
97
+
98
+ @workspace.command("delete")
99
+ @click.argument("name")
100
+ @click.confirmation_option(prompt="Delete workspace and all its data?")
101
+ def workspace_delete(name: str) -> None:
102
+ """Delete a workspace and all its indexed data."""
103
+ if name == get_active_workspace():
104
+ click.echo("Cannot delete active workspace. Switch first.")
105
+ return
106
+ ws_dir = get_workspace_dir(name)
107
+ if not ws_dir.exists():
108
+ click.echo(f"Workspace '{name}' does not exist.")
109
+ return
110
+ shutil.rmtree(ws_dir)
111
+ click.echo(f"Deleted workspace '{name}'.")
112
+
113
+
114
+ @workspace.command("current")
115
+ def workspace_current() -> None:
116
+ """Print the active workspace name."""
117
+ click.echo(get_active_workspace())
118
+
119
+
120
+ @cli.command("install")
121
+ def install_cmd() -> None:
122
+ """Download embedding models and register cortex MCP in Claude Code."""
123
+ from .embedder import pull_models as _pull
124
+ click.echo("Downloading embedding models...")
125
+ _pull()
126
+ mcp_cmd = "claude mcp add cortex --transport stdio cortex serve"
127
+ claude = shutil.which("claude")
128
+ if not claude:
129
+ click.echo("Models ready. Register MCP manually (claude not found in PATH):")
130
+ click.echo(f" {mcp_cmd}")
131
+ return
132
+ click.echo("Registering MCP server in Claude Code...")
133
+ result = subprocess.run(
134
+ [claude, "mcp", "add", "cortex", "--transport", "stdio", "cortex", "serve"],
135
+ capture_output=True, text=True,
136
+ )
137
+ if result.returncode == 0:
138
+ click.echo("Done. cortex is ready — restart Claude Code to activate.")
139
+ else:
140
+ click.echo(f"MCP registration failed. Run manually:\n {mcp_cmd}")
141
+ click.echo(result.stderr.strip())
@@ -0,0 +1,64 @@
1
+ """Paths and constants for cortex local mode."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ CORTEX_DIR = Path.home() / ".cortex"
7
+ WORKSPACES_DIR = CORTEX_DIR / "workspaces"
8
+ ACTIVE_WORKSPACE_FILE = CORTEX_DIR / "active_workspace"
9
+
10
+ EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
11
+ VECTOR_SIZE = 768
12
+
13
+ try:
14
+ from importlib.metadata import version
15
+ VERSION = version("cortex-local")
16
+ except Exception:
17
+ VERSION = "dev"
18
+
19
+
20
+ def get_active_workspace() -> str:
21
+ if ACTIVE_WORKSPACE_FILE.exists():
22
+ return ACTIVE_WORKSPACE_FILE.read_text().strip() or "default"
23
+ return "default"
24
+
25
+
26
+ def set_active_workspace(name: str) -> None:
27
+ CORTEX_DIR.mkdir(parents=True, exist_ok=True)
28
+ ACTIVE_WORKSPACE_FILE.write_text(name)
29
+
30
+
31
+ def get_workspace_dir(name: str | None = None) -> Path:
32
+ return WORKSPACES_DIR / (name or get_active_workspace())
33
+
34
+
35
+ def qdrant_path(ws: str | None = None) -> str:
36
+ return str(get_workspace_dir(ws) / "qdrant")
37
+
38
+
39
+ def data_dir(ws: str | None = None) -> Path:
40
+ return get_workspace_dir(ws)
41
+
42
+
43
+ def cache_file(ws: str | None = None) -> Path:
44
+ return get_workspace_dir(ws) / "embed_cache.json"
45
+
46
+
47
+ def stats_file(ws: str | None = None) -> Path:
48
+ return get_workspace_dir(ws) / "stats.json"
49
+
50
+
51
+ def migrate_legacy() -> None:
52
+ """Move old flat ~/.cortex/ layout into ~/.cortex/workspaces/default/ on first run."""
53
+ old_qdrant = CORTEX_DIR / "qdrant"
54
+ default_dir = WORKSPACES_DIR / "default"
55
+ if not old_qdrant.exists() or WORKSPACES_DIR.exists():
56
+ return
57
+ default_dir.mkdir(parents=True, exist_ok=True)
58
+ shutil.move(str(old_qdrant), str(default_dir / "qdrant"))
59
+ for fname in ("embed_cache.json", "stats.json"):
60
+ src = CORTEX_DIR / fname
61
+ if src.exists():
62
+ shutil.move(str(src), str(default_dir / fname))
63
+ for f in CORTEX_DIR.glob("graph_*.json"):
64
+ shutil.move(str(f), str(default_dir / f.name))
@@ -10,7 +10,7 @@ from qdrant_client.models import (
10
10
  MatchValue, PointStruct, SparseVector, SparseVectorParams, VectorParams,
11
11
  )
12
12
 
13
- from .config import QDRANT_PATH, DATA_DIR, CACHE_FILE, VECTOR_SIZE
13
+ from .config import qdrant_path, data_dir, cache_file, VECTOR_SIZE, migrate_legacy
14
14
  from .embedder import embed, sparse_embed
15
15
  from .core.chunker import chunk_file, CODE_EXTS, SKIP_DIRS
16
16
  from .core.cache import load_cache, save_cache
@@ -18,7 +18,10 @@ from .core import graph as _graph
18
18
 
19
19
 
20
20
  def _qdrant() -> QdrantClient:
21
- return QdrantClient(path=QDRANT_PATH)
21
+ migrate_legacy()
22
+ d = data_dir()
23
+ d.mkdir(parents=True, exist_ok=True)
24
+ return QdrantClient(path=qdrant_path())
22
25
 
23
26
 
24
27
  def _ensure_collection(client: QdrantClient, name: str) -> bool:
@@ -116,7 +119,7 @@ def index_notes(notes_dir: Path) -> int:
116
119
  md_files = [p for p in notes_dir.rglob("*.md")
117
120
  if ".obsidian" not in p.parts and ".git" not in p.parts]
118
121
 
119
- cache = load_cache("notes", CACHE_FILE)
122
+ cache = load_cache("notes", cache_file())
120
123
  if client.get_collection("notes").points_count == 0:
121
124
  cache = {}
122
125
  new_cache: dict = {}
@@ -139,12 +142,12 @@ def index_notes(notes_dir: Path) -> int:
139
142
  print(f" {rel}: {len(points)} chunk(s)", flush=True)
140
143
  new_cache[rel] = mtime
141
144
 
142
- save_cache("notes", new_cache, CACHE_FILE)
145
+ save_cache("notes", new_cache, cache_file())
143
146
  print(f"Done. {total} chunks indexed, {cached}/{len(md_files)} files cached.", flush=True)
144
147
 
145
148
  try:
146
149
  G = _graph.build_notes_graph(notes_dir)
147
- _graph.persist_notes_graph(G, DATA_DIR)
150
+ _graph.persist_notes_graph(G, data_dir())
148
151
  except Exception as e:
149
152
  print(f" notes graph failed: {e}")
150
153
 
@@ -170,7 +173,7 @@ def index_code(code_dir: Path) -> int:
170
173
  and not any(s in p.parts for s in SKIP_DIRS)
171
174
  ]
172
175
 
173
- cache = load_cache("code", CACHE_FILE)
176
+ cache = load_cache("code", cache_file())
174
177
  if client.get_collection("code").points_count == 0:
175
178
  cache = {}
176
179
  new_cache = dict(cache)
@@ -198,7 +201,7 @@ def index_code(code_dir: Path) -> int:
198
201
  total += len(points)
199
202
  new_cache[cache_key] = mtime
200
203
 
201
- save_cache("code", new_cache, CACHE_FILE)
204
+ save_cache("code", new_cache, cache_file())
202
205
  print(f" {repo_name}: {total} chunks from {len(code_files) - cached} files ({cached} cached)", flush=True)
203
206
 
204
207
  all_files = {
@@ -208,7 +211,7 @@ def index_code(code_dir: Path) -> int:
208
211
  G = _graph.build_code_graph(code_dir, all_files)
209
212
  if G is not None:
210
213
  metadata = _graph.compute_code_metadata(G)
211
- _graph.persist_code_graph(G, metadata, repo_name, DATA_DIR)
214
+ _graph.persist_code_graph(G, metadata, repo_name, data_dir())
212
215
  for file_rel, meta in metadata.items():
213
216
  pids = file_point_ids.get(file_rel, [])
214
217
  if pids:
@@ -233,7 +236,7 @@ def index_code(code_dir: Path) -> int:
233
236
  def index_path(path: Path) -> None:
234
237
  """Index a path — .md files as notes, code files as code."""
235
238
  path = path.resolve()
236
- DATA_DIR.mkdir(parents=True, exist_ok=True)
239
+ data_dir().mkdir(parents=True, exist_ok=True)
237
240
 
238
241
  md_files = list(path.rglob("*.md"))
239
242
  code_files = [p for p in path.rglob("*")
@@ -10,9 +10,13 @@ from mcp.server.fastmcp import FastMCP
10
10
  from qdrant_client import QdrantClient
11
11
  from qdrant_client.models import Prefetch, Fusion, SparseVector
12
12
 
13
- from .config import QDRANT_PATH, DATA_DIR, VERSION, VECTOR_SIZE
13
+ from .config import (
14
+ qdrant_path, data_dir, VERSION, VECTOR_SIZE,
15
+ WORKSPACES_DIR, get_active_workspace, set_active_workspace,
16
+ get_workspace_dir,
17
+ )
14
18
  from .embedder import embed, sparse_embed
15
- from .state import _stats, get_graph_meta, save_stats
19
+ from .state import _stats, get_graph_meta, save_stats, invalidate_graph_cache
16
20
  from .indexer import index_path
17
21
 
18
22
  mcp = FastMCP("cortex")
@@ -24,7 +28,7 @@ _reindex_path: str = ""
24
28
 
25
29
 
26
30
  def _qdrant() -> QdrantClient:
27
- return QdrantClient(path=QDRANT_PATH)
31
+ return QdrantClient(path=qdrant_path())
28
32
 
29
33
 
30
34
  # ── Reindex ─────────────────────────────────────────────────────────────────
@@ -54,7 +58,7 @@ def _ppr_block(matched_files: list, matched_scores: list) -> str | None:
54
58
  from .core.notes_graph import ppr_augment
55
59
  except Exception:
56
60
  return None
57
- graph_path = DATA_DIR / "graph_notes.json"
61
+ graph_path = data_dir() / "graph_notes.json"
58
62
  extras, reason = ppr_augment(matched_files, matched_scores, graph_path, _return_reason=True)
59
63
  if extras:
60
64
  _stats["ppr_fires"] += 1
@@ -376,6 +380,45 @@ cortex is available as an MCP tool. Use it proactively:
376
380
  {cortex_section}"""
377
381
 
378
382
 
383
+ @mcp.tool()
384
+ def switch_workspace(name: str) -> str:
385
+ """Switch the active cortex workspace.
386
+
387
+ Each workspace has its own isolated index (notes + code).
388
+ Use when switching between work/personal/project contexts.
389
+ Example: switch_workspace("work")
390
+ """
391
+ ws_dir = get_workspace_dir(name)
392
+ if not ws_dir.exists():
393
+ return (
394
+ f"Workspace '{name}' does not exist.\n"
395
+ f"Create it with: cortex workspace create {name}"
396
+ )
397
+ set_active_workspace(name)
398
+ invalidate_graph_cache()
399
+ return f"Switched to workspace '{name}'. search_code and search_notes now use this workspace's index."
400
+
401
+
402
+ @mcp.tool()
403
+ def list_workspaces() -> str:
404
+ """List all cortex workspaces and their status.
405
+
406
+ Shows which workspace is currently active and whether each has an index.
407
+ """
408
+ active = get_active_workspace()
409
+ if not WORKSPACES_DIR.exists():
410
+ return f"No workspaces found. Active: '{active}' (not yet indexed)."
411
+ workspaces = sorted([d.name for d in WORKSPACES_DIR.iterdir() if d.is_dir()])
412
+ if not workspaces:
413
+ return f"No workspaces found. Active: '{active}'."
414
+ lines = [f"Active workspace: {active}\n"]
415
+ for ws in workspaces:
416
+ marker = "* " if ws == active else " "
417
+ has_index = (WORKSPACES_DIR / ws / "qdrant").exists()
418
+ lines.append(f"{marker}{ws} ({'indexed' if has_index else 'empty'})")
419
+ return "\n".join(lines)
420
+
421
+
379
422
  def run() -> None:
380
423
  """Entry point for stdio MCP server."""
381
424
  mcp.run(transport="stdio")
@@ -4,10 +4,9 @@ import json
4
4
  from datetime import datetime, timezone
5
5
  from pathlib import Path
6
6
 
7
- from .config import DATA_DIR, STATS_FILE, VERSION
7
+ from .config import data_dir, stats_file, VERSION
8
8
 
9
9
  _graph_cache: dict = {}
10
-
11
10
  _stats: dict = {}
12
11
 
13
12
 
@@ -35,8 +34,9 @@ def _default_stats() -> dict:
35
34
  def _load_stats() -> dict:
36
35
  defaults = _default_stats()
37
36
  try:
38
- if STATS_FILE.exists():
39
- data = json.loads(STATS_FILE.read_text())
37
+ sf = stats_file()
38
+ if sf.exists():
39
+ data = json.loads(sf.read_text())
40
40
  saved = data.get("versions", {}).get(VERSION, {})
41
41
  if saved:
42
42
  return {**defaults, **saved}
@@ -47,12 +47,13 @@ def _load_stats() -> dict:
47
47
 
48
48
  def save_stats() -> None:
49
49
  try:
50
+ sf = stats_file()
50
51
  existing: dict = {}
51
- if STATS_FILE.exists():
52
- existing = json.loads(STATS_FILE.read_text()).get("versions", {})
52
+ if sf.exists():
53
+ existing = json.loads(sf.read_text()).get("versions", {})
53
54
  existing[VERSION] = {**_stats, "last_saved": datetime.now(timezone.utc).isoformat()}
54
- STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
55
- STATS_FILE.write_text(json.dumps({"versions": existing}, indent=2))
55
+ sf.parent.mkdir(parents=True, exist_ok=True)
56
+ sf.write_text(json.dumps({"versions": existing}, indent=2))
56
57
  except Exception:
57
58
  pass
58
59
 
@@ -61,7 +62,7 @@ def get_graph_meta(repo_name: str) -> dict:
61
62
  if repo_name in _graph_cache:
62
63
  _stats["graph_cache_hits"] += 1
63
64
  return _graph_cache[repo_name]
64
- path = DATA_DIR / f"graph_{repo_name}.json"
65
+ path = data_dir() / f"graph_{repo_name}.json"
65
66
  if not path.exists():
66
67
  _stats["graph_cache_misses"] += 1
67
68
  return {}
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cortex-local"
7
- version = "2.0.10"
7
+ version = "2.1.0"
8
8
  description = "Local semantic search MCP server — notes + code, embedded Qdrant, fastembed"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,68 +0,0 @@
1
- """cortex CLI — index <path> and serve (stdio MCP)."""
2
-
3
- import shutil
4
- import subprocess
5
- import sys
6
- from pathlib import Path
7
-
8
- import click
9
-
10
-
11
- @click.group()
12
- def cli() -> None:
13
- """cortex: local semantic search MCP server."""
14
-
15
-
16
- @cli.command()
17
- @click.argument("path", type=click.Path(exists=True, file_okay=False, resolve_path=True))
18
- def index(path: str) -> None:
19
- """Index PATH (notes and/or code) into ~/.cortex."""
20
- from .indexer import index_path
21
- index_path(Path(path))
22
-
23
-
24
- @cli.command()
25
- def serve() -> None:
26
- """Run cortex MCP server on stdio."""
27
- from .mcp_server import run
28
- run()
29
-
30
-
31
- @cli.command()
32
- def pull_models() -> None:
33
- """Pre-download fastembed models."""
34
- from .embedder import pull_models as _pull
35
- _pull()
36
-
37
-
38
- @cli.command()
39
- def stats() -> None:
40
- """Show cortex efficiency stats."""
41
- from .state import _stats
42
- from .mcp_server import _fmt_stats
43
- from .config import VERSION
44
- click.echo(_fmt_stats(VERSION, _stats, current=True))
45
-
46
-
47
- @cli.command("install")
48
- def install_cmd() -> None:
49
- """Download embedding models and register cortex MCP in Claude Code."""
50
- from .embedder import pull_models as _pull
51
- click.echo("Downloading embedding models...")
52
- _pull()
53
- mcp_cmd = "claude mcp add cortex --transport stdio cortex serve"
54
- claude = shutil.which("claude")
55
- if not claude:
56
- click.echo("Models ready. Register MCP manually (claude not found in PATH):")
57
- click.echo(f" {mcp_cmd}")
58
- return
59
- click.echo("Registering MCP server in Claude Code...")
60
- result = subprocess.run(
61
- [claude, "mcp", "add", "cortex", "--transport", "stdio", "cortex", "serve"],
62
- capture_output=True, text=True,
63
- )
64
- if result.returncode == 0:
65
- click.echo("Done. cortex is ready — restart Claude Code to activate.")
66
- else:
67
- click.echo(f"MCP registration failed. Run manually:\n {mcp_cmd}")
68
- click.echo(result.stderr.strip())
@@ -1,18 +0,0 @@
1
- """Paths and constants for cortex local mode."""
2
-
3
- from pathlib import Path
4
-
5
- CORTEX_DIR = Path.home() / ".cortex"
6
- QDRANT_PATH = str(CORTEX_DIR / "qdrant")
7
- DATA_DIR = CORTEX_DIR
8
- STATS_FILE = CORTEX_DIR / "stats.json"
9
- CACHE_FILE = CORTEX_DIR / "embed_cache.json"
10
-
11
- EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
12
- VECTOR_SIZE = 768
13
-
14
- try:
15
- from importlib.metadata import version
16
- VERSION = version("cortex")
17
- except Exception:
18
- VERSION = "dev"
File without changes