@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,453 @@
1
+ #!/usr/bin/env python3
2
+ """seed_search_queries.py — convert a project's seeded search TOPICS into a
3
+ cold-start QUERY bank (>=30 real X advanced-search strings) at setup time.
4
+
5
+ Why
6
+ ---
7
+ The Twitter cycle's deterministic Phase 1 (scripts/qualified_query_bank.py)
8
+ replays a project's historically *qualified* queries — distinct X search
9
+ strings that already produced an engaged post. A brand-new project has zero
10
+ post history, so that bank is empty and the cycle falls back to ONE crude
11
+ query (the single picked topic + "-filter:replies"). That's the "only one
12
+ search query" cold-start symptom the user hit on chosenhq.
13
+
14
+ This script fixes the supply side: it reads the project's ACTIVE topics from
15
+ project_search_topics, fans each topic out into several distinct X queries via
16
+ the SAME Claude drafting prompt invent_topics.py uses (reused, not duplicated),
17
+ optionally supply-tests them against the live browser harness, and persists the
18
+ survivors into project_search_queries with source='seed'. The bank backfills
19
+ from these active rows when the proven+invented set is still thin, so a fresh
20
+ project runs ~30 queries on day one and the seed rows fade as real winners
21
+ accumulate.
22
+
23
+ Reuse
24
+ -----
25
+ All drafting / parsing / dedup / supply-test logic is imported from
26
+ invent_topics.py (build_query_prompt, extract_queries, call_claude,
27
+ normalize_query, load_existing_query_cores, dedup_queries, supply_test,
28
+ harness_alive). This file is only the orchestration + persistence layer.
29
+
30
+ Topics + queries are read/written through the website API (/api/v1/*) per the
31
+ "no direct SQL in pipeline Python" rule.
32
+
33
+ CLI:
34
+ python3 scripts/seed_search_queries.py --project chosenhq
35
+ python3 scripts/seed_search_queries.py --project fazm --target 30
36
+ python3 scripts/seed_search_queries.py --project fazm --supply-test off
37
+ python3 scripts/seed_search_queries.py --project fazm --dry-run
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import json
43
+ import math
44
+ import os
45
+ import sys
46
+
47
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48
+
49
+ from http_api import api_get, api_post # noqa: E402
50
+ from pick_project import load_config # noqa: E402
51
+ from invent_topics import ( # noqa: E402
52
+ CDP_PORT,
53
+ FRESHNESS_HOURS,
54
+ build_query_prompt,
55
+ call_claude,
56
+ dedup_queries,
57
+ extract_queries,
58
+ harness_alive,
59
+ load_existing_query_cores,
60
+ normalize_query,
61
+ supply_test,
62
+ )
63
+
64
+ # How many real queries we want a fresh project to launch with. The user's
65
+ # target: "convert these search topics into at least 30 search queries
66
+ # altogether". 30 is enough to fan a typical 6-12 topic universe across
67
+ # multiple angles without exploding setup cost.
68
+ DEFAULT_TARGET = 30
69
+
70
+ # Per-topic draft cap so a project with very few topics doesn't ask Claude for
71
+ # 30 queries off one topic (they'd collapse into near-dupes). With >=4 topics
72
+ # the ceil(target/topics) math stays under this anyway.
73
+ MAX_PER_TOPIC = 8
74
+ MIN_PER_TOPIC = 2
75
+
76
+ # Query-draft Claude call timeout (one call per topic).
77
+ DRAFT_TIMEOUT_SEC = 240
78
+
79
+
80
+ def _load_active_topics(project: str) -> list[str]:
81
+ """Active topics for a project from project_search_topics (DB universe)."""
82
+ resp = api_get(
83
+ "/api/v1/project-search-topics",
84
+ {"project": project, "status": "active"},
85
+ )
86
+ data = (resp or {}).get("data") or {}
87
+ rows = data.get("topics") or []
88
+ out, seen = [], set()
89
+ for r in rows:
90
+ t = (r.get("topic") or "").strip()
91
+ if t and t.lower() not in seen:
92
+ seen.add(t.lower())
93
+ out.append(t)
94
+ return out
95
+
96
+
97
+ def _load_existing_seed_cores(project: str) -> set[str]:
98
+ """Normalized cores of seed queries already persisted for this project, so
99
+ re-running the seeder is idempotent and never double-drafts the same core."""
100
+ try:
101
+ resp = api_get(
102
+ "/api/v1/project-search-queries",
103
+ {"project": project, "status": "all"},
104
+ )
105
+ except SystemExit as exc:
106
+ print(f"[seed_search_queries] existing-seed read failed for "
107
+ f"{project!r}: {exc} (proceeding without seed dedup)",
108
+ file=sys.stderr)
109
+ return set()
110
+ data = (resp or {}).get("data") or {}
111
+ rows = data.get("queries") or []
112
+ return {normalize_query(r.get("query") or "") for r in rows if r.get("query")}
113
+
114
+
115
+ def _fetch_active_queries(project: str) -> list[dict]:
116
+ """The project's current ACTIVE seed-query bank (query + topic), so a caller
117
+ can show the user exactly what the cycle will fan out over. Best-effort:
118
+ returns [] on read failure."""
119
+ try:
120
+ resp = api_get(
121
+ "/api/v1/project-search-queries",
122
+ {"project": project, "status": "active"},
123
+ )
124
+ except SystemExit:
125
+ return []
126
+ data = (resp or {}).get("data") or {}
127
+ out = []
128
+ for r in (data.get("queries") or []):
129
+ q = (r.get("query") or "").strip()
130
+ if q:
131
+ out.append({"query": q, "topic": (r.get("topic") or "").strip()})
132
+ return out
133
+
134
+
135
+ def _load_provided_queries(path: str) -> list[tuple[str, str]]:
136
+ """Read AGENT-SUPPLIED queries from a JSON file: returns (query, topic) pairs.
137
+
138
+ Accepts either {"queries": [...]} or a bare top-level list. Each item may be
139
+ a string (topic left blank) or an object {"query": ..., "topic": ...}.
140
+ This is the claude-free seed path: the in-session agent already expanded the
141
+ topics into queries, so there is nothing to draft."""
142
+ with open(path, "r", encoding="utf-8") as fh:
143
+ raw = json.load(fh)
144
+ items = raw.get("queries") if isinstance(raw, dict) else raw
145
+ out: list[tuple[str, str]] = []
146
+ seen: set[str] = set()
147
+ for it in (items or []):
148
+ if isinstance(it, str):
149
+ q, t = it.strip(), ""
150
+ elif isinstance(it, dict):
151
+ q, t = (it.get("query") or "").strip(), (it.get("topic") or "").strip()
152
+ else:
153
+ continue
154
+ if not q:
155
+ continue
156
+ core = normalize_query(q)
157
+ if core in seen:
158
+ continue
159
+ seen.add(core)
160
+ out.append((q, t))
161
+ return out
162
+
163
+
164
+ def _find_project(cfg: dict, name: str) -> dict | None:
165
+ for p in cfg.get("projects", []):
166
+ if (p.get("name") or "").strip().lower() == name.strip().lower():
167
+ return p
168
+ return None
169
+
170
+
171
+ def _persist(project: str, query: str, topic: str,
172
+ supply_tested: bool, tweets_found, dry_run: bool) -> str:
173
+ """POST one seed query; returns the action ('inserted'/'updated'/'fail')."""
174
+ if dry_run:
175
+ return "dry"
176
+ try:
177
+ resp = api_post(
178
+ "/api/v1/project-search-queries",
179
+ body={
180
+ "project": project,
181
+ "query": query,
182
+ "topic": topic,
183
+ "source": "seed",
184
+ "status": "active",
185
+ "supply_tested": supply_tested,
186
+ "tweets_found": tweets_found,
187
+ },
188
+ )
189
+ except SystemExit as e:
190
+ print(f"[FAIL] {project}: {query!r}: {e}", file=sys.stderr)
191
+ return "fail"
192
+ data = (resp or {}).get("data") or resp or {}
193
+ return data.get("action") or "unknown"
194
+
195
+
196
+ def main() -> int:
197
+ ap = argparse.ArgumentParser(description=__doc__)
198
+ ap.add_argument("--project", required=True,
199
+ help="Project name (config.json casing).")
200
+ ap.add_argument("--target", type=int, default=DEFAULT_TARGET,
201
+ help=f"Total queries to aim for (default {DEFAULT_TARGET}).")
202
+ ap.add_argument("--supply-test", choices=["auto", "on", "off"],
203
+ default="auto",
204
+ help="auto (default): supply-test only if the harness is "
205
+ "up; on: require it; off: skip and seed all drafts.")
206
+ ap.add_argument("--dry-run", action="store_true",
207
+ help="Draft + (maybe) supply-test, but do NOT persist.")
208
+ ap.add_argument("--emit-json", action="store_true",
209
+ help="After seeding, print the project's full ACTIVE seed-query "
210
+ "bank as JSON on a sentinel line (===QUERIES_JSON===) so a "
211
+ "caller (e.g. the MCP setup tool) can hand the queries back "
212
+ "to the user.")
213
+ ap.add_argument("--queries-json",
214
+ help="Path to a JSON file of AGENT-SUPPLIED queries to seed "
215
+ "directly, bypassing the `claude -p` drafting loop entirely. "
216
+ "Shape: {\"queries\":[{\"query\":\"...\",\"topic\":\"...\"}]} "
217
+ "or a bare list of strings. Use this from the in-session MCP "
218
+ "setup path so the seed step never depends on the claude CLI.")
219
+ args = ap.parse_args()
220
+
221
+ cfg = load_config()
222
+ project_entry = _find_project(cfg, args.project)
223
+ if not project_entry:
224
+ print(f"seed_search_queries: project {args.project!r} not in config.json",
225
+ file=sys.stderr)
226
+ return 2
227
+ # canonical name as stored
228
+ project = (project_entry.get("name") or args.project).strip()
229
+
230
+ target = max(1, args.target)
231
+
232
+ # ---- Agent-supplied path (claude-free) ------------------------------
233
+ # When the caller (the in-session MCP setup tool) hands us queries the agent
234
+ # already expanded from the topics, we skip the `claude -p` drafting loop
235
+ # entirely and just dedup + (maybe) supply-test + persist them. This is the
236
+ # single setup path on machines without the claude CLI. (2026-06-19)
237
+ if args.queries_json:
238
+ topics = _load_active_topics(project) # best-effort, summary only
239
+ provided = _load_provided_queries(args.queries_json)
240
+ existing = (load_existing_query_cores(project)
241
+ | _load_existing_seed_cores(project))
242
+ drafted = [(q, t) for q, t in provided
243
+ if normalize_query(q) not in existing]
244
+ active_now = len(_fetch_active_queries(project))
245
+ print(f"seed_search_queries: project={project!r} mode=agent-supplied "
246
+ f"provided={len(provided)} new={len(drafted)} "
247
+ f"active_now={active_now} (no claude)", file=sys.stderr)
248
+ if not drafted:
249
+ # Everything provided was already seeded (or nothing usable) —
250
+ # healthy no-op: re-emit the live bank and succeed.
251
+ print(
252
+ f"seed_search_queries: project={project} topics={len(topics)} "
253
+ f"drafted=0 supply_ran=0 dropped_zero=0 seeded=0 inserted=0 "
254
+ f"updated=0 failed=0 (agent_supplied_nothing_new)"
255
+ )
256
+ if args.emit_json:
257
+ queries = _fetch_active_queries(project)
258
+ print("===QUERIES_JSON===")
259
+ print(json.dumps({"project": project, "count": len(queries),
260
+ "queries": queries}))
261
+ return 0
262
+ else:
263
+ topics = _load_active_topics(project)
264
+ if not topics:
265
+ print(f"seed_search_queries: no active topics for {project!r} — seed "
266
+ f"topics first (scripts/seed_search_topics.py).", file=sys.stderr)
267
+ return 3
268
+
269
+ # Idempotency: aim for `target` TOTAL active queries, not `target` NEW per
270
+ # run. A project that already has >= target active seed queries needs no
271
+ # drafting — re-running reseed just re-emits the existing bank instead of
272
+ # ballooning it. Otherwise we only draft the shortfall. (2026-06-04)
273
+ active_now = len(_fetch_active_queries(project))
274
+ need = max(0, target - active_now)
275
+ per_topic = math.ceil(need / len(topics)) if need else 0
276
+ per_topic = max(MIN_PER_TOPIC, min(MAX_PER_TOPIC, per_topic)) if need else 0
277
+
278
+ # Dedup against (a) every query EVER attempted for this project and (b) seed
279
+ # queries already persisted. Accumulates as we go so topics don't overlap.
280
+ avoid_cores = load_existing_query_cores(project) | _load_existing_seed_cores(project)
281
+
282
+ print(f"seed_search_queries: project={project!r} topics={len(topics)} "
283
+ f"target={target} active_now={active_now} need={need} "
284
+ f"per_topic={per_topic} existing_cores={len(avoid_cores)}",
285
+ file=sys.stderr)
286
+
287
+ # --- Draft queries topic by topic ------------------------------------
288
+ drafted = [] # (query, topic)
289
+ for topic in topics:
290
+ if need <= 0 or len({normalize_query(q) for q, _ in drafted}) >= need:
291
+ break
292
+ prompt = build_query_prompt(
293
+ project_entry, topic, per_topic, avoid_queries=avoid_cores
294
+ )
295
+ try:
296
+ out = call_claude(prompt, timeout_sec=DRAFT_TIMEOUT_SEC)
297
+ except SystemExit as e:
298
+ print(f"[seed_search_queries] draft failed for topic {topic!r}: {e}",
299
+ file=sys.stderr)
300
+ continue
301
+ qs = extract_queries(out, per_topic)
302
+ new_qs, dupes = dedup_queries(qs, avoid_cores)
303
+ for q in new_qs:
304
+ avoid_cores.add(normalize_query(q))
305
+ drafted.append((q, topic))
306
+ print(f" topic={topic!r}: drafted={len(qs)} new={len(new_qs)} "
307
+ f"dupes={len(dupes)}", file=sys.stderr)
308
+
309
+ if not drafted:
310
+ # Two cases: (a) the bank already meets target (need==0) — that's a
311
+ # healthy no-op, re-emit the existing bank and succeed; (b) we wanted
312
+ # queries but drafting produced none (every draft failed/duped) — that's
313
+ # a real failure.
314
+ if need <= 0:
315
+ print(f"seed_search_queries: project={project} already has "
316
+ f"{active_now} active queries (>= target {target}); nothing to "
317
+ f"draft.", file=sys.stderr)
318
+ print(
319
+ f"seed_search_queries: project={project} topics={len(topics)} "
320
+ f"drafted=0 supply_ran=0 dropped_zero=0 seeded=0 inserted=0 "
321
+ f"updated=0 failed=0 (already_full)"
322
+ )
323
+ if args.emit_json:
324
+ queries = _fetch_active_queries(project)
325
+ print("===QUERIES_JSON===")
326
+ print(json.dumps({"project": project, "count": len(queries),
327
+ "queries": queries}))
328
+ return 0
329
+ print("seed_search_queries: drafted 0 queries — nothing to seed.",
330
+ file=sys.stderr)
331
+ return 1
332
+
333
+ # --- Decide whether to supply-test -----------------------------------
334
+ do_supply = False
335
+ if args.supply_test == "on":
336
+ do_supply = True
337
+ elif args.supply_test == "auto":
338
+ do_supply = harness_alive(CDP_PORT)
339
+ if args.supply_test == "on" and not harness_alive(CDP_PORT):
340
+ print("seed_search_queries: --supply-test on but harness not reachable "
341
+ f"on :{CDP_PORT}", file=sys.stderr)
342
+ return 4
343
+
344
+ supply_map: dict[str, int] = {}
345
+ supply_ran = False
346
+ if do_supply:
347
+ # Group by topic so supply_test gets a coherent (topic, queries) batch.
348
+ by_topic: dict[str, list[str]] = {}
349
+ for q, t in drafted:
350
+ by_topic.setdefault(t, []).append(q)
351
+ for t, qs in by_topic.items():
352
+ tested, results = supply_test(project, t, qs,
353
+ freshness_hours=FRESHNESS_HOURS)
354
+ if not tested:
355
+ # Lock timeout / browser down: do NOT treat as zero supply.
356
+ print(f" supply-test topic={t!r}: not tested (harness "
357
+ f"unavailable) — keeping queries untested",
358
+ file=sys.stderr)
359
+ continue
360
+ supply_ran = True
361
+ for r in results:
362
+ supply_map[normalize_query(r.get("query") or "")] = int(
363
+ r.get("tweets_found") or 0
364
+ )
365
+
366
+ # --- Persist ----------------------------------------------------------
367
+ # When supply ran, drop zero-supply queries (they surfaced nothing fresh),
368
+ # but never let the drop take us below MIN_KEEP — a thin real bank beats an
369
+ # empty one for cold start. When supply did NOT run, seed everything
370
+ # untested (the bank still beats the 1-query fallback).
371
+ MIN_KEEP = max(1, target // 2)
372
+ to_seed: list[tuple[str, str, bool, object]] = []
373
+ dropped_zero = 0
374
+ for q, t in drafted:
375
+ core = normalize_query(q)
376
+ tested = supply_ran and core in supply_map
377
+ tw = supply_map.get(core) if tested else None
378
+ if supply_ran and tested and tw == 0:
379
+ dropped_zero += 1
380
+ continue
381
+ to_seed.append((q, t, bool(tested), tw))
382
+
383
+ if supply_ran and len(to_seed) < MIN_KEEP:
384
+ # Too aggressive — restore the highest-supply zeros... actually all were
385
+ # zero, so restore drafted order until MIN_KEEP. Keep them as tested=0.
386
+ restored = 0
387
+ have = {normalize_query(q) for q, _, _, _ in to_seed}
388
+ for q, t in drafted:
389
+ if restored and len(to_seed) >= MIN_KEEP:
390
+ break
391
+ core = normalize_query(q)
392
+ if core in have:
393
+ continue
394
+ to_seed.append((q, t, True, supply_map.get(core, 0)))
395
+ have.add(core)
396
+ restored += 1
397
+ print(f" supply-test dropped too many; restored {restored} to meet "
398
+ f"MIN_KEEP={MIN_KEEP}", file=sys.stderr)
399
+
400
+ inserted = updated = failed = 0
401
+ for q, t, tested, tw in to_seed:
402
+ action = _persist(project, q, t, tested, tw, args.dry_run)
403
+ if action == "inserted":
404
+ inserted += 1
405
+ elif action == "updated":
406
+ updated += 1
407
+ elif action == "fail":
408
+ failed += 1
409
+
410
+ # Machine-parseable summary line (consumed by mcp/src/index.ts setup hook).
411
+ print(
412
+ f"seed_search_queries: project={project} topics={len(topics)} "
413
+ f"drafted={len(drafted)} supply_ran={int(supply_ran)} "
414
+ f"dropped_zero={dropped_zero} seeded={inserted + updated} "
415
+ f"inserted={inserted} updated={updated} failed={failed}"
416
+ + (" [dry-run]" if args.dry_run else "")
417
+ )
418
+
419
+ # Hand the resulting bank back to the caller (MCP setup tool) so it can show
420
+ # the user exactly which queries the cycle will run. Sentinel-delimited so it
421
+ # survives alongside the human/stderr log noise. On --dry-run we report what
422
+ # we drafted (nothing persisted yet); otherwise the live active bank.
423
+ if args.emit_json:
424
+ if args.dry_run:
425
+ queries = [{"query": q, "topic": t} for q, t in drafted]
426
+ else:
427
+ queries = _fetch_active_queries(project)
428
+ print("===QUERIES_JSON===")
429
+ print(json.dumps({"project": project, "count": len(queries),
430
+ "queries": queries}))
431
+
432
+ return 1 if (failed and not (inserted or updated)) else 0
433
+
434
+
435
+ if __name__ == "__main__":
436
+ try:
437
+ _rc = main()
438
+ except BrokenPipeError:
439
+ # The MCP setup hook (our parent) closes stdout once it has read the
440
+ # sentinel ===QUERIES_JSON=== block; the trailing summary prints then hit
441
+ # a dead pipe and raise BrokenPipeError. All persistence already happened
442
+ # earlier in main(), so this is BENIGN. Previously it propagated as an
443
+ # uncaught exception and Sentry logged it as a "seeding failed" event
444
+ # (Karol, 2026-06-22) — a false positive that buried the real signal.
445
+ # Redirect stdout to devnull so interpreter shutdown doesn't re-raise on
446
+ # the final flush, then exit clean.
447
+ try:
448
+ _devnull = os.open(os.devnull, os.O_WRONLY)
449
+ os.dup2(_devnull, sys.stdout.fileno())
450
+ except Exception:
451
+ pass
452
+ _rc = 0
453
+ raise SystemExit(_rc)
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Seed project_search_topics from config.json (one-time-per-install bootstrap).
3
+
4
+ The DB is now the source of truth for the search-topic universe (see migration
5
+ 2026-05-27-project-search-topics.sql and pick_search_topic.py). This script
6
+ mirrors the project_name -> search_topics[] block in ~/social-autoposter/config.json
7
+ into /api/v1/project-search-topics with source='seed', status='active'. The API
8
+ upserts on (install_id, project_name, topic), so re-running this script:
9
+
10
+ - Inserts rows added to config.json since the last seed.
11
+ - Leaves source unchanged for rows that already exist (server preserves
12
+ source on UPDATE; status is touched only because of upsert semantics, but
13
+ it lands on the same 'active' default we send).
14
+ - Never deletes rows (paused/excluded topics in the DB are protected from
15
+ config.json drift; explicit pause/exclude lives in the dashboard, not here).
16
+
17
+ CLI:
18
+ python3 scripts/seed_search_topics.py # seed every project
19
+ python3 scripts/seed_search_topics.py --project fazm
20
+ python3 scripts/seed_search_topics.py --dry-run # show counts, don't POST
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+
29
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
30
+
31
+ from http_api import api_post # noqa: E402
32
+
33
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
34
+
35
+
36
+ def _load_projects(only_project: str | None = None):
37
+ with open(CONFIG_PATH) as f:
38
+ cfg = json.load(f)
39
+ out = []
40
+ for p in cfg.get("projects", []):
41
+ name = (p.get("name") or "").strip()
42
+ if not name:
43
+ continue
44
+ if only_project and name.lower() != only_project.lower():
45
+ continue
46
+ topics = []
47
+ seen = set()
48
+ for t in (p.get("search_topics") or []):
49
+ t = (t or "").strip()
50
+ if t and t not in seen:
51
+ seen.add(t)
52
+ topics.append(t)
53
+ out.append((name, topics))
54
+ return out
55
+
56
+
57
+ def main():
58
+ ap = argparse.ArgumentParser(description=__doc__)
59
+ ap.add_argument("--project", default=None,
60
+ help="Only seed this project name (default: all)")
61
+ ap.add_argument("--dry-run", action="store_true",
62
+ help="Print counts; do not POST")
63
+ args = ap.parse_args()
64
+
65
+ projects = _load_projects(args.project)
66
+ if not projects:
67
+ sys.stderr.write(
68
+ f"seed_search_topics: no projects matched "
69
+ f"(filter={args.project!r})\n"
70
+ )
71
+ sys.exit(2)
72
+
73
+ total_inserted = 0
74
+ total_updated = 0
75
+ total_failed = 0
76
+ total_planned = 0
77
+
78
+ for name, topics in projects:
79
+ total_planned += len(topics)
80
+ if args.dry_run:
81
+ print(f"[dry] {name}: {len(topics)} topics")
82
+ continue
83
+ inserted = 0
84
+ updated = 0
85
+ for topic in topics:
86
+ try:
87
+ resp = api_post(
88
+ "/api/v1/project-search-topics",
89
+ body={
90
+ "project": name,
91
+ "topic": topic,
92
+ "source": "seed",
93
+ "status": "active",
94
+ },
95
+ )
96
+ except SystemExit as e:
97
+ total_failed += 1
98
+ print(f"[FAIL] {name}: {topic!r}: {e}", file=sys.stderr)
99
+ continue
100
+ data = (resp or {}).get("data") or resp or {}
101
+ action = data.get("action") or ""
102
+ if action == "inserted":
103
+ inserted += 1
104
+ elif action == "updated":
105
+ updated += 1
106
+ total_inserted += inserted
107
+ total_updated += updated
108
+ print(
109
+ f"{name}: planned={len(topics)} inserted={inserted} "
110
+ f"updated={updated}"
111
+ )
112
+
113
+ if args.dry_run:
114
+ print(f"[dry] total topics across {len(projects)} project(s): {total_planned}")
115
+ return
116
+
117
+ print(
118
+ f"\ndone. projects={len(projects)} planned={total_planned} "
119
+ f"inserted={total_inserted} updated={total_updated} "
120
+ f"failed={total_failed}"
121
+ )
122
+ if total_failed:
123
+ sys.exit(1)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()