@m13v/s4l 1.6.197-rc.10

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 +1336 -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 +513 -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,194 @@
1
+ #!/usr/bin/env python3
2
+ """Per-day PostHog funnel metrics for the dashboard stats tab.
3
+
4
+ Emits JSON on stdout:
5
+ { "days": N,
6
+ "rows": [ {"day": "YYYY-MM-DD",
7
+ "pageviews": int,
8
+ "email_signups": int,
9
+ "schedule_clicks": int,
10
+ "get_started_clicks": int,
11
+ "cross_product_clicks": int,
12
+ "cta_clicks": int}, ... ] }
13
+
14
+ Aggregates across every project's domains listed in config.json, bucketed
15
+ by (POSTHOG_API_KEY, PROJECT_ID) so projects sharing a PostHog bucket
16
+ collapse into one HogQL call per metric.
17
+
18
+ Called by bin/server.js `/api/funnel/per-day`. Mirrors the auth/bucket
19
+ pattern of `project_stats_json.py`; cannot import it because that
20
+ module runs heavyweight project-stats work at import time.
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ from concurrent.futures import ThreadPoolExecutor
28
+ from datetime import datetime, timedelta, timezone
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+
32
+ import project_stats as ps
33
+ from project_stats_json import _hogql, _SAFE_DOMAIN_RE, HogqlError, _GET_STARTED_EVENTS
34
+
35
+
36
+ _EVENT_CLAUSES = {
37
+ "pageviews": "event = '$pageview'",
38
+ # Sessions: count distinct $session_id on $pageview events. PostHog sets
39
+ # $session_id on every autocapture/pageview, so this gives "web sessions"
40
+ # in the standard sense (one session per visitor per ~30min of activity).
41
+ # Used as the denominator for conversion-rate ratios on the Trends tab,
42
+ # since signups/sessions is the meaningful conversion metric (one user
43
+ # hitting 4 pages is one session, not four chances to convert).
44
+ "sessions": "event = '$pageview'",
45
+ # Email signups: client `newsletter_subscribed` is ad-blocker-lossy
46
+ # (~57% capture). Server-side `newsletter_subscribed_server` (added in
47
+ # @m13v/seo-components v0.38) fires from the API route after the Resend
48
+ # send succeeds, so it's ground truth. Both are counted with DISTINCT
49
+ # email so old client-only sites still show up while we transition;
50
+ # once both fire for the same submission they collapse into one row.
51
+ "email_signups": "event IN ('newsletter_subscribed', 'newsletter_subscribed_server')",
52
+ "schedule_clicks": "event = 'schedule_click'",
53
+ "get_started_clicks": f"event IN {_GET_STARTED_EVENTS}",
54
+ "cross_product_clicks": "event = 'cross_product_click'",
55
+ "cta_clicks": "event = 'cta_click'",
56
+ }
57
+
58
+ # Metrics that need DISTINCT counting (e.g. dedupe client + server captures
59
+ # of the same event by email). Other metrics use plain count().
60
+ # `coalesce(properties.email, distinct_id)` because some emitters (studyly's
61
+ # /api/signup, custom routes) set only distinct_id=email and leave
62
+ # properties.email null; without coalesce those rows fall out of the count.
63
+ _DISTINCT_KEY = {
64
+ "email_signups": "coalesce(properties.email, distinct_id)",
65
+ # Everything else counts unique visitors (distinct_id), not raw events.
66
+ # Matches `project_stats_json.py` so the Trends tab and the Status tab
67
+ # tell the same story: a user iterating on mk0r with 4 prompts in a
68
+ # session is 1 get_started, not 4. The "sessions" row was previously
69
+ # DISTINCT $session_id; we collapse it to distinct_id so it now reads
70
+ # as "unique visitors per day" rather than "unique sessions per day",
71
+ # which is the metric we actually care about for conversion rates.
72
+ # Pageviews now also count unique visitors per day rather than raw
73
+ # pageview events, so the column header "Pageviews" is effectively
74
+ # "Unique visitors". Kept the key name as `pageviews` so the dashboard
75
+ # JS, server rollups, and historical persisted JSON files don't need
76
+ # a coordinated rename.
77
+ "pageviews": "distinct_id",
78
+ "sessions": "distinct_id",
79
+ "schedule_clicks": "distinct_id",
80
+ "get_started_clicks": "distinct_id",
81
+ "cross_product_clicks": "distinct_id",
82
+ "cta_clicks": "distinct_id",
83
+ }
84
+
85
+
86
+ def _per_day_for_bucket(api_key, project_id, domains, days):
87
+ """One HogQL query per metric, grouped by day, filtered to this bucket's domains."""
88
+ safe = [d for d in domains if _SAFE_DOMAIN_RE.match(d or "")]
89
+ if not safe or not days:
90
+ return {m: {} for m in _EVENT_CLAUSES}
91
+ in_list = ", ".join(f"'{d}'" for d in safe)
92
+ since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
93
+ out = {}
94
+ for metric, clause in _EVENT_CLAUSES.items():
95
+ distinct_key = _DISTINCT_KEY.get(metric)
96
+ count_expr = (
97
+ f"count(DISTINCT {distinct_key}) AS c"
98
+ if distinct_key
99
+ else "count() AS c"
100
+ )
101
+ q = (
102
+ f"SELECT toDate(timestamp) AS day, {count_expr} FROM events "
103
+ f"WHERE {clause} "
104
+ f"AND properties.$host IN ({in_list}) "
105
+ f"AND timestamp >= toDateTime('{since_iso}') "
106
+ "GROUP BY day ORDER BY day"
107
+ )
108
+ try:
109
+ rows = _hogql(api_key, project_id, q)
110
+ except HogqlError as e:
111
+ print(f" HogQL error ({metric}, pid={project_id}): {e}", file=sys.stderr)
112
+ rows = []
113
+ out[metric] = {str(r[0]): int(r[1]) for r in (rows or []) if r and r[0] is not None}
114
+ return out
115
+
116
+
117
+ def main():
118
+ parser = argparse.ArgumentParser()
119
+ parser.add_argument("--days", type=int, default=30)
120
+ parser.add_argument("--project", help="Filter to a single project name")
121
+ args = parser.parse_args()
122
+ days = max(1, min(365, args.days))
123
+
124
+ ps.load_env()
125
+ env = os.environ
126
+ config = ps.load_config()
127
+
128
+ default_key = env.get("POSTHOG_PERSONAL_API_KEY")
129
+ default_pid = env.get("POSTHOG_PROJECT_ID", "330744")
130
+
131
+ if not default_key:
132
+ print(json.dumps({"error": "POSTHOG_PERSONAL_API_KEY not set", "days": days, "rows": []}))
133
+ return
134
+
135
+ buckets = {} # (api_key, project_id) -> set(domains)
136
+ for proj in config.get("projects", []):
137
+ name = proj.get("name") or ""
138
+ if args.project and args.project.lower() != name.lower():
139
+ continue
140
+ domains = ps.get_project_domains(proj) or []
141
+ if not domains:
142
+ continue
143
+ over = proj.get("posthog", {}) or {}
144
+ key = env.get(over.get("api_key_env", ""), default_key)
145
+ pid = over.get("project_id", default_pid)
146
+ bucket = buckets.setdefault((key, pid), set())
147
+ for d in domains:
148
+ bucket.add(d)
149
+
150
+ if not buckets:
151
+ print(json.dumps({"days": days, "rows": []}))
152
+ return
153
+
154
+ # One thread per bucket; each bucket issues len(_EVENT_CLAUSES) HogQL
155
+ # queries sequentially to stay inside PostHog's rate limit.
156
+ pool_size = max(2, min(8, len(buckets)))
157
+ metric_totals = {m: {} for m in _EVENT_CLAUSES} # metric -> {day: count}
158
+ error_msg = None
159
+ with ThreadPoolExecutor(max_workers=pool_size) as ex:
160
+ futs = {
161
+ ex.submit(_per_day_for_bucket, k, pid, sorted(ds), days): (k, pid)
162
+ for (k, pid), ds in buckets.items()
163
+ }
164
+ for fut in futs:
165
+ try:
166
+ bucket_metrics = fut.result()
167
+ except Exception as e:
168
+ error_msg = error_msg or f"PostHog batch error: {e}"
169
+ continue
170
+ for metric, day_counts in bucket_metrics.items():
171
+ agg = metric_totals[metric]
172
+ for day, c in day_counts.items():
173
+ agg[day] = agg.get(day, 0) + c
174
+
175
+ # Emit one row per day in the window (even zero-count days), sorted ascending.
176
+ today = datetime.now(timezone.utc).date()
177
+ start = today - timedelta(days=days - 1)
178
+ rows = []
179
+ for i in range(days):
180
+ d = start + timedelta(days=i)
181
+ key = d.strftime("%Y-%m-%d")
182
+ row = {"day": key}
183
+ for m in _EVENT_CLAUSES:
184
+ row[m] = int(metric_totals[m].get(key, 0))
185
+ rows.append(row)
186
+
187
+ out = {"days": days, "rows": rows}
188
+ if error_msg:
189
+ out["error"] = error_msg
190
+ print(json.dumps(out))
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env python3
2
+ """Daily synthesizer: per platform, distill ONE engagement style from the
3
+ top human replies captured in the last 24h on that platform.
4
+
5
+ Pipeline (per platform)
6
+ -----------------------
7
+ 1. Pull every `thread_top_replies` row from the last 24h on the platform
8
+ with `has_link = false` (human replies, not link-tail spam).
9
+ 2. Score by likes (the only reliable proxy in the window, since `views` is
10
+ missing for most rows). Take the top REPLY_POOL_SIZE.
11
+ 3. Build a Claude prompt that lists each reply with its like-count + thread
12
+ URL and asks the model to synthesize ONE new engagement style following
13
+ the seed-style schema (name / description / example / best_in / note).
14
+ 4. Parse the JSON, POST to /api/v1/engagement-styles/registry with
15
+ kind="human_derived" and platform="<platform>" so it lands in the
16
+ single source-of-truth table alongside seeds and model-invented styles.
17
+
18
+ The picker (scripts/engagement_styles.py) reads the most recent active
19
+ row of kind='human_derived' for the calling platform via the same route
20
+ with HUMAN_DERIVED_RATE_BY_PLATFORM[platform] probability per call. See
21
+ migrations/2026-05-22_consolidate_engagement_styles_human_derived.sql for
22
+ the table shape and the table-consolidation rationale.
23
+
24
+ Run manually: python3 scripts/generate_daily_human_style.py
25
+ python3 scripts/generate_daily_human_style.py --platform twitter
26
+ python3 scripts/generate_daily_human_style.py --dry-run
27
+
28
+ Cron entry : skill/run-generate-daily-style.sh (wraps this via run_claude.sh).
29
+ """
30
+ import argparse
31
+ import json
32
+ import os
33
+ import re
34
+ import subprocess
35
+ import sys
36
+ from datetime import datetime, timedelta, timezone
37
+
38
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
39
+ sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
40
+
41
+ from http_api import api_get, api_post # noqa: E402
42
+
43
+ # Platforms we attempt synthesis for. Each platform that has >= MIN_REPLIES
44
+ # human replies in the window gets its own row in engagement_styles_registry
45
+ # (kind='human_derived'). Platforms with fewer rows are skipped silently —
46
+ # next run will try again.
47
+ PLATFORMS = ["twitter", "reddit", "github", "moltbook", "linkedin"]
48
+
49
+ REPLY_POOL_SIZE = 10 # top N human replies fed to Claude
50
+ WINDOW_HOURS = 24
51
+ MIN_LIKES = 5 # exclude noise-floor replies
52
+ MIN_REPLIES = 3 # skip platform if <3 qualifying rows
53
+ CLAUDE_MODEL_DEFAULT = None # inherit from settings.json
54
+ RUN_CLAUDE_PATH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
55
+ SCRIPT_TAG = "daily-human-style"
56
+
57
+ # target_chars computed from the live top-human-reply median (the whole point
58
+ # of the human_derived lane: learn the length that actually wins TODAY, not a
59
+ # static seed). Clamped to a sane reply-sized band so an outlier essay-reply or
60
+ # a one-word "this." can't drag the target out of range. IG long-form captions
61
+ # are NOT synthesized here (this lane is reply/comment-shaped), so the ceiling
62
+ # stays tweet-sized.
63
+ TARGET_CHARS_FLOOR = 30
64
+ TARGET_CHARS_CEIL = 300
65
+ DEFAULT_TARGET_CHARS = 80 # used only if median can't be computed
66
+
67
+
68
+ def median_reply_chars(replies):
69
+ """Median char length of the top human replies' content, clamped to the
70
+ reply-sized band. This is the realized length of what actually won the
71
+ thread today, so it becomes the style's target_chars: we aim to land where
72
+ humans land, not where our prompts historically bloated to (~215)."""
73
+ lengths = sorted(
74
+ len((r.get("reply_content") or "").strip())
75
+ for r in replies
76
+ if (r.get("reply_content") or "").strip()
77
+ )
78
+ if not lengths:
79
+ return DEFAULT_TARGET_CHARS
80
+ n = len(lengths)
81
+ mid = n // 2
82
+ med = lengths[mid] if n % 2 else (lengths[mid - 1] + lengths[mid]) // 2
83
+ return max(TARGET_CHARS_FLOOR, min(TARGET_CHARS_CEIL, int(med)))
84
+
85
+
86
+ def fetch_top_human_replies(platform,
87
+ limit=REPLY_POOL_SIZE, hours=WINDOW_HOURS):
88
+ """Top human replies on `platform` from the last <hours>, ordered by likes.
89
+
90
+ `likes` is the only engagement column populated across all platforms
91
+ in thread_top_replies (views/comments/retweets are platform-shaped),
92
+ so we lean on it as the ranking key. has_link=false filters out
93
+ link-tail spam so the synthesizer only learns from organic moves.
94
+
95
+ Served via the HTTP API (thread-top-replies?top_human=1) so no DATABASE_URL
96
+ is needed. The route returns the same column shape this used to SELECT.
97
+ """
98
+ resp = api_get(
99
+ "/api/v1/thread-top-replies",
100
+ query={
101
+ "top_human": "1",
102
+ "platform": platform,
103
+ "within_hours": int(hours),
104
+ "min_likes": MIN_LIKES,
105
+ "limit": int(limit),
106
+ },
107
+ )
108
+ return (resp.get("data") or {}).get("replies") or []
109
+
110
+
111
+ def load_existing_style_names():
112
+ """Every active style name already in the registry (any kind), so the
113
+ model doesn't propose a collision. Falls back to the in-process STYLES
114
+ dict if the registry can't be reached.
115
+
116
+ Reads via the same route the picker uses, not via direct DB access.
117
+ """
118
+ names = set()
119
+ try:
120
+ from engagement_styles import get_all_styles # noqa: WPS433
121
+ names.update(get_all_styles().keys())
122
+ except Exception:
123
+ pass
124
+ # No DB fallback for the registry table — get_all_styles() already
125
+ # consults the route, and the in-memory STYLES dict is the cold-start
126
+ # floor it merges in. We don't want to bypass the route for an extra
127
+ # DB read here.
128
+ return names
129
+
130
+
131
+ def already_generated_recently(platform, hours=20):
132
+ """True if a human_derived style for `platform` was already created in the
133
+ last `hours`.
134
+
135
+ Idempotency guard. The daily cron fires ONCE at 16:00 PDT, so any second
136
+ human_derived row for the same platform inside ~20h is a duplicate, e.g. a
137
+ launchd catch-up run after the Mac woke from sleep, a manual rerun, or a
138
+ double-fire. Without this guard nothing stopped repeated invocations from
139
+ each minting a fresh style: on 2026-05-28 the synthesizer was invoked 4x
140
+ and inserted 4 twitter styles (peer_imperative, utility_for_reader_link,
141
+ build_in_public_artifact, proof_of_claim_link). The contract is "invent no
142
+ more than we consume" => at most one human_derived style per platform per
143
+ day.
144
+
145
+ Reads the newest human_derived row for the platform via the registry route
146
+ (kind=human_derived&platform=X&latest=1) and compares generated_at to the
147
+ window locally, so no DATABASE_URL is needed.
148
+
149
+ Fails OPEN (returns False) on any error so a transient blip never silently
150
+ kills the daily run.
151
+ """
152
+ try:
153
+ resp = api_get(
154
+ "/api/v1/engagement-styles/registry",
155
+ query={
156
+ "kind": "human_derived",
157
+ "platform": platform,
158
+ "latest": "1",
159
+ "status": "all",
160
+ },
161
+ )
162
+ styles = (resp.get("data") or {}).get("styles") or []
163
+ if not styles:
164
+ return False
165
+ gen = styles[0].get("generated_at")
166
+ if not gen:
167
+ return False
168
+ s = str(gen)
169
+ if s.endswith("Z"):
170
+ s = s[:-1] + "+00:00"
171
+ dt = datetime.fromisoformat(s)
172
+ if dt.tzinfo is None:
173
+ dt = dt.replace(tzinfo=timezone.utc)
174
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
175
+ return dt >= cutoff
176
+ except Exception as e:
177
+ sys.stderr.write(
178
+ f"[generate_daily_human_style] platform={platform} idempotency "
179
+ f"check failed ({e}); proceeding (fail-open)\n"
180
+ )
181
+ return False
182
+
183
+
184
+ def build_prompt(platform, replies, reserved_names):
185
+ lines = []
186
+ lines.append(
187
+ f"You are analyzing the top-performing human {platform} replies from "
188
+ f"the last {WINDOW_HOURS} hours and distilling ONE new engagement "
189
+ "style we can use for our own replies on that platform."
190
+ )
191
+ lines.append("")
192
+ lines.append("## What you're looking for")
193
+ lines.append("")
194
+ lines.append(
195
+ "These replies all WON the thread (top of the conversation by likes). "
196
+ "Find the shared pattern that makes them work — the rhetorical move, "
197
+ "the structural shape, the relationship to the OP. Most winners "
198
+ "share ONE pattern; that pattern is your new engagement style."
199
+ )
200
+ lines.append("")
201
+ lines.append(
202
+ "Ignore: replies that win because of follower count, fame, or "
203
+ "non-repeatable luck. Focus on the structural move that we (a "
204
+ "small account) could imitate and have a chance of replicating."
205
+ )
206
+ lines.append("")
207
+ lines.append(
208
+ f"## Top {len(replies)} human {platform} replies (by likes)"
209
+ )
210
+ lines.append("")
211
+ for i, r in enumerate(replies, 1):
212
+ lines.append(
213
+ f"### #{i} (likes={r['likes']}, replies={r['replies_count']}, "
214
+ f"rt={r['retweets']})"
215
+ )
216
+ lines.append(f"Thread: {r['thread_url']}")
217
+ handle = r.get("reply_author_handle") or "(unknown)"
218
+ lines.append(f"Reply by @{handle}:")
219
+ lines.append(f"> {r['reply_content']}")
220
+ lines.append("")
221
+
222
+ lines.append("## Schema (match exactly)")
223
+ lines.append("")
224
+ lines.append(
225
+ "Return ONE JSON object describing a single new engagement style. "
226
+ "No prose around it, no markdown code fence. The object MUST have "
227
+ "every field below:"
228
+ )
229
+ lines.append("")
230
+ lines.append("```")
231
+ lines.append("{")
232
+ lines.append(' "name": "<snake_case_name>",')
233
+ lines.append(' "description": "<one to three sentences describing the style>",')
234
+ lines.append(' "example": "<one short OP + reply pair demonstrating the style>",')
235
+ lines.append(' "best_in": {')
236
+ lines.append(f' "{platform}": ["<short context label>", ...],')
237
+ # Encourage the model to fill cross-platform `best_in` opportunistically
238
+ # when the move generalizes; leave as [] if not.
239
+ for other in PLATFORMS:
240
+ if other != platform:
241
+ lines.append(f' "{other}": [],')
242
+ lines.append(" },")
243
+ lines.append(' "note": "<one to two sentences: when to use, when not to>"')
244
+ lines.append("}")
245
+ lines.append("```")
246
+ lines.append("")
247
+ lines.append("## Rules")
248
+ lines.append("")
249
+ lines.append(
250
+ "1. The name must be unique. Reserved (do NOT propose these): "
251
+ f"{sorted(reserved_names)}."
252
+ )
253
+ lines.append(
254
+ "2. The name should be 2 to 4 snake_case tokens, descriptive of the "
255
+ "MOVE (e.g. `mirror_and_extend`, `flip_to_alt`, not `good_reply`)."
256
+ )
257
+ lines.append(
258
+ "3. The description should make the style copyable: a future model "
259
+ "reading just that one sentence should know what to write."
260
+ )
261
+ lines.append(
262
+ "4. The example should be a realistic OP + reply pair, not lifted "
263
+ "verbatim from the inputs."
264
+ )
265
+ lines.append(
266
+ f"5. best_in.{platform} is required (at least one context label). "
267
+ "Other platforms can stay empty arrays if the style is "
268
+ f"{platform}-specific."
269
+ )
270
+ lines.append(
271
+ "6. NEVER propose a style about including a product, a URL, or a "
272
+ "mechanism. Our link-tail layer handles that downstream. The style "
273
+ "is about the text BEFORE the link."
274
+ )
275
+ return "\n".join(lines)
276
+
277
+
278
+ def call_claude(prompt):
279
+ cmd = [RUN_CLAUDE_PATH, SCRIPT_TAG, "-p", prompt, "--output-format", "json"]
280
+ if CLAUDE_MODEL_DEFAULT:
281
+ cmd.extend(["--model", CLAUDE_MODEL_DEFAULT])
282
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
283
+ if result.returncode != 0:
284
+ sys.stderr.write(
285
+ f"[generate_daily_human_style] claude rc={result.returncode}\n"
286
+ f"stderr: {result.stderr[:2000]}\n"
287
+ )
288
+ raise RuntimeError(f"claude wrapper failed: rc={result.returncode}")
289
+ envelope = json.loads(result.stdout)
290
+ return envelope.get("result", "") or ""
291
+
292
+
293
+ _JSON_OBJ_RE = re.compile(r"\{[\s\S]*\}")
294
+
295
+
296
+ def extract_json(text):
297
+ """Tolerant of code fences or stray prose around the JSON object."""
298
+ fence = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
299
+ if fence:
300
+ return json.loads(fence.group(1))
301
+ m = _JSON_OBJ_RE.search(text)
302
+ if not m:
303
+ raise ValueError("No JSON object found in model output")
304
+ return json.loads(m.group(0))
305
+
306
+
307
+ def validate_style(style, platform, reserved_names):
308
+ """Sanity-check the model output before we POST."""
309
+ required = {"name", "description", "example", "best_in", "note"}
310
+ missing = required - set(style.keys())
311
+ if missing:
312
+ raise ValueError(f"missing fields: {sorted(missing)}")
313
+
314
+ name = style["name"]
315
+ if not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9_]{2,40}", name):
316
+ raise ValueError(f"bad name: {name!r}")
317
+ if name in reserved_names:
318
+ raise ValueError(f"name collision: {name!r}")
319
+
320
+ best_in = style["best_in"]
321
+ if not isinstance(best_in, dict):
322
+ raise ValueError("best_in must be object")
323
+ # We require the calling platform key to be a non-empty list. Other
324
+ # platforms may be missing or empty — the route accepts them as long
325
+ # as the JSON shape is sane.
326
+ pf = best_in.get(platform)
327
+ if not isinstance(pf, list) or not pf:
328
+ raise ValueError(
329
+ f"best_in.{platform} must be a non-empty list (source platform)"
330
+ )
331
+
332
+ for field in ("description", "example", "note"):
333
+ if not isinstance(style[field], str) or not style[field].strip():
334
+ raise ValueError(f"{field} must be non-empty string")
335
+
336
+
337
+ def post_style(style, platform, replies, prompt_chars):
338
+ """POST the synthesized style to the registry route. The route writes
339
+ to engagement_styles_registry with kind='human_derived' and platform=
340
+ <platform> (the picker filters on those two for the latest row).
341
+ Returns the parsed response (with style + created keys).
342
+ """
343
+ source_post_ids = [r["id"] for r in replies]
344
+ target_chars = median_reply_chars(replies)
345
+ window_end = datetime.now(timezone.utc)
346
+ window_start = window_end - timedelta(hours=WINDOW_HOURS)
347
+ gen_log = (
348
+ f"Synthesized {window_end.isoformat(timespec='seconds')} from "
349
+ f"top {len(replies)} human {platform} replies in last "
350
+ f"{WINDOW_HOURS}h. Prompt size: {prompt_chars} chars. "
351
+ f"Reply id range: {min(source_post_ids)}-{max(source_post_ids)}. "
352
+ f"target_chars={target_chars} (median of source-reply lengths)."
353
+ )
354
+
355
+ payload = {
356
+ "name": style["name"],
357
+ "description": style["description"],
358
+ "example": style["example"],
359
+ "note": style["note"],
360
+ "best_in": style["best_in"],
361
+ "target_chars": target_chars,
362
+ "kind": "human_derived",
363
+ "platform": platform,
364
+ "first_post_platform": platform,
365
+ "invented_by_model": "daily-human-style-synthesizer",
366
+ "source_window_start": window_start.isoformat(timespec="seconds"),
367
+ "source_window_end": window_end.isoformat(timespec="seconds"),
368
+ "source_post_ids": source_post_ids,
369
+ "generation_log": gen_log,
370
+ "generated_at": window_end.isoformat(timespec="seconds"),
371
+ }
372
+ return api_post(
373
+ "/api/v1/engagement-styles/registry", payload, ok_on_conflict=True,
374
+ )
375
+
376
+
377
+ def synthesize_for_platform(platform, reserved, dry_run=False):
378
+ """Run the synthesizer for ONE platform. Returns a result dict for
379
+ summary logging; raises only on genuinely fatal errors (e.g. Claude
380
+ wrapper crash). Insufficient-data is a soft skip.
381
+ """
382
+ # Idempotency: at most ONE human_derived style per platform per day. Skip
383
+ # before spending a Claude call if today's style already exists (rerun,
384
+ # launchd catch-up, double-fire). dry_run bypasses so prompts stay
385
+ # inspectable.
386
+ if not dry_run and already_generated_recently(platform):
387
+ sys.stderr.write(
388
+ f"[generate_daily_human_style] platform={platform} already has a "
389
+ f"human_derived style from the last 20h; skipping (idempotent).\n"
390
+ )
391
+ return {"platform": platform, "status": "skipped_already_today"}
392
+ replies = fetch_top_human_replies(platform)
393
+ if len(replies) < MIN_REPLIES:
394
+ sys.stderr.write(
395
+ f"[generate_daily_human_style] platform={platform} only "
396
+ f"{len(replies)} replies in last {WINDOW_HOURS}h (need "
397
+ f">={MIN_REPLIES}). Skipping.\n"
398
+ )
399
+ return {
400
+ "platform": platform,
401
+ "status": "skipped_insufficient_data",
402
+ "source_count": len(replies),
403
+ }
404
+ prompt = build_prompt(platform, replies, reserved)
405
+ sys.stderr.write(
406
+ f"[generate_daily_human_style] platform={platform} prompt "
407
+ f"{len(prompt)} chars, {len(replies)} replies, "
408
+ f"reserved={len(reserved)} names\n"
409
+ )
410
+ if dry_run:
411
+ return {
412
+ "platform": platform,
413
+ "status": "dry_run",
414
+ "source_count": len(replies),
415
+ "prompt_chars": len(prompt),
416
+ "source_likes_top": replies[0]["likes"],
417
+ "source_likes_bottom": replies[-1]["likes"],
418
+ "target_chars": median_reply_chars(replies),
419
+ }
420
+
421
+ text = call_claude(prompt)
422
+ if not text.strip():
423
+ sys.stderr.write(
424
+ f"[generate_daily_human_style] platform={platform} empty claude "
425
+ "output\n"
426
+ )
427
+ return {"platform": platform, "status": "empty_claude_output"}
428
+ style = extract_json(text)
429
+ validate_style(style, platform, reserved)
430
+ resp = post_style(style, platform, replies, len(prompt))
431
+ data = (resp or {}).get("data") or {}
432
+ created = bool(data.get("created"))
433
+ inserted = data.get("style") or {}
434
+ # Add the name to the live reserved set so the NEXT platform in the
435
+ # same run can't propose the same name.
436
+ if inserted.get("name"):
437
+ reserved.add(inserted["name"])
438
+ return {
439
+ "platform": platform,
440
+ "status": "ok" if created else "duplicate",
441
+ "name": inserted.get("name") or style["name"],
442
+ "kind": inserted.get("kind", "human_derived"),
443
+ "source_count": len(replies),
444
+ "source_likes_top": replies[0]["likes"],
445
+ "source_likes_bottom": replies[-1]["likes"],
446
+ "target_chars": median_reply_chars(replies),
447
+ "created": created,
448
+ }
449
+
450
+
451
+ def main():
452
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
453
+ ap.add_argument(
454
+ "--platform",
455
+ action="append",
456
+ choices=PLATFORMS,
457
+ help="Limit to one or more platforms (repeatable). Default: all.",
458
+ )
459
+ ap.add_argument(
460
+ "--dry-run",
461
+ action="store_true",
462
+ help="Build prompts and report counts; don't call Claude or POST.",
463
+ )
464
+ args = ap.parse_args()
465
+ platforms = args.platform or PLATFORMS
466
+
467
+ summary = []
468
+ reserved = load_existing_style_names()
469
+ for platform in platforms:
470
+ try:
471
+ result = synthesize_for_platform(
472
+ platform, reserved, dry_run=args.dry_run,
473
+ )
474
+ except Exception as e:
475
+ sys.stderr.write(
476
+ f"[generate_daily_human_style] platform={platform} "
477
+ f"failed: {e}\n"
478
+ )
479
+ result = {
480
+ "platform": platform,
481
+ "status": "error",
482
+ "error": str(e),
483
+ }
484
+ summary.append(result)
485
+
486
+ print(json.dumps({"runs": summary}, indent=2, default=str))
487
+ # Exit non-zero if every platform errored — soft skips don't count.
488
+ if summary and all(r.get("status") == "error" for r in summary):
489
+ return 1
490
+ return 0
491
+
492
+
493
+ if __name__ == "__main__":
494
+ sys.exit(main())