piocloop 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.
piocloop/pi_events.py ADDED
@@ -0,0 +1,326 @@
1
+ """Map raw PI RPC events onto the small vocabulary the TUI cares about.
2
+
3
+ Kept free of Textual imports so it can be unit-tested as pure data-in/data-out.
4
+ `tui.py` turns these dataclasses into Textual messages.
5
+
6
+ Wire-format facts this module relies on (verified against pi 0.82.1, not assumed):
7
+
8
+ * `tool_execution_start` carries `args`; `tool_execution_end` does **not**
9
+ (`args` is null there), so paths must be correlated by `toolCallId`.
10
+ * Built-in tools are `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`, and
11
+ file-taking tools use the key `path` with an absolute value.
12
+ * Token usage lives on the message in `message_end`; `turn_end` repeats the same
13
+ message, so usage is counted from `message_end` only.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any, Callable, Optional
22
+
23
+ from .pi_client import (
24
+ EVENT_DIALOG_ANSWERED,
25
+ EVENT_PROCESS_EXITED,
26
+ EVENT_PROTOCOL_ERROR,
27
+ EVENT_STDERR,
28
+ )
29
+
30
+ # Tools whose `path` argument means "this file was modified".
31
+ WRITE_TOOLS = frozenset({"edit", "write"})
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Event vocabulary
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class LoopEvent:
41
+ """Base class for everything the TUI consumes."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Settled(LoopEvent):
46
+ """The iteration is fully finished.
47
+
48
+ Emitted for `agent_settled` only — never for `agent_end`, which may be
49
+ followed by an automatic retry, compaction, or a queued continuation.
50
+ """
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Started(LoopEvent):
55
+ pass
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ToolStarted(LoopEvent):
60
+ tool: str
61
+ detail: str = ""
62
+ path: Optional[str] = None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ToolEnded(LoopEvent):
67
+ tool: str
68
+ path: Optional[str] = None
69
+ is_error: bool = False
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class PlanTouched(LoopEvent):
74
+ """A write/edit tool wrote to the plan file — reload it."""
75
+
76
+ path: str
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class AssistantText(LoopEvent):
81
+ text: str
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Thinking(LoopEvent):
86
+ text: str
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Usage(LoopEvent):
91
+ input_tokens: int = 0
92
+ output_tokens: int = 0
93
+ cost: float = 0.0
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Notice(LoopEvent):
98
+ """Something worth showing in the activity log.
99
+
100
+ `kind` is one of: retry, compaction, status, dialog, extension, stderr,
101
+ protocol, error.
102
+ """
103
+
104
+ kind: str
105
+ text: str
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ProcessExited(LoopEvent):
110
+ returncode: Optional[int]
111
+ stderr_tail: tuple[str, ...] = ()
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Mapper
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ class EventMapper:
120
+ """Stateful translator: raw event dict in, zero or more LoopEvents out.
121
+
122
+ State is needed for two things: correlating tool paths across
123
+ start/end, and coalescing streaming text deltas so a token-per-event stream
124
+ cannot flood the TUI.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ plan_file: Optional[Path] = None,
130
+ *,
131
+ text_interval: float = 0.5,
132
+ clock: Callable[[], float] = time.monotonic,
133
+ ) -> None:
134
+ self._plan_file = plan_file.resolve() if plan_file else None
135
+ self._text_interval = text_interval
136
+ self._clock = clock
137
+ self._tool_paths: dict[str, tuple[str, Optional[str]]] = {}
138
+ self._text_buf: list[str] = []
139
+ self._think_buf: list[str] = []
140
+ # Seed from the clock, not 0.0: otherwise the very first delta is
141
+ # always "overdue" and gets emitted on its own, defeating coalescing.
142
+ self._last_text_flush = clock()
143
+
144
+ # -- helpers -------------------------------------------------------
145
+
146
+ def _is_plan(self, path: Optional[str]) -> bool:
147
+ if not path or self._plan_file is None:
148
+ return False
149
+ try:
150
+ return Path(path).resolve() == self._plan_file
151
+ except OSError:
152
+ return False
153
+
154
+ def _flush_text(self, force: bool = False) -> list[LoopEvent]:
155
+ if not self._text_buf:
156
+ return []
157
+ now = self._clock()
158
+ if not force and (now - self._last_text_flush) < self._text_interval:
159
+ return []
160
+ text = "".join(self._text_buf).strip()
161
+ self._text_buf.clear()
162
+ self._last_text_flush = now
163
+ return [AssistantText(text)] if text else []
164
+
165
+ def _flush_thinking(self) -> list[LoopEvent]:
166
+ if not self._think_buf:
167
+ return []
168
+ text = "".join(self._think_buf).strip()
169
+ self._think_buf.clear()
170
+ return [Thinking(text)] if text else []
171
+
172
+ # -- main entry point ----------------------------------------------
173
+
174
+ def map(self, raw: dict) -> list[LoopEvent]:
175
+ etype = raw.get("type", "")
176
+ handler = getattr(self, f"_on_{etype}", None)
177
+ if handler is not None:
178
+ return handler(raw)
179
+ return self._on_other(etype, raw)
180
+
181
+ # -- lifecycle ------------------------------------------------------
182
+
183
+ def _on_agent_start(self, raw: dict) -> list[LoopEvent]:
184
+ return [Started()]
185
+
186
+ def _on_agent_settled(self, raw: dict) -> list[LoopEvent]:
187
+ # Flush anything buffered so the log is complete before the iteration ends.
188
+ return [*self._flush_text(force=True), Settled()]
189
+
190
+ def _on_agent_end(self, raw: dict) -> list[LoopEvent]:
191
+ if raw.get("willRetry"):
192
+ return [Notice("retry", "agent run failed — retrying automatically")]
193
+ return []
194
+
195
+ # -- tools ----------------------------------------------------------
196
+
197
+ def _on_tool_execution_start(self, raw: dict) -> list[LoopEvent]:
198
+ tool = raw.get("toolName", "?")
199
+ args = raw.get("args") or {}
200
+ path = args.get("path") if isinstance(args, dict) else None
201
+ call_id = raw.get("toolCallId")
202
+ if call_id:
203
+ self._tool_paths[call_id] = (tool, path)
204
+ return [*self._flush_text(force=True), ToolStarted(tool, _tool_detail(tool, args), path)]
205
+
206
+ def _on_tool_execution_end(self, raw: dict) -> list[LoopEvent]:
207
+ call_id = raw.get("toolCallId")
208
+ tool = raw.get("toolName") or "?"
209
+ path = None
210
+ if call_id and call_id in self._tool_paths:
211
+ remembered_tool, path = self._tool_paths.pop(call_id)
212
+ tool = raw.get("toolName") or remembered_tool
213
+
214
+ is_error = bool(raw.get("isError"))
215
+ events: list[LoopEvent] = [ToolEnded(tool, path, is_error)]
216
+ # Only a successful write/edit means the plan actually changed.
217
+ if not is_error and tool in WRITE_TOOLS and self._is_plan(path):
218
+ events.append(PlanTouched(str(path)))
219
+ return events
220
+
221
+ # -- streaming ------------------------------------------------------
222
+
223
+ def _on_message_update(self, raw: dict) -> list[LoopEvent]:
224
+ delta = raw.get("assistantMessageEvent") or {}
225
+ dtype = delta.get("type")
226
+
227
+ if dtype == "text_delta":
228
+ self._text_buf.append(delta.get("delta", ""))
229
+ return self._flush_text()
230
+ if dtype == "text_end":
231
+ return self._flush_text(force=True)
232
+ if dtype == "thinking_delta":
233
+ self._think_buf.append(delta.get("delta", ""))
234
+ return []
235
+ if dtype == "thinking_end":
236
+ return self._flush_thinking()
237
+ if dtype == "error":
238
+ reason = delta.get("reason", "error")
239
+ if reason == "aborted":
240
+ return [Notice("error", "generation aborted")]
241
+ return [Notice("error", f"generation error: {reason}")]
242
+ return []
243
+
244
+ def _on_message_end(self, raw: dict) -> list[LoopEvent]:
245
+ message = raw.get("message") or {}
246
+ usage = message.get("usage") or {}
247
+ if not usage:
248
+ return []
249
+ cost = usage.get("cost") or {}
250
+ return [
251
+ Usage(
252
+ input_tokens=int(usage.get("input") or 0),
253
+ output_tokens=int(usage.get("output") or 0),
254
+ cost=float(cost.get("total") or 0.0),
255
+ )
256
+ ]
257
+
258
+ # -- notices --------------------------------------------------------
259
+
260
+ def _on_auto_retry_start(self, raw: dict) -> list[LoopEvent]:
261
+ attempt = raw.get("attempt")
262
+ suffix = f" (attempt {attempt})" if attempt is not None else ""
263
+ return [Notice("retry", f"auto-retry after transient error{suffix}")]
264
+
265
+ def _on_auto_retry_end(self, raw: dict) -> list[LoopEvent]:
266
+ return [Notice("retry", "auto-retry finished")]
267
+
268
+ def _on_compaction_start(self, raw: dict) -> list[LoopEvent]:
269
+ return [Notice("compaction", "compacting context — this can take a while")]
270
+
271
+ def _on_compaction_end(self, raw: dict) -> list[LoopEvent]:
272
+ return [Notice("compaction", "compaction finished")]
273
+
274
+ def _on_extension_error(self, raw: dict) -> list[LoopEvent]:
275
+ return [Notice("extension", str(raw.get("error") or "extension error"))]
276
+
277
+ def _on_extension_ui_info(self, raw: dict) -> list[LoopEvent]:
278
+ text = raw.get("text") or ""
279
+ if not text:
280
+ return []
281
+ return [Notice("status", f"{raw.get('method', 'ui')}: {text}")]
282
+
283
+ def _on_queue_update(self, raw: dict) -> list[LoopEvent]:
284
+ pending = len(raw.get("steering") or []) + len(raw.get("followUp") or [])
285
+ if not pending:
286
+ return []
287
+ return [Notice("status", f"{pending} queued message(s)")]
288
+
289
+ # -- synthetic (from PiClient) --------------------------------------
290
+
291
+ def _on_other(self, etype: str, raw: dict) -> list[LoopEvent]:
292
+ if etype == EVENT_STDERR:
293
+ return [Notice("stderr", raw.get("text", ""))]
294
+ if etype == EVENT_PROTOCOL_ERROR:
295
+ return [Notice("protocol", raw.get("error", "protocol error"))]
296
+ if etype == EVENT_DIALOG_ANSWERED:
297
+ title = raw.get("title") or raw.get("method")
298
+ return [
299
+ Notice(
300
+ "dialog",
301
+ f"auto-answered {raw.get('method')} dialog "
302
+ f"({raw.get('policy')}): {title}",
303
+ )
304
+ ]
305
+ if etype == EVENT_PROCESS_EXITED:
306
+ return [
307
+ ProcessExited(
308
+ raw.get("returncode"),
309
+ tuple(raw.get("stderr_tail") or ()),
310
+ )
311
+ ]
312
+ if etype.startswith("summarization_retry"):
313
+ return [Notice("retry", etype.replace("_", " "))]
314
+ return []
315
+
316
+
317
+ def _tool_detail(tool: str, args: Any) -> str:
318
+ """A short one-line description of a tool call for the activity log."""
319
+ if not isinstance(args, dict):
320
+ return ""
321
+ if tool == "bash":
322
+ return str(args.get("command", ""))[:120]
323
+ for key in ("path", "pattern", "query"):
324
+ if key in args:
325
+ return str(args[key])[:120]
326
+ return ""
@@ -0,0 +1,140 @@
1
+ """PLAN.md parsing.
2
+
3
+ Agent-agnostic: this module knows nothing about PI or OpenCode. Ported verbatim
4
+ from pyocloop, which in turn ported it from OCLoop's src/lib/plan-parser.ts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+
14
+ @dataclass
15
+ class PlanProgress:
16
+ total: int
17
+ completed: int
18
+ pending: int
19
+ manual: int
20
+ blocked: int
21
+ percent_complete: int
22
+
23
+
24
+ @dataclass
25
+ class TaskLine:
26
+ type: str # "completed" | "pending" | "manual" | "blocked" | "not-a-task"
27
+ description: str
28
+ blocked_reason: str = ""
29
+
30
+
31
+ def parse_task_line(line: str) -> TaskLine:
32
+ trimmed = line.strip()
33
+
34
+ if not trimmed.startswith("- ["):
35
+ return TaskLine("not-a-task", "")
36
+
37
+ close = trimmed.find("]", 3)
38
+ if close == -1:
39
+ return TaskLine("not-a-task", "")
40
+
41
+ checkbox = trimmed[3:close].strip()
42
+ after = trimmed[close + 1:].strip()
43
+
44
+ if re.fullmatch(r"[xX]", checkbox):
45
+ return TaskLine("completed", after)
46
+
47
+ if re.fullmatch(r"MANUAL", checkbox, re.IGNORECASE):
48
+ return TaskLine("manual", after)
49
+
50
+ if checkbox == "" and after.upper().startswith("[MANUAL]"):
51
+ desc = re.sub(r"^\[MANUAL\]\s*", "", after, flags=re.IGNORECASE)
52
+ return TaskLine("manual", desc)
53
+
54
+ if re.match(r"^BLOCKED", checkbox, re.IGNORECASE):
55
+ reason = re.sub(r"^BLOCKED[:\s]*", "", checkbox, flags=re.IGNORECASE)
56
+ return TaskLine("blocked", after, reason)
57
+
58
+ if checkbox == "" and re.match(r"^\[BLOCKED", after, re.IGNORECASE):
59
+ m = re.match(r"^\[BLOCKED[:\s]*([^\]]*)\]\s*(.*)", after, re.IGNORECASE)
60
+ if m:
61
+ return TaskLine("blocked", m.group(2) or "", (m.group(1) or "").strip())
62
+
63
+ if checkbox == "":
64
+ return TaskLine("pending", after)
65
+
66
+ return TaskLine("not-a-task", "")
67
+
68
+
69
+ def parse_plan(content: str) -> PlanProgress:
70
+ total = completed = manual = blocked = 0
71
+
72
+ for line in content.splitlines():
73
+ task = parse_task_line(line)
74
+ if task.type == "not-a-task":
75
+ continue
76
+ total += 1
77
+ if task.type == "completed":
78
+ completed += 1
79
+ elif task.type == "manual":
80
+ manual += 1
81
+ elif task.type == "blocked":
82
+ blocked += 1
83
+
84
+ pending = total - completed - manual - blocked
85
+ denominator = total - manual
86
+ percent = round((completed / denominator) * 100) if denominator > 0 else 100
87
+
88
+ return PlanProgress(
89
+ total=total,
90
+ completed=completed,
91
+ pending=pending,
92
+ manual=manual,
93
+ blocked=blocked,
94
+ percent_complete=percent,
95
+ )
96
+
97
+
98
+ def parse_plan_complete(content: str) -> str | None:
99
+ matches = list(re.finditer(
100
+ r"^<plan-complete>([\s\S]*?)<\/plan-complete>",
101
+ content,
102
+ re.MULTILINE,
103
+ ))
104
+ if not matches:
105
+ return None
106
+ return matches[-1].group(1).strip()
107
+
108
+
109
+ def get_current_task(content: str) -> str | None:
110
+ for line in content.splitlines():
111
+ task = parse_task_line(line)
112
+ if task.type == "pending" and task.description:
113
+ return task.description
114
+ return None
115
+
116
+
117
+ def is_plan_complete(plan_path: Path) -> bool:
118
+ try:
119
+ content = plan_path.read_text(encoding="utf-8")
120
+ return parse_plan_complete(content) is not None
121
+ except OSError:
122
+ return False
123
+
124
+
125
+ def read_plan_progress(plan_path: Path) -> PlanProgress:
126
+ content = plan_path.read_text(encoding="utf-8")
127
+ return parse_plan(content)
128
+
129
+
130
+ def read_current_task(plan_path: Path) -> str | None:
131
+ content = plan_path.read_text(encoding="utf-8")
132
+ return get_current_task(content)
133
+
134
+
135
+ def read_plan_complete_summary(plan_path: Path) -> str | None:
136
+ try:
137
+ content = plan_path.read_text(encoding="utf-8")
138
+ return parse_plan_complete(content)
139
+ except OSError:
140
+ return None