differix 0.1.0a1__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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/context_assembler.py
|
|
3
|
+
--------------------------
|
|
4
|
+
Tiered Context Assembly (TCA) for LLM agents.
|
|
5
|
+
|
|
6
|
+
Problem
|
|
7
|
+
-------
|
|
8
|
+
Agents used to receive context assembled greedily: each component
|
|
9
|
+
(handshake, related files, lessons, iteration logs) added its own
|
|
10
|
+
contribution without a coordinating budget. This routinely produced
|
|
11
|
+
40,000+ token prompts of which 95% was noise, driving per-call cost up
|
|
12
|
+
by 3-5x.
|
|
13
|
+
|
|
14
|
+
Solution
|
|
15
|
+
--------
|
|
16
|
+
A single assembler with a fixed token budget per role. Content is
|
|
17
|
+
grouped into six tiers of decreasing priority. The assembler packs
|
|
18
|
+
tiers in order (T0 -> T5) until the budget is exhausted, then stops.
|
|
19
|
+
Content that doesn't fit is either downgraded to its fallback
|
|
20
|
+
representation or dropped.
|
|
21
|
+
|
|
22
|
+
Tiers
|
|
23
|
+
-----
|
|
24
|
+
T0_STATIC — system prompt, skills, response format. ALWAYS kept.
|
|
25
|
+
This is the cacheable prefix for provider-side caching.
|
|
26
|
+
T1_ACTION_CORE — goal, target file, task spec, session brief. Never
|
|
27
|
+
dropped silently (logged as WARNING if it doesn't fit).
|
|
28
|
+
T2_TARGET_SLICE — the file being edited (possibly sliced) or the diff.
|
|
29
|
+
T3_INTERFACE — related files (signatures), session structure.
|
|
30
|
+
T4_ENRICHMENT — A-Score ranked lessons, ADR summary.
|
|
31
|
+
T5_FEEDBACK — past compile errors, rejections. Only on retries.
|
|
32
|
+
|
|
33
|
+
Determinism
|
|
34
|
+
-----------
|
|
35
|
+
Given identical input (same items, same order, same budget) the output
|
|
36
|
+
is byte-identical. This preserves prompt caching across calls.
|
|
37
|
+
|
|
38
|
+
Public API
|
|
39
|
+
----------
|
|
40
|
+
Tier, TierItem, ContextBundle, AssembledContext, ContextAssembler
|
|
41
|
+
estimate_tokens(text)
|
|
42
|
+
smart_slice_code(code, target_file, max_lines)
|
|
43
|
+
dedupe_logs(logs, max_errors)
|
|
44
|
+
render_code_diff(old_code, new_code, target_file)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import logging
|
|
50
|
+
import re
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from enum import IntEnum
|
|
53
|
+
from typing import Dict, List
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
_CHARS_PER_TOKEN = 4
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Tier(IntEnum):
|
|
61
|
+
T0_STATIC = 0
|
|
62
|
+
T1_ACTION_CORE = 1
|
|
63
|
+
T2_TARGET_SLICE = 2
|
|
64
|
+
T3_INTERFACE = 3
|
|
65
|
+
T4_ENRICHMENT = 4
|
|
66
|
+
T5_FEEDBACK = 5
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_TIER_LABELS = {
|
|
70
|
+
Tier.T0_STATIC: "T0_static",
|
|
71
|
+
Tier.T1_ACTION_CORE: "T1_action_core",
|
|
72
|
+
Tier.T2_TARGET_SLICE: "T2_target_slice",
|
|
73
|
+
Tier.T3_INTERFACE: "T3_interface",
|
|
74
|
+
Tier.T4_ENRICHMENT: "T4_enrichment",
|
|
75
|
+
Tier.T5_FEEDBACK: "T5_feedback",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class TierItem:
|
|
81
|
+
"""One piece of context destined for a specific tier."""
|
|
82
|
+
tier: Tier
|
|
83
|
+
key: str
|
|
84
|
+
content: str
|
|
85
|
+
fallback_content: str = ""
|
|
86
|
+
optional: bool = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class ContextBundle:
|
|
91
|
+
"""Ordered collection of items, grouped by tier at assemble-time."""
|
|
92
|
+
items: List[TierItem] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
def add(self, item: TierItem) -> None:
|
|
95
|
+
if item.content and item.content.strip():
|
|
96
|
+
self.items.append(item)
|
|
97
|
+
|
|
98
|
+
def add_text(self, tier: Tier, key: str, content: str, **kwargs) -> None:
|
|
99
|
+
self.add(TierItem(tier=tier, key=key, content=content, **kwargs))
|
|
100
|
+
|
|
101
|
+
def get_tier_items(self, tier: Tier) -> List[TierItem]:
|
|
102
|
+
return [it for it in self.items if it.tier == tier]
|
|
103
|
+
|
|
104
|
+
def render_tier(self, tier: Tier) -> str:
|
|
105
|
+
items = self.get_tier_items(tier)
|
|
106
|
+
if not items:
|
|
107
|
+
return ""
|
|
108
|
+
return "\n\n".join(it.content for it in items)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class AssembledContext:
|
|
113
|
+
system_prompt: str = ""
|
|
114
|
+
user_prompt: str = ""
|
|
115
|
+
tokens_by_tier: Dict[str, int] = field(default_factory=dict)
|
|
116
|
+
tokens_total: int = 0
|
|
117
|
+
tokens_budget: int = 0
|
|
118
|
+
degraded_tiers: List[str] = field(default_factory=list)
|
|
119
|
+
dropped_items: List[str] = field(default_factory=list)
|
|
120
|
+
cacheable_prefix_len: int = 0
|
|
121
|
+
|
|
122
|
+
def log_summary(self) -> str:
|
|
123
|
+
parts = [f"[TCA] budget={self.tokens_budget} used={self.tokens_total}"]
|
|
124
|
+
for label, tok in self.tokens_by_tier.items():
|
|
125
|
+
if tok:
|
|
126
|
+
parts.append(f"{label}={tok}")
|
|
127
|
+
if self.degraded_tiers:
|
|
128
|
+
parts.append(f"degraded={','.join(self.degraded_tiers)}")
|
|
129
|
+
if self.dropped_items:
|
|
130
|
+
parts.append(f"dropped={len(self.dropped_items)}")
|
|
131
|
+
return " ".join(parts)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def estimate_tokens(text: str) -> int:
|
|
135
|
+
"""Rough estimate: ~4 chars per token for English/code."""
|
|
136
|
+
if not text:
|
|
137
|
+
return 0
|
|
138
|
+
return max(1, len(text) // _CHARS_PER_TOKEN)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ContextAssembler:
|
|
142
|
+
"""
|
|
143
|
+
Packs a ContextBundle into a bounded prompt.
|
|
144
|
+
|
|
145
|
+
Usage:
|
|
146
|
+
bundle = ContextBundle()
|
|
147
|
+
bundle.add_text(Tier.T0_STATIC, "skills", skill_prompt)
|
|
148
|
+
bundle.add_text(Tier.T1_ACTION_CORE, "spec", spec)
|
|
149
|
+
...
|
|
150
|
+
assembled = ContextAssembler(budget=15000).assemble(bundle)
|
|
151
|
+
# assembled.system_prompt, assembled.user_prompt
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, budget: int, safety_margin: float = 0.9):
|
|
155
|
+
self.budget = max(1000, int(budget))
|
|
156
|
+
self.safety_margin = float(safety_margin)
|
|
157
|
+
self._effective_budget = int(self.budget * self.safety_margin)
|
|
158
|
+
|
|
159
|
+
def assemble(self, bundle: ContextBundle) -> AssembledContext:
|
|
160
|
+
result = AssembledContext(
|
|
161
|
+
tokens_budget=self.budget,
|
|
162
|
+
tokens_by_tier={label: 0 for label in _TIER_LABELS.values()},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# ---- T0: always in full, this is the cacheable prefix ----
|
|
166
|
+
t0 = bundle.render_tier(Tier.T0_STATIC)
|
|
167
|
+
t0_tokens = estimate_tokens(t0)
|
|
168
|
+
result.system_prompt = t0
|
|
169
|
+
result.tokens_by_tier[_TIER_LABELS[Tier.T0_STATIC]] = t0_tokens
|
|
170
|
+
|
|
171
|
+
remaining = self._effective_budget - t0_tokens
|
|
172
|
+
if remaining < 0:
|
|
173
|
+
logger.warning(
|
|
174
|
+
"TCA: T0 (%d) exceeds budget (%d); truncating.",
|
|
175
|
+
t0_tokens, self._effective_budget,
|
|
176
|
+
)
|
|
177
|
+
result.system_prompt = _truncate_to_tokens(t0, self._effective_budget)
|
|
178
|
+
result.tokens_by_tier[_TIER_LABELS[Tier.T0_STATIC]] = self._effective_budget
|
|
179
|
+
result.degraded_tiers.append("T0_static")
|
|
180
|
+
result.tokens_total = self._effective_budget
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
# ---- T1..T5: waterfall ----
|
|
184
|
+
user_parts: List[str] = []
|
|
185
|
+
|
|
186
|
+
for tier in (
|
|
187
|
+
Tier.T1_ACTION_CORE,
|
|
188
|
+
Tier.T2_TARGET_SLICE,
|
|
189
|
+
Tier.T3_INTERFACE,
|
|
190
|
+
Tier.T4_ENRICHMENT,
|
|
191
|
+
Tier.T5_FEEDBACK,
|
|
192
|
+
):
|
|
193
|
+
tier_items = bundle.get_tier_items(tier)
|
|
194
|
+
if not tier_items:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
label = _TIER_LABELS[tier]
|
|
198
|
+
used = 0
|
|
199
|
+
|
|
200
|
+
for item in tier_items:
|
|
201
|
+
item_tokens = estimate_tokens(item.content)
|
|
202
|
+
|
|
203
|
+
if item_tokens <= remaining:
|
|
204
|
+
user_parts.append(item.content)
|
|
205
|
+
remaining -= item_tokens
|
|
206
|
+
used += item_tokens
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# Doesn't fit — try fallback
|
|
210
|
+
if item.fallback_content:
|
|
211
|
+
fb_tokens = estimate_tokens(item.fallback_content)
|
|
212
|
+
if fb_tokens <= remaining:
|
|
213
|
+
user_parts.append(item.fallback_content)
|
|
214
|
+
remaining -= fb_tokens
|
|
215
|
+
used += fb_tokens
|
|
216
|
+
result.degraded_tiers.append(f"{label}:{item.key}")
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Drop
|
|
220
|
+
result.dropped_items.append(f"{label}:{item.key}")
|
|
221
|
+
if not item.optional:
|
|
222
|
+
logger.warning(
|
|
223
|
+
"TCA: dropped non-optional %s:%s (%d tokens, %d remaining)",
|
|
224
|
+
label, item.key, item_tokens, remaining,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
result.tokens_by_tier[label] = used
|
|
228
|
+
|
|
229
|
+
result.user_prompt = "\n\n".join(user_parts)
|
|
230
|
+
result.tokens_total = sum(result.tokens_by_tier.values())
|
|
231
|
+
result.cacheable_prefix_len = len(result.system_prompt)
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _truncate_to_tokens(text: str, max_tokens: int) -> str:
|
|
236
|
+
max_chars = max_tokens * _CHARS_PER_TOKEN
|
|
237
|
+
if len(text) <= max_chars:
|
|
238
|
+
return text
|
|
239
|
+
return text[: max_chars - 30] + "\n...[truncated by TCA]"
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# AST-aware symbol slicing for Python
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
import ast as _ast
|
|
245
|
+
|
|
246
|
+
_STOPWORD_HINTS = {
|
|
247
|
+
"the", "and", "for", "with", "from", "into", "have", "will", "this",
|
|
248
|
+
"that", "then", "than", "when", "where", "what", "which", "while",
|
|
249
|
+
"there", "their", "them", "they", "make", "made", "with", "would",
|
|
250
|
+
"could", "should", "must", "been", "were", "does", "done", "into",
|
|
251
|
+
"also", "only", "just", "same", "each", "such", "some", "many",
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _extract_identifiers_from_text(text: str) -> set:
|
|
256
|
+
"""Pull identifier-like tokens from a hint string, drop stopwords."""
|
|
257
|
+
if not text:
|
|
258
|
+
return set()
|
|
259
|
+
tokens = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]{3,}", text)
|
|
260
|
+
return {t for t in tokens if t.lower() not in _STOPWORD_HINTS}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _ast_symbols_with_ranges(code: str) -> dict:
|
|
264
|
+
"""
|
|
265
|
+
Parse Python source, return {symbol_name: (start_line, end_line, kind)}.
|
|
266
|
+
Only top-level symbols. kind in {'function', 'class', 'assignment'}.
|
|
267
|
+
Lines are 1-indexed inclusive.
|
|
268
|
+
"""
|
|
269
|
+
try:
|
|
270
|
+
tree = _ast.parse(code)
|
|
271
|
+
except SyntaxError:
|
|
272
|
+
return {}
|
|
273
|
+
|
|
274
|
+
symbols: dict = {}
|
|
275
|
+
for node in tree.body:
|
|
276
|
+
end_line = getattr(node, "end_lineno", None) or getattr(node, "lineno", 1)
|
|
277
|
+
|
|
278
|
+
if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):
|
|
279
|
+
symbols[node.name] = (node.lineno, end_line, "function")
|
|
280
|
+
elif isinstance(node, _ast.ClassDef):
|
|
281
|
+
symbols[node.name] = (node.lineno, end_line, "class")
|
|
282
|
+
elif isinstance(node, _ast.Assign):
|
|
283
|
+
for target in node.targets:
|
|
284
|
+
if isinstance(target, _ast.Name):
|
|
285
|
+
symbols[target.id] = (node.lineno, end_line, "assignment")
|
|
286
|
+
elif isinstance(node, _ast.AnnAssign) and isinstance(node.target, _ast.Name):
|
|
287
|
+
symbols[node.target.id] = (node.lineno, end_line, "assignment")
|
|
288
|
+
return symbols
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _ast_imports_end_line(code: str) -> int:
|
|
292
|
+
"""Return the line number AFTER the last top-level import (or docstring)."""
|
|
293
|
+
try:
|
|
294
|
+
tree = _ast.parse(code)
|
|
295
|
+
except SyntaxError:
|
|
296
|
+
return 1
|
|
297
|
+
|
|
298
|
+
last_import_end = 0
|
|
299
|
+
for node in tree.body:
|
|
300
|
+
if isinstance(node, (_ast.Import, _ast.ImportFrom)):
|
|
301
|
+
last_import_end = max(last_import_end, getattr(node, "end_lineno", node.lineno))
|
|
302
|
+
elif isinstance(node, _ast.Expr) and isinstance(node.value, _ast.Constant) and isinstance(node.value.value, str):
|
|
303
|
+
# Module docstring — counts as part of the header
|
|
304
|
+
last_import_end = max(last_import_end, getattr(node, "end_lineno", node.lineno))
|
|
305
|
+
elif isinstance(node, _ast.Assign):
|
|
306
|
+
# Allow `__all__ = [...]` after imports
|
|
307
|
+
is_all = any(
|
|
308
|
+
isinstance(t, _ast.Name) and t.id == "__all__"
|
|
309
|
+
for t in node.targets
|
|
310
|
+
)
|
|
311
|
+
if is_all:
|
|
312
|
+
last_import_end = max(last_import_end, getattr(node, "end_lineno", node.lineno))
|
|
313
|
+
else:
|
|
314
|
+
break
|
|
315
|
+
else:
|
|
316
|
+
break
|
|
317
|
+
return last_import_end + 1
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _render_target_symbol_slice(
|
|
321
|
+
lines: list,
|
|
322
|
+
symbols: dict,
|
|
323
|
+
target_name: str,
|
|
324
|
+
imports_end: int,
|
|
325
|
+
max_lines: int,
|
|
326
|
+
) -> str:
|
|
327
|
+
"""Imports + interface signatures + full target symbol body."""
|
|
328
|
+
total = len(lines)
|
|
329
|
+
t_start, t_end, t_kind = symbols[target_name]
|
|
330
|
+
t_budget = t_end - t_start + 1
|
|
331
|
+
|
|
332
|
+
out = []
|
|
333
|
+
out.append(f"# ============ FILE (symbol slice) ============")
|
|
334
|
+
out.append(f"# Total lines: {total}")
|
|
335
|
+
out.append(f"# Target: {t_kind} `{target_name}` (lines {t_start}-{t_end})")
|
|
336
|
+
out.append(f"# Full file is on disk. Slices below are for reference only.")
|
|
337
|
+
out.append(f"# Use SEARCH/REPLACE blocks. Anchor MUST be copied verbatim from this view.")
|
|
338
|
+
out.append("")
|
|
339
|
+
|
|
340
|
+
# Imports (never truncated below 30 lines)
|
|
341
|
+
imports_slice = min(imports_end, max(15, max_lines // 6))
|
|
342
|
+
out.append(f"# --- IMPORTS (lines 1-{imports_slice}) ---")
|
|
343
|
+
out.extend(lines[:imports_slice])
|
|
344
|
+
if imports_end > imports_slice:
|
|
345
|
+
out.append(f"# ... [{imports_end - imports_slice} more import lines] ...")
|
|
346
|
+
out.append("")
|
|
347
|
+
|
|
348
|
+
# Interface signatures (excluding target)
|
|
349
|
+
out.append("# --- PUBLIC INTERFACE (signatures, for coherence) ---")
|
|
350
|
+
shown_interface = 0
|
|
351
|
+
for name, (s, e, k) in symbols.items():
|
|
352
|
+
if name == target_name:
|
|
353
|
+
continue
|
|
354
|
+
if shown_interface >= 30:
|
|
355
|
+
out.append(f"# ... [{len(symbols) - 1 - shown_interface} more symbols] ...")
|
|
356
|
+
break
|
|
357
|
+
first_line = lines[s - 1].rstrip()
|
|
358
|
+
out.append(f"{first_line} # L{s}-{e}")
|
|
359
|
+
shown_interface += 1
|
|
360
|
+
out.append("")
|
|
361
|
+
|
|
362
|
+
# Context before (max 3 lines)
|
|
363
|
+
before = 3
|
|
364
|
+
if t_start > imports_end + 1:
|
|
365
|
+
ctx_start = max(imports_end, t_start - before)
|
|
366
|
+
omitted = t_start - ctx_start - 1
|
|
367
|
+
if omitted > 0:
|
|
368
|
+
out.append(f"# ... [{omitted} lines omitted] ...")
|
|
369
|
+
if ctx_start < t_start:
|
|
370
|
+
out.append(f"# --- LINES {ctx_start + 1}-{t_start - 1} (context before) ---")
|
|
371
|
+
out.extend(lines[ctx_start:t_start - 1])
|
|
372
|
+
out.append("")
|
|
373
|
+
|
|
374
|
+
# Target body
|
|
375
|
+
out.append(f"# --- TARGET SYMBOL: {target_name} (lines {t_start}-{t_end}) ---")
|
|
376
|
+
# If the target alone exceeds the budget, truncate the body
|
|
377
|
+
already_used = len(out)
|
|
378
|
+
remaining_lines = max_lines - already_used - 20 # reserve for tail
|
|
379
|
+
if t_budget > remaining_lines > 30:
|
|
380
|
+
head_n = remaining_lines - 15
|
|
381
|
+
tail_n = 15
|
|
382
|
+
out.extend(lines[t_start - 1:t_start - 1 + head_n])
|
|
383
|
+
out.append(
|
|
384
|
+
f"# ... [{t_budget - head_n - tail_n} lines omitted from "
|
|
385
|
+
f"{target_name}; the full body exists on disk] ..."
|
|
386
|
+
)
|
|
387
|
+
out.extend(lines[t_end - tail_n:t_end])
|
|
388
|
+
else:
|
|
389
|
+
out.extend(lines[t_start - 1:t_end])
|
|
390
|
+
out.append("")
|
|
391
|
+
|
|
392
|
+
# Context after (max 3 lines)
|
|
393
|
+
after = 3
|
|
394
|
+
if t_end < total:
|
|
395
|
+
ctx_end = min(total, t_end + after)
|
|
396
|
+
out.append(f"# --- LINES {t_end + 1}-{ctx_end} (context after) ---")
|
|
397
|
+
out.extend(lines[t_end:ctx_end])
|
|
398
|
+
out.append("")
|
|
399
|
+
remaining = total - ctx_end
|
|
400
|
+
if remaining > 15:
|
|
401
|
+
out.append(f"# ... [{remaining} more lines to end of file] ...")
|
|
402
|
+
|
|
403
|
+
# Tail (last 10 lines) — matters for module-level registries / __all__
|
|
404
|
+
if total > 15 and t_end < total - 10:
|
|
405
|
+
out.append("")
|
|
406
|
+
out.append(f"# --- END OF FILE (last 10 lines) ---")
|
|
407
|
+
out.extend(lines[-10:])
|
|
408
|
+
|
|
409
|
+
return "\n".join(out)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _smart_slice_python(
|
|
413
|
+
code: str,
|
|
414
|
+
target_file: str,
|
|
415
|
+
hints: str,
|
|
416
|
+
max_lines: int,
|
|
417
|
+
) -> str:
|
|
418
|
+
"""AST-aware slicing for Python files with symbol-detection from hints."""
|
|
419
|
+
lines = code.splitlines()
|
|
420
|
+
if len(lines) <= max_lines:
|
|
421
|
+
return code
|
|
422
|
+
|
|
423
|
+
symbols = _ast_symbols_with_ranges(code)
|
|
424
|
+
imports_end = _ast_imports_end_line(code)
|
|
425
|
+
|
|
426
|
+
hint_tokens = _extract_identifiers_from_text(hints)
|
|
427
|
+
# Prefer function/class over assignment; among same kind, prefer longest name
|
|
428
|
+
# (longer names are more specific, less likely to be a coincidental match).
|
|
429
|
+
target_name = max(
|
|
430
|
+
(n for n in hint_tokens if n in symbols),
|
|
431
|
+
key=lambda n: (symbols[n][2] in ("function", "class"), len(n)),
|
|
432
|
+
default=None,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if target_name:
|
|
436
|
+
return _render_target_symbol_slice(
|
|
437
|
+
lines, symbols, target_name, imports_end, max_lines
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# No symbol match — fall through to line-based slicing
|
|
441
|
+
return _render_line_slice(code, target_file, max_lines)
|
|
442
|
+
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
# Code slicing for T2
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
_BACKEND_EXTS = {"py", "pyi", "rb", "go", "rs", "java", "kt", "c", "cpp", "h", "hpp"}
|
|
447
|
+
_FRONTEND_EXTS = {"html", "htm", "css", "scss"}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _render_line_slice(code: str, target_file: str, max_lines: int = 150) -> str:
|
|
451
|
+
"""
|
|
452
|
+
Slice a file to fit a token budget, keeping the parts most likely
|
|
453
|
+
relevant for editing.
|
|
454
|
+
|
|
455
|
+
- Frontend (HTML/CSS): keep first (max_lines - 30) + last 30 lines.
|
|
456
|
+
New sections typically get appended near the closing tags.
|
|
457
|
+
- Backend (Python/Rust/etc): keep first (max_lines - 15) + last 15.
|
|
458
|
+
- If file fits in max_lines, return unchanged.
|
|
459
|
+
|
|
460
|
+
Emits a prominent warning block instructing the model to use
|
|
461
|
+
SEARCH/REPLACE and NOT do a full rewrite on a truncated view.
|
|
462
|
+
"""
|
|
463
|
+
if not code:
|
|
464
|
+
return code
|
|
465
|
+
lines = code.splitlines()
|
|
466
|
+
if len(lines) <= max_lines:
|
|
467
|
+
return code
|
|
468
|
+
|
|
469
|
+
ext = target_file.rsplit(".", 1)[-1].lower() if "." in target_file else ""
|
|
470
|
+
|
|
471
|
+
if ext in _FRONTEND_EXTS:
|
|
472
|
+
head_lines = max_lines - 30
|
|
473
|
+
tail_lines = 30
|
|
474
|
+
if ext in ("html", "htm"):
|
|
475
|
+
marker = "<!--"
|
|
476
|
+
else:
|
|
477
|
+
marker = "/*"
|
|
478
|
+
else:
|
|
479
|
+
head_lines = max_lines - 15
|
|
480
|
+
tail_lines = 15
|
|
481
|
+
marker = "#" if ext in _BACKEND_EXTS else "//"
|
|
482
|
+
|
|
483
|
+
omitted = len(lines) - head_lines - tail_lines
|
|
484
|
+
if omitted <= 0:
|
|
485
|
+
return code
|
|
486
|
+
|
|
487
|
+
head = "\n".join(lines[:head_lines])
|
|
488
|
+
tail = "\n".join(lines[-tail_lines:])
|
|
489
|
+
last_visible = len(lines) - tail_lines
|
|
490
|
+
|
|
491
|
+
warning = (
|
|
492
|
+
f"\n{marker} ============ WARNING: FILE TRUNCATED ============\n"
|
|
493
|
+
f"{marker} Lines 1-{head_lines} and {last_visible + 1}-{len(lines)} "
|
|
494
|
+
f"of {len(lines)} shown. {omitted} lines omitted in the middle.\n"
|
|
495
|
+
f"{marker}\n"
|
|
496
|
+
f"{marker} RULES:\n"
|
|
497
|
+
f"{marker} 1. Use SEARCH/REPLACE blocks ONLY. Do NOT output the full file.\n"
|
|
498
|
+
f"{marker} 2. SEARCH anchors MUST come from the visible portions above.\n"
|
|
499
|
+
f"{marker} 3. To edit the omitted middle section, anchor on the nearest\n"
|
|
500
|
+
f"{marker} visible line at the boundary of the gap.\n"
|
|
501
|
+
f"{marker} =====================================================\n"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
return f"{head}\n{warning}\n{tail}"
|
|
505
|
+
|
|
506
|
+
def smart_slice_code(
|
|
507
|
+
code: str,
|
|
508
|
+
target_file: str,
|
|
509
|
+
max_lines: int = 150,
|
|
510
|
+
hints: str = "",
|
|
511
|
+
) -> str:
|
|
512
|
+
"""
|
|
513
|
+
Slice code for the editing prompt.
|
|
514
|
+
|
|
515
|
+
For Python files with symbol hints, uses AST to isolate the target
|
|
516
|
+
function/class. For everything else, falls back to head+tail line slicing.
|
|
517
|
+
|
|
518
|
+
`hints` should contain text likely to mention the symbol being edited
|
|
519
|
+
(e.g. the plan step title + researcher analysis).
|
|
520
|
+
"""
|
|
521
|
+
if not code:
|
|
522
|
+
return code
|
|
523
|
+
lines = code.splitlines()
|
|
524
|
+
if len(lines) <= max_lines:
|
|
525
|
+
return code
|
|
526
|
+
|
|
527
|
+
ext = target_file.rsplit(".", 1)[-1].lower() if "." in target_file else ""
|
|
528
|
+
|
|
529
|
+
if ext in ("py", "pyi") and hints:
|
|
530
|
+
return _smart_slice_python(code, target_file, hints, max_lines)
|
|
531
|
+
|
|
532
|
+
return _render_line_slice(code, target_file, max_lines)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
# Log deduplication for T5
|
|
537
|
+
# ---------------------------------------------------------------------------
|
|
538
|
+
_LINE_NUMBER_RE = re.compile(r":\d+:\d+|line \d+|Line \d+")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def dedupe_logs(logs: str, max_errors: int = 5, keep_tail_chars: int = 300) -> str:
|
|
542
|
+
"""
|
|
543
|
+
Compress compiler/iteration logs by extracting unique error/warning
|
|
544
|
+
lines, normalizing line numbers, and capping at max_errors.
|
|
545
|
+
"""
|
|
546
|
+
if not logs or len(logs) < 200:
|
|
547
|
+
return logs.strip()
|
|
548
|
+
|
|
549
|
+
seen: set = set()
|
|
550
|
+
picked: List[str] = []
|
|
551
|
+
|
|
552
|
+
for line in logs.splitlines():
|
|
553
|
+
stripped = line.strip()
|
|
554
|
+
if not stripped:
|
|
555
|
+
continue
|
|
556
|
+
lower = stripped.lower()
|
|
557
|
+
if not any(tok in lower for tok in (
|
|
558
|
+
"error", "warning", "fail", "reject", "invalid", "exception"
|
|
559
|
+
)):
|
|
560
|
+
continue
|
|
561
|
+
normalized = _LINE_NUMBER_RE.sub(":N", stripped)
|
|
562
|
+
if normalized in seen:
|
|
563
|
+
continue
|
|
564
|
+
seen.add(normalized)
|
|
565
|
+
picked.append(stripped)
|
|
566
|
+
if len(picked) >= max_errors:
|
|
567
|
+
break
|
|
568
|
+
|
|
569
|
+
if not picked:
|
|
570
|
+
tail = logs[-keep_tail_chars:]
|
|
571
|
+
return f"[no distinct errors; tail]\n{tail}"
|
|
572
|
+
|
|
573
|
+
header = f"[{len(picked)} distinct error/warning line(s), {len(logs)} chars in raw log]"
|
|
574
|
+
return header + "\n" + "\n".join(picked)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
# Diff rendering for reviewer T2
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
def render_code_diff(
|
|
581
|
+
old_code: str,
|
|
582
|
+
new_code: str,
|
|
583
|
+
target_file: str,
|
|
584
|
+
context_lines: int = 3,
|
|
585
|
+
) -> str:
|
|
586
|
+
"""
|
|
587
|
+
Render a unified diff. Returns "" if old == new or diff computation
|
|
588
|
+
fails (caller should fall back to sliced code).
|
|
589
|
+
|
|
590
|
+
For small edits in large files, this is 10-30x smaller than the full
|
|
591
|
+
code — the reviewer rarely needs the unchanged portions.
|
|
592
|
+
"""
|
|
593
|
+
if not old_code or not new_code:
|
|
594
|
+
return ""
|
|
595
|
+
if old_code == new_code:
|
|
596
|
+
return ""
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
import difflib
|
|
600
|
+
diff_lines = list(difflib.unified_diff(
|
|
601
|
+
old_code.splitlines(),
|
|
602
|
+
new_code.splitlines(),
|
|
603
|
+
fromfile=f"a/{target_file}",
|
|
604
|
+
tofile=f"b/{target_file}",
|
|
605
|
+
lineterm="",
|
|
606
|
+
n=context_lines,
|
|
607
|
+
))
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
logger.debug("Diff rendering failed: %s", exc)
|
|
610
|
+
return ""
|
|
611
|
+
|
|
612
|
+
if not diff_lines:
|
|
613
|
+
return ""
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
f"=== DIFF (unified, context={context_lines}) ===\n"
|
|
617
|
+
+ "\n".join(diff_lines)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
__all__ = [
|
|
622
|
+
"Tier",
|
|
623
|
+
"TierItem",
|
|
624
|
+
"ContextBundle",
|
|
625
|
+
"AssembledContext",
|
|
626
|
+
"ContextAssembler",
|
|
627
|
+
"estimate_tokens",
|
|
628
|
+
"smart_slice_code",
|
|
629
|
+
"dedupe_logs",
|
|
630
|
+
"render_code_diff",
|
|
631
|
+
]
|