@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,1787 @@
1
+ #!/usr/bin/env python3
2
+ """Shared engagement style definitions for all platforms.
3
+
4
+ Centralizes style taxonomy, platform-specific guidance, content rules,
5
+ and prompt generation so every pipeline (post_reddit, engage_reddit,
6
+ run-twitter-cycle, run-linkedin, engage-twitter, engage-linkedin) references
7
+ a single source of truth.
8
+
9
+ Usage:
10
+ from engagement_styles import VALID_STYLES, REPLY_STYLES, get_styles_prompt, get_content_rules, get_anti_patterns
11
+
12
+ Style universe (post 2026-05-22 cleanup, second pass):
13
+ The hardcoded STYLES dict is the curated baseline kept in-process so
14
+ the picker still works on a cold-start machine with no DB access. The
15
+ live "universe" is the union of STYLES + every row in the Postgres
16
+ table `engagement_styles_registry` (read via the s4l.ai API).
17
+
18
+ The registry table now carries THREE flavors discriminated by a `kind`
19
+ column:
20
+ - 'seed' : curated, ships with the repo
21
+ - 'model_invented' : created by register_style() when the orchestrator
22
+ proposes a new style inline via `new_style` JSON
23
+ - 'human_derived' : created once a day per platform by
24
+ scripts/generate_daily_human_style.py, distilled
25
+ from the top human replies in thread_top_replies
26
+
27
+ The picker bypasses score-based selection with HUMAN_DERIVED_RATE
28
+ probability per platform and asks the registry route for the latest
29
+ active human-derived row on that platform.
30
+
31
+ All reads/writes go through the s4l.ai /api/v1/engagement-styles/registry
32
+ route. We never touch the DB directly from this module.
33
+ """
34
+
35
+ import json
36
+ import os
37
+ import random
38
+ import sys as _sys_mod
39
+ from datetime import datetime, timezone
40
+
41
+ # ── Style taxonomy ──────────────────────────────────────────────────
42
+
43
+ STYLES = {
44
+ "critic": {
45
+ "description": "Point out what's missing, flawed, or naive. Reframe the problem.",
46
+ "example": "the missing piece is eval. without a way to catch regressions, every 'improvement' is just vibes",
47
+ "best_in": {
48
+ "reddit": ["r/Entrepreneur", "r/smallbusiness", "r/startups"],
49
+ "twitter": ["tech", "startup", "business"],
50
+ "linkedin": ["strategy", "leadership", "operations"],
51
+ },
52
+ "note": "NEVER just nitpick; offer a non-obvious insight.",
53
+ "target_chars": 96,
54
+ },
55
+ "storyteller": {
56
+ "description": (
57
+ "Narrative-driven comment. Per the GROUNDING RULE, every "
58
+ "storyteller comment picks ONE of two mutually exclusive lanes: "
59
+ "Lane 1 (DISCLOSED STORY) opens with a hedge like "
60
+ "'hypothetically', 'imagine someone running this', 'scenario:', "
61
+ "'say a friend tried' and is then free to invent any specifics; "
62
+ "Lane 2 (NO FABRICATION) keeps the narrative plain-voiced but "
63
+ "every specific (numbers, durations, places, course names, "
64
+ "brands, headcount) must appear verbatim in the matched "
65
+ "project's content_angle / voice / messaging in config.json, "
66
+ "otherwise drop the specifics or pattern-frame "
67
+ "('the part that breaks down is...', 'the typical failure mode "
68
+ "is...'). Lead with failure or surprise, not success. Whose "
69
+ "voice tells the story (maker vs outside observer) is set by "
70
+ "the VOICE RELATIONSHIP rule, not by this style."
71
+ ),
72
+ "example": (
73
+ "hypothetically, ran this for a few lecture blocks: recorder "
74
+ "into whisper into gpt into anki. raw prompts got a third "
75
+ "usable cards before duplicates took over. card gen, not "
76
+ "the pipeline, was the bottleneck."
77
+ ),
78
+ "best_in": {
79
+ "reddit": ["r/startups", "r/Meditation", "r/vipassana"],
80
+ "twitter": ["personal growth", "founder stories"],
81
+ "linkedin": ["career", "leadership", "lessons learned"],
82
+ },
83
+ "note": (
84
+ "NEVER pivot to a product pitch. NEVER mix lanes: presenting an "
85
+ "invented specific as a lived fact ('ran this exact pipeline "
86
+ "last semester for two anatomy blocks', 'ran 22 cameras across "
87
+ "three properties for 8 months', 'sat 6 courses across three "
88
+ "centers') without a Lane 1 opener and without config.json "
89
+ "grounding is the exact failure mode the GROUNDING RULE forbids."
90
+ ),
91
+ "target_chars": 206,
92
+ },
93
+ "pattern_recognizer": {
94
+ "description": "Name the pattern or phenomenon. Authority through pattern recognition, not credentials.",
95
+ "example": "This is called X / I've seen this play out dozens of times across Y.",
96
+ "best_in": {
97
+ "reddit": ["r/ExperiencedDevs", "r/programming", "r/webdev"],
98
+ "twitter": ["dev", "engineering", "tech trends"],
99
+ "linkedin": ["industry analysis", "tech leadership"],
100
+ },
101
+ "note": "Authority through pattern recognition, not credentials.",
102
+ "target_chars": 68,
103
+ },
104
+ "curious_probe": {
105
+ "description": "One specific follow-up question about the most interesting detail. Include 'curious because...' context.",
106
+ "example": "how are you handling two agents writing at once? curious because we hit silent overwrites and only a lock fixed it",
107
+ "best_in": {
108
+ "reddit": ["r/startups", "r/SaaS", "niche subs"],
109
+ "twitter": ["niche topics", "founder discussions"],
110
+ "linkedin": ["thought leadership", "niche B2B"],
111
+ },
112
+ "note": "ONE question only. Never multiple.",
113
+ "target_chars": 114,
114
+ },
115
+ "contrarian": {
116
+ "description": "Take a clear opposing position backed by experience.",
117
+ "example": "Everyone recommends X. I've done X for Y years and it's wrong.",
118
+ "best_in": {
119
+ "reddit": ["r/Entrepreneur", "r/ExperiencedDevs"],
120
+ "twitter": ["hot takes", "industry debates"],
121
+ "linkedin": ["industry debates", "contrarian leadership"],
122
+ },
123
+ "note": "Must have credible evidence. Empty hot takes get destroyed.",
124
+ "target_chars": 62,
125
+ },
126
+ "data_point_drop": {
127
+ "description": "Share one specific, believable metric. Let the number do the talking.",
128
+ "example": "$12k in a month (not 'a lot of money')",
129
+ "best_in": {
130
+ "reddit": ["r/Entrepreneur", "r/startups", "r/SaaS"],
131
+ "twitter": ["growth", "revenue", "metrics"],
132
+ "linkedin": ["results", "case studies"],
133
+ },
134
+ "note": "No links. Numbers must be believable, not impressive.",
135
+ "target_chars": 38,
136
+ },
137
+ "snarky_oneliner": {
138
+ "description": "Short, sharp, emotionally resonant observation (1 sentence max). Validates a shared frustration.",
139
+ "example": "the demo always works. that's the whole problem.",
140
+ "best_in": {
141
+ "reddit": ["large subs (500k+ members)"],
142
+ "twitter": ["viral threads", "tech complaints", "industry snark"],
143
+ "linkedin": [], # never on LinkedIn
144
+ },
145
+ "note": "NEVER in small/serious subs like r/vipassana. NEVER on LinkedIn.",
146
+ "target_chars": 48,
147
+ },
148
+ # ── Instagram-native caption styles (2026-05-21) ──
149
+ # Distinct from the reply/comment styles above: these describe the
150
+ # structural ARCHETYPE of a long-form IG caption (1400-2150 chars) +
151
+ # the matching 4-5 card overlay. Manually classified from the first
152
+ # 50 posted reels; the defeat-flip arc owns the viral lane (4 of top 5
153
+ # all-time hits, 1.14M peak). Walkin/studyly are product-gated.
154
+ "ig_defeat_flip_arc": {
155
+ "description": (
156
+ "8-beat first-person caption: 'i was [role] for N years. i posted a "
157
+ "confident take. last [time], [agent/junior] did [my job] in [short "
158
+ "time]. i sat at the kitchen counter at midnight with a coffee that "
159
+ "had gone cold. i changed what i sell. the lesson is [skill] was "
160
+ "never the job. [skill] was the typing, typing is free now. stop "
161
+ "[old behavior]. start [new behavior].' Self-deprecating founder "
162
+ "voice; specific numbers (ages, dollar amounts, dates, view counts); "
163
+ "lowercase throughout. Top performer for organic IG posts."
164
+ ),
165
+ "example": (
166
+ "i was 33. nine years writing typescript. fast hands, faster "
167
+ "opinions. last tuesday a 26-year-old shipped my roadmap in 3 days "
168
+ "with claude code. i sat in my kitchen at 1am with a coffee that "
169
+ "had gone cold. ... the lesson is the typing was the job. typing is "
170
+ "free now. stop defending your seat. start running the review."
171
+ ),
172
+ "best_in": {
173
+ "instagram": ["matt_diak", "matthewheartful", "organic AI-lesson reels"],
174
+ },
175
+ "note": (
176
+ "Caption MUST be 1400-2150 chars; overlay is 4 hook-arc cards (2s "
177
+ "each, white bg, black text). Open 'here is a story.'; close with "
178
+ "lesson + 'stop X, start Y' imperative. NO product mention (no "
179
+ "Fazm/Mediar/AppMaker/mk0r/studyly): organic only."
180
+ ),
181
+ "target_chars": 1800,
182
+ },
183
+ "ig_walkin_storefront_playbook": {
184
+ "description": (
185
+ "Product demo for mk0r: show mk0r building a REAL website for a "
186
+ "local business that has none. 'i noticed [a local spot] near me "
187
+ "with no website, just a maps pin and one blurry photo. i opened "
188
+ "mk0r.com, described the place in one prompt, and watched it build "
189
+ "a real site, hero, services, hours, a call button, live in "
190
+ "minutes.' Focus on the CAPABILITY and the speed, and what it "
191
+ "means for a small business to finally have a real site online. NO "
192
+ "earnings, NO 'they paid me', NO recurring-revenue or 'signed N "
193
+ "clients' totals. Reference mk0r.com plainly."
194
+ ),
195
+ "example": (
196
+ "there's a tire shop two blocks from me thats been open fifteen "
197
+ "years. i went to send a friend the link and there was no link, "
198
+ "just a maps pin and one blurry photo someone else uploaded. so i "
199
+ "opened mk0r.com and described the shop in one prompt, brakes, "
200
+ "tires, the hours, the phone number. it built the whole site while "
201
+ "i watched, a hero, a services list, a map, one big call button. "
202
+ "fifteen years open and it finally has a front door online. mk0r.com"
203
+ ),
204
+ "best_in": {
205
+ "instagram": [
206
+ "matt_diak / mk0r product reels",
207
+ "spa", "auto shops", "hotel", "retail", "motel",
208
+ ],
209
+ },
210
+ "note": (
211
+ "Caption is the product-demo arc: a real local business with no "
212
+ "website, mk0r builds a real site from one prompt, what it means "
213
+ "for the owner to finally be online. NO income/earnings/'they paid "
214
+ "me'/recurring-revenue/'signed N clients' framing -- that arc "
215
+ "tripped a Meta fraud-and-deceptive-practices restriction on "
216
+ "2026-06-02 (matt_diak link-sharing restricted 30d). Reference "
217
+ "'mk0r.com' plainly in the caption. project_name='mk0r' on the "
218
+ "row. Fires when TARGET=product AND selected_project=mk0r."
219
+ ),
220
+ "target_chars": 1800,
221
+ },
222
+ "ig_studyly_failing_student_arc": {
223
+ "description": (
224
+ "Study-method arc for studyly: 'i was [age], [program]. i was "
225
+ "[rereading/highlighting/making flashcards] for hours and still "
226
+ "blanking the moment the wording changed. a friend sent me "
227
+ "studyly.io. i pasted my [notes/chapter] in and it quizzed me on "
228
+ "my own material until i could answer without looking, rewording "
229
+ "each question so i couldnt pattern-match. for the first time i "
230
+ "could tell what i actually knew from what i only recognized.' "
231
+ "Focus on the METHOD shift (active recall vs rereading, testing on "
232
+ "your own deck). Closes with the rereading-is-theater lesson + "
233
+ "studyly.io footer. Do NOT promise or fabricate specific "
234
+ "before/after exam scores as a typical result."
235
+ ),
236
+ "example": (
237
+ "i was 19. premed, third semester, organic chemistry. i had reread "
238
+ "the chapter four times and could still blank on a mechanism the "
239
+ "second the wording changed. i pasted my notes into studyly.io at "
240
+ "2am and it asked me the things i thought i knew, rewording each "
241
+ "one so i couldnt coast on the first three words. that was the "
242
+ "first night i could actually tell what i knew from what i only "
243
+ "recognized. the lesson is rereading is recognizing. close the "
244
+ "book. let something ask you. studyly.io"
245
+ ),
246
+ "best_in": {
247
+ "instagram": [
248
+ "matt_diak / matthewheartful studyly product reels",
249
+ "premed", "MCAT", "nursing pharm",
250
+ ],
251
+ },
252
+ "note": (
253
+ "Caption is shorter than mk0r (1400-1900 chars). 'here is a story.' "
254
+ "opener optional. MUST include 'studyly.io' footer. "
255
+ "project_name='studyly'. Lesson is always rereading-is-theater. NO "
256
+ "specific before/after exam scores or 'failed -> passed/topped the "
257
+ "class' miracle jumps presented as a typical/guaranteed outcome -- "
258
+ "exaggerated-results claims are a deceptive-practices signal (same "
259
+ "Meta rail that restricted mk0r 2026-06-02); keep outcomes "
260
+ "qualitative and personal. Fires when TARGET=product AND "
261
+ "selected_project=studyly."
262
+ ),
263
+ "target_chars": 1650,
264
+ },
265
+ }
266
+
267
+ # Valid tone styles. Same set for posting and replying: tone is a separate
268
+ # dimension from project-recommendation intent, which is now tracked on its
269
+ # own boolean column (posts.is_recommendation / replies.is_recommendation).
270
+ # REPLY_STYLES is kept as an alias for backwards compatibility with callers
271
+ # that historically treated it as a superset.
272
+ VALID_STYLES = set(STYLES.keys())
273
+ REPLY_STYLES = VALID_STYLES
274
+
275
+ # ── Registry-backed style universe (DB, not JSON) ──────────────────
276
+ #
277
+ # Cleanup 2026-05-22: every model-invented style lands in the Postgres
278
+ # table `engagement_styles_registry` via POST
279
+ # /api/v1/engagement-styles/registry. The legacy file-based sidecar
280
+ # (scripts/engagement_styles_extra.json) and the two-tier
281
+ # candidate→active promoter are GONE. Every install sees every other
282
+ # install's registered styles, and a new invention is live for the next
283
+ # picker tick on every install (no JSON file to ship).
284
+ #
285
+ # DB registry row shape (engagement_styles_registry):
286
+ # {
287
+ # "name": str (PK), "description": str, "example": str, "note": str,
288
+ # "best_in": dict, # {platform: hint|bool|[..]}
289
+ # "status": "active" | "retired", # 'active' on every new row
290
+ # "why_existing_didnt_fit": str | None,
291
+ # "first_post_url": str | None,
292
+ # "first_post_id": int | None,
293
+ # "first_post_platform": str | None,
294
+ # "invented_by_model": str | None,
295
+ # "invented_at": ISO-8601 UTC,
296
+ # "promoted_at": ISO-8601 UTC, # set = invented_at on new rows
297
+ # "created_at" / "updated_at": ISO-8601 UTC,
298
+ # }
299
+ #
300
+ # The seeds in STYLES{} are kept in-process as a cold-start fallback so
301
+ # the picker works on a machine with no DB access; they're also seeded
302
+ # into the table via scripts/migrate_engagement_styles_to_db.py.
303
+
304
+ _REQUIRED_NEW_STYLE_FIELDS = ("description", "example", "why_existing_didnt_fit")
305
+
306
+ # In-process cache for registry reads. ~5 min keeps the picker from
307
+ # hammering the API on every pick (a Twitter cycle picks ~20 times per
308
+ # 15-minute window) while still surfacing a newly-invented style from
309
+ # another install within one window.
310
+ _REGISTRY_CACHE = {"ts": 0.0, "rows": None}
311
+ _REGISTRY_CACHE_TTL_SEC = 300
312
+
313
+
314
+ def _normalize_entry(entry, default_status="active", default_kind="seed"):
315
+ """Ensure a STYLES-style dict has the fields callers expect."""
316
+ out = dict(entry) if isinstance(entry, dict) else {}
317
+ out.setdefault("status", default_status)
318
+ out.setdefault("description", "")
319
+ out.setdefault("example", "")
320
+ out.setdefault("note", "")
321
+ out.setdefault("best_in", {})
322
+ # Authoritative per-style target comment length. Falls back to the
323
+ # short-biased default for legacy rows / cold-start entries that predate
324
+ # the target_chars column.
325
+ try:
326
+ out["target_chars"] = int(out.get("target_chars") or DEFAULT_TARGET_CHARS)
327
+ except (TypeError, ValueError):
328
+ out["target_chars"] = DEFAULT_TARGET_CHARS
329
+ # kind discriminates origin: 'seed' (hardcoded/top-performer), 'model_invented'
330
+ # (Claude proposed during a posting run), 'human_derived' (synthesized from
331
+ # the daily human-reply digest). Surfaced in the dashboard as a bracket
332
+ # next to the style name.
333
+ out.setdefault("kind", default_kind)
334
+ return out
335
+
336
+
337
+ def _fetch_registry_styles(force_refresh=False):
338
+ """Pull every active row in engagement_styles_registry via the API.
339
+
340
+ Returns {name: {description, example, note, best_in, status, ...}}.
341
+ Cached for _REGISTRY_CACHE_TTL_SEC; pass force_refresh=True to bust.
342
+
343
+ Best-effort: returns {} on any error (API unreachable, missing env,
344
+ cold start) so callers can fall back to the in-process STYLES dict.
345
+ """
346
+ import time as _time
347
+ now = _time.time()
348
+ if (
349
+ not force_refresh
350
+ and _REGISTRY_CACHE["rows"] is not None
351
+ and (now - _REGISTRY_CACHE["ts"]) < _REGISTRY_CACHE_TTL_SEC
352
+ ):
353
+ return _REGISTRY_CACHE["rows"]
354
+
355
+ try:
356
+ import sys as _sys
357
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
358
+ from http_api import api_get
359
+ resp = api_get("/api/v1/engagement-styles/registry", {"status": "active"})
360
+ data = (resp or {}).get("data") or {}
361
+ rows = data.get("styles") or []
362
+ except Exception:
363
+ # Don't poison the cache with an empty on transient failure: if we
364
+ # had data before, keep serving it.
365
+ if _REGISTRY_CACHE["rows"] is not None:
366
+ return _REGISTRY_CACHE["rows"]
367
+ return {}
368
+
369
+ out = {}
370
+ for r in rows:
371
+ name = r.get("name")
372
+ if not name:
373
+ continue
374
+ best_in = r.get("best_in") or {}
375
+ if isinstance(best_in, str):
376
+ try:
377
+ best_in = json.loads(best_in)
378
+ except Exception:
379
+ best_in = {}
380
+ # Default kind for legacy rows pre-consolidation is 'seed'; the
381
+ # 2026-05-22 migration backfilled invented_by_model<>null rows to
382
+ # 'model_invented' and the human_derived migration inserted those
383
+ # explicitly. Trust the column.
384
+ out[name] = {
385
+ "description": r.get("description") or "",
386
+ "example": r.get("example") or "",
387
+ "note": r.get("note") or "",
388
+ "best_in": best_in,
389
+ "status": r.get("status") or "active",
390
+ "kind": r.get("kind") or "seed",
391
+ "invented_by_model": r.get("invented_by_model"),
392
+ "invented_at": r.get("invented_at"),
393
+ "promoted_at": r.get("promoted_at"),
394
+ "first_post_url": r.get("first_post_url"),
395
+ "first_post_platform": r.get("first_post_platform"),
396
+ "why_existing_didnt_fit": r.get("why_existing_didnt_fit") or "",
397
+ "target_chars": r.get("target_chars") or DEFAULT_TARGET_CHARS,
398
+ }
399
+ _REGISTRY_CACHE["rows"] = out
400
+ _REGISTRY_CACHE["ts"] = now
401
+ return out
402
+
403
+
404
+ def get_all_styles():
405
+ """Merged universe: hardcoded STYLES + registry rows + human-derived rows.
406
+
407
+ Reads pull from the live Postgres registry (cached briefly), so a
408
+ style invented by any install is visible to every other install on
409
+ the next picker tick. STYLES{} is the cold-start fallback when the
410
+ API is unreachable.
411
+
412
+ Merge order (later wins on duplicate name):
413
+ 1. Hardcoded STYLES (cold-start floor)
414
+ 2. engagement_styles_registry rows (the live source of truth,
415
+ includes kind in {'seed','model_invented','human_derived'})
416
+ 3. Same registry filtered to kind='human_derived' (only for names
417
+ not already in 1/2; pure defense-in-depth — under normal
418
+ operation step 2 already returned every row, so step 3 is a
419
+ no-op. Kept for the case where _fetch_registry_styles failed
420
+ but _load_human_derived_styles succeeded.)
421
+
422
+ Caller MUST treat the returned dict as read-only.
423
+ """
424
+ merged = {
425
+ name: _normalize_entry(meta, "active", "seed")
426
+ for name, meta in STYLES.items()
427
+ }
428
+ for name, meta in _fetch_registry_styles().items():
429
+ if not isinstance(meta, dict):
430
+ continue
431
+ # Trust the kind column the registry already set; fall back to 'seed'.
432
+ merged[name] = _normalize_entry(meta, "active", meta.get("kind") or "seed")
433
+ for name, meta in _load_human_derived_styles().items():
434
+ if name in merged:
435
+ # Don't clobber a curated/registry entry if the synthesizer
436
+ # happens to pick a colliding snake_case name.
437
+ continue
438
+ merged[name] = _normalize_entry(meta, "active", "human_derived")
439
+ return merged
440
+
441
+
442
+ def _load_human_derived_styles():
443
+ """Map of {name: {description, example, note, best_in}} for every
444
+ active human_derived row in engagement_styles_registry.
445
+
446
+ Reads via the /api/v1/engagement-styles/registry route filtered by
447
+ kind=human_derived. Best-effort; returns {} on any failure so callers
448
+ don't have to wrap in try/except. Defense-in-depth alongside
449
+ _fetch_registry_styles(): if the synthesizer ever names a row the same
450
+ as an existing seed, get_all_styles() will already have the seed and
451
+ skip the human_derived entry.
452
+ """
453
+ try:
454
+ import sys as _sys
455
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
456
+ from http_api import api_get
457
+ resp = api_get(
458
+ "/api/v1/engagement-styles/registry",
459
+ {"status": "active", "kind": "human_derived"},
460
+ )
461
+ data = (resp or {}).get("data") or {}
462
+ rows = data.get("styles") or []
463
+ except Exception:
464
+ return {}
465
+ out = {}
466
+ for r in rows:
467
+ name = r.get("name")
468
+ if not name:
469
+ continue
470
+ best_in = r.get("best_in") or {}
471
+ if isinstance(best_in, str):
472
+ try:
473
+ best_in = json.loads(best_in)
474
+ except Exception:
475
+ best_in = {}
476
+ out[name] = {
477
+ "description": r.get("description") or "",
478
+ "example": r.get("example") or "",
479
+ "note": r.get("note") or "",
480
+ "best_in": best_in,
481
+ "target_chars": r.get("target_chars") or DEFAULT_TARGET_CHARS,
482
+ }
483
+ return out
484
+
485
+
486
+ def register_style(name, meta, source_post=None):
487
+ """Register a model-invented style into engagement_styles_registry.
488
+
489
+ Called when an orchestrator parses a decision JSON whose
490
+ engagement_style is not in get_all_styles() and whose `new_style`
491
+ block is well-formed.
492
+
493
+ POSTs to /api/v1/engagement-styles/registry; the server upserts the
494
+ row (ON CONFLICT DO NOTHING on name). Concurrency is handled at the
495
+ Postgres layer (PK uniqueness), so we don't need a file lock anymore.
496
+
497
+ Args:
498
+ name: the style name the model picked.
499
+ meta: dict with at least description/example/why_existing_didnt_fit
500
+ (and optionally note). Anything else is preserved verbatim.
501
+ source_post: optional dict {platform, post_url, post_id, model}
502
+ describing the post that birthed this style. Recorded only
503
+ the FIRST time a name is registered (server-side ON CONFLICT
504
+ keeps the original values).
505
+
506
+ Returns:
507
+ (status_str, entry_dict): status in {"new", "existing", "rejected"}.
508
+ On "rejected", entry_dict carries an "error" key describing why.
509
+ """
510
+ if not name or not isinstance(name, str):
511
+ return "rejected", {"error": "name must be a non-empty string"}
512
+ if not isinstance(meta, dict):
513
+ return "rejected", {"error": "new_style block must be an object"}
514
+ missing = [f for f in _REQUIRED_NEW_STYLE_FIELDS
515
+ if not (isinstance(meta.get(f), str) and meta[f].strip())]
516
+ if missing:
517
+ return "rejected", {"error": f"new_style missing fields: {missing}"}
518
+ if name in STYLES:
519
+ # The model picked a hardcoded name and *also* shipped a new_style
520
+ # block. Treat as "existing"; never overwrite the curated entry.
521
+ return "existing", _normalize_entry(STYLES[name], "active")
522
+
523
+ # Cheap local short-circuit: if our cached registry already has this
524
+ # name, skip the network call and return existing immediately. The
525
+ # cache is shared across calls within the same process so this saves
526
+ # one HTTP round-trip per duplicate invention attempt.
527
+ cached = _fetch_registry_styles()
528
+ if name in cached:
529
+ return "existing", cached[name]
530
+
531
+ src = source_post or {}
532
+ # Coerce the model-declared target length. The invent prompt requires a
533
+ # target_chars in the new_style block, but we default gracefully rather
534
+ # than reject an otherwise-valid invention just because the length is
535
+ # missing or garbage. Clamp to a sane 20..2200 band.
536
+ try:
537
+ _tc = int(meta.get("target_chars"))
538
+ target_chars = max(20, min(2200, _tc))
539
+ except (TypeError, ValueError):
540
+ target_chars = DEFAULT_TARGET_CHARS
541
+ # 2026-05-25: explicitly stamp kind='model_invented' on the payload.
542
+ # The server route (social-autoposter-website/src/app/api/v1/engagement-styles/
543
+ # registry/route.ts:220-234) defaults kind to 'seed' when invented_by_model
544
+ # is empty, and to 'model_invented' otherwise. Most callers above don't
545
+ # populate source_post["model"] (it's optional), so the server fallback
546
+ # silently buries every invention under kind='seed' — making the
547
+ # model_invented bucket forever empty. Sending kind explicitly bypasses
548
+ # the heuristic entirely; register_style() is ONLY called from the
549
+ # invent path (see validate_or_register at line ~542), so the label is
550
+ # always correct here.
551
+ payload = {
552
+ "name": name,
553
+ "kind": "model_invented",
554
+ "description": meta["description"].strip(),
555
+ "example": meta["example"].strip(),
556
+ "note": (meta.get("note") or "").strip(),
557
+ "why_existing_didnt_fit": meta["why_existing_didnt_fit"].strip(),
558
+ "first_post_url": src.get("post_url"),
559
+ "first_post_id": src.get("post_id"),
560
+ "first_post_platform": src.get("platform"),
561
+ "invented_by_model": src.get("model"),
562
+ "best_in": {},
563
+ "target_chars": target_chars,
564
+ }
565
+ try:
566
+ import sys as _sys
567
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
568
+ from http_api import api_post
569
+ resp = api_post("/api/v1/engagement-styles/registry", payload)
570
+ except SystemExit as e:
571
+ return "rejected", {"error": f"registry POST failed: {e}"}
572
+ except Exception as e:
573
+ return "rejected", {"error": f"registry POST raised: {e}"}
574
+
575
+ data = (resp or {}).get("data") or {}
576
+ style_row = data.get("style") or {}
577
+ created = bool(data.get("created"))
578
+
579
+ # Bust the local cache so the very next get_all_styles() includes this
580
+ # row (otherwise the picker's coerce-or-validate pass would still
581
+ # reject it for the next ~5 minutes).
582
+ _REGISTRY_CACHE["ts"] = 0.0
583
+ _REGISTRY_CACHE["rows"] = None
584
+
585
+ entry = _normalize_entry(
586
+ {
587
+ "description": style_row.get("description") or payload["description"],
588
+ "example": style_row.get("example") or payload["example"],
589
+ "note": style_row.get("note") or payload["note"],
590
+ "best_in": style_row.get("best_in") or {},
591
+ "status": style_row.get("status") or "active",
592
+ "target_chars": style_row.get("target_chars") or payload["target_chars"],
593
+ },
594
+ "active",
595
+ )
596
+ return ("new" if created else "existing"), entry
597
+
598
+
599
+ def validate_or_register(decision, source_post=None, context="posting",
600
+ assigned_style=None, assigned_mode=None):
601
+ """One-shot helper for orchestrators that parse a decision JSON.
602
+
603
+ Reads decision["engagement_style"] (and optional decision["new_style"]).
604
+ Returns (style_or_None, action) where action is one of:
605
+ "valid" → style is in the universe, accept it
606
+ "coerced" → USE-mode picker assigned a specific style and the
607
+ model drifted to something else; we silently coerce
608
+ back to the assigned style (drift-protection)
609
+ "registered" → INVENT-mode + well-formed new_style → registered
610
+ in the DB registry, accept it
611
+ "rejected" → unknown style and no usable new_style, OR drift in
612
+ a context where the assigned style is known but the
613
+ model neither used it nor shipped a valid new_style
614
+ "passthrough"→ no assignment context (legacy caller); same as
615
+ valid/registered/rejected branches but logged
616
+ distinctly
617
+
618
+ `assigned_style` / `assigned_mode` (added 2026-05-22): when the caller
619
+ used pick_style_for_post() it now passes the assignment back in. We
620
+ use it to (a) coerce drift in USE mode back to the assigned name
621
+ (eliminating the "model picks pattern_recognizer because it's
622
+ generic" bias), and (b) only allow invention when the picker
623
+ actually asked for it (INVENT mode). This closes the enforcement gap
624
+ where any rail could silently invent a style outside the assigned
625
+ path.
626
+
627
+ Logs the action to stdout for the orchestrator's run log.
628
+ """
629
+ style = decision.get("engagement_style") if isinstance(decision, dict) else None
630
+ new_style = decision.get("new_style") if isinstance(decision, dict) else None
631
+
632
+ # USE-mode drift protection: picker assigned a style, model picked
633
+ # something else. Don't trust the model's "improvement" — coerce
634
+ # back. Inventions in USE mode are not allowed; the assigned style
635
+ # already exists, so any new_style block here is the model
636
+ # over-reaching.
637
+ if assigned_mode == "use" and assigned_style:
638
+ if style == assigned_style:
639
+ return style, "valid"
640
+ universe = get_all_styles()
641
+ if style and style in universe and style != assigned_style:
642
+ print(
643
+ f"[engagement_styles] DRIFT in USE mode: model returned "
644
+ f"{style!r} but picker assigned {assigned_style!r}; "
645
+ f"coercing back."
646
+ )
647
+ return assigned_style, "coerced"
648
+ # Unknown style or no style — also coerce back to assigned. The
649
+ # assigned style is guaranteed to be in the universe (picker
650
+ # built it from get_all_styles()).
651
+ print(
652
+ f"[engagement_styles] DRIFT in USE mode: model returned "
653
+ f"{style!r} (not in universe); coercing to assigned "
654
+ f"{assigned_style!r}."
655
+ )
656
+ return assigned_style, "coerced"
657
+
658
+ # INVENT-mode: picker explicitly asked for a new style. Require a
659
+ # well-formed new_style block; if model returned an existing style
660
+ # name, accept it as if INVENT had landed on something already in
661
+ # the registry (rare but harmless).
662
+ if assigned_mode == "invent":
663
+ if style and style in get_all_styles() and not isinstance(new_style, dict):
664
+ return style, "valid"
665
+ if not style or not isinstance(new_style, dict):
666
+ print(
667
+ f"[engagement_styles] INVENT mode but model returned "
668
+ f"style={style!r} new_style_block={bool(new_style)}; "
669
+ f"rejecting."
670
+ )
671
+ return None, "rejected"
672
+ status, entry = register_style(style, new_style, source_post)
673
+ if status == "rejected":
674
+ print(
675
+ f"[engagement_styles] new_style for {style!r} rejected: "
676
+ f"{entry.get('error')}"
677
+ )
678
+ return None, "rejected"
679
+ if status == "new":
680
+ src_url = (source_post or {}).get("post_url", "?")
681
+ print(
682
+ f"[engagement_styles] REGISTERED new style {style!r} "
683
+ f"from {src_url}"
684
+ )
685
+ return style, "registered"
686
+
687
+ # Legacy callers (no assignment context): behave as before, with the
688
+ # caveat that any silent invention will create an `active` registry
689
+ # row instead of a `candidate` sidecar entry.
690
+ if style and style in get_all_styles():
691
+ return style, "valid"
692
+
693
+ if not style:
694
+ return None, "rejected"
695
+
696
+ if not isinstance(new_style, dict):
697
+ print(f"[engagement_styles] unknown style {style!r} and no new_style block; rejecting")
698
+ return None, "rejected"
699
+
700
+ status, entry = register_style(style, new_style, source_post)
701
+ if status == "rejected":
702
+ print(f"[engagement_styles] new_style for {style!r} rejected: {entry.get('error')}")
703
+ return None, "rejected"
704
+ if status == "new":
705
+ src_url = (source_post or {}).get("post_url", "?")
706
+ print(f"[engagement_styles] REGISTERED new style {style!r} from {src_url}")
707
+ return style, "registered"
708
+
709
+
710
+ # ── Platform-specific policy overlay ────────────────────────────────
711
+ #
712
+ # Tier assignment (dominant / secondary / rare) is DB-driven — see
713
+ # get_dynamic_tiers() below. This dict only stores static policy that
714
+ # is not a performance judgment:
715
+ # - `never`: tone/brand constraints (e.g. no snark on LinkedIn). Even
716
+ # if the data showed high upvotes, we still do not want this style.
717
+ # - `note`: per-platform tone/length hint shown at the top of the
718
+ # styles prompt.
719
+
720
+ PLATFORM_POLICY = {
721
+ "reddit": {
722
+ "never": ["curious_probe"],
723
+ "note": "Short wins. 1 punchy sentence or 4-5 of real substance. Start with 'I' or 'my'. Match style to subreddit culture.",
724
+ },
725
+ "twitter": {
726
+ "never": [],
727
+ "note": "Brevity wins. Direct product mentions OK (unlike Reddit). 1-2 sentences max.",
728
+ },
729
+ "linkedin": {
730
+ "never": ["snarky_oneliner"],
731
+ "note": "Professional but human. Softer critic framing. No snark. 2-4 sentences.",
732
+ },
733
+ "github": {
734
+ "never": ["snarky_oneliner"],
735
+ "note": "Technical and specific. Lead with the pain, then the fix. 400-600 chars.",
736
+ },
737
+ "moltbook": {
738
+ "never": [],
739
+ "note": "Agent voice ('my human'). Conversational but substantive. 2-4 sentences.",
740
+ },
741
+ "instagram": {
742
+ # Reply/comment styles don't apply to long-form IG captions.
743
+ # Product styles are project-gated and assigned by the render
744
+ # script directly (see skill/run-instagram-render.sh) so the
745
+ # picker can't accidentally roll a "walkin" style for an organic
746
+ # matt_diak post.
747
+ "never": [
748
+ "critic", "storyteller", "pattern_recognizer", "curious_probe",
749
+ "contrarian", "data_point_drop", "snarky_oneliner",
750
+ "ig_walkin_storefront_playbook",
751
+ "ig_studyly_failing_student_arc",
752
+ ],
753
+ "note": (
754
+ "IG captions are long-form ORIGINAL posts (1400-2150 chars), "
755
+ "lowercase, 8-beat story arc. Overlay is 4-5 short cards "
756
+ "(2s each, white bg, black text). Voice is self-deprecating "
757
+ "founder confession. NO em/en dashes. The picker only fires "
758
+ "on TARGET=organic; product posts assign style directly from "
759
+ "selected_project."
760
+ ),
761
+ },
762
+ }
763
+
764
+ # Minimum sample size to count a style as trusted. n=1 means "at least one
765
+ # real post in the 30-day window". 2026-05-25: lowered from 5 → 1 so an
766
+ # invented style that produced even a single post (small batch, partial-batch
767
+ # failure, etc.) competes on per-post score from day one. The 30-day recency
768
+ # window is the only freshness gate; ghost styles (n=0 registry rows) stay
769
+ # excluded from the use_pool by this floor.
770
+ MIN_SAMPLE_SIZE = 1
771
+
772
+ # Legacy target-distribution knobs. UNUSED since 2026-05-29: the picker
773
+ # (pick_style_for_post) samples on raw _picker_score with no exponent / floor /
774
+ # cap, and compute_target_distribution now reports that same true pick
775
+ # probability instead of a sharpened-floored-capped target. These only ever
776
+ # shaped the DISPLAY, which diverged hard from what actually got picked. Kept
777
+ # defined (not deleted) so any external import doesn't break.
778
+ WEIGHT_EXPONENT = 2.0 # (legacy, unused)
779
+ STYLE_FLOOR_PCT = 5.0 # (legacy, unused)
780
+ STYLE_CAP_PCT = 50.0 # (legacy, unused)
781
+
782
+ # Picker weight floor: mirrors max(_picker_score(r), 0.01) in
783
+ # pick_style_for_post so a score-0 trusted style still draws a tiny nonzero
784
+ # pick chance instead of being frozen out entirely.
785
+ PICK_FLOOR = 0.01
786
+
787
+
788
+ # Recency window for the picker target distribution. Lifetime aggregation
789
+ # drifted too far from current performance reality (e.g. 2025 wins from
790
+ # pattern_recognizer kept it in the pool even after 2026's audience shift).
791
+ # 2026-05-29: tightened 30 -> 7 days across all platforms (per user). The 30d
792
+ # window lagged badly: pattern_recognizer showed 963 posts / 24% volume at 30d
793
+ # but only 6 posts at 7d because the picker had already abandoned it; the 30d
794
+ # snapshot kept dragging dead historical volume. 7 days tracks the live picker
795
+ # much more tightly. Tradeoff: tail styles drop below the n>=50 density the 30d
796
+ # window held, so they fall back on the explore floor sooner, and low-volume /
797
+ # scrape-lagged platforms (LinkedIn) may cold-start to an equal split until 7d
798
+ # of data accumulates. Set RECENCY_DAYS=0 to fall back to lifetime.
799
+ RECENCY_DAYS = 7
800
+
801
+
802
+ def _fetch_style_stats(platform, days=None):
803
+ """Query the autoposter API for per-engagement_style performance.
804
+
805
+ Returns a dict:
806
+ {style_name: {"n": int, "avg_up": float, "avg_cm": float,
807
+ "avg_clicks": float}}
808
+ avg_up is NET (Reddit/Moltbook self-upvote stripped); avg_clicks is the
809
+ bot-filtered click count. The three combine into the same composite the
810
+ top_performers report ranks on. Returns {} on any error (API unreachable,
811
+ missing env, cold start).
812
+
813
+ Recency: `days` overrides the module-level RECENCY_DAYS (default 7).
814
+ Pass days=0 for lifetime aggregation.
815
+
816
+ Routes through the social-autoposter-website API (no direct DB access)
817
+ so VMs / sandboxes without a DATABASE_URL still get live weights.
818
+ """
819
+ try:
820
+ import os
821
+ import sys as _sys
822
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
823
+ from http_api import api_get
824
+ eff_days = RECENCY_DAYS if days is None else int(days)
825
+ resp = api_get(
826
+ "/api/v1/engagement-styles/style-stats",
827
+ {"platform": platform, "days": str(eff_days)},
828
+ )
829
+ data = (resp or {}).get("data") or {}
830
+ stats = data.get("stats") or {}
831
+ return {
832
+ name: {
833
+ "n": int(v.get("n", 0)),
834
+ "avg_up": float(v.get("avg_up", 0.0)),
835
+ "avg_cm": float(v.get("avg_cm", 0.0)),
836
+ "avg_clicks": float(v.get("avg_clicks", 0.0)),
837
+ }
838
+ for name, v in stats.items()
839
+ if isinstance(v, dict)
840
+ }
841
+ except Exception:
842
+ return {}
843
+
844
+
845
+ # Composite style score, mirroring scripts/top_performers.py SCORE_SQL:
846
+ # a real human click outweighs 10 upvotes of vibes, comments sit in the
847
+ # middle. The picker weights styles LINEARLY by this score (no exponent,
848
+ # no shrinkage) so the style that actually drives clicks wins proportionally
849
+ # more picks, not the one that merely accumulates passive likes.
850
+ CLICK_WEIGHT = 10.0
851
+ COMMENT_WEIGHT = 3.0
852
+
853
+
854
+ def _style_score(stat):
855
+ """Composite per-style score from a _fetch_style_stats() row.
856
+
857
+ stat is {"avg_up", "avg_cm", "avg_clicks", ...}. avg_up is already net.
858
+ """
859
+ return (
860
+ float(stat.get("avg_clicks", 0.0)) * CLICK_WEIGHT
861
+ + float(stat.get("avg_cm", 0.0)) * COMMENT_WEIGHT
862
+ + float(stat.get("avg_up", 0.0))
863
+ )
864
+
865
+
866
+ def get_dynamic_tiers(platform, context="posting"):
867
+ """Rank styles for `platform` by avg_upvotes from the posts table.
868
+
869
+ Returns (dominant, secondary, rare) tuple of style-name lists.
870
+
871
+ Policy:
872
+ - Styles in PLATFORM_POLICY[platform].never are excluded entirely.
873
+ - Styles with N < MIN_SAMPLE_SIZE are placed in `secondary` (explore),
874
+ regardless of their noisy avg_up.
875
+ - Styles with N >= MIN_SAMPLE_SIZE are sorted by avg_up DESC and split:
876
+ top third -> dominant
877
+ middle -> secondary
878
+ bottom third (or single worst) -> rare
879
+ - Any style with zero samples (never logged yet) is added to
880
+ `secondary` so the LLM still explores it.
881
+ - Cold start (no data at all): every non-never style becomes secondary.
882
+ """
883
+ never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
884
+ universe = get_all_styles()
885
+ candidate_styles = [s for s in universe.keys() if s not in never]
886
+
887
+ stats = _fetch_style_stats(platform)
888
+
889
+ trusted = [] # (style, avg_up) with N >= MIN_SAMPLE_SIZE
890
+ explore = [] # styles with N < MIN_SAMPLE_SIZE (incl. zero samples)
891
+
892
+ for style in candidate_styles:
893
+ s = stats.get(style)
894
+ # Every style with N >= MIN_SAMPLE_SIZE can be trusted. The legacy
895
+ # two-tier candidate→active gate was removed 2026-05-22 and the
896
+ # picker's sample-size shrinkage was removed 2026-05-25: invented
897
+ # styles now compete on raw per-post score from day one, protected
898
+ # only by the MIN_SAMPLE_SIZE=1 existence floor (excludes n=0 ghost
899
+ # styles) and the 30-day recency window.
900
+ if s and s["n"] >= MIN_SAMPLE_SIZE:
901
+ trusted.append((style, s["avg_up"]))
902
+ else:
903
+ explore.append(style)
904
+
905
+ trusted.sort(key=lambda x: x[1], reverse=True)
906
+
907
+ if not trusted:
908
+ # Cold start: no trusted performance data for this platform yet.
909
+ return [], explore, []
910
+
911
+ # Split trusted into thirds. Small lists (1-2 items) go entirely to dominant.
912
+ t = len(trusted)
913
+ if t <= 2:
914
+ dominant = [s for s, _ in trusted]
915
+ rare = []
916
+ else:
917
+ third = max(1, t // 3)
918
+ dominant = [s for s, _ in trusted[:third]]
919
+ rare = [s for s, _ in trusted[-third:]]
920
+ secondary = [s for s, _ in trusted if s not in dominant and s not in rare]
921
+ secondary = secondary + explore # untrusted styles always explore
922
+ return dominant, secondary, rare
923
+
924
+
925
+ # ── Target distribution ─────────────────────────────────────────────
926
+ # (_last_picks helper removed 2026-05-19 alongside the legacy
927
+ # "show all 9 styles" prompt block it served. The picker doesn't use
928
+ # recent-pick history; it samples weighted-random from the top-N each
929
+ # turn. The `/api/v1/engagement-styles/last-picks` endpoint is still
930
+ # live for the dashboard's audit surface.)
931
+
932
+
933
+ def compute_target_distribution(platform, context="posting"):
934
+ """Per-style pick probability, mirroring the live picker exactly.
935
+
936
+ Returns a list of dicts sorted by pct DESC:
937
+ [{"style", "pct", "n", "avg_up", "avg_cm", "avg_clicks", "score",
938
+ "trusted", "is_candidate", "weight"}]
939
+
940
+ `pct` is the probability that pick_style_for_post() assigns this style to a
941
+ given post, so the UI / snapshot now matches what actually happens:
942
+ - Styles in PLATFORM_POLICY[platform].never are excluded.
943
+ - score = clicks*10 + comments*3 + upvotes_net (the top_performers
944
+ composite). A real human click is the ground-truth conversion signal;
945
+ upvotes are passive vibes.
946
+ - The scored-use path (probability = 1 - INVENT_RATE - human_derived_rate)
947
+ samples across TRUSTED styles weighted LINEARLY by max(score,PICK_FLOOR)
948
+ — the exact weights pick_style_for_post() builds. So a trusted style's
949
+ pct = scored_use_fraction * max(score,PICK_FLOOR) / sum_trusted_weights.
950
+ - Non-trusted styles (n=0 ghost registry rows) are never on the use path,
951
+ so pct = 0. They can still surface via the invent-mode reference list.
952
+ - The leftover INVENT_RATE + human_derived_rate (~10%) is NOT attributed
953
+ to any fixed style (invent mints a new one, human_derived picks the
954
+ latest synthesized row), so trusted pcts sum to scored_use_fraction*100.
955
+ - Cold start (no trusted data): the picker always invents, so we show an
956
+ equal share across the non-never explore universe.
957
+
958
+ 2026-05-29: replaced the legacy floor/cap/exponent target math
959
+ (WEIGHT_EXPONENT / STYLE_FLOOR_PCT / STYLE_CAP_PCT). That predated the
960
+ 2026-05-28 switch to raw linear score sampling in pick_style_for_post and
961
+ diverged hard: with ~56 styles the 5% floor over-subscribed to ~245% and
962
+ zeroed the real winners, so the displayed % bore no relation to picks.
963
+ """
964
+ never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
965
+ universe = get_all_styles()
966
+ candidates = [s for s in universe.keys() if s not in never]
967
+ stats = _fetch_style_stats(platform)
968
+
969
+ rows = []
970
+ trusted_weight_sum = 0.0
971
+ for style in candidates:
972
+ s = stats.get(style)
973
+ n = int(s["n"]) if s else 0
974
+ avg_up = float(s["avg_up"]) if s else 0.0
975
+ avg_cm = float(s.get("avg_cm", 0.0)) if s else 0.0
976
+ avg_clicks = float(s.get("avg_clicks", 0.0)) if s else 0.0
977
+ score = _style_score(s) if s else 0.0
978
+ # Trusted = at least one real post in the recency window
979
+ # (MIN_SAMPLE_SIZE=1 excludes only n=0 ghost registry rows). The
980
+ # picker's use-path weight is max(_picker_score, PICK_FLOOR) with NO
981
+ # exponent and NO sample-size shrinkage, so we mirror it verbatim.
982
+ trusted = (s is not None and n >= MIN_SAMPLE_SIZE)
983
+ weight = max(score, PICK_FLOOR) if trusted else 0.0
984
+ if trusted:
985
+ trusted_weight_sum += weight
986
+ rows.append({"style": style, "n": n, "avg_up": avg_up,
987
+ "avg_cm": avg_cm, "avg_clicks": avg_clicks,
988
+ "score": score, "trusted": trusted,
989
+ "weight": weight, "pct": 0.0,
990
+ "is_candidate": False})
991
+
992
+ if not rows:
993
+ return []
994
+
995
+ # Cold start: no trusted data -> the picker always invents. Show an equal
996
+ # share across the explore universe (the invent-mode reference pool).
997
+ if trusted_weight_sum <= 0:
998
+ share = 100.0 / len(rows)
999
+ for r in rows:
1000
+ r["pct"] = share
1001
+ rows.sort(key=lambda r: r["style"])
1002
+ return rows
1003
+
1004
+ # Scored-use fraction: the picker spends INVENT_RATE on invention and
1005
+ # _human_derived_rate(platform) on the latest human-derived style BEFORE
1006
+ # the scored sample runs, so trusted styles share only the remainder.
1007
+ scored_use_fraction = max(
1008
+ 0.0, 1.0 - INVENT_RATE - _human_derived_rate(platform)
1009
+ )
1010
+ for r in rows:
1011
+ if r["trusted"]:
1012
+ r["pct"] = (
1013
+ (r["weight"] / trusted_weight_sum)
1014
+ * scored_use_fraction * 100.0
1015
+ )
1016
+ else:
1017
+ r["pct"] = 0.0
1018
+
1019
+ rows.sort(key=lambda r: r["pct"], reverse=True)
1020
+ return rows
1021
+
1022
+
1023
+ # ── Programmatic picker (2026-05-19) ────────────────────────────────
1024
+ #
1025
+ # The old flow ("show the model 9 styles + target % and let it pick") was
1026
+ # soft: the model anchored on the most-generic-fit style (pattern_recognizer)
1027
+ # and over-picked it ~30% of posts even when its target % was the 5% floor.
1028
+ # This picker flips the contract: code picks ONE style by weighted sample
1029
+ # across all trusted styles (weighted by composite score), the prompt assigns
1030
+ # that style, and the model only authors the comment. Higher-scoring styles
1031
+ # win proportionally more picks while the whole pool stays eligible, so styles
1032
+ # auto-rotate as their click-weighted score shifts. The
1033
+ # model can still invent: with probability INVENT_RATE the picker returns
1034
+ # mode="invent" and the prompt hands the model the top N as reference
1035
+ # material to derive a new style from.
1036
+
1037
+ INVENT_RATE = 0.05 # ~1 in 20 posts forces a new-style invention
1038
+ CURATED_TOP_N = 5 # size of the invent-mode reference list (top 5 by score)
1039
+
1040
+ # Fallback target comment length (chars) for any style that lacks an explicit
1041
+ # target_chars (legacy DB rows, cold-start before the registry is reachable).
1042
+ # Set just above the top-human-reply median (~74) so the long tail of styles
1043
+ # defaults SHORT, not to our historical ~215-char bloat. Mirrors the DB column
1044
+ # default in migrations/2026-05-30-engagement-styles-target-chars.sql.
1045
+ DEFAULT_TARGET_CHARS = 80
1046
+
1047
+ # Additive ~5% branch per platform (2026-05-22, second pass): with this
1048
+ # probability, the picker bypasses score-based selection and assigns the
1049
+ # most recently synthesized "human-derived" style on the calling platform.
1050
+ # Those rows are distilled by scripts/generate_daily_human_style.py from
1051
+ # the previous 24h of top-performing HUMAN replies in thread_top_replies
1052
+ # and live in engagement_styles_registry with kind='human_derived' (one
1053
+ # row per platform per day). Goal: keep our voice continuously calibrated
1054
+ # to whatever rhetorical move is winning on each platform right now,
1055
+ # without waiting for the historical scoring window to accumulate enough
1056
+ # samples to surface it naturally.
1057
+ #
1058
+ # Distribution per platform: HUMAN_DERIVED_RATE_BY_PLATFORM[platform] +
1059
+ # INVENT_RATE + scored-use (defaults: 5% + 5% + 90%).
1060
+ #
1061
+ # Rate is a per-platform dict so we can tune individually. A platform
1062
+ # missing from the dict defaults to HUMAN_DERIVED_RATE_DEFAULT. Set the
1063
+ # entry to 0 to disable the branch for one platform (e.g. while the
1064
+ # synthesizer is bootstrapping data for that platform).
1065
+ HUMAN_DERIVED_RATE_DEFAULT = 0.05
1066
+ HUMAN_DERIVED_RATE_BY_PLATFORM = {
1067
+ "twitter": 0.05,
1068
+ "reddit": 0.05,
1069
+ "github": 0.05,
1070
+ "moltbook": 0.05,
1071
+ "linkedin": 0.05,
1072
+ }
1073
+
1074
+
1075
+ def _human_derived_rate(platform):
1076
+ """Per-platform rate; falls back to HUMAN_DERIVED_RATE_DEFAULT."""
1077
+ return HUMAN_DERIVED_RATE_BY_PLATFORM.get(platform, HUMAN_DERIVED_RATE_DEFAULT)
1078
+
1079
+ # Credibility shrinkage was removed 2026-05-25 (per user instruction): we
1080
+ # don't favor or penalize styles by post-count, only by per-post performance.
1081
+ # Existence floor is MIN_SAMPLE_SIZE=1 (defined above): n=0 ghost registry
1082
+ # rows are excluded, single-post inventions count. RECENCY_DAYS=7 remains
1083
+ # the only freshness gate.
1084
+
1085
+
1086
+ def _picker_score(row):
1087
+ """Picker score = raw composite score (clicks*10 + comments*3 + upvotes).
1088
+
1089
+ No sample-size shrinkage. A style with n=1 averaging 50 clicks/post
1090
+ competes head-to-head with a style at n=400 averaging 5 clicks/post,
1091
+ and the better per-post style wins the slot. The MIN_SAMPLE_SIZE=1
1092
+ floor inside compute_target_distribution only excludes n=0 ghosts."""
1093
+ return float(row.get("score", 0.0))
1094
+
1095
+
1096
+ def _fetch_latest_human_derived(platform):
1097
+ """Return the most recently synthesized active human-derived style for
1098
+ `platform`, or None if the registry has none for that platform / the
1099
+ API is unreachable.
1100
+
1101
+ Reads via /api/v1/engagement-styles/registry?kind=human_derived
1102
+ &platform=<platform>&latest=1. The route returns 0 or 1 rows ordered
1103
+ by generated_at DESC.
1104
+
1105
+ Network failures are swallowed silently and return None so the picker
1106
+ falls through to the normal scored path. This branch is best-effort,
1107
+ never load-bearing.
1108
+ """
1109
+ try:
1110
+ import sys as _sys
1111
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
1112
+ from http_api import api_get
1113
+ resp = api_get(
1114
+ "/api/v1/engagement-styles/registry",
1115
+ {
1116
+ "status": "active",
1117
+ "kind": "human_derived",
1118
+ "platform": platform,
1119
+ "latest": "1",
1120
+ },
1121
+ )
1122
+ data = (resp or {}).get("data") or {}
1123
+ rows = data.get("styles") or []
1124
+ except Exception:
1125
+ return None
1126
+ if not rows:
1127
+ return None
1128
+ row = rows[0]
1129
+ best_in = row.get("best_in") or {}
1130
+ if isinstance(best_in, str):
1131
+ try:
1132
+ best_in = json.loads(best_in)
1133
+ except Exception:
1134
+ best_in = {}
1135
+ return {
1136
+ # Registry rows use `name` as the primary key, so there's no
1137
+ # standalone numeric id; expose name as the stable identifier.
1138
+ # Callers used to expect `human_derived_id` for log attribution.
1139
+ "id": row.get("name"),
1140
+ "name": row.get("name"),
1141
+ "description": row.get("description") or "",
1142
+ "example": row.get("example") or "",
1143
+ "best_in": best_in,
1144
+ "note": row.get("note") or "",
1145
+ "target_chars": row.get("target_chars") or DEFAULT_TARGET_CHARS,
1146
+ "generated_at": row.get("generated_at"),
1147
+ "platform": row.get("platform"),
1148
+ }
1149
+
1150
+
1151
+ def pick_style_for_post(platform, context="posting",
1152
+ top_n=CURATED_TOP_N, invent_rate=INVENT_RATE,
1153
+ rng=None):
1154
+ """Programmatically pick ONE engagement style for the model to use.
1155
+
1156
+ Replaces the legacy "show all styles, model picks" flow. Returns a
1157
+ dict that get_assigned_style_prompt() turns into a compact prompt
1158
+ block (one style + description + example + note, or invent + top-N
1159
+ reference). The caller also passes the picked style downstream to
1160
+ log_post / validate_or_register so we can detect drift between the
1161
+ assignment and the final logged style.
1162
+
1163
+ The scored-use pick samples across ALL trusted styles, weighted by raw
1164
+ composite score (clicks*10 + comments*3 + upvotes_net), so every style
1165
+ keeps a performance-proportional chance and none is frozen out. top_n
1166
+ bounds only the invent-mode reference list. Styles in
1167
+ PLATFORM_POLICY.never are excluded.
1168
+ Sidecar candidate styles (status="candidate") are excluded from the
1169
+ use path until the promoter graduates them, but stay available as
1170
+ invent-mode references once graduated.
1171
+
1172
+ Args:
1173
+ platform: "reddit" | "twitter" | "linkedin" | "github" | "moltbook"
1174
+ context: "posting" | "replying"
1175
+ top_n: size of the invent-mode reference list (top N by score). The
1176
+ scored-use pick samples across all trusted styles, not just N.
1177
+ invent_rate: probability of returning mode="invent" so the model
1178
+ creates a new style from the top N references.
1179
+ Set to 0 to disable invention entirely.
1180
+ rng: optional random.Random for deterministic tests.
1181
+
1182
+ Returns:
1183
+ {
1184
+ "mode": "use" | "invent",
1185
+ "style": str | None, # the assigned style, None on invent
1186
+ "description": str | None,
1187
+ "example": str | None,
1188
+ "note": str | None,
1189
+ "target_chars": int | None, # authoritative length; None on invent
1190
+ "reference_styles": [ # top-N meta (always populated)
1191
+ {"style", "description", "example", "note", "target_chars",
1192
+ "score", "pct", "n", "avg_clicks", "avg_cm", "avg_up"},
1193
+ ...
1194
+ ],
1195
+ "distribution_snapshot": list, # full target distribution at pick time
1196
+ "picked_at": ISO-8601 UTC,
1197
+ }
1198
+ """
1199
+ rnd = rng or random
1200
+
1201
+ # Human-derived branch (2026-05-22, second pass): on every platform,
1202
+ # with HUMAN_DERIVED_RATE_BY_PLATFORM[platform] probability, bypass the
1203
+ # score-based path entirely and assign the most recently synthesized
1204
+ # human_derived style for that platform from engagement_styles_registry
1205
+ # (read via the s4l.ai /api/v1/engagement-styles/registry route).
1206
+ #
1207
+ # ADDITIVE to the existing INVENT branch — both rates coexist on a
1208
+ # platform-by-platform basis, leaving the remainder for normal
1209
+ # scored-use. Fails open: if the route returns no active row for this
1210
+ # platform, we fall through to the normal flow as if this branch
1211
+ # didn't exist.
1212
+ _hd_rate = _human_derived_rate(platform)
1213
+ if _hd_rate > 0 and rnd.random() < _hd_rate:
1214
+ hd = _fetch_latest_human_derived(platform)
1215
+ if hd:
1216
+ return {
1217
+ "mode": "use",
1218
+ "style": hd["name"],
1219
+ "description": hd["description"],
1220
+ "example": hd["example"],
1221
+ "note": hd["note"],
1222
+ "target_chars": hd.get("target_chars") or DEFAULT_TARGET_CHARS,
1223
+ "source": "human_derived",
1224
+ "human_derived_id": hd["id"],
1225
+ "reference_styles": [],
1226
+ "distribution_snapshot": [],
1227
+ "picked_at": datetime.now(timezone.utc).isoformat(
1228
+ timespec="seconds"
1229
+ ),
1230
+ }
1231
+ # else fall through to normal scored path.
1232
+
1233
+ never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
1234
+ rows = compute_target_distribution(platform, context=context)
1235
+ rows = [r for r in rows if r["style"] not in never]
1236
+
1237
+ # Trust-filter first so n=0 ghost registry rows can't claim a top
1238
+ # reference slot. They stay in distribution_snapshot for audit but not
1239
+ # in use_pool or reference_pool. (MIN_SAMPLE_SIZE=1 since 2026-05-25;
1240
+ # single-post inventions are trusted from day one.)
1241
+ #
1242
+ # Ranking uses raw `score` (clicks*10 + comments*3 + upvotes) with no
1243
+ # sample-size shrinkage — a genuinely better per-post style outranks
1244
+ # established ones from its first post.
1245
+ trusted_rows = [r for r in rows if r.get("trusted")]
1246
+ trusted_sorted = sorted(trusted_rows, key=_picker_score, reverse=True)
1247
+ # Use path samples across ALL trusted styles, weighted by raw score
1248
+ # (2026-05-28, per user request). The old top_n cutoff froze out every
1249
+ # style ranked beyond top_n: it could never earn a post back, so its
1250
+ # 30-day sample aged out to n=0 and it dropped from the pool for good.
1251
+ # Now every trusted style keeps a performance-proportional chance; the
1252
+ # highest scorer still wins the most picks, while score-0 styles draw
1253
+ # ~0 via the 0.01 floor below. top_n now bounds ONLY the invent-mode
1254
+ # reference list (reference_pool), not the scored-use pool.
1255
+ use_pool = trusted_sorted
1256
+ reference_pool = trusted_sorted[:top_n]
1257
+
1258
+ universe = get_all_styles()
1259
+
1260
+ def _meta_for(row):
1261
+ m = universe.get(row["style"], {})
1262
+ return {
1263
+ "style": row["style"],
1264
+ "description": m.get("description", ""),
1265
+ "example": m.get("example", ""),
1266
+ "note": m.get("note", ""),
1267
+ "target_chars": m.get("target_chars") or DEFAULT_TARGET_CHARS,
1268
+ "score": round(row.get("score", 0.0), 3),
1269
+ "pct": round(row.get("pct", 0.0), 1),
1270
+ "n": row.get("n", 0),
1271
+ "avg_clicks": round(row.get("avg_clicks", 0.0), 3),
1272
+ "avg_cm": round(row.get("avg_cm", 0.0), 3),
1273
+ "avg_up": round(row.get("avg_up", 0.0), 3),
1274
+ }
1275
+
1276
+ reference_styles = [_meta_for(r) for r in reference_pool]
1277
+ distribution_snapshot = [
1278
+ {"style": r["style"], "score": round(r.get("score", 0.0), 3),
1279
+ "pct": round(r.get("pct", 0.0), 1), "n": r.get("n", 0),
1280
+ "trusted": bool(r.get("trusted"))}
1281
+ for r in rows
1282
+ ]
1283
+ picked_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
1284
+
1285
+ # Invent path. Also fires as fallback when no trusted style exists yet
1286
+ # (cold start), because we'd rather the model invent something fresh
1287
+ # than be assigned a noisy n=1 outlier.
1288
+ invent = (not use_pool) or (
1289
+ invent_rate > 0 and rnd.random() < invent_rate
1290
+ )
1291
+ if invent:
1292
+ return {
1293
+ "mode": "invent",
1294
+ "style": None,
1295
+ "description": None,
1296
+ "example": None,
1297
+ "note": None,
1298
+ "target_chars": None,
1299
+ "reference_styles": reference_styles,
1300
+ "distribution_snapshot": distribution_snapshot,
1301
+ "picked_at": picked_at,
1302
+ }
1303
+
1304
+ # Use path: weighted random sample by raw score across ALL trusted styles
1305
+ # (filtered by MIN_SAMPLE_SIZE=1 and the 30-day recency window). Linear
1306
+ # score weighting, so each style competes on per-post performance: the
1307
+ # head wins most picks while the tail keeps a proportional, nonzero chance.
1308
+ weights = [max(_picker_score(r), 0.01) for r in use_pool]
1309
+ total = sum(weights) or 1.0
1310
+ pick = rnd.uniform(0.0, total)
1311
+ cum = 0.0
1312
+ chosen_row = use_pool[0]
1313
+ for r, w in zip(use_pool, weights):
1314
+ cum += w
1315
+ if pick <= cum:
1316
+ chosen_row = r
1317
+ break
1318
+
1319
+ meta = _meta_for(chosen_row)
1320
+ return {
1321
+ "mode": "use",
1322
+ "style": chosen_row["style"],
1323
+ "description": meta["description"],
1324
+ "example": meta["example"],
1325
+ "note": meta["note"],
1326
+ "target_chars": meta.get("target_chars") or DEFAULT_TARGET_CHARS,
1327
+ "reference_styles": reference_styles,
1328
+ "distribution_snapshot": distribution_snapshot,
1329
+ "picked_at": picked_at,
1330
+ }
1331
+
1332
+
1333
+ def get_assigned_style_prompt(platform, assignment, context="posting"):
1334
+ """Compact prompt block built from a pick_style_for_post() assignment.
1335
+
1336
+ Replaces get_styles_prompt() for callers that have flipped to the
1337
+ programmatic picker. Two shapes:
1338
+
1339
+ USE mode (the common case):
1340
+ One style is assigned. The block shows description / example / note
1341
+ plus the platform tone hint and the grounding rule. No list of other
1342
+ styles, no target %, no over/under-used hints. Decision was already
1343
+ made; the model only needs to know what the assigned style means.
1344
+
1345
+ INVENT mode (~5% of posts):
1346
+ No style assigned. The block shows the top N curated styles as
1347
+ reference (each with description + example + score) and instructs
1348
+ the model to invent a fresh style. Output JSON must set
1349
+ engagement_style=<new_snake_case_name> AND include a new_style block;
1350
+ validate_or_register handles the rest.
1351
+ """
1352
+ policy = PLATFORM_POLICY.get(platform, PLATFORM_POLICY["reddit"])
1353
+ lines = []
1354
+
1355
+ if assignment["mode"] == "use":
1356
+ lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
1357
+ lines.append("")
1358
+ lines.append(
1359
+ f"This style was selected by the picker (weighted by live "
1360
+ f"click-driven performance across {platform}). Use it. Do not "
1361
+ f"swap it for a different listed style."
1362
+ )
1363
+ lines.append("")
1364
+ lines.append(f"Platform tone: {policy.get('note', '')}")
1365
+ lines.append("")
1366
+ lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1367
+ if assignment.get("example"):
1368
+ lines.append(f' Example: "{assignment["example"]}"')
1369
+ if assignment.get("note"):
1370
+ lines.append(f" Note: {assignment['note']}")
1371
+ # LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
1372
+ # uses the legacy generic length guidance. The treatment's per-style
1373
+ # target prompt remains preserved only in the shipped experiment card.
1374
+ lines.append("")
1375
+ lines.append(
1376
+ "**LENGTH: keep it tight.** One or two sentences, well under the "
1377
+ "250-character Twitter limit. A short, sharp reply almost always "
1378
+ "beats a paragraph. This applies to the comment text only; any "
1379
+ "link/CTA the system appends afterward is separate."
1380
+ )
1381
+ lines.append("")
1382
+ lines.append(
1383
+ 'In your output JSON, set "engagement_style" to exactly '
1384
+ f'"{assignment["style"]}" and leave "new_style" as null. '
1385
+ 'Do not substitute a different style. The picker has already '
1386
+ 'made the choice based on live performance data; your job is '
1387
+ 'to author a great comment in that style, not to second-guess '
1388
+ 'the assignment. If you return any other style name, the '
1389
+ 'orchestrator silently coerces it back to '
1390
+ f'"{assignment["style"]}" before logging.'
1391
+ )
1392
+ else:
1393
+ lines.append("## Invent a new engagement style for this post")
1394
+ lines.append("")
1395
+ lines.append(
1396
+ "The picker is asking you to derive a fresh style for this "
1397
+ f"thread (~{int(INVENT_RATE * 100)}% of posts get the invent path). "
1398
+ "Look at our top performers below as reference for what already "
1399
+ "works on this platform, then pick a fresh angle that none of "
1400
+ "them captures. Set `engagement_style` to your snake_case name "
1401
+ "AND include a full `new_style` block in the same JSON."
1402
+ )
1403
+ lines.append("")
1404
+ lines.append(f"Platform tone: {policy.get('note', '')}")
1405
+ lines.append("")
1406
+ lines.append(f"### Top {len(assignment.get('reference_styles', []))} reference styles on {platform}")
1407
+ lines.append("")
1408
+ for ref in assignment.get("reference_styles", []):
1409
+ lines.append(
1410
+ f"- **{ref['style']}** "
1411
+ f"(score {ref['score']:.2f}, clicks {ref['avg_clicks']:.2f}, "
1412
+ f"cm {ref['avg_cm']:.2f}, up {ref['avg_up']:.2f}, n={ref['n']}, "
1413
+ f"target ~{ref.get('target_chars') or DEFAULT_TARGET_CHARS} chars)"
1414
+ )
1415
+ lines.append(f" {ref['description']}")
1416
+ if ref.get("example"):
1417
+ lines.append(f' Example: "{ref["example"]}"')
1418
+ lines.append("")
1419
+ lines.append(
1420
+ "Your new style should be a real third option — not a rename "
1421
+ "of one above. Set the new_style block fields:"
1422
+ )
1423
+ lines.append(" - description: one sentence")
1424
+ lines.append(" - example: short utterance demonstrating the style")
1425
+ lines.append(" - note: when to use / when not to")
1426
+ lines.append(" - why_existing_didnt_fit: why none of the above worked here")
1427
+ lines.append(
1428
+ f" - target_chars: integer, the comment length this style wins "
1429
+ f"at. Bias SHORT; the top human replies cluster near {DEFAULT_TARGET_CHARS} "
1430
+ f"chars and below. Only go high (150+) if the style is genuinely "
1431
+ f"narrative; never propose a target just to fill space."
1432
+ )
1433
+
1434
+ lines.append("")
1435
+ lines.append(
1436
+ 'AVOID the "pleaser/validator" style ("this is great", "had similar '
1437
+ 'results", "100% agree"). Consistently the lowest engagement on every '
1438
+ 'platform.'
1439
+ )
1440
+ lines.append("")
1441
+ lines.append(get_grounding_rule())
1442
+ return "\n".join(lines)
1443
+
1444
+
1445
+ # ── Prompt generators ───────────────────────────────────────────────
1446
+
1447
+ def get_styles_prompt(platform, context="posting", assignment=None):
1448
+ """Generate the engagement-styles prompt block for a platform.
1449
+
1450
+ Always routes through the picker: one style is assigned (weighted by
1451
+ live click-driven performance over the recent window) and the prompt
1452
+ embeds that single style's description + example + note + grounding
1453
+ rule. The model is told to use it, not to choose from a menu.
1454
+
1455
+ Args:
1456
+ platform: "reddit", "twitter", "linkedin", "github", "moltbook"
1457
+ context: "posting" (new posts) or "replying" (engagement replies)
1458
+ assignment: an optional pre-computed pick_style_for_post() result.
1459
+ Orchestrators that need to know the picked style (to
1460
+ filter top_performers, pin the literal style name into
1461
+ their JSON output example, etc.) pick once, then pass
1462
+ the assignment in here to avoid double-picking. Callers
1463
+ that don't care can omit it and get a fresh pick.
1464
+ """
1465
+ if assignment is None:
1466
+ assignment = pick_style_for_post(platform, context=context)
1467
+ return get_assigned_style_prompt(platform, assignment, context=context)
1468
+
1469
+
1470
+ def get_grounding_rule():
1471
+ """The two-lane grounding rule, shared across every pipeline.
1472
+
1473
+ Purely about FABRICATION of specifics, not about whose voice is
1474
+ speaking. Voice positioning (whether the narrator is the maker of
1475
+ the matched project or an outside observer) is governed separately
1476
+ by get_voice_relationship_rule(), which reads the matched project's
1477
+ voice_relationship field in config.json.
1478
+
1479
+ Every comment picks ONE of two mutually exclusive lanes. They cannot
1480
+ be combined: a comment can disclose a worked example OR keep its
1481
+ specifics grounded, never present a fabricated specific as a lived
1482
+ fact.
1483
+
1484
+ LANE 1 - DISCLOSED STORY: invent freely, but flag the frame.
1485
+ Open with a hedge that signals the story is illustration, not
1486
+ testimony ('hypothetically', 'imagine someone running this',
1487
+ 'say a friend tried', 'as a thought experiment', 'scenario:').
1488
+ Once the frame is set, free to use any names, durations, counts,
1489
+ places. The reader can tell from the first phrase that this is
1490
+ a worked example, not a lived claim.
1491
+
1492
+ LANE 2 - NO FABRICATION: stay plain-voiced, but every specific
1493
+ (number, duration, date, place, course/program, headcount,
1494
+ named tool, named person) must appear verbatim in the matched
1495
+ project's content_angle / voice / messaging in config.json. If a
1496
+ specific is not in config: drop it, generalize ('a few months',
1497
+ 'a handful of cameras'), or pattern-frame ('the part that breaks
1498
+ down is...'). Pattern-frame counts as observation, not lived
1499
+ testimony, so no disclosure is needed.
1500
+
1501
+ Pick the lane that fits the thread. Niche / expert subs reward Lane 2;
1502
+ casual / advice subs tolerate Lane 1 when the disclosure feels natural.
1503
+ """
1504
+ return """## GROUNDING RULE (highest priority; overrides any other style guidance)
1505
+
1506
+ This rule is about FABRICATION of specifics. Whose voice is speaking
1507
+ (maker vs outside observer) is governed by the VOICE RELATIONSHIP rule,
1508
+ not here.
1509
+
1510
+ Every comment picks ONE lane. The two lanes are mutually exclusive: you
1511
+ cannot present an invented detail as a lived fact. Choose:
1512
+
1513
+ LANE 1 - DISCLOSED STORY (invent freely, but flag it)
1514
+ Open with a phrase that signals the story is illustration, not lived
1515
+ testimony. Once the frame is set, use whatever names / durations /
1516
+ counts / places fit the point.
1517
+ Acceptable openers: "hypothetically", "imagine someone running this",
1518
+ "say a friend tried", "as a thought experiment", "scenario:",
1519
+ "to make this concrete, picture", "made-up example but".
1520
+ After the opener, full creative license on the details.
1521
+
1522
+ LANE 2 - NO FABRICATION (specifics must be real)
1523
+ Stay plain-voiced. Any specific (number, duration, date, place name,
1524
+ course/program, headcount, named tool, named person) is allowed ONLY
1525
+ if it appears verbatim in the matched project's content_angle, voice
1526
+ (tone/examples/examples_good), or messaging (lead_with_pain / solution
1527
+ / proof) in config.json. If a specific is not in config: drop it,
1528
+ generalize ("a few months", "a handful of cameras"), or pattern-frame
1529
+ ("the part that breaks down is...", "the typical failure mode is...").
1530
+ Pattern-frame counts as observation, not lived testimony, so no
1531
+ disclosure is needed.
1532
+
1533
+ NEVER MIX: do not write "ran 22 cameras for 8 months" without either
1534
+ (a) a Lane 1 opener in front of it, or (b) those numbers being in
1535
+ config.json. That is the failure mode this rule exists to kill.
1536
+
1537
+ Worked examples (drawn from real posts in our DB):
1538
+
1539
+ BAD (fabricated anecdote, no disclosure, no config anchor):
1540
+ "ran this exact pipeline last semester for two anatomy blocks,
1541
+ cheap recorder into whisper into gpt into anki, raw gpt got
1542
+ about 35% usable cards..."
1543
+ LANE 1 REWRITE (same details, but disclosed):
1544
+ "hypothetically, imagine running this for a couple of lecture
1545
+ blocks: cheap recorder into whisper into gpt into anki. raw
1546
+ prompts get you somewhere around a third usable cards before
1547
+ duplicate distractors and trivial restatements take over."
1548
+ LANE 2 REWRITE (pattern-frame, no invented specifics):
1549
+ "the whisper-to-gpt-to-anki setup isn't where this breaks. card
1550
+ generation is. raw prompts produce roughly a third usable before
1551
+ duplicate distractors and trivial restatements take over."
1552
+
1553
+ BAD (fabricated rig, no disclosure):
1554
+ "ran 22 cameras across three properties for about 8 months and
1555
+ we were getting 400+ 'person detected' pings a night..."
1556
+ LANE 1 REWRITE (disclosed scenario):
1557
+ "scenario: 20-something cameras across a few properties, motion
1558
+ alerts firing 400+ times a night. by week two everyone has
1559
+ notifications muted and the system is dead..."
1560
+ LANE 2 REWRITE (no invented numbers):
1561
+ "the issue with motion alerts isn't reliability, it's signal to
1562
+ noise. once cameras start firing on every shadow and leaf blower,
1563
+ everyone mutes notifications inside a week. the fix is filtering
1564
+ on intent (vehicle at gate after midnight, person near door,
1565
+ loiter over a minute), not more cameras."
1566
+
1567
+ GOOD LANE 2 (config-grounded, specifics are real):
1568
+ "on a 90-slide deck the eval scored 81.3 vs the field average
1569
+ around 68; the cards weren't the bottleneck, the rubric was."
1570
+ (The 81.3 / 68 / 90-slide numbers come from the matched project's
1571
+ messaging.proof and voice.examples.)
1572
+
1573
+ This rule outranks "specificity is the #1 authenticity signal" wherever
1574
+ they conflict. Specificity still wins, but only via Lane 1 disclosure
1575
+ or Lane 2 config grounding."""
1576
+
1577
+
1578
+ def get_voice_relationship_rule():
1579
+ """Voice positioning rule, driven by each project's voice_relationship.
1580
+
1581
+ Communicates how the narrator should relate to the matched project's
1582
+ product. The relationship is a structural property of the project
1583
+ (whether @m13v_ / @matt_diak speaks AS the maker or as an outside
1584
+ observer), declared in config.json per project; the prompt does not
1585
+ hardcode it.
1586
+
1587
+ This rule is independent of the GROUNDING RULE: grounding governs
1588
+ whether SPECIFICS are real, this rule governs whose VOICE is speaking
1589
+ when those specifics are stated.
1590
+ """
1591
+ return """## VOICE RELATIONSHIP (highest priority; overrides any default voice in the styles block)
1592
+
1593
+ For EACH candidate, look up the matched project's `voice_relationship`
1594
+ field in its config.json entry. The field is always one of two values
1595
+ and dictates whose voice you write in. There is no third option and no
1596
+ default; every project declares the field explicitly.
1597
+
1598
+ - `voice_relationship: "first_party"`
1599
+ You speak AS the builder / maker of this product. First-person plural
1600
+ about the product ("we ship X", "our Y", "we built Z") is allowed
1601
+ when grounded in config.json. The audience expects to hear from the
1602
+ maker. Recommendation-style framing is also fine ("a thing we ship
1603
+ is...", "this is why we built...").
1604
+
1605
+ - `voice_relationship: "third_party"`
1606
+ You are an OUTSIDE OBSERVER of this product, NOT its team. Never use
1607
+ first-person plural about the product. Always refer to it in
1608
+ third-person, by name or by 'their': "Agora ships X", "their EAS
1609
+ variant", "the OODAO product", "the Studyly team rolled out Y". The
1610
+ narrator is a tech-curious user / builder who has noticed and
1611
+ recommends the project, NOT its marketer or staff. Acceptable narrator
1612
+ framings: "I tried [project]", "I noticed [project] does X", "[project]
1613
+ ships Y". Banned framings: "we ship", "our product", "we built", "I
1614
+ built this", "what we offer".
1615
+
1616
+ The narrator's own life (the @m13v_ / @matt_diak persona's own voice,
1617
+ "I", "my") is still available in BOTH modes. The rule only restricts
1618
+ "we" / "our" / "we built" CLAIMS ABOUT THE PROJECT'S PRODUCT. For
1619
+ first_party projects those claims are accurate; for third_party
1620
+ projects they impersonate the client and are banned.
1621
+
1622
+ Fabrication of specifics (numbers, dates, places, named tools) is
1623
+ governed by the GROUNDING RULE above; that rule is orthogonal to this
1624
+ one. A grounded specific stated in the wrong voice still violates this
1625
+ rule. An invented specific stated in the right voice still violates the
1626
+ GROUNDING RULE."""
1627
+
1628
+
1629
+ def get_content_rules(platform):
1630
+ """Generate platform-specific content rules.
1631
+
1632
+ Args:
1633
+ platform: "reddit", "twitter", or "linkedin"
1634
+
1635
+ Returns:
1636
+ Multi-line string of content rules.
1637
+ """
1638
+ common = [
1639
+ "NO em dashes. Use commas, periods, or regular dashes (-).",
1640
+ "Never mention any project by name unless recommending. Product voice (claims about the matched project) is governed by the VOICE RELATIONSHIP rule, not here.",
1641
+ 'Never start with "exactly", "yeah totally", "100%", "that\'s smart".',
1642
+ "Specificity is the #1 authenticity signal, but only for specifics you have license to use. See GROUNDING RULE: pick LANE 1 (open with a disclosure phrase like 'hypothetically' or 'scenario:' and then invent freely) OR LANE 2 (no fabricated specifics; every specific must come verbatim from the matched project's config.json). Specifics that pass the GROUNDING RULE still have to be stated in the voice required by the VOICE RELATIONSHIP rule.",
1643
+ "Include imperfections: contractions, casual asides, occasional lowercase.",
1644
+ ]
1645
+
1646
+ platform_rules = {
1647
+ "reddit": [
1648
+ "Go BIMODAL: either 1 punchy sentence (<100 chars, highest avg upvotes) or 4-5 sentences of real substance. AVOID the 2-3 sentence dead zone.",
1649
+ "Lead with personal experience or observation ('I tried X' / 'I noticed X' / 'the part that broke for me was X') rather than instructional advice ('you should X'). Whether you can speak AS the project's maker is governed by the VOICE RELATIONSHIP rule.",
1650
+ "No markdown in Reddit (no ##, **, numbered lists). Casual tone, lowercase OK, fragments OK.",
1651
+ "NEVER mention product names (fazm, assrt, pieline, cyrano, terminator, mk0r, s4l). NEVER include URLs or links.",
1652
+ "Statements beat questions. Be authoritative, not inquisitive. No 'anyone else experience this?'",
1653
+ ],
1654
+ "twitter": [
1655
+ "Keep it short: 1-2 sentences max. Fragments and lowercase OK.",
1656
+ "Direct product mentions OK when relevant (unlike Reddit).",
1657
+ "No hashtags. No threads. No 'RT if you agree' bait.",
1658
+ "Punch line first, context second.",
1659
+ ],
1660
+ "linkedin": [
1661
+ "Professional but casual tone. 2-4 sentences.",
1662
+ "Softer framing for critic style (constructive, not combative).",
1663
+ "No snark. No sarcasm. Earnest insights land better here.",
1664
+ "Line breaks between thoughts for readability.",
1665
+ ],
1666
+ }
1667
+
1668
+ rules = platform_rules.get(platform, platform_rules["reddit"]) + common
1669
+ return "\n".join(f"- {r}" for r in rules)
1670
+
1671
+
1672
+ def get_anti_patterns():
1673
+ """Content anti-patterns shared across all platforms."""
1674
+ return """## Anti-patterns
1675
+ - NEVER start with "exactly", "yeah totally", "100%", "that's smart". Vary first words.
1676
+ - NEVER claim authorship or operational control of a product whose voice_relationship is "third_party" (see VOICE RELATIONSHIP rule). For first_party projects, prefer recommendation framing over bare "I built it" self-promotion even though the voice is yours to use.
1677
+ - NEVER suggest calls, meetings, demos.
1678
+ - NEVER promise to share links/files not in config.json.
1679
+ - NEVER offer to DM. NEVER make time-bound promises.
1680
+ - Some replies should be 1 sentence. Not everything needs 3-4 sentences."""
1681
+
1682
+
1683
+ def get_valid_styles(context="posting"):
1684
+ """Return the set of valid style names.
1685
+
1686
+ Args:
1687
+ context: "posting" for new posts, "replying" for engagement replies.
1688
+ """
1689
+ if context == "replying":
1690
+ return REPLY_STYLES
1691
+ return VALID_STYLES
1692
+
1693
+
1694
+ def validate_style(style, context="posting"):
1695
+ """Check if a style name is valid. Returns the style or None.
1696
+
1697
+ Consults the live universe (hardcoded STYLES + sidecar candidates) so
1698
+ a candidate registered in this process or by another agent passes.
1699
+ """
1700
+ if not style:
1701
+ return None
1702
+ if style in get_all_styles():
1703
+ return style
1704
+ # Backwards path: a few callers (like locked octolens scripts) only
1705
+ # know the hardcoded set. Keep that path working for them.
1706
+ valid = get_valid_styles(context)
1707
+ if style in valid:
1708
+ return style
1709
+ return None
1710
+
1711
+
1712
+ def target_distribution_snapshot(platform, context="posting"):
1713
+ """Compact, JSON-serializable snapshot of the current target distribution.
1714
+
1715
+ This is what the picker would tell the model to aim for RIGHT NOW.
1716
+ Persisted into generation_trace.extras / the daily snapshot log so the
1717
+ "did the clicks-weighted reweight actually shift picks" audit can replay
1718
+ point-in-time targets — clicks accrue retroactively, so the live numbers
1719
+ cannot be reconstructed cleanly from posts after the fact.
1720
+ """
1721
+ rows = compute_target_distribution(platform, context=context)
1722
+ return [
1723
+ {
1724
+ "style": r["style"],
1725
+ "pct": round(r["pct"], 1),
1726
+ "score": round(r.get("score", 0.0), 3),
1727
+ "avg_clicks": round(r.get("avg_clicks", 0.0), 3),
1728
+ "avg_cm": round(r.get("avg_cm", 0.0), 3),
1729
+ "avg_up": round(r.get("avg_up", 0.0), 3),
1730
+ "n": r["n"],
1731
+ "trusted": bool(r["trusted"]),
1732
+ }
1733
+ for r in rows
1734
+ ]
1735
+
1736
+
1737
+ if __name__ == "__main__":
1738
+ import argparse
1739
+ import json as _json
1740
+
1741
+ _parser = argparse.ArgumentParser(
1742
+ description="Engagement styles CLI (target distribution inspection)"
1743
+ )
1744
+ _sub = _parser.add_subparsers(dest="cmd")
1745
+ _td = _sub.add_parser(
1746
+ "target-distribution",
1747
+ help="Print the current per-style target pick distribution as JSON",
1748
+ )
1749
+ _td.add_argument("--platform", required=True)
1750
+ _td.add_argument("--context", default="posting", choices=["posting", "replying"])
1751
+
1752
+ _pk = _sub.add_parser(
1753
+ "pick",
1754
+ help="Run pick_style_for_post() and print the assignment + prompt block",
1755
+ )
1756
+ _pk.add_argument("--platform", required=True)
1757
+ _pk.add_argument("--context", default="posting", choices=["posting", "replying"])
1758
+ _pk.add_argument("--top-n", type=int, default=CURATED_TOP_N)
1759
+ _pk.add_argument("--invent-rate", type=float, default=INVENT_RATE)
1760
+ _pk.add_argument("--seed", type=int, default=None,
1761
+ help="Deterministic seed for the picker RNG")
1762
+ _pk.add_argument("--show-prompt", action="store_true",
1763
+ help="Also print the compact prompt block the model would see")
1764
+
1765
+ _args = _parser.parse_args()
1766
+
1767
+ if _args.cmd == "target-distribution":
1768
+ print(_json.dumps(
1769
+ target_distribution_snapshot(_args.platform, context=_args.context),
1770
+ ensure_ascii=False,
1771
+ ))
1772
+ elif _args.cmd == "pick":
1773
+ _rng = random.Random(_args.seed) if _args.seed is not None else random
1774
+ _assignment = pick_style_for_post(
1775
+ _args.platform, context=_args.context,
1776
+ top_n=_args.top_n, invent_rate=_args.invent_rate, rng=_rng,
1777
+ )
1778
+ print(_json.dumps(_assignment, ensure_ascii=False, indent=2))
1779
+ if _args.show_prompt:
1780
+ print()
1781
+ print("=" * 60)
1782
+ print("PROMPT BLOCK")
1783
+ print("=" * 60)
1784
+ print(get_assigned_style_prompt(
1785
+ _args.platform, _assignment, context=_args.context))
1786
+ else:
1787
+ _parser.print_help()