playmaker-cli 0.4.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.
@@ -0,0 +1,352 @@
1
+ """Gemini CLI handler.
2
+
3
+ Empirically:
4
+ - oneshot: `gemini -p "<prompt>" -o json [--yolo] [--include-directories <list>]`
5
+ - session file: ~/.gemini/tmp/<cwd-basename>/chats/session-<timestamp>-<short-id>.<ext>
6
+ where short-id is first 8 chars of session_id
7
+ - TWO file formats coexist in chats/:
8
+ - .jsonl (interactive sessions: line-per-event, types metadata|user|gemini)
9
+ - .json (single object {sessionId, projectHash, startTime, kind, messages: [...]})
10
+ written by non-interactive `-p` runs
11
+ - stdout json output: {session_id, response, stats: {models: {...}}}
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
24
+
25
+ GEMINI_CHATS_ROOT = Path("~/.gemini/tmp").expanduser()
26
+
27
+
28
+ class GeminiHandler:
29
+ name = "gemini"
30
+
31
+ def is_available(self) -> bool:
32
+ return shutil.which("gemini") is not None
33
+
34
+ def dispatch(
35
+ self,
36
+ prompt: str,
37
+ cwd: Path,
38
+ files: list[Path] | None = None,
39
+ on_session_started: SessionStartedCallback | None = None,
40
+ model: str | None = None,
41
+ ) -> DispatchResult:
42
+ """Streaming dispatch via `gemini -p ... -o stream-json` so that
43
+ `on_session_started` fires within the first event instead of at
44
+ the very end. The first JSONL line carries `session_id` (or
45
+ `sessionId`); we callback on it so dispatch's state.db write
46
+ happens early — other commands can locate the session almost
47
+ immediately after CLI invocation.
48
+ """
49
+ full_prompt = self._build_prompt(prompt, files or [])
50
+ cmd = [
51
+ "gemini",
52
+ "-p",
53
+ full_prompt,
54
+ "-o",
55
+ "stream-json",
56
+ "--yolo",
57
+ ]
58
+ if model:
59
+ cmd += ["-m", model]
60
+ t0 = time.monotonic()
61
+ proc = subprocess.Popen(
62
+ cmd,
63
+ cwd=str(cwd),
64
+ stdout=subprocess.PIPE,
65
+ stderr=subprocess.PIPE,
66
+ text=True,
67
+ bufsize=1,
68
+ )
69
+
70
+ agent_session_id: str | None = None
71
+ last_response = ""
72
+ first_lines: list[str] = []
73
+
74
+ assert proc.stdout is not None
75
+ for raw in proc.stdout:
76
+ if len(first_lines) < 3:
77
+ first_lines.append(raw)
78
+ line = raw.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ obj = json.loads(line)
83
+ except json.JSONDecodeError:
84
+ continue
85
+
86
+ if agent_session_id is None:
87
+ sid = obj.get("session_id") or obj.get("sessionId")
88
+ if sid:
89
+ agent_session_id = sid
90
+ if on_session_started is not None:
91
+ try:
92
+ on_session_started(agent_session_id)
93
+ except Exception:
94
+ pass
95
+
96
+ # Capture latest "response" field (gemini emits incremental
97
+ # updates that overwrite each other; the last value wins).
98
+ if obj.get("response"):
99
+ last_response = obj["response"]
100
+
101
+ stderr_buf = proc.stderr.read() if proc.stderr else ""
102
+ proc.wait()
103
+ duration = time.monotonic() - t0
104
+
105
+ if proc.returncode != 0:
106
+ raise RuntimeError(
107
+ f"gemini failed (exit {proc.returncode}): "
108
+ f"{stderr_buf.strip() or ''.join(first_lines)[:500]}"
109
+ )
110
+
111
+ if not agent_session_id:
112
+ raise RuntimeError(
113
+ f"gemini stream-json missing session_id; first lines:\n"
114
+ f"{''.join(first_lines)[:500]}"
115
+ )
116
+
117
+ # `gemini -p -o stream-json` does not reliably emit a `response` field
118
+ # in the stream (varies by version), so `last_response` can be empty
119
+ # even on success. The final answer is always in the session file —
120
+ # fall back to its last assistant turn so callers get real output.
121
+ session_file = self.find_session_file(agent_session_id, cwd)
122
+ initial_output = last_response or self._last_assistant_text(session_file)
123
+ return DispatchResult(
124
+ agent_session_id=agent_session_id,
125
+ cwd=str(cwd),
126
+ session_file=session_file,
127
+ initial_output=initial_output,
128
+ cost_usd=None, # Gemini json reports tokens, not USD
129
+ duration_seconds=duration,
130
+ exit_code=proc.returncode,
131
+ )
132
+
133
+ def resume(
134
+ self,
135
+ prompt: str,
136
+ cwd: Path,
137
+ agent_session_id: str,
138
+ files: list[Path] | None = None,
139
+ on_session_started: SessionStartedCallback | None = None,
140
+ model: str | None = None,
141
+ ) -> DispatchResult:
142
+ # Gemini's --resume takes an INDEX (or "latest"), not a UUID — resolve
143
+ # it from `gemini --list-sessions` run in the same cwd, since the list
144
+ # is per-project.
145
+ index = self._resolve_session_index(agent_session_id, cwd)
146
+ if index is None:
147
+ raise RuntimeError(
148
+ f"gemini --list-sessions has no entry matching {agent_session_id!r} in {cwd}"
149
+ )
150
+
151
+ full_prompt = self._build_prompt(prompt, files or [])
152
+ cmd = [
153
+ "gemini",
154
+ "--resume",
155
+ str(index),
156
+ "-p",
157
+ full_prompt,
158
+ "-o",
159
+ "json",
160
+ "--yolo",
161
+ ]
162
+ if model:
163
+ cmd += ["-m", model]
164
+ t0 = time.monotonic()
165
+ proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
166
+ duration = time.monotonic() - t0
167
+
168
+ if proc.returncode != 0:
169
+ raise RuntimeError(
170
+ f"gemini resume failed (exit {proc.returncode}): "
171
+ f"{proc.stderr.strip() or proc.stdout.strip()}"
172
+ )
173
+
174
+ try:
175
+ data = json.loads(proc.stdout)
176
+ except json.JSONDecodeError as e:
177
+ raise RuntimeError(
178
+ f"gemini resume returned non-JSON output: {proc.stdout[:500]}"
179
+ ) from e
180
+
181
+ # Gemini keeps the same session_id on resume.
182
+ new_session_id = data.get("session_id") or data.get("sessionId") or agent_session_id
183
+ if on_session_started is not None:
184
+ try:
185
+ on_session_started(new_session_id)
186
+ except Exception:
187
+ pass
188
+ session_file = self.find_session_file(new_session_id, cwd)
189
+ initial_output = data.get("response", "") or self._last_assistant_text(session_file)
190
+ return DispatchResult(
191
+ agent_session_id=new_session_id,
192
+ cwd=str(cwd),
193
+ session_file=session_file,
194
+ initial_output=initial_output,
195
+ cost_usd=None,
196
+ duration_seconds=duration,
197
+ exit_code=proc.returncode,
198
+ )
199
+
200
+ def _last_assistant_text(self, session_file: Path | None) -> str:
201
+ """Last non-empty assistant turn from the session file.
202
+
203
+ Used as a fallback when the CLI's streamed/`-o json` `response` field
204
+ is empty. The file can lag process exit slightly, so retry briefly.
205
+ """
206
+ if session_file is None:
207
+ return ""
208
+ for _ in range(5):
209
+ for turn in reversed(self.parse_session_file(session_file)):
210
+ if turn.role == "assistant" and turn.content.strip():
211
+ return turn.content
212
+ time.sleep(0.2)
213
+ return ""
214
+
215
+ @staticmethod
216
+ def _resolve_session_index(uuid: str, cwd: Path) -> int | None:
217
+ """Run `gemini --list-sessions` in cwd and find the index whose UUID matches.
218
+
219
+ Output format (one session per line):
220
+ " <N>. <title> (<age>) [<uuid>]"
221
+ """
222
+ proc = subprocess.run(
223
+ ["gemini", "--list-sessions"],
224
+ cwd=str(cwd),
225
+ capture_output=True,
226
+ text=True,
227
+ )
228
+ if proc.returncode != 0:
229
+ return None
230
+ # Capture leading index AND trailing bracketed uuid; lazy middle.
231
+ pattern = re.compile(r"^\s*(\d+)\..*\[([0-9a-f-]+)\]\s*$")
232
+ for line in proc.stdout.splitlines():
233
+ m = pattern.match(line)
234
+ if m and m.group(2) == uuid:
235
+ return int(m.group(1))
236
+ return None
237
+
238
+ def find_session_file(self, agent_session_id: str, cwd: Path) -> Path | None:
239
+ chats_dir = GEMINI_CHATS_ROOT / cwd.name / "chats"
240
+ if not chats_dir.is_dir():
241
+ return None
242
+ short_id = agent_session_id[:8]
243
+ matches: list[Path] = []
244
+ for ext in ("json", "jsonl"):
245
+ matches.extend(chats_dir.glob(f"session-*-{short_id}.{ext}"))
246
+ matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
247
+ if not matches:
248
+ return None
249
+ # Prefer the one whose first JSON value carries this exact sessionId.
250
+ for path in matches:
251
+ try:
252
+ with path.open("r", encoding="utf-8") as fh:
253
+ if path.suffix == ".jsonl":
254
+ head = fh.readline().strip()
255
+ if not head:
256
+ continue
257
+ obj = json.loads(head)
258
+ else:
259
+ obj = json.load(fh)
260
+ if obj.get("sessionId") == agent_session_id:
261
+ return path
262
+ except (OSError, json.JSONDecodeError):
263
+ continue
264
+ return matches[0]
265
+
266
+ def parse_session_file(self, path: Path) -> list[Turn]:
267
+ """Read Gemini session file and yield turns.
268
+
269
+ Two file formats coexist:
270
+ - .json single object {sessionId, ..., messages: [...]}
271
+ - .jsonl one event per line: {kind: 'main', ...} metadata
272
+ and {id, timestamp, type: user|gemini, content}
273
+
274
+ For both, message content is either a list[{text}] (user) or
275
+ a string (gemini), or sometimes empty (token-only chunk).
276
+ """
277
+
278
+ turns: list[Turn] = []
279
+ if not path.exists():
280
+ return turns
281
+
282
+ if path.suffix == ".json":
283
+ try:
284
+ with path.open("r", encoding="utf-8") as fh:
285
+ obj = json.load(fh)
286
+ except (OSError, json.JSONDecodeError):
287
+ return turns
288
+ for msg in obj.get("messages", []):
289
+ turn = self._gemini_msg_to_turn(msg)
290
+ if turn is not None:
291
+ turns.append(turn)
292
+ return turns
293
+
294
+ # .jsonl
295
+ with path.open("r", encoding="utf-8") as fh:
296
+ for raw in fh:
297
+ raw = raw.strip()
298
+ if not raw:
299
+ continue
300
+ try:
301
+ obj = json.loads(raw)
302
+ except json.JSONDecodeError:
303
+ continue
304
+ if obj.get("kind") == "main" and "type" not in obj:
305
+ continue # session metadata
306
+ turn = self._gemini_msg_to_turn(obj)
307
+ if turn is not None:
308
+ turns.append(turn)
309
+ return turns
310
+
311
+ @staticmethod
312
+ def _gemini_msg_to_turn(msg: dict) -> Turn | None:
313
+ from datetime import datetime
314
+
315
+ mtype = msg.get("type")
316
+ if mtype not in ("user", "gemini", "model"):
317
+ return None
318
+
319
+ ts = None
320
+ ts_raw = msg.get("timestamp")
321
+ if isinstance(ts_raw, str):
322
+ try:
323
+ ts = datetime.fromisoformat(ts_raw.replace("Z", "+00:00"))
324
+ except ValueError:
325
+ pass
326
+
327
+ content_raw = msg.get("content")
328
+ text = ""
329
+ if isinstance(content_raw, str):
330
+ text = content_raw
331
+ elif isinstance(content_raw, list):
332
+ chunks = []
333
+ for block in content_raw:
334
+ if isinstance(block, dict):
335
+ chunks.append(block.get("text") or "")
336
+ elif isinstance(block, str):
337
+ chunks.append(block)
338
+ text = "\n".join(c for c in chunks if c)
339
+
340
+ if not text and not msg.get("thoughts"):
341
+ # Streaming placeholder; ignore.
342
+ return None
343
+
344
+ role = "user" if mtype == "user" else "assistant"
345
+ return Turn(role=role, content=text, timestamp=ts)
346
+
347
+ @staticmethod
348
+ def _build_prompt(prompt: str, files: list[Path]) -> str:
349
+ if not files:
350
+ return prompt
351
+ refs = "\n".join(f"@{p}" for p in files)
352
+ return f"{prompt}\n\n{refs}"