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.
- 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 +200 -62
- package/core/jobs/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
- package/core/jobs/auto_doc_worker.py +5 -3
- 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 +74 -0
- package/core/runtime/codex_cli.py +50 -0
- package/core/runtime/cursor.py +19 -0
- package/core/runtime/gemini_cli.py +157 -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 +387 -0
- package/core/runtime/pricing.py +85 -0
- package/core/shared/__init__.py +6 -0
- package/core/shared/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
- package/core/shared/safe_session_id.py +41 -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/synapse/kb_cache.py +7 -6
- package/core/synapse/layers.py +7 -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/core/workflow/flow_enforcer.py +6 -14
- package/core/workflow/marker_cache.py +6 -8
- package/core/workflow/research_gate.py +11 -9
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
26
|
+
from datetime import datetime, timezone
|
|
25
27
|
from pathlib import Path
|
|
26
|
-
from typing import Iterable
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
283
|
+
def synthesize(learning: Learning) -> str:
|
|
291
284
|
"""Produce a markdown body for the learning.
|
|
292
285
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
291
|
+
llm_out = _call_llm(learning)
|
|
298
292
|
if llm_out:
|
|
299
293
|
return llm_out
|
|
300
|
-
return _template_synthesize(learning
|
|
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
|
|
304
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
315
|
-
|
|
384
|
+
key_facts = _extract_key_facts(learning)
|
|
385
|
+
if key_facts:
|
|
386
|
+
parts.append("## Key Facts")
|
|
316
387
|
parts.append("")
|
|
317
|
-
for
|
|
318
|
-
parts.append(f"- {
|
|
388
|
+
for fact in key_facts:
|
|
389
|
+
parts.append(f"- {fact}")
|
|
319
390
|
parts.append("")
|
|
320
391
|
if learning.decisions:
|
|
321
|
-
parts.append("##
|
|
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
|
-
) ->
|
|
362
|
-
|
|
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
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
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,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
|