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
sourceindex/lib/llm.py ADDED
@@ -0,0 +1,298 @@
1
+ """LiteLLM-backed LLM adapter for sourceindex.
2
+
3
+ Single touch-point for every LLM call in build + search. Accepts any model
4
+ string LiteLLM understands:
5
+
6
+ anthropic/claude-haiku-4-5 (direct Anthropic)
7
+ openrouter/anthropic/claude-haiku-4-5 (Anthropic via OpenRouter)
8
+ openrouter/deepseek/deepseek-chat (DeepSeek via OpenRouter)
9
+ deepseek/deepseek-chat (direct DeepSeek)
10
+ gemini/gemini-2.5-flash (direct Gemini)
11
+
12
+ The bare ``claude-haiku-4-5`` form (no provider prefix) is also accepted —
13
+ LiteLLM treats it as Anthropic.
14
+
15
+ Prompts are sent as flat user messages — no explicit prompt caching.
16
+ Providers that auto-cache (DeepSeek, Gemini, OpenAI) still do so server-side;
17
+ the adapter just doesn't ask for it.
18
+ """
19
+
20
+ import os
21
+ import time
22
+ from contextlib import contextmanager
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from .log import get_logger
27
+ from .timing import elapsed_ms
28
+
29
+ _log = get_logger(__name__)
30
+
31
+
32
+ @contextmanager
33
+ def auth_error_to_systemexit(*, index_dir: Path | None = None, api_key: str | None = None):
34
+ """Wrap a block that may raise ``litellm.AuthenticationError`` and convert
35
+ it to a single ``SystemExit`` with the backend-aware translated message.
36
+ Used by every CLI subcommand that fires LLM calls. ``api_key`` is the key
37
+ actually used for the call — needed so the routing diagnostic matches
38
+ reality when ``--api-key`` was passed without setting the env var."""
39
+ import litellm
40
+ try:
41
+ yield
42
+ except litellm.AuthenticationError as e:
43
+ raise SystemExit(translate_auth_error(e, index_dir=index_dir, api_key=api_key))
44
+
45
+
46
+ def translate_auth_error(e: Exception, *, index_dir: Path | None = None, api_key: str | None = None) -> str:
47
+ """Build a user-facing message for an LLM auth failure.
48
+
49
+ LiteLLM relabels auth errors by the *model prefix* (``openrouter/...`` →
50
+ ``OpenrouterException``) regardless of who actually replied. When the call
51
+ was routed through the hosted backend, the rejector is our own Worker —
52
+ not OpenRouter — and the user needs to be pointed at SOURCEINDEX_API_KEY,
53
+ not their provider account. Surface that distinction here so the caller
54
+ can raise SystemExit with a message that matches reality.
55
+ """
56
+ from .backend import backend_base
57
+ base = backend_base(api_key=api_key)
58
+ env_hint = f" or the persisted key in {index_dir / '.env'}" if index_dir else ""
59
+ if base:
60
+ return (
61
+ f"[sourceindex] Hosted backend at {base} rejected the request — "
62
+ f"likely an invalid SOURCEINDEX_API_KEY{env_hint}. "
63
+ f"To talk to a provider directly with your own key instead, set "
64
+ f"SOURCEINDEX_BACKEND_URL=\"\" to opt out of the backend. "
65
+ f"(underlying: {e})"
66
+ )
67
+ return (
68
+ f"[sourceindex] Provider rejected credentials. Check SOURCEINDEX_API_KEY"
69
+ f"{env_hint}. (provider: {e})"
70
+ )
71
+
72
+
73
+ def resolve_api_key(cli_arg: str | None = None) -> str | None:
74
+ """Pick up the API key from (in order): explicit CLI arg, or
75
+ SOURCEINDEX_API_KEY in the environment."""
76
+ return cli_arg or os.environ.get("SOURCEINDEX_API_KEY")
77
+
78
+ # Strip SOCKS proxy from the environment *before* importing litellm — it
79
+ # inherits any ALL_PROXY=socks://... via httpx, which can't speak SOCKS
80
+ # without an extension and crashes the model-cost-map fetch at import time
81
+ # (and later, completion calls). The previous anthropic-SDK path worked
82
+ # around this with httpx.Client(trust_env=False); the closest LiteLLM
83
+ # equivalent is just removing the leak from this process's env.
84
+ for _k in ("ALL_PROXY", "all_proxy"):
85
+ if os.environ.get(_k, "").lower().startswith("socks"):
86
+ os.environ.pop(_k, None)
87
+
88
+ import litellm # noqa: E402 (must follow the env scrub above)
89
+
90
+
91
+ @dataclass
92
+ class LLMUsage:
93
+ """Per-call token + cost accounting. Aggregated by build.state into
94
+ build_meta.json. Search-side calls drop usage on the floor."""
95
+ input_tokens: int = 0
96
+ output_tokens: int = 0
97
+ cache_read_tokens: int = 0
98
+ cache_creation_tokens: int = 0
99
+ cost_usd: float = 0.0
100
+
101
+ def add(self, other: "LLMUsage") -> None:
102
+ self.input_tokens += other.input_tokens
103
+ self.output_tokens += other.output_tokens
104
+ self.cache_read_tokens += other.cache_read_tokens
105
+ self.cache_creation_tokens += other.cache_creation_tokens
106
+ self.cost_usd += other.cost_usd
107
+
108
+
109
+ def model_name_candidates(model: str | None) -> list[str | None]:
110
+ """Yield model-name forms to try against litellm's pricing JSON.
111
+
112
+ LiteLLM keys its model_cost map by canonical names (``gpt-5.4-nano``,
113
+ ``claude-haiku-4-5``) — but we call through router-prefixed forms like
114
+ ``openrouter/openai/gpt-5.4-nano``. Cost lookup needs to peel each
115
+ leading provider prefix until it lands on a known key. Empty/None
116
+ model returns ``[None]`` so the caller falls back to inferring from
117
+ the response object.
118
+ """
119
+ if not model:
120
+ return [None]
121
+ out: list[str | None] = [model]
122
+ parts = model.split("/")
123
+ for i in range(1, len(parts)):
124
+ out.append("/".join(parts[i:]))
125
+ return out
126
+
127
+
128
+ def lookup_pricing(model: str | None) -> dict | None:
129
+ """Find the pricing entry in ``litellm.model_cost`` by trying each
130
+ progressively-stripped form of ``model``. Returns the first hit
131
+ (a dict of per-token cost fields), or ``None`` if no form matches."""
132
+ if not model:
133
+ return None
134
+ for candidate in model_name_candidates(model):
135
+ if not candidate:
136
+ continue
137
+ info = litellm.model_cost.get(candidate)
138
+ if info:
139
+ return info
140
+ return None
141
+
142
+
143
+ def _extract_usage(response, *, model: str | None = None) -> LLMUsage:
144
+ """Best-effort token + cost extraction. Missing fields default to 0 —
145
+ accounting is a soft signal, not load-bearing.
146
+
147
+ Pricing is computed from ``litellm.model_cost`` directly rather than
148
+ via ``litellm.completion_cost``. The latter binds the lookup to the
149
+ response's ``custom_llm_provider`` (e.g. ``openrouter``), and
150
+ ``openrouter+claude-haiku-4-5`` isn't a key in the price map even
151
+ though bare ``claude-haiku-4-5`` is. Manual lookup with
152
+ prefix-stripping bypasses that binding.
153
+ """
154
+ usage = getattr(response, "usage", None)
155
+ if usage is None:
156
+ return LLMUsage()
157
+
158
+ def _g(name: str) -> int:
159
+ v = getattr(usage, name, None)
160
+ return int(v) if v is not None else 0
161
+
162
+ inp = _g("prompt_tokens")
163
+ out = _g("completion_tokens")
164
+ cr = _g("cache_read_input_tokens")
165
+ cc = _g("cache_creation_input_tokens")
166
+
167
+ cost = 0.0
168
+ info = lookup_pricing(model)
169
+ if info:
170
+ cost = (
171
+ inp * info.get("input_cost_per_token", 0.0)
172
+ + out * info.get("output_cost_per_token", 0.0)
173
+ + cr * info.get("cache_read_input_token_cost", 0.0)
174
+ + cc * info.get("cache_creation_input_token_cost", 0.0)
175
+ )
176
+ elif model:
177
+ _log.debug(
178
+ "LLM cost lookup failed",
179
+ extra={
180
+ "event": "llm.cost_unknown",
181
+ "model": model,
182
+ "tried": [c for c in model_name_candidates(model) if c],
183
+ },
184
+ )
185
+
186
+ return LLMUsage(
187
+ input_tokens=_g("prompt_tokens"),
188
+ output_tokens=_g("completion_tokens"),
189
+ cache_read_tokens=_g("cache_read_input_tokens"),
190
+ cache_creation_tokens=_g("cache_creation_input_tokens"),
191
+ cost_usd=cost,
192
+ )
193
+
194
+
195
+ # A self-signed cert from a localhost MITM proxy (e.g. the ``inspect`` proxy
196
+ # used during evals) would otherwise fail TLS verification. Match the gated
197
+ # behaviour of the previous Anthropic-SDK call site.
198
+ _PROXY_URL = (
199
+ os.environ.get("HTTPS_PROXY")
200
+ or os.environ.get("https_proxy")
201
+ or ""
202
+ )
203
+ if "127.0.0.1" in _PROXY_URL or "localhost" in _PROXY_URL:
204
+ litellm.ssl_verify = False
205
+
206
+ # Silently drop standardized params the upstream provider doesn't support
207
+ # (e.g. ``reasoning_effort`` on DeepSeek). Without this, LiteLLM raises
208
+ # UnsupportedParamsError and the call fails.
209
+ litellm.drop_params = True
210
+
211
+
212
+ def call_llm(
213
+ prompt: str,
214
+ *,
215
+ model: str,
216
+ api_key: str,
217
+ max_tokens: int = 16384,
218
+ ) -> tuple[str | None, LLMUsage]:
219
+ """Sync LiteLLM completion. Returns (text, usage).
220
+
221
+ ``text`` is the stripped assistant text, or ``None`` if empty. ``usage``
222
+ is always an ``LLMUsage`` (zeroed when the provider returned no usage
223
+ block) so callers can sum unconditionally without None-checking.
224
+
225
+ Callers MUST pass ``api_key`` explicitly — there is no implicit env-var
226
+ fallback inside this function. Resolve it via ``resolve_api_key()``
227
+ (CLI arg → ``SOURCEINDEX_API_KEY``) and pass the result in.
228
+ """
229
+ assert api_key, (
230
+ "call_llm requires an explicit api_key. Use resolve_api_key() "
231
+ "(CLI arg or SOURCEINDEX_API_KEY) and pass the result in."
232
+ )
233
+ # Pin OpenRouter to the model's official primary provider — disables
234
+ # fallback to resold/quantized/rate-limited alternates. Quality stays
235
+ # consistent and slow detours are avoided.
236
+ #
237
+ # NOTE on reasoning: do NOT send ``reasoning.max_tokens=0``. Empirically
238
+ # (gpt-5.4-nano via OpenRouter, 2026-05-04) sending that flag *triggers*
239
+ # ~2.5K reasoning tokens per call and pushes wall time from ~4s → ~23s.
240
+ # The default behaviour with no reasoning flag is reasoning_tokens=0.
241
+ # If a future model needs a budget cap, use ``reasoning_effort="minimal"``
242
+ # instead — never the max_tokens form.
243
+ extra: dict = {}
244
+ if model.startswith("openrouter/"):
245
+ extra["extra_body"] = {"provider": {"allow_fallbacks": False}}
246
+ # Lazy import so the SOCKS-proxy scrub above runs first and so this
247
+ # module can still be imported without hitting backend.py's stdlib deps.
248
+ from .backend import backend_base
249
+ backend_url = backend_base(api_key=api_key)
250
+ if backend_url:
251
+ extra["api_base"] = backend_url
252
+ session_id = os.environ.get("SOURCEINDEX_SESSION_ID")
253
+ if session_id:
254
+ extra.setdefault("extra_headers", {})["x-session-id"] = session_id
255
+ t0 = time.perf_counter()
256
+ try:
257
+ response = litellm.completion(
258
+ model=model,
259
+ messages=[{"role": "user", "content": prompt}],
260
+ max_tokens=max_tokens,
261
+ api_key=api_key,
262
+ **extra,
263
+ )
264
+ except Exception as e:
265
+ _log.debug(
266
+ "LLM call failed",
267
+ extra={
268
+ "event": "llm.call",
269
+ "model": model,
270
+ "prompt_len": len(prompt),
271
+ "max_tokens": max_tokens,
272
+ "latency_ms": elapsed_ms(t0),
273
+ "success": False,
274
+ "error_type": type(e).__name__,
275
+ "error": str(e),
276
+ },
277
+ )
278
+ raise
279
+ text = (response.choices[0].message.content or "").strip()
280
+ usage = _extract_usage(response, model=model)
281
+ _log.debug(
282
+ "LLM call complete",
283
+ extra={
284
+ "event": "llm.call",
285
+ "model": model,
286
+ "prompt_len": len(prompt),
287
+ "response_len": len(text),
288
+ "max_tokens": max_tokens,
289
+ "latency_ms": elapsed_ms(t0),
290
+ "input_tokens": usage.input_tokens,
291
+ "output_tokens": usage.output_tokens,
292
+ "cache_read_tokens": usage.cache_read_tokens,
293
+ "cache_creation_tokens": usage.cache_creation_tokens,
294
+ "cost_usd": round(usage.cost_usd, 6),
295
+ "success": True,
296
+ },
297
+ )
298
+ return (text or None), usage
sourceindex/lib/log.py ADDED
@@ -0,0 +1,228 @@
1
+ """Centralized logging for the sourceindex package.
2
+
3
+ All sourceindex modules obtain their logger via ``get_logger(__name__)``.
4
+ The root logger is ``sourceindex``; child loggers (``sourceindex.build``,
5
+ ``sourceindex.search``, …) inherit handlers and level from it, so a single
6
+ ``configure()`` call governs the whole package.
7
+
8
+ Two destinations:
9
+
10
+ - **stderr** — plain text. Reproduces the pre-logging
11
+ ``print(f"[sourceindex] ...", file=sys.stderr)`` surface byte-for-byte at
12
+ INFO so log scrapers / eval harnesses keep working. Level controlled by
13
+ ``--log-level`` / ``SOURCEINDEX_LOG_LEVEL`` (default INFO).
14
+ - **<repo>/.sourceindex/sourceindex.log** — JSONL, one event per line.
15
+ Always at DEBUG. Rotated via ``RotatingFileHandler`` (10 MB × 5 backups).
16
+ Attached only when ``repo_root`` is supplied to ``configure()`` — the
17
+ ``statusline`` / ``report-savings`` subcommands pass nothing and stay
18
+ file-silent.
19
+
20
+ Disable the file handler with ``SOURCEINDEX_NO_LOG_FILE=1`` or pass
21
+ ``--no-log-file``. Override the path via ``--log-file PATH`` or
22
+ ``SOURCEINDEX_LOG_FILE=...``.
23
+
24
+ JSONL schema: every line carries ``ts`` / ``level`` / ``logger`` /
25
+ ``message``. Callers can tag analytics-worthy events with structured
26
+ fields by passing ``extra={"event": "build.done", "elapsed_s": 12.4, ...}``
27
+ to the logger call — extras land at the top level of the JSON object
28
+ alongside the standard fields.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import logging
35
+ import logging.handlers
36
+ import os
37
+ import sys
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+
41
+ from .env import env_truthy
42
+
43
+ ROOT_NAME = "sourceindex"
44
+ ENV_LEVEL = "SOURCEINDEX_LOG_LEVEL"
45
+ ENV_LOG_FILE = "SOURCEINDEX_LOG_FILE"
46
+ ENV_NO_FILE = "SOURCEINDEX_NO_LOG_FILE"
47
+ LOG_FILENAME = "sourceindex.log"
48
+ DEFAULT_MAX_BYTES = 10 * 1024 * 1024
49
+ DEFAULT_BACKUP_COUNT = 5
50
+
51
+ _PREFIX = "[sourceindex]"
52
+
53
+ # Snapshot the built-in LogRecord field names by constructing a placeholder
54
+ # record at import time. Anything *not* in this set was added via
55
+ # ``extra={...}`` and gets surfaced as a top-level field in the JSONL
56
+ # output. Snapshotting (vs. a hand-written list) tracks stdlib additions
57
+ # automatically — e.g. ``taskName`` arrived in Python 3.12.
58
+ _STD_RECORD_ATTRS = frozenset(
59
+ logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys()
60
+ ) | {"asctime", "message"}
61
+
62
+
63
+ class _StderrFormatter(logging.Formatter):
64
+ """``[sourceindex] message`` for INFO, ``[sourceindex] LEVEL: message`` else."""
65
+
66
+ def format(self, record: logging.LogRecord) -> str:
67
+ msg = record.getMessage()
68
+ if record.exc_info:
69
+ msg = f"{msg}\n{self.formatException(record.exc_info)}"
70
+ if record.levelno == logging.INFO:
71
+ return f"{_PREFIX} {msg}"
72
+ return f"{_PREFIX} {record.levelname}: {msg}"
73
+
74
+
75
+ class _JsonlFormatter(logging.Formatter):
76
+ """One JSON object per record. ``ts``/``level``/``logger``/``message``
77
+ are always emitted; any extras supplied via ``extra={...}`` become
78
+ top-level fields. Non-JSON-serializable extras fall back to ``repr``
79
+ so a bad value can't sink the line."""
80
+
81
+ def format(self, record: logging.LogRecord) -> str:
82
+ obj: dict = {
83
+ "ts": datetime.fromtimestamp(record.created, timezone.utc).strftime(
84
+ "%Y-%m-%dT%H:%M:%S.%fZ"
85
+ ),
86
+ "level": record.levelname,
87
+ "logger": record.name,
88
+ "message": record.getMessage(),
89
+ }
90
+ if record.exc_info:
91
+ obj["traceback"] = self.formatException(record.exc_info)
92
+ for key, val in record.__dict__.items():
93
+ if key not in _STD_RECORD_ATTRS and not key.startswith("_"):
94
+ obj[key] = val
95
+ return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=repr)
96
+
97
+
98
+ def configure(
99
+ level: str | int | None = None,
100
+ *,
101
+ repo_root: Path | str | None = None,
102
+ log_file: Path | str | None = None,
103
+ enable_file: bool = True,
104
+ max_bytes: int = DEFAULT_MAX_BYTES,
105
+ backup_count: int = DEFAULT_BACKUP_COUNT,
106
+ ) -> logging.Logger:
107
+ """Install handlers + set the root level. Idempotent."""
108
+ logger = logging.getLogger(ROOT_NAME)
109
+ stderr_level = _resolve_level(level)
110
+ file_path = _resolve_log_path(log_file, repo_root) if enable_file else None
111
+ file_attached = _attach_file_handler(logger, file_path, max_bytes, backup_count)
112
+ _attach_stderr_handler(logger, stderr_level)
113
+
114
+ # Logger level is the floor — must allow DEBUG when the file is on so
115
+ # DEBUG records reach the file handler. The stderr handler does its own
116
+ # level filtering downstream.
117
+ logger.setLevel(logging.DEBUG if file_attached else stderr_level)
118
+
119
+ litellm_logger = logging.getLogger("LiteLLM")
120
+ if litellm_logger.level == 0:
121
+ litellm_logger.setLevel(logging.WARNING)
122
+
123
+ return logger
124
+
125
+
126
+ def get_logger(name: str = ROOT_NAME) -> logging.Logger:
127
+ """Return a logger for ``name`` (typically ``__name__``)."""
128
+ return logging.getLogger(name)
129
+
130
+
131
+ def log_file_error(
132
+ logger: logging.Logger,
133
+ path: str | Path,
134
+ exc: BaseException,
135
+ *,
136
+ phase: str,
137
+ ) -> None:
138
+ """Emit a ``file.read_error`` DEBUG event for a swallowed file IO failure.
139
+
140
+ Centralizes the OSError-on-read pattern (read/stat/etc. fails, caller
141
+ continues with the file dropped from the index) so every site reports
142
+ the same shape: path, phase, error type, error message.
143
+ """
144
+ logger.debug(
145
+ f"File operation failed ({phase}): {path}",
146
+ extra={
147
+ "event": "file.read_error",
148
+ "phase": phase,
149
+ "path": str(path),
150
+ "error_type": type(exc).__name__,
151
+ "error": str(exc),
152
+ },
153
+ )
154
+
155
+
156
+ # ── internals ──────────────────────────────────────────────────────────────
157
+
158
+
159
+ def _resolve_level(level: str | int | None) -> int:
160
+ if level is None:
161
+ level = os.environ.get(ENV_LEVEL) or "INFO"
162
+ if isinstance(level, int):
163
+ return level
164
+ return logging._nameToLevel.get(str(level).upper(), logging.INFO)
165
+
166
+
167
+ def _resolve_log_path(
168
+ explicit_path: Path | str | None, repo_root: Path | str | None
169
+ ) -> Path | None:
170
+ if env_truthy(ENV_NO_FILE):
171
+ return None
172
+ if explicit_path:
173
+ return Path(explicit_path).expanduser().resolve()
174
+ env_path = os.environ.get(ENV_LOG_FILE)
175
+ if env_path:
176
+ return Path(env_path).expanduser().resolve()
177
+ if repo_root is not None:
178
+ # Lazy import — log.py is foundational and used by every module.
179
+ from ..daemon import IndexDir
180
+ return IndexDir.for_repo(Path(repo_root)).path / LOG_FILENAME
181
+ return None
182
+
183
+
184
+ def _attach_stderr_handler(logger: logging.Logger, level: int) -> None:
185
+ for h in logger.handlers:
186
+ if getattr(h, "_si_stderr", False):
187
+ h.setLevel(level)
188
+ return
189
+ h = logging.StreamHandler(sys.stderr)
190
+ h.setFormatter(_StderrFormatter())
191
+ h.setLevel(level)
192
+ h._si_stderr = True # type: ignore[attr-defined]
193
+ logger.addHandler(h)
194
+ logger.propagate = False
195
+
196
+
197
+ def _attach_file_handler(
198
+ logger: logging.Logger,
199
+ path: Path | None,
200
+ max_bytes: int,
201
+ backup_count: int,
202
+ ) -> bool:
203
+ """Attach a JSONL rotating file handler at ``path``.
204
+
205
+ Returns True iff a handler is now attached (already-present or freshly
206
+ installed). False on disabled or on an OSError that prevents opening
207
+ the file (silently degrades to stderr-only).
208
+ """
209
+ if path is None:
210
+ return False
211
+ for h in logger.handlers:
212
+ if getattr(h, "_si_file", False):
213
+ return True
214
+ try:
215
+ path.parent.mkdir(parents=True, exist_ok=True)
216
+ handler = logging.handlers.RotatingFileHandler(
217
+ path,
218
+ maxBytes=max_bytes,
219
+ backupCount=backup_count,
220
+ encoding="utf-8",
221
+ )
222
+ except OSError:
223
+ return False
224
+ handler.setFormatter(_JsonlFormatter())
225
+ handler.setLevel(logging.DEBUG)
226
+ handler._si_file = True # type: ignore[attr-defined]
227
+ logger.addHandler(handler)
228
+ return True
@@ -0,0 +1,75 @@
1
+ """Local registry of repos this user has indexed.
2
+
3
+ `sourceindex init` appends here; `sourceindex deinit` removes. The login-time
4
+ sweep (`sourceindex deinit --all`) walks the file looking for entries whose
5
+ `repo_path` no longer exists and tears down their per-repo state.
6
+
7
+ The hash → path direction is one-way (sha256), so without this registry we
8
+ have no way to discover orphaned keys after a reboot wipes `XDG_RUNTIME_DIR`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+
17
+ Entry = tuple[str, str] # (repo_hash, repo_path)
18
+
19
+
20
+ def _registry_path() -> Path:
21
+ xdg = os.environ.get("XDG_CONFIG_HOME")
22
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
23
+ return base / "sourceindex" / "repos.tsv"
24
+
25
+
26
+ def read() -> list[Entry]:
27
+ path = _registry_path()
28
+ if not path.is_file():
29
+ return []
30
+ entries: list[Entry] = []
31
+ for raw in path.read_text().splitlines():
32
+ line = raw.strip()
33
+ if not line or line.startswith("#"):
34
+ continue
35
+ parts = line.split("\t", 1)
36
+ if len(parts) != 2:
37
+ continue
38
+ entries.append((parts[0], parts[1]))
39
+ return entries
40
+
41
+
42
+ def add(repo_hash: str, repo_path: str) -> None:
43
+ """Record (repo_hash, repo_path). If the hash already exists, update its
44
+ path — handles re-init after a path rename."""
45
+ entries = read()
46
+ found = False
47
+ updated: list[Entry] = []
48
+ for h, p in entries:
49
+ if h == repo_hash:
50
+ updated.append((h, repo_path))
51
+ found = True
52
+ else:
53
+ updated.append((h, p))
54
+ if not found:
55
+ updated.append((repo_hash, repo_path))
56
+ _write(updated)
57
+
58
+
59
+ def remove(repo_hash: str) -> None:
60
+ """Drop the entry for repo_hash. No-op if absent."""
61
+ entries = [(h, p) for h, p in read() if h != repo_hash]
62
+ _write(entries)
63
+
64
+
65
+ def _write(entries: list[Entry]) -> None:
66
+ path = _registry_path()
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ try:
69
+ path.parent.chmod(0o700)
70
+ except OSError:
71
+ pass
72
+ text = "".join(f"{h}\t{p}\n" for h, p in entries)
73
+ tmp = path.with_suffix(".tsv.tmp")
74
+ tmp.write_text(text)
75
+ tmp.replace(path)
@@ -0,0 +1,10 @@
1
+ """Timing helpers shared by callers that emit ``latency_ms`` log fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+
8
+ def elapsed_ms(start: float) -> int:
9
+ """Whole milliseconds between ``start`` (a ``time.perf_counter()`` value) and now."""
10
+ return int((time.perf_counter() - start) * 1000)