arkaos 4.1.1 → 4.3.0
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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/arka/skills/flow/SKILL.md +15 -0
- package/arka/skills/fusion/SKILL.md +91 -0
- package/config/constitution.yaml +15 -7
- package/config/hooks/session-start.sh +22 -0
- package/config/models.yaml +70 -0
- package/core/fusion/__init__.py +5 -0
- package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/fusion/cli.py +37 -0
- package/core/fusion/engine.py +153 -0
- package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
- package/core/governance/redo_counter.py +81 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
- package/core/hooks/pre_tool_use.py +55 -3
- package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/openrouter_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/openrouter_provider.cpython-314.pyc +0 -0
- package/core/runtime/llm_provider.py +10 -1
- package/core/runtime/model_router.py +204 -0
- package/core/runtime/model_router_cli.py +151 -0
- package/core/runtime/ollama_discovery.py +96 -0
- package/core/runtime/openrouter_provider.py +172 -0
- package/core/sync/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/sync/__pycache__/manifest.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
- package/core/workflow/frontend_gate.py +162 -0
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/models.vue +404 -0
- package/installer/autostart.js +16 -2
- package/installer/cli.js +23 -0
- package/installer/keys.js +1 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +79 -0
- package/scripts/start-dashboard.sh +25 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""OpenRouter provider — one API key, hundreds of models.
|
|
2
|
+
|
|
3
|
+
Model Fabric PR-B. One of the backends behind the ``LLMProvider``
|
|
4
|
+
Protocol in ``core/runtime/llm_provider.py``; callers go through
|
|
5
|
+
``get_llm_provider()`` or pass an explicit model resolved by
|
|
6
|
+
``core/runtime/model_router.py`` (e.g. role ``review`` →
|
|
7
|
+
``openrouter/deepseek/deepseek-v4-pro``).
|
|
8
|
+
|
|
9
|
+
OpenAI-compatible ``/api/v1/chat/completions`` via stdlib ``urllib`` —
|
|
10
|
+
no extra dependencies, mirroring ``ollama_provider.py``. Key resolution:
|
|
11
|
+
``OPENROUTER_API_KEY`` env, then ``~/.arkaos/keys.json``. Model from
|
|
12
|
+
constructor, then ``OPENROUTER_MODEL`` env, then
|
|
13
|
+
``models.yaml`` alias ``openrouter.default``.
|
|
14
|
+
|
|
15
|
+
OpenRouter returns token usage (and native cached-token counts for
|
|
16
|
+
providers that support caching) in the ``usage`` block; both are
|
|
17
|
+
forwarded so cost telemetry stays accurate.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from core.runtime.llm_provider import LLMResponse, LLMUnavailable
|
|
29
|
+
|
|
30
|
+
_API_BASE = "https://openrouter.ai/api/v1"
|
|
31
|
+
_TIMEOUT_S = 180 # frontier models on long prompts; fusion panels wait
|
|
32
|
+
_KEYS_PATH = Path.home() / ".arkaos" / "keys.json"
|
|
33
|
+
|
|
34
|
+
# Attribution headers — OpenRouter ranks apps by these; harmless otherwise.
|
|
35
|
+
_APP_HEADERS = {
|
|
36
|
+
"HTTP-Referer": "https://github.com/andreagroferreira/arka-os",
|
|
37
|
+
"X-Title": "ArkaOS",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_key_from_keys_json() -> str:
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(_KEYS_PATH.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
return ""
|
|
46
|
+
value = data.get("OPENROUTER_API_KEY", "")
|
|
47
|
+
return value.strip() if isinstance(value, str) else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _default_model_from_fabric() -> str:
|
|
51
|
+
"""Resolve the `openrouter.default` alias from models.yaml, if set."""
|
|
52
|
+
try:
|
|
53
|
+
from core.runtime.model_router import load_config
|
|
54
|
+
config, _ = load_config()
|
|
55
|
+
except Exception:
|
|
56
|
+
return ""
|
|
57
|
+
return config.aliases.get("openrouter", {}).get("default", "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OpenRouterProvider:
|
|
61
|
+
"""Provider that hits the OpenRouter chat-completions API."""
|
|
62
|
+
|
|
63
|
+
ENV_KEY = "OPENROUTER_API_KEY"
|
|
64
|
+
ENV_MODEL = "OPENROUTER_MODEL"
|
|
65
|
+
|
|
66
|
+
def __init__(self, model: str | None = None, api_base: str | None = None) -> None:
|
|
67
|
+
self._model_override = model
|
|
68
|
+
self._api_base = (api_base or _API_BASE).rstrip("/")
|
|
69
|
+
|
|
70
|
+
def name(self) -> str:
|
|
71
|
+
return "openrouter"
|
|
72
|
+
|
|
73
|
+
def _resolve_key(self) -> str:
|
|
74
|
+
env_value = os.environ.get(self.ENV_KEY, "").strip()
|
|
75
|
+
return env_value or _read_key_from_keys_json()
|
|
76
|
+
|
|
77
|
+
def _resolve_model(self) -> str:
|
|
78
|
+
if self._model_override:
|
|
79
|
+
return self._model_override
|
|
80
|
+
env_value = os.environ.get(self.ENV_MODEL, "").strip()
|
|
81
|
+
return env_value or _default_model_from_fabric()
|
|
82
|
+
|
|
83
|
+
def is_available(self) -> bool:
|
|
84
|
+
"""Static capability check: a key and a model are configured.
|
|
85
|
+
|
|
86
|
+
No network probe — OpenRouter is a paid remote API and this is
|
|
87
|
+
called on every provider-chain walk; a dead network surfaces as
|
|
88
|
+
``LLMUnavailable`` at call time and the chain moves on.
|
|
89
|
+
"""
|
|
90
|
+
return bool(self._resolve_key() and self._resolve_model())
|
|
91
|
+
|
|
92
|
+
def complete(
|
|
93
|
+
self,
|
|
94
|
+
prompt: str,
|
|
95
|
+
*,
|
|
96
|
+
max_tokens: int = 2000,
|
|
97
|
+
system: str = "",
|
|
98
|
+
) -> LLMResponse:
|
|
99
|
+
key = self._resolve_key()
|
|
100
|
+
if not key:
|
|
101
|
+
raise LLMUnavailable(
|
|
102
|
+
"OpenRouter key not configured (set OPENROUTER_API_KEY or "
|
|
103
|
+
"add it via `npx arkaos keys`)"
|
|
104
|
+
)
|
|
105
|
+
model = self._resolve_model()
|
|
106
|
+
if not model:
|
|
107
|
+
raise LLMUnavailable(
|
|
108
|
+
"OpenRouter model not configured (set OPENROUTER_MODEL, pass "
|
|
109
|
+
"model=, or set aliases.openrouter.default in models.yaml)"
|
|
110
|
+
)
|
|
111
|
+
data = self._post_chat(key, self._build_payload(model, prompt, system, max_tokens))
|
|
112
|
+
return self._to_response(data, model)
|
|
113
|
+
|
|
114
|
+
def _build_payload(
|
|
115
|
+
self, model: str, prompt: str, system: str, max_tokens: int
|
|
116
|
+
) -> dict:
|
|
117
|
+
messages: list[dict] = []
|
|
118
|
+
if system:
|
|
119
|
+
messages.append({"role": "system", "content": system})
|
|
120
|
+
messages.append({"role": "user", "content": prompt})
|
|
121
|
+
return {
|
|
122
|
+
"model": model,
|
|
123
|
+
"messages": messages,
|
|
124
|
+
"max_tokens": max_tokens,
|
|
125
|
+
"stream": False,
|
|
126
|
+
"usage": {"include": True}, # cost + cached tokens in response
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def _post_chat(self, key: str, payload: dict) -> dict:
|
|
130
|
+
request = urllib.request.Request(
|
|
131
|
+
f"{self._api_base}/chat/completions",
|
|
132
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
133
|
+
headers={
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"Authorization": f"Bearer {key}",
|
|
136
|
+
**_APP_HEADERS,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT_S) as response:
|
|
141
|
+
raw = response.read().decode("utf-8")
|
|
142
|
+
except urllib.error.HTTPError as exc:
|
|
143
|
+
detail = exc.read().decode("utf-8", errors="replace")[:300]
|
|
144
|
+
raise LLMUnavailable(
|
|
145
|
+
f"OpenRouter HTTP {exc.code}: {detail}"
|
|
146
|
+
) from exc
|
|
147
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
148
|
+
raise LLMUnavailable(f"OpenRouter request failed: {exc}") from exc
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(raw)
|
|
151
|
+
except json.JSONDecodeError as exc:
|
|
152
|
+
raise LLMUnavailable(f"OpenRouter returned invalid JSON: {exc}") from exc
|
|
153
|
+
|
|
154
|
+
def _to_response(self, data: dict, model: str) -> LLMResponse:
|
|
155
|
+
if data.get("error"):
|
|
156
|
+
message = data["error"].get("message", "unknown error")
|
|
157
|
+
raise LLMUnavailable(f"OpenRouter error: {message}")
|
|
158
|
+
choices = data.get("choices") or []
|
|
159
|
+
text = ""
|
|
160
|
+
if choices:
|
|
161
|
+
text = ((choices[0].get("message") or {}).get("content") or "").strip()
|
|
162
|
+
usage = data.get("usage") or {}
|
|
163
|
+
cached = int(
|
|
164
|
+
(usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) or 0
|
|
165
|
+
)
|
|
166
|
+
return LLMResponse(
|
|
167
|
+
text=text,
|
|
168
|
+
tokens_in=int(usage.get("prompt_tokens", 0) or 0),
|
|
169
|
+
tokens_out=int(usage.get("completion_tokens", 0) or 0),
|
|
170
|
+
cached_tokens=cached,
|
|
171
|
+
model=str(data.get("model") or model),
|
|
172
|
+
)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Frontend excellence gate — PreToolUse enforcement for UI file edits.
|
|
2
|
+
|
|
3
|
+
Constitution: ``excellence-mandate`` (NON-NEGOTIABLE, Excellence Reform
|
|
4
|
+
2026-07-05). UI work must load the frontend design skills and judge
|
|
5
|
+
against a named benchmark BEFORE code is written. This gate makes that
|
|
6
|
+
duty mechanical: a Write/Edit/MultiEdit touching a UI file requires an
|
|
7
|
+
``[arka:design] <skills/benchmark>`` marker in the recent assistant
|
|
8
|
+
messages — the same ceremony contract as ``[arka:routing]``
|
|
9
|
+
(flow_enforcer) and ``[arka:dispatch]`` (specialist_enforcer).
|
|
10
|
+
|
|
11
|
+
Modes via ``hooks.frontendGate`` in ``~/.arkaos/config.json``:
|
|
12
|
+
|
|
13
|
+
absent / "warn" → nudge on stderr, allow (rollout default)
|
|
14
|
+
true / "hard" → deny UI edits without the marker
|
|
15
|
+
false / "off" → gate disabled
|
|
16
|
+
|
|
17
|
+
Bypasses: ``[arka:trivial] <reason>`` marker (same contract as the
|
|
18
|
+
evidence flow — single-file edits under 10 lines) and
|
|
19
|
+
``ARKA_BYPASS_DESIGN=1`` env (logged for accountability).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from core.shared import safe_session_id as _safe_session_id_module
|
|
32
|
+
from core.workflow.flow_enforcer import (
|
|
33
|
+
TRIVIAL_RE,
|
|
34
|
+
_load_last_assistant_messages,
|
|
35
|
+
)
|
|
36
|
+
from core.workflow.specialist_enforcer import _locked_append
|
|
37
|
+
|
|
38
|
+
CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
|
|
39
|
+
TELEMETRY_PATH = Path.home() / ".arkaos" / "telemetry" / "frontend-gate.jsonl"
|
|
40
|
+
|
|
41
|
+
ASSISTANT_WINDOW = 20
|
|
42
|
+
|
|
43
|
+
DESIGN_RE = re.compile(r"\[arka:design\]\s*\S+", re.IGNORECASE)
|
|
44
|
+
|
|
45
|
+
_GATED_TOOLS = frozenset({"Write", "Edit", "MultiEdit"})
|
|
46
|
+
|
|
47
|
+
# Component + stylesheet extensions. Plain .ts/.js are excluded: they are
|
|
48
|
+
# ambiguous (stores, utils, configs) and would flood the gate with false
|
|
49
|
+
# positives — the component file is where the visual decision lands.
|
|
50
|
+
UI_SUFFIXES = frozenset({
|
|
51
|
+
".vue", ".tsx", ".jsx", ".svelte", ".astro",
|
|
52
|
+
".css", ".scss", ".sass", ".less",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Decision:
|
|
58
|
+
"""Outcome of frontend-gate evaluation."""
|
|
59
|
+
|
|
60
|
+
allow: bool
|
|
61
|
+
reason: str
|
|
62
|
+
mode: str = "warn"
|
|
63
|
+
target_file: str = ""
|
|
64
|
+
marker_found: str | None = None
|
|
65
|
+
|
|
66
|
+
def to_stderr_message(self) -> str:
|
|
67
|
+
if self.reason not in ("no-design-marker",):
|
|
68
|
+
return ""
|
|
69
|
+
head = "[arka:suggest]" if self.allow else "[ARKA:DESIGN]"
|
|
70
|
+
verb = "should" if self.allow else "MUST"
|
|
71
|
+
return (
|
|
72
|
+
f"{head} UI edit to {self.target_file or 'this file'} without "
|
|
73
|
+
f"design evidence. Frontend work {verb} load the design skills "
|
|
74
|
+
f"(frontend-design, ui-ux-pro-max, project design system) at "
|
|
75
|
+
f"maximum effort and judge against a named benchmark FIRST "
|
|
76
|
+
f"(constitution `excellence-mandate`). Emit "
|
|
77
|
+
f"`[arka:design] <skills> benchmark=<name>` before UI edits, "
|
|
78
|
+
f"or `[arka:trivial] <reason>` for sub-10-line fixes."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _mode() -> str:
|
|
83
|
+
"""Resolve ``hooks.frontendGate`` to 'off' | 'warn' | 'hard'."""
|
|
84
|
+
if not CONFIG_PATH.exists():
|
|
85
|
+
return "warn"
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
88
|
+
except (json.JSONDecodeError, OSError):
|
|
89
|
+
return "warn"
|
|
90
|
+
raw = data.get("hooks", {}).get("frontendGate", "warn")
|
|
91
|
+
if raw in (False, "off", "false"):
|
|
92
|
+
return "off"
|
|
93
|
+
if raw in (True, "hard", "true"):
|
|
94
|
+
return "hard"
|
|
95
|
+
return "warn"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_ui_file(file_path: str) -> bool:
|
|
99
|
+
"""True when the path carries a gated UI extension."""
|
|
100
|
+
return Path(file_path).suffix.lower() in UI_SUFFIXES
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _scan(messages: list[str]) -> str | None:
|
|
104
|
+
"""Return the matched evidence marker, or None."""
|
|
105
|
+
for message in messages:
|
|
106
|
+
match = DESIGN_RE.search(message) or TRIVIAL_RE.search(message)
|
|
107
|
+
if match:
|
|
108
|
+
return match.group(0)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def evaluate(
|
|
113
|
+
tool_name: str,
|
|
114
|
+
transcript_path: str,
|
|
115
|
+
session_id: str,
|
|
116
|
+
cwd: str,
|
|
117
|
+
tool_input: dict,
|
|
118
|
+
messages: list[str] | None = None,
|
|
119
|
+
) -> Decision:
|
|
120
|
+
"""Evaluate one tool call against the frontend excellence gate."""
|
|
121
|
+
file_path = str(tool_input.get("file_path", ""))
|
|
122
|
+
if tool_name not in _GATED_TOOLS or not is_ui_file(file_path):
|
|
123
|
+
return Decision(allow=True, reason="not-ui-scope", target_file=file_path)
|
|
124
|
+
mode = _mode()
|
|
125
|
+
if mode == "off":
|
|
126
|
+
return Decision(allow=True, reason="flag-off", mode=mode,
|
|
127
|
+
target_file=file_path)
|
|
128
|
+
if os.environ.get("ARKA_BYPASS_DESIGN") == "1":
|
|
129
|
+
return Decision(allow=True, reason="env-bypass", mode=mode,
|
|
130
|
+
target_file=file_path)
|
|
131
|
+
if messages is None:
|
|
132
|
+
messages = _load_last_assistant_messages(
|
|
133
|
+
transcript_path, ASSISTANT_WINDOW
|
|
134
|
+
)
|
|
135
|
+
marker = _scan(messages)
|
|
136
|
+
if marker is not None:
|
|
137
|
+
return Decision(allow=True, reason="design-evidence", mode=mode,
|
|
138
|
+
target_file=file_path, marker_found=marker)
|
|
139
|
+
return Decision(allow=(mode != "hard"), reason="no-design-marker",
|
|
140
|
+
mode=mode, target_file=file_path)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def record_telemetry(session_id: str, tool: str, decision: Decision) -> None:
|
|
144
|
+
"""Append a structured record to the frontend-gate telemetry log.
|
|
145
|
+
|
|
146
|
+
Drops the record silently when session_id fails the safe-id check
|
|
147
|
+
(path-traversal mitigation, CWE-22). Never blocks the hook.
|
|
148
|
+
"""
|
|
149
|
+
safe = _safe_session_id_module.safe_session_id(session_id)
|
|
150
|
+
if safe is None:
|
|
151
|
+
return
|
|
152
|
+
entry = {
|
|
153
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
154
|
+
"session_id": safe,
|
|
155
|
+
"tool": tool,
|
|
156
|
+
**asdict(decision),
|
|
157
|
+
}
|
|
158
|
+
try:
|
|
159
|
+
with _locked_append(TELEMETRY_PATH) as fh:
|
|
160
|
+
fh.write(json.dumps(entry) + "\n")
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|