arkaos 2.21.0 → 2.22.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.
Files changed (51) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +2 -1
  3. package/arka/skills/costs/SKILL.md +62 -0
  4. package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
  5. package/core/cognition/auto_documentor.py +200 -62
  6. package/core/jobs/__pycache__/__init__.cpython-313.pyc +0 -0
  7. package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
  8. package/core/jobs/auto_doc_worker.py +5 -3
  9. package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
  10. package/core/obsidian/__pycache__/cataloger.cpython-313.pyc +0 -0
  11. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  12. package/core/obsidian/__pycache__/taxonomy.cpython-313.pyc +0 -0
  13. package/core/runtime/__init__.py +22 -1
  14. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  16. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  17. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  18. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  19. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  20. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-313.pyc +0 -0
  21. package/core/runtime/__pycache__/llm_cost_telemetry_cli.cpython-313.pyc +0 -0
  22. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  23. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  24. package/core/runtime/base.py +30 -1
  25. package/core/runtime/claude_code.py +74 -0
  26. package/core/runtime/codex_cli.py +50 -0
  27. package/core/runtime/cursor.py +19 -0
  28. package/core/runtime/gemini_cli.py +157 -0
  29. package/core/runtime/llm_cost_telemetry.py +306 -0
  30. package/core/runtime/llm_cost_telemetry_cli.py +138 -0
  31. package/core/runtime/llm_provider.py +387 -0
  32. package/core/runtime/pricing.py +85 -0
  33. package/core/shared/__init__.py +6 -0
  34. package/core/shared/__pycache__/__init__.cpython-313.pyc +0 -0
  35. package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
  36. package/core/shared/safe_session_id.py +41 -0
  37. package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  39. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  40. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  41. package/core/synapse/kb_cache.py +7 -6
  42. package/core/synapse/layers.py +7 -0
  43. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  44. package/core/workflow/__pycache__/kb_first_decider.cpython-313.pyc +0 -0
  45. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  46. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  47. package/core/workflow/flow_enforcer.py +6 -14
  48. package/core/workflow/marker_cache.py +6 -8
  49. package/core/workflow/research_gate.py +11 -9
  50. package/package.json +1 -1
  51. package/pyproject.toml +1 -1
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.21.0
1
+ 2.22.1
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.
@@ -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
- Model routing is dynamic: a complexity heuristic over the learning
9
- content chooses haiku / sonnet / opus. Nothing is hardcoded per task
10
- type. The actual LLM call is abstracted behind `_call_llm` and falls
11
- back to a deterministic template when no SDK is wired this keeps the
12
- module testable and unblocks the SDK integration as a follow-up.
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
 
@@ -20,26 +21,29 @@ from __future__ import annotations
20
21
 
21
22
  import json
22
23
  import re
24
+ from contextlib import contextmanager
23
25
  from dataclasses import dataclass, field
24
- from datetime import date
26
+ from datetime import datetime, timezone
25
27
  from pathlib import Path
26
- from typing import Iterable, Optional
28
+ from typing import Iterable
27
29
 
28
30
  from core.obsidian import cataloger as _cataloger
29
31
  from core.obsidian import relator as _relator
30
32
  from core.obsidian.writer import ObsidianWriter
33
+ from core.shared import safe_session_id as _safe_session_id_module
31
34
 
35
+ try:
36
+ import fcntl # POSIX only
37
+ _HAS_FLOCK = True
38
+ except ImportError:
39
+ _HAS_FLOCK = False
32
40
 
33
- SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
34
41
 
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
- )
42
+ # Re-export for backward compatibility with any external importers.
43
+ SAFE_SESSION_ID_RE = _safe_session_id_module.SAFE_SESSION_ID_RE
44
+
45
+ AUTO_DOC_TELEMETRY_PATH = Path.home() / ".arkaos" / "telemetry" / "auto_doc.jsonl"
46
+
43
47
  _URL_RE = re.compile(r"https?://[^\s\)\]\"']+")
44
48
  _FILE_PATH_RE = re.compile(r"(?:^|[\s`'])(/[A-Za-z0-9_./\-]+\.[A-Za-z0-9]+)")
