@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,682 @@
1
+ #!/usr/bin/env python3
2
+ """DM conversation tracker - log messages, query history, update state.
3
+
4
+ This is the central module for all DM conversation tracking. Every DM
5
+ interaction (outbound or inbound) should go through here.
6
+
7
+ Usage:
8
+ # Log an outbound message we sent
9
+ python3 scripts/dm_conversation.py log-outbound --dm-id 5 --content "hey, what stack..."
10
+
11
+ # Log an inbound message we received
12
+ python3 scripts/dm_conversation.py log-inbound --dm-id 5 --author tolley --content "I use React..."
13
+
14
+ # Show full conversation history for a DM
15
+ python3 scripts/dm_conversation.py history --dm-id 5
16
+
17
+ # Show all conversations with pending inbound (needs reply)
18
+ python3 scripts/dm_conversation.py pending
19
+
20
+ # Set chat URL for a conversation
21
+ python3 scripts/dm_conversation.py set-url --dm-id 5 --url "https://www.reddit.com/chat/room/..."
22
+
23
+ # Update conversation tier
24
+ python3 scripts/dm_conversation.py set-tier --dm-id 5 --tier 2
25
+
26
+ # Mark conversation status
27
+ python3 scripts/dm_conversation.py set-status --dm-id 5 --status converted
28
+
29
+ # Find DM by author name (fuzzy)
30
+ python3 scripts/dm_conversation.py find --author tolley
31
+
32
+ # Summary of all active conversations
33
+ python3 scripts/dm_conversation.py summary
34
+ """
35
+
36
+ import argparse
37
+ import json
38
+ import os
39
+ import re
40
+ import sys
41
+
42
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
43
+ import db as dbmod
44
+
45
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
46
+
47
+
48
+ def _valid_chat_url(platform, url):
49
+ """Return a cleaned chat_url or None.
50
+
51
+ The dashboard only treats it as an "open chat" link when it looks like a
52
+ real DM thread URL. Post URLs / profile URLs silently leak in when the
53
+ prompt passes the wrong variable, so we reject anything that is not the
54
+ per-platform DM-thread shape.
55
+ """
56
+ if not url:
57
+ return None
58
+ u = url.strip()
59
+ if not u:
60
+ return None
61
+ p = (platform or "").lower()
62
+ if p == "reddit":
63
+ if "/chat/room/" in u or "/message/messages/" in u:
64
+ return u
65
+ if "/room/!" in u and "/chat/room/!" not in u:
66
+ return u.replace("/room/!", "/chat/room/!", 1)
67
+ return None
68
+ if p in ("twitter", "x"):
69
+ if "/i/chat/" in u or "/messages/" in u:
70
+ return u
71
+ return None
72
+ if p == "linkedin":
73
+ if "/messaging/thread/" in u:
74
+ return u
75
+ return None
76
+ return u
77
+
78
+
79
+ def load_config():
80
+ if os.path.exists(CONFIG_PATH):
81
+ with open(CONFIG_PATH) as f:
82
+ return json.load(f)
83
+ return {}
84
+
85
+
86
+ def get_our_account(config, platform):
87
+ accounts = config.get("accounts", {})
88
+ if platform == "reddit":
89
+ return accounts.get("reddit", {}).get("username", "Deep_Ad1959")
90
+ elif platform == "linkedin":
91
+ return accounts.get("linkedin", {}).get("name", "Matthew Diakonov")
92
+ elif platform == "x":
93
+ # No hardcoded fallback: stamping a default handle on an outbound DM
94
+ # silently mis-attributes it to the repo owner. Resolve from config / env
95
+ # and fail loud if absent.
96
+ from account_resolver import resolve as _resolve_account
97
+ h = _resolve_account("twitter")
98
+ if not h:
99
+ raise RuntimeError(
100
+ "no Twitter handle configured (accounts.twitter.handle / "
101
+ "AUTOPOSTER_TWITTER_HANDLE); refusing to stamp a fallback account "
102
+ "on an outbound DM to avoid wrong-attribution. Run connect_x first.")
103
+ return h.lstrip("@")
104
+ return "unknown"
105
+
106
+
107
+ def _http_link_wrap_guard(dm_id, content):
108
+ """Pure-Python (no DB) link-wrap guard, mirroring log_outbound's pre-pass.
109
+
110
+ Returns True if an unwrapped project URL is present (caller must abort the
111
+ log and print already happened here). Returns False when content is clean
112
+ or the classifier could not load.
113
+ """
114
+ try:
115
+ from dm_short_links import _classify_url, _load_projects, _URL_RE, _TRAILING_PUNCT
116
+ _wrap_check_projects = _load_projects()
117
+ for m in _URL_RE.finditer(content or ""):
118
+ raw_url = m.group(0).rstrip(_TRAILING_PUNCT)
119
+ if re.search(r'/r/[a-z0-9]{4,32}(?:[/?#]|$)', raw_url, re.IGNORECASE):
120
+ continue
121
+ kind, matched = _classify_url(raw_url, _wrap_check_projects)
122
+ if kind != 'other':
123
+ print(f" LINK BLOCKED: DM #{dm_id} content contains unwrapped {kind} URL "
124
+ f"({raw_url[:80]}) for project {matched!r}. Re-send via the wrap-text "
125
+ f"helper (python3 scripts/dm_short_links.py wrap-text --dm-id {dm_id} "
126
+ f"--text '...').")
127
+ return True
128
+ except Exception as _wrap_err:
129
+ print(f" WARNING: link-wrap guard skipped due to error: {_wrap_err}", file=sys.stderr)
130
+ return False
131
+
132
+
133
+ def _http_log_outbound(args):
134
+ """DB-free log-outbound over the s4l.ai API.
135
+
136
+ Preserves log_outbound's behaviour for the LinkedIn/HTTP lane: --verified
137
+ gate, link-wrap guard, timeline gate, dedup guard, message insert,
138
+ conversation_status='active'. The reddit-only campaign suffix attribution
139
+ and dm_links.message_id backfill (driven by WRAP_MINTED_CODES, set only by
140
+ reddit_browser/twitter_browser) are no-ops on this lane because LinkedIn
141
+ never mints codes through a Python pre-pass; those rails run on the
142
+ DB-equipped machine. Returns the process exit behaviour via sys.exit on
143
+ block (matching the DB path's sys.exit(3))."""
144
+ import http_api
145
+
146
+ dm_id = args.dm_id
147
+ content = args.content
148
+
149
+ if not args.verified:
150
+ print(f" VERIFY BLOCKED: refusing to log outbound for DM #{dm_id} without "
151
+ f"--verified. Pass it only when the browser send tool returned verified=true.")
152
+ sys.exit(3)
153
+
154
+ if _http_link_wrap_guard(dm_id, content):
155
+ sys.exit(3)
156
+
157
+ resp = http_api.api_get(f"/api/v1/dms/{dm_id}", ok_on_404=True)
158
+ if resp.get("_not_found"):
159
+ print(f" ERROR: DM #{dm_id} not found")
160
+ sys.exit(3)
161
+ row = (resp.get("data") or {}).get("dm") or {}
162
+
163
+ # Timeline gate (mirror log_outbound:194-201).
164
+ cur_count = row.get("message_count") or 0
165
+ qual_status = row.get("qualification_status") or "pending"
166
+ icp_list = row.get("icp_matches") or []
167
+ if cur_count >= 3 and qual_status == "pending" and not icp_list:
168
+ print(f" TIMELINE BLOCKED: DM #{dm_id} is at msg {cur_count} with qualification_status=pending and empty icp_matches.")
169
+ print(f" Run Step 2.4 (set-icp-precheck for every project in $PROJECTS) before logging this outbound.")
170
+ print(f" If nothing in $PROJECTS plausibly fits this prospect, call set-qualification --status disqualified --notes 'reason' and retry.")
171
+ sys.exit(3)
172
+
173
+ # Dedup guard: block if the last message is already outbound.
174
+ msgs = (http_api.api_get(f"/api/v1/dms/{dm_id}/messages", {"limit": 1000}).get("data") or {}).get("messages") or []
175
+ if msgs and msgs[-1].get("direction") == "outbound":
176
+ print(f" DEDUP BLOCKED: Last message to {row.get('their_author')} (DM #{dm_id}) was already outbound. Skipping.")
177
+ sys.exit(3)
178
+
179
+ config = load_config()
180
+ author = args.author or get_our_account(config, row.get("platform"))
181
+ claude_session_id = os.environ.get("CLAUDE_SESSION_ID") or None
182
+
183
+ http_api.api_post(
184
+ f"/api/v1/dms/{dm_id}/messages",
185
+ {
186
+ "direction": "outbound",
187
+ "author": author,
188
+ "content": content,
189
+ "claude_session_id": claude_session_id,
190
+ "bump_to_needs_reply": False,
191
+ },
192
+ )
193
+ # log_outbound sets conversation_status='active' on every outbound.
194
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"conversation_status": "active"})
195
+
196
+ print(f" Logged outbound to {row.get('their_author')} (DM #{dm_id})")
197
+ return True
198
+
199
+
200
+ def _http_dispatch(args):
201
+ """Route DB-free commands through the s4l.ai HTTP API.
202
+
203
+ Returns True when the command was handled (caller should return), False
204
+ when the command is not yet wired for the no-DATABASE_URL lane (caller
205
+ falls through to the clear "needs DB" error). Each branch prints the exact
206
+ same stdout the DB path emits so downstream shell parsing is unchanged.
207
+ """
208
+ import http_api
209
+
210
+ cmd = args.command
211
+ dm_id = getattr(args, "dm_id", None)
212
+
213
+ if cmd == "mark-skipped":
214
+ http_api.api_patch(
215
+ f"/api/v1/dms/{dm_id}",
216
+ {"status": "skipped", "only_if_status": "pending", "skip_reason": args.reason},
217
+ )
218
+ print(f" Set status=skipped (reason: {args.reason}) for DM #{dm_id}")
219
+ return True
220
+
221
+ if cmd == "set-icp-precheck":
222
+ body = {"project": args.project, "label": args.label}
223
+ if args.notes is not None:
224
+ body["notes"] = args.notes
225
+ http_api.api_post(f"/api/v1/dms/{dm_id}/icp-precheck", body)
226
+ suffix = f" (notes: {args.notes[:60]}...)" if args.notes else ""
227
+ print(f" Upserted icp_matches[{args.project}]={args.label} for DM #{dm_id}{suffix}")
228
+ return True
229
+
230
+ if cmd == "set-tier":
231
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"tier": args.tier})
232
+ print(f" Set tier={args.tier} for DM #{dm_id}")
233
+ return True
234
+
235
+ if cmd == "set-status":
236
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"conversation_status": args.status})
237
+ print(f" Set conversation_status={args.status} for DM #{dm_id}")
238
+ return True
239
+
240
+ if cmd == "set-interest":
241
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"interest_level": args.interest})
242
+ print(f" Set interest_level={args.interest} for DM #{dm_id}")
243
+ return True
244
+
245
+ if cmd == "set-mode":
246
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"mode": args.mode})
247
+ print(f" Set mode={args.mode} for DM #{dm_id}")
248
+ return True
249
+
250
+ if cmd == "set-project":
251
+ body = {"project_name": args.project}
252
+ if getattr(args, "append", False):
253
+ body["target_projects_add"] = args.project
254
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", body)
255
+ extra = " (appended to target_projects)" if getattr(args, "append", False) else ""
256
+ print(f" Set project_name={args.project} for DM #{dm_id}{extra}")
257
+ return True
258
+
259
+ if cmd == "set-target-project":
260
+ http_api.api_patch(
261
+ f"/api/v1/dms/{dm_id}",
262
+ {"target_project": args.project, "target_projects_add": args.project},
263
+ )
264
+ print(f" Set target_project={args.project} for DM #{dm_id} (target_projects union extended)")
265
+ return True
266
+
267
+ if cmd == "set-qualification":
268
+ body = {"qualification_status": args.status}
269
+ if args.notes is not None:
270
+ body["qualification_notes"] = args.notes
271
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", body)
272
+ suffix = f" (notes: {args.notes[:60]}...)" if args.notes else ""
273
+ print(f" Set qualification_status={args.status} for DM #{dm_id}{suffix}")
274
+ return True
275
+
276
+ if cmd == "mark-booking-sent":
277
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"booking_link_sent_at_now": True})
278
+ print(f" Set booking_link_sent_at=NOW() for DM #{dm_id}")
279
+ return True
280
+
281
+ if cmd == "mark-inspected":
282
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"last_inspected_at_now": True})
283
+ print(f" Marked DM #{dm_id} inspected at NOW()")
284
+ return True
285
+
286
+ if cmd == "set-url":
287
+ resp = http_api.api_get(f"/api/v1/dms/{dm_id}", ok_on_404=True)
288
+ if resp.get("_not_found"):
289
+ print(f" ERROR: DM #{dm_id} not found")
290
+ return True
291
+ platform = ((resp.get("data") or {}).get("dm") or {}).get("platform")
292
+ clean = _valid_chat_url(platform, args.url)
293
+ if args.url and not clean:
294
+ print(f" ERROR: '{args.url[:120]}' is not a valid {platform} DM-thread URL; refusing to save.")
295
+ print(f" Expected shapes: reddit=/chat/room/!..., x=/i/chat/..., linkedin=/messaging/thread/...")
296
+ sys.exit(2)
297
+ http_api.api_patch(f"/api/v1/dms/{dm_id}", {"chat_url": clean})
298
+ print(f" Set chat_url for DM #{dm_id}")
299
+ return True
300
+
301
+ if cmd == "log-inbound":
302
+ body = {"direction": "inbound", "author": args.author, "content": args.content}
303
+ if getattr(args, "message_at", None):
304
+ body["message_at"] = args.message_at
305
+ if getattr(args, "event_id", None):
306
+ body["event_id"] = args.event_id
307
+ http_api.api_post(f"/api/v1/dms/{dm_id}/messages", body)
308
+ print(f" Logged inbound from {args.author} (DM #{dm_id})")
309
+ return True
310
+
311
+ if cmd == "log-outbound":
312
+ return _http_log_outbound(args)
313
+
314
+ if cmd == "ensure-dm":
315
+ body = {"platform": args.platform, "author": args.author}
316
+ if getattr(args, "chat_url", None):
317
+ body["chat_url"] = args.chat_url
318
+ if getattr(args, "lookback_hours", None) is not None:
319
+ body["lookback_hours"] = args.lookback_hours
320
+ resp = http_api.api_post("/api/v1/dms/ensure", body)
321
+ data = resp.get("data") or {}
322
+ new_id = data.get("dm_id")
323
+ print(f"DM_ID={new_id}")
324
+ if data.get("created"):
325
+ linked = data.get("linked_reply_id")
326
+ if linked:
327
+ print(f" created (linked to replies.id={linked})")
328
+ else:
329
+ print(" created (no matching replies row within lookback, reply_id/post_id NULL)")
330
+ else:
331
+ print(" existing")
332
+ return True
333
+
334
+ if cmd == "history":
335
+ resp = http_api.api_get(f"/api/v1/dms/{dm_id}", ok_on_404=True)
336
+ if resp.get("_not_found"):
337
+ print(f"DM #{dm_id} not found")
338
+ return True
339
+ dm = (resp.get("data") or {}).get("dm") or {}
340
+ print(f"=== DM #{dm.get('id')} with {dm.get('their_author')} [{dm.get('platform')}] ===")
341
+ print(f"Status: {dm.get('conversation_status')} Tier: {dm.get('tier')} Messages: {dm.get('message_count')}")
342
+ if dm.get("chat_url"):
343
+ print(f"Chat URL: {dm['chat_url']}")
344
+ if dm.get("comment_context"):
345
+ print(f"Original context: {dm['comment_context'][:200]}...")
346
+ print()
347
+ msgs_resp = http_api.api_get(f"/api/v1/dms/{dm_id}/messages")
348
+ msgs = (msgs_resp.get("data") or {}).get("messages") or []
349
+ for m in msgs:
350
+ arrow = ">>" if m.get("direction") == "outbound" else "<<"
351
+ ma = m.get("message_at") or ""
352
+ ts = str(ma)[:16].replace("T", " ") if ma else "?"
353
+ print(f" {arrow} [{ts}] {m.get('author')}: {m.get('content')}")
354
+ print()
355
+ return True
356
+
357
+ if cmd == "pending":
358
+ resp = http_api.api_get("/api/v1/dms/pending")
359
+ rows = (resp.get("data") or {}).get("pending") or []
360
+ if not rows:
361
+ print("No conversations needing reply.")
362
+ return True
363
+ print(f"=== {len(rows)} conversations need reply ===\n")
364
+ for r in rows:
365
+ tier_label = f"T{r['tier']}" if r.get("tier") else "T1"
366
+ ma = r.get("last_message_at") or ""
367
+ ts = str(ma)[5:16].replace("T", " ") if ma else "?"
368
+ last = (r.get("last_msg") or "")[:100]
369
+ print(f" DM #{r['id']} [{r.get('platform')}] {r.get('their_author')} ({tier_label}, {r.get('message_count')} msgs, last: {ts})")
370
+ print(f" Last: {last}")
371
+ if r.get("chat_url"):
372
+ print(f" URL: {r['chat_url']}")
373
+ print()
374
+ return True
375
+
376
+ if cmd == "show-flagged":
377
+ resp = http_api.api_get("/api/v1/dms/flagged")
378
+ rows = (resp.get("data") or {}).get("flagged") or []
379
+ if not rows:
380
+ print("No conversations flagged for human attention.")
381
+ return True
382
+ print(f"=== {len(rows)} conversations need HUMAN attention ===\n")
383
+ for r in rows:
384
+ fa = r.get("flagged_at") or ""
385
+ ts = str(fa)[5:16].replace("T", " ") if fa else "?"
386
+ last = (r.get("last_msg") or "")[:150]
387
+ print(f" DM #{r['id']} [{r.get('platform')}] {r.get('their_author')} (T{r.get('tier') or 1}, {r.get('message_count')} msgs)")
388
+ print(f" REASON: {r.get('human_reason')}")
389
+ print(f" Flagged: {ts}")
390
+ print(f" Last msg ({r.get('last_dir')}): {last}")
391
+ if r.get("chat_url"):
392
+ print(f" URL: {r['chat_url']}")
393
+ print()
394
+ return True
395
+
396
+ if cmd == "flag-human":
397
+ resp = http_api.api_post(
398
+ f"/api/v1/dms/{dm_id}/flag-human", {"reason": args.reason}, ok_on_conflict=True
399
+ )
400
+ data = resp.get("data") or {}
401
+ if data.get("skipped"):
402
+ print(f" SKIP flag-human: DM #{dm_id} last message is OUTBOUND. We already replied; ball is in their court. Reason was: {args.reason}")
403
+ return True
404
+ print(f" FLAGGED DM #{dm_id} for human attention: {args.reason}")
405
+ if data.get("email_sent"):
406
+ print(f" Escalation email sent for DM #{dm_id}")
407
+ else:
408
+ print(f" WARNING: escalation email not sent for DM #{dm_id} (no RESEND_API_KEY on server, or send failed)")
409
+ return True
410
+
411
+ if cmd == "backfill-urls":
412
+ records = _load_records_arg(args)
413
+ resp = http_api.api_post(
414
+ "/api/v1/dms/backfill-urls",
415
+ {"platform": args.platform, "records": records},
416
+ )
417
+ stats = (resp.get("data") or {}).get("stats") or {}
418
+ print(f" backfill-urls [{args.platform}]: updated={stats.get('updated', 0)} "
419
+ f"already_set={stats.get('skipped_already_set', 0)} no_match={stats.get('no_match', 0)} "
420
+ f"invalid={stats.get('skipped_invalid', 0)} ambiguous={stats.get('ambiguous', 0)}")
421
+ return True
422
+
423
+ if cmd == "filter-inbox":
424
+ records = _load_records_arg(args)
425
+ resp = http_api.api_post(
426
+ "/api/v1/dms/filter-inbox",
427
+ {"platform": args.platform, "records": records},
428
+ )
429
+ data = resp.get("data") or {}
430
+ keep = data.get("keep") or []
431
+ counters = data.get("counters") or {}
432
+ norm = "x" if args.platform in ("twitter", "x") else args.platform
433
+ total_in = data.get("in", len(records))
434
+ total_keep = data.get("kept", len(keep))
435
+ print(
436
+ f" filter-inbox [{norm}]: in={total_in} kept={total_keep} "
437
+ f"(unread={counters.get('kept_unread', 0)}, "
438
+ f"no_db_row={counters.get('kept_no_db_row', 0)}, "
439
+ f"ambiguous={counters.get('kept_ambiguous', 0)}) "
440
+ f"skipped={total_in - total_keep} "
441
+ f"(is_from_us={counters.get('skip_is_from_us', 0)}, "
442
+ f"we_replied_after={counters.get('skip_we_replied_after', 0)}, "
443
+ f"recently_inspected={counters.get('skip_recently_inspected', 0)}, "
444
+ f"needs_human={counters.get('skip_needs_human', 0)}, "
445
+ f"closed={counters.get('skip_closed', 0)}, "
446
+ f"invalid_url={counters.get('skip_invalid_url', 0)})",
447
+ file=sys.stderr,
448
+ )
449
+ print(json.dumps(keep, default=str))
450
+ return True
451
+
452
+ if cmd == "find":
453
+ resp = http_api.api_get("/api/v1/dms/find", query={"author": args.author})
454
+ rows = (resp.get("data") or {}).get("matches") or []
455
+ if not rows:
456
+ print(f"No DMs found matching '{args.author}'")
457
+ return True
458
+ for r in rows:
459
+ ma = r.get("last_message_at") or ""
460
+ # API returns ISO 'YYYY-MM-DDTHH:MM:...'; DB path printed '%m/%d %H:%M'.
461
+ ts = (str(ma)[5:16].replace("T", " ").replace("-", "/")) if ma else "never"
462
+ print(f" DM #{r['id']} [{r.get('platform')}] {r.get('their_author')} - "
463
+ f"{r.get('status')}/{r.get('conversation_status')} T{r.get('tier') or 1} "
464
+ f"({r.get('message_count')} msgs, last: {ts})")
465
+ if r.get("chat_url"):
466
+ print(f" URL: {r['chat_url']}")
467
+ return True
468
+
469
+ if cmd == "summary":
470
+ resp = http_api.api_get("/api/v1/dms/summary")
471
+ s = (resp.get("data") or {}).get("summary") or {}
472
+ print("=== DM Pipeline Summary ===")
473
+ print(f" Conversations: {s.get('total', 0)} total ({s.get('sent', 0)} sent, {s.get('skipped', 0)} skipped)")
474
+ print(f" Unique authors: {s.get('unique_authors', 0)}")
475
+ print(f" Status: {s.get('needs_reply', 0)} needs_reply, {s.get('active', 0)} active, "
476
+ f"{s.get('converted', 0)} converted, {s.get('stale', 0)} stale")
477
+ print(f" Tiers: {s.get('tier2', 0)} at T2, {s.get('tier3', 0)} at T3")
478
+ print(f" Messages: {s.get('total_messages', 0)} total ({s.get('outbound', 0)} outbound, "
479
+ f"{s.get('inbound', 0)} inbound)")
480
+ print(f" Reply rate: {s.get('conversations_with_replies', 0)}/{s.get('sent', 0)} "
481
+ f"conversations have inbound replies")
482
+ print()
483
+ return True
484
+
485
+ if cmd == "send-escalation-email":
486
+ resp = http_api.api_post(
487
+ f"/api/v1/dms/{dm_id}/send-escalation-email", {}, ok_on_404=True
488
+ )
489
+ if resp.get("_not_found"):
490
+ print(f"ERROR: DM #{dm_id} not found")
491
+ return True
492
+ data = resp.get("data") or {}
493
+ if data.get("status_warning"):
494
+ print(f"WARNING: DM #{dm_id} is '{data.get('conversation_status')}', not 'needs_human'. Sending anyway.")
495
+ if data.get("email_sent"):
496
+ print(f" Escalation email sent for DM #{dm_id}")
497
+ else:
498
+ print(f" WARNING: escalation email not sent for DM #{dm_id} (no RESEND_API_KEY on server, or send failed)")
499
+ return True
500
+
501
+ return False
502
+
503
+
504
+ def _load_records_arg(args):
505
+ """Read a JSON array of records from --file or stdin for the bulk commands
506
+ (backfill-urls, filter-inbox), matching the DB-path's input handling so the
507
+ HTTP lane accepts the exact same scanner dumps. Returns a list (possibly
508
+ empty); exits 2 on unparseable input."""
509
+ raw = open(args.file).read() if getattr(args, "file", None) else sys.stdin.read()
510
+ try:
511
+ records = json.loads(raw)
512
+ except Exception as e:
513
+ print(f"ERROR: could not parse JSON input: {e}", file=sys.stderr)
514
+ sys.exit(2)
515
+ if isinstance(records, dict):
516
+ for k in ("conversations", "threads", "dms", "items"):
517
+ if k in records and isinstance(records[k], list):
518
+ return records[k]
519
+ if records.get("ok") is False:
520
+ return []
521
+ if not isinstance(records, list):
522
+ print("ERROR: expected a JSON array of records", file=sys.stderr)
523
+ sys.exit(2)
524
+ return records
525
+
526
+
527
+ def main():
528
+ parser = argparse.ArgumentParser(description="DM conversation tracker")
529
+ sub = parser.add_subparsers(dest="command")
530
+
531
+ p_out = sub.add_parser("log-outbound", help="Log outbound message")
532
+ p_out.add_argument("--dm-id", type=int, required=True)
533
+ p_out.add_argument("--content", required=True)
534
+ p_out.add_argument("--author")
535
+ p_out.add_argument(
536
+ "--verified",
537
+ action="store_true",
538
+ help="REQUIRED. Confirms the browser send_dm/compose_dm tool returned verified=true.",
539
+ )
540
+
541
+ p_ensure = sub.add_parser("ensure-dm",
542
+ help="Return dm_id for (platform, author), creating the row and auto-linking reply_id/post_id from the most recent matching replies row. Prints DM_ID=<n> on stdout.")
543
+ p_ensure.add_argument("--platform", required=True, choices=["reddit", "linkedin", "x", "twitter"])
544
+ p_ensure.add_argument("--author", required=True)
545
+ p_ensure.add_argument("--chat-url", default=None,
546
+ help="Optional chat URL to stamp on the DM row (set only if currently NULL).")
547
+ p_ensure.add_argument("--lookback-hours", type=int, default=720,
548
+ help="How far back to search for a matching replies row when auto-linking (default 720h = 30d).")
549
+
550
+ p_in = sub.add_parser("log-inbound", help="Log inbound message")
551
+ p_in.add_argument("--dm-id", type=int, required=True)
552
+ p_in.add_argument("--author", required=True)
553
+ p_in.add_argument("--content", required=True)
554
+ p_in.add_argument("--message-at", help="ISO timestamp (platform-provided); falls back to NOW() if omitted.")
555
+ p_in.add_argument("--event-id", help="Platform-native unique message id (e.g., Matrix $... event_id). When supplied, dedup is by event_id instead of content match.")
556
+
557
+ p_hist = sub.add_parser("history", help="Show conversation history")
558
+ p_hist.add_argument("--dm-id", type=int, required=True)
559
+
560
+ sub.add_parser("pending", help="Show conversations needing reply")
561
+
562
+ p_find = sub.add_parser("find", help="Find DM by author")
563
+ p_find.add_argument("--author", required=True)
564
+
565
+ sub.add_parser("summary", help="Pipeline summary")
566
+
567
+ p_url = sub.add_parser("set-url", help="Set chat URL")
568
+ p_url.add_argument("--dm-id", type=int, required=True)
569
+ p_url.add_argument("--url", required=True)
570
+
571
+ p_backfill = sub.add_parser("backfill-urls",
572
+ help=("Bulk-stamp chat_url onto orphan dms rows from a scanner JSON dump. "
573
+ "Input: a JSON array of {author|handle, chat_url|thread_url} on stdin or --file."))
574
+ p_backfill.add_argument("--platform", required=True, choices=["reddit", "linkedin", "x", "twitter"])
575
+ p_backfill.add_argument("--file", default=None,
576
+ help="Path to JSON file. If omitted, reads from stdin.")
577
+
578
+ p_filter = sub.add_parser("filter-inbox",
579
+ help=("Filter a sidebar scan dump down to threads that need inspection. "
580
+ "Combines sidebar signals (is_from_us, has_unread, time) with the "
581
+ "DB's last outbound message_at to drop threads where we already "
582
+ "sent the most recent message. "
583
+ "Input: JSON array on stdin or --file. "
584
+ "Output: filtered JSON array on stdout, summary on stderr."))
585
+ p_filter.add_argument("--platform", required=True, choices=["reddit", "linkedin", "x", "twitter"])
586
+ p_filter.add_argument("--file", default=None,
587
+ help="Path to JSON file. If omitted, reads from stdin.")
588
+
589
+ p_inspect = sub.add_parser("mark-inspected",
590
+ help=("Stamp NOW() onto dms.last_inspected_at after a read-conversation "
591
+ "call confirmed there is no new content to log. The next "
592
+ "filter-inbox run will skip this thread for 24h unless a fresh "
593
+ "outbound or inbound is logged in the meantime."))
594
+ p_inspect.add_argument("--dm-id", type=int, required=True)
595
+
596
+ p_tier = sub.add_parser("set-tier", help="Set conversation tier")
597
+ p_tier.add_argument("--dm-id", type=int, required=True)
598
+ p_tier.add_argument("--tier", type=int, required=True, choices=[1, 2, 3])
599
+
600
+ p_status = sub.add_parser("set-status", help="Set conversation status")
601
+ p_status.add_argument("--dm-id", type=int, required=True)
602
+ p_status.add_argument("--status", required=True,
603
+ choices=["active", "needs_reply", "stale", "converted", "closed", "needs_human"])
604
+
605
+ p_interest = sub.add_parser("set-interest", help="Set prospect interest level for product/topic")
606
+ p_interest.add_argument("--dm-id", type=int, required=True)
607
+ p_interest.add_argument("--interest", required=True,
608
+ choices=["no_response", "general_discussion", "cold", "warm", "hot", "declined", "not_our_prospect"])
609
+
610
+ p_mode = sub.add_parser("set-mode", help="Set per-turn conversational posture (rapport vs pitch). Reversible.")
611
+ p_mode.add_argument("--dm-id", type=int, required=True)
612
+ p_mode.add_argument("--mode", required=True, choices=["rapport", "pitch"])
613
+
614
+ p_flag = sub.add_parser("flag-human", help="Flag conversation for human attention")
615
+ p_flag.add_argument("--dm-id", type=int, required=True)
616
+ p_flag.add_argument("--reason", required=True)
617
+
618
+ sub.add_parser("show-flagged", help="Show conversations needing human attention")
619
+
620
+ p_resend = sub.add_parser("send-escalation-email",
621
+ help="Re-send the escalation email for an already-flagged DM (for testing / manual retry)")
622
+ p_resend.add_argument("--dm-id", type=int, required=True)
623
+
624
+ p_proj = sub.add_parser("set-project", help="Set project_name (project we recommended)")
625
+ p_proj.add_argument("--dm-id", type=int, required=True)
626
+ p_proj.add_argument("--project", required=True)
627
+ p_proj.add_argument("--append", action="store_true",
628
+ help="Also add to target_projects[] (the union of pursued projects)")
629
+
630
+ p_tproj = sub.add_parser("set-target-project",
631
+ help="Set primary target_project AND extend target_projects[] (always)")
632
+ p_tproj.add_argument("--dm-id", type=int, required=True)
633
+ p_tproj.add_argument("--project", required=True)
634
+ p_tproj.add_argument("--append", action="store_true",
635
+ help="Explicit caller intent (semantic no-op: union always grows)")
636
+
637
+ p_qual = sub.add_parser("set-qualification", help="Set qualification_status and optional notes")
638
+ p_qual.add_argument("--dm-id", type=int, required=True)
639
+ p_qual.add_argument("--status", required=True,
640
+ choices=["pending", "asked", "answered", "qualified", "disqualified"])
641
+ p_qual.add_argument("--notes", default=None)
642
+
643
+ p_book = sub.add_parser("mark-booking-sent", help="Record that a booking link was shared")
644
+ p_book.add_argument("--dm-id", type=int, required=True)
645
+
646
+ p_skip = sub.add_parser("mark-skipped", help="Skip a pending outreach DM (sets status=skipped). No-op on non-pending rows.")
647
+ p_skip.add_argument("--dm-id", type=int, required=True)
648
+ p_skip.add_argument("--reason", required=True)
649
+
650
+ p_icp = sub.add_parser("set-icp-precheck", help="Upsert per-project ICP verdict into icp_matches array (no filter)")
651
+ p_icp.add_argument("--dm-id", type=int, required=True)
652
+ p_icp.add_argument("--label", required=True,
653
+ choices=["icp_match", "icp_miss", "disqualified", "unknown"])
654
+ p_icp.add_argument("--project", required=True,
655
+ help="Project name from config.json (e.g., 'mk0r', 'Assrt')")
656
+ p_icp.add_argument("--notes", default=None)
657
+
658
+ args = parser.parse_args()
659
+
660
+ if not args.command:
661
+ parser.print_help()
662
+ return
663
+
664
+ dbmod.load_env()
665
+
666
+ # HTTP-only lane: every command routes through the s4l.ai HTTP API. The
667
+ # direct-Postgres lane was removed 2026-06-01 — there is NO database-driven
668
+ # path any more, not as primary, not as fallback. DATABASE_URL, if present
669
+ # in the environment, is deliberately ignored; all reads/writes go through
670
+ # _http_dispatch against /api/v1/*.
671
+ if _http_dispatch(args):
672
+ return
673
+ print(
674
+ f"ERROR: '{args.command}' is not wired for the HTTP API lane. "
675
+ f"Extend _http_dispatch in dm_conversation.py — there is no DB fallback.",
676
+ file=sys.stderr,
677
+ )
678
+ sys.exit(1)
679
+
680
+
681
+ if __name__ == "__main__":
682
+ main()