@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,250 @@
1
+ #!/usr/bin/env bash
2
+ # dm-outreach-linkedin.sh — Outbound LinkedIn DM outreach.
3
+ # Scans for DM candidates (users who engaged on our posts), then sends LinkedIn
4
+ # messages to continue the conversation. Inbound DM replies are handled separately
5
+ # by engage-dm-replies-linkedin.sh.
6
+ # Called by launchd (com.m13v.social-dm-outreach-linkedin) every 6 hours.
7
+
8
+ set -euo pipefail
9
+
10
+ # LinkedIn killswitch (2026-05-27): refuse to run if a prior fire detected
11
+ # session compromise (http_999, authwall, throttle, li_at cleared).
12
+ # State: ~/.claude/social-autoposter/linkedin.killswitch
13
+ # Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
14
+ if [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
15
+ echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
16
+ echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
17
+ exit 0
18
+ fi
19
+
20
+ # Cycle ID for cross-cycle cost accounting (see run-linkedin.sh / engage-linkedin.sh
21
+ # for the same pattern). Stamps claude_sessions.cycle_id via env inheritance.
22
+ BATCH_ID="${BATCH_ID:-dmli-$(date +%Y%m%d-%H%M%S)}"
23
+ export BATCH_ID
24
+ export SA_CYCLE_ID="$BATCH_ID"
25
+
26
+ # Browser-profile lock first (shared with other linkedin pipelines), then pipeline lock.
27
+ source "$(dirname "$0")/lock.sh"
28
+ # Browser backend bootstrap (linkedin-harness). Sets MCP_CONFIG_FILE,
29
+ # BROWSER_INSTRUCTIONS, exports LINKEDIN_CDP_URL, and provides
30
+ # ensure_linkedin_browser_for_backend. Migrated off the deprecated
31
+ # mcp__linkedin-agent Playwright MCP to the CDP-driven harness Chrome (port 9556).
32
+ source "$(dirname "$0")/lib/linkedin-backend.sh"
33
+ acquire_lock "linkedin-browser" 3600
34
+ ensure_linkedin_browser_for_backend
35
+ acquire_lock "dm-outreach-linkedin" 2700
36
+
37
+ # Load secrets
38
+ # shellcheck source=/dev/null
39
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
40
+
41
+ REPO_DIR="$HOME/social-autoposter"
42
+ SKILL_FILE="$REPO_DIR/SKILL.md"
43
+ LOG_DIR="$REPO_DIR/skill/logs"
44
+
45
+ mkdir -p "$LOG_DIR"
46
+ LOG_FILE="$LOG_DIR/dm-outreach-linkedin-$(date +%Y-%m-%d_%H%M%S).log"
47
+
48
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
49
+
50
+ RUN_START=$(date +%s)
51
+ log "=== LinkedIn DM Outreach Run: $(date) ==="
52
+
53
+ # DB-free since 2026-06-01: all DM state goes through the s4l.ai HTTP API
54
+ # (X-Installation auth). No DATABASE_URL needed.
55
+ PY_BIN="$(command -v python3 || echo /usr/bin/python3)"
56
+
57
+ # dm_count <status> -> integer count of linkedin dms in that status.
58
+ # Backed by GET /api/v1/dms/counts (same shape as /api/v1/replies/counts).
59
+ dm_count() {
60
+ "$PY_BIN" -c "
61
+ import sys; sys.path.insert(0, '$REPO_DIR/scripts')
62
+ from http_api import api_get
63
+ resp = api_get('/api/v1/dms/counts', {'platform': 'linkedin'})
64
+ counts = ((resp or {}).get('data') or {}).get('counts') or []
65
+ want = '$1'
66
+ print(next((int(r.get('count', 0)) for r in counts if r.get('status') == want), 0))
67
+ " 2>/dev/null || echo 0
68
+ }
69
+
70
+ # Scan for new DM candidates first (cheap Python, writes to dms table)
71
+ log "Scanning for DM candidates (all platforms)..."
72
+ (PYTHONUNBUFFERED=1 python3 "$REPO_DIR/scripts/scan_dm_candidates.py" 2>&1 || true) | tee -a "$LOG_FILE"
73
+
74
+ DM_PENDING=$(dm_count pending)
75
+
76
+ if [ "$DM_PENDING" -eq 0 ]; then
77
+ log "No pending LinkedIn DMs"
78
+ python3 "$REPO_DIR/scripts/log_run.py" --script "dm_outreach_linkedin" --posted 0 --skipped 0 --failed 0 --cost 0 --elapsed $(( $(date +%s) - RUN_START ))
79
+ exit 0
80
+ fi
81
+
82
+ log "LinkedIn: $DM_PENDING DMs to send"
83
+
84
+ # Pull the pending DM batch + 60-day cross-thread engagement via the
85
+ # outreach-queue endpoint (no DATABASE_URL). The route mirrors the old
86
+ # json_agg join exactly and returns {rows:[...]}. We extract the array so
87
+ # DM_DATA keeps the same JSON shape the prompt consumed before the migration.
88
+ DM_DATA=$("$PY_BIN" -c "
89
+ import json, sys
90
+ sys.path.insert(0, '$REPO_DIR/scripts')
91
+ from http_api import api_get
92
+ resp = api_get('/api/v1/dms/outreach-queue', {'platform': 'linkedin', 'status': 'pending', 'limit': 200})
93
+ rows = (resp.get('data') or {}).get('rows') or []
94
+ print(json.dumps(rows))
95
+ " 2>/dev/null || echo "[]")
96
+
97
+ # Per-project qualification context for ICP pre-check
98
+ PROJECTS_QUALIFICATION=$(python3 -c "
99
+ import json
100
+ c = json.load(open('$REPO_DIR/config.json'))
101
+ for p in c.get('projects', []):
102
+ q = p.get('qualification') or {}
103
+ if not q:
104
+ continue
105
+ print(f\"- {p['name']}:\")
106
+ if q.get('must_have'):
107
+ print(f\" must_have: {' ; '.join(q['must_have'])}\")
108
+ if q.get('disqualify'):
109
+ print(f\" disqualify: {' ; '.join(q['disqualify'])}\")
110
+ " 2>/dev/null || echo "")
111
+
112
+ export CLAUDE_SESSION_ID=$(uuidgen | tr 'A-Z' 'a-z')
113
+
114
+ PROMPT_FILE=$(mktemp)
115
+ cat > "$PROMPT_FILE" <<PROMPT_EOF
116
+ You are the Social Autoposter LinkedIn DM outreach bot.
117
+
118
+ $BROWSER_INSTRUCTIONS
119
+
120
+ Read $SKILL_FILE for content rules (tone, anti-AI detection, no em dashes).
121
+
122
+ ## Task: Send LinkedIn messages to continue comment conversations
123
+
124
+ These users engaged with our LinkedIn posts/comments. We already replied publicly. Now send a short, casual message to continue the conversation.
125
+
126
+ CRITICAL RULES:
127
+ 1. Messages must feel like a natural continuation of the comment discussion, NOT a cold outreach or sales pitch
128
+ 2. Reference the specific conversation topic, not generic "hey I saw your comment"
129
+ 3. Keep it short: 1-2 sentences max, like a text message
130
+ 4. No links in the first DM; earn the conversation first
131
+ 5. No em dashes. Write casually, like texting a coworker.
132
+
133
+ ## COMMITMENT GUARDRAILS (violating any of these is a critical failure)
134
+ - **NEVER suggest, offer, or agree to calls, meetings, demos, or video chats.** Keep everything in the DM thread.
135
+ - **NEVER agree to podcast appearances, X Spaces, interviews, or live events.**
136
+ - **NEVER offer to move the conversation to another platform** (Telegram, Discord, email, etc.). Stay in the current DM thread.
137
+ - **NEVER promise to share specific links, files, or resources you don't have right now.** If you don't have it in config.json projects, don't promise it.
138
+ - **NEVER make time-bound commitments** ("this week", "tomorrow", "Thursday"). Don't commit the human to any schedule.
139
+ - **NEVER say "I'm in [city]"** or share location/personal details not in config.json.
140
+ - If someone asks for any of the above, respond naturally but deflect: keep the conversation going in the DM without making promises. Example: "honestly easier to hash it out here, what specifically are you trying to set up?"
141
+
142
+ DM EXAMPLES (good):
143
+ - "yo your point about token costs scaling with agent count hit home, we're dealing with the exact same thing. what's your setup look like?"
144
+ - "that workaround you mentioned for the accessibility API crash is clever, did it hold up in production?"
145
+ - "curious how you ended up going with that approach for the MCP server, we tried something similar"
146
+
147
+ DM EXAMPLES (bad):
148
+ - "Hey! I noticed your comment on LinkedIn. I'm building something you might find interesting..." (cold pitch)
149
+ - "Great point! I'd love to connect and share what we're working on." (generic)
150
+ - "Hi there, I saw your insightful comment about AI agents..." (too formal)
151
+
152
+ ## Users to DM:
153
+ $DM_DATA
154
+
155
+ ## Cross-thread engagement awareness
156
+ Each row may include an \`other_engagement\` array: this user's other recent (60-day) interactions with our posts on the same platform. Each entry has thread_title, their_content snippet, our_reply_content snippet, depth (>1 = public follow-up to our reply in a thread), status, replied_at.
157
+
158
+ Use it as context for the DM:
159
+ - If the most recent other_engagement entry is on the SAME thread with depth>1 and replied_at < 6 hours ago, they're actively continuing the public conversation. Prefer a lighter-touch DM, or open with an acknowledgment of the ongoing thread instead of introducing a new angle.
160
+ - If they've engaged on multiple other threads, it signals genuine interest. The DM can be slightly more direct without feeling cold.
161
+ - Do NOT quote their other comments back at them or enumerate their history. It's context, not content.
162
+
163
+ ## Per-project ICP criteria (used for the pre-check step, NOT to skip sending):
164
+ $PROJECTS_QUALIFICATION
165
+
166
+ ## Pre-send profile fetch + ICP pre-check (MANDATORY per DM, no filter)
167
+
168
+ For each DM row, BEFORE you compose or send, do this in order. USE the bh_run tool from the BROWSER BACKEND block ONLY (follow its translation table for any Playwright-style step below); NEVER call /voyager/api/; NEVER run Python CDP scripts against LinkedIn.
169
+
170
+ 1. Look at the row's \`target_project\`. If it's NULL, set icp_precheck=unknown with notes="no_target_project" and proceed to step 4 — but still try to capture profile basics.
171
+
172
+ 2. Fetch the prospect's LinkedIn profile:
173
+ - From the original comment thread (r.their_comment_url), click into THEIR_AUTHOR's profile link, OR
174
+ - Search LinkedIn for THEIR_AUTHOR from the messaging UI once you have them open.
175
+ - Read their profile header DOM (bh_run with js(...) per the translation table, or capture_screenshot + Read the PNG). Extract: headline, current company, current role, a short summary of their About/experience top section, and (if visible) 1-2 recent posts/activity items.
176
+ - If you hit a login/checkpoint, STOP and print SESSION_INVALID; do NOT attempt to log in.
177
+ - If the profile is private or shows only a minimal header, record what you can and note "profile_limited".
178
+
179
+ 3. Persist the profile fields:
180
+ \`\`\`bash
181
+ python3 $REPO_DIR/scripts/fetch_prospect_profile.py upsert \\
182
+ --platform linkedin --author "THEIR_AUTHOR" \\
183
+ --profile-url "PROFILE_URL" \\
184
+ --headline "HEADLINE_FROM_PROFILE" \\
185
+ --company "CURRENT_COMPANY" \\
186
+ --role "CURRENT_ROLE" \\
187
+ --bio "SHORT_ABOUT_OR_SUMMARY" \\
188
+ --recent-activity "1-2 LINE RECENT ACTIVITY SUMMARY" \\
189
+ --notes "ANY_SIGNAL_WORTH_REMEMBERING" \\
190
+ --link-dm DM_ID
191
+ \`\`\`
192
+ Omit any flag whose value is empty or unknown. \`--link-dm\` also wires dms.prospect_id.
193
+
194
+ 4. Evaluate ICP match against EVERY project listed in "Per-project ICP criteria" above (not only target_project). For each project compare the profile + their_content + comment_context against its must_have (satisfy at least one) and disqualify (trigger ANY = fail), and pick one label: icp_match, icp_miss, disqualified, or unknown. Upsert one entry per project:
195
+ \`\`\`bash
196
+ python3 $REPO_DIR/scripts/dm_conversation.py set-icp-precheck \\
197
+ --dm-id DM_ID --project PROJECT_NAME --label LABEL --notes "SHORT_RATIONALE"
198
+ \`\`\`
199
+ Run this once per project from the list. Each call upserts one entry in dms.icp_matches (JSONB array) keyed by project.
200
+
201
+ 5. If ANY entry in icp_matches has label=disqualified, skip the send: run \`python3 scripts/dm_conversation.py mark-skipped --dm-id DM_ID --reason "disqualified: PROJECT - SHORT_NOTES"\` and move on. \`icp_miss\` alone does NOT gate; send when every project scored miss. Only explicit \`disqualified\` blocks the opener.
202
+
203
+ ## How to send messages on LinkedIn (use the bh_run tool):
204
+ 1. Navigate to https://www.linkedin.com/messaging/ (bh_run: new_tab/goto_url + wait_for_load)
205
+ 2. Start new message to THEIR_AUTHOR
206
+ 3. Type and send the message. Click the message box (click_at_xy) then type_text; click the Send button via click_at_xy. Do NOT press Enter (Enter inserts a newline in LinkedIn's contenteditable).
207
+
208
+ ## After each DM:
209
+
210
+ Inspect the send result (capture_screenshot + Read the PNG to confirm the message appeared in the thread). There are exactly three outcomes:
211
+
212
+ (A) The message was actually delivered (you saw it appear in the thread, no error toast) -> mark sent via the verified gateway:
213
+ CLAUDE_SESSION_ID=$CLAUDE_SESSION_ID python3 $REPO_DIR/scripts/dm_send_log.py \\
214
+ --dm-id DM_ID --message "DM_TEXT" --verified
215
+
216
+ Do NOT issue a raw "UPDATE dms SET status='sent'" psql command and do NOT call
217
+ dm_conversation.py log-outbound directly. dm_send_log.py is the ONLY path that
218
+ may flip status to 'sent'; it requires --verified, and refuses without it. It
219
+ also forwards to log-outbound internally with --verified, so dm_messages stays
220
+ in sync. This is intentional: prior phantom-DM bugs (April 2026 LinkedIn Haiku
221
+ cycle inserted 5 phantom outbound rows because the prompt let the LLM call
222
+ log-outbound without a verified send) came from bypassing this gateway.
223
+
224
+ (B) The send did not land (no toast, message did not appear in thread) -> mark error:
225
+ python3 $REPO_DIR/scripts/dm_db_update.py --dm-id DM_ID --status error --skip-reason send_unverified --claude-session-id "$CLAUDE_SESSION_ID"
226
+
227
+ (C) DMs disabled / chat blocked -> mark skipped:
228
+ python3 $REPO_DIR/scripts/dm_db_update.py --dm-id DM_ID --status skipped --skip-reason chat_disabled --claude-session-id "$CLAUDE_SESSION_ID"
229
+
230
+ (D) Rate limit, account checkpoint, or any other thrown exception -> mark error and STOP the run:
231
+ python3 $REPO_DIR/scripts/dm_db_update.py --dm-id DM_ID --status error --skip-reason "REASON" --claude-session-id "$CLAUDE_SESSION_ID"
232
+
233
+ CRITICAL: ALL browser calls MUST use the mcp__linkedin-harness__bh_run tool (the BROWSER BACKEND block above). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, or mcp__macos-use__* tools. If a bh_run call is blocked or times out, wait 30 seconds and retry (up to 3 times). Do NOT fall back to any other browser tool.
234
+ PROMPT_EOF
235
+
236
+ ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
237
+ gtimeout 2700 "$REPO_DIR/scripts/run_claude.sh" "dm-outreach-linkedin" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --output-format stream-json --verbose -p "$(cat "$PROMPT_FILE")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: LinkedIn DM outreach claude exited with code $?"
238
+ rm -f "$PROMPT_FILE"
239
+
240
+ SENT=$(dm_count sent)
241
+ STILL_PENDING=$(dm_count pending)
242
+ log "LinkedIn DM outreach summary: sent (all-time)=$SENT, still_pending=$STILL_PENDING"
243
+
244
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
245
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "dm-outreach-linkedin" 2>/dev/null || echo "0.0000")
246
+ python3 "$REPO_DIR/scripts/log_run.py" --script "dm_outreach_linkedin" --posted 0 --skipped 0 --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
247
+
248
+ find "$LOG_DIR" -name "dm-outreach-linkedin-*.log" -mtime +7 -delete 2>/dev/null || true
249
+
250
+ log "=== LinkedIn DM outreach complete: $(date) ==="
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env bash
2
+ # dm-outreach-reddit.sh — Outbound Reddit DM outreach.
3
+ # Scans for DM candidates (users who engaged on our posts), then sends Reddit DMs
4
+ # to continue the conversation. Inbound DM replies are handled separately by
5
+ # engage-dm-replies-reddit.sh.
6
+ # Called by launchd (com.m13v.social-dm-outreach-reddit) every 6 hours.
7
+
8
+ set -euo pipefail
9
+
10
+ # Cycle ID for cross-cycle cost accounting (matches the pattern in
11
+ # run-reddit-search.sh, engage-reddit.sh, etc.). Every claude session spawned
12
+ # in this script inherits SA_CYCLE_ID via env so log_claude_session.py stamps
13
+ # claude_sessions.cycle_id. Lets get_run_cost.py --cycle-id report THIS run's
14
+ # spend instead of bleeding into overlapping outreach cycles.
15
+ BATCH_ID="${BATCH_ID:-dmrd-$(date +%Y%m%d-%H%M%S)}"
16
+ export BATCH_ID
17
+ export SA_CYCLE_ID="$BATCH_ID"
18
+
19
+ # Pipeline lock at top (only-one-of-us guard). We DO NOT acquire
20
+ # reddit-browser at the bash level anymore — claude itself acquires it
21
+ # per-DM via scripts/reddit_browser_lock.py, only around the actual MCP
22
+ # browser operations (profile fetch + compose DM, ~30-90s per DM). This
23
+ # unblocks peer reddit pipelines (engage-reddit, dm-replies-reddit,
24
+ # link-edit-reddit, post-reddit) during the DB scan, prompt build, and
25
+ # HTTP PATCH update phases of each DM row.
26
+ source "$(dirname "$0")/lock.sh"
27
+ # reddit-harness backend (2026-05-29). Sets MCP_CONFIG_FILE (reddit-harness MCP),
28
+ # BROWSER_INSTRUCTIONS (bh_run tool surface + translation table), exports
29
+ # REDDIT_CDP_URL=:9557, and provides ensure_reddit_browser_for_backend.
30
+ # Source after lock.sh, before acquire_lock / claude -p.
31
+ source "$(dirname "$0")/lib/reddit-backend.sh"
32
+ acquire_lock "dm-outreach-reddit" 2700
33
+
34
+ # Load secrets
35
+ # shellcheck source=/dev/null
36
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
37
+
38
+ REPO_DIR="$HOME/social-autoposter"
39
+ SKILL_FILE="$REPO_DIR/SKILL.md"
40
+ LOG_DIR="$REPO_DIR/skill/logs"
41
+
42
+ # 2026-05-12 migration: bash-level DB access moved off psql / DATABASE_URL
43
+ # onto HTTP routes (/api/v1/dms*, /api/v1/dms/outreach-queue). The shell no
44
+ # longer needs Postgres credentials; everything flows through
45
+ # scripts/dm_outreach_helper.py which calls the website.
46
+ # DATABASE_URL may still be defined in .env for other tooling; we don't
47
+ # require it here. The Python http_api layer expects AUTOPOSTER_API_BASE
48
+ # (defaults to https://s4l.ai) and an installation identity header.
49
+
50
+ mkdir -p "$LOG_DIR"
51
+ LOG_FILE="$LOG_DIR/dm-outreach-reddit-$(date +%Y-%m-%d_%H%M%S).log"
52
+
53
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
54
+
55
+ RUN_START=$(date +%s)
56
+ log "=== Reddit DM Outreach Run: $(date) ==="
57
+
58
+ # Scan for new DM candidates first (cheap Python, writes to dms table)
59
+ log "Scanning for DM candidates (all platforms)..."
60
+ (PYTHONUNBUFFERED=1 python3 "$REPO_DIR/scripts/scan_dm_candidates.py" 2>&1 || true) | tee -a "$LOG_FILE"
61
+
62
+ DM_PENDING=$(python3 "$REPO_DIR/scripts/dm_outreach_helper.py" count --platform reddit --status pending 2>/dev/null || echo "0")
63
+
64
+ if [ "$DM_PENDING" -eq 0 ]; then
65
+ log "No pending Reddit DMs"
66
+ python3 "$REPO_DIR/scripts/log_run.py" --script "dm_outreach_reddit" --posted 0 --skipped 0 --failed 0 --cost 0 --elapsed $(( $(date +%s) - RUN_START ))
67
+ exit 0
68
+ fi
69
+
70
+ log "Reddit: $DM_PENDING DMs to send"
71
+
72
+ DM_DATA=$(python3 "$REPO_DIR/scripts/dm_outreach_helper.py" outreach-queue \
73
+ --platform reddit --status pending --limit 200 \
74
+ --other-engagement-days 60 2>/dev/null || echo "[]")
75
+
76
+ # Per-project qualification context for ICP pre-check
77
+ PROJECTS_QUALIFICATION=$(python3 -c "
78
+ import json
79
+ c = json.load(open('$REPO_DIR/config.json'))
80
+ for p in c.get('projects', []):
81
+ q = p.get('qualification') or {}
82
+ if not q:
83
+ continue
84
+ print(f\"- {p['name']}:\")
85
+ if q.get('must_have'):
86
+ print(f\" must_have: {' ; '.join(q['must_have'])}\")
87
+ if q.get('disqualify'):
88
+ print(f\" disqualify: {' ; '.join(q['disqualify'])}\")
89
+ " 2>/dev/null || echo "")
90
+
91
+ export CLAUDE_SESSION_ID=$(uuidgen | tr 'A-Z' 'a-z')
92
+
93
+ PROMPT_FILE=$(mktemp)
94
+ cat > "$PROMPT_FILE" <<PROMPT_EOF
95
+ You are the Social Autoposter Reddit DM outreach bot.
96
+
97
+ Read $SKILL_FILE for content rules (tone, anti-AI detection, no em dashes).
98
+
99
+ $BROWSER_INSTRUCTIONS
100
+
101
+ ## Task: Send Reddit DMs to continue comment conversations
102
+
103
+ These users engaged with our Reddit posts/comments. We already replied publicly. Now send a short, casual DM (Reddit Chat) to continue the conversation.
104
+
105
+ CRITICAL RULES:
106
+ 1. DMs must feel like a natural continuation of the comment discussion, NOT a cold outreach or sales pitch
107
+ 2. Reference the specific conversation topic, not generic "hey I saw your comment"
108
+ 3. Keep it short: 1-2 sentences max, like a text message
109
+ 4. No links in the first DM; earn the conversation first
110
+ 5. No em dashes. Write casually, like texting a coworker.
111
+
112
+ EXECUTION MODEL — STRICT SEQUENTIAL, NO BATCHING (read this twice):
113
+ - Process DMs ONE AT A TIME. Run the FULL chain (profile fetch → ICP pre-check → compose → send → log) end-to-end for DM N before reading DM N+1.
114
+ - The reddit-browser lock is NOT held by the parent shell. You acquire/release it explicitly per DM (steps 1.5 and 7.5 below), so peer reddit pipelines can use the browser during your DB queries, ICP scoring, and dm_outreach_helper.py PATCH calls.
115
+ - The lock has a 90s lease that auto-renews on every reddit-agent MCP call (PreToolUse / PostToolUse hooks), so you do NOT need to manually heartbeat. Just acquire before the first browser call for the DM, release after the last one.
116
+
117
+ ## COMMITMENT GUARDRAILS (violating any of these is a critical failure)
118
+ - **NEVER suggest, offer, or agree to calls, meetings, demos, or video chats.** Keep everything in the DM thread.
119
+ - **NEVER agree to podcast appearances, X Spaces, interviews, or live events.**
120
+ - **NEVER offer to move the conversation to another platform** (Telegram, Discord, email, etc.). Stay in the current DM thread.
121
+ - **NEVER promise to share specific links, files, or resources you don't have right now.** If you don't have it in config.json projects, don't promise it.
122
+ - **NEVER make time-bound commitments** ("this week", "tomorrow", "Thursday"). Don't commit the human to any schedule.
123
+ - **NEVER say "I'm in [city]"** or share location/personal details not in config.json.
124
+ - If someone asks for any of the above, respond naturally but deflect: keep the conversation going in the DM without making promises. Example: "honestly easier to hash it out here, what specifically are you trying to set up?"
125
+
126
+ DM EXAMPLES (good):
127
+ - "yo your point about token costs scaling with agent count hit home, we're dealing with the exact same thing. what's your setup look like?"
128
+ - "that workaround you mentioned for the accessibility API crash is clever, did it hold up in production?"
129
+ - "curious how you ended up going with that approach for the MCP server, we tried something similar"
130
+
131
+ DM EXAMPLES (bad):
132
+ - "Hey! I noticed your comment on Reddit. I'm building something you might find interesting..." (cold pitch)
133
+ - "Great point! I'd love to connect and share what we're working on." (generic)
134
+ - "Hi there - I saw your insightful comment about AI agents..." (too formal)
135
+
136
+ ## Users to DM:
137
+ $DM_DATA
138
+
139
+ ## Cross-thread engagement awareness
140
+ Each row may include an \`other_engagement\` array: this user's other recent (60-day) interactions with our posts on the same platform. Each entry has thread_title, their_content snippet, our_reply_content snippet, depth (>1 = public follow-up to our reply in a thread), status, replied_at.
141
+
142
+ Use it as context for the DM:
143
+ - If the most recent other_engagement entry is on the SAME thread with depth>1 and replied_at < 6 hours ago, they're actively continuing the public conversation. Prefer a lighter-touch DM, or open with an acknowledgment of the ongoing thread instead of introducing a new angle.
144
+ - If they've engaged on multiple other threads, it signals genuine interest. The DM can be slightly more direct without feeling cold.
145
+ - Do NOT quote their other comments back at them or enumerate their history. It's context, not content.
146
+
147
+ ## Per-project ICP criteria (used for the pre-check step, NOT to skip sending):
148
+ $PROJECTS_QUALIFICATION
149
+
150
+ ## Pre-send profile fetch + ICP pre-check (MANDATORY per DM, no filter)
151
+
152
+ For each DM row, BEFORE you compose or send, do this in order:
153
+
154
+ 1. Look at the row's \`target_project\`. If it's NULL, skip the ICP evaluation (set icp_precheck=unknown with notes="no_target_project") and move to step 4 — but still fetch the profile if it's cheap.
155
+
156
+ 1.5. ACQUIRE the reddit-browser lock NOW (just before any reddit-agent browser call for this DM). This is the ONLY moment you may touch the browser for this DM:
157
+ \`\`\`bash
158
+ LOCK_OUT=\$(python3 $REPO_DIR/scripts/reddit_browser_lock.py acquire --timeout 600 2>&1)
159
+ \`\`\`
160
+ - If stdout starts with "OK", proceed to step 2.
161
+ - If "BUSY", a peer reddit pipeline owns the browser and didn't release within 10 min. Mark this DM error/transient and move on to the NEXT DM:
162
+ \`python3 $REPO_DIR/scripts/dm_outreach_helper.py patch --id DM_ID --status error --skip-reason reddit_browser_busy --claude-session-id $CLAUDE_SESSION_ID\`
163
+ - If "ERROR", same handling as BUSY: mark error, move on. Do NOT call browser tools without the lock — collisions on the same chrome profile crash both runs.
164
+
165
+ 2. Fetch the prospect's Reddit profile with the browser backend (mcp__reddit-harness__bh_run, see BROWSER BACKEND block above):
166
+ - Navigate to https://www.reddit.com/user/THEIR_AUTHOR/
167
+ - Read the page (snapshot / capture_screenshot per the translation table). Pull:
168
+ - the profile bio/tagline (text under their name)
169
+ - karma numbers (post + comment karma)
170
+ - a 1-2 line summary of their most recent 3-5 posts/comments (titles + subreddits)
171
+ - If the profile page is a login wall, deleted, or suspended user, record notes="profile_inaccessible" and proceed with icp_precheck=unknown.
172
+
173
+ 3. Persist the profile fields via:
174
+ \`\`\`bash
175
+ python3 $REPO_DIR/scripts/fetch_prospect_profile.py upsert \\
176
+ --platform reddit --author "THEIR_AUTHOR" \\
177
+ --profile-url "https://www.reddit.com/user/THEIR_AUTHOR/" \\
178
+ --headline "SHORT_TAGLINE_OR_BIO_FIRST_LINE" \\
179
+ --bio "FULL_BIO_TEXT" \\
180
+ --recent-activity "SHORT_3-5_ITEM_SUMMARY" \\
181
+ --notes "ANY_SIGNAL_WORTH_REMEMBERING" \\
182
+ --link-dm DM_ID
183
+ \`\`\`
184
+ Omit any flag whose value is empty or unknown. \`--link-dm\` also wires dms.prospect_id.
185
+
186
+ 4. Evaluate ICP match against EVERY project listed in "Per-project ICP criteria" above (not only target_project). For each project compare the profile + their_content + comment_context against its must_have (satisfy at least one) and disqualify (trigger ANY = fail), and pick one label: icp_match, icp_miss, disqualified, or unknown. Upsert one entry per project:
187
+ \`\`\`bash
188
+ python3 $REPO_DIR/scripts/dm_conversation.py set-icp-precheck \\
189
+ --dm-id DM_ID --project PROJECT_NAME --label LABEL --notes "SHORT_RATIONALE"
190
+ \`\`\`
191
+ Run this once per project from the list. Each call upserts one entry in dms.icp_matches (JSONB array) keyed by project.
192
+
193
+ 5. If ANY entry in icp_matches has label=disqualified, skip the send: run \`python3 scripts/dm_conversation.py mark-skipped --dm-id DM_ID --reason "disqualified: PROJECT - SHORT_NOTES"\` and move on. \`icp_miss\` alone does NOT gate; send when every project scored miss. Only explicit \`disqualified\` blocks the opener.
194
+
195
+ ## How to send DMs on Reddit (use the browser backend, mcp__reddit-harness__bh_run):
196
+ 1. Navigate to https://www.reddit.com/message/compose/?to=THEIR_AUTHOR
197
+ 2. Reddit uses Chat now. Fill in subject (2-4 casual words) and body.
198
+ 3. Submit. The send_dm / compose_dm tool returns a JSON object with an
199
+ "ok" field and a "verified" field. The send only counts if BOTH are true.
200
+
201
+ ## After each DM:
202
+
203
+ Inspect the tool's return value. There are exactly three outcomes:
204
+
205
+ (A) ok=true AND verified=true -> success, mark sent:
206
+ CLAUDE_SESSION_ID=$CLAUDE_SESSION_ID python3 $REPO_DIR/scripts/dm_send_log.py \\
207
+ --dm-id DM_ID --message "DM_TEXT" --verified
208
+
209
+ Do NOT use dm_outreach_helper.py to set status='sent'. The helper
210
+ refuses, and dm_send_log.py is the only path that may flip status to
211
+ 'sent'; it requires --verified, and refuses without it. This is
212
+ intentional: prior phantom-DM bugs (~700 rows in 4/2026) came from
213
+ prose-driven status flips that ignored the verification result.
214
+
215
+ (B) ok=false OR verified=false -> send did not land, mark error:
216
+ python3 $REPO_DIR/scripts/dm_outreach_helper.py patch --id DM_ID --status error --skip-reason send_unverified --claude-session-id $CLAUDE_SESSION_ID
217
+
218
+ (C) Rate limit, account blocked, or any other thrown exception:
219
+ python3 $REPO_DIR/scripts/dm_outreach_helper.py patch --id DM_ID --status error --skip-reason REASON --claude-session-id $CLAUDE_SESSION_ID
220
+
221
+ DMs/Chat disabled (recipient setting, not a send failure):
222
+ python3 $REPO_DIR/scripts/dm_outreach_helper.py patch --id DM_ID --status skipped --skip-reason chat_disabled --claude-session-id $CLAUDE_SESSION_ID
223
+
224
+ 7.5. RELEASE the reddit-browser lock IMMEDIATELY after the DM result is logged (success, error, or skip). This is mandatory — failing to release blocks every other reddit pipeline:
225
+ \`\`\`bash
226
+ python3 $REPO_DIR/scripts/reddit_browser_lock.py release
227
+ \`\`\`
228
+ Run this even if step 2 (profile fetch) raised, the send threw, or you're skipping the DM. Wrap the per-DM browser block (steps 2 → 7) in a way that step 7.5 ALWAYS executes (mental try/finally). The release is idempotent and safe to call multiple times. Move to the NEXT DM only after the release.
229
+
230
+ CRITICAL - Browser agent rule: ONLY use the browser tool described in the BROWSER BACKEND block above (mcp__reddit-harness__bh_run). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, mcp__reddit-agent__*, or mcp__macos-use__* tools. If a bh_run call is blocked or times out, wait 30 seconds and retry (up to 3 times). Do NOT fall back to any other browser tool.
231
+ PROMPT_EOF
232
+
233
+ # NOTE: We do NOT acquire reddit-browser at the bash level. Claude itself
234
+ # acquires/releases it per DM via scripts/reddit_browser_lock.py
235
+ # (steps 1.5 and 7.5 in the prompt). This keeps the lock held only during
236
+ # the actual ~30-90s reddit-agent browser ops per DM (profile fetch +
237
+ # compose), not the full ~45-min run. Peer pipelines (engage-reddit,
238
+ # dm-replies-reddit, link-edit-reddit, post-reddit) can use the profile
239
+ # during our DB queries, ICP scoring, and HTTP PATCH update phases.
240
+ #
241
+ # Pre-flight: ensure the profile isn't wedged by a prior crashed run.
242
+ # Unified lock (2026-05-10): brief Python acquire+release so the orphan-Chrome
243
+ # sweep happens once before claude starts. Python acquire honors expires_at,
244
+ # so a TTL-stale-but-PID-alive holder gets reclaimed automatically instead of
245
+ # blocking us for the full bash timeout.
246
+ log "Pre-flight: sweep orphan reddit-agent Chrome / playwright-mcp before handing off to claude..."
247
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 60 --ttl 30 2>&1 | tee -a "$LOG_FILE" || \
248
+ log "WARNING: reddit_browser_lock.py pre-flight acquire failed; proceeding (claude will retry per-DM)."
249
+ if ! ensure_reddit_browser_for_backend 2>&1 | tee -a "$LOG_FILE"; then
250
+ log "WARNING: reddit-harness bootstrap failed; falling back to ensure_browser_healthy reddit"
251
+ ensure_browser_healthy "reddit"
252
+ fi
253
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
254
+
255
+ gtimeout 2700 "$REPO_DIR/scripts/run_claude.sh" "dm-outreach-reddit" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --output-format stream-json --verbose -p "$(cat "$PROMPT_FILE")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: Reddit DM outreach claude exited with code $?"
256
+ rm -f "$PROMPT_FILE"
257
+
258
+ # Belt-and-suspenders: if claude exited without releasing the lock (e.g.
259
+ # crashed mid-DM before reaching step 7.5), free it now so peer
260
+ # pipelines aren't stuck behind a phantom holder. release_lock checks
261
+ # the lock_dir and rm-rf's it; safe even if claude already released.
262
+ python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
263
+
264
+ SENT=$(python3 "$REPO_DIR/scripts/dm_outreach_helper.py" count --platform reddit --status sent 2>/dev/null || echo "0")
265
+ STILL_PENDING=$(python3 "$REPO_DIR/scripts/dm_outreach_helper.py" count --platform reddit --status pending 2>/dev/null || echo "0")
266
+ log "Reddit DM outreach summary: sent (all-time)=$SENT, still_pending=$STILL_PENDING"
267
+
268
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
269
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "dm-outreach-reddit" 2>/dev/null || echo "0.0000")
270
+ python3 "$REPO_DIR/scripts/log_run.py" --script "dm_outreach_reddit" --posted 0 --skipped 0 --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
271
+
272
+ find "$LOG_DIR" -name "dm-outreach-reddit-*.log" -mtime +7 -delete 2>/dev/null || true
273
+
274
+ log "=== Reddit DM outreach complete: $(date) ==="