interlatch 0.22.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.
Files changed (118) hide show
  1. chronicle/__init__.py +17 -0
  2. chronicle/__main__.py +3 -0
  3. chronicle/access.py +93 -0
  4. chronicle/agents.py +23 -0
  5. chronicle/analyze.py +498 -0
  6. chronicle/antigravity_parser.py +311 -0
  7. chronicle/artifacts.py +621 -0
  8. chronicle/bob_parser.py +259 -0
  9. chronicle/cases.py +75 -0
  10. chronicle/chat_import.py +325 -0
  11. chronicle/chatgpt_export.py +127 -0
  12. chronicle/claude_export.py +129 -0
  13. chronicle/cli.py +2980 -0
  14. chronicle/codex_cloud.py +138 -0
  15. chronicle/codex_parser.py +753 -0
  16. chronicle/config.py +627 -0
  17. chronicle/connectors.py +666 -0
  18. chronicle/container.py +202 -0
  19. chronicle/copilot_parser.py +494 -0
  20. chronicle/db.py +598 -0
  21. chronicle/desktop.py +543 -0
  22. chronicle/diagram.py +488 -0
  23. chronicle/digest.py +194 -0
  24. chronicle/export_md.py +210 -0
  25. chronicle/friction.py +841 -0
  26. chronicle/glossary.py +522 -0
  27. chronicle/groups.py +181 -0
  28. chronicle/hooks.py +169 -0
  29. chronicle/hub.py +2291 -0
  30. chronicle/i18n.py +37 -0
  31. chronicle/i18n_ja.py +421 -0
  32. chronicle/ingest.py +1190 -0
  33. chronicle/install.py +590 -0
  34. chronicle/instructions.py +699 -0
  35. chronicle/ladder.py +244 -0
  36. chronicle/learning.py +170 -0
  37. chronicle/llm.py +570 -0
  38. chronicle/mcp_server.py +403 -0
  39. chronicle/menubar.py +771 -0
  40. chronicle/migrate.py +580 -0
  41. chronicle/mirror.py +453 -0
  42. chronicle/news.py +110 -0
  43. chronicle/notify.py +92 -0
  44. chronicle/parser.py +908 -0
  45. chronicle/people.py +536 -0
  46. chronicle/pg.py +122 -0
  47. chronicle/pricing.py +168 -0
  48. chronicle/providers.py +591 -0
  49. chronicle/redact.py +50 -0
  50. chronicle/reviews.py +251 -0
  51. chronicle/screen.py +308 -0
  52. chronicle/search.py +238 -0
  53. chronicle/server.py +3009 -0
  54. chronicle/session_export.py +178 -0
  55. chronicle/statusline.py +258 -0
  56. chronicle/suggest.py +643 -0
  57. chronicle/synthesize.py +365 -0
  58. chronicle/systems.py +1265 -0
  59. chronicle/tailnet.py +97 -0
  60. chronicle/team_store.py +450 -0
  61. chronicle/update.py +404 -0
  62. chronicle/util.py +217 -0
  63. chronicle/views.py +249 -0
  64. chronicle/web/PLEX-OFL.txt +93 -0
  65. chronicle/web/app.css +2350 -0
  66. chronicle/web/app.js +7587 -0
  67. chronicle/web/art-book.webp +0 -0
  68. chronicle/web/art-camera.webp +0 -0
  69. chronicle/web/art-chip.webp +0 -0
  70. chronicle/web/art-clock.webp +0 -0
  71. chronicle/web/art-cloud.webp +0 -0
  72. chronicle/web/art-coins.webp +0 -0
  73. chronicle/web/art-container.webp +0 -0
  74. chronicle/web/art-dashboard.webp +0 -0
  75. chronicle/web/art-dex.webp +0 -0
  76. chronicle/web/art-document.webp +0 -0
  77. chronicle/web/art-flag.webp +0 -0
  78. chronicle/web/art-folder.webp +0 -0
  79. chronicle/web/art-gateway.webp +0 -0
  80. chronicle/web/art-gears.webp +0 -0
  81. chronicle/web/art-glitch.webp +0 -0
  82. chronicle/web/art-globe.webp +0 -0
  83. chronicle/web/art-laptop.webp +0 -0
  84. chronicle/web/art-log.webp +0 -0
  85. chronicle/web/art-lumi.webp +0 -0
  86. chronicle/web/art-magnifier.webp +0 -0
  87. chronicle/web/art-pip.webp +0 -0
  88. chronicle/web/art-plug.webp +0 -0
  89. chronicle/web/art-rack.webp +0 -0
  90. chronicle/web/art-tink.webp +0 -0
  91. chronicle/web/boot.js +25 -0
  92. chronicle/web/favicon.png +0 -0
  93. chronicle/web/i18n.js +50 -0
  94. chronicle/web/icon-180.png +0 -0
  95. chronicle/web/icon-192.png +0 -0
  96. chronicle/web/icon-512.png +0 -0
  97. chronicle/web/icon.png +0 -0
  98. chronicle/web/index.html +92 -0
  99. chronicle/web/ja.js +1880 -0
  100. chronicle/web/learning.js +438 -0
  101. chronicle/web/manifest.webmanifest +15 -0
  102. chronicle/web/panel.css +175 -0
  103. chronicle/web/panel.html +14 -0
  104. chronicle/web/panel.js +241 -0
  105. chronicle/web/plex-mono-400.woff2 +0 -0
  106. chronicle/web/plex-mono-500.woff2 +0 -0
  107. chronicle/web/plex-mono-600.woff2 +0 -0
  108. chronicle/web/plex-sans-400.woff2 +0 -0
  109. chronicle/web/plex-sans-500.woff2 +0 -0
  110. chronicle/web/plex-sans-600.woff2 +0 -0
  111. chronicle/web/plex-sans-700.woff2 +0 -0
  112. chronicle/web/rough.js +2 -0
  113. chronicle/worker.py +390 -0
  114. interlatch-0.22.0.dist-info/METADATA +221 -0
  115. interlatch-0.22.0.dist-info/RECORD +118 -0
  116. interlatch-0.22.0.dist-info/WHEEL +4 -0
  117. interlatch-0.22.0.dist-info/entry_points.txt +3 -0
  118. interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,259 @@
