ravmem-cli 0.1.0__py3-none-any.whl

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.
ravmem/cli/search.py ADDED
@@ -0,0 +1,91 @@
1
+ """ravmem search — hybrid symbol search via the remote server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+
12
+ def _detect_branch(repo_path: Path, explicit: str | None) -> str:
13
+ if explicit:
14
+ return explicit
15
+ try:
16
+ out = subprocess.run(
17
+ ["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"],
18
+ capture_output=True, text=True, check=True,
19
+ ).stdout.strip()
20
+ return out if out and out != "HEAD" else "main"
21
+ except Exception:
22
+ return "main"
23
+
24
+
25
+ def _get_client(remote: str | None):
26
+ from ravmem.client.api import RavMemClient
27
+ from ravmem.client.config import load_config
28
+
29
+ url = remote
30
+ api_key = None
31
+ if not url:
32
+ cfg = load_config()
33
+ url = cfg.get("server_url")
34
+ api_key = cfg.get("api_key")
35
+ if not url:
36
+ typer.echo(
37
+ "Error: No server configured. "
38
+ "Run `ravmem remote config --url <URL>` or pass --remote <URL>.",
39
+ err=True,
40
+ )
41
+ raise typer.Exit(1)
42
+ return RavMemClient(base_url=url, api_key=api_key or None)
43
+
44
+
45
+ def search_cmd(
46
+ query: str = typer.Argument(..., help="Search query"),
47
+ repo: Path = typer.Option(Path("."), "--repo", "-r", help="Path to the repository (used for branch auto-detection)"),
48
+ limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
49
+ remote: Optional[str] = typer.Option(None, "--remote", help="Server URL (overrides config)"),
50
+ project: Optional[str] = typer.Option(None, "--project", help="Project ID on the server"),
51
+ branch: Optional[str] = typer.Option(None, "--branch", help="Branch name (auto-detected from git if omitted)"),
52
+ output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
53
+ ) -> None:
54
+ """Search symbols using hybrid BM25 + semantic search."""
55
+ import json
56
+
57
+ repo_path = repo.resolve()
58
+ branch_name = _detect_branch(repo_path, branch)
59
+
60
+ if not project:
61
+ typer.echo("--project is required. Use `ravmem remote add` to register the repo first.", err=True)
62
+ raise typer.Exit(1)
63
+
64
+ client = _get_client(remote)
65
+ try:
66
+ results = client.search(project, branch_name, query, limit=limit)
67
+ finally:
68
+ client.close()
69
+
70
+ if output_json:
71
+ typer.echo(json.dumps(results, default=str))
72
+ return
73
+
74
+ if not results:
75
+ typer.echo("No results found.")
76
+ return
77
+
78
+ typer.echo(f"\nResults for '{query}' on branch '{branch_name}':\n")
79
+ for i, r in enumerate(results, 1):
80
+ sym = r.get("symbol", {})
81
+ name = sym.get("name", "")
82
+ kind = sym.get("kind", "")
83
+ file = sym.get("file", "")
84
+ line = sym.get("line_start", "")
85
+ score = r.get("score", 0.0)
86
+ location = f"{Path(file).name}:{line}" if file else ""
87
+ match_type = r.get("match_type", "hybrid")
88
+ typer.echo(
89
+ f" {i:2}. [{match_type:8}] {typer.style(name, bold=True)} "
90
+ f"({kind}) {location} score={score:.4f}"
91
+ )
ravmem/cli/serve.py ADDED
@@ -0,0 +1,41 @@
1
+ """ravmem serve — start the FastAPI server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from ravmem.config import DEFAULT_HOST, DEFAULT_PORT, SERVER_DATA_DIR
11
+
12
+
13
+ def serve_cmd(
14
+ repos_dir: Path = typer.Option(
15
+ SERVER_DATA_DIR / "repos",
16
+ "--repos-dir",
17
+ help="Directory containing indexed repositories",
18
+ ),
19
+ host: str = typer.Option(DEFAULT_HOST, "--host", help="Bind host"),
20
+ port: int = typer.Option(DEFAULT_PORT, "--port", "-p", help="Bind port"),
21
+ reload: bool = typer.Option(False, "--reload", help="Auto-reload on code changes (dev)"),
22
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging"),
23
+ config: Optional[str] = typer.Option(None, "--config", help="Path to ravmem.yaml config file"),
24
+ ) -> None:
25
+ """Start the RavMem API server (REST + MCP + WebSocket)."""
26
+ from ravmem.cli.main import setup_logging
27
+ import uvicorn
28
+ from ravmem.config_loader import reload_config
29
+ from ravmem.server.app import create_app
30
+
31
+ # Load config before anything else so all components see it
32
+ reload_config(config)
33
+
34
+ setup_logging(verbose)
35
+ repos_path = repos_dir.resolve()
36
+ typer.echo(f"Starting RavMem server on http://{host}:{port}")
37
+ typer.echo(f"Repos directory: {repos_path}")
38
+ typer.echo(f"MCP endpoint: http://{host}:{port}/mcp")
39
+
40
+ app = create_app(repos_dir=repos_path)
41
+ uvicorn.run(app, host=host, port=port, reload=reload)
@@ -0,0 +1,3 @@
1
+ from ravmem.client.api import RavMemClient
2
+
3
+ __all__ = ["RavMemClient"]
ravmem/client/api.py ADDED
@@ -0,0 +1,279 @@
1
+ """Synchronous HTTP client for a remote RavMem server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import httpx
8
+
9
+
10
+ class RavMemClient:
11
+ """Thin wrapper around the RavMem REST API."""
12
+
13
+ def __init__(
14
+ self,
15
+ base_url: str,
16
+ api_key: str | None = None,
17
+ timeout: float = 30.0,
18
+ ) -> None:
19
+ headers: dict[str, str] = {}
20
+ if api_key:
21
+ headers["Authorization"] = f"Bearer {api_key}"
22
+ self._client = httpx.Client(
23
+ base_url=base_url, headers=headers, timeout=timeout
24
+ )
25
+
26
+ # ------------------------------------------------------------------
27
+ # Lifecycle
28
+ # ------------------------------------------------------------------
29
+
30
+ def close(self) -> None:
31
+ self._client.close()
32
+
33
+ def __enter__(self):
34
+ return self
35
+
36
+ def __exit__(self, *exc):
37
+ self.close()
38
+
39
+ # ------------------------------------------------------------------
40
+ # Internal helper
41
+ # ------------------------------------------------------------------
42
+
43
+ def _request(self, method: str, path: str, **kwargs) -> dict | list:
44
+ resp = self._client.request(method, path, **kwargs)
45
+ resp.raise_for_status()
46
+ return resp.json()
47
+
48
+ # ------------------------------------------------------------------
49
+ # Projects
50
+ # ------------------------------------------------------------------
51
+
52
+ def create_project(
53
+ self,
54
+ git_url: str | None = None,
55
+ git_username: str | None = None,
56
+ git_token: str | None = None,
57
+ repo_path: str | None = None,
58
+ mount: bool = False,
59
+ name: str | None = None,
60
+ default_branch: str = "main",
61
+ langs: list[str] | None = None,
62
+ ) -> dict:
63
+ body: dict = {"default_branch": default_branch, "mount": mount}
64
+ if git_url is not None:
65
+ body["git_url"] = git_url
66
+ if git_username is not None:
67
+ body["git_username"] = git_username
68
+ if git_token is not None:
69
+ body["git_token"] = git_token
70
+ if repo_path is not None:
71
+ body["repo_path"] = repo_path
72
+ if name is not None:
73
+ body["name"] = name
74
+ if langs is not None:
75
+ body["langs"] = langs
76
+ return self._request("POST", "/api/projects", json=body)
77
+
78
+ def list_projects(self) -> list[dict]:
79
+ return self._request("GET", "/api/projects")
80
+
81
+ def get_project(self, project_id: str) -> dict:
82
+ return self._request("GET", f"/api/projects/{project_id}")
83
+
84
+ def delete_project(self, project_id: str) -> dict:
85
+ return self._request("DELETE", f"/api/projects/{project_id}")
86
+
87
+ # ------------------------------------------------------------------
88
+ # Branches
89
+ # ------------------------------------------------------------------
90
+
91
+ def sync_workspace(self, project_id: str) -> dict:
92
+ """Fetch the latest remote commits into the server-side workspace.
93
+
94
+ Returns ``{synced, fetched_branches, fetched_commits, workspace_head, duration_ms}``.
95
+ """
96
+ return self._request("POST", f"/api/projects/{project_id}/workspace/sync")
97
+
98
+ def reset_workspace(self, project_id: str) -> dict:
99
+ """Delete the server-side workspace and clear all branch index records.
100
+
101
+ Returns ``{reset: True, removed_workspace: bool, branches_cleared: int}``.
102
+ """
103
+ return self._request("POST", f"/api/projects/{project_id}/workspace/reset")
104
+
105
+ def set_credentials(self, project_id: str, git_username: str, git_token: str) -> dict:
106
+ """Store or replace git credentials for a clone-mode project.
107
+
108
+ Returns ``{stored: True}``.
109
+ """
110
+ return self._request(
111
+ "POST",
112
+ f"/api/projects/{project_id}/credentials",
113
+ json={"git_username": git_username, "git_token": git_token},
114
+ )
115
+
116
+ def delete_credentials(self, project_id: str) -> dict:
117
+ """Remove stored git credentials for a clone-mode project.
118
+
119
+ Returns ``{deleted: bool}``.
120
+ """
121
+ return self._request("DELETE", f"/api/projects/{project_id}/credentials")
122
+
123
+ def clone_project(self, project_id: str, branch: str | None = None) -> dict:
124
+ """Trigger initial clone + index for a clone-mode project.
125
+
126
+ Returns ``{"job_id": N}``.
127
+ """
128
+ params: dict = {}
129
+ if branch is not None:
130
+ params["branch"] = branch
131
+ return self._request(
132
+ "POST", f"/api/projects/{project_id}/index/clone", params=params
133
+ )
134
+
135
+ def upload_batch(
136
+ self,
137
+ project_id: str,
138
+ branch: str,
139
+ batch_index: int,
140
+ batch_total: int,
141
+ files: list[dict],
142
+ parent_branch: str | None = None,
143
+ to_commit: str = "",
144
+ from_commit: str = "",
145
+ mode: str = "full",
146
+ deleted_paths: list[str] | None = None,
147
+ langs: list[str] | None = None,
148
+ ) -> dict:
149
+ """POST one file batch to POST /api/projects/{id}/index.
150
+
151
+ Returns ``{"pending": True, "batch_index": N}`` for intermediate batches
152
+ and ``{"job_id": N}`` for the final batch.
153
+ """
154
+ body: dict = {
155
+ "branch": branch,
156
+ "to_commit": to_commit,
157
+ "from_commit": from_commit,
158
+ "mode": mode,
159
+ "batch_index": batch_index,
160
+ "batch_total": batch_total,
161
+ "files": files,
162
+ "deleted_paths": deleted_paths or [],
163
+ }
164
+ if parent_branch is not None:
165
+ body["parent_branch"] = parent_branch
166
+ if langs is not None:
167
+ body["langs"] = langs
168
+ return self._request("POST", f"/api/projects/{project_id}/index", json=body)
169
+
170
+ def index_branch(
171
+ self, project_id: str, branch: str, mode: str = "auto",
172
+ langs: list[str] | None = None,
173
+ no_auto_sync: bool = False,
174
+ ) -> dict:
175
+ body: dict = {"mode": mode}
176
+ if langs is not None:
177
+ body["langs"] = langs
178
+ if no_auto_sync:
179
+ body["no_auto_sync"] = True
180
+ return self._request(
181
+ "POST",
182
+ f"/api/projects/{project_id}/branches/{branch}/index",
183
+ json=body,
184
+ )
185
+
186
+ def list_branches(self, project_id: str) -> list[dict]:
187
+ return self._request("GET", f"/api/projects/{project_id}/branches")
188
+
189
+ def get_branch_status(self, project_id: str, branch: str) -> dict:
190
+ return self._request(
191
+ "GET", f"/api/projects/{project_id}/branches/{branch}/status"
192
+ )
193
+
194
+ def delete_branch_index(self, project_id: str, branch: str) -> dict:
195
+ return self._request(
196
+ "DELETE", f"/api/projects/{project_id}/branches/{branch}/index"
197
+ )
198
+
199
+ def get_divergence(self, project_id: str, branch: str) -> dict:
200
+ """Pre-flight check: returns changed_files, total_files, ratio, recommend, force_push."""
201
+ return self._request(
202
+ "GET", f"/api/projects/{project_id}/branches/{branch}/divergence"
203
+ )
204
+
205
+ def update_project(self, project_id: str, default_branch: str) -> dict:
206
+ """Update project metadata (e.g. change the default/parent branch)."""
207
+ return self._request(
208
+ "PATCH", f"/api/projects/{project_id}", json={"default_branch": default_branch}
209
+ )
210
+
211
+ # ------------------------------------------------------------------
212
+ # Queries
213
+ # ------------------------------------------------------------------
214
+
215
+ def search(
216
+ self,
217
+ project_id: str,
218
+ branch: str,
219
+ query: str,
220
+ limit: int = 20,
221
+ ) -> list[dict]:
222
+ return self._request(
223
+ "GET",
224
+ f"/api/projects/{project_id}/branches/{branch}/search",
225
+ params={"q": query, "limit": limit},
226
+ )
227
+
228
+ def impact(
229
+ self,
230
+ project_id: str,
231
+ branch: str,
232
+ symbol_id: str,
233
+ depth: int = 3,
234
+ ) -> dict:
235
+ return self._request(
236
+ "GET",
237
+ f"/api/projects/{project_id}/branches/{branch}/impact/{symbol_id}",
238
+ params={"depth": depth},
239
+ )
240
+
241
+ def get_symbol(
242
+ self, project_id: str, branch: str, symbol_id: str
243
+ ) -> dict:
244
+ return self._request(
245
+ "GET",
246
+ f"/api/projects/{project_id}/branches/{branch}/symbols/{symbol_id}",
247
+ )
248
+
249
+ def get_clusters(self, project_id: str, branch: str) -> list[dict]:
250
+ return self._request(
251
+ "GET",
252
+ f"/api/projects/{project_id}/branches/{branch}/clusters",
253
+ )
254
+
255
+ # ------------------------------------------------------------------
256
+ # Jobs
257
+ # ------------------------------------------------------------------
258
+
259
+ def get_job(self, job_id: int) -> dict:
260
+ return self._request("GET", f"/api/jobs/{job_id}")
261
+
262
+ def poll_job(
263
+ self,
264
+ job_id: int,
265
+ interval: float = 2.0,
266
+ timeout: float = 600.0,
267
+ ) -> dict:
268
+ """Block until the job reaches a terminal state or *timeout* elapses."""
269
+ deadline = time.monotonic() + timeout
270
+ while True:
271
+ job = self.get_job(job_id)
272
+ status = job.get("status", "")
273
+ if status in ("done", "failed", "cancelled"):
274
+ return job
275
+ if time.monotonic() >= deadline:
276
+ raise TimeoutError(
277
+ f"Job {job_id} did not complete within {timeout}s"
278
+ )
279
+ time.sleep(interval)
@@ -0,0 +1,52 @@
1
+ """Client configuration — manages ~/.ravmem/client.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from ravmem.config import GLOBAL_DIR
9
+
10
+ CONFIG_PATH = GLOBAL_DIR / "client.json"
11
+
12
+ _DEFAULTS = {
13
+ "server_url": "",
14
+ "api_key": "",
15
+ "default_project": "",
16
+ "default_branch": "",
17
+ }
18
+
19
+
20
+ def load_config() -> dict:
21
+ """Read client config from disk; returns defaults if file is missing."""
22
+ if not CONFIG_PATH.exists():
23
+ return dict(_DEFAULTS)
24
+ try:
25
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
26
+ # Merge with defaults so new keys are always present
27
+ merged = dict(_DEFAULTS)
28
+ merged.update(data)
29
+ return merged
30
+ except (json.JSONDecodeError, OSError):
31
+ return dict(_DEFAULTS)
32
+
33
+
34
+ def save_config(config: dict) -> None:
35
+ """Write client config to disk, creating parent dirs if needed."""
36
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
37
+ CONFIG_PATH.write_text(
38
+ json.dumps(config, indent=2) + "\n", encoding="utf-8"
39
+ )
40
+
41
+
42
+ def get_client_from_config():
43
+ """Return a RavMemClient from saved config, or None if not configured."""
44
+ from ravmem.client.api import RavMemClient
45
+
46
+ cfg = load_config()
47
+ if not cfg.get("server_url"):
48
+ return None
49
+ return RavMemClient(
50
+ base_url=cfg["server_url"],
51
+ api_key=cfg.get("api_key") or None,
52
+ )
ravmem/config.py ADDED
@@ -0,0 +1,103 @@
1
+ """Global constants and configuration defaults for RavMem."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ # Storage directory inside each indexed repo
7
+ INDEX_DIR = ".ravmem"
8
+
9
+ # Sub-paths within INDEX_DIR
10
+ GRAPH_SUBDIR = "graph"
11
+ VECTORS_SUBDIR = "vectors"
12
+ META_DB_NAME = "meta.db"
13
+ BM25_INDEX_NAME = "bm25.json"
14
+ CONFIG_SNAPSHOT_NAME = "config.json"
15
+
16
+ # Global user-level directory
17
+ GLOBAL_DIR = Path.home() / ".ravmem"
18
+ MODELS_DIR = GLOBAL_DIR / "models"
19
+ REGISTRY_DB = GLOBAL_DIR / "registry.db"
20
+
21
+ # Server data directory (centralised index storage for server mode)
22
+ SERVER_DATA_DIR = Path(os.environ.get("RAVMEM_DATA_DIR", str(GLOBAL_DIR / "server")))
23
+
24
+ # API key for server authentication (empty = auth disabled)
25
+ RAVMEM_API_KEY = os.environ.get("RAVMEM_API_KEY", "")
26
+
27
+ # Auth system config
28
+ RAVMEM_ADMIN_TOKEN = os.environ.get("RAVMEM_ADMIN_TOKEN", "")
29
+ RAVMEM_SYSTEM_DB = os.environ.get("RAVMEM_SYSTEM_DB", "ravmem_system")
30
+ RAVMEM_AUDIT_LOG_RETENTION_DAYS = int(os.environ.get("RAVMEM_AUDIT_LOG_RETENTION_DAYS", "90"))
31
+
32
+ # ArcadeDB connection config
33
+ ARCADEDB_HOST = os.environ.get("ARCADEDB_HOST", "localhost")
34
+ ARCADEDB_PORT = int(os.environ.get("ARCADEDB_PORT", "2480"))
35
+ ARCADEDB_PASSWORD = os.environ.get("ARCADEDB_PASSWORD", "arcadedb")
36
+
37
+ # Shared ArcadeDB database name (single DB for all branches; isolation via branch property)
38
+ ARCADE_DB_NAME = "codegraph"
39
+
40
+ # Embedding model
41
+ EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
42
+ EMBEDDING_DIM = 384
43
+
44
+ # Server defaults
45
+ DEFAULT_HOST = "0.0.0.0"
46
+ DEFAULT_PORT = 7432
47
+
48
+ # Indexing defaults
49
+ DEFAULT_LANGS = ["python", "typescript", "javascript", "go"]
50
+ MAX_FILE_SIZE_KB = 500 # skip files larger than this
51
+
52
+ # Clustering
53
+ CLUSTER_SEED = int(os.environ.get("RAVMEM_CLUSTER_SEED", "42"))
54
+ CLUSTER_RESOLUTION = float(os.environ.get("RAVMEM_CLUSTER_RESOLUTION", "1.0"))
55
+ CLUSTER_MAX_SIZE = int(os.environ.get("RAVMEM_CLUSTER_MAX_SIZE", "500"))
56
+ CLUSTER_SUBCLUSTER_THRESHOLD = int(os.environ.get("RAVMEM_CLUSTER_SUBCLUSTER_THRESHOLD", "200"))
57
+
58
+ # OIDC / SSO config (all optional; OIDC is disabled when OIDC_ISSUER_URL is not set)
59
+ OIDC_ENABLED = os.environ.get("RAVMEM_OIDC_ENABLED", "").lower() in ("1", "true", "yes")
60
+ OIDC_ISSUER_URL = os.environ.get("RAVMEM_OIDC_ISSUER_URL", "").rstrip("/")
61
+ OIDC_CLIENT_ID = os.environ.get("RAVMEM_OIDC_CLIENT_ID", "")
62
+ OIDC_CLIENT_SECRET = os.environ.get("RAVMEM_OIDC_CLIENT_SECRET", "")
63
+ OIDC_SCOPES = os.environ.get("RAVMEM_OIDC_SCOPES", "openid email profile")
64
+
65
+ # Language → file extension mapping
66
+ LANG_EXTENSIONS: dict[str, list[str]] = {
67
+ "python": [".py"],
68
+ "typescript": [".ts", ".tsx"],
69
+ "javascript": [".js", ".jsx"],
70
+ "go": [".go"],
71
+ "rust": [".rs"],
72
+ "java": [".java"],
73
+ "csharp": [".cs"],
74
+ "php": [".php"],
75
+ "ruby": [".rb"],
76
+ "kotlin": [".kt", ".kts"],
77
+ "c": [".c", ".h"],
78
+ "cpp": [".cpp", ".cc", ".cxx", ".hpp"],
79
+ "shell": [".sh", ".bash"],
80
+ }
81
+
82
+ EXT_TO_LANG: dict[str, str] = {
83
+ ext: lang for lang, exts in LANG_EXTENSIONS.items() for ext in exts
84
+ }
85
+
86
+ # Default ignore patterns (similar to .gitignore)
87
+ DEFAULT_IGNORE_PATTERNS = [
88
+ ".git",
89
+ ".ravmem",
90
+ "__pycache__",
91
+ "node_modules",
92
+ ".venv",
93
+ "venv",
94
+ "env",
95
+ "dist",
96
+ "build",
97
+ "*.egg-info",
98
+ ".mypy_cache",
99
+ ".pytest_cache",
100
+ ".ruff_cache",
101
+ "*.min.js",
102
+ "*.bundle.js",
103
+ ]