@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,555 @@
1
+ #!/usr/bin/env python3
2
+ """run_moltbook_cycle.py — phased MoltBook posting cycle.
3
+
4
+ Reduces volume by gating on:
5
+ 1. Historical (project, style) engagement signal injected into the drafter prompt.
6
+ 2. T0 -> T1 momentum gate: scan threads now, sleep 10 min, re-poll, compute delta.
7
+ 3. Adaptive cap: default 2 posts/cycle, bump to 5 only when >=3 candidates
8
+ show real-time momentum (delta >= threshold).
9
+
10
+ Phase 1: scan hot + new via API, snapshot T0 engagement (in-memory)
11
+ Sleep: --sleep seconds (default 600)
12
+ Phase 2a: re-poll same threads, compute delta
13
+ Phase 2b: Claude picks from top-N pre-filtered candidates, drafts, Python posts
14
+
15
+ Usage:
16
+ python3 scripts/run_moltbook_cycle.py
17
+ python3 scripts/run_moltbook_cycle.py --sleep 300 --dry-run
18
+ """
19
+ import argparse
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ import uuid
26
+ from datetime import datetime
27
+
28
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
29
+ from http_api import api_get, api_post, load_env
30
+ from moltbook_tools import fetch_moltbook_json, MoltbookRateLimitedError
31
+ from engagement_styles import validate_or_register, pick_style_for_post
32
+ from version import read_version as read_autoposter_version
33
+ from project_topics import topics_for_project
34
+
35
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
36
+ SCRIPTS = os.path.join(REPO_DIR, "scripts")
37
+ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
38
+ SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
39
+ MOLTBOOK_POST = os.path.join(SCRIPTS, "moltbook_post.py")
40
+ RUN_CLAUDE = os.path.join(SCRIPTS, "run_claude.sh")
41
+ HISTORICAL = os.path.join(SCRIPTS, "historical_engagement.py")
42
+
43
+ # --- Momentum + cap thresholds (single source of truth, tune here) ----------
44
+ DELTA_THRESHOLD = 5.0 # candidate counts as "high momentum" if delta_score >= this
45
+ HIGH_DELTA_BUMP = 3 # need this many high-momentum candidates to bump cap
46
+ CAP_DEFAULT = 1
47
+ CAP_BUMPED = 1
48
+ CLAUDE_CANDIDATE_LIMIT = 15 # show at most this many candidates to Claude
49
+
50
+
51
+ def log(msg):
52
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
53
+
54
+
55
+ def load_config():
56
+ with open(CONFIG_PATH) as f:
57
+ return json.load(f)
58
+
59
+
60
+ def api_key():
61
+ k = os.environ.get("MOLTBOOK_API_KEY")
62
+ if k:
63
+ return k
64
+ env_file = os.path.join(REPO_DIR, ".env")
65
+ if os.path.exists(env_file):
66
+ with open(env_file) as f:
67
+ for line in f:
68
+ if line.startswith("MOLTBOOK_API_KEY="):
69
+ return line.strip().split("=", 1)[1]
70
+ print("ERROR: MOLTBOOK_API_KEY not set", file=sys.stderr)
71
+ sys.exit(1)
72
+
73
+
74
+ def fetch_sorted(kind, api_key_, limit=50):
75
+ """kind: 'hot' or 'new'. Returns list of post dicts."""
76
+ url = f"https://www.moltbook.com/api/v1/posts?sort={kind}&limit={limit}"
77
+ data = fetch_moltbook_json(url, api_key=api_key_)
78
+ if not data:
79
+ return []
80
+ return data.get("posts", []) or data.get("data", []) or []
81
+
82
+
83
+ def fetch_one(post_id, api_key_):
84
+ """Re-fetch a single post for T1 measurement."""
85
+ url = f"https://www.moltbook.com/api/v1/posts/{post_id}"
86
+ data = fetch_moltbook_json(url, api_key=api_key_)
87
+ if not data:
88
+ return None
89
+ return data.get("post") or data
90
+
91
+
92
+ def already_posted_thread_ids(thread_ids):
93
+ """Return the subset we've already commented on, to exclude.
94
+
95
+ The old single SQL OR-LIKE query is replaced by one posts GET per
96
+ thread_id (thread_url_contains). thread_ids is this cycle's candidate
97
+ set (scan-limit, ~50), so the request count stays bounded.
98
+ """
99
+ if not thread_ids:
100
+ return set()
101
+ hit = set()
102
+ for tid in thread_ids:
103
+ resp = api_get(
104
+ "/api/v1/posts",
105
+ query={
106
+ "platform": "moltbook",
107
+ "thread_url_contains": tid,
108
+ "limit": 1,
109
+ },
110
+ )
111
+ rows = ((resp or {}).get("data") or {}).get("posts") or []
112
+ if rows:
113
+ hit.add(tid)
114
+ return hit
115
+
116
+
117
+ def snapshot(post):
118
+ pid = post.get("id")
119
+ return {
120
+ "id": pid,
121
+ "title": post.get("title", ""),
122
+ "content": (post.get("content") or ""),
123
+ "author": (post.get("user") or {}).get("username") or post.get("author") or "",
124
+ "submolt": (post.get("submolt") or {}).get("name") or post.get("submolt_name") or "",
125
+ "url": f"https://www.moltbook.com/post/{pid}",
126
+ "upvotes_t0": int(post.get("upvote_count") or post.get("upvotes") or 0),
127
+ "comments_t0": int(post.get("comment_count") or post.get("comments_count") or 0),
128
+ "created_at": post.get("created_at") or "",
129
+ }
130
+
131
+
132
+ def delta_score(t0_up, t0_cm, t1_up, t1_cm):
133
+ """Weight comments higher than upvotes (rarer, stronger signal)."""
134
+ return 2.0 * max(t1_up - t0_up, 0) + 5.0 * max(t1_cm - t0_cm, 0)
135
+
136
+
137
+ def build_prompt(candidates, cap, history_block, styles_block, projects_json):
138
+ cand_block = []
139
+ for i, c in enumerate(candidates, 1):
140
+ cand_block.append(
141
+ f"--- #{i} id={c['id']} delta={c['delta_score']:.1f} "
142
+ f"(up {c['upvotes_t0']}->{c['upvotes_t1']}, "
143
+ f"cm {c['comments_t0']}->{c['comments_t1']}) ---\n"
144
+ f"submolt: {c['submolt']} author: {c['author']}\n"
145
+ f"title: {c['title']}\n"
146
+ f"body: {c['content']}\n"
147
+ f"url: {c['url']}\n"
148
+ )
149
+ candidates_text = "\n".join(cand_block)
150
+
151
+ return f"""You are the Social Autoposter reviewing MoltBook candidates for commenting.
152
+
153
+ Read {SKILL_FILE} for content rules (agent voice, no em dashes, anti-AI).
154
+
155
+ ## Pre-filtered candidates (top {len(candidates)} by 10-minute engagement delta)
156
+
157
+ {candidates_text}
158
+
159
+ ## Project configs
160
+ {projects_json}
161
+
162
+ {styles_block}
163
+
164
+ {history_block}
165
+
166
+ ## YOUR JOB
167
+
168
+ Pick AT MOST {cap} candidates and draft a comment for each. **Post fewer than {cap} if
169
+ fewer than {cap} are genuinely on-brand.** Better to skip than to force a comment.
170
+
171
+ Rules:
172
+ - Skip candidates whose submolt/title are mbc20/crypto/spam or have no plausible angle.
173
+ - For each kept candidate, pick the ONE best-fit project from the config.
174
+ - Choose an engagement_style from the styles block.
175
+ - **Consult the historical engagement table above.** If a (project, style) pair has
176
+ the [dead] label (>=5 past posts, median engagement 0), avoid that pair unless the
177
+ thread is an unusually good fit. Prefer [good] pairs when plausible.
178
+ - Draft the comment in agent voice (\"my human\" not \"I\"), match the thread's language.
179
+ - Apply the matched project's `voice` block: follow `voice.tone`, never violate `voice.never`, mirror `voice.examples` / `voice.examples_good` when present.
180
+ - Comments must add a concrete, thread-relevant point. Do not paste generic product pitches.
181
+
182
+ ## OUTPUT FORMAT
183
+
184
+ Return ONLY a single JSON object, no prose, with this exact shape:
185
+
186
+ ```json
187
+ {{
188
+ "posts": [
189
+ {{
190
+ "thread_id": "<candidate id>",
191
+ "thread_url": "<candidate url>",
192
+ "thread_title": "<candidate title>",
193
+ "thread_author": "<candidate author>",
194
+ "matched_project": "<project name from config>",
195
+ "engagement_style": "<one of the valid styles, or your invented name>",
196
+ "new_style": null,
197
+ "language": "<detected language, e.g. en>",
198
+ "comment_text": "<the actual comment to post>"
199
+ }}
200
+ ],
201
+ "skipped": [
202
+ {{ "thread_id": "<id>", "reason": "<short reason>" }}
203
+ ]
204
+ }}
205
+ ```
206
+
207
+ If, and ONLY if, none of the listed styles fits, you may invent a new style.
208
+ To do so, set `engagement_style` to your new name (snake_case) AND replace the
209
+ `new_style: null` with a populated block:
210
+
211
+ ```json
212
+ "new_style": {{
213
+ "description": "<what this style is, in one sentence>",
214
+ "example": "<a short example utterance>",
215
+ "note": "<when to use, when not to>",
216
+ "why_existing_didnt_fit": "<which existing style was closest, and why it didn't fit>"
217
+ }}
218
+ ```
219
+
220
+ If the engagement_style matches one of the listed styles, leave `new_style` as null.
221
+ Inventing should be rare; prefer an existing style if it's even 80% right.
222
+
223
+ CRITICAL: Do NOT call moltbook_post.py or any Bash tool. Only return the JSON.
224
+ The orchestrator will post and log."""
225
+
226
+
227
+ def parse_claude_json(output):
228
+ # Claude's JSON sits inside a "result" field of its structured output.
229
+ try:
230
+ outer = json.loads(output)
231
+ result = outer.get("result", "") if isinstance(outer, dict) else ""
232
+ except Exception:
233
+ result = output
234
+ # result is a string containing either a JSON object or a fenced ```json block
235
+ m = result
236
+ start = m.find("{")
237
+ if start < 0:
238
+ return None
239
+ depth = 0
240
+ in_str = False
241
+ esc = False
242
+ end = -1
243
+ for i in range(start, len(m)):
244
+ ch = m[i]
245
+ if in_str:
246
+ if esc:
247
+ esc = False
248
+ elif ch == "\\":
249
+ esc = True
250
+ elif ch == '"':
251
+ in_str = False
252
+ continue
253
+ if ch == '"':
254
+ in_str = True
255
+ elif ch == "{":
256
+ depth += 1
257
+ elif ch == "}":
258
+ depth -= 1
259
+ if depth == 0:
260
+ end = i
261
+ break
262
+ if end < 0:
263
+ return None
264
+ try:
265
+ return json.loads(m[start : end + 1])
266
+ except Exception:
267
+ return None
268
+
269
+
270
+ def post_and_log(decisions, claude_session_id):
271
+ """Iterate Claude's picks, call moltbook_post.py, log each to DB."""
272
+ posted = 0
273
+ failed = 0
274
+
275
+ for p in decisions.get("posts", []):
276
+ tid = p.get("thread_id")
277
+ text = p.get("comment_text", "").strip()
278
+ if not tid or not text:
279
+ failed += 1
280
+ continue
281
+
282
+ try:
283
+ proc = subprocess.run(
284
+ ["python3", MOLTBOOK_POST, "comment", "--post-id", tid, "--content", text],
285
+ capture_output=True, text=True, timeout=120,
286
+ )
287
+ except Exception as e:
288
+ log(f" post error for {tid}: {e}")
289
+ failed += 1
290
+ continue
291
+
292
+ if proc.returncode != 0:
293
+ log(f" post failed rc={proc.returncode} for {tid}: {proc.stderr.strip()[:200]}")
294
+ failed += 1
295
+ continue
296
+
297
+ # moltbook_post prints a final JSON line with url + comment_id
298
+ our_url = ""
299
+ for line in reversed(proc.stdout.strip().splitlines()):
300
+ line = line.strip()
301
+ if line.startswith("{"):
302
+ try:
303
+ js = json.loads(line)
304
+ our_url = js.get("url", "")
305
+ break
306
+ except Exception:
307
+ continue
308
+
309
+ # Validate or register the engagement_style. In USE mode any drifted
310
+ # style label is coerced back to style_assignment["style"]; in INVENT
311
+ # mode the new_style block is registered into
312
+ # engagement_styles_registry via the s4l API (replaces the legacy
313
+ # file-based sidecar). The picker's choice is set once for the whole
314
+ # batch above.
315
+ validated_style, style_action = validate_or_register(
316
+ p,
317
+ source_post={
318
+ "platform": "moltbook",
319
+ "post_url": our_url or p.get("thread_url", ""),
320
+ "post_id": None,
321
+ "model": p.get("model"),
322
+ },
323
+ assigned_style=(style_assignment or {}).get("style"),
324
+ assigned_mode=(style_assignment or {}).get("mode"),
325
+ )
326
+
327
+ # POST /api/v1/posts requires a valid http(s) our_url for active rows
328
+ # (it derives thread_author_handle from thread_author and hardcodes
329
+ # feedback_report_used=TRUE, so those are omitted here). A blank
330
+ # our_url would 400 and crash the loop; the comment is already live,
331
+ # so on the rare parse miss we log a warning and skip the DB row
332
+ # rather than abort the cycle. `project` is the endpoint's key name.
333
+ if not our_url:
334
+ log(f" WARNING: posted to {tid} but could not parse our_url; "
335
+ f"skipping DB log for this row")
336
+ posted += 1
337
+ continue
338
+ api_post(
339
+ "/api/v1/posts",
340
+ {
341
+ "platform": "moltbook",
342
+ "thread_url": p.get("thread_url", ""),
343
+ "thread_author": p.get("thread_author", "various"),
344
+ "thread_title": p.get("thread_title", ""),
345
+ "thread_content": "",
346
+ "our_url": our_url,
347
+ "our_content": text,
348
+ "our_account": "matthew-autoposter",
349
+ "source_summary": "moltbook cycle comment",
350
+ "project": p.get("matched_project", ""),
351
+ "engagement_style": validated_style or "",
352
+ "language": p.get("language", "en"),
353
+ "status": "active",
354
+ "claude_session_id": claude_session_id,
355
+ "autoposter_version": read_autoposter_version(),
356
+ },
357
+ ok_on_conflict=True,
358
+ )
359
+ posted += 1
360
+ style_tag = validated_style or "(none)"
361
+ if style_action == "registered":
362
+ style_tag += " [REGISTERED candidate]"
363
+ log(f" posted to {tid} project={p.get('matched_project')} style={style_tag}")
364
+
365
+ return posted, failed
366
+
367
+
368
+ def main():
369
+ parser = argparse.ArgumentParser()
370
+ parser.add_argument("--sleep", type=int, default=600)
371
+ parser.add_argument("--scan-limit", type=int, default=50)
372
+ parser.add_argument("--dry-run", action="store_true")
373
+ args = parser.parse_args()
374
+
375
+ run_start = time.time()
376
+ log(f"=== MoltBook Cycle: sleep={args.sleep}s, scan-limit={args.scan_limit} ===")
377
+
378
+ # --- Phase 0: context ---------------------------------------------------
379
+ config = load_config()
380
+ def _project_record(p):
381
+ rec = {k: p.get(k) for k in ("description", "website", "voice")}
382
+ rec["search_topics"] = list(topics_for_project(p.get("name") or ""))
383
+ return rec
384
+ projects_json = json.dumps(
385
+ {p["name"]: _project_record(p)
386
+ for p in config.get("projects", [])
387
+ if p.get("weight", 0) > 0
388
+ and "moltbook" not in (p.get("platforms_disabled") or [])},
389
+ indent=2,
390
+ )
391
+
392
+ try:
393
+ history_block = subprocess.run(
394
+ ["python3", HISTORICAL, "--platform", "moltbook"],
395
+ capture_output=True, text=True, timeout=30,
396
+ ).stdout
397
+ except Exception:
398
+ history_block = "## Historical engagement\n(unavailable)\n"
399
+
400
+ try:
401
+ styles_block = subprocess.run(
402
+ ["bash", "-c", f"source {REPO_DIR}/skill/styles.sh && generate_styles_block moltbook posting"],
403
+ capture_output=True, text=True, timeout=15,
404
+ ).stdout
405
+ except Exception:
406
+ styles_block = ""
407
+
408
+ key = api_key()
409
+
410
+ # --- Phase 1: scan T0 ---------------------------------------------------
411
+ log("Phase 1: scanning MoltBook hot + new...")
412
+ try:
413
+ hot = fetch_sorted("hot", key, limit=args.scan_limit)
414
+ new = fetch_sorted("new", key, limit=args.scan_limit)
415
+ except MoltbookRateLimitedError as e:
416
+ log(f"MoltBook rate-limited, aborting cycle: {e.reset_seconds}s")
417
+ return 2
418
+
419
+ seen = {}
420
+ for p in (hot + new):
421
+ snap = snapshot(p)
422
+ if snap["id"] and snap["id"] not in seen:
423
+ seen[snap["id"]] = snap
424
+
425
+ candidates = list(seen.values())
426
+ log(f"Phase 1: {len(candidates)} unique candidates scanned.")
427
+
428
+ # Exclude threads we've already commented on
429
+ posted_before = already_posted_thread_ids([c["id"] for c in candidates])
430
+ candidates = [c for c in candidates if c["id"] not in posted_before]
431
+ log(f"Phase 1: {len(candidates)} after excluding already-posted ({len(posted_before)} filtered).")
432
+
433
+ if not candidates:
434
+ log("No candidates. Exiting.")
435
+ return 0
436
+
437
+ # --- Sleep --------------------------------------------------------------
438
+ log(f"Sleeping {args.sleep}s before T1 re-measurement...")
439
+ time.sleep(args.sleep)
440
+
441
+ # --- Phase 2a: re-poll T1 ----------------------------------------------
442
+ log("Phase 2a: re-polling T1 engagement...")
443
+ for c in candidates:
444
+ try:
445
+ t1 = fetch_one(c["id"], key)
446
+ except MoltbookRateLimitedError as e:
447
+ log(f" rate-limited mid re-poll ({e.reset_seconds}s), using T0 data for remaining")
448
+ break
449
+ if not t1:
450
+ c["upvotes_t1"] = c["upvotes_t0"]
451
+ c["comments_t1"] = c["comments_t0"]
452
+ c["delta_score"] = 0.0
453
+ continue
454
+ c["upvotes_t1"] = int(t1.get("upvote_count") or t1.get("upvotes") or c["upvotes_t0"])
455
+ c["comments_t1"] = int(t1.get("comment_count") or t1.get("comments_count") or c["comments_t0"])
456
+ c["delta_score"] = delta_score(c["upvotes_t0"], c["comments_t0"], c["upvotes_t1"], c["comments_t1"])
457
+
458
+ for c in candidates:
459
+ c.setdefault("upvotes_t1", c["upvotes_t0"])
460
+ c.setdefault("comments_t1", c["comments_t0"])
461
+ c.setdefault("delta_score", 0.0)
462
+
463
+ # --- Phase 2b: adaptive cap + Claude ------------------------------------
464
+ high_delta = [c for c in candidates if c["delta_score"] >= DELTA_THRESHOLD]
465
+ cap = CAP_BUMPED if len(high_delta) >= HIGH_DELTA_BUMP else CAP_DEFAULT
466
+ log(f"Phase 2b: {len(high_delta)} candidates with delta >= {DELTA_THRESHOLD} "
467
+ f"-> cap = {cap}")
468
+
469
+ candidates.sort(key=lambda c: c["delta_score"], reverse=True)
470
+ top = candidates[:CLAUDE_CANDIDATE_LIMIT]
471
+ log(f"Phase 2b: showing Claude top {len(top)} by delta, cap = {cap}")
472
+ for c in top:
473
+ log(f" #{c['id']} delta={c['delta_score']:.1f} "
474
+ f"t0={c['upvotes_t0']}up/{c['comments_t0']}cm "
475
+ f"t1={c['upvotes_t1']}up/{c['comments_t1']}cm")
476
+
477
+ if args.dry_run:
478
+ log("Dry run: skipping Claude + post.")
479
+ for c in top[:cap]:
480
+ log(f" would consider #{c['id']} delta={c['delta_score']:.1f} title={c['title'][:60]}")
481
+ return 0
482
+
483
+ claude_session_id = str(uuid.uuid4())
484
+ os.environ["CLAUDE_SESSION_ID"] = claude_session_id
485
+ # 2026-05-22: pick the engagement style for this draft batch so
486
+ # validate_or_register can coerce any drifted engagement_style label
487
+ # back to the picker's choice. Moltbook batches share one assignment
488
+ # per cycle (same pattern as github batches; cycles run often enough
489
+ # that the picker's distribution averages out). The styles_block in
490
+ # the prompt still shows the legacy menu because the prompt is built
491
+ # by a shell helper; the enforcement happens at the validate step.
492
+ style_assignment = pick_style_for_post("moltbook", context="posting")
493
+ log(f"Style assignment for this batch: mode={style_assignment.get('mode')} "
494
+ f"style={style_assignment.get('style') or '(invent)'}")
495
+ prompt = build_prompt(top, cap, history_block, styles_block, projects_json)
496
+
497
+ log("Phase 2b: invoking Claude for drafting...")
498
+ try:
499
+ proc = subprocess.run(
500
+ [RUN_CLAUDE, "run-moltbook-cycle",
501
+ "--strict-mcp-config",
502
+ "--mcp-config", os.path.expanduser("~/.claude/browser-agent-configs/no-agents-mcp.json"),
503
+ "-p", "--output-format", "json", prompt],
504
+ capture_output=True, text=True, timeout=900,
505
+ )
506
+ except subprocess.TimeoutExpired:
507
+ log("Claude timed out after 900s")
508
+ return 1
509
+
510
+ if proc.returncode != 0:
511
+ log(f"Claude exited rc={proc.returncode}: {proc.stderr[-500:]}")
512
+ return 1
513
+
514
+ decisions = parse_claude_json(proc.stdout)
515
+ if not decisions:
516
+ log("Could not parse Claude JSON output.")
517
+ log(f"Last 500 chars of output: {proc.stdout[-500:]}")
518
+ return 1
519
+
520
+ log(f"Claude picked {len(decisions.get('posts', []))} posts, "
521
+ f"skipped {len(decisions.get('skipped', []))}.")
522
+
523
+ posted, failed = post_and_log(decisions, claude_session_id)
524
+
525
+ elapsed = int(time.time() - run_start)
526
+ log(f"=== Cycle complete: posted={posted}, failed={failed}, elapsed={elapsed}s ===")
527
+
528
+ # Fetch real Claude cost from the session we ran (orchestrator/SDK billing).
529
+ cycle_cost = 0.0
530
+ try:
531
+ _resp = api_get(
532
+ "/api/v1/claude-sessions/cost",
533
+ query={"session_id": claude_session_id},
534
+ )
535
+ cycle_cost = float(((_resp or {}).get("data") or {}).get("parent_cost") or 0.0)
536
+ except Exception as _e:
537
+ log(f"WARNING: could not fetch session cost: {_e}")
538
+
539
+ # Log cycle summary to the run tracking table
540
+ try:
541
+ subprocess.run(
542
+ ["python3", os.path.join(SCRIPTS, "log_run.py"),
543
+ "--script", "run-moltbook-cycle",
544
+ "--posted", str(posted), "--skipped", str(len(decisions.get("skipped", []))),
545
+ "--failed", str(failed), "--cost", f"{cycle_cost:.4f}", "--elapsed", str(elapsed)],
546
+ timeout=15,
547
+ )
548
+ except Exception:
549
+ pass
550
+
551
+ return 0
552
+
553
+
554
+ if __name__ == "__main__":
555
+ sys.exit(main())