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,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)