@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,211 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ log_twitter_skips.py
4
+
5
+ Writes Phase 2b skip decisions back to twitter_candidates so we can audit why
6
+ each pre-scored candidate was rejected without ever posting.
7
+
8
+ Input shape (stdin or --file):
9
+
10
+ {
11
+ "skips": [
12
+ {"candidate_id": 1234, "reason": "off-topic for Mediar"},
13
+ {"candidate_id": 1235, "reason": "thread is toxic crypto promo",
14
+ "proposed_excludes": ["cricket", "kohli", "ipl"]}
15
+ ]
16
+ }
17
+
18
+ Or a bare list of skip objects (same fields).
19
+
20
+ Behavior per row:
21
+ UPDATE twitter_candidates
22
+ SET status = 'skipped',
23
+ skip_reason = <reason, trimmed to 500 chars>,
24
+ skipped_at = NOW()
25
+ WHERE id = <candidate_id>
26
+ AND status = 'pending';
27
+
28
+ Optional `proposed_excludes` (per skip): each term is fed into
29
+ project_excludes.propose() with platform='twitter' and project read from the
30
+ candidate's matched_project column. Validation, reservation guards, and the
31
+ distinct-batch activation gate all live in project_excludes.py — this script
32
+ just forwards the proposals.
33
+
34
+ Pending guard prevents clobbering rows Phase 2b-post already flipped to
35
+ 'posted', or rows Phase 0 will salvage on the next cycle. We deliberately do
36
+ NOT touch rows Claude omitted from BOTH chosen and rejected arrays; those are
37
+ treated as "not reviewed" and stay pending so the salvage path can re-judge
38
+ them next cycle.
39
+
40
+ Exit codes:
41
+ 0 = ok (even if zero rows updated; script is idempotent)
42
+ 1 = malformed input or DB error
43
+ """
44
+
45
+ import argparse
46
+ import json
47
+ import os
48
+ import sys
49
+
50
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
51
+ import project_excludes as pe_mod # noqa: E402
52
+ from http_api import api_patch # noqa: E402
53
+
54
+
55
+ REASON_MAX = 500
56
+ EXCLUDES_PER_SKIP_CAP = 3 # cap proposed_excludes per rejected entry
57
+
58
+
59
+ def _coerce_payload(raw):
60
+ """Accept either {"skips": [...]} or a bare list."""
61
+ if isinstance(raw, list):
62
+ return raw
63
+ if isinstance(raw, dict):
64
+ skips = raw.get("skips")
65
+ if isinstance(skips, list):
66
+ return skips
67
+ return []
68
+
69
+
70
+ def main():
71
+ parser = argparse.ArgumentParser()
72
+ parser.add_argument("--file", help="Read skip JSON from this file instead of stdin")
73
+ parser.add_argument(
74
+ "--require-batch-id",
75
+ help="If set, only update candidates whose batch_id matches this value (extra safety)",
76
+ )
77
+ args = parser.parse_args()
78
+
79
+ if args.file:
80
+ with open(args.file) as f:
81
+ raw = json.load(f)
82
+ else:
83
+ text = sys.stdin.read().strip()
84
+ if not text:
85
+ print("log_twitter_skips: empty stdin; nothing to do")
86
+ return 0
87
+ raw = json.loads(text)
88
+
89
+ skips = _coerce_payload(raw)
90
+ if not skips:
91
+ print("log_twitter_skips: no skip entries; nothing to do")
92
+ return 0
93
+
94
+ updated = 0
95
+ no_match = 0
96
+ bad = 0
97
+ seen_ids = set()
98
+ excludes_pending = [] # collect (candidate_id, project, term, batch_id, reason) tuples
99
+
100
+ for entry in skips:
101
+ if not isinstance(entry, dict):
102
+ bad += 1
103
+ continue
104
+
105
+ cid = entry.get("candidate_id")
106
+ try:
107
+ cid = int(cid)
108
+ except (TypeError, ValueError):
109
+ bad += 1
110
+ continue
111
+
112
+ # Dedupe within this batch in case the model emits the same id twice.
113
+ if cid in seen_ids:
114
+ continue
115
+ seen_ids.add(cid)
116
+
117
+ reason = (entry.get("reason") or "").strip()
118
+ if not reason:
119
+ reason = "unspecified"
120
+ if len(reason) > REASON_MAX:
121
+ reason = reason[: REASON_MAX - 1] + "…"
122
+
123
+ # Server-side WHERE: status='pending' (+ optional batch_id guard) lives
124
+ # inside /api/v1/twitter-candidates/by-id action=mark_skipped. 404 is
125
+ # the "row not pending / batch mismatch" signal; we don't fail the
126
+ # whole batch on it.
127
+ body = {
128
+ "id": cid,
129
+ "action": "mark_skipped",
130
+ "reason": reason,
131
+ }
132
+ if args.require_batch_id:
133
+ body["require_batch_id"] = args.require_batch_id
134
+ resp = api_patch(
135
+ "/api/v1/twitter-candidates/by-id",
136
+ body,
137
+ ok_on_404=True,
138
+ )
139
+ if resp.get("_not_found"):
140
+ no_match += 1
141
+ row_match = False
142
+ else:
143
+ updated += 1
144
+ row_match = True
145
+
146
+ # Stage proposed_excludes for THIS skip (only if status flipped to skipped).
147
+ # The PATCH response carries the full updated row, so we extract
148
+ # matched_project + batch_id from it instead of issuing a second GET.
149
+ proposed = entry.get("proposed_excludes")
150
+ if proposed and isinstance(proposed, list) and row_match:
151
+ data = resp.get("data") or {}
152
+ cand_row = data.get("candidate") or {}
153
+ project = cand_row.get("matched_project")
154
+ cand_batch = cand_row.get("batch_id") or args.require_batch_id
155
+ if project:
156
+ for term in proposed[:EXCLUDES_PER_SKIP_CAP]:
157
+ excludes_pending.append((cid, project, term, cand_batch, reason))
158
+
159
+ # Persist proposed excludes via project_excludes.propose(). Each call has
160
+ # its own DB connection (cheap, the volume is tiny: <=POST_LIMIT*EXCLUDES_PER_SKIP_CAP per cycle).
161
+ pe_inserted = 0
162
+ pe_bumped = 0
163
+ pe_dup = 0
164
+ pe_rejected_invalid = 0
165
+ pe_rejected_reserved = 0
166
+ for cid, project, term, batch_id, reason in excludes_pending:
167
+ try:
168
+ out = pe_mod.propose(
169
+ platform="twitter",
170
+ project=project,
171
+ term=term,
172
+ candidate_id=cid,
173
+ batch_id=batch_id,
174
+ reason=reason,
175
+ )
176
+ except Exception as e:
177
+ print(f"log_twitter_skips: propose error for {project}/{term}: {e}", file=sys.stderr)
178
+ continue
179
+ action = out.get("action")
180
+ if action == "inserted":
181
+ pe_inserted += 1
182
+ elif action == "bumped":
183
+ pe_bumped += 1
184
+ elif action == "duplicate_batch":
185
+ pe_dup += 1
186
+ elif action == "rejected_invalid":
187
+ pe_rejected_invalid += 1
188
+ elif action == "rejected_reserved":
189
+ pe_rejected_reserved += 1
190
+
191
+ print(
192
+ f"log_twitter_skips: updated={updated} no_match={no_match} bad_entries={bad} input={len(skips)}"
193
+ )
194
+ if excludes_pending:
195
+ print(
196
+ f"log_twitter_skips: excludes proposed={len(excludes_pending)} "
197
+ f"inserted={pe_inserted} bumped={pe_bumped} dup_batch={pe_dup} "
198
+ f"invalid={pe_rejected_invalid} reserved={pe_rejected_reserved}"
199
+ )
200
+ return 0
201
+
202
+
203
+ if __name__ == "__main__":
204
+ try:
205
+ sys.exit(main())
206
+ except json.JSONDecodeError as e:
207
+ print(f"log_twitter_skips: input is not valid JSON: {e}", file=sys.stderr)
208
+ sys.exit(1)
209
+ except Exception as e:
210
+ print(f"log_twitter_skips: error: {e}", file=sys.stderr)
211
+ sys.exit(1)
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Look up one of our posts by platform-native ID (tweet_id / activity_id).
3
+
4
+ Used by engage-twitter.sh and engage-linkedin.sh after the engage agent
5
+ navigates a thread, extracts the parent post ID, and needs to resolve which
6
+ project that post belongs to (so it can override replies.project_name and
7
+ draft in the right voice). Replaces the per-prompt OUR_POSTS_INDEX blob
8
+ that was costing 360-573 KB per engage prompt.
9
+
10
+ Usage:
11
+ python3 scripts/lookup_post.py twitter <tweet_id>
12
+ python3 scripts/lookup_post.py linkedin <activity_id>
13
+
14
+ Output (JSON, single line):
15
+ {"project": "fazm", "our_content": "...full text...", "thread_url": "..."}
16
+
17
+ If no match in the last 30 days of active posts:
18
+ {"project": null}
19
+
20
+ Migrated 2026-06-01 from raw psycopg2 (db.get_conn) to the s4l.ai HTTP API
21
+ (GET /api/v1/posts/lookup?platform=&post_id=). The platform-native id regex
22
+ match (twitter /status/<id>, linkedin urn:li:activity:<id>) now runs server-
23
+ side; the PLATFORM_PATTERNS table is mirrored there. Runs on a machine with
24
+ no DATABASE_URL.
25
+ """
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+
32
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33
+ from http_api import api_get
34
+
35
+
36
+ # Mirrored server-side in /api/v1/posts/lookup (ID_PATTERNS). Kept here only
37
+ # for input validation / unknown-platform rejection before the round trip.
38
+ PLATFORM_PATTERNS = {
39
+ "twitter": r"/status/{id}([^0-9]|$)",
40
+ "x": r"/status/{id}([^0-9]|$)",
41
+ "linkedin": r"urn:li:activity:{id}([^0-9]|$)",
42
+ }
43
+
44
+
45
+ def lookup(platform, post_id):
46
+ if platform.lower() not in PLATFORM_PATTERNS:
47
+ return {"project": None, "error": f"unknown platform: {platform}"}
48
+
49
+ if not re.fullmatch(r"[0-9]+", post_id):
50
+ return {"project": None, "error": "post_id must be digits"}
51
+
52
+ resp = api_get(
53
+ "/api/v1/posts/lookup",
54
+ {"platform": platform.lower(), "post_id": post_id},
55
+ )
56
+ post = (resp.get("data") or {}).get("post")
57
+ if not post:
58
+ return {"project": None}
59
+
60
+ return {
61
+ "project": post.get("project_name"),
62
+ "our_content": post.get("our_content"),
63
+ "thread_url": post.get("thread_url"),
64
+ "posted_at": post.get("posted_at"),
65
+ }
66
+
67
+
68
+ def main():
69
+ if len(sys.argv) != 3:
70
+ print(__doc__, file=sys.stderr)
71
+ sys.exit(2)
72
+ platform, post_id = sys.argv[1], sys.argv[2]
73
+ result = lookup(platform, post_id)
74
+ print(json.dumps(result))
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+ """Stamp web_chat_threads.processed_at = NOW() for a thread (HTTP-only).
3
+
4
+ POST /api/v1/web-chat/threads/<thread_id>/processed. Called by
5
+ skill/check-web-chats.sh at the END of a successful Claude session (exit code
6
+ 0), regardless of whether Claude replied or skipped. This is the idempotency
7
+ gate the recovery query in /api/v1/web-chat/unread uses to avoid re-spawning
8
+ Claude on threads that have already been handled.
9
+
10
+ Usage:
11
+ python3 mark_web_chat_processed.py <thread_id>
12
+ """
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+ from http_api import api_post
20
+
21
+
22
+ def main():
23
+ parser = argparse.ArgumentParser()
24
+ parser.add_argument("thread_id")
25
+ args = parser.parse_args()
26
+
27
+ api_post(f"/api/v1/web-chat/threads/{args.thread_id}/processed", {})
28
+ print(f"marked thread {args.thread_id} processed_at=NOW()")
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """mcp_lock_proxy.py — heartbeat wrapper for browser-MCP servers.
3
+
4
+ Spawns a real MCP server (e.g. `npx @playwright/mcp@latest`) as a stdio
5
+ subprocess and proxies JSON-RPC traffic between Claude and that server.
6
+ Whenever a `tools/call` request crosses the wire, the wrapper pushes the
7
+ matching `reddit_browser_lock.py` lease forward by `--ttl` seconds.
8
+
9
+ Why this exists
10
+ ---------------
11
+ Pre-2026-05-08 the reddit-browser lock was held for the full duration the
12
+ agent decided to "keep" it. If the agent forgot to call `release`, crashed,
13
+ or did 5+ minutes of non-browser work (page-gen, sleeps, DB updates), the
14
+ lock leaked and every peer reddit pipeline blocked behind a Chrome that
15
+ nobody was using. The fix has two halves:
16
+
17
+ 1. The lock now has a `expires_at` lease field (see `reddit_browser_lock.py`).
18
+ If `now() > expires_at`, peers steal it. Default lease = 90s (≫ p99 of
19
+ real reddit-agent MCP call durations, which is 30s).
20
+ 2. This wrapper renews the lease on every actual MCP browser call. So as
21
+ long as real browser work is happening the lease stays alive; the moment
22
+ it stops, the lease expires within 90s and peers proceed automatically.
23
+
24
+ Heartbeat strategy
25
+ ------------------
26
+ - On every JSON-RPC `tools/call` we see, fire `reddit_browser_lock.py heartbeat`
27
+ in a background thread (so we never block the request).
28
+ - On every response that matches a pending request id, fire heartbeat again.
29
+ - A daemon thread also fires a heartbeat every 30s while at least one request
30
+ is in flight. This covers the rare 5-min `browser_close` / `browser_tabs`
31
+ outliers without bloating the lease window for the common case.
32
+
33
+ Failure modes
34
+ -------------
35
+ - If the lock is currently held by a different owner, heartbeat returns
36
+ `HELD_BY_OTHER` and silently no-ops. We don't try to "fix" it; the actual
37
+ acquire/release logic is the source of truth.
38
+ - If the lock isn't held at all, heartbeat returns `NOT_HELD` and silently
39
+ no-ops. (Browser activity outside the lock is a separate prompt-discipline
40
+ bug; this wrapper isn't where we enforce it.)
41
+ - Heartbeat shells out to a short-lived python3 process. If it hangs or fails,
42
+ the timeout is 5s and we just drop the heartbeat. Worst case: a single MCP
43
+ call doesn't extend the lease — usually fine because subsequent calls
44
+ re-extend it; if it really IS the only call in a window, lease expires
45
+ exactly as the design intends.
46
+
47
+ Args
48
+ ----
49
+ mcp_lock_proxy.py [--lock-name reddit-browser] [--ttl 90] -- <real mcp cmd...>
50
+
51
+ Or via env:
52
+ BROWSER_LOCK_NAME=reddit-browser
53
+ BROWSER_LOCK_TTL=90
54
+ BROWSER_LOCK_SCRIPT=/path/to/reddit_browser_lock.py
55
+
56
+ Notes
57
+ -----
58
+ The proxy must be transparent to the MCP protocol. We never modify, drop,
59
+ or reorder messages. We only inspect them to decide whether to fire a
60
+ heartbeat side effect.
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ import argparse
66
+ import atexit
67
+ import json
68
+ import os
69
+ import signal
70
+ import subprocess
71
+ import sys
72
+ import threading
73
+ import time
74
+ from pathlib import Path
75
+
76
+ REPO_DIR = Path("/Users/matthewdi/social-autoposter")
77
+ DEFAULT_LOCK_SCRIPT = REPO_DIR / "scripts" / "reddit_browser_lock.py"
78
+ DEFAULT_LOCK_NAME = "reddit-browser"
79
+ DEFAULT_TTL = 90
80
+ HEARTBEAT_PULSE_INTERVAL = 30 # while a request is in flight
81
+
82
+ # Tunable via env so other browser agents can re-use this exact wrapper.
83
+ LOCK_NAME = os.environ.get("BROWSER_LOCK_NAME", DEFAULT_LOCK_NAME)
84
+ LOCK_TTL = int(os.environ.get("BROWSER_LOCK_TTL", str(DEFAULT_TTL)))
85
+ LOCK_SCRIPT = Path(os.environ.get("BROWSER_LOCK_SCRIPT", str(DEFAULT_LOCK_SCRIPT)))
86
+
87
+ # Optional debug log for proxy-internal events. Default off.
88
+ DEBUG_LOG_PATH = os.environ.get("BROWSER_LOCK_PROXY_LOG", "")
89
+ DEBUG_LOG_LOCK = threading.Lock()
90
+
91
+
92
+ def _log(msg: str) -> None:
93
+ if not DEBUG_LOG_PATH:
94
+ return
95
+ try:
96
+ with DEBUG_LOG_LOCK, open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
97
+ f.write(f"[{time.time():.3f}] {msg}\n")
98
+ except Exception:
99
+ pass
100
+
101
+
102
+ # ---- Heartbeat plumbing -----------------------------------------------------
103
+
104
+ _pending_lock = threading.Lock()
105
+ _pending_request_ids: set = set() # JSON-RPC ids we sent and haven't seen a response for
106
+ _last_heartbeat_at = 0.0
107
+ _heartbeat_min_interval = 1.0 # Don't fire more than once per second
108
+
109
+
110
+ def _fire_heartbeat() -> None:
111
+ """Shell out to `reddit_browser_lock.py heartbeat`. Always non-blocking."""
112
+ global _last_heartbeat_at
113
+ now = time.time()
114
+ # Cheap throttle: avoid stampedes when a burst of calls fires.
115
+ if now - _last_heartbeat_at < _heartbeat_min_interval:
116
+ return
117
+ _last_heartbeat_at = now
118
+ try:
119
+ subprocess.run(
120
+ [
121
+ sys.executable,
122
+ str(LOCK_SCRIPT),
123
+ "heartbeat",
124
+ "--name",
125
+ LOCK_NAME,
126
+ "--ttl",
127
+ str(LOCK_TTL),
128
+ ],
129
+ timeout=5,
130
+ stdout=subprocess.DEVNULL,
131
+ stderr=subprocess.DEVNULL,
132
+ check=False,
133
+ )
134
+ _log(f"heartbeat fired ttl={LOCK_TTL}")
135
+ except Exception as e:
136
+ _log(f"heartbeat failed: {e}")
137
+
138
+
139
+ def _heartbeat_async() -> None:
140
+ threading.Thread(target=_fire_heartbeat, daemon=True).start()
141
+
142
+
143
+ def _pulse_loop() -> None:
144
+ """Periodic heartbeat while any request is in flight.
145
+
146
+ This handles the rare case where a single MCP call legitimately runs
147
+ longer than the lease TTL (observed max: ~5.6 min on browser_close /
148
+ browser_tabs). Without this loop, that one call would let the lease
149
+ expire mid-flight and a peer would steal the browser from under us.
150
+ """
151
+ while True:
152
+ time.sleep(HEARTBEAT_PULSE_INTERVAL)
153
+ with _pending_lock:
154
+ has_pending = bool(_pending_request_ids)
155
+ if has_pending:
156
+ _heartbeat_async()
157
+
158
+
159
+ # ---- JSON-RPC stream proxy --------------------------------------------------
160
+
161
+
162
+ def _try_parse(line: bytes) -> dict | None:
163
+ if not line:
164
+ return None
165
+ s = line.strip()
166
+ if not s:
167
+ return None
168
+ try:
169
+ msg = json.loads(s.decode("utf-8", errors="replace"))
170
+ if isinstance(msg, dict):
171
+ return msg
172
+ except Exception:
173
+ pass
174
+ return None
175
+
176
+
177
+ def _proxy_stdin_to_proc(proc: subprocess.Popen) -> None:
178
+ """Read JSON-RPC from our stdin (Claude → us), forward to the MCP server.
179
+
180
+ Inspect for `tools/call` requests; record id and fire heartbeat.
181
+ """
182
+ while True:
183
+ try:
184
+ line = sys.stdin.buffer.readline()
185
+ except Exception as e:
186
+ _log(f"stdin read error: {e}")
187
+ break
188
+ if not line:
189
+ break
190
+ msg = _try_parse(line)
191
+ if msg is not None:
192
+ method = msg.get("method")
193
+ req_id = msg.get("id")
194
+ if method == "tools/call" and req_id is not None:
195
+ with _pending_lock:
196
+ _pending_request_ids.add(req_id)
197
+ _heartbeat_async()
198
+ _log(f"req in id={req_id}")
199
+ try:
200
+ proc.stdin.write(line)
201
+ proc.stdin.flush()
202
+ except (BrokenPipeError, ValueError):
203
+ break
204
+ except Exception as e:
205
+ _log(f"forward to subprocess failed: {e}")
206
+ break
207
+ try:
208
+ proc.stdin.close()
209
+ except Exception:
210
+ pass
211
+
212
+
213
+ def _proxy_proc_to_stdout(proc: subprocess.Popen) -> None:
214
+ """Read MCP server stdout, forward to our stdout (us → Claude).
215
+
216
+ Inspect for response objects matching pending request ids; on match,
217
+ drop the id from the pending set and fire one final heartbeat (so the
218
+ lease covers the moment the call resolved).
219
+ """
220
+ while True:
221
+ try:
222
+ line = proc.stdout.readline()
223
+ except Exception as e:
224
+ _log(f"subprocess stdout read error: {e}")
225
+ break
226
+ if not line:
227
+ break
228
+ msg = _try_parse(line)
229
+ if msg is not None:
230
+ resp_id = msg.get("id")
231
+ if resp_id is not None and "method" not in msg:
232
+ with _pending_lock:
233
+ if resp_id in _pending_request_ids:
234
+ _pending_request_ids.discard(resp_id)
235
+ _log(f"resp out id={resp_id}")
236
+ _heartbeat_async()
237
+ try:
238
+ sys.stdout.buffer.write(line)
239
+ sys.stdout.buffer.flush()
240
+ except (BrokenPipeError, ValueError):
241
+ break
242
+ except Exception as e:
243
+ _log(f"forward to claude failed: {e}")
244
+ break
245
+
246
+
247
+ # ---- Subprocess lifecycle ---------------------------------------------------
248
+
249
+ _subprocess_handle: subprocess.Popen | None = None
250
+
251
+
252
+ def _cleanup_subprocess() -> None:
253
+ """Make sure the wrapped MCP server dies if our proxy goes away.
254
+
255
+ Without this, killing the proxy (e.g. when claude exits abnormally) would
256
+ leave `npx @playwright/mcp@latest` and its Chrome child running, which
257
+ permanently holds the reddit browser profile lock.
258
+ """
259
+ p = _subprocess_handle
260
+ if p is None:
261
+ return
262
+ try:
263
+ if p.poll() is None:
264
+ try:
265
+ p.terminate()
266
+ except Exception:
267
+ pass
268
+ try:
269
+ p.wait(timeout=2)
270
+ except Exception:
271
+ try:
272
+ p.kill()
273
+ except Exception:
274
+ pass
275
+ except Exception:
276
+ pass
277
+
278
+
279
+ def _signal_exit(signum, _frame) -> None:
280
+ _log(f"received signal {signum}, exiting")
281
+ _cleanup_subprocess()
282
+ # Use os._exit to skip atexit (already ran cleanup) and avoid stuck threads.
283
+ os._exit(0)
284
+
285
+
286
+ # ---- Entrypoint -------------------------------------------------------------
287
+
288
+
289
+ def main() -> int:
290
+ # Hoist `global` declarations to the very top of main() so argparse
291
+ # defaults below can reference module-level values without Python's
292
+ # "used prior to global declaration" SyntaxError.
293
+ global LOCK_NAME, LOCK_TTL, LOCK_SCRIPT
294
+
295
+ p = argparse.ArgumentParser(
296
+ description="Heartbeat wrapper for a browser-MCP stdio server.",
297
+ allow_abbrev=False,
298
+ )
299
+ p.add_argument("--lock-name", default=LOCK_NAME)
300
+ p.add_argument("--ttl", type=int, default=LOCK_TTL)
301
+ p.add_argument(
302
+ "--lock-script", default=str(LOCK_SCRIPT),
303
+ help="Path to reddit_browser_lock.py (or compatible).",
304
+ )
305
+ p.add_argument(
306
+ "real_cmd", nargs=argparse.REMAINDER,
307
+ help="The real MCP server command (everything after `--`).",
308
+ )
309
+ args = p.parse_args()
310
+
311
+ # Apply CLI overrides (env was already read at import; CLI wins).
312
+ LOCK_NAME = args.lock_name
313
+ LOCK_TTL = args.ttl
314
+ LOCK_SCRIPT = Path(args.lock_script)
315
+
316
+ # Strip a leading `--` separator if argparse left it in REMAINDER.
317
+ real_cmd = list(args.real_cmd)
318
+ if real_cmd and real_cmd[0] == "--":
319
+ real_cmd = real_cmd[1:]
320
+ if not real_cmd:
321
+ print(
322
+ "mcp_lock_proxy: missing real MCP server command. "
323
+ "Pass it after `--`, e.g. `mcp_lock_proxy.py -- npx @playwright/mcp@latest ...`",
324
+ file=sys.stderr,
325
+ )
326
+ return 2
327
+
328
+ _log(f"starting wrapper lock_name={LOCK_NAME} ttl={LOCK_TTL} cmd={real_cmd}")
329
+
330
+ # Install lifecycle hooks BEFORE spawning the child, so any spawn-time
331
+ # crash still triggers cleanup.
332
+ atexit.register(_cleanup_subprocess)
333
+ try:
334
+ signal.signal(signal.SIGTERM, _signal_exit)
335
+ signal.signal(signal.SIGINT, _signal_exit)
336
+ signal.signal(signal.SIGHUP, _signal_exit)
337
+ except (ValueError, OSError):
338
+ # Not all platforms allow handler installation in non-main threads.
339
+ pass
340
+
341
+ global _subprocess_handle
342
+ try:
343
+ _subprocess_handle = subprocess.Popen(
344
+ real_cmd,
345
+ stdin=subprocess.PIPE,
346
+ stdout=subprocess.PIPE,
347
+ stderr=sys.stderr,
348
+ bufsize=0,
349
+ )
350
+ proc = _subprocess_handle
351
+ except FileNotFoundError as e:
352
+ print(f"mcp_lock_proxy: failed to spawn real MCP server: {e}", file=sys.stderr)
353
+ return 127
354
+
355
+ t_in = threading.Thread(target=_proxy_stdin_to_proc, args=(proc,), daemon=True)
356
+ t_out = threading.Thread(target=_proxy_proc_to_stdout, args=(proc,), daemon=True)
357
+ t_pulse = threading.Thread(target=_pulse_loop, daemon=True)
358
+ t_in.start()
359
+ t_out.start()
360
+ t_pulse.start()
361
+
362
+ rc = proc.wait()
363
+ # Give the output thread a moment to flush any tail.
364
+ t_out.join(timeout=1.0)
365
+ _log(f"wrapper exiting rc={rc}")
366
+ return rc
367
+
368
+
369
+ if __name__ == "__main__":
370
+ sys.exit(main())