@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,592 @@
1
+ #!/bin/bash
2
+ # Social Autoposter - Original Twitter thread poster
3
+ #
4
+ # Picks one (project, topic_angle) target via pick_twitter_thread_target.py,
5
+ # which enforces:
6
+ # 1. Hard global cap of 3 original threads per UTC calendar day.
7
+ # 2. Per-(project, topic_angle) floor window (default 2 days).
8
+ # 3. Per-project inverse-share weighting (don't pile on one project).
9
+ #
10
+ # Then spawns a Claude session with the twitter-harness browser to research, draft, and post
11
+ # ONE original thread (1-6 tweets, chained as a Twitter thread).
12
+ #
13
+ # Called by launchd. See com.m13v.social-twitter-threads.plist.
14
+ # Mirror of skill/run-reddit-threads.sh; deviations are commented.
15
+
16
+ set -euo pipefail
17
+
18
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
19
+
20
+ # Cycle ID for cross-cycle cost accounting (see run-twitter-cycle.sh for the
21
+ # same pattern). Stamps claude_sessions.cycle_id so get_run_cost.py --cycle-id
22
+ # reports per-cycle spend instead of bleeding across concurrent runs.
23
+ BATCH_ID="${BATCH_ID:-twthr-$(date +%Y%m%d-%H%M%S)}"
24
+ export BATCH_ID
25
+ export SA_CYCLE_ID="$BATCH_ID"
26
+
27
+ REPO_DIR="$HOME/social-autoposter"
28
+ CONFIG_FILE="$REPO_DIR/config.json"
29
+ SKILL_FILE="$REPO_DIR/SKILL.md"
30
+ LOG_DIR="$REPO_DIR/skill/logs"
31
+ mkdir -p "$LOG_DIR"
32
+ LOG_FILE="$LOG_DIR/run-twitter-threads-$(date +%Y-%m-%d_%H%M%S).log"
33
+
34
+ echo "=== Twitter Threads Run: $(date) ===" | tee "$LOG_FILE"
35
+ RUN_START_EPOCH=$(date +%s)
36
+
37
+ # Diagnostic trap (parallel to reddit version): log line + cmd before set -e exits.
38
+ trap 'rc=$?; echo "SCRIPT DIED line=$LINENO cmd=\"$BASH_COMMAND\" exit=$rc" | tee -a "$LOG_FILE" >&2' ERR
39
+
40
+ # Pipeline lock at top. Browser lock acquired later, just before the Claude/MCP step.
41
+ source "$REPO_DIR/skill/lock.sh"
42
+ # Harness-only browser bootstrap (twitter-agent path fully removed 2026-05-19).
43
+ # Sets MCP_CONFIG_FILE + BROWSER_INSTRUCTIONS for the Claude SDK call below.
44
+ source "$REPO_DIR/skill/lib/twitter-backend.sh"
45
+ acquire_lock "twitter-threads" 600
46
+
47
+ # Engagement styles
48
+ source "$REPO_DIR/skill/styles.sh"
49
+ STYLES_BLOCK=$(generate_styles_block twitter posting)
50
+
51
+ # Pick target. The picker enforces the daily cap; exit 3 = cap reached, exit 2 = no eligible angle.
52
+ set +e
53
+ TARGET_JSON=$(/usr/bin/python3 "$REPO_DIR/scripts/pick_twitter_thread_target.py" --json 2>&1)
54
+ PICK_RC=$?
55
+ set -e
56
+ if [ "$PICK_RC" -eq 3 ]; then
57
+ echo "DAILY_CAP_REACHED: skipping this fire (3 threads per UTC day)." | tee -a "$LOG_FILE"
58
+ exit 0
59
+ fi
60
+ if [ "$PICK_RC" -eq 2 ]; then
61
+ echo "NO_ELIGIBLE_TARGET: every (project,angle) is inside its floor window. Stopping." | tee -a "$LOG_FILE"
62
+ exit 0
63
+ fi
64
+ if [ "$PICK_RC" -ne 0 ]; then
65
+ echo "PICKER_FAILED rc=$PICK_RC output=$TARGET_JSON" | tee -a "$LOG_FILE"
66
+ exit 0
67
+ fi
68
+
69
+ PROJECT=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['project']['name'])")
70
+ TOPIC_ANGLE=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['topic_angle'])")
71
+ DAILY_COUNT=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['daily_count_today'])")
72
+ DAILY_CAP=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['daily_cap'])")
73
+
74
+ echo "Target: project=$PROJECT" | tee -a "$LOG_FILE"
75
+ echo "Angle: $TOPIC_ANGLE" | tee -a "$LOG_FILE"
76
+ echo "Daily: $DAILY_COUNT/$DAILY_CAP posts today (UTC)" | tee -a "$LOG_FILE"
77
+
78
+ # Posting account
79
+ POST_ACCOUNT=$(/usr/bin/python3 -c "
80
+ import json
81
+ c = json.load(open('$CONFIG_FILE'))
82
+ print((c.get('accounts',{}).get('twitter',{}).get('handle','@m13v_')).lstrip('@'))
83
+ ")
84
+
85
+ # Per-project context block (same JSON-driven shape as reddit version).
86
+ # Reads twitter_threads first, falls back to threads if a key is absent there.
87
+ export PROJECT_ENV="$PROJECT"
88
+ export CONFIG_PATH="$CONFIG_FILE"
89
+ CONTEXT_BLOCK=$(/usr/bin/python3 <<'PYEOF'
90
+ import json, datetime, os
91
+ CONFIG = os.environ['CONFIG_PATH']
92
+ name = os.environ['PROJECT_ENV']
93
+ c = json.load(open(CONFIG))
94
+ proj = next((p for p in c['projects'] if p['name'] == name), None)
95
+ if not proj:
96
+ print("(project not found)")
97
+ raise SystemExit(0)
98
+
99
+ tt = proj.get('twitter_threads') or {}
100
+ t = proj.get('threads') or {} # fallback for content_sources/dynamic_context
101
+ lp = proj.get('landing_pages') or {}
102
+
103
+ def first(*keys):
104
+ """Return the first non-empty value across (tt, t) for any of the given keys."""
105
+ for src in (tt, t):
106
+ for k in keys:
107
+ v = src.get(k)
108
+ if v:
109
+ return v
110
+ return None
111
+
112
+ out = []
113
+ out.append(f"Project: {proj['name']}")
114
+ out.append(f"Description: {proj.get('description','').strip()}")
115
+ if proj.get('website'): out.append(f"Website: {proj['website']}")
116
+ if lp.get('base_url'): out.append(f"Base URL: {lp['base_url']}")
117
+ if proj.get('content_angle'):
118
+ out.append(f"\nContent angle: {proj['content_angle']}")
119
+
120
+ voice = proj.get('voice')
121
+ if voice:
122
+ out.append(f"\nVoice tone: {voice.get('tone','')}")
123
+ if voice.get('never'):
124
+ out.append("Voice never: " + "; ".join(voice['never']))
125
+
126
+ # Dynamic day counter
127
+ dc = first('dynamic_context') or {}
128
+ day = dc.get('day_counter')
129
+ if day:
130
+ base = day['base_count']
131
+ ref = datetime.date.fromisoformat(day['ref_date'])
132
+ days = (datetime.date.today() - ref).days
133
+ count = base + days
134
+ label = day.get('label','day count')
135
+ out.append(f"\nLive {label}: {count}+")
136
+ for f in dc.get('static_facts') or []:
137
+ out.append(f"- {f}")
138
+
139
+ # Source paths
140
+ out.append("\n## Product source (READ for context before drafting)")
141
+ repo = lp.get('repo','')
142
+ if repo:
143
+ rp = os.path.expanduser(repo)
144
+ status = "" if os.path.isdir(rp) else " [MISSING ON DISK]"
145
+ out.append(f"- Website repo: {rp}{status}")
146
+ for s in lp.get('product_source') or []:
147
+ p = os.path.expanduser(s.get('path',''))
148
+ status = "" if os.path.isdir(p) else " [MISSING]"
149
+ desc = s.get('description','').strip()
150
+ out.append(f"- {p}{status}\n {desc}")
151
+
152
+ # content_sources
153
+ cs = first('content_sources') or {}
154
+ if cs.get('guide_dir'):
155
+ gd = os.path.expanduser(cs['guide_dir'])
156
+ out.append(f"\nGuide dir (read page.tsx files here for specific detail): {gd}")
157
+ if cs.get('link_base'):
158
+ out.append(f"Link base for any URL you include: {cs['link_base']}")
159
+ if cs.get('readme_url'):
160
+ out.append(f"README url: {cs['readme_url']}")
161
+ if cs.get('read_instructions'):
162
+ out.append(cs['read_instructions'])
163
+
164
+ print("\n".join(out))
165
+ PYEOF
166
+ )
167
+
168
+ echo "--- Context block ---" | tee -a "$LOG_FILE"
169
+ echo "$CONTEXT_BLOCK" | tee -a "$LOG_FILE"
170
+ echo "---------------------" | tee -a "$LOG_FILE"
171
+
172
+ # Prompt context loaders below all route through scripts/twitter_threads_helper.py
173
+ # (HTTP /api/v1/posts) instead of three direct psql one-liners as of 2026-05-18.
174
+ # Filters preserved byte-equivalent: thread_url = our_url, project + window +
175
+ # status WHERE clauses. Post 2026-05-23, the '(mention - no original post)'
176
+ # placeholder rows no longer live in `posts` (they moved to the dedicated
177
+ # `mentions` table), so the legacy NOT ILIKE '(mention%' guard was dropped.
178
+
179
+ # Recent originals by us in last 14 days for THIS project (avoid repeats).
180
+ RECENT_POSTS=$(python3 "$REPO_DIR/scripts/twitter_threads_helper.py" \
181
+ recent-posts --project "$PROJECT" --days 14 --limit 10 \
182
+ 2>/dev/null || echo "(api error)")
183
+
184
+ # Recent engagement styles for this project on Twitter.
185
+ RECENT_STYLES=$(python3 "$REPO_DIR/scripts/twitter_threads_helper.py" \
186
+ recent-styles --project "$PROJECT" --limit 5 \
187
+ 2>/dev/null || echo "(api error)")
188
+
189
+ # Top performers (tone calibration) — composite (upvotes + 3*comments + views/100).
190
+ TOP_POSTS=$(python3 "$REPO_DIR/scripts/twitter_threads_helper.py" \
191
+ top-posts --project "$PROJECT" --limit 8 \
192
+ 2>/dev/null || echo "(api error)")
193
+
194
+ # Structured output schema. The model returns a "tweets" array (1-6 items)
195
+ # representing a single chained Twitter thread, plus the same compliance fields
196
+ # as the reddit version.
197
+ RESULT_SCHEMA='{"type":"object","properties":{"research_files_read":{"type":"array","items":{"type":"string"}},"topic_angle":{"type":"string"},"engagement_style":{"type":"string"},"tweets":{"type":"array","minItems":1,"maxItems":6,"items":{"type":"string","maxLength":280},"description":"1-6 chained tweets. First is the hook, each <=280 chars."},"permalink":{"type":["string","null"],"description":"URL of the FIRST tweet in the thread, or null if aborted"},"abort_reason":{"type":["string","null"]},"source_summary":{"type":"string","description":"Rich summary: (a) topic angle and why, (b) source files read, (c) specific details used"}},"required":["research_files_read","topic_angle","engagement_style","tweets","permalink","abort_reason","source_summary"]}'
198
+
199
+ # Pre-generate session id so the prompt's inline INSERT can stamp it.
200
+ export CLAUDE_SESSION_ID=$(uuidgen | tr 'A-Z' 'a-z')
201
+
202
+ # --- Phase 0: link resolution (mirrors run-twitter-cycle.sh Phase 2b-gen) ---
203
+ # Resolve LINK_URL + LINK_SOURCE BEFORE acquiring the browser lock, so the
204
+ # 10-40 min generate_page.py mint (when A/B lands in the gen lane) does not
205
+ # block other twitter pipelines on the browser. Reuses scripts/twitter_gen_links.py
206
+ # unchanged; we feed it a single-candidate plan synthesised from the
207
+ # (project, topic_angle) the picker already chose.
208
+ THREADS_PLAN_FILE="/tmp/twitter_threads_link_$(date +%s)_$$.json"
209
+ PROJECT_ENV="$PROJECT" TOPIC_ANGLE_ENV="$TOPIC_ANGLE" CONFIG_PATH="$CONFIG_FILE" \
210
+ PLAN_FILE_ENV="$THREADS_PLAN_FILE" \
211
+ /usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE"
212
+ import json, os, re
213
+
214
+ CONFIG = os.environ['CONFIG_PATH']
215
+ project = os.environ['PROJECT_ENV']
216
+ topic_angle = os.environ['TOPIC_ANGLE_ENV']
217
+ plan_file = os.environ['PLAN_FILE_ENV']
218
+
219
+ c = json.load(open(CONFIG))
220
+ proj = next((p for p in c['projects'] if p['name'] == project), None)
221
+ lp = (proj or {}).get('landing_pages') or {}
222
+ has_lp = bool(lp.get('repo') and lp.get('base_url'))
223
+
224
+ def slugify(s):
225
+ s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-')
226
+ return s[:50].rstrip('-')
227
+
228
+ slug = slugify(topic_angle)
229
+ keyword = topic_angle.strip()
230
+
231
+ plan = {"candidates": [{
232
+ "candidate_id": 0,
233
+ "matched_project": project,
234
+ "has_landing_pages": has_lp,
235
+ "link_keyword": keyword,
236
+ "link_slug": slug,
237
+ }]}
238
+ with open(plan_file, 'w') as f:
239
+ json.dump(plan, f)
240
+ print(f"[link-prep] project={project!r} has_lp={has_lp} slug={slug!r}")
241
+ PYEOF
242
+
243
+ echo "[link-gen] running twitter_gen_links.py for ${PROJECT} (no browser lock held)..." | tee -a "$LOG_FILE"
244
+ /usr/bin/python3 "$REPO_DIR/scripts/twitter_gen_links.py" --plan "$THREADS_PLAN_FILE" 2>&1 | tee -a "$LOG_FILE"
245
+ GEN_EXIT=${PIPESTATUS[0]:-1}
246
+ if [ "$GEN_EXIT" -ne 0 ]; then
247
+ echo "[link-gen] WARN: twitter_gen_links.py exited $GEN_EXIT, continuing with whatever link it set" | tee -a "$LOG_FILE"
248
+ fi
249
+
250
+ LINK_URL=$(/usr/bin/python3 -c "
251
+ import json
252
+ try:
253
+ p = json.load(open('$THREADS_PLAN_FILE'))
254
+ print((p.get('candidates') or [{}])[0].get('link_url') or '')
255
+ except Exception:
256
+ print('')
257
+ ")
258
+ LINK_SOURCE=$(/usr/bin/python3 -c "
259
+ import json
260
+ try:
261
+ p = json.load(open('$THREADS_PLAN_FILE'))
262
+ print((p.get('candidates') or [{}])[0].get('link_source') or '')
263
+ except Exception:
264
+ print('')
265
+ ")
266
+ rm -f "$THREADS_PLAN_FILE"
267
+ export LINK_URL LINK_SOURCE
268
+ echo "[link-gen] resolved LINK_URL='${LINK_URL}' LINK_SOURCE='${LINK_SOURCE}'" | tee -a "$LOG_FILE"
269
+
270
+ # Build the prompt rule for tweet 1's link. Mandatory when we resolved a URL,
271
+ # omitted otherwise (e.g. project has no website AND no landing_pages config).
272
+ if [ -n "$LINK_URL" ]; then
273
+ LINK_LEN=${#LINK_URL}
274
+ TWEET1_BUDGET=$(( 280 - LINK_LEN - 1 ))
275
+ LINK_RULE="MANDATORY LINK: end your FIRST tweet with EXACTLY this URL preceded by a single space: ${LINK_URL}
276
+ - Reserve room: tweet 1 text BEFORE the URL must be <= ${TWEET1_BUDGET} chars (the URL itself is ${LINK_LEN} chars).
277
+ - Do NOT paraphrase, shorten, or wrap the URL. Do NOT include the link in tweets 2+ (X downranks link-heavy threads).
278
+ - The shell verifies post-flight that tweets[0] ends with this exact URL; if not, the post is logged with link_source='link_missing'."
279
+ else
280
+ LINK_RULE="No link: this project has no website or landing_pages configured this cycle. Do NOT include any URL in any tweet."
281
+ fi
282
+ export LINK_RULE
283
+
284
+ # Acquire browser lock right before MCP step.
285
+ acquire_lock "twitter-browser" 3600
286
+ ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
287
+
288
+ # Campaign wiring: pre-submit suffix injection (Twitter has no edit API, so we
289
+ # can't mirror reddit-threads' post-submit edit pattern; instead we instruct the
290
+ # model to end the LAST tweet with the literal suffix and verify post-flight).
291
+ # Dice are rolled in Python before Claude runs. campaigns.posts_made is bumped
292
+ # only on a verified-suffix outcome (parallels reddit-threads' verified-edit
293
+ # semantics). Multi-suffix concatenation matches post_reddit.py behavior.
294
+ # HTTP-only lane (2026-06-01): active twitter campaigns come from the s4l.ai
295
+ # API (/api/v1/campaigns?status=active&platform=twitter&has_suffix=true&
296
+ # with_budget_remaining=true), mirroring post_reddit.load_active_reddit_campaigns.
297
+ # No DATABASE_URL, no db.get_conn(), no fallback.
298
+ CAMPAIGN_ENV=$(/usr/bin/python3 <<'PYEOF'
299
+ import json, os, random, sys
300
+ sys.path.insert(0, os.path.join(os.environ.get("HOME",""), "social-autoposter", "scripts"))
301
+ from http_api import api_get
302
+
303
+ resp = api_get("/api/v1/campaigns", query={
304
+ "status": "active",
305
+ "platform": "twitter",
306
+ "has_suffix": "true",
307
+ "with_budget_remaining": "true",
308
+ "limit": 500,
309
+ })
310
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
311
+
312
+ applied_ids = []
313
+ suffix_parts = []
314
+ for r in rows:
315
+ cid = int(r["id"])
316
+ suffix = r.get("suffix")
317
+ rate = float(r.get("sample_rate") if r.get("sample_rate") is not None else 1.0)
318
+ if suffix and random.random() < rate:
319
+ applied_ids.append(cid)
320
+ suffix_parts.append(suffix)
321
+
322
+ print(json.dumps({
323
+ "ids_csv": ",".join(str(i) for i in applied_ids),
324
+ "suffix": "".join(suffix_parts),
325
+ }))
326
+ PYEOF
327
+ )
328
+ CAMPAIGN_IDS=$(/usr/bin/python3 -c "import json,sys; print(json.loads(sys.stdin.read())['ids_csv'])" <<< "$CAMPAIGN_ENV")
329
+ CAMPAIGN_SUFFIX=$(/usr/bin/python3 -c "import json,sys; print(json.loads(sys.stdin.read())['suffix'])" <<< "$CAMPAIGN_ENV")
330
+
331
+ if [ -n "$CAMPAIGN_IDS" ]; then
332
+ # Build the prompt block. The suffix is wrapped in backticks for the model's
333
+ # benefit but the model must NOT include the backticks in the actual tweet.
334
+ CAMPAIGN_BLOCK="## ACTIVE CAMPAIGN ATTRIBUTION (mandatory, non-negotiable)
335
+
336
+ The LAST tweet of your thread MUST end with EXACTLY this literal suffix (preserve any leading whitespace, do not include the surrounding backticks):
337
+ \`${CAMPAIGN_SUFFIX}\`
338
+
339
+ Do not paraphrase, translate, capitalize, punctuate, or wrap it in quotes.
340
+ Reserve enough characters in the LAST tweet so the suffix fits within the 280-char cap.
341
+ The same text must appear (a) at the end of the last entry in your tweets array AND (b) at the end of the actual posted tweet (these will be verified)."
342
+ echo "[campaign-twitter-thread] applying campaign_ids=${CAMPAIGN_IDS} suffix='${CAMPAIGN_SUFFIX}'" | tee -a "$LOG_FILE"
343
+ else
344
+ CAMPAIGN_BLOCK=""
345
+ echo "[campaign-twitter-thread] no active campaigns fired (or none active)" | tee -a "$LOG_FILE"
346
+ fi
347
+
348
+ # Capture Claude output to a temp file so non-zero exit doesn't swallow stderr.
349
+ CLAUDE_TMP=$(mktemp)
350
+ set +e
351
+ "$REPO_DIR/scripts/run_claude.sh" "run-twitter-threads" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" -p --output-format json --json-schema "$RESULT_SCHEMA" "${BROWSER_INSTRUCTIONS}
352
+
353
+ You are posting an ORIGINAL Twitter thread for the ${PROJECT} project as @${POST_ACCOUNT}.
354
+
355
+ ## Config & Rules
356
+ Read $SKILL_FILE for content rules and anti-AI-detection checklist.
357
+ You may also open $CONFIG_FILE for the full project block if you need anything not summarized below.
358
+
359
+ ## Target
360
+ Project: ${PROJECT}
361
+ Topic angle (use this, do NOT pick a different one): ${TOPIC_ANGLE}
362
+
363
+ ## Project context (live-assembled)
364
+ ${CONTEXT_BLOCK}
365
+
366
+ ${STYLES_BLOCK}
367
+
368
+ ## Recent originals by us for ${PROJECT} (last 14d, DO NOT recycle phrasing or closer)
369
+ Each entry shows: first 120 chars |ENDING| last 80 chars. Vary your closer.
370
+ ${RECENT_POSTS}
371
+
372
+ ## Recent engagement styles for ${PROJECT} on Twitter (avoid repeating back-to-back)
373
+ ${RECENT_STYLES}
374
+
375
+ ## Top performing ${PROJECT} originals (match tone)
376
+ ${TOP_POSTS}
377
+
378
+ ${CAMPAIGN_BLOCK}
379
+
380
+ ## Workflow
381
+
382
+ 1. RESEARCH (required): Read the product source paths listed in the context block. Pull 1-2 concrete, specific details from the source code or docs to anchor the thread. Generic threads get ignored.
383
+
384
+ 2. SCAN THE TIMELINE: Navigate to https://x.com/home using the navigate tool from the BROWSER BACKEND block above to get a quick read on what is being said in our space today.
385
+ - Read 5-10 recent tweets from accounts in adjacent topics (other indie devs, AI tooling, macOS automation, whatever fits the project).
386
+ - Note the current vocabulary, hot takes, and any thread that is getting unusually high engagement.
387
+ - Close the tab.
388
+
389
+ 3. DRAFT the thread.
390
+ - 1 to 6 tweets. Each <= 280 characters (hard cap). The first tweet must work as a standalone hook (people may only see that one).
391
+ - Use the assigned topic_angle (above). Pick an engagement_style from the styles list that fits and is NOT one of the last 3 used for this project.
392
+ - No em dashes anywhere. Commas, periods, plain '-' only.
393
+ - No hashtag spam. At most ONE hashtag total in the entire thread, only if it is genuinely the topical tag people search.
394
+ - No emojis at the start of a tweet. At most one per tweet, and only if it adds meaning.
395
+ - Lowercase first character on most tweets feels natural on X. Do not uniformly lowercase every sentence (that is an AI tell). Mix it.
396
+ - At least one imperfection (sentence fragment, aside, run-on) somewhere in the thread.
397
+ - Ground at least one claim in a specific detail from the source you read in step 1.
398
+ - VARY YOUR CLOSER. Banned closers: 'curious if anyone', 'anyone else', 'thoughts?', 'has anyone'. Sometimes end with a statement, sometimes mid-thought, sometimes a specific question.
399
+ - ${LINK_RULE}
400
+
401
+ 4. POST via the browser tools from the BROWSER BACKEND block above:
402
+ - Navigate to https://x.com/compose/post.
403
+ - Fill the first tweet into the textarea selected by [data-testid='tweetTextarea_0']. If the contenteditable does not accept .value=, use the type tool from the BROWSER BACKEND block to type the text directly into the focused element.
404
+ - For each subsequent tweet (if any): click the button with data-testid='addButton' (the small '+' that appends a new tweet to the chain), then fill its textarea. The new textarea will be data-testid='tweetTextarea_1', then 'tweetTextarea_2', etc.
405
+ - When all tweets are filled, click the button with data-testid='tweetButton' (label varies between 'Post' and 'Post all'). Wait 4 seconds.
406
+ - Capture the URL of the FIRST posted tweet:
407
+ - Navigate to https://x.com/${POST_ACCOUNT} and read the most recent pinned-or-top tweet's permalink (browser_evaluate: document.querySelector('article a[href*=\"/status/\"]').href). Confirm its text matches the first tweet you posted.
408
+ - Close the tab.
409
+
410
+ 5. DO NOT touch the database. The shell wrapper handles the INSERT after you return.
411
+ IMPORTANT: tweets, permalink, engagement_style, source_summary in your JSON output are what get logged. Make source_summary rich.
412
+
413
+ 6. Return the structured JSON output. Every field is required. permalink = URL of the first tweet if posted, null if aborted. tweets array must contain the EXACT text of each tweet posted (no markdown, no additions).
414
+
415
+ CRITICAL: NEVER use em dashes. Use commas, plain hyphens, or separate sentences.
416
+ CRITICAL: Each tweet <=280 chars. The schema enforces this; do not exceed.
417
+ CRITICAL: Use ONLY the browser tools listed in the BROWSER BACKEND block above (the Twitter-dedicated MCP for this run). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, or mcp__macos-use__* tools.
418
+ CRITICAL: Close browser tabs after each navigation (browser_tabs action 'close').
419
+ CRITICAL: If a browser call times out, wait 30s and retry up to 3 times." > "$CLAUDE_TMP" 2>&1
420
+ CLAUDE_RC=$?
421
+ set -e
422
+ CLAUDE_OUTPUT=$(cat "$CLAUDE_TMP")
423
+ rm -f "$CLAUDE_TMP"
424
+
425
+ echo "$CLAUDE_OUTPUT" | tee -a "$LOG_FILE"
426
+ if [ "$CLAUDE_RC" -ne 0 ]; then
427
+ echo "RUN_CLAUDE_NONZERO_EXIT rc=$CLAUDE_RC (output above is full stderr+stdout)" | tee -a "$LOG_FILE"
428
+ fi
429
+
430
+ # Extract structured_output. claude -p --output-format json wraps results.
431
+ PARSED=$(/usr/bin/python3 -c "
432
+ import json,sys
433
+ try:
434
+ raw = sys.stdin.read()
435
+ d, _ = json.JSONDecoder().raw_decode(raw)
436
+ so = d.get('structured_output') or d
437
+ print(json.dumps(so))
438
+ except Exception as e:
439
+ print(json.dumps({'_parse_error': str(e)}))
440
+ " <<< "$CLAUDE_OUTPUT" 2>/dev/null)
441
+
442
+ PERMALINK=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('permalink') or 'null')" <<< "$PARSED" 2>/dev/null)
443
+ ABORT_REASON=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('abort_reason') or '')" <<< "$PARSED" 2>/dev/null)
444
+
445
+ # Step compliance summary
446
+ /usr/bin/python3 -c "
447
+ import json,sys
448
+ r = json.loads(sys.stdin.read())
449
+ if '_parse_error' in r:
450
+ print(f'Step compliance: PARSE ERROR ({r[\"_parse_error\"]})')
451
+ else:
452
+ files = r.get('research_files_read', [])
453
+ tweets = r.get('tweets', [])
454
+ style = r.get('engagement_style', '?')
455
+ over = [i for i,t in enumerate(tweets) if len(t) > 280]
456
+ print(f'Step compliance: research={len(files)} files, tweets={len(tweets)}, style={style}, over_280={over or \"none\"}')
457
+ " <<< "$PARSED" 2>/dev/null | tee -a "$LOG_FILE"
458
+
459
+ if [ "$PERMALINK" != "null" ] && [ "$PERMALINK" != "" ] && [ "$PERMALINK" != "PARSE_ERROR" ]; then
460
+ echo "POSTED: $PERMALINK" | tee -a "$LOG_FILE"
461
+
462
+ # Authoritative DB INSERT. Same pattern as reddit threads runner.
463
+ PARSED="$PARSED" \
464
+ CLAUDE_SESSION_ID="$CLAUDE_SESSION_ID" \
465
+ PROJECT_ENV="$PROJECT" \
466
+ POST_ACCOUNT="$POST_ACCOUNT" \
467
+ REPO_DIR="$REPO_DIR" \
468
+ CAMPAIGN_IDS="$CAMPAIGN_IDS" \
469
+ CAMPAIGN_SUFFIX="$CAMPAIGN_SUFFIX" \
470
+ LINK_URL="$LINK_URL" \
471
+ LINK_SOURCE="$LINK_SOURCE" \
472
+ /usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE" || true
473
+ import json, os, subprocess, sys
474
+ sys.path.insert(0, os.path.join(os.environ["REPO_DIR"], "scripts"))
475
+ from http_api import api_post
476
+
477
+ parsed = json.loads(os.environ.get("PARSED") or "{}")
478
+ permalink = parsed.get("permalink") or ""
479
+ tweets = parsed.get("tweets") or []
480
+ summary = parsed.get("source_summary", "")
481
+ style = parsed.get("engagement_style", "") or None
482
+ session = os.environ.get("CLAUDE_SESSION_ID") or None
483
+ project = os.environ.get("PROJECT_ENV", "")
484
+ account = os.environ.get("POST_ACCOUNT", "")
485
+ link_url = (os.environ.get("LINK_URL") or "").strip()
486
+ link_source = (os.environ.get("LINK_SOURCE") or "").strip() or None
487
+
488
+ if not permalink or not tweets:
489
+ print("[db-insert] SKIP - empty permalink or tweets in structured_output")
490
+ sys.exit(0)
491
+
492
+ # Verify post-flight that tweet 1 ends with the resolved link. The cycle
493
+ # pipeline gets this for free (twitter_browser.py concatenates the URL); for
494
+ # threads the LLM does the appending, so we audit and downgrade link_source
495
+ # when the model dropped the link.
496
+ if link_url:
497
+ first_tweet = (tweets[0] or "").rstrip()
498
+ if first_tweet.endswith(link_url):
499
+ print(f"[link-verify] OK first tweet ends with {link_url!r}")
500
+ else:
501
+ print(f"[link-verify] MISS expected suffix {link_url!r} not at end of tweet1; "
502
+ f"tail={first_tweet[-80:]!r}")
503
+ link_source = "link_missing"
504
+
505
+ # Stitch tweets into our_content with double-newline separators so downstream
506
+ # stats/refresh queries treat the whole thread as one row.
507
+ body = "\n\n".join(t.strip() for t in tweets if t and t.strip())
508
+ # Twitter doesn't have a separate title; use the first tweet's first 100 chars
509
+ # so dashboard listings have something readable.
510
+ title = (tweets[0] or "")[:100]
511
+
512
+ # HTTP-only lane (2026-06-01): the authoritative post log goes through the
513
+ # s4l.ai API (POST /api/v1/posts). No DATABASE_URL, no db.get_conn(). The
514
+ # endpoint dedups on (platform, thread_url) server-side and returns 409 with
515
+ # existing_post_id, replacing the old SELECT idempotency guard (thread_url ==
516
+ # our_url == permalink here, so dedup is equivalent to the old our_url check).
517
+ resp = api_post("/api/v1/posts", {
518
+ "platform": "twitter",
519
+ "thread_url": permalink,
520
+ "thread_author": account,
521
+ "thread_title": title,
522
+ "thread_content": body,
523
+ "our_url": permalink,
524
+ "our_content": body,
525
+ "our_account": account,
526
+ "source_summary": summary,
527
+ "project": project,
528
+ "engagement_style": style,
529
+ "status": "active",
530
+ "claude_session_id": session,
531
+ "link_source": link_source,
532
+ }, ok_on_conflict=True)
533
+
534
+ if not resp.get("ok", True) and (resp.get("error") or {}).get("code") == "duplicate_thread":
535
+ existing_id = ((resp.get("error") or {}).get("details") or {}).get("existing_post_id")
536
+ print(f"[db-insert] SKIP — post {permalink} already in DB as id={existing_id}")
537
+ sys.exit(0)
538
+
539
+ post_id = (resp.get("data") or {}).get("post", {}).get("id")
540
+ print(f"[db-insert] OK — inserted posts.id={post_id} for {permalink}")
541
+
542
+ # Campaign verification gate. Twitter has no edit API so we cannot fix the
543
+ # tweet after posting; instead we verify the model honored the suffix
544
+ # instruction and bump the counter only if it did. Mirrors the verified-edit
545
+ # semantics in skill/run-reddit-threads.sh.
546
+ campaign_ids_csv = (os.environ.get("CAMPAIGN_IDS") or "").strip()
547
+ campaign_suffix = os.environ.get("CAMPAIGN_SUFFIX") or ""
548
+ if campaign_ids_csv and campaign_suffix:
549
+ last_tweet = (tweets[-1] or "").rstrip()
550
+ expected = campaign_suffix.rstrip()
551
+ if last_tweet.endswith(expected):
552
+ bump = os.path.join(os.environ["REPO_DIR"], "scripts", "campaign_bump.py")
553
+ for cid in [c for c in campaign_ids_csv.split(",") if c.strip()]:
554
+ try:
555
+ subprocess.run(
556
+ ["python3", bump,
557
+ "--table", "posts", "--id", str(post_id),
558
+ "--campaign-id", cid.strip()],
559
+ capture_output=True, text=True, timeout=15,
560
+ )
561
+ except Exception as e:
562
+ print(f"[campaign-twitter-thread] WARNING: campaign_bump failed (id={post_id} c={cid}): {e}")
563
+ print(f"[campaign-twitter-thread] OK — last tweet ends with suffix, campaigns {campaign_ids_csv} bumped on post {post_id}")
564
+ else:
565
+ # Verification failed: model did not add the suffix as instructed.
566
+ # Leave the post untagged. campaign_id stays NULL, so the row joins
567
+ # the control bucket for A/B purposes (parallels reddit-threads'
568
+ # degraded path on edit-thread failure).
569
+ print(f"[campaign-twitter-thread] WARNING: last tweet does not end with expected suffix; post {post_id} stays untagged. last_tail={last_tweet[-80:]!r} expected={expected!r}")
570
+ PYEOF
571
+
572
+ elif [ -n "$ABORT_REASON" ] && [ "$ABORT_REASON" != "PARSE_ERROR" ]; then
573
+ echo "ABORTED: $ABORT_REASON" | tee -a "$LOG_FILE"
574
+ else
575
+ echo "UNKNOWN OUTCOME (check JSON output above)" | tee -a "$LOG_FILE"
576
+ fi
577
+
578
+ # Surface this run in the dashboard's Job History under "Post Threads · Twitter".
579
+ # Script name `thread_twitter` is what bin/server.js classifyScript() matches.
580
+ ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
581
+ if [ "$PERMALINK" != "null" ] && [ "$PERMALINK" != "" ] && [ "$PERMALINK" != "PARSE_ERROR" ]; then
582
+ POSTED_CT=1; SKIPPED_CT=0; FAILED_CT=0
583
+ elif [ -n "$ABORT_REASON" ] && [ "$ABORT_REASON" != "PARSE_ERROR" ]; then
584
+ POSTED_CT=0; SKIPPED_CT=1; FAILED_CT=0
585
+ else
586
+ POSTED_CT=0; SKIPPED_CT=0; FAILED_CT=1
587
+ fi
588
+ _COST=$(/usr/bin/python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-twitter-threads" 2>/dev/null || echo "0.0000")
589
+ /usr/bin/python3 "$REPO_DIR/scripts/log_run.py" --script "thread_twitter" --posted "$POSTED_CT" --skipped "$SKIPPED_CT" --failed "$FAILED_CT" --cost "$_COST" --elapsed "$ELAPSED" || true
590
+
591
+ echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
592
+ find "$LOG_DIR" -name "run-twitter-threads-*.log" -mtime +14 -delete 2>/dev/null || true
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # scan-instagram-replies.sh — Discover new inbound comments on our Instagram
3
+ # posts via the Graph API and insert them into the `replies` table.
4
+ #
5
+ # Mirrors the pattern used by stats-instagram.sh: API-only (no browser),
6
+ # instagram-poster lock (so scan, stats, and post can't race for the same
7
+ # token-bucket), then a SUMMARY-line parsed by log_run.py for the dashboard
8
+ # Jobs panel.
9
+ #
10
+ # Logs: skill/logs/scan-instagram-replies-YYYY-MM-DD_HHMMSS.log
11
+
12
+ set -uo pipefail
13
+
14
+ REPO_DIR="$HOME/social-autoposter"
15
+ LOG_DIR="$REPO_DIR/skill/logs"
16
+ mkdir -p "$LOG_DIR"
17
+ LOG_FILE="$LOG_DIR/scan-instagram-replies-$(date +%Y-%m-%d_%H%M%S).log"
18
+
19
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
20
+ log "=== scan-instagram-replies fire: $(date) ==="
21
+
22
+ RUN_START=$(date +%s)
23
+
24
+ # instagram-poster lock — stats, scan, daily-post, and render all share this
25
+ # lane so we don't race on the same /me/media token bucket.
26
+ # shellcheck source=lock.sh
27
+ source "$REPO_DIR/skill/lock.sh"
28
+ acquire_lock instagram-poster 30
29
+
30
+ OUTPUT_FILE="/tmp/scan-instagram-replies-$$.out"
31
+ if ! /opt/homebrew/bin/python3.11 "$REPO_DIR/scripts/scan_instagram_comments.py" 2>>"$LOG_FILE" | tee -a "$LOG_FILE" >"$OUTPUT_FILE"; then
32
+ log "scan_instagram_comments.py exited non-zero — logging run as failed"
33
+ DISCOVERED=0; SKIPPED=0; CHECKED=0; ALREADY=0; ACCOUNTS=0
34
+ else
35
+ SUMMARY=$(grep '^SUMMARY:' "$OUTPUT_FILE" | tail -1)
36
+ DISCOVERED=$(echo "$SUMMARY" | sed -n 's/.*DISCOVERED=\([0-9]*\).*/\1/p'); DISCOVERED=${DISCOVERED:-0}
37
+ SKIPPED=$(echo "$SUMMARY" | sed -n 's/.*SKIPPED=\([0-9]*\).*/\1/p'); SKIPPED=${SKIPPED:-0}
38
+ CHECKED=$(echo "$SUMMARY" | sed -n 's/.*CHECKED=\([0-9]*\).*/\1/p'); CHECKED=${CHECKED:-0}
39
+ ALREADY=$(echo "$SUMMARY" | sed -n 's/.*ALREADY=\([0-9]*\).*/\1/p'); ALREADY=${ALREADY:-0}
40
+ ACCOUNTS=$(echo "$SUMMARY" | sed -n 's/.*ACCOUNTS=\([0-9]*\).*/\1/p'); ACCOUNTS=${ACCOUNTS:-0}
41
+ fi
42
+ rm -f "$OUTPUT_FILE"
43
+
44
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
45
+
46
+ log "logging run: discovered=$DISCOVERED skipped=$SKIPPED checked=$CHECKED already=$ALREADY accounts=$ACCOUNTS elapsed=${RUN_ELAPSED}s"
47
+
48
+ # discovered -> posted (new pending rows are the productive output of a scan,
49
+ # same convention scan_reddit_replies / scan_github_replies use).
50
+ # skipped -> skipped. checked -> scanned (media items inspected).
51
+ /opt/homebrew/bin/python3.11 "$REPO_DIR/scripts/log_run.py" \
52
+ --script "scan_instagram_comments" \
53
+ --posted "$DISCOVERED" \
54
+ --skipped "$SKIPPED" \
55
+ --failed 0 \
56
+ --scanned "$CHECKED" \
57
+ --cost 0 \
58
+ --elapsed "$RUN_ELAPSED" >>"$LOG_FILE" 2>&1 || log "log_run.py failed"
59
+
60
+ log "=== scan-instagram-replies done ==="
61
+ exit 0