opencode-arch 1.0.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 (65) hide show
  1. opencode_arch/__init__.py +3 -0
  2. opencode_arch/artifacts/__init__.py +48 -0
  3. opencode_arch/artifacts/context.py +451 -0
  4. opencode_arch/artifacts/diagrams.py +451 -0
  5. opencode_arch/artifacts/selector.py +331 -0
  6. opencode_arch/artifacts/templates.py +444 -0
  7. opencode_arch/cli/__init__.py +1 -0
  8. opencode_arch/cli/bench.py +25 -0
  9. opencode_arch/cli/calibrate.py +208 -0
  10. opencode_arch/cli/confidence.py +66 -0
  11. opencode_arch/cli/docs.py +333 -0
  12. opencode_arch/cli/docs_validator.py +295 -0
  13. opencode_arch/cli/export_data.py +133 -0
  14. opencode_arch/cli/extract.py +93 -0
  15. opencode_arch/cli/gap_analyzer.py +107 -0
  16. opencode_arch/cli/generate.py +68 -0
  17. opencode_arch/cli/launch.py +264 -0
  18. opencode_arch/cli/main.py +360 -0
  19. opencode_arch/cli/metrics.py +186 -0
  20. opencode_arch/cli/prompts.py +20 -0
  21. opencode_arch/cli/regen_loop.py +1028 -0
  22. opencode_arch/context/__init__.py +29 -0
  23. opencode_arch/context/formatter.py +492 -0
  24. opencode_arch/context/pipeline_bridge.py +201 -0
  25. opencode_arch/extract/__init__.py +8 -0
  26. opencode_arch/extract/constraint_detector.py +398 -0
  27. opencode_arch/extract/from_artifacts.py +837 -0
  28. opencode_arch/extract/from_code.py +646 -0
  29. opencode_arch/extract/route_detector.py +400 -0
  30. opencode_arch/extract/table_parser.py +177 -0
  31. opencode_arch/learning/__init__.py +19 -0
  32. opencode_arch/learning/adapter.py +157 -0
  33. opencode_arch/learning/assessor.py +170 -0
  34. opencode_arch/learning/classifier.py +144 -0
  35. opencode_arch/learning/lessons.py +139 -0
  36. opencode_arch/learning/maintainer.py +281 -0
  37. opencode_arch/learning/patterns.py +51 -0
  38. opencode_arch/mcp/__init__.py +1 -0
  39. opencode_arch/mcp/__main__.py +8 -0
  40. opencode_arch/mcp/server.py +183 -0
  41. opencode_arch/mcp/tools/__init__.py +1 -0
  42. opencode_arch/mcp/tools/check.py +159 -0
  43. opencode_arch/mcp/tools/extract.py +107 -0
  44. opencode_arch/mcp/tools/feedback.py +65 -0
  45. opencode_arch/mcp/tools/generate.py +104 -0
  46. opencode_arch/mcp/tools/group.py +62 -0
  47. opencode_arch/mcp/tools/ingest.py +101 -0
  48. opencode_arch/mcp/tools/require.py +77 -0
  49. opencode_arch/mcp/tools/scan.py +53 -0
  50. opencode_arch/mcp/tools/slice.py +235 -0
  51. opencode_arch/mcp/tools/validate.py +59 -0
  52. opencode_arch/prompts/__init__.py +1 -0
  53. opencode_arch/prompts/regen.py +36 -0
  54. opencode_arch/runner/__init__.py +5 -0
  55. opencode_arch/runner/base.py +21 -0
  56. opencode_arch/runner/opencode.py +66 -0
  57. opencode_arch/telemetry/__init__.py +6 -0
  58. opencode_arch/telemetry/collector.py +40 -0
  59. opencode_arch/telemetry/recorder.py +12 -0
  60. opencode_arch/telemetry/store.py +537 -0
  61. opencode_arch-1.0.0.dist-info/METADATA +247 -0
  62. opencode_arch-1.0.0.dist-info/RECORD +65 -0
  63. opencode_arch-1.0.0.dist-info/WHEEL +4 -0
  64. opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
  65. opencode_arch-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,29 @@
