arkaos 4.3.0 → 4.3.1
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/VERSION +1 -1
- package/arka/skills/fusion/SKILL.md +10 -3
- package/config/hooks/session-start.sh +23 -0
- package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
- package/core/fusion/cli.py +57 -6
- package/core/fusion/panel_builder.py +58 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -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/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/post_tool_use.py +26 -12
- package/core/hooks/pre_tool_use.py +4 -0
- package/core/hooks/user_prompt_submit.py +7 -0
- package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
- package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
- package/core/runtime/model_router.py +17 -0
- package/core/runtime/model_routing_context.py +82 -0
- package/core/runtime/runtime_models.py +51 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
- package/core/workflow/flow_authorization.py +181 -0
- package/core/workflow/flow_enforcer.py +56 -11
- package/dashboard/app/pages/models.vue +23 -4
- package/installer/cli.js +21 -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 +13 -2
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.3.
|
|
1
|
+
4.3.1
|
|
@@ -75,11 +75,18 @@ Produce a table: role → provider/model + ONE-line rationale each. Rules:
|
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
77
|
npx arkaos models set <role> <provider>/<model> --effort <effort>
|
|
78
|
+
npx arkaos fusion --save "<a first question>" # persist a panel + run it
|
|
78
79
|
```
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
`npx arkaos fusion --show` prints the panel that would run (a sensible
|
|
82
|
+
default is built from the machine's models when none is configured);
|
|
83
|
+
`npx arkaos fusion --save "question"` writes the panel into
|
|
84
|
+
`~/.arkaos/models.yaml` and runs it; `npx arkaos fusion "question"` runs
|
|
85
|
+
without persisting. For fine-grained panel/judge edits, edit
|
|
86
|
+
`~/.arkaos/models.yaml` directly (keep comments). Show the final
|
|
87
|
+
`npx arkaos models` table as proof and remind: the dashboard Models page
|
|
88
|
+
shows the same state + live usage, and the SessionStart hook now injects
|
|
89
|
+
the routing so it governs agent dispatch.
|
|
83
90
|
|
|
84
91
|
## Hard rules
|
|
85
92
|
|
|
@@ -105,6 +105,29 @@ MSG+="\\nFields: kb=N (Obsidian/KB notes consulted), research=X (MCPs invoked: p
|
|
|
105
105
|
MSG+="\\nMandatory after: EFFECT tool calls, plan/recommendation outputs, QG verdicts. Optional for pure read-only status replies."
|
|
106
106
|
MSG+="\\nAbsence is measured by the Stop hook (warn-only in v2.34.0) before promotion to hard enforcement."
|
|
107
107
|
|
|
108
|
+
# ─── Model Fabric routing (consumption layer) ───────────────────────────
|
|
109
|
+
# Feed the operator's ~/.arkaos/models.yaml back to the orchestrator so
|
|
110
|
+
# dashboard/CLI model choices actually govern agent dispatch. Cheap
|
|
111
|
+
# (single python read); skipped silently if the config can't be resolved.
|
|
112
|
+
if [ -n "$REPO" ]; then
|
|
113
|
+
# Prefer the ArkaOS venv python (has pydantic + yaml); the ambient
|
|
114
|
+
# python3 usually lacks them and routing_directive() would return "".
|
|
115
|
+
_MF_PY="python3"
|
|
116
|
+
[ -x "$HOME/.arkaos/venv/bin/python3" ] && _MF_PY="$HOME/.arkaos/venv/bin/python3"
|
|
117
|
+
if command -v "$_MF_PY" &>/dev/null || [ -x "$_MF_PY" ]; then
|
|
118
|
+
_MF_DIRECTIVE=$(cd "$REPO" && PYTHONPATH="$REPO" "$_MF_PY" -c "
|
|
119
|
+
try:
|
|
120
|
+
from core.runtime.model_routing_context import routing_directive
|
|
121
|
+
print(routing_directive())
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
" 2>/dev/null)
|
|
125
|
+
if [ -n "$_MF_DIRECTIVE" ]; then
|
|
126
|
+
MSG+="\\n\\n${_MF_DIRECTIVE}"
|
|
127
|
+
fi
|
|
128
|
+
fi
|
|
129
|
+
fi
|
|
130
|
+
|
|
108
131
|
# ─── Stale-aware reorganizer trigger (PR24 v2.46.0) ─────────────────────
|
|
109
132
|
# If today's proposal file is missing, fire the reorganizer in the
|
|
110
133
|
# background with a 30s timeout. Best-effort, never blocks session
|
|
Binary file
|
|
Binary file
|
package/core/fusion/cli.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
"""Fusion CLI —
|
|
1
|
+
"""Fusion CLI — run the panel → judge → synthesis pipeline.
|
|
2
2
|
|
|
3
3
|
Usage::
|
|
4
4
|
|
|
5
5
|
python -m core.fusion.cli "your question"
|
|
6
|
+
python -m core.fusion.cli --save "your question" # persist the panel
|
|
7
|
+
python -m core.fusion.cli --show # show the panel only
|
|
8
|
+
|
|
9
|
+
When ``models.yaml`` has no ``fusion.panel``, a default is built from the
|
|
10
|
+
machine's models (runtime + local Ollama) so fusion works out of the box.
|
|
11
|
+
``--save`` writes that panel into ``~/.arkaos/models.yaml`` so every
|
|
12
|
+
fusion consumer (and the dashboard) sees it.
|
|
6
13
|
"""
|
|
7
14
|
|
|
8
15
|
from __future__ import annotations
|
|
@@ -10,16 +17,60 @@ from __future__ import annotations
|
|
|
10
17
|
import sys
|
|
11
18
|
|
|
12
19
|
from core.fusion.engine import FusionUnavailable, fuse
|
|
20
|
+
from core.fusion.panel_builder import default_panel, describe_panel
|
|
21
|
+
from core.runtime.model_router import load_config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _save_panel(panel, judge) -> str:
|
|
25
|
+
import yaml
|
|
26
|
+
from core.runtime.model_router import USER_CONFIG_PATH, ensure_user_config
|
|
27
|
+
path = ensure_user_config()
|
|
28
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
29
|
+
data.setdefault("fusion", {})
|
|
30
|
+
data["fusion"]["judge"] = judge.model_dump()
|
|
31
|
+
data["fusion"]["panel"] = [c.model_dump() for c in panel]
|
|
32
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
|
33
|
+
return str(USER_CONFIG_PATH)
|
|
13
34
|
|
|
14
35
|
|
|
15
36
|
def main(argv: list[str] | None = None) -> int:
|
|
16
|
-
args = argv if argv is not None else sys.argv[1:]
|
|
17
|
-
|
|
18
|
-
|
|
37
|
+
args = list(argv if argv is not None else sys.argv[1:])
|
|
38
|
+
save = "--save" in args
|
|
39
|
+
show = "--show" in args
|
|
40
|
+
args = [a for a in args if a not in ("--save", "--show")]
|
|
41
|
+
|
|
42
|
+
config, _ = load_config()
|
|
43
|
+
panel, judge = default_panel(config)
|
|
44
|
+
|
|
45
|
+
if show:
|
|
46
|
+
print(f"\n Fusion panel — {describe_panel(panel, judge)}")
|
|
47
|
+
if not config.fusion.panel:
|
|
48
|
+
print(" (default built from the machine; run --save to persist)")
|
|
49
|
+
print()
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
if len(panel) < 2:
|
|
53
|
+
print(" ✗ No panel available — configure fusion.panel in "
|
|
54
|
+
"models.yaml (try /arka-fusion) or start Ollama with 2+ "
|
|
55
|
+
"models for a local panel.", file=sys.stderr)
|
|
19
56
|
return 1
|
|
20
|
-
|
|
57
|
+
|
|
58
|
+
if save:
|
|
59
|
+
where = _save_panel(panel, judge)
|
|
60
|
+
print(f" ✓ Fusion panel saved to {where}")
|
|
61
|
+
config, _ = load_config() # reload so fuse() uses the saved panel
|
|
62
|
+
|
|
63
|
+
prompt = " ".join(args).strip()
|
|
64
|
+
if not prompt:
|
|
65
|
+
print("usage: python -m core.fusion.cli [--save] \"question\"",
|
|
66
|
+
file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
# Build an effective config carrying the (possibly default) panel.
|
|
70
|
+
config.fusion.panel = panel
|
|
71
|
+
config.fusion.judge = judge
|
|
21
72
|
try:
|
|
22
|
-
result = fuse(prompt)
|
|
73
|
+
result = fuse(prompt, config=config)
|
|
23
74
|
except FusionUnavailable as exc:
|
|
24
75
|
print(f" ✗ {exc}", file=sys.stderr)
|
|
25
76
|
return 1
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Build a fusion panel from what the machine can actually run.
|
|
2
|
+
|
|
3
|
+
Fusion needs a diverse panel + a judge. The user's ``models.yaml`` may
|
|
4
|
+
have an explicit ``fusion.panel`` (configured via ``/arka-fusion``); when
|
|
5
|
+
it is empty, this builds a sensible default from discovered models so
|
|
6
|
+
``npx arkaos fusion "question"`` does something out of the box instead of
|
|
7
|
+
failing with FusionUnavailable.
|
|
8
|
+
|
|
9
|
+
Diversity is the point (OpenRouter's result: a diverse panel beats a
|
|
10
|
+
single model): pair the runtime model with up to two large, distinct
|
|
11
|
+
local Ollama models. The judge stays the configured frontier model.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from core.runtime.model_router import ModelsConfig, RoleChoice, load_config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_panel(
|
|
20
|
+
config: ModelsConfig | None = None,
|
|
21
|
+
) -> tuple[list[RoleChoice], RoleChoice]:
|
|
22
|
+
"""Return (panel, judge). Respects an explicit panel; else builds one.
|
|
23
|
+
|
|
24
|
+
An empty return panel means the machine offers nothing to fuse (no
|
|
25
|
+
local models and no explicit config) — the caller reports that rather
|
|
26
|
+
than fanning out to a single seat.
|
|
27
|
+
"""
|
|
28
|
+
if config is None:
|
|
29
|
+
config, _ = load_config()
|
|
30
|
+
judge = config.fusion.judge
|
|
31
|
+
if config.fusion.panel:
|
|
32
|
+
return list(config.fusion.panel), judge
|
|
33
|
+
|
|
34
|
+
panel: list[RoleChoice] = [
|
|
35
|
+
RoleChoice(provider="runtime", model="default", effort="high"),
|
|
36
|
+
]
|
|
37
|
+
try:
|
|
38
|
+
from core.runtime.ollama_discovery import discover
|
|
39
|
+
status = discover()
|
|
40
|
+
except Exception:
|
|
41
|
+
status = None
|
|
42
|
+
if status and status.running:
|
|
43
|
+
# Panel-grade locals: >= 4GB, or cloud-proxied (size 0). Sort by
|
|
44
|
+
# size desc so the strongest come first; take two distinct ones.
|
|
45
|
+
candidates = sorted(
|
|
46
|
+
(m for m in status.models if m.size_gb >= 4 or m.size_gb == 0),
|
|
47
|
+
key=lambda m: m.size_gb,
|
|
48
|
+
reverse=True,
|
|
49
|
+
)
|
|
50
|
+
for m in candidates[:2]:
|
|
51
|
+
panel.append(RoleChoice(provider="ollama", model=m.name, effort="high"))
|
|
52
|
+
return panel, judge
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def describe_panel(panel: list[RoleChoice], judge: RoleChoice) -> str:
|
|
56
|
+
"""Human summary of a panel for CLI output."""
|
|
57
|
+
seats = ", ".join(f"{c.provider}/{c.model}" for c in panel)
|
|
58
|
+
return f"judge {judge.provider}/{judge.model} | panel: {seats}"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -111,19 +111,29 @@ def _unlocked(fh) -> None:
|
|
|
111
111
|
# ─── Section 1: flow marker cache ────────────────────────────────────────
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
def
|
|
115
|
-
|
|
114
|
+
def _confirm_flow_authorization(session_id: str, transcript_path: str) -> None:
|
|
115
|
+
"""Confirm persistent flow authorization from the transcript.
|
|
116
|
+
|
|
117
|
+
Replaces the dead ``_write_flow_marker`` that read a non-existent
|
|
118
|
+
``assistant_message`` payload field. Reads the same transcript the
|
|
119
|
+
enforcer trusts and, when a flow marker is present, writes a confirmed
|
|
120
|
+
authorization that survives compaction and the 20-message window.
|
|
121
|
+
"""
|
|
122
|
+
if not session_id:
|
|
116
123
|
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
try:
|
|
125
|
+
from core.workflow.flow_authorization import confirm
|
|
126
|
+
from core.workflow.flow_enforcer import (
|
|
127
|
+
_load_last_assistant_messages,
|
|
128
|
+
_scan_markers,
|
|
129
|
+
)
|
|
130
|
+
except Exception:
|
|
123
131
|
return
|
|
124
132
|
try:
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
messages = _load_last_assistant_messages(transcript_path, 20)
|
|
134
|
+
marker_found, _ = _scan_markers(messages)
|
|
135
|
+
if marker_found is not None:
|
|
136
|
+
confirm(session_id, marker_found)
|
|
127
137
|
except Exception:
|
|
128
138
|
pass
|
|
129
139
|
|
|
@@ -614,9 +624,13 @@ def main(stdin_json: dict | None = None) -> int:
|
|
|
614
624
|
exit_code = get_str(stdin_json, "exit_code") or "0"
|
|
615
625
|
cwd = get_str(stdin_json, "cwd")
|
|
616
626
|
session_id = get_str(stdin_json, "session_id")
|
|
617
|
-
|
|
627
|
+
transcript_path = get_str(stdin_json, "transcript_path")
|
|
618
628
|
|
|
619
|
-
|
|
629
|
+
# Claude Code sends no `assistant_message` field (confirmed against the
|
|
630
|
+
# hook docs, 2026-07-05) — the old read was dead and the marker cache
|
|
631
|
+
# was never written. Scan the transcript (the only source hooks have)
|
|
632
|
+
# and confirm persistent authorization when a marker is present.
|
|
633
|
+
_confirm_flow_authorization(session_id, transcript_path)
|
|
620
634
|
|
|
621
635
|
if tool_name in ("Task", "Agent"):
|
|
622
636
|
subagent_type = get_str(stdin_json, "tool_input", "subagent_type")
|
|
@@ -260,6 +260,10 @@ def _flow_gate(
|
|
|
260
260
|
except Exception:
|
|
261
261
|
pass
|
|
262
262
|
if decision.allow:
|
|
263
|
+
# Grace path: allow, but surface the non-blocking warning so the
|
|
264
|
+
# operator sees the routing nudge instead of a silent pass.
|
|
265
|
+
if decision.warning:
|
|
266
|
+
print(decision.warning, file=sys.stderr)
|
|
263
267
|
return 0
|
|
264
268
|
return _deny(decision.to_stderr_message())
|
|
265
269
|
|
|
@@ -171,6 +171,13 @@ def _invalidate_turn_caches(session_id: str) -> None:
|
|
|
171
171
|
invalidate_marker(session_id)
|
|
172
172
|
except Exception:
|
|
173
173
|
pass
|
|
174
|
+
try:
|
|
175
|
+
# Reset only the per-turn grace flag; confirmed authorization and
|
|
176
|
+
# the grace counter persist across turns (enforcer resilience).
|
|
177
|
+
from core.workflow.flow_authorization import reset_turn
|
|
178
|
+
reset_turn(session_id)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
174
181
|
try:
|
|
175
182
|
from core.synapse.kb_cache import invalidate_obsidian_query
|
|
176
183
|
invalidate_obsidian_query(session_id)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -33,6 +33,23 @@ USER_CONFIG_PATH = Path.home() / ".arkaos" / "models.yaml"
|
|
|
33
33
|
QUALITY_ROLES = frozenset({
|
|
34
34
|
"design", "review", "architecture", "strategy", "quality_gate",
|
|
35
35
|
})
|
|
36
|
+
|
|
37
|
+
# Plain-language explanation of each role, surfaced in the dashboard and
|
|
38
|
+
# `npx arkaos models` so the operator knows what "execution" etc. drive.
|
|
39
|
+
ROLE_DESCRIPTIONS: dict[str, str] = {
|
|
40
|
+
"design": "UI/UX and visual design — layout, typography, aesthetics.",
|
|
41
|
+
"review": "Code review, Quality Gate reviewers, adversarial verification.",
|
|
42
|
+
"architecture": "System design, API contracts, ADRs, technical decisions.",
|
|
43
|
+
"strategy": "Business and product strategy, market analysis, planning.",
|
|
44
|
+
"quality_gate": "The mandatory Quality Gate (Marta, Eduardo, Francisca).",
|
|
45
|
+
"execution": "Implementation — specialists writing code and running build tasks.",
|
|
46
|
+
"mechanical": "Rote work — commit messages, changelog, formatting, data fetch.",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def role_description(role: str) -> str:
|
|
51
|
+
"""Human-readable description of a role, or empty if unknown."""
|
|
52
|
+
return ROLE_DESCRIPTIONS.get(role, "")
|
|
36
53
|
KNOWN_EFFORTS = ("low", "medium", "high", "max")
|
|
37
54
|
|
|
38
55
|
_ALIAS_NAMES = frozenset({"best", "default", "fast"})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Model Fabric → orchestrator bridge (the consumption layer).
|
|
2
|
+
|
|
3
|
+
The dashboard and ``npx arkaos models`` write ``~/.arkaos/models.yaml``.
|
|
4
|
+
Until now nothing fed that config back to the Claude Code orchestrator,
|
|
5
|
+
so changing a role's model changed nothing at dispatch time. This module
|
|
6
|
+
turns the config into a compact directive the SessionStart hook injects,
|
|
7
|
+
so the orchestrator resolves an agent's model from the user's config
|
|
8
|
+
instead of only the agent YAML default.
|
|
9
|
+
|
|
10
|
+
It is context injection — the same control surface ArkaOS uses for the
|
|
11
|
+
constitution, routing, and evidence flow — not a hard rewrite of the Task
|
|
12
|
+
`model` param (Claude Code exposes no hook for that). The PostToolUse
|
|
13
|
+
telemetry already records the model actually requested per dispatch, so
|
|
14
|
+
mismatches are observable (see model_routing_check).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
# Which Model Fabric role governs each kind of agent work. Agents are a
|
|
20
|
+
# different taxonomy from roles, so we map by what the agent DOES. The
|
|
21
|
+
# orchestrator uses this to pick the role, then resolves the model.
|
|
22
|
+
AGENT_ROLE_HINTS: dict[str, str] = {
|
|
23
|
+
# quality-critical
|
|
24
|
+
"architect": "architecture",
|
|
25
|
+
"cto": "architecture",
|
|
26
|
+
"strategist": "strategy",
|
|
27
|
+
"brand-strategist": "strategy",
|
|
28
|
+
"creative-director": "design",
|
|
29
|
+
"visual-designer": "design",
|
|
30
|
+
"frontend-dev": "design",
|
|
31
|
+
"marta-cqo": "quality_gate",
|
|
32
|
+
"eduardo-copy": "quality_gate",
|
|
33
|
+
"francisca-tech": "quality_gate",
|
|
34
|
+
"code-reviewer": "review",
|
|
35
|
+
"security": "review",
|
|
36
|
+
"qa": "review",
|
|
37
|
+
# execution
|
|
38
|
+
"senior-dev": "execution",
|
|
39
|
+
"paulo-tech-lead": "execution",
|
|
40
|
+
"devops": "execution",
|
|
41
|
+
# mechanical
|
|
42
|
+
"analyst": "mechanical",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def routing_summary() -> str:
|
|
47
|
+
"""One-line role→provider/model@effort summary, or '' if unavailable."""
|
|
48
|
+
try:
|
|
49
|
+
from core.runtime.model_router import resolve_all
|
|
50
|
+
items = resolve_all()
|
|
51
|
+
except Exception:
|
|
52
|
+
return ""
|
|
53
|
+
parts = [
|
|
54
|
+
f"{i.role}={i.provider}/{i.model or '?'}@{i.effort}" for i in items
|
|
55
|
+
]
|
|
56
|
+
return " ".join(parts)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def routing_directive() -> str:
|
|
60
|
+
"""Full SessionStart block: the config plus how to honor it.
|
|
61
|
+
|
|
62
|
+
Returns '' when the config cannot be read, so the hook can skip the
|
|
63
|
+
block entirely rather than inject an empty directive.
|
|
64
|
+
"""
|
|
65
|
+
summary = routing_summary()
|
|
66
|
+
if not summary:
|
|
67
|
+
return ""
|
|
68
|
+
# No backticks: this string is embedded in session-start.sh which
|
|
69
|
+
# passes it through $(echo -e "$MSG") — backticks would trigger shell
|
|
70
|
+
# command substitution. Use plain quotes.
|
|
71
|
+
return (
|
|
72
|
+
"[ARKA:MODEL-FABRIC] The operator's model routing (from "
|
|
73
|
+
"~/.arkaos/models.yaml — dashboard / npx arkaos models):\n"
|
|
74
|
+
f" {summary}\n"
|
|
75
|
+
"When dispatching a subagent via the Task tool, set its model "
|
|
76
|
+
"param from this table by the KIND of work it does (design, "
|
|
77
|
+
"review, architecture, strategy, quality_gate, execution, "
|
|
78
|
+
"mechanical) — this is the user's explicit choice and OVERRIDES "
|
|
79
|
+
"the agent YAML default. Quality roles never downgrade below the "
|
|
80
|
+
"configured model (excellence-mandate). Genuinely mechanical "
|
|
81
|
+
"dispatches (commit messages, formatting) use the mechanical role."
|
|
82
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Known models per runtime, for the Model Fabric UI + advisor.
|
|
2
|
+
|
|
3
|
+
The Model Fabric ``runtime`` provider shells out to the active CLI. This
|
|
4
|
+
module lists the concrete models that CLI accepts, with human labels and
|
|
5
|
+
tier, so the dashboard dropdown and ``/arka-fusion`` can offer real
|
|
6
|
+
choices (Fable 5, Opus, Sonnet, Haiku) instead of only the abstract
|
|
7
|
+
best/default/fast aliases.
|
|
8
|
+
|
|
9
|
+
Model IDs verified against the Claude API reference (2026-07). This is
|
|
10
|
+
the single place they are hand-listed — update HERE when the runtime
|
|
11
|
+
ships new models; the dashboard and CLI read from this module. Codex /
|
|
12
|
+
Gemini / Cursor models are left empty on purpose: we do not hardcode
|
|
13
|
+
another vendor's catalogue, and the UI falls back to a free-text field
|
|
14
|
+
so the user types the exact id their runtime accepts.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Claude Code accepts the short aliases opus/sonnet/haiku and full model
|
|
21
|
+
# IDs. `value` is what gets written to models.yaml as the role's model.
|
|
22
|
+
CLAUDE_CODE_MODELS: list[dict[str, str]] = [
|
|
23
|
+
{"value": "claude-fable-5", "label": "Fable 5", "tier": "frontier",
|
|
24
|
+
"note": "most capable"},
|
|
25
|
+
{"value": "opus", "label": "Opus 4.8", "tier": "frontier",
|
|
26
|
+
"note": "frontier"},
|
|
27
|
+
{"value": "sonnet", "label": "Sonnet 5", "tier": "balanced",
|
|
28
|
+
"note": "balanced speed/quality"},
|
|
29
|
+
{"value": "haiku", "label": "Haiku 4.5", "tier": "fast",
|
|
30
|
+
"note": "fast + cheap"},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_MODELS_BY_RUNTIME: dict[str, list[dict[str, str]]] = {
|
|
34
|
+
"claude-code": CLAUDE_CODE_MODELS,
|
|
35
|
+
# codex / gemini / cursor intentionally omitted — free-text in the UI.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def models_for_runtime(runtime_id: str) -> list[dict[str, str]]:
|
|
40
|
+
"""Return the known runtime models for the given runtime id."""
|
|
41
|
+
return _MODELS_BY_RUNTIME.get(runtime_id, [])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_runtime_models() -> tuple[str, list[dict[str, str]]]:
|
|
45
|
+
"""Best-effort (runtime_id, models) for the active runtime."""
|
|
46
|
+
try:
|
|
47
|
+
from core.runtime.registry import detect_runtime
|
|
48
|
+
runtime_id = detect_runtime()
|
|
49
|
+
except Exception:
|
|
50
|
+
runtime_id = "claude-code"
|
|
51
|
+
return runtime_id, models_for_runtime(runtime_id)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Persistent per-session flow authorization — enforcer resilience.
|
|
2
|
+
|
|
3
|
+
Claude Code exposes NO hook access to the current assistant message
|
|
4
|
+
(confirmed against the hook docs, 2026-07-05: neither PreToolUse nor
|
|
5
|
+
PostToolUse carries the message text, and the transcript at
|
|
6
|
+
``transcript_path`` does not yet include the in-progress turn). So the
|
|
7
|
+
current turn's ``[arka:routing]`` marker is structurally invisible to
|
|
8
|
+
that turn's own tool calls, and the old PostToolUse marker cache read a
|
|
9
|
+
non-existent ``assistant_message`` field — it was never written, leaving
|
|
10
|
+
the enforcer with only a 20-message transcript window that a long
|
|
11
|
+
session or a context compaction empties (652 false-positive blocks
|
|
12
|
+
observed in one session).
|
|
13
|
+
|
|
14
|
+
This module rebuilds resilience on what hooks CAN see: markers observed
|
|
15
|
+
in the transcript (past turns) plus persistent ``/tmp`` state that
|
|
16
|
+
survives compaction and window-rolling.
|
|
17
|
+
|
|
18
|
+
Two tiers:
|
|
19
|
+
|
|
20
|
+
- **CONFIRMED** — written whenever a flow marker is actually observed in
|
|
21
|
+
the transcript. Valid for a session TTL, survives compaction. Grants
|
|
22
|
+
a silent allow. This is the normal steady state: route once, then
|
|
23
|
+
every subsequent turn is authorised.
|
|
24
|
+
- **TURN GRACE** — the current turn's marker cannot be seen, so the very
|
|
25
|
+
first turn of a session (before any marker reaches the transcript) has
|
|
26
|
+
no confirmed auth. Rather than a false-positive block, the turn is
|
|
27
|
+
allowed WITH A WARNING and a per-turn grace flag so the rest of the
|
|
28
|
+
turn proceeds. A never-routing session is warned every turn and, after
|
|
29
|
+
``GRACE_CAP`` consecutive graced turns with no confirmation, escalates
|
|
30
|
+
to a hard block — a normally-routing session confirms by turn 2 and
|
|
31
|
+
never reaches the cap.
|
|
32
|
+
|
|
33
|
+
State dir: ``/tmp/arkaos-flow-auth`` (override via ``ARKA_FLOW_AUTH_DIR``).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import time
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from core.shared import safe_session_id as _safe_session_id_module
|
|
45
|
+
|
|
46
|
+
_safe_session_id = _safe_session_id_module.safe_session_id
|
|
47
|
+
|
|
48
|
+
# A working session; after this a stale /tmp record expires on its own.
|
|
49
|
+
DEFAULT_TTL_SECONDS = 12 * 60 * 60
|
|
50
|
+
# Consecutive graced turns with no confirmation before a hard block.
|
|
51
|
+
GRACE_CAP = 3
|
|
52
|
+
|
|
53
|
+
VALID_MARKER_TYPES: frozenset[str] = frozenset(
|
|
54
|
+
{"routing", "trivial", "gate", "phase"}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class GraceState:
|
|
60
|
+
count: int
|
|
61
|
+
escalate: bool
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _base_dir() -> Path:
|
|
65
|
+
override = os.environ.get("ARKA_FLOW_AUTH_DIR", "").strip()
|
|
66
|
+
return Path(override) if override else Path("/tmp/arkaos-flow-auth")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _auth_path(session_id: str) -> Path | None:
|
|
70
|
+
safe = _safe_session_id(session_id)
|
|
71
|
+
return (_base_dir() / f"{safe}.json") if safe else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read(path: Path | None) -> dict:
|
|
75
|
+
if path is None or not path.exists():
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except (json.JSONDecodeError, OSError):
|
|
80
|
+
return {}
|
|
81
|
+
return data if isinstance(data, dict) else {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _write(path: Path | None, data: dict) -> None:
|
|
85
|
+
if path is None:
|
|
86
|
+
return
|
|
87
|
+
try:
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
pass # authorization state must never block the hook
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def confirm(session_id: str, marker_type: str) -> None:
|
|
95
|
+
"""Persist a confirmed authorization (a marker was observed)."""
|
|
96
|
+
if marker_type not in VALID_MARKER_TYPES:
|
|
97
|
+
return
|
|
98
|
+
path = _auth_path(session_id)
|
|
99
|
+
if path is None:
|
|
100
|
+
return
|
|
101
|
+
_write(path, {
|
|
102
|
+
"marker_type": marker_type,
|
|
103
|
+
"confirmed_ts": time.time(),
|
|
104
|
+
"grace_count": 0, # confirmation clears accumulated grace
|
|
105
|
+
"turn_grace": False,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def confirmed_record(
|
|
110
|
+
session_id: str, ttl_seconds: int = DEFAULT_TTL_SECONDS
|
|
111
|
+
) -> dict | None:
|
|
112
|
+
"""Return the confirmed record if present and within TTL, else None."""
|
|
113
|
+
data = _read(_auth_path(session_id))
|
|
114
|
+
ts = data.get("confirmed_ts")
|
|
115
|
+
if ts is None:
|
|
116
|
+
return None
|
|
117
|
+
if time.time() - float(ts) > ttl_seconds:
|
|
118
|
+
return None
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_confirmed(
|
|
123
|
+
session_id: str, ttl_seconds: int = DEFAULT_TTL_SECONDS
|
|
124
|
+
) -> bool:
|
|
125
|
+
return confirmed_record(session_id, ttl_seconds) is not None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def grant_turn_grace(session_id: str) -> None:
|
|
129
|
+
"""Flag the current turn as graced so its remaining tools pass."""
|
|
130
|
+
path = _auth_path(session_id)
|
|
131
|
+
if path is None:
|
|
132
|
+
return
|
|
133
|
+
data = _read(path)
|
|
134
|
+
data["turn_grace"] = True
|
|
135
|
+
_write(path, data)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def has_turn_grace(session_id: str) -> bool:
|
|
139
|
+
return bool(_read(_auth_path(session_id)).get("turn_grace"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def reset_turn(session_id: str) -> None:
|
|
143
|
+
"""Clear the per-turn grace flag (called at each new user prompt).
|
|
144
|
+
|
|
145
|
+
Confirmed authorization and the grace counter are preserved — only
|
|
146
|
+
the within-turn flag resets, so the next turn re-evaluates.
|
|
147
|
+
"""
|
|
148
|
+
path = _auth_path(session_id)
|
|
149
|
+
if path is None:
|
|
150
|
+
return
|
|
151
|
+
data = _read(path)
|
|
152
|
+
if data.get("turn_grace"):
|
|
153
|
+
data["turn_grace"] = False
|
|
154
|
+
_write(path, data)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def grace_count(session_id: str) -> int:
|
|
158
|
+
return int(_read(_auth_path(session_id)).get("grace_count", 0) or 0)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def register_grace(session_id: str) -> GraceState:
|
|
162
|
+
"""Increment the consecutive-grace counter; escalate past the cap."""
|
|
163
|
+
path = _auth_path(session_id)
|
|
164
|
+
if path is None:
|
|
165
|
+
return GraceState(count=0, escalate=False)
|
|
166
|
+
data = _read(path)
|
|
167
|
+
count = int(data.get("grace_count", 0) or 0) + 1
|
|
168
|
+
data["grace_count"] = count
|
|
169
|
+
_write(path, data)
|
|
170
|
+
return GraceState(count=count, escalate=count > GRACE_CAP)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def clear(session_id: str) -> None:
|
|
174
|
+
"""Remove all authorization state for a session (tests / session end)."""
|
|
175
|
+
path = _auth_path(session_id)
|
|
176
|
+
if path is None:
|
|
177
|
+
return
|
|
178
|
+
try:
|
|
179
|
+
path.unlink()
|
|
180
|
+
except (FileNotFoundError, OSError):
|
|
181
|
+
pass
|
|
@@ -25,7 +25,7 @@ from datetime import datetime, timezone
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
27
|
from core.shared import safe_session_id as _safe_session_id_module
|
|
28
|
-
from core.workflow import marker_cache
|
|
28
|
+
from core.workflow import flow_authorization, marker_cache
|
|
29
29
|
|
|
30
30
|
try:
|
|
31
31
|
import fcntl # POSIX only
|
|
@@ -222,15 +222,24 @@ class Decision:
|
|
|
222
222
|
marker_found: str | None = None
|
|
223
223
|
phase_observed: str | None = None
|
|
224
224
|
bypass_used: bool = False
|
|
225
|
+
warning: str = ""
|
|
225
226
|
|
|
226
227
|
def to_stderr_message(self) -> str:
|
|
227
228
|
if self.allow:
|
|
228
|
-
return
|
|
229
|
+
return self.warning
|
|
230
|
+
# Recovery that actually works, given Claude Code exposes no
|
|
231
|
+
# current-message text to hooks (the inline `ARKA_BYPASS_FLOW=1`
|
|
232
|
+
# never reached this separate hook process — a documented trap).
|
|
229
233
|
return (
|
|
230
|
-
f"[ARKA:ENFORCEMENT]
|
|
231
|
-
f"
|
|
232
|
-
f"
|
|
233
|
-
f"
|
|
234
|
+
f"[ARKA:ENFORCEMENT] No routing marker for "
|
|
235
|
+
f"{flow_authorization.GRACE_CAP}+ turns. Emit "
|
|
236
|
+
f"`[arka:routing] <dept> -> <lead>` (or `[arka:trivial] "
|
|
237
|
+
f"<reason>`) at the START of your reply — it authorises this "
|
|
238
|
+
f"turn and every turn after. To turn enforcement off set "
|
|
239
|
+
f"hooks.hardEnforcement=false in ~/.arkaos/config.json; to "
|
|
240
|
+
f"bypass programmatically export ARKA_BYPASS_FLOW=1 in Claude "
|
|
241
|
+
f"Code's own environment (settings.json env), not inline on a "
|
|
242
|
+
f"command. Reason: {self.reason}."
|
|
234
243
|
)
|
|
235
244
|
|
|
236
245
|
|
|
@@ -414,18 +423,54 @@ def evaluate(
|
|
|
414
423
|
)
|
|
415
424
|
marker_found, phase_observed = _scan_markers(messages)
|
|
416
425
|
|
|
417
|
-
if marker_found is None:
|
|
426
|
+
if marker_found is not None:
|
|
427
|
+
# Persist a confirmed authorization so this survives compaction and
|
|
428
|
+
# the 20-message window rolling — the fix for the 652 false blocks.
|
|
429
|
+
flow_authorization.confirm(session_id, marker_found)
|
|
418
430
|
return Decision(
|
|
419
|
-
allow=
|
|
420
|
-
reason=f"
|
|
431
|
+
allow=True,
|
|
432
|
+
reason=f"marker-found:{marker_found}",
|
|
433
|
+
marker_found=marker_found,
|
|
421
434
|
phase_observed=phase_observed,
|
|
422
435
|
)
|
|
423
436
|
|
|
437
|
+
# No marker in the transcript window. The current turn's marker is
|
|
438
|
+
# structurally invisible to hooks, so fall back to persistent auth
|
|
439
|
+
# before ever blocking.
|
|
440
|
+
if flow_authorization.is_confirmed(session_id):
|
|
441
|
+
return Decision(
|
|
442
|
+
allow=True, reason="session-authorized", phase_observed=phase_observed
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Same turn, already graced: let the rest of the turn's tools through.
|
|
446
|
+
if flow_authorization.has_turn_grace(session_id):
|
|
447
|
+
return Decision(
|
|
448
|
+
allow=True, reason="turn-grace", phase_observed=phase_observed
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# First effect-tool of a turn with no confirmed auth. A hard deny here
|
|
452
|
+
# is a false positive (the assistant may have routed in this very
|
|
453
|
+
# message — invisible to us). Grace it with a warning; escalate to a
|
|
454
|
+
# real block only after GRACE_CAP consecutive graced turns without any
|
|
455
|
+
# confirmation (a normally-routing session confirms by turn 2).
|
|
456
|
+
grace = flow_authorization.register_grace(session_id)
|
|
457
|
+
if grace.escalate:
|
|
458
|
+
return Decision(
|
|
459
|
+
allow=False,
|
|
460
|
+
reason=f"no-marker-and-grace-exhausted-after-{grace.count}-turns",
|
|
461
|
+
phase_observed=phase_observed,
|
|
462
|
+
)
|
|
463
|
+
flow_authorization.grant_turn_grace(session_id)
|
|
424
464
|
return Decision(
|
|
425
465
|
allow=True,
|
|
426
|
-
reason=f"
|
|
427
|
-
marker_found=marker_found,
|
|
466
|
+
reason=f"first-tool-grace:{grace.count}",
|
|
428
467
|
phase_observed=phase_observed,
|
|
468
|
+
warning=(
|
|
469
|
+
f"[arka:suggest] Effect tool allowed without an observable "
|
|
470
|
+
f"routing marker (grace {grace.count}/{flow_authorization.GRACE_CAP}). "
|
|
471
|
+
f"Emit `[arka:routing] <dept> -> <lead>` at the start of your "
|
|
472
|
+
f"reply to authorise this session."
|
|
473
|
+
),
|
|
429
474
|
)
|
|
430
475
|
|
|
431
476
|
|
|
@@ -8,6 +8,14 @@ interface ResolvedRole {
|
|
|
8
8
|
model: string
|
|
9
9
|
effort: string
|
|
10
10
|
source: string
|
|
11
|
+
description?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RuntimeModel {
|
|
15
|
+
value: string
|
|
16
|
+
label: string
|
|
17
|
+
tier: string
|
|
18
|
+
note: string
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
interface OllamaModel {
|
|
@@ -24,6 +32,7 @@ interface ModelsOverview {
|
|
|
24
32
|
providers: Record<string, { type: string }>
|
|
25
33
|
aliases: Record<string, Record<string, string>>
|
|
26
34
|
fusion: { judge: Record<string, string>, panel: Array<Record<string, string>> }
|
|
35
|
+
runtime: { id: string, models: RuntimeModel[] }
|
|
27
36
|
ollama: { installed: boolean, running: boolean, host: string, models: OllamaModel[] }
|
|
28
37
|
keys: { openrouter: boolean, anthropic: boolean }
|
|
29
38
|
}
|
|
@@ -119,8 +128,13 @@ const targetOptions = computed(() => {
|
|
|
119
128
|
const o = overview.value
|
|
120
129
|
if (!o) return []
|
|
121
130
|
const options: { label: string, value: string }[] = []
|
|
131
|
+
// Concrete runtime models first (Fable 5, Opus, Sonnet, Haiku) so the
|
|
132
|
+
// operator picks a real model, not just an abstract alias.
|
|
133
|
+
for (const m of o.runtime?.models ?? []) {
|
|
134
|
+
options.push({ label: `runtime / ${m.label} · ${m.note}`, value: `runtime/${m.value}` })
|
|
135
|
+
}
|
|
122
136
|
for (const alias of ['best', 'default', 'fast']) {
|
|
123
|
-
options.push({ label: `runtime / ${alias}`, value: `runtime/${alias}` })
|
|
137
|
+
options.push({ label: `runtime / ${alias} (alias)`, value: `runtime/${alias}` })
|
|
124
138
|
}
|
|
125
139
|
for (const m of o.ollama?.models ?? []) {
|
|
126
140
|
options.push({ label: `ollama / ${m.name}`, value: `ollama/${m.name}` })
|
|
@@ -148,7 +162,7 @@ async function saveEdit(role: string) {
|
|
|
148
162
|
}
|
|
149
163
|
saving.value = true
|
|
150
164
|
try {
|
|
151
|
-
await $fetch(`${apiBase
|
|
165
|
+
await $fetch(`${apiBase}/api/models/role`, {
|
|
152
166
|
method: 'POST',
|
|
153
167
|
body: { role, target: editTarget.value },
|
|
154
168
|
})
|
|
@@ -269,10 +283,15 @@ function formatCost(value: number | null): string {
|
|
|
269
283
|
>
|
|
270
284
|
<UIcon
|
|
271
285
|
:name="QUALITY_ROLES.has(row.role) ? 'i-lucide-gem' : 'i-lucide-cog'"
|
|
272
|
-
class="size-4 shrink-0"
|
|
286
|
+
class="size-4 shrink-0 mt-0.5 self-start"
|
|
273
287
|
:class="QUALITY_ROLES.has(row.role) ? 'text-primary' : 'text-muted'"
|
|
274
288
|
/>
|
|
275
|
-
<
|
|
289
|
+
<div class="w-56 shrink-0">
|
|
290
|
+
<p class="text-sm font-medium">{{ row.role }}</p>
|
|
291
|
+
<p v-if="row.description" class="text-xs text-muted leading-snug">
|
|
292
|
+
{{ row.description }}
|
|
293
|
+
</p>
|
|
294
|
+
</div>
|
|
276
295
|
<template v-if="editingRole === row.role">
|
|
277
296
|
<UInputMenu
|
|
278
297
|
v-model="editTarget"
|
package/installer/cli.js
CHANGED
|
@@ -28,6 +28,9 @@ const { values, positionals } = parseArgs({
|
|
|
28
28
|
// Model Fabric PR-A — `npx arkaos models [--json] [set ... --effort max]`
|
|
29
29
|
json: { type: "boolean" },
|
|
30
30
|
effort: { type: "string" },
|
|
31
|
+
// Fusion — `npx arkaos fusion [--save|--show] "question"`
|
|
32
|
+
save: { type: "boolean" },
|
|
33
|
+
show: { type: "boolean" },
|
|
31
34
|
},
|
|
32
35
|
allowPositionals: true,
|
|
33
36
|
strict: false,
|
|
@@ -177,6 +180,24 @@ async function main() {
|
|
|
177
180
|
break;
|
|
178
181
|
}
|
|
179
182
|
|
|
183
|
+
case "fusion": {
|
|
184
|
+
const { execSync } = await import("node:child_process");
|
|
185
|
+
const repoRootFusion = join(__dirname, "..");
|
|
186
|
+
const pyFusion = getArkaosPython();
|
|
187
|
+
if (!pyFusion) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
|
|
188
|
+
const fusionArgs = positionals.slice(1).map((a) => `"${a}"`).join(" ");
|
|
189
|
+
const saveFlag = values.save ? " --save" : "";
|
|
190
|
+
const showFlag = values.show ? " --show" : "";
|
|
191
|
+
try {
|
|
192
|
+
execSync(`"${pyFusion}" -m core.fusion.cli${saveFlag}${showFlag} ${fusionArgs}`, {
|
|
193
|
+
stdio: "inherit",
|
|
194
|
+
cwd: repoRootFusion,
|
|
195
|
+
env: { ...process.env, ARKAOS_ROOT: repoRootFusion, PYTHONPATH: repoRootFusion },
|
|
196
|
+
});
|
|
197
|
+
} catch { process.exit(1); }
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
180
201
|
case "index": {
|
|
181
202
|
const { execSync } = await import("node:child_process");
|
|
182
203
|
const indexArgs = positionals.slice(1).join(" ");
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -867,8 +867,14 @@ def commands(dept: Optional[str] = Query(None), q: Optional[str] = Query(None)):
|
|
|
867
867
|
@app.get("/api/models")
|
|
868
868
|
def models_overview():
|
|
869
869
|
"""Model Fabric: roles, providers, Ollama discovery, key presence."""
|
|
870
|
-
from core.runtime.model_router import
|
|
870
|
+
from core.runtime.model_router import (
|
|
871
|
+
USER_CONFIG_PATH,
|
|
872
|
+
load_config,
|
|
873
|
+
resolve_all,
|
|
874
|
+
role_description,
|
|
875
|
+
)
|
|
871
876
|
from core.runtime.ollama_discovery import discover
|
|
877
|
+
from core.runtime.runtime_models import detect_runtime_models
|
|
872
878
|
|
|
873
879
|
config, source = load_config()
|
|
874
880
|
keys_path = Path.home() / ".arkaos" / "keys.json"
|
|
@@ -876,16 +882,21 @@ def models_overview():
|
|
|
876
882
|
keys = json.loads(keys_path.read_text(encoding="utf-8"))
|
|
877
883
|
except (OSError, json.JSONDecodeError):
|
|
878
884
|
keys = {}
|
|
885
|
+
runtime_id, runtime_models = detect_runtime_models()
|
|
879
886
|
return {
|
|
880
887
|
"source": source,
|
|
881
888
|
"config_path": str(USER_CONFIG_PATH),
|
|
882
|
-
"roles": [
|
|
889
|
+
"roles": [
|
|
890
|
+
{**item.model_dump(), "description": role_description(item.role)}
|
|
891
|
+
for item in resolve_all()
|
|
892
|
+
],
|
|
883
893
|
"providers": {
|
|
884
894
|
name: {"type": spec.get("type", "")}
|
|
885
895
|
for name, spec in config.providers.items()
|
|
886
896
|
},
|
|
887
897
|
"aliases": config.aliases,
|
|
888
898
|
"fusion": config.fusion.model_dump(),
|
|
899
|
+
"runtime": {"id": runtime_id, "models": runtime_models},
|
|
889
900
|
"ollama": discover().to_dict(),
|
|
890
901
|
"keys": {
|
|
891
902
|
"openrouter": bool(
|