@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,1235 @@
1
+ #!/usr/bin/env python3
2
+ """Reddit reply engagement orchestrator.
3
+
4
+ Processes pending Reddit replies one at a time, each in its own Claude session.
5
+ This avoids the context accumulation problem of batching 200 replies into one session.
6
+
7
+ Usage:
8
+ python3 scripts/engage_reddit.py
9
+ python3 scripts/engage_reddit.py --dry-run # Print prompt for first reply, don't post
10
+ python3 scripts/engage_reddit.py --limit 5 # Process at most 5 replies
11
+ python3 scripts/engage_reddit.py --timeout 3600 # Global timeout in seconds (default: 5400)
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import random
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import uuid
23
+ from collections import Counter
24
+ from datetime import datetime, timezone
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ from http_api import api_get, api_post
28
+
29
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
30
+ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
31
+ REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
32
+ CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
33
+ REDDIT_MCP_CONFIG = os.path.expanduser("~/.claude/browser-agent-configs/reddit-agent-mcp.json")
34
+ REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
35
+
36
+ # Interpreter every child subprocess must run under. A bare PYTHON resolved
37
+ # to the user's system python, which lacks the pipeline deps (Playwright and
38
+ # friends) that live only in the owned uv runtime — so on a fresh box every
39
+ # reddit_browser.py reply died (the same class as the Karol/Twitter bug,
40
+ # 2026-06-22). Honor the authoritative S4L_PYTHON pin (set by the launchd
41
+ # plist), else sys.executable (the owned interpreter the MCP launches us under).
42
+ # Never the literal PYTHON: that re-rolls the PATH dice. Re-exported so
43
+ # grandchildren inherit it.
44
+ PYTHON = os.environ.get("S4L_PYTHON") or sys.executable
45
+ os.environ["S4L_PYTHON"] = PYTHON
46
+
47
+ from engagement_styles import (
48
+ REPLY_STYLES as VALID_STYLES,
49
+ get_styles_prompt,
50
+ get_content_rules,
51
+ get_anti_patterns,
52
+ get_voice_relationship_rule,
53
+ validate_or_register,
54
+ pick_style_for_post,
55
+ )
56
+
57
+
58
+ def _acquire_browser_lease(timeout: int = 600, ttl: int = 90):
59
+ """Acquire the reddit-browser lease for THIS reply's Claude+CDP work.
60
+
61
+ Per-reply acquire (not per-cycle) shipped 2026-05-13. Before this change,
62
+ engage-reddit.sh held the lease around the whole `engage_reddit.py --limit
63
+ N` run, so a 5-reply batch monopolised the browser for ~10-25 min while
64
+ peer reddit pipelines (run-reddit-search post phase, link-edit-reddit,
65
+ dm-outreach-reddit, engage-dm-replies) sat blocked through every Claude
66
+ session and 2s inter-reply sleep.
67
+
68
+ The reddit-agent MCP wrapper (scripts/mcp_lock_proxy.py) auto-heartbeats
69
+ expires_at on every JSON-RPC `tools/call`, so the lease stays alive
70
+ through Claude's MCP-driven search/fetch/draft loop without manual pulses.
71
+ Default 90s TTL gives plenty of headroom for Claude session startup
72
+ (~20s before the first MCP call) plus subsequent CDP posting.
73
+
74
+ Returns (ok: bool, msg: str). msg is the helper's last stdout line on
75
+ success, or BUSY/ERROR diagnostic on failure.
76
+ """
77
+ try:
78
+ r = subprocess.run(
79
+ [PYTHON, REDDIT_BROWSER_LOCK, "acquire",
80
+ "--timeout", str(timeout), "--ttl", str(ttl)],
81
+ capture_output=True, text=True, timeout=timeout + 30,
82
+ )
83
+ out_lines = [ln for ln in (r.stdout or "").strip().splitlines() if ln]
84
+ last = out_lines[-1] if out_lines else ""
85
+ if r.returncode == 0 and last.startswith("OK"):
86
+ return True, last
87
+ return False, last or (r.stderr or "").strip()[:200] or f"rc={r.returncode}"
88
+ except subprocess.TimeoutExpired:
89
+ return False, "subprocess_timeout"
90
+ except Exception as e:
91
+ return False, f"exception:{e}"
92
+
93
+
94
+ def _release_browser_lease() -> None:
95
+ """Release the reddit-browser lease. Idempotent (NOT_HELD is fine)."""
96
+ try:
97
+ subprocess.run(
98
+ [PYTHON, REDDIT_BROWSER_LOCK, "release"],
99
+ capture_output=True, text=True, timeout=10,
100
+ )
101
+ except Exception:
102
+ pass
103
+
104
+
105
+ def load_config():
106
+ with open(CONFIG_PATH) as f:
107
+ return json.load(f)
108
+
109
+
110
+ def load_active_reddit_campaigns():
111
+ """Active Reddit campaigns with a literal suffix and budget remaining.
112
+
113
+ Tool-level enforcement: the LLM never sees these. We append suffix to the
114
+ drafted text in Python before the browser submits, so the literal text is
115
+ guaranteed on Reddit. sample_rate gates the per-reply coin flip for A/B.
116
+
117
+ Reads /api/v1/campaigns?status=active&platform=reddit&has_suffix=true&with_budget_remaining=true.
118
+ """
119
+ resp = api_get(
120
+ "/api/v1/campaigns",
121
+ query={
122
+ "status": "active",
123
+ "platform": "reddit",
124
+ "has_suffix": "true",
125
+ "with_budget_remaining": "true",
126
+ "limit": 500,
127
+ },
128
+ )
129
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
130
+ return [
131
+ {
132
+ "id": int(r["id"]),
133
+ "suffix": r.get("suffix"),
134
+ "sample_rate": float(r.get("sample_rate") if r.get("sample_rate") is not None else 1.0),
135
+ }
136
+ for r in rows
137
+ ]
138
+
139
+
140
+ def strip_active_suffixes(text, active_campaigns):
141
+ """Remove any active-campaign suffix from `text` (idempotent, trailing-only).
142
+
143
+ Used to sanitize `recent_replies` snippets BEFORE feeding them into the
144
+ LLM prompt. Without this, the LLM sees prior tagged replies in the
145
+ "Your last N replies" block, copies the literal suffix into its draft,
146
+ and `engage_reddit.py`'s tool-level injection then appends a SECOND
147
+ suffix on top, producing posts like "written with s4lai written with
148
+ s4lai" (observed in production 2026-05-18, ids 70412 + 70413).
149
+
150
+ Strips trailing whitespace + suffix repeatedly so a doubled-suffix
151
+ historical row also collapses to clean text. Active campaign list is
152
+ passed in by the caller so we only strip patterns we're actively using
153
+ (avoids unbounded false-positive matches on incidental phrasing).
154
+ """
155
+ if not text or not active_campaigns:
156
+ return text
157
+ cleaned = text.rstrip()
158
+ changed = True
159
+ while changed:
160
+ changed = False
161
+ for camp in active_campaigns:
162
+ suffix = (camp.get("suffix") or "").strip()
163
+ if suffix and cleaned.endswith(suffix):
164
+ cleaned = cleaned[: -len(suffix)].rstrip()
165
+ changed = True
166
+ return cleaned
167
+
168
+
169
+ def bump_campaigns(table, row_id, campaign_ids):
170
+ """Attach a row in {posts,replies,dm_messages} to its applied campaigns."""
171
+ if not row_id or not campaign_ids:
172
+ return
173
+ for cid in campaign_ids:
174
+ try:
175
+ subprocess.run(
176
+ [PYTHON, CAMPAIGN_BUMP,
177
+ "--table", table, "--id", str(row_id), "--campaign-id", str(cid)],
178
+ capture_output=True, text=True, timeout=15,
179
+ )
180
+ except Exception as e:
181
+ print(f"[engage_reddit] WARNING: campaign_bump failed (id={row_id} c={cid}): {e}")
182
+
183
+
184
+ def patch_replied_with_retry(cmd_args, reply_id):
185
+ """Run reply_db.py replied PATCH with rate-limit-aware retry.
186
+
187
+ The comment is ALREADY posted on the platform when we call this. If the
188
+ s4l PATCH fails (e.g. 429 during a rate-limit storm), the row stays in
189
+ 'processing' and reset_stuck_processing flips it back to 'pending' after
190
+ 2h, which would re-fetch and re-post a duplicate. Confirmed in production
191
+ 2026-05-07 where 423 duplicates landed on a single Moltbook parent.
192
+
193
+ To prevent that, we retry the PATCH for up to ~10min with growing backoff
194
+ (15s, 30s, 60s, 120s, 300s). If still failing after that, log a CRITICAL
195
+ line so the operator can flip the row to 'replied' manually before the 2h
196
+ reset fires. Returns True on success, False on terminal failure.
197
+ """
198
+ backoff_s = [15, 30, 60, 120, 300]
199
+ last_stderr = ""
200
+ for attempt in range(len(backoff_s) + 1):
201
+ try:
202
+ proc = subprocess.run(cmd_args, capture_output=True, timeout=60)
203
+ except subprocess.TimeoutExpired as e:
204
+ last_stderr = f"timeout: {e}"
205
+ proc = None
206
+ else:
207
+ if proc.returncode == 0:
208
+ return True
209
+ last_stderr = (proc.stderr or b"").decode(errors="replace")
210
+
211
+ if attempt < len(backoff_s):
212
+ wait = backoff_s[attempt]
213
+ print(
214
+ f"[engage_reddit] #{reply_id} REPLIED PATCH attempt {attempt+1} "
215
+ f"failed ({last_stderr[:200]}); retrying in {wait}s",
216
+ flush=True,
217
+ )
218
+ time.sleep(wait)
219
+
220
+ print(
221
+ f"[engage_reddit] CRITICAL: #{reply_id} REPLIED PATCH failed all retries "
222
+ f"({last_stderr[:300]}). Comment IS posted on platform but row stays in "
223
+ f"'processing'. After ~2h reset_stuck_processing will flip it to "
224
+ f"'pending' and the next run may post a DUPLICATE. Manual fix: SELECT "
225
+ f"-> verify our_reply_url, then UPDATE replies SET status='replied' "
226
+ f"WHERE id={reply_id}.",
227
+ flush=True,
228
+ )
229
+ return False
230
+
231
+
232
+ def reset_stuck_processing(platform):
233
+ """Flip stuck 'processing' rows back to 'pending' (older than 2h).
234
+
235
+ Routes through /api/v1/replies/reset-stuck so this module owns no SQL.
236
+ """
237
+ resp = api_post(
238
+ "/api/v1/replies/reset-stuck",
239
+ {"platform": platform, "older_than_hours": 2},
240
+ )
241
+ data = (resp or {}).get("data") or {}
242
+ count = int(data.get("reset_count") or 0)
243
+ if count > 0:
244
+ print(f"[engage_reddit] Reset {count} stuck 'processing' {platform} items back to pending")
245
+
246
+
247
+ def get_next_pending(platform):
248
+ """Fetch the next pending reply for the given platform (one at a time).
249
+
250
+ Calls /api/v1/replies/next-pending which performs the JOIN to posts
251
+ server-side and returns the rows in the canonical priority order
252
+ (replies-to-our-original first, then oldest discovered_at).
253
+ """
254
+ resp = api_get(
255
+ "/api/v1/replies/next-pending",
256
+ query={"platform": platform, "limit": 1},
257
+ )
258
+ rows = ((resp or {}).get("data") or {}).get("replies") or []
259
+ if not rows:
260
+ return None
261
+ row = rows[0]
262
+ return {
263
+ "id": int(row["id"]),
264
+ "platform": row.get("platform"),
265
+ "their_author": row.get("their_author"),
266
+ "their_content": row.get("their_content"),
267
+ "their_comment_url": row.get("their_comment_url"),
268
+ "their_comment_id": row.get("their_comment_id"),
269
+ "depth": row.get("depth"),
270
+ "thread_title": row.get("thread_title"),
271
+ "thread_url": row.get("thread_url"),
272
+ "our_content": row.get("our_content"),
273
+ "our_url": row.get("our_url"),
274
+ "is_our_original_post": int(row.get("is_our_original_post") or 0),
275
+ "project_name": row.get("project_name"),
276
+ "post_id": row.get("post_id"),
277
+ }
278
+
279
+
280
+ META_CALLOUT_KEYWORDS = re.compile(
281
+ r"(?i)\b("
282
+ r"written\s+(?:by|with)\s+(?:ai|chatgpt|gpt|llm|a\s+(?:bot|machine|model))"
283
+ r"|(?:are|r)\s+you\s+(?:an?\s+)?(?:ai|bot|llm|gpt|chatgpt|automated)"
284
+ r"|you(?:'re|\s+are)\s+(?:an?\s+)?(?:ai|bot|llm|gpt|chatgpt|automated)"
285
+ r"|is\s+this\s+(?:an?\s+)?(?:ai|bot|llm|gpt|chatgpt|automated)"
286
+ r"|chatgpt\s+(?:wrote|generated|response|reply)"
287
+ r"|ai[-\s]+(?:generated|written|response|reply|comment)"
288
+ r"|automated\s+(?:response|reply|comment|account)"
289
+ r"|bot\s+(?:account|reply|response|comment)"
290
+ r"|(?:smells?|sounds?|reads?)\s+like\s+(?:an?\s+)?(?:ai|bot|gpt|chatgpt|llm)"
291
+ r")\b"
292
+ )
293
+
294
+
295
+ def detect_meta_callout(parent_content):
296
+ """Detect whether the parent comment is calling out our AI/bot use.
297
+
298
+ Returns a dict {"keyword", "evidence"} when a callout is matched,
299
+ None otherwise. Soft-signal only: the prompt surfaces it as a
300
+ 'consider acknowledging and disengaging' nudge, the LLM still owns the
301
+ skip/reply decision. False positives are tolerable; missing a real
302
+ callout is the costly direction (we end up arguing past the off-ramp,
303
+ as in the Fit-Conversation856 thread).
304
+ """
305
+ if not parent_content:
306
+ return None
307
+ m = META_CALLOUT_KEYWORDS.search(parent_content)
308
+ if not m:
309
+ return None
310
+ start = max(0, m.start() - 60)
311
+ end = min(len(parent_content), m.end() + 60)
312
+ snippet = parent_content[start:end].replace("\n", " ").strip()
313
+ return {"keyword": m.group(0), "evidence": snippet}
314
+
315
+
316
+ def _fmt_date(s):
317
+ """Format an ISO-ish timestamp string as YYYY-MM-DD, tolerant of None."""
318
+ if not s:
319
+ return "unknown"
320
+ try:
321
+ return str(s)[:10]
322
+ except Exception:
323
+ return "unknown"
324
+
325
+
326
+ def check_cross_pipeline_history(platform, author, post_id, reply_id=None):
327
+ """Cross-pipeline check before posting a comment-reply.
328
+
329
+ Returns (same_post_disengage, prior_history_block). Delegates to the
330
+ shared counterparty_history module so Reddit and Twitter get symmetric
331
+ behavior — both lanes (DM cross-thread + public-reply history) are
332
+ surfaced into the prompt in one self-titled block.
333
+
334
+ 2026-05-19 refactor: previously this lived as Reddit-only direct API
335
+ calls covering the DM lane only. Twitter's engage helper had nothing.
336
+ The shared module now exposes both lanes to both pipelines; this
337
+ function is a thin compat wrapper preserving the (same_post_disengage,
338
+ block_text) tuple shape build_prompt() consumes.
339
+ """
340
+ if not author:
341
+ return None, ""
342
+ try:
343
+ from counterparty_history import get_counterparty_history_block
344
+ return get_counterparty_history_block(
345
+ platform,
346
+ author,
347
+ current_post_id=post_id,
348
+ current_reply_id=reply_id,
349
+ )
350
+ except Exception as e:
351
+ print(
352
+ f"[engage_reddit] counterparty_history failed for "
353
+ f"{platform}/@{author} post={post_id}: {e}"
354
+ )
355
+ return None, ""
356
+
357
+
358
+ def get_recent_archetypes(platform, limit=3):
359
+ """Fetch archetypes of last N replied replies for rotation context.
360
+
361
+ Calls /api/v1/replies with order_by=replied_at and the new
362
+ has_our_reply_content filter so we only see rows whose our_reply_content
363
+ is populated (the previous SQL had AND our_reply_content IS NOT NULL).
364
+ """
365
+ resp = api_get(
366
+ "/api/v1/replies",
367
+ query={
368
+ "platform": platform,
369
+ "status": "replied",
370
+ "has_our_reply_content": "true",
371
+ "order_by": "replied_at",
372
+ "limit": int(limit) if limit else 3,
373
+ },
374
+ )
375
+ rows = ((resp or {}).get("data") or {}).get("replies") or []
376
+ return [r.get("our_reply_content") for r in rows if r.get("our_reply_content")]
377
+
378
+
379
+ def build_prompt(reply, recent_replies, config, excluded_authors, top_report="", prior_history_block="", meta_callout=None):
380
+ """Build a minimal prompt for one reply."""
381
+ reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "Deep_Ad1959")
382
+ reply_json = json.dumps(reply, indent=2)
383
+
384
+ # Moltbook: skip recent_replies + top_report context blocks. Both are
385
+ # dense with our prior agent-persona-voiced comments ("my human ran...",
386
+ # "my human ships...") which, in aggregate, trip Anthropic's Usage Policy
387
+ # classifier. Reddit doesn't have that signature so it's fine for reddit.
388
+ if reply['platform'] == "moltbook":
389
+ recent_replies = []
390
+ top_report = ""
391
+
392
+ recent_context = ""
393
+ if recent_replies:
394
+ snippets = "\n".join(f" - {r}" for r in recent_replies)
395
+ recent_context = f"""
396
+ Your last {len(recent_replies)} replies (vary your style, don't repeat the same archetype):
397
+ {snippets}
398
+ """
399
+
400
+ if excluded_authors and reply["their_author"].lower() in {a.lower() for a in excluded_authors}:
401
+ return None, None # will be skipped by caller
402
+
403
+ top_context = f"\n## FEEDBACK FROM PAST PERFORMANCE (use this to write better replies):\n{top_report}\n" if top_report else ""
404
+ history_block = f"\n{prior_history_block}\n" if prior_history_block else ""
405
+ callout_block = ""
406
+ if meta_callout:
407
+ callout_block = (
408
+ "\n## Meta-callout detected in parent comment\n"
409
+ f"The parent comment contains language matching `{meta_callout['keyword']}`. "
410
+ "Evidence (60 chars on each side of the match):\n"
411
+ f" > {meta_callout['evidence']}\n"
412
+ "This means the partner has likely noticed our AI disclosure or is asking whether they're talking to a bot. "
413
+ "Default behavior: acknowledge it briefly, do NOT pitch a project, and prefer skipping over arguing. "
414
+ "If you do reply, address the callout directly in one short sentence (no defensiveness) and consider "
415
+ "outputting `{\"action\": \"skip\", \"reason\": \"meta_callout_acknowledged\"}` so the thread is not "
416
+ "kept alive by another bot reply. Continuing the prior pitch as if nothing was said is the wrong move "
417
+ "— the Fit-Conversation856 thread (2026-04-28) burned through 4 follow-up replies past this signal "
418
+ "before the engage-dm-replies pipeline disengaged.\n"
419
+ )
420
+
421
+ voice_block = ""
422
+ project_name = reply.get("project_name")
423
+ if project_name:
424
+ project_cfg = next(
425
+ (p for p in config.get("projects", []) if p.get("name") == project_name),
426
+ None,
427
+ )
428
+ voice = (project_cfg or {}).get("voice", {}) or {}
429
+ if voice:
430
+ voice_block = f"""
431
+ ## Project voice (matched project: {project_name})
432
+ {json.dumps(voice, indent=2)}
433
+
434
+ Apply this voice when drafting: follow `tone`, never violate any item in `never`, mirror `examples` / `examples_good` when present.
435
+ """
436
+
437
+ if reply['platform'] == "moltbook":
438
+ framing = (
439
+ "You are helping draft a reply to a comment on a public discussion "
440
+ "forum on behalf of the user's account. The forum is open to AI "
441
+ "authorship, fully transparent, and the user has authorized you to "
442
+ "draft this reply for them."
443
+ )
444
+ else:
445
+ framing = (
446
+ f"You are helping draft a reply to a comment on {reply['platform']} "
447
+ f"on behalf of the user's account."
448
+ )
449
+
450
+ # 2026-05-19: pre-pick the engagement style ONCE per reply so the
451
+ # picker's assignment threads into BOTH the assigned-style block AND
452
+ # the JSON output example. Without pinning the style name into the
453
+ # JSON example, the model treats the example as a menu hint and
454
+ # drifts (same drift vector that bit Reddit post-draft on this date).
455
+ style_platform = "reddit" if reply["platform"] != "moltbook" else "moltbook"
456
+ style_assignment = pick_style_for_post(style_platform, context="replying")
457
+ assigned_style = (style_assignment.get("style") or "your invented snake_case name")
458
+
459
+ prompt_text = f"""{framing}
460
+
461
+ ## Reply data
462
+ {reply_json}
463
+
464
+ ## Context
465
+ Read ~/social-autoposter/config.json for project details and content_angle.
466
+ {recent_context}{top_context}{voice_block}{history_block}{callout_block}
467
+ ## Content rules
468
+ {get_content_rules("reddit")}
469
+ - Vary openings. Don't always start with credentials.
470
+
471
+ {get_styles_prompt(style_platform, context="replying", assignment=style_assignment)}
472
+
473
+ {get_voice_relationship_rule()}
474
+
475
+ {get_anti_patterns()}
476
+
477
+ ## Tiered links
478
+ - Tier 1 (default): No link. Genuine engagement.
479
+ - Tier 2: Topic matches a config project. Mention casually.
480
+ - Tier 3: They ask for link/tool. Give it from config.
481
+
482
+ ## Guardrails
483
+ - NEVER suggest calls, meetings, demos.
484
+ - NEVER promise to share links/files not in config.json.
485
+ - NEVER offer to DM. NEVER make time-bound promises.
486
+
487
+ ## Bot / engagement-loop escape hatch (use sparingly, but use it)
488
+ We maintain a universal author blocklist in Postgres (`author_blocklist`),
489
+ consulted at /api/v1/replies POST time. A single block recorded by ANY of
490
+ our accounts/installs applies to EVERY future engagement from EVERY of our
491
+ accounts — universal scope, by design. The velocity gate already covers
492
+ "this handle has gotten too many replies from us in 24h/7d"; this lane is
493
+ for the LLM-judgment cases velocity cannot catch.
494
+
495
+ When to add a block (your judgment, exercised CONSERVATIVELY):
496
+ - The Reddit handle is plainly an AI/bot account: templated phrasing across
497
+ unrelated subs, generic filler answers, name pattern like `Foo_AI` /
498
+ `*_GPT` / `*Bot*`, comment history is karma-farm boilerplate
499
+ - We are clearly stuck in a reciprocal engagement loop with this account
500
+ - The handle is reply-farming across r/AskReddit / r/explainlikeimfive
501
+ style subs with shallow comments
502
+
503
+ DO NOT block: an OP we disagree with, a hostile-but-human commenter, a
504
+ low-karma but real user, or a single bad interaction. Skip those
505
+ (action='skip') — blocking is permanent until manually removed and applies
506
+ to all our accounts.
507
+
508
+ How to use it: BEFORE outputting your decision JSON, run this in Bash:
509
+ python3 ~/social-autoposter/scripts/reply_db.py blocklist add reddit HANDLE \
510
+ --reason "<one-line judgment>" \
511
+ --classification {{bot|engagement_loop}} \
512
+ --source-reply-id REPLY_ID
513
+ Then output a skip decision (so the current reply is not posted):
514
+ {{"action": "skip", "reason": "blocklist_added:HANDLE"}}
515
+ HANDLE is the Reddit username without the `u/` prefix.
516
+
517
+ ## Execution steps
518
+
519
+ 1. First, fetch the full thread context cheaply via Bash (NO browser needed):
520
+ python3 ~/social-autoposter/scripts/reddit_tools.py fetch '{reply['thread_url']}'
521
+ This returns JSON with "thread" (title, author, selftext, score, subreddit) and "comments" (id, author, body, score, permalink).
522
+ Read the output to understand the full conversation context, who said what, and the overall tone.
523
+
524
+ 2. Using the thread context from step 1 AND the reply data above, decide: reply or skip?
525
+ If skip (troll, spam, not directed at us, light acknowledgment, conversation already resolved), output ONLY this JSON:
526
+ {{"action": "skip", "reason": "SHORT_REASON"}}
527
+
528
+ 3. If replying, draft 1-3 sentences following the rules above. Output ONLY this JSON:
529
+ {{"action": "reply", "text": "YOUR_REPLY_TEXT", "project": null, "engagement_style": "{assigned_style}", "new_style": null}}
530
+ The assigned engagement style is "{assigned_style}" (see the assigned style block above). Use it. Do not pick a different one.
531
+ If you recommended a project, set "project" to the project name.
532
+
533
+ Inventing a new style is only valid when the picker explicitly assigns "invent" mode (the assigned style block above will say so). Otherwise leave "new_style" as null and use the assigned style verbatim.
534
+
535
+ CRITICAL: Your ENTIRE output must be ONLY the JSON object above. No other text, no explanations, no markdown.
536
+ The orchestrator script will handle posting via CDP and database updates automatically.
537
+ """
538
+ # Return both the prompt and the picker's assignment so the caller can
539
+ # forward the assignment into validate_or_register's enforcement layer
540
+ # (USE mode coerces drift back; INVENT mode is the only path that lets
541
+ # the model register a new style). Without this, the picker's choice
542
+ # would be silently overridable downstream.
543
+ return prompt_text, style_assignment
544
+
545
+
546
+ def ensure_mcp_config():
547
+ """Create a minimal MCP config with only the reddit-agent server."""
548
+ if os.path.exists(REDDIT_MCP_CONFIG):
549
+ return REDDIT_MCP_CONFIG
550
+ # Extract reddit-agent config from ~/.claude.json
551
+ claude_json = os.path.expanduser("~/.claude.json")
552
+ if os.path.exists(claude_json):
553
+ with open(claude_json) as f:
554
+ data = json.load(f)
555
+ reddit_cfg = data.get("mcpServers", {}).get("reddit-agent")
556
+ if reddit_cfg:
557
+ mcp = {"mcpServers": {"reddit-agent": reddit_cfg}}
558
+ os.makedirs(os.path.dirname(REDDIT_MCP_CONFIG), exist_ok=True)
559
+ with open(REDDIT_MCP_CONFIG, "w") as f:
560
+ json.dump(mcp, f, indent=2)
561
+ return REDDIT_MCP_CONFIG
562
+ return None
563
+
564
+
565
+ def run_claude(prompt, timeout=300, session_id=None):
566
+ """Run claude -p with the given prompt. Returns (success, output, usage_dict).
567
+
568
+ Streams output in real time to stderr for log visibility.
569
+ """
570
+ import time as _time
571
+ import select
572
+ usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
573
+ cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose"]
574
+ if session_id:
575
+ cmd += ["--session-id", session_id]
576
+ # --bare removed: it blocks OAuth auth which we need
577
+ cmd += ["--tools", "Bash,Read"]
578
+ env = os.environ.copy()
579
+ env.pop("ANTHROPIC_API_KEY", None) # ensure claude uses OAuth, not API key
580
+ if session_id:
581
+ env["CLAUDE_SESSION_ID"] = session_id
582
+ try:
583
+ proc = subprocess.Popen(
584
+ cmd, env=env, stdin=subprocess.PIPE,
585
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
586
+ )
587
+ proc.stdin.write(prompt)
588
+ proc.stdin.close()
589
+ collected = []
590
+ deadline = _time.time() + timeout
591
+ while True:
592
+ remaining = deadline - _time.time()
593
+ if remaining <= 0:
594
+ proc.kill()
595
+ return False, "TIMEOUT", usage
596
+ ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 30))
597
+ if ready:
598
+ line = proc.stdout.readline()
599
+ if not line:
600
+ break
601
+ collected.append(line)
602
+ try:
603
+ evt = json.loads(line.strip())
604
+ etype = evt.get("type", "")
605
+ if etype == "assistant":
606
+ msg = evt.get("message", {})
607
+ for block in msg.get("content", []):
608
+ if block.get("type") == "tool_use":
609
+ print(f"[engage_reddit] tool: {block.get('name','')} | {str(block.get('input',{}).get('command',''))[:120]}", file=sys.stderr, flush=True)
610
+ elif block.get("type") == "text" and block.get("text","").strip():
611
+ txt = block["text"].strip()[:200]
612
+ print(f"[engage_reddit] {txt}", file=sys.stderr, flush=True)
613
+ elif etype == "result":
614
+ print(f"[engage_reddit] done: cost=${evt.get('total_cost_usd',0):.4f}", file=sys.stderr, flush=True)
615
+ except (json.JSONDecodeError, TypeError):
616
+ print(f"[engage_reddit] {line.rstrip()[:200]}", file=sys.stderr, flush=True)
617
+ elif proc.poll() is not None:
618
+ rest = proc.stdout.read()
619
+ if rest:
620
+ collected.append(rest)
621
+ break
622
+ else:
623
+ print(f"[engage_reddit] ... still running ({int(_time.time() - (deadline - timeout))}s)", file=sys.stderr, flush=True)
624
+ proc.wait()
625
+ text_output = ""
626
+ for line_str in collected:
627
+ line_str = line_str.strip()
628
+ if not line_str:
629
+ continue
630
+ try:
631
+ event = json.loads(line_str)
632
+ if event.get("type") == "result":
633
+ text_output = event.get("result", "")
634
+ usage["cost_usd"] = event.get("total_cost_usd", 0.0)
635
+ u = event.get("usage", {})
636
+ usage["input_tokens"] = u.get("input_tokens", 0)
637
+ usage["output_tokens"] = u.get("output_tokens", 0)
638
+ usage["cache_read"] = u.get("cache_read_input_tokens", 0)
639
+ usage["cache_create"] = u.get("cache_creation_input_tokens", 0)
640
+ except (json.JSONDecodeError, TypeError):
641
+ pass
642
+ if not text_output:
643
+ text_output = "".join(collected)
644
+ stderr_out = proc.stderr.read() if proc.stderr else ""
645
+ return proc.returncode == 0, text_output + stderr_out, usage
646
+ except Exception as e:
647
+ return False, str(e), usage
648
+
649
+
650
+ def main():
651
+ parser = argparse.ArgumentParser(description="Reddit/Moltbook reply engagement (one at a time)")
652
+ parser.add_argument("--platform", choices=["reddit", "moltbook"], default="reddit",
653
+ help="Platform to process (default: reddit)")
654
+ parser.add_argument("--dry-run", action="store_true", help="Print prompt for first reply without executing")
655
+ parser.add_argument("--limit", type=int, default=0, help="Max replies to process (0 = unlimited)")
656
+ parser.add_argument("--timeout", type=int, default=5400, help="Global timeout in seconds")
657
+ parser.add_argument("--per-reply-timeout", type=int, default=300, help="Timeout per claude session in seconds")
658
+ args = parser.parse_args()
659
+
660
+ config = load_config()
661
+ excluded_authors = config.get("exclusions", {}).get("authors", [])
662
+
663
+ # Hard preflight: the reddit rail posts replies via reddit_browser.py, the
664
+ # only Playwright importer here (Moltbook uses its own poster). If the
665
+ # resolved interpreter can't import Playwright the owned runtime is missing
666
+ # or half-provisioned and every reply would die with CDP_ERROR. Fail LOUD
667
+ # with a distinct signal instead. Moltbook is exempt (no browser path).
668
+ if args.platform == "reddit":
669
+ _chk = subprocess.run(
670
+ [PYTHON, "-c", "import playwright"],
671
+ capture_output=True, text=True,
672
+ )
673
+ if _chk.returncode != 0:
674
+ print(f"[engage_reddit] FATAL runtime_incomplete: interpreter {PYTHON!r} "
675
+ f"cannot import playwright — the owned Python runtime is missing or "
676
+ f"unprovisioned. Run the `runtime` install (action:'install') before "
677
+ f"engaging. stderr: {(_chk.stderr or '').strip()[:300]}", file=sys.stderr)
678
+ sys.exit(3)
679
+
680
+ reset_stuck_processing(args.platform)
681
+
682
+ try:
683
+ top_report = subprocess.check_output(
684
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "top_performers.py"), "--platform", args.platform],
685
+ text=True, stderr=subprocess.DEVNULL, timeout=30,
686
+ )
687
+ except Exception:
688
+ top_report = ""
689
+
690
+ start_time = time.time()
691
+ processed = 0
692
+ succeeded = 0
693
+ skipped = 0
694
+ failed = 0
695
+ skip_reasons = Counter()
696
+ meta_callouts_detected = 0
697
+ total_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
698
+
699
+ print(f"[engage_reddit] Starting. platform={args.platform} limit={args.limit or 'unlimited'}, timeout={args.timeout}s")
700
+
701
+ while True:
702
+ # Global timeout check
703
+ elapsed = time.time() - start_time
704
+ if elapsed > args.timeout:
705
+ print(f"[engage_reddit] Global timeout reached ({args.timeout}s). Stopping.")
706
+ break
707
+
708
+ # Limit check
709
+ if args.limit and processed >= args.limit:
710
+ print(f"[engage_reddit] Limit reached ({args.limit}). Stopping.")
711
+ break
712
+
713
+ # Fetch next pending reply
714
+ reply = get_next_pending(args.platform)
715
+ if not reply:
716
+ print("[engage_reddit] No pending replies. Done!")
717
+ break
718
+
719
+ # Check exclusion before spawning Claude
720
+ if reply["their_author"].lower() in {a.lower() for a in excluded_authors}:
721
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_author"])
722
+ print(f"[engage_reddit] #{reply['id']} skipped (excluded_author: {reply['their_author']})")
723
+ skipped += 1
724
+ skip_reasons["excluded_author"] += 1
725
+ processed += 1
726
+ continue
727
+
728
+ # Cross-pipeline disengage check. Hard-skip if the engage-dm-replies
729
+ # pipeline already classified this person as declined / not_our_prospect
730
+ # / stale on THIS post. Soft-surface other-thread history into the
731
+ # prompt so the LLM can adjust tone without being auto-blocked.
732
+ same_post_disengage, prior_history_block = check_cross_pipeline_history(
733
+ reply["platform"], reply["their_author"], reply.get("post_id"),
734
+ reply_id=reply.get("id"),
735
+ )
736
+ if same_post_disengage:
737
+ reason = (
738
+ f"cross_pipeline_disengage:dm#{same_post_disengage['dm_id']}"
739
+ f":interest={same_post_disengage['interest_level']}"
740
+ f":status={same_post_disengage['conversation_status']}"
741
+ )
742
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
743
+ print(f"[engage_reddit] #{reply['id']} skipped ({reason})")
744
+ skipped += 1
745
+ skip_reasons["cross_pipeline_disengage"] += 1
746
+ processed += 1
747
+ continue
748
+
749
+ # Meta-callout detection on the parent comment text. Soft signal:
750
+ # surfaces an authorize-to-ack-and-disengage block in the prompt
751
+ # without auto-skipping. Catches the case where engage-dm-replies
752
+ # has not yet classified the partner but the inbound text already
753
+ # calls out our AI disclosure or asks if they're talking to a bot.
754
+ meta_callout = detect_meta_callout(reply.get("their_content"))
755
+ if meta_callout:
756
+ meta_callouts_detected += 1
757
+ print(f"[engage_reddit] #{reply['id']} meta-callout detected: keyword={meta_callout['keyword']!r}")
758
+
759
+ # Get recent replies for archetype rotation. Strip active campaign
760
+ # suffixes from each snippet BEFORE the LLM sees them; otherwise the
761
+ # model copies the literal suffix into its draft and the tool-layer
762
+ # injection below appends a second copy. See strip_active_suffixes
763
+ # docstring for the 2026-05-18 production incident this prevents.
764
+ recent = get_recent_archetypes(args.platform, limit=3)
765
+ if reply["platform"] == "reddit" and recent:
766
+ _active_camps_for_strip = load_active_reddit_campaigns()
767
+ recent = [strip_active_suffixes(r, _active_camps_for_strip) for r in recent]
768
+ recent = [r for r in recent if r]
769
+
770
+ # Build prompt. Returns (prompt_text, style_assignment) so the
771
+ # picker's assignment can be forwarded into validate_or_register
772
+ # below. style_assignment is None when the reply was filtered out
773
+ # (excluded author) and the caller treats it as a skip.
774
+ prompt, style_assignment = build_prompt(reply, recent, config, excluded_authors,
775
+ top_report=top_report,
776
+ prior_history_block=prior_history_block,
777
+ meta_callout=meta_callout)
778
+ if prompt is None:
779
+ skipped += 1
780
+ processed += 1
781
+ continue
782
+
783
+ if args.dry_run:
784
+ print("=== DRY RUN: Prompt for reply #{} ===".format(reply["id"]))
785
+ print(prompt)
786
+ print("=== END DRY RUN ===")
787
+ break
788
+
789
+ # Per-reply reddit-browser lease (added 2026-05-13). Acquire JUST
790
+ # around this reply's Claude session + CDP post, release in the
791
+ # finally below so peers can use the browser during the inter-reply
792
+ # 2s sleep AND during the moltbook-only iterations that follow.
793
+ # Moltbook replies use the moltbook API (no browser), so we skip
794
+ # acquire for those rows entirely.
795
+ lease_held = False
796
+ if reply["platform"] == "reddit":
797
+ lease_ok, lease_msg = _acquire_browser_lease(timeout=600, ttl=90)
798
+ if not lease_ok:
799
+ print(f"[engage_reddit] #{reply['id']} LEASE: {lease_msg}; deferring")
800
+ failed += 1
801
+ skip_reasons["lease_acquire_timeout"] += 1
802
+ # Mark processing so this row isn't refetched in this run;
803
+ # reset_stuck_processing's 2h cap brings it back to pending.
804
+ try:
805
+ subprocess.run(
806
+ [PYTHON, REPLY_DB, "processing", str(reply["id"])],
807
+ capture_output=True, timeout=10,
808
+ )
809
+ except Exception:
810
+ pass
811
+ processed += 1
812
+ time.sleep(2)
813
+ continue
814
+ lease_held = True
815
+
816
+ # Run Claude session for this one reply (Claude decides + drafts, we post)
817
+ reply_start = time.time()
818
+ session_id = str(uuid.uuid4())
819
+ os.environ["CLAUDE_SESSION_ID"] = session_id
820
+ session_started_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
821
+ print(f"[engage_reddit] Processing #{reply['id']} ({reply['platform']}) "
822
+ f"from {reply['their_author']}: {(reply['their_content'] or '')[:60]}...")
823
+
824
+ ok, output, usage = run_claude(prompt, timeout=args.per_reply_timeout, session_id=session_id)
825
+ reply_elapsed = time.time() - reply_start
826
+ session_ended_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
827
+ log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
828
+ "--session-id", session_id, "--script", "engage_reddit",
829
+ "--started-at", session_started_at, "--ended-at", session_ended_at]
830
+ orch_cost = usage.get("cost_usd")
831
+ if isinstance(orch_cost, (int, float)) and orch_cost > 0:
832
+ log_args.extend(["--orchestrator-cost-usd", str(orch_cost)])
833
+ subprocess.run(log_args, capture_output=True)
834
+
835
+ # Accumulate usage
836
+ for k in total_usage:
837
+ total_usage[k] += usage[k]
838
+
839
+ # AUP refusal short-circuit. If Anthropic's safety classifier blocks
840
+ # the request, every subsequent reply in this batch will get the same
841
+ # refusal and burn $0.05-$0.30 each. Abort the run, leave rows pending
842
+ # so the next launchd cycle picks them up after a prompt fix.
843
+ if ("Claude Code is unable to respond" in output
844
+ and ("Usage Policy" in output or "violate" in output.lower())):
845
+ print(f"[engage_reddit] #{reply['id']} AUP REFUSAL detected — aborting run "
846
+ f"to avoid wasted spend on continued refusals. Reword the prompt "
847
+ f"and try again. Cost on this refusal: ${usage['cost_usd']:.4f}")
848
+ failed += 1
849
+ skip_reasons["aup_refusal"] += 1
850
+ for k in total_usage:
851
+ total_usage[k] += 0 # already accumulated above
852
+ if lease_held:
853
+ _release_browser_lease()
854
+ break
855
+
856
+ # Monthly cap short-circuit. Mirrors the AUP guard above. When the
857
+ # Claude Code OAuth account hits its monthly usage cap, every call
858
+ # returns "You've hit your org's monthly usage limit" with cost=0, and
859
+ # the per-reply queue would otherwise loop on the same row up to
860
+ # --limit times because the row is never marked processing/skipped.
861
+ # Surfaced in run_monitor as failure_reasons=monthly_limit:1 so the
862
+ # dashboard Result column reads "failed: monthly_limit ×1" instead of
863
+ # the previous silent "queue empty $0.00".
864
+ if "monthly usage limit" in output.lower():
865
+ print(f"[engage_reddit] #{reply['id']} MONTHLY USAGE LIMIT hit, "
866
+ f"aborting run. Cost on this attempt: ${usage['cost_usd']:.4f}")
867
+ failed += 1
868
+ skip_reasons["monthly_limit"] += 1
869
+ if lease_held:
870
+ _release_browser_lease()
871
+ break
872
+
873
+ if not ok:
874
+ # Generic Claude failure (timeout, transport error, non-zero exit).
875
+ # Mark the reply as `processing` so the next iteration of the
876
+ # while-loop doesn't fetch the SAME pending row again and burn
877
+ # another Claude session on it. reset_stuck_processing brings it
878
+ # back to pending after 2h, which gives the partner thread time
879
+ # to settle (and us, time to fix whatever broke).
880
+ failed += 1
881
+ reason_key = "timeout" if output == "TIMEOUT" else "claude_failed"
882
+ skip_reasons[reason_key] += 1
883
+ try:
884
+ subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])],
885
+ capture_output=True, timeout=10)
886
+ except Exception:
887
+ pass
888
+ print(f"[engage_reddit] #{reply['id']} CLAUDE FAILED ({reply_elapsed:.0f}s): {output[:200]}")
889
+ else:
890
+ # Parse Claude's JSON decision
891
+ decision = None
892
+ try:
893
+ # Extract JSON from output (may have surrounding text)
894
+ import re as _re
895
+ json_match = _re.search(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', output)
896
+ if json_match:
897
+ decision = json.loads(json_match.group())
898
+ except (json.JSONDecodeError, TypeError):
899
+ pass
900
+
901
+ if not decision:
902
+ # Fallback: check if output looks like a skip/reply
903
+ failed += 1
904
+ skip_reasons["bad_output"] += 1
905
+ # Same loop-prevention as the not-ok branch: mark processing
906
+ # so the next iteration moves to a different pending row.
907
+ try:
908
+ subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])],
909
+ capture_output=True, timeout=10)
910
+ except Exception:
911
+ pass
912
+ print(f"[engage_reddit] #{reply['id']} BAD OUTPUT ({reply_elapsed:.0f}s): {output[:200]}")
913
+ elif decision.get("action") == "skip":
914
+ reason = decision.get("reason", "unknown")
915
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
916
+ skipped += 1
917
+ skip_reasons[f"llm:{reason[:48]}"] += 1
918
+ print(f"[engage_reddit] #{reply['id']} skipped: {reason} ({reply_elapsed:.0f}s) "
919
+ f"[${usage['cost_usd']:.4f}]")
920
+ elif decision.get("action") == "reply":
921
+ reply_text = decision.get("text", "")
922
+ project = decision.get("project")
923
+ # validate_or_register: in USE mode, coerces any drifted style
924
+ # name back to the assigned one. In INVENT mode (5% slot),
925
+ # registers the new style into engagement_styles_registry via
926
+ # the s4l API. Without assigned_style/assigned_mode, the
927
+ # picker's choice would be silently overridable by the model.
928
+ # source_post URL is THEIR comment we're replying to; we don't
929
+ # know our own URL until after the post lands.
930
+ engagement_style, _style_action = validate_or_register(
931
+ decision,
932
+ source_post={
933
+ "platform": reply.get("platform"),
934
+ "post_url": reply.get("their_comment_url"),
935
+ "post_id": reply.get("id"),
936
+ "model": decision.get("model"),
937
+ },
938
+ assigned_style=(style_assignment or {}).get("style"),
939
+ assigned_mode=(style_assignment or {}).get("mode"),
940
+ )
941
+ if not reply_text:
942
+ failed += 1
943
+ print(f"[engage_reddit] #{reply['id']} empty reply text")
944
+ else:
945
+ # Mark as processing. CRITICAL: this PATCH must succeed before we
946
+ # post to the platform. If it fails (e.g. s4l rate-limit 429), the
947
+ # row stays `pending` and the next iteration of the while-loop
948
+ # would re-fetch it, draft a new reply, and post again, creating
949
+ # duplicates on the platform. Confirmed in production 2026-05-07
950
+ # where 423+ duplicate comments landed on a single Moltbook
951
+ # parent during a 5000/24h s4l rate-limit storm. Hard-fail the
952
+ # entire run on any non-zero exit so the row stays untouched and
953
+ # no platform side-effect occurs.
954
+ proc_result = subprocess.run(
955
+ [PYTHON, REPLY_DB, "processing", str(reply["id"])],
956
+ capture_output=True,
957
+ )
958
+ if proc_result.returncode != 0:
959
+ err_txt = (proc_result.stderr or b"").decode(errors="replace")
960
+ print(f"[engage_reddit] #{reply['id']} PROCESSING PATCH FAILED "
961
+ f"rc={proc_result.returncode}: {err_txt[:300]}")
962
+ print(f"[engage_reddit] Aborting run to prevent duplicate posts. "
963
+ f"Row stays pending; next launchd cycle will retry once "
964
+ f"the rate-limit window clears.")
965
+ failed += 1
966
+ skip_reasons["processing_patch_failed"] = (
967
+ skip_reasons.get("processing_patch_failed", 0) + 1
968
+ )
969
+ if lease_held:
970
+ _release_browser_lease()
971
+ break
972
+
973
+ # Tool-level campaign suffix injection (Reddit only).
974
+ # The LLM never sees the campaign; we append the literal
975
+ # suffix here so the actual posted text carries the tag.
976
+ applied_campaign_ids = []
977
+ if reply["platform"] == "reddit":
978
+ for camp in load_active_reddit_campaigns():
979
+ if random.random() < camp["sample_rate"]:
980
+ reply_text = reply_text + camp["suffix"]
981
+ applied_campaign_ids.append(camp["id"])
982
+ if applied_campaign_ids:
983
+ print(f"[engage_reddit] #{reply['id']} applied campaigns "
984
+ f"{applied_campaign_ids} (suffix appended)")
985
+
986
+ # URL-wrap the final reply_text (suffix included) so every
987
+ # outbound URL routes through /r/<code> for click attribution.
988
+ # project_name comes from the LLM decision (Tier 2/3) or
989
+ # falls back to the reply row's project_name; either is
990
+ # populated for any reply that includes a URL we care about.
991
+ # We backfill post_links.reply_id after the platform call
992
+ # succeeds (using reply["id"]).
993
+ minted_session = None
994
+ wrap_project = (project or reply.get("project_name") or "").strip()
995
+ wrap_platform = "reddit" if reply["platform"] == "reddit" else reply["platform"]
996
+ if wrap_project:
997
+ try:
998
+ from dm_short_links import wrap_text_for_post, utm_only_text
999
+ wrap_res = wrap_text_for_post(
1000
+ text=reply_text,
1001
+ platform=wrap_platform,
1002
+ project_name=wrap_project,
1003
+ )
1004
+ if wrap_res.get("ok"):
1005
+ reply_text = wrap_res["text"]
1006
+ minted_session = wrap_res.get("minted_session")
1007
+ if wrap_res.get("codes"):
1008
+ print(f"[engage_reddit] #{reply['id']} wrapped "
1009
+ f"{len(wrap_res['codes'])} URL(s)")
1010
+ else:
1011
+ print(f"[engage_reddit] #{reply['id']} WARNING: URL wrap "
1012
+ f"failed ({wrap_res.get('error')}); falling back to UTM-only")
1013
+ reply_text = utm_only_text(
1014
+ text=reply_text, platform=wrap_platform,
1015
+ project_name=wrap_project)
1016
+ except Exception as e:
1017
+ print(f"[engage_reddit] #{reply['id']} WARNING: URL wrap "
1018
+ f"raised ({e}); falling back to UTM-only")
1019
+ try:
1020
+ from dm_short_links import utm_only_text
1021
+ reply_text = utm_only_text(
1022
+ text=reply_text, platform=wrap_platform,
1023
+ project_name=wrap_project)
1024
+ except Exception as ee:
1025
+ print(f"[engage_reddit] #{reply['id']} WARNING: UTM-only "
1026
+ f"fallback also failed ({ee}); posting unwrapped")
1027
+
1028
+ # Post via CDP (reddit) or Moltbook API (moltbook)
1029
+ post_result = None
1030
+ if reply["platform"] == "moltbook":
1031
+ m = re.search(
1032
+ r"/post/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
1033
+ reply.get("their_comment_url") or "",
1034
+ )
1035
+ if not m:
1036
+ post_result = {"ok": False, "error": "missing_moltbook_post_uuid"}
1037
+ else:
1038
+ post_uuid = m.group(1)
1039
+ parent_id = reply.get("their_comment_id") or ""
1040
+ for attempt in range(3):
1041
+ try:
1042
+ out = subprocess.check_output(
1043
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "moltbook_post.py"),
1044
+ "comment",
1045
+ "--post-id", post_uuid,
1046
+ "--parent-id", parent_id,
1047
+ "--content", reply_text,
1048
+ "--no-upvote"],
1049
+ text=True, timeout=120, stderr=subprocess.DEVNULL,
1050
+ )
1051
+ # moltbook_post.py prints logs + a final JSON line
1052
+ json_line = next((ln for ln in reversed(out.splitlines())
1053
+ if ln.strip().startswith("{")), "")
1054
+ post_result = json.loads(json_line) if json_line else None
1055
+ if post_result and post_result.get("ok"):
1056
+ break
1057
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, json.JSONDecodeError, StopIteration) as e:
1058
+ print(f"[engage_reddit] #{reply['id']} moltbook attempt {attempt+1} failed: {e}")
1059
+ if attempt < 2:
1060
+ time.sleep(10)
1061
+ else:
1062
+ for attempt in range(3):
1063
+ try:
1064
+ cdp_out = subprocess.check_output(
1065
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "reddit_browser.py"),
1066
+ "reply", reply["their_comment_url"], reply_text],
1067
+ text=True, timeout=120, stderr=subprocess.DEVNULL,
1068
+ )
1069
+ post_result = json.loads(cdp_out)
1070
+ if post_result.get("ok"):
1071
+ break
1072
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, json.JSONDecodeError) as e:
1073
+ print(f"[engage_reddit] #{reply['id']} CDP attempt {attempt+1} failed: {e}")
1074
+ if attempt < 2:
1075
+ time.sleep(10)
1076
+
1077
+ if post_result and post_result.get("ok"):
1078
+ # Check if already replied (dedup)
1079
+ if post_result.get("already_replied"):
1080
+ existing = post_result.get("existing_text", "")
1081
+ existing_url = post_result.get("existing_url", "")
1082
+ cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), existing]
1083
+ if existing_url:
1084
+ cmd_args.append(existing_url)
1085
+ patch_replied_with_retry(cmd_args, reply["id"])
1086
+ succeeded += 1
1087
+ print(f"[engage_reddit] #{reply['id']} DEDUP (already replied) ({reply_elapsed:.0f}s)")
1088
+ print(f"[engage_reddit] #{reply['id']} tokens: in={usage['input_tokens']} out={usage['output_tokens']} "
1089
+ f"cache_r={usage['cache_read']} cache_w={usage['cache_create']} "
1090
+ f"${usage['cost_usd']:.4f}")
1091
+ processed += 1
1092
+ time.sleep(2)
1093
+ continue
1094
+
1095
+ # Mark as replied in DB. patch_replied_with_retry adds
1096
+ # rate-limit-aware retries so a transient s4l 429 after a
1097
+ # successful platform post does not leave the row in
1098
+ # 'processing' (which 2h reset_stuck_processing would flip
1099
+ # back to 'pending' and cause a duplicate post).
1100
+ reply_url = post_result.get("url", "")
1101
+ cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), reply_text, reply_url]
1102
+ if engagement_style:
1103
+ cmd_args.append(engagement_style)
1104
+ patch_replied_with_retry(cmd_args, reply["id"])
1105
+ # Attribute reply to any campaigns that applied a suffix
1106
+ bump_campaigns("replies", reply["id"], applied_campaign_ids)
1107
+ # Stamp post_links.reply_id for the URLs minted before
1108
+ # the platform call (idempotent; no-op when reply had
1109
+ # no URLs to wrap).
1110
+ if minted_session:
1111
+ try:
1112
+ from dm_short_links import backfill_reply_id
1113
+ backfill_reply_id(minted_session=minted_session,
1114
+ reply_id=reply["id"])
1115
+ except Exception as e:
1116
+ print(f"[engage_reddit] #{reply['id']} WARNING: "
1117
+ f"backfill_reply_id failed ({e})")
1118
+ # Cross-pipeline linkage: ensure a dms row exists for
1119
+ # this person on this thread so engage-dm-replies'
1120
+ # next cycle picks up any inbound on this chain
1121
+ # immediately, instead of waiting for the unread-dms
1122
+ # scan (which can lag up to 30 min). ensure-dm is
1123
+ # idempotent and auto-links to the most recent
1124
+ # replies row for this author within lookback.
1125
+ if reply["platform"] == "reddit":
1126
+ try:
1127
+ subprocess.run(
1128
+ [PYTHON,
1129
+ os.path.join(REPO_DIR, "scripts", "dm_conversation.py"),
1130
+ "ensure-dm",
1131
+ "--platform", "reddit",
1132
+ "--author", reply["their_author"]],
1133
+ capture_output=True, text=True, timeout=20,
1134
+ )
1135
+ except Exception as e:
1136
+ print(f"[engage_reddit] #{reply['id']} ensure-dm failed: {e}")
1137
+ # Update project if recommended. Routes through the
1138
+ # HTTPS PATCH lane in reply_db.py so the project name
1139
+ # travels as a JSON field (no shell interpolation, no
1140
+ # SQL injection vector) and benefits from the same
1141
+ # retry-on-transient policy as the rest of the
1142
+ # mutations.
1143
+ if project:
1144
+ subprocess.run(
1145
+ [PYTHON, REPLY_DB, "set_project",
1146
+ str(reply["id"]), project],
1147
+ capture_output=True,
1148
+ )
1149
+ succeeded += 1
1150
+ print(f"[engage_reddit] #{reply['id']} POSTED ({reply_elapsed:.0f}s) "
1151
+ f"[${usage['cost_usd']:.4f}]")
1152
+ else:
1153
+ err = post_result.get("error", "unknown") if post_result else "no_response"
1154
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), f"CDP_ERROR: {err}"])
1155
+ skip_reasons[f"cdp_error:{(err or 'unknown')[:32]}"] += 1
1156
+ failed += 1
1157
+ print(f"[engage_reddit] #{reply['id']} CDP FAILED: {err} ({reply_elapsed:.0f}s)")
1158
+ else:
1159
+ failed += 1
1160
+ print(f"[engage_reddit] #{reply['id']} unknown action: {decision}")
1161
+
1162
+ print(f"[engage_reddit] #{reply['id']} tokens: in={usage['input_tokens']} out={usage['output_tokens']} "
1163
+ f"cache_r={usage['cache_read']} cache_w={usage['cache_create']} "
1164
+ f"${usage['cost_usd']:.4f}")
1165
+
1166
+ processed += 1
1167
+
1168
+ # Release the reddit-browser lease before the inter-reply sleep so
1169
+ # peers can use the browser during that gap (2s now; widening it
1170
+ # later would only multiply the value). Belt-and-suspenders: if any
1171
+ # branch above hit a `break`, it already released; this fires on the
1172
+ # normal end-of-iteration path. Idempotent (NOT_HELD is fine).
1173
+ if lease_held:
1174
+ _release_browser_lease()
1175
+
1176
+ # Brief pause between sessions
1177
+ time.sleep(2)
1178
+
1179
+ total_elapsed = time.time() - start_time
1180
+ print(f"\n[engage_reddit] === SUMMARY ===")
1181
+ print(f"[engage_reddit] processed={processed} succeeded={succeeded} "
1182
+ f"skipped={skipped} failed={failed} elapsed={total_elapsed:.0f}s")
1183
+ print(f"[engage_reddit] meta_callouts_detected={meta_callouts_detected}")
1184
+ if skip_reasons:
1185
+ print(f"[engage_reddit] skip_reasons:")
1186
+ for reason, n in skip_reasons.most_common():
1187
+ print(f"[engage_reddit] {n:>3} {reason}")
1188
+ print(f"[engage_reddit] Total tokens: input={total_usage['input_tokens']} "
1189
+ f"output={total_usage['output_tokens']} "
1190
+ f"cache_read={total_usage['cache_read']} cache_create={total_usage['cache_create']}")
1191
+ print(f"[engage_reddit] Total cost: ${total_usage['cost_usd']:.4f}")
1192
+ if succeeded > 0:
1193
+ print(f"[engage_reddit] Avg cost per reply: ${total_usage['cost_usd'] / succeeded:.4f}")
1194
+
1195
+ # Build the failure-reasons string for the dashboard Result column. We
1196
+ # only count *hard* failure categories here (monthly_limit, aup_refusal,
1197
+ # timeout, claude_failed, bad_output) so that recoverable LLM-driven
1198
+ # skips (`llm:not_directed`, `llm:troll`, ...) don't get surfaced as
1199
+ # failures. Missing keys map to 0 via Counter, so this is safe even
1200
+ # when the run had zero failures.
1201
+ HARD_FAILURE_KEYS = ("monthly_limit", "aup_refusal", "timeout",
1202
+ "claude_failed", "bad_output")
1203
+ fr_pairs = [f"{k}:{skip_reasons[k]}" for k in HARD_FAILURE_KEYS
1204
+ if skip_reasons.get(k, 0) > 0]
1205
+ # Also surface CDP_ERROR rollups so a Reddit posting outage shows up as
1206
+ # "failed: cdp_error ×N" instead of dropping into the generic skip pile.
1207
+ cdp_total = sum(n for r, n in skip_reasons.items() if r.startswith("cdp_error:"))
1208
+ if cdp_total > 0:
1209
+ fr_pairs.append(f"cdp_error:{cdp_total}")
1210
+ failure_reasons_arg = ",".join(fr_pairs)
1211
+
1212
+ # Canonical machine-readable summary line for the shell wrapper
1213
+ # (engage-reddit.sh) to grep. The wrapper combines these engage-stage
1214
+ # counters with its own scan-stage counters and writes ONE log_run.py row.
1215
+ # Previously we wrote our own log_run row here AND the shell wrote one too,
1216
+ # producing two rows per cycle in run_monitor.log -- the duplicate without
1217
+ # scan info was the row the dashboard surfaced, which is why empty cycles
1218
+ # rendered as "0 0 0 0" instead of "scanned N / 0 new".
1219
+ print(
1220
+ f"[engage_reddit] LOG_RUN_SUMMARY"
1221
+ f" posted={succeeded}"
1222
+ f" skipped={skipped}"
1223
+ f" failed={failed}"
1224
+ f" cost={total_usage['cost_usd']:.4f}"
1225
+ f" elapsed={int(total_elapsed)}"
1226
+ f" failure_reasons={failure_reasons_arg}"
1227
+ )
1228
+
1229
+ # Print final status (per-platform counts) via reply_db.py status helper,
1230
+ # which now reads /api/v1/replies/counts under the hood.
1231
+ subprocess.run([PYTHON, REPLY_DB, "status", args.platform])
1232
+
1233
+
1234
+ if __name__ == "__main__":
1235
+ main()