@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,215 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ merge_review_queue.py — deliver a DRAFT_ONLY cycle's plan into the approval cards.
4
+
5
+ The deterministic pipeline (run-twitter-cycle.sh DRAFT_ONLY) writes its drafts to
6
+ a per-batch plan file (/tmp/twitter_cycle_plan_<batch>.json) and prints
7
+ `DRAFT_ONLY_PLAN=<path>`. On a customer box NOTHING used to consume that — the
8
+ only writer of the review-queue cards was the (now-removed) host-draft
9
+ submit_drafts path. This script closes that gap: it merges the batch plan's
10
+ candidates into the single review-queue plan the menu-bar cards read, deduped by
11
+ thread/candidate URL, and refreshes the review-request marker the menu bar polls.
12
+
13
+ This is the SAME merge submit_drafts did, reimplemented in Python so the launchd
14
+ kicker (no node/MCP) can run it after the cycle. ONE pipeline, one set of cards.
15
+
16
+ Usage:
17
+ merge_review_queue.py --plan /tmp/twitter_cycle_plan_<batch>.json [--project NAME]
18
+ merge_review_queue.py --plan-from-marker '<stdout containing DRAFT_ONLY_PLAN=...>'
19
+
20
+ State dir (for review-request.json) honors $S4L_STATE_DIR; the review-queue plan
21
+ lives in $S4L_TMP_DIR or /tmp (matching the MCP's planPath()).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import time
32
+
33
+ REVIEW_QUEUE_ID = "review-queue"
34
+
35
+
36
+ def tmp_dir() -> str:
37
+ return os.environ.get("S4L_TMP_DIR") or "/tmp"
38
+
39
+
40
+ def state_dir() -> str:
41
+ return os.environ.get("S4L_STATE_DIR") or os.path.join(
42
+ os.path.expanduser("~"), ".social-autoposter-mcp"
43
+ )
44
+
45
+
46
+ def plan_path(batch_id: str) -> str:
47
+ return os.path.join(tmp_dir(), f"twitter_cycle_plan_{batch_id}.json")
48
+
49
+
50
+ def review_request_path() -> str:
51
+ return os.path.join(state_dir(), "review-request.json")
52
+
53
+
54
+ def _atomic_write(path: str, obj) -> None:
55
+ os.makedirs(os.path.dirname(path), exist_ok=True)
56
+ tmp = f"{path}.tmp.{os.getpid()}"
57
+ with open(tmp, "w") as f:
58
+ json.dump(obj, f, indent=2)
59
+ os.replace(tmp, path)
60
+
61
+
62
+ def _dedup_key(c: dict) -> str:
63
+ """Match submit_drafts: dedup by the thread/candidate URL, else candidate_id."""
64
+ for k in ("candidate_url", "tweet_url", "thread_url", "candidate_id"):
65
+ v = c.get(k)
66
+ if v:
67
+ return str(v)
68
+ # last resort: the reply text, so identical drafts don't double up
69
+ return (c.get("reply_text") or "")[:120]
70
+
71
+
72
+ def _thread_url(c: dict) -> str:
73
+ for k in ("candidate_url", "tweet_url", "thread_url"):
74
+ v = c.get(k)
75
+ if v:
76
+ return str(v)
77
+ return ""
78
+
79
+
80
+ # Discovery-time author/engagement fields stamped onto each plan candidate so the
81
+ # approval card can show them. All already captured on the twitter_candidates row
82
+ # by the discovery pipeline (and refreshed at T1); no scrape happens here.
83
+ STATS_KEYS = (
84
+ "author_handle",
85
+ "author_followers",
86
+ "likes",
87
+ "retweets",
88
+ "replies",
89
+ "views",
90
+ "virality_score",
91
+ "tweet_posted_at",
92
+ )
93
+
94
+
95
+ def _enrich_with_stats(cands: list) -> int:
96
+ """Stamp a `stats` sidecar onto plan candidates that lack one, from the
97
+ twitter_candidates rows the discovery pipeline already wrote. ONE listing
98
+ call (/api/v1/twitter-candidates?tweet_urls=...) covers the whole queue.
99
+ Best-effort: any failure (offline box, missing identity, API error) leaves
100
+ candidates unstamped and NEVER blocks card delivery. Returns count stamped."""
101
+ want = [c for c in cands if not c.get("stats") and not c.get("posted") and _thread_url(c)]
102
+ if not want:
103
+ return 0
104
+ urls = sorted({_thread_url(c) for c in want})[:500]
105
+ try:
106
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
107
+ from http_api import api_get
108
+
109
+ resp = api_get(
110
+ "/api/v1/twitter-candidates",
111
+ query={"tweet_urls": ",".join(urls), "limit": 500},
112
+ )
113
+ rows = (resp.get("data") or {}).get("candidates") or []
114
+ except BaseException as e: # http_api raises SystemExit on terminal failure
115
+ print(f"[merge_review_queue] stats enrichment skipped: {e}", file=sys.stderr)
116
+ return 0
117
+ by_url = {str(r.get("tweet_url")): r for r in rows if r.get("tweet_url")}
118
+ stamped = 0
119
+ for c in want:
120
+ row = by_url.get(_thread_url(c))
121
+ if not row:
122
+ continue
123
+ c["stats"] = {k: row.get(k) for k in STATS_KEYS}
124
+ stamped += 1
125
+ return stamped
126
+
127
+
128
+ def main() -> int:
129
+ ap = argparse.ArgumentParser(description="Merge a DRAFT_ONLY plan into the review-queue cards")
130
+ ap.add_argument("--plan", help="path to the per-batch DRAFT_ONLY plan file")
131
+ ap.add_argument(
132
+ "--plan-from-marker",
133
+ help="text containing a DRAFT_ONLY_PLAN=<path> marker (e.g. cycle stdout)",
134
+ )
135
+ ap.add_argument("--project", default=None, help="project name for the review-request marker")
136
+ ns = ap.parse_args()
137
+
138
+ src = ns.plan
139
+ if not src and ns.plan_from_marker:
140
+ m = re.search(r"DRAFT_ONLY_PLAN=(\S+\.json)", ns.plan_from_marker)
141
+ if m:
142
+ src = m.group(1)
143
+ if not src:
144
+ print("[merge_review_queue] no source plan (need --plan or a DRAFT_ONLY_PLAN marker)", file=sys.stderr)
145
+ return 2
146
+ if not os.path.exists(src):
147
+ print(f"[merge_review_queue] source plan not found: {src}", file=sys.stderr)
148
+ return 2
149
+
150
+ try:
151
+ with open(src) as f:
152
+ batch = json.load(f)
153
+ except Exception as e:
154
+ print(f"[merge_review_queue] could not read source plan: {e}", file=sys.stderr)
155
+ return 2
156
+
157
+ new_cands = batch.get("candidates") or []
158
+ if not new_cands:
159
+ print("[merge_review_queue] source plan has 0 candidates; nothing to merge", file=sys.stderr)
160
+ return 0
161
+
162
+ dst = plan_path(REVIEW_QUEUE_ID)
163
+ existing = []
164
+ if os.path.exists(dst):
165
+ try:
166
+ with open(dst) as f:
167
+ existing = json.load(f).get("candidates") or []
168
+ except Exception:
169
+ existing = []
170
+
171
+ seen = {_dedup_key(c) for c in existing}
172
+ added = 0
173
+ merged = list(existing)
174
+ for c in new_cands:
175
+ k = _dedup_key(c)
176
+ if k in seen:
177
+ continue
178
+ seen.add(k)
179
+ merged.append(c)
180
+ added += 1
181
+
182
+ stamped = _enrich_with_stats(merged)
183
+ if stamped:
184
+ print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
185
+
186
+ _atomic_write(dst, {"candidates": merged})
187
+
188
+ # Refresh the review-request marker the menu bar polls (count = pending, not posted).
189
+ pending = len([c for c in merged if not c.get("posted")])
190
+ project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
191
+ _atomic_write(
192
+ review_request_path(),
193
+ {
194
+ "batch_id": REVIEW_QUEUE_ID,
195
+ "project": project,
196
+ "count": pending,
197
+ "plan_path": dst,
198
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
199
+ },
200
+ )
201
+ print(
202
+ f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
203
+ f"({pending} pending total) from {os.path.basename(src)}",
204
+ file=sys.stderr,
205
+ )
206
+ # Clean up the consumed batch plan so /tmp doesn't fill with orphans.
207
+ try:
208
+ os.remove(src)
209
+ except Exception:
210
+ pass
211
+ return 0
212
+
213
+
214
+ if __name__ == "__main__":
215
+ sys.exit(main())
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """Pre-mint a pool of `post_links` codes for projects whose redirector lives on
3
+ the CLIENT'S domain (external_short_links=true in config.json).
4
+
5
+ Why this exists: for projects where we own the domain (fazm.ai, cyrano.systems,
6
+ etc.) we ship a /r/[code] route via @m13v/seo-components and resolve codes
7
+ live by hitting our DB. For projects where the CLIENT owns the domain and
8
+ doesn't want a PR (Kent: runner.now, agora.xyz, podlog.io), we hand them a
9
+ static CSV of `code -> destination` pairs they drop into their own redirector.
10
+
11
+ The pool is rows in `post_links` with post_id IS NULL AND reply_id IS NULL
12
+ and minted_session LIKE 'pool:%'. When a pipeline posts for a project with
13
+ external_short_links=true, wrap_text_for_post pops the next unclaimed pool
14
+ row matching (project_name, platform) instead of minting a fresh code, so the
15
+ client's CSV stays valid forever (until the pool runs dry, then we top up).
16
+
17
+ Usage:
18
+ python3 scripts/mint_external_pool.py \
19
+ --project Runner --platforms reddit,twitter,linkedin,github_issues,moltbook \
20
+ --per-platform 250
21
+
22
+ python3 scripts/mint_external_pool.py --status # show pool depth
23
+ python3 scripts/mint_external_pool.py --export-csv DIR # write CSVs
24
+ """
25
+
26
+ from __future__ import annotations
27
+ import argparse
28
+ import csv
29
+ import json
30
+ import os
31
+ import secrets
32
+ import sys
33
+ from urllib.parse import urlencode
34
+
35
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
36
+ sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
37
+
38
+ from http_api import api_get, api_post # noqa: E402
39
+ from dm_short_links import CODE_ALPHABET, CODE_LEN, _load_projects # noqa: E402
40
+
41
+ POOL_SESSION_PREFIX = 'pool:'
42
+
43
+
44
+ def _slug(name: str) -> str:
45
+ return ''.join(c.lower() if c.isalnum() else '-' for c in name).strip('-')
46
+
47
+
48
+ def _website(projects: list, project_name: str) -> str:
49
+ for p in projects:
50
+ if p.get('name') == project_name:
51
+ return (p.get('website') or '').rstrip('/')
52
+ raise SystemExit(f"project '{project_name}' not found in config.json")
53
+
54
+
55
+ def _gen_code() -> str:
56
+ return ''.join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
57
+
58
+
59
+ def _build_target(homepage: str, *, platform: str, slug: str, code: str) -> str:
60
+ # Canonical UTM scheme: utm_source='s4l' identifies the agency, utm_term
61
+ # carries the platform. Keep aligned with dm_short_links._build_target_url
62
+ # / _build_target_url_for_post. utm_content stays as <code> so the customer's
63
+ # static-CSV redirector can PostHog-join clicks back to post_links.code.
64
+ params = {
65
+ 'utm_source': 's4l',
66
+ 'utm_medium': 'post',
67
+ 'utm_campaign': slug,
68
+ 'utm_term': platform,
69
+ 'utm_content': code,
70
+ }
71
+ sep = '&' if '?' in homepage else '?'
72
+ return f"{homepage}{sep}{urlencode(params)}"
73
+
74
+
75
+ def mint_pool(*, project_name: str, platforms: list, per_platform: int,
76
+ session_tag: str | None = None) -> dict:
77
+ projects = _load_projects()
78
+ homepage = _website(projects, project_name)
79
+ slug = _slug(project_name)
80
+ session = session_tag or f"{POOL_SESSION_PREFIX}{slug}-{platforms[0] if len(platforms)==1 else 'multi'}"
81
+
82
+ minted = {plat: 0 for plat in platforms}
83
+ skipped = {plat: 0 for plat in platforms}
84
+ for platform in platforms:
85
+ tries = 0
86
+ while minted[platform] < per_platform and tries < per_platform * 3:
87
+ tries += 1
88
+ code = _gen_code()
89
+ target = _build_target(homepage, platform=platform, slug=slug, code=code)
90
+ result = api_post(
91
+ "/api/v1/post-links/mint",
92
+ {
93
+ "code": code,
94
+ "platform": platform,
95
+ "project_name": project_name,
96
+ "target_url": target,
97
+ "kind": "website",
98
+ "project_at_mint": project_name,
99
+ "minted_session": f"{POOL_SESSION_PREFIX}{slug}-{platform}",
100
+ },
101
+ ok_on_conflict=True,
102
+ )
103
+ if not result.get("ok", True):
104
+ # code_collision (409): retry with a fresh random code.
105
+ skipped[platform] += 1
106
+ continue
107
+ minted[platform] += 1
108
+ return {
109
+ 'project': project_name,
110
+ 'homepage': homepage,
111
+ 'minted': minted,
112
+ 'skipped_collisions': skipped,
113
+ 'total_minted': sum(minted.values()),
114
+ }
115
+
116
+
117
+ def pool_status(project_filter: str | None = None) -> list[dict]:
118
+ query = {"project_name": project_filter} if project_filter else None
119
+ resp = api_get("/api/v1/post-links/pool-status", query=query)
120
+ return (resp.get("data") or {}).get("rows") or []
121
+
122
+
123
+ def export_csv(out_dir: str, project_filter: str | None = None) -> dict:
124
+ os.makedirs(out_dir, exist_ok=True)
125
+ query = {"project_name": project_filter} if project_filter else None
126
+ resp = api_get("/api/v1/post-links/pool-export", query=query)
127
+ all_rows = (resp.get("data") or {}).get("rows") or []
128
+
129
+ by_project: dict[str, list[dict]] = {}
130
+ for r in all_rows:
131
+ by_project.setdefault(r['project_name'], []).append(r)
132
+
133
+ written = {}
134
+ for project_name, rows in by_project.items():
135
+ slug = _slug(project_name)
136
+ path = os.path.join(out_dir, f"kent-shortlinks-{slug}.csv")
137
+ with open(path, 'w', newline='') as f:
138
+ w = csv.writer(f)
139
+ w.writerow(['short_path', 'destination_url', 'platform', 'project'])
140
+ for r in rows:
141
+ w.writerow([f"/r/{r['code']}", r['target_url'], r['platform'], project_name])
142
+ written[project_name] = {'path': path, 'rows': len(rows)}
143
+ return written
144
+
145
+
146
+ def main():
147
+ ap = argparse.ArgumentParser(description=__doc__)
148
+ ap.add_argument('--project', help='project_name from config.json')
149
+ ap.add_argument('--platforms', default='reddit,twitter,linkedin,github_issues,moltbook',
150
+ help='comma-separated platforms to seed')
151
+ ap.add_argument('--per-platform', type=int, default=250,
152
+ help='codes to mint per platform (default 250)')
153
+ ap.add_argument('--status', action='store_true', help='print pool depth per project/platform')
154
+ ap.add_argument('--export-csv', metavar='DIR', help='export CSVs to DIR (one per project)')
155
+ args = ap.parse_args()
156
+
157
+ if args.status:
158
+ rows = pool_status(args.project)
159
+ if not rows:
160
+ print('no pool rows found')
161
+ return
162
+ print(f"{'project':<22} {'platform':<14} {'avail':>6} {'claim':>6} {'total':>6} {'last_mint'}")
163
+ for r in rows:
164
+ print(f"{r['project_name']:<22} {r['platform']:<14} {r['available']:>6} {r['claimed']:>6} {r['total']:>6} {r['last_minted']}")
165
+ return
166
+
167
+ if args.export_csv:
168
+ out = export_csv(args.export_csv, args.project)
169
+ print(json.dumps(out, indent=2, default=str))
170
+ return
171
+
172
+ if not args.project:
173
+ ap.error('--project required (unless using --status or --export-csv)')
174
+
175
+ platforms = [p.strip() for p in args.platforms.split(',') if p.strip()]
176
+ result = mint_pool(project_name=args.project, platforms=platforms,
177
+ per_platform=args.per_platform)
178
+ print(json.dumps(result, indent=2, default=str))
179
+
180
+
181
+ if __name__ == '__main__':
182
+ main()
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """Mint Kent's external short-link pool: 10k codes per site, 75% homepage,
3
+ 25% across discovered subpages, distributed evenly across 5 platforms.
4
+
5
+ Designed to be fast (bulk INSERT + ON CONFLICT DO NOTHING, no per-row commit)
6
+ so the full 30k mint completes in seconds rather than the ~20min the legacy
7
+ mint_external_pool.py took for 3,750 rows.
8
+
9
+ Usage:
10
+ python3 scripts/mint_kent_pool.py --dry-run # preview the plan
11
+ python3 scripts/mint_kent_pool.py # mint it
12
+ python3 scripts/mint_kent_pool.py --status # show pool depth by destination
13
+ """
14
+
15
+ from __future__ import annotations
16
+ import argparse
17
+ import json
18
+ import os
19
+ import secrets
20
+ import sys
21
+ from datetime import date
22
+ from typing import Any
23
+ from urllib.parse import urlencode
24
+
25
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26
+ sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
27
+
28
+ from http_api import api_get, api_post # noqa: E402
29
+ from dm_short_links import CODE_ALPHABET, CODE_LEN # noqa: E402
30
+
31
+ PLATFORMS = ['reddit', 'twitter', 'linkedin', 'github_issues', 'moltbook']
32
+ TOTAL_PER_SITE = 10_000
33
+ HOME_FRACTION = 0.75
34
+
35
+ SITE_CONFIG: dict[str, dict[str, Any]] = {
36
+ 'Runner': {
37
+ 'origin': 'https://runner.now',
38
+ 'slug': 'runner',
39
+ 'subpages': [
40
+ '/download/',
41
+ '/workflows/',
42
+ '/apps/',
43
+ '/runner-for-business/',
44
+ '/blog/',
45
+ '/changelog/',
46
+ '/workflows/ai-executive-assistant/',
47
+ '/workflows/morning-founder-briefing/',
48
+ '/workflows/meeting-notes-to-action-items/',
49
+ '/workflows/ai-email-assistant/',
50
+ '/workflows/qualify-inbound-leads-from-gmail-hubspot/',
51
+ '/workflows/stakeholder-research-after-sales-call/',
52
+ '/apps/gmail/',
53
+ '/apps/google-calendar/',
54
+ '/apps/slack/',
55
+ '/apps/notion/',
56
+ '/apps/hubspot/',
57
+ '/apps/granola/',
58
+ '/apps/linear/',
59
+ '/blog/best-ai-apps-2026/',
60
+ ],
61
+ },
62
+ 'Agora': {
63
+ 'origin': 'https://www.agora.xyz',
64
+ 'slug': 'agora',
65
+ 'subpages': [
66
+ '/about',
67
+ '/blogs',
68
+ '/talk-to-our-team',
69
+ '/jobs',
70
+ ],
71
+ },
72
+ 'Podlog': {
73
+ 'origin': 'https://podlog.io',
74
+ 'slug': 'podlog',
75
+ 'subpages': [],
76
+ },
77
+ }
78
+
79
+ POOL_PREFIX = 'pool:'
80
+
81
+
82
+ def _slug_path(path: str) -> str:
83
+ if not path or path == '/':
84
+ return 'home'
85
+ cleaned = path.strip('/').replace('/', '-')
86
+ return cleaned or 'home'
87
+
88
+
89
+ def _build_target(origin: str, path: str, *, platform: str, campaign_slug: str, code: str) -> str:
90
+ base = origin.rstrip('/') + path
91
+ # Canonical UTM scheme: see dm_short_links._build_target_url for rationale.
92
+ params = {
93
+ 'utm_source': 's4l',
94
+ 'utm_medium': 'post',
95
+ 'utm_campaign': campaign_slug,
96
+ 'utm_term': platform,
97
+ 'utm_content': code,
98
+ }
99
+ sep = '&' if '?' in base else '?'
100
+ return f"{base}{sep}{urlencode(params)}"
101
+
102
+
103
+ def _session_tag(today_iso: str, slug: str, path: str, platform: str) -> str:
104
+ return f"{POOL_PREFIX}kent-{today_iso}:{slug}:{_slug_path(path)}:{platform}"
105
+
106
+
107
+ def _gen_unique_codes(n: int, existing: set[str]) -> list[str]:
108
+ out: set[str] = set()
109
+ while len(out) < n:
110
+ c = ''.join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
111
+ if c not in existing and c not in out:
112
+ out.add(c)
113
+ return list(out)
114
+
115
+
116
+ def _plan(per_site: int = TOTAL_PER_SITE, home_frac: float = HOME_FRACTION) -> list[dict]:
117
+ """Compute (project, platform, path, count) tuples for the full mint."""
118
+ rows = []
119
+ per_platform = per_site // len(PLATFORMS)
120
+ home_per_platform = int(per_platform * home_frac)
121
+ subpage_per_platform = per_platform - home_per_platform
122
+ for project, cfg in SITE_CONFIG.items():
123
+ subpages = cfg['subpages']
124
+ if not subpages:
125
+ actual_home = per_platform
126
+ actual_sub_each = 0
127
+ else:
128
+ actual_home = home_per_platform
129
+ actual_sub_each = subpage_per_platform // len(subpages)
130
+ remainder = subpage_per_platform - actual_sub_each * len(subpages)
131
+ actual_home += remainder
132
+ for platform in PLATFORMS:
133
+ rows.append({
134
+ 'project': project,
135
+ 'platform': platform,
136
+ 'path': '/',
137
+ 'count': actual_home,
138
+ })
139
+ for path in subpages:
140
+ rows.append({
141
+ 'project': project,
142
+ 'platform': platform,
143
+ 'path': path,
144
+ 'count': actual_sub_each,
145
+ })
146
+ return rows
147
+
148
+
149
+ def mint_all(*, dry_run: bool = False) -> dict:
150
+ plan = _plan()
151
+ if dry_run:
152
+ per_site_totals: dict[str, int] = {}
153
+ for r in plan:
154
+ per_site_totals[r['project']] = per_site_totals.get(r['project'], 0) + r['count']
155
+ return {
156
+ 'plan_rows': len(plan),
157
+ 'codes_by_site': per_site_totals,
158
+ 'sample': plan[:3] + plan[-3:],
159
+ }
160
+
161
+ today = date.today().isoformat()
162
+ minted = {p: 0 for p in SITE_CONFIG}
163
+ total = 0
164
+ # Server enforces uniqueness via ON CONFLICT (code) DO NOTHING; we only
165
+ # dedup within this run so a single batch never carries two identical codes.
166
+ existing: set[str] = set()
167
+ BATCH = 1000
168
+ for entry in plan:
169
+ project = entry['project']
170
+ platform = entry['platform']
171
+ path = entry['path']
172
+ n = entry['count']
173
+ if n <= 0:
174
+ continue
175
+ cfg = SITE_CONFIG[project]
176
+ slug = cfg['slug']
177
+ session_tag = _session_tag(today, slug, path, platform)
178
+ codes = _gen_unique_codes(n, existing)
179
+ existing.update(codes)
180
+ rows = []
181
+ for code in codes:
182
+ target = _build_target(
183
+ cfg['origin'], path,
184
+ platform=platform, campaign_slug=slug, code=code,
185
+ )
186
+ rows.append({
187
+ "code": code,
188
+ "platform": platform,
189
+ "project_name": project,
190
+ "target_url": target,
191
+ "kind": "website",
192
+ "project_at_mint": project,
193
+ "minted_session": session_tag,
194
+ })
195
+ inserted_here = 0
196
+ for i in range(0, len(rows), BATCH):
197
+ chunk = rows[i:i + BATCH]
198
+ resp = api_post("/api/v1/post-links/mint-batch", {"rows": chunk})
199
+ inserted_here += int((resp.get("data") or {}).get("inserted") or 0)
200
+ minted[project] += inserted_here
201
+ total += inserted_here
202
+ print(f" + {project:<8} {platform:<14} {path:<60} count={inserted_here}", flush=True)
203
+ return {
204
+ 'minted_total': total,
205
+ 'minted_by_project': minted,
206
+ 'session_date': today,
207
+ }
208
+
209
+
210
+ def pool_status_detailed() -> list[dict]:
211
+ resp = api_get(
212
+ "/api/v1/post-links/pool-status",
213
+ query={"session_like": "pool:kent-%", "group_by_session": "1"},
214
+ )
215
+ return (resp.get("data") or {}).get("rows") or []
216
+
217
+
218
+ def main():
219
+ ap = argparse.ArgumentParser(description=__doc__)
220
+ ap.add_argument('--dry-run', action='store_true', help='print the plan, do not write')
221
+ ap.add_argument('--status', action='store_true', help='print pool depth grouped by destination')
222
+ args = ap.parse_args()
223
+
224
+ if args.status:
225
+ rows = pool_status_detailed()
226
+ if not rows:
227
+ print('no kent pool rows found')
228
+ return
229
+ print(f"{'project':<10} {'platform':<14} {'session':<70} {'avail':>6} {'claim':>6}")
230
+ for r in rows:
231
+ sess = (r['minted_session'] or '')[-70:]
232
+ print(f"{r['project_name']:<10} {r['platform']:<14} {sess:<70} {r['available']:>6} {r['claimed']:>6}")
233
+ totals: dict[str, int] = {}
234
+ for r in rows:
235
+ totals[r['project_name']] = totals.get(r['project_name'], 0) + r['available']
236
+ print('---')
237
+ for k, v in sorted(totals.items()):
238
+ print(f" {k}: {v} available")
239
+ return
240
+
241
+ if args.dry_run:
242
+ print(json.dumps(mint_all(dry_run=True), indent=2))
243
+ return
244
+
245
+ print(json.dumps(mint_all(), indent=2, default=str))
246
+
247
+
248
+ if __name__ == '__main__':
249
+ main()