@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,130 @@
1
+ #!/usr/bin/env python3
2
+ """twitter_cookie_mirror.py - local 0600 mirror of the managed X session cookies.
3
+
4
+ Why this exists (Gap B, 2026-06-02)
5
+ -----------------------------------
6
+ On a persistent (non-VM) machine the server-side session store
7
+ (social_accounts.session_cookies) is SKIPPED during connect_x because there is no
8
+ social_accounts row to attach the cookies to. That left Chrome's own encrypted
9
+ Cookies SQLite as the ONLY thing keeping the X session across a Chrome relaunch.
10
+
11
+ That store is not durable on a headless / SSH box: macOS encrypts Chrome cookies
12
+ with the per-app `Chrome Safe Storage` key, which lives in the login keychain.
13
+ When the keychain re-locks (idle ~5 min) between the import and the next Chrome
14
+ launch, the freshly-launched Chrome cannot read the Safe Storage key, cannot
15
+ decrypt the existing blobs, and reinitializes the Cookies DB to an empty schema.
16
+ The imported session silently evaporates between `connect_x` and the first cycle.
17
+
18
+ This module is the keychain-independent durability layer. On a successful import
19
+ connect_x writes the validated x.com/twitter.com cookies (CDP-shaped, straight
20
+ from Network.getAllCookies) here as plaintext JSON, and the cycle preflight
21
+ (restore_twitter_session.py, invoked from skill/lib/twitter-backend.sh) re-injects
22
+ them via CDP whenever the live session comes up logged out. A keychain re-lock or
23
+ a wiped Cookies DB is therefore no longer fatal — the next cycle restores.
24
+
25
+ Security
26
+ --------
27
+ The file grants access to the X account: it is exactly as sensitive as the Chrome
28
+ profile itself, and is written 0600 (owner read/write only). Treat it like a
29
+ token. It is intentionally NOT encrypted — the whole point is to survive a locked
30
+ keychain, so adding a keychain-derived key would reintroduce the dependency this
31
+ file exists to remove. On a multi-user host, restrict the home directory.
32
+
33
+ CLI (debug / doctor):
34
+ python3 twitter_cookie_mirror.py count # prints the mirrored cookie count
35
+ python3 twitter_cookie_mirror.py path # prints the mirror file path
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import sys
42
+ import time
43
+ from pathlib import Path
44
+
45
+ # Sibling of the harness profile dir, NOT inside it: a VM profile reseed wipes
46
+ # the profile but a persistent machine keeps this file across Chrome relaunches.
47
+ # (On a VM the server-side store is the durable path; the mirror just stays empty
48
+ # there and restore_twitter_session falls through to the API.)
49
+ MIRROR_PATH = (
50
+ Path.home() / ".claude" / "browser-profiles" / "browser-harness.x-cookies.json"
51
+ )
52
+
53
+
54
+ def save_cookies(cookies, handle: str | None = None) -> int:
55
+ """Write the given CDP-shaped cookies to the 0600 mirror. Returns count saved.
56
+
57
+ Atomic (temp file + os.replace) so a crash mid-write never leaves a partial
58
+ JSON that the reader would choke on. No-op (returns 0) on an empty list."""
59
+ clean = [c for c in (cookies or []) if isinstance(c, dict) and c.get("name")]
60
+ if not clean:
61
+ return 0
62
+ # Never downgrade a previously-resolved @handle to null. Live handle
63
+ # resolution (_resolve_live_handle) is best-effort and races React hydration,
64
+ # so a re-import can legitimately arrive with handle=None even though we
65
+ # already knew the account. Clobbering it would drop the handle the dashboard
66
+ # + account_resolver rely on. Carry the prior handle forward in that case.
67
+ if not handle:
68
+ prev = (_read().get("handle") or "") if MIRROR_PATH.exists() else ""
69
+ if prev:
70
+ handle = prev
71
+ MIRROR_PATH.parent.mkdir(parents=True, exist_ok=True)
72
+ payload = {"handle": handle, "saved_at": int(time.time()), "cookies": clean}
73
+ tmp = MIRROR_PATH.with_name(MIRROR_PATH.name + ".tmp")
74
+ # Create with 0600 from the start so the secret is never briefly world-readable.
75
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
76
+ with os.fdopen(fd, "w") as f:
77
+ json.dump(payload, f)
78
+ os.replace(tmp, MIRROR_PATH)
79
+ try:
80
+ os.chmod(MIRROR_PATH, 0o600)
81
+ except OSError:
82
+ pass
83
+ return len(clean)
84
+
85
+
86
+ def _read() -> dict:
87
+ try:
88
+ with open(MIRROR_PATH) as f:
89
+ data = json.load(f)
90
+ except (OSError, ValueError):
91
+ return {}
92
+ return data if isinstance(data, dict) else {}
93
+
94
+
95
+ def load_cookies() -> list:
96
+ """Return the mirrored CDP-shaped cookies, or [] if no/invalid mirror."""
97
+ cks = _read().get("cookies")
98
+ return cks if isinstance(cks, list) else []
99
+
100
+
101
+ def load_meta() -> dict:
102
+ """Return {handle, saved_at, count} for the mirror, or {} if absent."""
103
+ data = _read()
104
+ if not data:
105
+ return {}
106
+ return {
107
+ "handle": data.get("handle"),
108
+ "saved_at": data.get("saved_at"),
109
+ "count": len(data.get("cookies") or []),
110
+ }
111
+
112
+
113
+ def _cli(argv: list[str] | None = None) -> int:
114
+ argv = argv if argv is not None else sys.argv[1:]
115
+ cmd = argv[0] if argv else "count"
116
+ if cmd == "path":
117
+ print(MIRROR_PATH)
118
+ return 0
119
+ if cmd == "count":
120
+ print(len(load_cookies()))
121
+ return 0
122
+ if cmd == "meta":
123
+ print(json.dumps(load_meta()))
124
+ return 0
125
+ print(f"usage: {Path(sys.argv[0]).name} [count|path|meta]", file=sys.stderr)
126
+ return 2
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(_cli())
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ """twitter_cycle_helper.py — small CLI wrapper used by skill/run-twitter-cycle.sh
3
+ to replace the six `psql -t -A -c "..."` one-liners the cycle orchestrator
4
+ used to embed inline. Every subcommand prints exactly one value to stdout
5
+ (string / int / pipe-separated row dump) so bash can capture it with $(...)
6
+ without changing shape.
7
+
8
+ Subcommands:
9
+ status-counts --batch-id ID
10
+ -> GET /api/v1/twitter-candidates/counts-by-batch
11
+ -> prints two integers space-separated: "<posted> <skipped_or_expired>"
12
+ (mirrors the two timeout-10 psql one-liners in the run-summary block)
13
+
14
+ phase0-salvage --batch-id ID --freshness-hours N --legacy-cutoff BATCH_ID
15
+ -> POST /api/v1/twitter-candidates/phase0-salvage
16
+ -> prints "<expired_count>|<salvaged_count>"
17
+ (matches the legacy PHASE0_RESULT pipe-shape)
18
+
19
+ engaged-tweet-ids [--window-hours 48]
20
+ -> GET /api/v1/twitter/engaged-tweet-ids
21
+ -> prints a single JSON array on stdout
22
+
23
+ batch-count --batch-id ID
24
+ -> GET /api/v1/twitter-candidates/counts-by-batch
25
+ -> prints the total integer (all statuses)
26
+
27
+ candidates --batch-id ID
28
+ -> GET /api/v1/twitter-candidates?batch_id=ID&status=pending
29
+ -> prints pipe-separated rows in the exact column order
30
+ run-twitter-cycle.sh's old psql query produced:
31
+ id|tweet_url|author_handle|tweet_text|virality_score|
32
+ delta_score|matched_project|search_topic|likes_t1|retweets_t1|
33
+ replies_t1|views_t1|author_followers|age_hours|
34
+ draft_reply_text|draft_engagement_style|drafted_minutes_ago
35
+
36
+ expire-batch --batch-id ID
37
+ -> POST /api/v1/twitter-candidates/expire-batch
38
+ -> prints the resulting expired_count integer
39
+
40
+ batch-summary --batch-id ID
41
+ -> GET /api/v1/twitter-candidates/counts-by-batch
42
+ -> prints "status1|count1\\nstatus2|count2" (mirrors the legacy
43
+ SUMMARY = `psql -F '|' SELECT status, COUNT(*) ... GROUP BY status`
44
+ pipe-format the cycle log line consumes)
45
+
46
+ Migrated 2026-05-18: removes 6 direct psql calls from
47
+ skill/run-twitter-cycle.sh. The cycle no longer requires DATABASE_URL for
48
+ its core SQL surface (only for the few legacy non-twitter paths that still
49
+ embed psql in other shells).
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import argparse
54
+ import json
55
+ import os
56
+ import sys
57
+
58
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
59
+ from http_api import api_get, api_post # noqa: E402
60
+ from twitter_account import resolve_handle as _resolve_twitter_handle # noqa: E402
61
+
62
+
63
+ def _counts(batch_id: str) -> dict:
64
+ resp = api_get(
65
+ "/api/v1/twitter-candidates/counts-by-batch",
66
+ query={"batch_id": batch_id},
67
+ )
68
+ return resp.get("data") or {}
69
+
70
+
71
+ def cmd_status_counts(batch_id: str) -> int:
72
+ c = _counts(batch_id)
73
+ posted = int(c.get("posted") or 0)
74
+ skipped_or_expired = int(c.get("skipped_or_expired") or 0)
75
+ sys.stdout.write(f"{posted} {skipped_or_expired}\n")
76
+ return 0
77
+
78
+
79
+ def cmd_batch_count(batch_id: str) -> int:
80
+ c = _counts(batch_id)
81
+ sys.stdout.write(f"{int(c.get('total') or 0)}\n")
82
+ return 0
83
+
84
+
85
+ def cmd_batch_summary(batch_id: str) -> int:
86
+ c = _counts(batch_id)
87
+ by_status = c.get("by_status") or {}
88
+ # Pipe-separated, one row per status, like the legacy psql -F '|' output.
89
+ parts = [f"{status}|{count}" for status, count in by_status.items()]
90
+ sys.stdout.write("\n".join(parts) + ("\n" if parts else ""))
91
+ return 0
92
+
93
+
94
+ def cmd_phase0_salvage(batch_id: str, freshness_hours: int, legacy_cutoff: str) -> int:
95
+ resp = api_post(
96
+ "/api/v1/twitter-candidates/phase0-salvage",
97
+ {
98
+ "batch_id": batch_id,
99
+ "freshness_hours": freshness_hours,
100
+ "legacy_salvage_cutoff_batch_id": legacy_cutoff,
101
+ },
102
+ )
103
+ d = resp.get("data") or {}
104
+ expired = int(d.get("expired_count") or 0)
105
+ salvaged = int(d.get("salvaged_count") or 0)
106
+ try:
107
+ _salvaged = int(d.get("salvaged_count", 0) or 0)
108
+ _expired = int(d.get("expired_count", 0) or 0)
109
+ _sources = d.get("salvaged_from_batches") or []
110
+ if _salvaged > 0:
111
+ _src_str = ",".join(str(s) for s in _sources) if _sources else "unknown"
112
+ print(
113
+ f"[twitter_salvage] batch={batch_id} salvaged_count={_salvaged} "
114
+ f"expired_count={_expired} salvaged_from_batches={_src_str}",
115
+ file=sys.stderr,
116
+ flush=True,
117
+ )
118
+ except Exception:
119
+ pass
120
+ sys.stdout.write(f"{expired}|{salvaged}\n")
121
+ return 0
122
+
123
+
124
+ def cmd_engaged_tweet_ids(window_hours: int) -> int:
125
+ # Scope the dedupe pool to THIS machine's Twitter handle. Without
126
+ # this, two machines posting as different handles (e.g. @m13v_ on the
127
+ # local cron, @matt_diak on the VM) share one 486-ID pool and starve
128
+ # each other's candidate supply. Falls back to unscoped (legacy) when
129
+ # no handle is configured, preserving single-account behavior.
130
+ query: dict[str, object] = {"window_hours": window_hours}
131
+ handle = _resolve_twitter_handle()
132
+ if handle:
133
+ query["our_account"] = handle
134
+ resp = api_get("/api/v1/twitter/engaged-tweet-ids", query=query)
135
+ ids = (resp.get("data") or {}).get("tweet_ids") or []
136
+ # The legacy shell expects a JSON array string; mirror that exactly so
137
+ # `python3 -c 'import json,sys; print(len(json.load(sys.stdin)))'`
138
+ # downstream parses unchanged.
139
+ sys.stdout.write(json.dumps(ids))
140
+ sys.stdout.write("\n")
141
+ return 0
142
+
143
+
144
+ def cmd_expire_batch(batch_id: str) -> int:
145
+ resp = api_post(
146
+ "/api/v1/twitter-candidates/expire-batch",
147
+ {"batch_id": batch_id},
148
+ )
149
+ d = resp.get("data") or {}
150
+ sys.stdout.write(f"{int(d.get('expired_count') or 0)}\n")
151
+ return 0
152
+
153
+
154
+ def cmd_stamp_cycle_variant(batch_id: str, variant: str) -> int:
155
+ resp = api_post(
156
+ "/api/v1/twitter-candidates/stamp-cycle-variant",
157
+ {"batch_id": batch_id, "cycle_variant": variant},
158
+ )
159
+ d = resp.get("data") or {}
160
+ sys.stdout.write(f"{int(d.get('stamped_count') or 0)}\n")
161
+ return 0
162
+
163
+
164
+ def _sanitize(s) -> str:
165
+ """Mirror the SQL `REPLACE(REPLACE(..., E'\n', ' '), E'\r', ' ')` so a
166
+ multi-line tweet/draft body doesn't break the pipe-delimited row format."""
167
+ if s is None:
168
+ return ""
169
+ return str(s).replace("\n", " ").replace("\r", " ")
170
+
171
+
172
+ def cmd_candidates(batch_id: str) -> int:
173
+ """List pending candidates for a batch in the EXACT pipe-separated
174
+ column order run-twitter-cycle.sh's old psql query produced.
175
+
176
+ Sort key (2026-05-27): virality_score DESC.
177
+ virality_score is the composite predictor stamped by score_twitter_candidates.py
178
+ at discovery (velocity * reach_mult * age_decay * rt_bonus * (1+reply_bonus)
179
+ * (1+discussion_bonus)). Cohort analysis on 30d posted data showed the
180
+ [10k+) virality bucket gets ~36x the median reply views of the [0-10)
181
+ bucket, while the previous sort (delta_score + flat-5 intent regex
182
+ boost) ignored author reach, age decay, and discussion quality. The
183
+ intent regex was a crutch when the sort key was raw delta; the model
184
+ reads tweet text directly in the prep prompt and can detect intent
185
+ itself, so the lexical layer is now redundant.
186
+ The 25-row cap is unchanged (draft budget, not a quality gate).
187
+ """
188
+ from datetime import datetime, timezone
189
+
190
+ # Scope by our_account so a peer machine's pending rows on the same
191
+ # tweet_url don't surface in this machine's batch. The composite
192
+ # (tweet_url, our_account) unique guarantees each machine has its own
193
+ # candidate row; this filter just makes the GET match the INSERT shape.
194
+ query: dict[str, object] = {
195
+ "batch_id": batch_id,
196
+ "status": "pending",
197
+ "limit": 500,
198
+ }
199
+ handle = _resolve_twitter_handle()
200
+ if handle:
201
+ query["our_account"] = handle
202
+ resp = api_get("/api/v1/twitter-candidates", query=query)
203
+ rows = (resp.get("data") or {}).get("candidates") or []
204
+
205
+ def composite(r):
206
+ return float(r.get("virality_score") or 0)
207
+
208
+ now = datetime.now(timezone.utc)
209
+
210
+ def age_hours(r):
211
+ ts = r.get("tweet_posted_at")
212
+ if not ts:
213
+ return 0.0
214
+ try:
215
+ dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
216
+ return (now - dt).total_seconds() / 3600.0
217
+ except Exception:
218
+ return 0.0
219
+
220
+ def drafted_minutes_ago(r):
221
+ ts = r.get("drafted_at")
222
+ if not ts:
223
+ return -1.0
224
+ try:
225
+ dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
226
+ return (now - dt).total_seconds() / 60.0
227
+ except Exception:
228
+ return -1.0
229
+
230
+ rows.sort(key=composite, reverse=True)
231
+ rows = rows[:25]
232
+
233
+ for r in rows:
234
+ cols = [
235
+ str(r.get("id") or ""),
236
+ str(r.get("tweet_url") or ""),
237
+ str(r.get("author_handle") or ""),
238
+ _sanitize(r.get("tweet_text")),
239
+ f"{float(r.get('virality_score') or 0):g}",
240
+ f"{float(r.get('delta_score') or 0):g}",
241
+ str(r.get("matched_project") or ""),
242
+ str(r.get("search_topic") or ""),
243
+ str(r.get("likes_t1") or ""),
244
+ str(r.get("retweets_t1") or ""),
245
+ str(r.get("replies_t1") or ""),
246
+ str(r.get("views_t1") or ""),
247
+ str(r.get("author_followers") or ""),
248
+ f"{age_hours(r):g}",
249
+ _sanitize(r.get("draft_reply_text")),
250
+ str(r.get("draft_engagement_style") or ""),
251
+ f"{drafted_minutes_ago(r):g}",
252
+ ]
253
+ sys.stdout.write("|".join(cols) + "\n")
254
+ return 0
255
+
256
+
257
+ def main() -> int:
258
+ ap = argparse.ArgumentParser(description="Helper for run-twitter-cycle.sh")
259
+ sub = ap.add_subparsers(dest="cmd", required=True)
260
+
261
+ p_sc = sub.add_parser("status-counts")
262
+ p_sc.add_argument("--batch-id", required=True)
263
+
264
+ p_bc = sub.add_parser("batch-count")
265
+ p_bc.add_argument("--batch-id", required=True)
266
+
267
+ p_bs = sub.add_parser("batch-summary")
268
+ p_bs.add_argument("--batch-id", required=True)
269
+
270
+ p_p0 = sub.add_parser("phase0-salvage")
271
+ p_p0.add_argument("--batch-id", required=True)
272
+ p_p0.add_argument("--freshness-hours", type=int, required=True)
273
+ p_p0.add_argument("--legacy-cutoff", required=True)
274
+
275
+ p_et = sub.add_parser("engaged-tweet-ids")
276
+ p_et.add_argument("--window-hours", type=int, default=48)
277
+
278
+ p_eb = sub.add_parser("expire-batch")
279
+ p_eb.add_argument("--batch-id", required=True)
280
+
281
+ p_ca = sub.add_parser("candidates")
282
+ p_ca.add_argument("--batch-id", required=True)
283
+
284
+ p_cv = sub.add_parser("stamp-cycle-variant")
285
+ p_cv.add_argument("--batch-id", required=True)
286
+ p_cv.add_argument("--variant", required=True)
287
+
288
+ args = ap.parse_args()
289
+
290
+ if args.cmd == "status-counts":
291
+ return cmd_status_counts(args.batch_id)
292
+ if args.cmd == "batch-count":
293
+ return cmd_batch_count(args.batch_id)
294
+ if args.cmd == "batch-summary":
295
+ return cmd_batch_summary(args.batch_id)
296
+ if args.cmd == "phase0-salvage":
297
+ return cmd_phase0_salvage(args.batch_id, args.freshness_hours, args.legacy_cutoff)
298
+ if args.cmd == "engaged-tweet-ids":
299
+ return cmd_engaged_tweet_ids(args.window_hours)
300
+ if args.cmd == "expire-batch":
301
+ return cmd_expire_batch(args.batch_id)
302
+ if args.cmd == "candidates":
303
+ return cmd_candidates(args.batch_id)
304
+ if args.cmd == "stamp-cycle-variant":
305
+ return cmd_stamp_cycle_variant(args.batch_id, args.variant)
306
+ return 1
307
+
308
+
309
+ if __name__ == "__main__":
310
+ sys.exit(main())