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,235 @@
1
+ """architect_slice MCP tool — compress repository context for LLM consumption."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ def compute_adaptive_budget(module_count: int, base: int = 4000) -> int:
11
+ """Scale token budget with repository size.
12
+
13
+ Small repos (<=20 modules): base budget (4000 tokens)
14
+ Medium repos: +200 tokens per 10 modules over 20
15
+ Large repos: capped at 16000 tokens
16
+
17
+ Examples:
18
+ 20 modules → 4000 tokens
19
+ 50 modules → 4600 tokens
20
+ 100 modules → 5600 tokens
21
+ 161 modules → 6800 tokens
22
+ 500 modules → 16000 tokens (capped)
23
+ """
24
+ if module_count <= 20:
25
+ return base
26
+ extra = ((module_count - 20) // 10) * 200
27
+ return min(base + extra, 16000)
28
+
29
+
30
+ # Compression ratio thresholds (from telemetry analysis of 389 regen outcomes)
31
+ # <2x: 78% pass | 2-10x: 69% | 10-50x: 55% | 50-200x: 44% | >200x: 19%
32
+ COMPRESSION_WARN_THRESHOLD = 50 # warn above this
33
+ COMPRESSION_CRITICAL_THRESHOLD = 200 # strongly recommend per-block above this
34
+
35
+
36
+ def _estimate_source_size(path: Path) -> int:
37
+ """Estimate total source code size in chars (quick heuristic)."""
38
+ total = 0
39
+ for ext in ("*.py", "*.ts", "*.js", "*.go", "*.rs", "*.java"):
40
+ for f in path.rglob(ext):
41
+ # Skip vendor, node_modules, .git
42
+ parts = f.parts
43
+ if any(p in parts for p in ("vendor", "_vendor", "node_modules", ".git", "__pycache__")):
44
+ continue
45
+ try:
46
+ total += f.stat().st_size
47
+ except OSError:
48
+ pass
49
+ return total
50
+
51
+
52
+ async def slice_context(
53
+ repo_path: str,
54
+ focus: str = "all",
55
+ budget: int = 0,
56
+ detail: str = "standard",
57
+ ) -> str:
58
+ """Generate an optimized context slice from a repository.
59
+
60
+ This is the core token-arbitrage function. It compresses a full repository
61
+ into a dense, structured context string within the token budget.
62
+
63
+ If an .architecture-model.yaml exists, uses the model + slicer + formatter.
64
+ Otherwise, falls back to manifest-based context.
65
+
66
+ Args:
67
+ repo_path: Absolute path to the repository root.
68
+ focus: Focus scope - "all", an F-block ID (e.g. "F1"), a layer name,
69
+ or an artifact name (e.g. "icd", "requirements-analysis").
70
+ budget: Maximum token budget (1 token ~ 4 chars).
71
+ detail: Detail level - "minimal", "standard", or "full".
72
+
73
+ Returns:
74
+ Formatted context string within budget, or error message.
75
+ """
76
+ path = Path(repo_path)
77
+ if not path.exists():
78
+ return f"Error: Repository path does not exist: {repo_path}"
79
+
80
+ try:
81
+ model_file = path / ".architecture-model.yaml"
82
+
83
+ # Adaptive budget: compute from repo size if not specified
84
+ if budget <= 0:
85
+ if model_file.exists():
86
+ from architecture_model.core.parser import load_model
87
+ model = load_model(model_file)
88
+ file_count = sum(len(getattr(c, 'files', [])) for c in model.entities.components)
89
+ budget = compute_adaptive_budget(file_count)
90
+ else:
91
+ from architecture_model.manifest.generator import generate_manifest
92
+ manifest = generate_manifest(path)
93
+ budget = compute_adaptive_budget(len(manifest.modules))
94
+
95
+ # Secondary check: ensure compression ratio stays below 50x
96
+ source_size = _estimate_source_size(path)
97
+ min_budget_for_50x = source_size // (50 * 4) # 50x compression, 4 chars/token
98
+ if min_budget_for_50x > budget:
99
+ budget = min(min_budget_for_50x, 16000) # cap at 16K tokens
100
+
101
+ if model_file.exists():
102
+ result = _slice_from_model(path, focus, budget, detail)
103
+ else:
104
+ result = _slice_from_manifest(path, focus, budget)
105
+
106
+ # Compression ratio guard: warn if context is dangerously compressed
107
+ source_size = _estimate_source_size(path)
108
+ char_budget = budget * 4
109
+ if source_size > 0 and char_budget > 0:
110
+ ratio = source_size / char_budget
111
+ if ratio > COMPRESSION_CRITICAL_THRESHOLD:
112
+ warning = (
113
+ f"# COMPRESSION WARNING: {ratio:.0f}x compression detected!\n"
114
+ f"# Source: {source_size // 1024}KB compressed into {char_budget // 1024}KB context.\n"
115
+ f"# At >200x compression, regeneration pass rate drops to ~19%.\n"
116
+ f"# RECOMMENDATION: Use focused slicing (architect_slice with focus='F1', 'F2', etc.)\n"
117
+ f"# to slice per-block. Available F-blocks can be found via architect_scan.\n\n"
118
+ )
119
+ result = warning + result
120
+ elif ratio > COMPRESSION_WARN_THRESHOLD:
121
+ warning = (
122
+ f"# NOTE: {ratio:.0f}x compression ratio (>50x reduces pass rate).\n"
123
+ f"# Consider per-block slicing for better regeneration outcomes.\n\n"
124
+ )
125
+ result = warning + result
126
+
127
+ try:
128
+ from opencode_arch.telemetry.collector import drain_and_store
129
+ drain_and_store(tool="architect_slice", repo=path.name)
130
+ except Exception:
131
+ pass
132
+ return result
133
+
134
+ except Exception as e:
135
+ return f"Error during context slicing: {e}"
136
+
137
+
138
+ def _slice_from_model(project_root: Path, focus: str, budget: int, detail: str) -> str:
139
+ """Slice context using the architecture model (rich path)."""
140
+ from architecture_model.core.parser import load_model
141
+ from opencode_arch.context import (
142
+ format_model_context,
143
+ format_fblock_context,
144
+ format_artifact_context,
145
+ )
146
+ from architecture_model.core.slicer import slice_by_layer
147
+
148
+ model_path = project_root / ".architecture-model.yaml"
149
+ model = load_model(model_path)
150
+
151
+ if focus == "all":
152
+ return format_model_context(model, max_tokens=budget, detail_level=detail)
153
+ elif focus.startswith("F") and focus[1:].isdigit():
154
+ # Complexity-proportional budget: complex blocks get more tokens
155
+ block_budget = _compute_block_budget(model, focus, budget)
156
+ return format_fblock_context(model, f_block=focus, max_tokens=block_budget, project_root=project_root)
157
+ elif focus in (
158
+ "functional-architecture", "logical-architecture", "use-cases",
159
+ "icd", "requirements-analysis", "operations-manual", "conops",
160
+ "testing", "deployment-guide", "data-dictionary", "readme",
161
+ ):
162
+ return format_artifact_context(model, artifact_name=focus, max_tokens=budget)
163
+ else:
164
+ try:
165
+ sliced = slice_by_layer(model, layer_id=focus)
166
+ return format_model_context(sliced, max_tokens=budget, detail_level=detail)
167
+ except (KeyError, ValueError):
168
+ return format_model_context(model, max_tokens=budget, detail_level=detail)
169
+
170
+
171
+ def _compute_block_budget(model: Any, f_block: str, total_budget: int) -> int:
172
+ """Allocate budget proportionally to block complexity.
173
+
174
+ Complex blocks (many signatures/files) get more tokens.
175
+ Simple blocks get the minimum needed.
176
+ Telemetry: <10 signatures reliably converge; complex blocks need 2-3x more context.
177
+ """
178
+ components = [c for c in model.entities.components if getattr(c, 'f_block', '') == f_block]
179
+ if not components:
180
+ # No f_block match — give full budget
181
+ return total_budget
182
+
183
+ # Complexity = total signatures + total files
184
+ sig_count = sum(len(getattr(c, 'signatures', [])) for c in components)
185
+ file_count = sum(len(getattr(c, 'files', [])) for c in components)
186
+ complexity = sig_count + file_count
187
+
188
+ # Simple (< 10): base budget, Complex (10-30): 1.5x, Very complex (>30): 2x
189
+ if complexity < 10:
190
+ return min(total_budget, 4000)
191
+ elif complexity < 30:
192
+ return min(int(total_budget * 1.5), 8000)
193
+ else:
194
+ return min(total_budget * 2, 16000)
195
+
196
+
197
+ def _slice_from_manifest(project_root: Path, focus: str, budget: int) -> str:
198
+ """Slice context using manifest only (no architecture model yet)."""
199
+ from architecture_model.manifest.generator import generate_manifest
200
+
201
+ manifest = generate_manifest(project_root)
202
+
203
+ # Try to include grouped component suggestions for richer context
204
+ groups_info = []
205
+ try:
206
+ from architecture_model.manifest.grouping import group_modules
207
+ groups = group_modules(manifest.modules, manifest.interfaces)
208
+ groups_info = [
209
+ {"name": g.name, "files": g.modules, "primary": g.primary_file}
210
+ for g in groups
211
+ ]
212
+ except Exception:
213
+ pass
214
+
215
+ manifest_yaml = yaml.dump(manifest, default_flow_style=False, sort_keys=False)
216
+
217
+ char_budget = budget * 4
218
+ if len(manifest_yaml) > char_budget:
219
+ summary: dict[str, Any] = {
220
+ "project_root": manifest.get("project_root"),
221
+ "metrics": manifest.get("metrics", {}),
222
+ "module_count": len(manifest.get("modules", [])),
223
+ }
224
+ if groups_info:
225
+ summary["suggested_components"] = groups_info
226
+ else:
227
+ summary["functional_blocks"] = {
228
+ k: {"file_count": len(v.get("sub_functions", []))}
229
+ for k, v in manifest.get("functional_blocks", {}).items()
230
+ }
231
+ if focus != "all":
232
+ summary["focus"] = focus
233
+ manifest_yaml = yaml.dump(summary, default_flow_style=False, sort_keys=False)
234
+
235
+ return manifest_yaml[:char_budget]
@@ -0,0 +1,59 @@
1
+ # src/opencode_arch/mcp/tools/validate.py
2
+ """architect_validate MCP tool — validate architecture model quality."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ async def validate_architecture(
11
+ model_yaml: str,
12
+ ) -> dict[str, Any]:
13
+ """Validate an architecture model for structural correctness.
14
+
15
+ Args:
16
+ model_yaml: The YAML architecture model string to validate.
17
+
18
+ Returns:
19
+ Dict with: score (0-100), issues (list), entity_count, relationship_count, is_valid.
20
+ """
21
+ try:
22
+ raw = yaml.safe_load(model_yaml)
23
+ if raw is None:
24
+ return {
25
+ "score": 0,
26
+ "issues": ["Empty YAML document"],
27
+ "entity_count": 0,
28
+ "relationship_count": 0,
29
+ "is_valid": False,
30
+ }
31
+
32
+ from architecture_model.core.parser import _parse_raw
33
+ from architecture_model.core.validator import validate_model
34
+
35
+ model = _parse_raw(raw)
36
+ validation_result = validate_model(model)
37
+
38
+ try:
39
+ from opencode_arch.telemetry.collector import drain_and_store
40
+ drain_and_store(tool="architect_validate", repo="")
41
+ except Exception:
42
+ pass
43
+
44
+ return {
45
+ "score": validation_result.score,
46
+ "issues": [str(issue) for issue in validation_result.issues],
47
+ "entity_count": model.entity_count,
48
+ "relationship_count": model.relationship_count,
49
+ "is_valid": validation_result.is_valid,
50
+ }
51
+
52
+ except Exception as e:
53
+ return {
54
+ "score": 0,
55
+ "issues": [f"Parse/validation error: {e}"],
56
+ "entity_count": 0,
57
+ "relationship_count": 0,
58
+ "is_valid": False,
59
+ }
@@ -0,0 +1 @@
1
+ """Prompt templates for regen-loop and related workflows."""
@@ -0,0 +1,36 @@
1
+ """Prompt templates for the decomposed regen-loop."""
2
+
3
+ REGEN_PROMPT = """\
4
+ ## Regenerate {subsystem_name} (iteration {iteration}/{max_iterations})
5
+
6
+ You are regenerating a subsystem of a Python project. Your goal is to produce
7
+ source files that pass the associated test suite.
8
+
9
+ ### Source files to produce:
10
+ {source_files}
11
+
12
+ ### Architecture Model (structural)
13
+ {model_context}
14
+
15
+ ### Constants (must be exact)
16
+ {constants}
17
+
18
+ ### Function Signatures
19
+ {signatures}
20
+
21
+ ### Test Contracts (assertions that MUST pass)
22
+ {test_contracts}
23
+
24
+ ### Dependency Context
25
+ {dependency_apis}
26
+
27
+ {previous_feedback}\
28
+ """
29
+
30
+ FEEDBACK_HEADER = """\
31
+ ### Feedback from previous iteration ({prev_iteration})
32
+
33
+ The following tests failed. Fix these issues:
34
+
35
+ {failure_analysis}
36
+ """
@@ -0,0 +1,5 @@
1
+ """Runner backends for agent invocation."""
2
+ from opencode_arch.runner.base import RunResult, RunnerBackend
3
+ from opencode_arch.runner.opencode import OpencodeRunner
4
+
5
+ __all__ = ["RunResult", "RunnerBackend", "OpencodeRunner"]
@@ -0,0 +1,21 @@
1
+ """Runner protocol and result types."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Protocol
6
+
7
+
8
+ @dataclass
9
+ class RunResult:
10
+ """Result of an agent run."""
11
+ output: str
12
+ exit_code: int
13
+ success: bool
14
+
15
+
16
+ class RunnerBackend(Protocol):
17
+ """Protocol for agent invocation backends."""
18
+
19
+ async def run(self, prompt: str, repo_path: str) -> RunResult:
20
+ """Run the agent with a prompt in a repo context."""
21
+ ...
@@ -0,0 +1,66 @@
1
+ """OpenCode subprocess runner backend."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+
6
+ from opencode_arch.runner.base import RunResult
7
+
8
+
9
+ class OpencodeRunner:
10
+ """Invokes `opencode run` as a subprocess."""
11
+
12
+ def __init__(self, timeout: int = 600, model: str | None = None):
13
+ self.timeout = timeout
14
+ self.model = model
15
+
16
+ async def run(self, prompt: str, repo_path: str) -> RunResult:
17
+ """Run OpenCode with a prompt in the given repo directory.
18
+
19
+ Uses stdin to pass the prompt (avoids ARG_MAX limits for long prompts).
20
+ Uses cwd to set the working directory (no --dir flag needed).
21
+ """
22
+ cmd = ["opencode", "run"]
23
+ if self.model:
24
+ cmd.extend(["--model", self.model])
25
+
26
+ try:
27
+ result = subprocess.run(
28
+ cmd,
29
+ input=prompt,
30
+ capture_output=True,
31
+ text=True,
32
+ timeout=self.timeout,
33
+ cwd=repo_path,
34
+ )
35
+ # Strip ANSI escape codes from output
36
+ output = _strip_ansi(result.stdout)
37
+ return RunResult(
38
+ output=output,
39
+ exit_code=result.returncode,
40
+ success=result.returncode == 0,
41
+ )
42
+ except subprocess.TimeoutExpired:
43
+ return RunResult(
44
+ output=f"Timeout: opencode run exceeded {self.timeout}s",
45
+ exit_code=-1,
46
+ success=False,
47
+ )
48
+ except FileNotFoundError:
49
+ return RunResult(
50
+ output="Error: opencode not found. Install with: npm i -g opencode",
51
+ exit_code=-1,
52
+ success=False,
53
+ )
54
+ except Exception as e:
55
+ return RunResult(
56
+ output=f"Error: {e}",
57
+ exit_code=-1,
58
+ success=False,
59
+ )
60
+
61
+
62
+ def _strip_ansi(text: str) -> str:
63
+ """Remove ANSI escape sequences from text."""
64
+ import re
65
+ ansi_pattern = re.compile(r'\x1b\[[0-9;]*m|\x1b\[\?[0-9;]*[a-zA-Z]|\x1b\[[0-9;]*[a-zA-Z]')
66
+ return ansi_pattern.sub('', text)
@@ -0,0 +1,6 @@
1
+ """Telemetry: records tool usage and outcomes for optimization."""
2
+
3
+ from opencode_arch.telemetry.store import TelemetryStore
4
+ from opencode_arch.telemetry.recorder import record_invocation
5
+
6
+ __all__ = ["TelemetryStore", "record_invocation"]
@@ -0,0 +1,40 @@
1
+ """Drain metrics from architecture-model-standard and store in telemetry.
2
+
3
+ Call drain_and_store() at the end of each MCP tool invocation to persist
4
+ all function-level metrics that were collected during the operation.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+
12
+ def drain_and_store(tool: str, repo: str = "", db_path: str | None = None) -> int:
13
+ """Drain the thread-local metrics collector and write to telemetry DB.
14
+
15
+ Returns the number of metrics stored. Never raises — swallows all exceptions.
16
+ """
17
+ try:
18
+ from architecture_model.monitoring import get_collector
19
+ from opencode_arch.telemetry.store import TelemetryStore
20
+
21
+ collector = get_collector()
22
+ metrics = collector.drain()
23
+ if not metrics:
24
+ return 0
25
+
26
+ store = TelemetryStore(Path(db_path)) if db_path else TelemetryStore()
27
+ for m in metrics:
28
+ store.record_function_metric(
29
+ tool=tool,
30
+ function=m.function,
31
+ module=m.module,
32
+ repo=repo,
33
+ time_ms=m.time_ms,
34
+ quality_scores=json.dumps(m.quality_scores),
35
+ input_metrics=json.dumps(m.input_metrics),
36
+ output_metrics=json.dumps(m.output_metrics),
37
+ )
38
+ return len(metrics)
39
+ except Exception:
40
+ return 0
@@ -0,0 +1,12 @@
1
+ """Async recorder for tool invocations."""
2
+ from __future__ import annotations
3
+
4
+ from opencode_arch.telemetry.store import TelemetryStore
5
+
6
+
7
+ async def record_invocation(store: TelemetryStore, tool: str, repo: str = "",
8
+ context_tokens: int = 0, output_quality: int = 0,
9
+ iterations: int = 1, metadata: str = ""):
10
+ """Record a tool invocation asynchronously."""
11
+ store.record(tool=tool, repo=repo, context_tokens=context_tokens,
12
+ output_quality=output_quality, iterations=iterations, metadata=metadata)