@m13v/s4l 1.6.197-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,875 @@
1
+ #!/bin/bash
2
+ # run-instagram-render.sh — Spawn `claude -p` to render ONE fresh IG reel
3
+ # end-to-end per mixer/SKILL.md.
4
+ #
5
+ # Cadence (com.m13v.social-instagram-render.plist):
6
+ # 5 fires/day at 08:30, 11:30, 14:30, 17:30, 20:30 local time, 30 min
7
+ # before each post-cycle slot.
8
+ #
9
+ # Per-fire logic:
10
+ # 1. acquire_lock instagram-render (data.ts edits race otherwise)
11
+ # 2. compute target post_type via 4:1 of last 5 posted IG rows
12
+ # 3. count existing drafts of that type. If >=3, SKIP (buffer healthy).
13
+ # 4. pull from Postgres: local_audio_lru (LRU-ordered local mixer/audio pool),
14
+ # used_angles (14d), used_variant_ids (all-time).
15
+ # 5. spawn run_claude.sh with mixer/SKILL.md as the procedure, plus a
16
+ # compact request envelope (type, post_number, exclusions).
17
+ # 6. on exit, verify post-NNN.mp4, post-NNN.caption.txt, media_posts row
18
+ # with status='draft' and post_type matching target.
19
+ #
20
+ # Exit codes:
21
+ # 0 - rendered, OR buffer healthy and skipped, OR another run holds the lock
22
+ # 1 - real failure (claude error, ffmpeg fail, missing deliverables)
23
+
24
+ set -uo pipefail
25
+
26
+ REPO_DIR="$HOME/social-autoposter"
27
+ LOG_DIR="$REPO_DIR/skill/logs"
28
+ mkdir -p "$LOG_DIR"
29
+
30
+ # Cycle ID for cross-cycle cost accounting. The single claude -p invocation
31
+ # inherits this via env so log_claude_session.py stamps claude_sessions.cycle_id.
32
+ BATCH_ID="${BATCH_ID:-igren-$(date +%Y%m%d-%H%M%S)}"
33
+ export BATCH_ID
34
+ export SA_CYCLE_ID="$BATCH_ID"
35
+
36
+ LOG_FILE="$LOG_DIR/instagram-render-$(date +%Y-%m-%d_%H%M%S).log"
37
+ PICK_FILE="/tmp/ig_render_pick_$(date +%s)_$$.json"
38
+
39
+ if [ -f "$REPO_DIR/.env" ]; then
40
+ set -a
41
+ # shellcheck disable=SC1091
42
+ source "$REPO_DIR/.env"
43
+ set +a
44
+ fi
45
+
46
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
47
+
48
+ # Run accounting for dashboard Job History (Render · Instagram). Matches the
49
+ # pattern in run-instagram-daily.sh / run-twitter-threads.sh / run-reddit-threads.sh:
50
+ # each exit site updates POSTED_CT / SKIPPED_CT / FAILED_CT; the EXIT trap
51
+ # always emits one log_run.py line so the run shows up under render_instagram.
52
+ # "posted" here means "rendered a fresh draft"; "skipped" means buffer was
53
+ # healthy (>=3 drafts) or the lock was already held; "failed" is any real
54
+ # error path.
55
+ RUN_START_EPOCH=$(date +%s)
56
+ POSTED_CT=0
57
+ SKIPPED_CT=0
58
+ FAILED_CT=0
59
+
60
+ cleanup() {
61
+ local rc=$?
62
+ rm -f "$PICK_FILE" "${UNPROVEN_JSON_FILE:-}"
63
+ if [ "$POSTED_CT" -eq 0 ] && [ "$SKIPPED_CT" -eq 0 ] && [ "$FAILED_CT" -eq 0 ]; then
64
+ if [ "$rc" -eq 0 ]; then SKIPPED_CT=1; else FAILED_CT=1; fi
65
+ fi
66
+ local elapsed=$(( $(date +%s) - RUN_START_EPOCH ))
67
+ local cost
68
+ cost=$(/usr/bin/python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-instagram-render" 2>/dev/null || echo "0.0000")
69
+ /usr/bin/python3 "$REPO_DIR/scripts/log_run.py" \
70
+ --script "render_instagram" \
71
+ --posted "$POSTED_CT" --skipped "$SKIPPED_CT" --failed "$FAILED_CT" \
72
+ --cost "$cost" --elapsed "$elapsed" >/dev/null 2>&1 || true
73
+ }
74
+ trap cleanup EXIT INT TERM HUP
75
+
76
+ log "=== instagram-render fire: $(date) ==="
77
+
78
+ # Lock against parallel renders. data.ts edits + npx remotion compositions
79
+ # are not safe to interleave.
80
+ # shellcheck source=lock.sh
81
+ source "$REPO_DIR/skill/lock.sh"
82
+ acquire_lock instagram-render 60
83
+
84
+ # Step 0: pick TARGET account (inverse-recent-share over enabled IG accounts in
85
+ # config.json:instagram.accounts[]). Honors FORCE_ACCOUNT env override. The
86
+ # chosen account scopes Step 1 queries (type buffer, used angles, used
87
+ # variants, audio LRU) so each account has its own rotation.
88
+ log "step 0: picking target account"
89
+ if [ -n "${FORCE_ACCOUNT:-}" ]; then
90
+ TARGET_ACCOUNT=$("$REPO_DIR/scripts/pick_ig_account.py" --account "$FORCE_ACCOUNT" 2>>"$LOG_FILE")
91
+ else
92
+ TARGET_ACCOUNT=$("$REPO_DIR/scripts/pick_ig_account.py" 2>>"$LOG_FILE")
93
+ fi
94
+ if [ -z "$TARGET_ACCOUNT" ]; then
95
+ log "ERROR: pick_ig_account.py returned empty; no enabled accounts?"
96
+ FAILED_CT=1
97
+ exit 1
98
+ fi
99
+ export TARGET_ACCOUNT
100
+ log "target_account=$TARGET_ACCOUNT"
101
+
102
+ # Step 1: compute target type + exclusion lists (scoped to TARGET_ACCOUNT)
103
+ log "step 1: querying Postgres for target_type, draft_count, exclusions (account=$TARGET_ACCOUNT)"
104
+ /opt/homebrew/bin/python3.11 - > "$PICK_FILE" 2>>"$LOG_FILE" <<'PY'
105
+ import glob, json, os, random, sys
106
+ from datetime import datetime
107
+ # HTTP-only (2026-06-01): all media_posts reads route through
108
+ # /api/v1/media-posts/picker-context via http_api. No DATABASE_URL, no
109
+ # psycopg2, no fallback. The api_get call happens once below (after
110
+ # target_account + recent_window_days are known) and every aggregate the
111
+ # picker needs is read out of the returned context dict.
112
+ sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
113
+ from http_api import api_get
114
+
115
+ # Inverse-recent-share weighting at TWO levels, mirroring the Twitter pipeline
116
+ # (scripts/pick_project.py). Effective weight = config_weight / (1 + posts in
117
+ # the last RECENT_WINDOW_DAYS). Over-posting damps without ever exceeding the
118
+ # raw config weight; under-posting catches up automatically.
119
+ #
120
+ # Two env-var overrides bypass the stochastic rolls (one-shot / debugging):
121
+ # FORCE_TYPE=organic|product skip Level-1 roll
122
+ # FORCE_PROJECT=<name> skip Level-2 roll (implies product)
123
+
124
+ cfg = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
125
+ ig_cfg = cfg.get('instagram', {}) or {}
126
+ recent_window_days = int(ig_cfg.get('recent_window_days', 7))
127
+ post_type_weights_cfg = (
128
+ ig_cfg.get('post_type_weights')
129
+ or ig_cfg.get('post_type_ratio')
130
+ or {'organic': 4, 'product': 1}
131
+ )
132
+ force_type = os.environ.get('FORCE_TYPE') or ''
133
+ force_project = os.environ.get('FORCE_PROJECT') or ''
134
+ if force_project and not force_type:
135
+ force_type = 'product' # FORCE_PROJECT implies product
136
+
137
+ # Target account: scopes every recency / buffer / exclusion query so each
138
+ # account has its own type rotation, variant pool, angle list, audio LRU.
139
+ target_account = os.environ.get('TARGET_ACCOUNT', '').strip()
140
+ if not target_account:
141
+ raise SystemExit("TARGET_ACCOUNT env var missing (Step 0 should have set it)")
142
+
143
+ # Per-account overrides: each account in instagram.accounts[] may override the
144
+ # global post_type_weights and supply a `tlh` block (source_dir,
145
+ # variant_prefix, story_brief, unproven_dir, caption_opener) that scopes the
146
+ # TLH render to its own clip pool + voice. Accounts without these fields
147
+ # fall back to globals (matt_diak / matthewheartful behavior is unchanged).
148
+ account_record = next(
149
+ (a for a in (ig_cfg.get('accounts') or [])
150
+ if a.get('username', '').lower() == target_account.lower()),
151
+ {}
152
+ )
153
+ if account_record.get('post_type_weights'):
154
+ post_type_weights_cfg = account_record['post_type_weights']
155
+ account_tlh_config = account_record.get('tlh') or {}
156
+
157
+ # Single HTTP read: every media_posts aggregate the picker needs, scoped to
158
+ # this account + recency window. Local weighting / glob / JSON shaping below
159
+ # is unchanged; only the data source moved from psycopg2 to the API.
160
+ _pc = api_get(
161
+ '/api/v1/media-posts/picker-context',
162
+ query={'target_account': target_account, 'window_days': recent_window_days},
163
+ )
164
+ ctx = (_pc.get('data') or {})
165
+
166
+ # Last-N posted descriptor (kept for telemetry / log readability).
167
+ last10 = list(ctx.get('last10_posted') or [])
168
+
169
+ # ---- LEVEL 1: organic vs product, inverse-recent-share (or FORCE_TYPE) ----
170
+ recent_type_counts = {k: v for k, v in (ctx.get('recent_type_counts') or {}).items() if k}
171
+ for t in ('organic', 'product'):
172
+ recent_type_counts.setdefault(t, 0)
173
+ type_weights = {
174
+ t: float(post_type_weights_cfg.get(t, 0)) / (1 + recent_type_counts[t])
175
+ for t in ('organic', 'product')
176
+ if float(post_type_weights_cfg.get(t, 0)) > 0
177
+ }
178
+ if force_type in ('organic', 'product'):
179
+ target = force_type
180
+ elif not type_weights:
181
+ target = 'organic' # defensive default if config is empty
182
+ else:
183
+ names = list(type_weights.keys())
184
+ ws = [type_weights[n] for n in names]
185
+ target = random.choices(names, weights=ws, k=1)[0]
186
+
187
+ # ---- LEVEL 2: which project, inverse-recent-share (product only) ----
188
+ # Organic content is intentionally product-free -> project_name=NULL.
189
+ selected_project = None
190
+ mixer_enabled_projects = []
191
+ project_post_counts = {}
192
+ project_weights = {}
193
+ if target == 'product':
194
+ enabled = [
195
+ p for p in cfg.get('projects', [])
196
+ if isinstance(p.get('mixer'), dict)
197
+ and p['mixer'].get('enabled') is True
198
+ and p.get('weight', 0) > 0
199
+ ]
200
+ mixer_enabled_projects = sorted([p['name'] for p in enabled])
201
+ if not enabled:
202
+ # defensive fallback to mk0r if no project is flagged
203
+ enabled = [{'name': 'mk0r', 'weight': 1}]
204
+ mixer_enabled_projects = ['mk0r']
205
+
206
+ recent_proj_counts = dict(ctx.get('recent_product_counts_by_project') or {})
207
+ for p in enabled:
208
+ project_post_counts[p['name']] = recent_proj_counts.get(p['name'], 0)
209
+ project_weights = {
210
+ p['name']: float(p['weight']) / (1 + project_post_counts[p['name']])
211
+ for p in enabled
212
+ }
213
+ if force_project:
214
+ if force_project not in [p['name'] for p in enabled]:
215
+ raise SystemExit(
216
+ f"FORCE_PROJECT={force_project!r} not in mixer.enabled projects: "
217
+ f"{[p['name'] for p in enabled]}"
218
+ )
219
+ selected_project = force_project
220
+ else:
221
+ names = list(project_weights.keys())
222
+ ws = [project_weights[n] for n in names]
223
+ selected_project = random.choices(names, weights=ws, k=1)[0]
224
+
225
+ # Per-(account, type, project) draft buffer. Each account has its own
226
+ # rotation; a build-up on matt_diak should not block a heartfulmatthew render.
227
+ # draft_counts comes back grouped by (post_type, project_name); sum the rows
228
+ # the same way the two SQL variants did (product = type+project, organic =
229
+ # type only, project-agnostic).
230
+ _draft_rows = ctx.get('draft_counts') or []
231
+ if target == 'product':
232
+ draft_count = sum(
233
+ int(r.get('count') or 0) for r in _draft_rows
234
+ if r.get('post_type') == target and r.get('project_name') == selected_project
235
+ )
236
+ else:
237
+ draft_count = sum(
238
+ int(r.get('count') or 0) for r in _draft_rows
239
+ if r.get('post_type') == target
240
+ )
241
+
242
+ next_num = int(ctx.get('next_post_number') or 1)
243
+
244
+ # Audio policy: local-only, least-recently-used rotation. The render must
245
+ # reuse the existing mixer/audio/ pool and NEVER source fresh audio from the
246
+ # network. We list the on-disk tracks and order them by last use in
247
+ # media_posts (never-used first, then oldest-used first). audio_source values
248
+ # vary in shape (local:/abs/path, local:~/path, ig://reel/<code>), so a track
249
+ # counts as "used" by a row if the row's audio_source contains the track's
250
+ # basename OR its trailing token (reel code / label after the last '_').
251
+ audio_dir = os.path.expanduser('~/social-autoposter/mixer/audio')
252
+ local_files = sorted(glob.glob(os.path.join(audio_dir, '*.m4a')))
253
+
254
+ # audio_usage rows arrive as [audio_source, used_at_iso]; parse the ISO
255
+ # timestamp back to a datetime so the LRU comparison + .isoformat() sort below
256
+ # work exactly as they did with psycopg2-returned datetimes.
257
+ def _parse_dt(s):
258
+ if not s:
259
+ return None
260
+ try:
261
+ return datetime.fromisoformat(str(s).replace('Z', '+00:00'))
262
+ except Exception:
263
+ return None
264
+ audio_usage = [(r[0] or '', _parse_dt(r[1])) for r in (ctx.get('audio_usage') or [])]
265
+
266
+
267
+ def _audio_token(path):
268
+ stem = os.path.splitext(os.path.basename(path))[0]
269
+ return stem.rsplit('_', 1)[-1] if '_' in stem else stem
270
+
271
+
272
+ audio_lru = []
273
+ for f in local_files:
274
+ base = os.path.basename(f)
275
+ tok = _audio_token(f)
276
+ last_used = None
277
+ for src, used_at in audio_usage:
278
+ if used_at is None:
279
+ continue
280
+ if base in src or (tok and tok in src):
281
+ if last_used is None or used_at > last_used:
282
+ last_used = used_at
283
+ audio_lru.append((f, last_used))
284
+
285
+ # never-used tracks first, then oldest-used first
286
+ audio_lru.sort(key=lambda x: (1, x[1].isoformat()) if x[1] else (0, ''))
287
+ local_audio_lru = [f for f, _ in audio_lru]
288
+
289
+ used_angles = sorted({a for a in (ctx.get('used_theme_angles_14d') or []) if a})
290
+
291
+ used_variants = sorted({v for v in (ctx.get('used_variant_ids') or []) if v is not None})
292
+
293
+ print(json.dumps({
294
+ 'target_account': target_account,
295
+ 'target_type': target,
296
+ 'last10_posted': last10,
297
+ 'recent_window_days': recent_window_days,
298
+ 'recent_type_counts': recent_type_counts,
299
+ 'type_weights_effective': type_weights,
300
+ 'post_type_weights_config': post_type_weights_cfg,
301
+ 'draft_count_target': draft_count,
302
+ 'next_post_number': next_num,
303
+ 'local_audio_lru': local_audio_lru,
304
+ 'used_theme_angles_14d': used_angles,
305
+ 'used_variant_ids': used_variants,
306
+ # Level-2 product routing (NULL when target=='organic').
307
+ 'selected_project': selected_project,
308
+ 'mixer_enabled_projects': mixer_enabled_projects,
309
+ 'recent_product_posts_by_project': project_post_counts,
310
+ 'project_weights_effective': project_weights,
311
+ # Per-account TLH overrides (organic format). Empty dict {} means
312
+ # this account uses SKILL.md defaults (Matt's '5. time lapse hooks/'
313
+ # source + AI-defeat caption arc). When non-empty, Claude MUST use
314
+ # these overrides for source_dir, variant_prefix, and story_brief.
315
+ 'account_tlh_config': account_tlh_config,
316
+ }, indent=2))
317
+ PY
318
+
319
+ if [ ! -s "$PICK_FILE" ]; then
320
+ log "ERROR: pick query produced no output"
321
+ FAILED_CT=1
322
+ exit 1
323
+ fi
324
+
325
+ TARGET=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$PICK_FILE'))['target_type'])")
326
+ DRAFT_COUNT=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$PICK_FILE'))['draft_count_target'])")
327
+ NEXT_NUM=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$PICK_FILE'))['next_post_number'])")
328
+ SELECTED_PROJECT=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$PICK_FILE')).get('selected_project') or '')")
329
+ NNN=$(printf "%03d" "$NEXT_NUM")
330
+
331
+ log "target=$TARGET selected_project=${SELECTED_PROJECT:-<null/organic>} draft_count_target=$DRAFT_COUNT next_post_number=$NEXT_NUM"
332
+
333
+ # Step 1.6: engagement style assignment. Mirrors the twitter cycle pattern:
334
+ # - Organic runs: roll via picker (95% top performer / 5% invent).
335
+ # - Product runs: deterministic mapping from selected_project (the picker
336
+ # never sees walkin/studyly because they're in PLATFORM_POLICY.instagram.never).
337
+ # The assignment is injected into the Claude prompt envelope below and Claude
338
+ # stamps metadata.engagement_style on the media_posts row. sync_ig_to_posts.py
339
+ # mirrors that field to posts.engagement_style for the dashboard A/B.
340
+ source "$REPO_DIR/skill/styles.sh"
341
+ STYLE_ASSIGN_FILE=$(mktemp -t saps_ig_style_XXXXXX.json)
342
+ if [ "$TARGET" = "organic" ]; then
343
+ saps_pick_style instagram posting "$STYLE_ASSIGN_FILE" >/dev/null 2>>"$LOG_FILE" || true
344
+ PICKED_STYLE=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$STYLE_ASSIGN_FILE')).get('style') or '')")
345
+ PICK_MODE=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$STYLE_ASSIGN_FILE')).get('mode',''))")
346
+ else
347
+ case "$SELECTED_PROJECT" in
348
+ mk0r) PICKED_STYLE="ig_walkin_storefront_playbook" ;;
349
+ studyly) PICKED_STYLE="ig_studyly_failing_student_arc" ;;
350
+ *) PICKED_STYLE="" ;;
351
+ esac
352
+ PICK_MODE="use"
353
+ /opt/homebrew/bin/python3.11 - "$STYLE_ASSIGN_FILE" "$PICKED_STYLE" "$PICK_MODE" <<'PY'
354
+ import json, sys
355
+ out, style, mode = sys.argv[1], sys.argv[2], sys.argv[3]
356
+ with open(out, 'w') as f:
357
+ json.dump({
358
+ "mode": mode, "style": style or None,
359
+ "description": None, "example": None, "note": None,
360
+ "reference_styles": [], "distribution_snapshot": [],
361
+ "source": "project_gated",
362
+ }, f)
363
+ PY
364
+ fi
365
+ STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" instagram posting)
366
+ log "engagement_style: picked='${PICKED_STYLE}' mode='${PICK_MODE}' target=${TARGET} project=${SELECTED_PROJECT:-organic}"
367
+
368
+ # Buffer guard: if 3+ drafts of target type already exist, skip.
369
+ # Override with FORCE_RENDER=1 for manual / first-fire runs.
370
+ if [ "${FORCE_RENDER:-0}" != "1" ] && [ "$DRAFT_COUNT" -ge 3 ]; then
371
+ log "skipped: $DRAFT_COUNT drafts of $TARGET already in queue (>= 3 buffer); no render needed"
372
+ SKIPPED_CT=1
373
+ exit 0
374
+ fi
375
+
376
+ # Step 1.5: organic only — 50% rotation injects one untried clip from
377
+ # 'mixer/unproven new content/' as one of the TLH slots. "Tried once" is
378
+ # tracked by media_posts.metadata->>'unproven_clip_basename'; once that
379
+ # basename appears on any row, the clip is retired from the rotation pool.
380
+ # Override with FORCE_UNPROVEN=1 (always pick if any untried) or
381
+ # FORCE_UNPROVEN=0 (never pick). Default: probabilistic 50%.
382
+ UNPROVEN_JSON_FILE="/tmp/ig_unproven_pick_$(date +%s)_$$.json"
383
+ echo '{"use": false, "reason": "default"}' > "$UNPROVEN_JSON_FILE"
384
+ if [ "$TARGET" = "organic" ]; then
385
+ /opt/homebrew/bin/python3.11 - "$UNPROVEN_JSON_FILE" > /dev/null 2>>"$LOG_FILE" <<'PY'
386
+ import json, os, random, sys, glob
387
+ # HTTP-only (2026-06-01): used-clip basenames come from
388
+ # /api/v1/media-posts/unproven-clips. No DATABASE_URL, no psycopg2.
389
+ sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
390
+ from http_api import api_get
391
+ out_path = sys.argv[1]
392
+ # Resolve per-account unproven_dir override from config.json. If the
393
+ # account opts out (tlh.unproven_dir == null) the step short-circuits.
394
+ cfg = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
395
+ target_account_init = os.environ.get('TARGET_ACCOUNT', '').strip()
396
+ account_record_init = next(
397
+ (a for a in ((cfg.get('instagram') or {}).get('accounts') or [])
398
+ if a.get('username', '').lower() == target_account_init.lower()),
399
+ {}
400
+ )
401
+ tlh_cfg_init = account_record_init.get('tlh') or {}
402
+ if 'unproven_dir' in tlh_cfg_init:
403
+ unproven_dir_raw = tlh_cfg_init.get('unproven_dir')
404
+ unproven_dir = os.path.expanduser(unproven_dir_raw) if unproven_dir_raw else None
405
+ else:
406
+ # Default: matt_diak / matthewheartful unchanged.
407
+ unproven_dir = os.path.expanduser('~/social-autoposter/mixer/unproven new content')
408
+ result = {"use": False}
409
+ if unproven_dir is None:
410
+ result = {"use": False, "reason": "account opted out of unproven rotation (tlh.unproven_dir=null)"}
411
+ with open(out_path, 'w') as f:
412
+ json.dump(result, f, indent=2)
413
+ raise SystemExit(0)
414
+ try:
415
+ target_account = os.environ.get('TARGET_ACCOUNT', '').strip()
416
+ if not target_account:
417
+ raise SystemExit("TARGET_ACCOUNT env var missing in unproven-clip step")
418
+ _uc = api_get(
419
+ '/api/v1/media-posts/unproven-clips',
420
+ query={'target_account': target_account},
421
+ )
422
+ used = {b for b in ((_uc.get('data') or {}).get('basenames') or []) if b}
423
+ if not os.path.isdir(unproven_dir):
424
+ result = {"use": False, "reason": "unproven dir missing", "dir": unproven_dir}
425
+ else:
426
+ candidates = []
427
+ for ext in ('*.MP4', '*.mp4', '*.MOV', '*.mov', '*.m4v', '*.M4V'):
428
+ candidates.extend(glob.glob(os.path.join(unproven_dir, ext)))
429
+ untried = [p for p in candidates if os.path.basename(p) not in used]
430
+ force = os.environ.get('FORCE_UNPROVEN')
431
+ if force == '0':
432
+ roll_use = False
433
+ elif force == '1':
434
+ roll_use = True
435
+ else:
436
+ roll_use = (random.random() < 0.5)
437
+ if roll_use and untried:
438
+ pick = random.choice(untried)
439
+ result = {
440
+ "use": True,
441
+ "basename": os.path.basename(pick),
442
+ "path": pick,
443
+ "untried_count": len(untried),
444
+ "total_count": len(candidates),
445
+ "used_count": len(used),
446
+ "force": force,
447
+ }
448
+ else:
449
+ result = {
450
+ "use": False,
451
+ "reason": (
452
+ "no untried clips" if not untried
453
+ else f"coin flip skipped (force={force})"
454
+ ),
455
+ "untried_count": len(untried),
456
+ "total_count": len(candidates),
457
+ "used_count": len(used),
458
+ "force": force,
459
+ }
460
+ except Exception as e:
461
+ result = {"use": False, "reason": f"error: {e}"}
462
+ with open(out_path, 'w') as f:
463
+ json.dump(result, f, indent=2)
464
+ PY
465
+ UNPROVEN_USE=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$UNPROVEN_JSON_FILE')).get('use', False))")
466
+ if [ "$UNPROVEN_USE" = "True" ]; then
467
+ UNPROVEN_BASENAME=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$UNPROVEN_JSON_FILE'))['basename'])")
468
+ log "unproven clip injected: $UNPROVEN_BASENAME"
469
+ else
470
+ UNPROVEN_REASON=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$UNPROVEN_JSON_FILE')).get('reason', 'n/a'))")
471
+ log "unproven clip not injected: $UNPROVEN_REASON"
472
+ fi
473
+ fi
474
+
475
+ # Step 2: build prompt and spawn claude
476
+ PROMPT_FILE="/tmp/ig_render_prompt_$(date +%s)_$$.txt"
477
+
478
+ cat > "$PROMPT_FILE" <<PROMPT_EOF
479
+ You are the Instagram render-cycle agent for the social-autoposter project.
480
+ Your single job for this fire: render ONE fresh Instagram reel for posting,
481
+ following ~/social-autoposter/mixer/SKILL.md to the letter.
482
+
483
+ TARGET ACCOUNT FOR THIS RUN: ${TARGET_ACCOUNT}
484
+ This reel will be posted to the @${TARGET_ACCOUNT} Instagram account. Every
485
+ recency/exclusion list in the request envelope below is already scoped to
486
+ this account, so you can treat them as authoritative. You MUST set
487
+ target_account='${TARGET_ACCOUNT}' on the media_posts row you write.
488
+
489
+ READ THE SKILL FIRST: ~/social-autoposter/mixer/SKILL.md is your complete
490
+ creative + technical procedure. It explains the two formats (Mixer for niche
491
+ product reels, TLH for AI-lesson hook reels), the caption arc, the ffmpeg
492
+ encode + dub commands, the data.ts variant schema, and the media_posts row
493
+ shape. Follow it exactly. When SKILL and this prompt disagree, SKILL wins.
494
+
495
+ REQUEST ENVELOPE FOR THIS RUN:
496
+ $(cat "$PICK_FILE")
497
+
498
+ UNPROVEN CLIP INJECTION (organic runs only):
499
+ $(cat "$UNPROVEN_JSON_FILE")
500
+ If "use": true above, you MUST include the clip at "path" as ONE of the 3-6
501
+ TLH slots in this render, encoded via the same pure-speedup ffmpeg recipe
502
+ SKILL Section 3 step 2 uses for any raw clip from '5. time lapse hooks/'.
503
+ After insert, set metadata.unproven_clip_basename = "<basename>" on the
504
+ media_posts row. The render-cycle uses this to retire the clip from future
505
+ rotation. See SKILL Section 3 "Unproven clip injection" for details.
506
+ If "use": false, ignore this block; render normally from the existing pool.
507
+
508
+ TYPE MAPPING (do NOT swap these):
509
+ - post_type='organic' -> TLH format. AI-themed lesson, NO product mention by
510
+ name (no Fazm, no Mediar, no AppMaker, no mk0r, no studyly). 7-8s total.
511
+ project_name MUST be NULL in the media_posts row (organic content is
512
+ intentionally product-free; null is the correct attribution).
513
+ - post_type='product' -> Mixer format. The picker has already chosen which
514
+ PROJECT this product reel promotes; see selected_project in the request
515
+ envelope above (currently '${SELECTED_PROJECT}'). The variant you render
516
+ MUST belong to that project (variant.project field in data.ts). Pick from
517
+ the project's pool, oldest-rendered first. project_name MUST equal that
518
+ project string in the media_posts row.
519
+
520
+ DELIVERABLES (must all exist when you exit):
521
+ 1. ~/social-autoposter/mixer/remotion/out/post-${NNN}.mp4
522
+ 1080x1920, audio dubbed, ready to post. Filename uses post_number=${NEXT_NUM}.
523
+ 2. ~/social-autoposter/mixer/remotion/out/post-${NNN}.caption.txt
524
+ The Instagram caption story. UTF-8, plain text, no markdown.
525
+ **CAPTION HARD LIMIT: ≤ 2150 chars total** (Instagram's cap is 2200; we leave
526
+ 50 chars of safety buffer for emoji / unicode). If your draft overshoots,
527
+ tighten ruthlessly BEFORE writing the file. Verify with \`wc -c <file>\`.
528
+ The harness enforces this programmatically: if the file is > 2150 on exit, it
529
+ will spawn a focused tighten-only Claude call (up to 3 attempts); if all 3
530
+ fail, the row is flipped to status='caption_too_long' and the render fails.
531
+ 3. media_posts row with post_number=${NEXT_NUM}, status='draft',
532
+ post_type='${TARGET}', target_account='${TARGET_ACCOUNT}', and all
533
+ SKILL Section 5 columns populated (variant_id, video_path, caption_text,
534
+ source_clips, overlays, audio_source, metadata.theme_angle, etc.).
535
+ project_name='${SELECTED_PROJECT}' for product, NULL for organic.
536
+ target_account is a NOT NULL column — you MUST set it to '${TARGET_ACCOUNT}'
537
+ on this row. The post-cycle uses target_account to load the right token
538
+ and route the reel to the right Instagram account.
539
+ The caption_text column MUST match the caption.txt file exactly.
540
+
541
+ VISUAL STYLE (current as of May 7 2026, do NOT regress):
542
+ The Overlays.tsx and TimeLapseHookComposition.tsx components were rewritten
543
+ on May 7 to:
544
+ - white background, black text on title cards and overlays
545
+ - instant on: NO spring pop-in, NO fade-up, NO scale-in, NO fade-out
546
+ - elements stay solid for the full overlay duration
547
+ DO NOT modify Overlays.tsx, TimeLapseHookComposition.tsx, or MixerComposition.tsx.
548
+ If the existing components don't render the way you want for this variant, FAIL
549
+ the run and ask the user, do not "fix" the components. The components are the
550
+ deliverable contract for ALL future renders.
551
+
552
+ EXCLUSIONS (read from request envelope above; honor strictly):
553
+ - local_audio_lru: the LOCAL audio pool (mixer/audio/*.m4a), ordered
554
+ least-recently-used first. Pick the FIRST entry, the most stale local
555
+ track. Reusing a track is fine; audio repeating across reels is normal on
556
+ Instagram. NEVER download new audio, NEVER run yt-dlp, NEVER open a browser
557
+ to Instagram for audio. The pool only grows when the user manually drops a
558
+ track in. If the list is empty, FAIL the run; do not source from the network.
559
+ - used_theme_angles_14d: pick a different angle. SKILL Section 3 lists 5
560
+ acceptable AI angles; pick one not in the exclusion list.
561
+ - used_variant_ids: for product runs, exclude these globally; even within
562
+ the selected project, prefer a variant not in this list. For TLH (organic),
563
+ pick a variant_id not in this list.
564
+
565
+ ENGAGEMENT STYLE ASSIGNMENT FOR THIS RUN: ${PICKED_STYLE} (mode=${PICK_MODE})
566
+
567
+ ${STYLES_BLOCK}
568
+
569
+ You MUST stamp metadata.engagement_style='${PICKED_STYLE}' on the media_posts
570
+ row you write (in addition to caption_style, theme_angle, theme_label, etc.).
571
+ The dashboard A/B-tests on this label and the next picker round re-reads it as
572
+ performance signal, so it MUST match the assigned style verbatim. If mode=invent
573
+ (no style assigned above), invent a new ig_-prefixed snake_case style name that
574
+ describes the structural archetype of your caption (not the topic), and stamp
575
+ THAT on metadata.engagement_style. Do NOT use any non-ig_ prefixed style for
576
+ Instagram captions.
577
+
578
+ PRODUCT-PATH (post_type='product', project='${SELECTED_PROJECT}'):
579
+ The Mixer registry lives in mixer/remotion/src/mixer/data.ts. Every variant
580
+ declares a 'project' field. For this run you MUST pick a variant whose
581
+ variant.project === '${SELECTED_PROJECT}'. Variants are pre-registered in
582
+ Remotion via Root.tsx; you re-render an existing variant, write a fresh
583
+ caption targeted at the selected project, and log the row.
584
+
585
+ For mk0r: 4 niche variants (spa/autoshop/hotel/mk0r-retail). The reel shows the
586
+ mk0r workflow ("find a local business with no website -> go to mk0r.com ->
587
+ prompt it -> publish"). Each render MUST generate fresh TITLE-OVERLAY text that
588
+ fits the caption: before running npx remotion render, edit
589
+ mixer/remotion/src/mixer/data.ts and update MK0R_OVERLAY_TEXT["<picked-variant-id>"]
590
+ with three model-generated values:
591
+ - headline: 2-3 short lines, with __ACCENT__ marking the accented line
592
+ (e.g. "build an auto shop\n__ACCENT__\nin minutes")
593
+ - accentText: 1-4 words shown in accent color (e.g. "a real website")
594
+ - tagline: 3-7 word subtitle (e.g. "no agency, no template")
595
+ All three MUST describe the CAPABILITY (mk0r builds a real site fast, in one
596
+ prompt) and vary from the current defaults and across runs. Only edit the
597
+ picked variant's entry; leave the other 3 untouched.
598
+
599
+ CAPTION RULES (mk0r) -- HARD, do NOT violate:
600
+ The caption is a PRODUCT DEMO: a real local business that has no website, mk0r
601
+ builds it a real site from one prompt, and what that means for the owner. You
602
+ may reference mk0r.com plainly. You MUST NOT write any income/earnings framing:
603
+ no "\$X a month", no "they paid me", no "recurring revenue", no "signed N
604
+ clients", no "make money / side income / quit your job / flip websites", no
605
+ fabricated dollar amounts or client counts. That get-rich-quick framing tripped
606
+ a Meta fraud-and-deceptive-practices restriction on 2026-06-02 and is
607
+ permanently banned from mk0r captions. Keep it about what mk0r BUILDS, never
608
+ about money the viewer earns.
609
+
610
+ For studyly: 8 generated variants (studyly-i{1,2}-r{1,2,3,4}). Each render
611
+ MUST generate fresh overlay text that fits the caption story arc. Before
612
+ running npx remotion render, edit mixer/remotion/src/mixer/data.ts and update
613
+ STUDYLY_OVERLAY_TEXT["<picked-variant-id>"] with five model-generated values:
614
+ - headline: 2-3 short lines, with __ACCENT__ marking the accented word/phrase
615
+ on its own line (e.g. "stop wasting\n__ACCENT__\nbefore exams")
616
+ - accentText: 1-4 words shown in accent color (e.g. "the night before")
617
+ - tagline: 3-7 word subtitle under the headline (e.g. "the smarter study method")
618
+ - stepOverlay: action text shown during the guide clip, ~40 chars max
619
+ (e.g. "drop your notes into studyly.io")
620
+ - finaleOverlay: payoff text shown during the result clip, ~40 chars max
621
+ (e.g. "and actually remember it this time")
622
+ All five values MUST vary from the current defaults and from each other across
623
+ runs. Only edit the picked variant's entry; leave the other 7 untouched.
624
+ The caption arc is: a real study-method frustration (rereading / flashcards not
625
+ sticking, blanking when the wording changes) -> opens studyly.io -> the method
626
+ shift (it tests you on your OWN notes, rewording so you cant pattern-match) ->
627
+ the lesson. Reference studyly.io as the product. Do NOT fabricate specific
628
+ before/after exam scores or "failed -> passed / topped the class" miracle jumps
629
+ as a typical result; keep any outcome qualitative and personal (exaggerated-
630
+ results claims are a deceptive-practices signal, the same Meta rail that
631
+ restricted mk0r 2026-06-02).
632
+ Studyly variants are intentionally simpler/shorter (15-25s vs mk0r's 26-28s).
633
+
634
+ Pick the variant within the selected project that is least-recently-rendered
635
+ (check media_posts created_at WHERE project_name=selected_project AND variant_id IS NOT NULL).
636
+
637
+ ORGANIC-PATH (post_type='organic'):
638
+ Compose a new TLH variant. You may remix existing pre-encoded
639
+ remotion/public/mixer/tlh-*.mp4 slots (cheaper, faster) OR encode fresh raw
640
+ clips from the account's source folder if available (only if remixing
641
+ produces a stale recombination). The audio_source MUST be a LOCAL file from
642
+ local_audio_lru -- pick the least-recently-used (first) entry. NEVER source
643
+ audio from the network. The caption MUST follow SKILL Section 3 caption arc
644
+ (8 beats). Theme angle must be in SKILL Section 3 list and NOT in
645
+ used_theme_angles_14d.
646
+
647
+ PER-ACCOUNT TLH CONFIG (account_tlh_config in the envelope above):
648
+ - If account_tlh_config is non-empty, it REPLACES the SKILL Section 3 defaults
649
+ for THIS account's organic renders. Specifically:
650
+ * source_dir -> raw clip folder (use this, NOT '5. time lapse hooks/')
651
+ * variant_prefix -> variant_id prefix (e.g. 'omi-lesson-'); pick the next
652
+ free integer (omi-lesson-1, omi-lesson-2, ...).
653
+ Existing variants for this account are in
654
+ used_variant_ids; the new variant_id MUST start with
655
+ variant_prefix AND not collide with used_variant_ids.
656
+ * unproven_dir -> null means this account opts out of the unproven
657
+ rotation entirely (the harness already short-circuits
658
+ the injection step; treat 'use':false as authoritative).
659
+ * caption_opener -> override the 'here is a story.' default if set.
660
+ * story_brief -> REPLACES SKILL Section 3's AI-defeat brief. The
661
+ 8-beat structure still applies but the persona,
662
+ setup, forgetting moment, etc. come from the brief.
663
+ Voice + content come from THE BRIEF, not from
664
+ SKILL examples. SKILL examples are reference for the
665
+ default (matt_diak / matthewheartful) account.
666
+ - If account_tlh_config is empty {}, use SKILL Section 3 defaults as before
667
+ (matt_diak / matthewheartful behavior is unchanged).
668
+ - Variant encoding still uses the pure-speedup recipe in SKILL Section 3
669
+ step 2. Variant registration in data.ts is unchanged.
670
+
671
+ DO NOT post to Instagram. The post-cycle (skill/run-instagram-daily.sh)
672
+ posts separately on its own schedule. Your job ends at status='draft'.
673
+
674
+ Your final stdout line MUST be exactly one summary line in this format:
675
+ RENDERED post-${NNN} type=${TARGET} variant=<variant_id> angle="<theme_angle>"
676
+
677
+ If you fail or skip, your final stdout line MUST be:
678
+ FAILED post-${NNN} reason=<short reason>
679
+
680
+ Begin. Read the SKILL, then execute.
681
+ PROMPT_EOF
682
+
683
+ log "step 2: spawning claude -p (will run for several minutes)"
684
+
685
+ # CLAUDE_MODEL is honored if exported (override of global default in
686
+ # ~/.claude/settings.json); otherwise CLI uses settings.json default.
687
+ if ! "$REPO_DIR/scripts/run_claude.sh" "run-instagram-render" \
688
+ ${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} \
689
+ --permission-mode bypassPermissions \
690
+ --output-format stream-json --verbose \
691
+ -p "$(cat "$PROMPT_FILE")" >>"$LOG_FILE" 2>&1; then
692
+ rc=$?
693
+ log "claude exited rc=$rc"
694
+ rm -f "$PROMPT_FILE"
695
+ if [ "$rc" -eq 79 ]; then
696
+ log "claude blocked by quota stamp; will retry next cycle"
697
+ SKIPPED_CT=1
698
+ exit 0
699
+ fi
700
+ log "render failed"
701
+ FAILED_CT=1
702
+ exit 1
703
+ fi
704
+ rm -f "$PROMPT_FILE"
705
+
706
+ # Step 3: verify deliverables
707
+ OUT_MP4="$REPO_DIR/mixer/remotion/out/post-${NNN}.mp4"
708
+ OUT_CAP="$REPO_DIR/mixer/remotion/out/post-${NNN}.caption.txt"
709
+
710
+ log "step 3: verifying deliverables"
711
+ log " expected: $OUT_MP4"
712
+ log " expected: $OUT_CAP"
713
+
714
+ if [ ! -f "$OUT_MP4" ]; then
715
+ log "ERROR: $OUT_MP4 missing"
716
+ FAILED_CT=1
717
+ exit 1
718
+ fi
719
+ if [ ! -f "$OUT_CAP" ]; then
720
+ log "ERROR: $OUT_CAP missing"
721
+ FAILED_CT=1
722
+ exit 1
723
+ fi
724
+
725
+ ROW_OK=$(/opt/homebrew/bin/python3.11 - "$NEXT_NUM" "$TARGET" 2>>"$LOG_FILE" <<'PY'
726
+ import os, sys
727
+ # HTTP-only (2026-06-01): row check via /api/v1/media-posts/by-number/<n>.
728
+ # No DATABASE_URL, no psycopg2. ok_on_404 lets us print MISSING ourselves.
729
+ sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
730
+ from http_api import api_get
731
+ resp = api_get(f"/api/v1/media-posts/by-number/{int(sys.argv[1])}", ok_on_404=True)
732
+ row = None if resp.get('_not_found') else (resp.get('data') or {}).get('media_post')
733
+ if not row:
734
+ print("MISSING")
735
+ elif row.get('post_type') != sys.argv[2]:
736
+ print(f"BAD_TYPE got={row.get('post_type')} want={sys.argv[2]}")
737
+ elif row.get('status') != 'draft':
738
+ print(f"BAD_STATUS got={row.get('status')} want=draft")
739
+ else:
740
+ print(f"OK variant={row.get('variant_id')}")
741
+ PY
742
+ )
743
+
744
+ case "$ROW_OK" in
745
+ OK*) log "DB row OK: $ROW_OK" ;;
746
+ *) log "ERROR: DB row check failed: $ROW_OK"; FAILED_CT=1; exit 1 ;;
747
+ esac
748
+
749
+ VARIANT=$(echo "$ROW_OK" | sed 's/^OK variant=//')
750
+
751
+ # Step 3.5: caption length gate (HARD LIMIT 2150 chars).
752
+ # IG's actual limit is 2200; we keep a 50-char safety buffer for emoji/unicode.
753
+ # If over: spawn focused tighten-only claude calls (max 3 attempts).
754
+ # If still over after 3 attempts: flip row to status='caption_too_long' and
755
+ # exit non-zero. The picker filters by status='draft' so the bad row is
756
+ # automatically skipped; the next render fire creates a fresh draft.
757
+ # Never auto-truncate -- silent author-voice loss is worse than a failed render.
758
+ CAP_LIMIT=2150
759
+ CAP_LEN=$(wc -c < "$OUT_CAP" | tr -d ' ')
760
+ log "step 3.5: caption length check len=${CAP_LEN} limit=${CAP_LIMIT}"
761
+
762
+ if [ "$CAP_LEN" -gt "$CAP_LIMIT" ]; then
763
+ log "caption over limit (${CAP_LEN} > ${CAP_LIMIT}); spawning tighten loop (max 3 attempts)"
764
+ attempt=0
765
+ while [ "$attempt" -lt 3 ] && [ "$CAP_LEN" -gt "$CAP_LIMIT" ]; do
766
+ attempt=$((attempt + 1))
767
+ log " tighten attempt ${attempt}/3 (current len=${CAP_LEN})"
768
+
769
+ TIGHTEN_PROMPT=$(mktemp /tmp/ig_tighten_prompt_XXXXXX.txt)
770
+ NEW_CAP_OUT=$(mktemp /tmp/ig_tighten_out_XXXXXX.txt)
771
+
772
+ cat > "$TIGHTEN_PROMPT" <<TIGHTEN_EOF
773
+ You are the caption-tightening agent for an Instagram reel.
774
+
775
+ Your single job: rewrite the caption below so total length is <= ${CAP_LIMIT}
776
+ characters, while preserving the voice and ALL 8 beats of the caption arc
777
+ (opener "here is a story.", age+setup, wrong-about-AI moment, breaking event,
778
+ felt sense, workflow change, contrarian lesson in one sharp line, closing
779
+ instruction). For Mixer/product captions, preserve the product-demo structure
780
+ and the plain product reference (mk0r.com / studyly.io) if present. NEVER add
781
+ income/earnings framing ("\$X a month", "they paid me", "recurring revenue",
782
+ "signed N clients") to an mk0r caption while tightening -- that framing is
783
+ banned (Meta fraud restriction 2026-06-02).
784
+
785
+ RULES (hard):
786
+ - Total length MUST be <= ${CAP_LIMIT} chars. Count and verify before responding.
787
+ - Keep ALL beats. Do NOT drop a beat to fit.
788
+ - Preserve the voice: lowercase, plain, no markdown. Keep existing emoji.
789
+ - Cut adjectives, collapse compound sentences, drop redundant examples,
790
+ prefer "i was tired" over "i was tired in a way that didn't show up in a paycheck".
791
+ - Output ONLY the rewritten caption. No prose around it. No 'here is the
792
+ rewritten caption' preamble. No backticks. No commentary. Just the caption text.
793
+
794
+ CURRENT CAPTION (length=${CAP_LEN}, must shrink to <= ${CAP_LIMIT}):
795
+ ---BEGIN---
796
+ $(cat "$OUT_CAP")
797
+ ---END---
798
+
799
+ Output the tightened caption now. Just the text body. Nothing else.
800
+ TIGHTEN_EOF
801
+
802
+ if "$REPO_DIR/scripts/run_claude.sh" "run-instagram-render-tighten" \
803
+ ${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} \
804
+ --permission-mode bypassPermissions \
805
+ -p "$(cat "$TIGHTEN_PROMPT")" > "$NEW_CAP_OUT" 2>>"$LOG_FILE"; then
806
+ # run_claude.sh prepends a JSON session-log line; strip any leading
807
+ # JSON-looking line before treating the rest as the new caption.
808
+ # The actual caption is everything except trailing JSON metadata.
809
+ /opt/homebrew/bin/python3.11 - "$NEW_CAP_OUT" "$OUT_CAP" <<'PY'
810
+ import sys, json, re
811
+ raw = open(sys.argv[1]).read()
812
+ # claude -p output: caption text, then possibly a trailing JSON line from
813
+ # log_claude_session.py. Strip any line that parses as a single JSON object
814
+ # containing 'session_id' or 'logged' keys (the session marker).
815
+ lines = raw.splitlines(keepends=True)
816
+ out = []
817
+ for ln in lines:
818
+ stripped = ln.strip()
819
+ if stripped.startswith('{') and stripped.endswith('}'):
820
+ try:
821
+ j = json.loads(stripped)
822
+ if isinstance(j, dict) and ('session_id' in j or 'logged' in j):
823
+ continue # drop the session marker line
824
+ except Exception:
825
+ pass
826
+ out.append(ln)
827
+ text = ''.join(out).strip() + '\n'
828
+ open(sys.argv[2], 'w').write(text)
829
+ PY
830
+ CAP_LEN=$(wc -c < "$OUT_CAP" | tr -d ' ')
831
+ log " attempt ${attempt} result: new len=${CAP_LEN}"
832
+ else
833
+ log " attempt ${attempt} failed: claude exited non-zero"
834
+ fi
835
+ rm -f "$TIGHTEN_PROMPT" "$NEW_CAP_OUT"
836
+ done
837
+
838
+ if [ "$CAP_LEN" -gt "$CAP_LIMIT" ]; then
839
+ log "ERROR: caption still over limit after 3 tighten attempts (final len=${CAP_LEN})"
840
+ log "flipping media_posts row to status='caption_too_long' so picker skips it"
841
+ FAILED_CT=1
842
+ /opt/homebrew/bin/python3.11 - "$NEXT_NUM" "$CAP_LEN" 2>>"$LOG_FILE" <<'PY'
843
+ import os, sys
844
+ # HTTP-only (2026-06-01): status flip via PATCH /api/v1/media-posts/by-number.
845
+ sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
846
+ from http_api import api_patch
847
+ api_patch(
848
+ f"/api/v1/media-posts/by-number/{int(sys.argv[1])}",
849
+ {"action": "caption_too_long", "caption_len": int(sys.argv[2])},
850
+ )
851
+ print(f"flipped post-{int(sys.argv[1]):03d} to caption_too_long (len={sys.argv[2]})")
852
+ PY
853
+ exit 1
854
+ fi
855
+
856
+ # Sync tightened caption into DB row.
857
+ log "tighten loop succeeded; syncing caption_text column"
858
+ /opt/homebrew/bin/python3.11 - "$NEXT_NUM" "$OUT_CAP" 2>>"$LOG_FILE" <<'PY'
859
+ import os, sys
860
+ # HTTP-only (2026-06-01): caption sync via PATCH /api/v1/media-posts/by-number.
861
+ sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
862
+ from http_api import api_patch
863
+ cap = open(sys.argv[2]).read()
864
+ api_patch(
865
+ f"/api/v1/media-posts/by-number/{int(sys.argv[1])}",
866
+ {"action": "sync_caption", "caption_text": cap},
867
+ )
868
+ print(f"synced post-{int(sys.argv[1]):03d} caption_text (len={len(cap)})")
869
+ PY
870
+ log "caption tightened OK (final len=${CAP_LEN})"
871
+ fi
872
+
873
+ log "=== rendered post-${NNN} (${TARGET}, variant=${VARIANT}) successfully ==="
874
+ POSTED_CT=1
875
+ exit 0