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.
Files changed (41) 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 +75 -52
  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/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/core/obsidian/__pycache__/cataloger.cpython-313.pyc +0 -0
  10. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  11. package/core/obsidian/__pycache__/taxonomy.cpython-313.pyc +0 -0
  12. package/core/runtime/__init__.py +22 -1
  13. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  15. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  16. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  17. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  18. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  19. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-313.pyc +0 -0
  20. package/core/runtime/__pycache__/llm_cost_telemetry_cli.cpython-313.pyc +0 -0
  21. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  22. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  23. package/core/runtime/base.py +30 -1
  24. package/core/runtime/claude_code.py +68 -0
  25. package/core/runtime/codex_cli.py +33 -0
  26. package/core/runtime/cursor.py +19 -0
  27. package/core/runtime/gemini_cli.py +33 -0
  28. package/core/runtime/llm_cost_telemetry.py +306 -0
  29. package/core/runtime/llm_cost_telemetry_cli.py +138 -0
  30. package/core/runtime/llm_provider.py +382 -0
  31. package/core/runtime/pricing.py +85 -0
  32. package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
  33. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  34. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  35. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  36. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  37. package/core/workflow/__pycache__/kb_first_decider.cpython-313.pyc +0 -0
  38. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  39. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  40. package/package.json +1 -1
  41. package/pyproject.toml +1 -1