45
49
  _ROUTING_MARKER_RE = re.compile(
@@ -58,8 +62,17 @@ _EXTERNAL_RESEARCH_TOOLS = frozenset({
58
62
  "mcp__firecrawl__firecrawl_extract",
59
63
  })
60
64
 
61
- _HAIKU_MAX_CHARS = 600
62
- _OPUS_MIN_CHARS = 4000
65
+ _AUTO_DOC_SUFFIX = "Auto-documented by ArkaOS"
66
+ _LLM_MAX_TOKENS = 1500
67
+
68
+ _SYSTEM_PROMPT = (
69
+ "You are ArkaOS's auto-documentor. Produce a concise knowledge note "
70
+ "(150-300 words) summarising the session. Structure: short intro, "
71
+ "then markdown sections for Key Facts, Decisions, and Sources. "
72
+ "Preserve every URL and file path verbatim. Use Obsidian wikilinks "
73
+ "([[Topic]]) for reusable concepts. Do not include preamble, sign-off, "
74
+ "or meta commentary about the model or prompt. Output only markdown."
75
+ )
63
76
 
64
77
 
65
78
  @dataclass
@@ -264,65 +277,129 @@ def _dedupe_keep_order(items: Iterable[str]) -> list[str]:
264
277
  return out
265
278
 
266
279
 
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
280
  # ─── Synthesis ─────────────────────────────────────────────────────────
288
281
 
289
282
 
290
- def synthesize(learning: Learning, model_hint: str) -> str:
283
+ def synthesize(learning: Learning) -> str:
291
284
  """Produce a markdown body for the learning.
292
285
 
293
- Calls `_call_llm` when a real LLM integration is wired; otherwise
294
- falls back to a deterministic template that preserves all extracted
295
- information. The template path is what ships in Task #7.
286
+ Delegates to the active `LLMProvider` via `_call_llm`. If the
287
+ provider is unavailable, returns empty text, or raises, falls
288
+ through to a deterministic template that preserves every extracted
289
+ fact. No model name ever crosses this boundary.
296
290
  """
297
- llm_out = _call_llm(learning, model_hint)
291
+ llm_out = _call_llm(learning)
298
292
  if llm_out:
299
293
  return llm_out
300
- return _template_synthesize(learning, model_hint)
294
+ return _template_synthesize(learning)
295
+
296
+
297
+ def _call_llm(learning: Learning) -> str:
298
+ from core.runtime import get_llm_provider
299
+ from core.runtime.llm_provider import LLMUnavailable
300
+
301
+ try:
302
+ provider = get_llm_provider()
303
+ if not provider.is_available():
304
+ return ""
305
+ prompt = _build_synthesis_prompt(learning)
306
+ response = provider.complete(
307
+ prompt, max_tokens=_LLM_MAX_TOKENS, system=_SYSTEM_PROMPT
308
+ )
309
+ return response.text.strip()
310
+ except LLMUnavailable:
311
+ return ""
312
+ except Exception: # noqa: BLE001 — LLM path must never crash the doc job
313
+ return ""
301
314
 
302
315
 
303
- def _call_llm(learning: Learning, model_hint: str) -> str:
304
- return ""
316
+ def _build_synthesis_prompt(learning: Learning) -> str:
317
+ lines = [f"Topic: {learning.topic}", ""]
318
+ if learning.content.strip():
319
+ lines.append("Session blob:")
320
+ lines.append(learning.content.strip())
321
+ lines.append("")
322
+ if learning.sources:
323
+ lines.append("Sources consulted:")
324
+ for src in learning.sources[:20]:
325
+ lines.append(f"- {src}")
326
+ lines.append("")
327
+ if learning.decisions:
328
+ lines.append("Decisions recorded:")
329
+ for dec in learning.decisions[:10]:
330
+ lines.append(f"- {dec}")
331
+ lines.append("")
332
+ if learning.metadata:
333
+ meta_pairs = sorted(learning.metadata.items())
334
+ lines.append("Metadata:")
335
+ for key, value in meta_pairs:
336
+ lines.append(f"- {key}: {value}")
337
+ lines.append("")
338
+ lines.append(
339
+ "Write the note now. Obey the system prompt. Output only markdown."
340
+ )
341
+ return "\n".join(lines)
342
+
343
+
344
+ def _extract_key_facts(learning: Learning, limit: int = 5) -> list[str]:
345
+ """Pull 3-5 bullet candidates from the learning content.
346
+
347
+ Used by the template fallback so both the LLM and template paths
348
+ produce a ``## Key Facts`` section in the same order as
349
+ ``_SYSTEM_PROMPT`` requires.
350
+ """
351
+ text = (learning.content or "").strip()
352
+ if not text:
353
+ return []
354
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
355
+ bullets: list[str] = []
356
+ for para in paragraphs:
357
+ for raw in para.splitlines():
358
+ line = raw.strip().lstrip("-*• ").strip()
359
+ # Skip markdown section headings; they aren't facts.
360
+ if not line or line.startswith("#"):
361
+ continue
362
+ if line.startswith(">") or line.startswith("`"):
363
+ continue
364
+ if len(line) < 8:
365
+ continue
366
+ bullets.append(line[:240])
367
+ if len(bullets) >= limit:
368
+ return bullets
369
+ if len(bullets) >= limit:
370
+ break
371
+ return bullets
305
372
 
306
373
 
307
- def _template_synthesize(learning: Learning, model_hint: str) -> str:
308
- parts = [f"# {learning.topic}", ""]
309
- parts.append(f"> Auto-documented via ArkaOS ({model_hint}).")
374
+ def _template_synthesize(learning: Learning) -> str:
375
+ # Section order mirrors _SYSTEM_PROMPT: Key Facts → Decisions →
376
+ # Sources. Keeping both synthesis paths aligned means downstream
377
+ # consumers (MOC generation, relator) never branch on provider.
378
+ parts: list[str] = [f"# {learning.topic}", ""]
379
+ parts.append(f"> {_AUTO_DOC_SUFFIX}.")
310
380
  parts.append("")
311
381
  if learning.content.strip():
312
382
  parts.append(learning.content.strip())
313
383
  parts.append("")
314
- if learning.sources:
315
- parts.append("## Sources")
384
+ key_facts = _extract_key_facts(learning)
385
+ if key_facts:
386
+ parts.append("## Key Facts")
316
387
  parts.append("")
317
- for src in learning.sources[:20]:
318
- parts.append(f"- {src}")
388
+ for fact in key_facts:
389
+ parts.append(f"- {fact}")
319
390
  parts.append("")
320
391
  if learning.decisions:
321
- parts.append("## Key decisions")
392
+ parts.append("## Decisions")
322
393
  parts.append("")
323
394
  for dec in learning.decisions[:10]:
324
395
  parts.append(f"- {dec}")
325
396
  parts.append("")
397
+ if learning.sources:
398
+ parts.append("## Sources")
399
+ parts.append("")
400
+ for src in learning.sources[:20]:
401
+ parts.append(f"- {src}")
402
+ parts.append("")
326
403
  return "\n".join(parts).rstrip() + "\n"
327
404
 
328
405
 
@@ -358,23 +435,86 @@ def _document_one(
358
435
  writer: ObsidianWriter,
359
436
  vault_path: Path,
360
437
  session_id: str,
361
- ) -> Optional[Path]:
362
- model_hint = choose_model(learning)
363
- body = synthesize(learning, model_hint)
438
+ ) -> Path | None:
439
+ body = synthesize(learning)
364
440
  meta = dict(learning.metadata)
