artikeep 0.2.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.
- artikeep/__init__.py +8 -0
- artikeep/__main__.py +5 -0
- artikeep/adapters/__init__.py +1 -0
- artikeep/adapters/claude_code.py +478 -0
- artikeep/adapters/codex.py +200 -0
- artikeep/cli.py +201 -0
- artikeep/config.py +73 -0
- artikeep/gallery.py +242 -0
- artikeep/gallery.template.html +958 -0
- artikeep/importers/__init__.py +23 -0
- artikeep/importers/chatgpt.py +92 -0
- artikeep/importers/claude_ai.py +84 -0
- artikeep/importers/files.py +40 -0
- artikeep/install.py +424 -0
- artikeep/issues.py +61 -0
- artikeep/mcp_server.py +248 -0
- artikeep/notify.py +39 -0
- artikeep/search.py +151 -0
- artikeep/server.py +320 -0
- artikeep/store.py +294 -0
- artikeep/util.py +197 -0
- artikeep/viewers.py +406 -0
- artikeep/worker.py +111 -0
- artikeep-0.2.0.dist-info/METADATA +257 -0
- artikeep-0.2.0.dist-info/RECORD +29 -0
- artikeep-0.2.0.dist-info/WHEEL +5 -0
- artikeep-0.2.0.dist-info/entry_points.txt +2 -0
- artikeep-0.2.0.dist-info/licenses/LICENSE +21 -0
- artikeep-0.2.0.dist-info/top_level.txt +1 -0
artikeep/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""artikeep: a local, versioned archive of what AI agents make for you.
|
|
2
|
+
|
|
3
|
+
Hooks of Claude Code and Codex copy every artifact into a git-backed folder the
|
|
4
|
+
moment it is made; importers take official chat exports; a stdio MCP server lets
|
|
5
|
+
any agent search and read the archive. Standard library only, Python >= 3.9.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
artikeep/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Capture adapters: one module per agent host. Each reads hook JSON from stdin."""
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Claude Code: the Artifact tool, the Claude Docs connector, scratchpad drafts.
|
|
2
|
+
|
|
3
|
+
Hooks (installed by `artikeep install`, or by the Claude Code plugin):
|
|
4
|
+
publish PostToolUse on Artifact: copy the page and its files, snapshot a version.
|
|
5
|
+
docs PostToolUse on mcp__claude_ai_Claude_Docs__*: log writes; decode `export`
|
|
6
|
+
results into doc-<tab>.<ext>; remember which docs are unexported.
|
|
7
|
+
stop Stop: mirror scratchpad pages into drafts/; once per edit series, ask
|
|
8
|
+
Claude to export docs it changed (Docs has no other way to get the text).
|
|
9
|
+
check SessionStart: archive recent publishes the hook missed, from transcripts.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from artikeep import config
|
|
21
|
+
from artikeep.store import Store
|
|
22
|
+
from artikeep.util import (
|
|
23
|
+
copy_file, html_title, now_iso, project_root, resolve, text_blob, with_charset,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
URL_RE = re.compile(r"https://claude\.ai/(?:code/)?artifact/[0-9A-Za-z-]+")
|
|
27
|
+
ABS_PATH_RE = re.compile(r"(/(?:private/)?(?:tmp|var|Users|home)/[^\s\"'`<>|]+)")
|
|
28
|
+
UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
|
29
|
+
DOC_WRITE_TOOLS = ("__batch", "__update", "__create", "__delete")
|
|
30
|
+
EXPORT_EXT = {"markdown": "md", "notion": "notion.md", "text": "txt", "html": "html", "docx": "docx", "pdf": "pdf"}
|
|
31
|
+
TMP_ROOT = Path("/private/tmp") if Path("/private/tmp").exists() else Path("/tmp")
|
|
32
|
+
TRANSCRIPTS = Path.home() / ".claude" / "projects"
|
|
33
|
+
DRAFT_EXT = {".html", ".htm", ".css", ".js", ".mjs", ".svg", ".json"}
|
|
34
|
+
DRAFT_SKIP_DIRS = {"node_modules", "__pycache__", ".git", "Default", "GPUPersistentCache"}
|
|
35
|
+
DRAFT_MAX_BYTES = 5 * 1024 * 1024
|
|
36
|
+
DRAFT_MAX_DEPTH = 3 # page sources sit shallow; deeper trees are tool output
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _t(store: Store, ru: str, en: str) -> str:
|
|
40
|
+
return ru if config.lang(store.settings) == "ru" else en
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _say(msg: str) -> None:
|
|
44
|
+
print(json.dumps({"systemMessage": msg}, ensure_ascii=False))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _alarm(store: Store, kind: str, msg: str) -> None:
|
|
48
|
+
"""Say it in the session and on the desktop: a failed capture must be seen."""
|
|
49
|
+
from artikeep.notify import notify
|
|
50
|
+
_say(msg)
|
|
51
|
+
notify(store, kind, msg)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------- Artifact tool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def handle_artifact(store: Store, payload: dict):
|
|
58
|
+
ti = payload.get("tool_input") or {}
|
|
59
|
+
action = ti.get("action") or "publish"
|
|
60
|
+
if action not in ("publish", "read", "delete"):
|
|
61
|
+
return None
|
|
62
|
+
resp_text = json.dumps(payload.get("tool_response"), ensure_ascii=False, default=str)
|
|
63
|
+
resp_urls = URL_RE.findall(resp_text)
|
|
64
|
+
url = ti.get("url") or (resp_urls[0] if resp_urls else None)
|
|
65
|
+
cwd = payload.get("cwd") or os.getcwd()
|
|
66
|
+
root = resolve(ti["root"], cwd) if ti.get("root") else Path(cwd)
|
|
67
|
+
when = payload.get("timestamp") or now_iso()
|
|
68
|
+
|
|
69
|
+
with store.lock("manifest"):
|
|
70
|
+
m = store.load()
|
|
71
|
+
if action == "delete":
|
|
72
|
+
if url and url in m["items"] and not ti.get("path"):
|
|
73
|
+
m["items"][url]["deleted"] = now_iso()
|
|
74
|
+
store.save(m)
|
|
75
|
+
return "deleted"
|
|
76
|
+
if action == "read":
|
|
77
|
+
# A read of our own artifact saves the server's page to a file: keep it as a snapshot.
|
|
78
|
+
if not url or ti.get("path") or ti.get("paths"):
|
|
79
|
+
return None
|
|
80
|
+
saved = [p for p in ABS_PATH_RE.findall(resp_text) if p.endswith((".html", ".htm")) and Path(p).is_file()]
|
|
81
|
+
if not saved:
|
|
82
|
+
return None
|
|
83
|
+
entry = store.entry_for(m, url, ti.get("title") or html_title(saved[0]))
|
|
84
|
+
entry.setdefault("url", url)
|
|
85
|
+
entry.setdefault("agent", "claude-code")
|
|
86
|
+
copy_file(saved[0], store.item_dir(entry) / "server" / "index.html")
|
|
87
|
+
entry["server_snapshot"] = now_iso()
|
|
88
|
+
store.save(m)
|
|
89
|
+
return "read"
|
|
90
|
+
|
|
91
|
+
key = url or ("local:" + str(ti.get("file_path") or ti.get("type_url") or now_iso()))
|
|
92
|
+
source = resolve(ti["file_path"], cwd) if ti.get("file_path") and not ti.get("asset") else None
|
|
93
|
+
title_guess = ti.get("title") or (html_title(source) if source else None) or (source.stem if source else None)
|
|
94
|
+
entry = store.entry_for(m, key, title_guess, when)
|
|
95
|
+
entry["agent"] = "claude-code"
|
|
96
|
+
if url:
|
|
97
|
+
entry["url"] = url
|
|
98
|
+
d = store.item_dir(entry)
|
|
99
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
copied, missing = [], []
|
|
101
|
+
if ti.get("type_url"):
|
|
102
|
+
entry["type_url"] = ti["type_url"]
|
|
103
|
+
typed = bool(entry.get("type_url"))
|
|
104
|
+
|
|
105
|
+
def take(src, rel):
|
|
106
|
+
(copied if copy_file(src, d / rel) else missing).append(str(src))
|
|
107
|
+
|
|
108
|
+
if ti.get("asset"):
|
|
109
|
+
for p in ti.get("file_paths") or ([ti["file_path"]] if ti.get("file_path") else []):
|
|
110
|
+
take(resolve(p, cwd), Path("assets") / Path(p).name)
|
|
111
|
+
elif ti.get("file_path"):
|
|
112
|
+
src = resolve(ti["file_path"], root if ti.get("root") else cwd)
|
|
113
|
+
rel = os.path.relpath(src, root) if ti.get("root") else Path(src).name
|
|
114
|
+
if typed:
|
|
115
|
+
take(src, Path("data") / rel)
|
|
116
|
+
elif Path(src).suffix.lower() in (".html", ".htm"):
|
|
117
|
+
take(src, "index.html")
|
|
118
|
+
entry["source_name"] = Path(src).name
|
|
119
|
+
else:
|
|
120
|
+
# e.g. a Design canvas: canvas.json is the entry; the viewer becomes index.html
|
|
121
|
+
take(src, rel)
|
|
122
|
+
entry["source_name"] = str(rel)
|
|
123
|
+
files = ti.get("files")
|
|
124
|
+
if isinstance(files, dict):
|
|
125
|
+
for pub, spec in files.items():
|
|
126
|
+
if spec is None:
|
|
127
|
+
continue
|
|
128
|
+
if isinstance(spec, dict) and "artifact" in spec:
|
|
129
|
+
entry.setdefault("foreign_files", {})[pub] = spec
|
|
130
|
+
continue
|
|
131
|
+
src = spec["from"] if isinstance(spec, dict) else spec
|
|
132
|
+
take(resolve(src, root), pub)
|
|
133
|
+
elif isinstance(files, list):
|
|
134
|
+
for spec in files:
|
|
135
|
+
take(resolve(spec["path"], root), spec["path"])
|
|
136
|
+
|
|
137
|
+
title = ti.get("title") or html_title(d / "index.html") or entry.get("title") or title_guess
|
|
138
|
+
if title:
|
|
139
|
+
entry["title"] = title
|
|
140
|
+
if ti.get("description"):
|
|
141
|
+
entry["description"] = ti["description"]
|
|
142
|
+
entry["project"] = project_root(cwd)
|
|
143
|
+
if when >= entry.get("updated", ""):
|
|
144
|
+
entry["updated"] = when
|
|
145
|
+
entry["publishes"].append({
|
|
146
|
+
"at": when, "session": payload.get("session_id"), "tool_use_id": payload.get("tool_use_id"),
|
|
147
|
+
"source": ti.get("file_path"), "asset": bool(ti.get("asset")), "copied": len(copied), "missing": missing,
|
|
148
|
+
})
|
|
149
|
+
if not url:
|
|
150
|
+
entry["url_unknown"] = True
|
|
151
|
+
if copied and not ti.get("asset"):
|
|
152
|
+
store.snapshot(d, entry, when, ti.get("label"))
|
|
153
|
+
store.save(m)
|
|
154
|
+
if missing or not url:
|
|
155
|
+
return "partial: missing=%s url=%s" % (missing, url)
|
|
156
|
+
return "ok"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_publish(store: Store, payload: dict) -> None:
|
|
160
|
+
store.log_payload("publish", payload)
|
|
161
|
+
if payload.get("tool_name") != "Artifact":
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
result = handle_artifact(store, payload)
|
|
165
|
+
except Exception:
|
|
166
|
+
store.error("publish")
|
|
167
|
+
_alarm(store, "publish", _t(store, "artikeep: копия не сохранена, см. %s/errors.log", "artikeep: copy not saved, see %s/errors.log") % store.log)
|
|
168
|
+
return
|
|
169
|
+
if result is None:
|
|
170
|
+
return
|
|
171
|
+
store.kick()
|
|
172
|
+
if result.startswith("partial"):
|
|
173
|
+
_alarm(store, "partial", _t(store, "artikeep: сохранено не всё (%s)", "artikeep: not everything was saved (%s)") % result)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------- Claude Docs
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def doc_id_of(ti: dict, resp: str):
|
|
180
|
+
c = ti.get("container") or {}
|
|
181
|
+
if c.get("kind") == "project" and c.get("id"):
|
|
182
|
+
return c["id"]
|
|
183
|
+
ref = ti.get("ref") or {}
|
|
184
|
+
if ref.get("object") == "project" and ref.get("id"):
|
|
185
|
+
return ref["id"]
|
|
186
|
+
m = re.search(r"claude\.ai/(?:code/)?artifact/(?:[^/\s\"]*-)?(%s)" % UUID_RE.pattern, resp)
|
|
187
|
+
if m:
|
|
188
|
+
return m.group(1)
|
|
189
|
+
m = re.search(r'"project"\s*:\s*\{[^}]*"id"\s*:\s*"(%s)"' % UUID_RE.pattern, resp)
|
|
190
|
+
return m.group(1) if m else None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def decode_export(resp_obj, resp: str):
|
|
194
|
+
"""The export tool returns the tab inline as base64; find and decode it."""
|
|
195
|
+
import base64
|
|
196
|
+
|
|
197
|
+
cands = []
|
|
198
|
+
|
|
199
|
+
def walk(o):
|
|
200
|
+
if isinstance(o, dict):
|
|
201
|
+
for k, v in o.items():
|
|
202
|
+
if isinstance(v, str) and (k.lower() in ("base64", "bytes_b64", "b64")
|
|
203
|
+
or (k.lower() in ("data", "content", "bytes", "file") and len(v) > 16)):
|
|
204
|
+
cands.append(v)
|
|
205
|
+
walk(v)
|
|
206
|
+
elif isinstance(o, list):
|
|
207
|
+
for v in o:
|
|
208
|
+
walk(v)
|
|
209
|
+
elif isinstance(o, str) and o.lstrip().startswith("{"):
|
|
210
|
+
try:
|
|
211
|
+
walk(json.loads(o))
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
walk(resp_obj)
|
|
216
|
+
cands += sorted(re.findall(r"[A-Za-z0-9+/=\r\n]{200,}", resp), key=len, reverse=True)[:3]
|
|
217
|
+
for c in cands:
|
|
218
|
+
s = re.sub(r"\s+", "", c.split(",", 1)[1] if c.startswith("data:") else c)
|
|
219
|
+
try:
|
|
220
|
+
data = base64.b64decode(s, validate=True)
|
|
221
|
+
except Exception:
|
|
222
|
+
continue
|
|
223
|
+
if data:
|
|
224
|
+
return data
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _docs_state_path(store: Store) -> Path:
|
|
229
|
+
return store.log / "docs_state.json"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _load_docs_state(store: Store) -> dict:
|
|
233
|
+
try:
|
|
234
|
+
return json.loads(_docs_state_path(store).read_text(encoding="utf-8"))
|
|
235
|
+
except Exception:
|
|
236
|
+
return {}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _save_docs_state(store: Store, st: dict) -> None:
|
|
240
|
+
p = _docs_state_path(store)
|
|
241
|
+
tmp = p.with_suffix(".tmp")
|
|
242
|
+
tmp.write_text(json.dumps(st, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
243
|
+
tmp.replace(p)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_docs(store: Store, payload: dict) -> None:
|
|
247
|
+
name = payload.get("tool_name") or ""
|
|
248
|
+
if "Claude_Docs__" not in name:
|
|
249
|
+
return
|
|
250
|
+
is_write = name.endswith(DOC_WRITE_TOOLS)
|
|
251
|
+
is_export = name.endswith("__export")
|
|
252
|
+
if not (is_write or is_export):
|
|
253
|
+
return
|
|
254
|
+
ti = payload.get("tool_input") or {}
|
|
255
|
+
resp_obj = payload.get("tool_response")
|
|
256
|
+
resp = text_blob(resp_obj)
|
|
257
|
+
doc = doc_id_of(ti, resp)
|
|
258
|
+
if not doc:
|
|
259
|
+
store.log_payload("docs-unknown", payload)
|
|
260
|
+
return
|
|
261
|
+
url = "https://claude.ai/code/artifact/" + doc
|
|
262
|
+
now = now_iso()
|
|
263
|
+
with store.lock("manifest"):
|
|
264
|
+
m = store.load()
|
|
265
|
+
# A doc made from the Docs artifact type may already be filed under its short artifact url.
|
|
266
|
+
key = next((k for k, e in m["items"].items() if doc in k or doc in " ".join(e.get("aliases", []))), url)
|
|
267
|
+
create = (ti.get("container") or {}).get("create") or {}
|
|
268
|
+
title = create.get("name")
|
|
269
|
+
entry = store.entry_for(m, key, title or ("doc " + doc[:8]))
|
|
270
|
+
entry.setdefault("url", url)
|
|
271
|
+
entry["kind"] = "claude-doc"
|
|
272
|
+
entry["agent"] = "claude-code"
|
|
273
|
+
entry.setdefault("project", project_root(payload.get("cwd") or os.getcwd()))
|
|
274
|
+
if title:
|
|
275
|
+
entry["title"] = title
|
|
276
|
+
d = store.item_dir(entry)
|
|
277
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
rec = {"at": now, "session": payload.get("session_id"), "tool": name.split("__")[-1], "input": ti, "response": resp[:20000]}
|
|
279
|
+
with open(d / "docs-ops.jsonl", "a", encoding="utf-8") as f:
|
|
280
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
281
|
+
saved = None
|
|
282
|
+
if is_export:
|
|
283
|
+
data = decode_export(resp_obj, resp)
|
|
284
|
+
fmt = ti.get("format") or "markdown"
|
|
285
|
+
if data:
|
|
286
|
+
saved = "doc-%s.%s" % (re.sub(r"[^\w-]", "_", ti.get("file") or "tab"), EXPORT_EXT.get(fmt, fmt))
|
|
287
|
+
(d / saved).write_bytes(with_charset(data) if fmt == "html" else data)
|
|
288
|
+
entry["exported"] = now
|
|
289
|
+
store.snapshot(d, entry, now)
|
|
290
|
+
else:
|
|
291
|
+
store.log_payload("docs-export-undecoded", payload)
|
|
292
|
+
entry["updated"] = now
|
|
293
|
+
store.save(m)
|
|
294
|
+
with store.lock("docs"):
|
|
295
|
+
st = _load_docs_state(store)
|
|
296
|
+
s = st.setdefault(doc, {"tabs": []})
|
|
297
|
+
s["title"] = entry.get("title")
|
|
298
|
+
tabs = set(s.get("tabs", []))
|
|
299
|
+
tabs |= set(re.findall(r'"f\d*"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4})"', resp))
|
|
300
|
+
for t in re.findall(r'"files"\s*:\s*\[(.*?)\]', resp, re.S):
|
|
301
|
+
tabs |= set(re.findall(r'"id"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4})"', t))
|
|
302
|
+
if ti.get("file"):
|
|
303
|
+
tabs.add(ti["file"])
|
|
304
|
+
s["tabs"] = sorted(tabs)
|
|
305
|
+
tick = time.time()
|
|
306
|
+
if is_write:
|
|
307
|
+
s["last_write"] = tick
|
|
308
|
+
s["session"] = payload.get("session_id")
|
|
309
|
+
if is_export and saved:
|
|
310
|
+
s.setdefault("exported_tabs", {})[ti.get("file") or "tab"] = now
|
|
311
|
+
s["last_export"] = tick
|
|
312
|
+
_save_docs_state(store, st)
|
|
313
|
+
store.kick()
|
|
314
|
+
if is_export and not saved:
|
|
315
|
+
_alarm(store, "docs", _t(store, "artikeep: экспорт документа не удалось раскодировать, см. .hooklog/payloads.jsonl",
|
|
316
|
+
"artikeep: could not decode the document export, see .hooklog/payloads.jsonl"))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def docs_reminder(store: Store, payload: dict):
|
|
320
|
+
"""Block the stop once per edit series if a doc changed after its last export."""
|
|
321
|
+
sess = payload.get("session_id")
|
|
322
|
+
with store.lock("docs"):
|
|
323
|
+
st = _load_docs_state(store)
|
|
324
|
+
due = []
|
|
325
|
+
for doc, s in st.items():
|
|
326
|
+
if s.get("session") != sess or not s.get("last_write"):
|
|
327
|
+
continue
|
|
328
|
+
if s.get("last_export", 0) >= s["last_write"] or s.get("reminded_for") == s["last_write"]:
|
|
329
|
+
continue
|
|
330
|
+
s["reminded_for"] = s["last_write"]
|
|
331
|
+
due.append((doc, s))
|
|
332
|
+
if due:
|
|
333
|
+
_save_docs_state(store, st)
|
|
334
|
+
if not due:
|
|
335
|
+
return None
|
|
336
|
+
ru = config.lang(store.settings) == "ru"
|
|
337
|
+
lines = []
|
|
338
|
+
for doc, s in due:
|
|
339
|
+
tabs = ", ".join(s.get("tabs") or []) or ("id вкладок возьми из read документа" if ru else "take tab ids from a read of the doc")
|
|
340
|
+
lines.append(("«%s» (doc %s; вкладки: %s)" if ru else "\"%s\" (doc %s; tabs: %s)") % (s.get("title") or doc, doc, tabs))
|
|
341
|
+
if ru:
|
|
342
|
+
return ("artikeep: документы Claude Docs правились после последнего экспорта — " + "; ".join(lines)
|
|
343
|
+
+ '. Экспортируй каждую вкладку: mcp__claude_ai_Claude_Docs__export с container {"kind":"project","id":"<doc>"}, '
|
|
344
|
+
'file = id вкладки, format = "markdown" — hook сохранит копию в %s. '
|
|
345
|
+
"Если экспорт недоступен, скажи об этом пользователю одной строкой и заканчивай." % store.root)
|
|
346
|
+
return ("artikeep: Claude Docs documents changed after their last export — " + "; ".join(lines)
|
|
347
|
+
+ '. Export every tab: mcp__claude_ai_Claude_Docs__export with container {"kind":"project","id":"<doc>"}, '
|
|
348
|
+
'file = the tab id, format = "markdown"; the hook keeps a copy in %s. '
|
|
349
|
+
"If export is unavailable, tell the user in one line and finish." % store.root)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------- drafts
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def scratchpads(session_id):
|
|
356
|
+
if not session_id:
|
|
357
|
+
return []
|
|
358
|
+
return [p for p in TMP_ROOT.glob("claude-*/*/%s/scratchpad" % session_id) if p.is_dir()]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def mirror_drafts(store: Store, session_id, cwd=None) -> int:
|
|
362
|
+
changed = 0
|
|
363
|
+
droot = store.root / "drafts"
|
|
364
|
+
for sp in scratchpads(session_id):
|
|
365
|
+
html_dirs = set()
|
|
366
|
+
for dirpath, dirnames, filenames in os.walk(sp):
|
|
367
|
+
depth = len(Path(dirpath).relative_to(sp).parts)
|
|
368
|
+
dirnames[:] = [] if depth >= DRAFT_MAX_DEPTH else [
|
|
369
|
+
x for x in dirnames if x not in DRAFT_SKIP_DIRS and not x.endswith((".xcarchive", ".app"))]
|
|
370
|
+
if any(f.endswith((".html", ".htm")) for f in filenames):
|
|
371
|
+
html_dirs.add(Path(dirpath))
|
|
372
|
+
if not html_dirs:
|
|
373
|
+
continue
|
|
374
|
+
existing = list(droot.glob("*-%s" % session_id[:8])) if droot.exists() else []
|
|
375
|
+
stamp = dt.datetime.fromtimestamp(sp.stat().st_mtime).date().isoformat()
|
|
376
|
+
dest_root = existing[0] if existing else droot / ("%s-%s" % (stamp, session_id[:8]))
|
|
377
|
+
meta = dest_root / "_meta.json"
|
|
378
|
+
if not meta.exists():
|
|
379
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
meta.write_text(json.dumps({"session": session_id, "agent": "claude-code", "project": project_root(cwd)},
|
|
381
|
+
ensure_ascii=False), encoding="utf-8")
|
|
382
|
+
for hd in html_dirs:
|
|
383
|
+
for f in hd.iterdir():
|
|
384
|
+
if not f.is_file() or f.suffix.lower() not in DRAFT_EXT or f.stat().st_size > DRAFT_MAX_BYTES:
|
|
385
|
+
continue
|
|
386
|
+
dst = dest_root / f.relative_to(sp)
|
|
387
|
+
if dst.exists() and dst.stat().st_mtime >= f.stat().st_mtime:
|
|
388
|
+
continue
|
|
389
|
+
copy_file(f, dst)
|
|
390
|
+
changed += 1
|
|
391
|
+
return changed
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def cmd_stop(store: Store, payload: dict) -> None:
|
|
395
|
+
try:
|
|
396
|
+
if mirror_drafts(store, payload.get("session_id"), payload.get("cwd")):
|
|
397
|
+
store.kick()
|
|
398
|
+
except Exception:
|
|
399
|
+
store.error("drafts")
|
|
400
|
+
try:
|
|
401
|
+
reason = docs_reminder(store, payload)
|
|
402
|
+
if reason:
|
|
403
|
+
print(json.dumps({"decision": "block", "reason": reason}, ensure_ascii=False))
|
|
404
|
+
except Exception:
|
|
405
|
+
store.error("docs reminder")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ---------------------------------------------------------------- self-check
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def recent_publishes(store: Store, days: int) -> list:
|
|
412
|
+
cutoff = time.time() - days * 86400
|
|
413
|
+
found = []
|
|
414
|
+
for f in TRANSCRIPTS.rglob("*.jsonl") if TRANSCRIPTS.exists() else []:
|
|
415
|
+
try:
|
|
416
|
+
if f.stat().st_mtime < cutoff:
|
|
417
|
+
continue
|
|
418
|
+
results, uses = {}, []
|
|
419
|
+
with open(f, encoding="utf-8", errors="ignore") as fh:
|
|
420
|
+
for line in fh:
|
|
421
|
+
if '"Artifact"' not in line and "tool_result" not in line:
|
|
422
|
+
continue
|
|
423
|
+
try:
|
|
424
|
+
rec = json.loads(line)
|
|
425
|
+
except Exception:
|
|
426
|
+
continue
|
|
427
|
+
content = (rec.get("message") or {}).get("content")
|
|
428
|
+
if not isinstance(content, list):
|
|
429
|
+
continue
|
|
430
|
+
for c in content:
|
|
431
|
+
if not isinstance(c, dict):
|
|
432
|
+
continue
|
|
433
|
+
if c.get("type") == "tool_use" and c.get("name") == "Artifact":
|
|
434
|
+
uses.append((c, rec))
|
|
435
|
+
elif c.get("type") == "tool_result":
|
|
436
|
+
results[c.get("tool_use_id")] = c
|
|
437
|
+
for c, rec in uses:
|
|
438
|
+
inp = c.get("input") or {}
|
|
439
|
+
if (inp.get("action") or "publish") != "publish":
|
|
440
|
+
continue
|
|
441
|
+
r = results.get(c["id"])
|
|
442
|
+
if r is None or r.get("is_error"):
|
|
443
|
+
continue
|
|
444
|
+
if "refused" in json.dumps(r.get("content"), ensure_ascii=False)[:300].lower():
|
|
445
|
+
continue
|
|
446
|
+
found.append({
|
|
447
|
+
"tool_use_id": c["id"], "tool_name": "Artifact", "tool_input": inp,
|
|
448
|
+
"tool_response": r.get("content"), "session_id": rec.get("sessionId") or f.stem,
|
|
449
|
+
"cwd": rec.get("cwd"), "timestamp": rec.get("timestamp"),
|
|
450
|
+
})
|
|
451
|
+
except Exception:
|
|
452
|
+
store.error("recent_publishes %s" % f)
|
|
453
|
+
return found
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def cmd_check(store: Store, payload: dict, days: int = 3) -> None:
|
|
457
|
+
m = store.load()
|
|
458
|
+
known = {p.get("tool_use_id") for e in m["items"].values() for p in e.get("publishes", [])}
|
|
459
|
+
lost, backfilled = [], 0
|
|
460
|
+
for p in recent_publishes(store, days):
|
|
461
|
+
if p["tool_use_id"] in known:
|
|
462
|
+
continue
|
|
463
|
+
r = handle_artifact(store, p)
|
|
464
|
+
if r and r.startswith("partial"):
|
|
465
|
+
lost.append(p["tool_input"].get("file_path") or p["tool_use_id"])
|
|
466
|
+
backfilled += 1
|
|
467
|
+
store.kick() # also pulls what other machines pushed
|
|
468
|
+
if backfilled or lost:
|
|
469
|
+
msg = _t(store, "artikeep: hook пропустил %d публикаций, дописаны задним числом.",
|
|
470
|
+
"artikeep: the hook missed %d publishes; archived them now.") % backfilled
|
|
471
|
+
if lost:
|
|
472
|
+
msg += _t(store, " Без исходника: %s.", " Source gone: %s.") % ", ".join(lost[:5])
|
|
473
|
+
msg += _t(store, " Проверьте, что hook artikeep подключён (artikeep doctor).",
|
|
474
|
+
" Check that the artikeep hook is installed (artikeep doctor).")
|
|
475
|
+
_alarm(store, "missed", msg)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
COMMANDS = {"publish": cmd_publish, "docs": cmd_docs, "stop": cmd_stop, "drafts": cmd_stop, "check": cmd_check}
|