1
+ """Parse IBM Bob tasks (~/.bob/db/bob.db) into the same model as Claude Code sessions.
2
+
3
+ bob.db holds `tasks` (directory, title, git info, costs JSON, status) and `messages` (one chat message per row in
4
+ `data`). Tool calls come in two shapes: OpenAI-style `tool_calls[].function` answered by a `tool_call_id`, and Bob's
5
+ own `toolCalls[]` ({id, name, arguments}) answered by a tool message whose `toolUsage.signature` names the call.
6
+ A subagent's whole conversation rides inside its spawn_subagent result (`messages`, `_meta.subagentId`). Each
7
+ assistant message reports its own spend in `_meta.spend` (cost, contextTokens). The database also stores login
8
+ state elsewhere in ~/.bob; only these two tables are read, read-only.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import posixpath
16
+ import re
17
+ import sqlite3
18
+ from pathlib import Path
19
+
20
+ from .copilot_parser import _Builder, _args, estimate_cost, finish_session, ms_to_iso, uri_path
21
+ from .parser import ApiCall, FileStat, ParsedSession, Subagent
22
+ from .pricing import normalize_model
23
+ from .util import one_line, safe_text
24
+
25
+ log = logging.getLogger("chronicle.bob")
26
+
27
+ BOB_PARSER_VERSION = 2 # 2: Bob's own toolCalls shape, subagents, per-message spend, relative paths
28
+ _ENV_RE = re.compile(r"<environment_details>.*?</environment_details>", re.S)
29
+ _TASK_RE = re.compile(r"^\s*<(?:task|user_message)>(.*?)</(?:task|user_message)>\s*$", re.S)
30
+
31
+
32
+ def bob_db(home: Path) -> Path:
33
+ return home / "db" / "bob.db"
34
+
35
+
36
+ def load_tasks(home: Path) -> list[dict]:
37
+ """Tasks with their messages, oldest message first. Read-only; returns [] if the database is unreadable."""
38
+ db = bob_db(home)
39
+ if not db.exists():
40
+ return []
41
+ try:
42
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
43
+ conn.row_factory = sqlite3.Row
44
+ tasks = [dict(r) for r in conn.execute("SELECT * FROM tasks ORDER BY created_at")]
45
+ msgs: dict[str, list[dict]] = {}
46
+ for r in conn.execute("SELECT id, task_id, role, data, created_at FROM messages ORDER BY created_at, id"):
47
+ msgs.setdefault(r["task_id"], []).append(dict(r))
48
+ conn.close()
49
+ except sqlite3.Error as exc:
50
+ log.warning("bob.db unreadable: %s", exc)
51
+ return []
52
+ for t in tasks:
53
+ t["messages"] = msgs.get(t["id"], [])
54
+ return tasks
55
+
56
+
57
+ def task_folder(task: dict) -> str | None:
58
+ """The folder a task ran in: its directory, else its project (a file: URI)."""
59
+ return uri_path(task.get("directory") or task.get("project_id") or "") or None
60
+
61
+
62
+ def task_signature(task: dict) -> str:
63
+ last = task["messages"][-1] if task["messages"] else {}
64
+ return f"{task.get('updated_at')}:{len(task['messages'])}:{last.get('id')}:{task.get('status')}"
65
+
66
+
67
+ def _text(content) -> str:
68
+ if isinstance(content, str):
69
+ return content
70
+ if isinstance(content, list):
71
+ return "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") in ("text", "input_text", "output_text"))
72
+ return ""
73
+
74
+
75
+ def _user_text(content) -> str:
76
+ text = _ENV_RE.sub("", _text(content)).strip()
77
+ m = _TASK_RE.match(text)
78
+ return m.group(1).strip() if m else text
79
+
80
+
81
+ def _calls(d: dict) -> list[tuple[str | None, str, dict]]:
82
+ """(call id, tool name, arguments) for each tool call in an assistant message, in either shape."""
83
+ out = []
84
+ for tc in d.get("tool_calls") or []:
85
+ fn = tc.get("function") if isinstance(tc, dict) else None
86
+ if isinstance(fn, dict):
87
+ out.append((tc.get("id"), fn.get("name") or "tool", _args(fn.get("arguments"))))
88
+ for tc in d.get("toolCalls") or []:
89
+ if isinstance(tc, dict):
90
+ out.append((tc.get("id"), tc.get("name") or "tool", _args(tc.get("arguments"))))
91
+ return out
92
+
93
+
94
+ def _signature(d: dict) -> dict:
95
+ usage = d.get("toolUsage")
96
+ sig = usage.get("signature") if isinstance(usage, dict) else None
97
+ return sig if isinstance(sig, dict) else {}
98
+
99
+
100
+ def _meta(d: dict) -> dict:
101
+ return d["_meta"] if isinstance(d.get("_meta"), dict) else {}
102
+
103
+
104
+ def _absolute(args: dict, cwd: str | None) -> dict:
105
+ """Bob passes workspace-relative paths (docs/x.html); resolve them against the task's directory."""
106
+ if not cwd or not cwd.startswith("/"):
107
+ return args
108
+ out = dict(args)
109
+ for key in ("path", "file_path", "filePath"):
110
+ v = out.get(key)
111
+ if isinstance(v, str) and v and not v.startswith(("/", "~")) and ":" not in v:
112
+ out[key] = posixpath.normpath(posixpath.join(cwd, v))
113
+ return out
114
+
115
+
116
+ def _patch_lines(patch: str) -> tuple[int, int]:
117
+ """Lines added and removed in a unified diff (the ---/+++ file headers before the first hunk aren't lines)."""
118
+ added = removed = 0
119
+ in_hunk = False
120
+ for line in patch.splitlines():
121
+ if line.startswith("@@"):
122
+ in_hunk = True
123
+ elif in_hunk and line.startswith("+"):
124
+ added += 1
125
+ elif in_hunk and line.startswith("-"):
126
+ removed += 1
127
+ return added, removed
128
+
129
+
130
+ def _count_changes(ps: ParsedSession, changes: dict, counted: str | None) -> None:
131
+ """A tool result's `_meta.changes` ({file URI: {before, after, patch}}): what the call did to each file on disk."""
132
+ for uri, change in changes.items():
133
+ path = uri_path(uri)
134
+ if not path or not isinstance(change, dict):
135
+ continue
136
+ fs = ps.files.setdefault(path, FileStat())
137
+ if path != counted: # the call's own file was already counted as a write or an edit
138
+ fs.edits += 1
139
+ if isinstance(change.get("patch"), str):
140
+ added, removed = _patch_lines(change["patch"])
141
+ fs.lines_added += added
142
+ fs.lines_removed += removed
143
+
144
+
145
+ def _feed(b: _Builder, msgs: list[tuple[str | None, dict]], cwd: str | None) -> None:
146
+ """Replay one thread's messages (the task's, or a subagent's) into `b`."""
147
+ ps = b.ps
148
+ results: dict[str, dict] = {}
149
+ for _, d in msgs:
150
+ if d.get("role") != "tool":
151
+ continue
152
+ sig = _signature(d)
153
+ cid = d.get("tool_call_id") or sig.get("id")
154
+ if cid:
155
+ results[cid] = d
156
+ for ts, d in msgs:
157
+ role = d.get("role")
158
+ if role == "system":
159
+ b.event(ts, "system", "meta", f"[system prompt, {len(_text(d.get('content'))):,} chars]")
160
+ elif role == "user":
161
+ if b.agent_id: # a subagent's brief, not a prompt the user typed
162
+ b.event(ts, "user", "prompt", _user_text(d.get("content")))
163
+ else:
164
+ b.prompt(ts, _user_text(d.get("content")))
165
+ elif role == "assistant":
166
+ reasoning = d.get("reasoning") or d.get("reasoning_content")
167
+ if isinstance(reasoning, str) and reasoning.strip():
168
+ b.event(ts, "assistant", "thinking", reasoning)
169
+ text = _text(d.get("content"))
170
+ if text.strip():
171
+ b.event(ts, "assistant", "text", text)
172
+ spend = _meta(d).get("spend")
173
+ if isinstance(spend, dict) and (spend.get("cost") or spend.get("contextTokens")):
174
+ # Bob reports what each call cost and how big its context was, but not the token split:
175
+ # the context counts as input, and the cost is Bob's own figure rather than an estimate
176
+ ps.api_calls.append(ApiCall(b.agent_id, str(d.get("id") or ""), ts, None,
177
+ input_tokens=int(spend.get("contextTokens") or 0),
178
+ cost_usd=float(spend.get("cost") or 0)))
179
+ for cid, name, args in _calls(d):
180
+ res = results.get(cid) or {}
181
+ text = _text(res.get("content")) if res else None
182
+ sig = _signature(res)
183
+ is_error = bool(sig["isError"]) if "isError" in sig else bool(text) and text.lower().startswith(("error", "failed"))
184
+ rmeta = _meta(res)
185
+ call = b.tool(ts, cid, name, _absolute(args, cwd), result=text, is_error=is_error,
186
+ duration_ms=rmeta.get("durationMs") if isinstance(rmeta.get("durationMs"), int) else None)
187
+ if not is_error and isinstance(rmeta.get("changes"), dict):
188
+ _count_changes(ps, rmeta["changes"], call.file_path)
189
+ sub = res.get("messages")
190
+ if rmeta.get("subagentId") and isinstance(sub, list):
191
+ _subagent(b, str(rmeta["subagentId"]), rmeta, args, cid, sub, cwd)
192
+
193
+
194
+ def _thread(rows: list[tuple[int | None, object]]) -> list[tuple[str | None, dict]]:
195
+ """[(created_at ms, raw data)] -> [(iso timestamp, message dict)], skipping rows that aren't JSON objects."""
196
+ out = []
197
+ for created_at, raw in rows:
198
+ try:
199
+ d = json.loads(raw) if isinstance(raw, str) else raw
200
+ except (TypeError, ValueError):
201
+ continue
202
+ if isinstance(d, dict):
203
+ out.append((ms_to_iso(_meta(d).get("timestamp") or created_at), d))
204
+ return out
205
+
206
+
207
+ def _subagent(parent: _Builder, aid: str, meta: dict, args: dict, call_id, msgs: list, cwd: str | None) -> None:
208
+ ps = parent.ps
209
+ sub = ps.subagents.setdefault(aid, Subagent(aid))
210
+ sub.agent_type = meta.get("agentType") or args.get("name") or None
211
+ sub.description = one_line(safe_text(str(args.get("description") or "")), 120) or None
212
+ sub.tool_use_id = call_id
213
+ b = _Builder(ps, agent_id=aid)
214
+ _feed(b, _thread([(None, m) for m in msgs]), cwd)
215
+ parent.stamps.extend(b.stamps)
216
+ stamps = sorted(s for s in b.stamps if s)
217
+ if stamps:
218
+ sub.started_at, sub.ended_at = stamps[0], stamps[-1]
219
+ calls = [c for c in ps.tool_calls if c.agent_id == aid]
220
+ sub.n_tool_calls, sub.n_tool_errors = len(calls), sum(c.is_error for c in calls)
221
+ for c in ps.api_calls:
222
+ if c.agent_id == aid:
223
+ sub.input_tokens += c.input_tokens
224
+ sub.est_cost_usd += c.cost_usd
225
+
226
+
227
+ def parse_bob_task(task: dict) -> ParsedSession | None:
228
+ msgs = task.get("messages") or []
229
+ if not any(m["role"] == "user" for m in msgs):
230
+ return None
231
+ ps = ParsedSession(id=task["id"])
232
+ ps.project_path = task_folder(task)
233
+ ps.git_branch = task.get("git_branch") or None
234
+ ps.cc_version = task.get("version") or None
235
+ ps.entrypoint = f"bob:{task.get('task_type') or 'normal'}"
236
+ ps.custom_title = task.get("title") or None
237
+ b = _Builder(ps)
238
+ for ms in (task.get("created_at"), task.get("updated_at")):
239
+ if ms_to_iso(ms):
240
+ b.stamps.append(ms_to_iso(ms))
241
+ thread = _thread([(m.get("created_at"), m["data"]) for m in msgs])
242
+ ps.parse_errors += len(msgs) - len(thread)
243
+ _feed(b, thread, ps.project_path)
244
+ if task.get("status") == "error" and task.get("last_error"):
245
+ ps.n_api_errors += 1
246
+ b.event(b.stamps[-1] if b.stamps else None, "system", "api_error", safe_text(str(task["last_error"]))[:300])
247
+ try:
248
+ costs = json.loads(task.get("costs") or "{}")
249
+ except ValueError:
250
+ costs = {}
251
+ if isinstance(costs, dict) and not ps.api_calls: # per-message spend, when Bob wrote it, already covers the task
252
+ model = normalize_model(costs.get("model"))
253
+ inp, out, cr, cw = (int(costs.get(k) or 0) for k in ("input", "output", "cacheRead", "cacheWrite"))
254
+ cost = float(costs.get("cost") or 0)
255
+ if inp or out or cr or cw or cost:
256
+ ps.api_calls.append(ApiCall("", "task-total", ms_to_iso(task.get("updated_at")), model, input_tokens=inp,
257
+ output_tokens=out, cache_read_tokens=cr, cache_write_tokens=cw,
258
+ cost_usd=cost or estimate_cost(model, inp, cr, cw, out)))
259
+ return finish_session(ps, b.stamps)
chronicle/cases.py ADDED
@@ -0,0 +1,75 @@
1
+ """A lesson's teaching material beyond its case file (analyze.case_of): principle, checks, clues, topics and a diagram.
2
+ Grounded in the session; personal learning history stays in the browser."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+
8
+ from .redact import redact
9
+ from .learning import topic_ids
10
+
11
+ LESSON_KINDS = ("fix", "gotcha", "decision", "learning", "pattern")
12
+
13
+
14
+ VISUAL_SCHEMA = {
15
+ "type": "object", "additionalProperties": False,
16
+ "required": ["type", "title", "nodes", "edges"],
17
+ "properties": {
18
+ "type": {"type": "string", "enum": ["flow", "relationship", "comparison"]},
19
+ "title": {"type": "string", "description": "Short title of the concept being illustrated"},
20
+ "nodes": {"type": "array", "items": {
21
+ "type": "object", "additionalProperties": False, "required": ["id", "label", "detail"],
22
+ "properties": {"id": {"type": "string", "description": "Unique short ASCII slug"},
23
+ "label": {"type": "string", "description": "1-4 words, using actual session terminology"},
24
+ "detail": {"type": "string", "description": "One sentence explaining this part or tradeoff"}}}},
25
+ "edges": {"type": "array", "items": {
26
+ "type": "object", "additionalProperties": False, "required": ["from", "to", "label"],
27
+ "properties": {"from": {"type": "string"}, "to": {"type": "string"},
28
+ "label": {"type": "string", "description": "Short, established relationship or action"}}}},
29
+ },
30
+ "description": "Small explanatory diagram: 2-4 parts, up to 5 connections, grounded in the lesson. "
31
+ "A flow shows order, a relationship shows connections, a comparison shows real alternatives. "
32
+ "Empty title, nodes and edges when unsupported. Never invent components or relationships.",
33
+ }
34
+
35
+
36
+ def visual_of(raw) -> dict | None:
37
+ if not isinstance(raw, dict) or raw.get("type") not in ("flow", "relationship", "comparison"):
38
+ return None
39
+
40
+ def text(value, limit):
41
+ return redact(value.strip())[:limit] if isinstance(value, str) else ""
42
+
43
+ nodes = {}
44
+ for node in raw.get("nodes") if isinstance(raw.get("nodes"), list) else []:
45
+ if not isinstance(node, dict):
46
+ continue
47
+ nid, label = text(node.get("id"), 32), text(node.get("label"), 80)
48
+ if re.fullmatch(r"[A-Za-z0-9_-]+", nid) and label and nid not in nodes and len(nodes) < 4:
49
+ nodes[nid] = {"id": nid, "label": label, "detail": text(node.get("detail"), 600)}
50
+ edges, seen = [], set()
51
+ for edge in raw.get("edges") if isinstance(raw.get("edges"), list) else []:
52
+ if not isinstance(edge, dict):
53
+ continue
54
+ a, b = edge.get("from"), edge.get("to")
55
+ if not isinstance(a, str) or not isinstance(b, str) or a not in nodes or b not in nodes or a == b or (a, b) in seen:
56
+ continue
57
+ seen.add((a, b))
58
+ edges.append({"from": a, "to": b, "label": text(edge.get("label"), 40)})
59
+ if len(nodes) < 2 or not edges and raw["type"] != "comparison":
60
+ return None
61
+ return {"type": raw["type"], "title": text(raw.get("title"), 100), "nodes": list(nodes.values()), "edges": edges[:5]}
62
+
63
+
64
+ def lesson_of(raw: dict) -> dict:
65
+ """The teaching fields the analysis supported, redacted; empty ones are left out, nothing is invented."""
66
+ def text(value) -> str:
67
+ return redact(value.strip())[:6000] if isinstance(value, str) else ""
68
+
69
+ def texts(value, limit: int) -> list[str]:
70
+ return [text(x) for x in value if isinstance(x, str) and text(x)][:limit] if isinstance(value, list) else []
71
+
72
+ out = {"clues": texts(raw.get("clues"), 6), "principle": text(raw.get("principle")),
73
+ "checklist": texts(raw.get("checklist"), 4), "topics": topic_ids(raw.get("topics")),
74
+ "visual": visual_of(raw.get("visual"))}
75
+ return {k: v for k, v in out.items() if v}
@@ -0,0 +1,325 @@
1
+ """Import chats from a claude.ai or ChatGPT data export as sessions (`interlatch import`, Sources › Import export…).
2
+
3
+ Both exports are a .zip with conversations.json; the format is recognized from its contents. Only the chats
4
+ (conversations.json, and claude.ai's projects.json for project names) are read and archived; the account files
5
+ (users.json, user.json) and ChatGPT's chat.html are never opened. Imports are repeatable: a newer export updates the
6
+ chats that changed and adds new ones.
7
+
8
+ Imported chats are not analyzed automatically (years of chats would use up the Claude plan's limits in one go):
9
+ screen them (screen.py) to queue the ones worth it, analyze one with **Analyze now**, or import with `--analyze` to
10
+ queue them all.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ import hashlib
17
+ import json
18
+ import logging
19
+ import re
20
+ import struct
21
+ import zipfile
22
+ import zlib
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from . import chatgpt_export, claude_export
27
+ from .util import utcnow_iso
28
+ from .views import NOT_ANALYZED_CHAT
29
+
30
+ log = logging.getLogger("chronicle.chat_import")
31
+
32
+ _CONVERSATIONS_RE = re.compile(r"^conversations(?:-\d+)?\.json$") # large exports may be split
33
+ _LOCAL_HEADER = struct.Struct("<4s5H3I2H") # signature, version, flags, method, time, date, crc, sizes, name/extra lengths
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Format:
38
+ key: str # kv and archive name
39
+ label: str
40
+ module: object
41
+
42
+ @property
43
+ def agent(self) -> str:
44
+ return self.module.AGENT
45
+
46
+ @property
47
+ def source(self) -> str:
48
+ return self.module.SOURCE
49
+
50
+ @property
51
+ def last_import_key(self) -> str:
52
+ return f"{self.key}:last-import"
53
+
54
+ @property
55
+ def not_analyzed(self) -> str:
56
+ return NOT_ANALYZED_CHAT.format(label=self.label)
57
+
58
+
59
+ CLAUDE_AI = Format("claude-ai", "claude.ai", claude_export)
60
+ CHATGPT = Format("chatgpt", "ChatGPT", chatgpt_export)
61
+ FORMATS = (CLAUDE_AI, CHATGPT)
62
+
63
+
64
+ class ExportError(Exception):
65
+ """The file is not an export Interlatch can read."""
66
+
67
+
68
+ @dataclass
69
+ class Export:
70
+ format: Format
71
+ conversations: list[dict]
72
+ files: dict[str, bytes] # what gets archived: the conversations file(s), claude.ai's projects.json
73
+ note: str | None = None # read from a zip cut off after its chats
74
+
75
+
76
+ def _wanted(name: str) -> bool:
77
+ return bool(_CONVERSATIONS_RE.match(name)) or name == "projects.json"
78
+
79
+
80
+ def _files(path: Path, name: str) -> tuple[dict[str, bytes], str | None]:
81
+ """The chat files of an export .zip, its unpacked folder, or a conversations.json, and a note when the zip was cut off."""
82
+ path = Path(path).expanduser()
83
+ if zipfile.is_zipfile(path):
84
+ with zipfile.ZipFile(path) as z:
85
+ names = {Path(n).name: n for n in z.namelist() if not n.startswith("__MACOSX/")}
86
+ return {n: z.read(full) for n, full in sorted(names.items()) if _wanted(n)}, None
87
+ if path.is_dir():
88
+ return {p.name: p.read_bytes() for p in sorted(path.iterdir()) if p.is_file() and _wanted(p.name)}, None
89
+ if path.suffix == ".json" and path.is_file():
90
+ sibling = path.with_name("projects.json")
91
+ return {"conversations.json": path.read_bytes(), **({"projects.json": sibling.read_bytes()} if sibling.is_file() else {})}, None
92
+ if path.is_file():
93
+ with open(path, "rb") as f:
94
+ if f.read(4) == b"PK\x03\x04":
95
+ return _cut_off_zip(path, name)
96
+ if path.exists():
97
+ raise ExportError(f"{name} is not a chat export: give the .zip, its unpacked folder, or conversations.json")
98
+ raise ExportError(f"{path} not found")
99
+
100
+
101
+ def _zip64_size(extra: bytes) -> int:
102
+ """The compressed size from a local header's zip64 extra field (original size first, then compressed)."""
103
+ i = 0
104
+ while i + 4 <= len(extra):
105
+ tag, n = struct.unpack_from("<HH", extra, i)
106
+ if tag == 1 and n >= 16:
107
+ return struct.unpack_from("<Q", extra, i + 12)[0]
108
+ i += 4 + n
109
+ return 0xFFFFFFFF
110
+
111
+
112
+ def _cut_off_zip(path: Path, name: str) -> tuple[dict[str, bytes], str]:
113
+ """The chat files of a zip without its end, as an interrupted download leaves it, read entry by entry.
114
+
115
+ `zipfile` needs the central directory, the last thing in a zip, so a partial download looks like no zip at all
116
+ even when every chat is there: ChatGPT puts conversations-*.json near the start and gigabytes of attachments
117
+ after them. The chats are accepted only when the cut falls in a later file Interlatch does not read.
118
+ """
119
+ size = path.stat().st_size
120
+ files: dict[str, bytes] = {}
121
+ cut_in = None # the entry the file ends in
122
+ redownload = f"{name} is an incomplete download ({size / 1e9:.2f} GB, the end of the zip is missing)"
123
+ with open(path, "rb") as f:
124
+ while True:
125
+ head = f.read(_LOCAL_HEADER.size)
126
+ if head[:4] == b"PK\x01\x02": # the central directory: every entry was read, only the zip's end is damaged
127
+ cut_in = ""
128
+ break
129
+ if len(head) < _LOCAL_HEADER.size or head[:4] != b"PK\x03\x04":
130
+ break
131
+ _, _, flags, method, _, _, crc, csize, _, nlen, xlen = _LOCAL_HEADER.unpack(head)
132
+ raw, extra = f.read(nlen), f.read(xlen)
133
+ entry = Path(raw.decode("utf-8", "replace")).name
134
+ if len(raw) < nlen or len(extra) < xlen:
135
+ cut_in = entry
136
+ break
137
+ if flags & 0x9: # encrypted, or the size only follows the data: the next entry cannot be found
138
+ break
139
+ if csize == 0xFFFFFFFF:
140
+ csize = _zip64_size(extra)
141
+ if f.tell() + csize > size:
142
+ cut_in = entry
143
+ break
144
+ if not (_wanted(entry) and not raw.startswith(b"__MACOSX/")):
145
+ f.seek(csize, 1)
146
+ continue
147
+ data = f.read(csize)
148
+ try:
149
+ data = data if method == 0 else zlib.decompress(data, -15) if method == 8 else None
150
+ except zlib.error:
151
+ data = None
152
+ if data is None or zlib.crc32(data) != crc:
153
+ raise ExportError(f"{redownload}, and {entry} in it is damaged: download the export again")
154
+ files[entry] = data
155
+ shards = [n for n in files if _CONVERSATIONS_RE.match(n)]
156
+ if cut_in is None or not shards or _wanted(cut_in):
157
+ where = f"inside {cut_in}" if cut_in and _wanted(cut_in) else "before all its chats could be read"
158
+ raise ExportError(f"{redownload}: it is cut off {where}. Download the export again")
159
+ note = ("the end of the zip is damaged, but every chat was read" if not cut_in else
160
+ f"incomplete download, cut off at {size / 1e9:.2f} GB in {cut_in}, after the chats: all {len(shards)} "
161
+ f"conversations file{'s' if len(shards) > 1 else ''} read")
162
+ log.warning("%s: %s", name, note)
163
+ return files, note
164
+
165
+
166
+ def _folders(path: Path) -> set[str]:
167
+ try:
168
+ with zipfile.ZipFile(path) as z:
169
+ return {n.split("/")[0] for n in z.namelist() if "/" in n}
170
+ except (OSError, zipfile.BadZipFile):
171
+ return set()
172
+
173
+
174
+ def read_export(path: Path, name: str | None = None) -> Export:
175
+ name = name or Path(path).name
176
+ files, note = _files(path, name)
177
+ shards = [n for n in files if _CONVERSATIONS_RE.match(n)]
178
+ if not shards and "projects" in _folders(path):
179
+ raise ExportError(f"{name} holds your claude.ai projects, not chats, and the export does not say which project a "
180
+ "chat belongs to: there is nothing to import from it. The chats are in conversations-000.zip.")
181
+ if not shards:
182
+ raise ExportError(f"no conversations.json in {name}: is it a claude.ai or ChatGPT data export? "
183
+ "(claude.ai sends several zips; the chats are in conversations-000.zip)")
184
+ conversations: list[dict] = []
185
+ for name in shards:
186
+ try:
187
+ data = json.loads(files[name])
188
+ except ValueError as exc:
189
+ raise ExportError(f"{name} is not valid JSON") from exc
190
+ if isinstance(data, dict):
191
+ data = data.get("conversations") or []
192
+ if not isinstance(data, list):
193
+ raise ExportError(f"{name} is not a list of conversations")
194
+ conversations += [c for c in data if isinstance(c, dict)]
195
+ fmt = CHATGPT if chatgpt_export.is_chatgpt(conversations) else CLAUDE_AI
196
+ if fmt is CLAUDE_AI and conversations and not claude_export.is_claude(conversations):
197
+ raise ExportError("conversations.json is neither a claude.ai nor a ChatGPT export")
198
+ if fmt is CHATGPT:
199
+ files.pop("projects.json", None)
200
+ return Export(fmt, conversations, files, note)
201
+
202
+
203
+ def import_export(cfg, conn, path: Path, *, analyze: bool = False, progress=None, name: str | None = None) -> dict:
204
+ """Import (or re-import) an export. `name` is the file's name when `path` is an upload's temporary copy."""
205
+ from .db import kv_set
206
+ from .ingest import _unchanged, forgotten_ids, store_parsed
207
+ from .util import file_lock
208
+
209
+ name = name or Path(path).name
210
+ export = read_export(Path(path), name)
211
+ fmt, mod = export.format, export.format.module
212
+ projects = claude_export.project_names(export.files.get("projects.json")) if fmt is CLAUDE_AI else {}
213
+ digest = hashlib.sha1(b"".join(export.files[k] for k in sorted(export.files))).hexdigest()[:12]
214
+ dst = cfg.archive_dir / fmt.key / digest
215
+ shard = next(n for n in export.files if _CONVERSATIONS_RE.match(n))
216
+ archive = dst / f"{shard}.gz"
217
+ if not archive.exists(): # the export's chats, kept for good
218
+ dst.mkdir(parents=True, exist_ok=True)
219
+ for fname, raw in export.files.items():
220
+ with gzip.open(dst / f"{fname}.gz", "wb") as f:
221
+ f.write(raw)
222
+ counts = {"format": fmt.label, "conversations": len(export.conversations), "new": 0, "updated": 0, "unchanged": 0,
223
+ "empty": 0, "excluded": 0, **({"note": export.note} if export.note else {})}
224
+ with file_lock(cfg.locks_dir / "ingest.lock", timeout=900) as got:
225
+ if not got:
226
+ raise ExportError("a sync is running; try again when it finishes")
227
+ skip = forgotten_ids(conn)
228
+ for i, conv in enumerate(export.conversations):
229
+ if progress and i % 50 == 0:
230
+ progress(f"importing {fmt.label} chats… {i:,}/{len(export.conversations):,}")
231
+ sid, sig = mod.conversation_id(conv), mod.conversation_signature(conv)
232
+ if not sid:
233
+ counts["empty"] += 1
234
+ continue
235
+ if sid in skip or _unchanged(conn, sid, sig, False):
236
+ counts["unchanged"] += 1
237
+ continue
238
+ ps = mod.parse_conversation(conv, projects)
239
+ if ps is None:
240
+ counts["empty"] += 1
241
+ continue
242
+ if cfg.is_excluded(ps.project_path):
243
+ counts["excluded"] += 1
244
+ continue
245
+ queued = conn.execute("SELECT 1 FROM sessions WHERE id = ? AND analysis_status = 'pending'", (sid,)).fetchone()
246
+ result = store_parsed(conn, cfg, ps, claude_dir=dst, project_dir=None, transcript_path=archive,
247
+ archive_path=archive, files_sig=sig, agent=fmt.agent, source=fmt.source)
248
+ if not analyze and not queued: # a chat already queued for analysis (screen --queue) stays queued
249
+ conn.execute("UPDATE sessions SET analysis_status = 'skipped', analysis_reason = ? "
250
+ "WHERE id = ? AND analysis_status = 'pending'", (fmt.not_analyzed, sid))
251
+ counts["new" if result == "new" else "updated"] += 1
252
+ conn.commit()
253
+ kv_set(conn, fmt.last_import_key, json.dumps({"at": utcnow_iso(), "file": name, **counts}))
254
+ conn.commit()
255
+ log.info("%s import from %s: %s", fmt.label, name, counts)
256
+ return counts
257
+
258
+
259
+ def reparse_archived(cfg, conn, skip: set[str] = frozenset()) -> int:
260
+ """Re-read, from the export each was last imported from (kept in the archive), the chats an older parser stored,
261
+ after a parser upgrade; each keeps its analysis state. Returns how many chats were re-parsed."""
262
+ from .ingest import store_parsed
263
+
264
+ done = 0
265
+ for fmt in FORMATS:
266
+ mod = fmt.module
267
+ tag = "|" + mod.conversation_signature({}).rsplit("|", 1)[-1] # every signature ends with the parser version
268
+ by_dir: dict[Path, set[str]] = {}
269
+ for sid, sig, archive in conn.execute("SELECT id, files_sig, archive_path FROM sessions WHERE source = ?", (fmt.source,)):
270
+ if sid not in skip and archive and not (sig or "").endswith(tag):
271
+ by_dir.setdefault(Path(archive).parent, set()).add(sid)
272
+ for d, stale in by_dir.items():
273
+ projects_gz = d / "projects.json.gz"
274
+ try:
275
+ projects = claude_export.project_names(gzip.decompress(projects_gz.read_bytes())) \
276
+ if fmt is CLAUDE_AI and projects_gz.exists() else {}
277
+ except OSError:
278
+ projects = {}
279
+ for shard in sorted(d.glob("conversations*.json.gz")):
280
+ try:
281
+ data = json.loads(gzip.decompress(shard.read_bytes()))
282
+ except (OSError, ValueError):
283
+ log.warning("could not read archived export %s", shard)
284
+ continue
285
+ data = data.get("conversations") or [] if isinstance(data, dict) else data
286
+ for conv in data if isinstance(data, list) else []:
287
+ sid = mod.conversation_id(conv) if isinstance(conv, dict) else None
288
+ if sid not in stale:
289
+ continue
290
+ stale.discard(sid)
291
+ ps = mod.parse_conversation(conv, projects)
292
+ if ps is None or cfg.is_excluded(ps.project_path):
293
+ continue
294
+ store_parsed(conn, cfg, ps, claude_dir=d, project_dir=None, transcript_path=shard, archive_path=shard,
295
+ files_sig=mod.conversation_signature(conv), agent=fmt.agent, source=fmt.source)
296
+ conn.commit()
297
+ done += 1
298
+ return done
299
+
300
+
301
+ def summary(counts: dict) -> str:
302
+ parts = [f"{counts['new']} new", f"{counts['updated']} updated", f"{counts['unchanged']} unchanged"]
303
+ if counts.get("empty"):
304
+ parts.append(f"{counts['empty']} empty")
305
+ if counts.get("excluded"):
306
+ parts.append(f"{counts['excluded']} excluded")
307
+ note = f" ({counts['note']})" if counts.get("note") else ""
308
+ return f"{counts['format']} export: {counts['conversations']} chats · " + ", ".join(parts) + note
309
+
310
+
311
+ def import_status(conn) -> dict:
312
+ """Per format: chats imported and analyzed, how screening sorted them, and the last import."""
313
+ from .db import kv_get
314
+ from .screen import screen_status
315
+
316
+ out = {}
317
+ for fmt in FORMATS:
318
+ r = conn.execute("SELECT COUNT(*), SUM(analysis_status = 'done') FROM sessions WHERE source = ?", (fmt.source,)).fetchone()
319
+ try:
320
+ last = json.loads(kv_get(conn, fmt.last_import_key) or "null")
321
+ except ValueError:
322
+ last = None
323
+ out[fmt.key] = {"label": fmt.label, "agent": fmt.agent, "sessions": r[0] or 0, "analyzed": r[1] or 0, "last_import": last,
324
+ "screen": screen_status(conn, fmt.source)}
325
+ return out