1
+ """Context compression and token brokering."""
2
+
3
+ from opencode_arch.context.formatter import (
4
+ format_model_context,
5
+ format_fblock_context,
6
+ format_artifact_context,
7
+ query_model,
8
+ impact_analysis,
9
+ )
10
+ from opencode_arch.context.pipeline_bridge import (
11
+ get_model,
12
+ get_artifact_context,
13
+ get_fblock_context,
14
+ get_model_summary,
15
+ enrich_manifest_slice,
16
+ )
17
+
18
+ __all__ = [
19
+ "format_model_context",
20
+ "format_fblock_context",
21
+ "format_artifact_context",
22
+ "query_model",
23
+ "impact_analysis",
24
+ "get_model",
25
+ "get_artifact_context",
26
+ "get_fblock_context",
27
+ "get_model_summary",
28
+ "enrich_manifest_slice",
29
+ ]
@@ -0,0 +1,492 @@
1
+ """
2
+ LLM Context Formatter: Produce compact model representations for LLM prompt injection.
3
+
4
+ Implements the LLM Integration Protocol:
5
+ - LOAD: Serialize model (or slice) into compact text for system prompt
6
+ - QUERY: Answer structural questions from model data
7
+ - IMPACT: Determine what entities are affected by a proposed change
8
+
9
+ The key constraint is TOKEN BUDGET — we need maximum information density.
10
+ Format: structured YAML-like text, ~25:1 compression vs full artifact markdown.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from architecture_model.core.types import (
18
+ ArchitectureModel,
19
+ Actor,
20
+ Behavior,
21
+ Capability,
22
+ Component,
23
+ Constraint,
24
+ Interface,
25
+ Layer,
26
+ Relationship,
27
+ Status,
28
+ )
29
+ from architecture_model.core.slicer import slice_by_fblock, slice_for_artifact
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Public API
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def format_model_context(
38
+ model: ArchitectureModel,
39
+ max_tokens: int = 4000,
40
+ detail_level: str = "standard",
41
+ ) -> str:
42
+ """
43
+ Format the full model as compact LLM context.
44
+
45
+ Args:
46
+ model: Architecture model to format.
47
+ max_tokens: Approximate token budget (1 token ~ 4 chars).
48
+ detail_level: "minimal", "standard", or "full".
49
+
50
+ Returns:
51
+ Formatted text suitable for LLM system prompt injection.
52
+ """
53
+ char_budget = max_tokens * 4
54
+
55
+ # Progressive summarization: add sections by priority, stop before exceeding budget
56
+ priority_1: list[str] = []
57
+ priority_2: list[str] = []
58
+ priority_3: list[str] = []
59
+ priority_4: list[str] = []
60
+
61
+ # Priority 1: Header + component names (always included)
62
+ priority_1.append(_format_header(model))
63
+ if model.entities.components:
64
+ lines = [f"\n## Components ({len(model.entities.components)})"]
65
+ for comp in model.entities.components:
66
+ file_count = len(comp.files) if comp.files else 0
67
+ lines.append(f" {comp.id}: {comp.name} ({file_count} files)")
68
+ # Include interface contracts at full detail
69
+ if detail_level == "full" and hasattr(comp, "interfaces") and comp.interfaces:
70
+ for iface in comp.interfaces:
71
+ sym_str = f" [{', '.join(iface.symbols[:5])}]" if iface.symbols else ""
72
+ lines.append(f" {iface.kind}: {iface.target_component}{sym_str}")
73
+ priority_1.append("\n".join(lines))
74
+
75
+ # Priority 2: Key relationships (grouped by type, top connections)
76
+ if model.relationships:
77
+ priority_2.append(_format_relationships_compact(model))
78
+
79
+ # Priority 3: Capabilities + behaviors (compact)
80
+ if detail_level in ("standard", "full"):
81
+ priority_3.append(_format_capabilities(model))
82
+ priority_3.append(_format_actors(model))
83
+ if detail_level == "full":
84
+ priority_3.append(_format_behaviors(model))
85
+ else:
86
+ priority_3.append(_format_behaviors_compact(model))
87
+
88
+ # Priority 4: Full detail (interfaces, layers, constraints)
89
+ if detail_level == "full":
90
+ priority_4.append(_format_interfaces(model))
91
+ priority_4.append(_format_layers(model))
92
+ priority_4.append(_format_constraints(model))
93
+ elif detail_level == "standard":
94
+ priority_4.append(_format_interfaces_compact(model))
95
+ priority_4.append(_format_layers_compact(model))
96
+
97
+ # Progressively add sections until budget is reached
98
+ result_parts: list[str] = []
99
+ used = 0
100
+
101
+ for section_group in [priority_1, priority_2, priority_3, priority_4]:
102
+ for section in section_group:
103
+ if not section:
104
+ continue
105
+ section_len = len(section)
106
+ if used + section_len <= char_budget:
107
+ result_parts.append(section)
108
+ used += section_len
109
+ else:
110
+ # Don't truncate mid-section; stop here
111
+ break
112
+ else:
113
+ continue
114
+ break
115
+
116
+ return "\n".join(result_parts)
117
+
118
+
119
+ def format_fblock_context(
120
+ model: ArchitectureModel,
121
+ f_block: str,
122
+ max_tokens: int = 2000,
123
+ project_root: "Path | None" = None,
124
+ ) -> str:
125
+ """
126
+ Format context for a single F-block (for artifact section regeneration).
127
+
128
+ Produces: capability description, related UCs, components, interfaces.
129
+ If *project_root* is given, sub-models are auto-loaded for richer detail,
130
+ and per-block manifests are consumed for function-level context.
131
+ """
132
+ sliced = slice_by_fblock(model, f_block, project_root=project_root)
133
+ base_context = format_model_context(sliced, max_tokens=max_tokens, detail_level="full")
134
+
135
+ # Consume per-block manifest if available (reduces compression ratio)
136
+ if project_root:
137
+ block_manifest = _load_block_manifest(project_root, f_block)
138
+ if block_manifest:
139
+ char_budget = max_tokens * 4
140
+ remaining = char_budget - len(base_context)
141
+ if remaining > 200:
142
+ manifest_section = _format_block_manifest(block_manifest, remaining)
143
+ if manifest_section:
144
+ base_context += "\n" + manifest_section
145
+
146
+ return base_context
147
+
148
+
149
+ def format_artifact_context(
150
+ model: ArchitectureModel,
151
+ artifact_name: str,
152
+ max_tokens: int = 3000,
153
+ ) -> str:
154
+ """
155
+ Format context appropriate for regenerating a specific artifact.
156
+
157
+ Uses artifact-specific slicing then formats at appropriate detail level.
158
+ """
159
+ sliced = slice_for_artifact(model, artifact_name)
160
+
161
+ detail_map = {
162
+ "functional-architecture": "full",
163
+ "logical-architecture": "full",
164
+ "use-cases": "full",
165
+ "icd": "full",
166
+ "requirements-analysis": "standard",
167
+ "readme": "minimal",
168
+ }
169
+ detail = detail_map.get(artifact_name, "standard")
170
+
171
+ return format_model_context(sliced, max_tokens=max_tokens, detail_level=detail)
172
+
173
+
174
+ def query_model(model: ArchitectureModel, question: str) -> str:
175
+ """
176
+ Answer a structural question from model data.
177
+
178
+ Supports questions like:
179
+ - "What realizes F3?" → list behaviors with tag F3
180
+ - "What does UC-14 depend on?" → follow depends-on relationships
181
+ - "What interfaces does F4 expose?" → filter interfaces by provider
182
+ """
183
+ q = question.lower().strip()
184
+
185
+ # Pattern: "what realizes <X>?"
186
+ if "realizes" in q:
187
+ import re
188
+
189
+ m = re.search(r"(f\d+|cap-\w+)", q, re.IGNORECASE)
190
+ if m:
191
+ target = m.group(1).upper()
192
+ realizers = [
193
+ r.from_id
194
+ for r in model.relationships
195
+ if r.type.value == "realizes" and target in r.to_id.upper()
196
+ ]
197
+ if realizers:
198
+ lines = [f"Entities realizing {target}:"]
199
+ for rid in realizers:
200
+ beh = next((b for b in model.entities.behaviors if b.id == rid), None)
201
+ if beh:
202
+ lines.append(f" - {beh.id}: {beh.name} [{beh.status.value}]")
203
+ else:
204
+ lines.append(f" - {rid}")
205
+ return "\n".join(lines)
206
+ return f"No entities realize {target}"
207
+
208
+ # Pattern: "what does <X> depend on?"
209
+ if "depend" in q:
210
+ import re
211
+
212
+ m = re.search(r"(uc-\d+|[a-z][\w-]+)", q, re.IGNORECASE)
213
+ if m:
214
+ source = m.group(1)
215
+ deps = [
216
+ r.to_id
217
+ for r in model.relationships
218
+ if r.from_id.lower() == source.lower() and r.type.value == "depends-on"
219
+ ]
220
+ if deps:
221
+ return f"{source} depends on: {', '.join(deps)}"
222
+ return f"{source} has no recorded dependencies"
223
+
224
+ # Pattern: count/summary
225
+ if "how many" in q or "count" in q:
226
+ return (
227
+ f"Model contains: {model.entity_count} entities, {model.relationship_count} relationships\n"
228
+ f" Actors: {len(model.entities.actors)}\n"
229
+ f" Capabilities: {len(model.entities.capabilities)}\n"
230
+ f" Behaviors: {len(model.entities.behaviors)}\n"
231
+ f" Interfaces: {len(model.entities.interfaces)}\n"
232
+ f" Constraints: {len(model.entities.constraints)}\n"
233
+ f" Layers: {len(model.entities.layers)}\n"
234
+ f" Components: {len(model.entities.components)}"
235
+ )
236
+
237
+ return f"Unable to answer: {question}\nTry: 'what realizes F3?', 'what does UC-14 depend on?', 'how many entities?'"
238
+
239
+
240
+ def impact_analysis(model: ArchitectureModel, entity_id: str, depth: int = 2) -> str:
241
+ """
242
+ Determine what entities are affected if a given entity changes.
243
+
244
+ Traces relationships transitively up to `depth` levels.
245
+ """
246
+ affected: dict[str, int] = {} # entity_id -> distance
247
+ frontier = {entity_id}
248
+ current_depth = 0
249
+
250
+ while frontier and current_depth < depth:
251
+ next_frontier: set[str] = set()
252
+ for eid in frontier:
253
+ for rel in model.relationships:
254
+ # Forward direction: what depends on this?
255
+ if rel.to_id == eid and rel.from_id not in affected and rel.from_id != entity_id:
256
+ affected[rel.from_id] = current_depth + 1
257
+ next_frontier.add(rel.from_id)
258
+ # Reverse for 'realizes': if this behavior changes, its capability is affected
259
+ if rel.from_id == eid and rel.to_id not in affected and rel.to_id != entity_id:
260
+ if rel.type.value in ("realizes", "contains", "exposes"):
261
+ affected[rel.to_id] = current_depth + 1
262
+ next_frontier.add(rel.to_id)
263
+ frontier = next_frontier
264
+ current_depth += 1
265
+
266
+ if not affected:
267
+ return f"No entities are directly affected by changes to {entity_id}"
268
+
269
+ lines = [f"Impact analysis for {entity_id} (depth={depth}):"]
270
+ for eid, dist in sorted(affected.items(), key=lambda x: x[1]):
271
+ # Try to find name
272
+ name = _find_entity_name(model, eid)
273
+ lines.append(f" {' ' * dist}[depth {dist}] {eid}: {name}")
274
+
275
+ return "\n".join(lines)
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Formatters
280
+ # ---------------------------------------------------------------------------
281
+
282
+
283
+ def _format_header(model: ArchitectureModel) -> str:
284
+ return (
285
+ f"# Architecture Model: {model.meta.project}\n"
286
+ f"System: {model.meta.system} | Schema: {model.meta.schema_version}\n"
287
+ f"Entities: {model.entity_count} | Relationships: {model.relationship_count}\n"
288
+ f"Sources: {', '.join(model.meta.source_artifacts)}"
289
+ )
290
+
291
+
292
+ def _format_capabilities(model: ArchitectureModel) -> str:
293
+ if not model.entities.capabilities:
294
+ return ""
295
+ lines = ["\n## Capabilities (F-blocks)"]
296
+ for cap in model.entities.capabilities:
297
+ lines.append(f" {cap.id} ({cap.f_block}): {cap.name} [{cap.status.value}]")
298
+ return "\n".join(lines)
299
+
300
+
301
+ def _format_actors(model: ArchitectureModel) -> str:
302
+ if not model.entities.actors:
303
+ return ""
304
+ lines = ["\n## Actors"]
305
+ for actor in model.entities.actors:
306
+ goals = "; ".join(actor.goals[:3]) if actor.goals else ""
307
+ lines.append(f" {actor.id}: {actor.name} ({actor.type.value}) — {goals}")
308
+ return "\n".join(lines)
309
+
310
+
311
+ def _format_behaviors(model: ArchitectureModel) -> str:
312
+ if not model.entities.behaviors:
313
+ return ""
314
+ lines = ["\n## Behaviors (Use Cases)"]
315
+ for beh in model.entities.behaviors:
316
+ post = beh.postconditions[0][:60] if beh.postconditions else ""
317
+ lines.append(
318
+ f" {beh.id}: {beh.name} [{beh.status.value}] "
319
+ f"actor={beh.actor} freq={beh.frequency} pri={beh.priority.value}"
320
+ )
321
+ if post:
322
+ lines.append(f" acceptance: {post}")
323
+ return "\n".join(lines)
324
+
325
+
326
+ def _format_behaviors_compact(model: ArchitectureModel) -> str:
327
+ if not model.entities.behaviors:
328
+ return ""
329
+ lines = ["\n## Behaviors (30 UCs)"]
330
+ for beh in model.entities.behaviors:
331
+ tag = beh.tags[0] if beh.tags else "?"
332
+ lines.append(f" {beh.id}: {beh.name} [{beh.status.value}] {tag} pri={beh.priority.value}")
333
+ return "\n".join(lines)
334
+
335
+
336
+ def _format_interfaces(model: ArchitectureModel) -> str:
337
+ if not model.entities.interfaces:
338
+ return ""
339
+ lines = ["\n## Interfaces"]
340
+ for iface in model.entities.interfaces:
341
+ lines.append(
342
+ f" {iface.id}: {iface.type.value} | {iface.provider} -> {iface.consumer} "
343
+ f"via {iface.protocol} [{iface.status.value}]"
344
+ )
345
+ return "\n".join(lines)
346
+
347
+
348
+ def _format_interfaces_compact(model: ArchitectureModel) -> str:
349
+ if not model.entities.interfaces:
350
+ return ""
351
+ lines = [f"\n## Interfaces ({len(model.entities.interfaces)})"]
352
+ for iface in model.entities.interfaces:
353
+ lines.append(f" {iface.id}: {iface.provider} -> {iface.consumer} ({iface.type.value})")
354
+ return "\n".join(lines)
355
+
356
+
357
+ def _format_layers(model: ArchitectureModel) -> str:
358
+ if not model.entities.layers:
359
+ return ""
360
+ lines = ["\n## Layers"]
361
+ for layer in model.entities.layers:
362
+ comp_count = sum(1 for c in model.entities.components if c.layer == layer.id)
363
+ lines.append(f" {layer.id}: {layer.name} (order={layer.order}, {comp_count} components)")
364
+ if layer.directories:
365
+ lines.append(f" dirs: {', '.join(layer.directories)}")
366
+ return "\n".join(lines)
367
+
368
+
369
+ def _format_layers_compact(model: ArchitectureModel) -> str:
370
+ if not model.entities.layers:
371
+ return ""
372
+ lines = [f"\n## Layers ({len(model.entities.layers)})"]
373
+ for layer in model.entities.layers:
374
+ lines.append(f" {layer.id}: {layer.name}")
375
+ return "\n".join(lines)
376
+
377
+
378
+ def _format_components(model: ArchitectureModel) -> str:
379
+ if not model.entities.components:
380
+ return ""
381
+ lines = [f"\n## Components ({len(model.entities.components)})"]
382
+ for comp in model.entities.components:
383
+ files = ", ".join(comp.files[:2]) if comp.files else ""
384
+ lines.append(f" {comp.id}: {comp.name} (layer={comp.layer}, {comp.f_block}) [{files}]")
385
+ return "\n".join(lines)
386
+
387
+
388
+ def _format_constraints(model: ArchitectureModel) -> str:
389
+ if not model.entities.constraints:
390
+ return ""
391
+ lines = [f"\n## Constraints ({len(model.entities.constraints)})"]
392
+ for con in model.entities.constraints:
393
+ lines.append(f" {con.id}: {con.name} ({con.type.value}) threshold={con.threshold}")
394
+ return "\n".join(lines)
395
+
396
+
397
+ def _format_relationships_compact(model: ArchitectureModel) -> str:
398
+ if not model.relationships:
399
+ return ""
400
+ # Group by type
401
+ by_type: dict[str, list[Relationship]] = {}
402
+ for rel in model.relationships:
403
+ by_type.setdefault(rel.type.value, []).append(rel)
404
+
405
+ lines = [f"\n## Relationships ({len(model.relationships)})"]
406
+ for rtype, rels in by_type.items():
407
+ lines.append(f" {rtype} ({len(rels)}):")
408
+ for rel in rels[:10]: # Limit per type
409
+ lines.append(f" {rel.from_id} -> {rel.to_id}")
410
+ if len(rels) > 10:
411
+ lines.append(f" ... +{len(rels) - 10} more")
412
+ return "\n".join(lines)
413
+
414
+
415
+ def _find_entity_name(model: ArchitectureModel, entity_id: str) -> str:
416
+ """Find the human-readable name for an entity ID."""
417
+ for lst in [
418
+ model.entities.actors,
419
+ model.entities.capabilities,
420
+ model.entities.behaviors,
421
+ model.entities.interfaces,
422
+ model.entities.constraints,
423
+ model.entities.layers,
424
+ model.entities.components,
425
+ ]:
426
+ for e in lst:
427
+ if e.id == entity_id:
428
+ return e.name
429
+ return entity_id
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # Block manifest consumption
434
+ # ---------------------------------------------------------------------------
435
+
436
+
437
+ def _load_block_manifest(project_root: "Path", f_block: str) -> dict | None:
438
+ """Load per-block manifest.json if it exists."""
439
+ import json
440
+ from pathlib import Path
441
+
442
+ # Try common locations
443
+ for subdir in (f_block, f_block.lower(), f"block_{f_block}"):
444
+ manifest_path = Path(project_root) / ".architecture-models" / subdir / "manifest.json"
445
+ if manifest_path.exists():
446
+ try:
447
+ return json.loads(manifest_path.read_text())
448
+ except (json.JSONDecodeError, OSError):
449
+ pass
450
+ return None
451
+
452
+
453
+ def _format_block_manifest(manifest: dict, char_budget: int) -> str:
454
+ """Format block manifest data as compact context.
455
+
456
+ Includes: module names, key function signatures, imports.
457
+ This fills the semantic gap that causes cross_dep failures.
458
+ """
459
+ lines = ["\n## Block Manifest (function-level detail)"]
460
+
461
+ modules = manifest.get("modules", [])
462
+ for mod in modules:
463
+ file_path = mod.get("file", mod.get("path", "unknown"))
464
+ lines.append(f" {file_path}:")
465
+
466
+ # Functions with signatures (critical for cross_dep)
467
+ functions = mod.get("functions", [])
468
+ for fn in functions[:10]:
469
+ name = fn.get("name", "") if isinstance(fn, dict) else fn
470
+ sig = fn.get("signature", "") if isinstance(fn, dict) else ""
471
+ if name.startswith("_"):
472
+ continue
473
+ if sig:
474
+ lines.append(f" {name}{sig}")
475
+ else:
476
+ lines.append(f" {name}()")
477
+
478
+ # Classes
479
+ classes = mod.get("classes", [])
480
+ for cls in classes[:5]:
481
+ name = cls.get("name", "") if isinstance(cls, dict) else cls
482
+ if not name.startswith("_"):
483
+ lines.append(f" class {name}")
484
+
485
+ # Check budget
486
+ current = "\n".join(lines)
487
+ if len(current) >= char_budget * 0.9:
488
+ lines.append(" ... (truncated)")
489
+ break
490
+
491
+ result = "\n".join(lines)
492
+ return result[:char_budget]