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 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,5 @@
1
+ import sys
2
+
3
+ from artikeep.cli import main
4
+
5
+ sys.exit(main())
@@ -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}