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.
- chronicle/__init__.py +17 -0
- chronicle/__main__.py +3 -0
- chronicle/access.py +93 -0
- chronicle/agents.py +23 -0
- chronicle/analyze.py +498 -0
- chronicle/antigravity_parser.py +311 -0
- chronicle/artifacts.py +621 -0
- chronicle/bob_parser.py +259 -0
- chronicle/cases.py +75 -0
- chronicle/chat_import.py +325 -0
- chronicle/chatgpt_export.py +127 -0
- chronicle/claude_export.py +129 -0
- chronicle/cli.py +2980 -0
- chronicle/codex_cloud.py +138 -0
- chronicle/codex_parser.py +753 -0
- chronicle/config.py +627 -0
- chronicle/connectors.py +666 -0
- chronicle/container.py +202 -0
- chronicle/copilot_parser.py +494 -0
- chronicle/db.py +598 -0
- chronicle/desktop.py +543 -0
- chronicle/diagram.py +488 -0
- chronicle/digest.py +194 -0
- chronicle/export_md.py +210 -0
- chronicle/friction.py +841 -0
- chronicle/glossary.py +522 -0
- chronicle/groups.py +181 -0
- chronicle/hooks.py +169 -0
- chronicle/hub.py +2291 -0
- chronicle/i18n.py +37 -0
- chronicle/i18n_ja.py +421 -0
- chronicle/ingest.py +1190 -0
- chronicle/install.py +590 -0
- chronicle/instructions.py +699 -0
- chronicle/ladder.py +244 -0
- chronicle/learning.py +170 -0
- chronicle/llm.py +570 -0
- chronicle/mcp_server.py +403 -0
- chronicle/menubar.py +771 -0
- chronicle/migrate.py +580 -0
- chronicle/mirror.py +453 -0
- chronicle/news.py +110 -0
- chronicle/notify.py +92 -0
- chronicle/parser.py +908 -0
- chronicle/people.py +536 -0
- chronicle/pg.py +122 -0
- chronicle/pricing.py +168 -0
- chronicle/providers.py +591 -0
- chronicle/redact.py +50 -0
- chronicle/reviews.py +251 -0
- chronicle/screen.py +308 -0
- chronicle/search.py +238 -0
- chronicle/server.py +3009 -0
- chronicle/session_export.py +178 -0
- chronicle/statusline.py +258 -0
- chronicle/suggest.py +643 -0
- chronicle/synthesize.py +365 -0
- chronicle/systems.py +1265 -0
- chronicle/tailnet.py +97 -0
- chronicle/team_store.py +450 -0
- chronicle/update.py +404 -0
- chronicle/util.py +217 -0
- chronicle/views.py +249 -0
- chronicle/web/PLEX-OFL.txt +93 -0
- chronicle/web/app.css +2350 -0
- chronicle/web/app.js +7587 -0
- chronicle/web/art-book.webp +0 -0
- chronicle/web/art-camera.webp +0 -0
- chronicle/web/art-chip.webp +0 -0
- chronicle/web/art-clock.webp +0 -0
- chronicle/web/art-cloud.webp +0 -0
- chronicle/web/art-coins.webp +0 -0
- chronicle/web/art-container.webp +0 -0
- chronicle/web/art-dashboard.webp +0 -0
- chronicle/web/art-dex.webp +0 -0
- chronicle/web/art-document.webp +0 -0
- chronicle/web/art-flag.webp +0 -0
- chronicle/web/art-folder.webp +0 -0
- chronicle/web/art-gateway.webp +0 -0
- chronicle/web/art-gears.webp +0 -0
- chronicle/web/art-glitch.webp +0 -0
- chronicle/web/art-globe.webp +0 -0
- chronicle/web/art-laptop.webp +0 -0
- chronicle/web/art-log.webp +0 -0
- chronicle/web/art-lumi.webp +0 -0
- chronicle/web/art-magnifier.webp +0 -0
- chronicle/web/art-pip.webp +0 -0
- chronicle/web/art-plug.webp +0 -0
- chronicle/web/art-rack.webp +0 -0
- chronicle/web/art-tink.webp +0 -0
- chronicle/web/boot.js +25 -0
- chronicle/web/favicon.png +0 -0
- chronicle/web/i18n.js +50 -0
- chronicle/web/icon-180.png +0 -0
- chronicle/web/icon-192.png +0 -0
- chronicle/web/icon-512.png +0 -0
- chronicle/web/icon.png +0 -0
- chronicle/web/index.html +92 -0
- chronicle/web/ja.js +1880 -0
- chronicle/web/learning.js +438 -0
- chronicle/web/manifest.webmanifest +15 -0
- chronicle/web/panel.css +175 -0
- chronicle/web/panel.html +14 -0
- chronicle/web/panel.js +241 -0
- chronicle/web/plex-mono-400.woff2 +0 -0
- chronicle/web/plex-mono-500.woff2 +0 -0
- chronicle/web/plex-mono-600.woff2 +0 -0
- chronicle/web/plex-sans-400.woff2 +0 -0
- chronicle/web/plex-sans-500.woff2 +0 -0
- chronicle/web/plex-sans-600.woff2 +0 -0
- chronicle/web/plex-sans-700.woff2 +0 -0
- chronicle/web/rough.js +2 -0
- chronicle/worker.py +390 -0
- interlatch-0.22.0.dist-info/METADATA +221 -0
- interlatch-0.22.0.dist-info/RECORD +118 -0
- interlatch-0.22.0.dist-info/WHEEL +4 -0
- interlatch-0.22.0.dist-info/entry_points.txt +3 -0
- interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Parse ChatGPT data export conversations (chatgpt.com › Settings › Data controls › Export data).
|
|
2
|
+
|
|
3
|
+
Each conversation in conversations.json is a tree (`mapping`: node id -> message, parent, children), because edits
|
|
4
|
+
and regenerated answers branch it. The branch ChatGPT shows ends at `current_node`; only that branch is read.
|
|
5
|
+
Messages are OpenAI-style: author role user / assistant / tool / system, content with a `content_type` (text,
|
|
6
|
+
multimodal_text, code, execution_output, thoughts, ...), and an assistant message addressed to a tool (`recipient`
|
|
7
|
+
other than "all") is a tool call whose output follows as a tool message.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .copilot_parser import _Builder, finish_session
|
|
13
|
+
from .parser import ParsedSession
|
|
14
|
+
from .util import parse_ts, safe_text, to_iso
|
|
15
|
+
|
|
16
|
+
CHATGPT_PARSER_VERSION = 1
|
|
17
|
+
AGENT = "chatgpt"
|
|
18
|
+
SOURCE = "chatgpt-export"
|
|
19
|
+
_SKIP = {"user_editable_context", "model_editable_context", "reasoning_recap", "system_error"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _iso(value) -> str | None:
|
|
23
|
+
return to_iso(parse_ts(value))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_chatgpt(conversations: list[dict]) -> bool:
|
|
27
|
+
return any("mapping" in c for c in conversations[:5])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def conversation_id(conv: dict) -> str | None:
|
|
31
|
+
return conv.get("conversation_id") or conv.get("id")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def conversation_signature(conv: dict) -> str:
|
|
35
|
+
return f"{conv.get('update_time')}|{len(conv.get('mapping') or {})}|{conv.get('current_node')}|v{CHATGPT_PARSER_VERSION}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def thread(conv: dict) -> list[dict]:
|
|
39
|
+
"""The messages of the branch ChatGPT displays, oldest first."""
|
|
40
|
+
mapping = conv.get("mapping") or {}
|
|
41
|
+
node = conv.get("current_node")
|
|
42
|
+
if node not in mapping: # no current node recorded: the newest leaf
|
|
43
|
+
leaves = [k for k, v in mapping.items() if isinstance(v, dict) and not v.get("children")]
|
|
44
|
+
node = max(leaves, key=lambda k: ((mapping[k].get("message") or {}).get("create_time") or 0), default=None)
|
|
45
|
+
out, seen = [], set()
|
|
46
|
+
while node in mapping and node not in seen:
|
|
47
|
+
seen.add(node)
|
|
48
|
+
if isinstance(mapping[node].get("message"), dict):
|
|
49
|
+
out.append(mapping[node]["message"])
|
|
50
|
+
node = mapping[node].get("parent")
|
|
51
|
+
return out[::-1]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _text(content: dict) -> tuple[str, int]:
|
|
55
|
+
"""A message's text and how many images it carries."""
|
|
56
|
+
if isinstance(content.get("text"), str): # code, execution_output, tether_quote
|
|
57
|
+
return content["text"].strip(), 0
|
|
58
|
+
texts, images = [], 0
|
|
59
|
+
for part in content.get("parts") or []:
|
|
60
|
+
if isinstance(part, str):
|
|
61
|
+
texts.append(part)
|
|
62
|
+
elif isinstance(part, dict):
|
|
63
|
+
if part.get("content_type") == "image_asset_pointer":
|
|
64
|
+
images += 1
|
|
65
|
+
elif isinstance(part.get("text"), str):
|
|
66
|
+
texts.append(part["text"])
|
|
67
|
+
if not texts and isinstance(content.get("result"), str): # browsing output
|
|
68
|
+
texts.append(content["result"])
|
|
69
|
+
return "\n".join(t for t in texts if t).strip(), images
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _thoughts(content: dict) -> str:
|
|
73
|
+
return "\n\n".join(t.get("content") or t.get("summary") or "" for t in content.get("thoughts") or []
|
|
74
|
+
if isinstance(t, dict)).strip()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_conversation(conv: dict, projects: dict | None = None) -> ParsedSession | None:
|
|
78
|
+
msgs = [m for m in thread(conv) if not (m.get("metadata") or {}).get("is_visually_hidden_from_conversation")]
|
|
79
|
+
if not any((m.get("author") or {}).get("role") == "user" and any(_text(m.get("content") or {})) for m in msgs):
|
|
80
|
+
return None # an empty chat
|
|
81
|
+
ps = ParsedSession(id=conversation_id(conv))
|
|
82
|
+
ps.project_path = "chatgpt.com"
|
|
83
|
+
ps.entrypoint = "chatgpt"
|
|
84
|
+
ps.custom_title = safe_text(conv.get("title") or "") or None
|
|
85
|
+
b = _Builder(ps)
|
|
86
|
+
b.stamps += [t for t in (_iso(conv.get("create_time")), _iso(conv.get("update_time"))) if t]
|
|
87
|
+
calls: list[tuple] = [] # tool calls waiting for their output: (id, recipient, ts, args)
|
|
88
|
+
for m in msgs:
|
|
89
|
+
role = (m.get("author") or {}).get("role")
|
|
90
|
+
content = m.get("content") or {}
|
|
91
|
+
kind = content.get("content_type")
|
|
92
|
+
meta = m.get("metadata") or {}
|
|
93
|
+
ts = _iso(m.get("create_time"))
|
|
94
|
+
if meta.get("model_slug"):
|
|
95
|
+
ps.models[meta["model_slug"]] += 1
|
|
96
|
+
if role == "system" or kind in _SKIP:
|
|
97
|
+
continue
|
|
98
|
+
text, images = _text(content)
|
|
99
|
+
if role == "user":
|
|
100
|
+
ps.n_images += images
|
|
101
|
+
notes = [f"[attached {a.get('name') or 'a file'}]" for a in meta.get("attachments") or [] if isinstance(a, dict)]
|
|
102
|
+
if images:
|
|
103
|
+
notes.append(f"[{images} image{'s' if images > 1 else ''}]")
|
|
104
|
+
b.prompt(ts, "\n\n".join([text, *notes]).strip())
|
|
105
|
+
elif role == "assistant" and kind == "thoughts":
|
|
106
|
+
if _thoughts(content):
|
|
107
|
+
b.event(ts, "assistant", "thinking", _thoughts(content))
|
|
108
|
+
elif role == "assistant" and m.get("recipient") not in (None, "all"):
|
|
109
|
+
calls.append((m.get("id"), m["recipient"], ts, {"code" if kind == "code" else "input": text} if text else {}))
|
|
110
|
+
elif role == "assistant":
|
|
111
|
+
if text:
|
|
112
|
+
b.event(ts, "assistant", "text", text)
|
|
113
|
+
elif role == "tool":
|
|
114
|
+
name = (m.get("author") or {}).get("name") or "tool"
|
|
115
|
+
call = next((c for c in calls if c[1] == name or name.startswith(c[1].split(".")[0])), calls[0] if calls else None)
|
|
116
|
+
status = (meta.get("aggregate_result") or {}).get("status")
|
|
117
|
+
result = text or f"[{kind or 'output'}]"
|
|
118
|
+
if call:
|
|
119
|
+
calls.remove(call)
|
|
120
|
+
b.tool(call[2], call[0], call[1], call[3], result=result, is_error=status not in (None, "success"))
|
|
121
|
+
else: # output of a step ChatGPT did not show as a call (browsing, image generation)
|
|
122
|
+
b.tool(ts, m.get("id"), name, {}, result=result)
|
|
123
|
+
for call in calls: # calls whose output is not in the export
|
|
124
|
+
b.tool(call[2], call[0], call[1], call[3])
|
|
125
|
+
if conv.get("default_model_slug") and not ps.models:
|
|
126
|
+
ps.models[conv["default_model_slug"]] += 1
|
|
127
|
+
return finish_session(ps, b.stamps)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Parse claude.ai data export conversations (claude.ai › Settings › Privacy › Export data).
|
|
2
|
+
|
|
3
|
+
Each conversation in conversations.json has `chat_messages` (sender human / assistant) whose `content` blocks
|
|
4
|
+
hold text, thinking, tool use and tool results, plus attached files; projects.json names the claude.ai projects.
|
|
5
|
+
Importing is in chat_import.py, shared with ChatGPT exports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections import Counter
|
|
12
|
+
|
|
13
|
+
from . import artifacts
|
|
14
|
+
from .copilot_parser import _Builder, finish_session
|
|
15
|
+
from .parser import ParsedSession
|
|
16
|
+
from .util import parse_ts, safe_text, to_iso
|
|
17
|
+
|
|
18
|
+
CLAUDE_AI_PARSER_VERSION = 3 # 3: artifacts (claude.ai artifacts, files presented); 2: only the branch shown, pasted images
|
|
19
|
+
AGENT = "claude-ai"
|
|
20
|
+
SOURCE = "claude-ai-export"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _iso(value) -> str | None:
|
|
24
|
+
return to_iso(parse_ts(value))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_claude(conversations: list[dict]) -> bool:
|
|
28
|
+
return any("chat_messages" in c for c in conversations[:5])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def conversation_id(conv: dict) -> str | None:
|
|
32
|
+
return conv.get("uuid")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def project_names(raw: bytes | None) -> dict[str, str]:
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(raw) if raw else []
|
|
38
|
+
except ValueError:
|
|
39
|
+
return {}
|
|
40
|
+
return {p["uuid"]: p.get("name") or "" for p in data if isinstance(p, dict) and p.get("uuid")} if isinstance(data, list) else {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def conversation_signature(conv: dict) -> str:
|
|
44
|
+
msgs = conv.get("chat_messages") or []
|
|
45
|
+
last = msgs[-1].get("uuid") if msgs and isinstance(msgs[-1], dict) else None
|
|
46
|
+
return f"{conv.get('updated_at')}|{len(msgs)}|{last}|v{CLAUDE_AI_PARSER_VERSION}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _blocks(msg: dict) -> list[dict]:
|
|
50
|
+
content = msg.get("content")
|
|
51
|
+
if isinstance(content, list) and any(isinstance(b, dict) for b in content):
|
|
52
|
+
return [b for b in content if isinstance(b, dict)]
|
|
53
|
+
return [{"type": "text", "text": msg.get("text") or ""}]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _result_text(block: dict) -> str:
|
|
57
|
+
content = block.get("content")
|
|
58
|
+
if isinstance(content, list):
|
|
59
|
+
return "\n".join(c.get("text", "") for c in content if isinstance(c, dict))
|
|
60
|
+
return content if isinstance(content, str) else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def thread(msgs: list[dict]) -> list[dict]:
|
|
64
|
+
"""The branch claude.ai shows. Editing a prompt or retrying an answer forks the chat (messages point to their
|
|
65
|
+
parent), and the export has every branch: keep the chain that ends at the newest message."""
|
|
66
|
+
by_id = {m.get("uuid"): m for m in msgs}
|
|
67
|
+
children = Counter(m.get("parent_message_uuid") for m in msgs if m.get("parent_message_uuid") in by_id)
|
|
68
|
+
if not children or max(children.values()) < 2:
|
|
69
|
+
return msgs # no forks (or no parent links, as in older exports)
|
|
70
|
+
node, chain, seen = max(msgs, key=lambda m: m.get("created_at") or ""), [], set()
|
|
71
|
+
while node is not None and node.get("uuid") not in seen:
|
|
72
|
+
seen.add(node.get("uuid"))
|
|
73
|
+
chain.append(node)
|
|
74
|
+
node = by_id.get(node.get("parent_message_uuid"))
|
|
75
|
+
return chain[::-1]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_conversation(conv: dict, projects: dict[str, str]) -> ParsedSession | None:
|
|
79
|
+
msgs = thread([m for m in conv.get("chat_messages") or [] if isinstance(m, dict)])
|
|
80
|
+
if not any(m.get("sender") == "human" for m in msgs):
|
|
81
|
+
return None # an empty chat
|
|
82
|
+
ps = ParsedSession(id=conv["uuid"])
|
|
83
|
+
project = conv.get("project") if isinstance(conv.get("project"), dict) else {}
|
|
84
|
+
name = project.get("name") or projects.get(conv.get("project_uuid") or project.get("uuid") or "")
|
|
85
|
+
ps.project_path = f"claude.ai/{name}" if name else "claude.ai"
|
|
86
|
+
ps.entrypoint = "claude.ai"
|
|
87
|
+
ps.custom_title = safe_text(conv.get("name") or "") or None
|
|
88
|
+
b = _Builder(ps, where="claude.ai")
|
|
89
|
+
b.stamps += [t for t in (_iso(conv.get("created_at")), _iso(conv.get("updated_at"))) if t]
|
|
90
|
+
for m in msgs:
|
|
91
|
+
ts = _iso(m.get("created_at"))
|
|
92
|
+
blocks = _blocks(m)
|
|
93
|
+
if m.get("sender") == "human":
|
|
94
|
+
text = "\n\n".join(bl.get("text") or "" for bl in blocks if bl.get("type") == "text").strip()
|
|
95
|
+
notes = [f"[attached {a.get('file_name') or 'a file'}, {len(a.get('extracted_content') or ''):,} chars]"
|
|
96
|
+
for a in m.get("attachments") or [] if isinstance(a, dict)]
|
|
97
|
+
files = [f for f in m.get("files") or m.get("files_v2") or [] if isinstance(f, dict)]
|
|
98
|
+
ps.n_images += sum(1 for f in files if f.get("file_kind") in (None, "image"))
|
|
99
|
+
notes += [f"[file {f.get('file_name') or 'image'}]" for f in files]
|
|
100
|
+
known = {f.get("file_uuid") for f in files}
|
|
101
|
+
pasted = sum(1 for bl in blocks if bl.get("type") == "image" and (not bl.get("file_uuid") or bl["file_uuid"] not in known))
|
|
102
|
+
if pasted: # images in the prompt that are not listed as files
|
|
103
|
+
ps.n_images += pasted
|
|
104
|
+
notes.append(f"[{pasted} image{'s' if pasted > 1 else ''}]")
|
|
105
|
+
b.prompt(ts, "\n\n".join([text, *notes]).strip())
|
|
106
|
+
continue
|
|
107
|
+
results = [bl for bl in blocks if bl.get("type") == "tool_result"]
|
|
108
|
+
for bl in blocks:
|
|
109
|
+
bts = _iso(bl.get("start_timestamp")) or ts
|
|
110
|
+
kind = bl.get("type")
|
|
111
|
+
if kind == "thinking" and (bl.get("thinking") or "").strip():
|
|
112
|
+
b.event(bts, "assistant", "thinking", bl["thinking"])
|
|
113
|
+
elif kind == "text" and (bl.get("text") or "").strip():
|
|
114
|
+
b.event(bts, "assistant", "text", bl["text"])
|
|
115
|
+
elif kind == "tool_use":
|
|
116
|
+
match = next((r for r in results if bl.get("id") and r.get("tool_use_id") == bl.get("id")), None) \
|
|
117
|
+
or next((r for r in results if r.get("name") == bl.get("name")), None)
|
|
118
|
+
if match:
|
|
119
|
+
results.remove(match)
|
|
120
|
+
inp = bl.get("input") if isinstance(bl.get("input"), dict) else {}
|
|
121
|
+
b.tool(bts, bl.get("id"), bl.get("name") or "tool", inp, result=_result_text(match) if match else None,
|
|
122
|
+
is_error=bool(match and match.get("is_error")))
|
|
123
|
+
if bl.get("name") == "artifacts":
|
|
124
|
+
artifacts.claude_ai_artifact(ps, inp, ts=bts, tool_use_id=bl.get("id"))
|
|
125
|
+
elif bl.get("name") == "present_files" and match and not match.get("is_error"):
|
|
126
|
+
artifacts.claude_ai_presented(ps, match.get("content"), ts=bts, tool_use_id=bl.get("id"))
|
|
127
|
+
if conv.get("model"):
|
|
128
|
+
ps.models[conv["model"]] += 1
|
|
129
|
+
return finish_session(ps, b.stamps)
|