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.
- opencode_arch/__init__.py +3 -0
- opencode_arch/artifacts/__init__.py +48 -0
- opencode_arch/artifacts/context.py +451 -0
- opencode_arch/artifacts/diagrams.py +451 -0
- opencode_arch/artifacts/selector.py +331 -0
- opencode_arch/artifacts/templates.py +444 -0
- opencode_arch/cli/__init__.py +1 -0
- opencode_arch/cli/bench.py +25 -0
- opencode_arch/cli/calibrate.py +208 -0
- opencode_arch/cli/confidence.py +66 -0
- opencode_arch/cli/docs.py +333 -0
- opencode_arch/cli/docs_validator.py +295 -0
- opencode_arch/cli/export_data.py +133 -0
- opencode_arch/cli/extract.py +93 -0
- opencode_arch/cli/gap_analyzer.py +107 -0
- opencode_arch/cli/generate.py +68 -0
- opencode_arch/cli/launch.py +264 -0
- opencode_arch/cli/main.py +360 -0
- opencode_arch/cli/metrics.py +186 -0
- opencode_arch/cli/prompts.py +20 -0
- opencode_arch/cli/regen_loop.py +1028 -0
- opencode_arch/context/__init__.py +29 -0
- opencode_arch/context/formatter.py +492 -0
- opencode_arch/context/pipeline_bridge.py +201 -0
- opencode_arch/extract/__init__.py +8 -0
- opencode_arch/extract/constraint_detector.py +398 -0
- opencode_arch/extract/from_artifacts.py +837 -0
- opencode_arch/extract/from_code.py +646 -0
- opencode_arch/extract/route_detector.py +400 -0
- opencode_arch/extract/table_parser.py +177 -0
- opencode_arch/learning/__init__.py +19 -0
- opencode_arch/learning/adapter.py +157 -0
- opencode_arch/learning/assessor.py +170 -0
- opencode_arch/learning/classifier.py +144 -0
- opencode_arch/learning/lessons.py +139 -0
- opencode_arch/learning/maintainer.py +281 -0
- opencode_arch/learning/patterns.py +51 -0
- opencode_arch/mcp/__init__.py +1 -0
- opencode_arch/mcp/__main__.py +8 -0
- opencode_arch/mcp/server.py +183 -0
- opencode_arch/mcp/tools/__init__.py +1 -0
- opencode_arch/mcp/tools/check.py +159 -0
- opencode_arch/mcp/tools/extract.py +107 -0
- opencode_arch/mcp/tools/feedback.py +65 -0
- opencode_arch/mcp/tools/generate.py +104 -0
- opencode_arch/mcp/tools/group.py +62 -0
- opencode_arch/mcp/tools/ingest.py +101 -0
- opencode_arch/mcp/tools/require.py +77 -0
- opencode_arch/mcp/tools/scan.py +53 -0
- opencode_arch/mcp/tools/slice.py +235 -0
- opencode_arch/mcp/tools/validate.py +59 -0
- opencode_arch/prompts/__init__.py +1 -0
- opencode_arch/prompts/regen.py +36 -0
- opencode_arch/runner/__init__.py +5 -0
- opencode_arch/runner/base.py +21 -0
- opencode_arch/runner/opencode.py +66 -0
- opencode_arch/telemetry/__init__.py +6 -0
- opencode_arch/telemetry/collector.py +40 -0
- opencode_arch/telemetry/recorder.py +12 -0
- opencode_arch/telemetry/store.py +537 -0
- opencode_arch-1.0.0.dist-info/METADATA +247 -0
- opencode_arch-1.0.0.dist-info/RECORD +65 -0
- opencode_arch-1.0.0.dist-info/WHEEL +4 -0
- opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
- 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,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,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)
|