@m13v/s4l 1.6.197-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """dm_db_update.py — single-row PATCH helper for the `dms` table.
3
+
4
+ Created 2026-05-18 as the replacement for the three inline
5
+ `psql "$DATABASE_URL" -c "UPDATE dms SET ..."` lines the dm-outreach-*
6
+ shell pipelines used to embed in their Claude prompts. The LLM is told to
7
+ shell out to this script instead of psql so all DB writes route through
8
+ /api/v1/dms/:id and we keep the credentials surface inside the helper.
9
+
10
+ Usage:
11
+ python3 scripts/dm_db_update.py --dm-id N \
12
+ [--status pending|sent|error|skipped|...] \
13
+ [--skip-reason TEXT] \
14
+ [--claude-session-id UUID]
15
+
16
+ At least one of --status / --skip-reason / --claude-session-id is
17
+ required. Status and skip_reason can be set independently (the PATCH
18
+ route uses COALESCE for every field, so omitted fields stay unchanged).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import os
24
+ import sys
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ from http_api import api_patch # noqa: E402
28
+
29
+
30
+ def main() -> int:
31
+ ap = argparse.ArgumentParser()
32
+ ap.add_argument("--dm-id", type=int, required=True)
33
+ ap.add_argument("--status")
34
+ ap.add_argument("--skip-reason")
35
+ ap.add_argument("--claude-session-id")
36
+ args = ap.parse_args()
37
+
38
+ body: dict = {}
39
+ if args.status:
40
+ body["status"] = args.status
41
+ if args.skip_reason:
42
+ body["skip_reason"] = args.skip_reason
43
+ if args.claude_session_id:
44
+ body["claude_session_id"] = args.claude_session_id
45
+
46
+ if not body:
47
+ print(
48
+ "dm_db_update: nothing to update; pass at least --status, "
49
+ "--skip-reason, or --claude-session-id",
50
+ file=sys.stderr,
51
+ )
52
+ return 1
53
+
54
+ try:
55
+ resp = api_patch(f"/api/v1/dms/{args.dm_id}", body)
56
+ except SystemExit as e:
57
+ print(f"dm_db_update: PATCH /api/v1/dms/{args.dm_id} failed: {e}", file=sys.stderr)
58
+ return 1
59
+
60
+ dm = (resp.get("data") or {}).get("dm") or {}
61
+ print(
62
+ f"dm_db_update: dm #{args.dm_id} status={dm.get('status')!r} "
63
+ f"skip_reason={dm.get('skip_reason')!r}",
64
+ )
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ sys.exit(main())
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """HTTP-only stdout shims for the psql one-liners in skill/engage-dm-replies.sh.
3
+
4
+ The engage pipeline used to embed raw `psql "$DATABASE_URL" -t -A -c "..."`
5
+ calls for its read-side gates and end-of-run summary. The direct-Postgres lane
6
+ was removed 2026-06-01; DATABASE_URL is deliberately ignored, no DB, no
7
+ fallback. Each subcommand here calls the s4l.ai HTTP API (scripts/http_api.py)
8
+ and prints EXACTLY what the corresponding psql call printed, so the shell
9
+ parsing around it (json.load, `tr '|' ' '`, integer compares) is unchanged.
10
+
11
+ Subcommands (each maps 1:1 to a former psql call):
12
+ pending --platform X --limit 30 -> PENDING_CONVOS: JSON array, or 'null'
13
+ needs-reply --platform X -> needs_reply_count_for: integer
14
+ run-counts --platform X --since N -> dm_counts_for: 'POSTED STALE'
15
+ summary -> DM_SUMMARY: json object
16
+ reddit-authors -> KNOWN_REDDIT_AUTHORS: 'a, b, c'
17
+ reddit-campaign-suffix -> REDDIT_CAMPAIGN_SUFFIX_LITERAL
18
+ reddit-campaign-sample-rate -> REDDIT_CAMPAIGN_SAMPLE_RATE
19
+ flagged-count -> FLAGGED_COUNT: integer
20
+
21
+ All endpoints live under /api/v1/dms/engage except the campaign subcommands
22
+ (/api/v1/campaigns) and flagged-count (/api/v1/dms/flagged).
23
+ """
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+
29
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
30
+ from http_api import api_get
31
+
32
+
33
+ def _data(resp):
34
+ return (resp or {}).get("data") or {}
35
+
36
+
37
+ def cmd_pending(args):
38
+ resp = api_get(
39
+ "/api/v1/dms/engage",
40
+ query={"mode": "pending", "platform": args.platform or "", "limit": args.limit},
41
+ )
42
+ rows = _data(resp).get("rows")
43
+ # Mirror psql's `json_agg(...) -> NULL when empty` which the shell echoed as
44
+ # the literal string 'null'.
45
+ if not rows:
46
+ print("null")
47
+ else:
48
+ print(json.dumps(rows))
49
+
50
+
51
+ def cmd_needs_reply(args):
52
+ resp = api_get(
53
+ "/api/v1/dms/engage",
54
+ query={"mode": "needs_reply", "platform": args.platform or ""},
55
+ )
56
+ print(_data(resp).get("count", 0))
57
+
58
+
59
+ def cmd_run_counts(args):
60
+ resp = api_get(
61
+ "/api/v1/dms/engage",
62
+ query={"mode": "run_counts", "platform": args.platform or "", "since": args.since},
63
+ )
64
+ d = _data(resp)
65
+ # psql printed 'posted|stale' then the shell did `tr '|' ' '`; emit the
66
+ # already-split form so `read -r POSTED STALE` works directly.
67
+ print(f"{d.get('posted', 0)} {d.get('stale', 0)}")
68
+
69
+
70
+ def cmd_summary(_args):
71
+ resp = api_get("/api/v1/dms/engage", query={"mode": "summary"})
72
+ summary = _data(resp).get("summary") or {}
73
+ # Match the json_build_object the shell logs verbatim.
74
+ print(json.dumps(summary))
75
+
76
+
77
+ def cmd_reddit_authors(_args):
78
+ resp = api_get("/api/v1/dms/engage", query={"mode": "reddit_authors"})
79
+ print(_data(resp).get("authors") or "")
80
+
81
+
82
+ def _active_reddit_campaign():
83
+ """First active reddit campaign with budget remaining + a non-empty suffix.
84
+
85
+ Mirrors the two REDDIT_CAMPAIGN_* psql queries: status='active',
86
+ platforms includes reddit, max_posts_total set AND posts_made < it,
87
+ suffix non-empty, ORDER BY id LIMIT 1.
88
+ """
89
+ resp = api_get(
90
+ "/api/v1/campaigns",
91
+ query={
92
+ "status": "active",
93
+ "platform": "reddit",
94
+ "has_suffix": "true",
95
+ "with_budget_remaining": "true",
96
+ "limit": 500,
97
+ },
98
+ )
99
+ rows = _data(resp).get("campaigns") or []
100
+ for r in rows: # already ORDER BY id ASC server-side
101
+ max_total = r.get("max_posts_total")
102
+ posts_made = r.get("posts_made") or 0
103
+ suffix = r.get("suffix")
104
+ if max_total is None or posts_made >= max_total:
105
+ continue
106
+ if not suffix:
107
+ continue
108
+ return r
109
+ return None
110
+
111
+
112
+ def cmd_reddit_campaign_suffix(_args):
113
+ c = _active_reddit_campaign()
114
+ # psql piped through `tr -d '\n'`; print with no trailing newline.
115
+ sys.stdout.write(c.get("suffix") if c else "")
116
+
117
+
118
+ def cmd_reddit_campaign_sample_rate(_args):
119
+ c = _active_reddit_campaign()
120
+ if not c:
121
+ sys.stdout.write("")
122
+ return
123
+ rate = c.get("sample_rate")
124
+ sys.stdout.write("1.000" if rate is None else str(rate))
125
+
126
+
127
+ def cmd_flagged_count(_args):
128
+ resp = api_get("/api/v1/dms/flagged")
129
+ print(_data(resp).get("count", 0))
130
+
131
+
132
+ def main():
133
+ p = argparse.ArgumentParser()
134
+ sub = p.add_subparsers(dest="cmd", required=True)
135
+
136
+ sp = sub.add_parser("pending")
137
+ sp.add_argument("--platform", default="")
138
+ sp.add_argument("--limit", type=int, default=30)
139
+ sp.set_defaults(func=cmd_pending)
140
+
141
+ sp = sub.add_parser("needs-reply")
142
+ sp.add_argument("--platform", default="")
143
+ sp.set_defaults(func=cmd_needs_reply)
144
+
145
+ sp = sub.add_parser("run-counts")
146
+ sp.add_argument("--platform", default="")
147
+ sp.add_argument("--since", type=int, required=True)
148
+ sp.set_defaults(func=cmd_run_counts)
149
+
150
+ sub.add_parser("summary").set_defaults(func=cmd_summary)
151
+ sub.add_parser("reddit-authors").set_defaults(func=cmd_reddit_authors)
152
+ sub.add_parser("reddit-campaign-suffix").set_defaults(func=cmd_reddit_campaign_suffix)
153
+ sub.add_parser("reddit-campaign-sample-rate").set_defaults(func=cmd_reddit_campaign_sample_rate)
154
+ sub.add_parser("flagged-count").set_defaults(func=cmd_flagged_count)
155
+
156
+ args = p.parse_args()
157
+ args.func(args)
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """dm_outreach_helper.py — shell-friendly entrypoints for the dm-outreach
3
+ {reddit,twitter,linkedin}.sh pipelines that used to inline `psql` calls.
4
+
5
+ Subcommands (all route through /api/v1/dms* on the website):
6
+
7
+ count --platform reddit --status pending
8
+ -> prints integer count to stdout (one line). Used by the bash
9
+ script's "DM_PENDING / SENT / STILL_PENDING" sentinels.
10
+
11
+ outreach-queue --platform reddit
12
+ -> prints JSON shape matching the legacy
13
+ `psql ... "SELECT json_agg(q) FROM ... JOIN replies ... JOIN posts ..."`
14
+ query: an array of dms rows joined with reply + post + a 60-day
15
+ other_engagement summary per author. Output is exactly the same
16
+ JSON the LLM prompt expects (DM_DATA variable in dm-outreach-*.sh).
17
+
18
+ patch --id 123 --status error --skip-reason send_unverified
19
+ -> PATCH /api/v1/dms/123. Replaces the bash-embedded
20
+ `psql ... "UPDATE dms SET status=..., skip_reason=..."` blocks
21
+ (the ones the LLM is told to run). Supports any combo of
22
+ --status / --skip-reason / --claude-session-id.
23
+
24
+ (NOTE: this still flips status freely. dm_send_log.py is the only
25
+ path that's allowed to set status='sent' with verification — DO
26
+ NOT use this `patch` subcommand to mark a DM as sent. The legacy
27
+ bash script's prompt was already careful about this; we preserve
28
+ that constraint.)
29
+
30
+ This script intentionally does NOT touch dm_conversation.py or the dms
31
+ DB schema directly. Everything goes through HTTP routes, no psycopg2.
32
+ """
33
+ import argparse
34
+ import json
35
+ import os
36
+ import sys
37
+
38
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
39
+ from http_api import api_get, api_patch
40
+
41
+
42
+ def _cmd_count(args):
43
+ query = {
44
+ "platform": args.platform,
45
+ "status": args.status,
46
+ "count_only": "true",
47
+ }
48
+ if args.target_project:
49
+ query["target_project"] = args.target_project
50
+ resp = api_get("/api/v1/dms", query=query)
51
+ data = (resp or {}).get("data") or {}
52
+ print(int(data.get("count") or 0))
53
+
54
+
55
+ def _cmd_outreach_queue(args):
56
+ query = {
57
+ "platform": args.platform,
58
+ "status": args.status,
59
+ "limit": args.limit,
60
+ "other_engagement_days": args.other_engagement_days,
61
+ }
62
+ resp = api_get("/api/v1/dms/outreach-queue", query=query)
63
+ data = (resp or {}).get("data") or {}
64
+ rows = data.get("rows") or []
65
+ # Mirror the legacy `SELECT json_agg(q) FROM (...) q;` output shape.
66
+ # The psql command returned a single JSON array (or empty string when
67
+ # zero rows). The LLM prompt expects an array literal it can read
68
+ # directly. Print [] when empty to match.
69
+ json.dump(rows, sys.stdout)
70
+ print("", file=sys.stdout)
71
+
72
+
73
+ def _cmd_patch(args):
74
+ if args.status == "sent":
75
+ # status='sent' must go through scripts/dm_send_log.py (verified
76
+ # outbound path). Anything else (error, skipped, queued, ...) is
77
+ # fine to flip from here.
78
+ print(
79
+ "ERROR: dm_outreach_helper.py patch refuses to set status=sent. "
80
+ "Use scripts/dm_send_log.py with --verified instead.",
81
+ file=sys.stderr,
82
+ )
83
+ sys.exit(2)
84
+
85
+ body: dict = {}
86
+ if args.status:
87
+ body["status"] = args.status
88
+ if args.skip_reason is not None:
89
+ body["skip_reason"] = args.skip_reason
90
+ if args.claude_session_id:
91
+ body["claude_session_id"] = args.claude_session_id
92
+ if args.conversation_status:
93
+ body["conversation_status"] = args.conversation_status
94
+
95
+ if not body:
96
+ print("ERROR: nothing to patch (no --status / --skip-reason / ...)",
97
+ file=sys.stderr)
98
+ sys.exit(2)
99
+
100
+ resp = api_patch(f"/api/v1/dms/{args.id}", body)
101
+ data = (resp or {}).get("data") or {}
102
+ dm = data.get("dm")
103
+ if dm:
104
+ print(f"PATCHED dm_id={dm.get('id')} status={dm.get('status')} "
105
+ f"skip_reason={dm.get('skip_reason')}")
106
+ else:
107
+ # Route returned no body (shouldn't happen on 200) — emit raw resp.
108
+ json.dump(resp, sys.stdout)
109
+ print("", file=sys.stdout)
110
+
111
+
112
+ def main():
113
+ p = argparse.ArgumentParser(description=__doc__,
114
+ formatter_class=argparse.RawDescriptionHelpFormatter)
115
+ sub = p.add_subparsers(dest="cmd", required=True)
116
+
117
+ pc = sub.add_parser("count", help="Print COUNT(*) for filtered dms")
118
+ pc.add_argument("--platform", required=True)
119
+ pc.add_argument("--status", default="pending")
120
+ pc.add_argument("--target-project", default=None)
121
+ pc.set_defaults(func=_cmd_count)
122
+
123
+ pq = sub.add_parser("outreach-queue",
124
+ help="Emit the join'd DM/reply/post JSON for the LLM prompt")
125
+ pq.add_argument("--platform", required=True)
126
+ pq.add_argument("--status", default="pending")
127
+ pq.add_argument("--limit", type=int, default=50)
128
+ pq.add_argument("--other-engagement-days", type=int, default=60)
129
+ pq.set_defaults(func=_cmd_outreach_queue)
130
+
131
+ pp = sub.add_parser("patch",
132
+ help="PATCH a dms row (status / skip_reason / etc.)")
133
+ pp.add_argument("--id", required=True, type=int)
134
+ pp.add_argument("--status", default=None,
135
+ help="New status (NOT 'sent' — use dm_send_log.py for that).")
136
+ pp.add_argument("--skip-reason", default=None,
137
+ help="Reason string (e.g. 'send_unverified', 'reddit_browser_busy').")
138
+ pp.add_argument("--conversation-status", default=None)
139
+ pp.add_argument("--claude-session-id", default=None)
140
+ pp.set_defaults(func=_cmd_patch)
141
+
142
+ args = p.parse_args()
143
+ args.func(args)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """dm_outreach_twitter_helper.py — small CLI wrapper used by
3
+ skill/dm-outreach-twitter.sh to replace the four direct `psql` one-liners
4
+ the script used to embed inline (pending count, outreach JSON aggregation,
5
+ MCP-failure recovery sweep, sent/still-pending summary counts).
6
+
7
+ Subcommands:
8
+ pending-count
9
+ -> GET /api/v1/dms/counts?platform=twitter (canonicalises to 'x')
10
+ -> prints the integer pending count
11
+
12
+ outreach-queue
13
+ -> GET /api/v1/dms/outreach-queue?platform=twitter&status=pending
14
+ -> prints the rows as a JSON ARRAY (mirrors the legacy
15
+ `SELECT json_agg(q) FROM (...) q` shape the bash prompt embeds)
16
+
17
+ recover-mcp --session-id UUID
18
+ -> POST /api/v1/dms/recover-mcp-failures { platform, claude_session_id }
19
+ -> prints the recovered_count integer
20
+
21
+ summary
22
+ -> GET /api/v1/dms/counts?platform=twitter
23
+ -> prints "<sent> <still_pending>" so the legacy two-variable capture
24
+ keeps working (SENT/STILL_PENDING in dm-outreach-twitter.sh).
25
+
26
+ Migrated 2026-05-18: removes 4 direct psql calls from
27
+ skill/dm-outreach-twitter.sh. The dms table stores Twitter rows with
28
+ platform='x' (scan_dm_candidates.py:219-220 normalises 'twitter' → 'x'
29
+ before INSERT); routes accept either form and the canonicalDmPlatform
30
+ helper rewrites the WHERE clauses uniformly.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import os
37
+ import sys
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+ from http_api import api_get, api_post # noqa: E402
41
+
42
+
43
+ def _counts_dict() -> dict[str, int]:
44
+ resp = api_get(
45
+ "/api/v1/dms/counts",
46
+ query={"platform": "twitter"},
47
+ )
48
+ rows = (resp.get("data") or {}).get("counts") or []
49
+ out: dict[str, int] = {}
50
+ for r in rows:
51
+ s = r.get("status")
52
+ if s is None:
53
+ continue
54
+ try:
55
+ out[str(s)] = int(r.get("count") or 0)
56
+ except (TypeError, ValueError):
57
+ out[str(s)] = 0
58
+ return out
59
+
60
+
61
+ def cmd_pending_count() -> int:
62
+ counts = _counts_dict()
63
+ sys.stdout.write(f"{int(counts.get('pending') or 0)}\n")
64
+ return 0
65
+
66
+
67
+ def cmd_summary() -> int:
68
+ counts = _counts_dict()
69
+ sent = int(counts.get("sent") or 0)
70
+ pending = int(counts.get("pending") or 0)
71
+ sys.stdout.write(f"{sent} {pending}\n")
72
+ return 0
73
+
74
+
75
+ def cmd_outreach_queue() -> int:
76
+ resp = api_get(
77
+ "/api/v1/dms/outreach-queue",
78
+ query={
79
+ "platform": "twitter",
80
+ "status": "pending",
81
+ "limit": 200,
82
+ "other_engagement_days": 60,
83
+ },
84
+ )
85
+ rows = (resp.get("data") or {}).get("rows") or []
86
+ # The legacy psql query returned an array (json_agg result); reshape
87
+ # to that same array shape. Each row already carries the embedded
88
+ # other_engagement array from the route's correlated subquery.
89
+ sys.stdout.write(json.dumps(rows))
90
+ sys.stdout.write("\n")
91
+ return 0
92
+
93
+
94
+ def cmd_recover_mcp(session_id: str) -> int:
95
+ resp = api_post(
96
+ "/api/v1/dms/recover-mcp-failures",
97
+ {"platform": "twitter", "claude_session_id": session_id},
98
+ )
99
+ d = resp.get("data") or {}
100
+ sys.stdout.write(f"{int(d.get('recovered_count') or 0)}\n")
101
+ return 0
102
+
103
+
104
+ def main() -> int:
105
+ ap = argparse.ArgumentParser(description="Helper for dm-outreach-twitter.sh")
106
+ sub = ap.add_subparsers(dest="cmd", required=True)
107
+
108
+ sub.add_parser("pending-count")
109
+ sub.add_parser("outreach-queue")
110
+ sub.add_parser("summary")
111
+
112
+ p_rec = sub.add_parser("recover-mcp")
113
+ p_rec.add_argument("--session-id", required=True)
114
+
115
+ args = ap.parse_args()
116
+
117
+ if args.cmd == "pending-count":
118
+ return cmd_pending_count()
119
+ if args.cmd == "summary":
120
+ return cmd_summary()
121
+ if args.cmd == "outreach-queue":
122
+ return cmd_outreach_queue()
123
+ if args.cmd == "recover-mcp":
124
+ return cmd_recover_mcp(args.session_id)
125
+ return 1
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(main())
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """Log a successful, verified DM send.
3
+
4
+ Usage:
5
+ dm_send_log.py --dm-id DM_ID --message TEXT --verified \
6
+ [--session-id UUID]
7
+
8
+ REQUIRES --verified. Without it the script refuses to flip status='sent'.
9
+ This is the gate against the prompt-driven "always mark sent" bug that
10
+ produced ~700 phantom rows in April 2026. The browser tool's send_dm /
11
+ compose_dm now returns ok=False when DOM verification fails; the LLM
12
+ running the outreach pipeline must only call this script when the tool
13
+ actually returned verified=true.
14
+ """
15
+ import argparse
16
+ import os
17
+ import subprocess
18
+ import sys
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+
22
+
23
+ def load_env():
24
+ env_path = "/Users/matthewdi/social-autoposter/.env"
25
+ if not os.path.exists(env_path):
26
+ return
27
+ for line in open(env_path):
28
+ line = line.strip()
29
+ if not line or line.startswith("#"):
30
+ continue
31
+ if "=" in line:
32
+ k, v = line.split("=", 1)
33
+ os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
34
+
35
+
36
+ def main():
37
+ parser = argparse.ArgumentParser(
38
+ description="Log a verified DM send (gates status='sent' on --verified)."
39
+ )
40
+ parser.add_argument("--dm-id", required=True, help="dms.id")
41
+ parser.add_argument("--message", required=True, help="DM body that was sent")
42
+ parser.add_argument(
43
+ "--verified",
44
+ action="store_true",
45
+ help="REQUIRED. Confirms the browser tool returned verified=true.",
46
+ )
47
+ parser.add_argument(
48
+ "--session-id",
49
+ default=os.environ.get("CLAUDE_SESSION_ID"),
50
+ help="claude_session_id UUID (defaults to $CLAUDE_SESSION_ID)",
51
+ )
52
+
53
+ # Back-compat: old call sites used positional dm_id + message.
54
+ # Detect that shape so we can refuse cleanly instead of crashing.
55
+ if len(sys.argv) >= 3 and not sys.argv[1].startswith("--"):
56
+ print(
57
+ "ERROR: dm_send_log.py now requires named flags. Call as:\n"
58
+ " dm_send_log.py --dm-id ID --message TEXT --verified",
59
+ file=sys.stderr,
60
+ )
61
+ sys.exit(2)
62
+
63
+ args = parser.parse_args()
64
+
65
+ if not args.verified:
66
+ print(
67
+ "ERROR: refusing to mark dm_id={} as sent without --verified.\n"
68
+ "The browser send_dm/compose_dm tool must return verified=true "
69
+ "first. If verification failed, mark the row as 'error' instead.".format(
70
+ args.dm_id
71
+ ),
72
+ file=sys.stderr,
73
+ )
74
+ sys.exit(3)
75
+
76
+ load_env()
77
+ import http_api
78
+ from version import read_version as read_autoposter_version
79
+ patch_body: dict = {"status": "sent", "our_dm_content": args.message}
80
+ if args.session_id:
81
+ patch_body["claude_session_id"] = args.session_id
82
+ # autoposter_version: stamp on the 'sent' transition so DM engagement
83
+ # (replies / bookings) can be attributed to the release of the autoposter
84
+ # code that drafted the message.
85
+ autoposter_version = read_autoposter_version()
86
+ if autoposter_version:
87
+ patch_body["autoposter_version"] = autoposter_version
88
+ http_api.api_patch(f"/api/v1/dms/{args.dm_id}", patch_body)
89
+
90
+ subprocess.run(
91
+ [
92
+ "python3",
93
+ "/Users/matthewdi/social-autoposter/scripts/dm_conversation.py",
94
+ "log-outbound",
95
+ "--dm-id",
96
+ str(args.dm_id),
97
+ "--content",
98
+ args.message,
99
+ "--verified",
100
+ ],
101
+ check=True,
102
+ )
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()