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
sourceindex/__init__.py
ADDED
|
@@ -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]
|