sari 0.0.1__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.
Files changed (58) hide show
  1. app/__init__.py +1 -0
  2. app/config.py +240 -0
  3. app/db.py +932 -0
  4. app/dedup_queue.py +77 -0
  5. app/engine_registry.py +56 -0
  6. app/engine_runtime.py +472 -0
  7. app/http_server.py +204 -0
  8. app/indexer.py +1532 -0
  9. app/main.py +147 -0
  10. app/models.py +39 -0
  11. app/queue_pipeline.py +65 -0
  12. app/ranking.py +144 -0
  13. app/registry.py +172 -0
  14. app/search_engine.py +572 -0
  15. app/watcher.py +124 -0
  16. app/workspace.py +286 -0
  17. deckard/__init__.py +3 -0
  18. deckard/__main__.py +4 -0
  19. deckard/main.py +345 -0
  20. deckard/version.py +1 -0
  21. mcp/__init__.py +1 -0
  22. mcp/__main__.py +19 -0
  23. mcp/cli.py +485 -0
  24. mcp/daemon.py +149 -0
  25. mcp/proxy.py +304 -0
  26. mcp/registry.py +218 -0
  27. mcp/server.py +519 -0
  28. mcp/session.py +234 -0
  29. mcp/telemetry.py +112 -0
  30. mcp/test_cli.py +89 -0
  31. mcp/test_daemon.py +124 -0
  32. mcp/test_server.py +197 -0
  33. mcp/tools/__init__.py +14 -0
  34. mcp/tools/_util.py +244 -0
  35. mcp/tools/deckard_guide.py +32 -0
  36. mcp/tools/doctor.py +208 -0
  37. mcp/tools/get_callers.py +60 -0
  38. mcp/tools/get_implementations.py +60 -0
  39. mcp/tools/index_file.py +75 -0
  40. mcp/tools/list_files.py +138 -0
  41. mcp/tools/read_file.py +48 -0
  42. mcp/tools/read_symbol.py +99 -0
  43. mcp/tools/registry.py +212 -0
  44. mcp/tools/repo_candidates.py +89 -0
  45. mcp/tools/rescan.py +46 -0
  46. mcp/tools/scan_once.py +54 -0
  47. mcp/tools/search.py +208 -0
  48. mcp/tools/search_api_endpoints.py +72 -0
  49. mcp/tools/search_symbols.py +63 -0
  50. mcp/tools/status.py +135 -0
  51. sari/__init__.py +1 -0
  52. sari/__main__.py +4 -0
  53. sari-0.0.1.dist-info/METADATA +521 -0
  54. sari-0.0.1.dist-info/RECORD +58 -0
  55. sari-0.0.1.dist-info/WHEEL +5 -0
  56. sari-0.0.1.dist-info/entry_points.txt +2 -0
  57. sari-0.0.1.dist-info/licenses/LICENSE +21 -0
  58. sari-0.0.1.dist-info/top_level.txt +4 -0
