@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,324 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ twitter_scan.py — deterministic X/Twitter search scrape.
4
+
5
+ Runs inside the browser-harness CLI process (BU_NAME=twitter-harness,
6
+ BU_CDP_URL=http://127.0.0.1:9555) and drives the live managed Chrome on 9555.
7
+ Called once per drafted query by run-twitter-cycle.sh's Phase 1 lean loop:
8
+
9
+ BU_NAME=twitter-harness BU_CDP_URL=http://127.0.0.1:9555 \
10
+ browser-harness -c "
11
+ import sys; sys.path.insert(0, '/Users/matthewdi/social-autoposter/scripts')
12
+ from twitter_scan import scan
13
+ for q in <queries list>:
14
+ scan(query=q['query'], project=q['project'],
15
+ search_topic=q['search_topic'],
16
+ freshness_hours=<env FRESHNESS_HOURS_DISCOVER>,
17
+ skip_ids=<env ENGAGED_TWEET_IDS>)
18
+ "
19
+
20
+ The cycle shell drafts queries via a small Claude call (no tools), then loops
21
+ this function per query. Result tweets go to SCAN_TWEETS_FILE (env-set), which
22
+ the cycle reads directly into $RAW_FILE + $QUERIES_FILE for the scorer.
23
+
24
+ What scan() does:
25
+ - Strips any since/until/since_time/until_time from the query so the
26
+ freshness window is operator-controlled, not caller-controlled.
27
+ - Builds an x.com/search URL with `&f=live` (Latest tab forced) and appends
28
+ `since_time:<now - freshness_hours*3600>` to the query.
29
+ - Reuses an existing real tab or opens one on the first call.
30
+ - Scrapes the first ~8 article cards.
31
+ - Applies a deterministic Python age gate behind the URL since_time
32
+ (belt-and-suspenders against cached / lazy-loaded stale viewports).
33
+ - Drops skip_ids (recently-engaged tweets).
34
+ - Stamps search_topic / matched_project / query on every kept tweet.
35
+ - Appends a sidecar JSONL record to
36
+ ~/social-autoposter/skill/logs/twitter-scan-attempts.jsonl for operator
37
+ visibility, and a per-attempt record to SCAN_TWEETS_FILE for the shell.
38
+ - Returns the kept tweet list.
39
+
40
+ Standalone test (no cycle shell):
41
+
42
+ ~/.local/bin/browser-harness -c '
43
+ import sys; sys.path.insert(0, "/Users/matthewdi/social-autoposter/scripts")
44
+ from twitter_scan import scan
45
+ scan(query="AI agent min_faves:10",
46
+ project="WhatsApp MCP",
47
+ search_topic="AI agent",
48
+ freshness_hours=6)
49
+ '
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import datetime
54
+ import json
55
+ import os
56
+ import pathlib
57
+ import re
58
+ import time
59
+ import urllib.parse
60
+
61
+ # Pin the daemon socket BEFORE importing helpers — helpers.py reads BU_NAME
62
+ # at module-load time (helpers.py:37). setdefault is a no-op when the bh_run
63
+ # wrapper or the cycle shell already set these; required when invoked from a
64
+ # bare `browser-harness -c` test invocation where the env happens to be empty.
65
+ os.environ.setdefault("BU_NAME", "twitter-harness")
66
+ os.environ.setdefault("BU_CDP_URL", "http://127.0.0.1:9555")
67
+
68
+ from browser_harness.helpers import ( # noqa: E402 (env must be set first)
69
+ goto_url,
70
+ js,
71
+ list_tabs,
72
+ new_tab,
73
+ wait_for_load,
74
+ )
75
+
76
+ _SIDECAR = (
77
+ pathlib.Path.home()
78
+ / "social-autoposter"
79
+ / "skill"
80
+ / "logs"
81
+ / "twitter-scan-attempts.jsonl"
82
+ )
83
+
84
+ # Derived from skill/run-twitter-cycle.sh:666-708 (the legacy inline scan JS).
85
+ # twitter_candidates fields (handle, text, tweetUrl, datetime, engagement
86
+ # counters) land in the same shape the scorer and dashboard already consume.
87
+ # 2026-06-04 DIVERGENCE: this copy adds repost awareness on top of the legacy
88
+ # JS — `handle` is now taken from the status URL (authoritative original author,
89
+ # since on a repost the first profile link is the REPOSTER), plus `is_repost`
90
+ # and `reposted_by` from the "<X> reposted" socialContext banner. This is the
91
+ # live data path (the cycle reads SCAN_TWEETS_FILE written here); the locked
92
+ # shell's inline JS is the inert fallback and simply omits the two new fields.
93
+ _SCRAPE_JS = r"""
94
+ (() => {
95
+ const SNOWFLAKE = /\/status\/(\d{15,19})(?:[\/?#]|$)/;
96
+ const FAKE_TAIL = /0{6,}$/;
97
+ const results = [];
98
+ for (const article of [...document.querySelectorAll('article[data-testid="tweet"]')].slice(0, 8)) {
99
+ try {
100
+ let handle = '';
101
+ for (const link of article.querySelectorAll('a[role="link"]')) {
102
+ const href = link.getAttribute('href');
103
+ if (href && href.startsWith('/') && !href.includes('/status/') && !href.includes('/search') && href.length > 1 && href.split('/').length === 2) {
104
+ handle = href.replace('/', ''); break;
105
+ }
106
+ }
107
+ const tweetText = article.querySelector('[data-testid="tweetText"]');
108
+ const text = tweetText ? tweetText.textContent : '';
109
+ const timeEl = article.querySelector('time');
110
+ const timeParent = timeEl ? timeEl.closest('a') : null;
111
+ const tweetUrl = timeParent ? 'https://x.com' + timeParent.getAttribute('href') : '';
112
+ const datetime = timeEl ? timeEl.getAttribute('datetime') : '';
113
+ const sm = tweetUrl.match(SNOWFLAKE);
114
+ if (!sm || FAKE_TAIL.test(sm[1])) continue;
115
+ if (!datetime || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(datetime)) continue;
116
+ // Author = first path segment of the status URL. This is authoritative:
117
+ // on a repost the displayed/first-link handle above is the REPOSTER, not
118
+ // the original author, so prefer the URL author and keep the link-scan
119
+ // handle only as a fallback when the URL can't be parsed.
120
+ const authorM = tweetUrl.match(/x\.com\/([^\/]+)\/status\//);
121
+ if (authorM && authorM[1]) handle = authorM[1];
122
+ // Repost detection: a "<X> reposted" banner lives in socialContext. The
123
+ // SAME testid is reused for "Pinned", so match the text, not presence.
124
+ // reposted_by = the account whose profile link wraps the banner.
125
+ let is_repost = false, reposted_by = '';
126
+ const sc = article.querySelector('[data-testid="socialContext"]');
127
+ if (sc && /\breposted\b/i.test(sc.textContent || '')) {
128
+ is_repost = true;
129
+ const a = sc.closest('a');
130
+ const rh = a ? (a.getAttribute('href') || '') : '';
131
+ if (rh.startsWith('/') && rh.split('/').length === 2) reposted_by = rh.replace('/', '');
132
+ }
133
+ let replies=0, retweets=0, likes=0, views=0, bookmarks=0;
134
+ for (const btn of article.querySelectorAll('[role="group"] button')) {
135
+ const al = btn.getAttribute('aria-label') || '';
136
+ let m;
137
+ if (m=al.match(/([\d,]+)\s*repl/i)) replies=parseInt(m[1].replace(/,/g,''));
138
+ if (m=al.match(/([\d,]+)\s*repost/i)) retweets=parseInt(m[1].replace(/,/g,''));
139
+ if (m=al.match(/([\d,]+)\s*like/i)) likes=parseInt(m[1].replace(/,/g,''));
140
+ if (m=al.match(/([\d,]+)\s*view/i)) views=parseInt(m[1].replace(/,/g,''));
141
+ if (m=al.match(/([\d,]+)\s*bookmark/i)) bookmarks=parseInt(m[1].replace(/,/g,''));
142
+ }
143
+ results.push({handle, text, tweetUrl, datetime, replies, retweets, likes, views, bookmarks, is_repost, reposted_by});
144
+ } catch(e) {}
145
+ }
146
+ return results;
147
+ })()
148
+ """
149
+
150
+ # 2026-05-28: also matches the bash-arithmetic form
151
+ # `since_time:$(( $(date +%s) - FRESHNESS_HOURS_DISCOVER * 3600 ))` that was
152
+ # accidentally taught to the model when an escaping bug in the prompt sent
153
+ # the literal template text instead of an evaluated epoch. \S+ alone stops
154
+ # at the first space and leaves the tail (`$(date +%s) - ... ))`) behind as
155
+ # keyword garbage that X searches for literally. The non-greedy `.*?` inside
156
+ # `$((...))` matches up to the first `))` which is the template's own close.
157
+ _DATE_OPS_RE = re.compile(
158
+ r"\b(since|until|since_time|until_time):(?:\$\(\(.*?\)\)|\S+)",
159
+ re.IGNORECASE,
160
+ )
161
+ # Belt + suspenders: even after _DATE_OPS_RE, residual orphan fragments could
162
+ # remain if the model invents some other broken template. Strip common ones.
163
+ _BASH_GARBAGE_RE = re.compile(
164
+ r"\$\(\(|\$\([^)]*\)|\bFRESHNESS_HOURS_DISCOVER\s*\*\s*\d+\b|\)\)"
165
+ )
166
+ _STATUS_ID_RE = re.compile(r"/status/(\d+)")
167
+
168
+
169
+ def _build_url(query: str, freshness_hours: int) -> str:
170
+ """Force-build the Latest-tab URL with since_time pinned `freshness_hours` ago.
171
+
172
+ Stripping the model's date operators first is what closes the dodge: a
173
+ rogue `since:2020-01-01` in the model's query string can no longer widen
174
+ the window. `f=live` is what closes the Top-tab dodge: without it X may
175
+ serve the Top tab where the time operator is advisory."""
176
+ cleaned = _DATE_OPS_RE.sub("", query)
177
+ cleaned = _BASH_GARBAGE_RE.sub("", cleaned)
178
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
179
+ cap_epoch = int(time.time()) - int(freshness_hours) * 3600
180
+ full = f"{cleaned} since_time:{cap_epoch}".strip()
181
+ return "https://x.com/search?q=" + urllib.parse.quote(full) + "&src=typed_query&f=live"
182
+
183
+
184
+ def _parse_dt_epoch(ds: str):
185
+ if not ds:
186
+ return None
187
+ try:
188
+ return int(
189
+ datetime.datetime.fromisoformat(ds.replace("Z", "+00:00")).timestamp()
190
+ )
191
+ except (ValueError, TypeError):
192
+ return None
193
+
194
+
195
+ def _status_id(url: str):
196
+ m = _STATUS_ID_RE.search(url or "")
197
+ return m.group(1) if m else None
198
+
199
+
200
+ def _write_sidecar(rec: dict) -> None:
201
+ try:
202
+ _SIDECAR.parent.mkdir(parents=True, exist_ok=True)
203
+ with _SIDECAR.open("a") as f:
204
+ f.write(json.dumps(rec) + "\n")
205
+ except OSError:
206
+ pass # fail-open; sidecar is operator visibility only, not on the data path
207
+
208
+
209
+ def _write_scan_tweets_record(rec: dict) -> None:
210
+ """Append one JSONL record per scan() call to the path in SCAN_TWEETS_FILE.
211
+
212
+ 2026-05-28: shell-side data path. When the cycle exports SCAN_TWEETS_FILE,
213
+ run-twitter-cycle.sh reads this file after the scan claude session ends
214
+ and uses it as the source of truth for both $RAW_FILE (tweets fed to the
215
+ scorer) and $QUERIES_FILE (attempts fed to log_twitter_search_attempts.py),
216
+ skipping the model's structured_output relay entirely. This cuts the
217
+ relay-tokens bill (model no longer has to copy the tweets/queries_used
218
+ arrays from bh_run stdout into structured_output).
219
+
220
+ Inert when SCAN_TWEETS_FILE is unset; the model's structured_output path
221
+ remains the fallback so existing standalone test invocations (no cycle
222
+ env) and any session where the file write fails still produce candidates."""
223
+ path = os.environ.get("SCAN_TWEETS_FILE")
224
+ if not path:
225
+ return
226
+ try:
227
+ with open(path, "a") as f:
228
+ f.write(json.dumps(rec) + "\n")
229
+ except OSError:
230
+ pass # fail-open; shell falls back to structured_output if file is missing
231
+
232
+
233
+ def _navigate(url: str) -> None:
234
+ """Reuse the existing real tab if there is one (typical cycle behavior),
235
+ otherwise open one. The MCP-managed Chrome always has at least an
236
+ about:blank tab from launch, but be defensive: a hung tab close between
237
+ cycles can leave us with only chrome:// tabs."""
238
+ real = [
239
+ t for t in list_tabs(include_chrome=False)
240
+ if (t.get("url") or "").startswith(("http", "about:"))
241
+ ]
242
+ if real:
243
+ goto_url(url)
244
+ else:
245
+ new_tab(url)
246
+
247
+
248
+ def scan(
249
+ query: str,
250
+ project: str,
251
+ search_topic: str,
252
+ freshness_hours: int = 6,
253
+ skip_ids=None,
254
+ settle_seconds: float = 4.0,
255
+ ) -> list:
256
+ """Deterministic scrape + age gate. Prints JSON between
257
+ ###TWEETS_BEGIN###/###TWEETS_END### sentinels for the scan model to relay
258
+ into StructuredOutput; also returns the kept list so direct callers (tests,
259
+ future shell-driven invocations) can consume it without parsing stdout."""
260
+ skip = {str(s) for s in (skip_ids or [])}
261
+ url = _build_url(query, int(freshness_hours))
262
+ _navigate(url)
263
+ wait_for_load(timeout=15.0)
264
+ # X lazy-loads the result list; settle briefly before scraping. Matches
265
+ # the legacy template's `time.sleep(4)`.
266
+ time.sleep(float(settle_seconds))
267
+
268
+ raw = js(_SCRAPE_JS)
269
+ tweets = raw if isinstance(raw, list) else []
270
+ pre_count = len(tweets)
271
+
272
+ cap_epoch = int(time.time()) - int(freshness_hours) * 3600
273
+ fresh = []
274
+ for t in tweets:
275
+ ep = _parse_dt_epoch(t.get("datetime", ""))
276
+ if ep is not None and ep >= cap_epoch:
277
+ fresh.append(t)
278
+ dropped_age = pre_count - len(fresh)
279
+
280
+ kept = [t for t in fresh if _status_id(t.get("tweetUrl", "")) not in skip]
281
+ dropped_skip = len(fresh) - len(kept)
282
+
283
+ for t in kept:
284
+ t["search_topic"] = search_topic
285
+ t["matched_project"] = project
286
+ t["query"] = query
287
+
288
+ _write_sidecar(
289
+ {
290
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
291
+ "ts_epoch": int(time.time()),
292
+ "query": query,
293
+ "project": project,
294
+ "search_topic": search_topic,
295
+ "freshness_hours": int(freshness_hours),
296
+ "url": url,
297
+ "pre_count": pre_count,
298
+ "kept_after_age": len(fresh),
299
+ "dropped_age": dropped_age,
300
+ "kept_after_skip": len(kept),
301
+ "dropped_skip": dropped_skip,
302
+ "batch_id": os.environ.get("BATCH_ID"),
303
+ "cycle_variant": os.environ.get("TWITTER_CYCLE_VARIANT"),
304
+ }
305
+ )
306
+
307
+ # Shell-side data path. The cycle (when it exports SCAN_TWEETS_FILE) reads
308
+ # this file directly instead of asking the scan model to relay tweets via
309
+ # structured_output, saving relay tokens. One JSONL record per scan() call;
310
+ # the cycle aggregates across all calls in one Phase 1 attempt.
311
+ _write_scan_tweets_record(
312
+ {
313
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
314
+ "query": query,
315
+ "project": project,
316
+ "search_topic": search_topic,
317
+ "tweets": kept,
318
+ }
319
+ )
320
+
321
+ # 2026-05-28 cleanup: sentinel-print removed. The cycle reads SCAN_TWEETS_FILE
322
+ # directly via _write_scan_tweets_record() above; the bh_run stdout relay path
323
+ # is no longer wired. scan() still returns `kept` so direct callers can use it.
324
+ return kept
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ twitter_supply_signal.py
4
+
5
+ Per-project supply table: at each `min_faves:N` tier, what's the median
6
+ number of tweets X actually returned for queries we ran for that project?
7
+
8
+ This is the answer to the question the Phase 1 scanner has been guessing
9
+ at since the cycle was written: "what min_faves should I use for this
10
+ project?". Today the prompt says a flat "broad=50, narrow=20" rule, which
11
+ works for tech-Twitter (mk0r, claude-meter, fazm) but starves student-
12
+ Twitter (studyly), where even niche audience tweets rarely clear 20 likes.
13
+
14
+ Output: JSON list of
15
+ {"project": "<name>", "tiers": [{"min_faves": N, "attempts": N,
16
+ "median_tweets_found": N,
17
+ "zero_result_pct": 0-100}, ...]}
18
+ sorted by project. Within each project, tiers ordered ascending min_faves
19
+ so the model can read "as I raise the floor, supply collapses; pick the
20
+ lowest min_faves where supply is still ≥3".
21
+
22
+ Usage:
23
+
24
+ python3 scripts/twitter_supply_signal.py [--window-days 14] [--project NAME]
25
+
26
+ Migrated 2026-05-18: reads now go through /api/v1/twitter-search-attempts/
27
+ supply-signal via scripts/http_api.py instead of psycopg2.
28
+ """
29
+ import argparse
30
+ import json
31
+ import os
32
+ import sys
33
+
34
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
35
+ from http_api import api_get # noqa: E402
36
+
37
+
38
+ def main():
39
+ p = argparse.ArgumentParser()
40
+ p.add_argument("--window-days", type=int, default=14)
41
+ p.add_argument("--project", default=None,
42
+ help="If set, only return supply table for this project.")
43
+ args = p.parse_args()
44
+
45
+ query = {"window_days": args.window_days}
46
+ if args.project:
47
+ query["project"] = args.project
48
+
49
+ resp = api_get("/api/v1/twitter-search-attempts/supply-signal", query=query)
50
+ out = (resp.get("data") or {}).get("rows") or []
51
+
52
+ json.dump(out, sys.stdout)
53
+ print("", file=sys.stdout)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """twitter_threads_helper.py — small CLI wrapper used by
3
+ skill/run-twitter-threads.sh to replace the three `psql` one-liners that
4
+ loaded recent posts / styles / top performers for the original-thread
5
+ prompt context. Each subcommand prints exactly one value to stdout (raw
6
+ newline-separated content or pipe-separated tuples) so the surrounding
7
+ bash code can keep using $(...) capture unchanged.
8
+
9
+ Subcommands:
10
+ recent-posts --project P [--days 14] [--limit 10]
11
+ -> GET /api/v1/posts?platform=twitter&project=P
12
+ &since=<now-14d>&limit=...
13
+ -> filter rows to platform='twitter' AND thread_url = our_url
14
+ (= our original posts, not mention placeholders) and print one
15
+ line per row containing the post's our_content (newlines and
16
+ pipes inside content survive because the legacy psql -t -A
17
+ output did the same).
18
+
19
+ recent-styles --project P [--limit 5]
20
+ -> Same source; print one engagement_style per line for the most
21
+ recent N our-original posts where engagement_style is set.
22
+
23
+ top-posts --project P [--limit 8]
24
+ -> Same source; print pipe-separated tuples:
25
+ our_content|upvotes|comments_count|views
26
+ filtered to rows with composite (upvotes + 3*comments + views/100)
27
+ > 5 and sorted by the same composite DESC.
28
+
29
+ Migrated 2026-05-18: removes 3 direct psql calls from
30
+ skill/run-twitter-threads.sh. The route at /api/v1/posts already supports
31
+ platform + project + since + status filters server-side; this helper just
32
+ shapes the response into the legacy line/pipe format the bash prompt
33
+ consumes verbatim.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import os
39
+ import sys
40
+ from datetime import datetime, timedelta, timezone
41
+
42
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
43
+ from http_api import api_get # noqa: E402
44
+
45
+
46
+ def _fetch_posts(project: str, days: int | None = None, limit: int = 500):
47
+ """Pull recent twitter posts for a project via /api/v1/posts. The
48
+ server-side WHERE handles platform/project/status/since; the
49
+ thread_url = our_url filter (= our original posts, not replies)
50
+ is applied client-side so the route stays general-purpose.
51
+
52
+ Post 2026-05-23, mentions live in the dedicated `mentions` table,
53
+ so we no longer need to filter '(mention%' placeholders client-side.
54
+ """
55
+ query: dict = {
56
+ "platform": "twitter",
57
+ "project": project,
58
+ "limit": limit,
59
+ "status": "active",
60
+ "has_our_url": "true",
61
+ }
62
+ if days is not None:
63
+ since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
64
+ query["since"] = since
65
+ resp = api_get("/api/v1/posts", query=query)
66
+ rows = (resp.get("data") or {}).get("posts") or []
67
+ out = []
68
+ for r in rows:
69
+ if r.get("thread_url") != r.get("our_url"):
70
+ continue
71
+ out.append(r)
72
+ return out
73
+
74
+
75
+ def cmd_recent_posts(project: str, days: int, limit: int) -> int:
76
+ rows = _fetch_posts(project, days=days, limit=max(limit * 5, 50))
77
+ # Posts come back posted_at DESC already; just take the first N.
78
+ for r in rows[:limit]:
79
+ content = (r.get("our_content") or "").replace("\n", " ").replace("\r", " ")
80
+ sys.stdout.write(content + "\n")
81
+ return 0
82
+
83
+
84
+ def cmd_recent_styles(project: str, limit: int) -> int:
85
+ # No --days bound originally; pull a generous window so we don't miss
86
+ # styles when the account has been quiet recently.
87
+ rows = _fetch_posts(project, days=90, limit=max(limit * 10, 50))
88
+ n = 0
89
+ for r in rows:
90
+ style = (r.get("engagement_style") or "").strip()
91
+ if not style:
92
+ continue
93
+ sys.stdout.write(style + "\n")
94
+ n += 1
95
+ if n >= limit:
96
+ break
97
+ return 0
98
+
99
+
100
+ def cmd_top_posts(project: str, limit: int) -> int:
101
+ rows = _fetch_posts(project, days=None, limit=500)
102
+
103
+ def composite(r):
104
+ return (
105
+ int(r.get("upvotes") or 0)
106
+ + int(r.get("comments_count") or 0) * 3
107
+ + int(r.get("views") or 0) // 100
108
+ )
109
+
110
+ # Same composite + threshold + sort as the legacy SQL: floor=5,
111
+ # ORDER BY composite DESC, LIMIT.
112
+ filtered = [r for r in rows if composite(r) > 5]
113
+ filtered.sort(key=composite, reverse=True)
114
+ for r in filtered[:limit]:
115
+ content = (r.get("our_content") or "").replace("\n", " ").replace("\r", " ")
116
+ upvotes = int(r.get("upvotes") or 0)
117
+ comments = int(r.get("comments_count") or 0)
118
+ views = int(r.get("views") or 0)
119
+ sys.stdout.write(f"{content}|{upvotes}|{comments}|{views}\n")
120
+ return 0
121
+
122
+
123
+ def main() -> int:
124
+ ap = argparse.ArgumentParser(description="Helper for run-twitter-threads.sh")
125
+ sub = ap.add_subparsers(dest="cmd", required=True)
126
+
127
+ p_rp = sub.add_parser("recent-posts")
128
+ p_rp.add_argument("--project", required=True)
129
+ p_rp.add_argument("--days", type=int, default=14)
130
+ p_rp.add_argument("--limit", type=int, default=10)
131
+
132
+ p_rs = sub.add_parser("recent-styles")
133
+ p_rs.add_argument("--project", required=True)
134
+ p_rs.add_argument("--limit", type=int, default=5)
135
+
136
+ p_tp = sub.add_parser("top-posts")
137
+ p_tp.add_argument("--project", required=True)
138
+ p_tp.add_argument("--limit", type=int, default=8)
139
+
140
+ args = ap.parse_args()
141
+
142
+ if args.cmd == "recent-posts":
143
+ return cmd_recent_posts(args.project, args.days, args.limit)
144
+ if args.cmd == "recent-styles":
145
+ return cmd_recent_styles(args.project, args.limit)
146
+ if args.cmd == "top-posts":
147
+ return cmd_top_posts(args.project, args.limit)
148
+ return 1
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env python3
2
+ """Unclaim a web-chat thread (re-set unread_by_founder=1, clear cooldown).
3
+
4
+ HTTP-only: POST /api/v1/web-chat/threads/<thread_id>/unclaim. Used when a Claude
5
+ session fails so the next pipeline tick will retry.
6
+
7
+ Usage:
8
+ python3 unclaim_web_chat.py <thread_id>
9
+ """
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ from http_api import api_post
17
+
18
+
19
+ def main():
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("thread_id")
22
+ args = parser.parse_args()
23
+
24
+ api_post(f"/api/v1/web-chat/threads/{args.thread_id}/unclaim", {})
25
+ print(f"unclaimed thread {args.thread_id}")
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()