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