@@ -0,0 +1,60 @@
1
+ import json
2
+ from typing import Any, Dict, List
3
+ try:
4
+ from ._util import mcp_response, pack_header, pack_line, pack_encode_id, pack_encode_text, resolve_root_ids, pack_error, ErrorCode
5
+ except ImportError:
6
+ from _util import mcp_response, pack_header, pack_line, pack_encode_id, pack_encode_text, resolve_root_ids, pack_error, ErrorCode
7
+
8
+ def execute_get_callers(args: Dict[str, Any], db: Any, roots: List[str]) -> Dict[str, Any]:
9
+ """Find symbols that call a specific symbol."""
10
+ target_symbol = args.get("name", "").strip()
11
+ if not target_symbol:
12
+ return mcp_response(
13
+ "get_callers",
14
+ lambda: pack_error("get_callers", ErrorCode.INVALID_ARGS, "Symbol name is required"),
15
+ lambda: {"error": {"code": ErrorCode.INVALID_ARGS.value, "message": "Symbol name is required"}, "isError": True},
16
+ )
17
+
18
+ # Search in symbol_relations table
19
+ sql = """
20
+ SELECT from_path, from_symbol, line, rel_type
21
+ FROM symbol_relations
22
+ WHERE to_symbol = ?
23
+ ORDER BY from_path, line
24
+ """
25
+ params = [target_symbol]
26
+ root_ids = resolve_root_ids(roots)
27
+ if root_ids:
28
+ root_clause = " OR ".join(["from_path LIKE ?"] * len(root_ids))
29
+ sql = sql.replace("ORDER BY", f"AND ({root_clause}) ORDER BY")
30
+ params.extend([f"{rid}/%" for rid in root_ids])
31
+
32
+ with db._read_lock:
33
+ rows = db._read.execute(sql, params).fetchall()
34
+
35
+ results = []
36
+ for r in rows:
37
+ results.append({
38
+ "caller_path": r["from_path"],
39
+ "caller_symbol": r["from_symbol"],
40
+ "line": r["line"],
41
+ "rel_type": r["rel_type"]
42
+ })
43
+
44
+ def build_pack() -> str:
45
+ lines = [pack_header("get_callers", {"name": pack_encode_text(target_symbol)}, returned=len(results))]
46
+ for r in results:
47
+ kv = {
48
+ "caller_path": pack_encode_id(r["caller_path"]),
49
+ "caller_symbol": pack_encode_id(r["caller_symbol"]),
50
+ "line": str(r["line"]),
51
+ "rel_type": pack_encode_id(r["rel_type"]),
52
+ }
53
+ lines.append(pack_line("r", kv))
54
+ return "\n".join(lines)
55
+
56
+ return mcp_response(
57
+ "get_callers",
58
+ build_pack,
59
+ lambda: {"target": target_symbol, "results": results, "count": len(results)},
60
+ )
@@ -0,0 +1,60 @@
1
+ import json
2
+ from typing import Any, Dict, List
3
+ try:
4
+ from ._util import mcp_response, pack_header, pack_line, pack_encode_id, pack_encode_text, resolve_root_ids, pack_error, ErrorCode
5
+ except ImportError:
6
+ from _util import mcp_response, pack_header, pack_line, pack_encode_id, pack_encode_text, resolve_root_ids, pack_error, ErrorCode
7
+
8
+ def execute_get_implementations(args: Dict[str, Any], db: Any, roots: List[str]) -> Dict[str, Any]:
9
+ """Find symbols that implement or extend a specific symbol."""
10
+ target_symbol = args.get("name", "").strip()
11
+ if not target_symbol:
12
+ return mcp_response(
13
+ "get_implementations",
14
+ lambda: pack_error("get_implementations", ErrorCode.INVALID_ARGS, "Symbol name is required"),
15
+ lambda: {"error": {"code": ErrorCode.INVALID_ARGS.value, "message": "Symbol name is required"}, "isError": True},
16
+ )
17
+
18
+ # Search in symbol_relations table for implements and extends relations
19
+ sql = """
20
+ SELECT from_path, from_symbol, rel_type, line
21
+ FROM symbol_relations
22
+ WHERE to_symbol = ? AND (rel_type = 'implements' OR rel_type = 'extends')
23
+ ORDER BY from_path, line
24
+ """
25
+ params = [target_symbol]
26
+ root_ids = resolve_root_ids(roots)
27
+ if root_ids:
28
+ root_clause = " OR ".join(["from_path LIKE ?"] * len(root_ids))
29
+ sql = sql.replace("ORDER BY", f"AND ({root_clause}) ORDER BY")
30
+ params.extend([f"{rid}/%" for rid in root_ids])
31
+
32
+ with db._read_lock:
33
+ rows = db._read.execute(sql, params).fetchall()
34
+
35
+ results = []
36
+ for r in rows:
37
+ results.append({
38
+ "implementer_path": r["from_path"],
39
+ "implementer_symbol": r["from_symbol"],
40
+ "rel_type": r["rel_type"],
41
+ "line": r["line"]
42
+ })
43
+
44
+ def build_pack() -> str:
45
+ lines = [pack_header("get_implementations", {"name": pack_encode_text(target_symbol)}, returned=len(results))]
46
+ for r in results:
47
+ kv = {
48
+ "implementer_path": pack_encode_id(r["implementer_path"]),
49
+ "implementer_symbol": pack_encode_id(r["implementer_symbol"]),
50
+ "rel_type": pack_encode_id(r["rel_type"]),
51
+ "line": str(r["line"]),
52
+ }
53
+ lines.append(pack_line("r", kv))
54
+ return "\n".join(lines)
55
+
56
+ return mcp_response(
57
+ "get_implementations",
58
+ build_pack,
59
+ lambda: {"target": target_symbol, "results": results, "count": len(results)},
60
+ )
@@ -0,0 +1,75 @@
1
+ import time
2
+ from typing import Any, Dict, List
3
+
4
+ try:
5
+ from app.queue_pipeline import FsEvent, FsEventKind
6
+ except Exception:
7
+ FsEvent = None
8
+ FsEventKind = None
9
+
10
+ from mcp.tools._util import mcp_response, pack_error, ErrorCode, resolve_db_path, pack_header, pack_line, pack_encode_id
11
+
12
+ def execute_index_file(args: Dict[str, Any], indexer: Any, roots: List[str]) -> Dict[str, Any]:
13
+ """Force immediate re-indexing of a specific file."""
14
+ path = args.get("path", "").strip()
15
+ if not path:
16
+ return mcp_response(
17
+ "index_file",
18
+ lambda: pack_error("index_file", ErrorCode.INVALID_ARGS, "File path is required"),
19
+ lambda: {"error": {"code": ErrorCode.INVALID_ARGS.value, "message": "File path is required"}, "isError": True},
20
+ )
21
+
22
+ if not indexer:
23
+ return mcp_response(
24
+ "index_file",
25
+ lambda: pack_error("index_file", ErrorCode.INTERNAL, "Indexer not available"),
26
+ lambda: {"error": {"code": ErrorCode.INTERNAL.value, "message": "Indexer not available"}, "isError": True},
27
+ )
28
+
29
+ if not getattr(indexer, "indexing_enabled", True):
30
+ mode = getattr(indexer, "indexer_mode", "off")
31
+ code = ErrorCode.ERR_INDEXER_DISABLED if mode == "off" else ErrorCode.ERR_INDEXER_FOLLOWER
32
+ return mcp_response(
33
+ "index_file",
34
+ lambda: pack_error("index_file", code, "Indexer is not available in follower/off mode", fields={"mode": mode}),
35
+ lambda: {"error": {"code": code.value, "message": "Indexer is not available in follower/off mode", "data": {"mode": mode}}, "isError": True},
36
+ )
37
+
38
+ db_path = resolve_db_path(path, roots)
39
+ if not db_path:
40
+ return mcp_response(
41
+ "index_file",
42
+ lambda: pack_error("index_file", ErrorCode.ERR_ROOT_OUT_OF_SCOPE, f"Path out of scope: {path}", hints=["outside final_roots"]),
43
+ lambda: {"error": {"code": ErrorCode.ERR_ROOT_OUT_OF_SCOPE.value, "message": f"Path out of scope: {path}"}, "isError": True},
44
+ )
45
+
46
+ try:
47
+ fs_path = path
48
+ if hasattr(indexer, "_decode_db_path"):
49
+ decoded = indexer._decode_db_path(db_path) # type: ignore[attr-defined]
50
+ if decoded:
51
+ _, fs_path = decoded
52
+ fs_path = str(fs_path)
53
+ # Trigger watcher event logic which handles upsert/delete
54
+ if FsEvent and FsEventKind:
55
+ evt = FsEvent(kind=FsEventKind.MODIFIED, path=fs_path, dest_path=None, ts=time.time())
56
+ indexer._process_watcher_event(evt)
57
+ else:
58
+ indexer._process_watcher_event(fs_path)
59
+
60
+ def build_pack() -> str:
61
+ lines = [pack_header("index_file", {}, returned=1)]
62
+ lines.append(pack_line("m", {"path": pack_encode_id(db_path), "requested": "true"}))
63
+ return "\n".join(lines)
64
+
65
+ return mcp_response(
66
+ "index_file",
67
+ build_pack,
68
+ lambda: {"success": True, "path": db_path, "message": f"Successfully requested re-indexing for {db_path}"},
69
+ )
70
+ except Exception as e:
71
+ return mcp_response(
72
+ "index_file",
73
+ lambda: pack_error("index_file", ErrorCode.INTERNAL, str(e)),
74
+ lambda: {"error": {"code": ErrorCode.INTERNAL.value, "message": str(e)}, "isError": True},
75
+ )
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ List files tool for Local Search MCP Server.
4
+ """
5
+ import time
6
+ from typing import Any, Dict, List
7
+
8
+ try:
9
+ from app.db import LocalSearchDB
10
+ from mcp.telemetry import TelemetryLogger
11
+ from mcp.tools._util import mcp_response, pack_header, pack_line, pack_truncated, pack_encode_id, resolve_root_ids
12
+ except ImportError:
13
+ # Fallback for direct script execution
14
+ import sys
15
+ from pathlib import Path
16
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
17
+ from app.db import LocalSearchDB
18
+ from mcp.telemetry import TelemetryLogger
19
+ from mcp.tools._util import mcp_response, pack_header, pack_line, pack_truncated, pack_encode_id, resolve_root_ids
20
+
21
+
22
+ def execute_list_files(args: Dict[str, Any], db: LocalSearchDB, logger: TelemetryLogger, roots: List[str]) -> Dict[str, Any]:
23
+ """Execute list_files tool."""
24
+ start_ts = time.time()
25
+ root_ids = resolve_root_ids(roots)
26
+
27
+ # Parse args
28
+ repo = args.get("repo")
29
+ path_pattern = args.get("path_pattern")
30
+ file_types = args.get("file_types")
31
+ include_hidden = bool(args.get("include_hidden", False))
32
+ try:
33
+ offset = int(args.get("offset", 0))
34
+ except (ValueError, TypeError):
35
+ offset = 0
36
+
37
+ try:
38
+ limit_arg = int(args.get("limit", 100))
39
+ except (ValueError, TypeError):
40
+ limit_arg = 100
41
+
42
+ # --- JSON Builder (Legacy) ---
43
+ def build_json() -> Dict[str, Any]:
44
+ summary_only = bool(args.get("summary", False)) or (not repo and not path_pattern and not file_types)
45
+
46
+ if summary_only:
47
+ repo_stats = db.get_repo_stats(root_ids=root_ids)
48
+ repos = [{"repo": k, "file_count": v} for k, v in repo_stats.items()]
49
+ repos.sort(key=lambda r: r["file_count"], reverse=True)
50
+ total = sum(repo_stats.values())
51
+ return {
52
+ "files": [],
53
+ "meta": {
54
+ "total": total,
55
+ "returned": 0,
56
+ "offset": 0,
57
+ "limit": 0,
58
+ "repos": repos,
59
+ "include_hidden": include_hidden,
60
+ "mode": "summary",
61
+ },
62
+ }
63
+ else:
64
+ files, meta = db.list_files(
65
+ repo=repo,
66
+ path_pattern=path_pattern,
67
+ file_types=file_types,
68
+ include_hidden=include_hidden,
69
+ limit=limit_arg,
70
+ offset=offset,
71
+ root_ids=root_ids,
72
+ )
73
+ return {
74
+ "files": files,
75
+ "meta": meta,
76
+ }
77
+
78
+ # --- PACK1 Builder ---
79
+ def build_pack() -> str:
80
+ # Hard limit for PACK1: 200
81
+ pack_limit = min(limit_arg, 200)
82
+
83
+ files, meta = db.list_files(
84
+ repo=repo,
85
+ path_pattern=path_pattern,
86
+ file_types=file_types,
87
+ include_hidden=include_hidden,
88
+ limit=pack_limit,
89
+ offset=offset,
90
+ root_ids=root_ids,
91
+ )
92
+
93
+ total = meta.get("total", 0)
94
+ returned = len(files)
95
+ total_mode = "exact" # list_files usually returns exact counts via DB
96
+
97
+ # Header
98
+ kv = {
99
+ "offset": offset,
100
+ "limit": pack_limit
101
+ }
102
+ lines = [
103
+ pack_header("list_files", kv, returned=returned, total=total, total_mode=total_mode)
104
+ ]
105
+
106
+ # Records
107
+ for f in files:
108
+ # p:<path> (ENC_ID)
109
+ path_enc = pack_encode_id(f["path"])
110
+ lines.append(pack_line("p", single_value=path_enc))
111
+
112
+ # Truncation
113
+ is_truncated = (offset + returned) < total
114
+ if is_truncated:
115
+ next_offset = offset + returned
116
+ lines.append(pack_truncated(next_offset, pack_limit, "true"))
117
+
118
+ return "\n".join(lines)
119
+
120
+ # Execute and Telemetry
121
+ response = mcp_response("list_files", build_pack, build_json)
122
+
123
+ # Telemetry logging
124
+ latency_ms = int((time.time() - start_ts) * 1000)
125
+ # Estimate payload size (rough)
126
+ payload_text = response["content"][0]["text"]
127
+ payload_bytes = len(payload_text.encode('utf-8'))
128
+
129
+ # Log simplified telemetry
130
+ repo_val = repo or "all"
131
+ item_count = payload_text.count('\n') if "PACK1" in payload_text else 0 # Approximation for PACK
132
+ if "PACK1" not in payload_text:
133
+ # Rough count for JSON without parsing
134
+ item_count = payload_text.count('"path":')
135
+
136
+ logger.log_telemetry(f"tool=list_files repo='{repo_val}' items={item_count} payload_bytes={payload_bytes} latency={latency_ms}ms")
137
+
138
+ return response
mcp/tools/read_file.py ADDED
@@ -0,0 +1,48 @@
1
+ from typing import Any, Dict, List
2
+ from app.db import LocalSearchDB
3
+ from mcp.tools._util import mcp_response, pack_error, ErrorCode, resolve_db_path, pack_header, pack_line, pack_encode_text
4
+
5
+ def execute_read_file(args: Dict[str, Any], db: LocalSearchDB, roots: List[str]) -> Dict[str, Any]:
6
+ """
7
+ Execute read_file tool.
8
+
9
+ Args:
10
+ args: {"path": str}
11
+ db: LocalSearchDB instance
12
+ """
13
+ path = args.get("path")
14
+ if not path:
15
+ return mcp_response(
16
+ "read_file",
17
+ lambda: pack_error("read_file", ErrorCode.INVALID_ARGS, "'path' is required"),
18
+ lambda: {"error": {"code": ErrorCode.INVALID_ARGS.value, "message": "'path' is required"}, "isError": True},
19
+ )
20
+
21
+ db_path = resolve_db_path(path, roots)
22
+ if not db_path and db.has_legacy_paths():
23
+ db_path = path
24
+ if not db_path:
25
+ return mcp_response(
26
+ "read_file",
27
+ lambda: pack_error("read_file", ErrorCode.ERR_ROOT_OUT_OF_SCOPE, f"Path out of scope: {path}", hints=["outside final_roots"]),
28
+ lambda: {"error": {"code": ErrorCode.ERR_ROOT_OUT_OF_SCOPE.value, "message": f"Path out of scope: {path}"}, "isError": True},
29
+ )
30
+
31
+ content = db.read_file(db_path)
32
+ if content is None:
33
+ return mcp_response(
34
+ "read_file",
35
+ lambda: pack_error("read_file", ErrorCode.NOT_INDEXED, f"File not found or not indexed: {db_path}"),
36
+ lambda: {"error": {"code": ErrorCode.NOT_INDEXED.value, "message": f"File not found or not indexed: {db_path}"}, "isError": True},
37
+ )
38
+
39
+ def build_pack() -> str:
40
+ lines = [pack_header("read_file", {}, returned=1)]
41
+ lines.append(pack_line("t", single_value=pack_encode_text(content)))
42
+ return "\n".join(lines)
43
+
44
+ return mcp_response(
45
+ "read_file",
46
+ build_pack,
47
+ lambda: {"content": [{"type": "text", "text": content}]},
48
+ )
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Read Symbol Tool for Local Search MCP Server.
4
+ Reads only the specific code block (function/class) of a symbol.
5
+ """
6
+ import json
7
+ import time
8
+ from typing import Any, Dict, List
9
+
10
+ try:
11
+ from app.db import LocalSearchDB
12
+ from mcp.telemetry import TelemetryLogger
13
+ from mcp.tools._util import mcp_response, pack_error, ErrorCode, resolve_db_path, pack_header, pack_line, pack_encode_text
14
+ except ImportError:
15
+ import sys
16
+ from pathlib import Path
17
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
18
+ from app.db import LocalSearchDB
19
+ from mcp.telemetry import TelemetryLogger
20
+ from mcp.tools._util import mcp_response, pack_error, ErrorCode, resolve_db_path, pack_header, pack_line, pack_encode_text
21
+
22
+
23
+ def execute_read_symbol(args: Dict[str, Any], db: LocalSearchDB, logger: TelemetryLogger, roots: List[str]) -> Dict[str, Any]:
24
+ """Execute read_symbol tool (v2.7.0)."""
25
+ start_ts = time.time()
26
+
27
+ path = args.get("path")
28
+ symbol_name = args.get("name")
29
+
30
+ if not path or not symbol_name:
31
+ return mcp_response(
32
+ "read_symbol",
33
+ lambda: pack_error("read_symbol", ErrorCode.INVALID_ARGS, "'path' and 'name' are required."),
34
+ lambda: {"error": {"code": ErrorCode.INVALID_ARGS.value, "message": "'path' and 'name' are required."}, "isError": True},
35
+ )
36
+
37
+ db_path = resolve_db_path(path, roots)
38
+ if not db_path and db.has_legacy_paths():
39
+ db_path = path
40
+ if not db_path:
41
+ return mcp_response(
42
+ "read_symbol",
43
+ lambda: pack_error("read_symbol", ErrorCode.ERR_ROOT_OUT_OF_SCOPE, f"Path out of scope: {path}", hints=["outside final_roots"]),
44
+ lambda: {"error": {"code": ErrorCode.ERR_ROOT_OUT_OF_SCOPE.value, "message": f"Path out of scope: {path}"}, "isError": True},
45
+ )
46
+
47
+ block = db.get_symbol_block(db_path, symbol_name)
48
+
49
+ latency_ms = int((time.time() - start_ts) * 1000)
50
+ logger.log_telemetry(f"tool=read_symbol path='{path}' name='{symbol_name}' found={bool(block)} latency={latency_ms}ms")
51
+
52
+ if not block:
53
+ return mcp_response(
54
+ "read_symbol",
55
+ lambda: pack_error("read_symbol", ErrorCode.NOT_INDEXED, f"Symbol '{symbol_name}' not found in '{db_path}' (or no block range available)."),
56
+ lambda: {"error": {"code": ErrorCode.NOT_INDEXED.value, "message": f"Symbol '{symbol_name}' not found in '{db_path}' (or no block range available)."}, "isError": True},
57
+ )
58
+
59
+ # Format output
60
+ doc = block.get('docstring', '')
61
+ meta = block.get('metadata', '{}')
62
+
63
+ header = [
64
+ f"File: {db_path}",
65
+ f"Symbol: {block['name']}",
66
+ f"Range: L{block['start_line']} - L{block['end_line']}"
67
+ ]
68
+
69
+ try:
70
+ m = json.loads(meta)
71
+ if m.get("annotations"):
72
+ header.append(f"Annotations: {', '.join(m['annotations'])}")
73
+ if m.get("http_path"):
74
+ header.append(f"API Endpoint: {m['http_path']}")
75
+ except: pass
76
+
77
+ output_lines = [
78
+ "\n".join(header),
79
+ "--------------------------------------------------"
80
+ ]
81
+
82
+ if doc:
83
+ output_lines.append(f"/* DOCSTRING */\n{doc}\n")
84
+
85
+ output_lines.append(block['content'])
86
+ output_lines.append("--------------------------------------------------")
87
+
88
+ output = "\n".join(output_lines)
89
+
90
+ def build_pack() -> str:
91
+ lines = [pack_header("read_symbol", {}, returned=1)]
92
+ lines.append(pack_line("t", single_value=pack_encode_text(output)))
93
+ return "\n".join(lines)
94
+
95
+ return mcp_response(
96
+ "read_symbol",
97
+ build_pack,
98
+ lambda: {"content": [{"type": "text", "text": output}]},
99
+ )