SourceIndex 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.
Files changed (47) hide show
  1. sourceindex/__init__.py +30 -0
  2. sourceindex/build/__init__.py +592 -0
  3. sourceindex/build/indexer.py +403 -0
  4. sourceindex/build/linerange/__init__.py +24 -0
  5. sourceindex/build/linerange/python_ast.py +155 -0
  6. sourceindex/build/linerange/treesitter.py +397 -0
  7. sourceindex/build/prompts.py +76 -0
  8. sourceindex/build/state.py +261 -0
  9. sourceindex/build/walker.py +94 -0
  10. sourceindex/claudecode/__init__.py +0 -0
  11. sourceindex/claudecode/savings.py +325 -0
  12. sourceindex/claudecode/savings_summary.py +88 -0
  13. sourceindex/claudecode/statusline.py +116 -0
  14. sourceindex/cli/__init__.py +304 -0
  15. sourceindex/cli/__main__.py +10 -0
  16. sourceindex/cli/api_key.py +171 -0
  17. sourceindex/cli/commands.py +678 -0
  18. sourceindex/cli/install.py +378 -0
  19. sourceindex/daemon/__init__.py +83 -0
  20. sourceindex/daemon/client.py +141 -0
  21. sourceindex/daemon/crypto.py +48 -0
  22. sourceindex/daemon/keyring_store.py +129 -0
  23. sourceindex/daemon/lifecycle.py +237 -0
  24. sourceindex/daemon/protocol.py +134 -0
  25. sourceindex/daemon/server.py +389 -0
  26. sourceindex/daemon/store.py +426 -0
  27. sourceindex/lib/__init__.py +0 -0
  28. sourceindex/lib/backend.py +240 -0
  29. sourceindex/lib/cost.py +96 -0
  30. sourceindex/lib/env.py +30 -0
  31. sourceindex/lib/git.py +33 -0
  32. sourceindex/lib/languages.py +406 -0
  33. sourceindex/lib/llm.py +298 -0
  34. sourceindex/lib/log.py +228 -0
  35. sourceindex/lib/registry.py +75 -0
  36. sourceindex/lib/timing.py +10 -0
  37. sourceindex/search/__init__.py +251 -0
  38. sourceindex/search/experiments.py +276 -0
  39. sourceindex/search/imports.py +155 -0
  40. sourceindex/search/passes.py +51 -0
  41. sourceindex/search/prompts.py +379 -0
  42. sourceindex/search/roadmap.py +265 -0
  43. sourceindex/server.py +127 -0
  44. sourceindex-0.1.0.dist-info/METADATA +73 -0
  45. sourceindex-0.1.0.dist-info/RECORD +47 -0
  46. sourceindex-0.1.0.dist-info/WHEEL +4 -0
  47. sourceindex-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,129 @@
