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.
- sourceindex/__init__.py +30 -0
- sourceindex/build/__init__.py +592 -0
- sourceindex/build/indexer.py +403 -0
- sourceindex/build/linerange/__init__.py +24 -0
- sourceindex/build/linerange/python_ast.py +155 -0
- sourceindex/build/linerange/treesitter.py +397 -0
- sourceindex/build/prompts.py +76 -0
- sourceindex/build/state.py +261 -0
- sourceindex/build/walker.py +94 -0
- sourceindex/claudecode/__init__.py +0 -0
- sourceindex/claudecode/savings.py +325 -0
- sourceindex/claudecode/savings_summary.py +88 -0
- sourceindex/claudecode/statusline.py +116 -0
- sourceindex/cli/__init__.py +304 -0
- sourceindex/cli/__main__.py +10 -0
- sourceindex/cli/api_key.py +171 -0
- sourceindex/cli/commands.py +678 -0
- sourceindex/cli/install.py +378 -0
- sourceindex/daemon/__init__.py +83 -0
- sourceindex/daemon/client.py +141 -0
- sourceindex/daemon/crypto.py +48 -0
- sourceindex/daemon/keyring_store.py +129 -0
- sourceindex/daemon/lifecycle.py +237 -0
- sourceindex/daemon/protocol.py +134 -0
- sourceindex/daemon/server.py +389 -0
- sourceindex/daemon/store.py +426 -0
- sourceindex/lib/__init__.py +0 -0
- sourceindex/lib/backend.py +240 -0
- sourceindex/lib/cost.py +96 -0
- sourceindex/lib/env.py +30 -0
- sourceindex/lib/git.py +33 -0
- sourceindex/lib/languages.py +406 -0
- sourceindex/lib/llm.py +298 -0
- sourceindex/lib/log.py +228 -0
- sourceindex/lib/registry.py +75 -0
- sourceindex/lib/timing.py +10 -0
- sourceindex/search/__init__.py +251 -0
- sourceindex/search/experiments.py +276 -0
- sourceindex/search/imports.py +155 -0
- sourceindex/search/passes.py +51 -0
- sourceindex/search/prompts.py +379 -0
- sourceindex/search/roadmap.py +265 -0
- sourceindex/server.py +127 -0
- sourceindex-0.1.0.dist-info/METADATA +73 -0
- sourceindex-0.1.0.dist-info/RECORD +47 -0
- sourceindex-0.1.0.dist-info/WHEEL +4 -0
- 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()
|