@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,588 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a feedback report from top/bottom performing posts.
3
+
4
+ Queries Postgres for engagement data and outputs a factual report
5
+ organized by project and platform. This is the self-improvement
6
+ feedback loop — Claude reads this before drafting new comments.
7
+
8
+ Usage:
9
+ python3 scripts/top_performers.py
10
+ python3 scripts/top_performers.py --platform reddit
11
+ python3 scripts/top_performers.py --project Fazm
12
+ python3 scripts/top_performers.py --project Fazm --platform reddit
13
+ python3 scripts/top_performers.py --top 20
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import sys
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+
23
+ MIN_CONTENT_LEN = 30 # skip posts with empty/placeholder content
24
+
25
+ # CTE that adds a bot-filtered `clicks` column to every row of `posts`.
26
+ # Sources from `post_link_clicks` (per-hit log, populated by the redirector
27
+ # after 2026-05-07) with `is_bot=false`. This is the same attribution path
28
+ # used by top_search_topics.py and matches what the dashboard reports on
29
+ # the Top Comments tab. The legacy `post_links.real_clicks` column is a
30
+ # stale PostHog backfill and is wildly inaccurate (twitter ~7x undercount,
31
+ # reddit permanently 0), so we do NOT use it here.
32
+ #
33
+ # Why a CTE: the score expression below references `clicks` in WHERE,
34
+ # ORDER BY, and SELECT clauses across multiple functions. A correlated
35
+ # subquery inline-repeated 3x per query would compile, but the CTE form
36
+ # stays readable and Postgres can hoist the per-post aggregation once.
37
+ POSTS_WITH_CLICKS_CTE = """
38
+ WITH posts_w_clicks AS (
39
+ SELECT p.*,
40
+ COALESCE((
41
+ SELECT COUNT(plc.id)
42
+ FROM post_links pl
43
+ LEFT JOIN post_link_clicks plc
44
+ ON plc.code = pl.code AND plc.is_bot = false
45
+ WHERE pl.post_id = p.id
46
+ ), 0) AS clicks
47
+ FROM posts p
48
+ )
49
+ """
50
+
51
+ # Composite score (2026-05-12 reweight): real human clicks are the ONLY
52
+ # signal that proves a comment drove someone to actually visit the
53
+ # project's link. Comments are the next-best imitation signal (real
54
+ # discussion). Upvotes are passive approval, kept faint. Views deliberately
55
+ # excluded (viral-by-algorithm ≠ a pattern worth imitating). Reddit and
56
+ # Moltbook upvotes get -1 to strip the OP's auto self-upvote.
57
+ #
58
+ # Click weight ×10 means one real human click outvalues 10 likes worth
59
+ # of vibes when ranking top examples for the generator's few-shot context.
60
+ # This is the same direction top_search_topics.py already takes (×100
61
+ # there because that script ranks SEARCH QUERIES, where a single click
62
+ # across a query's posts is rare). For per-post example ranking ×10
63
+ # keeps zero-click posts with very strong discussion (Reddit threads with
64
+ # 20 comments) still competitive.
65
+ SCORE_SQL = (
66
+ "(COALESCE(clicks, 0) * 10 + "
67
+ "COALESCE(comments_count,0) * 3 + "
68
+ "CASE WHEN LOWER(platform) IN ('reddit', 'moltbook') "
69
+ "THEN GREATEST(0, COALESCE(upvotes,0) - 1) "
70
+ "ELSE COALESCE(upvotes,0) END)"
71
+ )
72
+
73
+ # Per-row net upvotes: Reddit and Moltbook auto-apply a +1 OP self-upvote on
74
+ # every post, so the raw `upvotes` column starts at 1 for a brand-new post with
75
+ # zero real engagement. Strip that +1 per row (clamped at 0 so downvoted posts
76
+ # don't go negative). All human-facing display, AVG, MAX, etc. in this script
77
+ # should aggregate this expression instead of `upvotes` directly so the report
78
+ # matches the score and the dashboard.
79
+ UPVOTES_NET_SQL = (
80
+ "(CASE WHEN LOWER(platform) IN ('reddit', 'moltbook') "
81
+ "THEN GREATEST(0, COALESCE(upvotes,0) - 1) "
82
+ "ELSE COALESCE(upvotes,0) END)"
83
+ )
84
+
85
+ # Recency window for every SCORE_SQL-driven query in this module. Lifetime
86
+ # aggregation drifted too far from current performance reality (old wins kept
87
+ # old styles in the picker pool even after the audience/algorithm shifted).
88
+ # 30 days keeps n large enough for stable averages while letting the report
89
+ # track the live algorithm. Set RECENCY_DAYS=0 to fall back to lifetime.
90
+ # Mirrors engagement_styles.RECENCY_DAYS so the picker and few-shot context
91
+ # never disagree on which window defines "top".
92
+ RECENCY_DAYS = 30
93
+
94
+
95
+ def _recency_clause():
96
+ """Return a WHERE-clause fragment that limits posts to the recency window,
97
+ or an empty string if RECENCY_DAYS == 0 (lifetime mode)."""
98
+ if not RECENCY_DAYS or RECENCY_DAYS <= 0:
99
+ return ""
100
+ return f"posted_at >= NOW() - INTERVAL '{int(RECENCY_DAYS)} days'"
101
+
102
+ # Per-platform "meaningful engagement" floor for the SCORE_SQL composite.
103
+ # Twitter/LinkedIn reactions are rarer than Reddit upvotes, so thresholds differ.
104
+ PLATFORM_MIN_SCORE = {
105
+ "reddit": 10,
106
+ "twitter": 5,
107
+ "x": 5,
108
+ "linkedin": 3,
109
+ "moltbook": 3,
110
+ "github": 3,
111
+ }
112
+ DEFAULT_MIN_SCORE = 5
113
+
114
+ def min_score_for(platform):
115
+ if platform is None:
116
+ return DEFAULT_MIN_SCORE
117
+ return PLATFORM_MIN_SCORE.get(str(platform).lower(), DEFAULT_MIN_SCORE)
118
+
119
+ # =====================================================================
120
+ # DO NOT REMOVE OR SIMPLIFY THE FUNCTIONS BELOW.
121
+ # These are data-driven improvements based on analysis of 3,000+ posts.
122
+ # They have been reverted by other agents twice already.
123
+ # Protected by pre-commit hook. See CLAUDE.md.
124
+ # =====================================================================
125
+
126
+ # Product names that indicate self-promotion (teaching Claude bad habits)
127
+ PRODUCT_NAMES = [
128
+ "fazm", "assrt", "pieline", "cyrano", "terminator", "mk0r", "s4l",
129
+ "vipassana.cool", "vipassana-cool",
130
+ ]
131
+
132
+
133
+ def get_distilled_rules(platform):
134
+ """Return guidance on how to interpret the performance data below."""
135
+ if platform == "reddit":
136
+ return """## HOW TO USE THIS REPORT
137
+ - Comments are the strongest signal: a post that sparked replies taught people something or hit a nerve. Prioritize imitating posts with high comment counts, even if upvotes are modest.
138
+ - Upvotes are second-tier (passive approval). Views are excluded because viral-by-algorithm is not a pattern worth copying.
139
+ - Study the top posts: what style, length, and tone got real discussion? Do more of that.
140
+ - Study the bottom posts and their FAILURE REASON annotations: avoid those patterns entirely.
141
+ - Compare avg_cm (then avg_up) across styles in the summary. Pick styles that actually drive conversation, not just familiar ones.
142
+ - Posts with product mentions or URLs consistently underperform. The top posts never contain them.
143
+ - Look at content length in top vs bottom posts. Let the data guide whether to go short or long.
144
+ """
145
+ return ""
146
+
147
+
148
+ def has_anti_pattern(content):
149
+ """Check if content contains product names or links (bad teaching examples)."""
150
+ if not content:
151
+ return False
152
+ lower = content.lower()
153
+ for name in PRODUCT_NAMES:
154
+ if name in lower:
155
+ return True
156
+ if "http://" in lower or "https://" in lower or "www." in lower:
157
+ return True
158
+ return False
159
+
160
+
161
+ def annotate_failure(row):
162
+ """Detect why a bottom post likely failed and return a reason string."""
163
+ content = (row[5] or "").lower()
164
+ reasons = []
165
+ for name in PRODUCT_NAMES:
166
+ if name in content:
167
+ reasons.append(f"mentions '{name}'")
168
+ break
169
+ if "http://" in content or "https://" in content or "www." in content:
170
+ reasons.append("contains URL/link")
171
+ if any(phrase in content for phrase in [
172
+ "phone order", "missed call", "phone call", "unanswered call",
173
+ "call capture", "answering service",
174
+ ]):
175
+ reasons.append("product-adjacent pitch (phone/call capture)")
176
+ if any(phrase in content for phrase in [
177
+ "macOS app", "macos app", "desktop agent", "accessibility api",
178
+ "mcp server", "mcp layer",
179
+ ]):
180
+ reasons.append("product-adjacent (mentions own project)")
181
+ if content.count("?") >= 3:
182
+ reasons.append("too many questions (reads as interrogation)")
183
+ if "curious" in content and ("?" in content):
184
+ reasons.append("curious_probe style (negative avg on Reddit)")
185
+ if len(content) < 100:
186
+ reasons.append("too short without being punchy")
187
+ if not reasons:
188
+ reasons.append("likely wrong subreddit or off-topic")
189
+ return " | ".join(reasons)
190
+
191
+
192
+ _ACTIVE_CAMPAIGN_SUFFIXES_CACHE = None
193
+
194
+
195
+ def _load_active_campaign_suffixes():
196
+ """Best-effort: return a list of currently-active campaign suffix literals.
197
+
198
+ Cached per-process. Used to strip the suffix from `our_content` before
199
+ feeding it into the few-shot prompt context, so the LLM never learns to
200
+ echo the suffix in its drafts (which then double-fires with the
201
+ tool-layer injection, observed 2026-05-18 on Reddit IDs 70412 + 70413).
202
+ On any failure returns []: missing strip is preferable to crashing
203
+ the report pipeline. Routes through the HTTP API (/api/v1/campaigns).
204
+ """
205
+ global _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
206
+ if _ACTIVE_CAMPAIGN_SUFFIXES_CACHE is not None:
207
+ return _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
208
+ suffixes = []
209
+ try:
210
+ from http_api import api_get
211
+ resp = api_get(
212
+ "/api/v1/campaigns",
213
+ query={"status": "active", "has_suffix": "true", "limit": 500},
214
+ )
215
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
216
+ for r in rows:
217
+ s = (r.get("suffix") or "").strip()
218
+ if s and s not in suffixes:
219
+ suffixes.append(s)
220
+ except Exception as e:
221
+ print(f"[top_performers] _load_active_campaign_suffixes (api) failed: {e}",
222
+ file=sys.stderr)
223
+ _ACTIVE_CAMPAIGN_SUFFIXES_CACHE = suffixes
224
+ return suffixes
225
+
226
+
227
+ def _strip_active_campaign_suffixes(text, suffixes):
228
+ """Trailing-only, idempotent strip of any active-campaign suffix.
229
+
230
+ Idempotent loop also collapses an already-doubled historical suffix to
231
+ clean text. Trailing-only so we never touch the body of the comment.
232
+ """
233
+ if not text or not suffixes:
234
+ return text
235
+ cleaned = text.rstrip()
236
+ changed = True
237
+ while changed:
238
+ changed = False
239
+ for sfx in suffixes:
240
+ if sfx and cleaned.endswith(sfx):
241
+ cleaned = cleaned[: -len(sfx)].rstrip()
242
+ changed = True
243
+ return cleaned
244
+
245
+
246
+ def format_post(row, include_thread_content=True, suffix_strip_list=None):
247
+ """Format a single post as factual text.
248
+
249
+ Upvotes are reported NET on Reddit and Moltbook: both platforms auto-apply
250
+ a +1 OP self-upvote on every post, so the raw `upvotes` column starts at 1
251
+ for a brand-new post with zero real engagement. Strip that +1 here so the
252
+ display matches SCORE_SQL and the dashboard. Other platforms pass through.
253
+
254
+ `suffix_strip_list`: list of active-campaign suffix literals to strip from
255
+ `our_content` before emitting the "Our comment:" line. Without this, the
256
+ LLM sees historical tagged comments in the few-shot block, copies the
257
+ suffix into its draft, and the tool-layer injection (engage_reddit,
258
+ twitter_browser) appends a second copy. See _strip_active_campaign_suffixes.
259
+ """
260
+ lines = []
261
+ platform_lc = str(row[1] or "").lower()
262
+ raw_upvotes = row[2] if row[2] is not None else 0
263
+ if platform_lc in ("reddit", "moltbook"):
264
+ upvotes = max(0, raw_upvotes - 1)
265
+ else:
266
+ upvotes = raw_upvotes
267
+ comments = row[3] if row[3] is not None else 0
268
+ views = row[4] if row[4] is not None else 0
269
+ our_content = row[5] or ""
270
+ if suffix_strip_list:
271
+ our_content = _strip_active_campaign_suffixes(our_content, suffix_strip_list)
272
+ thread_title = row[6] or ""
273
+ thread_content = row[7] or ""
274
+ project = row[8] or "(no project)"
275
+ date = row[9]
276
+ account = row[10] or ""
277
+ # New column 11 = clicks. Pre-2026-05-12 rows fed from older callers
278
+ # may not include this column, so guard with len() before indexing
279
+ # to keep this function backward-compatible with the (now-rewritten)
280
+ # SELECT lists in this file and anywhere else still on the old shape.
281
+ clicks = row[11] if len(row) > 11 and row[11] is not None else 0
282
+
283
+ # Clicks lead the header because they are the ground-truth conversion
284
+ # signal: a click means a real human actually went to the project
285
+ # link. Upvotes/comments/views are leading indicators of attention,
286
+ # not behavior. If a top-tier example has 0 clicks, Claude should
287
+ # see that and weight discussion shape (comments) over "this post
288
+ # drove traffic".
289
+ header = (
290
+ f"[{clicks} clicks, {upvotes} upvotes, {comments} comments, "
291
+ f"{views} views] {row[1]} | {project} | {date}"
292
+ )
293
+ lines.append(header)
294
+
295
+ if thread_title:
296
+ lines.append(f" Thread: {thread_title}")
297
+ if include_thread_content and thread_content:
298
+ snippet = thread_content.replace('\n', ' ')
299
+ lines.append(f" Thread body: {snippet}")
300
+ lines.append(f" Our comment: {our_content}")
301
+ return "\n".join(lines)
302
+
303
+
304
+ def format_report(summary, top, bottom, project=None, platform=None,
305
+ top_by_group=None, fallback_top=None, style_perf=None,
306
+ top_by_style=None, suffix_strip_list=None):
307
+ """Format the full report.
308
+
309
+ `suffix_strip_list` is forwarded to every `format_post` call so
310
+ historical campaign-tagged comments don't leak the suffix into the
311
+ LLM's few-shot context. Passed in by `main()` after loading from the
312
+ `campaigns` table (cached per-process).
313
+ """
314
+ lines = []
315
+ filters = []
316
+ if project:
317
+ filters.append(f"project={project}")
318
+ if platform:
319
+ filters.append(f"platform={platform}")
320
+ scope = f" ({', '.join(filters)})" if filters else ""
321
+ lines.append(f"## Performance Feedback Report{scope}")
322
+ lines.append("")
323
+
324
+ # Distilled rules first (most important part of the report)
325
+ if platform:
326
+ rules = get_distilled_rules(platform)
327
+ if rules:
328
+ lines.append(rules)
329
+
330
+ # "meaningful engagement" is scored as clicks*10 + comments*3 + upvotes
331
+ # (Reddit upvote -1), with a per-platform floor (see PLATFORM_MIN_SCORE).
332
+ # Report it to Claude so it understands why borderline posts are/aren't
333
+ # included. Clicks dominate because a single human click is worth more
334
+ # than 10 upvotes of vibes when picking examples for the few-shot prompt.
335
+ threshold_label = (
336
+ f">= score {min_score_for(platform)} "
337
+ f"(clicks*10 + comments*3 + upvotes, Reddit upvote -1)"
338
+ )
339
+
340
+ # Style performance (live from DB). Report clicks AND comments AND
341
+ # upvotes so click-driving styles surface FIRST, discussion-driving
342
+ # styles second, and upvote-accumulating ones last. avg_clicks (col 4)
343
+ # is the new column; legacy callers that grouped only on upvotes/
344
+ # comments will not see it but every caller in this repo now does.
345
+ if style_perf:
346
+ lines.append("### Engagement Style Performance (live data, sorted by avg clicks → avg comments)")
347
+ for row in style_perf:
348
+ lines.append(
349
+ f" {row[0]:<22} {row[1]:>5} posts "
350
+ f"avg_clicks={row[4]} avg_cm={row[3]} avg_up={row[2]} "
351
+ f"best_clicks={row[7]} best_cm={row[6]} best_up={row[5]}"
352
+ )
353
+ lines.append("")
354
+
355
+ # Per-style top exemplar. The style table above is just numbers; this
356
+ # section shows the single highest-scoring real post we have for each
357
+ # style, so when the model picks a style it can see what a great post
358
+ # in that style actually reads like. Ordered to match the style table
359
+ # (avg clicks DESC) so the click-winning styles and their exemplars
360
+ # appear first. Styles with no clean example are listed so the absence
361
+ # is itself a signal ("this style has never landed a usable post").
362
+ if top_by_style and style_perf:
363
+ exemplars = _best_exemplar_per_style(top_by_style)
364
+ lines.append(
365
+ "### Best Example Per Style (imitate this when you pick the style)"
366
+ )
367
+ lines.append(
368
+ "One real post per style — the highest-scoring one we have. "
369
+ "Pick the style, then write something with the same shape as its example."
370
+ )
371
+ lines.append("")
372
+ for row in style_perf:
373
+ style = row[0]
374
+ header = (
375
+ f"#### {style} "
376
+ f"(n={row[1]}, avg_clicks={row[4]}, avg_cm={row[3]}, avg_up={row[2]})"
377
+ )
378
+ lines.append(header)
379
+ ex = exemplars.get(style)
380
+ if ex:
381
+ lines.append(format_post(ex, suffix_strip_list=suffix_strip_list))
382
+ else:
383
+ lines.append(" (no clean example yet — style unproven or all examples filtered)")
384
+ lines.append("")
385
+
386
+ # Summary table. Per-project/platform now shows total_clicks (col 9)
387
+ # so Claude can see at-a-glance which projects converted at all.
388
+ # Projects with zero total_clicks across many posts are the canaries
389
+ # for "this product/voice combination isn't landing" (the 'General'
390
+ # bucket in the 7d audit on 2026-05-12: 56 posts, 0 clicks).
391
+ lines.append("### Posts per Project per Platform")
392
+ for row in summary:
393
+ lines.append(
394
+ f" {row[0]:<20} {row[1]:<12} {row[2]:>5} posts "
395
+ f"avg_clicks={row[5]} avg_cm={row[4]} avg_up={row[3]} "
396
+ f"best_clicks={row[8]} best_cm={row[7]} best_up={row[6]} "
397
+ f"total_clicks={row[9]}"
398
+ )
399
+ lines.append("")
400
+
401
+ # Per-project top performers (when no project filter)
402
+ if top_by_group:
403
+ lines.append(f"### Top Posts by Project ({threshold_label})")
404
+ for group_name, posts in top_by_group.items():
405
+ if not posts:
406
+ continue
407
+ lines.append(f"\n#### {group_name}")
408
+ for p in posts:
409
+ lines.append(format_post(p, suffix_strip_list=suffix_strip_list))
410
+ lines.append("")
411
+ elif top:
412
+ # Filtered view with results
413
+ lines.append(
414
+ f"### Top {len(top)} Posts for {project or 'all projects'} ({threshold_label})"
415
+ )
416
+ for p in top:
417
+ lines.append(format_post(p, suffix_strip_list=suffix_strip_list))
418
+ lines.append("")
419
+ elif fallback_top:
420
+ # No project-specific posts met threshold — show general high performers
421
+ platform_label = f" on {platform}" if platform else ""
422
+ lines.append(f"### No {project} posts meeting {threshold_label}{platform_label}.")
423
+ lines.append(f"### Showing top posts from OTHER projects{platform_label} as reference:")
424
+ lines.append("")
425
+ for p in fallback_top:
426
+ lines.append(format_post(p, suffix_strip_list=suffix_strip_list))
427
+ lines.append("")
428
+
429
+ # Bottom posts with failure annotations
430
+ if bottom:
431
+ lines.append(f"### Bottom {len(bottom)} Posts (avoid these patterns)")
432
+ for p in bottom:
433
+ lines.append(format_post(p, include_thread_content=False,
434
+ suffix_strip_list=suffix_strip_list))
435
+ reason = annotate_failure(p)
436
+ lines.append(f" >> FAILURE REASON: {reason}")
437
+ lines.append("")
438
+
439
+ return "\n".join(lines)
440
+
441
+
442
+ def _apply_top_filter(rows, limit):
443
+ """Anti-pattern filter applied to top-N candidates.
444
+
445
+ PRODUCT_NAMES: hard-drop self-promotional examples regardless of
446
+ clicks (don't teach Claude to namedrop). URL/www. mention: only
447
+ drop when clicks==0 (a URL-bearing post with real human clicks IS
448
+ the gold example by definition; see 2026-05-12 click-aware fix).
449
+ Caller passes overfetched rows; we trim to `limit` after filter.
450
+ """
451
+ clean = []
452
+ for r in rows:
453
+ content = (r[5] or "")
454
+ clicks = r[11] if len(r) > 11 and r[11] is not None else 0
455
+ lower = content.lower()
456
+ if any(name in lower for name in PRODUCT_NAMES):
457
+ continue
458
+ has_url = ("http://" in lower or "https://" in lower or "www." in lower)
459
+ if has_url and clicks == 0:
460
+ continue
461
+ clean.append(r)
462
+ return clean[:limit]
463
+
464
+
465
+ def _best_exemplar_per_style(rows):
466
+ """Collapse the flat get_top_post_per_style() result to {style: row}.
467
+
468
+ Each style ships up to 3 candidate rows (ranked by SCORE_SQL). Run the
469
+ shared anti-pattern filter per style and keep the best survivor. Styles
470
+ whose every candidate is filtered out (e.g. all product-name posts) are
471
+ simply absent from the dict — the caller renders them with no example.
472
+ The engagement_style key is column 12 of each row.
473
+ """
474
+ by_style = {}
475
+ for r in rows:
476
+ if len(r) <= 12 or not r[12]:
477
+ continue
478
+ by_style.setdefault(r[12], []).append(r)
479
+ out = {}
480
+ for style, group in by_style.items():
481
+ clean = _apply_top_filter(group, 1)
482
+ if clean:
483
+ out[style] = clean[0]
484
+ return out
485
+
486
+
487
+ def _fetch_report_via_api(*, platform, project, top, bottom):
488
+ """Pull all SQL aggregations in one call via the v1 route.
489
+
490
+ Returns (summary, style_perf, top_posts, bottom_posts,
491
+ fallback_top|None, top_by_group|None, top_by_style). Row
492
+ shapes match the column order format_post / format_report expect.
493
+ """
494
+ from http_api import api_get
495
+ resp = api_get(
496
+ "/api/v1/posts/top-performers-report",
497
+ query={
498
+ "platform": platform or "",
499
+ "project": project or "",
500
+ "top": str(top),
501
+ "bottom": str(bottom),
502
+ },
503
+ )
504
+ data = (resp or {}).get("data") or {}
505
+ summary = data.get("summary") or []
506
+ style_perf = data.get("style_perf") or []
507
+ raw_top = data.get("top_posts") or []
508
+ raw_bottom = data.get("bottom_posts") or []
509
+ raw_fallback = data.get("fallback_top") or []
510
+ raw_group = data.get("top_by_group") or {}
511
+ top_by_style = data.get("top_by_style") or []
512
+
513
+ top_filtered = _apply_top_filter(raw_top, top) if raw_top else []
514
+ fallback_filtered = None
515
+ if project and not top_filtered and raw_fallback:
516
+ fallback_filtered = _apply_top_filter(raw_fallback, top)
517
+ top_by_group = None
518
+ if not project:
519
+ top_by_group = {
520
+ proj: _apply_top_filter(rows, 5)
521
+ for proj, rows in raw_group.items()
522
+ }
523
+ return (summary, style_perf, top_filtered, raw_bottom,
524
+ fallback_filtered, top_by_group, top_by_style)
525
+
526
+
527
+ def main():
528
+ parser = argparse.ArgumentParser(description="Generate top performers feedback report")
529
+ parser.add_argument("--platform", default=None, help="Filter to specific platform")
530
+ parser.add_argument("--project", default=None, help="Filter to specific project")
531
+ parser.add_argument("--top", type=int, default=5, help="Number of top posts to show (per group or total)")
532
+ parser.add_argument("--bottom", type=int, default=5, help="Number of bottom posts to show")
533
+ parser.add_argument("--style", default=None,
534
+ help=("Restrict per-style exemplars + perf table to the "
535
+ "given engagement_style(s). Accepts a single style "
536
+ "(data_point_drop) or comma-separated (style1,style2). "
537
+ "Added 2026-05-19 for the assigned-style picker rollout: "
538
+ "when a post_*/engage_* orchestrator assigns one style "
539
+ "via pick_style_for_post(), it passes that style here so "
540
+ "the few-shot exemplar section shows only the matching "
541
+ "high-scoring posts instead of every style. Summary, "
542
+ "fallback_top, and top_by_group are not affected."))
543
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
544
+ args = parser.parse_args()
545
+
546
+ (summary, style_perf, top, bottom, fallback_top,
547
+ top_by_group, top_by_style) = _fetch_report_via_api(
548
+ platform=args.platform, project=args.project, top=args.top, bottom=args.bottom,
549
+ )
550
+
551
+ if args.style:
552
+ wanted = {s.strip() for s in args.style.split(",") if s.strip()}
553
+ # style_perf row col 0 = style name. top_by_style row col 12 = style name.
554
+ style_perf = [row for row in style_perf if row and row[0] in wanted]
555
+ top_by_style = [
556
+ row for row in top_by_style
557
+ if row and len(row) > 12 and row[12] in wanted
558
+ ]
559
+
560
+ if args.json:
561
+ output = {
562
+ "summary": [list(row) for row in summary],
563
+ "top_posts": [list(row) for row in top],
564
+ "bottom_posts": [list(row) for row in bottom],
565
+ "fallback_top": [list(row) for row in fallback_top] if fallback_top else [],
566
+ "top_by_style": [list(row) for row in top_by_style],
567
+ "style_perf": [list(row) for row in style_perf],
568
+ }
569
+ print(json.dumps(output, indent=2, default=str))
570
+ else:
571
+ # Load active-campaign suffix literals so format_report can strip them
572
+ # from every embedded `our_content` snippet. Without this, the LLM
573
+ # downstream (post_reddit, engage_reddit, twitter Phase 2b drafting,
574
+ # post_github) sees historical campaign-tagged comments in the
575
+ # few-shot context, copies the suffix into its draft, and the
576
+ # tool-layer injection appends a SECOND suffix, producing
577
+ # "written with s4lai written with s4lai" (Reddit 2026-05-18 incident).
578
+ # API path is preferred; legacy direct-DB path passes a conn instead.
579
+ suffix_list = _load_active_campaign_suffixes()
580
+ print(format_report(summary, top, bottom,
581
+ project=args.project, platform=args.platform,
582
+ top_by_group=top_by_group, fallback_top=fallback_top,
583
+ style_perf=style_perf, top_by_style=top_by_style,
584
+ suffix_strip_list=suffix_list))
585
+
586
+
587
+ if __name__ == "__main__":
588
+ main()