whiletrue 0.1.0__py3-none-any.whl

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 (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,98 @@
1
+ """Stdio MCP server exposing whiletrue's agent tools.
2
+
3
+ Passed to Claude Code with `--mcp-config`. It holds no logic of its own: the tool list and every
4
+ call are forwarded to the whiletrue process, which validates results against the step's output
5
+ caps. Standard library only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import urllib.request
13
+ from collections.abc import Mapping
14
+ from typing import IO, Any
15
+
16
+ PROTOCOL_FALLBACK = "2024-11-05"
17
+
18
+
19
+ def post_json(url: str, path: str, body: Any, token: str, session: str) -> Any:
20
+ request = urllib.request.Request(
21
+ url + path,
22
+ data=json.dumps(body).encode(),
23
+ headers={"X-WT-Token": token, "X-WT-Session": session, "Content-Type": "application/json"},
24
+ )
25
+ with urllib.request.urlopen(request, timeout=60) as response:
26
+ return json.loads(response.read() or b"{}")
27
+
28
+
29
+ def _reply(message_id: Any, result: Any) -> dict[str, Any]:
30
+ return {"jsonrpc": "2.0", "id": message_id, "result": result}
31
+
32
+
33
+ def handle(message: dict[str, Any], env: Mapping[str, str]) -> dict[str, Any] | None:
34
+ """Answers one JSON-RPC message (None for notifications)."""
35
+ method, message_id = message.get("method"), message.get("id")
36
+ if message_id is None:
37
+ return None
38
+ url, token, session = (
39
+ env.get("WT_ENDPOINT", ""),
40
+ env.get("WT_TOKEN", ""),
41
+ env.get("WT_SESSION", ""),
42
+ )
43
+ params: dict[str, Any] = message.get("params") or {}
44
+ if method == "initialize":
45
+ return _reply(
46
+ message_id,
47
+ {
48
+ "protocolVersion": params.get("protocolVersion", PROTOCOL_FALLBACK),
49
+ "capabilities": {"tools": {}},
50
+ "serverInfo": {"name": "whiletrue", "version": "0.1.0"},
51
+ },
52
+ )
53
+ if method == "ping":
54
+ return _reply(message_id, {})
55
+ if method == "tools/list":
56
+ try:
57
+ tools = post_json(url, "/tools/list", {}, token, session).get("tools", [])
58
+ except Exception:
59
+ tools = []
60
+ return _reply(message_id, {"tools": tools})
61
+ if method == "tools/call":
62
+ try:
63
+ answer = post_json(
64
+ url,
65
+ "/tools/call",
66
+ {"name": params.get("name"), "arguments": params.get("arguments") or {}},
67
+ token,
68
+ session,
69
+ )
70
+ text, is_error = str(answer.get("text", "")), bool(answer.get("is_error"))
71
+ except Exception as error:
72
+ text, is_error = f"whiletrue is unreachable: {error}", True
73
+ return _reply(
74
+ message_id, {"content": [{"type": "text", "text": text}], "isError": is_error}
75
+ )
76
+ return {
77
+ "jsonrpc": "2.0",
78
+ "id": message_id,
79
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
80
+ }
81
+
82
+
83
+ def serve(stdin: IO[str], stdout: IO[str], env: Mapping[str, str]) -> None:
84
+ for line in stdin:
85
+ if not line.strip():
86
+ continue
87
+ try:
88
+ message = json.loads(line)
89
+ except ValueError:
90
+ continue
91
+ response = handle(message, env) if isinstance(message, dict) else None # pyright: ignore[reportUnknownArgumentType]
92
+ if response is not None:
93
+ stdout.write(json.dumps(response) + "\n")
94
+ stdout.flush()
95
+
96
+
97
+ if __name__ == "__main__": # pragma: no cover
98
+ serve(sys.stdin, sys.stdout, __import__("os").environ)
@@ -0,0 +1,345 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Literal
9
+
10
+ from whiletrue.claude_code.endpoint import ToolResult
11
+ from whiletrue.claude_code.learnings import LearningsStore
12
+ from whiletrue.claude_code.transcript import Price, Usage, read_usage
13
+ from whiletrue.core import AgentLimits, CapValidation
14
+
15
+ #: Why a session was parked rather than finished.
16
+ ParkReason = Literal["needs_human", "budget_exceeded", "turns_exceeded", "wall_exceeded"]
17
+
18
+ SUBMIT_TOOL = "whiletrue_submit"
19
+ LEARN_TOOL = "whiletrue_learn"
20
+ ASK_TOOL = "whiletrue_ask"
21
+ CONTEXT_TOOL = "whiletrue_context"
22
+
23
+ _NUDGE = (
24
+ "You have not submitted a result yet. Call the whiletrue_submit tool now with your result "
25
+ "(it is how this step finishes). If you are blocked, call whiletrue_ask with your question."
26
+ )
27
+
28
+
29
+ @dataclass
30
+ class SessionOutcome:
31
+ #: `submitted`: a result arrived; `parked`: the step needs a person (see `reason`); `ended`:
32
+ #: the session closed without a result.
33
+ status: Literal["submitted", "parked", "ended"]
34
+ value: Any = None
35
+ #: The agent's last message.
36
+ text: str = ""
37
+ reason: ParkReason | None = None
38
+ detail: str | None = None
39
+ session_id: str | None = None
40
+ steered: bool = False
41
+ turns: int = 0
42
+ cost_usd: float = 0.0
43
+ steered_cost_usd: float = 0.0
44
+
45
+
46
+ @dataclass(kw_only=True)
47
+ class SessionSpec:
48
+ #: The step key the pane is registered under.
49
+ key: str
50
+ item: dict[str, Any] = field(default_factory=lambda: {})
51
+ #: Validates a submitted result against the step's output cap (None: accept anything).
52
+ validate: Callable[[Any], CapValidation] | None = None
53
+ result_cap: str | None = None
54
+ result_schema: dict[str, Any] | None = None
55
+ limits: AgentLimits | None = None
56
+ learnings: LearningsStore | None = None
57
+ #: Extra fields stored with every learning (run, item, step).
58
+ provenance: dict[str, Any] = field(default_factory=lambda: {})
59
+ #: Extra context returned by `whiletrue_context` (for example spec excerpts).
60
+ extra_context: dict[str, Any] = field(default_factory=lambda: {})
61
+ max_nudges: int = 3
62
+ prices: dict[str, Price] | None = None
63
+ clock: Callable[[], float] = time.time
64
+ on_event: Callable[[dict[str, Any]], None] | None = None
65
+ on_attention: Callable[[str], None] | None = None
66
+ on_attention_cleared: Callable[[], None] | None = None
67
+ #: Called once when a limit is hit, so the runner can end the session.
68
+ on_limit: Callable[[ParkReason], None] | None = None
69
+
70
+
71
+ class StepSession:
72
+ """The in-process half of one interactive agent session.
73
+
74
+ The plugin's hooks and MCP tools land here (through the endpoint). It decides when the step
75
+ is done, parks it when it needs a person or hits a limit, and records a steer when a human
76
+ types into the pane.
77
+ """
78
+
79
+ def __init__(self, spec: SessionSpec) -> None:
80
+ self._spec = spec
81
+ self._started = spec.clock()
82
+ self.outcome: asyncio.Future[SessionOutcome] = asyncio.get_running_loop().create_future()
83
+ self.session_id: str | None = None
84
+ self.transcript_path: str | None = None
85
+ self.submitted = False
86
+ self._value: Any = None
87
+ self._question: str | None = None
88
+ self._last_message = ""
89
+ self._nudges = 0
90
+ self._prompts = 0
91
+ self._steered_at: float | None = None
92
+ self.tool_calls = 0
93
+ self.needs_attention = False
94
+ self.limit_hit: ParkReason | None = None
95
+
96
+ @property
97
+ def steered(self) -> bool:
98
+ return self._steered_at is not None
99
+
100
+ def _event(self, kind: str, **data: Any) -> None:
101
+ if self._spec.on_event is not None:
102
+ self._spec.on_event({"kind": kind, **data})
103
+
104
+ # ---- tools -----------------------------------------------------------------------------
105
+
106
+ def tools(self) -> list[dict[str, Any]]:
107
+ spec = self._spec
108
+ cap = f" The result must match the {spec.result_cap} schema." if spec.result_cap else ""
109
+ result_schema: dict[str, Any] = spec.result_schema if spec.result_schema else {}
110
+ return [
111
+ {
112
+ "name": SUBMIT_TOOL,
113
+ "description": "Finish this step by submitting your result. Call it when the "
114
+ f"work is complete.{cap} Invalid results are returned with the errors.",
115
+ "inputSchema": {
116
+ "type": "object",
117
+ "properties": {"result": result_schema},
118
+ "required": ["result"],
119
+ },
120
+ },
121
+ {
122
+ "name": LEARN_TOOL,
123
+ "description": "Record a short, reusable lesson about this project.",
124
+ "inputSchema": {
125
+ "type": "object",
126
+ "properties": {"note": {"type": "string"}},
127
+ "required": ["note"],
128
+ },
129
+ },
130
+ {
131
+ "name": ASK_TOOL,
132
+ "description": "Ask the human a question and stop. The step parks until answered.",
133
+ "inputSchema": {
134
+ "type": "object",
135
+ "properties": {"question": {"type": "string"}},
136
+ "required": ["question"],
137
+ },
138
+ },
139
+ {
140
+ "name": CONTEXT_TOOL,
141
+ "description": "Get this item's details and recent project learnings.",
142
+ "inputSchema": {"type": "object", "properties": {}},
143
+ },
144
+ ]
145
+
146
+ async def on_tool(self, name: str, args: dict[str, Any]) -> ToolResult:
147
+ if name == SUBMIT_TOOL:
148
+ return self._submit(args)
149
+ if name == LEARN_TOOL:
150
+ return self._learn(args)
151
+ if name == ASK_TOOL:
152
+ question = args.get("question")
153
+ if not isinstance(question, str) or not question.strip():
154
+ return ToolResult("`question` is required", is_error=True)
155
+ self._question = question
156
+ self._event("ask", question=question)
157
+ return ToolResult("Recorded. Stop now; a person will answer and resume the work.")
158
+ if name == CONTEXT_TOOL:
159
+ learnings = self._spec.learnings.recent(20) if self._spec.learnings else []
160
+ body = {"item": self._spec.item, "learnings": learnings, **self._spec.extra_context}
161
+ return ToolResult(json.dumps(body))
162
+ return ToolResult(f'Unknown tool "{name}"', is_error=True)
163
+
164
+ def _submit(self, args: dict[str, Any]) -> ToolResult:
165
+ if "result" not in args:
166
+ return ToolResult("`result` is required", is_error=True)
167
+ value: Any = args["result"]
168
+ if self._spec.validate is not None:
169
+ check = self._spec.validate(value)
170
+ if not check.ok:
171
+ self._event("submit_rejected", errors=check.errors)
172
+ return ToolResult(
173
+ "Result rejected, fix these and call whiletrue_submit again:\n"
174
+ + "\n".join(f"- {e}" for e in check.errors),
175
+ is_error=True,
176
+ )
177
+ value = check.value
178
+ self._value = value
179
+ self.submitted = True
180
+ self._event("submit")
181
+ return ToolResult("Result accepted.")
182
+
183
+ def _learn(self, args: dict[str, Any]) -> ToolResult:
184
+ note = args.get("note")
185
+ if not isinstance(note, str) or not note.strip():
186
+ return ToolResult("`note` is required", is_error=True)
187
+ if self._spec.learnings is None:
188
+ return ToolResult("Learnings are not enabled for this run", is_error=True)
189
+ self._spec.learnings.append(note.strip(), **self._spec.provenance)
190
+ self._event("learn")
191
+ return ToolResult("Noted.")
192
+
193
+ # ---- hooks -----------------------------------------------------------------------------
194
+
195
+ async def on_hook(self, event: str, payload: dict[str, Any]) -> dict[str, Any] | None:
196
+ match event:
197
+ case "SessionStart":
198
+ self.session_id = payload.get("session_id") or self.session_id
199
+ self.transcript_path = payload.get("transcript_path") or self.transcript_path
200
+ self._event("session_start", session_id=self.session_id)
201
+ case "UserPromptSubmit":
202
+ self._on_prompt(str(payload.get("prompt", "")))
203
+ case "PreToolUse":
204
+ return self._on_pre_tool(payload)
205
+ case "PostToolUse":
206
+ self.tool_calls += 1
207
+ self._clear_attention()
208
+ self._event("tool_use", name=payload.get("tool_name", ""))
209
+ case "Notification":
210
+ message = str(payload.get("message", ""))
211
+ self.needs_attention = True
212
+ self._event("attention", message=message)
213
+ if self._spec.on_attention is not None:
214
+ self._spec.on_attention(message)
215
+ case "Stop":
216
+ return self._on_stop(payload)
217
+ case "SessionEnd":
218
+ self._on_end()
219
+ case _:
220
+ pass
221
+ return None
222
+
223
+ def _clear_attention(self) -> None:
224
+ if self.needs_attention:
225
+ self.needs_attention = False
226
+ if self._spec.on_attention_cleared is not None:
227
+ self._spec.on_attention_cleared()
228
+
229
+ def _on_prompt(self, prompt: str) -> None:
230
+ self._prompts += 1
231
+ self._clear_attention()
232
+ if self._prompts > 1 and self._steered_at is None:
233
+ # The prompt we launched with is the first; anything later was typed by a person.
234
+ self._steered_at = self._spec.clock()
235
+ if self._prompts > 1:
236
+ self._event("steer", prompt=prompt)
237
+
238
+ def _usage(self) -> Usage:
239
+ return read_usage(self.transcript_path, self._spec.prices, steered_since=self._steered_at)
240
+
241
+ def _check_limits(self) -> ParkReason | None:
242
+ limits = self._spec.limits
243
+ if limits is None or self.steered:
244
+ return None # a person driving the session lifts every limit
245
+ if (
246
+ limits.wall_ms is not None
247
+ and (self._spec.clock() - self._started) * 1000 >= limits.wall_ms
248
+ ):
249
+ return "wall_exceeded"
250
+ if limits.budget_usd is None and limits.turns is None:
251
+ return None
252
+ usage = self._usage()
253
+ if limits.budget_usd is not None and usage.cost_usd >= limits.budget_usd:
254
+ return "budget_exceeded"
255
+ if limits.turns is not None and usage.turns > limits.turns:
256
+ return "turns_exceeded"
257
+ return None
258
+
259
+ def _on_pre_tool(self, payload: dict[str, Any]) -> dict[str, Any] | None:
260
+ if self.limit_hit is None:
261
+ hit = self._check_limits()
262
+ if hit is None:
263
+ return None
264
+ self.limit_hit = hit
265
+ self._event("limit", reason=hit)
266
+ if self._spec.on_limit is not None:
267
+ self._spec.on_limit(hit)
268
+ elif self.steered:
269
+ return None
270
+ label = {
271
+ "budget_exceeded": "the budget estimate was exceeded",
272
+ "turns_exceeded": "the turn limit was reached",
273
+ "wall_exceeded": "the wall-time limit was reached",
274
+ }.get(self.limit_hit, "a limit was reached")
275
+ return {
276
+ "hookSpecificOutput": {
277
+ "hookEventName": "PreToolUse",
278
+ "permissionDecision": "deny",
279
+ "permissionDecisionReason": f"whiletrue: {label}; this step is being parked.",
280
+ }
281
+ }
282
+
283
+ def _on_stop(self, payload: dict[str, Any]) -> dict[str, Any] | None:
284
+ if self.outcome.done():
285
+ return None
286
+ self._last_message = str(payload.get("last_assistant_message", self._last_message))
287
+ if self.limit_hit is not None and not self.steered:
288
+ self._finish("parked", reason=self.limit_hit, detail=f"limit hit: {self.limit_hit}")
289
+ elif self._question is not None:
290
+ self._finish("parked", reason="needs_human", detail=self._question)
291
+ elif self.submitted:
292
+ self._finish("submitted")
293
+ elif self.steered:
294
+ return None # a person is driving: waiting for them is not a failure
295
+ elif self._nudges < self._spec.max_nudges:
296
+ self._nudges += 1
297
+ self._event("nudge", count=self._nudges)
298
+ return {"decision": "block", "reason": _NUDGE}
299
+ else:
300
+ self._finish(
301
+ "parked",
302
+ reason="needs_human",
303
+ detail="the agent stopped without submitting a result after "
304
+ f"{self._nudges} reminder(s)",
305
+ )
306
+ return None
307
+
308
+ def park(self, reason: ParkReason, detail: str) -> None:
309
+ """Parks the step from outside (the runner's wall-time timer, a dead pane, ...)."""
310
+ if self.outcome.done():
311
+ return
312
+ self.limit_hit = reason if reason != "needs_human" else self.limit_hit
313
+ self._finish("parked", reason=reason, detail=detail)
314
+
315
+ def _on_end(self) -> None:
316
+ if self.outcome.done():
317
+ return
318
+ if self.submitted:
319
+ self._finish("submitted")
320
+ else:
321
+ self._finish("ended", detail="the session ended without submitting a result")
322
+
323
+ def _finish(
324
+ self,
325
+ status: Literal["submitted", "parked", "ended"],
326
+ *,
327
+ reason: ParkReason | None = None,
328
+ detail: str | None = None,
329
+ ) -> None:
330
+ usage = self._usage()
331
+ self._event("done", status=status)
332
+ self.outcome.set_result(
333
+ SessionOutcome(
334
+ status=status,
335
+ value=self._value,
336
+ text=self._last_message,
337
+ reason=reason,
338
+ detail=detail,
339
+ session_id=self.session_id,
340
+ steered=self.steered,
341
+ turns=usage.turns,
342
+ cost_usd=usage.cost_usd,
343
+ steered_cost_usd=usage.steered_cost_usd,
344
+ )
345
+ )
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Price:
12
+ """USD per million tokens."""
13
+
14
+ input: float
15
+ output: float
16
+ cache_read: float
17
+ cache_write: float
18
+
19
+
20
+ #: Rough list prices by model family, matched by substring of the model id. These drive the
21
+ #: *estimate* that enforces budgets in interactive sessions; they are not billing data.
22
+ DEFAULT_PRICES: dict[str, Price] = {
23
+ "opus": Price(input=15.0, output=75.0, cache_read=1.5, cache_write=18.75),
24
+ "sonnet": Price(input=3.0, output=15.0, cache_read=0.3, cache_write=3.75),
25
+ "haiku": Price(input=1.0, output=5.0, cache_read=0.1, cache_write=1.25),
26
+ }
27
+
28
+
29
+ @dataclass
30
+ class Usage:
31
+ #: Assistant messages (model calls) so far.
32
+ turns: int = 0
33
+ cost_usd: float = 0.0
34
+ #: The part of `cost_usd` spent after a human steered the session.
35
+ steered_cost_usd: float = 0.0
36
+
37
+ @property
38
+ def agent_cost_usd(self) -> float:
39
+ return self.cost_usd - self.steered_cost_usd
40
+
41
+
42
+ def _price(model: str, prices: dict[str, Price]) -> Price:
43
+ for family, price in prices.items():
44
+ if family in model:
45
+ return price
46
+ # Unknown model: assume the dearest family so a budget is never silently under-counted.
47
+ return max(prices.values(), key=lambda p: p.output)
48
+
49
+
50
+ def _epoch(stamp: Any) -> float | None:
51
+ if not isinstance(stamp, str):
52
+ return None
53
+ try:
54
+ return datetime.fromisoformat(stamp.replace("Z", "+00:00")).timestamp()
55
+ except ValueError:
56
+ return None
57
+
58
+
59
+ def _loads(raw: str) -> Any:
60
+ try:
61
+ return json.loads(raw)
62
+ except ValueError:
63
+ return None
64
+
65
+
66
+ def _obj(value: Any) -> dict[str, Any] | None:
67
+ return cast(dict[str, Any], value) if isinstance(value, dict) else None
68
+
69
+
70
+ def _tokens(usage: dict[str, Any], key: str) -> int:
71
+ value = usage.get(key)
72
+ return value if isinstance(value, int) else 0
73
+
74
+
75
+ def read_usage(
76
+ path: str | None,
77
+ prices: dict[str, Price] | None = None,
78
+ *,
79
+ steered_since: float | None = None,
80
+ ) -> Usage:
81
+ """Estimates turns and spend from a Claude Code session transcript (JSONL).
82
+
83
+ A message is logged once per content block, so messages are deduplicated by id. Spend after
84
+ `steered_since` (epoch seconds) is reported separately.
85
+ """
86
+ table = prices if prices is not None else DEFAULT_PRICES
87
+ usage = Usage()
88
+ if path is None:
89
+ return usage
90
+ try:
91
+ text = Path(path).read_text(encoding="utf-8")
92
+ except OSError:
93
+ return usage
94
+ seen: set[str] = set()
95
+ for raw in text.splitlines():
96
+ entry = _obj(_loads(raw))
97
+ if entry is None or entry.get("type") != "assistant":
98
+ continue
99
+ message = _obj(entry.get("message"))
100
+ if message is None:
101
+ continue
102
+ message_id = message.get("id")
103
+ if isinstance(message_id, str):
104
+ if message_id in seen:
105
+ continue
106
+ seen.add(message_id)
107
+ usage.turns += 1
108
+ counts = _obj(message.get("usage"))
109
+ if counts is None:
110
+ continue
111
+ model = message.get("model")
112
+ price = _price(model if isinstance(model, str) else "", table)
113
+ cost = (
114
+ _tokens(counts, "input_tokens") * price.input
115
+ + _tokens(counts, "output_tokens") * price.output
116
+ + _tokens(counts, "cache_read_input_tokens") * price.cache_read
117
+ + _tokens(counts, "cache_creation_input_tokens") * price.cache_write
118
+ ) / 1_000_000
119
+ usage.cost_usd += cost
120
+ when = _epoch(entry.get("timestamp"))
121
+ if steered_since is not None and when is not None and when >= steered_since:
122
+ usage.steered_cost_usd += cost
123
+ return usage
@@ -0,0 +1,15 @@
1
+ """The `whiletrue` command-line interface."""
2
+
3
+ from whiletrue.cli.app import entrypoint, main
4
+ from whiletrue.cli.common import EXIT, Io
5
+ from whiletrue.cli.registry import RegistryOptions, create_default_registry, load_plugins
6
+
7
+ __all__ = [
8
+ "EXIT",
9
+ "Io",
10
+ "RegistryOptions",
11
+ "create_default_registry",
12
+ "entrypoint",
13
+ "load_plugins",
14
+ "main",
15
+ ]
@@ -0,0 +1,3 @@
1
+ from whiletrue.cli.app import entrypoint
2
+
3
+ entrypoint()