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.
- playmaker/__init__.py +10 -0
- playmaker/__main__.py +4 -0
- playmaker/_skills/playmaker-coach/SKILL.md +282 -0
- playmaker/agents/__init__.py +0 -0
- playmaker/agents/agy.py +342 -0
- playmaker/agents/base.py +96 -0
- playmaker/agents/claude.py +307 -0
- playmaker/agents/codex.py +387 -0
- playmaker/agents/gemini.py +352 -0
- playmaker/cli.py +978 -0
- playmaker/config.py +30 -0
- playmaker/notify.py +82 -0
- playmaker/quotas.py +940 -0
- playmaker/registry.py +41 -0
- playmaker/state.py +173 -0
- playmaker/watcher.py +145 -0
- playmaker_cli-0.4.0.dist-info/METADATA +335 -0
- playmaker_cli-0.4.0.dist-info/RECORD +21 -0
- playmaker_cli-0.4.0.dist-info/WHEEL +4 -0
- playmaker_cli-0.4.0.dist-info/entry_points.txt +2 -0
- playmaker_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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}"
|