@m13v/s4l 1.6.197-rc.7

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.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,636 @@
1
+ #!/usr/bin/env python3
2
+ """Log a Claude Code session's cost into the claude_sessions table.
3
+
4
+ Reads the session transcript at ~/.claude/projects/<encoded-cwd>/<session_id>.jsonl,
5
+ sums per-model token usage from each assistant turn, applies a local pricing
6
+ table to compute total cost, and inserts one row.
7
+
8
+ Usage:
9
+ python3 scripts/log_claude_session.py \\
10
+ --session-id <uuid> \\
11
+ --script run-linkedin \\
12
+ [--started-at ISO8601] [--ended-at ISO8601]
13
+
14
+ Designed to be called by run_claude.sh after `claude -p --session-id $UUID` exits.
15
+ Idempotent: ON CONFLICT DO NOTHING on session_id.
16
+ """
17
+
18
+ import argparse
19
+ import glob
20
+ import json
21
+ import os
22
+ import sys
23
+ from datetime import datetime
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+
27
+
28
+ PROJECTS_ROOT = os.path.expanduser("~/.claude/projects")
29
+
30
+ # Archive root for post-facto investigation. ~/.claude/projects/ is Claude
31
+ # Code's own scratch — it survives normal runs but is not under our control
32
+ # for retention or rotation, and the encoded-cwd subdirectory layout is
33
+ # annoying to navigate after the fact. We hardlink each finished session's
34
+ # transcript here so investigations of watchdog-killed phases are
35
+ # `tail skill/logs/claude-sessions/<date>/<HHMMSS>_<script>_<sid>.jsonl`
36
+ # instead of forensics across `~/.claude/projects/-/<sid>.jsonl` candidates.
37
+ ARCHIVE_ROOT = os.path.expanduser("~/social-autoposter/skill/logs/claude-sessions")
38
+
39
+
40
+ def find_transcript(session_id: str):
41
+ """Locate the transcript .jsonl for a session id.
42
+
43
+ Claude Code writes transcripts under `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`.
44
+ The encoded-cwd depends on the working directory at invocation time:
45
+ interactive runs land under `-Users-matthewdi-social-autoposter`, but
46
+ launchd-fired runs (cwd=/) land under `-`. Glob across all project dirs.
47
+ """
48
+ matches = glob.glob(os.path.join(PROJECTS_ROOT, "*", f"{session_id}.jsonl"))
49
+ return matches[0] if matches else None
50
+
51
+
52
+ def archive_transcript(transcript_path, session_id: str, script: str, started_iso):
53
+ """Hardlink (or copy) the live transcript into ARCHIVE_ROOT.
54
+
55
+ Best-effort: any failure returns None and the caller proceeds. The
56
+ archive lives under <date>/<HHMMSS>_<script>_<session_id>.jsonl so
57
+ investigators can navigate by day. Hardlink first (free, atomic, and
58
+ keeps the archive in sync if claude appends final bytes between our
59
+ archive call and parse_transcript); fall back to copy across volumes.
60
+ Idempotent: returns the existing path if already archived.
61
+ """
62
+ if not transcript_path or not os.path.exists(transcript_path):
63
+ return None
64
+ try:
65
+ dt = None
66
+ if started_iso:
67
+ try:
68
+ dt = datetime.fromisoformat(started_iso.replace("Z", "+00:00"))
69
+ except (ValueError, AttributeError):
70
+ dt = None
71
+ if dt is None:
72
+ try:
73
+ dt = datetime.utcfromtimestamp(os.path.getmtime(transcript_path))
74
+ except OSError:
75
+ dt = datetime.utcnow()
76
+
77
+ date_subdir = dt.strftime("%Y-%m-%d")
78
+ time_part = dt.strftime("%H%M%S")
79
+ safe_script = "".join(
80
+ c if (c.isalnum() or c in ("-", "_")) else "_"
81
+ for c in (script or "unknown")
82
+ ) or "unknown"
83
+
84
+ archive_dir = os.path.join(ARCHIVE_ROOT, date_subdir)
85
+ os.makedirs(archive_dir, exist_ok=True)
86
+ archive_path = os.path.join(
87
+ archive_dir, f"{time_part}_{safe_script}_{session_id}.jsonl"
88
+ )
89
+ if os.path.exists(archive_path):
90
+ return archive_path
91
+ try:
92
+ os.link(transcript_path, archive_path)
93
+ except OSError:
94
+ import shutil
95
+ shutil.copy2(transcript_path, archive_path)
96
+ return archive_path
97
+ except Exception:
98
+ return None
99
+
100
+ # USD per 1M tokens. Cache_5m / cache_1h are the WRITE rates (Anthropic charges
101
+ # a premium for caching writes); cache_read is the discounted re-read rate.
102
+ # Fallback (unknown model) uses Opus rates so we never underestimate.
103
+ PRICING = {
104
+ "opus": {"input": 15.0, "output": 75.0, "cache_5m": 18.75, "cache_1h": 30.0, "cache_read": 1.5},
105
+ "sonnet": {"input": 3.0, "output": 15.0, "cache_5m": 3.75, "cache_1h": 6.0, "cache_read": 0.3},
106
+ "haiku": {"input": 1.0, "output": 5.0, "cache_5m": 1.25, "cache_1h": 2.0, "cache_read": 0.1},
107
+ }
108
+
109
+
110
+ def price_for_model(model_id: str) -> dict:
111
+ m = (model_id or "").lower()
112
+ if "opus" in m:
113
+ return PRICING["opus"]
114
+ if "sonnet" in m:
115
+ return PRICING["sonnet"]
116
+ if "haiku" in m:
117
+ return PRICING["haiku"]
118
+ return PRICING["opus"]
119
+
120
+
121
+ def cost_from_usage(model: str, usage: dict) -> float:
122
+ p = price_for_model(model)
123
+ inp = usage.get("input_tokens", 0) or 0
124
+ out = usage.get("output_tokens", 0) or 0
125
+ cache_read = usage.get("cache_read_input_tokens", 0) or 0
126
+ cache_5m = (usage.get("cache_creation") or {}).get("ephemeral_5m_input_tokens", 0) or 0
127
+ cache_1h = (usage.get("cache_creation") or {}).get("ephemeral_1h_input_tokens", 0) or 0
128
+ if not (cache_5m or cache_1h):
129
+ cache_5m = usage.get("cache_creation_input_tokens", 0) or 0
130
+ return (
131
+ inp * p["input"]
132
+ + out * p["output"]
133
+ + cache_read * p["cache_read"]
134
+ + cache_5m * p["cache_5m"]
135
+ + cache_1h * p["cache_1h"]
136
+ ) / 1_000_000
137
+
138
+
139
+ def _parse_subagent_transcript(path: str, meta: dict):
140
+ """Parse a single agent-<id>.jsonl into a cost summary.
141
+
142
+ Subagent transcripts have the same per-turn shape as the orchestrator
143
+ (assistant turns with usage), but every event carries ``isSidechain:
144
+ true``, an ``agentId``, and references back to the parent via
145
+ ``sessionId`` (matches orchestrator's session id).
146
+ """
147
+ if not os.path.exists(path):
148
+ return None
149
+ by_model = {}
150
+ first_ts = None
151
+ last_ts = None
152
+ turns = 0
153
+ with open(path) as f:
154
+ for line in f:
155
+ try:
156
+ ev = json.loads(line)
157
+ except json.JSONDecodeError:
158
+ continue
159
+ ts = ev.get("timestamp")
160
+ if ts:
161
+ first_ts = first_ts or ts
162
+ last_ts = ts
163
+ if ev.get("type") != "assistant":
164
+ continue
165
+ msg = ev.get("message") or {}
166
+ usage = msg.get("usage") or {}
167
+ if not usage:
168
+ continue
169
+ model = msg.get("model") or "unknown"
170
+ entry = by_model.setdefault(model, {
171
+ "input_tokens": 0, "output_tokens": 0,
172
+ "cache_read_tokens": 0, "cache_creation_tokens": 0,
173
+ "cost_usd": 0.0,
174
+ })
175
+ inp = usage.get("input_tokens", 0) or 0
176
+ out = usage.get("output_tokens", 0) or 0
177
+ cr = usage.get("cache_read_input_tokens", 0) or 0
178
+ cc = usage.get("cache_creation_input_tokens", 0) or 0
179
+ entry["input_tokens"] += inp
180
+ entry["output_tokens"] += out
181
+ entry["cache_read_tokens"] += cr
182
+ entry["cache_creation_tokens"] += cc
183
+ entry["cost_usd"] += cost_from_usage(model, usage)
184
+ turns += 1
185
+ cost = sum(v["cost_usd"] for v in by_model.values())
186
+ primary = None
187
+ if by_model:
188
+ primary = max(
189
+ by_model.items(),
190
+ key=lambda kv: (kv[1].get("output_tokens", 0), kv[1].get("input_tokens", 0)),
191
+ )[0]
192
+ return {
193
+ "by_model": {
194
+ m: {**v, "cost_usd": round(v["cost_usd"], 6)}
195
+ for m, v in by_model.items()
196
+ },
197
+ "cost_usd": round(cost, 6),
198
+ "turns": turns,
199
+ "first_ts": first_ts,
200
+ "last_ts": last_ts,
201
+ "model": primary or "unknown",
202
+ "agent_type": (meta or {}).get("agentType"),
203
+ "description": (meta or {}).get("description"),
204
+ }
205
+
206
+
207
+ def _find_subagent_dir(orchestrator_path: str) -> str:
208
+ """Given path .../<session_id>.jsonl, return .../<session_id>/subagents/."""
209
+ if not orchestrator_path:
210
+ return None
211
+ base = orchestrator_path[:-len(".jsonl")] if orchestrator_path.endswith(".jsonl") else orchestrator_path
212
+ candidate = os.path.join(base, "subagents")
213
+ return candidate if os.path.isdir(candidate) else None
214
+
215
+
216
+ def parse_transcript(path: str):
217
+ """Parse a Claude Code transcript .jsonl into per-session cost.
218
+
219
+ Subagent (Agent tool) handling
220
+ ------------------------------
221
+ Claude Code SDK >= 2.1.x writes subagent transcripts to a SEPARATE
222
+ sibling directory, NOT inline in the orchestrator's .jsonl. Layout:
223
+
224
+ ~/.claude/projects/<encoded-cwd>/<orchestrator-session-id>.jsonl
225
+ ~/.claude/projects/<encoded-cwd>/<orchestrator-session-id>/
226
+ subagents/
227
+ agent-<short-id>.jsonl
228
+ agent-<short-id>.meta.json
229
+
230
+ The orchestrator's .jsonl only records the ``tool_use`` block (name
231
+ "Agent", with ``subagent_type``/``description``/``prompt`` input) and
232
+ the consolidated ``tool_result`` carrying the agent's final reply. The
233
+ Agent's internal chain of assistant turns lives entirely in
234
+ ``agent-<id>.jsonl`` with ``isSidechain: true`` on every event.
235
+
236
+ This parser:
237
+ * Sums orchestrator-only token usage and cost into by_model/totals
238
+ (so total_cost_usd matches the parent thread only — subagent cost
239
+ is broken out separately so the user can see exactly what the
240
+ subagents added).
241
+ * Counts ``Agent`` tool_use blocks in orchestrator turns -> the
242
+ legacy ``task_call_count`` field (kept the name for back-compat
243
+ even though the tool name is "Agent", not "Task").
244
+ * Scans the sibling ``subagents/`` directory; for each
245
+ agent-<id>.jsonl, parses it as an independent transcript and adds
246
+ its cost to ``subagent_cost_usd``. Per-subagent details land in
247
+ ``subagent_breakdown`` keyed by short agent id, with the
248
+ agentType/description from the .meta.json sidecar.
249
+ * As a defensive fallback, also detects legacy ``isSidechain: true``
250
+ events inside the same .jsonl (older SDK versions may have used
251
+ that layout). Currently zero hits across 14k+ historical sessions,
252
+ but kept so we don't have to revisit when the SDK changes again.
253
+
254
+ Historical note (2026-05-10): the prior parser version looked for
255
+ tool_use name="Task" and inline isSidechain entries. Both miss the
256
+ actual SDK layout. The corpus has 2041 Agent invocations (mostly in
257
+ seo_generate_page sessions) whose cost was previously invisible.
258
+ """
259
+ if not os.path.exists(path):
260
+ return None
261
+
262
+ by_model = {}
263
+ totals = {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}
264
+ first_ts = None
265
+ last_ts = None
266
+
267
+ # Subagent (sidechain) accounting. Keyed by chain-root uuid so chained
268
+ # sidechain turns under the same Task() invocation aggregate together.
269
+ # When parentUuid linkage is ambiguous we fall back to a single synthetic
270
+ # group ("unknown") so the cost still gets counted.
271
+ sidechain_groups = {} # root_uuid -> {model: per-model dict, cost_usd, turns, first_ts, last_ts, description}
272
+ # parentUuid -> root_uuid map, built as we walk the transcript. The first
273
+ # sidechain turn we see introduces its uuid as a root candidate; later
274
+ # turns chain by parentUuid.
275
+ uuid_to_root = {}
276
+
277
+ task_call_count = 0
278
+
279
+ def _bump_model_bucket(bucket, model, usage):
280
+ entry = bucket.setdefault(model, {
281
+ "input_tokens": 0, "output_tokens": 0,
282
+ "cache_read_tokens": 0, "cache_creation_tokens": 0,
283
+ "cost_usd": 0.0,
284
+ })
285
+ inp = usage.get("input_tokens", 0) or 0
286
+ out = usage.get("output_tokens", 0) or 0
287
+ cr = usage.get("cache_read_input_tokens", 0) or 0
288
+ cc = usage.get("cache_creation_input_tokens", 0) or 0
289
+ entry["input_tokens"] += inp
290
+ entry["output_tokens"] += out
291
+ entry["cache_read_tokens"] += cr
292
+ entry["cache_creation_tokens"] += cc
293
+ entry["cost_usd"] += cost_from_usage(model, usage)
294
+ return inp, out, cr, cc
295
+
296
+ with open(path) as f:
297
+ for line in f:
298
+ line = line.strip()
299
+ if not line:
300
+ continue
301
+ try:
302
+ ev = json.loads(line)
303
+ except json.JSONDecodeError:
304
+ continue
305
+
306
+ ts = ev.get("timestamp")
307
+ if ts:
308
+ first_ts = first_ts or ts
309
+ last_ts = ts
310
+
311
+ is_sidechain = bool(ev.get("isSidechain"))
312
+ ev_uuid = ev.get("uuid")
313
+ parent_uuid = ev.get("parentUuid")
314
+
315
+ # Count subagent tool_use blocks (both legacy "Task" name and
316
+ # current "Agent" name) in orchestrator turns. This gives us an
317
+ # authoritative subagent-invocation count, independent of whether
318
+ # the sibling subagents/ transcripts came through fully (a
319
+ # watchdog SIGTERM mid-subagent can leave the tool_use stamped
320
+ # but the sibling .jsonl never finished). 2026-05-10 the actual
321
+ # SDK tool name is "Agent"; "Task" kept for forward-compat if
322
+ # the SDK ever renames it again.
323
+ msg = ev.get("message") or {}
324
+ if not is_sidechain and ev.get("type") == "assistant":
325
+ content = msg.get("content")
326
+ if isinstance(content, list):
327
+ for c in content:
328
+ if (isinstance(c, dict)
329
+ and c.get("type") == "tool_use"
330
+ and c.get("name") in ("Task", "Agent")):
331
+ task_call_count += 1
332
+
333
+ if ev.get("type") != "assistant":
334
+ continue
335
+ usage = msg.get("usage") or {}
336
+ model = msg.get("model") or "unknown"
337
+
338
+ if not is_sidechain:
339
+ # Orchestrator turn.
340
+ inp, out, cr, cc = _bump_model_bucket(by_model, model, usage)
341
+ totals["input"] += inp
342
+ totals["output"] += out
343
+ totals["cache_read"] += cr
344
+ totals["cache_creation"] += cc
345
+ else:
346
+ # Sidechain (subagent) turn. Resolve to a chain root: if
347
+ # parentUuid is already mapped to a root, attach there;
348
+ # otherwise this is a new root.
349
+ root = uuid_to_root.get(parent_uuid)
350
+ if root is None:
351
+ root = ev_uuid or "unknown"
352
+ if ev_uuid:
353
+ uuid_to_root[ev_uuid] = root
354
+
355
+ grp = sidechain_groups.setdefault(root, {
356
+ "by_model": {},
357
+ "cost_usd": 0.0,
358
+ "turns": 0,
359
+ "first_ts": None,
360
+ "last_ts": None,
361
+ "root_uuid": root,
362
+ })
363
+ _bump_model_bucket(grp["by_model"], model, usage)
364
+ grp["cost_usd"] += cost_from_usage(model, usage)
365
+ grp["turns"] += 1
366
+ if ts:
367
+ grp["first_ts"] = grp["first_ts"] or ts
368
+ grp["last_ts"] = ts
369
+
370
+ if not by_model and not sidechain_groups:
371
+ return None
372
+
373
+ total_cost = sum(m["cost_usd"] for m in by_model.values())
374
+
375
+ # Dominant model = the one that produced the most output tokens in this
376
+ # session. Claude Code's transcript emits `"model": "<synthetic>"` on
377
+ # interrupted/stopped events with zero usage; those shouldn't win just
378
+ # because they sort alphabetically when all real candidates tie.
379
+ real_models = {k: v for k, v in by_model.items() if not k.startswith("<")}
380
+ pool = real_models or by_model
381
+ if pool:
382
+ primary_model = max(
383
+ pool.items(),
384
+ key=lambda kv: (kv[1].get("output_tokens", 0), kv[1].get("input_tokens", 0)),
385
+ )[0]
386
+ else:
387
+ # Subagents-only session (no orchestrator turns logged — unusual but
388
+ # possible if the orchestrator was SIGTERMed before its first
389
+ # assistant turn yet a sidechain had already started). Fall back to
390
+ # the dominant model across sidechains.
391
+ all_models = {}
392
+ for grp in sidechain_groups.values():
393
+ for m, v in grp["by_model"].items():
394
+ e = all_models.setdefault(m, {"output_tokens": 0, "input_tokens": 0})
395
+ e["output_tokens"] += v.get("output_tokens", 0)
396
+ e["input_tokens"] += v.get("input_tokens", 0)
397
+ primary_model = max(
398
+ all_models.items(),
399
+ key=lambda kv: (kv[1].get("output_tokens", 0), kv[1].get("input_tokens", 0)),
400
+ )[0] if all_models else "unknown"
401
+
402
+ # Compact per-subagent breakdown for the subagent_breakdown jsonb column.
403
+ # Two sources feed this map:
404
+ # 1. Inline isSidechain entries (legacy SDK layout, ~0 hits today).
405
+ # Keyed by chain-root uuid.
406
+ # 2. Sibling agent-<id>.jsonl files (current SDK layout, post-2.1.x).
407
+ # Keyed by short agent id (e.g. "ab24e352623c7d99b").
408
+ subagent_breakdown = {}
409
+ for root, grp in sidechain_groups.items():
410
+ # Dominant model for this subagent group.
411
+ bm = grp["by_model"]
412
+ if bm:
413
+ sg_model = max(
414
+ bm.items(),
415
+ key=lambda kv: (kv[1].get("output_tokens", 0), kv[1].get("input_tokens", 0)),
416
+ )[0]
417
+ else:
418
+ sg_model = "unknown"
419
+ subagent_breakdown[root] = {
420
+ "source": "inline_sidechain",
421
+ "cost_usd": round(grp["cost_usd"], 6),
422
+ "turns": grp["turns"],
423
+ "first_ts": grp["first_ts"],
424
+ "last_ts": grp["last_ts"],
425
+ "model": sg_model,
426
+ "by_model": {
427
+ m: {
428
+ "input_tokens": v["input_tokens"],
429
+ "output_tokens": v["output_tokens"],
430
+ "cache_read_tokens": v["cache_read_tokens"],
431
+ "cache_creation_tokens": v["cache_creation_tokens"],
432
+ "cost_usd": round(v["cost_usd"], 6),
433
+ }
434
+ for m, v in bm.items()
435
+ },
436
+ }
437
+ subagent_cost_usd = sum(grp["cost_usd"] for grp in sidechain_groups.values())
438
+
439
+ # ------- Scan sibling subagents/ directory for current-SDK transcripts -------
440
+ sub_dir = _find_subagent_dir(path)
441
+ if sub_dir:
442
+ for agent_file in sorted(os.listdir(sub_dir)):
443
+ if not agent_file.endswith(".jsonl"):
444
+ continue
445
+ short_id = agent_file[len("agent-"):-len(".jsonl")] if agent_file.startswith("agent-") else agent_file
446
+ meta_path = os.path.join(sub_dir, agent_file.replace(".jsonl", ".meta.json"))
447
+ meta = {}
448
+ try:
449
+ if os.path.exists(meta_path):
450
+ with open(meta_path) as mf:
451
+ meta = json.load(mf)
452
+ except Exception:
453
+ meta = {}
454
+ sub = _parse_subagent_transcript(os.path.join(sub_dir, agent_file), meta)
455
+ if not sub:
456
+ continue
457
+ subagent_breakdown[short_id] = {
458
+ "source": "sibling_dir",
459
+ "cost_usd": sub["cost_usd"],
460
+ "turns": sub["turns"],
461
+ "first_ts": sub["first_ts"],
462
+ "last_ts": sub["last_ts"],
463
+ "model": sub["model"],
464
+ "agent_type": sub["agent_type"],
465
+ "description": sub["description"],
466
+ "by_model": sub["by_model"],
467
+ }
468
+ subagent_cost_usd += sub["cost_usd"]
469
+ # Total distinct subagents (inline + sibling-dir) for the count column.
470
+ subagent_count = len(subagent_breakdown)
471
+
472
+ return {
473
+ "by_model": by_model,
474
+ "totals": totals,
475
+ "total_cost_usd": total_cost,
476
+ "primary_model": primary_model,
477
+ "first_ts": first_ts,
478
+ "last_ts": last_ts,
479
+ "task_call_count": task_call_count,
480
+ "subagent_count": subagent_count,
481
+ "subagent_cost_usd": round(subagent_cost_usd, 6),
482
+ "subagent_breakdown": subagent_breakdown,
483
+ }
484
+
485
+
486
+ _BACKFILL_TABLES = (
487
+ "posts", "replies", "dms", "dm_messages",
488
+ "seo_escalations", "seo_keywords", "seo_page_improvements", "gsc_queries",
489
+ )
490
+
491
+
492
+ def _persist_via_api(args, parsed, started, ended, duration_ms, orch_cost, cycle_id):
493
+ """Upsert claude_sessions row + backfill model column via HTTP routes.
494
+
495
+ Two calls:
496
+ POST /api/v1/claude-sessions -> upsert by session_id
497
+ POST /api/v1/claude-sessions/backfill-model -> stamp model on activity rows
498
+ """
499
+ from http_api import api_post
500
+ api_post(
501
+ "/api/v1/claude-sessions",
502
+ {
503
+ "session_id": args.session_id,
504
+ "script": args.script,
505
+ "started_at": started,
506
+ "ended_at": ended,
507
+ "duration_ms": duration_ms,
508
+ "total_cost_usd": round(parsed["total_cost_usd"], 6),
509
+ "orchestrator_cost_usd": orch_cost,
510
+ "input_tokens": parsed["totals"]["input"],
511
+ "output_tokens": parsed["totals"]["output"],
512
+ "cache_read_tokens": parsed["totals"]["cache_read"],
513
+ "cache_creation_tokens": parsed["totals"]["cache_creation"],
514
+ "model_breakdown": parsed["by_model"],
515
+ "model": parsed["primary_model"],
516
+ "cycle_id": cycle_id,
517
+ "task_call_count": parsed.get("task_call_count", 0),
518
+ "subagent_count": parsed.get("subagent_count", 0),
519
+ "subagent_cost_usd": parsed.get("subagent_cost_usd", 0.0),
520
+ "subagent_breakdown": parsed.get("subagent_breakdown") or None,
521
+ },
522
+ )
523
+
524
+ resp = api_post(
525
+ "/api/v1/claude-sessions/backfill-model",
526
+ {
527
+ "session_id": args.session_id,
528
+ "model": parsed["primary_model"],
529
+ "tables": list(_BACKFILL_TABLES),
530
+ },
531
+ )
532
+ data = (resp or {}).get("data") or {}
533
+ backfill_counts = data.get("backfilled") or {}
534
+ for t in _BACKFILL_TABLES:
535
+ backfill_counts.setdefault(t, 0)
536
+ return backfill_counts
537
+
538
+
539
+ def main():
540
+ parser = argparse.ArgumentParser()
541
+ parser.add_argument("--session-id", required=True)
542
+ parser.add_argument("--script", required=True)
543
+ parser.add_argument("--started-at", default=None,
544
+ help="ISO8601 timestamp; falls back to first transcript ts")
545
+ parser.add_argument("--ended-at", default=None,
546
+ help="ISO8601 timestamp; falls back to last transcript ts")
547
+ parser.add_argument("--orchestrator-cost-usd", default=None, type=float,
548
+ help="Native SDK cost (streamRes.total_cost_usd) for the "
549
+ "orchestrator session, captured from claude -p stdout. "
550
+ "Stored in claude_sessions.orchestrator_cost_usd. "
551
+ "Authoritative (matches Anthropic billing for the "
552
+ "orchestrator), but excludes Task subagent costs "
553
+ "(see anthropics/claude-code #43945). When omitted, "
554
+ "the column stays NULL and dashboards fall back to "
555
+ "total_cost_usd (manual transcript-derived estimate).")
556
+ parser.add_argument("--cycle-id", default=None,
557
+ help="Optional per-cycle batch identifier (e.g. "
558
+ "'rdcycle-20260510-110005'). Lets get_run_cost.py / "
559
+ "the dashboard scope cost to ONE pipeline cycle "
560
+ "even when multiple cycles of the same script "
561
+ "(double-forked run-reddit-search.sh / "
562
+ "run-twitter-cycle.sh) overlap in wall-clock time. "
563
+ "Falls back to env SA_CYCLE_ID; NULL if unset.")
564
+ args = parser.parse_args()
565
+
566
+ # Allow callers (run_claude.sh, post_reddit.py spawning a child claude) to
567
+ # propagate cycle_id via env without re-plumbing every call site. CLI flag
568
+ # takes precedence so explicit overrides still work.
569
+ cycle_id = args.cycle_id or os.environ.get("SA_CYCLE_ID") or None
570
+ if cycle_id == "":
571
+ cycle_id = None
572
+
573
+ transcript = find_transcript(args.session_id)
574
+ # Archive the transcript BEFORE parsing so even an empty/short session
575
+ # leaves a forensics trail. This is the only path that runs reliably on
576
+ # watchdog SIGTERM — once the wrapper's EXIT trap fires, log_claude_session
577
+ # is the last chance to capture what claude was doing before death.
578
+ archive_path = archive_transcript(
579
+ transcript, args.session_id, args.script, args.started_at
580
+ )
581
+ parsed = parse_transcript(transcript) if transcript else None
582
+
583
+ if parsed is None:
584
+ print(json.dumps({
585
+ "logged": False,
586
+ "reason": "no-transcript-or-empty",
587
+ "transcript": transcript,
588
+ "archive_path": archive_path,
589
+ "session_id": args.session_id,
590
+ }))
591
+ return
592
+
593
+ started = args.started_at or parsed["first_ts"]
594
+ ended = args.ended_at or parsed["last_ts"]
595
+ duration_ms = None
596
+ try:
597
+ if started and ended:
598
+ s = datetime.fromisoformat(started.replace("Z", "+00:00"))
599
+ e = datetime.fromisoformat(ended.replace("Z", "+00:00"))
600
+ duration_ms = int((e - s).total_seconds() * 1000)
601
+ except (ValueError, AttributeError):
602
+ pass
603
+
604
+ # Orchestrator cost: prefer the native SDK value passed via flag (from
605
+ # streamRes.total_cost_usd in the caller); fall back to NULL so the column
606
+ # only holds authoritative values. Manual transcript-derived estimate goes
607
+ # into total_cost_usd unchanged.
608
+ orch_cost = (
609
+ round(args.orchestrator_cost_usd, 6)
610
+ if args.orchestrator_cost_usd is not None
611
+ else None
612
+ )
613
+
614
+ backfill_counts = _persist_via_api(args, parsed, started, ended, duration_ms,
615
+ orch_cost, cycle_id)
616
+
617
+ print(json.dumps({
618
+ "logged": True,
619
+ "session_id": args.session_id,
620
+ "script": args.script,
621
+ "cycle_id": cycle_id,
622
+ "total_cost_usd": round(parsed["total_cost_usd"], 6),
623
+ "orchestrator_cost_usd": orch_cost,
624
+ "duration_ms": duration_ms,
625
+ "model": parsed["primary_model"],
626
+ "models": list(parsed["by_model"].keys()),
627
+ "task_call_count": parsed.get("task_call_count", 0),
628
+ "subagent_count": parsed.get("subagent_count", 0),
629
+ "subagent_cost_usd": parsed.get("subagent_cost_usd", 0.0),
630
+ "backfilled": backfill_counts,
631
+ "archive_path": archive_path,
632
+ }))
633
+
634
+
635
+ if __name__ == "__main__":
636
+ main()