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
chronicle/artifacts.py ADDED
@@ -0,0 +1,621 @@
1
+ """What a session made: the documents, pages, diagrams, decks, images, published links, pull requests and commits an
2
+ agent produced. Each comes from an explicit signal in the transcript (a file the agent created, a page it published,
3
+ a PR it opened, a commit it made), never from guessing at the filesystem. Parsers call these while they read a
4
+ transcript, when they still hold a tool's full input; ingest stores the result in the `artifacts` table.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+
14
+ from .util import one_line, safe_text
15
+
16
+ KINDS = ("doc", "page", "diagram", "deck", "sheet", "image", "published", "pr", "commit")
17
+ _EXT_KIND = {
18
+ ".md": "doc", ".markdown": "doc", ".mdx": "doc", ".rst": "doc", ".pdf": "doc", ".docx": "doc", ".odt": "doc", ".rtf": "doc",
19
+ ".html": "page", ".htm": "page",
20
+ ".svg": "diagram", ".excalidraw": "diagram", ".drawio": "diagram", ".mmd": "diagram", ".mermaid": "diagram", ".puml": "diagram",
21
+ ".pptx": "deck", ".odp": "deck", # not .key: it names private keys as often as Keynote decks
22
+ ".xlsx": "sheet", ".ods": "sheet", ".csv": "sheet",
23
+ ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image",
24
+ }
25
+ # where writing a file is housekeeping, not a deliverable: agent config and memory, dependencies, build output, fixtures
26
+ _SKIP_DIRS = {".claude", ".codex", ".git", "node_modules", ".venv", "venv", "site-packages", "__pycache__", ".playwright-mcp",
27
+ "dist", "build", ".next", "coverage", "fixtures", "__snapshots__", "testdata"}
28
+ _SKIP_NAMES = {"claude.md", "agents.md", "gemini.md", "memory.md", "changelog.md"}
29
+ # an HTML page, SVG or image inside an app's source tree is part of the app (a template, an icon), not something made to be read
30
+ _SOURCE_DIRS = {"src", "app", "public", "static", "templates", "components", "pages", "views", "layouts", "web", "frontend",
31
+ "client", "media", "icons", "lib", "resources"}
32
+ # claude.ai artifact types -> kind
33
+ _CLAUDE_AI_TYPES = {"text/markdown": "doc", "text/html": "page", "image/svg+xml": "diagram", "application/vnd.ant.mermaid": "diagram",
34
+ "application/vnd.ant.react": "page"}
35
+ _MIME_KIND = {"text/markdown": "doc", "text/html": "page", "image/svg+xml": "diagram", "application/pdf": "doc",
36
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "deck",
37
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "doc",
38
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "sheet", "text/csv": "sheet"}
39
+
40
+ _PR_URL_RE = re.compile(r"https://([\w.-]+)/([\w.-]+/[\w.-]+)/pull/(\d+)") # github.com or a GitHub Enterprise host
41
+ _COMMIT_RE = re.compile(r"^\[([^\]\s]+)(?: \(root-commit\))? ([0-9a-f]{7,40})\] (.+)$", re.M)
42
+ _ONELINE_RE = re.compile(r"^([0-9a-f]{7,40}) (.+)$", re.M) # `git log --oneline -1` after a quiet commit
43
+ # git commit where a shell command starts (not inside a string that merely mentions it)
44
+ _GIT_COMMIT_RE = re.compile(r"(?:^|[;&|(]|\bthen\b|\bdo\b)\s*git(?:\s+-C\s+\S+)?\s+commit\b", re.M)
45
+ _MESSAGE_RE = re.compile(r"""\s-[a-z]*m\s+(?:"((?:[^"\\]|\\.)*)"|'([^']*)')""")
46
+ _HEREDOC_RE = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?\s*\n(.*?)\n\1\b", re.S)
47
+ _CLAUDE_URL_RE = re.compile(r"https://claude\.ai/(?:code/)?(?:artifact|public/artifacts)/[\w-]+")
48
+ _GOOGLE_URL_RE = re.compile(r"https://(?:docs|drive)\.google\.com/[^\s\"')\]]+")
49
+ _SLACK_CANVAS_RE = re.compile(r"https://[\w.-]+\.slack\.com/(?:docs|canvas)/[^\s\"')\]]+")
50
+ # office files and PDFs a script saves (python-pptx, a converter): the session names the path, and the file was created
51
+ # while the session ran (finish() checks); a file it only read existed before
52
+ _OFFICE_EXT = {".pptx", ".odp", ".docx", ".odt", ".xlsx", ".ods", ".pdf"}
53
+ _OFFICE_PATH_RE = re.compile(r"""([^\s'"`<>|;&(){}\[\]]+\.(?:pptx|odp|docx|odt|xlsx|ods|pdf))(?![\w.])""", re.I)
54
+ _BATCH = 8 # more files of one type saved into one folder than this is data being processed (downloads, a cache), not deliverables
55
+ _HASH_NAME_RE = re.compile(r"^[0-9a-f]{32,}$")
56
+ _NOT_MADE_HERE = tuple(os.path.expanduser(p) for p in ("~/Library/", "/Library/", "/System/", "/Applications/", "/private/var/folders/",
57
+ "/var/folders/"))
58
+ _GENERATED_IMAGE_RE = re.compile(r"(/[^\s\"'`]*/\.codex/generated_images/[^\s\"'`]+\.(?:png|jpe?g|webp))")
59
+ _TITLE_ARG_RE = re.compile(r"""--title\s+(?:"((?:[^"\\]|\\.)*)"|'([^']*)')""")
60
+
61
+
62
+ def kind_for_path(path: str | None) -> str | None:
63
+ """The kind of deliverable a file is, or None when writing it is ordinary work (code, config, agent memory)."""
64
+ if not path:
65
+ return None
66
+ low = path.lower()
67
+ parts = low.replace("\\", "/").split("/")
68
+ dirs = parts[:-1]
69
+ if _SKIP_DIRS.intersection(dirs) or parts[-1] in _SKIP_NAMES:
70
+ return None
71
+ if any(d.startswith(".") and d != ".github" for d in dirs): # a tool's own folder (.cursor, .coworker…)
72
+ return None
73
+ kind = _EXT_KIND.get(os.path.splitext(low)[1])
74
+ if kind in ("page", "diagram", "image", "sheet") and _SOURCE_DIRS.intersection(dirs):
75
+ return None
76
+ return kind
77
+
78
+
79
+ def _digest(content) -> tuple[int | None, str | None]:
80
+ if not isinstance(content, str):
81
+ return None, None
82
+ data = content.encode("utf-8", "surrogatepass")
83
+ return len(data), hashlib.sha256(data).hexdigest()
84
+
85
+
86
+ def _record(ps, art: dict) -> None:
87
+ """Add an artifact; the same file written again or the same link published again is one artifact, one more version."""
88
+ key = art["key"]
89
+ for prev in ps.outputs:
90
+ if prev["key"] == key:
91
+ prev["versions"] += 1
92
+ prev["ts"] = art.get("ts") or prev["ts"]
93
+ for k in ("title", "size", "sha256", "url", "tool_use_id", "agent_id"):
94
+ if art.get(k) is not None:
95
+ prev[k] = art[k]
96
+ prev["meta"].update(art.get("meta") or {})
97
+ return
98
+ ps.outputs.append({"versions": 1, "meta": {}, **art})
99
+
100
+
101
+ def file_written(ps, path: str | None, *, content=None, created: bool = True, ts=None, agent_id: str = "",
102
+ tool_use_id: str | None = None, where: str | None = None) -> None:
103
+ """A file the agent wrote whole (a new file, or a full rewrite). Edits to an existing file are not artifacts."""
104
+ kind = kind_for_path(path)
105
+ if not kind or (where == "claude.ai" and not path.startswith("/mnt/user-data/outputs/")):
106
+ return # claude.ai drafts in its sandbox's home; what it hands over goes to outputs/
107
+ size, sha = _digest(content)
108
+ _record(ps, {"key": f"file:{path}", "kind": kind, "action": "created" if created else "rewritten",
109
+ "title": os.path.basename(path), "path": path, "url": None, "size": size, "sha256": sha, "ts": ts,
110
+ "agent_id": agent_id, "tool_use_id": tool_use_id, "meta": {"where": where} if where else {}})
111
+
112
+
113
+ def _link(ps, kind: str, url: str, title: str | None, *, action: str, ts, agent_id: str, tool_use_id, meta=None) -> None:
114
+ if not isinstance(url, str) or not re.match(r"https?://", url, re.I):
115
+ return # a transcript states the link: one the dashboard can't open as a page (javascript:, say) isn't kept
116
+ _record(ps, {"key": f"url:{url}", "kind": kind, "action": action, "title": one_line(safe_text(title or ""), 200) or None,
117
+ "path": None, "url": url, "size": None, "sha256": None, "ts": ts, "agent_id": agent_id,
118
+ "tool_use_id": tool_use_id, "meta": dict(meta or {})})
119
+
120
+
121
+ def from_tool(ps, name: str, inp, text: str | None, *, ts=None, agent_id: str = "", tool_use_id: str | None = None,
122
+ command: str | None = None, tur=None) -> None:
123
+ """Artifacts a successful tool call made, read from its input and result: a published page, a PR, a commit, an image.
124
+ `tur` is Claude Code's structured toolUseResult, when there is one."""
125
+ inp = inp if isinstance(inp, dict) else {}
126
+ text = text or ""
127
+ tur = tur if isinstance(tur, dict) else {}
128
+ base = name.rsplit("__", 1)[-1].rsplit(".", 1)[-1].lower()
129
+ at = {"ts": ts, "agent_id": agent_id, "tool_use_id": tool_use_id}
130
+
131
+ # a page published to claude.ai (Claude Code's Artifact tool); reading or listing artifacts made nothing
132
+ if name == "Artifact" and (inp.get("action") or "publish") == "publish" and not inp.get("asset"):
133
+ url = tur.get("url") if isinstance(tur.get("url"), str) else next(iter(_CLAUDE_URL_RE.findall(text)), None)
134
+ if url:
135
+ meta = {k: tur[k] for k in ("artifact_id", "version") if tur.get(k)}
136
+ if isinstance(tur.get("path") or inp.get("file_path"), str):
137
+ meta["source"] = tur.get("path") or inp.get("file_path")
138
+ _link(ps, "published", url, tur.get("title") or inp.get("title") or inp.get("file_path"), action="published",
139
+ meta=meta, **at)
140
+ elif "claude_docs" in name.lower() and base in ("batch", "create"):
141
+ for url in dict.fromkeys(_CLAUDE_URL_RE.findall(text)):
142
+ create = (inp.get("container") or {}).get("create") if isinstance(inp.get("container"), dict) else None
143
+ title = create.get("name") if isinstance(create, dict) else None
144
+ _link(ps, "published", url, title or "Claude doc", action="published", **at)
145
+ elif "google_drive" in name.lower() and base in ("create_file", "copy_file"):
146
+ for url in dict.fromkeys(_GOOGLE_URL_RE.findall(text)):
147
+ _link(ps, "published", url, inp.get("title") or inp.get("name") or "Google Drive file", action="published", **at)
148
+ elif base == "slack_create_canvas":
149
+ for url in dict.fromkeys(_SLACK_CANVAS_RE.findall(text)):
150
+ _link(ps, "published", url, inp.get("title") or "Slack canvas", action="published", **at)
151
+
152
+ cmd = command or ""
153
+ mentions = getattr(ps, "_mentions", None)
154
+ if mentions is not None:
155
+ for raw in _OFFICE_PATH_RE.findall(f"{cmd}\n{text}"):
156
+ mentions.setdefault(raw, (ts, agent_id, tool_use_id))
157
+
158
+ # pull requests: `gh pr create` in a shell, or a GitHub tool that opens one
159
+ if "gh pr create" in cmd or base in ("create_pull_request", "create_pr"):
160
+ m = _TITLE_ARG_RE.search(cmd)
161
+ title = (m.group(1) or m.group(2)) if m else inp.get("title")
162
+ for host, repo, number in dict.fromkeys(_PR_URL_RE.findall(text)):
163
+ url = f"https://{host}/{repo}/pull/{number}"
164
+ _link(ps, "pr", url, title or f"{repo}#{number}", action="opened", meta={"repo": repo, "number": int(number)}, **at)
165
+
166
+ # commits: git's "[branch sha] subject" line, a `git log --oneline` after a quiet commit, or Claude Code's own record
167
+ git = tur.get("gitOperation") if isinstance(tur.get("gitOperation"), dict) else {}
168
+ if _commit_at(cmd) is not None or git.get("commit"):
169
+ commits = list(_COMMIT_RE.findall(text))
170
+ if not commits and "git log" in cmd:
171
+ commits = [("", sha, subj) for sha, subj in _ONELINE_RE.findall(text)[:1]] # the branch is not printed: don't guess
172
+ c = git.get("commit") if isinstance(git.get("commit"), dict) else None
173
+ if c and c.get("sha") and not any(s.startswith(c["sha"]) or c["sha"].startswith(s) for _, s, _ in commits):
174
+ commits.append((c.get("branch") or "", c["sha"], ""))
175
+ subject = commit_message(cmd)
176
+ for branch, sha, subj in commits:
177
+ _record(ps, {"key": f"commit:{sha[:12]}", "kind": "commit", "action": "committed",
178
+ "title": one_line(safe_text(subj or subject or ""), 200) or f"commit {sha[:7]}", "path": None,
179
+ "url": None, "size": None, "sha256": None, "meta": {"sha": sha, "branch": branch}, **at})
180
+ if not commits and subject: # a quiet commit that printed no hash: it succeeded, so it happened
181
+ _record(ps, {"key": f"commit:{getattr(ps, 'id', '')}:{tool_use_id}", "kind": "commit", "action": "committed",
182
+ "title": one_line(safe_text(subject), 200), "path": None, "url": None, "size": None,
183
+ "sha256": None, "meta": {}, **at})
184
+
185
+ # images an image model generated (Codex's image_gen tool, called directly or from a code-mode script)
186
+ script = inp.get("script") if isinstance(inp.get("script"), str) else ""
187
+ if "imagegen" in base.replace("_", "") or "imagegen(" in script.replace("_", ""):
188
+ prompt = inp.get("prompt") if isinstance(inp.get("prompt"), str) else _script_prompt(script)
189
+ for path in dict.fromkeys(_GENERATED_IMAGE_RE.findall(text)):
190
+ _record(ps, {"key": f"file:{path}", "kind": "image", "action": "generated",
191
+ "title": prompt_title(prompt) or os.path.basename(path),
192
+ "path": path, "url": None, "size": None, "sha256": None,
193
+ "meta": {"prompt": one_line(prompt, 300)} if prompt else {}, **at})
194
+
195
+
196
+ _PROMPT_RE = re.compile(r"""prompt\s*[:=]\s*([`'"])(.*?)(?<!\\)\1""", re.S)
197
+
198
+
199
+ def _script_prompt(script: str) -> str | None:
200
+ """The prompt a code-mode script gives the image generator (`prompt: "…"` or `const prompt = `…``)."""
201
+ m = _PROMPT_RE.search(script or "")
202
+ return m.group(2).replace("\\n", "\n") if m else None
203
+
204
+
205
+ def prompt_title(prompt: str | None) -> str | None:
206
+ """A generated image's name from its prompt: the "Asset type:" it asked for, or the prompt's first sentence."""
207
+ if not prompt:
208
+ return None
209
+ m = re.search(r"^\s*Asset type:\s*(.+)$", prompt, re.M)
210
+ line = m.group(1) if m else next((ln for ln in prompt.splitlines() if ln.strip()), "")
211
+ first = re.split(r"(?<=[.!?])\s", line.strip(), maxsplit=1)[0].rstrip(".")
212
+ return one_line(safe_text(first), 90) or None
213
+
214
+
215
+ def _commit_at(cmd: str) -> int | None:
216
+ """Where a `git commit` the command runs starts; one in a heredoc's body is text being written, not run."""
217
+ bodies = [(m.start(2), m.end(2)) for m in _HEREDOC_RE.finditer(cmd)]
218
+ return next((m.start() for m in _GIT_COMMIT_RE.finditer(cmd) if not any(a <= m.start() < b for a, b in bodies)), None)
219
+
220
+
221
+ def commit_message(cmd: str) -> str | None:
222
+ """The subject line a `git commit` command sets: -m "...", -m "$(cat <<'EOF' ...)", or -F - <<'EOF'."""
223
+ at = _commit_at(cmd)
224
+ if at is None:
225
+ return None
226
+ cmd = cmd[at:]
227
+ m = _MESSAGE_RE.search(cmd)
228
+ body = (m.group(1) or m.group(2) or "") if m else ""
229
+ if (not body and re.match(r"[^\n;&|]*\s(?:-F\s*-|--file[= ]-)(?:\s|$)", cmd)) or body.startswith("$(cat <<"):
230
+ h = _HEREDOC_RE.search(cmd)
231
+ body = h.group(2) if h else ""
232
+ first = next((ln.strip() for ln in body.splitlines() if ln.strip()), "")
233
+ return first if first and not first.startswith("$(") else None
234
+
235
+
236
+ def claude_ai_artifact(ps, inp: dict, *, ts=None, tool_use_id: str | None = None) -> None:
237
+ """claude.ai's artifacts tool: one artifact per id, every create, rewrite or update a version of it."""
238
+ aid = inp.get("id")
239
+ if not isinstance(aid, str) or not aid:
240
+ return
241
+ kind = _CLAUDE_AI_TYPES.get(inp.get("type") or "", "doc")
242
+ content = inp.get("content") if inp.get("command") in ("create", "rewrite") else None
243
+ size, sha = _digest(content)
244
+ _record(ps, {"key": f"claude-ai:{ps.id}:{aid}", "kind": kind, "action": "created" if inp.get("command") == "create" else "updated",
245
+ "title": one_line(safe_text(inp.get("title") or ""), 200) or None, "path": None, "url": None,
246
+ "size": size, "sha256": sha, "ts": ts, "agent_id": "", "tool_use_id": tool_use_id,
247
+ "meta": {"artifact_id": aid, "type": inp.get("type"), "where": "claude.ai"}})
248
+
249
+
250
+ def claude_ai_presented(ps, content, *, ts=None, tool_use_id: str | None = None) -> None:
251
+ """Files claude.ai's present_files handed to the user (they live in claude.ai's sandbox, not on this machine)."""
252
+ for block in content if isinstance(content, list) else []:
253
+ if not isinstance(block, dict) or block.get("type") != "local_resource" or not isinstance(block.get("file_path"), str):
254
+ continue
255
+ path = block["file_path"]
256
+ kind = kind_for_path(path) or _MIME_KIND.get(block.get("mime_type") or "")
257
+ if not kind:
258
+ continue
259
+ _record(ps, {"key": f"file:{path}", "kind": kind, "action": "presented",
260
+ "title": one_line(safe_text(block.get("name") or os.path.basename(path)), 200), "path": path, "url": None,
261
+ "size": None, "sha256": None, "ts": ts, "agent_id": "", "tool_use_id": tool_use_id,
262
+ "meta": {"where": "claude.ai"}})
263
+
264
+
265
+ def _saved_files(ps) -> None:
266
+ """Decks, documents, spreadsheets and PDFs a script saved: a path the session's tool calls named, whose file was
267
+ created while the session ran (macOS keeps a file's creation time; elsewhere its last change stands in)."""
268
+ from .util import parse_ts
269
+
270
+ start, end = parse_ts(ps.started_at), parse_ts(ps.ended_at)
271
+ if not start or not end or not getattr(ps, "_mentions", None):
272
+ return
273
+ lo, hi = start.timestamp() - 60, end.timestamp() + 600
274
+ root = ps.project_path if ps.project_path and os.path.isabs(ps.project_path) else None
275
+ found = []
276
+ for raw, (ts, agent_id, tool_use_id) in ps._mentions.items():
277
+ path = os.path.expanduser(raw.removeprefix("file://"))
278
+ if not os.path.isabs(path):
279
+ if not root:
280
+ continue
281
+ path = os.path.normpath(os.path.join(root, path))
282
+ kind = kind_for_path(path)
283
+ name, folders = os.path.basename(path), path.lower().split("/")[:-1]
284
+ if not kind or os.path.splitext(path)[1].lower() not in _OFFICE_EXT or path.startswith(_NOT_MADE_HERE) \
285
+ or name.startswith(("~$", ".~lock")) or _HASH_NAME_RE.match(os.path.splitext(name)[0].lower()) \
286
+ or any("cache" in d or d == "attachments" for d in folders) \
287
+ or any(o["key"] == f"file:{path}" for o in ps.outputs) or any(f[0] == path for f in found):
288
+ continue # an Office lock file, a cache entry, a downloaded attachment, or already counted
289
+ try:
290
+ st = os.stat(path)
291
+ except OSError:
292
+ continue
293
+ if not lo <= (getattr(st, "st_birthtime", None) or st.st_mtime) <= hi:
294
+ continue # it existed before the session (an input it read), or came later
295
+ found.append((path, kind, st, ts, agent_id, tool_use_id))
296
+ batches: dict[tuple, int] = {}
297
+ for path, *_ in found:
298
+ batch = (os.path.dirname(path), os.path.splitext(path)[1].lower())
299
+ batches[batch] = batches.get(batch, 0) + 1
300
+ for path, kind, st, ts, agent_id, tool_use_id in found:
301
+ if batches[(os.path.dirname(path), os.path.splitext(path)[1].lower())] > _BATCH:
302
+ continue
303
+ h = hashlib.sha256()
304
+ try:
305
+ with open(path, "rb") as f:
306
+ for chunk in iter(lambda: f.read(1 << 20), b""):
307
+ h.update(chunk)
308
+ except OSError:
309
+ continue
310
+ _record(ps, {"key": f"file:{path}", "kind": kind, "action": "saved", "title": os.path.basename(path), "path": path,
311
+ "url": None, "size": st.st_size, "sha256": h.hexdigest(), "ts": ts, "agent_id": agent_id,
312
+ "tool_use_id": tool_use_id, "meta": {}})
313
+
314
+
315
+ def finish(ps) -> None:
316
+ """Fold in the links Claude Code records outside tool calls (pr-link, frame-link) and the files scripts saved, and
317
+ share what tools revealed with the session's own PR and artifact lists, which the Markdown export and MCP read."""
318
+ _saved_files(ps)
319
+ for pr in ps.prs:
320
+ if pr.get("url") and not any(o["key"] == f"url:{pr['url']}" for o in ps.outputs):
321
+ repo, number = pr.get("repo"), pr.get("number")
322
+ _link(ps, "pr", pr["url"], f"{repo}#{number}" if repo and number else None, action="opened", ts=pr.get("ts"),
323
+ agent_id="", tool_use_id=None, meta={k: v for k, v in (("repo", repo), ("number", number)) if v})
324
+ for a in ps.artifacts:
325
+ if a.get("url") and "claude.ai" in a["url"] and not any(o["key"] == f"url:{a['url']}" for o in ps.outputs):
326
+ _link(ps, "published", a["url"], a.get("title"), action="published", ts=a.get("ts"), agent_id="", tool_use_id=None)
327
+ known_prs = {p.get("url") for p in ps.prs}
328
+ known_links = {a.get("url") for a in ps.artifacts}
329
+ for o in ps.outputs:
330
+ if o["kind"] == "pr" and o["url"] not in known_prs:
331
+ ps.prs.append({"number": o["meta"].get("number"), "url": o["url"], "repo": o["meta"].get("repo"), "ts": o.get("ts")})
332
+ elif o["kind"] == "published" and o["url"] not in known_links:
333
+ ps.artifacts.append({"title": o.get("title"), "url": o["url"], "ts": o.get("ts")})
334
+
335
+
336
+ def meta_json(o: dict) -> str | None:
337
+ meta = {k: v for k, v in (o.get("meta") or {}).items() if v not in (None, "", [], {})}
338
+ return json.dumps(meta, ensure_ascii=False) if meta else None
339
+
340
+
341
+ # ---------------------------------------------------------------------------------------------------- reading them back
342
+ STATUS = {"present": "on disk", "changed": "changed since", "gone": "gone", "link": "link", "commit": "commit",
343
+ "chat": "in the chat", "elsewhere": "another machine"}
344
+ _HASHES: dict[tuple, str] = {}
345
+ PREVIEW_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
346
+ ".svg": "image/svg+xml"}
347
+ PREVIEW_MAX = 25_000_000
348
+ THUMB_EXT = {".pptx", ".odp", ".docx", ".odt", ".xlsx", ".ods", ".pdf"} # Quick Look draws their first page
349
+
350
+
351
+ def can_thumbnail() -> bool:
352
+ import shutil
353
+ import sys
354
+
355
+ return sys.platform == "darwin" and bool(shutil.which("qlmanage")) # (path, mtime, size) -> sha256, so a gallery page does not re-read unchanged files
356
+
357
+
358
+ def status_of(row: dict, local_machine: str | None = None) -> str:
359
+ """Where an artifact stands now: still on disk as written, changed since, gone; or a link, a commit, a chat's file."""
360
+ if row["kind"] in ("pr", "published"):
361
+ return "link"
362
+ if row["kind"] == "commit":
363
+ return "commit"
364
+ if (row.get("meta") or {}).get("where") or row.get("agent") in ("claude-ai", "chatgpt"):
365
+ return "chat"
366
+ if local_machine and row.get("machine_id") and row["machine_id"] != local_machine:
367
+ return "elsewhere"
368
+ path = row.get("path")
369
+ try:
370
+ st = os.stat(path)
371
+ except (OSError, TypeError, ValueError):
372
+ return "gone"
373
+ if not row.get("sha256") or st.st_size > 20_000_000:
374
+ return "present"
375
+ key = (path, st.st_mtime_ns, st.st_size)
376
+ if key not in _HASHES:
377
+ h = hashlib.sha256()
378
+ try:
379
+ with open(path, "rb") as f:
380
+ for chunk in iter(lambda: f.read(1 << 20), b""):
381
+ h.update(chunk)
382
+ except OSError:
383
+ return "present"
384
+ _HASHES[key] = h.hexdigest()
385
+ return "present" if _HASHES[key] == row["sha256"] else "changed"
386
+
387
+
388
+ def query(conn, *, kind: str | None = None, project: str | None = None, agent: str | None = None, q: str | None = None,
389
+ session_id: str | None = None, local_machine: str | None = None) -> tuple[list[dict], dict[str, int]]:
390
+ """Artifacts newest first, with where each stands now, and the count of each kind (before the kind filter).
391
+ Across sessions the same file, link or commit is one item (its latest version) that knows how many sessions made it."""
392
+ where, params = [], []
393
+ if session_id:
394
+ where.append("a.session_id = ?")
395
+ params.append(session_id)
396
+ if project:
397
+ where.append("s.project_path = ?")
398
+ params.append(project)
399
+ if agent:
400
+ where.append("s.agent = ?")
401
+ params.append(agent)
402
+ if q:
403
+ where.append("(LOWER(COALESCE(a.title, '')) LIKE ? OR LOWER(COALESCE(a.path, '')) LIKE ? OR LOWER(COALESCE(a.url, '')) LIKE ?)")
404
+ params += [f"%{q.lower()}%"] * 3
405
+ rows = conn.execute(
406
+ "SELECT a.*, s.project_path, s.project_name, s.agent, s.title AS session_title, s.machine_id, s.archive_path FROM artifacts a "
407
+ f"JOIN sessions s ON s.id = a.session_id {'WHERE ' + ' AND '.join(where) if where else ''} "
408
+ "ORDER BY COALESCE(a.ts, s.started_at) DESC, a.id DESC", params).fetchall()
409
+ items: dict[str, dict] = {}
410
+ for r in rows:
411
+ d = dict(r)
412
+ d["meta"] = json.loads(d.pop("meta_json") or "{}")
413
+ key = d["key"] if not session_id else f"{d['key']}#{d['id']}"
414
+ first = items.get(key)
415
+ if first is None:
416
+ d["session_ids"] = [d["session_id"]]
417
+ items[key] = d
418
+ else: # an older version from another session
419
+ if d["session_id"] not in first["session_ids"]:
420
+ first["session_ids"].append(d["session_id"])
421
+ first["versions"] += d["versions"] or 1
422
+ counts: dict[str, int] = {}
423
+ for d in items.values():
424
+ counts[d["kind"]] = counts.get(d["kind"], 0) + 1
425
+ out = [d for d in items.values() if not kind or d["kind"] == kind]
426
+ for d in out:
427
+ d["sessions"] = len(d.pop("session_ids"))
428
+ d["status"] = status_of(d, local_machine)
429
+ d["preview"] = previewable(d)
430
+ d["openable"] = openable(d)
431
+ d.pop("machine_id", None)
432
+ d.pop("archive_path", None)
433
+ return out, counts
434
+
435
+
436
+ def previewable(a: dict) -> bool:
437
+ """An image or SVG still on disk, or a deck, document, spreadsheet or PDF Quick Look can draw (on a Mac)."""
438
+ ext = os.path.splitext(a.get("path") or "")[1].lower()
439
+ return a["status"] in ("present", "changed") and (ext in PREVIEW_TYPES or (ext in THUMB_EXT and can_thumbnail()))
440
+
441
+
442
+ def quicklook_thumbnail(path: str, cache_dir, size: int = 800) -> bytes | None:
443
+ """A PNG of a file's first page or slide, drawn by macOS Quick Look and kept until the file changes."""
444
+ import shutil
445
+ import subprocess
446
+ import tempfile
447
+ from pathlib import Path
448
+
449
+ try:
450
+ st = os.stat(path)
451
+ except OSError:
452
+ return None
453
+ out = Path(cache_dir) / (hashlib.sha1(f"{path}|{st.st_mtime_ns}|{st.st_size}|{size}".encode()).hexdigest() + ".png")
454
+ if not out.exists():
455
+ with tempfile.TemporaryDirectory() as tmp:
456
+ try:
457
+ subprocess.run(["qlmanage", "-t", "-s", str(size), "-o", tmp, path], capture_output=True, timeout=30,
458
+ stdin=subprocess.DEVNULL)
459
+ except (OSError, subprocess.TimeoutExpired):
460
+ return None
461
+ made = Path(tmp) / f"{os.path.basename(path)}.png"
462
+ if not made.exists():
463
+ return None
464
+ out.parent.mkdir(parents=True, exist_ok=True)
465
+ shutil.copyfile(made, out)
466
+ try:
467
+ return out.read_bytes()
468
+ except OSError:
469
+ return None
470
+
471
+
472
+ def artifact_by_id(conn, artifact_id: int, local_machine: str | None = None) -> dict | None:
473
+ """One recorded artifact with where it stands now; the only way a request reaches a file."""
474
+ row = conn.execute("SELECT a.*, s.agent, s.machine_id, s.archive_path FROM artifacts a JOIN sessions s ON s.id = a.session_id "
475
+ "WHERE a.id = ?", (artifact_id,)).fetchone()
476
+ if row is None:
477
+ return None
478
+ a = dict(row)
479
+ a["meta"] = json.loads(a.pop("meta_json") or "{}")
480
+ a["status"] = status_of(a, local_machine)
481
+ return a
482
+
483
+
484
+ def preview_file(conn, artifact_id: int, local_machine: str | None = None, cache_dir=None) -> tuple[bytes, str] | None:
485
+ """An artifact's picture by its id (only a file Interlatch recorded as an artifact, never a path a request names): an
486
+ image or SVG as it is, or a Quick Look thumbnail of a deck, document, spreadsheet or PDF."""
487
+ a = artifact_by_id(conn, artifact_id, local_machine)
488
+ if a is None or a["status"] not in ("present", "changed"):
489
+ return None
490
+ ext = os.path.splitext(a.get("path") or "")[1].lower()
491
+ if ext in THUMB_EXT and cache_dir and can_thumbnail():
492
+ png = quicklook_thumbnail(a["path"], cache_dir)
493
+ return (png, "image/png") if png else None
494
+ ctype = PREVIEW_TYPES.get(ext)
495
+ if not ctype:
496
+ return None
497
+ try:
498
+ if os.path.getsize(a["path"]) > PREVIEW_MAX:
499
+ return None
500
+ with open(a["path"], "rb") as f:
501
+ return f.read(), ctype
502
+ except OSError:
503
+ return None
504
+
505
+
506
+ # ---------------------------------------------------------------------------------------------------- opening them
507
+ OPEN_MAX = 60_000_000
508
+ _TEXT_EXT = {".md", ".markdown", ".mdx", ".rst", ".csv", ".mmd", ".mermaid", ".puml", ".excalidraw", ".drawio", ".txt"}
509
+ # an agent's HTML page keeps its own scripts (charts, interactivity) but runs in a sandbox: an opaque origin, with no
510
+ # way into Interlatch's data, cookies or API
511
+ HTML_POLICY = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads"
512
+ IMAGE_POLICY = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; sandbox"
513
+ TEXT_POLICY = "default-src 'none'; sandbox"
514
+
515
+
516
+ def openable(a: dict) -> bool:
517
+ """Whether "Open" can show the file: it is on disk, or it is gone but the archived transcript holds what was written."""
518
+ if not a.get("path") or a["kind"] in ("pr", "published", "commit"):
519
+ return False
520
+ if a["status"] in ("present", "changed"):
521
+ return True
522
+ return (a["status"] == "gone" and bool(a.get("sha256")) and a.get("agent") in ("claude", "codex")
523
+ and bool(a.get("archive_path")) and os.path.exists(a["archive_path"]))
524
+
525
+
526
+ def written_content(a: dict) -> str | None:
527
+ """The text a session wrote to a file, read back from the archived transcript (for a file no longer on disk)."""
528
+ from pathlib import Path
529
+
530
+ from .util import iter_jsonl
531
+
532
+ archive, path, tid = a.get("archive_path"), a["path"], a.get("tool_use_id")
533
+ if not archive or not os.path.exists(archive):
534
+ return None
535
+ found: list[str] = []
536
+ if a.get("agent") == "claude":
537
+ from .parser import session_dir_for, subagent_files
538
+
539
+ main = Path(archive)
540
+ for f in [main, *subagent_files(session_dir_for(main))]:
541
+ for d in iter_jsonl(f):
542
+ msg = d.get("message") if isinstance(d.get("message"), dict) else {}
543
+ for b in msg.get("content") if d.get("type") == "assistant" and isinstance(msg.get("content"), list) else []:
544
+ if not isinstance(b, dict) or b.get("type") != "tool_use" or b.get("name") != "Write":
545
+ continue
546
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
547
+ if (b.get("id") == tid if tid else inp.get("file_path") == path) and isinstance(inp.get("content"), str):
548
+ found.append(inp["content"])
549
+ elif a.get("agent") == "codex": # a FileChange item carries a new file's content
550
+ for d in iter_jsonl(Path(archive)):
551
+ p = d.get("payload") if isinstance(d.get("payload"), dict) else {}
552
+ item = p.get("item") if p.get("type") == "item_completed" and isinstance(p.get("item"), dict) else {}
553
+ if item.get("type") != "FileChange" or not isinstance(item.get("changes"), dict):
554
+ continue
555
+ for k, ch in item["changes"].items():
556
+ if isinstance(ch, dict) and str(ch.get("type")).lower() == "add" and isinstance(ch.get("content"), str) \
557
+ and (k == path or path.endswith("/" + k)):
558
+ found.append(ch["content"])
559
+ exact = [c for c in found if a.get("sha256") and _digest(c)[1] == a["sha256"]]
560
+ return (exact or found or [None])[-1]
561
+
562
+
563
+ def open_file(conn, artifact_id: int, local_machine: str | None = None) -> dict | None:
564
+ """What "Open" shows: the file as it is on disk or, once it is gone, as the agent wrote it. Returns the bytes, their
565
+ type, whether a browser can show them (else they download), and the content policy to send with them."""
566
+ a = artifact_by_id(conn, artifact_id, local_machine)
567
+ if a is None or not openable(a):
568
+ return None
569
+ data, source = None, "disk"
570
+ if a["status"] in ("present", "changed"):
571
+ try:
572
+ if os.path.getsize(a["path"]) > OPEN_MAX:
573
+ return None
574
+ with open(a["path"], "rb") as f:
575
+ data = f.read()
576
+ except OSError:
577
+ data = None
578
+ if data is None:
579
+ text = written_content(a)
580
+ if text is None:
581
+ return None
582
+ data, source = text.encode("utf-8", "surrogatepass"), "archive"
583
+ ext = os.path.splitext(a["path"])[1].lower()
584
+ out = {"body": data, "name": os.path.basename(a["path"]), "source": source, "inline": True}
585
+ if ext in (".html", ".htm"):
586
+ return {**out, "ctype": "text/html; charset=utf-8", "policy": HTML_POLICY}
587
+ if ext in PREVIEW_TYPES:
588
+ return {**out, "ctype": PREVIEW_TYPES[ext], "policy": IMAGE_POLICY}
589
+ if ext == ".pdf": # the browser's PDF viewer refuses to run in a sandbox; a PDF's scripts are its own viewer's business
590
+ return {**out, "ctype": "application/pdf", "policy": None}
591
+ if ext in _TEXT_EXT:
592
+ return {**out, "ctype": "text/plain; charset=utf-8", "policy": TEXT_POLICY}
593
+ import mimetypes
594
+
595
+ return {**out, "ctype": mimetypes.guess_type(a["path"])[0] or "application/octet-stream", "policy": "sandbox", "inline": False}
596
+
597
+
598
+ def reveal(conn, artifact_id: int, how: str, local_machine: str | None = None) -> str | None:
599
+ """Open an artifact still on disk in its own app ("open"), or show it in Finder ("reveal"). Returns an error or None.
600
+ Only a recorded artifact of a deliverable type (a document, page, deck…), never a program."""
601
+ import subprocess
602
+ import sys
603
+
604
+ from .i18n import tr
605
+
606
+ a = artifact_by_id(conn, artifact_id, local_machine)
607
+ if a is None or a["status"] not in ("present", "changed"):
608
+ return tr("that file is not on this computer")
609
+ if os.path.splitext(a["path"])[1].lower() not in _EXT_KIND:
610
+ return tr("that kind of file is not opened from here")
611
+ if sys.platform == "darwin":
612
+ cmd = ["open", "-R", a["path"]] if how == "reveal" else ["open", a["path"]]
613
+ elif sys.platform.startswith("linux"):
614
+ cmd = ["xdg-open", os.path.dirname(a["path"]) if how == "reveal" else a["path"]]
615
+ else:
616
+ return tr("opening files is not supported on this system")
617
+ try:
618
+ subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
619
+ except OSError as exc:
620
+ return tr("could not open it: {error}", error=exc)
621
+ return None