arkaos 4.3.5 → 4.4.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/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/bin/arka-claude +35 -4
- package/bin/arka-py +22 -7
- package/config/hooks/_lib/arka_python.ps1 +31 -0
- package/config/hooks/_lib/arka_python.sh +38 -0
- package/config/hooks/post-tool-use.ps1 +8 -0
- package/config/hooks/post-tool-use.sh +11 -5
- package/config/hooks/pre-tool-use.ps1 +11 -6
- package/config/hooks/pre-tool-use.sh +11 -6
- package/config/hooks/session-start.ps1 +10 -0
- package/config/hooks/session-start.sh +5 -0
- package/config/hooks/stop.ps1 +9 -1
- package/config/hooks/stop.sh +11 -5
- package/config/hooks/user-prompt-submit.ps1 +18 -0
- package/config/hooks/user-prompt-submit.sh +11 -5
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +7 -3
- package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/core/forge/orchestrator.py +2 -1
- package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/_shared.py +29 -9
- package/core/hooks/stop.py +4 -3
- package/core/hooks/user_prompt_submit.py +5 -4
- package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/indexer.py +1 -1
- package/core/knowledge/vector_store.py +10 -3
- package/core/runtime/__pycache__/model_router.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_check.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
- package/core/runtime/gateway/__init__.py +28 -0
- package/core/runtime/gateway/__main__.py +30 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__main__.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-312.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-314.pyc +0 -0
- package/core/runtime/gateway/litellm_config.py +205 -0
- package/core/runtime/model_routing_check.py +71 -0
- package/core/runtime/model_routing_context.py +22 -1
- package/core/runtime/native_usage.py +2 -1
- package/core/shared/__pycache__/temp_paths.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/temp_paths.cpython-314.pyc +0 -0
- package/core/shared/temp_paths.py +40 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
- package/core/synapse/kb_cache.py +3 -2
- package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/ai_mcp_decider.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/content_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/policy_loader.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
- package/core/sync/agent_provisioner.py +5 -5
- package/core/sync/ai_mcp_decider.py +2 -2
- package/core/sync/content_syncer.py +14 -14
- package/core/sync/descriptor_syncer.py +2 -2
- package/core/sync/discovery.py +4 -4
- package/core/sync/engine.py +4 -4
- package/core/sync/manifest.py +1 -1
- package/core/sync/mcp_optimizer.py +5 -5
- package/core/sync/mcp_syncer.py +3 -3
- package/core/sync/policy_loader.py +1 -1
- package/core/sync/reporter.py +1 -1
- package/core/sync/settings_syncer.py +2 -2
- package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/session_windows.cpython-313.pyc +0 -0
- package/core/terminal/session.py +66 -11
- package/core/terminal/session_windows.py +211 -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/__pycache__/marker_cache.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
- package/core/workflow/flow_authorization.py +2 -1
- package/core/workflow/flow_enforcer.py +2 -1
- package/core/workflow/marker_cache.py +2 -1
- package/core/workflow/research_gate.py +2 -1
- package/dashboard/app/pages/index.vue +6 -1
- package/installer/cli.js +5 -4
- package/installer/core-snapshot.js +53 -0
- package/installer/doctor.js +8 -3
- package/installer/index.js +29 -6
- package/installer/migrate.js +7 -4
- package/installer/update.js +13 -0
- package/package.json +1 -1
- package/pyproject.toml +8 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +69 -33
- package/scripts/gateway-ensure.sh +93 -0
- package/scripts/knowledge-index.py +35 -2
- package/scripts/start-dashboard.ps1 +34 -14
- package/scripts/synapse-bridge.py +3 -1
|
@@ -347,10 +347,17 @@ class VectorStore:
|
|
|
347
347
|
for r in rows
|
|
348
348
|
]
|
|
349
349
|
|
|
350
|
-
def is_file_indexed(self, file_hash: str) -> bool:
|
|
351
|
-
"""Check if a file
|
|
350
|
+
def is_file_indexed(self, source: str, file_hash: str) -> bool:
|
|
351
|
+
"""Check if a file (identified by source path + content hash) is indexed.
|
|
352
|
+
|
|
353
|
+
The source path is part of the key so identical content in two
|
|
354
|
+
different locations (e.g. two npx cache directories) each gets
|
|
355
|
+
indexed — a hash-only check would skip every subsequent cache
|
|
356
|
+
and leave the new location unsearchable.
|
|
357
|
+
"""
|
|
352
358
|
row = self._db.execute(
|
|
353
|
-
"SELECT COUNT(*) as cnt FROM chunks WHERE file_hash = ?",
|
|
359
|
+
"SELECT COUNT(*) as cnt FROM chunks WHERE source = ? AND file_hash = ?",
|
|
360
|
+
(source, file_hash),
|
|
354
361
|
).fetchone()
|
|
355
362
|
return row["cnt"] > 0
|
|
356
363
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""ArkaOS model-routing gateway.
|
|
2
|
+
|
|
3
|
+
Turns the operator's ``~/.arkaos/models.yaml`` role routing into a running
|
|
4
|
+
LiteLLM proxy so per-role model choices actually take effect at dispatch
|
|
5
|
+
time. Claude Code points ``ANTHROPIC_BASE_URL`` at the proxy; the proxy
|
|
6
|
+
fans each request out to the right upstream (Anthropic-direct or local
|
|
7
|
+
Ollama) by the model name the alias slot carries.
|
|
8
|
+
|
|
9
|
+
See ``docs/adr`` and the plan for why a gateway is required: Claude Code's
|
|
10
|
+
``ANTHROPIC_BASE_URL`` is one endpoint per process, so honouring mixed
|
|
11
|
+
providers per role in a single session needs a fan-out proxy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from core.runtime.gateway.litellm_config import (
|
|
15
|
+
GatewayPlan,
|
|
16
|
+
build_gateway_plan,
|
|
17
|
+
build_launch_env,
|
|
18
|
+
build_litellm_config,
|
|
19
|
+
role_alias,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"GatewayPlan",
|
|
24
|
+
"build_gateway_plan",
|
|
25
|
+
"build_launch_env",
|
|
26
|
+
"build_litellm_config",
|
|
27
|
+
"role_alias",
|
|
28
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""CLI: render the LiteLLM gateway config from models.yaml.
|
|
2
|
+
|
|
3
|
+
arka-py -m core.runtime.gateway [--local] # print config.yaml
|
|
4
|
+
arka-py -m core.runtime.gateway [--local] --env KEY # print launch env
|
|
5
|
+
|
|
6
|
+
``--local`` renders the keyless local-only variant (every route → local
|
|
7
|
+
Ollama) for subscription users with no ANTHROPIC_API_KEY.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from core.runtime.gateway.litellm_config import build_launch_env, render_config_yaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str]) -> int:
|
|
18
|
+
local_only = "--local" in argv
|
|
19
|
+
argv = [a for a in argv if a != "--local"]
|
|
20
|
+
if len(argv) >= 2 and argv[0] == "--env":
|
|
21
|
+
env = build_launch_env(master_key=argv[1])
|
|
22
|
+
for key, value in env.items():
|
|
23
|
+
print(f"{key}={value}")
|
|
24
|
+
return 0
|
|
25
|
+
sys.stdout.write(render_config_yaml(local_only=local_only))
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Render a LiteLLM proxy config + Claude Code launch env from models.yaml.
|
|
2
|
+
|
|
3
|
+
Claude Code exposes at most three subagent alias slots (``opus``,
|
|
4
|
+
``sonnet``, ``haiku``) plus the interactive main-loop model. The seven
|
|
5
|
+
Model Fabric roles therefore collapse onto those slots by tier:
|
|
6
|
+
|
|
7
|
+
quality tier (design/review/architecture/quality_gate) -> opus slot
|
|
8
|
+
execution -> haiku slot
|
|
9
|
+
mechanical -> sonnet slot
|
|
10
|
+
strategy / orchestrator -> main model
|
|
11
|
+
|
|
12
|
+
Each slot maps to a LiteLLM route (``arka-<slot>``) that fans out to the
|
|
13
|
+
role's real upstream — Anthropic-direct or local Ollama. The launch env
|
|
14
|
+
points ``ANTHROPIC_DEFAULT_<TIER>_MODEL`` at those routes, so a subagent
|
|
15
|
+
dispatched with ``model: haiku`` reaches Ollama while ``model: opus``
|
|
16
|
+
reaches Anthropic — in the same session. The Anthropic API key never
|
|
17
|
+
enters the client env; it stays server-side in the proxy's environment.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from core.runtime.model_router import ModelsConfig, load_config, resolve
|
|
25
|
+
|
|
26
|
+
# The three Claude Code subagent alias slots we can address, plus a
|
|
27
|
+
# sentinel for the interactive main-loop model (not an alias).
|
|
28
|
+
_SLOTS = ("opus", "sonnet", "haiku")
|
|
29
|
+
_MAIN = "main"
|
|
30
|
+
|
|
31
|
+
# Which alias slot each work-role dispatches through. The orchestrator
|
|
32
|
+
# uses this to choose the Task `model` param per role; the launch env
|
|
33
|
+
# points each slot's default model at the matching gateway route.
|
|
34
|
+
ROLE_ALIAS: dict[str, str] = {
|
|
35
|
+
"design": "opus",
|
|
36
|
+
"review": "opus",
|
|
37
|
+
"architecture": "opus",
|
|
38
|
+
"quality_gate": "opus",
|
|
39
|
+
"strategy": _MAIN,
|
|
40
|
+
"execution": "haiku",
|
|
41
|
+
"mechanical": "sonnet",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Representative role whose configured upstream defines each slot's route.
|
|
45
|
+
# All quality roles share the opus slot, so one of them stands in.
|
|
46
|
+
_SLOT_ROLE: dict[str, str] = {
|
|
47
|
+
"opus": "quality_gate",
|
|
48
|
+
"sonnet": "mechanical",
|
|
49
|
+
"haiku": "execution",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# runtime-tier tokens -> real Anthropic model ids the proxy calls upstream.
|
|
53
|
+
# Real ids (claude-fable-5, claude-*) pass straight through.
|
|
54
|
+
_RUNTIME_TO_ANTHROPIC: dict[str, str] = {
|
|
55
|
+
"opus": "claude-opus-4-8",
|
|
56
|
+
"best": "claude-opus-4-8",
|
|
57
|
+
"sonnet": "claude-sonnet-5",
|
|
58
|
+
"default": "claude-sonnet-5",
|
|
59
|
+
"haiku": "claude-haiku-4-5-20251001",
|
|
60
|
+
"fast": "claude-haiku-4-5-20251001",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_DEFAULT_OLLAMA_BASE = "http://localhost:11434"
|
|
64
|
+
_DEFAULT_PORT = 4000
|
|
65
|
+
_ANTHROPIC_KEY_REF = "os.environ/ANTHROPIC_API_KEY"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def role_alias(role: str) -> str:
|
|
69
|
+
"""Which Claude Code alias slot a role dispatches through.
|
|
70
|
+
|
|
71
|
+
Unknown roles fall back to the opus slot — never the cheap tier —
|
|
72
|
+
consistent with the model-router's quality-first posture.
|
|
73
|
+
"""
|
|
74
|
+
return ROLE_ALIAS.get(role, "opus")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Upstream(BaseModel):
|
|
78
|
+
"""A concrete backend a slot routes to."""
|
|
79
|
+
|
|
80
|
+
kind: str # "anthropic" | "ollama"
|
|
81
|
+
model_id: str
|
|
82
|
+
api_base: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GatewayPlan(BaseModel):
|
|
86
|
+
"""Resolved routing: one upstream per subagent alias slot.
|
|
87
|
+
|
|
88
|
+
The strategy role rides the interactive main-loop model (set by
|
|
89
|
+
``--model`` / the operator's ``/model``), which reaches Anthropic via
|
|
90
|
+
the wildcard passthrough route — it is not a gateway-controlled slot.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
slots: dict[str, Upstream] # keyed by "opus"/"sonnet"/"haiku"
|
|
94
|
+
source: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _upstream_for(provider: str, model: str, ollama_base: str) -> Upstream:
|
|
98
|
+
if provider == "ollama":
|
|
99
|
+
return Upstream(kind="ollama", model_id=model, api_base=ollama_base)
|
|
100
|
+
# runtime + anthropic both terminate at Anthropic-direct.
|
|
101
|
+
return Upstream(kind="anthropic", model_id=_RUNTIME_TO_ANTHROPIC.get(model, model))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ollama_base(config: ModelsConfig) -> str:
|
|
105
|
+
return (config.providers.get("ollama", {}) or {}).get("base_url") or _DEFAULT_OLLAMA_BASE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_gateway_plan(user_path=None, local_only: bool = False) -> GatewayPlan:
|
|
109
|
+
"""Resolve every alias slot to its upstream from the operator's config.
|
|
110
|
+
|
|
111
|
+
``local_only`` (for subscription users with no ANTHROPIC_API_KEY):
|
|
112
|
+
every slot that would hit Anthropic is redirected to the local Ollama
|
|
113
|
+
model instead, so the whole session runs keyless on local models. Raises
|
|
114
|
+
if the config has no Ollama route to fall back to.
|
|
115
|
+
"""
|
|
116
|
+
config, source = load_config(user_path)
|
|
117
|
+
ollama_base = _ollama_base(config)
|
|
118
|
+
slots: dict[str, Upstream] = {}
|
|
119
|
+
for slot, role in _SLOT_ROLE.items():
|
|
120
|
+
r = resolve(role, user_path)
|
|
121
|
+
slots[slot] = _upstream_for(r.provider, r.model, ollama_base)
|
|
122
|
+
if local_only:
|
|
123
|
+
fallback = _local_fallback(slots)
|
|
124
|
+
if fallback is None:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"local-only gateway requested but models.yaml has no Ollama "
|
|
127
|
+
"route to fall back to — point at least one role at ollama/<model>"
|
|
128
|
+
)
|
|
129
|
+
slots = {
|
|
130
|
+
slot: (up if up.kind == "ollama" else fallback)
|
|
131
|
+
for slot, up in slots.items()
|
|
132
|
+
}
|
|
133
|
+
return GatewayPlan(slots=slots, source=source)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _local_fallback(slots: dict[str, Upstream]) -> Upstream | None:
|
|
137
|
+
"""The local Ollama upstream to redirect Anthropic routes to (prefer
|
|
138
|
+
execution/haiku), or None when no slot is local."""
|
|
139
|
+
if slots.get("haiku", Upstream(kind="anthropic", model_id="")).kind == "ollama":
|
|
140
|
+
return slots["haiku"]
|
|
141
|
+
for up in slots.values():
|
|
142
|
+
if up.kind == "ollama":
|
|
143
|
+
return up
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _litellm_params(up: Upstream) -> dict:
|
|
148
|
+
if up.kind == "ollama":
|
|
149
|
+
# ollama_chat/ enables tool-use translation; api_base points at the
|
|
150
|
+
# local daemon (which forwards :cloud models to Ollama Cloud).
|
|
151
|
+
return {"model": f"ollama_chat/{up.model_id}", "api_base": up.api_base}
|
|
152
|
+
return {"model": up.model_id, "api_key": _ANTHROPIC_KEY_REF}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_litellm_config(user_path=None, local_only: bool = False) -> dict:
|
|
156
|
+
"""The LiteLLM proxy config.yaml structure (regenerated per launch).
|
|
157
|
+
|
|
158
|
+
One ``arka-<slot>`` route per alias slot, plus a wildcard route for the
|
|
159
|
+
interactive main-loop model and any raw model id. In the default mixed
|
|
160
|
+
mode the wildcard forwards to Anthropic (key via ``os.environ``, never
|
|
161
|
+
inlined); in ``local_only`` mode it forwards to the local Ollama model
|
|
162
|
+
so the whole session is keyless. The client authenticates with the
|
|
163
|
+
gateway master key either way.
|
|
164
|
+
"""
|
|
165
|
+
plan = build_gateway_plan(user_path, local_only=local_only)
|
|
166
|
+
model_list = [
|
|
167
|
+
{"model_name": f"arka-{slot}", "litellm_params": _litellm_params(plan.slots[slot])}
|
|
168
|
+
for slot in _SLOTS
|
|
169
|
+
]
|
|
170
|
+
if local_only:
|
|
171
|
+
wildcard = _litellm_params(_local_fallback(plan.slots))
|
|
172
|
+
else:
|
|
173
|
+
# LiteLLM matches explicit names first; this catches the main-loop
|
|
174
|
+
# model and any raw claude-* id and forwards it to Anthropic.
|
|
175
|
+
wildcard = {"model": "anthropic/*", "api_key": _ANTHROPIC_KEY_REF}
|
|
176
|
+
model_list.append({"model_name": "*", "litellm_params": wildcard})
|
|
177
|
+
return {
|
|
178
|
+
"model_list": model_list,
|
|
179
|
+
"litellm_settings": {"drop_params": True},
|
|
180
|
+
"general_settings": {"master_key": "os.environ/ARKA_GATEWAY_KEY"},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_launch_env(master_key: str, port: int = _DEFAULT_PORT, user_path=None) -> dict[str, str]:
|
|
185
|
+
"""Env vars Claude Code launches with so subagent aliases hit the gateway.
|
|
186
|
+
|
|
187
|
+
``ANTHROPIC_API_KEY`` is deliberately absent — it belongs to the proxy,
|
|
188
|
+
not the client. The client presents only the gateway master key.
|
|
189
|
+
"""
|
|
190
|
+
return {
|
|
191
|
+
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
|
|
192
|
+
"ANTHROPIC_AUTH_TOKEN": master_key,
|
|
193
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL": "arka-opus",
|
|
194
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL": "arka-sonnet",
|
|
195
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "arka-haiku",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def render_config_yaml(user_path=None, local_only: bool = False) -> str:
|
|
200
|
+
"""The LiteLLM config as a YAML string (what arka-claude writes to disk)."""
|
|
201
|
+
import yaml
|
|
202
|
+
|
|
203
|
+
return yaml.safe_dump(
|
|
204
|
+
build_litellm_config(user_path, local_only=local_only), sort_keys=False
|
|
205
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Model-routing telemetry — answer "is the Model Fabric actually used?".
|
|
2
|
+
|
|
3
|
+
The gateway makes per-role routing real; this module makes it *observable*.
|
|
4
|
+
It reports the resolved route table, whether the LiteLLM proxy is live, and
|
|
5
|
+
a best-effort count of which ``arka-<slot>`` routes actually served traffic
|
|
6
|
+
(parsed from the proxy log). Everything degrades to a plain summary when the
|
|
7
|
+
gateway is off, so ``/arka status`` always has a truthful line to show.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from core.runtime.gateway.litellm_config import build_gateway_plan
|
|
16
|
+
|
|
17
|
+
_GATEWAY_LOG = Path.home() / ".arkaos" / "gateway" / "litellm.log"
|
|
18
|
+
_DEFAULT_PORT = 4000
|
|
19
|
+
_ROUTE_RE = re.compile(r"\barka-(opus|sonnet|haiku)\b")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolved_routes(user_path=None) -> dict[str, str]:
|
|
23
|
+
"""Alias slot -> human 'kind:model' the gateway would route it to."""
|
|
24
|
+
plan = build_gateway_plan(user_path)
|
|
25
|
+
return {
|
|
26
|
+
slot: f"{up.kind}:{up.model_id}" for slot, up in plan.slots.items()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def gateway_healthy(port: int = _DEFAULT_PORT, timeout: float = 1.0) -> bool:
|
|
31
|
+
"""True when the LiteLLM proxy answers its liveness probe."""
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(
|
|
37
|
+
f"http://127.0.0.1:{port}/health/liveliness", timeout=timeout
|
|
38
|
+
) as resp:
|
|
39
|
+
return resp.status == 200
|
|
40
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def served_counts(log_path: Path | None = None) -> dict[str, int]:
|
|
45
|
+
"""Best-effort count of arka-<slot> routes seen in the proxy log."""
|
|
46
|
+
path = log_path or _GATEWAY_LOG
|
|
47
|
+
counts: dict[str, int] = {}
|
|
48
|
+
try:
|
|
49
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
50
|
+
except OSError:
|
|
51
|
+
return counts
|
|
52
|
+
for match in _ROUTE_RE.finditer(text):
|
|
53
|
+
slot = match.group(1)
|
|
54
|
+
counts[slot] = counts.get(slot, 0) + 1
|
|
55
|
+
return counts
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def status_summary(port: int = _DEFAULT_PORT, user_path=None, log_path=None) -> str:
|
|
59
|
+
"""One compact block for /arka status — reality, not intent."""
|
|
60
|
+
live = gateway_healthy(port)
|
|
61
|
+
routes = resolved_routes(user_path)
|
|
62
|
+
header = (
|
|
63
|
+
f"Gateway: {'LIVE' if live else 'off'} (:{port}) — per-role model routing "
|
|
64
|
+
f"{'active' if live else 'not active; honour-system context injection only'}"
|
|
65
|
+
)
|
|
66
|
+
lines = [f" {slot} → {target}" for slot, target in sorted(routes.items())]
|
|
67
|
+
counts = served_counts(log_path)
|
|
68
|
+
if counts:
|
|
69
|
+
served = " ".join(f"{slot}={n}" for slot, n in sorted(counts.items()))
|
|
70
|
+
lines.append(f" served: {served}")
|
|
71
|
+
return header + "\n" + "\n".join(lines)
|
|
@@ -57,7 +57,7 @@ def routing_summary() -> str:
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def routing_directive() -> str:
|
|
60
|
-
"""Full SessionStart block: the config plus how to
|
|
60
|
+
"""Full SessionStart block: the config plus how to honour it.
|
|
61
61
|
|
|
62
62
|
Returns '' when the config cannot be read, so the hook can skip the
|
|
63
63
|
block entirely rather than inject an empty directive.
|
|
@@ -65,6 +65,26 @@ def routing_directive() -> str:
|
|
|
65
65
|
summary = routing_summary()
|
|
66
66
|
if not summary:
|
|
67
67
|
return ""
|
|
68
|
+
|
|
69
|
+
# When the LiteLLM gateway is live, the alias a subagent is dispatched
|
|
70
|
+
# with is what physically selects the upstream (opus→Anthropic,
|
|
71
|
+
# haiku→Ollama for execution), so the directive names the concrete
|
|
72
|
+
# alias per role. Off, it stays advisory context injection.
|
|
73
|
+
gateway_line = ""
|
|
74
|
+
try:
|
|
75
|
+
from core.runtime.model_routing_check import gateway_healthy
|
|
76
|
+
|
|
77
|
+
if gateway_healthy():
|
|
78
|
+
gateway_line = (
|
|
79
|
+
"\nGateway LIVE: the Task model param physically routes the "
|
|
80
|
+
"upstream. Dispatch execution with model=haiku (→ Ollama), "
|
|
81
|
+
"mechanical with model=sonnet, and design/review/architecture/"
|
|
82
|
+
"quality_gate with model=opus (→ Anthropic). strategy is the "
|
|
83
|
+
"main loop model."
|
|
84
|
+
)
|
|
85
|
+
except Exception:
|
|
86
|
+
gateway_line = ""
|
|
87
|
+
|
|
68
88
|
# No backticks: this string is embedded in session-start.sh which
|
|
69
89
|
# passes it through $(echo -e "$MSG") — backticks would trigger shell
|
|
70
90
|
# command substitution. Use plain quotes.
|
|
@@ -79,4 +99,5 @@ def routing_directive() -> str:
|
|
|
79
99
|
"the agent YAML default. Quality roles never downgrade below the "
|
|
80
100
|
"configured model (excellence-mandate). Genuinely mechanical "
|
|
81
101
|
"dispatches (commit messages, formatting) use the mechanical role."
|
|
102
|
+
f"{gateway_line}"
|
|
82
103
|
)
|
|
@@ -33,9 +33,10 @@ from typing import Any
|
|
|
33
33
|
from core.runtime.llm_cost_telemetry import record_cost
|
|
34
34
|
from core.runtime.pricing import estimate_cost_usd
|
|
35
35
|
from core.shared.safe_session_id import safe_session_id
|
|
36
|
+
from core.shared.temp_paths import arkaos_temp_dir
|
|
36
37
|
|
|
37
38
|
|
|
38
|
-
DEFAULT_CURSOR_DIR =
|
|
39
|
+
DEFAULT_CURSOR_DIR = arkaos_temp_dir("arkaos-native-cost")
|
|
39
40
|
|
|
40
41
|
_USAGE_KEYS = (
|
|
41
42
|
"input_tokens",
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Cross-platform base directory for ArkaOS inter-process coordination files.
|
|
2
|
+
|
|
3
|
+
Several core modules and the bash hooks coordinate through small marker
|
|
4
|
+
and cache files: workflow-required markers (``stop.sh`` ->
|
|
5
|
+
``flow_enforcer``), the turn-scoped KB query cache
|
|
6
|
+
(``user-prompt-submit.sh`` -> ``kb_cache``), hook metrics
|
|
7
|
+
(``user-prompt-submit.sh`` -> ``dashboard-api``). The hooks write them
|
|
8
|
+
under ``/tmp/arkaos-*``; the Python side MUST resolve to the *same*
|
|
9
|
+
directory or the coordination silently breaks.
|
|
10
|
+
|
|
11
|
+
Why this lives in ``core.shared``: every module that coordinates through
|
|
12
|
+
these files (flow_enforcer, marker_cache, research_gate, kb_cache,
|
|
13
|
+
forge.orchestrator, the consolidated hooks, native_usage, the synapse
|
|
14
|
+
bridge, the dashboard API, …) used to hardcode the literal string
|
|
15
|
+
``/tmp``. ``/tmp`` does not exist on Windows, so every one of those
|
|
16
|
+
features failed there. A single helper keeps the resolution in one place.
|
|
17
|
+
|
|
18
|
+
POSIX keeps the literal ``/tmp`` the bash hooks use (avoiding a
|
|
19
|
+
``$TMPDIR`` divergence on macOS where ``tempfile.gettempdir()`` returns
|
|
20
|
+
``/var/folders/...`` but the hooks still write ``/tmp``). On Windows
|
|
21
|
+
there is no ``/tmp``: Git-for-Windows maps a hook's ``/tmp`` to
|
|
22
|
+
``%TEMP%``, which is exactly what ``tempfile.gettempdir()`` returns, so
|
|
23
|
+
both sides still meet.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def arkaos_temp_dir(*parts: str) -> Path:
|
|
34
|
+
"""Return ``<coordination base>/<parts...>`` (the path is not created).
|
|
35
|
+
|
|
36
|
+
POSIX -> ``/tmp/<parts>`` (matches the bash hooks verbatim).
|
|
37
|
+
Windows -> ``<%TEMP%>/<parts>`` (matches Git-for-Windows ``/tmp``).
|
|
38
|
+
"""
|
|
39
|
+
base = Path(tempfile.gettempdir()) if os.name == "nt" else Path("/tmp")
|
|
40
|
+
return base.joinpath(*parts)
|
|
Binary file
|
|
Binary file
|
package/core/synapse/kb_cache.py
CHANGED
|
@@ -30,11 +30,12 @@ from pathlib import Path
|
|
|
30
30
|
from typing import Any, Optional
|
|
31
31
|
|
|
32
32
|
from core.shared import safe_session_id as _safe_session_id_module
|
|
33
|
+
from core.shared.temp_paths import arkaos_temp_dir
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
# Re-export for backward compatibility with any external importers.
|
|
36
37
|
SAFE_SESSION_ID_RE = _safe_session_id_module.SAFE_SESSION_ID_RE
|
|
37
|
-
KB_QUERY_MARKER_DIR =
|
|
38
|
+
KB_QUERY_MARKER_DIR = arkaos_temp_dir("arkaos-kb-query")
|
|
38
39
|
_MAX_QUERIES_PER_TURN = 32
|
|
39
40
|
_MAX_QUERY_LEN = 512
|
|
40
41
|
|
|
@@ -159,7 +160,7 @@ class KBSessionCache:
|
|
|
159
160
|
self._cache_dir = Path(cache_dir)
|
|
160
161
|
else:
|
|
161
162
|
project_hash = self._hash_project(project_path or "")
|
|
162
|
-
self._cache_dir =
|
|
163
|
+
self._cache_dir = arkaos_temp_dir(f"arkaos-kb-{project_hash}")
|
|
163
164
|
|
|
164
165
|
self._cache_file = self._cache_dir / f"{session_id}.json"
|
|
165
166
|
self._ensure_dir()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -55,7 +55,7 @@ def _extend_from_file(path: Path, agents: set[str]) -> None:
|
|
|
55
55
|
if not path.exists():
|
|
56
56
|
return
|
|
57
57
|
try:
|
|
58
|
-
data = yaml.safe_load(path.read_text()) or {}
|
|
58
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
59
59
|
except yaml.YAMLError:
|
|
60
60
|
return
|
|
61
61
|
for name in data.get("baseline", []) or []:
|
|
@@ -95,10 +95,10 @@ def _apply_allowlist(
|
|
|
95
95
|
continue
|
|
96
96
|
|
|
97
97
|
target = agents_dir / f"{name}.md"
|
|
98
|
-
if target.exists() and target.read_text() == rendered:
|
|
98
|
+
if target.exists() and target.read_text(encoding="utf-8") == rendered:
|
|
99
99
|
unchanged.append(name)
|
|
100
100
|
continue
|
|
101
|
-
target.write_text(rendered)
|
|
101
|
+
target.write_text(rendered, encoding="utf-8")
|
|
102
102
|
added.append(name)
|
|
103
103
|
|
|
104
104
|
return added, unchanged, errored
|
|
@@ -124,10 +124,10 @@ def _render_agent(core: Path, name: str) -> str | None:
|
|
|
124
124
|
parts: list[str] = []
|
|
125
125
|
if yaml_path is not None:
|
|
126
126
|
parts.append("---")
|
|
127
|
-
parts.append(yaml_path.read_text().strip())
|
|
127
|
+
parts.append(yaml_path.read_text(encoding="utf-8").strip())
|
|
128
128
|
parts.append("---")
|
|
129
129
|
if md_path is not None:
|
|
130
|
-
parts.append(md_path.read_text().rstrip())
|
|
130
|
+
parts.append(md_path.read_text(encoding="utf-8").rstrip())
|
|
131
131
|
|
|
132
132
|
return "\n".join(parts) + "\n"
|
|
133
133
|
|
|
@@ -76,11 +76,11 @@ def _load_cache(path: Path) -> dict[str, str]:
|
|
|
76
76
|
if not path.exists():
|
|
77
77
|
return {}
|
|
78
78
|
try:
|
|
79
|
-
return json.loads(path.read_text())
|
|
79
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
80
80
|
except (json.JSONDecodeError, OSError):
|
|
81
81
|
return {}
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
def _save_cache(path: Path, data: dict[str, str]) -> None:
|
|
85
85
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
-
path.write_text(json.dumps(data, indent=2, sort_keys=True))
|
|
86
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
|