365
441
  meta.setdefault("title", learning.topic[:80])
366
442
  meta.setdefault("session", session_id)
367
443
  meta.setdefault("auto_documented", True)
368
- meta.setdefault("model_hint", model_hint)
369
444
  try:
370
445
  plan = _cataloger.plan(body, meta)
371
- except ValueError:
446
+ except ValueError as exc:
447
+ _log_auto_doc_event(
448
+ session_id=session_id,
449
+ event="classification-failed",
450
+ topic=learning.topic,
451
+ reason=str(exc),
452
+ )
453
+ return None
454
+ if plan is None:
455
+ _log_auto_doc_event(
456
+ session_id=session_id,
457
+ event="succeeded-empty",
458
+ topic=learning.topic,
459
+ reason="cataloger returned no plan",
460
+ )
372
461
  return None
373
462
  note_path = _cataloger.execute(plan, body, writer)
374
463
  _relate_note(note_path, body, vault_path, plan)
464
+ _log_auto_doc_event(
465
+ session_id=session_id,
466
+ event="succeeded-wrote-note",
467
+ topic=learning.topic,
468
+ reason=str(note_path),
469
+ )
375
470
  return note_path
376
471
 
377
472
 
473
+ @contextmanager
474
+ def _locked_append(path: Path):
475
+ """Append to ``path`` under an exclusive advisory lock (POSIX flock).
476
+
477
+ Mirrors the pattern in ``core/workflow/flow_enforcer._locked_append``
478
+ — see that module for the platform-fallback rationale.
479
+ """
480
+ path.parent.mkdir(parents=True, exist_ok=True)
481
+ fh = path.open("a", encoding="utf-8")
482
+ try:
483
+ if _HAS_FLOCK:
484
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
485
+ yield fh
486
+ finally:
487
+ if _HAS_FLOCK:
488
+ try:
489
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
490
+ except OSError:
491
+ pass
492
+ fh.close()
493
+
494
+
495
+ def _log_auto_doc_event(
496
+ *,
497
+ session_id: str,
498
+ event: str,
499
+ topic: str,
500
+ reason: str,
501
+ ) -> None:
502
+ """Append a structured auto-doc telemetry entry, degrade silently."""
503
+ entry = {
504
+ "ts": datetime.now(timezone.utc).isoformat(),
505
+ "session_id": session_id,
506
+ "event": event,
507
+ "topic": topic[:120],
508
+ "reason": reason[:240],
509
+ }
510
+ try:
511
+ with _locked_append(AUTO_DOC_TELEMETRY_PATH) as fh:
512
+ fh.write(json.dumps(entry) + "\n")
513
+ except OSError:
514
+ # Telemetry failures must never break the doc job.
515
+ return
516
+
517
+
378
518
  def _relate_note(note_path: Path, body: str, vault_path: Path, plan) -> None:
