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,389 @@
1
+ """Daemon process: UDS server, RPC dispatch, in-process store ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Awaitable, Callable
12
+
13
+ from . import lifecycle
14
+ from .keyring_store import delete_key, load_or_create_key
15
+ from .protocol import (
16
+ ADMIN_METHODS,
17
+ USER_METHODS,
18
+ E_AUTH_REQUIRED,
19
+ E_INVALID_PARAMS,
20
+ E_METHOD_NOT_FOUND,
21
+ E_NO_INDEX,
22
+ RpcError,
23
+ encode_frame,
24
+ make_error,
25
+ make_response,
26
+ read_frame_async,
27
+ )
28
+ from .store import EncryptedStore
29
+
30
+
31
+ _log = logging.getLogger(__name__)
32
+
33
+
34
+ IDLE_TIMEOUT_S = 600.0
35
+
36
+
37
+ class Daemon:
38
+ watchdog_interval_s = 30.0
39
+
40
+ def __init__(self, repo_root: Path, *, allow_dump: bool = False) -> None:
41
+ self.repo_root = Path(repo_root).resolve()
42
+ self.allow_dump = allow_dump
43
+ self.repo_hash = lifecycle.repo_hash_for(self._main_worktree())
44
+ self.key = load_or_create_key(self.repo_hash)
45
+ from . import IndexDir
46
+ self.store = EncryptedStore(
47
+ IndexDir.for_repo(self.repo_root).path, self.key, self.repo_hash,
48
+ )
49
+ self._last_activity = time.monotonic()
50
+ self._shutdown_evt: asyncio.Event | None = None
51
+ self._repo_was_deleted = False
52
+ self._user_handlers: dict[str, Callable[[dict], Awaitable[dict]]] = {}
53
+ self._admin_handlers: dict[str, Callable[[dict], Awaitable[dict]]] = {}
54
+ self._register_user_handlers()
55
+ if allow_dump:
56
+ self._register_admin_handlers()
57
+
58
+ def _main_worktree(self) -> Path:
59
+ from ..lib.git import resolve_main_worktree
60
+ return resolve_main_worktree(self.repo_root)
61
+
62
+ def run(self) -> None:
63
+ if self.store.looks_like_plaintext_layout():
64
+ n = self.store.migrate_plaintext_in_place()
65
+ _log.warning(
66
+ "Migrated %d plaintext file(s) into the opaque-chunk store at %s",
67
+ n, self.store.index_dir,
68
+ extra={"event": "store.migrated", "files": n},
69
+ )
70
+ elif self.store.looks_like_old_encrypted_layout():
71
+ n = self.store.migrate_old_encrypted_in_place()
72
+ _log.warning(
73
+ "Migrated %d legacy encrypted file(s) into the opaque-chunk store at %s",
74
+ n, self.store.index_dir,
75
+ extra={"event": "store.chunked_migration", "files": n},
76
+ )
77
+ else:
78
+ try:
79
+ self.store.ensure_initialized()
80
+ except Exception as e:
81
+ _log.error("store init failed: %s", e)
82
+ raise
83
+
84
+ # Load encrypted .env into our env so RPC handlers inherit
85
+ # SOURCEINDEX_API_KEY without the client having to re-supply it
86
+ # (post-commit hook can't decrypt .env itself).
87
+ self._load_env_from_store()
88
+
89
+ lifecycle.write_pidfile(self.repo_hash)
90
+ try:
91
+ asyncio.run(self._serve())
92
+ finally:
93
+ if self._repo_was_deleted:
94
+ delete_key(self.repo_hash)
95
+ # Drop our own pidfile before calling cleanup_stale_files so
96
+ # its liveness guard (which protects a different live daemon's
97
+ # files from being yanked) doesn't bail on our own PID.
98
+ lifecycle.pidfile(self.repo_hash).unlink(missing_ok=True)
99
+ lifecycle.cleanup_stale_files(self.repo_hash)
100
+
101
+ def _load_env_from_store(self) -> None:
102
+ if not self.store.exists(".env"):
103
+ return
104
+ try:
105
+ text = self.store.read_text(".env")
106
+ except Exception as e:
107
+ _log.warning("could not load encrypted .env: %s", e)
108
+ return
109
+ from ..lib.env import parse_env_text
110
+ for k, v in parse_env_text(text).items():
111
+ if k and k not in os.environ:
112
+ os.environ[k] = v
113
+
114
+ async def _serve(self) -> None:
115
+ self._shutdown_evt = asyncio.Event()
116
+ loop = asyncio.get_running_loop()
117
+ for sig in (signal.SIGTERM, signal.SIGINT):
118
+ try:
119
+ loop.add_signal_handler(sig, self._request_shutdown)
120
+ except (NotImplementedError, RuntimeError, ValueError):
121
+ # Off-main-thread (tests) — stop_daemon's SIGKILL fallback
122
+ # still cleans up if shutdown never arrives.
123
+ pass
124
+
125
+ user_path = lifecycle.user_socket(self.repo_hash)
126
+ admin_path = lifecycle.admin_socket(self.repo_hash)
127
+ for p in (user_path, admin_path):
128
+ try:
129
+ p.unlink()
130
+ except FileNotFoundError:
131
+ pass
132
+
133
+ user_server = await asyncio.start_unix_server(self._handle_user, str(user_path))
134
+ os.chmod(user_path, 0o600)
135
+ servers = [user_server]
136
+ if self.allow_dump:
137
+ admin_server = await asyncio.start_unix_server(
138
+ self._handle_admin, str(admin_path)
139
+ )
140
+ os.chmod(admin_path, 0o600)
141
+ servers.append(admin_server)
142
+
143
+ watchdog_task = asyncio.create_task(self._watchdog())
144
+ try:
145
+ await self._shutdown_evt.wait()
146
+ finally:
147
+ watchdog_task.cancel()
148
+ for s in servers:
149
+ s.close()
150
+ for s in servers:
151
+ await s.wait_closed()
152
+
153
+ def _request_shutdown(self) -> None:
154
+ if self._shutdown_evt is not None:
155
+ self._shutdown_evt.set()
156
+
157
+ async def _watchdog(self) -> None:
158
+ # Single periodic loop guards two unrelated concerns:
159
+ # 1. The repo directory got deleted (rm -rf) — tear down and
160
+ # drop the encryption key so it doesn't orphan in the keyring.
161
+ # 2. No client activity for IDLE_TIMEOUT_S — shut down to release
162
+ # the socket so a stale daemon doesn't outlive its usefulness.
163
+ # Repo-gone wins if both fire on the same tick.
164
+ while True:
165
+ await asyncio.sleep(self.watchdog_interval_s)
166
+ if self._detect_repo_deleted():
167
+ _log.info(
168
+ "Repo root no longer exists; deleting key and shutting down",
169
+ extra={"event": "daemon.repo_deleted"},
170
+ )
171
+ self._repo_was_deleted = True
172
+ self._request_shutdown()
173
+ return
174
+ if time.monotonic() - self._last_activity > IDLE_TIMEOUT_S:
175
+ _log.info(
176
+ "Idle timeout — shutting down",
177
+ extra={"event": "daemon.idle_shutdown"},
178
+ )
179
+ self._request_shutdown()
180
+ return
181
+
182
+ def _detect_repo_deleted(self) -> bool:
183
+ # Require the parent to still exist so a temporarily unmounted
184
+ # drive or unreachable network mount doesn't trigger a wipe.
185
+ return (
186
+ not self.repo_root.exists()
187
+ and self.repo_root.parent.exists()
188
+ )
189
+
190
+ async def _handle_user(self, reader, writer) -> None:
191
+ await self._handle_conn(reader, writer, self._user_handlers, USER_METHODS)
192
+
193
+ async def _handle_admin(self, reader, writer) -> None:
194
+ await self._handle_conn(reader, writer, self._admin_handlers, ADMIN_METHODS)
195
+
196
+ async def _handle_conn(self, reader, writer, handlers, allowed) -> None:
197
+ try:
198
+ while True:
199
+ try:
200
+ req = await read_frame_async(reader)
201
+ except asyncio.IncompleteReadError:
202
+ return
203
+ self._last_activity = time.monotonic()
204
+ response = await self._dispatch(req, handlers, allowed)
205
+ writer.write(encode_frame(response))
206
+ await writer.drain()
207
+ except (ConnectionResetError, BrokenPipeError):
208
+ pass
209
+ finally:
210
+ try:
211
+ writer.close()
212
+ await writer.wait_closed()
213
+ except Exception:
214
+ pass
215
+
216
+ async def _dispatch(self, req: dict, handlers: dict, allowed) -> dict:
217
+ rid = req.get("id")
218
+ method = req.get("method")
219
+ params = req.get("params") or {}
220
+ if not isinstance(method, str) or method not in allowed:
221
+ return make_error(
222
+ rid, RpcError(E_METHOD_NOT_FOUND, f"method not found: {method!r}")
223
+ )
224
+ handler = handlers.get(method)
225
+ if handler is None:
226
+ # In allowed set but not registered (e.g. admin method on user socket).
227
+ return make_error(
228
+ rid, RpcError(E_METHOD_NOT_FOUND, f"method not found: {method!r}")
229
+ )
230
+ try:
231
+ result = await handler(params)
232
+ return make_response(rid, result)
233
+ except RpcError as e:
234
+ return make_error(rid, e)
235
+ except Exception as e:
236
+ _log.exception("handler error in %s", method)
237
+ return make_error(rid, e)
238
+
239
+ def _register_user_handlers(self) -> None:
240
+ self._user_handlers = {
241
+ "ping": self._h_ping,
242
+ "search": self._h_search,
243
+ "init": self._h_init,
244
+ "update": self._h_update,
245
+ "install_hooks": self._h_install_hooks,
246
+ "usage": self._h_usage,
247
+ }
248
+
249
+ async def _h_ping(self, params: dict) -> dict:
250
+ return {
251
+ "ok": True,
252
+ "repo_hash": self.repo_hash,
253
+ "allow_dump": self.allow_dump,
254
+ "idle_for_s": time.monotonic() - self._last_activity,
255
+ }
256
+
257
+ async def _h_search(self, params: dict) -> dict:
258
+ query = params.get("query")
259
+ model = params.get("model")
260
+ api_key = params.get("api_key") or os.environ.get("SOURCEINDEX_API_KEY")
261
+ if not isinstance(query, str) or not query:
262
+ raise RpcError(E_INVALID_PARAMS, "query is required (non-empty string)")
263
+ if not self.store.exists("index.md"):
264
+ raise RpcError(E_NO_INDEX, "no index found; run `sourceindex init` first")
265
+ from ..search import search_index_with_store
266
+ t0 = time.perf_counter()
267
+ roadmap = await asyncio.to_thread(
268
+ search_index_with_store,
269
+ query, self.repo_root, self.store, model, api_key,
270
+ )
271
+ return {
272
+ "roadmap": roadmap,
273
+ "latency_ms": int((time.perf_counter() - t0) * 1000),
274
+ }
275
+
276
+ def _progress_writer_for_this_rpc(self) -> "Callable[[], None]":
277
+ """Register a writer that mirrors each progress tick into
278
+ $XDG_RUNTIME_DIR/sourceindex/<hash>.progress for the CLI to tail.
279
+ Returns a cleanup callable to invoke in finally."""
280
+ from ..build import indexer
281
+ progress_path = lifecycle.progress_file(self.repo_hash)
282
+ progress_path.unlink(missing_ok=True)
283
+
284
+ def write_progress(line: str) -> None:
285
+ try:
286
+ progress_path.write_text(line + "\n")
287
+ except OSError:
288
+ pass
289
+
290
+ indexer.set_progress_writer(write_progress)
291
+
292
+ def cleanup() -> None:
293
+ indexer.set_progress_writer(None)
294
+ progress_path.unlink(missing_ok=True)
295
+ return cleanup
296
+
297
+ async def _h_init(self, params: dict) -> dict:
298
+ from ..build import build_index_with_store
299
+ api_key = params.get("api_key") or os.environ.get("SOURCEINDEX_API_KEY")
300
+ if not api_key:
301
+ raise RpcError(E_AUTH_REQUIRED, "api_key is required")
302
+ cleanup = self._progress_writer_for_this_rpc()
303
+ try:
304
+ return await asyncio.to_thread(
305
+ build_index_with_store,
306
+ self.repo_root, self.store,
307
+ model=params.get("model"),
308
+ api_key=api_key,
309
+ languages=params.get("languages"),
310
+ workers=params.get("workers"),
311
+ )
312
+ finally:
313
+ cleanup()
314
+
315
+ async def _h_update(self, params: dict) -> dict:
316
+ from ..build import update_index_with_store
317
+ api_key = params.get("api_key") or os.environ.get("SOURCEINDEX_API_KEY")
318
+ if not api_key:
319
+ raise RpcError(E_AUTH_REQUIRED, "api_key is required")
320
+ cleanup = self._progress_writer_for_this_rpc()
321
+ try:
322
+ return await asyncio.to_thread(
323
+ update_index_with_store,
324
+ self.repo_root, self.store,
325
+ model=params.get("model"),
326
+ api_key=api_key,
327
+ languages=params.get("languages"),
328
+ changed_files=params.get("changed_files"),
329
+ workers=params.get("workers"),
330
+ )
331
+ finally:
332
+ cleanup()
333
+
334
+ async def _h_install_hooks(self, params: dict) -> dict:
335
+ from ..cli.install import _install_hooks
336
+ await asyncio.to_thread(_install_hooks, self.repo_root)
337
+ return {"installed": True}
338
+
339
+ async def _h_usage(self, params: dict) -> dict:
340
+ from ..lib.cost import fetch_budget
341
+ budget = await asyncio.to_thread(fetch_budget)
342
+ return {"budget": budget}
343
+
344
+ def _register_admin_handlers(self) -> None:
345
+ self._admin_handlers = {
346
+ "dump_all": self._h_dump_all,
347
+ "read_index_md": self._h_read_index_md,
348
+ "read_detail": self._h_read_detail,
349
+ "read_env": self._h_read_env,
350
+ "shutdown": self._h_shutdown,
351
+ }
352
+
353
+ async def _h_dump_all(self, params: dict) -> dict:
354
+ out_dir = params.get("out_dir")
355
+ if not isinstance(out_dir, str) or not out_dir:
356
+ raise RpcError(E_INVALID_PARAMS, "out_dir is required")
357
+ files, total = await asyncio.to_thread(
358
+ self.store.plaintext_dump, Path(out_dir)
359
+ )
360
+ return {"files_written": files, "bytes": total}
361
+
362
+ async def _h_read_index_md(self, params: dict) -> dict:
363
+ return {"text": self.store.read_text("index.md")}
364
+
365
+ async def _h_read_detail(self, params: dict) -> dict:
366
+ rel = params.get("rel_path")
367
+ if not isinstance(rel, str) or not rel:
368
+ raise RpcError(E_INVALID_PARAMS, "rel_path is required")
369
+ return {"text": self.store.read_text(f"details/{rel}.md")}
370
+
371
+ async def _h_read_env(self, params: dict) -> dict:
372
+ if not self.store.exists(".env"):
373
+ return {"vars": {}}
374
+ from ..lib.env import parse_env_text
375
+ return {"vars": parse_env_text(self.store.read_text(".env"))}
376
+
377
+ async def _h_shutdown(self, params: dict) -> dict:
378
+ self._request_shutdown()
379
+ return {"ok": True}
380
+
381
+
382
+ def run_daemon(repo_root: Path, *, allow_dump: bool = False) -> None:
383
+ if lifecycle.is_daemon_running(repo_root):
384
+ _log.info(
385
+ "Daemon already running for %s; exiting", repo_root,
386
+ extra={"event": "daemon.already_running"},
387
+ )
388
+ return
389
+ Daemon(repo_root, allow_dump=allow_dump).run()