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,51 @@
1
+ """Two-pass LLM file/function selection.
2
+
3
+ Pass 1a: index_search_model reads tier-1 index + query → picks relevant files.
4
+ Pass 1b: index_search_model reads tier-2 function indexes → picks specific
5
+ functions, tagging each EDIT or READ.
6
+
7
+ The two helpers here also clean up LLM prose around the selection lines so
8
+ roadmap synthesis sees clean data.
9
+ """
10
+
11
+ from ..lib.languages import all_extensions, default_languages
12
+
13
+
14
+ # All registered source-file extensions, used to detect path-like lines in
15
+ # LLM output (Pass 1a/1b post-processing) regardless of language. Cached at
16
+ # import time so the every-file roadmap loop doesn't rebuild it.
17
+ _KNOWN_EXTENSIONS: tuple[str, ...] = all_extensions(list(default_languages()))
18
+
19
+
20
+ def _looks_like_path(line: str) -> bool:
21
+ """Heuristic: line looks like a file path (any registered language) or a
22
+ file::function entry. Used to filter LLM prose out of Pass 1a/1b output."""
23
+ s = line.strip()
24
+ if not s:
25
+ return False
26
+ if "::" in s or "/" in s:
27
+ return True
28
+ return s.lower().endswith(_KNOWN_EXTENSIONS)
29
+
30
+
31
+ def _strip_llm_wrapper(text: str) -> str:
32
+ """Strip markdown fences and prose from LLM output, keeping only data lines.
33
+
34
+ Smarter models (Sonnet/Opus) sometimes wrap output in ```...``` blocks
35
+ and add explanatory prose despite being told not to. This extracts only
36
+ the lines that look like file paths or file::function entries across all
37
+ registered languages.
38
+ """
39
+ lines = text.strip().split("\n")
40
+ in_fence = False
41
+ fenced: list[str] = []
42
+ for line in lines:
43
+ stripped = line.strip()
44
+ if stripped.startswith("```"):
45
+ in_fence = not in_fence
46
+ continue
47
+ if in_fence:
48
+ fenced.append(stripped)
49
+ if fenced:
50
+ return "\n".join(fenced)
51
+ return "\n".join(ln.strip() for ln in lines if _looks_like_path(ln))
@@ -0,0 +1,379 @@
1
+ """LLM prompts + roadmap template used by the search pipeline.
2
+
3
+ PASS_1A_PROMPT — file-level selection from the tier-1 index.
4
+ PASS_1B_PROMPT — function-level selection from tier-2 details, with
5
+ EDIT/READ tagging.
6
+ ROADMAP_TEMPLATE — wraps Pass 1b output into the authoritative pre-analysis
7
+ delivered to the agent.
8
+
9
+ QA variants (suffix _QA) are used when mode="qa": no EDIT tags, relaxed
10
+ constraints (runtime exploration encouraged, no recon-bash ban).
11
+ """
12
+
13
+
14
+ PASS_1A_PROMPT = """You are analyzing a source codebase to help another AI implement a feature.
15
+
16
+ === CODEBASE INDEX ===
17
+ Each line: file_path — one-line description.
18
+
19
+ {index_content}
20
+
21
+ === TASK ===
22
+ {instruction}
23
+
24
+ === YOUR JOB ===
25
+ Based on the task and codebase index, list the file paths (one per line) that are most likely to need reading or modification to implement this feature.
26
+
27
+ Output ONLY file paths, one per line. No explanation, no markdown, no numbering.
28
+ Include files that:
29
+ - Would need to be modified to implement the feature
30
+ - Contain related classes/functions the implementation depends on
31
+ - Contain test patterns that reveal expected behavior
32
+ List every file that is genuinely relevant — do not cap the count artificially."""
33
+
34
+
35
+ PASS_1B_PROMPT = """You are analyzing a source codebase to help another AI implement a feature.
36
+
37
+ === FUNCTION-LEVEL INDEXES OF SELECTED FILES ===
38
+ Each file lists its classes/functions with name, line range, and purpose.
39
+
40
+ {selected_details}
41
+
42
+ === TASK ===
43
+ {instruction}
44
+
45
+ === YOUR JOB ===
46
+ Produce two sections, in this order, separated by the literal marker lines
47
+ shown below. Output nothing outside these two sections.
48
+
49
+ ------------------------------------------------------------------------
50
+ SECTION A — CODE LANDSCAPE (markdown, target 800–1500 tokens)
51
+ ------------------------------------------------------------------------
52
+ Write a structured handoff describing the code landscape around the change
53
+ site, in the style of a thorough codebase-exploration report. Use markdown
54
+ sub-headers (### …). Cover:
55
+
56
+ • Where the change site sits in the architecture (which module, what
57
+ it's next to, what calls it).
58
+ • Conventions / patterns the surrounding code follows — naming, base
59
+ classes, how siblings are structured.
60
+ • Sibling files relevant as templates or examples — list their paths
61
+ with their one-line role, even if you do NOT recommend reading them.
62
+ The downstream agent should know these exist as patterns.
63
+ • Non-obvious gotchas the tier-2 indexes reveal — special cases,
64
+ version branches, edge cases tested.
65
+ • Cite specific paths and line ranges where load-bearing.
66
+
67
+ Do NOT inline code snippets — function bodies belong in the agent's later
68
+ Read calls, not in this landscape. Path + range citations only.
69
+
70
+ Do NOT recap the SECTION B selections in prose — landscape only.
71
+
72
+ ------------------------------------------------------------------------
73
+ SECTION B — SELECTIONS (structured, one per line)
74
+ ------------------------------------------------------------------------
75
+ List the specific functions and classes that are most relevant to
76
+ implementing this task. For each, output one line in this exact format:
77
+ <TAG> file_path::QualifiedName (Lstart-Lend)
78
+
79
+ Where <TAG> is:
80
+ - EDIT: this function/class will be modified (new code added, existing
81
+ code changed) to implement the task.
82
+ - READ: this function/class is reference material — called, extended,
83
+ followed as a pattern, or verified via tests.
84
+
85
+ STRICT TAGGING RULE: A file is EDIT only if new lines will be added or
86
+ existing lines changed there. Everything else — tests, sibling plugins,
87
+ base classes, utilities, reference patterns, callers, examples — is READ.
88
+
89
+ VETO RULE: A file in the input list may turn out to have no function
90
+ genuinely relevant to this task — Pass 1a casts a wide net by design.
91
+ If that is the case for a file, do NOT emit any SELECTIONS line for it.
92
+ Files with zero emitted entries are dropped from the structured roadmap.
93
+ You can still mention the file in SECTION A as a pattern/sibling if it
94
+ helps the agent — that's exactly the landscape signal.
95
+
96
+ The veto's purpose is to remove files that are clearly siblings/extras
97
+ Pass 1a picked up by proximity. It is NOT to second-guess Pass 1a's
98
+ central picks when their relevance is ambiguous.
99
+
100
+ FLOOR: The SELECTIONS list MUST contain at least 3 files. If relevance is
101
+ ambiguous for some of Pass 1a's picks, default to keeping rather than
102
+ dropping — the LANDSCAPE block can de-emphasize a file without removing
103
+ it from the structured roadmap. An empty or near-empty SELECTIONS list
104
+ is a Pass-1b failure mode, not a feature. Only emit fewer than 3 files
105
+ when the task genuinely touches a single file (e.g. a one-line config
106
+ fix); in that case still include the file's tests as READ entries.
107
+
108
+ Example output (format only — content depends on task):
109
+
110
+ === CODE LANDSCAPE ===
111
+ ### Change site
112
+ The feature touches `beets/autotag/match.py`, specifically `tag_album`
113
+ (L200-L250) which orchestrates album-level matching after track candidates
114
+ are scored.
115
+
116
+ ### Surrounding patterns
117
+ Matching helpers follow a consistent shape: each scoring function returns
118
+ a `Distance` object built from comparator deltas. See `match.py::distance`
119
+ (L80-L110) for the canonical pattern.
120
+
121
+ ### Sibling files (pattern reference, not necessarily to be read)
122
+ - `beets/autotag/mb.py` — MusicBrainz lookup hooks, mirrors `match.py`'s
123
+ callback shape.
124
+ - `beets/util.py` — utility helpers; offered by Pass 1a but no function
125
+ here is task-relevant.
126
+
127
+ ### Gotchas
128
+ The candidate cache (`match.py` L150) silently dedupes by track-id; new
129
+ scoring logic must handle the deduped list, not the raw candidates.
130
+
131
+ === SELECTIONS ===
132
+ EDIT beets/autotag/match.py::tag_album (L200-L250)
133
+ READ beets/plugins.py::BeetsPlugin.commands (L45-L60)
134
+ READ test/test_match.py::test_tag_album (L100-L140)
135
+
136
+ Output the literal markers `=== CODE LANDSCAPE ===` and `=== SELECTIONS ===`
137
+ exactly. No markdown fences around the whole output. No prose outside the
138
+ two sections."""
139
+
140
+
141
+ # ── QA variants ──────────────────────────────────────────────────────────
142
+
143
+ PASS_1A_QA_PROMPT = """You are analyzing a source codebase to help another AI answer a question.
144
+
145
+ === CODEBASE INDEX ===
146
+ Each line: file_path — one-line description.
147
+
148
+ {index_content}
149
+
150
+ === QUESTION ===
151
+ {instruction}
152
+
153
+ === YOUR JOB ===
154
+ Based on the question and codebase index, list the file paths (one per line)
155
+ that are most relevant to answering this question.
156
+
157
+ Output ONLY file paths, one per line. No explanation, no markdown, no numbering.
158
+ Copy each path EXACTLY as it appears in the index above — same relative form,
159
+ no added prefix (such as /app/) and no leading slash.
160
+ Include files that:
161
+ - Contain code directly referenced or asked about in the question
162
+ - Implement the subsystem, feature, or behavior the question concerns
163
+ - Contain configuration, initialization, or entry points relevant to the topic
164
+ - Would help the answerer understand the architecture around the question
165
+ List every file that is genuinely relevant — do not cap the count artificially."""
166
+
167
+
168
+ PASS_1B_QA_PROMPT = """You are analyzing a source codebase to help another AI answer a question.
169
+
170
+ === FUNCTION-LEVEL INDEXES OF SELECTED FILES ===
171
+ Each file lists its classes/functions with name, line range, and purpose.
172
+
173
+ {selected_details}
174
+
175
+ === QUESTION ===
176
+ {instruction}
177
+
178
+ === YOUR JOB ===
179
+ Produce two sections, in this order, separated by the literal marker lines
180
+ shown below. Output nothing outside these two sections.
181
+
182
+ ------------------------------------------------------------------------
183
+ SECTION A — CODE LANDSCAPE (markdown, target 300–700 tokens)
184
+ ------------------------------------------------------------------------
185
+ Write a structured handoff describing the code landscape relevant to the
186
+ question. Keep it narrow: include only context that helps the downstream
187
+ agent decide where to read or what to verify. Use markdown sub-headers
188
+ (### …). Cover:
189
+
190
+ • How the subsystem(s) the question asks about fit in the architecture
191
+ (which module, what depends on them, what calls them).
192
+ • Key abstractions, patterns, and conventions in the relevant code —
193
+ class hierarchies, naming, configuration loading, etc.
194
+ • Sibling files relevant as context — list their paths with their
195
+ one-line role, even if you do NOT recommend reading them.
196
+ • Non-obvious gotchas the tier-2 indexes reveal — edge cases, version
197
+ branches, runtime vs compile-time behavior, dynamic dispatch.
198
+ • Cite specific paths and line ranges where load-bearing.
199
+
200
+ Do NOT inline code snippets or answer the question — function bodies and
201
+ runtime facts belong in the agent's later Reads/Bash output, not in this
202
+ landscape. Path + range citations only.
203
+
204
+ Do NOT recap the SECTION B selections in prose — landscape only.
205
+
206
+ ------------------------------------------------------------------------
207
+ SECTION B — SELECTIONS (structured, one per line)
208
+ ------------------------------------------------------------------------
209
+ List the specific functions and classes that are most relevant to
210
+ answering this question. For each, output one line in this exact format:
211
+ READ file_path::QualifiedName (Lstart-Lend)
212
+
213
+ Every entry is READ — this is an investigation, not a modification task.
214
+ The agent will read these to understand the code, not edit it.
215
+
216
+ VETO RULE: A file in the input list may turn out to have no function
217
+ genuinely relevant to this question — Pass 1a casts a wide net by design.
218
+ If that is the case for a file, do NOT emit any SELECTIONS line for it.
219
+ Files with zero emitted entries are dropped from the structured roadmap.
220
+ You can still mention the file in SECTION A as context/sibling if it
221
+ helps the agent — that's exactly the landscape signal.
222
+
223
+ FLOOR: The SELECTIONS list MUST contain at least 3 files. If relevance is
224
+ ambiguous for some of Pass 1a's picks, default to keeping rather than
225
+ dropping. An empty or near-empty SELECTIONS list is a Pass-1b failure mode.
226
+
227
+ Example output (format only — content depends on question):
228
+
229
+ === CODE LANDSCAPE ===
230
+ ### Relevant subsystem
231
+ The question concerns `src/auth/middleware.py`, the WSGI middleware that
232
+ intercepts every request and resolves the session token (L40-L85).
233
+
234
+ ### How auth is wired
235
+ The middleware is registered in `src/app.py::create_app` (L20-L35). It
236
+ delegates to `src/auth/backends.py` for token-to-user resolution.
237
+
238
+ ### Sibling files (context, not necessarily to be read)
239
+ - `src/auth/backends.py` — token resolution strategies (DB, JWT, cookie).
240
+ - `src/auth/models.py` — the User and Session ORM models.
241
+
242
+ ### Gotchas
243
+ Token refresh (L60-L72) silently extends expired sessions by 30 min;
244
+ the logging at L68 only fires in DEBUG mode — easy to miss in production.
245
+
246
+ === SELECTIONS ===
247
+ READ src/auth/middleware.py::AuthMiddleware.__call__ (L40-L85)
248
+ READ src/auth/backends.py::resolve_token (L15-L45)
249
+ READ src/app.py::create_app (L20-L35)
250
+
251
+ Output the literal markers `=== CODE LANDSCAPE ===` and `=== SELECTIONS ===`
252
+ exactly. No markdown fences around the whole output. No prose outside the
253
+ two sections."""
254
+
255
+
256
+ ROADMAP_TEMPLATE = """=== CODEBASE ROADMAP (authoritative pre-analysis) ===
257
+
258
+ This roadmap is the output of a dedicated analysis pass over the full codebase
259
+ (file-level Pass 1a + function-level Pass 1b, both reading complete tier-1 and
260
+ tier-2 indexes). Line ranges are AST-extracted function boundaries, not
261
+ estimates. Treat it as authoritative — it IS the index. Do not rebuild it
262
+ with grep/find/ls; do not second-guess its ranges with whole-file reads.
263
+
264
+ {context_section}
265
+
266
+ OPENING TURN — read every roadmap file at once, in parallel:
267
+
268
+ Your first response should contain ONE Read tool call PER file listed
269
+ below, all in the same response (multiple tool_use blocks in one turn).
270
+ For a 4-file roadmap, that is 4 Read calls in turn 1, NOT 4 separate
271
+ turns. Each new turn rebuilds ~$0.05 of input cache and adds latency;
272
+ the roadmap was paid for once — read all of it at once. After that
273
+ single turn you have the entire context — proceed to Edit.
274
+
275
+ CONSTRAINTS (after the opening turn):
276
+
277
+ 1. PARALLELIZE: Any time you need multiple independent Reads, Greps, or Bash
278
+ commands, issue them in a SINGLE response with multiple tool_use blocks —
279
+ not one tool call per turn. Serializing wastes turns and rebuilds cache.
280
+
281
+ 2. USE THE LISTED RANGE: Each file below has an explicit (offset, limit).
282
+ Pass them to Read as-is. Do NOT call Read with no offset/limit on a
283
+ file that has a range listed — the range came from AST function
284
+ boundaries, not heuristics. Whole-file reads on roadmap files burn
285
+ tokens for no information gain.
286
+
287
+ 3. READ ONCE: Read each listed (offset, limit) exactly once. Files marked
288
+ [EDIT TARGET] may carry a small imports-preamble hint alongside the
289
+ function range — read both so you can add new imports without duplicating.
290
+ Do not re-Read a span you have already Read — scroll back instead.
291
+
292
+ 4. GAP-BEFORE-READ: Before reading or searching any file NOT listed below, you
293
+ MUST first state the specific gap in one sentence. Example:
294
+ "The roadmap covers the Discogs plugin but not the MusicBrainz sibling;
295
+ reading musicbrainz.py to find the extra_mb_field_by_tag pattern."
296
+ If you cannot name a concrete gap, the roadmap is sufficient — proceed to Edit.
297
+
298
+ 5. NO RECON BASH: Do not run `git log`, `ls`, `find`, `pip list`, `which`,
299
+ `env`, or other reconnaissance commands. The roadmap + its imports IS the
300
+ recon. If you think you need to look at the filesystem or environment,
301
+ you don't — re-read the roadmap entry instead.
302
+
303
+ 6. TEST RECOVERY: If a test fails with ImportError / ModuleNotFoundError /
304
+ command-not-found, the recovery is a single chained bash:
305
+ `pip install <pkg> -q && <retry command>`. Do not `find` the package on
306
+ disk, `ls` the venv, or inspect `env` first — the package name is in the
307
+ error and pip will resolve the path.
308
+
309
+ Entries are grouped by file.
310
+
311
+ {grouped}
312
+ === IMPORT STATEMENTS (extracted from files above) ===
313
+
314
+ {import_section}
315
+ === END CODEBASE ROADMAP ===
316
+ """
317
+
318
+
319
+ ROADMAP_QA_TEMPLATE = """=== CODEBASE ROADMAP (navigation pre-context) ===
320
+
321
+ This roadmap is the output of a dedicated analysis pass over the full codebase
322
+ (file-level Pass 1a + function-level Pass 1b, both reading complete tier-1 and
323
+ tier-2 indexes). Line ranges are AST-extracted function boundaries, not
324
+ estimates. Treat it as a navigation index, not evidence or a substitute answer.
325
+
326
+ === QUESTION TO ANSWER ===
327
+
328
+ {question}
329
+
330
+ === ANSWER CONTRACT ===
331
+
332
+ - Answer every part of the question.
333
+ - If the question asks to run, verify, observe, measure, trace, or show output,
334
+ execute the relevant command or workflow and paste the literal runtime evidence.
335
+ - Use this roadmap to find where to investigate; do not satisfy evidence
336
+ requirements from the roadmap or static source summaries alone.
337
+
338
+ {context_section}
339
+
340
+ OPENING TURN — read every roadmap file at once, in parallel:
341
+
342
+ Your first response should contain ONE Read tool call PER file listed
343
+ below, all in the same response (multiple tool_use blocks in one turn).
344
+ For a 4-file roadmap, that is 4 Read calls in turn 1, NOT 4 separate
345
+ turns. Each new turn rebuilds ~$0.05 of input cache and adds latency;
346
+ the roadmap was paid for once — read all of it at once. After that
347
+ single turn you have the full structural context.
348
+
349
+ CONSTRAINTS (after the opening turn):
350
+
351
+ 1. PARALLELIZE: Any time you need multiple independent Reads, Greps, or Bash
352
+ commands, issue them in a SINGLE response with multiple tool_use blocks —
353
+ not one tool call per turn. Serializing wastes turns and rebuilds cache.
354
+
355
+ 2. USE THE LISTED RANGE: Each file below has an explicit (offset, limit).
356
+ Pass them to Read as-is. Do NOT call Read with no offset/limit on a
357
+ file that has a range listed — the range came from AST function
358
+ boundaries, not heuristics. Whole-file reads on roadmap files burn
359
+ tokens for no information gain.
360
+
361
+ 3. READ ONCE: Read each listed (offset, limit) exactly once.
362
+ Do not re-Read a span you have already Read — scroll back instead.
363
+
364
+ 4. EXPLORE BEYOND THE ROADMAP WHEN NEEDED: The roadmap covers the structural
365
+ layout — the files and functions most relevant to the question. If you need
366
+ to trace a call chain further, check runtime behavior, inspect configuration,
367
+ or follow an import the roadmap didn't cover, go ahead. State the gap briefly
368
+ ("the roadmap covers X but I need to check Y for Z") and proceed.
369
+
370
+ 5. RUNTIME EXPLORATION IS EXPECTED: Many questions require observing actual
371
+ behavior — running the application, executing test scripts, inspecting output.
372
+ Use Bash freely for runtime investigation. The roadmap tells you WHERE to
373
+ look; execution tells you WHAT HAPPENS.
374
+
375
+ Entries are grouped by file.
376
+
377
+ {grouped}
378
+ === END CODEBASE ROADMAP ===
379
+ """
@@ -0,0 +1,265 @@
1
+ """Roadmap synthesis: turn Pass-1b output into the authoritative pre-analysis.
2
+
3
+ Annotates each function entry with its tier-2 description, groups entries
4
+ per file, computes merged read windows from per-function ranges, marks
5
+ EDIT targets, prepends an imports-preamble Read window for Python EDIT
6
+ targets, and wraps everything in ROADMAP_TEMPLATE.
7
+ """
8
+
9
+ import re
10
+ from pathlib import Path
11
+
12
+ from ..lib.log import get_logger, log_file_error
13
+ from .imports import _imports_preamble_end, extract_imports
14
+ from .prompts import ROADMAP_QA_TEMPLATE, ROADMAP_TEMPLATE
15
+
16
+ _log = get_logger(__name__)
17
+
18
+
19
+ _RANGE_RE = re.compile(r"\(L(\d+)-L(\d+)\)")
20
+ _TAG_RE = re.compile(r"^(EDIT|READ)\s+")
21
+
22
+ # Test files are never an imports-preamble target — tests don't gain new imports
23
+ # from the main code change. Haiku's Pass 1b was observed to mis-tag test files
24
+ # as EDIT ~half the time on beets benchmarks; this regex is the belt to the prompt's
25
+ # suspenders.
26
+ _TEST_PATH_RE = re.compile(r"(^|/)tests?/|(^|/)(test_\w+|\w+_test)\.\w+$")
27
+
28
+
29
+ def _strip_desc_prefix(line: str) -> str:
30
+ """Detail files store each entry as `<name/range>: <description>`."""
31
+ idx = line.find(": ")
32
+ return (line[idx + 2:] if idx >= 0 else line).strip()
33
+
34
+
35
+ def _lookup_description(
36
+ detail_lines: list[str],
37
+ range_part: str,
38
+ func_name: str,
39
+ ) -> str:
40
+ """Find the tier-2 description. Range match wins (disambiguates duplicate
41
+ method names like `__init__`); qualified-name match is the fallback."""
42
+ if range_part:
43
+ for line in detail_lines:
44
+ if range_part in line:
45
+ return _strip_desc_prefix(line)
46
+
47
+ if func_name:
48
+ name_re = re.compile(rf"(^|\.){re.escape(func_name)} \(L")
49
+ for line in detail_lines:
50
+ if name_re.search(line):
51
+ return _strip_desc_prefix(line)
52
+
53
+ return ""
54
+
55
+
56
+ def _load_detail_lines(
57
+ fpath: str,
58
+ store,
59
+ detail_texts: dict[str, str] | None,
60
+ cache: dict[str, list[str]],
61
+ ) -> list[str]:
62
+ """Memoize splitlines() across entries that share a source file.
63
+
64
+ Prefers the pre-loaded ``detail_texts`` dict (populated by Pass 1a so
65
+ we don't double-decrypt encrypted blobs) and falls back to a fresh
66
+ store read for files Pass 1b raised that weren't in 1a's selection.
67
+ """
68
+ lines = cache.get(fpath)
69
+ if lines is None:
70
+ text: str | None = None
71
+ if detail_texts is not None and fpath in detail_texts:
72
+ text = detail_texts[fpath]
73
+ else:
74
+ rel = f"details/{fpath}.md"
75
+ try:
76
+ if store is not None and store.exists(rel):
77
+ text = store.read_text(rel)
78
+ except Exception as e:
79
+ log_file_error(_log, rel, e, phase="roadmap.detail")
80
+ text = None
81
+ lines = text.splitlines() if text else []
82
+ cache[fpath] = lines
83
+ return lines
84
+
85
+
86
+ def _looks_like_test(fpath: str) -> bool:
87
+ return bool(_TEST_PATH_RE.search(fpath))
88
+
89
+
90
+ def _compute_read_windows(
91
+ ranges: list[tuple[int, int]],
92
+ merge_gap: int = 10_000,
93
+ ) -> list[tuple[int, int]]:
94
+ """Collapse per-function ranges into read windows.
95
+
96
+ 1. Drop wrappers — an entry whose range fully contains another entry's
97
+ range is redundant (Haiku often lists the whole class alongside its
98
+ methods; the class entry is descriptive, the methods tell us what
99
+ to actually read). If every range wraps something, keep them all.
100
+ 2. Merge close-or-overlapping — adjacent windows within ``merge_gap``
101
+ lines collapse into one. Default is effectively "always merge":
102
+ experiments with narrow multi-window output (``merge_gap=30``) showed
103
+ opus didn't reliably parallelize the resulting Reads, adding turns
104
+ and cost. A single merged window per file tracks the pre-wrapper-drop
105
+ behaviour while still avoiding the "whole-class-entry inflates limit"
106
+ pathology.
107
+ """
108
+ if not ranges:
109
+ return []
110
+ uniq = sorted(set(ranges))
111
+ non_wrappers = [
112
+ (s, e) for s, e in uniq
113
+ if not any(os >= s and oe <= e and (os, oe) != (s, e) for os, oe in uniq)
114
+ ]
115
+ effective = non_wrappers or uniq
116
+ effective.sort()
117
+ merged: list[tuple[int, int]] = [effective[0]]
118
+ for s, e in effective[1:]:
119
+ last_s, last_e = merged[-1]
120
+ if s <= last_e + merge_gap:
121
+ merged[-1] = (last_s, max(last_e, e))
122
+ else:
123
+ merged.append((s, e))
124
+ return merged
125
+
126
+
127
+ def _build_roadmap(
128
+ function_list: str,
129
+ repo_root: Path,
130
+ store=None,
131
+ detail_texts: dict[str, str] | None = None,
132
+ *,
133
+ details_dir: Path | None = None,
134
+ landscape: str | None = None,
135
+ mode: str = "implement",
136
+ question: str | None = None,
137
+ ) -> str:
138
+ """Annotate, group, and wrap pass-1b output into the roadmap preamble.
139
+
140
+ ``store`` is the daemon-aware path. ``details_dir`` is the legacy path
141
+ for callers that haven't migrated yet — when set, we wrap it in a
142
+ ``PlaintextStore`` so the same lookup logic applies."""
143
+ if store is None and details_dir is not None:
144
+ # Reconstruct a store from the legacy details_dir argument by
145
+ # walking back to the index_dir (the parent of details/).
146
+ from ..daemon import PlaintextStore
147
+ store = PlaintextStore(details_dir.parent)
148
+ order: list[str] = []
149
+ per_file: dict[str, list[str]] = {}
150
+ file_ranges: dict[str, list[tuple[int, int]]] = {}
151
+ edit_files: set[str] = set()
152
+ detail_cache: dict[str, list[str]] = {}
153
+
154
+ for raw in function_list.split("\n"):
155
+ line = raw.strip()
156
+ if not line or "::" not in line:
157
+ continue
158
+
159
+ # Strip the optional EDIT/READ tag. Absence => treat as READ for
160
+ # backward compatibility with older Pass 1b outputs.
161
+ tag_match = _TAG_RE.match(line)
162
+ tag = tag_match.group(1) if tag_match else "READ"
163
+ line = _TAG_RE.sub("", line, count=1)
164
+
165
+ fpath_raw, rest = line.split("::", 1)
166
+ fpath = fpath_raw.strip().lstrip("/")
167
+
168
+ range_match = _RANGE_RE.search(rest)
169
+ range_part = range_match.group(0) if range_match else ""
170
+ func_name = rest.split("(", 1)[0].strip()
171
+ detail_lines = _load_detail_lines(fpath, store, detail_texts, detail_cache)
172
+ desc = _lookup_description(detail_lines, range_part, func_name)
173
+ entry = f" {rest} — {desc}" if desc else f" {rest}"
174
+
175
+ if fpath not in per_file:
176
+ per_file[fpath] = []
177
+ order.append(fpath)
178
+ per_file[fpath].append(entry)
179
+
180
+ is_qa = mode == "qa"
181
+ if not is_qa and tag == "EDIT" and not _looks_like_test(fpath):
182
+ edit_files.add(fpath)
183
+
184
+ if range_match:
185
+ s, e = int(range_match.group(1)), int(range_match.group(2))
186
+ file_ranges.setdefault(fpath, []).append((s, e))
187
+
188
+ if mode != "qa" and not edit_files:
189
+ promoted = next(
190
+ (fp for fp in order if fp in file_ranges and not _looks_like_test(fp)),
191
+ None,
192
+ )
193
+ if promoted:
194
+ edit_files.add(promoted)
195
+
196
+ # Module tops (imports, constants, class/module setup) are frequent targets
197
+ # of follow-up reads. Extend the first window's start back to line 1 when
198
+ # its first line sits within HEADER_PREFIX_MAX lines of the top; beyond
199
+ # that, keep the narrow range (and add a separate imports-preamble hint
200
+ # for EDIT targets, since they'll add new imports at the file top).
201
+ # Path-emission helper: header / import-block lines are absolute so the
202
+ # agent has an unambiguous Read target regardless of its cwd. Roadmap
203
+ # consumers should never need to guess between EXTRACT_DIR / WORKSPACE_DIR.
204
+ # Function-detail lines (per_file entries with `file::QualifiedName` syntax)
205
+ # stay relative — they're descriptive, not Read targets.
206
+ abs_root = str(repo_root)
207
+ def _abs(fp: str) -> str:
208
+ return f"{abs_root}/{fp}"
209
+
210
+ is_qa = mode == "qa"
211
+ HEADER_PREFIX_MAX = 80
212
+ grouped_lines: list[str] = []
213
+ for fpath in order:
214
+ marker = "" if is_qa else (" [EDIT TARGET]" if fpath in edit_files else "")
215
+ ranges = file_ranges.get(fpath, [])
216
+ if not ranges:
217
+ grouped_lines.append(f"{_abs(fpath)}{marker} — Read ONCE (full file)")
218
+ grouped_lines.extend(per_file[fpath])
219
+ grouped_lines.append("")
220
+ continue
221
+
222
+ windows = _compute_read_windows(ranges)
223
+ first_s, first_e = windows[0]
224
+ if first_s <= HEADER_PREFIX_MAX:
225
+ windows[0] = (1, first_e)
226
+ elif not is_qa and fpath.endswith(".py") and fpath in edit_files:
227
+ preamble_end = _imports_preamble_end(repo_root / fpath)
228
+ if preamble_end > 0:
229
+ windows.insert(0, (1, preamble_end))
230
+
231
+ for s, e in windows:
232
+ grouped_lines.append(
233
+ f"{_abs(fpath)}{marker} — Read ONCE: offset={s} limit={e - s + 1}"
234
+ )
235
+ marker = ""
236
+ grouped_lines.extend(per_file[fpath])
237
+ grouped_lines.append("")
238
+ grouped = "\n".join(grouped_lines)
239
+
240
+ landscape_text = (landscape or "").strip()
241
+ context_section = (
242
+ f"=== CODE LANDSCAPE (from Pass 1b) ===\n\n{landscape_text}\n"
243
+ if landscape_text
244
+ else ""
245
+ )
246
+
247
+ if is_qa:
248
+ return ROADMAP_QA_TEMPLATE.format(
249
+ grouped=grouped,
250
+ context_section=context_section,
251
+ question=(question or "").strip(),
252
+ )
253
+
254
+ import_blocks: list[str] = []
255
+ for fpath in order:
256
+ imports = extract_imports(repo_root / fpath, fpath)
257
+ if imports:
258
+ import_blocks.append(f"--- {_abs(fpath)} ---\n{imports}\n")
259
+ import_section = "\n".join(import_blocks)
260
+
261
+ return ROADMAP_TEMPLATE.format(
262
+ grouped=grouped,
263
+ import_section=import_section,
264
+ context_section=context_section,
265
+ )