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,30 @@
1
+ __version__ = "0.1.0"
2
+
3
+ # Surfaced at the top level so the CLI's argparse setup can use it for
4
+ # --workers help/default without importing the build pipeline (which
5
+ # transitively pulls litellm and inflates `sourceindex statusline` startup
6
+ # from ~20ms to ~2.3s on every render tick).
7
+ REALTIME_MAX_WORKERS = 128
8
+
9
+
10
+ # Suppress LiteLLM's bedrock/sagemaker pre-load warnings. They fire at import
11
+ # from litellm.litellm_core_utils.common_utils when botocore isn't installed
12
+ # — a baseline "expected" state for our shipped wheel (we route through
13
+ # OpenAI/Anthropic/OpenRouter, never Bedrock or SageMaker, and don't pull in
14
+ # a 100MB+ botocore just to satisfy that startup probe). Installed at package
15
+ # import so it runs before any submodule imports litellm.
16
+ import logging as _logging
17
+
18
+
19
+ class _LiteLLMBotocorePreloadFilter(_logging.Filter):
20
+ _SUPPRESS = (
21
+ "could not pre-load bedrock-runtime",
22
+ "could not pre-load sagemaker-runtime",
23
+ )
24
+
25
+ def filter(self, record: _logging.LogRecord) -> bool:
26
+ msg = record.getMessage()
27
+ return not any(s in msg for s in self._SUPPRESS)
28
+
29
+
30
+ _logging.getLogger("LiteLLM").addFilter(_LiteLLMBotocorePreloadFilter())
@@ -0,0 +1,592 @@
1
+ """Tier-1 + tier-2 index build/update pipeline.
2
+
3
+ The ``_with_store`` variants are what the daemon calls. The legacy
4
+ ``index_dir``-based entry points wrap a ``PlaintextStore`` for tests
5
+ and the eval-dumped cache flow."""
6
+
7
+ import os
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from ..lib.languages import (
13
+ Language,
14
+ all_extensions,
15
+ detect_languages,
16
+ get_languages,
17
+ language_for_path,
18
+ )
19
+ from ..lib.env import env_truthy
20
+ from ..lib.llm import LLMUsage
21
+ from ..lib.log import get_logger, log_file_error
22
+ from .indexer import (
23
+ BATCH_MIN_FILES,
24
+ DEFAULT_MODEL,
25
+ REALTIME_MAX_WORKERS,
26
+ emit_status,
27
+ index_by_hash,
28
+ index_file,
29
+ )
30
+ from .state import (
31
+ BUILD_META_FILENAME,
32
+ load_file_index,
33
+ load_summary_cache,
34
+ merge_build_meta,
35
+ save_file_index,
36
+ save_summary_cache,
37
+ try_summary_cache_hit,
38
+ utcnow_iso,
39
+ write_build_meta,
40
+ write_details,
41
+ )
42
+ from .walker import (
43
+ collect_source_files,
44
+ detect_changes_by_mtime,
45
+ hash_file_content,
46
+ )
47
+
48
+ _log = get_logger(__name__)
49
+
50
+
51
+ def _resolve_plaintext_store(repo_root: Path, index_dir: Path | None):
52
+ """Always returns a PlaintextStore — the legacy-API back-compat shim
53
+ for callers that haven't migrated to the daemon path."""
54
+ from ..daemon import IndexDir, PlaintextStore
55
+ path = index_dir if index_dir is not None else IndexDir.for_repo(repo_root).path
56
+ return PlaintextStore(path)
57
+
58
+
59
+ def build_index(
60
+ repo_root: Path,
61
+ index_dir: Path | None = None,
62
+ model: str = DEFAULT_MODEL,
63
+ api_key: str | None = None,
64
+ languages: list[Language] | None = None,
65
+ *,
66
+ workers: int = REALTIME_MAX_WORKERS,
67
+ ) -> None:
68
+ """Legacy entry point: build full tier-1 and tier-2 index for a repository.
69
+
70
+ Args:
71
+ repo_root: Path to the repository root.
72
+ index_dir: Where to write the index (default: repo_root/.sourceindex).
73
+ model: LLM model to use for summarization.
74
+ api_key: Anthropic API key for batch mode. If None, uses `claude -p`.
75
+ languages: Language objects to index. If None, auto-detects.
76
+ workers: Max concurrent LLM requests in batch mode.
77
+ """
78
+ store = _resolve_plaintext_store(repo_root, index_dir)
79
+ build_index_with_store(
80
+ repo_root,
81
+ store,
82
+ model=model,
83
+ api_key=api_key,
84
+ languages=languages,
85
+ workers=workers,
86
+ )
87
+
88
+
89
+ def build_index_with_store(
90
+ repo_root: Path,
91
+ store,
92
+ *,
93
+ model: str | None = None,
94
+ api_key: str | None = None,
95
+ languages: list[Language] | list[str] | None = None,
96
+ workers: int | None = None,
97
+ ) -> dict:
98
+ model = model or DEFAULT_MODEL
99
+ workers = workers or REALTIME_MAX_WORKERS
100
+ languages_resolved = _resolve_languages_arg(repo_root, languages)
101
+
102
+ store.ensure_initialized()
103
+
104
+ started_at = utcnow_iso()
105
+ start_time = time.time()
106
+ total_usage = LLMUsage()
107
+
108
+ _log.debug(
109
+ "Build starting",
110
+ extra={
111
+ "event": "build.start",
112
+ "repo_root": str(repo_root),
113
+ "model": model,
114
+ "workers": workers,
115
+ "language_count": len(languages_resolved),
116
+ },
117
+ )
118
+
119
+ source_files = collect_source_files(repo_root, languages_resolved)
120
+ if not source_files:
121
+ _log.info("No source files found.", extra={"event": "build.files_found", "count": 0})
122
+ store.write_text("index.md", "(no source files found)\n")
123
+ write_build_meta(
124
+ store,
125
+ model=model,
126
+ files_indexed=0,
127
+ files_from_cache=0,
128
+ usage=total_usage,
129
+ elapsed_s=time.time() - start_time,
130
+ started_at=started_at,
131
+ )
132
+ return {"files_indexed": 0, "files_from_cache": 0, "cost_usd": 0.0,
133
+ "elapsed_s": time.time() - start_time}
134
+
135
+ _log.info(
136
+ f"Found {len(source_files)} source files to index.",
137
+ extra={"event": "build.files_found", "count": len(source_files)},
138
+ )
139
+
140
+ summary_cache = load_summary_cache(store)
141
+
142
+ file_contents: dict[str, str] = {}
143
+ file_hashes: dict[str, str] = {}
144
+ file_langs: dict[str, Language] = {}
145
+ needs_llm: dict[str, str] = {}
146
+ oneliners: dict[str, str] = {}
147
+
148
+ for rel_path in source_files:
149
+ full_path = repo_root / rel_path
150
+ try:
151
+ content = full_path.read_text(errors="replace")
152
+ except OSError as e:
153
+ log_file_error(_log, rel_path, e, phase="build.read")
154
+ continue
155
+ if not content.strip():
156
+ continue
157
+
158
+ lang = language_for_path(rel_path, languages_resolved)
159
+ if lang is None:
160
+ continue
161
+ h = hash_file_content(content)
162
+ file_hashes[rel_path] = h
163
+ file_contents[rel_path] = content
164
+ file_langs[rel_path] = lang
165
+
166
+ hit = try_summary_cache_hit(summary_cache, h, lang, store, rel_path)
167
+ if hit is not None:
168
+ oneliners[rel_path] = hit[0]
169
+ else:
170
+ needs_llm[rel_path] = content
171
+
172
+ cache_hits = len(oneliners)
173
+ cache_misses = len(needs_llm)
174
+ _log.info(
175
+ f"Summary cache: {cache_hits} hits, {cache_misses} misses.",
176
+ extra={
177
+ "event": "build.cache_summary",
178
+ "cache_hits": cache_hits,
179
+ "cache_misses": cache_misses,
180
+ },
181
+ )
182
+
183
+ if api_key and cache_misses > 0:
184
+ hash_to_content: dict[str, tuple[str, str, Language]] = {}
185
+ for rel_path, content in needs_llm.items():
186
+ hash_to_content[file_hashes[rel_path]] = (content, rel_path, file_langs[rel_path])
187
+
188
+ hash_to_parsed, batch_usage = index_by_hash(
189
+ hash_to_content,
190
+ summary_cache=summary_cache,
191
+ model=model,
192
+ api_key=api_key,
193
+ workers=workers,
194
+ label="index",
195
+ )
196
+ total_usage.add(batch_usage)
197
+
198
+ emit_status("[finalize] writing tier-2 details and saving cache...")
199
+ for rel_path in needs_llm:
200
+ h = file_hashes[rel_path]
201
+ if h in hash_to_parsed:
202
+ oneliner, functions = hash_to_parsed[h]
203
+ if oneliner:
204
+ oneliners[rel_path] = oneliner
205
+ if file_langs[rel_path].build_tier2:
206
+ write_details(store, rel_path, functions)
207
+ else:
208
+ oneliners[rel_path] = "(LLM summary failed)"
209
+ if file_langs[rel_path].build_tier2:
210
+ write_details(store, rel_path, "")
211
+ else:
212
+ for rel_path, content in needs_llm.items():
213
+ _log.info(f"[{rel_path}] Indexing...", extra={"event": "build.file_indexing", "path": rel_path})
214
+ lang = file_langs[rel_path]
215
+ oneliner, functions, usage = index_file(content, rel_path, lang, model=model, api_key=api_key)
216
+ total_usage.add(usage)
217
+ oneliners[rel_path] = oneliner
218
+ summary_cache[file_hashes[rel_path]] = {
219
+ "oneliner": oneliner,
220
+ "functions": functions,
221
+ }
222
+ if lang.build_tier2:
223
+ write_details(store, rel_path, functions)
224
+
225
+ emit_status("[finalize] writing index.md and per-file metadata...")
226
+ index_lines = []
227
+ for rel_path in source_files:
228
+ if rel_path in oneliners:
229
+ index_lines.append(f"{rel_path} — {oneliners[rel_path]}")
230
+
231
+ store.write_text("index.md", "\n".join(index_lines) + "\n")
232
+
233
+ if cache_misses > 0:
234
+ save_summary_cache(store, summary_cache)
235
+
236
+ file_index_map: dict[str, dict] = {}
237
+ for rel_path in source_files:
238
+ if rel_path not in oneliners:
239
+ continue
240
+ try:
241
+ mtime = (repo_root / rel_path).stat().st_mtime_ns
242
+ except OSError as e:
243
+ log_file_error(_log, rel_path, e, phase="build.stat")
244
+ continue
245
+ file_index_map[rel_path] = {
246
+ "mtime_ns": mtime,
247
+ "hash": file_hashes.get(rel_path, ""),
248
+ }
249
+ save_file_index(store, file_index_map)
250
+
251
+ elapsed_s = time.time() - start_time
252
+ write_build_meta(
253
+ store,
254
+ model=model,
255
+ files_indexed=cache_misses,
256
+ files_from_cache=cache_hits,
257
+ usage=total_usage,
258
+ elapsed_s=elapsed_s,
259
+ started_at=started_at,
260
+ )
261
+
262
+ _log.info(
263
+ f"Done. Index: {store.path_for('index.md')} ({len(index_lines)} files, "
264
+ f"{cache_hits} from cache)",
265
+ extra={
266
+ "event": "build.done",
267
+ "index_path": str(store.path_for("index.md")),
268
+ "files_in_index": len(index_lines),
269
+ "files_indexed": cache_misses,
270
+ "files_from_cache": cache_hits,
271
+ "elapsed_s": round(elapsed_s, 3),
272
+ "input_tokens": total_usage.input_tokens,
273
+ "output_tokens": total_usage.output_tokens,
274
+ "cost_usd": round(total_usage.cost_usd, 6),
275
+ },
276
+ )
277
+ return {
278
+ "files_indexed": cache_misses,
279
+ "files_from_cache": cache_hits,
280
+ "cost_usd": round(total_usage.cost_usd, 6),
281
+ "elapsed_s": round(elapsed_s, 3),
282
+ }
283
+
284
+
285
+ def update_index(
286
+ repo_root: Path,
287
+ index_dir: Path | None = None,
288
+ changed_files: list[str] | None = None,
289
+ model: str = DEFAULT_MODEL,
290
+ languages: list[Language] | None = None,
291
+ api_key: str | None = None,
292
+ *,
293
+ persist_file_index: bool = True,
294
+ workers: int = REALTIME_MAX_WORKERS,
295
+ ) -> None:
296
+ store = _resolve_plaintext_store(repo_root, index_dir)
297
+ update_index_with_store(
298
+ repo_root,
299
+ store,
300
+ changed_files=changed_files,
301
+ model=model,
302
+ languages=languages,
303
+ api_key=api_key,
304
+ persist_file_index=persist_file_index,
305
+ workers=workers,
306
+ )
307
+
308
+
309
+ def update_index_with_store(
310
+ repo_root: Path,
311
+ store,
312
+ *,
313
+ changed_files: list[str] | None = None,
314
+ model: str | None = None,
315
+ languages: list[Language] | list[str] | None = None,
316
+ api_key: str | None = None,
317
+ persist_file_index: bool = True,
318
+ workers: int | None = None,
319
+ ) -> dict:
320
+ model = model or DEFAULT_MODEL
321
+ workers = workers or REALTIME_MAX_WORKERS
322
+ languages_resolved = _resolve_languages_arg(repo_root, languages)
323
+
324
+ if not store.exists("index.md"):
325
+ _log.info(
326
+ "No existing index found. Run `sourceindex init` first.",
327
+ extra={"event": "update.no_index", "index_path": str(store.path_for("index.md"))},
328
+ )
329
+ return {"files_indexed": 0, "elapsed_s": 0.0, "cost_usd": 0.0}
330
+
331
+ exts = all_extensions(languages_resolved)
332
+
333
+ if changed_files is None:
334
+ try:
335
+ pathspecs = [f"*{ext}" for ext in exts]
336
+ result = subprocess.run(
337
+ ["git", "diff", "--name-only", "HEAD~1", "HEAD", "--", *pathspecs],
338
+ capture_output=True, text=True, cwd=repo_root,
339
+ )
340
+ changed_files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
341
+ except Exception as e:
342
+ _log.warning(
343
+ "Could not detect changed files via git.",
344
+ extra={
345
+ "event": "update.git_diff_failed",
346
+ "error_type": type(e).__name__,
347
+ "error": str(e),
348
+ },
349
+ )
350
+ return {"files_indexed": 0, "elapsed_s": 0.0, "cost_usd": 0.0}
351
+
352
+ changed_files = [f for f in changed_files if f.lower().endswith(exts)]
353
+
354
+ if not changed_files:
355
+ _log.info(
356
+ "No changed source files to update.",
357
+ extra={"event": "update.no_changes"},
358
+ )
359
+ return {"files_indexed": 0, "elapsed_s": 0.0, "cost_usd": 0.0}
360
+
361
+ _log.info(
362
+ f"Updating {len(changed_files)} changed files...",
363
+ extra={"event": "update.start", "changed_count": len(changed_files)},
364
+ )
365
+
366
+ started_at = utcnow_iso()
367
+ start_time = time.time()
368
+ total_usage = LLMUsage()
369
+ files_indexed = 0
370
+ summary_cache = load_summary_cache(store)
371
+ file_index_map = load_file_index(store) if persist_file_index else {}
372
+
373
+ existing_index: dict[str, str] = {}
374
+ for line in store.read_text("index.md").strip().split("\n"):
375
+ if " — " in line:
376
+ path, oneliner = line.split(" — ", 1)
377
+ existing_index[path] = oneliner
378
+
379
+ needs_llm: dict[str, tuple[str, Language, int, str]] = {}
380
+
381
+ for rel_path in changed_files:
382
+ full_path = repo_root / rel_path
383
+ if not full_path.is_file():
384
+ existing_index.pop(rel_path, None)
385
+ file_index_map.pop(rel_path, None)
386
+ store.unlink(f"details/{rel_path}.md", missing_ok=True)
387
+ _log.info(
388
+ f" Removed: {rel_path}",
389
+ extra={"event": "update.file_removed", "path": rel_path},
390
+ )
391
+ continue
392
+
393
+ try:
394
+ st = full_path.stat()
395
+ content = full_path.read_text(errors="replace")
396
+ except OSError as e:
397
+ log_file_error(_log, rel_path, e, phase="update.read")
398
+ continue
399
+
400
+ if not content.strip():
401
+ continue
402
+
403
+ lang = language_for_path(rel_path, languages_resolved)
404
+ if lang is None:
405
+ continue
406
+
407
+ h = hash_file_content(content)
408
+ hit = try_summary_cache_hit(summary_cache, h, lang, store, rel_path)
409
+ if hit is not None:
410
+ existing_index[rel_path] = hit[0]
411
+ file_index_map[rel_path] = {"mtime_ns": st.st_mtime_ns, "hash": h}
412
+ _log.info(
413
+ f" Cached: {rel_path}",
414
+ extra={"event": "update.cache_hit", "path": rel_path},
415
+ )
416
+ continue
417
+
418
+ needs_llm[rel_path] = (content, lang, st.st_mtime_ns, h)
419
+
420
+ if api_key and len(needs_llm) >= BATCH_MIN_FILES:
421
+ hash_to_content: dict[str, tuple[str, str, Language]] = {}
422
+ for rel_path, (content, lang, _, h) in needs_llm.items():
423
+ hash_to_content[h] = (content, rel_path, lang)
424
+ _log.info(
425
+ f" Concurrent indexing: {len(needs_llm)} files "
426
+ f"({len(hash_to_content)} unique)...",
427
+ extra={
428
+ "event": "update.concurrent_indexing",
429
+ "files": len(needs_llm),
430
+ "unique": len(hash_to_content),
431
+ },
432
+ )
433
+ hash_to_parsed, batch_usage = index_by_hash(
434
+ hash_to_content,
435
+ summary_cache=summary_cache,
436
+ model=model,
437
+ api_key=api_key,
438
+ workers=workers,
439
+ label="update",
440
+ )
441
+ total_usage.add(batch_usage)
442
+
443
+ for rel_path, (_, lang, mtime_ns, h) in needs_llm.items():
444
+ if h in hash_to_parsed:
445
+ oneliner, functions = hash_to_parsed[h]
446
+ else:
447
+ oneliner, functions = "(LLM summary failed)", ""
448
+ if lang.build_tier2:
449
+ write_details(store, rel_path, functions)
450
+ existing_index[rel_path] = oneliner
451
+ file_index_map[rel_path] = {"mtime_ns": mtime_ns, "hash": h}
452
+ files_indexed += 1
453
+ else:
454
+ for rel_path, (content, lang, mtime_ns, h) in needs_llm.items():
455
+ _log.info(
456
+ f" Updating: {rel_path}",
457
+ extra={"event": "update.file_indexing", "path": rel_path},
458
+ )
459
+ oneliner, functions, usage = index_file(content, rel_path, lang, model=model, api_key=api_key)
460
+ total_usage.add(usage)
461
+ files_indexed += 1
462
+ summary_cache[h] = {"oneliner": oneliner, "functions": functions}
463
+ if lang.build_tier2:
464
+ write_details(store, rel_path, functions)
465
+ existing_index[rel_path] = oneliner
466
+ file_index_map[rel_path] = {"mtime_ns": mtime_ns, "hash": h}
467
+
468
+ index_lines = [f"{p} — {o}" for p, o in sorted(existing_index.items())]
469
+ store.write_text("index.md", "\n".join(index_lines) + "\n")
470
+ save_summary_cache(store, summary_cache)
471
+ if persist_file_index:
472
+ save_file_index(store, file_index_map)
473
+
474
+ elapsed_s = time.time() - start_time
475
+ merge_build_meta(
476
+ store,
477
+ model=model,
478
+ files_indexed=files_indexed,
479
+ usage=total_usage,
480
+ elapsed_s=elapsed_s,
481
+ started_at=started_at,
482
+ )
483
+
484
+ _log.info(
485
+ f"Updated {len(changed_files)} files.",
486
+ extra={
487
+ "event": "update.done",
488
+ "changed_count": len(changed_files),
489
+ "files_indexed": files_indexed,
490
+ "elapsed_s": round(elapsed_s, 3),
491
+ "input_tokens": total_usage.input_tokens,
492
+ "output_tokens": total_usage.output_tokens,
493
+ "cost_usd": round(total_usage.cost_usd, 6),
494
+ },
495
+ )
496
+ return {
497
+ "files_indexed": files_indexed,
498
+ "elapsed_s": round(elapsed_s, 3),
499
+ "cost_usd": round(total_usage.cost_usd, 6),
500
+ }
501
+
502
+
503
+ def refresh_index_lazy(
504
+ repo_root: Path,
505
+ index_dir: Path | None = None,
506
+ *,
507
+ store=None,
508
+ model: str = DEFAULT_MODEL,
509
+ languages: list[Language] | None = None,
510
+ api_key: str | None = None,
511
+ workers: int = REALTIME_MAX_WORKERS,
512
+ ) -> bool:
513
+ """Pick up working-tree edits between commits. Pass ``store`` (daemon
514
+ path) or ``index_dir`` (legacy); store wins when both are supplied."""
515
+ if env_truthy("SOURCEINDEX_NO_AUTO_REFRESH"):
516
+ _log.debug(
517
+ "Lazy refresh disabled by SOURCEINDEX_NO_AUTO_REFRESH",
518
+ extra={"event": "lazy_refresh.disabled"},
519
+ )
520
+ return False
521
+
522
+ if store is None:
523
+ store = _resolve_plaintext_store(repo_root, index_dir)
524
+ if not store.exists("index.md"):
525
+ return False # cold builds belong to `init`, not the search path
526
+
527
+ if languages is None:
528
+ languages = detect_languages(repo_root) or get_languages()
529
+
530
+ changed, updated, deleted, stored = detect_changes_by_mtime(
531
+ repo_root, store, languages
532
+ )
533
+
534
+ real_work = changed + deleted
535
+ if not real_work:
536
+ if updated != stored:
537
+ save_file_index(store, updated)
538
+ _log.debug(
539
+ "Lazy refresh: touch-only mtime bumps persisted",
540
+ extra={"event": "lazy_refresh.touch_only", "count": len(updated)},
541
+ )
542
+ return True
543
+ return False
544
+
545
+ from ..lib.llm import resolve_api_key
546
+ resolved_key = resolve_api_key(api_key)
547
+ if not resolved_key:
548
+ _log.warning(
549
+ f"Lazy refresh skipped: SOURCEINDEX_API_KEY not set; "
550
+ f"{len(changed)} changed file(s) detected.",
551
+ extra={
552
+ "event": "lazy_refresh.skipped",
553
+ "reason": "no_api_key",
554
+ "changed_count": len(changed),
555
+ },
556
+ )
557
+ return False
558
+
559
+ _log.debug(
560
+ "Lazy refresh starting",
561
+ extra={
562
+ "event": "lazy_refresh.applied",
563
+ "changed_count": len(changed),
564
+ "deleted_count": len(deleted),
565
+ },
566
+ )
567
+ update_index_with_store(
568
+ repo_root,
569
+ store,
570
+ changed_files=real_work,
571
+ model=model,
572
+ languages=languages,
573
+ api_key=resolved_key,
574
+ persist_file_index=False,
575
+ workers=workers,
576
+ )
577
+ save_file_index(store, updated)
578
+ return True
579
+
580
+
581
+ def _resolve_languages_arg(
582
+ repo_root: Path,
583
+ languages: list[Language] | list[str] | None,
584
+ ) -> list[Language]:
585
+ # RPC callers pass list[str]; in-process callers pass list[Language].
586
+ if languages is None:
587
+ return detect_languages(repo_root) or get_languages()
588
+ if not languages:
589
+ return get_languages()
590
+ if isinstance(languages[0], str):
591
+ return get_languages(languages) # type: ignore[arg-type]
592
+ return languages # type: ignore[return-value]