@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,581 @@
1
+ #!/usr/bin/env bash
2
+ # engage-linkedin.sh — LinkedIn engagement loop
3
+ # Phase A: Discover replies/mentions from LinkedIn notifications (Claude-driven MCP).
4
+ # Phase B: Respond to pending LinkedIn replies (Claude-driven, OAuth API for posting).
5
+ # Called by launchd every 3 hours.
6
+ #
7
+ # IMPORTANT: all LinkedIn browser work goes through the linkedin-harness MCP
8
+ # (bh_run, CDP-driven real Chrome on port 9556), driven by Claude (the LLM).
9
+ # Do NOT re-introduce Python Playwright scrapers, Voyager API calls
10
+ # (/voyager/api/*), comment-page scroll+expand loops, or programmatic re-login
11
+ # flows. See CLAUDE.md "LinkedIn: flagged patterns to avoid" for why.
12
+
13
+ set -euo pipefail
14
+
15
+ # LinkedIn killswitch (2026-05-27): refuse to run if a prior fire detected
16
+ # session compromise (http_999, authwall, throttle, li_at cleared).
17
+ # State: ~/.claude/social-autoposter/linkedin.killswitch
18
+ # Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
19
+ if [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
20
+ echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
21
+ echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
22
+ exit 0
23
+ fi
24
+
25
+ # 2026-05-01: lock policy changed from "hold the entire run" to "hold only
26
+ # while a Claude phase is actively driving the browser" — same pattern as
27
+ # run-linkedin.sh. The old policy held linkedin-browser for the whole 25-45min
28
+ # cycle, starving peer pipelines (run-linkedin, dm-replies-linkedin) and
29
+ # defeating launchd schedules. The browser is only used inside the two
30
+ # run_claude.sh invocations (Phase A discovery, Phase B reply). Everything
31
+ # between (DB cleanup, pending pull, top performers, styles) is pure DB/CPU.
32
+ source "$(dirname "$0")/lock.sh"
33
+ # Browser backend bootstrap (linkedin-harness). Sets MCP_CONFIG_FILE,
34
+ # BROWSER_INSTRUCTIONS, exports LINKEDIN_CDP_URL, and provides
35
+ # ensure_linkedin_browser_for_backend. Migrated off the deprecated
36
+ # linkedin-agent MCP on 2026-05-29 (mirrors the Twitter harness migration).
37
+ source "$(dirname "$0")/lib/linkedin-backend.sh"
38
+
39
+ # Load secrets
40
+ # shellcheck source=/dev/null
41
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
42
+
43
+ REPO_DIR="$HOME/social-autoposter"
44
+ SKILL_FILE="$REPO_DIR/SKILL.md"
45
+ LOG_DIR="$REPO_DIR/skill/logs"
46
+ BATCH_SIZE=500
47
+ MCP_CONFIG="$MCP_CONFIG_FILE"
48
+
49
+ # DB-free since 2026-06-01: all reply state goes through the s4l.ai HTTP API
50
+ # (X-Installation auth). No DATABASE_URL needed; the helpers below call the API.
51
+ PY_BIN="$(command -v python3 || echo /usr/bin/python3)"
52
+
53
+ # li_reply_count <status> -> integer count of linkedin replies in that status.
54
+ # Backed by GET /api/v1/replies/counts (same aggregate reply_db.py status uses).
55
+ li_reply_count() {
56
+ "$PY_BIN" -c "
57
+ import sys; sys.path.insert(0, '$REPO_DIR/scripts')
58
+ from http_api import api_get
59
+ resp = api_get('/api/v1/replies/counts', {'platform': 'linkedin'})
60
+ counts = ((resp or {}).get('data') or {}).get('counts') or []
61
+ want = '$1'
62
+ print(next((int(r.get('count', 0)) for r in counts if r.get('status') == want), 0))
63
+ " 2>/dev/null || echo 0
64
+ }
65
+
66
+ # li_reset_processing <older_than_hours> -> count of rows reset to pending.
67
+ # older_than_hours=0 means no time gate (reset every processing row). Backed by
68
+ # POST /api/v1/replies/reset-stuck.
69
+ li_reset_processing() {
70
+ "$PY_BIN" -c "
71
+ import sys; sys.path.insert(0, '$REPO_DIR/scripts')
72
+ from http_api import api_post
73
+ resp = api_post('/api/v1/replies/reset-stuck', {'platform': 'linkedin', 'older_than_hours': int('$1')})
74
+ print(((resp or {}).get('data') or {}).get('reset_count', 0))
75
+ " 2>/dev/null || echo 0
76
+ }
77
+
78
+ mkdir -p "$LOG_DIR"
79
+ LOG_FILE="$LOG_DIR/engage-linkedin-$(date +%Y-%m-%d_%H%M%S).log"
80
+
81
+ # Per-cycle batch id stamped onto every claude_sessions row spawned by this
82
+ # engagement run (via SA_CYCLE_ID env -> log_claude_session.py). 2026-05-10
83
+ # cycle_id rollout.
84
+ BATCH_ID="enli-$(date +%Y%m%d_%H%M%S)-$$"
85
+ export SA_CYCLE_ID="$BATCH_ID"
86
+
87
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
88
+
89
+ RUN_START=$(date +%s)
90
+ log "=== LinkedIn Engagement Run: $(date) ==="
91
+
92
+ # Load exclusions from config
93
+ EXCLUDED_AUTHORS=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(', '.join(c.get('exclusions',{}).get('authors',[])))" 2>/dev/null || echo "")
94
+ EXCLUDED_LINKEDIN=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(', '.join(c.get('exclusions',{}).get('linkedin_profiles',[])))" 2>/dev/null || echo "")
95
+
96
+ # ═══════════════════════════════════════════════════════
97
+ # PHASE A: Discover new replies/mentions from LinkedIn notifications
98
+ # Claude-driven: LLM navigates linkedin-agent MCP to /notifications/, extracts
99
+ # actionable items from the notifications page DOM (NOT from Voyager API,
100
+ # NOT by opening each permalink).
101
+ # ═══════════════════════════════════════════════════════
102
+ log "Phase A: Scanning LinkedIn notifications (Claude-driven)..."
103
+
104
+ PHASE_A_PROMPT=$(mktemp)
105
+ cat > "$PHASE_A_PROMPT" <<PROMPT_EOF
106
+ You are the Social Autoposter LinkedIn discovery bot.
107
+
108
+ Read $SKILL_FILE for content rules (tone, anti-AI detection, no em dashes).
109
+
110
+ $BROWSER_INSTRUCTIONS
111
+
112
+ ## Task: Discover new LinkedIn replies and mentions from the notifications page
113
+
114
+ CRITICAL - Browser agent rule: ONLY use the browser tool described in the BROWSER BACKEND block above (mcp__linkedin-harness__bh_run). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, or mcp__macos-use__* tools.
115
+ CRITICAL: If a browser tool call is blocked or times out, wait 30 seconds and retry. Repeat up to 3 times. If still blocked, stop.
116
+ CRITICAL: Do NOT open individual post permalinks to fetch comment text. Everything we need is on the notifications page. Opening per-comment permalinks is a flagged scraping pattern.
117
+ CRITICAL: Do NOT call any /voyager/api/ endpoint, do NOT fetch() from the linkedin.com session. Use only UI navigation (the navigate/snapshot/run-code equivalents in the BROWSER BACKEND block).
118
+
119
+ EXCLUSIONS - do NOT engage with these accounts:
120
+ - Excluded authors: $EXCLUDED_AUTHORS
121
+ - Excluded LinkedIn profiles: $EXCLUDED_LINKEDIN
122
+ - Skip comments by "Matthew Diakonov" or "m13v" (our own account).
123
+
124
+ ### Step 1: Load existing reply comment IDs (dedup)
125
+ \`\`\`bash
126
+ python3 ~/social-autoposter/scripts/li_discovery.py comment-ids
127
+ \`\`\`
128
+ Skip any notification whose comment URN is already in this list.
129
+
130
+ ### Step 2: Load author+post pairs we already engaged with
131
+ \`\`\`bash
132
+ python3 ~/social-autoposter/scripts/li_discovery.py engaged-pairs
133
+ \`\`\`
134
+ Skip any notification whose (author, post) pair is already here. One reply per author per thread.
135
+
136
+ ### Step 3: Load our LinkedIn posts for matching
137
+ \`\`\`bash
138
+ python3 ~/social-autoposter/scripts/li_discovery.py posts
139
+ \`\`\`
140
+
141
+ ### Step 4: Navigate to LinkedIn notifications and verify session
142
+ Navigate (per the BROWSER BACKEND block) to https://www.linkedin.com/notifications/
143
+
144
+ Take a snapshot/screenshot and verify the page is the notifications feed (not a login/checkpoint page). If you see login, captcha, or a verification challenge, STOP immediately and print: SESSION_INVALID — do not attempt to log in. Exit.
145
+
146
+ ### Step 5: Load more notifications
147
+ Scroll the page down a few times to lazy-load notifications. If a "Show more results" button is visible, click it — up to 5 times total, with a pause of 2-3 seconds between clicks. Stop if the button disappears.
148
+
149
+ ### Step 6: Extract actionable notifications from the notifications page DOM
150
+ Run this DOM-extraction JS via the BROWSER BACKEND block's run-code equivalent
151
+ (bh_run with js("""...""")). It is a single read of the already-loaded page — do
152
+ NOT navigate to any other URL. The js() helper runs the body in page context and
153
+ returns the result, so pass exactly this body (it ends with \`return\`):
154
+
155
+ \`\`\`javascript
156
+ const actionable = [];
157
+ const actionablePhrases = [
158
+ 'replied to your comment',
159
+ 'mentioned you in a comment',
160
+ 'mentioned you in this',
161
+ 'commented on your post',
162
+ 'commented on your update',
163
+ ];
164
+
165
+ for (const article of document.querySelectorAll('article')) {
166
+ const text = (article.innerText || '').toLowerCase();
167
+ const matched = actionablePhrases.find(p => text.includes(p));
168
+ if (!matched) continue;
169
+
170
+ const strong = article.querySelector('strong');
171
+ const author = strong ? strong.textContent.trim() : 'unknown';
172
+
173
+ const link = article.querySelector('a[href*="commentUrn"]') ||
174
+ article.querySelector('a[href*="replyUrn"]') ||
175
+ article.querySelector('a[href*="feed/update"]');
176
+ const href = link ? link.getAttribute('href') : null;
177
+ if (!href) continue;
178
+
179
+ // Extract activity ID and commentUrn from the href
180
+ const activityMatch = href.match(/urn:li:activity:(\d+)/);
181
+ const activityId = activityMatch ? activityMatch[1] : null;
182
+ const commentUrnMatch = href.match(/commentUrn=([^&]+)/);
183
+ const commentUrn = commentUrnMatch ? decodeURIComponent(commentUrnMatch[1]) : null;
184
+
185
+ // Best-effort snippet: text inside the article, minus the author header
186
+ const snippet = (article.innerText || '').replace(/\s+/g, ' ').trim();
187
+
188
+ actionable.push({
189
+ type: matched,
190
+ author,
191
+ href: href.startsWith('http') ? href : ('https://www.linkedin.com' + href),
192
+ activity_id: activityId,
193
+ comment_urn: commentUrn,
194
+ snippet,
195
+ });
196
+ }
197
+ return JSON.stringify(actionable);
198
+ \`\`\`
199
+
200
+ ### Step 7: For each extracted notification, decide whether to insert
201
+ For each item:
202
+ - If comment_urn is null OR activity_id is null: skip (no_comment_urn)
203
+ - If comment_urn is in the Step 1 dedup list: skip (already_tracked)
204
+ - If author matches an excluded account or is our own: skip (excluded_author / own_account)
205
+ - Build author_post_key = author + '|||' + our_url-for-this-post. If this pair is in the Step 2 list: skip (author_already_engaged)
206
+ - Find matching post_id from Step 3 by activity_id in the our_url. If none: create one (use PROJECT_NAME matched from config.json projects[].topics against the post topic):
207
+ \`\`\`bash
208
+ python3 ~/social-autoposter/scripts/li_discovery.py create-post --activity-id "ACTIVITY_ID" --project "PROJECT_NAME" --author "AUTHOR"
209
+ \`\`\`
210
+ This prints the post id (reusing the existing row on duplicate). Use that id as POST_ID below.
211
+
212
+ Insert the reply:
213
+ \`\`\`bash
214
+ python3 ~/social-autoposter/scripts/li_discovery.py insert-reply --post-id POST_ID --comment-urn "COMMENT_URN" --author "AUTHOR" --content "SNIPPET" --href "HREF"
215
+ \`\`\`
216
+ Prints the new reply id, or "duplicate" / "gated:<reason>" (both are non-fatal, just move to the next item).
217
+
218
+ ### Step 8: Summary
219
+ Print:
220
+ - N new replies discovered
221
+ - N already tracked
222
+ - N author already engaged on thread
223
+ - N excluded
224
+ - N own account
225
+ - N no comment URN
226
+
227
+ ### Step 8b: Structured scan-summary marker line (REQUIRED)
228
+ After the human-readable summary above, print EXACTLY ONE line with this
229
+ format (no other text on the line, all values are integers):
230
+
231
+ LINKEDIN_SCAN_SUMMARY: scanned=<TOTAL_NOTIFICATIONS_INSPECTED> new=<NEW_INSERTED> already=<ALREADY_TRACKED> excluded=<EXCLUDED+OWN_ACCOUNT> unmatched=<NO_COMMENT_URN>
232
+
233
+ The wrapper shell script greps this line to surface scan-stage pills
234
+ (scanned / new / excluded) on the dashboard Result column. If a counter
235
+ doesn't apply this run, emit it as 0 anyway. The line MUST start with
236
+ "LINKEDIN_SCAN_SUMMARY:" at column 0 with that exact spelling and casing.
237
+ PROMPT_EOF
238
+
239
+ # Acquire linkedin-browser ONLY around the Phase A Claude run. lock.sh is
240
+ # FIFO-queued, so a peer pipeline (run-linkedin, dm-replies-linkedin) that's
241
+ # mid-run blocks here rather than skipping. run_claude.sh auto-exports
242
+ # SA_PIPELINE_LOCKED=1 + SA_PIPELINE_PLATFORM so the PreToolUse hook
243
+ # (~/.claude/hooks/linkedin-agent-lock.sh) skips the cross-session block check.
244
+ acquire_lock "linkedin-browser" 3600
245
+ ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
246
+
247
+ gtimeout 1800 "$REPO_DIR/scripts/run_claude.sh" "engage-linkedin-phaseA" --strict-mcp-config --mcp-config "$MCP_CONFIG" --output-format stream-json --verbose -p "$(cat "$PHASE_A_PROMPT")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: Phase A claude exited with code $?"
248
+
249
+ release_lock "linkedin-browser"
250
+ # Defense-in-depth: explicitly clear the hook-layer lockfile so the next
251
+ # pipeline cycle's PreToolUse never sees a stale entry from us. The
252
+ # run_claude.sh exit trap already does this in the happy path; this
253
+ # repeat is harmless and covers SIGKILL of run_claude.sh.
254
+ rm -f "$HOME/.claude/linkedin-agent-lock.json"
255
+ rm -f "$PHASE_A_PROMPT"
256
+
257
+ # ═══════════════════════════════════════════════════════
258
+ # PHASE B: Respond to pending LinkedIn replies
259
+ # Claude-driven. Posts via OAuth API (api.linkedin.com/v2/socialActions) by
260
+ # default (documented, authorized integration). Falls back to linkedin-agent
261
+ # MCP browser click-through if API errors.
262
+ # ═══════════════════════════════════════════════════════
263
+
264
+ # Reset any 'processing' items older than 2 hours back to 'pending'
265
+ RESET_COUNT=$(li_reset_processing 2)
266
+ [ "$RESET_COUNT" -gt 0 ] && log "Phase B: Reset $RESET_COUNT stuck 'processing' LinkedIn items back to pending"
267
+
268
+ PENDING_COUNT=$(li_reply_count pending)
269
+
270
+ if [ "$PENDING_COUNT" -eq 0 ]; then
271
+ log "Phase B: No pending LinkedIn replies. Done!"
272
+ else
273
+ log "Phase B: $PENDING_COUNT pending LinkedIn replies to process"
274
+
275
+ # Pull the full pending batch via the next-pending endpoint (no DATABASE_URL).
276
+ # The route applies the same ordering the old json_agg query used (our own
277
+ # posts first, then oldest discovered_at) and returns {replies:[...]}. We
278
+ # extract the array so PENDING_DATA stays the same JSON shape the prompt
279
+ # consumed before the migration.
280
+ PENDING_DATA=$("$PY_BIN" -c "
281
+ import json, sys
282
+ sys.path.insert(0, '$REPO_DIR/scripts')
283
+ from http_api import api_get
284
+ resp = api_get('/api/v1/replies/next-pending', {'platform': 'linkedin', 'limit': int('$BATCH_SIZE')})
285
+ rows = (resp.get('data') or {}).get('replies') or []
286
+ print(json.dumps(rows))
287
+ " 2>/dev/null || echo "[]")
288
+
289
+ # Per-project voice map (so each reply can be drafted in the matched project's voice)
290
+ PROJECTS_VOICE_JSON=$(python3 -c "
291
+ import json
292
+ c = json.load(open('$REPO_DIR/config.json'))
293
+ print(json.dumps({p['name']: p.get('voice', {}) for p in c.get('projects', []) if p.get('voice')}, indent=2))
294
+ " 2>/dev/null || echo "{}")
295
+
296
+ # Engagement-style picker (2026-05-31 LinkedIn alignment to Twitter): pick
297
+ # ONE assigned style per reply iteration PROGRAMMATICALLY, mirroring
298
+ # engage-twitter.sh. The picked style flows two places: (1) --style filter
299
+ # for top_performers.py so the per-style exemplars match the assignment,
300
+ # (2) saps_render_style_block so the prompt embeds the same assignment. On
301
+ # invent mode picked_style is empty and top_performers stays unfiltered.
302
+ # Replaces the legacy generate_styles_block (which discarded the pick and
303
+ # let the model invent freely).
304
+ source "$REPO_DIR/skill/styles.sh"
305
+ STYLE_ASSIGN_FILE=$(mktemp -t saps_linkedin_eng_assign_XXXXXX.json)
306
+ saps_pick_style linkedin replying "$STYLE_ASSIGN_FILE" >/dev/null 2>&1 || true
307
+ PICKED_STYLE=$(python3 -c "
308
+ import json
309
+ try:
310
+ with open('$STYLE_ASSIGN_FILE') as f:
311
+ d = json.load(f)
312
+ print(d.get('style') or '')
313
+ except Exception:
314
+ print('')
315
+ " 2>/dev/null)
316
+ PICKED_MODE=$(python3 -c "
317
+ import json
318
+ try:
319
+ with open('$STYLE_ASSIGN_FILE') as f:
320
+ d = json.load(f)
321
+ print(d.get('mode') or 'use')
322
+ except Exception:
323
+ print('use')
324
+ " 2>/dev/null)
325
+ STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" linkedin replying)
326
+ rm -f "$STYLE_ASSIGN_FILE" 2>/dev/null || true
327
+
328
+ # Top performers feedback report — filtered to the picked style when in
329
+ # 'use' mode so the few-shot exemplars match the assignment.
330
+ if [ -n "$PICKED_STYLE" ]; then
331
+ TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform linkedin --style "$PICKED_STYLE" 2>/dev/null || echo "(top performers report unavailable)")
332
+ else
333
+ TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform linkedin 2>/dev/null || echo "(top performers report unavailable)")
334
+ fi
335
+
336
+ PHASE_B_PROMPT=$(mktemp)
337
+ cat > "$PHASE_B_PROMPT" <<PROMPT_EOF
338
+ You are the Social Autoposter LinkedIn engagement bot.
339
+
340
+ Read $SKILL_FILE for the full workflow, content rules, and platform details.
341
+
342
+ $BROWSER_INSTRUCTIONS
343
+
344
+ EXCLUSIONS - do NOT engage with these accounts (skip and mark as 'skipped' with reason 'excluded_author'):
345
+ - Excluded authors: $EXCLUDED_AUTHORS
346
+ - Excluded LinkedIn profiles: $EXCLUDED_LINKEDIN
347
+
348
+ ### BOT / ENGAGEMENT-LOOP ESCAPE HATCH (use sparingly, but use it)
349
+ We maintain a universal author blocklist in Postgres (\`author_blocklist\`),
350
+ consulted at /api/v1/replies POST time. A single block recorded by ANY of
351
+ our accounts/installs applies to EVERY future engagement from EVERY of our
352
+ accounts — universal scope, by design. The velocity gate already covers
353
+ "this handle has gotten too many replies from us in 24h/7d"; this lane is
354
+ for the LLM-judgment cases velocity cannot catch.
355
+
356
+ When to add a block (your judgment, exercised CONSERVATIVELY):
357
+ - The profile is plainly an AI/bot account: templated phrasing, generic
358
+ filler answers, name/headline reads "AI growth agent" / "comments on
359
+ posts for you", bio is engagement-farming boilerplate
360
+ - We are clearly stuck in a reciprocal engagement loop with this profile
361
+ (they comment on every one of our posts, we reply to every one of theirs,
362
+ no substance is exchanged)
363
+ - The profile is engagement farming (mass low-effort comments across the
364
+ platform, not actually engaging with the topic)
365
+
366
+ DO NOT add a block for: someone we disagree with, a hostile-but-human
367
+ critic, a low-quality but human comment, or a single bad interaction.
368
+ Skip those (status='skipped') — blocking is permanent until manually
369
+ removed and applies to all our accounts.
370
+
371
+ How to add the block (run BEFORE marking the current reply skipped). The
372
+ handle to pass is the URL-vanity portion of /in/<vanity>/ (e.g. for
373
+ linkedin.com/in/jane-doe-123/, pass jane-doe-123):
374
+ python3 \$REPO_DIR/scripts/reply_db.py blocklist add linkedin HANDLE \\
375
+ --reason "<one-line judgment>" \\
376
+ --classification {bot|engagement_loop} \\
377
+ --source-reply-id REPLY_ID
378
+
379
+ Then mark the current reply skipped with a clear reason:
380
+ python3 \$REPO_DIR/scripts/reply_db.py skipped REPLY_ID "blocklist_added:HANDLE"
381
+
382
+ CRITICAL - Browser agent rule: ONLY use the browser tool described in the BROWSER BACKEND block above (mcp__linkedin-harness__bh_run). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, or mcp__macos-use__* tools.
383
+ CRITICAL: If a browser tool call is blocked or times out, DO NOT fall back to any other browser tool. Wait 30 seconds and retry. Repeat up to 3 times.
384
+ CRITICAL: TECHNICAL FAILURES ARE NOT TERMINAL. If after retries the action still failed for any technical reason (browser blocked, MCP timeout, page rendering issue, linkedin.com unreachable, linkedin_api.py 5xx), DO NOT call reply_db.py skipped. Leave the row in 'processing' status and move on to the next pending item. The next engage run's start-of-script cleanup resets stuck 'processing' rows back to 'pending' and retries automatically.
385
+ CRITICAL: ONLY call reply_db.py skipped for content/policy reasons (e.g., light_acknowledgment, drive_by_self_promo, hostile_user, off_topic, troll, excluded_author). NEVER skip for technical browser/network failures: those must be retry-able.
386
+ CRITICAL: Do NOT call /voyager/api/ endpoints. Posting goes through linkedin_api.py (OAuth api.linkedin.com). Browser is the fallback only.
387
+
388
+ ## Respond to pending LinkedIn replies ($PENDING_COUNT total)
389
+
390
+ ### Priority order:
391
+ 1. **Replies on our original posts** (is_our_original_post=1) - highest priority
392
+ 2. **Direct questions** ("what tool", "how do you", "can you share")
393
+ 3. **Everything else** - general engagement
394
+
395
+ ### Tiered link strategy:
396
+ - **Tier 1 (default):** No link. Genuine engagement, expand topic.
397
+ - **Tier 2 (natural mention):** Conversation touches a topic matching a project in config. Recommend it casually as a tool you've come across.
398
+ - **Tier 3 (direct ask):** They ask for link/tool/source. Give it immediately.
399
+
400
+ ## FEEDBACK FROM PAST PERFORMANCE (use this to write better replies):
401
+ $TOP_REPORT
402
+
403
+ $STYLES_BLOCK
404
+
405
+ ## Per-project voice map
406
+ For each reply you draft, look up the matched project's voice block below and apply it: follow \`voice.tone\`, never violate any item in \`voice.never\`, mirror \`voice.examples\` / \`voice.examples_good\` when present.
407
+ $PROJECTS_VOICE_JSON
408
+
409
+ ## Resolving the parent post (replaces the old prompt-blob index)
410
+ Each pending row's \`project_name\` is a best-effort guess. After navigating the thread (Step 2), extract the activity_id from the page URL/comment URN and resolve it via:
411
+ python3 $REPO_DIR/scripts/lookup_post.py linkedin ACTIVITY_ID
412
+ Returns JSON: {"project": "fazm", "our_content": "...full text...", "thread_url": "..."} or {"project": null} if it's not one of our posts.
413
+
414
+ Here are the replies to process:
415
+ $PENDING_DATA
416
+
417
+ CRITICAL: Reply in the SAME LANGUAGE as the message you are responding to. Match the language exactly.
418
+ CRITICAL: Process EVERY reply. For each: either post a response and mark as 'replied', OR mark as 'skipped' with a skip_reason.
419
+
420
+ CRITICAL: For ALL database operations, use the reply_db.py helper (NOT raw psql):
421
+ python3 $REPO_DIR/scripts/reply_db.py processing ID # BEFORE posting
422
+ python3 $REPO_DIR/scripts/reply_db.py replied ID "reply text" [url] [engagement_style] [is_recommendation] # AFTER posting. engagement_style is TONE (critic, storyteller, etc). Pass "1" for is_recommendation ONLY when the reply casually recommends a project (Tier 2/3); leave blank otherwise.
423
+ python3 $REPO_DIR/scripts/reply_db.py skipped ID "reason"
424
+ python3 $REPO_DIR/scripts/reply_db.py skip_batch '{"ids":[1,2,3],"reason":"..."}'
425
+ python3 $REPO_DIR/scripts/reply_db.py status
426
+ NEVER use psql directly for reply status updates.
427
+
428
+ ### Project tracking on replies
429
+ When you recommend a project in a reply (Tier 2 or Tier 3), set project_name on the reply:
430
+ python3 $REPO_DIR/scripts/reply_db.py set_project REPLY_ID "PROJECT_NAME"
431
+
432
+ MANDATORY reply flow for every item:
433
+ Step 1: python3 reply_db.py processing ID <- mark BEFORE posting
434
+ Step 2: NAVIGATE TO THE THREAD AND READ CONTEXT (mandatory, do NOT skip).
435
+ Do NOT draft a reply from the notification snippet alone — the snippet
436
+ is truncated and lacks the parent post content + sibling replies.
437
+ a) Navigate (per the BROWSER BACKEND block) to their_comment_url
438
+ b) Snapshot/screenshot (per the BROWSER BACKEND block) to read:
439
+ - the FULL parent post text (our original post if this is on our thread)
440
+ - the immediate ancestor of their_comment_id
441
+ - sibling replies (so you don't repeat what someone else already said)
442
+ c) Extract the activity_id (the long numeric string after \`urn:li:activity:\`)
443
+ from the URL or comment URN. Resolve it:
444
+ python3 $REPO_DIR/scripts/lookup_post.py linkedin ACTIVITY_ID
445
+ If the response has a non-null \"project\", that's our post, OVERRIDE
446
+ the reply row and use that project's voice for drafting:
447
+ python3 $REPO_DIR/scripts/reply_db.py set_project REPLY_ID "RESOLVED_PROJECT"
448
+ Use the returned \"our_content\" as the FULL text of the post being
449
+ replied to (more accurate than the truncated our_content in PENDING_DATA).
450
+ If \"project\" is null, we're a guest in someone else's thread; keep
451
+ the existing project_name and follow global content rules.
452
+ Step 3: Draft the reply using the resolved project's voice + the ASSIGNED
453
+ engagement style. This cycle: mode=$PICKED_MODE style='${PICKED_STYLE:-(invent)}'.
454
+ In USE mode ($PICKED_MODE=use) apply the assigned style '${PICKED_STYLE}'
455
+ verbatim; do NOT pick a different style and do NOT invent one. In INVENT
456
+ mode ($PICKED_MODE=invent) craft a NEW snake_case style name not in the
457
+ curated block above and pass it as the [engagement_style] arg in Step 5.
458
+ Professional but casual. NEVER em dashes. Match parent post language.
459
+ Step 4: post reply (OAuth API first, browser fallback)
460
+ Step 5: python3 reply_db.py replied ID "text" [url] [engagement_style] [is_recommendation] <- mark AFTER success. engagement_style is the style name you applied (in USE mode the assigned '${PICKED_STYLE}'). is_recommendation="1" only when you mentioned a project (Tier 2/3).
461
+ If Step 5 fails, the item stays 'processing' and will be reset to 'pending' on the next run.
462
+
463
+ For LinkedIn replies - use the OAuth API first:
464
+ 1. Extract the activity ID from their_comment_url or their_comment_id.
465
+ - From their_comment_id like \`urn:li:comment:(activity:7438226125077549056,7438815640536170496)\`, the activity ID is \`7438226125077549056\` and the full URN is the parent_comment_urn.
466
+ - From their_comment_url, extract the activity ID from the URL path.
467
+ 2. Post the reply via API:
468
+ \`\`\`bash
469
+ python3 $REPO_DIR/scripts/linkedin_api.py reply ACTIVITY_ID "PARENT_COMMENT_URN" "YOUR REPLY TEXT" --project "RESOLVED_PROJECT_NAME" --reply-id REPLY_ID
470
+ \`\`\`
471
+ Replace RESOLVED_PROJECT_NAME with the project from lookup_post.py (or the row's project_name if lookup returned null).
472
+ Replace REPLY_ID with the numeric id from the current reply row.
473
+ The API will automatically wrap any URLs in the reply text with short tracking links and backfill the reply_id after posting.
474
+ This returns JSON with {ok, reply_urn, permalink}. Use permalink as the reply URL.
475
+ On {"ok": true}: skip step 3 (browser fallback) and skip the browser-based
476
+ verification in step 5 below — the API success response (with reply_urn) is
477
+ itself authoritative. Mark replied with the permalink. Do NOT navigate the
478
+ browser to verify; that would burn the linkedin-browser lock for no gain.
479
+ 3. If the API call fails (e.g., token expired, comment deleted), fall back to the linkedin-agent browser:
480
+ - UTM-WRAP YOUR_REPLY_TEXT FIRST. The browser-typing path has no Python wrap
481
+ layer, so a bare URL would be posted as-is and we'd lose attribution. Run:
482
+ python3 \$REPO_DIR/scripts/dm_short_links.py utm-text \\
483
+ --platform linkedin \\
484
+ --project RESOLVED_PROJECT_NAME \\
485
+ --text "YOUR_REPLY_TEXT"
486
+ Use the printed string going forward. No DB write happens. Safe to run
487
+ unconditionally (no-op when YOUR_REPLY_TEXT contains zero URLs).
488
+ - Navigate to their_comment_url (per the BROWSER BACKEND block)
489
+ - Snapshot/screenshot to find the comment, click Reply (click_at_xy on the Reply control), type the (UTM-wrapped) text, submit
490
+ - Do NOT aggressively scroll-and-expand comments; if the comment isn't visible after a normal scroll, mark as 'skipped' with reason 'comment_not_found'
491
+ 4. If both API and browser fail, mark as 'skipped' with reason 'comment_not_found'.
492
+
493
+ 5. POST-SUBMIT VERIFICATION (mandatory, BROWSER-FALLBACK PATH ONLY).
494
+ If you posted via the OAuth API in step 2 and got {"ok": true}, SKIP this
495
+ block entirely — the API response is the verification. Run this block ONLY
496
+ when step 3's browser fallback was used. Verify visually + via DOM (the
497
+ harness has no network-capture tool; do NOT try to read /voyager or socialActions
498
+ network traffic — that is a flagged pattern anyway):
499
+ 5a. Capture a screenshot (bh_run print(capture_screenshot())) and Read the PNG to
500
+ check for a toast / submit feedback.
501
+ 5b. Read the DOM via the BROWSER BACKEND block's run-code equivalent (bh_run with
502
+ js("""...""")) and check:
503
+ (a) a fresh comment by 'Matthew Diakonov' / 'You' is rendered under their_comment
504
+ (b) NO 'could not be created' / 'try again' / 'something went wrong' toast text
505
+ (c) reply editor textbox cleared (empty contenteditable)
506
+ 5c. SUCCESS = (a) passes with no toast. REJECTED = error toast present OR our
507
+ reply not visible in the DOM.
508
+ 6. If REJECTED, do NOT call reply_db.py replied. Mark soft-blocked:
509
+ python3 $REPO_DIR/scripts/reply_db.py skipped ID "soft_blocked: <verbatim toast or 'quiet_fail_count_unchanged'>"
510
+ Then STOP this row and move to the next pending reply.
511
+ 7. If step 5 SUCCESS (or step 2 OAuth success), mark replied via Step 5 of the
512
+ MANDATORY reply flow above (reply_db.py replied ID "text" [url] [style] [is_recommendation]).
513
+
514
+ After every 10 replies, run: python3 $REPO_DIR/scripts/reply_db.py status
515
+ PROMPT_EOF
516
+
517
+ # Re-acquire linkedin-browser ONLY for Phase B. The lock was released after
518
+ # Phase A so peer pipelines could use the browser during our DB-pull /
519
+ # styles-prep window (~1-3s). FIFO ticket queue in lock.sh ensures
520
+ # fairness if a peer or parallel cycle grabbed it in the meantime.
521
+ acquire_lock "linkedin-browser" 3600
522
+ ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
523
+
524
+ gtimeout 5400 "$REPO_DIR/scripts/run_claude.sh" "engage-linkedin-phaseB" --strict-mcp-config --mcp-config "$MCP_CONFIG" --output-format stream-json --verbose -p "$(cat "$PHASE_B_PROMPT")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: Phase B claude exited with code $?"
525
+
526
+ release_lock "linkedin-browser"
527
+ # Defense-in-depth: explicit hook-lockfile cleanup; see Phase A note.
528
+ rm -f "$HOME/.claude/linkedin-agent-lock.json"
529
+ rm -f "$PHASE_B_PROMPT"
530
+ fi
531
+
532
+ # Reset any items left in 'processing' after subprocess exit (tech-failure
533
+ # retry path: agent leaves rows here on browser/MCP failure rather than
534
+ # calling reply_db.py skipped, so the next run picks them up automatically).
535
+ # older_than_hours=0 resets ALL 'processing' rows regardless of age.
536
+ POST_RESET=$(li_reset_processing 0)
537
+ [ "$POST_RESET" -gt 0 ] && log "Post-run: Reset $POST_RESET 'processing' LinkedIn items back to pending"
538
+
539
+ # ═══════════════════════════════════════════════════════
540
+ # Cleanup
541
+ # ═══════════════════════════════════════════════════════
542
+ TOTAL_PENDING=$(li_reply_count pending)
543
+ TOTAL_REPLIED=$(li_reply_count replied)
544
+ TOTAL_SKIPPED=$(li_reply_count skipped)
545
+
546
+ log "LinkedIn summary: pending=$TOTAL_PENDING replied=$TOTAL_REPLIED skipped=$TOTAL_SKIPPED"
547
+
548
+ # Log run to persistent monitor
549
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
550
+ _COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "engage-linkedin-phaseA" "engage-linkedin-phaseB" 2>/dev/null || echo "0.0000")
551
+ # Pull Phase A scan-stage counters from the structured marker line Claude emits
552
+ # at the end of Phase A: "LINKEDIN_SCAN_SUMMARY: scanned=N new=N already=N
553
+ # excluded=N unmatched=N". If the marker is missing (Claude failed before
554
+ # printing or the prompt drifted), fall back to no scan= segment and the
555
+ # dashboard renders the old way.
556
+ LI_SCAN_LINE=$(grep -m1 "^LINKEDIN_SCAN_SUMMARY:" "$LOG_FILE" 2>/dev/null || true)
557
+ LI_SCAN_ARG=""
558
+ if [ -n "$LI_SCAN_LINE" ]; then
559
+ li_scanned=$(echo "$LI_SCAN_LINE" | grep -oE "scanned=[0-9]+" | head -1 | cut -d= -f2)
560
+ li_new=$(echo "$LI_SCAN_LINE" | grep -oE "new=[0-9]+" | head -1 | cut -d= -f2)
561
+ li_already=$(echo "$LI_SCAN_LINE" | grep -oE "already=[0-9]+" | head -1 | cut -d= -f2)
562
+ li_excl=$(echo "$LI_SCAN_LINE" | grep -oE "excluded=[0-9]+" | head -1 | cut -d= -f2)
563
+ li_unm=$(echo "$LI_SCAN_LINE" | grep -oE "unmatched=[0-9]+" | head -1 | cut -d= -f2)
564
+ parts=""
565
+ [ -n "$li_scanned" ] && parts="${parts}scanned=${li_scanned},"
566
+ [ -n "$li_new" ] && parts="${parts}new=${li_new},"
567
+ [ -n "$li_already" ] && [ "$li_already" -gt 0 ] && parts="${parts}already=${li_already},"
568
+ [ -n "$li_excl" ] && [ "$li_excl" -gt 0 ] && parts="${parts}excluded=${li_excl},"
569
+ [ -n "$li_unm" ] && [ "$li_unm" -gt 0 ] && parts="${parts}unmatched=${li_unm},"
570
+ LI_SCAN_ARG="${parts%,}"
571
+ fi
572
+ if [ -n "$LI_SCAN_ARG" ]; then
573
+ python3 "$REPO_DIR/scripts/log_run.py" --script "engage_linkedin" --posted "$TOTAL_REPLIED" --skipped "$TOTAL_SKIPPED" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED" --scan "$LI_SCAN_ARG"
574
+ else
575
+ python3 "$REPO_DIR/scripts/log_run.py" --script "engage_linkedin" --posted "$TOTAL_REPLIED" --skipped "$TOTAL_SKIPPED" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
576
+ fi
577
+
578
+ # Delete old logs
579
+ find "$LOG_DIR" -name "engage-linkedin-*.log" -mtime +7 -delete 2>/dev/null || true
580
+
581
+ log "=== LinkedIn engagement complete: $(date) ==="
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # engage-moltbook.sh — MoltBook reply engagement loop
3
+ # Calls engage_reddit.py --platform moltbook to process pending MoltBook replies.
4
+ # Discovery runs separately via run-scan-moltbook-replies.sh.
5
+ # Called by launchd every 10 minutes.
6
+
7
+ set -euo pipefail
8
+
9
+ source "$(dirname "$0")/lock.sh"
10
+ acquire_lock "engage-moltbook" 3600
11
+
12
+ [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
13
+
14
+ # Cycle ID for cross-cycle cost accounting (see engage.sh / run-reddit-search.sh
15
+ # for the same pattern). engage_reddit.py's claude subprocess inherits this via
16
+ # env, and log_claude_session.py stamps claude_sessions.cycle_id.
17
+ BATCH_ID="${BATCH_ID:-enmb-$(date +%Y%m%d-%H%M%S)}"
18
+ export BATCH_ID
19
+ export SA_CYCLE_ID="$BATCH_ID"
20
+
21
+ REPO_DIR="$HOME/social-autoposter"
22
+ LOG_DIR="$REPO_DIR/skill/logs"
23
+ mkdir -p "$LOG_DIR"
24
+ LOG_FILE="$LOG_DIR/engage-moltbook-$(date +%Y-%m-%d_%H%M%S).log"
25
+
26
+ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
27
+
28
+ RUN_START=$(date +%s)
29
+ log "=== MoltBook Engage Run: $(date) ==="
30
+
31
+ python3 "$REPO_DIR/scripts/engage_reddit.py" --platform moltbook 2>&1 | tee -a "$LOG_FILE" || log "WARNING: engage_reddit.py exited non-zero"
32
+
33
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
34
+ log "=== MoltBook Engage complete: $(date) (elapsed ${RUN_ELAPSED}s) ==="
35
+
36
+ find "$LOG_DIR" -name "engage-moltbook-*.log" -mtime +7 -delete 2>/dev/null || true