379
519
  try:
380
520
  related = _relator.find_related(
@@ -405,6 +545,4 @@ def _append_related_block(note_path: Path, related) -> None:
405
545
 
406
546
 
407
547
  def _safe_session_id(session_id: str) -> bool:
408
- if not isinstance(session_id, str) or not session_id:
409
- return False
410
- return bool(SAFE_SESSION_ID_RE.match(session_id))
548
+ return _safe_session_id_module.safe_session_id(session_id) is not None
@@ -28,7 +28,6 @@ from __future__ import annotations
28
28
  import argparse
29
29
  import json
30
30
  import os
31
- import re
32
31
  import sys
33
32
  import time
34
33
  import uuid
@@ -36,9 +35,12 @@ from datetime import datetime, timezone
36
35
  from pathlib import Path
37
36
  from typing import Optional
38
37
 
38
+ from core.shared import safe_session_id as _safe_session_id_module
39
+
39
40
 
40
41
  MAX_ATTEMPTS = 3
41
- SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
42
+ # Re-export for backward compatibility with any external importers.
43
+ SAFE_SESSION_ID_RE = _safe_session_id_module.SAFE_SESSION_ID_RE
42
44
  _QUEUE_SUBDIRS = ("pending", "processing", "completed", "failed")
43
45
 
44
46
 
@@ -77,7 +79,7 @@ def enqueue_job(
77
79
  """Write a pending job file. Returns the job id."""
78
80
  root = queue_root or _queue_root()
79
81
  _ensure_queue(root)
80
- safe = session_id if SAFE_SESSION_ID_RE.match(session_id or "") else "unknown"
82
+ safe = _safe_session_id_module.safe_session_id(session_id or "") or "unknown"
81
83
  job_id = f"{int(time.time())}-{uuid.uuid4().hex[:12]}"
82
84
  payload = {
83
85
  "job_id": job_id,
@@ -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__ = ["RuntimeAdapter", "RuntimeConfig", "get_adapter", "detect_runtime"]
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
+ ]
@@ -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,70 @@ 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 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
+ proc = _run_claude_cli(cmd)
135
+ if proc.returncode != 0:
136
+ raise LLMUnavailable(
137
+ f"claude CLI exited {proc.returncode}: {proc.stderr.strip()[:200]}"
138
+ )
139
+ return _parse_claude_cli_output(proc.stdout)
140
+
141
+
142
+ def _run_claude_cli(cmd: list[str]) -> subprocess.CompletedProcess:
143
+ from core.runtime.llm_provider import LLMUnavailable
144
+
145
+ try:
146
+ return subprocess.run(
147
+ cmd, capture_output=True, text=True, timeout=60, check=False
148
+ )
149
+ except subprocess.TimeoutExpired as exc:
150
+ raise LLMUnavailable("claude CLI timed out after 60s") from exc
151
+ except OSError as exc:
152
+ raise LLMUnavailable(f"claude CLI subprocess failed: {exc}") from exc
153
+
154
+
155
+ def _parse_claude_cli_output(stdout: str) -> "LLMResponse":
156
+ from core.runtime.llm_provider import LLMResponse
157
+
158
+ payload = json.loads(stdout) if stdout.strip() else {}
159
+ text = str(payload.get("result") or payload.get("response") or "")
160
+ usage = payload.get("usage") or {}
161
+ tokens_in = int(usage.get("input_tokens") or 0)
162
+ tokens_out = int(usage.get("output_tokens") or 0)
163
+ cache_read = int(usage.get("cache_read_input_tokens") or 0)
164
+ cache_write = int(usage.get("cache_creation_input_tokens") or 0)
165
+ total_input = tokens_in + cache_read + cache_write
166
+ model = str(payload.get("model") or "")
167
+ return LLMResponse(
168
+ text=text,
169
+ tokens_in=total_input,
170
+ tokens_out=tokens_out,
171
+ cached_tokens=cache_read,
172
+ model=model,
173
+ )
174
+
175
+
176
+ # Backward compatibility alias — tests and external importers that used
177
+ # the old helper name continue to work without modification.
178
+ _parse_claude_json = _parse_claude_cli_output