@@ -0,0 +1,306 @@
1
+ """JSONL cost telemetry writer and aggregator for LLM completions.
2
+
3
+ One call == one line appended to `~/.arkaos/telemetry/llm-cost.jsonl`.
4
+ Writers are concurrent-safe on POSIX (fcntl advisory lock); on Windows
5
+ we rely on O_APPEND atomicity for line-sized writes. Never raises —
6
+ telemetry failures are swallowed so they cannot break a user-facing
7
+ completion call.
8
+
9
+ Per ADR-011 ("Token budgets are informational, not restrictive") this
10
+ module exposes aggregation helpers (`summarise`, `list_expensive_sessions`)
11
+ used by the `/arka costs` visibility command. NO hard caps are enforced
12
+ here — advisories are soft strings attached to the returned summary.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from contextlib import contextmanager
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timedelta, timezone
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ try:
26
+ import fcntl # POSIX only
27
+ _HAS_FLOCK = True
28
+ except ImportError:
29
+ _HAS_FLOCK = False
30
+
31
+
32
+ DEFAULT_TELEMETRY_PATH = Path.home() / ".arkaos" / "telemetry" / "llm-cost.jsonl"
33
+
34
+ VALID_PERIODS = ("today", "week", "month", "all")
35
+
36
+
37
+ def _telemetry_path() -> Path:
38
+ override = os.environ.get("ARKA_LLM_COST_PATH", "").strip()
39
+ if override:
40
+ return Path(override)
41
+ return DEFAULT_TELEMETRY_PATH
42
+
43
+
44
+ @contextmanager
45
+ def _locked_append(path: Path):
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ fh = path.open("a", encoding="utf-8")
48
+ try:
49
+ if _HAS_FLOCK:
50
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
51
+ yield fh
52
+ finally:
53
+ if _HAS_FLOCK:
54
+ try:
55
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
56
+ except OSError:
57
+ pass
58
+ fh.close()
59
+
60
+
61
+ def record_cost(
62
+ session_id: str,
63
+ provider: str,
64
+ model: str,
65
+ tokens_in: int,
66
+ tokens_out: int,
67
+ cached_tokens: int,
68
+ estimated_cost_usd: float | None,
69
+ ) -> None:
70
+ """Append one JSONL line describing an LLM call's cost.
71
+
72
+ Silently swallows all errors. Telemetry must never break a
73
+ completion call. The caller decides whether to compute the cost via
74
+ `core.runtime.pricing.estimate_cost_usd` or pass None.
75
+ """
76
+ try:
77
+ entry: dict[str, Any] = {
78
+ "ts": datetime.now(timezone.utc).isoformat(),
79
+ "session_id": str(session_id or ""),
80
+ "provider": str(provider or ""),
81
+ "model": str(model or ""),
82
+ "tokens_in": int(tokens_in or 0),
83
+ "tokens_out": int(tokens_out or 0),
84
+ "cached_tokens": int(cached_tokens or 0),
85
+ "estimated_cost_usd": (
86
+ float(estimated_cost_usd)
87
+ if estimated_cost_usd is not None
88
+ else None
89
+ ),
90
+ }
91
+ with _locked_append(_telemetry_path()) as fh:
92
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
93
+ except Exception: # noqa: BLE001 — telemetry must never raise
94
+ return
95
+
96
+
97
+ def read_entries(path: Path | None = None) -> list[dict[str, Any]]:
98
+ """Read and parse all JSONL entries from the telemetry file.
99
+
100
+ Returns an empty list if the file does not exist. Malformed lines
101
+ are skipped silently.
102
+ """
103
+ target = path or _telemetry_path()
104
+ if not target.exists():
105
+ return []
106
+ out: list[dict[str, Any]] = []
107
+ for line in target.read_text(encoding="utf-8").splitlines():
108
+ line = line.strip()
109
+ if not line:
110
+ continue
111
+ try:
112
+ out.append(json.loads(line))
113
+ except json.JSONDecodeError:
114
+ continue
115
+ return out
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Aggregation layer (visibility-only, per ADR-011)
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ @dataclass
124
+ class CostSummary:
125
+ """Aggregated cost view over a telemetry slice."""
126
+
127
+ period: str
128
+ total_cost_usd: float | None
129
+ total_tokens_in: int
130
+ total_tokens_out: int
131
+ total_cached_tokens: int
132
+ cache_hit_rate: float
133
+ call_count: int
134
+ by_provider: dict[str, dict[str, Any]] = field(default_factory=dict)
135
+ by_model: dict[str, dict[str, Any]] = field(default_factory=dict)
136
+ by_session: list[dict[str, Any]] = field(default_factory=list)
137
+ advisories: list[str] = field(default_factory=list)
138
+ corrupt_line_count: int = 0
139
+
140
+
141
+ def _period_cutoff(period: str, now: datetime | None = None) -> datetime | None:
142
+ ref = now or datetime.now(timezone.utc)
143
+ if period == "today":
144
+ return ref.replace(hour=0, minute=0, second=0, microsecond=0)
145
+ if period == "week":
146
+ return ref - timedelta(days=7)
147
+ if period == "month":
148
+ return ref - timedelta(days=30)
149
+ if period == "all":
150
+ return None
151
+ raise ValueError(f"invalid period: {period!r}")
152
+
153
+
154
+ def _parse_ts(raw: Any) -> datetime | None:
155
+ if not isinstance(raw, str) or not raw:
156
+ return None
157
+ try:
158
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
159
+ except ValueError:
160
+ return None
161
+ if parsed.tzinfo is None:
162
+ parsed = parsed.replace(tzinfo=timezone.utc)
163
+ return parsed
164
+
165
+
166
+ def _zero_bucket() -> dict[str, Any]:
167
+ return {
168
+ "total_cost_usd": 0.0,
169
+ "any_cost_known": False,
170
+ "total_tokens_in": 0,
171
+ "total_tokens_out": 0,
172
+ "total_cached_tokens": 0,
173
+ "call_count": 0,
174
+ }
175
+
176
+
177
+ def _accumulate(bucket: dict[str, Any], entry: dict[str, Any]) -> None:
178
+ bucket["total_tokens_in"] += int(entry.get("tokens_in") or 0)
179
+ bucket["total_tokens_out"] += int(entry.get("tokens_out") or 0)
180
+ bucket["total_cached_tokens"] += int(entry.get("cached_tokens") or 0)
181
+ bucket["call_count"] += 1
182
+ cost = entry.get("estimated_cost_usd")
183
+ if cost is not None:
184
+ bucket["total_cost_usd"] += float(cost)
185
+ bucket["any_cost_known"] = True
186
+
187
+
188
+ def _finalise_bucket(bucket: dict[str, Any]) -> dict[str, Any]:
189
+ out = dict(bucket)
190
+ if not out.pop("any_cost_known"):
191
+ out["total_cost_usd"] = None
192
+ else:
193
+ out["total_cost_usd"] = round(out["total_cost_usd"], 6)
194
+ tin = out["total_tokens_in"]
195
+ out["cache_hit_rate"] = (
196
+ round(out["total_cached_tokens"] / tin, 4) if tin > 0 else 0.0
197
+ )
198
+ return out
199
+
200
+
201
+ def _load_slice(
202
+ path: Path | None,
203
+ cutoff: datetime | None,
204
+ ) -> tuple[list[dict[str, Any]], int]:
205
+ target = path or _telemetry_path()
206
+ if not target.exists():
207
+ return [], 0
208
+ kept: list[dict[str, Any]] = []
209
+ corrupt = 0
210
+ for line in target.read_text(encoding="utf-8").splitlines():
211
+ line = line.strip()
212
+ if not line:
213
+ continue
214
+ try:
215
+ entry = json.loads(line)
216
+ except json.JSONDecodeError:
217
+ corrupt += 1
218
+ continue
219
+ if cutoff is not None:
220
+ ts = _parse_ts(entry.get("ts"))
221
+ if ts is None or ts < cutoff:
222
+ continue
223
+ kept.append(entry)
224
+ return kept, corrupt
225
+
226
+
227
+ def _group(entries: list[dict[str, Any]], key: str) -> dict[str, dict[str, Any]]:
228
+ out: dict[str, dict[str, Any]] = {}
229
+ for entry in entries:
230
+ k = str(entry.get(key) or "")
231
+ bucket = out.setdefault(k, _zero_bucket())
232
+ _accumulate(bucket, entry)
233
+ return {k: _finalise_bucket(v) for k, v in out.items()}
234
+
235
+
236
+ def _top_sessions(
237
+ entries: list[dict[str, Any]], top_n: int
238
+ ) -> list[dict[str, Any]]:
239
+ grouped = _group(entries, "session_id")
240
+ rows = [{"session_id": sid, **vals} for sid, vals in grouped.items()]
241
+ rows.sort(key=lambda r: (r["total_cost_usd"] or 0.0), reverse=True)
242
+ return rows[:top_n]
243
+
244
+
245
+ def _build_advisories(
246
+ sessions: list[dict[str, Any]], threshold_usd: float
247
+ ) -> list[str]:
248
+ out: list[str] = []
249
+ for row in sessions:
250
+ cost = row.get("total_cost_usd") or 0.0
251
+ if cost >= threshold_usd:
252
+ out.append(
253
+ f"Session {row['session_id'] or '<unknown>'} exceeded "
254
+ f"${threshold_usd:.2f} (spent ${cost:.2f})"
255
+ )
256
+ return out
257
+
258
+
259
+ def _totals_bucket(entries: list[dict[str, Any]]) -> dict[str, Any]:
260
+ totals = _zero_bucket()
261
+ for entry in entries:
262
+ _accumulate(totals, entry)
263
+ return _finalise_bucket(totals)
264
+
265
+
266
+ def summarise(
267
+ period: str = "today",
268
+ path: Path | None = None,
269
+ advisory_threshold_usd: float = 5.0,
270
+ now: datetime | None = None,
271
+ ) -> CostSummary:
272
+ """Aggregate telemetry for the given period.
273
+
274
+ Graceful on missing file, empty file, corrupt JSONL lines.
275
+ Raises only on invalid `period` (programmer error).
276
+ """
277
+ if period not in VALID_PERIODS:
278
+ raise ValueError(
279
+ f"invalid period: {period!r}; expected one of {VALID_PERIODS}"
280
+ )
281
+ entries, corrupt = _load_slice(path, _period_cutoff(period, now=now))
282
+ finalised = _totals_bucket(entries)
283
+ sessions = _top_sessions(entries, top_n=10)
284
+ return CostSummary(
285
+ period=period,
286
+ total_cost_usd=finalised["total_cost_usd"],
287
+ total_tokens_in=finalised["total_tokens_in"],
288
+ total_tokens_out=finalised["total_tokens_out"],
289
+ total_cached_tokens=finalised["total_cached_tokens"],
290
+ cache_hit_rate=finalised["cache_hit_rate"],
291
+ call_count=finalised["call_count"],
292
+ by_provider=_group(entries, "provider"),
293
+ by_model=_group(entries, "model"),
294
+ by_session=sessions,
295
+ advisories=_build_advisories(sessions, advisory_threshold_usd),
296
+ corrupt_line_count=corrupt,
297
+ )
298
+
299
+
300
+ def list_expensive_sessions(
301
+ path: Path | None = None,
302
+ top_n: int = 10,
303
+ ) -> list[dict[str, Any]]:
304
+ """Return the top-N sessions by total cost across the entire history."""
305
+ entries, _ = _load_slice(path, cutoff=None)
306
+ return _top_sessions(entries, top_n=max(0, int(top_n)))
@@ -0,0 +1,138 @@
1
+ """CLI entry for `/arka costs`. Prints a formatted cost summary.
2
+
3
+ Invoked as `python -m core.runtime.llm_cost_telemetry_cli <period>`
4
+ where `<period>` is one of: today, week, month, all, sessions.
5
+
6
+ Output is plain markdown with pipe tables so it renders cleanly in both
7
+ terminal and Obsidian. No external dependencies.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from typing import Any
14
+
15
+ from core.runtime.llm_cost_telemetry import (
16
+ VALID_PERIODS,
17
+ CostSummary,
18
+ list_expensive_sessions,
19
+ summarise,
20
+ )
21
+
22
+
23
+ def _fmt_cost(value: float | None) -> str:
24
+ if value is None:
25
+ return "n/a"
26
+ return f"${value:.4f}"
27
+
28
+
29
+ def _fmt_rate(value: float) -> str:
30
+ return f"{value * 100:.1f}%"
31
+
32
+
33
+ def _headline(summary: CostSummary) -> list[str]:
34
+ return [
35
+ f"# LLM costs — {summary.period}",
36
+ "",
37
+ f"- Calls: **{summary.call_count}**",
38
+ f"- Total cost: **{_fmt_cost(summary.total_cost_usd)}**",
39
+ f"- Tokens in / out: **{summary.total_tokens_in:,}** / "
40
+ f"**{summary.total_tokens_out:,}**",
41
+ f"- Cached tokens: **{summary.total_cached_tokens:,}** "
42
+ f"(hit rate {_fmt_rate(summary.cache_hit_rate)})",
43
+ ]
44
+
45
+
46
+ def _render_group(title: str, group: dict[str, dict[str, Any]]) -> list[str]:
47
+ if not group:
48
+ return [f"## {title}", "", "_(no data)_", ""]
49
+ lines = [
50
+ f"## {title}",
51
+ "",
52
+ "| Key | Calls | Tokens in | Tokens out | Cache hit | Cost |",
53
+ "| --- | ---: | ---: | ---: | ---: | ---: |",
54
+ ]
55
+ ordered = sorted(
56
+ group.items(),
57
+ key=lambda kv: (kv[1]["total_cost_usd"] or 0.0),
58
+ reverse=True,
59
+ )
60
+ for key, row in ordered:
61
+ label = key or "<unknown>"
62
+ lines.append(
63
+ f"| {label} | {row['call_count']} | "
64
+ f"{row['total_tokens_in']:,} | {row['total_tokens_out']:,} | "
65
+ f"{_fmt_rate(row['cache_hit_rate'])} | "
66
+ f"{_fmt_cost(row['total_cost_usd'])} |"
67
+ )
68
+ lines.append("")
69
+ return lines
70
+
71
+
72
+ def _render_sessions(rows: list[dict[str, Any]], title: str) -> list[str]:
73
+ if not rows:
74
+ return [f"## {title}", "", "_(no sessions)_", ""]
75
+ lines = [
76
+ f"## {title}",
77
+ "",
78
+ "| Session | Calls | Tokens in | Tokens out | Cost |",
79
+ "| --- | ---: | ---: | ---: | ---: |",
80
+ ]
81
+ for row in rows:
82
+ sid = row.get("session_id") or "<unknown>"
83
+ lines.append(
84
+ f"| {sid} | {row['call_count']} | "
85
+ f"{row['total_tokens_in']:,} | {row['total_tokens_out']:,} | "
86
+ f"{_fmt_cost(row['total_cost_usd'])} |"
87
+ )
88
+ lines.append("")
89
+ return lines
90
+
91
+
92
+ def _render_advisories(advisories: list[str]) -> list[str]:
93
+ if not advisories:
94
+ return []
95
+ return ["## Advisories", "", *[f"- {a}" for a in advisories], ""]
96
+
97
+
98
+ def _format_summary(summary: CostSummary) -> str:
99
+ parts: list[str] = []
100
+ parts.extend(_headline(summary))
101
+ parts.append("")
102
+ parts.extend(_render_group("By provider", summary.by_provider))
103
+ parts.extend(_render_group("By model", summary.by_model))
104
+ parts.extend(_render_sessions(summary.by_session, "Top 10 sessions"))
105
+ parts.extend(_render_advisories(summary.advisories))
106
+ if summary.corrupt_line_count:
107
+ parts.append(
108
+ f"_Note: skipped {summary.corrupt_line_count} corrupt JSONL line(s)._"
109
+ )
110
+ return "\n".join(parts).rstrip() + "\n"
111
+
112
+
113
+ def _format_sessions(rows: list[dict[str, Any]]) -> str:
114
+ parts = ["# LLM costs — top expensive sessions", ""]
115
+ parts.extend(_render_sessions(rows, "Top sessions (all time)"))
116
+ return "\n".join(parts).rstrip() + "\n"
117
+
118
+
119
+ def _usage_error() -> str:
120
+ valid = "|".join((*VALID_PERIODS, "sessions"))
121
+ return f"Unknown period. Use: {valid}"
122
+
123
+
124
+ def main(argv: list[str]) -> int:
125
+ args = argv[1:] if len(argv) > 1 else ["today"]
126
+ cmd = (args[0] or "today").strip().lower()
127
+ if cmd in VALID_PERIODS:
128
+ print(_format_summary(summarise(period=cmd)))
129
+ return 0
130
+ if cmd == "sessions":
131
+ print(_format_sessions(list_expensive_sessions(top_n=10)))
132
+ return 0
133
+ print(_usage_error(), file=sys.stderr)
134
+ return 1
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main(sys.argv))