sessionreel 0.1.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.
@@ -0,0 +1,7 @@
1
+ """sessionreel: turn a coding-agent session log into a short recap video."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("sessionreel")
6
+ except PackageNotFoundError: # running from a source checkout
7
+ __version__ = "0.0.0+dev"
sessionreel/board.py ADDED
@@ -0,0 +1,101 @@
1
+ """Checks a storyboard must pass before any frame is drawn.
2
+
3
+ A storyboard is plain JSON that people and agents edit between `plan` and `render`, so the
4
+ renderer cannot trust it: `validate` normalises its shape (clear errors instead of tracebacks),
5
+ `reredact` runs the redactor over every string again (idempotent — placeholders do not re-match),
6
+ and `caption_claims` flags numbers in captions that the scene's own data does not contain.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import math
12
+ import re
13
+ from typing import Any
14
+
15
+ from .redact import Redactor
16
+
17
+ KINDS = {"title", "prompt", "montage", "terminal", "diff", "ship", "say", "stats", "end"}
18
+ MAX_SECONDS = 30.0
19
+
20
+
21
+ class BoardError(ValueError):
22
+ pass
23
+
24
+
25
+ def _str(v: Any) -> str:
26
+ return "" if v is None else str(v)
27
+
28
+
29
+ def validate(board: Any) -> dict:
30
+ """Return a cleaned copy of `board` or raise BoardError naming the first problem."""
31
+ if not isinstance(board, dict):
32
+ raise BoardError("storyboard must be a JSON object")
33
+ scenes = board.get("scenes")
34
+ if not isinstance(scenes, list):
35
+ raise BoardError("storyboard has no 'scenes' list")
36
+ out = copy.deepcopy(board)
37
+ clean: list[dict] = []
38
+ for i, sc in enumerate(scenes):
39
+ if not isinstance(sc, dict):
40
+ raise BoardError(f"scene {i} is not an object")
41
+ kind = sc.get("kind")
42
+ if kind not in KINDS:
43
+ raise BoardError(f"scene {i}: unknown kind {kind!r} (expected one of {', '.join(sorted(KINDS))})")
44
+ sc = dict(sc)
45
+ try:
46
+ secs = float(sc.get("seconds", 3))
47
+ except (TypeError, ValueError):
48
+ raise BoardError(f"scene {i} ({kind}): seconds must be a number, got {sc.get('seconds')!r}") from None
49
+ if not math.isfinite(secs) or secs <= 0:
50
+ raise BoardError(f"scene {i} ({kind}): seconds must be a positive number, got {secs!r}")
51
+ sc["seconds"] = min(secs, MAX_SECONDS)
52
+ for key in ("caption", "fact", "text", "command", "file", "message", "badge", "status",
53
+ "project", "title", "date", "model", "branch", "duration"):
54
+ if key in sc:
55
+ sc[key] = _str(sc[key])
56
+ for key in ("output", "lines", "files"):
57
+ if key in sc:
58
+ if not isinstance(sc[key], list):
59
+ raise BoardError(f"scene {i} ({kind}): {key} must be a list of strings")
60
+ sc[key] = [_str(x) for x in sc[key]]
61
+ if kind == "stats":
62
+ items = sc.get("items", [])
63
+ if not isinstance(items, list) or not all(isinstance(it, (list, tuple)) and len(it) == 2 for it in items):
64
+ raise BoardError(f"scene {i} (stats): items must be a list of [label, value] pairs")
65
+ sc["items"] = [[_str(a), _str(b)] for a, b in items]
66
+ for key in ("added", "removed"):
67
+ if key in sc:
68
+ try:
69
+ sc[key] = int(sc[key])
70
+ except (TypeError, ValueError):
71
+ raise BoardError(f"scene {i} ({kind}): {key} must be an integer") from None
72
+ clean.append(sc)
73
+ out["scenes"] = clean
74
+ return out
75
+
76
+
77
+ def reredact(board: dict, redactor: Redactor | None = None) -> tuple[dict, dict[str, int]]:
78
+ """Run redaction over every string in every scene. Returns the board and what changed."""
79
+ r = redactor or Redactor()
80
+ scenes = r.value(board["scenes"])
81
+ return {**board, "scenes": scenes}, dict(r.counts)
82
+
83
+
84
+ _NUM = re.compile(r"\d[\d,.]*")
85
+
86
+
87
+ def caption_claims(board: dict) -> list[str]:
88
+ """Numbers in a caption that appear nowhere in that scene's data or its template fact."""
89
+ warnings: list[str] = []
90
+ for i, sc in enumerate(board.get("scenes", [])):
91
+ cap = sc.get("caption", "")
92
+ if not cap or cap == sc.get("fact"):
93
+ continue
94
+ support = " ".join(_str(v) if not isinstance(v, list) else " ".join(map(_str, v))
95
+ for k, v in sc.items() if k != "caption")
96
+ known = {n.strip(".,").replace(",", "") for n in _NUM.findall(support)}
97
+ for n in _NUM.findall(cap):
98
+ n = n.strip(".,").replace(",", "")
99
+ if n and n not in known:
100
+ warnings.append(f"scene {i} ({sc.get('kind')}): caption says {n!r}, which is not in the scene data")
101
+ return warnings
sessionreel/cli.py ADDED
@@ -0,0 +1,193 @@
1
+ """sessionreel command line."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ from . import __version__, ingest, redact, render, story
13
+
14
+
15
+ def _eprint(*a: object) -> None:
16
+ print(*a, file=sys.stderr)
17
+
18
+
19
+ def _progress(done: int, total: int) -> None:
20
+ if done == total or done % 15 == 0:
21
+ bar = int(28 * done / max(1, total))
22
+ sys.stderr.write(f"\r rendering [{'█' * bar}{'·' * (28 - bar)}] {done}/{total} frames")
23
+ if done == total:
24
+ sys.stderr.write("\n")
25
+ sys.stderr.flush()
26
+
27
+
28
+ def _resolve(ref: str | None) -> Path:
29
+ if ref:
30
+ return ingest.find_session(ref)
31
+ found = ingest.current_session()
32
+ if found is None:
33
+ raise FileNotFoundError(
34
+ f"no Claude Code sessions for {os.getcwd()} (or its parents) under {ingest.projects_root()}\n"
35
+ " run `sessionreel list` to see all sessions, or pass a session id / .jsonl path")
36
+ return found
37
+
38
+
39
+ def _plan(args: argparse.Namespace) -> dict:
40
+ path = _resolve(args.session)
41
+ sess = ingest.load(path)
42
+ redact.redact_session(sess, extra=args.redact or ())
43
+ board = story.build(sess, lang=args.lang, whole=args.whole,
44
+ project_name=getattr(args, "project", None), show_branch=not getattr(args, "no_branch", False))
45
+ n = sum(board["redactions"].values())
46
+ ask = next((s["text"] for s in board["scenes"] if s["kind"] == "prompt"), "")
47
+ what = ", ".join(f"{v} {k}" for k, v in sorted(board["redactions"].items(), key=lambda kv: -kv[1]))
48
+ _eprint(f" session {path.stem} ({len(sess.events)} events"
49
+ + (f", {sess.skipped_lines} malformed lines skipped" if sess.skipped_lines else "") + ")")
50
+ if ask:
51
+ _eprint(f" ask {ask[:90]}{'…' if len(ask) > 90 else ''}")
52
+ _eprint(f" story {' → '.join(s['kind'] for s in board['scenes'])}"
53
+ + ("" if board["arc"] else " (no red→green arc found; built from the largest edits)"))
54
+ _eprint(f" redacted {n} item{'s' if n != 1 else ''}" + (f" ({what})" if what else ""))
55
+ return board
56
+
57
+
58
+ def _render(board: dict, args: argparse.Namespace) -> Path:
59
+ from .board import caption_claims, reredact, validate
60
+ board = validate(board)
61
+ board, changed = reredact(board)
62
+ if changed:
63
+ _eprint(f" redacted {sum(changed.values())} more item(s) found in the storyboard ({', '.join(changed)})")
64
+ for w in caption_claims(board):
65
+ _eprint(f" warning {w}")
66
+ out = Path(args.output)
67
+ audio = None
68
+ if args.voice is not None:
69
+ from . import voice
70
+ board, audio = voice.narrate(board, voice=args.voice or None)
71
+ t0 = time.time()
72
+ render.render(board, out, fmt=args.format, audio=audio, progress=_progress)
73
+ secs = sum(float(s.get("seconds", 3)) for s in board["scenes"])
74
+ _eprint(f" wrote {out} ({secs:.0f}s video, {out.stat().st_size / 1e6:.1f} MB, {time.time() - t0:.0f}s to render)")
75
+ if args.gif:
76
+ gif = render.to_gif(out, out.with_suffix(".gif"))
77
+ _eprint(f" wrote {gif} ({gif.stat().st_size / 1e6:.1f} MB)")
78
+ return out
79
+
80
+
81
+ def cmd_make(args: argparse.Namespace) -> int:
82
+ board = _plan(args)
83
+ _render(board, args)
84
+ return 0
85
+
86
+
87
+ def cmd_plan(args: argparse.Namespace) -> int:
88
+ board = _plan(args)
89
+ text = json.dumps(board, ensure_ascii=False, indent=2)
90
+ if args.output == "-":
91
+ print(text)
92
+ else:
93
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
94
+ _eprint(f" wrote {args.output} — edit captions, then: sessionreel render {args.output}")
95
+ return 0
96
+
97
+
98
+ def cmd_render(args: argparse.Namespace) -> int:
99
+ board = json.loads(Path(args.storyboard).read_text(encoding="utf-8"))
100
+ if board.get("version") != story.STORYBOARD_VERSION:
101
+ _eprint(f" storyboard version {board.get('version')} is not supported by this sessionreel ({__version__})")
102
+ return 2
103
+ _render(board, args)
104
+ return 0
105
+
106
+
107
+ def cmd_list(args: argparse.Namespace) -> int:
108
+ files = ingest.session_files(cwd=os.getcwd() if args.here else None)[: args.limit]
109
+ if not files:
110
+ _eprint(f" no sessions under {ingest.projects_root()}")
111
+ return 1
112
+ home = str(Path.home())
113
+ for f in files:
114
+ sess = ingest.load(f)
115
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(f.stat().st_mtime))
116
+ cwd = sess.cwd.replace(home, "~") if sess.cwd else "?"
117
+ mins = int(story.active_seconds(sess.events) // 60)
118
+ print(f"{f.stem[:8]} {when} {mins:>4}m {len(sess.prompts):>3} prompts {len(sess.tools):>4} tools {cwd}")
119
+ return 0
120
+
121
+
122
+ def cmd_demo(args: argparse.Namespace) -> int:
123
+ args.session = str(resources.files("sessionreel").joinpath("demo", "demo-session.jsonl"))
124
+ return cmd_make(args)
125
+
126
+
127
+ def build_parser() -> argparse.ArgumentParser:
128
+ p = argparse.ArgumentParser(
129
+ prog="sessionreel",
130
+ description="Turn a Claude Code session into a short recap video. Local, redacted, no API key.",
131
+ )
132
+ p.add_argument("--version", action="version", version=f"sessionreel {__version__}")
133
+ sub = p.add_subparsers(dest="cmd")
134
+
135
+ def story_opts(sp: argparse.ArgumentParser) -> None:
136
+ sp.add_argument("session", nargs="?", help="session id, id prefix or .jsonl path (default: latest here)")
137
+ sp.add_argument("--lang", choices=sorted(story.T), default="en", help="caption language")
138
+ sp.add_argument("--whole", action="store_true", help="tell the whole session, not just the episode around the fix")
139
+ sp.add_argument("--redact", action="append", metavar="REGEX", help="extra pattern to redact (repeatable)")
140
+ sp.add_argument("--project", metavar="NAME", help="name shown on every frame instead of the directory name")
141
+ sp.add_argument("--no-branch", action="store_true", help="do not show the git branch")
142
+
143
+ def video_opts(sp: argparse.ArgumentParser, default_out: str = "reel.mp4") -> None:
144
+ sp.add_argument("-o", "--output", default=default_out)
145
+ sp.add_argument("--format", choices=sorted(render.FORMATS), default="square")
146
+ sp.add_argument("--voice", nargs="?", const="", default=None, metavar="NAME",
147
+ help="narrate captions with the OS voice (optionally a voice name)")
148
+ sp.add_argument("--gif", action="store_true", help="also write a GIF next to the mp4")
149
+
150
+ mk = sub.add_parser("make", help="session → mp4 (default command)")
151
+ story_opts(mk)
152
+ video_opts(mk)
153
+ mk.set_defaults(fn=cmd_make)
154
+
155
+ pl = sub.add_parser("plan", help="session → editable storyboard.json")
156
+ story_opts(pl)
157
+ pl.add_argument("-o", "--output", default="storyboard.json", help="path, or - for stdout")
158
+ pl.set_defaults(fn=cmd_plan)
159
+
160
+ rd = sub.add_parser("render", help="storyboard.json → mp4")
161
+ rd.add_argument("storyboard")
162
+ video_opts(rd)
163
+ rd.set_defaults(fn=cmd_render)
164
+
165
+ ls = sub.add_parser("list", help="recent sessions")
166
+ ls.add_argument("--here", action="store_true", help="only sessions for the current directory")
167
+ ls.add_argument("-n", "--limit", type=int, default=20)
168
+ ls.set_defaults(fn=cmd_list)
169
+
170
+ dm = sub.add_parser("demo", help="render the bundled sample session (no logs needed)")
171
+ dm.add_argument("--lang", choices=sorted(story.T), default="en")
172
+ video_opts(dm, "sessionreel-demo.mp4")
173
+ dm.set_defaults(fn=cmd_demo, whole=False, redact=None, project=None, no_branch=False)
174
+ return p
175
+
176
+
177
+ def main(argv: list[str] | None = None) -> int:
178
+ argv = sys.argv[1:] if argv is None else argv
179
+ parser = build_parser()
180
+ if not argv or argv[0] not in {"make", "plan", "render", "list", "demo", "-h", "--help", "--version"}:
181
+ argv = ["make", *argv]
182
+ args = parser.parse_args(argv)
183
+ try:
184
+ return args.fn(args)
185
+ except (FileNotFoundError, ValueError, RuntimeError, OSError, json.JSONDecodeError) as e:
186
+ _eprint(f"sessionreel: {e}")
187
+ return 1
188
+ except KeyboardInterrupt:
189
+ return 130
190
+
191
+
192
+ if __name__ == "__main__":
193
+ raise SystemExit(main())
@@ -0,0 +1,25 @@
1
+ {"type": "ai-title", "aiTitle": "Unicode titles produce empty slugs", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001"}
2
+ {"parentUuid": null, "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "72acfd2f-7482-4fdc-a3f1-c2c764cc7afc", "timestamp": "2026-09-18T14:02:11Z", "message": {"role": "user", "content": "Titles with accents or emoji come out as broken slugs — \"Crème brûlée 🍮\" becomes \"cr-me-br-l-e\". Fix slugify so it produces \"creme-brulee\", cap slugs at 80 chars, and add tests."}}
3
+ {"parentUuid": "72acfd2f-7482-4fdc-a3f1-c2c764cc7afc", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "db7b1b2f-7827-4ef8-908a-f85fdc9535db", "timestamp": "2026-09-18T14:02:15Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "text", "text": "I'll look at how slugs are built and what the tests cover first."}], "usage": {"input_tokens": 6, "cache_creation_input_tokens": 2100, "cache_read_input_tokens": 31000, "output_tokens": 180}}}
4
+ {"parentUuid": "db7b1b2f-7827-4ef8-908a-f85fdc9535db", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "5926dc4d-f3de-4731-b3f5-97611cdd5910", "timestamp": "2026-09-18T14:02:20Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_e4987968ed914b69b42e1f81", "name": "Read", "input": {"file_path": "/home/dev/slugkit/slugkit/slug.py"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
5
+ {"parentUuid": "5926dc4d-f3de-4731-b3f5-97611cdd5910", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "6310dfe0-48de-4a3b-9ada-02a0c2ae149f", "timestamp": "2026-09-18T14:02:22Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_e4987968ed914b69b42e1f81", "type": "tool_result", "content": "import re\n\n\ndef slugify(title: str) -> str:\n \"\"\"Lowercase, keep a-z0-9, join words with '-'.\"\"\"\n words = re.findall(r\"[a-z0-9]+\", title.lower())\n return \"-\".join(words)\n", "is_error": false}]}}
6
+ {"parentUuid": "6310dfe0-48de-4a3b-9ada-02a0c2ae149f", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "8cf3b5ab-9691-4a02-8e5f-b5a5e155dde4", "timestamp": "2026-09-18T14:02:27Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_ee00f0f96c7445b78df7e9dd", "name": "Grep", "input": {"pattern": "slugify\\(", "path": "/home/dev/slugkit"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
7
+ {"parentUuid": "8cf3b5ab-9691-4a02-8e5f-b5a5e155dde4", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "972acd37-17ce-47e9-aac8-8e50230e96c3", "timestamp": "2026-09-18T14:02:29Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_ee00f0f96c7445b78df7e9dd", "type": "tool_result", "content": "slugkit/slug.py\nslugkit/posts.py\nslugkit/feed.py\ntests/test_slug.py", "is_error": false}]}}
8
+ {"parentUuid": "972acd37-17ce-47e9-aac8-8e50230e96c3", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "4ef38ca9-3398-42d2-941a-0f546d0309db", "timestamp": "2026-09-18T14:02:34Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_1eeabb0fc4e344129403c1dc", "name": "Read", "input": {"file_path": "/home/dev/slugkit/slugkit/posts.py"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
9
+ {"parentUuid": "4ef38ca9-3398-42d2-941a-0f546d0309db", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "4628f880-f74a-4792-bc63-d8ba78f623a9", "timestamp": "2026-09-18T14:02:36Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_1eeabb0fc4e344129403c1dc", "type": "tool_result", "content": "from .slug import slugify\n...", "is_error": false}]}}
10
+ {"parentUuid": "4628f880-f74a-4792-bc63-d8ba78f623a9", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "0cd11491-21df-488b-bf48-379f99069811", "timestamp": "2026-09-18T14:02:41Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_120cce50341b4d16bf5bb118", "name": "Read", "input": {"file_path": "/home/dev/slugkit/tests/test_slug.py"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
11
+ {"parentUuid": "0cd11491-21df-488b-bf48-379f99069811", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "5f19650e-1e3f-4ba3-ad36-fe20a7482f6d", "timestamp": "2026-09-18T14:02:43Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_120cce50341b4d16bf5bb118", "type": "tool_result", "content": "def test_basic(): ...", "is_error": false}]}}
12
+ {"parentUuid": "5f19650e-1e3f-4ba3-ad36-fe20a7482f6d", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "d8e21392-7966-4f56-972f-7ad755a267cb", "timestamp": "2026-09-18T14:02:48Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_e9ddc3d9bd144b919c460c96", "name": "Read", "input": {"file_path": "/home/dev/slugkit/pyproject.toml"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
13
+ {"parentUuid": "d8e21392-7966-4f56-972f-7ad755a267cb", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "318366d2-a5e9-47d2-802d-c7fad6e7c391", "timestamp": "2026-09-18T14:02:50Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_e9ddc3d9bd144b919c460c96", "type": "tool_result", "content": "[project]\nname = \"slugkit\"", "is_error": false}]}}
14
+ {"parentUuid": "318366d2-a5e9-47d2-802d-c7fad6e7c391", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "3cf5b1fe-4c72-4e7f-a34a-e40bf435141b", "timestamp": "2026-09-18T14:02:59Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_537f9b8411a24b07a132a244", "name": "Edit", "input": {"file_path": "/home/dev/slugkit/tests/test_slug.py", "old_string": "def test_basic():", "new_string": "def test_accents():\n assert slugify(\"Crème brûlée 🍮\") == \"creme-brulee\"\n\n\ndef test_max_len():\n assert len(slugify(\"word \" * 40)) <= 80\n\n\ndef test_basic():"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
15
+ {"parentUuid": "3cf5b1fe-4c72-4e7f-a34a-e40bf435141b", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "b02e5ee3-a7e3-4e43-854b-f52fa3965b44", "timestamp": "2026-09-18T14:03:01Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_537f9b8411a24b07a132a244", "type": "tool_result", "content": "The file tests/test_slug.py has been updated.", "is_error": false}]}, "toolUseResult": {"filePath": "/home/dev/slugkit/tests/test_slug.py", "structuredPatch": [{"oldStart": 1, "oldLines": 3, "newStart": 1, "newLines": 11, "lines": [" from slugkit.slug import slugify", "+", "+", "+def test_accents():", "+ assert slugify(\"Crème brûlée 🍮\") == \"creme-brulee\"", "+", "+", "+def test_max_len():", "+ assert len(slugify(\"word \" * 40)) <= 80", " ", " "]}]}}
16
+ {"parentUuid": "b02e5ee3-a7e3-4e43-854b-f52fa3965b44", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "bbead3f9-a6c8-43b9-98ef-806d2d2580f4", "timestamp": "2026-09-18T14:03:15Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_f575db7dcbef41228716d19f", "name": "Bash", "input": {"command": "cd /home/dev/slugkit && python -m pytest -q", "description": "Run tests"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
17
+ {"parentUuid": "bbead3f9-a6c8-43b9-98ef-806d2d2580f4", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "17cb27ba-2866-4a3b-9be4-21320117bc30", "timestamp": "2026-09-18T14:03:17Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_f575db7dcbef41228716d19f", "type": "tool_result", "content": "Exit code 1\n..F.F......... [100%]\n=================================== FAILURES ===================================\n________________________________ test_accents _________________________________\n> assert slugify(\"Crème brûlée 🍮\") == \"creme-brulee\"\nE AssertionError: assert 'cr-me-br-l-e' == 'creme-brulee'\n________________________________ test_max_len _________________________________\nE assert 199 <= 80\n=========================== short test summary info ============================\nFAILED tests/test_slug.py::test_accents - AssertionError\nFAILED tests/test_slug.py::test_max_len - assert 199 <= 80\n2 failed, 12 passed in 0.31s", "is_error": true}]}}
18
+ {"parentUuid": "17cb27ba-2866-4a3b-9be4-21320117bc30", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "8b9fa0cf-2791-47be-b6e2-41e59fba9621", "timestamp": "2026-09-18T14:03:21Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "text", "text": "Two failures, as expected: accented letters are dropped instead of folded, and there is no length cap. Normalising to NFKD and encoding to ASCII folds é→e and drops the emoji."}], "usage": {"input_tokens": 6, "cache_creation_input_tokens": 2100, "cache_read_input_tokens": 31000, "output_tokens": 180}}}
19
+ {"parentUuid": "8b9fa0cf-2791-47be-b6e2-41e59fba9621", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "ef99cae3-cd36-4292-9925-dbcd8b9ef4a1", "timestamp": "2026-09-18T14:03:42Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_e3e03248803c49ab9521cf24", "name": "Edit", "input": {"file_path": "/home/dev/slugkit/slugkit/slug.py", "old_string": "import re\n\n\ndef slugify(title: str) -> str:\n \"\"\"Lowercase, keep a-z0-9, join words with '-'.\"\"\"\n words = re.findall(r\"[a-z0-9]+\", title.lower())\n return \"-\".join(words)\n", "new_string": "import re\nimport unicodedata\n\n\ndef slugify(title: str, max_len: int = 80) -> str:\n \"\"\"ASCII slug: strip accents, drop symbols and emoji, join words with '-'.\"\"\"\n text = unicodedata.normalize(\"NFKD\", title)\n text = text.encode(\"ascii\", \"ignore\").decode(\"ascii\")\n words = re.findall(r\"[a-z0-9]+\", text.lower())\n return \"-\".join(words)[:max_len].rstrip(\"-\")\n"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
20
+ {"parentUuid": "ef99cae3-cd36-4292-9925-dbcd8b9ef4a1", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "0deb9e41-898a-46ae-b721-a5c4325f9063", "timestamp": "2026-09-18T14:03:44Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_e3e03248803c49ab9521cf24", "type": "tool_result", "content": "The file slugkit/slug.py has been updated.", "is_error": false}]}, "toolUseResult": {"filePath": "/home/dev/slugkit/slugkit/slug.py", "structuredPatch": [{"oldStart": 1, "oldLines": 8, "newStart": 1, "newLines": 11, "lines": [" import re", "+import unicodedata", " ", " ", "-def slugify(title: str) -> str:", "- \"\"\"Lowercase, keep a-z0-9, join words with '-'.\"\"\"", "- words = re.findall(r\"[a-z0-9]+\", title.lower())", "- return \"-\".join(words)", "+def slugify(title: str, max_len: int = 80) -> str:", "+ \"\"\"ASCII slug: strip accents, drop symbols and emoji, join words with '-'.\"\"\"", "+ text = unicodedata.normalize(\"NFKD\", title)", "+ text = text.encode(\"ascii\", \"ignore\").decode(\"ascii\")", "+ words = re.findall(r\"[a-z0-9]+\", text.lower())", "+ return \"-\".join(words)[:max_len].rstrip(\"-\")", " "]}]}}
21
+ {"parentUuid": "0deb9e41-898a-46ae-b721-a5c4325f9063", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "2755f14f-5fe0-4396-9edb-90b8c4ed30f5", "timestamp": "2026-09-18T14:03:56Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_867fd826f46a4126ac85342b", "name": "Bash", "input": {"command": "cd /home/dev/slugkit && python -m pytest -q", "description": "Run tests"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
22
+ {"parentUuid": "2755f14f-5fe0-4396-9edb-90b8c4ed30f5", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "9d8e2ffe-cd19-450b-a5c2-e62718c5d99d", "timestamp": "2026-09-18T14:03:58Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_867fd826f46a4126ac85342b", "type": "tool_result", "content": ".............. [100%]\n14 passed in 0.27s", "is_error": false}]}}
23
+ {"parentUuid": "9d8e2ffe-cd19-450b-a5c2-e62718c5d99d", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "92e963d0-f683-4fd8-ae5a-4df0135eea6a", "timestamp": "2026-09-18T14:04:06Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "tool_use", "id": "toolu_7fcddb74f9304f6b8f9ef07a", "name": "Bash", "input": {"command": "cd /home/dev/slugkit && git add -A && git commit -q -m \"slugify: fold accents, drop emoji, cap at 80 chars\" && git push -q", "description": "Commit and push"}}], "usage": {"input_tokens": 4, "cache_creation_input_tokens": 900, "cache_read_input_tokens": 33000, "output_tokens": 220}}}
24
+ {"parentUuid": "92e963d0-f683-4fd8-ae5a-4df0135eea6a", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "user", "uuid": "b8dbc652-6557-48c1-ac3d-8a66f0bd24f1", "timestamp": "2026-09-18T14:04:08Z", "message": {"role": "user", "content": [{"tool_use_id": "toolu_7fcddb74f9304f6b8f9ef07a", "type": "tool_result", "content": "", "is_error": false}]}}
25
+ {"parentUuid": "b8dbc652-6557-48c1-ac3d-8a66f0bd24f1", "isSidechain": false, "userType": "external", "cwd": "/home/dev/slugkit", "sessionId": "5e55a0d0-de40-4c1e-9d3e-000000000001", "version": "2.1.270", "gitBranch": "fix/unicode-slugs", "type": "assistant", "uuid": "52dca5ee-d8de-4c15-a93f-646783fcd3e3", "timestamp": "2026-09-18T14:04:12Z", "message": {"model": "claude-opus-5", "role": "assistant", "type": "message", "content": [{"type": "text", "text": "Fixed. `slugify(\"Crème brûlée 🍮\")` now returns `creme-brulee`: titles are NFKD-normalised and folded to ASCII, symbols and emoji are dropped, and slugs are capped at 80 characters. Two new tests cover it; all 14 pass."}], "usage": {"input_tokens": 6, "cache_creation_input_tokens": 2100, "cache_read_input_tokens": 31000, "output_tokens": 180}}}
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,92 @@
1
+ Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font creation
14
+ efforts of academic and linguistic communities, and to provide a free and
15
+ open framework in which fonts may be shared and improved in partnership
16
+ with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply
25
+ to any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software components as
36
+ distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to, deleting,
39
+ or substituting -- in part or in whole -- any of the components of the
40
+ Original Version, by changing formats or by porting the Font Software to a
41
+ new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION AND CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
49
+ redistribute, and sell modified and unmodified copies of the Font
50
+ Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components,
53
+ in Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the corresponding
64
+ Copyright Holder. This restriction only applies to the primary font name as
65
+ presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created
77
+ using the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.