@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,131 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ apply_onboarding_selections.py -- PROTOTYPE handler for the S4L bundled-question
4
+ widget. Turns the widget's sendPrompt confirmation into concrete actions:
5
+
6
+ 1. Engagement lanes -> saps_mode.py enable/disable (DRY-RUN by default so a
7
+ prototype never flips the LIVE autopilot; pass --commit-lanes to really run).
8
+ 2. History consent -> history_context.set_optin(...) (persisted sidecar).
9
+ 3. If consent == yes -> history_context.pull(project) and summarize candidates.
10
+
11
+ The widget sends a line like:
12
+ "... personal_brand lane: ON, product lane: OFF, read past Claude
13
+ conversations: YES. Apply these ..."
14
+
15
+ so this handler also accepts that raw text via --from-prompt, or explicit flags.
16
+
17
+ Usage:
18
+ python3 scripts/apply_onboarding_selections.py --project S4L \
19
+ --personal-brand on --product off --read-history yes
20
+ python3 scripts/apply_onboarding_selections.py --project S4L \
21
+ --from-prompt "personal_brand lane: ON, product lane: OFF, read past Claude conversations: YES"
22
+ # add --commit-lanes to actually toggle saps_mode (default is dry-run)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+ import history_context as hc # noqa: E402
36
+
37
+ S4L_MODE = Path(__file__).resolve().parent / "saps_mode.py"
38
+
39
+
40
+ def parse_from_prompt(text: str) -> dict:
41
+ """Extract the three toggles from the widget's confirmation sentence."""
42
+ t = text.lower()
43
+
44
+ def flag(label: str, on_words=("on", "yes")) -> bool | None:
45
+ m = re.search(re.escape(label) + r"[^:]*:\s*(on|off|yes|no)", t)
46
+ return None if not m else m.group(1) in on_words
47
+
48
+ return {
49
+ "personal_brand": flag("personal_brand"),
50
+ "product_mode": flag("product lane"),
51
+ "read_history": flag("read past claude conversations"),
52
+ }
53
+
54
+
55
+ def apply_lanes(personal_brand: bool, product: bool, commit: bool) -> list[str]:
56
+ """Return the saps_mode commands, running them only when commit=True."""
57
+ plan = [
58
+ [sys.executable, str(S4L_MODE),
59
+ "enable" if personal_brand else "disable", "personal_brand"],
60
+ [sys.executable, str(S4L_MODE),
61
+ "enable" if product else "disable", "promotion"],
62
+ ]
63
+ rendered = [" ".join(c) for c in plan]
64
+ if commit:
65
+ for c in plan:
66
+ subprocess.run(c, check=False)
67
+ return rendered
68
+
69
+
70
+ def main() -> None:
71
+ ap = argparse.ArgumentParser(description=__doc__)
72
+ ap.add_argument("--project", required=True)
73
+ ap.add_argument("--from-prompt", help="raw widget confirmation text")
74
+ ap.add_argument("--personal-brand", choices=["on", "off"])
75
+ ap.add_argument("--product", choices=["on", "off"])
76
+ ap.add_argument("--read-history", choices=["yes", "no"])
77
+ ap.add_argument("--commit-lanes", action="store_true",
78
+ help="actually toggle saps_mode (default: dry-run print only)")
79
+ args = ap.parse_args()
80
+
81
+ sel = {"personal_brand": None, "product_mode": None, "read_history": None}
82
+ if args.from_prompt:
83
+ sel.update({k: v for k, v in parse_from_prompt(args.from_prompt).items()
84
+ if v is not None})
85
+ if args.personal_brand:
86
+ sel["personal_brand"] = args.personal_brand == "on"
87
+ if args.product:
88
+ sel["product_mode"] = args.product == "on"
89
+ if args.read_history:
90
+ sel["read_history"] = args.read_history == "yes"
91
+
92
+ for k in sel:
93
+ if sel[k] is None:
94
+ raise SystemExit(f"missing selection for '{k}'")
95
+
96
+ out = {"project": args.project, "selections": sel, "actions": {}}
97
+
98
+ lane_cmds = apply_lanes(sel["personal_brand"], sel["product_mode"],
99
+ commit=args.commit_lanes)
100
+ out["actions"]["lanes"] = {
101
+ "committed": args.commit_lanes,
102
+ "commands": lane_cmds,
103
+ }
104
+
105
+ optin = hc.set_optin(sel["read_history"])
106
+ out["actions"]["history_optin"] = optin
107
+
108
+ if sel["read_history"]:
109
+ pull = hc.pull(args.project, terms=None, limit=40)
110
+ if pull.get("ok"):
111
+ summary = {
112
+ "sessions": pull["session_count"],
113
+ "snippets": pull["snippet_count"],
114
+ "scope": pull["scope"],
115
+ "sample": [
116
+ {"session": sid[:8],
117
+ "recent_previews": [s["preview"][:140] for s in snips[:2]]}
118
+ for sid, snips in list(pull["sessions"].items())[:5]
119
+ ],
120
+ }
121
+ out["actions"]["history_pull"] = summary
122
+ else:
123
+ out["actions"]["history_pull"] = pull
124
+ else:
125
+ out["actions"]["history_pull"] = {"skipped": "consent=no"}
126
+
127
+ print(json.dumps(out, indent=2)[:6000])
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ """Audience-page routing helper.
3
+
4
+ Each project in config.json can declare `landing_pages.audience_pages`, a list
5
+ of curated deep landing pages (not auto-generated SEO /t/<slug> pages — those
6
+ live in a separate rail). Each entry looks like:
7
+
8
+ {
9
+ "angle": "founder-ghostwriting",
10
+ "url": "https://s4l.ai/ghostwriting",
11
+ "match_keywords": ["ghostwriter", "tweet ghostwriter", ...],
12
+ "when": "human-readable trigger description for LLMs / docs"
13
+ }
14
+
15
+ This module is the single source of truth for:
16
+
17
+ - loading audience_pages for a project
18
+ - matching a candidate's nominated topic/keyword to an audience-page angle
19
+ - mapping a URL back to its angle (for post-hoc tagging)
20
+ - formatting an audience_pages block for injection into post-draft prompts
21
+ (used once post_reddit.py and post_github.py are unlocked to consume it)
22
+
23
+ Used by twitter_gen_links.py to short-circuit the A/B page-gen lane when a
24
+ curated audience page exists for the candidate's topic.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import re
32
+ from typing import Optional
33
+ from urllib.parse import urlsplit
34
+
35
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
36
+ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
37
+
38
+
39
+ def _load_config() -> dict:
40
+ with open(CONFIG_PATH, "r") as f:
41
+ return json.load(f)
42
+
43
+
44
+ def _find_project(cfg: dict, name: str) -> Optional[dict]:
45
+ if not name:
46
+ return None
47
+ name_lc = name.lower()
48
+ for p in cfg.get("projects", []):
49
+ if (p.get("name") or "").lower() == name_lc:
50
+ return p
51
+ return None
52
+
53
+
54
+ def load_audience_pages(project_name: str) -> list[dict]:
55
+ """Return the audience_pages list for a project, or [] if none configured.
56
+
57
+ Each entry is the raw dict from config.json. Caller does not mutate.
58
+ """
59
+ try:
60
+ cfg = _load_config()
61
+ except Exception:
62
+ return []
63
+ proj = _find_project(cfg, project_name)
64
+ if not proj:
65
+ return []
66
+ lp = (proj.get("landing_pages") or {})
67
+ pages = lp.get("audience_pages") or []
68
+ out = []
69
+ for entry in pages:
70
+ if not isinstance(entry, dict):
71
+ continue
72
+ if not entry.get("url") or not entry.get("angle"):
73
+ continue
74
+ out.append(entry)
75
+ return out
76
+
77
+
78
+ def _normalize(s: str) -> str:
79
+ """Lowercase + collapse whitespace + strip punctuation for substring match."""
80
+ if not s:
81
+ return ""
82
+ s = s.lower()
83
+ s = re.sub(r"[^a-z0-9\s]+", " ", s)
84
+ s = re.sub(r"\s+", " ", s).strip()
85
+ return s
86
+
87
+
88
+ def match_by_keyword(
89
+ project_name: str,
90
+ *,
91
+ keyword: Optional[str] = None,
92
+ topic: Optional[str] = None,
93
+ reply_text: Optional[str] = None,
94
+ thread_title: Optional[str] = None,
95
+ ) -> Optional[dict]:
96
+ """Pick the best-matching audience page for a candidate.
97
+
98
+ Match strategy: for each audience-page entry, check whether ANY of its
99
+ `match_keywords` (case-insensitive, normalized substring) appears in
100
+ ANY of the provided signals (keyword, topic, reply_text, thread_title).
101
+
102
+ Returns the matched entry dict (with `angle`, `url`, ...) or None.
103
+
104
+ First-match-wins ordered by audience_pages list order (so config.json
105
+ list ordering acts as priority). This is intentional: the most specific
106
+ angle should sit first.
107
+ """
108
+ pages = load_audience_pages(project_name)
109
+ if not pages:
110
+ return None
111
+
112
+ haystacks: list[str] = []
113
+ for v in (keyword, topic, reply_text, thread_title):
114
+ n = _normalize(v or "")
115
+ if n:
116
+ haystacks.append(n)
117
+ if not haystacks:
118
+ return None
119
+
120
+ for entry in pages:
121
+ kws = entry.get("match_keywords") or []
122
+ for kw in kws:
123
+ kw_norm = _normalize(kw)
124
+ if not kw_norm:
125
+ continue
126
+ for hay in haystacks:
127
+ if kw_norm in hay:
128
+ return entry
129
+ return None
130
+
131
+
132
+ def classify_url_as_audience_page(url: str, project_name: str) -> Optional[str]:
133
+ """Map a URL back to an audience-page angle, or None if not a known page.
134
+
135
+ Used for post-hoc tagging in `posts.link_source` when a URL was baked
136
+ into a draft directly (without going through resolve_link()). Match is
137
+ exact-URL OR same-host + same-path (ignoring query/fragment).
138
+ """
139
+ if not url or not project_name:
140
+ return None
141
+ pages = load_audience_pages(project_name)
142
+ if not pages:
143
+ return None
144
+ try:
145
+ target = urlsplit(url.strip())
146
+ except Exception:
147
+ return None
148
+ target_host = (target.netloc or "").lower().lstrip("www.")
149
+ target_path = (target.path or "/").rstrip("/") or "/"
150
+
151
+ for entry in pages:
152
+ try:
153
+ ep = urlsplit(entry["url"])
154
+ except Exception:
155
+ continue
156
+ ep_host = (ep.netloc or "").lower().lstrip("www.")
157
+ ep_path = (ep.path or "/").rstrip("/") or "/"
158
+ if ep_host == target_host and ep_path == target_path:
159
+ return entry.get("angle")
160
+ return None
161
+
162
+
163
+ def prompt_block(project_name: str) -> str:
164
+ """Render an audience_pages block for injection into a post-draft LLM prompt.
165
+
166
+ Returns "" if the project has no audience_pages. Otherwise returns a short
167
+ markdown-friendly block the LLM can use to pick the right deep URL.
168
+ """
169
+ pages = load_audience_pages(project_name)
170
+ if not pages:
171
+ return ""
172
+ lines = [
173
+ "Curated audience landing pages for this project. Pick the BEST match",
174
+ "for the thread topic and bake the chosen URL into the reply text;",
175
+ "if none obviously match, link to the project homepage as usual.",
176
+ "",
177
+ ]
178
+ for entry in pages:
179
+ lines.append(f"- angle: {entry['angle']}")
180
+ lines.append(f" url: {entry['url']}")
181
+ when = entry.get("when") or ""
182
+ if when:
183
+ lines.append(f" when_to_use: {when}")
184
+ kws = entry.get("match_keywords") or []
185
+ if kws:
186
+ lines.append(f" keyword_signals: {', '.join(kws[:12])}")
187
+ lines.append("")
188
+ return "\n".join(lines).rstrip() + "\n"
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # CLI for ops / testing
193
+ # ---------------------------------------------------------------------------
194
+
195
+ def _cli() -> int:
196
+ import argparse
197
+
198
+ ap = argparse.ArgumentParser(description="Audience-page lookup helper")
199
+ sub = ap.add_subparsers(dest="cmd", required=True)
200
+
201
+ p_list = sub.add_parser("list", help="List audience pages for a project")
202
+ p_list.add_argument("--project", required=True)
203
+
204
+ p_match = sub.add_parser("match", help="Match a keyword/topic to an audience page")
205
+ p_match.add_argument("--project", required=True)
206
+ p_match.add_argument("--keyword", default=None)
207
+ p_match.add_argument("--topic", default=None)
208
+ p_match.add_argument("--reply", default=None)
209
+ p_match.add_argument("--title", default=None)
210
+
211
+ p_classify = sub.add_parser("classify", help="Classify a URL as an audience page")
212
+ p_classify.add_argument("--project", required=True)
213
+ p_classify.add_argument("--url", required=True)
214
+
215
+ p_prompt = sub.add_parser("prompt", help="Render the prompt block for a project")
216
+ p_prompt.add_argument("--project", required=True)
217
+
218
+ args = ap.parse_args()
219
+ if args.cmd == "list":
220
+ print(json.dumps(load_audience_pages(args.project), indent=2))
221
+ return 0
222
+ if args.cmd == "match":
223
+ out = match_by_keyword(
224
+ args.project,
225
+ keyword=args.keyword,
226
+ topic=args.topic,
227
+ reply_text=args.reply,
228
+ thread_title=args.title,
229
+ )
230
+ print(json.dumps(out, indent=2) if out else "null")
231
+ return 0 if out else 1
232
+ if args.cmd == "classify":
233
+ angle = classify_url_as_audience_page(args.url, args.project)
234
+ print(angle or "")
235
+ return 0 if angle else 1
236
+ if args.cmd == "prompt":
237
+ print(prompt_block(args.project))
238
+ return 0
239
+ return 2
240
+
241
+
242
+ if __name__ == "__main__":
243
+ raise SystemExit(_cli())
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """audit_helper.py — CLI wrapper used by the audit pipelines
3
+ (skill/audit.sh, skill/audit-dm-staleness.sh, skill/audit-reddit-resurrect.sh)
4
+ to replace the inline `psql "$DATABASE_URL"` one-liners they used to embed. The
5
+ direct-Postgres lane was removed 2026-06-01; DATABASE_URL is deliberately
6
+ ignored, no DB, no fallback. Every subcommand prints exactly what the
7
+ corresponding psql call printed so the surrounding shell capture ($(...)),
8
+ integer compares, and `IFS='|' read` loops are unchanged.
9
+
10
+ Subcommands:
11
+ twitter-active-count
12
+ -> GET /api/v1/posts/count?platform=twitter&status=active&has_our_url=true
13
+ -> prints int (was: COUNT(*) FROM posts WHERE platform='twitter'
14
+ AND status='active' AND our_url IS NOT NULL)
15
+ orphan-report
16
+ -> GET /api/v1/posts/status-breakdown
17
+ -> prints pipe-delimited "platform|status|count" lines (one per group),
18
+ empty when none (was: SELECT platform, status, COUNT(*) ... GROUP BY
19
+ ... for status NOT IN ('active','deleted','removed'))
20
+ broken-url-count
21
+ -> GET /api/v1/posts/count?status=active&broken_url=true
22
+ -> prints int (was: COUNT(*) FROM posts WHERE status='active'
23
+ AND (our_url IS NULL OR ''='' OR our_url NOT LIKE 'http%'))
24
+ status-count --status S
25
+ -> GET /api/v1/posts/count?status=S
26
+ -> prints int (was: COUNT(*) FROM posts WHERE status='S')
27
+ resurrect-candidates
28
+ -> GET /api/v1/posts/count?platform=reddit&status_in=deleted,removed
29
+ &within_seconds=5184000&has_our_url=true
30
+ -> prints int (was: COUNT(*) FROM posts WHERE platform='reddit'
31
+ AND status IN ('deleted','removed') AND posted_at > NOW() - 60 days
32
+ AND our_url IS NOT NULL)
33
+ dm-staleness-sweep
34
+ -> POST /api/v1/dms/staleness-sweep
35
+ -> prints JSON {aged, downgraded} (was: two UPDATE ... RETURNING CTEs)
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import json
41
+ import os
42
+ import sys
43
+
44
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
45
+ from http_api import api_get, api_post # noqa: E402
46
+
47
+
48
+ def _count(query: dict) -> int:
49
+ resp = api_get("/api/v1/posts/count", query=query)
50
+ return int((resp.get("data") or {}).get("count") or 0)
51
+
52
+
53
+ def cmd_twitter_active_count(_args) -> int:
54
+ print(_count({"platform": "twitter", "status": "active", "has_our_url": "true"}))
55
+ return 0
56
+
57
+
58
+ def cmd_orphan_report(_args) -> int:
59
+ resp = api_get("/api/v1/posts/status-breakdown")
60
+ rows = (resp.get("data") or {}).get("rows") or []
61
+ for r in rows:
62
+ print(f"{r.get('platform')}|{r.get('status')}|{int(r.get('count') or 0)}")
63
+ return 0
64
+
65
+
66
+ def cmd_broken_url_count(_args) -> int:
67
+ print(_count({"status": "active", "broken_url": "true"}))
68
+ return 0
69
+
70
+
71
+ def cmd_status_count(args) -> int:
72
+ print(_count({"status": args.status}))
73
+ return 0
74
+
75
+
76
+ def cmd_resurrect_candidates(_args) -> int:
77
+ print(_count({
78
+ "platform": "reddit",
79
+ "status_in": "deleted,removed",
80
+ "within_seconds": 60 * 24 * 60 * 60, # 60 days
81
+ "has_our_url": "true",
82
+ }))
83
+ return 0
84
+
85
+
86
+ def cmd_dm_staleness_sweep(_args) -> int:
87
+ resp = api_post("/api/v1/dms/staleness-sweep", body={})
88
+ data = resp.get("data") or {}
89
+ out = {
90
+ "aged": int(data.get("aged") or 0),
91
+ "downgraded": int(data.get("downgraded") or 0),
92
+ }
93
+ json.dump(out, sys.stdout, separators=(",", ":"))
94
+ sys.stdout.write("\n")
95
+ return 0
96
+
97
+
98
+ def main() -> int:
99
+ p = argparse.ArgumentParser()
100
+ sub = p.add_subparsers(dest="cmd", required=True)
101
+ sub.add_parser("twitter-active-count")
102
+ sub.add_parser("orphan-report")
103
+ sub.add_parser("broken-url-count")
104
+ sc = sub.add_parser("status-count")
105
+ sc.add_argument("--status", required=True)
106
+ sub.add_parser("resurrect-candidates")
107
+ sub.add_parser("dm-staleness-sweep")
108
+ args = p.parse_args()
109
+ return {
110
+ "twitter-active-count": cmd_twitter_active_count,
111
+ "orphan-report": cmd_orphan_report,
112
+ "broken-url-count": cmd_broken_url_count,
113
+ "status-count": cmd_status_count,
114
+ "resurrect-candidates": cmd_resurrect_candidates,
115
+ "dm-staleness-sweep": cmd_dm_staleness_sweep,
116
+ }[args.cmd](args)
117
+
118
+
119
+ if __name__ == "__main__":
120
+ sys.exit(main())