@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,177 @@
1
+ #!/usr/bin/env python3
2
+ """Restore the harness Chrome's Twitter session from the durable cookie mirror.
3
+
4
+ This is the keychain-independent recovery path (Gap B). On a persistent Mac the
5
+ harness Chrome can come up logged out: a hard restart or a macOS keychain
6
+ re-lock can leave Chrome unable to decrypt its cookie store, so it wipes it to
7
+ an empty schema. The cycle preflight calls this to heal that automatically:
8
+
9
+ 1. Attach to the harness Chrome (TWITTER_CDP_URL, default 127.0.0.1:9555 —
10
+ the Mac harness port; AppMaker VMs override it to :9222 via the env file).
11
+ 2. Navigate to x.com/home; if it redirects to /login, the session is gone.
12
+ 3. Load cookies from the local 0600 mirror written on every connect
13
+ (keychain-independent).
14
+ 4. Inject them via CDP Network.setCookies and reload.
15
+ 5. Verify we land on /home (logged in).
16
+
17
+ Idempotent + safe to run every cycle preflight: if already logged in, it's a
18
+ no-op. Exits 0 on logged-in (restored or already), 1 on failure (caller can
19
+ fall back to alerting for a manual re-login).
20
+
21
+ Run: python3 scripts/restore_twitter_session.py
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ import urllib.request
30
+
31
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
32
+
33
+ # Local 0600 cookie mirror — the keychain-independent restore source (Gap B).
34
+ # It is the ONLY cookie source; the VM-era server store
35
+ # (/api/v1/twitter/session-cookies) was removed 2026-06-17. Stdlib-only;
36
+ # guarded so a path quirk never breaks the cycle preflight.
37
+ try:
38
+ import twitter_cookie_mirror # noqa: E402
39
+ except Exception:
40
+ twitter_cookie_mirror = None
41
+
42
+ try:
43
+ from websocket import create_connection
44
+ except ImportError:
45
+ print("restore_twitter_session: websocket-client not installed", file=sys.stderr)
46
+ sys.exit(1)
47
+
48
+ CDP = os.environ.get("TWITTER_CDP_URL", "http://127.0.0.1:9555").rstrip("/")
49
+
50
+
51
+ def _attach():
52
+ targets = json.load(urllib.request.urlopen(f"{CDP}/json", timeout=10))
53
+ page = next((t for t in targets if t.get("type") == "page"), None)
54
+ if not page:
55
+ # create a tab if none
56
+ new = json.load(urllib.request.urlopen(
57
+ urllib.request.Request(f"{CDP}/json/new?about:blank", method="PUT"), timeout=10))
58
+ page = new
59
+ # suppress_origin: Chrome 111+ enforces CDP WebSocket origin checking and
60
+ # rejects the handshake with 403 unless Chrome was launched with
61
+ # --remote-allow-origins. The harness Chrome (twitter-backend.sh) is launched
62
+ # without that flag, so we must suppress the Origin header (localhost CDP is
63
+ # already privileged), matching setup_twitter_auth.py / copy_browser_cookies.py.
64
+ ws = create_connection(page["webSocketDebuggerUrl"], timeout=20, suppress_origin=True)
65
+ state = {"id": 0}
66
+
67
+ def send(method, params=None):
68
+ state["id"] += 1
69
+ ws.send(json.dumps({"id": state["id"], "method": method, "params": params or {}}))
70
+ while True:
71
+ msg = json.loads(ws.recv())
72
+ if msg.get("id") == state["id"]:
73
+ return msg
74
+ return ws, send
75
+
76
+
77
+ def _current_url(send):
78
+ r = send("Runtime.evaluate", {"expression": "location.href", "returnByValue": True})
79
+ return (r.get("result", {}).get("result", {}) or {}).get("value", "") or ""
80
+
81
+
82
+ def _has_auth_cookie(send):
83
+ """The reliable logged-in signal: an auth_token cookie on x.com.
84
+ URL heuristics are unreliable — x.com/ (root) is the logged-OUT landing,
85
+ not a login URL, so a URL-only check false-positives."""
86
+ r = send("Network.getAllCookies")
87
+ cks = r.get("result", {}).get("cookies", []) or []
88
+ return any(
89
+ c.get("name") == "auth_token" and "x.com" in (c.get("domain") or "")
90
+ for c in cks
91
+ )
92
+
93
+
94
+ def _logged_in(send):
95
+ send("Network.enable")
96
+ if _has_auth_cookie(send):
97
+ return True
98
+ # No auth cookie in the current store — navigate to force x.com to set/clear
99
+ # session cookies, then re-check.
100
+ send("Page.enable")
101
+ send("Page.navigate", {"url": "https://x.com/home"})
102
+ for _ in range(15):
103
+ time.sleep(1)
104
+ if _has_auth_cookie(send):
105
+ return True
106
+ u = _current_url(send)
107
+ if "/login" in u or "/i/flow/login" in u or u.rstrip("/") == "https://x.com":
108
+ return False
109
+ return _has_auth_cookie(send)
110
+
111
+
112
+ def _inject(send, cookies) -> int:
113
+ """Inject CDP-shaped cookies via Network.setCookie. Returns accepted count."""
114
+ send("Network.enable")
115
+ ok_count = 0
116
+ for c in cookies:
117
+ params = {k: c[k] for k in (
118
+ "name", "value", "domain", "path", "secure", "httpOnly",
119
+ "sameSite", "expires") if k in c and c[k] is not None}
120
+ r = send("Network.setCookie", params)
121
+ if r.get("result", {}).get("success", True):
122
+ ok_count += 1
123
+ return ok_count
124
+
125
+
126
+ def _stored_cookies():
127
+ """Return (cookies, source) from the LOCAL 0600 mirror, or ([], None).
128
+
129
+ The mirror is the only cookie source; the VM-era server store
130
+ (/api/v1/twitter/session-cookies) was removed 2026-06-17."""
131
+ if twitter_cookie_mirror is not None:
132
+ try:
133
+ mirrored = twitter_cookie_mirror.load_cookies()
134
+ except Exception:
135
+ mirrored = []
136
+ if mirrored:
137
+ return mirrored, f"local mirror ({twitter_cookie_mirror.MIRROR_PATH.name})"
138
+ return [], None
139
+
140
+
141
+ def main():
142
+ try:
143
+ ws, send = _attach()
144
+ except Exception as e:
145
+ print(f"restore_twitter_session: cannot attach to {CDP}: {e}", file=sys.stderr)
146
+ return 1
147
+
148
+ try:
149
+ if _logged_in(send):
150
+ print("restore_twitter_session: already logged in; no-op")
151
+ return 0
152
+
153
+ cookies, source = _stored_cookies()
154
+ if not cookies:
155
+ print("restore_twitter_session: no stored cookies (local mirror empty); "
156
+ "manual connect_x required", file=sys.stderr)
157
+ return 1
158
+
159
+ print(f"restore_twitter_session: logged out, restoring from {source}...")
160
+ ok_count = _inject(send, cookies)
161
+ print(f"restore_twitter_session: injected {ok_count}/{len(cookies)} cookies")
162
+
163
+ if _logged_in(send):
164
+ print(f"restore_twitter_session: RESTORED session from {source}")
165
+ return 0
166
+ print("restore_twitter_session: injection done but still logged out "
167
+ "(cookies may be expired); manual connect_x required", file=sys.stderr)
168
+ return 1
169
+ finally:
170
+ try:
171
+ ws.close()
172
+ except Exception:
173
+ pass
174
+
175
+
176
+ if __name__ == "__main__":
177
+ sys.exit(main())
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ripen_reddit_plan.py
4
+
5
+ Reddit equivalent of Twitter's Phase 2a (T1 re-poll + delta gate). Reads a
6
+ plan JSON written by `post_reddit.py --phase discover`, captures T0 score/comments
7
+ for each target_thread_url, sleeps SLEEP_SECONDS (default 300), re-polls T1,
8
+ computes composite delta = Δupvotes + W_COMMENTS * Δcomments, and drops
9
+ decisions whose composite <= FLOOR (default 5).
10
+
11
+ Survivors are written to --out as a new plan JSON consumed by
12
+ `post_reddit.py --phase post`. Dropped decisions are logged to stderr and
13
+ into the output JSON under `ripen_dropped_details`.
14
+
15
+ Defaults match the design agreed on 2026-05-06, with a 2026-05-10 product-intent
16
+ boost added to mirror the Twitter cycle's hybrid sort:
17
+ raw_composite = Δup + 4*Δcomments
18
+ intent_boost = +5 if title/selftext matches a product-discussion regex
19
+ (asking for a tool, venting a pain, comparing alternatives,
20
+ "anyone know a way to...", etc), else 0
21
+ composite = raw_composite + intent_boost
22
+ floor = composite >= 1 (any positive momentum OR an on-theme
23
+ intent signal passes; +1 upvote OR a clearly stated need
24
+ is enough to reach the LLM relevance gate)
25
+ sleep = 300s (5 min) by default; run-reddit-search.sh sets 1800s
26
+
27
+ Failure modes:
28
+ - T0 fetch fails for a URL: drop that decision (fail-closed; we cannot
29
+ measure delta without T0)
30
+ - All T0 fetches fail: bail with passthrough (likely Reddit-wide rate
31
+ limit; better to post stale than nothing on a bad-network cycle)
32
+ - T1 fetch fails for a URL: drop that decision (same logic)
33
+ """
34
+ import argparse
35
+ import json
36
+ import os
37
+ import re
38
+ import subprocess
39
+ import sys
40
+ import time
41
+
42
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
43
+ SCRIPTS_DIR = os.path.join(REPO_DIR, "scripts")
44
+
45
+ sys.path.insert(0, SCRIPTS_DIR)
46
+
47
+ # Mirrors the Twitter cycle's product-discussion intent regex (run-twitter-cycle.sh
48
+ # Phase 2b). When a thread title or selftext contains an explicit "asking for a
49
+ # tool / venting a pain point / comparing alternatives" signal, add a +5 boost
50
+ # to the composite delta before the floor check. This lets quiet on-theme
51
+ # threads ("anyone know a way to track Claude Code usage?" with 1 upvote and 0
52
+ # new comments in 30 min) compete with viral drama on raw growth.
53
+ _INTENT_REGEX = re.compile(
54
+ r"\b("
55
+ r"wish|need a|need an|looking for|recommend|alternative to|frustrated|"
56
+ r"hate (that|when)|should exist|would pay|missing.*(feature|tool|app)|"
57
+ r"why (is there no|doesn't|don't)|anyone (know|use|tried|using)|"
58
+ r"how do you|what do you use|best (tool|app|way)|any (good|decent) (tool|app|way)"
59
+ r")\b",
60
+ re.IGNORECASE,
61
+ )
62
+ INTENT_BOOST = 5.0
63
+
64
+
65
+ def _intent_boost(title, selftext):
66
+ """Return INTENT_BOOST if the title shows product-discussion intent, else 0.
67
+
68
+ TITLE-ONLY by design. Earlier versions matched title+selftext, but Reddit
69
+ selftext can be 30k chars of narrative (camping ghost stories, long reviews)
70
+ where words like "looking for", "wish", "recommend" appear in their plain-
71
+ English sense ("looking for a spot to hang a bear bag"), causing 30%+ false-
72
+ positive rates. Titles are short, deliberate, and intent-rich; if "anyone
73
+ know" appears in the title, it's almost always a real product ask. The
74
+ `selftext` arg is kept in the signature for future use but ignored today.
75
+
76
+ The LLM relevance gate downstream (post_reddit.py draft phase, surfaces as
77
+ `draft_gate_omit`) is still the real safety net for the small remaining
78
+ false-positive rate. The boost only changes ranking + lets zero-momentum
79
+ on-theme threads clear the floor; it does not auto-post anything.
80
+ """
81
+ if not title:
82
+ return 0.0
83
+ return INTENT_BOOST if _INTENT_REGEX.search(title) else 0.0
84
+
85
+
86
+ def _fetch_thread_text_map(thread_urls):
87
+ """Batch-fetch (thread_title, thread_selftext) via /api/v1/reddit-candidates.
88
+
89
+ Returns {url: (title, selftext)}. Missing rows return ('', '').
90
+ """
91
+ if not thread_urls:
92
+ return {}
93
+ try:
94
+ from http_api import api_get
95
+ # The route accepts a CSV `thread_urls` query param (up to 500 URLs).
96
+ resp = api_get(
97
+ "/api/v1/reddit-candidates",
98
+ query={
99
+ "thread_urls": ",".join(thread_urls),
100
+ "limit": 500,
101
+ },
102
+ )
103
+ rows = ((resp or {}).get("data") or {}).get("candidates") or []
104
+ return {
105
+ r.get("thread_url"): (r.get("thread_title") or "", r.get("thread_selftext") or "")
106
+ for r in rows if r.get("thread_url")
107
+ }
108
+ except Exception as e:
109
+ print(f"[ripen] _fetch_thread_text_map: {e}", file=sys.stderr)
110
+ return {}
111
+
112
+
113
+ def _db_update_ripen_metrics(thread_url, t0_score, t0_comments,
114
+ t1_score, t1_comments, composite, bump_attempt):
115
+ """Persist T0/T1/delta via /api/v1/reddit-candidates/by-thread-url action=set_ripen.
116
+
117
+ Server-side: bump_attempt=True bumps attempt_count, sets
118
+ last_failure_reason='ripen_floor_miss', and flips status='failed' (one-strike
119
+ rule from 2026-05-07). bump_attempt=False just records the metrics.
120
+ """
121
+ if not thread_url:
122
+ return
123
+ try:
124
+ from http_api import api_patch
125
+ api_patch(
126
+ "/api/v1/reddit-candidates/by-thread-url",
127
+ {
128
+ "thread_url": thread_url,
129
+ "action": "set_ripen",
130
+ "score_t0": int(t0_score) if t0_score is not None else None,
131
+ "comments_t0": int(t0_comments) if t0_comments is not None else None,
132
+ "score_t1": int(t1_score) if t1_score is not None else None,
133
+ "comments_t1": int(t1_comments) if t1_comments is not None else None,
134
+ "delta_score": float(composite) if composite is not None else None,
135
+ "bump_attempt": bool(bump_attempt),
136
+ },
137
+ ok_on_404=True,
138
+ )
139
+ except Exception as e:
140
+ print(f"[ripen] WARN: db update failed for {thread_url}: {e}",
141
+ file=sys.stderr)
142
+
143
+
144
+ def _db_load_persisted_t0(urls):
145
+ """Load score_t0 / comments_t0 via /api/v1/reddit-candidates.
146
+
147
+ Returns dict {url: {"score": s, "comments": c, "ok": True}} for every row
148
+ where BOTH score_t0 and comments_t0 are non-null. URLs without persisted
149
+ T0 are absent so callers fall back to a live fetch.
150
+ """
151
+ if not urls:
152
+ return {}
153
+ try:
154
+ from http_api import api_get
155
+ resp = api_get(
156
+ "/api/v1/reddit-candidates",
157
+ query={
158
+ "thread_urls": ",".join(urls),
159
+ "has_t0": "true",
160
+ "limit": 500,
161
+ },
162
+ )
163
+ rows = ((resp or {}).get("data") or {}).get("candidates") or []
164
+ out = {}
165
+ for r in rows:
166
+ url = r.get("thread_url")
167
+ if not url:
168
+ continue
169
+ s = r.get("score_t0")
170
+ c = r.get("comments_t0")
171
+ if s is None or c is None:
172
+ continue
173
+ out[url] = {"score": int(s), "comments": int(c), "ok": True}
174
+ return out
175
+ except Exception as e:
176
+ print(f"[ripen] WARN: load_persisted_t0 failed: {e}",
177
+ file=sys.stderr)
178
+ return {}
179
+
180
+
181
+ def _db_mark_html_locked(thread_url, state):
182
+ """Mark a candidate as permanently failed via the action=mark_html_locked
183
+ lane. The server flips status='failed', sets last_failure_reason='html_<state>',
184
+ and stamps last_attempt_at=NOW().
185
+ """
186
+ if not thread_url:
187
+ return
188
+ try:
189
+ from http_api import api_patch
190
+ api_patch(
191
+ "/api/v1/reddit-candidates/by-thread-url",
192
+ {
193
+ "thread_url": thread_url,
194
+ "action": "mark_html_locked",
195
+ "state": state,
196
+ },
197
+ ok_on_404=True,
198
+ )
199
+ except Exception as e:
200
+ print(f"[ripen] WARN: html_locked db update failed for {thread_url}: {e}",
201
+ file=sys.stderr)
202
+
203
+
204
+ def repoll(urls, timeout=120):
205
+ """Call reddit_tools.py repoll with the given URLs. Returns the parsed
206
+ {"results": {url: {ok, score, comments}}} dict (or {} on hard failure)."""
207
+ if not urls:
208
+ return {}
209
+ payload = json.dumps({"urls": urls})
210
+ try:
211
+ proc = subprocess.run(
212
+ ["python3", os.path.join(SCRIPTS_DIR, "reddit_tools.py"), "repoll"],
213
+ input=payload,
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=timeout,
217
+ )
218
+ except subprocess.TimeoutExpired:
219
+ print(f"[ripen] ERROR: repoll subprocess timeout", file=sys.stderr)
220
+ return {}
221
+ if proc.returncode != 0:
222
+ print(f"[ripen] ERROR: repoll exit={proc.returncode} stderr={proc.stderr[:200]}",
223
+ file=sys.stderr)
224
+ return {}
225
+ try:
226
+ out = json.loads(proc.stdout)
227
+ except json.JSONDecodeError as e:
228
+ print(f"[ripen] ERROR: repoll bad JSON: {e}", file=sys.stderr)
229
+ return {}
230
+ return out.get("results") or {}
231
+
232
+
233
+ def main():
234
+ p = argparse.ArgumentParser()
235
+ p.add_argument("--in", dest="in_path", required=True, help="Input plan JSON path")
236
+ p.add_argument("--out", required=True, help="Output filtered plan JSON path")
237
+ p.add_argument("--floor", type=float, default=1.0,
238
+ help="Composite delta must be GREATER THAN OR EQUAL to this "
239
+ "(default: 1.0). composite = Δup + 4*Δcomments; +1 upvote in 5min "
240
+ "is enough signal that the thread is still alive.")
241
+ p.add_argument("--w-comments", type=float, default=4.0,
242
+ help="Comment weight in composite formula (default: 4.0)")
243
+ p.add_argument("--sleep", type=int, default=300,
244
+ help="Seconds to sleep between T0 and T1 (default: 300)")
245
+ p.add_argument("--no-sleep", action="store_true",
246
+ help="Skip the sleep (for tests)")
247
+ args = p.parse_args()
248
+
249
+ with open(args.in_path) as f:
250
+ plan = json.load(f)
251
+
252
+ decisions = plan.get("decisions") or []
253
+ if not decisions:
254
+ print(f"[ripen] empty plan, passthrough", file=sys.stderr)
255
+ with open(args.out, "w") as f:
256
+ json.dump(plan, f)
257
+ return 0
258
+
259
+ urls = []
260
+ for d in decisions:
261
+ # post_reddit.py writes the field as `thread_url` (not target_thread_url).
262
+ # Tolerate both for safety in case the schema ever changes.
263
+ u = (d.get("thread_url") or d.get("target_thread_url") or "").strip()
264
+ if u:
265
+ urls.append(u)
266
+
267
+ if not urls:
268
+ print(f"[ripen] no thread_urls in {len(decisions)} decisions; passthrough",
269
+ file=sys.stderr)
270
+ with open(args.out, "w") as f:
271
+ json.dump(plan, f)
272
+ return 0
273
+
274
+ # ---- T0 capture ---------------------------------------------------------
275
+ # Always prefer PERSISTED T0 from reddit_candidates (captured at discover
276
+ # time from the search response, no extra HTTP), falling back to a fresh
277
+ # live fetch for URLs that don't have one yet. This unifies the salvage
278
+ # and fresh-discover paths and mirrors twitter's behavior:
279
+ # - Fresh discoveries: T0 was just captured seconds ago at INSERT time,
280
+ # so cumulative delta over the upcoming 5-min sleep ≈ a fresh window.
281
+ # - Salvaged rows: T0 is the FIRST-SIGHTING value (could be hours
282
+ # old), so delta is cumulative since discovery — catches slow-trickle
283
+ # threads a fresh 5-min window would miss.
284
+ # Live fetch fallback only fires for URLs the orchestrator never INSERTed
285
+ # (e.g. legacy tmpfiles from before the candidates migration). Pure
286
+ # safety net.
287
+ is_salvaged = bool(plan.get("salvaged"))
288
+ persisted = _db_load_persisted_t0(urls)
289
+ missing = [u for u in urls if u not in persisted]
290
+ print(f"[ripen] T0: {len(persisted)} from reddit_candidates, "
291
+ f"{len(missing)} need live fetch (salvaged={'yes' if is_salvaged else 'no'})",
292
+ file=sys.stderr)
293
+ if missing:
294
+ live = repoll(missing)
295
+ for u, r in live.items():
296
+ if r.get("ok"):
297
+ persisted[u] = r
298
+ t0_ok = persisted
299
+ if not t0_ok:
300
+ print(f"[ripen] WARN: 0 of {len(urls)} T0 fetches succeeded; "
301
+ "passthrough (likely rate limit)", file=sys.stderr)
302
+ with open(args.out, "w") as f:
303
+ json.dump(plan, f)
304
+ return 0
305
+ print(f"[ripen] T0: {len(t0_ok)}/{len(urls)} succeeded "
306
+ f"(salvaged={'yes' if is_salvaged else 'no'})", file=sys.stderr)
307
+
308
+ # ---- Sleep --------------------------------------------------------------
309
+ if not args.no_sleep:
310
+ print(f"[ripen] sleeping {args.sleep}s for engagement to develop...",
311
+ file=sys.stderr)
312
+ time.sleep(args.sleep)
313
+
314
+ # ---- T1 re-poll ---------------------------------------------------------
315
+ print(f"[ripen] T1: re-fetching {len(t0_ok)} thread(s)...", file=sys.stderr)
316
+ t1 = repoll(list(t0_ok.keys()))
317
+
318
+ # ---- Batch-fetch title+selftext for product-intent boost ----------------
319
+ # Mirrors the Twitter cycle's hybrid sort: a thread asking for a tool /
320
+ # venting a pain point gets +5 added to composite, so quiet on-theme rows
321
+ # clear the floor and rank above pure noise of equivalent raw growth.
322
+ intent_text_map = _fetch_thread_text_map(
323
+ [(d.get("thread_url") or d.get("target_thread_url") or "").strip()
324
+ for d in decisions]
325
+ )
326
+
327
+ # ---- Filter -------------------------------------------------------------
328
+ survivors = []
329
+ drops = []
330
+ for d in decisions:
331
+ url = (d.get("thread_url") or d.get("target_thread_url") or "").strip()
332
+ t0r = t0_ok.get(url)
333
+ t1r = t1.get(url, {}) if t1 else {}
334
+ if not t0r:
335
+ drops.append({"url": url, "reason": "no_t0"})
336
+ continue
337
+ if not t1r.get("ok"):
338
+ drops.append({
339
+ "url": url,
340
+ "reason": f"t1_fail:{t1r.get('error', 'unknown')}",
341
+ })
342
+ continue
343
+ d_up = int(t1r["score"]) - int(t0r["score"])
344
+ d_co = int(t1r["comments"]) - int(t0r["comments"])
345
+ raw_composite = d_up + args.w_comments * d_co
346
+ title, selftext = intent_text_map.get(url, ("", ""))
347
+ intent = _intent_boost(title, selftext)
348
+ composite = raw_composite + intent
349
+ # Annotate decision with measurement (always, even if dropped — useful
350
+ # for downstream analysis/debug). Both raw and boosted composites are
351
+ # surfaced so post-hoc analysis can separate growth signal from intent.
352
+ d["ripen"] = {
353
+ "t0_score": t0r["score"],
354
+ "t0_comments": t0r["comments"],
355
+ "t1_score": t1r["score"],
356
+ "t1_comments": t1r["comments"],
357
+ "delta_up": d_up,
358
+ "delta_comments": d_co,
359
+ "raw_composite": raw_composite,
360
+ "intent_boost": intent,
361
+ "composite": composite,
362
+ "window_sec": args.sleep if not args.no_sleep else 0,
363
+ "floor": args.floor,
364
+ "w_comments": args.w_comments,
365
+ }
366
+ if composite >= args.floor:
367
+ survivors.append(d)
368
+ # Persist T0/T1/delta for the survivor; do NOT bump attempt_count
369
+ # — passing the floor isn't an "attempt" against the post budget.
370
+ _db_update_ripen_metrics(url, t0r["score"], t0r["comments"],
371
+ t1r["score"], t1r["comments"],
372
+ composite, bump_attempt=False)
373
+ print(f"[ripen] PASS composite={composite:.1f} (Δup={d_up}, Δcomm={d_co}) "
374
+ f"{url}", file=sys.stderr)
375
+ else:
376
+ drops.append({
377
+ "url": url,
378
+ "reason": f"composite={composite:.1f} < floor={args.floor}",
379
+ "delta_up": d_up,
380
+ "delta_comments": d_co,
381
+ })
382
+ # Floor miss counts against the candidate's attempt budget so a
383
+ # chronically-flat thread eventually drops out of the salvage
384
+ # rotation. Phase 0's MAX_ATTEMPTS=3 ceiling auto-promotes it.
385
+ _db_update_ripen_metrics(url, t0r["score"], t0r["comments"],
386
+ t1r["score"], t1r["comments"],
387
+ composite, bump_attempt=True)
388
+ print(f"[ripen] DROP composite={composite:.1f} (Δup={d_up}, Δcomm={d_co}) "
389
+ f"{url}", file=sys.stderr)
390
+
391
+ # 2026-05-10: top-k cap removed. The cap was disabled (--top-k 0) since
392
+ # 2026-05-08 because trimming survivors before the LLM relevance gate threw
393
+ # away potentially-good fits below the engagement-velocity cutoff. The
394
+ # final cap now lives in _post_iteration via S4L_REDDIT_MAX_POSTS_PER_CYCLE
395
+ # (default 10), which sorts decisions by ripen composite DESC.
396
+
397
+ # ---- HTML lock pre-flight for delta-gate survivors ----------------------
398
+ # cmd_repoll checks the JSON locked flag, but Reddit's AutoMod sometimes
399
+ # renders .locked-tagline without setting locked=true in the JSON API
400
+ # (observed on r/Entrepreneur). One unauthenticated GET per survivor (~1s).
401
+ # Failures in the lock check are non-fatal: we log a warning and keep the
402
+ # survivor rather than fail-closed on a network blip.
403
+ check_locked_bin = os.path.join(SCRIPTS_DIR, "reddit_tools.py")
404
+ if survivors:
405
+ print(f"[ripen] HTML lock pre-flight for {len(survivors)} survivor(s)...",
406
+ file=sys.stderr)
407
+ clean_survivors = []
408
+ for d in survivors:
409
+ url = (d.get("thread_url") or d.get("target_thread_url") or "").strip()
410
+ try:
411
+ proc = subprocess.run(
412
+ ["python3", check_locked_bin, "check-locked", url],
413
+ capture_output=True, text=True, timeout=20,
414
+ )
415
+ out = json.loads(proc.stdout.strip()) if proc.stdout.strip() else {}
416
+ state = out.get("state", "ok")
417
+ if state in ("locked", "archived"):
418
+ print(f"[ripen] HTML-{state}: dropping survivor {url}",
419
+ file=sys.stderr)
420
+ drops.append({"url": url, "reason": f"html_{state}"})
421
+ # Permanent failure in the queue: Phase 0 salvage skips
422
+ # status='failed', and the dashboard renders the reason
423
+ # via last_failure_reason. No retry on locked threads.
424
+ _db_mark_html_locked(url, state)
425
+ continue
426
+ except Exception as e:
427
+ print(f"[ripen] WARN: check-locked failed for {url}: {e}; keeping survivor",
428
+ file=sys.stderr)
429
+ clean_survivors.append(d)
430
+ survivors = clean_survivors
431
+
432
+ plan["decisions"] = survivors
433
+ plan["ripen_summary"] = {
434
+ "input_count": len(decisions),
435
+ "survivors": len(survivors),
436
+ "drops": len(drops),
437
+ "floor": args.floor,
438
+ "w_comments": args.w_comments,
439
+ "sleep_sec": args.sleep if not args.no_sleep else 0,
440
+ }
441
+ plan["ripen_dropped_details"] = drops
442
+
443
+ with open(args.out, "w") as f:
444
+ json.dump(plan, f)
445
+
446
+ # Compact, parseable summary marker for the dashboard's
447
+ # enrichPostCommentsRedditRuns() in bin/server.js. Field order matters; keep
448
+ # in sync with the regex on the JS side.
449
+ best_composite = None
450
+ best_d_up = None
451
+ best_d_co = None
452
+ for d in survivors:
453
+ rip = d.get("ripen") or {}
454
+ c = rip.get("composite")
455
+ if c is None:
456
+ continue
457
+ if best_composite is None or c > best_composite:
458
+ best_composite = c
459
+ best_d_up = rip.get("delta_up")
460
+ best_d_co = rip.get("delta_comments")
461
+ bc = "" if best_composite is None else f"{best_composite:.1f}"
462
+ bu = "" if best_d_up is None else str(best_d_up)
463
+ bk = "" if best_d_co is None else str(best_d_co)
464
+ print(
465
+ f"[ripen] summary input={len(decisions)} survivors={len(survivors)} "
466
+ f"drops={len(drops)} floor={args.floor} w_comments={args.w_comments} "
467
+ f"window_sec={args.sleep if not args.no_sleep else 0} "
468
+ f"best_composite={bc} best_d_up={bu} best_d_co={bk}",
469
+ file=sys.stderr,
470
+ )
471
+ print(f"[ripen] done: {len(survivors)} survivors, {len(drops)} drops "
472
+ f"(floor>={args.floor}, w_comments={args.w_comments})",
473
+ file=sys.stderr)
474
+ return 0
475
+
476
+
477
+ if __name__ == "__main__":
478
+ sys.exit(main())