misterdev 0.2.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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- misterdev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Parse LLM output into structured file edits.
|
|
2
|
+
|
|
3
|
+
A line-by-line state machine extracts whole-file fenced blocks, anchored
|
|
4
|
+
SEARCH/REPLACE hunks, and unified diffs, plus the pure helpers that detect
|
|
5
|
+
fences and resolve file paths from fence lines, leading comments, or
|
|
6
|
+
surrounding prose.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .models import SearchReplaceEdit, _CodeBlock
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LLMResponseParser:
|
|
15
|
+
"""Parses LLM output into structured file edits using a line-by-line
|
|
16
|
+
state machine. No regex; handles nested backticks, variable fence
|
|
17
|
+
widths, and all common LLM output formats.
|
|
18
|
+
|
|
19
|
+
Supported formats:
|
|
20
|
+
1. Tagged: ```python:path/to/file.py
|
|
21
|
+
2. Comment: ```python\\n# path/to/file.py\\n...
|
|
22
|
+
3. Path-only: ```path/to/file.py
|
|
23
|
+
4. Preceding: Update `path/to/file.py`:\\n```
|
|
24
|
+
5. Unified diff: --- a/file +++ b/file
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_CODE_EXTENSIONS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
".py",
|
|
30
|
+
".js",
|
|
31
|
+
".ts",
|
|
32
|
+
".tsx",
|
|
33
|
+
".jsx",
|
|
34
|
+
".rs",
|
|
35
|
+
".go",
|
|
36
|
+
".java",
|
|
37
|
+
".c",
|
|
38
|
+
".cpp",
|
|
39
|
+
".h",
|
|
40
|
+
".hpp",
|
|
41
|
+
".rb",
|
|
42
|
+
".php",
|
|
43
|
+
".swift",
|
|
44
|
+
".kt",
|
|
45
|
+
".scala",
|
|
46
|
+
".zig",
|
|
47
|
+
".cs",
|
|
48
|
+
".csproj",
|
|
49
|
+
".csx",
|
|
50
|
+
".xaml",
|
|
51
|
+
".sln",
|
|
52
|
+
".cc",
|
|
53
|
+
".cxx",
|
|
54
|
+
".hh",
|
|
55
|
+
".hxx",
|
|
56
|
+
".toml",
|
|
57
|
+
".yaml",
|
|
58
|
+
".yml",
|
|
59
|
+
".json",
|
|
60
|
+
".md",
|
|
61
|
+
".html",
|
|
62
|
+
".css",
|
|
63
|
+
".scss",
|
|
64
|
+
".sql",
|
|
65
|
+
".sh",
|
|
66
|
+
".bash",
|
|
67
|
+
".zsh",
|
|
68
|
+
".cfg",
|
|
69
|
+
".ini",
|
|
70
|
+
".env",
|
|
71
|
+
".txt",
|
|
72
|
+
".xml",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def parse_file_edits(llm_output: str) -> Dict[str, str]:
|
|
78
|
+
edits = LLMResponseParser._parse_unified_diffs(llm_output)
|
|
79
|
+
if edits:
|
|
80
|
+
return edits
|
|
81
|
+
|
|
82
|
+
blocks = LLMResponseParser._extract_code_blocks(llm_output)
|
|
83
|
+
result: Dict[str, str] = {}
|
|
84
|
+
for block in blocks:
|
|
85
|
+
path = LLMResponseParser._resolve_path(block)
|
|
86
|
+
if path:
|
|
87
|
+
result[path] = "\n".join(block.content_lines)
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def parse_search_replace_blocks(llm_output: str) -> List[SearchReplaceEdit]:
|
|
92
|
+
"""Extract surgical SEARCH/REPLACE hunks from the model's output.
|
|
93
|
+
|
|
94
|
+
Recognized format (the path may sit on a fence-open line, on a bare
|
|
95
|
+
line, or in backticks immediately above the marker)::
|
|
96
|
+
|
|
97
|
+
```rust:path/to/file.rs
|
|
98
|
+
<<<<<<< SEARCH
|
|
99
|
+
exact existing snippet
|
|
100
|
+
=======
|
|
101
|
+
replacement snippet
|
|
102
|
+
>>>>>>> REPLACE
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Multiple hunks may follow one path. Returns an empty list when the
|
|
106
|
+
output contains no markers, in which case the caller falls back to the
|
|
107
|
+
whole-file parser.
|
|
108
|
+
"""
|
|
109
|
+
lines = llm_output.split("\n")
|
|
110
|
+
edits: List[SearchReplaceEdit] = []
|
|
111
|
+
current_path: Optional[str] = None
|
|
112
|
+
i = 0
|
|
113
|
+
n = len(lines)
|
|
114
|
+
while i < n:
|
|
115
|
+
line = lines[i]
|
|
116
|
+
fence, remainder = _parse_fence_open(line)
|
|
117
|
+
if fence is not None:
|
|
118
|
+
_, path_hint = _parse_opening(remainder)
|
|
119
|
+
if path_hint:
|
|
120
|
+
current_path = path_hint.strip().strip("`'\"")
|
|
121
|
+
i += 1
|
|
122
|
+
continue
|
|
123
|
+
if _is_search_marker(line):
|
|
124
|
+
search_lines: List[str] = []
|
|
125
|
+
i += 1
|
|
126
|
+
while i < n and not _is_divider_marker(lines[i]):
|
|
127
|
+
search_lines.append(lines[i])
|
|
128
|
+
i += 1
|
|
129
|
+
i += 1 # skip divider
|
|
130
|
+
replace_lines: List[str] = []
|
|
131
|
+
while i < n and not _is_replace_marker(lines[i]):
|
|
132
|
+
replace_lines.append(lines[i])
|
|
133
|
+
i += 1
|
|
134
|
+
i += 1 # skip replace marker
|
|
135
|
+
if current_path:
|
|
136
|
+
edits.append(
|
|
137
|
+
SearchReplaceEdit(
|
|
138
|
+
path=current_path,
|
|
139
|
+
search="\n".join(search_lines),
|
|
140
|
+
replace="\n".join(replace_lines),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
stripped = line.strip().strip("`'\"")
|
|
145
|
+
if stripped.startswith("./"):
|
|
146
|
+
stripped = stripped[2:]
|
|
147
|
+
if _looks_like_path(stripped):
|
|
148
|
+
current_path = stripped
|
|
149
|
+
i += 1
|
|
150
|
+
return edits
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# State machine: extract code blocks
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _extract_code_blocks(text: str) -> List[_CodeBlock]:
|
|
158
|
+
blocks = []
|
|
159
|
+
lines = text.split("\n")
|
|
160
|
+
i = 0
|
|
161
|
+
preceding_start = 0
|
|
162
|
+
|
|
163
|
+
while i < len(lines):
|
|
164
|
+
fence, remainder = _parse_fence_open(lines[i])
|
|
165
|
+
if fence is None:
|
|
166
|
+
i += 1
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Capture the text before this block for path detection
|
|
170
|
+
preceding = "\n".join(lines[preceding_start:i])
|
|
171
|
+
|
|
172
|
+
# Parse lang:path or lang or path from the remainder
|
|
173
|
+
lang, path_hint = _parse_opening(remainder)
|
|
174
|
+
|
|
175
|
+
# Collect content lines until closing fence
|
|
176
|
+
content_lines = []
|
|
177
|
+
i += 1
|
|
178
|
+
while i < len(lines):
|
|
179
|
+
if _is_fence_close(lines[i], fence):
|
|
180
|
+
break
|
|
181
|
+
content_lines.append(lines[i])
|
|
182
|
+
i += 1
|
|
183
|
+
|
|
184
|
+
blocks.append(
|
|
185
|
+
_CodeBlock(
|
|
186
|
+
lang=lang,
|
|
187
|
+
path_hint=path_hint,
|
|
188
|
+
content_lines=content_lines,
|
|
189
|
+
preceding_text=preceding,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
preceding_start = i + 1
|
|
194
|
+
i += 1
|
|
195
|
+
|
|
196
|
+
return blocks
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Path resolution: try each strategy in priority order
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _resolve_path(block: _CodeBlock) -> Optional[str]:
|
|
204
|
+
path = None
|
|
205
|
+
|
|
206
|
+
# 1. Explicit path from opening line (```python:path or ```path)
|
|
207
|
+
if block.path_hint:
|
|
208
|
+
path = block.path_hint
|
|
209
|
+
|
|
210
|
+
# 2. First line of content is a path comment
|
|
211
|
+
if not path and block.content_lines:
|
|
212
|
+
extracted = _extract_path_from_first_line(block.content_lines[0])
|
|
213
|
+
if extracted:
|
|
214
|
+
path = extracted
|
|
215
|
+
block.content_lines = block.content_lines[1:]
|
|
216
|
+
|
|
217
|
+
# 3. Preceding text contains a backtick-quoted or labeled path
|
|
218
|
+
if not path:
|
|
219
|
+
path = _extract_path_from_preceding(block.preceding_text)
|
|
220
|
+
|
|
221
|
+
if path:
|
|
222
|
+
path = path.strip().strip("`'\"")
|
|
223
|
+
if path.startswith("./"):
|
|
224
|
+
path = path[2:]
|
|
225
|
+
if _looks_like_path(path):
|
|
226
|
+
return path
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# Unified diff parser (line-by-line, no regex)
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _parse_unified_diffs(text: str) -> Dict[str, str]:
|
|
236
|
+
if "--- a/" not in text and "--- " not in text:
|
|
237
|
+
return {}
|
|
238
|
+
|
|
239
|
+
edits: Dict[str, str] = {}
|
|
240
|
+
lines = text.split("\n")
|
|
241
|
+
i = 0
|
|
242
|
+
|
|
243
|
+
while i < len(lines):
|
|
244
|
+
# Look for --- a/path
|
|
245
|
+
if not lines[i].startswith("--- "):
|
|
246
|
+
i += 1
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Next line must be +++ b/path
|
|
250
|
+
if i + 1 >= len(lines) or not lines[i + 1].startswith("+++ "):
|
|
251
|
+
i += 1
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
plus_line = lines[i + 1]
|
|
255
|
+
file_path = plus_line[4:].strip()
|
|
256
|
+
if file_path.startswith("b/"):
|
|
257
|
+
file_path = file_path[2:]
|
|
258
|
+
|
|
259
|
+
i += 2
|
|
260
|
+
new_lines = []
|
|
261
|
+
hunk_count = 0
|
|
262
|
+
new_start: Optional[int] = None
|
|
263
|
+
|
|
264
|
+
# Collect hunks
|
|
265
|
+
while i < len(lines):
|
|
266
|
+
line = lines[i]
|
|
267
|
+
if line.startswith("--- "):
|
|
268
|
+
break # next diff
|
|
269
|
+
if line.startswith("@@"):
|
|
270
|
+
hunk_count += 1
|
|
271
|
+
if hunk_count == 1:
|
|
272
|
+
new_start = _unified_new_start(line)
|
|
273
|
+
i += 1
|
|
274
|
+
continue
|
|
275
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
276
|
+
new_lines.append(line[1:])
|
|
277
|
+
elif line.startswith(" "):
|
|
278
|
+
new_lines.append(line[1:])
|
|
279
|
+
elif not line.startswith("-"):
|
|
280
|
+
break # end of diff section
|
|
281
|
+
i += 1
|
|
282
|
+
|
|
283
|
+
# This parser rebuilds the whole file from the diff's +/context
|
|
284
|
+
# lines, which equals the real file ONLY for a single hunk that
|
|
285
|
+
# starts at line 1 (or a brand-new file). A partial or multi-hunk
|
|
286
|
+
# diff omits the unchanged regions between hunks, so reconstructing
|
|
287
|
+
# from it would TRUNCATE the file. Skip that unsafe case so the
|
|
288
|
+
# caller falls back (or retries) instead of writing a corrupt file.
|
|
289
|
+
if new_lines and hunk_count == 1 and new_start in (0, 1):
|
|
290
|
+
edits[file_path] = "\n".join(new_lines)
|
|
291
|
+
|
|
292
|
+
return edits
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------
|
|
296
|
+
# Pure-function helpers (no regex, no state)
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _unified_new_start(header: str) -> Optional[int]:
|
|
301
|
+
"""New-file start line from a ``@@ -a,b +c,d @@`` hunk header, or None.
|
|
302
|
+
|
|
303
|
+
Returns ``c`` (the first line number of the new side), used to confirm a
|
|
304
|
+
single-hunk diff begins at line 1 before its reconstruction is trusted as
|
|
305
|
+
whole-file content.
|
|
306
|
+
"""
|
|
307
|
+
plus = header.find("+")
|
|
308
|
+
if plus == -1:
|
|
309
|
+
return None
|
|
310
|
+
digits = ""
|
|
311
|
+
for ch in header[plus + 1 :]:
|
|
312
|
+
if ch.isdigit():
|
|
313
|
+
digits += ch
|
|
314
|
+
else:
|
|
315
|
+
break
|
|
316
|
+
return int(digits) if digits else None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_search_marker(line: str) -> bool:
|
|
320
|
+
stripped = line.strip()
|
|
321
|
+
return stripped.startswith("<<<<<<<") and "SEARCH" in stripped
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _is_replace_marker(line: str) -> bool:
|
|
325
|
+
stripped = line.strip()
|
|
326
|
+
return stripped.startswith(">>>>>>>") and "REPLACE" in stripped
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _is_divider_marker(line: str) -> bool:
|
|
330
|
+
stripped = line.strip()
|
|
331
|
+
return len(stripped) >= 5 and all(c == "=" for c in stripped)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _parse_fence_open(line: str) -> tuple:
|
|
335
|
+
"""Check if a line is a code fence opener (``` or ~~~~).
|
|
336
|
+
|
|
337
|
+
Returns (fence_char, remainder) or (None, None).
|
|
338
|
+
fence_char is the repeated character (` or ~) at the exact width used,
|
|
339
|
+
so the closer can be matched precisely.
|
|
340
|
+
"""
|
|
341
|
+
stripped = line.lstrip()
|
|
342
|
+
for char in ("`", "~"):
|
|
343
|
+
if stripped.startswith(char * 3):
|
|
344
|
+
# Count fence width
|
|
345
|
+
width = 0
|
|
346
|
+
for c in stripped:
|
|
347
|
+
if c == char:
|
|
348
|
+
width += 1
|
|
349
|
+
else:
|
|
350
|
+
break
|
|
351
|
+
remainder = stripped[width:].strip()
|
|
352
|
+
fence = char * width
|
|
353
|
+
return fence, remainder
|
|
354
|
+
return None, None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _is_fence_close(line: str, fence: str) -> bool:
|
|
358
|
+
"""Check if a line closes the given fence."""
|
|
359
|
+
stripped = line.strip()
|
|
360
|
+
# Must start with at least as many fence chars and have nothing else
|
|
361
|
+
if not stripped.startswith(fence):
|
|
362
|
+
return False
|
|
363
|
+
# Everything after the fence chars must also be the fence char or empty
|
|
364
|
+
rest = stripped[len(fence) :]
|
|
365
|
+
return all(c == fence[0] for c in rest)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _parse_opening(remainder: str) -> tuple:
|
|
369
|
+
"""Parse the text after ``` into (lang, path_hint).
|
|
370
|
+
|
|
371
|
+
Examples:
|
|
372
|
+
"python:src/main.py" -> ("python", "src/main.py")
|
|
373
|
+
"python" -> ("python", "")
|
|
374
|
+
"src/main.py" -> ("", "src/main.py")
|
|
375
|
+
"" -> ("", "")
|
|
376
|
+
"""
|
|
377
|
+
if not remainder:
|
|
378
|
+
return "", ""
|
|
379
|
+
|
|
380
|
+
# Check for lang:path format
|
|
381
|
+
if ":" in remainder:
|
|
382
|
+
parts = remainder.split(":", 1)
|
|
383
|
+
lang_candidate = parts[0].strip()
|
|
384
|
+
path_candidate = parts[1].strip()
|
|
385
|
+
if _looks_like_path(path_candidate):
|
|
386
|
+
return lang_candidate, path_candidate
|
|
387
|
+
|
|
388
|
+
# Is the whole thing a path?
|
|
389
|
+
if _looks_like_path(remainder):
|
|
390
|
+
return "", remainder
|
|
391
|
+
|
|
392
|
+
# Otherwise it's a language identifier
|
|
393
|
+
return remainder, ""
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _extract_path_from_first_line(line: str) -> Optional[str]:
|
|
397
|
+
"""Extract a file path from a comment on the first line of a code block."""
|
|
398
|
+
stripped = line.strip()
|
|
399
|
+
|
|
400
|
+
# Comment prefixes: # path, // path, -- path, /* path */, <!-- path -->
|
|
401
|
+
comment_prefixes = ("#", "//", "--", "/*", "<!--")
|
|
402
|
+
for prefix in comment_prefixes:
|
|
403
|
+
if stripped.startswith(prefix):
|
|
404
|
+
remainder = stripped[len(prefix) :].strip()
|
|
405
|
+
# Strip trailing comment closers
|
|
406
|
+
for closer in ("*/", "-->"):
|
|
407
|
+
if remainder.endswith(closer):
|
|
408
|
+
remainder = remainder[: -len(closer)].strip()
|
|
409
|
+
if _looks_like_path(remainder):
|
|
410
|
+
return remainder
|
|
411
|
+
|
|
412
|
+
# "File: path" or "Filename: path" or "Path: path"
|
|
413
|
+
for label in ("file:", "filename:", "path:"):
|
|
414
|
+
lower = stripped.lower()
|
|
415
|
+
if lower.startswith(label):
|
|
416
|
+
candidate = stripped[len(label) :].strip()
|
|
417
|
+
if _looks_like_path(candidate):
|
|
418
|
+
return candidate
|
|
419
|
+
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _extract_path_from_preceding(text: str) -> Optional[str]:
|
|
424
|
+
"""Find a file path in text that precedes a code block.
|
|
425
|
+
|
|
426
|
+
Scans backward from the end looking for backtick-quoted paths,
|
|
427
|
+
bold paths, or labeled paths like "Update `file.py`:" or
|
|
428
|
+
"**file.py**:".
|
|
429
|
+
"""
|
|
430
|
+
# Only look at the last ~200 chars
|
|
431
|
+
segment = text[-200:] if len(text) > 200 else text
|
|
432
|
+
|
|
433
|
+
# Strategy 1: find last backtick-quoted path
|
|
434
|
+
path = _find_last_quoted_path(segment, "`")
|
|
435
|
+
if path:
|
|
436
|
+
return path
|
|
437
|
+
|
|
438
|
+
# Strategy 2: find last bold-quoted path (**path**)
|
|
439
|
+
path = _find_last_delimited_path(segment, "**", "**")
|
|
440
|
+
if path:
|
|
441
|
+
return path
|
|
442
|
+
|
|
443
|
+
# Strategy 3: labeled path (File: path, Update: path, etc.)
|
|
444
|
+
for label in (
|
|
445
|
+
"file:",
|
|
446
|
+
"update:",
|
|
447
|
+
"create:",
|
|
448
|
+
"modify:",
|
|
449
|
+
"in:",
|
|
450
|
+
"filename:",
|
|
451
|
+
"path:",
|
|
452
|
+
):
|
|
453
|
+
idx = segment.lower().rfind(label)
|
|
454
|
+
if idx >= 0:
|
|
455
|
+
after = segment[idx + len(label) :].strip()
|
|
456
|
+
# Take first whitespace-delimited token
|
|
457
|
+
candidate = after.split()[0].strip("`'\",:") if after else ""
|
|
458
|
+
if _looks_like_path(candidate):
|
|
459
|
+
return candidate
|
|
460
|
+
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _find_last_quoted_path(text: str, quote: str) -> Optional[str]:
|
|
465
|
+
"""Find the last `path.ext` in text — the same-delimiter special case."""
|
|
466
|
+
return _find_last_delimited_path(text, quote, quote)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _find_last_delimited_path(text: str, opener: str, closer: str) -> Optional[str]:
|
|
470
|
+
"""Find the last **path.ext** in text."""
|
|
471
|
+
end = len(text)
|
|
472
|
+
while True:
|
|
473
|
+
close = text.rfind(closer, 0, end)
|
|
474
|
+
if close < 0:
|
|
475
|
+
return None
|
|
476
|
+
open_pos = text.rfind(opener, 0, close)
|
|
477
|
+
if open_pos < 0:
|
|
478
|
+
return None
|
|
479
|
+
candidate = text[open_pos + len(opener) : close]
|
|
480
|
+
if _looks_like_path(candidate):
|
|
481
|
+
return candidate
|
|
482
|
+
end = open_pos
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _looks_like_path(s: str) -> bool:
|
|
486
|
+
"""Check if a string looks like a file path."""
|
|
487
|
+
s = s.strip().strip("`'\"")
|
|
488
|
+
if not s or " " in s or len(s) > 200:
|
|
489
|
+
return False
|
|
490
|
+
dot = s.rfind(".")
|
|
491
|
+
if dot < 0:
|
|
492
|
+
return False
|
|
493
|
+
ext = s[dot:]
|
|
494
|
+
return ext in LLMResponseParser._CODE_EXTENSIONS
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def setup_logger(name="project_orchestrator", level=logging.INFO):
|
|
6
|
+
"""Sets up a standardized logger for the project orchestrator."""
|
|
7
|
+
logger = logging.getLogger(name)
|
|
8
|
+
logger.setLevel(level)
|
|
9
|
+
|
|
10
|
+
if not logger.handlers:
|
|
11
|
+
formatter = logging.Formatter(
|
|
12
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Console Handler
|
|
16
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
17
|
+
console_handler.setFormatter(formatter)
|
|
18
|
+
logger.addHandler(console_handler)
|
|
19
|
+
|
|
20
|
+
return logger
|