@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
+ #!/opt/homebrew/bin/python3.11
2
+ """Pick which Instagram account should post next.
3
+
4
+ Mirrors scripts/pick_project.py: inverse-recent-share weighting over
5
+ enabled `instagram.accounts` entries in config.json. Effective weight =
6
+ config_weight / (1 + posts in the last `recent_window_days`). An account
7
+ that has been posting heavily damps toward under-posted ones; never
8
+ selected above its raw weight; settles toward the configured weight ratio
9
+ over time.
10
+
11
+ Usage:
12
+ pick_ig_account.py # print chosen username
13
+ pick_ig_account.py --json # full account record as JSON
14
+ pick_ig_account.py --account NAME # force a specific account (must be enabled)
15
+ pick_ig_account.py --show-weights # diagnostic table of weights vs recent posts
16
+ pick_ig_account.py --list # all enabled accounts, JSON array
17
+
18
+ Exit codes:
19
+ 0 picked successfully
20
+ 2 no enabled accounts (returns the legacy single-account default if any
21
+ account is found, else exits 2)
22
+ 3 --account requested an unknown / disabled account
23
+ 4 config / DB error
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import random
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ from http_api import api_get
35
+
36
+ CONFIG_PATH = Path.home() / "social-autoposter" / "config.json"
37
+ ENV_PATH = Path.home() / "social-autoposter" / ".env"
38
+
39
+
40
+ def load_env():
41
+ env = {}
42
+ if ENV_PATH.exists():
43
+ for ln in ENV_PATH.read_text().splitlines():
44
+ ln = ln.strip()
45
+ if ln and not ln.startswith("#") and "=" in ln:
46
+ k, v = ln.split("=", 1)
47
+ env[k.strip()] = v.strip()
48
+ return env
49
+
50
+
51
+ def load_ig_cfg():
52
+ cfg = json.loads(CONFIG_PATH.read_text())
53
+ ig = cfg.get("instagram") or {}
54
+ accounts = ig.get("accounts") or []
55
+ window_days = int(ig.get("recent_window_days", 7))
56
+ return accounts, window_days
57
+
58
+
59
+ def recent_posts_by_account(window_days):
60
+ """Return {target_account: post count over last `window_days`} from
61
+ media_posts WHERE status='posted' AND posted_urls ? 'instagram'.
62
+
63
+ media_posts is the per-platform table for IG; we don't need the unified
64
+ `posts` table here because all IG posts route through this pipeline.
65
+ Served via the HTTP API (account-post-counts) so no DATABASE_URL is needed.
66
+ """
67
+ resp = api_get(
68
+ "/api/v1/media-posts/account-post-counts",
69
+ query={"window_days": int(window_days)},
70
+ )
71
+ return (resp.get("data") or {}).get("account_counts") or {}
72
+
73
+
74
+ def pick_account(accounts, window_days):
75
+ """Inverse-recent-share weighted draw from enabled accounts.
76
+
77
+ Effective weight is `posts_per_day * weight` (defaults: posts_per_day=
78
+ global instagram.posts_per_account_per_day, weight=1). Dividing by
79
+ (1 + recent_posts) damps over-posting toward the target rate.
80
+ """
81
+ enabled = [a for a in accounts if a.get("enabled") and float(a.get("weight", 0)) > 0]
82
+ if not enabled:
83
+ return None, {}, {}
84
+ counts = recent_posts_by_account(window_days)
85
+ # Pull global default for posts_per_day fallback.
86
+ cfg = json.loads(CONFIG_PATH.read_text())
87
+ global_ppd = int((cfg.get("instagram") or {}).get("posts_per_account_per_day", 5))
88
+ effective = {
89
+ a["username"]: (
90
+ float(a["weight"]) * float(a.get("posts_per_day", global_ppd))
91
+ ) / (1 + counts.get(a["username"], 0))
92
+ for a in enabled
93
+ }
94
+ names = list(effective.keys())
95
+ ws = [effective[n] for n in names]
96
+ chosen_name = random.choices(names, weights=ws, k=1)[0]
97
+ chosen = next(a for a in enabled if a["username"] == chosen_name)
98
+ return chosen, counts, effective
99
+
100
+
101
+ def main():
102
+ ap = argparse.ArgumentParser(description="Pick next IG account to post for")
103
+ ap.add_argument("--json", action="store_true", help="emit full account record")
104
+ ap.add_argument("--account", help="force a specific account (must be enabled)")
105
+ ap.add_argument("--show-weights", action="store_true", help="diagnostic table")
106
+ ap.add_argument("--list", action="store_true", help="list enabled accounts as JSON array")
107
+ args = ap.parse_args()
108
+
109
+ accounts, window_days = load_ig_cfg()
110
+
111
+ if args.list:
112
+ enabled = [a for a in accounts if a.get("enabled")]
113
+ print(json.dumps(enabled, indent=2))
114
+ return
115
+
116
+ if args.show_weights:
117
+ counts = recent_posts_by_account(window_days)
118
+ cfg = json.loads(CONFIG_PATH.read_text())
119
+ global_ppd = int((cfg.get("instagram") or {}).get("posts_per_account_per_day", 5))
120
+ print(f"{'Account':25} {'Enabled':>8} {'Weight':>7} {'PPD':>5} {'Recent':>7} {'Effective':>10}")
121
+ print("-" * 70)
122
+ for a in accounts:
123
+ ppd = int(a.get("posts_per_day", global_ppd))
124
+ eff = (
125
+ (float(a.get("weight", 0)) * ppd) / (1 + counts.get(a["username"], 0))
126
+ if a.get("enabled") else 0
127
+ )
128
+ print(
129
+ f"{a['username']:25} {str(a.get('enabled', False)):>8} "
130
+ f"{a.get('weight', 0):>7} {ppd:>5} {counts.get(a['username'], 0):>7} "
131
+ f"{eff:>10.3f}"
132
+ )
133
+ return
134
+
135
+ if args.account:
136
+ match = next(
137
+ (a for a in accounts if a.get("username", "").lower() == args.account.lower()),
138
+ None,
139
+ )
140
+ if not match:
141
+ sys.stderr.write(f"unknown account: {args.account}\n")
142
+ sys.exit(3)
143
+ if not match.get("enabled"):
144
+ sys.stderr.write(f"account disabled: {args.account}\n")
145
+ sys.exit(3)
146
+ chosen = match
147
+ else:
148
+ chosen, _, _ = pick_account(accounts, window_days)
149
+ if chosen is None:
150
+ # Legacy fallback: if config has no enabled accounts but the
151
+ # single-account env vars exist, fall back to matt_diak so a
152
+ # misconfigured config doesn't take the pipeline down. Exit 2
153
+ # signals the caller it was a fallback so the harness can log.
154
+ env = load_env()
155
+ if env.get("IG_USER_ID") and env.get("IG_LONG_TOKEN"):
156
+ sys.stderr.write(
157
+ "no enabled accounts in config; falling back to legacy IG_USER_ID/IG_LONG_TOKEN as 'matt_diak'\n"
158
+ )
159
+ chosen = {
160
+ "username": "matt_diak",
161
+ "ig_user_id_env": "IG_USER_ID",
162
+ "ig_long_token_env": "IG_LONG_TOKEN",
163
+ "weight": 1,
164
+ "enabled": True,
165
+ }
166
+ else:
167
+ sys.stderr.write("no enabled accounts and no legacy env vars\n")
168
+ sys.exit(2)
169
+
170
+ if args.json:
171
+ print(json.dumps(chosen, indent=2))
172
+ else:
173
+ print(chosen["username"])
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """Pick which project(s) to post about. Shared across every platform.
3
+
4
+ Inverse-recent-share weighting: a project's selection weight is its config
5
+ `weight` divided by (1 + its posts in the last RECENT_WINDOW_DAYS), so a
6
+ project that has been posting heavily is dampened toward under-posted ones
7
+ (but never selected above its raw weight). Single-pick (pick_project) and
8
+ multi-pick (pick_projects / --count N) share one code path, so Twitter,
9
+ GitHub and Reddit all select projects the same way.
10
+
11
+ Usage:
12
+ python3 scripts/pick_project.py # one project, any platform
13
+ python3 scripts/pick_project.py --platform reddit # one project for a platform
14
+ python3 scripts/pick_project.py --json # one project, full JSON
15
+ python3 scripts/pick_project.py --platform twitter --count 8 --json # N projects, JSON array
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import random
22
+ import sys
23
+
24
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
+
26
+ from project_topics import topics_for_project # noqa: E402
27
+
28
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
29
+
30
+ # Rolling window (days) for inverse-recent-share weighting in pick_projects().
31
+ RECENT_WINDOW_DAYS = 7
32
+
33
+
34
+ def load_config():
35
+ with open(CONFIG_PATH) as f:
36
+ return json.load(f)
37
+
38
+
39
+ def _counts_via_api(platform=None):
40
+ from http_api import api_get
41
+ query = {"platform": platform} if platform else None
42
+ resp = api_get("/api/v1/posts/counts-today-by-project", query=query)
43
+ data = (resp or {}).get("data") or {}
44
+ counts = data.get("counts") or {}
45
+ return {k: int(v) for k, v in counts.items()}
46
+
47
+
48
+ def get_posts_today_by_project(platform=None):
49
+ """Return dict of project_name -> post count for today.
50
+
51
+ Routes through /api/v1/posts/counts-today-by-project (HTTP-only).
52
+ """
53
+ return _counts_via_api(platform)
54
+
55
+
56
+ def _recent_counts_via_api(platform=None, days=RECENT_WINDOW_DAYS):
57
+ from http_api import api_get
58
+ query = {"days": str(int(days))}
59
+ if platform:
60
+ query["platform"] = platform
61
+ resp = api_get("/api/v1/posts/counts-by-project-window", query=query)
62
+ data = (resp or {}).get("data") or {}
63
+ counts = data.get("counts") or {}
64
+ return {k: int(v) for k, v in counts.items()}
65
+
66
+
67
+ def recent_posts_by_project(platform=None, days=RECENT_WINDOW_DAYS):
68
+ """Return {project_name: post count} over the last `days` days.
69
+
70
+ Routes through /api/v1/posts/counts-by-project-window (HTTP-only).
71
+ Feeds the inverse-recent-share weighting in pick_projects().
72
+ """
73
+ return _recent_counts_via_api(platform, days)
74
+
75
+
76
+ def _eligible_pool(config, platform=None, exclude=None):
77
+ """Projects eligible for selection: enabled, weight>0, platform-compatible."""
78
+ pool = [
79
+ p for p in config.get("projects", [])
80
+ if p.get("enabled", True) and p.get("weight", 0) > 0
81
+ ]
82
+ if exclude:
83
+ excluded = {n.lower() for n in exclude}
84
+ pool = [p for p in pool if p.get("name", "").lower() not in excluded]
85
+ # Explicit per-project platforms_disabled deny list.
86
+ if platform:
87
+ pool = [p for p in pool if platform not in (p.get("platforms_disabled") or [])]
88
+ # twitter/linkedin/github draft a search query, so they need seed topics
89
+ # (DB-backed project_search_topics, post 2026-05-27 config.json removal).
90
+ if platform in ("twitter", "linkedin", "github"):
91
+ pool = [p for p in pool if topics_for_project(p.get("name") or "")]
92
+ return pool
93
+
94
+
95
+ def pick_projects(config, platform=None, n=1, exclude=None):
96
+ """Pick up to `n` distinct projects. Shared by every platform's pipeline.
97
+
98
+ Inverse-recent-share weighting: effective_weight = weight / (1 + posts in
99
+ the last RECENT_WINDOW_DAYS). Sampled without replacement, so a project
100
+ that has been posting heavily is dampened in favor of under-posted ones,
101
+ but a project is never selected above its raw `weight`. Returns a list of
102
+ project dicts (shorter than `n` only when the eligible pool is smaller).
103
+ """
104
+ pool = _eligible_pool(config, platform, exclude)
105
+ if not pool:
106
+ return []
107
+ counts = recent_posts_by_project(platform)
108
+ chosen = []
109
+ remaining = list(pool)
110
+ for _ in range(min(n, len(remaining))):
111
+ weights = [p["weight"] / (1 + counts.get(p["name"], 0)) for p in remaining]
112
+ idx = random.choices(range(len(remaining)), weights=weights, k=1)[0]
113
+ chosen.append(remaining.pop(idx))
114
+ return chosen
115
+
116
+
117
+ def pick_project(config, platform=None, exclude=None):
118
+ """Pick a single project. Thin wrapper around pick_projects() kept for the
119
+ existing callers (post_reddit.py, the bare CLI, --json, etc.)."""
120
+ picks = pick_projects(config, platform, n=1, exclude=exclude)
121
+ if picks:
122
+ return picks[0]
123
+ if exclude:
124
+ return None
125
+ # No eligible project at all: legacy fallback to any project in config.
126
+ projects = config.get("projects", [])
127
+ return random.choice(projects) if projects else None
128
+
129
+
130
+ def main():
131
+ parser = argparse.ArgumentParser(description="Pick next project to post about")
132
+ parser.add_argument("--platform", default=None, help="Platform to check distribution for")
133
+ parser.add_argument("--json", action="store_true", help="Output full project config as JSON")
134
+ parser.add_argument("--project", default=None, help="Select a specific project by name")
135
+ parser.add_argument("--show-weights", action="store_true", help="Show all projects and their current distribution")
136
+ parser.add_argument("--distribution", action="store_true", help="Show compact distribution for LLM prompts")
137
+ parser.add_argument("--exclude", default=None, help="Comma-separated project names to exclude from picking")
138
+ parser.add_argument("--count", type=int, default=1, help="Number of projects to pick; >1 emits a JSON array")
139
+ args = parser.parse_args()
140
+
141
+ exclude = None
142
+ if args.exclude:
143
+ exclude = [n.strip() for n in args.exclude.split(",") if n.strip()]
144
+
145
+ config = load_config()
146
+
147
+ if args.distribution:
148
+ projects = config.get("projects", [])
149
+ weighted = [p for p in projects if p.get("weight", 0) > 0]
150
+ if args.platform:
151
+ weighted = [p for p in weighted if args.platform not in (p.get("platforms_disabled") or [])]
152
+ total_weight = sum(p.get("weight", 0) for p in weighted)
153
+ counts = get_posts_today_by_project(args.platform)
154
+ lines = []
155
+ for p in sorted(weighted, key=lambda x: x["weight"], reverse=True):
156
+ target_pct = (p["weight"] / total_weight * 100) if total_weight else 0
157
+ actual = counts.get(p["name"], 0)
158
+ lines.append(f"{p['name']}: {actual} posts today (target {target_pct:.0f}%)")
159
+ print("\n".join(lines))
160
+ return
161
+
162
+ if args.show_weights:
163
+ projects = config.get("projects", [])
164
+ weighted = [p for p in projects if p.get("weight", 0) > 0]
165
+ if args.platform:
166
+ weighted = [p for p in weighted if args.platform not in (p.get("platforms_disabled") or [])]
167
+ total_weight = sum(p.get("weight", 0) for p in weighted)
168
+ counts = get_posts_today_by_project(args.platform)
169
+ total_posts = sum(counts.values()) or 1
170
+
171
+ print(f"{'Project':25} {'Weight':>8} {'Target%':>8} {'Today':>6} {'Actual%':>8} {'Deficit':>8}")
172
+ print("-" * 73)
173
+ for p in sorted(weighted, key=lambda x: x["weight"], reverse=True):
174
+ target_pct = (p["weight"] / total_weight * 100) if total_weight else 0
175
+ actual = counts.get(p["name"], 0)
176
+ actual_pct = (actual / total_posts * 100) if total_posts > 0 else 0
177
+ deficit = target_pct - actual_pct
178
+ print(f"{p['name']:25} {p['weight']:>8} {target_pct:>7.1f}% {actual:>6} {actual_pct:>7.1f}% {deficit:>+7.1f}%")
179
+ return
180
+
181
+ if args.count and args.count > 1:
182
+ picks = pick_projects(config, args.platform, n=args.count, exclude=exclude)
183
+ print(json.dumps(picks, indent=2))
184
+ return
185
+
186
+ if args.project:
187
+ project = None
188
+ for p in config.get("projects", []):
189
+ if p.get("name", "").lower() == args.project.lower():
190
+ project = p
191
+ break
192
+ if not project:
193
+ print(f"Unknown project: {args.project}", file=sys.stderr)
194
+ sys.exit(1)
195
+ else:
196
+ project = pick_project(config, args.platform, exclude=exclude)
197
+ if project is None:
198
+ print("No eligible project (all excluded)", file=sys.stderr)
199
+ sys.exit(2)
200
+
201
+ if args.json:
202
+ print(json.dumps(project, indent=2))
203
+ else:
204
+ print(project["name"])
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()