1
+ """Per-repo master key: OS keychain when available, mode-0600 file fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from . import crypto
11
+
12
+
13
+ _log = logging.getLogger(__name__)
14
+
15
+
16
+ KEYRING_SERVICE = "sourceindex"
17
+ ENV_KEY_FILE_DIR = "SOURCEINDEX_KEY_DIR"
18
+
19
+
20
+ def _key_file_dir() -> Path:
21
+ override = os.environ.get(ENV_KEY_FILE_DIR)
22
+ if override:
23
+ return Path(override).expanduser().resolve()
24
+ xdg = os.environ.get("XDG_CONFIG_HOME")
25
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
26
+ return base / "sourceindex" / "keys"
27
+
28
+
29
+ def _try_keyring_get(repo_hash: str) -> bytes | None:
30
+ try:
31
+ import keyring # type: ignore[import-not-found]
32
+ import keyring.errors # noqa: F401 type: ignore[import-not-found]
33
+ except Exception:
34
+ return None
35
+ try:
36
+ encoded = keyring.get_password(KEYRING_SERVICE, repo_hash)
37
+ except Exception as e:
38
+ _log.debug("keyring.get_password failed: %s", e)
39
+ return None
40
+ if not encoded:
41
+ return None
42
+ try:
43
+ key = base64.b64decode(encoded)
44
+ except Exception:
45
+ return None
46
+ if len(key) != crypto.KEY_LEN:
47
+ return None
48
+ return key
49
+
50
+
51
+ def _try_keyring_set(repo_hash: str, key: bytes) -> bool:
52
+ try:
53
+ import keyring # type: ignore[import-not-found]
54
+ except Exception:
55
+ return False
56
+ encoded = base64.b64encode(key).decode("ascii")
57
+ try:
58
+ keyring.set_password(KEYRING_SERVICE, repo_hash, encoded)
59
+ except Exception as e:
60
+ _log.debug("keyring.set_password failed: %s", e)
61
+ return False
62
+ return True
63
+
64
+
65
+ def _read_key_file(path: Path) -> bytes | None:
66
+ try:
67
+ raw = path.read_bytes()
68
+ except FileNotFoundError:
69
+ return None
70
+ if len(raw) != crypto.KEY_LEN:
71
+ # Refuse rather than silently regenerate — operator may have hand-edited.
72
+ raise ValueError(
73
+ f"Key file at {path} is {len(raw)} bytes; expected {crypto.KEY_LEN}."
74
+ )
75
+ return raw
76
+
77
+
78
+ def _write_key_file(path: Path, key: bytes) -> None:
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ try:
81
+ path.parent.chmod(0o700)
82
+ except OSError:
83
+ pass
84
+ # O_EXCL+0o600 avoids a window where the key sits at the default umask.
85
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
86
+ try:
87
+ os.write(fd, key)
88
+ finally:
89
+ os.close(fd)
90
+
91
+
92
+ def load_or_create_key(repo_hash: str) -> bytes:
93
+ key = _try_keyring_get(repo_hash)
94
+ if key is not None:
95
+ return key
96
+
97
+ key_path = _key_file_dir() / f"{repo_hash}.key"
98
+ key = _read_key_file(key_path)
99
+ if key is not None:
100
+ return key
101
+
102
+ key = crypto.generate_key()
103
+ if _try_keyring_set(repo_hash, key):
104
+ return key
105
+
106
+ _write_key_file(key_path, key)
107
+ _log.warning(
108
+ "No OS keychain backend available; persisted the encryption key to "
109
+ "%s (mode 0600). Set up gnome-keyring / kwallet / Keychain.app for "
110
+ "stronger at-rest protection. Override the directory with "
111
+ "$SOURCEINDEX_KEY_DIR.",
112
+ key_path,
113
+ )
114
+ return key
115
+
116
+
117
+ def delete_key(repo_hash: str) -> None:
118
+ try:
119
+ import keyring # type: ignore[import-not-found]
120
+ keyring.delete_password(KEYRING_SERVICE, repo_hash)
121
+ except Exception as e:
122
+ _log.debug("keyring.delete_password failed: %s", e)
123
+ key_path = _key_file_dir() / f"{repo_hash}.key"
124
+ try:
125
+ key_path.unlink()
126
+ except FileNotFoundError:
127
+ pass
128
+ except OSError as e:
129
+ _log.debug("unlink %s failed: %s", key_path, e)
@@ -0,0 +1,237 @@
1
+ """Daemon lifecycle: socket paths, spawn-on-first-RPC, pidfile, graceful stop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import fcntl
7
+ import hashlib
8
+ import logging
9
+ import os
10
+ import shutil
11
+ import signal
12
+ import socket
13
+ import sys
14
+ import time
15
+ from contextlib import contextmanager
16
+ from pathlib import Path
17
+
18
+
19
+ _log = logging.getLogger(__name__)
20
+
21
+
22
+ def repo_hash_for(main_worktree: Path) -> str:
23
+ # Resolve symlinks so a symlinked-in path and its target hash the same.
24
+ canonical = str(Path(main_worktree).resolve())
25
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
26
+
27
+
28
+ def runtime_dir() -> Path:
29
+ xdg = os.environ.get("XDG_RUNTIME_DIR")
30
+ if xdg and Path(xdg).is_dir():
31
+ base = Path(xdg) / "sourceindex"
32
+ else:
33
+ # Per-user fallback so two accounts on a shared box don't collide.
34
+ base = Path("/tmp") / f"sourceindex-{os.getuid()}"
35
+ base.mkdir(parents=True, exist_ok=True)
36
+ try:
37
+ base.chmod(0o700)
38
+ except OSError:
39
+ pass
40
+ return base
41
+
42
+
43
+ def user_socket(repo_hash: str) -> Path:
44
+ return runtime_dir() / f"{repo_hash}.sock"
45
+
46
+
47
+ def admin_socket(repo_hash: str) -> Path:
48
+ return runtime_dir() / f"{repo_hash}.admin.sock"
49
+
50
+
51
+ def pidfile(repo_hash: str) -> Path:
52
+ return runtime_dir() / f"{repo_hash}.pid"
53
+
54
+
55
+ def spawn_lock(repo_hash: str) -> Path:
56
+ return runtime_dir() / f"{repo_hash}.spawn.lock"
57
+
58
+
59
+ def progress_file(repo_hash: str) -> Path:
60
+ return runtime_dir() / f"{repo_hash}.progress"
61
+
62
+
63
+ def write_pidfile(repo_hash: str) -> None:
64
+ p = pidfile(repo_hash)
65
+ payload = f"{os.getpid()} {int(time.time())}\n"
66
+ tmp = p.with_suffix(".pid.tmp")
67
+ tmp.write_text(payload)
68
+ os.replace(tmp, p)
69
+
70
+
71
+ def read_pidfile(repo_hash: str) -> tuple[int, int] | None:
72
+ p = pidfile(repo_hash)
73
+ try:
74
+ raw = p.read_text().strip()
75
+ except OSError:
76
+ return None
77
+ parts = raw.split()
78
+ if len(parts) != 2:
79
+ return None
80
+ try:
81
+ return int(parts[0]), int(parts[1])
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ def pid_alive(pid: int) -> bool:
87
+ try:
88
+ os.kill(pid, 0)
89
+ except ProcessLookupError:
90
+ return False
91
+ except PermissionError:
92
+ return True
93
+ return True
94
+
95
+
96
+ def cleanup_stale_files(repo_hash: str) -> None:
97
+ info = read_pidfile(repo_hash)
98
+ if info is not None and pid_alive(info[0]):
99
+ return
100
+ for p in (pidfile(repo_hash), user_socket(repo_hash), admin_socket(repo_hash)):
101
+ try:
102
+ p.unlink()
103
+ except FileNotFoundError:
104
+ pass
105
+
106
+
107
+ @contextmanager
108
+ def spawn_lock_held(repo_hash: str):
109
+ lock_path = spawn_lock(repo_hash)
110
+ lock_path.touch(exist_ok=True)
111
+ fd = os.open(str(lock_path), os.O_RDWR)
112
+ try:
113
+ fcntl.flock(fd, fcntl.LOCK_EX)
114
+ yield
115
+ finally:
116
+ try:
117
+ fcntl.flock(fd, fcntl.LOCK_UN)
118
+ finally:
119
+ os.close(fd)
120
+
121
+
122
+ def _double_fork_exec(argv: list[str]) -> None:
123
+ pid = os.fork()
124
+ if pid > 0:
125
+ os.waitpid(pid, 0)
126
+ return
127
+ os.setsid()
128
+ pid2 = os.fork()
129
+ if pid2 > 0:
130
+ os._exit(0)
131
+ # Reopen stdio onto /dev/null so a stray print() can't leak to the client.
132
+ try:
133
+ fd_null = os.open(os.devnull, os.O_RDWR)
134
+ for target_fd in (0, 1, 2):
135
+ os.dup2(fd_null, target_fd)
136
+ if fd_null > 2:
137
+ os.close(fd_null)
138
+ except OSError:
139
+ pass
140
+ try:
141
+ os.execvp(argv[0], argv)
142
+ except FileNotFoundError as e:
143
+ _log.error("daemon execvp failed: %s", e)
144
+ os._exit(2)
145
+
146
+
147
+ def daemon_argv(repo_root: Path, *, allow_dump: bool) -> list[str]:
148
+ binary = shutil.which("sourceindex") or sys.executable
149
+ if binary == sys.executable:
150
+ argv = [binary, "-m", "sourceindex.cli", "daemon"]
151
+ else:
152
+ argv = [binary, "daemon"]
153
+ argv += ["--repo-root", str(repo_root)]
154
+ if allow_dump:
155
+ argv.append("--allow-dump")
156
+ return argv
157
+
158
+
159
+ def spawn_daemon(repo_root: Path, *, allow_dump: bool, timeout_s: float = 5.0) -> None:
160
+ rh = repo_hash_for(repo_root)
161
+ with spawn_lock_held(rh):
162
+ # File-existence alone is not a liveness signal — a crashed daemon
163
+ # leaves its socket behind. Check pidfile + pid_alive + socket so
164
+ # stale orphans fall through to cleanup_stale_files instead of
165
+ # tricking the client into connecting to a dead listener.
166
+ if is_daemon_running(repo_root):
167
+ return
168
+ cleanup_stale_files(rh)
169
+ _double_fork_exec(daemon_argv(repo_root, allow_dump=allow_dump))
170
+
171
+ deadline = time.monotonic() + timeout_s
172
+ while time.monotonic() < deadline:
173
+ if user_socket(rh).exists():
174
+ return
175
+ time.sleep(0.05)
176
+ raise TimeoutError(
177
+ f"daemon for {repo_root} did not bind {user_socket(rh)} within "
178
+ f"{timeout_s}s — check `sourceindex daemon --repo-root {repo_root}`"
179
+ )
180
+
181
+
182
+ def stop_daemon(repo_root: Path, *, timeout_s: float = 5.0) -> bool:
183
+ rh = repo_hash_for(repo_root)
184
+ info = read_pidfile(rh)
185
+ if info is None or not pid_alive(info[0]):
186
+ cleanup_stale_files(rh)
187
+ return False
188
+ pid, _ = info
189
+ try:
190
+ os.kill(pid, signal.SIGTERM)
191
+ except ProcessLookupError:
192
+ cleanup_stale_files(rh)
193
+ return False
194
+ deadline = time.monotonic() + timeout_s
195
+ while time.monotonic() < deadline:
196
+ if not pid_alive(pid):
197
+ cleanup_stale_files(rh)
198
+ return True
199
+ time.sleep(0.05)
200
+ try:
201
+ os.kill(pid, signal.SIGKILL)
202
+ except ProcessLookupError:
203
+ pass
204
+ cleanup_stale_files(rh)
205
+ return True
206
+
207
+
208
+ def is_daemon_running(repo_root: Path) -> bool:
209
+ rh = repo_hash_for(repo_root)
210
+ info = read_pidfile(rh)
211
+ if info is None:
212
+ return False
213
+ return pid_alive(info[0]) and user_socket(rh).exists()
214
+
215
+
216
+ def connect_blocking(path: Path, *, timeout_s: float = 5.0) -> socket.socket:
217
+ deadline = time.monotonic() + timeout_s
218
+ while True:
219
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
220
+ try:
221
+ s.connect(str(path))
222
+ return s
223
+ except (FileNotFoundError, ConnectionRefusedError) as e:
224
+ s.close()
225
+ if time.monotonic() >= deadline:
226
+ raise FileNotFoundError(
227
+ f"could not connect to {path} within {timeout_s}s: {e}"
228
+ ) from e
229
+ time.sleep(0.05)
230
+ except OSError as e:
231
+ s.close()
232
+ if e.errno in (errno.ENOENT, errno.ECONNREFUSED):
233
+ if time.monotonic() >= deadline:
234
+ raise
235
+ time.sleep(0.05)
236
+ continue
237
+ raise
@@ -0,0 +1,134 @@
1
+ """JSON-RPC 2.0 wire protocol over UDS, length-prefixed frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ from typing import Any
8
+
9
+
10
+ MAX_FRAME_BYTES = 16 * 1024 * 1024
11
+
12
+
13
+ USER_METHODS = frozenset(
14
+ {
15
+ "ping",
16
+ "search",
17
+ "init",
18
+ "update",
19
+ "install_hooks",
20
+ "usage",
21
+ }
22
+ )
23
+
24
+ ADMIN_METHODS = frozenset(
25
+ {
26
+ "dump_all",
27
+ "read_index_md",
28
+ "read_detail",
29
+ "read_env",
30
+ "shutdown",
31
+ }
32
+ )
33
+
34
+
35
+ # JSON-RPC reserved
36
+ E_PARSE_ERROR = -32700
37
+ E_INVALID_REQUEST = -32600
38
+ E_METHOD_NOT_FOUND = -32601
39
+ E_INVALID_PARAMS = -32602
40
+ E_INTERNAL = -32603
41
+ E_SERVER = -32000
42
+
43
+ # SourceIndex-specific
44
+ E_NO_INDEX = 1001
45
+ E_AUTH_REQUIRED = 1002
46
+ E_BUDGET_EXCEEDED = 1003
47
+ E_KEY_UNAVAILABLE = 1004
48
+ E_FRAME_TOO_LARGE = 1005
49
+
50
+
51
+ class RpcError(Exception):
52
+ def __init__(self, code: int, message: str, data: Any | None = None) -> None:
53
+ super().__init__(message)
54
+ self.code = code
55
+ self.message = message
56
+ self.data = data
57
+
58
+ def to_dict(self) -> dict:
59
+ out: dict[str, Any] = {"code": self.code, "message": self.message}
60
+ if self.data is not None:
61
+ out["data"] = self.data
62
+ return out
63
+
64
+
65
+ def encode_frame(obj: dict) -> bytes:
66
+ body = json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
67
+ if len(body) > MAX_FRAME_BYTES:
68
+ raise RpcError(
69
+ E_FRAME_TOO_LARGE,
70
+ f"frame is {len(body)} bytes; max is {MAX_FRAME_BYTES}",
71
+ )
72
+ return struct.pack(">I", len(body)) + body
73
+
74
+
75
+ async def read_frame_async(reader) -> dict:
76
+ hdr = await reader.readexactly(4)
77
+ (n,) = struct.unpack(">I", hdr)
78
+ if n > MAX_FRAME_BYTES:
79
+ raise RpcError(
80
+ E_FRAME_TOO_LARGE,
81
+ f"incoming frame is {n} bytes; max is {MAX_FRAME_BYTES}",
82
+ )
83
+ body = await reader.readexactly(n)
84
+ try:
85
+ return json.loads(body.decode("utf-8"))
86
+ except (UnicodeDecodeError, json.JSONDecodeError) as e:
87
+ raise RpcError(E_PARSE_ERROR, f"frame parse error: {e}") from e
88
+
89
+
90
+ def read_frame_sync(sock) -> dict:
91
+ hdr = _recv_exact(sock, 4)
92
+ (n,) = struct.unpack(">I", hdr)
93
+ if n > MAX_FRAME_BYTES:
94
+ raise RpcError(
95
+ E_FRAME_TOO_LARGE,
96
+ f"incoming frame is {n} bytes; max is {MAX_FRAME_BYTES}",
97
+ )
98
+ body = _recv_exact(sock, n)
99
+ return json.loads(body.decode("utf-8"))
100
+
101
+
102
+ def _recv_exact(sock, n: int) -> bytes:
103
+ buf = bytearray()
104
+ while len(buf) < n:
105
+ chunk = sock.recv(n - len(buf))
106
+ if not chunk:
107
+ raise ConnectionError("connection closed mid-frame")
108
+ buf.extend(chunk)
109
+ return bytes(buf)
110
+
111
+
112
+ def make_request(method: str, params: dict | None = None, *, id_: int = 1) -> dict:
113
+ req: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "id": id_}
114
+ if params is not None:
115
+ req["params"] = params
116
+ return req
117
+
118
+
119
+ def make_response(id_: Any, result: Any) -> dict:
120
+ return {"jsonrpc": "2.0", "id": id_, "result": result}
121
+
122
+
123
+ def make_error(id_: Any, err: RpcError | Exception) -> dict:
124
+ if isinstance(err, RpcError):
125
+ return {"jsonrpc": "2.0", "id": id_, "error": err.to_dict()}
126
+ return {
127
+ "jsonrpc": "2.0",
128
+ "id": id_,
129
+ "error": {
130
+ "code": E_SERVER,
131
+ "message": str(err) or type(err).__name__,
132
+ "data": {"error_type": type(err).__name__},
133
+ },
134
+ }