Repository navigation
Expand file tree
/
Copy pathapp.py
More file actions
3009 lines (2540 loc) · 118 KB
/
Copy pathapp.py
File metadata and controls
3009 lines (2540 loc) · 118 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
yapmap -- turn the yap into a map.
Paste a YouTube link, get back a canvas: a mindmap where every idea is
timestamped and explained, a tool built for that kind of video (flashcards, a
cook-along, a buy/skip card, claims to check, or a timeline), a wall of sticky
notes, the shelf of everything they name-dropped, a summary you can re-voice,
next moves you can commit to, and timestamped receipts you can tick, download,
or watch as a no-yap cut. Then ask the video anything, with answers that cite
the second they came from.
Around the canvas sit the other tools: beef mode (two videos, where they
clash), the purge (triage a pile of videos), a searchable library of
everything you've mapped, a spaced-repetition review deck, a follow-through
tracker, and a monthly Wrapped.
Flask backend: fetches transcripts, shells out to the Claude Code CLI
(authenticated with your Claude subscription -- no API key, no per-token
billing), and streams structured JSON to the frontend as Claude writes it.
One-time setup:
npm install -g @anthropic-ai/claude-code
claude auth login # sign in with your Claude subscription
claude auth status # confirm it shows you as logged in
Run:
pip install -r requirements.txt
python app.py
Then open http://localhost:5000
"""
import hashlib
import json
import math
import os
import re
import shutil
import signal
import subprocess
import threading
import time
import uuid
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from pathlib import Path
from flask import Flask, Response, render_template, request
import requests
from youtube_transcript_api import (
NoTranscriptFound,
TranscriptsDisabled,
VideoUnavailable,
YouTubeTranscriptApi,
)
from youtube_transcript_api import _errors as transcript_errors
from youtube_transcript_api._errors import CouldNotRetrieveTranscript
# Newer failure types, looked up by name so an older library still imports.
_transcript_error = lambda *names: tuple(c for c in (getattr(transcript_errors, n, None) for n in names) if c) # noqa: E731
YOUTUBE_BLOCKED = _transcript_error("RequestBlocked", "IpBlocked")
AGE_RESTRICTED = _transcript_error("AgeRestricted")
UNPLAYABLE = _transcript_error("VideoUnplayable", "PoTokenRequired")
app = Flask(__name__)
# Always revalidate static files: after an update, a browser must never run
# half the old modules and half the new ones.
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
ytt_api = YouTubeTranscriptApi()
# ------------------------------------------------------------------ config
# The Claude Code CLI binary. Override with an env var if it isn't on PATH
# under this name (or point it at a full path to claude.cmd on Windows).
CLAUDE_BIN = os.getenv("CLAUDE_BIN", "claude")
# Sonnet by default: this job is structured extraction over a long transcript,
# not deep reasoning, and on a Pro plan it goes much further per usage limit
# than Opus. Override with an alias ("opus", "haiku") or a full model name.
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "sonnet")
# A full canvas, or a beef over two transcripts, is a big ask -- give it room.
CLAUDE_TIMEOUT_SECONDS = int(os.getenv("CLAUDE_TIMEOUT_SECONDS", "600"))
# On a big job Claude may think before it writes. That took a canvas from about
# a minute to over four, and spends far more of your usage limit, so it's off
# by default. "auto" leaves it to the CLI's own default.
CLAUDE_THINKING = os.getenv("CLAUDE_THINKING", "off").strip().lower()
# Preferred transcript languages, in order. If none are available we fall
# back to whatever language the video actually has.
PREFERRED_LANGUAGES = ["en", "en-US", "en-GB"]
# Safety cap so a multi-hour video doesn't blow up the prompt. ~120k chars
# is roughly 30k tokens, comfortably inside context for any current Claude
# model while still covering a very long video.
MAX_TRANSCRIPT_CHARS = 120_000
# Beef puts two transcripts in one prompt, so each gets half the room.
BEEF_TRANSCRIPT_CHARS = 55_000
# The purge judges a pile of videos in one call from a slice of each: the
# start, the middle and the end. Twelve is where the prompt stays sane and
# YouTube stays happy with the transcript requests.
PURGE_MAX_VIDEOS = 12
PURGE_SAMPLE_CHARS = 6_000
# Asking a question: a transcript up to this size goes in whole, which gives
# the best answers. Anything longer sends only the parts that match the
# question best, so one question never costs a whole long video's worth.
ASK_FULL_TRANSCRIPT_CHARS = 36_000
ASK_CONTEXT_CHARS = 16_000
ASK_HISTORY_TURNS = 3
# Generated output is cached on disk. Subscription usage limits are a real
# budget, so reopening anything you've already made costs nothing.
CACHE_DIR = Path(os.getenv("YAPMAP_CACHE_DIR", ".cache"))
# Things only you create -- commitments, flashcard reviews and activity for
# Wrapped -- live apart from the cache, so clearing it never wipes them.
DATA_DIR = Path(os.getenv("YAPMAP_DATA_DIR", ".yapmap"))
COMMITMENTS_FILE = DATA_DIR / "commitments.json"
EVENTS_FILE = DATA_DIR / "events.json"
REVIEWS_FILE = DATA_DIR / "reviews.json"
# Bumped whenever the canvas gains fields. Canvases from an older version
# still open -- the page offers to regenerate them for the new sections.
SCHEMA_VERSION = 3
# Content modes the frontend knows how to style. Claude picks one; anything
# unrecognised falls back to "study".
KNOWN_MODES = {"study", "yap", "howto", "verdict", "story"}
DEFAULT_MODE = "study"
SHELF_KINDS = {"book", "tool", "person", "paper", "site", "other"}
EVENT_TYPES = {"storycard", "call", "cut", "review", "ask"}
CALL_GRADES = {"nailed", "close", "off"}
# Spaced repetition: days until a card comes back, by how many times in a row
# you've known it. Missing it sends it back to the start.
SRS_INTERVALS = [0, 1, 3, 7, 16, 35, 90]
REVIEW_GRADES = {"again", "good", "easy"}
# How long a finished job stays around, so a tab that reconnects late still
# gets the ending.
JOB_RETENTION_SECONDS = 15 * 60
VIDEO_ID_RE = re.compile(r"[0-9A-Za-z_-]{11}")
VIDEO_ID_PATTERNS = [
re.compile(r"(?:youtube\.com/watch\?.*[?&]v=|youtube\.com/watch\?v=)([0-9A-Za-z_-]{11})"),
re.compile(r"youtu\.be/([0-9A-Za-z_-]{11})"),
re.compile(r"youtube\.com/embed/([0-9A-Za-z_-]{11})"),
re.compile(r"youtube\.com/shorts/([0-9A-Za-z_-]{11})"),
re.compile(r"youtube\.com/live/([0-9A-Za-z_-]{11})"),
]
# The language everything is written in: (label, how to tell Claude). The
# app's own buttons stay English; this is about what Claude writes. "auto"
# means whatever the video is spoken in.
LANGUAGES = {
"en": ("English", "English (even if the video itself is in another language)"),
"hinglish": ("Hinglish", "Hinglish: Hindi written in the Latin alphabet and mixed with English, the way people text in India (e.g. 'yeh video basically batata hai ki...')"),
"hi": ("हिन्दी", "Hindi, in Devanagari script"),
"es": ("Español", "Spanish"),
"pt": ("Português", "Brazilian Portuguese"),
"fr": ("Français", "French"),
"de": ("Deutsch", "German"),
"ja": ("日本語", "Japanese"),
"ko": ("한국어", "Korean"),
"id": ("Bahasa Indonesia", "Indonesian"),
"auto": ("same as the video", None),
}
DEFAULT_LANG = "en"
# Each voice: (label shown in the UI, how Claude should rewrite the summary).
VOICES = {
"groupchat": (
"group chat",
"Rewrite it as a group chat between two or three friends who just watched it, with "
"short lowercase names like maya, dev and sam. 8 to 14 messages, one per line, each "
"formatted exactly as 'name: message'. Casual and lowercase, the odd emoji.",
),
"commentator": (
"sports commentator",
"Rewrite it as a breathless sports commentator calling the video like a cup final, "
"play by play. 2 or 3 short paragraphs.",
),
"brainrot": (
"brainrot",
"Rewrite it in full Gen Z brainrot: no cap, fr, lowkey, it's giving, aura, cooked, "
"ate, rizz, delulu, the works. 2 or 3 short paragraphs. The slang changes; the facts don't.",
),
"eli5": (
"like i'm five",
"Explain it to a five-year-old: short sentences, simple words, and one everyday "
"comparison. 2 or 3 short paragraphs.",
),
}
_data_lock = threading.Lock()
# ------------------------------------------------------------------ helpers
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def mtime_iso(path: Path) -> str:
return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds")
def parse_iso(value) -> datetime | None:
try:
return datetime.fromisoformat(str(value))
except (TypeError, ValueError):
return None
def digest(*parts: str) -> str:
return hashlib.sha256("\x00".join(parts).encode("utf-8")).hexdigest()[:8]
def short_hash(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:10]
def read_json(path: Path, default=None):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return default
def write_json(path: Path, payload) -> bool:
"""Atomic write: a crash mid-write never leaves a half-file behind."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, path)
return True
except OSError:
return False
def as_seconds(value) -> int | None:
"""Timestamps come back as ints, floats, or the occasional '12:30' string."""
if isinstance(value, bool) or value is None:
return None
if isinstance(value, (int, float)):
if isinstance(value, float) and not math.isfinite(value):
return None
return max(0, int(value))
if isinstance(value, str):
text = value.strip().strip("[]")
if text.isdigit():
return int(text)
if re.fullmatch(r"\d{1,2}(:\d{2}){1,2}", text):
total = 0
for part in text.split(":"):
total = total * 60 + int(part)
return total
return None
def clamp_t(value, duration: int | None = None) -> int | None:
"""A timestamp, dropped if it points past the end of the video."""
t = as_seconds(value)
if t is not None and duration and t > duration + 15:
return None
return t
def as_text(value, limit: int = 400) -> str:
if value is None:
return ""
return " ".join(str(value).split())[:limit]
def as_list(value) -> list:
return value if isinstance(value, list) else []
def error(message: str, status: int, **extra):
return {"error": message, **extra}, status
# ------------------------------------------------------------------ languages
def clean_lang(value) -> str:
code = str(value or "").strip().lower()
return code if code in LANGUAGES else DEFAULT_LANG
def resolve_lang(lang: str, transcript_code: str) -> str:
"""'Same as the video' for an English video is just English -- one cache entry, not two."""
if lang == "auto" and (transcript_code or "").lower().startswith("en"):
return "en"
return lang
def language_line(lang: str, transcript_language: str = "") -> str:
if lang == "auto":
spoken = transcript_language.replace("(auto-generated)", "").strip() or "the video's own language"
return f"Write every human-readable value in the language the video is spoken in: {spoken}."
return f"Write every human-readable value in {LANGUAGES.get(lang, LANGUAGES['en'])[1]}."
def lang_suffix(lang: str) -> str:
return "" if lang == DEFAULT_LANG else f".{lang}"
# ------------------------------------------------------------------ youtube
def extract_video_id(url: str) -> str | None:
for pattern in VIDEO_ID_PATTERNS:
match = pattern.search(url)
if match:
return match.group(1)
# A bare 11-char id pasted on its own.
if VIDEO_ID_RE.fullmatch(url.strip()):
return url.strip()
return None
_meta_cache: dict[str, tuple[str, str]] = {}
def get_video_meta(video_id: str) -> tuple[str, str]:
"""Best-effort title/channel lookup via YouTube's public oEmbed endpoint (no API key needed)."""
if video_id in _meta_cache:
return _meta_cache[video_id]
try:
resp = requests.get(
"https://www.youtube.com/oembed",
params={
"url": f"https://www.youtube.com/watch?v={video_id}",
"format": "json",
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
meta = data.get("title") or "Untitled video", data.get("author_name") or ""
_meta_cache[video_id] = meta
return meta
except Exception:
return "Untitled video", ""
def get_transcript(video_id: str) -> tuple[list[tuple[float, str]], str, str]:
"""
Returns (snippets, language_label, language_code) where snippets is a list
of (start_seconds, text). Prefers a manually-created English transcript,
falls back to an auto-generated one, then to whatever language the video
actually has.
"""
transcript_list = ytt_api.list(video_id)
transcript = None
try:
transcript = transcript_list.find_manually_created_transcript(PREFERRED_LANGUAGES)
except NoTranscriptFound:
pass
if transcript is None:
try:
transcript = transcript_list.find_generated_transcript(PREFERRED_LANGUAGES)
except NoTranscriptFound:
pass
if transcript is None:
transcript = next(iter(transcript_list), None)
if transcript is None:
raise NoTranscriptFound(video_id, PREFERRED_LANGUAGES, transcript_list)
fetched = transcript.fetch()
snippets = [(float(s.start), s.text) for s in fetched]
# YouTube already spells out "(auto-generated)" in the language name for
# generated tracks, so only add it when it isn't there.
label = transcript.language
if transcript.is_generated and "auto-generated" not in label.lower():
label += " (auto-generated)"
return snippets, label, transcript.language_code or ""
class TranscriptProblem(Exception):
def __init__(self, message: str, status: int = 422):
super().__init__(message)
self.status = status
def transcript_cache_path(video_id: str) -> Path:
return CACHE_DIR / "transcripts" / f"{video_id}.json"
def fetch_transcript(video_id: str) -> tuple[list[tuple[float, str]], str, str]:
"""
get_transcript, cached on disk -- regenerating, asking and reading along
shouldn't each hit YouTube -- with the library's exceptions turned into
messages a person can act on.
"""
cached = read_json(transcript_cache_path(video_id))
if isinstance(cached, dict) and cached.get("snippets"):
snippets = [(float(s), str(t)) for s, t in cached["snippets"]]
return snippets, cached.get("language") or "", cached.get("language_code") or ""
try:
snippets, label, code = get_transcript(video_id)
except TranscriptsDisabled as exc:
raise TranscriptProblem("Captions are switched off for this video, so there's no transcript to work with.") from exc
except NoTranscriptFound as exc:
raise TranscriptProblem("No transcript exists for this video.") from exc
except VideoUnavailable as exc:
raise TranscriptProblem("That video is unavailable -- private, deleted, or region-locked.") from exc
except YOUTUBE_BLOCKED as exc:
raise TranscriptProblem(
"YouTube is blocking transcript requests from your connection for now. It happens after a lot of "
"requests in a short time, or on a VPN or cloud network. Give it a while (often 15 minutes to an "
"hour) and try again -- everything you've already mapped still works.",
429,
) from exc
except AGE_RESTRICTED as exc:
raise TranscriptProblem("This video is age-restricted, and YouTube won't share its transcript without a signed-in account.") from exc
except UNPLAYABLE as exc:
raise TranscriptProblem("YouTube won't play this video here -- it may be members-only, a premiere, or blocked where you are.") from exc
except CouldNotRetrieveTranscript as exc:
raise TranscriptProblem(f"Couldn't fetch the transcript: {str(exc)[:400]}", 502) from exc
except Exception as exc: # noqa: BLE001 -- surface anything unexpected with context
raise TranscriptProblem(f"Unexpected error fetching the transcript: {str(exc)[:400]}", 500) from exc
write_json(transcript_cache_path(video_id), {
"video_id": video_id,
"language": label,
"language_code": code,
"snippets": [[round(s, 2), t] for s, t in snippets],
"fetched_at": now_iso(),
})
return snippets, label, code
def format_timestamp(seconds: float) -> str:
seconds = max(0, int(seconds))
hours, remainder = divmod(seconds, 3600)
minutes, secs = divmod(remainder, 60)
if hours:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
def transcript_blocks(snippets: list[tuple[float, str]], size: int = 220) -> list[tuple[float, str]]:
"""
Collapse raw caption snippets into ~220-char blocks, each stamped with the
second it starts. The timestamps are the whole point: they're what lets
Claude cite a moment, and what lets the frontend seek the player to it.
"""
blocks: list[tuple[float, str]] = []
buffer: list[str] = []
buffer_start: float | None = None
for start, text in snippets:
# Captions carry their own line breaks; one stray newline in a prompt
# line would leave the next line without a timestamp.
text = " ".join(str(text).split())
if not text:
continue
if buffer_start is None:
buffer_start = start
buffer.append(text)
if sum(len(t) + 1 for t in buffer) >= size:
blocks.append((buffer_start, " ".join(buffer)))
buffer, buffer_start = [], None
if buffer and buffer_start is not None:
blocks.append((buffer_start, " ".join(buffer)))
return blocks
def block_line(t: float, text: str) -> str:
return f"[{format_timestamp(t)}] {text}"
def build_prompt_transcript(
snippets: list[tuple[float, str]], max_chars: int = MAX_TRANSCRIPT_CHARS
) -> tuple[str, bool]:
joined = "\n".join(block_line(t, text) for t, text in transcript_blocks(snippets))
truncated = len(joined) > max_chars
if truncated:
joined = joined[:max_chars].rsplit("\n", 1)[0]
return joined, truncated
def sample_transcript(snippets: list[tuple[float, str]], budget: int = PURGE_SAMPLE_CHARS) -> str:
"""The start, the middle and the end of a transcript -- enough to judge a video by."""
full, _ = build_prompt_transcript(snippets, max_chars=10**9)
if len(full) <= budget:
return full
lines = full.split("\n")
def take(seq, limit):
out, used = [], 0
for line in seq:
if used + len(line) + 1 > limit:
break
out.append(line)
used += len(line) + 1
return out
head = take(lines, int(budget * 0.45))
middle = take(lines[len(lines) // 2 :], int(budget * 0.30))
tail = list(reversed(take(reversed(lines), int(budget * 0.25))))
return "\n".join(head) + "\n[...]\n" + "\n".join(middle) + "\n[...]\n" + "\n".join(tail)
def duration_of(snippets: list[tuple[float, str]]) -> int:
return int(snippets[-1][0]) if snippets else 0
# ------------------------------------------------------------------ claude
class ClaudeProblem(RuntimeError):
def __init__(self, message: str, code: str = "claude", **extra):
super().__init__(message)
self.code = code
self.extra = extra
def resolve_claude() -> str:
# Resolve to a full path before handing it to subprocess. On Windows the
# npm install is a `claude.cmd` shim, and CreateProcess only ever tries
# appending .exe -- it doesn't walk PATHEXT the way shutil.which does --
# so a bare "claude" dies with "[WinError 2] The system cannot find the
# file specified" even though the CLI is installed and on PATH.
claude_path = shutil.which(CLAUDE_BIN)
if claude_path is None:
raise ClaudeProblem(
f"Couldn't find the Claude Code CLI ('{CLAUDE_BIN}') on your PATH. "
"Install it with 'npm install -g @anthropic-ai/claude-code', then run "
"'claude auth login' once to sign in with your Claude subscription.",
"cli_missing",
)
return claude_path
def claude_env() -> dict:
env = dict(os.environ)
if CLAUDE_THINKING == "off":
env["MAX_THINKING_TOKENS"] = "0"
return env
def base_command(claude_path: str) -> list[str]:
cmd = [
claude_path,
"-p",
"--tools",
"", # no tool access needed -- this is a pure text-in, text-out call
"--no-session-persistence",
]
if CLAUDE_MODEL:
cmd += ["--model", CLAUDE_MODEL]
return cmd
def friendly_claude_error(detail: str) -> ClaudeProblem:
"""The CLI's own error text, turned into something a person can act on."""
low = detail.lower()
if "limit" in low and any(word in low for word in ("usage", "reached", "hit your", "resets")):
resets_at = None
stamp = re.search(r"\|(\d{9,})", detail)
if stamp:
resets_at = datetime.fromtimestamp(int(stamp.group(1))).astimezone().isoformat(timespec="minutes")
return ClaudeProblem(
"You've hit your Claude usage limit for now. Everything you've already mapped still "
"opens for free -- new ones will work again once the limit resets.",
"usage_limit",
resets_at=resets_at,
)
if any(word in low for word in ("not logged in", "/login", "please run", "invalid api key", "authenticat")):
return ClaudeProblem(
"The Claude Code CLI isn't signed in. Run 'claude auth login' once in a terminal, then try again.",
"auth",
)
return ClaudeProblem(f"Claude Code CLI failed: {detail[:600]}", "cli_failed")
def run_claude(prompt: str) -> str:
"""One blocking call: prompt in, full text out."""
cmd = base_command(resolve_claude()) + ["--output-format", "text"]
try:
# The prompt goes over stdin, never as an argv element. Launching the
# .cmd shim routes the command line through cmd.exe, which cuts it off
# at the first newline -- silently sending Claude the instructions
# without the transcript -- and would read quotes, & and %VAR% inside
# transcript text as shell syntax.
#
# encoding must be explicit. text=True alone decodes with the locale
# codec, which is cp1252 on Windows -- every em dash Claude writes came
# back as "a€"", and a non-English transcript would be mangled on the
# way in as well.
result = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=CLAUDE_TIMEOUT_SECONDS,
env=claude_env(),
)
except subprocess.TimeoutExpired as exc:
raise ClaudeProblem(
f"Claude didn't respond within {CLAUDE_TIMEOUT_SECONDS}s. Very long "
"videos may need a higher CLAUDE_TIMEOUT_SECONDS.",
"timeout",
) from exc
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
raise friendly_claude_error(detail)
return result.stdout.strip()
def kill_tree(proc) -> None:
"""Stop the CLI and everything it started -- on Windows the .cmd shim's node child too."""
if proc is None or proc.poll() is not None:
return
try:
if os.name == "nt":
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True, timeout=15)
else:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception: # noqa: BLE001 -- fall back to the plain kill below
pass
try:
if proc.poll() is None:
proc.kill()
except Exception: # noqa: BLE001
pass
def stream_claude(prompt: str, job: "Job", on_text=None) -> str:
"""
Run the CLI with token streaming. on_text gets each piece of text as
Claude writes it; the full text comes back at the end. The job can cancel
it at any point, which kills the process.
"""
cmd = base_command(resolve_claude()) + [
"--output-format", "stream-json", "--verbose", "--include-partial-messages",
]
popen_kwargs = dict(
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", bufsize=1, env=claude_env(),
)
if os.name != "nt":
popen_kwargs["start_new_session"] = True # so the whole group can be killed
proc = subprocess.Popen(cmd, **popen_kwargs)
job.proc = proc
if job.cancelled:
kill_tree(proc)
raise JobCancelled()
def feed():
try:
proc.stdin.write(prompt)
proc.stdin.close()
except (OSError, ValueError):
pass # the process died early; its exit code tells the story
stderr_parts: list[str] = []
threading.Thread(target=feed, daemon=True).start()
threading.Thread(target=lambda: stderr_parts.append(proc.stderr.read() or ""), daemon=True).start()
timed_out = threading.Event()
def on_timeout():
timed_out.set()
kill_tree(proc)
watchdog = threading.Timer(CLAUDE_TIMEOUT_SECONDS, on_timeout)
watchdog.daemon = True
watchdog.start()
streamed: list[str] = []
thought = 0
final_text = None
final_error = None
try:
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
kind = event.get("type")
if kind == "stream_event":
inner = event.get("event") or {}
delta = inner.get("delta") or {}
block = inner.get("content_block") or {}
if inner.get("type") == "content_block_delta" and delta.get("type") == "text_delta":
piece = delta.get("text") or ""
if piece:
streamed.append(piece)
if on_text:
on_text(piece)
elif inner.get("type") == "content_block_start" and block.get("type") in ("thinking", "redacted_thinking"):
# Claude plans before it writes on big jobs. Say so, instead
# of sitting on "reading" for minutes.
job.set_stage("thinking")
elif inner.get("type") == "content_block_delta" and delta.get("type") == "thinking_delta":
thought += len(delta.get("thinking") or "")
job.progress(thinking=thought)
elif kind == "assistant" and not streamed:
# A CLI without partial messages still sends the whole message
# once -- hand it over in one go so nothing downstream changes.
blocks = ((event.get("message") or {}).get("content")) or []
whole = "".join(b.get("text", "") for b in blocks if isinstance(b, dict) and b.get("type") == "text")
if whole:
streamed.append(whole)
if on_text:
on_text(whole)
elif kind == "rate_limit_event":
job.rate_limit(event.get("rate_limit_info") or {})
elif kind == "result":
if event.get("is_error") or event.get("subtype") not in (None, "success"):
final_error = str(event.get("result") or event.get("error") or event.get("subtype") or "unknown error")
else:
final_text = event.get("result")
proc.wait()
finally:
watchdog.cancel()
job.proc = None
if job.cancelled:
raise JobCancelled()
if timed_out.is_set():
raise ClaudeProblem(
f"Claude didn't finish within {CLAUDE_TIMEOUT_SECONDS}s. Very long videos may need a "
"higher CLAUDE_TIMEOUT_SECONDS.",
"timeout",
)
if final_error:
raise friendly_claude_error(final_error)
text = final_text if isinstance(final_text, str) else "".join(streamed)
if proc.returncode != 0 and not text.strip():
detail = "".join(stderr_parts).strip() or f"exit code {proc.returncode}"
raise friendly_claude_error(detail)
return text.strip()
def extract_json(raw: str) -> dict:
"""Pull the JSON object out of a model response, fences and preamble and all."""
text = re.sub(r"^```(?:json)?\s*", "", raw.strip())
text = re.sub(r"\s*```$", "", text)
try:
return json.loads(text)
except json.JSONDecodeError:
pass
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
raise ClaudeProblem("Claude didn't return JSON. Try regenerating.", "bad_json")
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError as exc:
raise ClaudeProblem(f"Claude returned malformed JSON: {exc}", "bad_json") from exc
def ask_claude(prompt: str) -> dict:
data = extract_json(run_claude(prompt))
if not isinstance(data, dict):
raise ClaudeProblem("Claude returned JSON, but not an object. Try regenerating.", "bad_json")
return data
# ------------------------------------------------------------------ reading JSON while it streams
class TopLevelStream:
"""
Feed it the text of one JSON object as it streams in; it hands back each
top-level field the moment that field is complete. That's what lets the
canvas fill in section by section instead of all at once at the end.
"""
def __init__(self):
self.text = ""
self.fields: dict = {}
self._pos = 0
self._started = False
self._finished = False
self._depth = 0
self._in_str = False
self._esc = False
self._state = "key"
self._key = None
self._key_start = 0
self._val_start = 0
def feed(self, chunk: str) -> list[tuple[str, object]]:
self.text += chunk
out = []
text, i, n = self.text, self._pos, len(self.text)
while i < n and not self._finished:
c = text[i]
if not self._started:
if c == "{": # skips any preamble or code fence before the object
self._started, self._depth, self._state = True, 1, "key"
i += 1
continue
if self._in_str:
if self._esc:
self._esc = False
elif c == "\\":
self._esc = True
elif c == '"':
self._in_str = False
if self._state == "keystr":
try:
self._key = json.loads(text[self._key_start : i + 1])
except json.JSONDecodeError:
self._key = text[self._key_start + 1 : i]
self._state = "colon"
i += 1
continue
if self._state == "key":
if c == '"':
self._in_str, self._state, self._key_start = True, "keystr", i
elif c == "}":
self._finished = True
elif self._state == "colon":
if c == ":":
self._state = "gap"
elif self._state == "gap":
if not c.isspace():
self._state, self._val_start = "value", i
continue # look at this character again as the value's first
elif self._state == "value":
if c == '"':
self._in_str = True
elif c in "{[":
self._depth += 1
elif c in "}]":
if self._depth == 1: # the object itself just closed
out.append(self._complete(i))
self._finished = True
else:
self._depth -= 1
elif c == "," and self._depth == 1:
out.append(self._complete(i))
self._state = "key"
i += 1
self._pos = i
return [x for x in out if x]
def _complete(self, end: int):
raw = self.text[self._val_start : end].strip()
try:
value = json.loads(raw)
except json.JSONDecodeError:
return None
self.fields[self._key] = value
return self._key, value
def current(self) -> tuple[str | None, str]:
"""The field being written right now, and its text so far."""
if self._state == "value" and not self._finished:
return self._key, self.text[self._val_start :]
return None, ""
def repair_json_prefix(text: str, items_only: bool = False):
"""
The start of a JSON value that's still streaming, cut back to its last
complete piece and closed off -- so half a mindmap is still a mindmap.
items_only cuts only between whole items of a top-level list, so a list
never shows an item that's half written.
"""
stack: list[str] = []
in_str = esc = False
cut = None
for i, c in enumerate(text):
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
continue
if c == '"':
in_str = True
elif c in "{[":
if items_only and not stack and c != "[":
return None
stack.append(c)
if not items_only or len(stack) == 1:
cut = (i + 1, tuple(stack))
elif c in "}]":
if not stack:
break
stack.pop()
if not items_only or len(stack) <= 1:
cut = (i + 1, tuple(stack))
if not stack:
break
elif c == ",":
if not items_only or len(stack) == 1:
cut = (i, tuple(stack))
if cut is None:
return None
end, open_stack = cut
closing = "".join("}" if c == "{" else "]" for c in reversed(open_stack))
try:
return json.loads(text[:end] + closing)
except json.JSONDecodeError:
return None
# ------------------------------------------------------------------ jobs
class JobCancelled(Exception):
pass
class Job:
"""
One long-running piece of work -- a canvas, a beef, a purge, an answer --
that the page can watch as it happens, leave and come back to, or cancel.
Every event is kept, so a tab that connects late replays the story so far.
Progress and half-finished fields are kept only as their latest version.
"""
def __init__(self, kind: str, key: str, params: dict):
self.id = uuid.uuid4().hex[:12]
self.kind = kind
self.key = key
self.params = params
self.title = params.get("title") or ""
self.status = "running"
self.created = time.time()
self.finished_at = None
self.events: list[dict] = []
self.partials: dict[str, dict] = {}
self.stage = "queued"
self.cancelled = False
self.proc = None
self._partial_version = 0
self._last_progress = 0.0
self._warned = False
self._cond = threading.Condition()
# -- writing
def emit(self, type_: str, **data) -> None:
with self._cond:
self.events.append({"id": len(self.events) + 1, "type": type_, **data})
self._cond.notify_all()
def set_stage(self, stage: str, **data) -> None:
self.stage = stage
self.emit("stage", stage=stage, **data)
def field(self, key: str, value) -> None:
with self._cond:
self.partials.pop(key, None)
self.events.append({"id": len(self.events) + 1, "type": "field", "key": key, "value": value})
self._cond.notify_all()
def partial(self, key: str, value) -> None:
with self._cond:
self._partial_version += 1
self.partials[key] = {"type": "partial", "key": key, "value": value, "v": self._partial_version}
self._cond.notify_all()
def progress(self, force: bool = False, **data) -> None:
now = time.monotonic()
if not force and now - self._last_progress < 0.8:
return
self._last_progress = now
with self._cond:
self._partial_version += 1
self.partials["_progress"] = {"type": "progress", "v": self._partial_version, **data}
self._cond.notify_all()
def rate_limit(self, info: dict) -> None:
status = str(info.get("status") or "")
if status in ("", "allowed") or self._warned:
return
self._warned = True
windows = info.get("unifiedWindows") or {}
window = windows.get(info.get("rateLimitType") or "") or {}
used = window.get("utilization", info.get("utilization"))
percent = None
if isinstance(used, (int, float)):
percent = round(used * 100 if used <= 1 else used)