arkaos 2.21.0 → 2.22.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 +2 -1
- package/arka/skills/costs/SKILL.md +62 -0
- package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
- package/core/cognition/auto_documentor.py +75 -52
- package/core/jobs/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/cataloger.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/taxonomy.cpython-313.pyc +0 -0
- package/core/runtime/__init__.py +22 -1
- package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_cost_telemetry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_cost_telemetry_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
- package/core/runtime/base.py +30 -1
- package/core/runtime/claude_code.py +68 -0
- package/core/runtime/codex_cli.py +33 -0
- package/core/runtime/cursor.py +19 -0
- package/core/runtime/gemini_cli.py +33 -0
- package/core/runtime/llm_cost_telemetry.py +306 -0
- package/core/runtime/llm_cost_telemetry_cli.py +138 -0
- package/core/runtime/llm_provider.py +382 -0
- package/core/runtime/pricing.py +85 -0
- package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/kb_first_decider.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.22.0
|
package/arka/SKILL.md
CHANGED
|
@@ -109,7 +109,8 @@ violation (squad-routing, arka-supremacy, spec-driven, mandatory-qa).
|
|
|
109
109
|
|
|
110
110
|
| Command | Description |
|
|
111
111
|
|---------|-------------|
|
|
112
|
-
| `/arka status` | System status (version, departments, agents, active projects) |
|
|
112
|
+
| `/arka status` | System status (version, departments, agents, active projects). Includes **LLM costs (24h)** section: top-line cost + cache hit rate + call count from `core.runtime.llm_cost_telemetry.summarise(period="today")`. |
|
|
113
|
+
| `/arka costs [period]` | LLM cost visibility — aggregates telemetry by day/week/month/all, with top expensive sessions. See `arka/skills/costs/SKILL.md`. Shells out to `python -m core.runtime.llm_cost_telemetry_cli <period>`. |
|
|
113
114
|
| `/arka standup` | Daily standup (projects, priorities, blockers, updates) |
|
|
114
115
|
| `/arka monitor` | System health monitoring |
|
|
115
116
|
| `/arka onboard <path>` | Onboard an existing project into ArkaOS |
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: arka-costs
|
|
3
|
+
description: >
|
|
4
|
+
LLM cost visibility — aggregates `~/.arkaos/telemetry/llm-cost.jsonl` by
|
|
5
|
+
day/week/month/all, breaks down by provider/model/session, surfaces top
|
|
6
|
+
expensive sessions and cache hit rate. Visibility-only per ADR-011;
|
|
7
|
+
never imposes hard caps.
|
|
8
|
+
allowed-tools: [Bash, Read]
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# /arka costs — LLM cost visibility
|
|
12
|
+
|
|
13
|
+
Aggregates runtime-agnostic LLM call telemetry written by
|
|
14
|
+
`core/runtime/llm_cost_telemetry.record_cost`. Per ADR-011, token
|
|
15
|
+
budgets are **informational, not restrictive** — this command only
|
|
16
|
+
surfaces usage and emits soft advisories. It never blocks a call.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
| Command | What it shows |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `/arka costs` | Today (UTC midnight → now) |
|
|
23
|
+
| `/arka costs today` | Same as above |
|
|
24
|
+
| `/arka costs week` | Rolling last 7 days |
|
|
25
|
+
| `/arka costs month` | Rolling last 30 days |
|
|
26
|
+
| `/arka costs all` | Entire history in the JSONL |
|
|
27
|
+
| `/arka costs sessions` | Top 10 most expensive sessions (all time) |
|
|
28
|
+
|
|
29
|
+
## Output
|
|
30
|
+
|
|
31
|
+
- Total cost (USD, `n/a` when all entries are unpriced models)
|
|
32
|
+
- Total tokens in / out, plus cached tokens
|
|
33
|
+
- Cache hit rate (`cached / tokens_in`)
|
|
34
|
+
- Breakdown by provider
|
|
35
|
+
- Breakdown by model (`<unknown>` bucket for calls with no model)
|
|
36
|
+
- Top 10 sessions sorted by cost
|
|
37
|
+
- Advisories — a soft line per session that crossed the
|
|
38
|
+
`advisory_threshold_usd` (default $5 per session)
|
|
39
|
+
|
|
40
|
+
## Implementation
|
|
41
|
+
|
|
42
|
+
This skill shells out to the Python CLI:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m core.runtime.llm_cost_telemetry_cli <period>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Source:
|
|
49
|
+
- `core/runtime/llm_cost_telemetry.py` — `summarise`, `list_expensive_sessions`
|
|
50
|
+
- `core/runtime/llm_cost_telemetry_cli.py` — markdown renderer
|
|
51
|
+
|
|
52
|
+
## Data source
|
|
53
|
+
|
|
54
|
+
`~/.arkaos/telemetry/llm-cost.jsonl` (override with `ARKA_LLM_COST_PATH`).
|
|
55
|
+
One JSONL line per LLM call, written by every provider adapter.
|
|
56
|
+
Malformed lines are skipped and counted, never raised.
|
|
57
|
+
|
|
58
|
+
## Non-negotiables
|
|
59
|
+
|
|
60
|
+
1. Read-only. This skill never edits state.
|
|
61
|
+
2. No hard budget caps — advisories are strings, not errors.
|
|
62
|
+
3. No external dependencies; stdlib only.
|
|
Binary file
|
|
@@ -5,14 +5,15 @@ synthesises learnings about external sources consulted, decisions made,
|
|
|
5
5
|
and deliverables produced, then invokes the Obsidian cataloger + relator
|
|
6
6
|
(Task #4 modules) to file structured, wikilinked notes into the vault.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
The synthesis step is runtime- and model-agnostic: it delegates to the
|
|
9
|
+
active `LLMProvider` (see `core.runtime.llm_provider`). This module
|
|
10
|
+
NEVER picks a model — the provider / runtime / env does. When no
|
|
11
|
+
provider is available or the call fails, it falls through to a
|
|
12
|
+
deterministic template synthesiser that preserves every extracted fact.
|
|
13
13
|
|
|
14
14
|
ADR/Plan references:
|
|
15
15
|
- ~/.arkaos/plans/2026-04-20-intelligence-v2.md (Task #7 — Épico B)
|
|
16
|
+
- ~/.arkaos/plans/2026-04-20-llm-agnostic.md (Task #12/#13 — LLMProvider)
|
|
16
17
|
- core/obsidian/cataloger.py, core/obsidian/relator.py (Task #4)
|
|
17
18
|
"""
|
|
18
19
|
|
|
@@ -21,9 +22,8 @@ from __future__ import annotations
|
|
|
21
22
|
import json
|
|
22
23
|
import re
|
|
23
24
|
from dataclasses import dataclass, field
|
|
24
|
-
from datetime import date
|
|
25
25
|
from pathlib import Path
|
|
26
|
-
from typing import Iterable
|
|
26
|
+
from typing import Iterable
|
|
27
27
|
|
|
28
28
|
from core.obsidian import cataloger as _cataloger
|
|
29
29
|
from core.obsidian import relator as _relator
|
|
@@ -32,14 +32,6 @@ from core.obsidian.writer import ObsidianWriter
|
|
|
32
32
|
|
|
33
33
|
SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
34
34
|
|
|
35
|
-
_ARCHITECTURAL_KEYWORDS = (
|
|
36
|
-
"architecture", "adr", "decision", "trade-off", "tradeoff",
|
|
37
|
-
"design pattern", "refactor", "migration", "schema", "bounded context",
|
|
38
|
-
)
|
|
39
|
-
_ANALYSIS_KEYWORDS = (
|
|
40
|
-
"analysis", "investigation", "compare", "benchmark", "evaluate",
|
|
41
|
-
"profile", "review", "audit",
|
|
42
|
-
)
|
|
43
35
|
_URL_RE = re.compile(r"https?://[^\s\)\]\"']+")
|
|
44
36
|
_FILE_PATH_RE = re.compile(r"(?:^|[\s`'])(/[A-Za-z0-9_./\-]+\.[A-Za-z0-9]+)")
|
|
45
37
|
_ROUTING_MARKER_RE = re.compile(
|
|
@@ -58,8 +50,17 @@ _EXTERNAL_RESEARCH_TOOLS = frozenset({
|
|
|
58
50
|
"mcp__firecrawl__firecrawl_extract",
|
|
59
51
|
})
|
|
60
52
|
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
_AUTO_DOC_SUFFIX = "Auto-documented by ArkaOS"
|
|
54
|
+
_LLM_MAX_TOKENS = 1500
|
|
55
|
+
|
|
56
|
+
_SYSTEM_PROMPT = (
|
|
57
|
+
"You are ArkaOS's auto-documentor. Produce a concise knowledge note "
|
|
58
|
+
"(150-300 words) summarising the session. Structure: short intro, "
|
|
59
|
+
"then markdown sections for Key Facts, Decisions, and Sources. "
|
|
60
|
+
"Preserve every URL and file path verbatim. Use Obsidian wikilinks "
|
|
61
|
+
"([[Topic]]) for reusable concepts. No preamble, no sign-off, no "
|
|
62
|
+
"meta commentary about the model or prompt. Output only markdown."
|
|
63
|
+
)
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
@dataclass
|
|
@@ -264,49 +265,73 @@ def _dedupe_keep_order(items: Iterable[str]) -> list[str]:
|
|
|
264
265
|
return out
|
|
265
266
|
|
|
266
267
|
|
|
267
|
-
# ─── Model routing ─────────────────────────────────────────────────────
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def choose_model(learning: Learning) -> str:
|
|
271
|
-
"""Return 'haiku' | 'sonnet' | 'opus' based on content complexity."""
|
|
272
|
-
low = learning.content.lower()
|
|
273
|
-
length = len(learning.content)
|
|
274
|
-
if any(kw in low for kw in _ARCHITECTURAL_KEYWORDS):
|
|
275
|
-
return "opus"
|
|
276
|
-
if length >= _OPUS_MIN_CHARS and len(learning.decisions) >= 3:
|
|
277
|
-
return "opus"
|
|
278
|
-
if length <= _HAIKU_MAX_CHARS and len(learning.decisions) <= 1:
|
|
279
|
-
return "haiku"
|
|
280
|
-
if any(kw in low for kw in _ANALYSIS_KEYWORDS):
|
|
281
|
-
return "sonnet"
|
|
282
|
-
if length >= _HAIKU_MAX_CHARS:
|
|
283
|
-
return "sonnet"
|
|
284
|
-
return "haiku"
|
|
285
|
-
|
|
286
|
-
|
|
287
268
|
# ─── Synthesis ─────────────────────────────────────────────────────────
|
|
288
269
|
|
|
289
270
|
|
|
290
|
-
def synthesize(learning: Learning
|
|
271
|
+
def synthesize(learning: Learning) -> str:
|
|
291
272
|
"""Produce a markdown body for the learning.
|
|
292
273
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
274
|
+
Delegates to the active `LLMProvider` via `_call_llm`. If the
|
|
275
|
+
provider is unavailable, returns empty text, or raises, falls
|
|
276
|
+
through to a deterministic template that preserves every extracted
|
|
277
|
+
fact. No model name ever crosses this boundary.
|
|
296
278
|
"""
|
|
297
|
-
llm_out = _call_llm(learning
|
|
279
|
+
llm_out = _call_llm(learning)
|
|
298
280
|
if llm_out:
|
|
299
281
|
return llm_out
|
|
300
|
-
return _template_synthesize(learning
|
|
282
|
+
return _template_synthesize(learning)
|
|
301
283
|
|
|
302
284
|
|
|
303
|
-
def _call_llm(learning: Learning
|
|
304
|
-
|
|
285
|
+
def _call_llm(learning: Learning) -> str:
|
|
286
|
+
from core.runtime import get_llm_provider
|
|
287
|
+
from core.runtime.llm_provider import LLMUnavailable
|
|
305
288
|
|
|
289
|
+
try:
|
|
290
|
+
provider = get_llm_provider()
|
|
291
|
+
if not provider.is_available():
|
|
292
|
+
return ""
|
|
293
|
+
prompt = _build_synthesis_prompt(learning)
|
|
294
|
+
response = provider.complete(
|
|
295
|
+
prompt, max_tokens=_LLM_MAX_TOKENS, system=_SYSTEM_PROMPT
|
|
296
|
+
)
|
|
297
|
+
return response.text.strip()
|
|
298
|
+
except LLMUnavailable:
|
|
299
|
+
return ""
|
|
300
|
+
except Exception: # noqa: BLE001 — LLM path must never crash the doc job
|
|
301
|
+
return ""
|
|
306
302
|
|
|
307
|
-
|
|
303
|
+
|
|
304
|
+
def _build_synthesis_prompt(learning: Learning) -> str:
|
|
305
|
+
lines = [f"Topic: {learning.topic}", ""]
|
|
306
|
+
if learning.content.strip():
|
|
307
|
+
lines.append("Session blob:")
|
|
308
|
+
lines.append(learning.content.strip())
|
|
309
|
+
lines.append("")
|
|
310
|
+
if learning.sources:
|
|
311
|
+
lines.append("Sources consulted:")
|
|
312
|
+
for src in learning.sources[:20]:
|
|
313
|
+
lines.append(f"- {src}")
|
|
314
|
+
lines.append("")
|
|
315
|
+
if learning.decisions:
|
|
316
|
+
lines.append("Decisions recorded:")
|
|
317
|
+
for dec in learning.decisions[:10]:
|
|
318
|
+
lines.append(f"- {dec}")
|
|
319
|
+
lines.append("")
|
|
320
|
+
if learning.metadata:
|
|
321
|
+
meta_pairs = sorted(learning.metadata.items())
|
|
322
|
+
lines.append("Metadata:")
|
|
323
|
+
for key, value in meta_pairs:
|
|
324
|
+
lines.append(f"- {key}: {value}")
|
|
325
|
+
lines.append("")
|
|
326
|
+
lines.append(
|
|
327
|
+
"Write the note now. Obey the system prompt. Output only markdown."
|
|
328
|
+
)
|
|
329
|
+
return "\n".join(lines)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _template_synthesize(learning: Learning) -> str:
|
|
308
333
|
parts = [f"# {learning.topic}", ""]
|
|
309
|
-
parts.append(f">
|
|
334
|
+
parts.append(f"> {_AUTO_DOC_SUFFIX}.")
|
|
310
335
|
parts.append("")
|
|
311
336
|
if learning.content.strip():
|
|
312
337
|
parts.append(learning.content.strip())
|
|
@@ -318,7 +343,7 @@ def _template_synthesize(learning: Learning, model_hint: str) -> str:
|
|
|
318
343
|
parts.append(f"- {src}")
|
|
319
344
|
parts.append("")
|
|
320
345
|
if learning.decisions:
|
|
321
|
-
parts.append("##
|
|
346
|
+
parts.append("## Decisions")
|
|
322
347
|
parts.append("")
|
|
323
348
|
for dec in learning.decisions[:10]:
|
|
324
349
|
parts.append(f"- {dec}")
|
|
@@ -358,14 +383,12 @@ def _document_one(
|
|
|
358
383
|
writer: ObsidianWriter,
|
|
359
384
|
vault_path: Path,
|
|
360
385
|
session_id: str,
|
|
361
|
-
) ->
|
|
362
|
-
|
|
363
|
-
body = synthesize(learning, model_hint)
|
|
386
|
+
) -> Path | None:
|
|
387
|
+
body = synthesize(learning)
|
|
364
388
|
meta = dict(learning.metadata)
|
|
365
389
|
meta.setdefault("title", learning.topic[:80])
|
|
366
390
|
meta.setdefault("session", session_id)
|
|
367
391
|
meta.setdefault("auto_documented", True)
|
|
368
|
-
meta.setdefault("model_hint", model_hint)
|
|
369
392
|
try:
|
|
370
393
|
plan = _cataloger.plan(body, meta)
|
|
371
394
|
except ValueError:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/core/runtime/__init__.py
CHANGED
|
@@ -2,5 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
from core.runtime.base import RuntimeAdapter, RuntimeConfig
|
|
4
4
|
from core.runtime.registry import get_adapter, detect_runtime
|
|
5
|
+
from core.runtime.llm_provider import (
|
|
6
|
+
AnthropicDirectProvider,
|
|
7
|
+
LLMProvider,
|
|
8
|
+
LLMResponse,
|
|
9
|
+
LLMUnavailable,
|
|
10
|
+
StubProvider,
|
|
11
|
+
SubagentProvider,
|
|
12
|
+
get_llm_provider,
|
|
13
|
+
)
|
|
5
14
|
|
|
6
|
-
__all__ = [
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AnthropicDirectProvider",
|
|
17
|
+
"LLMProvider",
|
|
18
|
+
"LLMResponse",
|
|
19
|
+
"LLMUnavailable",
|
|
20
|
+
"RuntimeAdapter",
|
|
21
|
+
"RuntimeConfig",
|
|
22
|
+
"StubProvider",
|
|
23
|
+
"SubagentProvider",
|
|
24
|
+
"detect_runtime",
|
|
25
|
+
"get_adapter",
|
|
26
|
+
"get_llm_provider",
|
|
27
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/core/runtime/base.py
CHANGED
|
@@ -11,7 +11,10 @@ knowing which runtime is active. Each adapter handles the translation.
|
|
|
11
11
|
from abc import ABC, abstractmethod
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import Any
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from core.runtime.llm_provider import LLMResponse
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
@dataclass
|
|
@@ -141,3 +144,29 @@ class RuntimeAdapter(ABC):
|
|
|
141
144
|
"mcp": config.supports_mcp,
|
|
142
145
|
}
|
|
143
146
|
return feature_map.get(feature, False)
|
|
147
|
+
|
|
148
|
+
def headless_supported(self) -> bool:
|
|
149
|
+
"""Whether this adapter can perform a headless CLI completion.
|
|
150
|
+
|
|
151
|
+
Default: False. Override in adapters that implement
|
|
152
|
+
`headless_complete`. `SubagentProvider` consults this before
|
|
153
|
+
attempting a call so the factory can fall back cleanly.
|
|
154
|
+
"""
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
def headless_complete(
|
|
158
|
+
self,
|
|
159
|
+
prompt: str,
|
|
160
|
+
*,
|
|
161
|
+
max_tokens: int = 2000,
|
|
162
|
+
system: str = "",
|
|
163
|
+
) -> "LLMResponse":
|
|
164
|
+
"""Run a one-shot headless completion via this runtime's CLI.
|
|
165
|
+
|
|
166
|
+
Default implementation raises NotImplementedError — adapters
|
|
167
|
+
that do not have a headless CLI mode (e.g. Cursor) inherit this
|
|
168
|
+
behaviour. Never hardcodes a model.
|
|
169
|
+
"""
|
|
170
|
+
raise NotImplementedError(
|
|
171
|
+
f"{self.get_config().name} does not support headless LLM completion"
|
|
172
|
+
)
|
|
@@ -4,11 +4,18 @@ Claude Code is the primary and most capable runtime for ArkaOS.
|
|
|
4
4
|
It supports hooks, subagents (Agent tool), MCP servers, and worktrees.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
7
10
|
from pathlib import Path
|
|
8
11
|
from os.path import expanduser
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
9
13
|
|
|
10
14
|
from core.runtime.base import RuntimeAdapter, RuntimeConfig, AgentContext, AgentResult
|
|
11
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from core.runtime.llm_provider import LLMResponse
|
|
18
|
+
|
|
12
19
|
|
|
13
20
|
class ClaudeCodeAdapter(RuntimeAdapter):
|
|
14
21
|
"""Adapter for Anthropic's Claude Code CLI."""
|
|
@@ -102,3 +109,64 @@ class ClaudeCodeAdapter(RuntimeAdapter):
|
|
|
102
109
|
def supports_feature(self, feature: str) -> bool:
|
|
103
110
|
"""Claude Code supports all features."""
|
|
104
111
|
return True
|
|
112
|
+
|
|
113
|
+
def headless_supported(self) -> bool:
|
|
114
|
+
return shutil.which("claude") is not None
|
|
115
|
+
|
|
116
|
+
def headless_complete(
|
|
117
|
+
self,
|
|
118
|
+
prompt: str,
|
|
119
|
+
*,
|
|
120
|
+
max_tokens: int = 2000,
|
|
121
|
+
system: str = "",
|
|
122
|
+
) -> "LLMResponse":
|
|
123
|
+
from core.runtime.llm_provider import LLMResponse, LLMUnavailable
|
|
124
|
+
|
|
125
|
+
binary = shutil.which("claude")
|
|
126
|
+
if binary is None:
|
|
127
|
+
raise NotImplementedError(
|
|
128
|
+
"claude CLI not found on PATH — install Claude Code to enable "
|
|
129
|
+
"headless completion via this adapter."
|
|
130
|
+
)
|
|
131
|
+
cmd = [binary, "-p", prompt, "--output-format", "json"]
|
|
132
|
+
if system:
|
|
133
|
+
cmd.extend(["--append-system-prompt", system])
|
|
134
|
+
try:
|
|
135
|
+
proc = subprocess.run(
|
|
136
|
+
cmd,
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=60,
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
except subprocess.TimeoutExpired as exc:
|
|
143
|
+
raise LLMUnavailable("claude CLI timed out after 60s") from exc
|
|
144
|
+
except OSError as exc:
|
|
145
|
+
raise LLMUnavailable(f"claude CLI subprocess failed: {exc}") from exc
|
|
146
|
+
|
|
147
|
+
if proc.returncode != 0:
|
|
148
|
+
raise LLMUnavailable(
|
|
149
|
+
f"claude CLI exited {proc.returncode}: {proc.stderr.strip()[:200]}"
|
|
150
|
+
)
|
|
151
|
+
return _parse_claude_json(proc.stdout)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_claude_json(stdout: str) -> "LLMResponse":
|
|
155
|
+
from core.runtime.llm_provider import LLMResponse
|
|
156
|
+
|
|
157
|
+
payload = json.loads(stdout) if stdout.strip() else {}
|
|
158
|
+
text = str(payload.get("result") or payload.get("response") or "")
|
|
159
|
+
usage = payload.get("usage") or {}
|
|
160
|
+
tokens_in = int(usage.get("input_tokens") or 0)
|
|
161
|
+
tokens_out = int(usage.get("output_tokens") or 0)
|
|
162
|
+
cache_read = int(usage.get("cache_read_input_tokens") or 0)
|
|
163
|
+
cache_write = int(usage.get("cache_creation_input_tokens") or 0)
|
|
164
|
+
total_input = tokens_in + cache_read + cache_write
|
|
165
|
+
model = str(payload.get("model") or "")
|
|
166
|
+
return LLMResponse(
|
|
167
|
+
text=text,
|
|
168
|
+
tokens_in=total_input,
|
|
169
|
+
tokens_out=tokens_out,
|
|
170
|
+
cached_tokens=cache_read,
|
|
171
|
+
model=model,
|
|
172
|
+
)
|
|
@@ -4,11 +4,16 @@ OpenAI's Codex CLI. Supports sandboxed execution and file operations.
|
|
|
4
4
|
More limited than Claude Code: no native hooks, no MCP servers.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import shutil
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from os.path import expanduser
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
9
11
|
|
|
10
12
|
from core.runtime.base import RuntimeAdapter, RuntimeConfig, AgentContext, AgentResult
|
|
11
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from core.runtime.llm_provider import LLMResponse
|
|
16
|
+
|
|
12
17
|
|
|
13
18
|
class CodexCliAdapter(RuntimeAdapter):
|
|
14
19
|
"""Adapter for OpenAI's Codex CLI."""
|
|
@@ -69,3 +74,31 @@ class CodexCliAdapter(RuntimeAdapter):
|
|
|
69
74
|
|
|
70
75
|
def search_content(self, pattern: str, path: str = ".") -> list[str]:
|
|
71
76
|
raise NotImplementedError("Use Codex CLI's native content search")
|
|
77
|
+
|
|
78
|
+
def headless_supported(self) -> bool:
|
|
79
|
+
# Codex CLI headless invocation syntax is not stable as of
|
|
80
|
+
# 2026-04-20. Until verified, we surface unsupported and let
|
|
81
|
+
# SubagentProvider fall back to AnthropicDirect or stub.
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def headless_complete(
|
|
85
|
+
self,
|
|
86
|
+
prompt: str,
|
|
87
|
+
*,
|
|
88
|
+
max_tokens: int = 2000,
|
|
89
|
+
system: str = "",
|
|
90
|
+
) -> "LLMResponse":
|
|
91
|
+
binary = shutil.which("codex")
|
|
92
|
+
if binary is None:
|
|
93
|
+
raise NotImplementedError(
|
|
94
|
+
"codex CLI not found on PATH — install Codex CLI to "
|
|
95
|
+
"enable headless completion."
|
|
96
|
+
)
|
|
97
|
+
# TODO(llm-agnostic): Verify Codex CLI headless invocation
|
|
98
|
+
# syntax (`codex exec "<prompt>"` was the working hypothesis
|
|
99
|
+
# but has not been confirmed for the current release). Until
|
|
100
|
+
# then, refuse rather than guess. Tracked in Task #12 report.
|
|
101
|
+
raise NotImplementedError(
|
|
102
|
+
"Codex CLI headless completion not yet wired — verify CLI "
|
|
103
|
+
"syntax before enabling. See core/runtime/codex_cli.py TODO."
|
|
104
|
+
)
|
package/core/runtime/cursor.py
CHANGED
|
@@ -6,9 +6,13 @@ Supports agent mode with tool execution.
|
|
|
6
6
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from os.path import expanduser
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
9
10
|
|
|
10
11
|
from core.runtime.base import RuntimeAdapter, RuntimeConfig, AgentContext, AgentResult
|
|
11
12
|
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from core.runtime.llm_provider import LLMResponse
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
class CursorAdapter(RuntimeAdapter):
|
|
14
18
|
"""Adapter for Cursor AI IDE."""
|
|
@@ -69,3 +73,18 @@ class CursorAdapter(RuntimeAdapter):
|
|
|
69
73
|
|
|
70
74
|
def search_content(self, pattern: str, path: str = ".") -> list[str]:
|
|
71
75
|
raise NotImplementedError("Use Cursor's native content search")
|
|
76
|
+
|
|
77
|
+
def headless_supported(self) -> bool:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def headless_complete(
|
|
81
|
+
self,
|
|
82
|
+
prompt: str,
|
|
83
|
+
*,
|
|
84
|
+
max_tokens: int = 2000,
|
|
85
|
+
system: str = "",
|
|
86
|
+
) -> "LLMResponse":
|
|
87
|
+
raise NotImplementedError(
|
|
88
|
+
"Cursor has no headless CLI mode as of 2026-04. "
|
|
89
|
+
"Fall back to AnthropicDirectProvider or StubProvider."
|
|
90
|
+
)
|
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
Google's Gemini CLI. Uses GEMINI.md for instructions and activate_skill for skills.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import shutil
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from os.path import expanduser
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
8
10
|
|
|
9
11
|
from core.runtime.base import RuntimeAdapter, RuntimeConfig, AgentContext, AgentResult
|
|
10
12
|
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from core.runtime.llm_provider import LLMResponse
|
|
15
|
+
|
|
11
16
|
|
|
12
17
|
class GeminiCliAdapter(RuntimeAdapter):
|
|
13
18
|
"""Adapter for Google's Gemini CLI."""
|
|
@@ -66,3 +71,31 @@ class GeminiCliAdapter(RuntimeAdapter):
|
|
|
66
71
|
|
|
67
72
|
def search_content(self, pattern: str, path: str = ".") -> list[str]:
|
|
68
73
|
raise NotImplementedError("Use Gemini CLI's native content search")
|
|
74
|
+
|
|
75
|
+
def headless_supported(self) -> bool:
|
|
76
|
+
# Gemini CLI headless invocation syntax is not verified for the
|
|
77
|
+
# current release. Returning False lets SubagentProvider fall
|
|
78
|
+
# back gracefully rather than shell out blindly.
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def headless_complete(
|
|
82
|
+
self,
|
|
83
|
+
prompt: str,
|
|
84
|
+
*,
|
|
85
|
+
max_tokens: int = 2000,
|
|
86
|
+
system: str = "",
|
|
87
|
+
) -> "LLMResponse":
|
|
88
|
+
binary = shutil.which("gemini")
|
|
89
|
+
if binary is None:
|
|
90
|
+
raise NotImplementedError(
|
|
91
|
+
"gemini CLI not found on PATH — install Gemini CLI to "
|
|
92
|
+
"enable headless completion."
|
|
93
|
+
)
|
|
94
|
+
# TODO(llm-agnostic): Verify Gemini CLI's headless invocation
|
|
95
|
+
# (`gemini -p "<prompt>"` was the working hypothesis). Until
|
|
96
|
+
# confirmed for the shipped CLI version, refuse rather than
|
|
97
|
+
# guess. Tracked in Task #12 report.
|
|
98
|
+
raise NotImplementedError(
|
|
99
|
+
"Gemini CLI headless completion not yet wired — verify CLI "
|
|
100
|
+
"syntax before enabling. See core/runtime/gemini_cli.py TODO."
|
|
101
|
+
)
|