fleetwatcher 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,377 @@
1
+ """Gemini CLI adapter.
2
+
3
+ The Gemini CLI keeps one directory per working tree under ``~/.gemini/tmp``. The
4
+ directory name is the *basename* of the project (not the full encoded cwd, as
5
+ Claude does), e.g. a session run in ``/Users/luke/workspace/cyoa-ios`` lands in
6
+ ``~/.gemini/tmp/cyoa-ios/``. Each such directory holds:
7
+
8
+ ~/.gemini/tmp/<project>/.project_root one line: the full cwd
9
+ ~/.gemini/tmp/<project>/chats/session-<ISO>-<8hex>.jsonl the transcript(s)
10
+ ~/.gemini/tmp/<project>/logs.json compact [{...user prompt...}]
11
+
12
+ There is one ``session-*.jsonl`` per session and one JSON record per line. The
13
+ record shapes this adapter cares about, as observed in the real transcripts on
14
+ this machine:
15
+
16
+ * **header / ``main``** — the very first record. It has *no* ``type`` key;
17
+ instead it carries ``{"sessionId", "projectHash", "startTime", "lastUpdated",
18
+ "kind": "main"}``. ``sessionId`` is the canonical id.
19
+ * **``user``** — a turn from the human's side. ``content`` is a list. A *real*
20
+ human prompt is ``[{"text": "..."}]``; but Gemini also writes tool results
21
+ back as ``user`` records whose ``content`` is ``[{"functionResponse": {...}}]``
22
+ — those are NOT human text and must be skipped when picking ``last_user``.
23
+ * **``gemini``** — a model response: ``content`` is a string (often ``""`` while
24
+ the turn is still tool-calling), plus ``thoughts`` (array), ``toolCalls``
25
+ (array), ``tokens``, ``model``.
26
+ * **``info``** — UI/system notices (e.g. "an extension update is available").
27
+ Not a conversation turn; ignored.
28
+ * Trailing **``$set``** bookkeeping records (no ``type``) also appear; ignored.
29
+
30
+ Tool calls are NOT stranded across records the way Claude's are: each entry in a
31
+ ``gemini`` record's ``toolCalls`` already carries its ``result`` (with an inline
32
+ ``functionResponse``) and a ``status`` in the *same* record. There is therefore
33
+ no dangling-tool_use signal, and Gemini CLI has **no human-approval gate** — a
34
+ tool runs without pausing for the user to grant permission.
35
+
36
+ WAITING — by design this adapter NEVER emits ``State.WAITING``. There is no
37
+ reliable "blocked on the human" signal in this format: tool calls resolve inline
38
+ (no permission prompt to wait on), and a transcript that ends on a ``user``
39
+ record just means the model is still thinking (waiting on the *model*, not on the
40
+ human). That is ACTIVE if the file is fresh and IDLE/DONE once it goes cold. The
41
+ state machine is purely recency-driven:
42
+
43
+ ACTIVE : now - mtime <= ACTIVE_WINDOW
44
+ IDLE : ACTIVE_WINDOW < now - mtime < DONE_AFTER
45
+ DONE : now - mtime >= DONE_AFTER
46
+ ERROR : unreadable / no records
47
+
48
+ Todos: Gemini has no todo/plan tool in this format (its ``update_topic`` call is
49
+ a research-framing note, not a task list), so ``todos`` is always empty.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import os
55
+ import time
56
+ from datetime import datetime
57
+ from typing import Optional
58
+
59
+ from ..config import ACTIVE_WINDOW, DONE_AFTER
60
+ from ..models import SessionState, State
61
+ from .base import Adapter, SessionRef, Source
62
+
63
+ GEMINI_ROOT = "~/.gemini"
64
+ TMP_ROOT = "~/.gemini/tmp"
65
+ SESSIONS_GLOB = "~/.gemini/tmp/*/chats/session-*.jsonl"
66
+
67
+
68
+ def _clip(text: str, limit: int = 280) -> str:
69
+ """Collapse whitespace and clip to ``limit`` chars for the detail pane."""
70
+ text = " ".join((text or "").split())
71
+ if len(text) <= limit:
72
+ return text
73
+ return text[: limit - 1].rstrip() + "…"
74
+
75
+
76
+ def _basename(path: Optional[str]) -> str:
77
+ if not path:
78
+ return ""
79
+ return os.path.basename(path.replace("\\", "/").rstrip("/"))
80
+
81
+
82
+ def _parse_ts(value) -> Optional[float]:
83
+ """ISO8601 (``...Z``) -> epoch seconds, or ``None`` if unparseable."""
84
+ if not isinstance(value, str) or not value:
85
+ return None
86
+ try:
87
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
88
+ except ValueError:
89
+ return None
90
+
91
+
92
+ def _project_dir(path: str) -> Optional[str]:
93
+ """The ``<project>`` directory name for a ``.../tmp/<project>/chats/x.jsonl``.
94
+
95
+ That is the parent of ``chats/`` (i.e. the chats dir's parent's basename).
96
+ """
97
+ parts = path.replace("\\", "/").rstrip("/").split("/")
98
+ # .../tmp/<project>/chats/<file>.jsonl -> <project> is parts[-3]
99
+ if len(parts) >= 3 and parts[-2] == "chats":
100
+ return parts[-3]
101
+ return None
102
+
103
+
104
+ def _session_id_from_filename(path: str) -> str:
105
+ """Best-effort session id from ``session-<ISO>-<8hex>.jsonl`` (fallback only)."""
106
+ stem = _basename(path)
107
+ if stem.endswith(".jsonl"):
108
+ stem = stem[: -len(".jsonl")]
109
+ return stem
110
+
111
+
112
+ def _cwd_from_project_root(source: Source, path: str) -> Optional[str]:
113
+ """Read the full cwd from the sibling ``.project_root`` file, if present.
114
+
115
+ ``path`` is the session file; ``.project_root`` lives two levels up, beside
116
+ the ``chats/`` directory: ``.../tmp/<project>/.project_root``.
117
+ """
118
+ norm = path.replace("\\", "/").rstrip("/")
119
+ parts = norm.split("/")
120
+ if len(parts) < 3 or parts[-2] != "chats":
121
+ return None
122
+ project_dir = "/".join(parts[:-2]) # drop "chats/<file>.jsonl"
123
+ root_file = project_dir + "/.project_root"
124
+ try:
125
+ if not source.exists(root_file):
126
+ return None
127
+ text = source.read_text(root_file).strip()
128
+ except Exception:
129
+ return None
130
+ return text or None
131
+
132
+
133
+ def _user_text(record: dict) -> str:
134
+ """Human-readable text from a ``user`` record.
135
+
136
+ ``content`` is a list; real human prompts carry ``{"text": ...}`` items.
137
+ ``functionResponse`` items (tool results echoed back as a user turn) are not
138
+ human text and contribute nothing.
139
+ """
140
+ content = record.get("content")
141
+ if isinstance(content, str): # defensive; not seen in the wild
142
+ return content
143
+ if not isinstance(content, list):
144
+ return ""
145
+ chunks = []
146
+ for item in content:
147
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
148
+ chunks.append(item["text"])
149
+ return " ".join(chunks).strip()
150
+
151
+
152
+ def _is_system_prompt(text: str) -> bool:
153
+ """The session-bootstrap prompts the UI shouldn't show as a human turn.
154
+
155
+ Gemini seeds a session with a ``<session_context>`` block and ``/init``-style
156
+ instruction templates; treat those as scaffolding, not real prompts.
157
+ """
158
+ head = text.lstrip()[:64]
159
+ return ("<session_context>" in text
160
+ or head.startswith("You are an AI agent")
161
+ or head.startswith("You are Gemini"))
162
+
163
+
164
+ def _gemini_text(record: dict) -> str:
165
+ """The model's visible text for a ``gemini`` record (``content`` string)."""
166
+ content = record.get("content")
167
+ return content if isinstance(content, str) else ""
168
+
169
+
170
+ def _last_user(records: list) -> str:
171
+ """The most recent real human prompt across the transcript."""
172
+ for rec in reversed(records):
173
+ if rec.get("type") != "user":
174
+ continue
175
+ text = _user_text(rec)
176
+ if text and not _is_system_prompt(text):
177
+ return text
178
+ return ""
179
+
180
+
181
+ def _last_agent(records: list) -> str:
182
+ """The most recent non-empty model text across the transcript."""
183
+ for rec in reversed(records):
184
+ if rec.get("type") != "gemini":
185
+ continue
186
+ text = _gemini_text(rec)
187
+ if text.strip():
188
+ return text
189
+ return ""
190
+
191
+
192
+ def _last_user_from_logs(source: Source, session_path: str, session_id: str) -> str:
193
+ """Fallback for ``last_user``: the compact ``logs.json`` beside the project.
194
+
195
+ ``logs.json`` is a list of ``{sessionId, messageId, type, message, timestamp}``
196
+ records (the human prompts only). Used only when the transcript tail yields
197
+ no usable prompt (e.g. a long tool-only tail past the 0.5 MB window).
198
+ """
199
+ norm = session_path.replace("\\", "/").rstrip("/")
200
+ parts = norm.split("/")
201
+ if len(parts) < 3 or parts[-2] != "chats":
202
+ return ""
203
+ logs_file = "/".join(parts[:-2]) + "/logs.json"
204
+ try:
205
+ if not source.exists(logs_file):
206
+ return ""
207
+ records = source.tail_records(logs_file)
208
+ except Exception:
209
+ return ""
210
+ # tail_records parses JSONL; logs.json is a single JSON array, so fall back to
211
+ # reading + json-loading it directly if the line parse came back empty.
212
+ if not records:
213
+ try:
214
+ import json as _json
215
+
216
+ data = _json.loads(source.read_text(logs_file))
217
+ records = data if isinstance(data, list) else []
218
+ except Exception:
219
+ return ""
220
+ best = ""
221
+ for rec in records:
222
+ if not isinstance(rec, dict):
223
+ continue
224
+ if rec.get("sessionId") not in (None, session_id):
225
+ continue
226
+ msg = rec.get("message")
227
+ if isinstance(msg, str) and msg.strip() and not msg.startswith("/"):
228
+ best = msg # keep walking; last match wins
229
+ return best
230
+
231
+
232
+ def _doing(records: list) -> str:
233
+ """A short one-liner describing what the session is doing right now.
234
+
235
+ Driven by the last meaningful record:
236
+
237
+ * last record is a ``user`` turn -> "thinking" (model is composing a reply)
238
+ * last ``gemini`` record has tool calls -> "calling <fn>"
239
+ * otherwise the model produced text -> "responding"
240
+ """
241
+ last_meaningful = None
242
+ for rec in reversed(records):
243
+ if rec.get("type") in ("user", "gemini"):
244
+ last_meaningful = rec
245
+ break
246
+ if last_meaningful is None:
247
+ return ""
248
+ if last_meaningful.get("type") == "user":
249
+ return "thinking"
250
+ tool_calls = last_meaningful.get("toolCalls")
251
+ if isinstance(tool_calls, list) and tool_calls:
252
+ last_call = tool_calls[-1]
253
+ name = ""
254
+ if isinstance(last_call, dict):
255
+ name = last_call.get("name") or last_call.get("displayName") or ""
256
+ return f"calling {name}".rstrip() if name else "calling a tool"
257
+ return "responding"
258
+
259
+
260
+ def _session_id(records: list, ref: SessionRef) -> str:
261
+ """The canonical id from the ``main`` header, else the filename fallback."""
262
+ for rec in records:
263
+ if rec.get("kind") == "main":
264
+ sid = rec.get("sessionId")
265
+ if isinstance(sid, str) and sid:
266
+ return sid
267
+ return ref.session_id
268
+
269
+
270
+ def _last_timestamp(records: list) -> Optional[float]:
271
+ for rec in reversed(records):
272
+ ts = _parse_ts(rec.get("timestamp"))
273
+ if ts is not None:
274
+ return ts
275
+ return None
276
+
277
+
278
+ class GeminiAdapter(Adapter):
279
+ vendor = "gemini"
280
+
281
+ def discover(self, source: Source) -> list[SessionRef]:
282
+ try:
283
+ if not source.exists(GEMINI_ROOT):
284
+ return []
285
+ refs: list[SessionRef] = []
286
+ for path in source.glob(SESSIONS_GLOB):
287
+ norm = path.replace("\\", "/")
288
+ if "/chats/" not in norm:
289
+ continue
290
+ refs.append(
291
+ SessionRef(
292
+ path=path,
293
+ session_id=_session_id_from_filename(path),
294
+ cwd=_cwd_from_project_root(source, path),
295
+ )
296
+ )
297
+ return refs
298
+ except Exception:
299
+ return []
300
+
301
+ def read(
302
+ self, source: Source, ref: SessionRef, prev: Optional[SessionState]
303
+ ) -> Optional[SessionState]:
304
+ try:
305
+ return self._read(source, ref)
306
+ except Exception as exc: # read() must never raise
307
+ return SessionState(
308
+ vendor=self.vendor,
309
+ session_id=ref.session_id,
310
+ project=_project_dir(ref.path) or _basename(ref.cwd) or ref.session_id,
311
+ cwd=ref.cwd,
312
+ source=getattr(source, "host", "local"),
313
+ last_activity=None,
314
+ state=State.ERROR,
315
+ error=f"read failed: {type(exc).__name__}",
316
+ )
317
+
318
+ def _read(self, source: Source, ref: SessionRef) -> Optional[SessionState]:
319
+ records = source.tail_records(ref.path)
320
+
321
+ now = time.time()
322
+ mtime = source.mtime(ref.path)
323
+ recency = now - mtime if mtime else None
324
+
325
+ cwd = ref.cwd or _cwd_from_project_root(source, ref.path)
326
+ project = _project_dir(ref.path) or _basename(cwd) or ref.session_id
327
+
328
+ if not records:
329
+ # Empty or wholly unparseable tail — flag rather than fabricate state.
330
+ return SessionState(
331
+ vendor=self.vendor,
332
+ session_id=ref.session_id,
333
+ project=project,
334
+ cwd=cwd,
335
+ source=getattr(source, "host", "local"),
336
+ last_activity=mtime or None,
337
+ state=State.ERROR,
338
+ error="no readable records",
339
+ )
340
+
341
+ session_id = _session_id(records, ref)
342
+ last_ts = _last_timestamp(records)
343
+ last_activity = mtime or last_ts
344
+
345
+ last_user = _last_user(records)
346
+ if not last_user:
347
+ last_user = _last_user_from_logs(source, ref.path, session_id)
348
+ last_user = _clip(last_user)
349
+ last_agent = _clip(_last_agent(records))
350
+ doing = _doing(records)
351
+
352
+ # Purely recency-driven. WAITING is intentionally never emitted: Gemini
353
+ # has no approval gate and resolves tool calls inline, so there is no
354
+ # reliable "blocked on the human" signal to key off of.
355
+ is_active = recency is not None and recency <= ACTIVE_WINDOW
356
+ if is_active:
357
+ state = State.ACTIVE
358
+ elif recency is not None and recency >= DONE_AFTER:
359
+ state = State.DONE
360
+ else:
361
+ state = State.IDLE
362
+
363
+ return SessionState(
364
+ vendor=self.vendor,
365
+ session_id=session_id,
366
+ project=project,
367
+ cwd=cwd,
368
+ source=getattr(source, "host", "local"),
369
+ last_activity=last_activity,
370
+ state=state,
371
+ doing=doing,
372
+ needs=None,
373
+ summary=None,
374
+ todos=[],
375
+ last_user=last_user,
376
+ last_agent=last_agent,
377
+ )