@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,146 @@
1
+ #!/bin/bash
2
+ # Social Autoposter - Reddit engagement loop
3
+ # Runs scan_reddit_replies.py every 10 min via launchd.
4
+ # Inbox-based discovery + engage_reddit.py --limit 5 in one job.
5
+ # Skip-if-locked (timeout 0) since runs are frequent and a previous tick may still be engaging.
6
+ #
7
+ # Renamed 2026-04-29 from run-scan-reddit-replies.sh / com.m13v.social-scan-reddit-replies
8
+ # to engage-reddit.sh / com.m13v.social-engage-reddit so the file/plist/log names
9
+ # match what the dashboard already calls this job ("Engage Reddit"). The Python
10
+ # discovery module (scripts/scan_reddit_replies.py) keeps its name since other
11
+ # helpers still import from it.
12
+
13
+
14
+ set -euo pipefail
15
+
16
+ source "$(dirname "$0")/lock.sh"
17
+ # reddit-harness backend (2026-05-29): exports REDDIT_CDP_URL=:9557 so
18
+ # reddit_browser.py (shelled from engage_reddit.py) attaches to the harness
19
+ # Chrome instead of ps-discovering the reddit-agent profile. Source after
20
+ # lock.sh, before acquire_lock / browser pre-flight.
21
+ source "$(dirname "$0")/lib/reddit-backend.sh"
22
+ acquire_lock "engage-reddit" 0
23
+
24
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
25
+
26
+ REPO_DIR="$HOME/social-autoposter"
27
+ LOG_DIR="$REPO_DIR/skill/logs"
28
+ mkdir -p "$LOG_DIR"
29
+ LOG_FILE="$LOG_DIR/engage-reddit-$(date +%Y-%m-%d_%H%M%S).log"
30
+
31
+ # Per-cycle batch id stamped onto every claude_sessions row spawned by this
32
+ # engagement run (via SA_CYCLE_ID env -> log_claude_session.py). Lets the
33
+ # dashboard / get_run_cost.py --cycle-id report exact per-cycle cost instead
34
+ # of the legacy script+since query that bleeds across concurrent runs.
35
+ # 2026-05-10 cycle_id rollout.
36
+ BATCH_ID="enrdt-$(date +%Y%m%d-%H%M%S)-$$"
37
+ export SA_CYCLE_ID="$BATCH_ID"
38
+
39
+ echo "=== Engage Reddit Run: $(date) (cycle=$BATCH_ID) ===" | tee "$LOG_FILE"
40
+ START_TS=$(date +%s)
41
+
42
+ # Reddit-browser lease strategy (migrated 2026-05-13): the lease is now
43
+ # acquired/released PER REPLY inside engage_reddit.py's main() while-loop,
44
+ # around each Claude+CDP iteration. Previously we held the lease around the
45
+ # whole engage_reddit.py run, so a 5-reply batch monopolised the browser for
46
+ # ~10-25 min while peer reddit pipelines (run-reddit-search post phase,
47
+ # run-reddit-threads, link-edit-reddit, dm-outreach-reddit, engage-dm-replies)
48
+ # sat blocked even during the per-reply Claude "thinking" gaps. Mirrors the
49
+ # pattern shipped to post_reddit.py + run-reddit-search.sh on 2026-05-13 and
50
+ # to link-edit-reddit.sh + dm-outreach-reddit.sh in May 2026.
51
+ #
52
+ # Pre-flight: brief acquire+ensure_browser_healthy+release ONCE per cycle so
53
+ # orphan-Chrome cleanup / Singleton-lock clear runs before the first reply.
54
+ # Best-effort: if acquire is BUSY (peer pipeline mid-post), warn and proceed.
55
+ echo "[engage-reddit] Pre-flight: brief reddit-browser acquire + ensure_browser_healthy + release..." | tee -a "$LOG_FILE"
56
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 60 --ttl 30 2>&1 | tee -a "$LOG_FILE" || \
57
+ echo "[engage-reddit] WARNING: pre-flight acquire BUSY; harness bootstrap will run anyway; per-reply acquires inside engage_reddit.py will retry." | tee -a "$LOG_FILE"
58
+ if ! ensure_reddit_browser_for_backend 2>&1 | tee -a "$LOG_FILE"; then
59
+ echo "[engage-reddit] WARNING: reddit-harness bootstrap failed; falling back to ensure_browser_healthy reddit" | tee -a "$LOG_FILE"
60
+ ensure_browser_healthy "reddit"
61
+ fi
62
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
63
+
64
+ # Belt-and-suspenders trap: free the lease on any exit path. Idempotent.
65
+ # With the per-reply pattern the lease is normally released at the end of
66
+ # each iteration, but a SIGTERM mid-iteration would otherwise leave it
67
+ # held for ~90s before peers could steal it.
68
+ #
69
+ # Trap chaining: lock.sh sourced above installed `_sa_release_locks` on
70
+ # EXIT INT TERM HUP. Bash trap REPLACES, not appends, so we re-set with
71
+ # both handlers. Order: release the lease first (cheap, lets peers in),
72
+ # then release pipeline locks. Mirrors run-reddit-search.sh.
73
+ _release_reddit_lease() {
74
+ timeout 3 python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
75
+ }
76
+ trap '_release_reddit_lease; _sa_release_locks' EXIT INT TERM HUP
77
+
78
+ PYTHONUNBUFFERED=1 python3 "$REPO_DIR/scripts/scan_reddit_replies.py" 2>&1 | tee -a "$LOG_FILE" || true
79
+
80
+ ELAPSED=$(( $(date +%s) - START_TS ))
81
+ # Pull scan-stage counters out of the "Inbox scan complete:" line so the
82
+ # dashboard Result column can show "scanned N / new N / excluded N" on empty
83
+ # cycles instead of all-zeros. Format on disk:
84
+ # Inbox scan complete: seen=51 new_pending=0 backfill_skipped=0 \
85
+ # already_replied=0 excluded_author=1 unmatched_thread=0
86
+ # We rename the keys to short forms (seen->scanned, new_pending->new,
87
+ # excluded_author->excluded, unmatched_thread->unmatched) before passing to
88
+ # log_run.py via --scan.
89
+ SCAN_LINE=$(grep -m1 "^Inbox scan complete:" "$LOG_FILE" 2>/dev/null || true)
90
+ SCAN_ARG=""
91
+ if [ -n "$SCAN_LINE" ]; then
92
+ scan_seen=$(echo "$SCAN_LINE" | grep -oE "seen=[0-9]+" | head -1 | cut -d= -f2)
93
+ scan_new=$(echo "$SCAN_LINE" | grep -oE "new_pending=[0-9]+" | head -1 | cut -d= -f2)
94
+ scan_excl=$(echo "$SCAN_LINE" | grep -oE "excluded_author=[0-9]+" | head -1 | cut -d= -f2)
95
+ scan_unm=$(echo "$SCAN_LINE" | grep -oE "unmatched_thread=[0-9]+" | head -1 | cut -d= -f2)
96
+ scan_back=$(echo "$SCAN_LINE" | grep -oE "backfill_skipped=[0-9]+" | head -1 | cut -d= -f2)
97
+ parts=""
98
+ [ -n "$scan_seen" ] && parts="${parts}scanned=${scan_seen},"
99
+ [ -n "$scan_new" ] && parts="${parts}new=${scan_new},"
100
+ [ -n "$scan_excl" ] && parts="${parts}excluded=${scan_excl},"
101
+ [ -n "$scan_unm" ] && parts="${parts}unmatched=${scan_unm},"
102
+ [ -n "$scan_back" ] && parts="${parts}backfill=${scan_back},"
103
+ SCAN_ARG="${parts%,}"
104
+ fi
105
+
106
+ # Pull engage-stage counters from the canonical LOG_RUN_SUMMARY line that
107
+ # engage_reddit.py prints right before exiting. Previously engage_reddit.py
108
+ # wrote its own log_run.py row AND we wrote one here, producing two rows per
109
+ # cycle in run_monitor.log -- the python-side row had no scan info and showed
110
+ # as zeros on the dashboard. Now python emits the line, the shell parses it,
111
+ # and we write ONE row that combines engage + scan counters.
112
+ SUMMARY_LINE=$(grep -m1 "^\[engage_reddit\] LOG_RUN_SUMMARY" "$LOG_FILE" 2>/dev/null || true)
113
+ ENG_POSTED=0; ENG_SKIPPED=0; ENG_FAILED=0; ENG_COST="0.0000"; ENG_ELAPSED="$ELAPSED"; ENG_FAILURE_REASONS=""
114
+ if [ -n "$SUMMARY_LINE" ]; then
115
+ ENG_POSTED=$(echo "$SUMMARY_LINE" | grep -oE "posted=[0-9]+" | head -1 | cut -d= -f2)
116
+ ENG_SKIPPED=$(echo "$SUMMARY_LINE" | grep -oE "skipped=[0-9]+" | head -1 | cut -d= -f2)
117
+ ENG_FAILED=$(echo "$SUMMARY_LINE" | grep -oE "failed=[0-9]+" | head -1 | cut -d= -f2)
118
+ ENG_COST=$(echo "$SUMMARY_LINE" | grep -oE "cost=[0-9.]+" | head -1 | cut -d= -f2)
119
+ # `failure_reasons=` is the last key on the line; it may be empty. Capture
120
+ # everything after the literal token, trim trailing whitespace.
121
+ ENG_FAILURE_REASONS=$(echo "$SUMMARY_LINE" | sed -n 's/.* failure_reasons=\([^ ]*\).*/\1/p')
122
+ : "${ENG_POSTED:=0}" "${ENG_SKIPPED:=0}" "${ENG_FAILED:=0}" "${ENG_COST:=0.0000}"
123
+ fi
124
+
125
+ # Rescue Anthropic-side failures the python-side classifier didn't catch.
126
+ # When engage_reddit.py died mid-run (stream idle timeout, monthly cap hit,
127
+ # context overflow), SUMMARY_LINE may be missing OR present-with-empty-reasons.
128
+ # Either way, scan the log for a real cause before falling back to a silent
129
+ # row on the dashboard.
130
+ if [ -z "$ENG_FAILURE_REASONS" ] && [ -f "$LOG_FILE" ]; then
131
+ API_REASON=$(python3 "$REPO_DIR/scripts/classify_run_error.py" "$LOG_FILE" 2>/dev/null)
132
+ if [ -n "$API_REASON" ]; then
133
+ ENG_FAILURE_REASONS="${API_REASON}:1"
134
+ # Bump failed count by 1 if the classifier found something but the python
135
+ # side reported zero failures (i.e. the process died before it could log).
136
+ [ "$ENG_FAILED" = "0" ] && ENG_FAILED=1
137
+ fi
138
+ fi
139
+
140
+ LOG_RUN_ARGS=(--script "engage_reddit" --posted "$ENG_POSTED" --skipped "$ENG_SKIPPED" --failed "$ENG_FAILED" --cost "$ENG_COST" --elapsed "$ENG_ELAPSED")
141
+ [ -n "$SCAN_ARG" ] && LOG_RUN_ARGS+=(--scan "$SCAN_ARG")
142
+ [ -n "$ENG_FAILURE_REASONS" ] && LOG_RUN_ARGS+=(--failure-reasons "$ENG_FAILURE_REASONS")
143
+ python3 "$REPO_DIR/scripts/log_run.py" "${LOG_RUN_ARGS[@]}" || true
144
+
145
+ echo "=== Engage Reddit complete: $(date) (elapsed ${ELAPSED}s) ===" | tee -a "$LOG_FILE"
146
+ find "$LOG_DIR" -name "engage-reddit-*.log" -mtime +7 -delete 2>/dev/null || true
@@ -0,0 +1,467 @@
1
+ #!/usr/bin/env bash
2
+ # engage-twitter.sh — X/Twitter engagement loop
3
+ # Phase A: Discover replies/mentions via Twitter API (no browser needed)
4
+ # Phase B: Respond to pending Twitter replies via browser (API can't reply to most tweets)
5
+ # Called by launchd every 3 hours.
6
+
7
+
8
+ set -euo pipefail
9
+
10
+ # Bootstrap log paths early so the singleton-cleanup output below gets captured
11
+ # in the same log file the rest of the run uses.
12
+ LOG_DIR="$HOME/social-autoposter/skill/logs"
13
+ mkdir -p "$LOG_DIR"
14
+ LOG_FILE="$LOG_DIR/engage-twitter-$(date +%Y-%m-%d_%H%M%S).log"
15
+
16
+ # Per-cycle batch id stamped onto every claude_sessions row spawned by this
17
+ # engagement run (via SA_CYCLE_ID env -> log_claude_session.py). 2026-05-10
18
+ # cycle_id rollout.
19
+ BATCH_ID="entw-$(date +%Y%m%d-%H%M%S)-$$"
20
+ export SA_CYCLE_ID="$BATCH_ID"
21
+
22
+ # Browser-profile lock first (shared with other twitter pipelines), then pipeline lock.
23
+ source "$(dirname "$0")/lock.sh"
24
+ # Harness-only browser bootstrap (twitter-agent path fully removed 2026-05-19).
25
+ # Sets MCP_CONFIG_FILE, BROWSER_INSTRUCTIONS, exports TWITTER_CDP_URL=9555.
26
+ source "$(dirname "$0")/lib/twitter-backend.sh"
27
+
28
+ echo "[$(date +%H:%M:%S)] Acquiring twitter-browser lock (pid=$$)..." | tee -a "$LOG_FILE"
29
+ acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
30
+ echo "[$(date +%H:%M:%S)] twitter-browser lock held (pid=$$)" | tee -a "$LOG_FILE"
31
+ # Probe + launch harness Chrome on port 9555 if needed, then sweep leftover tabs.
32
+ ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
33
+ echo "[$(date +%H:%M:%S)] Acquiring twitter (pipeline) lock (pid=$$)..." | tee -a "$LOG_FILE"
34
+ acquire_lock "twitter" 3600 2>>"$LOG_FILE"
35
+
36
+ # Load secrets
37
+ # shellcheck source=/dev/null
38
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
39
+
40
+ REPO_DIR="$HOME/social-autoposter"
41
+ SKILL_FILE="$REPO_DIR/SKILL.md"
42
+ BATCH_SIZE=500
43
+
44
+ # All Twitter engage DB I/O routes through scripts/engage_twitter_helper.py
45
+ # (HTTP API at /api/v1/*) since 2026-05-18. DATABASE_URL is no longer
46
+ # required for this script and is left for downstream tooling only.
47
+ ENGAGE_TWITTER_HELPER="$REPO_DIR/scripts/engage_twitter_helper.py"
48
+ # (LOG_DIR/LOG_FILE bootstrapped at top of script.)
49
+
50
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
51
+
52
+ RUN_START=$(date +%s)
53
+ log "=== Twitter Engagement Run: $(date) ==="
54
+
55
+ # Load exclusions from config
56
+ EXCLUDED_AUTHORS=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(', '.join(c.get('exclusions',{}).get('authors',[])))" 2>/dev/null || echo "")
57
+ EXCLUDED_TWITTER=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(', '.join(c.get('exclusions',{}).get('twitter_accounts',[])))" 2>/dev/null || echo "")
58
+
59
+ # ═══════════════════════════════════════════════════════
60
+ # PHASE A: Discover new replies/mentions from Twitter notifications
61
+ # ═══════════════════════════════════════════════════════
62
+ log "Phase A: Scanning Twitter mentions via browser (no API cost)..."
63
+ NOTIFS_JSON=$(mktemp -t twitter_notifs.XXXXXX.json)
64
+ python3 "$REPO_DIR/scripts/twitter_browser.py" notifications 8 > "$NOTIFS_JSON" 2>>"$LOG_FILE" \
65
+ || log "WARNING: twitter_browser.py notifications failed"
66
+ python3 "$REPO_DIR/scripts/scan_twitter_mentions_browser.py" --json-file "$NOTIFS_JSON" 2>&1 \
67
+ | tee -a "$LOG_FILE" \
68
+ || log "WARNING: Phase A scan_twitter_mentions_browser.py exited with code $?"
69
+ rm -f "$NOTIFS_JSON"
70
+
71
+ # ═══════════════════════════════════════════════════════
72
+ # PHASE B: Respond to pending Twitter replies
73
+ # ═══════════════════════════════════════════════════════
74
+
75
+ # Reset any 'processing' items older than 2 hours back to 'pending'.
76
+ # Server-side WHERE in /api/v1/replies/reset-stuck mirrors the old SQL.
77
+ RESET_COUNT=$(python3 "$ENGAGE_TWITTER_HELPER" reset-stuck-replies)
78
+ [ "$RESET_COUNT" -gt 0 ] && log "Phase B: Reset $RESET_COUNT stuck 'processing' Twitter items back to pending"
79
+
80
+ PENDING_COUNT=$(python3 "$ENGAGE_TWITTER_HELPER" pending-count)
81
+
82
+ if [ "$PENDING_COUNT" -eq 0 ]; then
83
+ log "Phase B: No pending Twitter replies. Done!"
84
+ else
85
+ log "Phase B: $PENDING_COUNT pending Twitter replies to process"
86
+
87
+ # /api/v1/replies/next-pending returns the SAME join (replies + posts)
88
+ # with the SAME priority ordering (our_thread first, then discovered_at
89
+ # ASC) the previous json_agg() build emitted; the helper just reshapes
90
+ # the response into the legacy field set the prompt expects.
91
+ PENDING_DATA=$(python3 "$ENGAGE_TWITTER_HELPER" pending-data --batch-size "$BATCH_SIZE")
92
+
93
+ # JOIN-aware emptiness guard (2026-05-26). /api/v1/replies/counts returns
94
+ # the raw pending count (no JOIN), but /api/v1/replies/next-pending INNER
95
+ # JOINs posts; orphan replies whose post_id no longer exists make these
96
+ # two disagree. Without this guard, Phase B burns the full gtimeout
97
+ # holding the twitter-browser lock while Claude finds nothing to do,
98
+ # starving dm-outreach-twitter and dm-replies-twitter in the lock queue
99
+ # for 30+ min. Skip Phase B and release the browser lock early when
100
+ # /next-pending returns 0 rows.
101
+ PENDING_REAL_COUNT=$(echo "$PENDING_DATA" | python3 -c "
102
+ import sys, json
103
+ try:
104
+ d = json.loads(sys.stdin.read() or '{}')
105
+ if isinstance(d, dict):
106
+ rows = d.get('replies', [])
107
+ elif isinstance(d, list):
108
+ rows = d
109
+ else:
110
+ rows = []
111
+ print(len(rows))
112
+ except Exception:
113
+ print(0)
114
+ " 2>/dev/null || echo 0)
115
+
116
+ if [ "$PENDING_REAL_COUNT" -eq 0 ]; then
117
+ log "Phase B: counts says $PENDING_COUNT pending but JOIN returned 0 rows (likely orphan replies whose post_id is missing). Skipping Phase B."
118
+ log "Releasing twitter + twitter-browser locks so other pipelines (dm-outreach-twitter, dm-replies-twitter) can run."
119
+ release_lock "twitter" 2>>"$LOG_FILE" || true
120
+ release_lock "twitter-browser" 2>>"$LOG_FILE" || true
121
+ # (2026-06-16) session-lock rm removed (defect b); dead holders self-reclaim
122
+ # in twitter_browser.py now. Do NOT re-add. See docs/twitter_browser_lock.md.
123
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
124
+ log "=== Twitter Engagement Run done (no real work): elapsed=${RUN_ELAPSED}s ==="
125
+ python3 "$REPO_DIR/scripts/log_run.py" --script "engage_twitter" --posted 0 --skipped 0 --failed 0 --cost 0 --elapsed "$RUN_ELAPSED" 2>/dev/null || true
126
+ exit 0
127
+ fi
128
+
129
+ log "Phase B: $PENDING_REAL_COUNT pending Twitter replies confirmed via JOIN (counts said $PENDING_COUNT)"
130
+
131
+ # Per-project voice map (so each reply can be drafted in the matched project's voice)
132
+ PROJECTS_VOICE_JSON=$(python3 -c "
133
+ import json
134
+ c = json.load(open('$REPO_DIR/config.json'))
135
+ print(json.dumps({p['name']: p.get('voice', {}) for p in c.get('projects', []) if p.get('voice')}, indent=2))
136
+ " 2>/dev/null || echo "{}")
137
+
138
+ # Engagement-style picker (2026-05-19): pick ONE assigned style per
139
+ # reply iteration. The picked style flows two places: (1) --style
140
+ # filter for top_performers.py so the per-style exemplars match the
141
+ # assignment, (2) saps_render_style_block so the prompt embeds the
142
+ # same assignment. On invent mode picked_style is empty and
143
+ # top_performers stays unfiltered.
144
+ source "$REPO_DIR/skill/styles.sh"
145
+ STYLE_ASSIGN_FILE=$(mktemp -t saps_twitter_eng_assign_XXXXXX.json)
146
+ saps_pick_style twitter replying "$STYLE_ASSIGN_FILE" >/dev/null 2>&1 || true
147
+ PICKED_STYLE=$(python3 -c "
148
+ import json
149
+ try:
150
+ with open('$STYLE_ASSIGN_FILE') as f:
151
+ d = json.load(f)
152
+ print(d.get('style') or '')
153
+ except Exception:
154
+ print('')
155
+ " 2>/dev/null)
156
+ PICKED_MODE=$(python3 -c "
157
+ import json
158
+ try:
159
+ with open('$STYLE_ASSIGN_FILE') as f:
160
+ d = json.load(f)
161
+ print(d.get('mode') or 'use')
162
+ except Exception:
163
+ print('use')
164
+ " 2>/dev/null)
165
+ STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" twitter replying)
166
+ rm -f "$STYLE_ASSIGN_FILE" 2>/dev/null || true
167
+
168
+ # Top performers feedback report — filtered to the picked style when
169
+ # in 'use' mode so the few-shot exemplars match the assignment.
170
+ if [ -n "$PICKED_STYLE" ]; then
171
+ TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --style "$PICKED_STYLE" 2>/dev/null || echo "(top performers report unavailable)")
172
+ else
173
+ TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter 2>/dev/null || echo "(top performers report unavailable)")
174
+ fi
175
+
176
+ # Precompute active Twitter campaign suffix + sample_rate + id for the
177
+ # prompt to inline. Phase B replies go through the browser typing tool
178
+ # (twitter_browser.py reply wedges against the same profile), so tool-level
179
+ # injection is unavailable; the LLM has to flip a coin and append the
180
+ # literal suffix by hand. When no active campaign exists, all three vars
181
+ # resolve to empty strings and the prompt's "if empty, do nothing extra"
182
+ # branch fires. Mirrors the Reddit MCP-fallback pattern in
183
+ # engage-dm-replies.sh.
184
+ # Three psql one-liners collapsed into one HTTP call via active-campaign.
185
+ # Returns JSON {} when no active campaign matches, or
186
+ # {id, suffix, sample_rate}. Same WHERE (status='active', platform
187
+ # contains twitter, budget remaining, non-empty suffix) runs server-side.
188
+ TWITTER_CAMPAIGN_JSON=$(python3 "$ENGAGE_TWITTER_HELPER" active-campaign 2>/dev/null || echo "{}")
189
+ TWITTER_CAMPAIGN_SUFFIX_LITERAL=$(echo "$TWITTER_CAMPAIGN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.stdout.write(d.get('suffix','') or '')" 2>/dev/null || echo "")
190
+ TWITTER_CAMPAIGN_SAMPLE_RATE=$(echo "$TWITTER_CAMPAIGN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.stdout.write(str(d.get('sample_rate','') or ''))" 2>/dev/null || echo "")
191
+ TWITTER_CAMPAIGN_ID=$(echo "$TWITTER_CAMPAIGN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.stdout.write(str(d.get('id','') or ''))" 2>/dev/null || echo "")
192
+
193
+ PHASE_B_PROMPT=$(mktemp)
194
+ cat > "$PHASE_B_PROMPT" <<PROMPT_EOF
195
+ You are the Social Autoposter Twitter/X engagement bot.
196
+
197
+ $BROWSER_INSTRUCTIONS
198
+
199
+ Read $SKILL_FILE for the full workflow, content rules, and platform details.
200
+
201
+ EXCLUSIONS - do NOT engage with these accounts (skip and mark as 'skipped' with reason 'excluded_author'):
202
+ - Excluded authors: $EXCLUDED_AUTHORS
203
+ - Excluded Twitter accounts: $EXCLUDED_TWITTER
204
+
205
+ ### BOT / ENGAGEMENT-LOOP ESCAPE HATCH (use sparingly, but use it)
206
+ We maintain a universal author blocklist in Postgres (\`author_blocklist\`),
207
+ consulted at /api/v1/replies POST time. A single block recorded by ANY of
208
+ our accounts/installs applies to EVERY future engagement from EVERY of our
209
+ accounts — universal scope, by design. The velocity gate already covers
210
+ "this handle has gotten too many replies from us in 24h/7d"; this lane is
211
+ for the LLM-judgment cases velocity cannot catch.
212
+
213
+ When to add a block (your judgment, exercised CONSERVATIVELY):
214
+ - The handle is plainly an AI/bot account: templated phrasing, generic
215
+ filler answers, name pattern like \`SomethingAI\` / \`Foo_GPT\`, bio reads
216
+ "AI agent that replies to…"
217
+ - We are clearly stuck in a reciprocal engagement loop with this handle
218
+ (they reply to every one of our posts, we reply to every one of theirs,
219
+ no substance is exchanged)
220
+ - The handle is engagement farming (mass low-effort replies across the
221
+ platform, not actually engaging with the topic)
222
+
223
+ DO NOT add a block for: someone we disagree with, a hostile-but-human
224
+ critic, a low-quality but human reply, or a single bad interaction.
225
+ Skip those (status='skipped') — blocking is permanent until manually
226
+ removed and applies to all our accounts.
227
+
228
+ How to add the block (run BEFORE marking the current reply skipped):
229
+ python3 \$REPO_DIR/scripts/reply_db.py blocklist add x HANDLE \\
230
+ --reason "<one-line judgment, e.g. 'AI-named account, templated replies>" \\
231
+ --classification {bot|engagement_loop} \\
232
+ --source-reply-id REPLY_ID
233
+
234
+ Then mark the current reply skipped with a clear reason:
235
+ python3 \$REPO_DIR/scripts/reply_db.py skipped REPLY_ID "blocklist_added:HANDLE"
236
+
237
+ You can verify with:
238
+ python3 \$REPO_DIR/scripts/reply_db.py blocklist check x HANDLE
239
+
240
+ CRITICAL - Reply posting: Use the SAME browser session you used in Step 2 (navigate), via the tools described in the BROWSER BACKEND block above. Do NOT call scripts/twitter_browser.py reply: that launches a second Chromium against the same profile dir, which wedges x.com on a Loading state and times out. NEVER use any other browser MCP (playwright-extension, isolated-browser, macos-use, etc.) for posting.
241
+ CRITICAL: If a click or type fails (stale ref, button not found, page not ready, page wedged on Loading), re-snapshot the page (per the BROWSER BACKEND block) and retry up to 2 times.
242
+ CRITICAL: TECHNICAL FAILURES ARE NOT TERMINAL. If after retries the post still failed for any technical reason (browser, network, MCP, x.com unreachable, page rendering issue), DO NOT call reply_db.py skipped. Leave the row in 'processing' status (i.e., do nothing further with it) and move on to the next pending item. The post-run cleanup will reset 'processing' rows back to 'pending' so the next engage run retries automatically.
243
+ CRITICAL: ONLY call reply_db.py skipped for content/policy reasons (e.g., light_acknowledgment, drive_by_self_promo_link_drop, hostile_user, off_topic, troll, mod_removal, excluded_author). NEVER skip for technical browser/network failures: those must be retry-able.
244
+
245
+ ## Respond to pending Twitter/X replies ($PENDING_COUNT total)
246
+
247
+ ### Priority order:
248
+ 1. **Replies on our original posts** (is_our_original_post=1) - highest priority
249
+ 2. **Direct questions** ("what tool", "how do you", "can you share")
250
+ 3. **Everything else** - general engagement
251
+
252
+ ### Tiered link strategy:
253
+ - **Tier 1 (default):** No link. Genuine engagement, expand topic.
254
+ - **Tier 2 (natural mention):** Conversation touches a topic matching a project in config. Recommend it casually as a tool you've come across.
255
+ - **Tier 3 (direct ask):** They ask for link/tool/source. Give it immediately.
256
+
257
+ ## FEEDBACK FROM PAST PERFORMANCE (use this to write better replies):
258
+ $TOP_REPORT
259
+
260
+ $STYLES_BLOCK
261
+
262
+ ## Per-project voice map
263
+ For each reply you draft, look up the matched project's voice block below and apply it: follow \`voice.tone\`, never violate any item in \`voice.never\`, mirror \`voice.examples\` / \`voice.examples_good\` when present.
264
+ $PROJECTS_VOICE_JSON
265
+
266
+ ## Resolving the parent post (replaces the old prompt-blob index)
267
+ Each pending row's \`project_name\` is a best-effort guess made at scan time. After navigating the thread (Step 2), extract the parent tweet ID from the page URL/DOM and resolve it via:
268
+ python3 $REPO_DIR/scripts/lookup_post.py twitter PARENT_TWEET_ID
269
+ Returns JSON: {"project": "fazm", "our_content": "...full text...", "thread_url": "..."} or {"project": null} if it's not one of our posts.
270
+
271
+ Here are the replies to process:
272
+ $PENDING_DATA
273
+
274
+ NOTE ON MEDIA: a row may include a non-empty \`their_media_block\` describing images / videos / GIFs / link-cards attached to the comment you are replying to (captured at scan time). When present, treat it as part of the comment: react to what it VISUALLY shows, not just the text. When absent or empty, the comment was text-only (or media was not captured), so reply to the text as usual. You will also see the media live when you navigate the thread in Step 2; the block is the persisted fallback.
275
+
276
+ CRITICAL: Reply in the SAME LANGUAGE as the message you are responding to. Match the language exactly.
277
+ CRITICAL: Process EVERY reply. For each: either post a response and mark as 'replied', OR mark as 'skipped' with a skip_reason.
278
+
279
+ CRITICAL: For ALL database operations, use the reply_db.py helper (NOT raw psql):
280
+ python3 $REPO_DIR/scripts/reply_db.py processing ID # BEFORE posting
281
+ python3 $REPO_DIR/scripts/reply_db.py replied ID "reply text" [url] [engagement_style] [is_recommendation] # AFTER posting. engagement_style is TONE (critic, storyteller, etc). is_recommendation is "1" ONLY when you casually mentioned a project (Tier 2/3); leave blank otherwise. Tone and intent are independent.
282
+ python3 $REPO_DIR/scripts/reply_db.py skipped ID "reason"
283
+ python3 $REPO_DIR/scripts/reply_db.py skip_batch '{"ids":[1,2,3],"reason":"..."}'
284
+ python3 $REPO_DIR/scripts/reply_db.py status
285
+ NEVER use psql directly for reply status updates.
286
+
287
+ ### Project tracking on replies
288
+ When you recommend a project in a reply (Tier 2 or Tier 3), set project_name on the reply via reply_db.py (which writes through /api/v1/replies/:id — DO NOT shell out to psql):
289
+ python3 $REPO_DIR/scripts/reply_db.py set_project REPLY_ID "PROJECT_NAME"
290
+ This lets the DM pipeline know which project the conversation is about.
291
+
292
+ MANDATORY reply flow for every item:
293
+ Step 1: python3 reply_db.py processing ID <- mark BEFORE posting
294
+ Step 2: NAVIGATE TO THE THREAD AND READ CONTEXT (mandatory, do NOT skip).
295
+ Do NOT draft a reply from the notification snippet alone — the snippet
296
+ is truncated and lacks the parent tweet content + sibling replies.
297
+ a) Navigate to their_comment_url (use the navigate tool from the BROWSER BACKEND block above)
298
+ b) Snapshot or query the DOM (per the BROWSER BACKEND block) to read:
299
+ - the FULL parent tweet text (our original post if this is on our thread)
300
+ - the immediate ancestors of their_comment_id (so you understand the
301
+ conversational beat being replied to)
302
+ - sibling replies (so you don't repeat what someone else already said)
303
+ c) Extract the parent tweet ID (the long numeric string after \`/status/\`)
304
+ from the URL chain or page DOM. Resolve it:
305
+ python3 $REPO_DIR/scripts/lookup_post.py twitter PARENT_TWEET_ID
306
+ If the response has a non-null \"project\", that's our post — OVERRIDE
307
+ the reply row and use that project's voice for drafting:
308
+ python3 $REPO_DIR/scripts/reply_db.py set_project REPLY_ID "RESOLVED_PROJECT"
309
+ Use the returned \"our_content\" as the FULL text of the post being
310
+ replied to (more accurate than the truncated our_content in PENDING_DATA).
311
+ If \"project\" is null, we're a guest in someone else's thread; keep
312
+ the existing project_name and follow global content rules.
313
+ Step 3: Draft the reply using the resolved project's voice + chosen engagement
314
+ style. One sentence or a tight fragment — aim for under 100 chars. NEVER em dashes. Match parent tweet language.
315
+ Step 3a: ACTIVE CAMPAIGN SUFFIX (MCP fallback, mirrors Reddit's pattern).
316
+ The phase-B reply path goes through the browser typing tool
317
+ (twitter_browser.py reply wedges against the same profile), so
318
+ the tool layer can NOT inject the campaign suffix — the LLM has to
319
+ do it by hand. Active Twitter campaign (precomputed by the shell):
320
+ TWITTER_CAMPAIGN_ID=$TWITTER_CAMPAIGN_ID
321
+ TWITTER_CAMPAIGN_SUFFIX_LITERAL='$TWITTER_CAMPAIGN_SUFFIX_LITERAL'
322
+ TWITTER_CAMPAIGN_SAMPLE_RATE=$TWITTER_CAMPAIGN_SAMPLE_RATE
323
+ If TWITTER_CAMPAIGN_SUFFIX_LITERAL is empty, do nothing extra.
324
+ Otherwise: flip a coin at TWITTER_CAMPAIGN_SAMPLE_RATE. On heads,
325
+ append the literal suffix verbatim to YOUR_REPLY_TEXT and remember
326
+ that the campaign fired (CAMPAIGN_FIRED=1 + CAMPAIGN_ID_TO_BUMP=$TWITTER_CAMPAIGN_ID).
327
+ On tails, leave the text alone and CAMPAIGN_FIRED=0.
328
+ Do this BEFORE Step 4, since the typed text in Step 4c must include
329
+ the suffix. The literal text rule is the entire point: never paraphrase
330
+ or reformat the suffix.
331
+ Step 3b: SHORT-LINK ANY URL IN YOUR_REPLY_TEXT. Mandatory for Tier 2/3 replies
332
+ that drop a brand URL (runner.now, agora.xyz, podlog.io, fazm.ai,
333
+ usenightowl.com, etc.). The browser type path has NO Python
334
+ wrap layer, so a bare URL would be posted as-is and we lose all
335
+ per-post click attribution. Mint a real /r/<code> short link the
336
+ SAME way the post/link-edit pipelines do, in bash:
337
+ WRAP_RESULT=\$(python3 \$REPO_DIR/scripts/dm_short_links.py wrap-post-text \\
338
+ --platform twitter \\
339
+ --project RESOLVED_PROJECT_NAME \\
340
+ --text "YOUR_REPLY_TEXT")
341
+ RESOLVED_PROJECT_NAME must be the EXACT \`name\` field from config.json
342
+ (case-sensitive; e.g. "fazm" lowercase, "Cyrano", "WhatsApp MCP").
343
+ Parse the JSON output (\`{ok, text, minted_session, ...}\`):
344
+ - Use \`text\` (every URL replaced with a /r/<code> short link on
345
+ the project's own domain) as YOUR_REPLY_TEXT going forward; this
346
+ is what you type in Step 4.
347
+ - Save \`minted_session\` as MINTED_SESSION for the Step 5b backfill.
348
+ If \`ok\` is false, log the error and SKIP this reply (leave it to be
349
+ reset to 'pending' on the next run); do NOT type a bare URL. If
350
+ YOUR_REPLY_TEXT contains zero URLs (Tier 1, default case),
351
+ wrap-post-text is a no-op: it returns the text unchanged and
352
+ minted_session is null (that's fine), carry on and skip Step 5b.
353
+
354
+ Step 4: Post the reply via the SAME browser session from Step 2 (use the
355
+ tools described in the BROWSER BACKEND block).
356
+ a) Re-snapshot the page to refresh element state.
357
+ b) Find the reply textbox: role="textbox" with name like "Post your reply"
358
+ or "Post text". Click it.
359
+ c) Type YOUR_REPLY_TEXT (post-Step-3b short-link-wrapped form, post-Step-3a
360
+ suffix) into that textbox. Do NOT auto-submit; we click the Reply
361
+ button explicitly in step e.
362
+ d) Re-snapshot the page (refs can shift after typing).
363
+ e) Find the submit button: role="button" with name="Reply", or selector
364
+ [data-testid="tweetButtonInline"]. Click it.
365
+ Do NOT match a generic "Reply" by accessible name without checking testid:
366
+ every reply icon on the page also reads as "Reply" and you'll click the
367
+ wrong one.
368
+ f) Wait ~3s, then re-snapshot to confirm the textbox is empty
369
+ (= post landed). If your draft text is still in the textbox after
370
+ 8s, treat as a failed click and retry per the rule above.
371
+ g) Capture REPLY_URL:
372
+ - Navigate to https://x.com/m13v_/with_replies
373
+ - Snapshot the page
374
+ - Find the topmost link matching /m13v_/status/<digits>. That is REPLY_URL.
375
+ If no fresh reply URL appears within 30s, leave REPLY_URL empty and
376
+ continue to Step 5 (the reply IS posted; we just lack the URL link).
377
+ Step 5: python3 reply_db.py replied ID "reply text" REPLY_URL ENGAGEMENT_STYLE [IS_RECOMMENDATION] <- mark AFTER success. ENGAGEMENT_STYLE is TONE (e.g. critic, storyteller). Pass IS_RECOMMENDATION="1" ONLY when the reply casually recommends a project (Tier 2/3); leave unset otherwise. Tone and intent are independent. Use the FINAL TYPED TEXT (with any campaign suffix from Step 3a) as "reply text" so the stored content matches what was posted.
378
+ Step 5a: If CAMPAIGN_FIRED=1 from Step 3a, attribute this reply to the
379
+ campaign and advance the counter. The reply id is the ID you passed
380
+ to reply_db.py in Step 5 (it returns the row id; or query
381
+ \`SELECT id FROM replies ORDER BY id DESC LIMIT 1\` if you can't parse it):
382
+ python3 $REPO_DIR/scripts/campaign_bump.py --table replies --id REPLY_ROW_ID --campaign-id CAMPAIGN_ID_TO_BUMP
383
+ If CAMPAIGN_FIRED=0, skip this step entirely.
384
+ Step 5b: BACKFILL SHORT-LINK ATTRIBUTION. If you minted a short link in Step 3b
385
+ (MINTED_SESSION is non-empty AND not the string "null"), stamp it onto
386
+ this reply row now that Step 5 succeeded. The reply id is the same ID
387
+ you passed to reply_db.py in Step 5:
388
+ python3 $REPO_DIR/scripts/dm_short_links.py backfill-reply --minted-session MINTED_SESSION --reply-id ID
389
+ This sets post_links.reply_id so the /r/<code> clicks attribute to this
390
+ engagement reply (same mechanism link-edit pipelines use via backfill-post).
391
+ If Step 3b minted nothing (no URL in the reply, MINTED_SESSION null), skip this step.
392
+ If Step 5 fails, the item stays 'processing' and will be reset to 'pending' on the next run.
393
+ If the tweet has been deleted or is unavailable, mark as 'skipped' with reason 'tweet_not_found'.
394
+
395
+ After every 10 replies, run: python3 $REPO_DIR/scripts/reply_db.py status
396
+ PROMPT_EOF
397
+
398
+ # Phase B Claude timeout: 30 min (was 5400=90 min). Real engage runs
399
+ # complete in 5-15 min. The 90-min cap let a single broken-data run hold
400
+ # the twitter-browser lock for 45+ min and starve DM lanes (see 2026-05-26
401
+ # incident). 30 min is a generous ceiling for legitimate work; the
402
+ # JOIN-aware guard above already cuts the no-op case to <3 s.
403
+ gtimeout 1800 "$REPO_DIR/scripts/run_claude.sh" "engage-twitter-phaseB" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --output-format stream-json --verbose -p "$(cat "$PHASE_B_PROMPT")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: Phase B claude exited with code $?"
404
+ rm -f "$PHASE_B_PROMPT"
405
+ fi
406
+
407
+ # Reset any items left in 'processing' after subprocess exit. The
408
+ # /api/v1/replies/reset-stuck route requires a positive
409
+ # older_than_hours; we use 1h here so a freshly-stuck row from this run's
410
+ # Claude subprocess gets reset on the next cycle's pre-Phase-B sweep
411
+ # instead of immediately (so we don't race a still-progressing Claude that
412
+ # JUST set processing_at = NOW()).
413
+ POST_RESET=$(python3 "$ENGAGE_TWITTER_HELPER" post-reset)
414
+ [ "$POST_RESET" -gt 0 ] && log "Post-run: Reset $POST_RESET 'processing' Twitter items back to pending"
415
+
416
+ # ═══════════════════════════════════════════════════════
417
+ # Cleanup
418
+ # ═══════════════════════════════════════════════════════
419
+ # One HTTP roundtrip for all three counts instead of three psql one-liners.
420
+ COUNTS_JSON=$(python3 "$ENGAGE_TWITTER_HELPER" reply-counts 2>/dev/null || echo '{"pending":0,"replied":0,"skipped":0}')
421
+ TOTAL_PENDING=$(echo "$COUNTS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('pending',0))" 2>/dev/null || echo "0")
422
+ TOTAL_REPLIED=$(echo "$COUNTS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('replied',0))" 2>/dev/null || echo "0")
423
+ TOTAL_SKIPPED=$(echo "$COUNTS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('skipped',0))" 2>/dev/null || echo "0")
424
+
425
+ log "Twitter summary: pending=$TOTAL_PENDING replied=$TOTAL_REPLIED skipped=$TOTAL_SKIPPED"
426
+
427
+ # Log run to persistent monitor
428
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
429
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "engage-twitter-phaseB" 2>/dev/null || echo "0.0000")
430
+ # Pull Phase A scan-stage counters out of the log so the dashboard Result
431
+ # column shows "scanned N / new N / excluded N" on engage runs. Phase A prints:
432
+ # Processing N mentions...
433
+ # Summary: N new, N already tracked, N excluded, N own account, N too short, N no_tweet_id
434
+ # We normalize to scanned/new/excluded/unmatched. Empty (Phase A failed before
435
+ # printing) -> no --scan arg, dashboard falls back to old rendering.
436
+ TW_SCAN_PROC_LINE=$(grep -m1 -E "^Processing [0-9]+ mentions\.\.\.$" "$LOG_FILE" 2>/dev/null || true)
437
+ TW_SCAN_SUMMARY_LINE=$(grep -m1 -E "^Summary: [0-9]+ new" "$LOG_FILE" 2>/dev/null || true)
438
+ TW_SCAN_ARG=""
439
+ if [ -n "$TW_SCAN_PROC_LINE" ] || [ -n "$TW_SCAN_SUMMARY_LINE" ]; then
440
+ tw_scanned=$(echo "$TW_SCAN_PROC_LINE" | grep -oE "[0-9]+" | head -1)
441
+ tw_new=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ new" | grep -oE "[0-9]+" | head -1)
442
+ tw_already=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ already tracked" | grep -oE "[0-9]+" | head -1)
443
+ tw_excluded=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ excluded" | grep -oE "[0-9]+" | head -1)
444
+ tw_own=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ own account" | grep -oE "[0-9]+" | head -1)
445
+ tw_short=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ too short" | grep -oE "[0-9]+" | head -1)
446
+ tw_noid=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ no tweet_id" | grep -oE "[0-9]+" | head -1)
447
+ # excluded pill = excluded + own_account; unmatched pill = too_short + no_tweet_id
448
+ tw_excl_total=$(( ${tw_excluded:-0} + ${tw_own:-0} ))
449
+ tw_unm_total=$(( ${tw_short:-0} + ${tw_noid:-0} ))
450
+ parts=""
451
+ [ -n "$tw_scanned" ] && parts="${parts}scanned=${tw_scanned},"
452
+ [ -n "$tw_new" ] && parts="${parts}new=${tw_new},"
453
+ [ -n "$tw_already" ] && parts="${parts}already=${tw_already},"
454
+ [ "$tw_excl_total" -gt 0 ] && parts="${parts}excluded=${tw_excl_total},"
455
+ [ "$tw_unm_total" -gt 0 ] && parts="${parts}unmatched=${tw_unm_total},"
456
+ TW_SCAN_ARG="${parts%,}"
457
+ fi
458
+ if [ -n "$TW_SCAN_ARG" ]; then
459
+ python3 "$REPO_DIR/scripts/log_run.py" --script "engage_twitter" --posted "$TOTAL_REPLIED" --skipped "$TOTAL_SKIPPED" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED" --scan "$TW_SCAN_ARG"
460
+ else
461
+ python3 "$REPO_DIR/scripts/log_run.py" --script "engage_twitter" --posted "$TOTAL_REPLIED" --skipped "$TOTAL_SKIPPED" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
462
+ fi
463
+
464
+ # Delete old logs
465
+ find "$LOG_DIR" -name "engage-twitter-*.log" -mtime +7 -delete 2>/dev/null || true
466
+
467
+ log "=== Twitter engagement complete: $(date) ==="