opencode-chat-exporter 1.0.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,2 @@
1
+ """chat_exporter - export full OpenCode / AntiGravity conversations from local data."""
2
+ __version__ = "1.1.0"
@@ -0,0 +1,16 @@
1
+ """Entry point: `python -m chat_exporter` launches the GUI, `--cli` runs the console tool."""
2
+ import sys
3
+
4
+ from .cli import main as cli_main
5
+
6
+
7
+ def main():
8
+ if len(sys.argv) > 1 and sys.argv[1] in ("--cli", "export", "list", "export-all", "watch"):
9
+ sys.argv = sys.argv[:1] + [a for a in sys.argv[1:] if a != "--cli"]
10
+ sys.exit(cli_main())
11
+ from .gui import main as gui_main
12
+ gui_main()
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
@@ -0,0 +1,599 @@
1
+ """AntiGravity conversation reader.
2
+
3
+ Conversation data for Google's AntiGravity (the Gemini coding agent) lives under
4
+ `%USERPROFILE%\\.gemini\\antigravity`:
5
+
6
+ - `conversations\\<cascade-uuid>.db` SQLite database with protobuf-encoded steps
7
+ - `brain\\<cascade-uuid>\\.system_generated\\logs\\transcript.jsonl` readable JSON-Lines transcript
8
+
9
+ The transcript file is the primary source (plain JSON, one event per line). When it is
10
+ missing, this module falls back to decoding the protobuf blobs inside the SQLite database
11
+ (steps / task_details / render_info / metadata / step_payload columns).
12
+ """
13
+ import json
14
+ import os
15
+ import re
16
+ import sqlite3
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ DEFAULT_AG_DIR = Path(os.path.expandvars(r"%USERPROFILE%\.gemini\antigravity"))
22
+
23
+ # transcript.jsonl entry kinds that represent tool execution results
24
+ TOOL_KIND_NAMES = {
25
+ "LIST_DIRECTORY": "list_dir",
26
+ "VIEW_FILE": "view_file",
27
+ "RUN_COMMAND": "run_command",
28
+ "CODE_ACTION": "code",
29
+ "SEARCH_WEB": "search_web",
30
+ "GENERIC": "tool",
31
+ }
32
+ SKIP_KINDS = {"CONVERSATION_HISTORY"}
33
+
34
+
35
+ # ---------------------------------------------------------------- small helpers
36
+ def _parse_iso(value: str) -> int | None:
37
+ """ISO timestamp string -> epoch milliseconds."""
38
+ try:
39
+ s = value.replace("Z", "+00:00")
40
+ t = datetime.fromisoformat(s)
41
+ if t.tzinfo is None:
42
+ t = t.replace(tzinfo=timezone.utc)
43
+ return int(t.timestamp() * 1000)
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ def _strip_meta_lines(text: str) -> str:
49
+ """Drop the leading 'Created At: ... / Completed At: ...' header."""
50
+ lines = text.splitlines()
51
+ while lines and (lines[0].startswith("Created At:") or lines[0].startswith("Completed At:")):
52
+ lines.pop(0)
53
+ return "\n".join(lines).strip()
54
+
55
+
56
+ def _clean_tool_content(kind: str, content: str | None, exit_code: Any) -> tuple[str | None, dict]:
57
+ """Normalize a tool result into (output_text, metadata)."""
58
+ if not content:
59
+ return None, {}
60
+ text = _strip_meta_lines(content)
61
+ metadata: dict = {}
62
+ if kind == "RUN_COMMAND":
63
+ if exit_code is not None:
64
+ metadata["exit"] = exit_code
65
+ m = re.search(r"The command exited with code (\d+)\.\s*\n*\s*Output:\s*\n*", text,
66
+ re.IGNORECASE)
67
+ if m:
68
+ text = text[m.end():].strip()
69
+ return (text or None), metadata
70
+
71
+
72
+ def _unquote(value: Any) -> Any:
73
+ """AntiGravity sometimes stores JSON argument values as extra-quoted strings."""
74
+ if isinstance(value, str) and value.startswith('"') and value.endswith('"'):
75
+ try:
76
+ return json.loads(value)
77
+ except Exception:
78
+ return value
79
+ if isinstance(value, list):
80
+ return [_unquote(v) for v in value]
81
+ if isinstance(value, dict):
82
+ return {k: _unquote(v) for k, v in value.items()}
83
+ return value
84
+
85
+
86
+ # ---------------------------------------------------------------- transcript.jsonl
87
+ def load_transcript(path: Path) -> list[dict]:
88
+ """Read a transcript.jsonl file into a list of event dicts."""
89
+ out: list[dict] = []
90
+ with open(path, encoding="utf-8", errors="replace") as f:
91
+ for line in f:
92
+ line = line.strip()
93
+ if not line:
94
+ continue
95
+ try:
96
+ out.append(json.loads(line))
97
+ except Exception:
98
+ continue
99
+ return out
100
+
101
+
102
+ # ------------------------------------------------------------ SQLite fallback dir
103
+ def _iter_fields(buf: bytes):
104
+ """Minimal protobuf wire-format reader: yields (field, wire_type, value).
105
+
106
+ value is an int for varints and bytes for length-delimited fields.
107
+ """
108
+ i, n = 0, len(buf)
109
+ while i < n:
110
+ tag = 0
111
+ shift = 0
112
+ while i < n:
113
+ b = buf[i]
114
+ i += 1
115
+ tag |= (b & 0x7F) << shift
116
+ if not (b & 0x80):
117
+ break
118
+ shift += 7
119
+ field = tag >> 3
120
+ wt = tag & 7
121
+ if wt == 0:
122
+ val = 0
123
+ shift = 0
124
+ while i < n:
125
+ b = buf[i]
126
+ i += 1
127
+ val |= (b & 0x7F) << shift
128
+ if not (b & 0x80):
129
+ break
130
+ shift += 7
131
+ yield field, wt, val
132
+ elif wt == 1:
133
+ i += 8
134
+ elif wt == 2:
135
+ ln = 0
136
+ shift = 0
137
+ while i < n:
138
+ b = buf[i]
139
+ i += 1
140
+ ln |= (b & 0x7F) << shift
141
+ if not (b & 0x80):
142
+ break
143
+ shift += 7
144
+ end = i + ln
145
+ if end > n:
146
+ return
147
+ yield field, wt, buf[i:end]
148
+ i = end
149
+ elif wt == 5:
150
+ i += 4
151
+ else:
152
+ return
153
+
154
+
155
+ def _field(buf: bytes, wanted: int) -> list[Any]:
156
+ """Collect field values matching a field number."""
157
+ found: list[Any] = []
158
+ try:
159
+ for f, wt, v in _iter_fields(bytes(buf)):
160
+ if f == wanted:
161
+ found.append(v)
162
+ except Exception:
163
+ pass
164
+ return found
165
+
166
+
167
+ def _str_field(buf: bytes, wanted: int) -> str | None:
168
+ for v in _field(buf, wanted):
169
+ if isinstance(v, bytes):
170
+ try:
171
+ s = v.decode("utf-8")
172
+ if s:
173
+ return s
174
+ except Exception:
175
+ continue
176
+ return None
177
+
178
+
179
+ def _varint_field(buf: bytes, wanted: int) -> int | None:
180
+ for v in _field(buf, wanted):
181
+ if isinstance(v, int):
182
+ return v
183
+ return None
184
+
185
+
186
+ def _decode_timestamp_ms(buf: bytes) -> int | None:
187
+ """Protobuf google.protobuf.Timestamp {seconds, nanos} -> epoch milliseconds."""
188
+ secs = _varint_field(buf, 1)
189
+ if secs is None:
190
+ return None
191
+ nanos = _varint_field(buf, 2) or 0
192
+ return int(secs * 1000 + nanos / 1_000_000)
193
+
194
+
195
+ def _db_entry_to_json(r: sqlite3.Row, title_out: list) -> dict | None:
196
+ """Convert one `steps` row into the same shape as a transcript.jsonl entry.
197
+
198
+ Returns None for internal bookkeeping steps that should be skipped.
199
+ """
200
+ payload = r["step_payload"] or b""
201
+ meta = r["metadata"] or b""
202
+ step_type = r["step_type"]
203
+ status = r["status"]
204
+ e: dict = {
205
+ "step_index": r["idx"],
206
+ "source": "MODEL",
207
+ "type": "GENERIC",
208
+ "status": "DONE",
209
+ "created_at": "",
210
+ }
211
+
212
+ # --- tool call info from metadata.f4 { id, name, argsJson } ---
213
+ tool_name: str | None = None
214
+ for f in _field(meta, 4):
215
+ name = _str_field(f, 2)
216
+ if name:
217
+ tool_name = name
218
+ summary = _str_field(meta, 30)
219
+ action = _str_field(meta, 31)
220
+
221
+ if step_type == 14: # user message
222
+ text = None
223
+ for f19 in _field(payload, 19):
224
+ text = _str_field(f19, 2)
225
+ if text:
226
+ break
227
+ if not text:
228
+ return None
229
+ e.update(type="USER_INPUT", source="USER_EXPLICIT", status="DONE", content=text)
230
+ return e
231
+
232
+ if step_type == 15: # assistant turn
233
+ content = None
234
+ tool_calls: list[dict] = []
235
+ for f20 in _field(payload, 20):
236
+ if content is None:
237
+ content = _str_field(f20, 3)
238
+ for f7 in _field(f20, 7):
239
+ tc = {"name": _str_field(f7, 2) or "tool"}
240
+ aj = _str_field(f7, 3)
241
+ if aj:
242
+ try:
243
+ tc["args"] = json.loads(aj)
244
+ except Exception:
245
+ tc["args"] = {"raw": aj}
246
+ tool_calls.append(tc)
247
+ e.update(type="PLANNER_RESPONSE", status="DONE")
248
+ if content:
249
+ e["content"] = content
250
+ if tool_calls:
251
+ e["tool_calls"] = tool_calls
252
+ return e
253
+
254
+ if step_type in (5, 9, 8): # tool calls / results
255
+ kind = {5: "CODE_ACTION", 8: "VIEW_FILE", 9: "LIST_DIRECTORY"}[step_type]
256
+ e.update(type=kind, status="DONE")
257
+ parts: list[str] = []
258
+ if tool_name:
259
+ parts.append(f"Tool: {tool_name}")
260
+ if action:
261
+ parts.append(action)
262
+ if summary:
263
+ parts.append(summary)
264
+ # file content result
265
+ if step_type == 8:
266
+ for f14 in _field(payload, 14):
267
+ content = _str_field(f14, 4)
268
+ if content:
269
+ parts.append(content)
270
+ elif step_type == 5:
271
+ for f10 in _field(payload, 10):
272
+ inner = _field(f10, 1)
273
+ if inner:
274
+ content = _str_field(inner[0], 2)
275
+ if content:
276
+ parts.append(content)
277
+ e["content"] = "\n".join(parts).strip()
278
+ return e
279
+
280
+ if step_type == 21: # run_command
281
+ e.update(type="RUN_COMMAND")
282
+ stdout: str | None = None
283
+ exit_code: int | None = None
284
+ for f28 in _field(payload, 28):
285
+ for f21 in _field(f28, 21):
286
+ stdout = _str_field(f21, 1)
287
+ exit_code = _varint_field(f28, 6)
288
+ if status in (6, 7): # failed / denied
289
+ e["status"] = "ERROR" if status == 6 else "DENIED"
290
+ err = None
291
+ for f31 in _field(payload, 31):
292
+ err = _str_field(f31, 1) or _str_field(f31, 2) or _str_field(f31, 3)
293
+ if err:
294
+ break
295
+ e["content"] = err or stdout
296
+ if exit_code is not None:
297
+ e["exit_code"] = exit_code
298
+ return e
299
+ e["status"] = "DONE"
300
+ e["exit_code"] = exit_code if exit_code is not None else 0
301
+ e["content"] = stdout or f"{tool_name or 'run_command'} returned {summary or 'ok'}"
302
+ return e
303
+
304
+ if step_type == 17: # invalid tool call
305
+ e.update(type="ERROR_MESSAGE", source="SYSTEM", status="DONE")
306
+ err = None
307
+ for f24 in _field(payload, 24):
308
+ err = _str_field(f24, 9) or _str_field(f24, 3)
309
+ if err:
310
+ break
311
+ e["error"] = err or "invalid tool call"
312
+ e["content"] = "Error invalid tool call: " + (err or "")
313
+ return e
314
+
315
+ if step_type == 33: # search_web
316
+ e.update(type="SEARCH_WEB", status="DONE")
317
+ text = None
318
+ for f42 in _field(payload, 42):
319
+ text = _str_field(f42, 5) or _str_field(f42, 1)
320
+ if text:
321
+ break
322
+ e["content"] = text or summary or "search completed"
323
+ return e
324
+
325
+ if step_type == 132: # manage_task -> generic result
326
+ e.update(type="GENERIC", status="DONE")
327
+ text = None
328
+ for f140 in _field(payload, 140):
329
+ for f2 in _field(f140, 2):
330
+ text = _str_field(f2, 1)
331
+ if text:
332
+ break
333
+ e["content"] = text or summary or "task updated"
334
+ return e
335
+
336
+ if step_type == 101: # task notification
337
+ e.update(type="SYSTEM_MESSAGE", source="SYSTEM", status="DONE")
338
+ text: str | None = None
339
+ for f114 in _field(payload, 114):
340
+ for f2 in _field(f114, 2):
341
+ text = _str_field(f2, 1)
342
+ if text:
343
+ break
344
+ if text:
345
+ break
346
+ e["content"] = text or "task notification"
347
+ return e
348
+
349
+ if step_type == 23: # conversation header: carries the title
350
+ for f30 in _field(payload, 30):
351
+ title = _str_field(f30, 4)
352
+ if title:
353
+ title_out.append(title)
354
+ return None
355
+
356
+ return None
357
+
358
+
359
+ def load_steps_from_db(db: Path) -> list[dict]:
360
+ """Decode every meaningful step from the conversations SQLite database."""
361
+ entries: list[dict] = []
362
+ title_holder: list = []
363
+ with sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=30) as con:
364
+ con.row_factory = sqlite3.Row
365
+ rows = con.execute(
366
+ "SELECT idx, step_type, status, metadata, step_payload FROM steps ORDER BY idx"
367
+ ).fetchall()
368
+ for r in rows:
369
+ try:
370
+ e = _db_entry_to_json(r, title_holder)
371
+ except Exception:
372
+ e = None
373
+ if e:
374
+ entries.append(e)
375
+ return entries
376
+
377
+
378
+ # ---------------------------------------------------------------- messages model
379
+ def _msg(role: str, time_ms: int | None, parts: list[dict], mid: str) -> dict:
380
+ return {
381
+ "id": mid,
382
+ "time_created": time_ms,
383
+ "time_updated": time_ms,
384
+ "data": {"role": role},
385
+ "parts": parts,
386
+ }
387
+
388
+
389
+ def _text_part(text: str) -> dict:
390
+ return {"data": {"type": "text", "text": text}}
391
+
392
+
393
+ def _reasoning_part(text: str) -> dict:
394
+ return {"data": {"type": "reasoning", "text": text}}
395
+
396
+
397
+ def _tool_part(tool: str, status: str, inp: Any, output: Any = None,
398
+ metadata: dict | None = None) -> dict:
399
+ state: dict = {"status": status, "input": inp or {}}
400
+ if output is not None:
401
+ state["output"] = output
402
+ if metadata:
403
+ state["metadata"] = metadata
404
+ return {"data": {"type": "tool", "tool": tool, "state": state}}
405
+
406
+
407
+ def entries_to_messages(entries: list[dict]) -> list[dict]:
408
+ """Convert transcript-style entries into the OpenCode message model used by the GUI."""
409
+ messages: list[dict] = []
410
+ for n, e in enumerate(entries):
411
+ kind = e.get("type", "")
412
+ time_ms = _parse_iso(e.get("created_at") or "")
413
+ mid = f"ag-{n}"
414
+ if kind == "USER_INPUT":
415
+ text = (e.get("content") or "").strip()
416
+ if text.startswith("<USER_REQUEST>"):
417
+ text = text[len("<USER_REQUEST>"):]
418
+ end = text.find("</USER_REQUEST>")
419
+ if end != -1:
420
+ text = text[:end]
421
+ text = text.strip()
422
+ messages.append(_msg("user", time_ms, [_text_part(text)], mid))
423
+ elif kind == "PLANNER_RESPONSE":
424
+ parts: list[dict] = []
425
+ content = (e.get("content") or "").strip()
426
+ if content:
427
+ parts.append(_text_part(content))
428
+ thinking = (e.get("thinking") or "").strip()
429
+ if thinking:
430
+ parts.append(_reasoning_part(thinking))
431
+ for tc in e.get("tool_calls") or []:
432
+ parts.append(_tool_part(tc.get("name", "tool"), "completed",
433
+ _unquote(tc.get("args", {}))))
434
+ if not parts:
435
+ parts.append(_text_part("*(no visible response)*"))
436
+ messages.append(_msg("assistant", time_ms, parts, mid))
437
+ elif kind in TOOL_KIND_NAMES:
438
+ tool = TOOL_KIND_NAMES[kind]
439
+ output, metadata = _clean_tool_content(kind, e.get("content"), e.get("exit_code"))
440
+ status = "error" if e.get("status") == "ERROR" else (
441
+ "cancelled" if e.get("status") == "DENIED" else "completed")
442
+ messages.append(_msg("assistant", time_ms,
443
+ [_tool_part(tool, status, {}, output, metadata or None)], mid))
444
+ elif kind == "ERROR_MESSAGE":
445
+ messages.append(_msg("assistant", time_ms,
446
+ [_tool_part("error", "error", {},
447
+ e.get("content") or e.get("error") or "tool error")], mid))
448
+ elif kind == "SYSTEM_MESSAGE":
449
+ text = (e.get("content") or "").strip()
450
+ if text:
451
+ messages.append(_msg("system", time_ms, [_text_part(text)], mid))
452
+ elif kind == "CHECKPOINT":
453
+ text = (e.get("content") or "").strip()
454
+ if text:
455
+ text = text[:2000] + "\n…[checkpoint truncated]" if len(text) > 2000 else text
456
+ messages.append(_msg("system", time_ms, [_text_part("🧹 " + text)], mid))
457
+ # CONVERSATION_HISTORY and unknown kinds are skipped
458
+ return messages
459
+
460
+
461
+ # ---------------------------------------------------------------- public class
462
+ class AntiGravityDB:
463
+ """Read-only access to AntiGravity conversations, mirroring OpenCodeDB's interface."""
464
+
465
+ def __init__(self, root: Path | None = None) -> None:
466
+ p = Path(root) if root else DEFAULT_AG_DIR
467
+ self.brain_dir: Path | None = None
468
+ self.single_db: Path | None = None
469
+ if p.is_file() and p.suffix.lower() == ".db":
470
+ # a single conversations *.db file was given directly
471
+ self.single_db = Path(p)
472
+ self.conv_dir = self.single_db.parent
473
+ self.brain_dir = self.single_db.parent.parent / "brain"
474
+ elif p.is_dir():
475
+ if (p / "conversations").is_dir():
476
+ self.conv_dir = p / "conversations"
477
+ self.brain_dir = p / "brain"
478
+ elif any(p.glob("*.db")):
479
+ # the conversations folder itself was given
480
+ self.conv_dir = p
481
+ self.brain_dir = p.parent / "brain"
482
+ else:
483
+ self.conv_dir = p / "conversations"
484
+ self.brain_dir = p / "brain"
485
+ else:
486
+ raise FileNotFoundError(f"AntiGravity folder not found: {p}")
487
+ if self.single_db is None and not self.conv_dir.is_dir():
488
+ raise FileNotFoundError(f"No AntiGravity conversations folder at: {self.conv_dir}")
489
+ self._cache: dict[str, dict] = {}
490
+ self._info_cache: dict[str, dict] = {}
491
+
492
+ # ----------------------------------------------------------------- sessions
493
+ def list_cascades(self) -> list[str]:
494
+ if self.single_db is not None:
495
+ return [self.single_db.stem]
496
+ if not self.conv_dir.is_dir():
497
+ return []
498
+ return sorted(p.stem for p in self.conv_dir.glob("*.db"))
499
+
500
+ def _could_load(self, cascade_id: str) -> bool:
501
+ return self._transcript(cascade_id).exists() or self._db(cascade_id).exists()
502
+
503
+ def _transcript(self, cascade_id: str) -> Path:
504
+ if self.brain_dir is not None:
505
+ return self.brain_dir / cascade_id / ".system_generated" / "logs" / "transcript.jsonl"
506
+ return _transcript_path(Path.cwd(), cascade_id)
507
+
508
+ def _db(self, cascade_id: str) -> Path:
509
+ if self.single_db is not None:
510
+ return self.single_db
511
+ return self.conv_dir / f"{cascade_id}.db"
512
+
513
+ def _session_info(self, cascade_id: str, entries: list[dict],
514
+ messages: list[dict]) -> dict:
515
+ title = ""
516
+ created = updated = None
517
+ for e in entries:
518
+ t = _parse_iso(e.get("created_at") or "")
519
+ if t:
520
+ created = t if created is None else min(created, t)
521
+ updated = t if updated is None else max(updated, t)
522
+ if not title:
523
+ for e in entries:
524
+ if e.get("type") == "USER_INPUT":
525
+ text = (e.get("content") or "").strip()
526
+ text = re.sub(r"^<USER_REQUEST>\s*", "", text)
527
+ title = text.replace("\r", " ").replace("\n", " ")[:64].strip()
528
+ break
529
+ if not title:
530
+ title = f"Conversation {cascade_id[:8]}"
531
+ brain = self.brain_dir / cascade_id if self.brain_dir is not None else None
532
+ directory = str(brain) if brain is not None and brain.is_dir() else str(
533
+ self.conv_dir if self.single_db is None else self.single_db.parent.parent)
534
+ return {
535
+ "id": cascade_id,
536
+ "title": title,
537
+ "directory": directory,
538
+ "agent": "antigravity",
539
+ "model": None,
540
+ "time_created": created,
541
+ "time_updated": updated,
542
+ "time_archived": None,
543
+ "message_count": len(messages),
544
+ "part_count": sum(len(m.get("parts", [])) for m in messages),
545
+ "steps": len(entries),
546
+ }
547
+
548
+ def _load_cascade(self, cascade_id: str) -> tuple[list[dict], list[dict]]:
549
+ """Return (entries, messages) for one cascade."""
550
+ tr = load_transcript(self._transcript(cascade_id))
551
+ if tr:
552
+ entries = [e for e in tr if e.get("type") not in SKIP_KINDS]
553
+ return entries, entries_to_messages(entries)
554
+ db = self._db(cascade_id)
555
+ if db.exists():
556
+ entries = load_steps_from_db(db)
557
+ return entries, entries_to_messages(entries)
558
+ return [], []
559
+
560
+ def list_sessions(self, include_archived: bool = False) -> list[dict]:
561
+ sessions = []
562
+ for cid in self.list_cascades():
563
+ try:
564
+ entries, messages = self._load_cascade(cid)
565
+ except Exception:
566
+ continue
567
+ if not messages and not entries:
568
+ continue
569
+ info = self._session_info(cid, entries, messages)
570
+ sessions.append(info)
571
+ self._cache[cid] = entries
572
+ self._info_cache[cid] = info
573
+ sessions.sort(key=lambda s: (s["time_updated"] or 0), reverse=True)
574
+ return sessions
575
+
576
+ # ------------------------------------------------------------- conversation
577
+ def get_session(self, cascade_id: str) -> dict | None:
578
+ if cascade_id in self._info_cache:
579
+ return self._info_cache[cascade_id]
580
+ entries, messages = self._load_cascade(cascade_id)
581
+ if not entries and not messages:
582
+ return None
583
+ info = self._session_info(cascade_id, entries, messages)
584
+ self._info_cache[cascade_id] = info
585
+ return info
586
+
587
+ def get_session_messages(self, cascade_id: str) -> list[dict]:
588
+ if cascade_id in self._cache:
589
+ entries = self._cache[cascade_id]
590
+ else:
591
+ entries, _ = self._load_cascade(cascade_id)
592
+ return entries_to_messages(entries)
593
+
594
+ # ----------------------------------------------------------------- helpers
595
+ @staticmethod
596
+ def ts(ms: int | None) -> datetime | None:
597
+ if not isinstance(ms, int) or ms <= 0:
598
+ return None
599
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).astimezone()