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,426 @@
|
|
|
1
|
+
"""Encrypted-at-rest store backing ``.sourceindex/``.
|
|
2
|
+
|
|
3
|
+
Both file CONTENTS and PATHS are hidden on disk. Logical paths like
|
|
4
|
+
``index.md`` or ``details/foo/bar.py.md`` are translated to opaque random
|
|
5
|
+
chunk IDs via an encrypted manifest. Outside observers see only
|
|
6
|
+
``manifest.enc`` and a churn of ``chunks/<random_id>.enc`` blobs; the
|
|
7
|
+
filesystem layout no longer betrays the repo's file tree.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Iterator
|
|
19
|
+
|
|
20
|
+
from . import crypto
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SENTINEL_FILE = ".daemon_key_id"
|
|
24
|
+
ENC_SUFFIX = ".enc"
|
|
25
|
+
TIER1_FILENAME = "index.md"
|
|
26
|
+
MANIFEST_FILENAME = "manifest"
|
|
27
|
+
CHUNKS_DIRNAME = "chunks"
|
|
28
|
+
DETAILS_DIRNAME = "details"
|
|
29
|
+
MANIFEST_VERSION = 1
|
|
30
|
+
_CHUNK_ID_BYTES = 16
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PlaintextStore:
|
|
34
|
+
"""Direct read/write of a ``.sourceindex/`` directory without encryption.
|
|
35
|
+
Mirrors the EncryptedStore API so callers can be agnostic."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, index_dir: Path) -> None:
|
|
38
|
+
self.index_dir = Path(index_dir)
|
|
39
|
+
|
|
40
|
+
def _resolve(self, rel: str) -> Path:
|
|
41
|
+
return self.index_dir / rel.lstrip("/")
|
|
42
|
+
|
|
43
|
+
def path_for(self, rel: str) -> Path:
|
|
44
|
+
return self._resolve(rel)
|
|
45
|
+
|
|
46
|
+
def ensure_initialized(self) -> None:
|
|
47
|
+
self.index_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
def read_bytes(self, rel: str) -> bytes:
|
|
50
|
+
return self._resolve(rel).read_bytes()
|
|
51
|
+
|
|
52
|
+
def read_text(self, rel: str) -> str:
|
|
53
|
+
return self._resolve(rel).read_text()
|
|
54
|
+
|
|
55
|
+
def write_bytes(self, rel: str, data: bytes) -> None:
|
|
56
|
+
target = self._resolve(rel)
|
|
57
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
target.write_bytes(data)
|
|
59
|
+
|
|
60
|
+
def write_text(self, rel: str, data: str) -> None:
|
|
61
|
+
target = self._resolve(rel)
|
|
62
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
target.write_text(data)
|
|
64
|
+
|
|
65
|
+
def exists(self, rel: str) -> bool:
|
|
66
|
+
return self._resolve(rel).is_file()
|
|
67
|
+
|
|
68
|
+
def unlink(self, rel: str, *, missing_ok: bool = True) -> None:
|
|
69
|
+
try:
|
|
70
|
+
self._resolve(rel).unlink()
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
if not missing_ok:
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
def iter_relative(self, subdir: str = "") -> "Iterator[str]":
|
|
76
|
+
root = self.index_dir / subdir if subdir else self.index_dir
|
|
77
|
+
if not root.exists():
|
|
78
|
+
return
|
|
79
|
+
for p in root.rglob("*"):
|
|
80
|
+
if not p.is_file():
|
|
81
|
+
continue
|
|
82
|
+
if p.parent == self.index_dir and p.name == SENTINEL_FILE:
|
|
83
|
+
continue
|
|
84
|
+
yield p.relative_to(self.index_dir).as_posix()
|
|
85
|
+
|
|
86
|
+
def plaintext_dump(self, out_dir: Path) -> tuple[int, int]:
|
|
87
|
+
out_dir = Path(out_dir)
|
|
88
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
files = 0
|
|
90
|
+
total = 0
|
|
91
|
+
for rel in self.iter_relative():
|
|
92
|
+
data = self.read_bytes(rel)
|
|
93
|
+
target = out_dir / rel
|
|
94
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
target.write_bytes(data)
|
|
96
|
+
files += 1
|
|
97
|
+
total += len(data)
|
|
98
|
+
return files, total
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class StoreError(RuntimeError):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class KeyMismatchError(StoreError):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class EncryptedStore:
|
|
110
|
+
def __init__(self, index_dir: Path, key: bytes, repo_hash: str) -> None:
|
|
111
|
+
self.index_dir = Path(index_dir)
|
|
112
|
+
self._key = key
|
|
113
|
+
self._repo_hash = repo_hash
|
|
114
|
+
self._lock = threading.Lock()
|
|
115
|
+
self._manifest: dict[str, str] | None = None
|
|
116
|
+
|
|
117
|
+
# ── path helpers ──────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def _manifest_path(self) -> Path:
|
|
120
|
+
return self.index_dir / (MANIFEST_FILENAME + ENC_SUFFIX)
|
|
121
|
+
|
|
122
|
+
def _chunks_dir(self) -> Path:
|
|
123
|
+
return self.index_dir / CHUNKS_DIRNAME
|
|
124
|
+
|
|
125
|
+
def _chunk_path(self, chunk_id: str) -> Path:
|
|
126
|
+
return self._chunks_dir() / (chunk_id + ENC_SUFFIX)
|
|
127
|
+
|
|
128
|
+
def _new_chunk_id(self) -> str:
|
|
129
|
+
return secrets.token_hex(_CHUNK_ID_BYTES)
|
|
130
|
+
|
|
131
|
+
def path_for(self, rel: str) -> Path:
|
|
132
|
+
"""Logical path under ``index_dir`` for error-message use only;
|
|
133
|
+
the actual on-disk file is an opaque chunk under ``chunks/``."""
|
|
134
|
+
return self.index_dir / rel.lstrip("/")
|
|
135
|
+
|
|
136
|
+
# ── manifest plumbing ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def _load_manifest_locked(self) -> dict[str, str]:
|
|
139
|
+
path = self._manifest_path()
|
|
140
|
+
if not path.is_file():
|
|
141
|
+
return {}
|
|
142
|
+
try:
|
|
143
|
+
blob = path.read_bytes()
|
|
144
|
+
plaintext = crypto.decrypt(blob, self._key)
|
|
145
|
+
except crypto.InvalidBlob as e:
|
|
146
|
+
raise KeyMismatchError(
|
|
147
|
+
f"Could not decrypt manifest at {path}: {e}. The on-disk "
|
|
148
|
+
"store and the loaded master key disagree — likely a stale "
|
|
149
|
+
"key file or a moved repository."
|
|
150
|
+
) from e
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(plaintext.decode("utf-8"))
|
|
153
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
154
|
+
raise StoreError(f"Corrupt manifest at {path}: {e}") from e
|
|
155
|
+
if not isinstance(data, dict):
|
|
156
|
+
raise StoreError(f"Corrupt manifest at {path}: not a JSON object")
|
|
157
|
+
entries = data.get("entries", {})
|
|
158
|
+
if not isinstance(entries, dict):
|
|
159
|
+
raise StoreError(
|
|
160
|
+
f"Corrupt manifest at {path}: 'entries' is not a JSON object"
|
|
161
|
+
)
|
|
162
|
+
return {str(k): str(v) for k, v in entries.items()}
|
|
163
|
+
|
|
164
|
+
def _save_manifest_locked(self) -> None:
|
|
165
|
+
manifest = self._manifest if self._manifest is not None else {}
|
|
166
|
+
payload = {
|
|
167
|
+
"version": MANIFEST_VERSION,
|
|
168
|
+
"entries": dict(sorted(manifest.items())),
|
|
169
|
+
}
|
|
170
|
+
blob = crypto.encrypt(
|
|
171
|
+
json.dumps(payload, separators=(",", ":")).encode("utf-8"),
|
|
172
|
+
self._key,
|
|
173
|
+
)
|
|
174
|
+
path = self._manifest_path()
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
self._atomic_write_bytes(path, blob)
|
|
177
|
+
|
|
178
|
+
def _get_manifest_locked(self) -> dict[str, str]:
|
|
179
|
+
if self._manifest is None:
|
|
180
|
+
self._manifest = self._load_manifest_locked()
|
|
181
|
+
return self._manifest
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _atomic_write_bytes(target: Path, blob: bytes) -> None:
|
|
185
|
+
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=str(target.parent))
|
|
186
|
+
try:
|
|
187
|
+
with os.fdopen(fd, "wb") as f:
|
|
188
|
+
f.write(blob)
|
|
189
|
+
os.replace(tmp, target)
|
|
190
|
+
except Exception:
|
|
191
|
+
try:
|
|
192
|
+
os.unlink(tmp)
|
|
193
|
+
except OSError:
|
|
194
|
+
pass
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
# ── lifecycle ─────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def ensure_initialized(self) -> None:
|
|
200
|
+
self.index_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
sentinel = self.index_dir / SENTINEL_FILE
|
|
202
|
+
if sentinel.exists():
|
|
203
|
+
existing = sentinel.read_text().strip()
|
|
204
|
+
if existing and existing != self._repo_hash:
|
|
205
|
+
raise KeyMismatchError(
|
|
206
|
+
f"Store at {self.index_dir} carries repo_hash={existing!r} "
|
|
207
|
+
f"but this daemon is initialized for {self._repo_hash!r}. "
|
|
208
|
+
"Either you moved the repo (re-init to mint a new key) or "
|
|
209
|
+
"the keyring entry was deleted; nothing was modified."
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
sentinel.write_text(self._repo_hash + "\n")
|
|
213
|
+
|
|
214
|
+
# ── core read / write API ─────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def read_bytes(self, rel: str) -> bytes:
|
|
217
|
+
rel = rel.lstrip("/")
|
|
218
|
+
with self._lock:
|
|
219
|
+
manifest = self._get_manifest_locked()
|
|
220
|
+
chunk_id = manifest.get(rel)
|
|
221
|
+
if chunk_id is None:
|
|
222
|
+
raise FileNotFoundError(
|
|
223
|
+
f"{rel!r} is not present in the encrypted index"
|
|
224
|
+
)
|
|
225
|
+
chunk_path = self._chunk_path(chunk_id)
|
|
226
|
+
try:
|
|
227
|
+
blob = chunk_path.read_bytes()
|
|
228
|
+
except FileNotFoundError as e:
|
|
229
|
+
raise StoreError(
|
|
230
|
+
f"Manifest references chunk {chunk_id!r} for {rel!r} but "
|
|
231
|
+
f"the chunk file is missing at {chunk_path}. The store "
|
|
232
|
+
"is corrupt."
|
|
233
|
+
) from e
|
|
234
|
+
try:
|
|
235
|
+
return crypto.decrypt(blob, self._key)
|
|
236
|
+
except crypto.InvalidBlob as e:
|
|
237
|
+
raise KeyMismatchError(
|
|
238
|
+
f"Could not decrypt {rel!r}: {e}. The on-disk store and the "
|
|
239
|
+
"loaded master key disagree — likely a stale key file or a "
|
|
240
|
+
"moved repository."
|
|
241
|
+
) from e
|
|
242
|
+
|
|
243
|
+
def read_text(self, rel: str) -> str:
|
|
244
|
+
return self.read_bytes(rel).decode("utf-8")
|
|
245
|
+
|
|
246
|
+
def write_bytes(self, rel: str, data: bytes) -> None:
|
|
247
|
+
rel = rel.lstrip("/")
|
|
248
|
+
chunks_dir = self._chunks_dir()
|
|
249
|
+
chunks_dir.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
blob = crypto.encrypt(data, self._key)
|
|
251
|
+
|
|
252
|
+
with self._lock:
|
|
253
|
+
new_id = self._new_chunk_id()
|
|
254
|
+
new_chunk = self._chunk_path(new_id)
|
|
255
|
+
self._atomic_write_bytes(new_chunk, blob)
|
|
256
|
+
|
|
257
|
+
manifest = self._get_manifest_locked()
|
|
258
|
+
old_id = manifest.get(rel)
|
|
259
|
+
manifest[rel] = new_id
|
|
260
|
+
self._save_manifest_locked()
|
|
261
|
+
|
|
262
|
+
if old_id and old_id != new_id:
|
|
263
|
+
try:
|
|
264
|
+
self._chunk_path(old_id).unlink()
|
|
265
|
+
except FileNotFoundError:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
def write_text(self, rel: str, data: str) -> None:
|
|
269
|
+
self.write_bytes(rel, data.encode("utf-8"))
|
|
270
|
+
|
|
271
|
+
def exists(self, rel: str) -> bool:
|
|
272
|
+
rel = rel.lstrip("/")
|
|
273
|
+
with self._lock:
|
|
274
|
+
return rel in self._get_manifest_locked()
|
|
275
|
+
|
|
276
|
+
def unlink(self, rel: str, *, missing_ok: bool = True) -> None:
|
|
277
|
+
rel = rel.lstrip("/")
|
|
278
|
+
with self._lock:
|
|
279
|
+
manifest = self._get_manifest_locked()
|
|
280
|
+
chunk_id = manifest.pop(rel, None)
|
|
281
|
+
if chunk_id is None:
|
|
282
|
+
if not missing_ok:
|
|
283
|
+
raise FileNotFoundError(rel)
|
|
284
|
+
return
|
|
285
|
+
self._save_manifest_locked()
|
|
286
|
+
try:
|
|
287
|
+
self._chunk_path(chunk_id).unlink()
|
|
288
|
+
except FileNotFoundError:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
def iter_relative(self, subdir: str = "") -> Iterator[str]:
|
|
292
|
+
with self._lock:
|
|
293
|
+
keys = sorted(self._get_manifest_locked().keys())
|
|
294
|
+
if not subdir:
|
|
295
|
+
yield from keys
|
|
296
|
+
return
|
|
297
|
+
prefix = subdir.rstrip("/") + "/"
|
|
298
|
+
bare = subdir.rstrip("/")
|
|
299
|
+
for k in keys:
|
|
300
|
+
if k == bare or k.startswith(prefix):
|
|
301
|
+
yield k
|
|
302
|
+
|
|
303
|
+
def plaintext_dump(self, out_dir: Path) -> tuple[int, int]:
|
|
304
|
+
out_dir = Path(out_dir)
|
|
305
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
files = 0
|
|
307
|
+
total = 0
|
|
308
|
+
for rel in self.iter_relative():
|
|
309
|
+
data = self.read_bytes(rel)
|
|
310
|
+
target = out_dir / rel
|
|
311
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
target.write_bytes(data)
|
|
313
|
+
files += 1
|
|
314
|
+
total += len(data)
|
|
315
|
+
return files, total
|
|
316
|
+
|
|
317
|
+
# ── migration: plaintext → chunked ────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
def looks_like_plaintext_layout(self) -> bool:
|
|
320
|
+
"""True iff plaintext-named files exist that need migrating into the
|
|
321
|
+
chunked layout. Catches partial-migration state too (e.g. crash
|
|
322
|
+
between writing the manifest and unlinking the plaintext)."""
|
|
323
|
+
if not self.index_dir.exists():
|
|
324
|
+
return False
|
|
325
|
+
return bool(self._collect_plaintext_targets())
|
|
326
|
+
|
|
327
|
+
def migrate_plaintext_in_place(self) -> int:
|
|
328
|
+
migrated = 0
|
|
329
|
+
for path in self._collect_plaintext_targets():
|
|
330
|
+
rel = path.relative_to(self.index_dir).as_posix()
|
|
331
|
+
try:
|
|
332
|
+
data = path.read_bytes()
|
|
333
|
+
except OSError:
|
|
334
|
+
continue
|
|
335
|
+
self.write_bytes(rel, data)
|
|
336
|
+
try:
|
|
337
|
+
path.unlink()
|
|
338
|
+
except OSError:
|
|
339
|
+
pass
|
|
340
|
+
migrated += 1
|
|
341
|
+
self._prune_empty_details_dir()
|
|
342
|
+
# Sentinel last so a torn migration is still detectable on rerun.
|
|
343
|
+
self.ensure_initialized()
|
|
344
|
+
return migrated
|
|
345
|
+
|
|
346
|
+
def _collect_plaintext_targets(self) -> list[Path]:
|
|
347
|
+
owned_top = {
|
|
348
|
+
TIER1_FILENAME,
|
|
349
|
+
"_summary_cache.json",
|
|
350
|
+
"_file_index.json",
|
|
351
|
+
"build_meta.json",
|
|
352
|
+
".env",
|
|
353
|
+
}
|
|
354
|
+
out: list[Path] = []
|
|
355
|
+
for name in owned_top:
|
|
356
|
+
p = self.index_dir / name
|
|
357
|
+
if p.is_file() and not p.name.endswith(ENC_SUFFIX):
|
|
358
|
+
out.append(p)
|
|
359
|
+
details = self.index_dir / DETAILS_DIRNAME
|
|
360
|
+
if details.is_dir():
|
|
361
|
+
for p in details.rglob("*.md"):
|
|
362
|
+
if p.is_file() and not p.name.endswith(ENC_SUFFIX):
|
|
363
|
+
out.append(p)
|
|
364
|
+
return out
|
|
365
|
+
|
|
366
|
+
# ── migration: legacy per-file encrypted → chunked ────────────────────
|
|
367
|
+
|
|
368
|
+
def looks_like_old_encrypted_layout(self) -> bool:
|
|
369
|
+
"""True iff legacy per-file ``<rel>.enc`` blobs are present and need
|
|
370
|
+
migrating. Independent of whether ``manifest.enc`` already exists,
|
|
371
|
+
so a crashed partial migration resumes on the next start."""
|
|
372
|
+
if not self.index_dir.exists():
|
|
373
|
+
return False
|
|
374
|
+
return any(True for _ in self._iter_legacy_encrypted_files())
|
|
375
|
+
|
|
376
|
+
def migrate_old_encrypted_in_place(self) -> int:
|
|
377
|
+
migrated = 0
|
|
378
|
+
for path in list(self._iter_legacy_encrypted_files()):
|
|
379
|
+
rel = path.relative_to(self.index_dir).as_posix()
|
|
380
|
+
if not rel.endswith(ENC_SUFFIX):
|
|
381
|
+
continue
|
|
382
|
+
rel = rel[: -len(ENC_SUFFIX)]
|
|
383
|
+
try:
|
|
384
|
+
blob = path.read_bytes()
|
|
385
|
+
plaintext = crypto.decrypt(blob, self._key)
|
|
386
|
+
except (OSError, crypto.InvalidBlob):
|
|
387
|
+
continue
|
|
388
|
+
self.write_bytes(rel, plaintext)
|
|
389
|
+
try:
|
|
390
|
+
path.unlink()
|
|
391
|
+
except OSError:
|
|
392
|
+
pass
|
|
393
|
+
migrated += 1
|
|
394
|
+
self._prune_empty_details_dir()
|
|
395
|
+
self.ensure_initialized()
|
|
396
|
+
return migrated
|
|
397
|
+
|
|
398
|
+
def _iter_legacy_encrypted_files(self) -> Iterator[Path]:
|
|
399
|
+
manifest_path = self._manifest_path()
|
|
400
|
+
chunks_dir = self._chunks_dir()
|
|
401
|
+
for p in self.index_dir.rglob("*" + ENC_SUFFIX):
|
|
402
|
+
if not p.is_file():
|
|
403
|
+
continue
|
|
404
|
+
if p == manifest_path:
|
|
405
|
+
continue
|
|
406
|
+
try:
|
|
407
|
+
p.relative_to(chunks_dir)
|
|
408
|
+
except ValueError:
|
|
409
|
+
yield p
|
|
410
|
+
|
|
411
|
+
def _prune_empty_details_dir(self) -> None:
|
|
412
|
+
details = self.index_dir / DETAILS_DIRNAME
|
|
413
|
+
if not details.is_dir():
|
|
414
|
+
return
|
|
415
|
+
for sub in sorted(
|
|
416
|
+
details.rglob("*"), key=lambda p: len(p.parts), reverse=True
|
|
417
|
+
):
|
|
418
|
+
if sub.is_dir():
|
|
419
|
+
try:
|
|
420
|
+
sub.rmdir()
|
|
421
|
+
except OSError:
|
|
422
|
+
pass
|
|
423
|
+
try:
|
|
424
|
+
details.rmdir()
|
|
425
|
+
except OSError:
|
|
426
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""HTTP client for the sourceindex hosted backend.
|
|
2
|
+
|
|
3
|
+
Used opt-in: only fires when ``SOURCEINDEX_BACKEND_URL`` is set. Three helpers:
|
|
4
|
+
|
|
5
|
+
- ``fetch_config()`` — pull the central preamble + model selection assigned
|
|
6
|
+
to this user's cohort. Cached on disk with TTL from the response.
|
|
7
|
+
- ``apply_remote_config()`` — resolve the LLM model + (optionally) re-pin the
|
|
8
|
+
backend URL from the fetched config. Called once per CLI command.
|
|
9
|
+
- ``upload_telemetry()`` — send anonymized session metrics. Caller is
|
|
10
|
+
responsible for anonymization (no file paths, no source).
|
|
11
|
+
|
|
12
|
+
All three are best-effort. On any HTTP, network, or parse error they return
|
|
13
|
+
``None``/``False``/the caller-supplied default and the caller falls back to
|
|
14
|
+
local defaults. We do NOT hard-fail sourceindex when the backend is unreachable.
|
|
15
|
+
|
|
16
|
+
Stdlib only — no new dependency on requests/httpx so the search subagent and
|
|
17
|
+
post-commit hook keep their lean import footprint.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from urllib.error import URLError
|
|
27
|
+
from urllib.request import Request, urlopen
|
|
28
|
+
|
|
29
|
+
from .log import get_logger
|
|
30
|
+
|
|
31
|
+
_log = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
_TIMEOUT_S = 5
|
|
34
|
+
_CONFIG_CACHE_FILENAME = ".config_cache.json"
|
|
35
|
+
|
|
36
|
+
# Hardcoded default — the operator's hosted deployment. Users who only paste
|
|
37
|
+
# a sourceindex bearer key during ``sourceindex init`` get routed here without
|
|
38
|
+
# ever seeing or configuring the URL. Override at runtime by exporting
|
|
39
|
+
# ``SOURCEINDEX_BACKEND_URL=<other-url>`` (e.g. http://localhost:8787 for
|
|
40
|
+
# local ``wrangler dev``), or ``SOURCEINDEX_BACKEND_URL=`` (empty string) to
|
|
41
|
+
# disable the backend entirely and talk to providers directly.
|
|
42
|
+
DEFAULT_BACKEND_URL = "https://sourceindex-backend.sourceindex.workers.dev"
|
|
43
|
+
|
|
44
|
+
# Direct-provider key prefixes. The hosted backend expects an ``sk-si-...``
|
|
45
|
+
# bearer and would reject these with a misleading auth error; when the user
|
|
46
|
+
# is holding one of these (e.g. an OpenRouter key for eval), ``backend_base``
|
|
47
|
+
# auto-opts out so the call hits the provider directly. Explicit
|
|
48
|
+
# ``SOURCEINDEX_BACKEND_URL`` (any value, including a custom URL) still wins.
|
|
49
|
+
_PROVIDER_KEY_PREFIXES = ("sk-or-", "sk-ant-", "gsk_", "AIza")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def looks_like_provider_key(key: str) -> bool:
|
|
53
|
+
return bool(key) and any(key.startswith(p) for p in _PROVIDER_KEY_PREFIXES)
|
|
54
|
+
|
|
55
|
+
# Process-scoped override set by ``apply_remote_config()`` when the backend
|
|
56
|
+
# response includes a ``url`` field. Higher priority than the env var; we
|
|
57
|
+
# avoid mutating ``os.environ`` because that would leak into Claude subagents
|
|
58
|
+
# and the post-commit hook (which we explicitly do NOT want to redirect).
|
|
59
|
+
_pinned_backend_url: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_backend_url(url: str) -> str:
|
|
63
|
+
"""Strip trailing slashes and idempotently append ``/v1``. Accepts a bare
|
|
64
|
+
host (``https://x.workers.dev``) or a fully qualified base (``…/v1``)."""
|
|
65
|
+
url = url.rstrip("/")
|
|
66
|
+
if not url.endswith("/v1"):
|
|
67
|
+
url = f"{url}/v1"
|
|
68
|
+
return url
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _api_key() -> str | None:
|
|
72
|
+
# Lazy import: backend.py is imported on the backend code path from
|
|
73
|
+
# llm.py; resolve_api_key here would create a cycle at module load.
|
|
74
|
+
from .llm import resolve_api_key
|
|
75
|
+
return resolve_api_key(None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def backend_base(api_key: str | None = None) -> str | None:
|
|
79
|
+
"""Return ``<URL>/v1`` for the hosted backend, or ``None`` if disabled.
|
|
80
|
+
|
|
81
|
+
Resolution order:
|
|
82
|
+
1. ``SOURCEINDEX_BACKEND_URL=""`` (explicit empty) → ``None`` (opt-out).
|
|
83
|
+
Checked first so a local opt-out beats admin pins — otherwise admins
|
|
84
|
+
could re-enable the backend on a user who explicitly disabled it.
|
|
85
|
+
2. ``_pinned_backend_url`` (set by ``apply_remote_config()``) → use it.
|
|
86
|
+
3. ``SOURCEINDEX_BACKEND_URL=<other>`` → use that.
|
|
87
|
+
4. Default to ``DEFAULT_BACKEND_URL`` — unless ``api_key`` (explicit arg,
|
|
88
|
+
falling back to env-resolved) looks like a direct-provider key, in
|
|
89
|
+
which case auto-opt-out. The hosted backend would reject such a key;
|
|
90
|
+
auto-routing direct keeps "paste your OpenRouter key" zero-config.
|
|
91
|
+
"""
|
|
92
|
+
env = os.environ.get("SOURCEINDEX_BACKEND_URL")
|
|
93
|
+
if env == "":
|
|
94
|
+
return None
|
|
95
|
+
if _pinned_backend_url is not None:
|
|
96
|
+
return _pinned_backend_url
|
|
97
|
+
if env is not None:
|
|
98
|
+
return _normalize_backend_url(env)
|
|
99
|
+
effective_key = api_key if api_key is not None else _api_key()
|
|
100
|
+
if effective_key and looks_like_provider_key(effective_key):
|
|
101
|
+
return None
|
|
102
|
+
return _normalize_backend_url(DEFAULT_BACKEND_URL)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_json(path: str, *, event: str) -> dict | None:
|
|
106
|
+
"""Authenticated GET ``<backend>/v1/<path>`` returning the parsed JSON.
|
|
107
|
+
Returns ``None`` if the backend is disabled, no API key is available,
|
|
108
|
+
or the request fails — callers fall back to local defaults. ``event``
|
|
109
|
+
tags the debug log on failure for grep-ability."""
|
|
110
|
+
base = backend_base()
|
|
111
|
+
if not base:
|
|
112
|
+
return None
|
|
113
|
+
key = _api_key()
|
|
114
|
+
if not key:
|
|
115
|
+
return None
|
|
116
|
+
req = Request(f"{base}/{path}", headers={"authorization": f"Bearer {key}"})
|
|
117
|
+
try:
|
|
118
|
+
with urlopen(req, timeout=_TIMEOUT_S) as resp:
|
|
119
|
+
return json.loads(resp.read())
|
|
120
|
+
except (URLError, OSError, TimeoutError, ValueError) as e:
|
|
121
|
+
_log.debug(
|
|
122
|
+
f"backend {path} fetch failed",
|
|
123
|
+
extra={"event": event, "error": str(e)},
|
|
124
|
+
)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fetch_config(*, cache_dir: Path | None = None, force: bool = False) -> dict | None:
|
|
129
|
+
"""GET ``/v1/config``. Returns the parsed dict, or ``None`` if no backend
|
|
130
|
+
is configured / the request fails / no API key is available.
|
|
131
|
+
|
|
132
|
+
Response shape (all fields optional, admin-controlled in the Worker
|
|
133
|
+
dashboard):
|
|
134
|
+
|
|
135
|
+
- ``model`` (str): LiteLLM model string to use for indexing + search.
|
|
136
|
+
Any provider LiteLLM understands works — ``bedrock/...``,
|
|
137
|
+
``openrouter/...``, ``vertex_ai/...``, ``anthropic/...``, etc.
|
|
138
|
+
- ``url`` (str): alternate SourceIndex deployment URL. When set, future
|
|
139
|
+
config refreshes AND LLM ``api_base`` both redirect to this URL (they
|
|
140
|
+
stay coupled — the Worker handles provider routing internally).
|
|
141
|
+
- ``ttl_seconds`` (int): how long the client may reuse this response
|
|
142
|
+
before re-fetching. Default 60 if omitted.
|
|
143
|
+
|
|
144
|
+
When ``cache_dir`` is provided, the response is cached at
|
|
145
|
+
``<cache_dir>/.config_cache.json`` and reused for ``ttl_seconds``.
|
|
146
|
+
The internal ``_fetched_at`` timestamp is added on cache write.
|
|
147
|
+
"""
|
|
148
|
+
cache_path = cache_dir / _CONFIG_CACHE_FILENAME if cache_dir else None
|
|
149
|
+
if cache_path and not force:
|
|
150
|
+
try:
|
|
151
|
+
payload = json.loads(cache_path.read_text())
|
|
152
|
+
ttl = float(payload.get("ttl_seconds", 60))
|
|
153
|
+
if payload.get("_fetched_at", 0) + ttl > time.time():
|
|
154
|
+
return payload
|
|
155
|
+
except (OSError, ValueError):
|
|
156
|
+
pass # missing or malformed cache → fall through and re-fetch
|
|
157
|
+
|
|
158
|
+
data = get_json("config", event="backend.fetch_config_failed")
|
|
159
|
+
if data is None:
|
|
160
|
+
return None
|
|
161
|
+
data["_fetched_at"] = time.time()
|
|
162
|
+
if cache_path:
|
|
163
|
+
try:
|
|
164
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
cache_path.write_text(json.dumps(data))
|
|
166
|
+
except OSError:
|
|
167
|
+
pass
|
|
168
|
+
return data
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def apply_remote_config(*, index_dir: Path | None, default_model: str) -> str:
|
|
172
|
+
"""Fetch the admin-controlled config and apply its overrides.
|
|
173
|
+
|
|
174
|
+
Returns the model string to use — backend ``model`` if set, else
|
|
175
|
+
``default_model``. As a side effect, pins ``_pinned_backend_url`` when
|
|
176
|
+
the response carries a non-empty ``url`` so subsequent ``backend_base()``
|
|
177
|
+
calls (both for further config refreshes and for LiteLLM ``api_base``)
|
|
178
|
+
target the new deployment.
|
|
179
|
+
|
|
180
|
+
Always returns ``default_model`` unchanged if the backend is disabled,
|
|
181
|
+
no API key is available, the request fails, or the response is empty —
|
|
182
|
+
callers can use the return value unconditionally.
|
|
183
|
+
"""
|
|
184
|
+
global _pinned_backend_url
|
|
185
|
+
config = fetch_config(cache_dir=index_dir)
|
|
186
|
+
if not config:
|
|
187
|
+
return default_model
|
|
188
|
+
|
|
189
|
+
url_override = config.get("url")
|
|
190
|
+
model_override = config.get("model")
|
|
191
|
+
|
|
192
|
+
if isinstance(url_override, str) and url_override.strip():
|
|
193
|
+
_pinned_backend_url = _normalize_backend_url(url_override.strip())
|
|
194
|
+
|
|
195
|
+
resolved_model = (
|
|
196
|
+
model_override
|
|
197
|
+
if isinstance(model_override, str) and model_override.strip()
|
|
198
|
+
else default_model
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
_log.debug(
|
|
202
|
+
"remote config applied",
|
|
203
|
+
extra={
|
|
204
|
+
"event": "backend.config_applied",
|
|
205
|
+
"model_override": resolved_model if resolved_model != default_model else None,
|
|
206
|
+
"url_override": _pinned_backend_url,
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
return resolved_model
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def upload_telemetry(payload: dict) -> bool:
|
|
213
|
+
"""POST ``/v1/telemetry``. Returns True on 2xx, False otherwise.
|
|
214
|
+
|
|
215
|
+
Caller-supplied ``payload`` must already be anonymized — no file paths,
|
|
216
|
+
no raw source, no tool args. See plan §Anonymization.
|
|
217
|
+
"""
|
|
218
|
+
base = backend_base()
|
|
219
|
+
key = _api_key()
|
|
220
|
+
if not base or not key:
|
|
221
|
+
return False
|
|
222
|
+
body = json.dumps(payload).encode()
|
|
223
|
+
req = Request(
|
|
224
|
+
f"{base}/telemetry",
|
|
225
|
+
data=body,
|
|
226
|
+
headers={
|
|
227
|
+
"authorization": f"Bearer {key}",
|
|
228
|
+
"content-type": "application/json",
|
|
229
|
+
},
|
|
230
|
+
method="POST",
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
with urlopen(req, timeout=_TIMEOUT_S) as resp:
|
|
234
|
+
return 200 <= resp.status < 300
|
|
235
|
+
except (URLError, OSError, TimeoutError) as e:
|
|
236
|
+
_log.debug(
|
|
237
|
+
"telemetry upload failed",
|
|
238
|
+
extra={"event": "backend.telemetry_failed", "error": str(e)},
|
|
239
|
+
)
|
|
240
|
+
return False
|