@m13v/s4l 1.6.197-rc.10

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 +1336 -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 +513 -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,99 @@
1
+ #!/usr/bin/env python3
2
+ """One-shot: create recurring accelerator-application reminders in
3
+ matt@mediar.ai's Google Calendar via DWD (same SA as the gmail keepalive),
4
+ requesting a calendar scope. If the scope isn't authorized in the mediar.ai
5
+ Workspace DWD config, this fails with unauthorized_client and we fall back.
6
+ """
7
+ import json, time, urllib.parse, urllib.request, urllib.error
8
+ import google.auth
9
+ from google.auth.transport.requests import Request
10
+ from googleapiclient.discovery import build
11
+
12
+ SA_EMAIL = "gmail-dwd-impersonator@gmail-api-integration-486018.iam.gserviceaccount.com"
13
+ TARGET_USER = "matt@mediar.ai"
14
+ SCOPE = "https://www.googleapis.com/auth/calendar"
15
+
16
+
17
+ def mint_access_token():
18
+ creds, _ = google.auth.default(
19
+ scopes=["https://www.googleapis.com/auth/cloud-platform"]
20
+ )
21
+ creds.refresh(Request())
22
+ iat = int(time.time()); exp = iat + 3600
23
+ claim = {"iss": SA_EMAIL, "sub": TARGET_USER, "scope": SCOPE,
24
+ "aud": "https://oauth2.googleapis.com/token", "iat": iat, "exp": exp}
25
+ iam = build("iamcredentials", "v1", credentials=creds, cache_discovery=False)
26
+ signed = iam.projects().serviceAccounts().signJwt(
27
+ name=f"projects/-/serviceAccounts/{SA_EMAIL}",
28
+ body={"payload": json.dumps(claim)}).execute()
29
+ body = urllib.parse.urlencode({
30
+ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
31
+ "assertion": signed["signedJwt"]}).encode()
32
+ req = urllib.request.Request("https://oauth2.googleapis.com/token", data=body,
33
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=30) as resp:
36
+ return json.loads(resp.read())["access_token"]
37
+ except urllib.error.HTTPError as e:
38
+ raise RuntimeError(f"token exchange {e.code}: {e.read().decode()}") from None
39
+
40
+
41
+ def main():
42
+ from google.oauth2.credentials import Credentials
43
+ token = mint_access_token()
44
+ cal = build("calendar", "v3", credentials=Credentials(token=token),
45
+ cache_discovery=False)
46
+
47
+ events = [
48
+ {
49
+ "summary": "Apply to a16z Speedrun (next cohort) - S4L",
50
+ "start": "2026-09-15",
51
+ "rrule": "RRULE:FREQ=MONTHLY;INTERVAL=4",
52
+ "description": (
53
+ "Reapply S4L to a16z Speedrun. Speedrun runs ~3 cohorts/year; "
54
+ "this fires every 4 months so you catch the next deadline.\n\n"
55
+ "Apply: https://speedrun.a16z.com/apply (start with email i@m13v.com)\n"
56
+ "Status / update existing app: https://speedrun.a16z.com/application-login\n\n"
57
+ "Reuse the saved answer set (pitch, traction, funding, founder bio). "
58
+ "Last filled 2026-06-03; still need citizenship, university, years of "
59
+ "experience, last-round date, and the 3 investor emails."
60
+ ),
61
+ },
62
+ {
63
+ "summary": "Apply to PearX (next batch) - S4L",
64
+ "start": "2026-10-01",
65
+ "rrule": "RRULE:FREQ=MONTHLY;INTERVAL=6",
66
+ "description": (
67
+ "Reapply S4L to PearX. Pear runs 2 batches/year (summer + winter); "
68
+ "this fires every 6 months for the next window.\n\n"
69
+ "Apply: https://pear.vc/pearx-application/ (Airtable form; long-text "
70
+ "fields are contenteditable divs)\n\n"
71
+ "Reuse the saved answer set. PearX S26 app was filled 2026-06-03 "
72
+ "(left for review, not submitted)."
73
+ ),
74
+ },
75
+ ]
76
+
77
+ created = []
78
+ for ev in events:
79
+ body = {
80
+ "summary": ev["summary"],
81
+ "description": ev["description"],
82
+ "start": {"date": ev["start"]},
83
+ "end": {"date": ev["start"]},
84
+ "recurrence": [ev["rrule"]],
85
+ "reminders": {"useDefault": False, "overrides": [
86
+ {"method": "popup", "minutes": 24 * 60},
87
+ {"method": "email", "minutes": 24 * 60},
88
+ ]},
89
+ "transparency": "transparent",
90
+ }
91
+ out = cal.events().insert(calendarId="primary", body=body).execute()
92
+ created.append((ev["summary"], out.get("htmlLink")))
93
+
94
+ for s, link in created:
95
+ print(f"CREATED: {s}\n {link}")
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
@@ -0,0 +1,141 @@
1
+ """Single source of truth for the posting account on every platform.
2
+
3
+ Resolution order for each platform (first non-empty wins):
4
+
5
+ 1. Env var `AUTOPOSTER_<PLATFORM>_HANDLE` (used by the VM / per-account
6
+ systemd or launchd units to override config.json without rewriting the
7
+ checked-in file). Twitter retains the legacy `AUTOPOSTER_TWITTER_HANDLE`
8
+ name as an alias.
9
+ 2. The matching field in `config.json` -> `accounts.<platform>.<field>`.
10
+
11
+ The handle is normalized:
12
+ - leading `@` is stripped (twitter)
13
+ - leading `u/` is stripped (reddit)
14
+ - surrounding whitespace is stripped
15
+ So both `@matt_diak` and `matt_diak` resolve to `matt_diak`, both
16
+ `u/Deep_Ad1959` and `Deep_Ad1959` resolve to `Deep_Ad1959`, matching the
17
+ canonical shape stored in `posts.our_account` after the 2026-05-20 migration.
18
+
19
+ Returns None if neither source has a value. Callers should treat None as
20
+ "unknown account" and decline to scope per-account work that needs a handle
21
+ (e.g. dedupe filters).
22
+
23
+ Platform key map:
24
+ twitter -> accounts.twitter.handle (env: AUTOPOSTER_TWITTER_HANDLE)
25
+ reddit -> accounts.reddit.username (env: AUTOPOSTER_REDDIT_USERNAME)
26
+ linkedin -> accounts.linkedin.name (env: AUTOPOSTER_LINKEDIN_NAME)
27
+ github -> accounts.github.username (env: AUTOPOSTER_GITHUB_USERNAME)
28
+ moltbook -> accounts.moltbook.username (env: AUTOPOSTER_MOLTBOOK_USERNAME)
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ from functools import lru_cache
35
+ from typing import Optional
36
+
37
+ _PLATFORM_CONFIG_FIELD = {
38
+ "twitter": ("twitter", "handle"),
39
+ "x": ("twitter", "handle"), # alias for the canonical post-platform
40
+ "reddit": ("reddit", "username"),
41
+ "linkedin": ("linkedin", "name"),
42
+ "github": ("github", "username"),
43
+ "moltbook": ("moltbook", "username"),
44
+ }
45
+
46
+ _PLATFORM_ENV_NAME = {
47
+ "twitter": "AUTOPOSTER_TWITTER_HANDLE",
48
+ "x": "AUTOPOSTER_TWITTER_HANDLE",
49
+ "reddit": "AUTOPOSTER_REDDIT_USERNAME",
50
+ "linkedin": "AUTOPOSTER_LINKEDIN_NAME",
51
+ "github": "AUTOPOSTER_GITHUB_USERNAME",
52
+ "moltbook": "AUTOPOSTER_MOLTBOOK_USERNAME",
53
+ }
54
+
55
+
56
+ def normalize(handle: Optional[str]) -> Optional[str]:
57
+ """Canonicalize a raw account handle.
58
+
59
+ Drops leading `@` (twitter) and `u/` (reddit) plus surrounding
60
+ whitespace. Returns None for empty input.
61
+ """
62
+ if not handle:
63
+ return None
64
+ h = handle.strip()
65
+ if h.startswith("@"):
66
+ h = h[1:]
67
+ elif h.lower().startswith("u/"):
68
+ h = h[2:]
69
+ h = h.strip()
70
+ return h or None
71
+
72
+
73
+ @lru_cache(maxsize=1)
74
+ def _load_config() -> dict:
75
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
76
+ cfg_path = os.path.join(repo_root, "config.json")
77
+ try:
78
+ with open(cfg_path, "r", encoding="utf-8") as f:
79
+ return json.load(f) or {}
80
+ except (OSError, json.JSONDecodeError):
81
+ return {}
82
+
83
+
84
+ def resolve(platform: str) -> Optional[str]:
85
+ """Return the normalized posting handle for `platform`, or None."""
86
+ key = (platform or "").strip().lower()
87
+ if key not in _PLATFORM_CONFIG_FIELD:
88
+ return None
89
+
90
+ env_name = _PLATFORM_ENV_NAME[key]
91
+ env_value = normalize(os.environ.get(env_name))
92
+ if env_value:
93
+ return env_value
94
+
95
+ section, field = _PLATFORM_CONFIG_FIELD[key]
96
+ cfg = _load_config()
97
+ accounts = cfg.get("accounts") or {}
98
+ block = accounts.get(section) or {}
99
+ return normalize(block.get(field))
100
+
101
+
102
+ def require(platform: str) -> str:
103
+ """Like resolve() but raises if no handle is configured."""
104
+ h = resolve(platform)
105
+ if not h:
106
+ section, field = _PLATFORM_CONFIG_FIELD.get(
107
+ (platform or "").lower(), ("?", "?")
108
+ )
109
+ env_name = _PLATFORM_ENV_NAME.get(
110
+ (platform or "").lower(), f"AUTOPOSTER_{platform.upper()}_HANDLE"
111
+ )
112
+ raise RuntimeError(
113
+ f"No account configured for platform={platform!r}. "
114
+ f"Set env {env_name} or accounts.{section}.{field} in config.json."
115
+ )
116
+ return h
117
+
118
+
119
+ # Backwards-compatible shim so the existing twitter-only call site keeps
120
+ # working without churn. `from twitter_account import resolve_handle` will
121
+ # continue to work; new code should call `account_resolver.resolve('twitter')`.
122
+ def resolve_handle() -> Optional[str]:
123
+ return resolve("twitter")
124
+
125
+
126
+ def require_handle() -> str:
127
+ return require("twitter")
128
+
129
+
130
+ if __name__ == "__main__":
131
+ import sys
132
+ if len(sys.argv) > 1:
133
+ plat = sys.argv[1]
134
+ else:
135
+ plat = "twitter"
136
+ h = resolve(plat)
137
+ if h:
138
+ sys.stdout.write(h + "\n")
139
+ sys.exit(0)
140
+ sys.stderr.write(f"no handle configured for platform={plat}\n")
141
+ sys.exit(1)
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch active campaigns for a given platform with budget remaining.
3
+
4
+ A campaign is "active" when:
5
+ - status = 'active'
6
+ - its platforms list includes the requested platform
7
+ - max_posts_total is set AND posts_made < max_posts_total
8
+
9
+ Campaigns without max_posts_total are ignored by this script on purpose.
10
+ Every campaign must declare a lifetime cap to be considered.
11
+
12
+ Usage:
13
+ python3 active_campaigns.py --platform reddit # prompt block (stdout)
14
+ python3 active_campaigns.py --platform reddit --json # machine-readable
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+
24
+
25
+ def _get_active_via_api(platform):
26
+ from http_api import api_get
27
+ resp = api_get(
28
+ "/api/v1/campaigns",
29
+ query={
30
+ "status": "active",
31
+ "platform": platform,
32
+ "with_budget_remaining": "true",
33
+ "limit": 500,
34
+ },
35
+ )
36
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
37
+ out = []
38
+ for r in rows:
39
+ max_total = r.get("max_posts_total")
40
+ posts_made = r.get("posts_made") or 0
41
+ if max_total is None or posts_made >= max_total:
42
+ continue
43
+ out.append({
44
+ "id": int(r["id"]),
45
+ "name": r.get("name"),
46
+ "prompt": r.get("prompt"),
47
+ "max_posts_total": int(max_total),
48
+ "posts_made": int(posts_made),
49
+ "remaining": int(max_total) - int(posts_made),
50
+ })
51
+ return out
52
+
53
+
54
+ def get_active_campaigns(platform):
55
+ """Active campaigns for `platform` with budget remaining.
56
+
57
+ Routes through /api/v1/campaigns (HTTP-only).
58
+ """
59
+ return _get_active_via_api(platform)
60
+
61
+
62
+ def format_prompt_block(campaigns, repo_dir):
63
+ if not campaigns:
64
+ return ""
65
+
66
+ ids_csv = ",".join(str(c["id"]) for c in campaigns)
67
+ lines = []
68
+ lines.append("## ACTIVE CAMPAIGNS (mandatory for every post this run)")
69
+ lines.append("")
70
+ lines.append("The following campaign instructions override your defaults. Follow them exactly.")
71
+ lines.append("")
72
+
73
+ for c in campaigns:
74
+ lines.append(f"### CAMPAIGN id={c['id']} name={c['name']}")
75
+ lines.append(f"Lifetime budget: {c['remaining']} of {c['max_posts_total']} posts remaining.")
76
+ lines.append("Instruction:")
77
+ lines.append(c["prompt"])
78
+ lines.append("")
79
+
80
+ lines.append("## REQUIRED campaign attribution (do this for EVERY post you create)")
81
+ lines.append("")
82
+ lines.append("1. When inserting the post row, use `INSERT INTO posts (...) VALUES (...) RETURNING id;` to capture NEW_POST_ID.")
83
+ lines.append("2. Immediately after, run this shell command to attach the post to the active campaigns:")
84
+ lines.append("")
85
+ lines.append(f" python3 {repo_dir}/scripts/campaign_bump.py --post-id NEW_POST_ID --campaign-ids {ids_csv}")
86
+ lines.append("")
87
+ lines.append("This is mandatory. If you skip it, the campaign counter does not advance and the campaign will over-post.")
88
+ return "\n".join(lines)
89
+
90
+
91
+ def main():
92
+ ap = argparse.ArgumentParser()
93
+ ap.add_argument("--platform", required=True)
94
+ ap.add_argument("--json", action="store_true")
95
+ ap.add_argument("--repo-dir", default=os.path.expanduser("~/social-autoposter"))
96
+ args = ap.parse_args()
97
+
98
+ campaigns = get_active_campaigns(args.platform)
99
+
100
+ if args.json:
101
+ print(json.dumps({
102
+ "platform": args.platform,
103
+ "active_count": len(campaigns),
104
+ "campaign_ids": ",".join(str(c["id"]) for c in campaigns),
105
+ "campaigns": campaigns,
106
+ }))
107
+ else:
108
+ block = format_prompt_block(campaigns, args.repo_dir)
109
+ if block:
110
+ print(block)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """Who is actually using social-autoposter right now.
3
+
4
+ Reads the `installations` heartbeat table (the only live per-install signal) and
5
+ answers "how many real, external people are active" without the inflation that a
6
+ raw install count carries:
7
+
8
+ * install_id is per identity.json, NOT per machine. A reinstall / reset / each
9
+ ephemeral mk0r E2B sandbox mints a fresh id. So we dedupe by `hardware_uuid`
10
+ (the stable per-machine key) and report MACHINES, not install rows.
11
+ * Our own infra (i@m13v.com operator Mac, the agent@mk0r.com / e2b.local VM
12
+ fleet) is filtered out by default so the roster is real customers. Pass
13
+ --all to include it.
14
+ * Cross-references the `posts` table so you can see the alive-but-not-posting
15
+ gap (the blind spot the Cloud Logging stream exists to explain).
16
+
17
+ Usage:
18
+ python3 scripts/active_users.py # external machines, last 7d
19
+ python3 scripts/active_users.py --days 30 # different window
20
+ python3 scripts/active_users.py --all # include our own infra
21
+ python3 scripts/active_users.py --json # machine-readable
22
+
23
+ Operator-local only: uses the direct-Postgres lane via scripts/db.py (absent in
24
+ the shipped npm package), reading DATABASE_URL from ~/social-autoposter/.env.
25
+ """
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import sys
31
+ from urllib.parse import unquote
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ from db import load_env, get_conn # noqa: E402
35
+
36
+ # Our own installs, hidden by default so the roster is real external users.
37
+ INTERNAL_EMAILS = {"i@m13v.com", "agent@mk0r.com", "matt@mediar.ai"}
38
+ INTERNAL_HOSTNAME_SUBSTR = ("e2b.local", "71522") # mk0r E2B sandboxes; MacStadium QA box
39
+ # MacStadium remote QA box (hostname "71522", no git_email). It actively runs the
40
+ # pipeline and posts, so without this it masquerades as our only posting customer.
41
+ INTERNAL_HARDWARE_UUIDS = {"07CB793D-6E32-5EF8-82E2-7CDEABD47FBC"}
42
+
43
+ # Connected X handle resolves only from posts.our_account; drop scaffolding values.
44
+ PLACEHOLDER_HANDLES = {"your-twitter-handle", "your_handle", "your-handle", "none", "null", ""}
45
+
46
+
47
+ def parse_handles(raw):
48
+ out = []
49
+ for h in (raw or "").split(","):
50
+ h = h.strip().lstrip("@")
51
+ if h and h.lower() not in PLACEHOLDER_HANDLES and h not in out:
52
+ out.append(h)
53
+ return out
54
+
55
+
56
+ def is_internal(emails, hostnames, hardware_uuids):
57
+ if any((e or "").lower() in INTERNAL_EMAILS for e in emails):
58
+ return True
59
+ if any(sub in (h or "") for h in hostnames for sub in INTERNAL_HOSTNAME_SUBSTR):
60
+ return True
61
+ if any((u or "") in INTERNAL_HARDWARE_UUIDS for u in hardware_uuids):
62
+ return True
63
+ return False
64
+
65
+
66
+ def fetch(days):
67
+ # One row per MACHINE (hardware_uuid; fall back to a per-install key when the
68
+ # client never reported a hardware_uuid so those installs aren't all merged).
69
+ # `days` is an argparse int (injection-safe), inlined because the wrapper's
70
+ # SQL translation mangles %s placeholders.
71
+ days = int(days)
72
+ q = f"""
73
+ WITH win AS (
74
+ SELECT *,
75
+ COALESCE(NULLIF(git_email, ''), NULLIF(hardware_uuid, ''),
76
+ 'anon:' || install_id::text) AS entity_key
77
+ FROM installations
78
+ WHERE last_seen_at > now() - interval '{days} days'
79
+ ),
80
+ posted AS (
81
+ SELECT install_id, count(*) AS n
82
+ FROM posts
83
+ WHERE posted_at > now() - interval '{days} days' AND install_id IS NOT NULL
84
+ GROUP BY install_id
85
+ ),
86
+ handles AS (
87
+ -- The connected X handle is NOT in the heartbeat; it only reaches the
88
+ -- central DB via posts.our_account, so it exists ONLY for installs that
89
+ -- ever posted (all-time, not windowed: a handle is identity, not activity).
90
+ SELECT install_id, string_agg(DISTINCT our_account, ',') AS hs
91
+ FROM posts
92
+ WHERE our_account IS NOT NULL AND length(trim(our_account)) > 0
93
+ GROUP BY install_id
94
+ )
95
+ SELECT
96
+ w.entity_key,
97
+ count(DISTINCT w.install_id) AS installs,
98
+ count(DISTINCT w.hardware_uuid) AS machines,
99
+ array_remove(array_agg(DISTINCT w.hardware_uuid), NULL) AS hardware_uuids,
100
+ array_remove(array_agg(DISTINCT NULLIF(w.git_email, '')), NULL) AS emails,
101
+ array_remove(array_agg(DISTINCT w.hostname), NULL) AS hostnames,
102
+ string_agg(DISTINCT h.hs, ',') AS handles_raw,
103
+ max(w.os_version) AS os,
104
+ array_remove(array_agg(DISTINCT
105
+ w.last_country || '/' || COALESCE(w.last_city, '-')), NULL) AS locations,
106
+ max(w.last_seen_at) AS last_seen,
107
+ COALESCE(sum(p.n), 0) AS posts
108
+ FROM win w
109
+ LEFT JOIN posted p ON p.install_id = w.install_id
110
+ LEFT JOIN handles h ON h.install_id = w.install_id
111
+ GROUP BY w.entity_key
112
+ ORDER BY last_seen DESC;
113
+ """
114
+ conn = get_conn()
115
+ try:
116
+ cur = conn.execute(q)
117
+ cols = [c.name for c in cur.description]
118
+ return [dict(zip(cols, row)) for row in cur.fetchall()]
119
+ finally:
120
+ conn.close()
121
+
122
+
123
+ def person(row):
124
+ if row["emails"]:
125
+ return row["emails"][0]
126
+ if row["hostnames"]:
127
+ return row["hostnames"][0]
128
+ return row["entity_key"][:12]
129
+
130
+
131
+ def loc(row):
132
+ return ", ".join(unquote(x) for x in (row["locations"] or [])) or "?"
133
+
134
+
135
+ def main():
136
+ ap = argparse.ArgumentParser(
137
+ description="Active social-autoposter users, deduped per person (email, else machine).")
138
+ ap.add_argument("--days", type=int, default=7, help="lookback window in days (default 7)")
139
+ ap.add_argument("--all", action="store_true", help="include our own infra (i@m13v / mk0r)")
140
+ ap.add_argument("--json", action="store_true", help="emit JSON")
141
+ args = ap.parse_args()
142
+
143
+ load_env()
144
+ rows = fetch(args.days)
145
+ for r in rows:
146
+ r["internal"] = is_internal(r["emails"], r["hostnames"], r["hardware_uuids"])
147
+ r["handles"] = parse_handles(r.get("handles_raw"))
148
+
149
+ external = [r for r in rows if not r["internal"]]
150
+ internal = [r for r in rows if r["internal"]]
151
+ shown = rows if args.all else external
152
+
153
+ if args.json:
154
+ out = [{
155
+ "person": person(r), "x_handles": r["handles"], "machines": r["machines"],
156
+ "installs": r["installs"], "hostnames": r["hostnames"], "emails": r["emails"],
157
+ "os": r["os"], "location": loc(r), "posts": int(r["posts"]),
158
+ "last_seen": r["last_seen"].isoformat() if r["last_seen"] else None,
159
+ "internal": r["internal"],
160
+ } for r in shown]
161
+ print(json.dumps({
162
+ "window_days": args.days,
163
+ "external_machines": len(external),
164
+ "external_people": len({e for r in external for e in r["emails"]}),
165
+ "internal_machines_hidden": 0 if args.all else len(internal),
166
+ "rows": out,
167
+ }, indent=2))
168
+ return
169
+
170
+ people = len({e for r in external for e in r["emails"]})
171
+ print(f"\nActive in last {args.days}d: {len(external)} external machines "
172
+ f"(~{people} identified people){'' if args.all else f', {len(internal)} internal hidden'}\n")
173
+ hdr = (f"{'PERSON':<30} {'X HANDLE':<16} {'HOST':<22} {'OS':<7} {'LOC':<16} "
174
+ f"{'INST':>4} {'POSTS':>6} LAST SEEN")
175
+ print(hdr)
176
+ print("-" * len(hdr))
177
+ for r in shown:
178
+ tag = " [internal]" if r["internal"] else ""
179
+ host = (r["hostnames"][0] if r["hostnames"] else "?")[:22]
180
+ handle = (", ".join(r["handles"]) or "-")[:16]
181
+ print(f"{person(r)[:30]:<30} {handle:<16} {host:<22} {(r['os'] or '?'):<7} "
182
+ f"{loc(r)[:16]:<16} {r['installs']:>4} {int(r['posts']):>6} "
183
+ f"{r['last_seen']:%Y-%m-%d %H:%M}{tag}")
184
+ posting = sum(1 for r in external if r["posts"] > 0)
185
+ print(f"\n of {len(external)} external machines, {posting} posted in the window, "
186
+ f"{len(external) - posting} are alive-but-not-posting.\n")
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()