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.
Files changed (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. 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
+ ]