@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,350 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ invent_mcp_server.py — MCP stdio server exposing topic + query lookup tools
4
+ to the invent_topics.py Claude session.
5
+
6
+ WHY
7
+ ---
8
+ Before this server, invent_topics.py spawned a fresh `claude -p` for every
9
+ topic proposal — Claude saw only the top-12-per-bucket ledger slice and had
10
+ NO way to verify a topic against the full universe before proposing it.
11
+ Dupes were caught AFTER the session ended, then a brand-new session retried
12
+ with a longer avoid-list. Lots of wasted Claude calls.
13
+
14
+ This MCP server gives Claude **in-session tools** to:
15
+ - search the FULL active topic universe by substring (no truncation)
16
+ - read per-topic funnel stats (attempts / supply / candidates / posts /
17
+ clicks / verdict) for any topic Claude is considering
18
+ - submit the topic itself, with Jaccard dedup running ON THE SERVER so a
19
+ near-dupe returns a tool-call error Claude can react to (try a different
20
+ angle) instead of silently dying outside the session
21
+ - the same shape for queries: search distinct query history, read per-
22
+ query performance, see invented-but-not-posted winners
23
+
24
+ ARCHITECTURE
25
+ ------------
26
+ Pure stdio MCP server (`mcp.server.fastmcp.FastMCP`). All persistence flows
27
+ through the social-autoposter-website /api/v1/* routes — no direct DB.
28
+ Mirrors the same routes invent_topics.py already uses, so the two paths
29
+ stay consistent.
30
+
31
+ USAGE
32
+ -----
33
+ The server is launched as a subprocess by `claude -p --mcp-config <cfg>`.
34
+ The cfg file points stdio at:
35
+ python3 /Users/matthewdi/social-autoposter/scripts/invent_mcp_server.py
36
+ The session inherits the calling user's identity (via http_api's installation
37
+ header), so writes are correctly attributed.
38
+ """
39
+ import os
40
+ import re
41
+ import sys
42
+ from typing import Optional
43
+
44
+ # Make the sibling scripts/ directory importable so we can reuse http_api
45
+ # (the same client log_twitter_search_attempts.py + invent_topics.py use).
46
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
47
+
48
+ from http_api import api_get, api_post # noqa: E402
49
+ from mcp.server.fastmcp import FastMCP # noqa: E402
50
+
51
+
52
+ # --- Constants mirrored from invent_topics.py -------------------------------
53
+ # Kept in lockstep with invent_topics.SIMILARITY_THRESHOLD so the in-session
54
+ # dedup verdict matches what the standalone post-hoc gate would say. If you
55
+ # change one, change the other.
56
+ SIMILARITY_THRESHOLD = 0.6
57
+
58
+ # Topic-funnel window the prompt-time ledger uses (same default).
59
+ WINDOW_DAYS = 30
60
+
61
+
62
+ # --- Cheap text helpers (ported, not imported, so this file is self-contained
63
+ # and the MCP server doesn't pull in the full invent module). ------------
64
+
65
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
66
+
67
+
68
+ def _tokens(text: str) -> set[str]:
69
+ return set(_TOKEN_RE.findall((text or "").lower()))
70
+
71
+
72
+ def _jaccard(a: str, b: str) -> float:
73
+ ta, tb = _tokens(a), _tokens(b)
74
+ if not ta or not tb:
75
+ return 0.0
76
+ return len(ta & tb) / len(ta | tb)
77
+
78
+
79
+ def _normalize_query(q: str) -> str:
80
+ """Strip per-cycle operators so since:/min_faves:/lang: collapse to one core.
81
+ Mirrors invent_topics.normalize_query."""
82
+ q = (q or "").lower()
83
+ for pat in (
84
+ r"\bsince:\S+", r"\buntil:\S+",
85
+ r"\bsince_time:\S+", r"\buntil_time:\S+",
86
+ r"\bmin_faves:\d+", r"\bmin_retweets:\d+", r"\bmin_replies:\d+",
87
+ r"\b-?filter:\S+", r"\blang:\S+",
88
+ ):
89
+ q = re.sub(pat, "", q)
90
+ q = re.sub(r'[()"]', "", q)
91
+ q = re.sub(r"\s+", " ", q).strip()
92
+ return q
93
+
94
+
95
+ # --- MCP server --------------------------------------------------------------
96
+
97
+ mcp = FastMCP("invent-tools")
98
+
99
+
100
+ # === TOPIC tools ===========================================================
101
+
102
+ @mcp.tool()
103
+ def search_topics(project: str, q: str = "", limit: int = 200) -> dict:
104
+ """Search the FULL active topic universe for a project.
105
+
106
+ Use this BEFORE proposing a new topic to verify nothing similar already
107
+ exists. Returns matching active topics for the project. If `q` is empty,
108
+ returns the full list (capped at `limit`). If `q` is a substring, only
109
+ topics containing it (case-insensitive) are returned.
110
+
111
+ Args:
112
+ project: project name (config.json casing, e.g. 'fazm')
113
+ q: substring filter, case-insensitive. Empty means "all".
114
+ limit: cap on returned rows (default 200, max 1000)
115
+
116
+ Returns: { "count": int, "topics": [{"topic": str, "source": str,
117
+ "status": str, "created_at": str}, ...] }
118
+ """
119
+ limit = max(1, min(int(limit or 200), 1000))
120
+ try:
121
+ resp = api_get("/api/v1/project-search-topics",
122
+ {"project": project, "status": "active"})
123
+ except SystemExit as e:
124
+ return {"error": f"api_get failed: {e}"}
125
+ rows = ((resp or {}).get("data") or {}).get("topics") or []
126
+ q_norm = (q or "").strip().lower()
127
+ out = []
128
+ for r in rows:
129
+ topic = (r.get("topic") or "").strip()
130
+ if not topic:
131
+ continue
132
+ if q_norm and q_norm not in topic.lower():
133
+ continue
134
+ out.append({
135
+ "topic": topic,
136
+ "source": r.get("source"),
137
+ "status": r.get("status"),
138
+ "created_at": r.get("created_at"),
139
+ })
140
+ if len(out) >= limit:
141
+ break
142
+ return {"count": len(out), "topics": out}
143
+
144
+
145
+ @mcp.tool()
146
+ def get_topic_stats(project: str, topic: str) -> dict:
147
+ """Read the topic-funnel row for one topic.
148
+
149
+ Use this when a topic from search_topics looks adjacent to what you want
150
+ to propose — read its performance to decide: STRONG/DECENT (good, propose
151
+ an ADJACENT angle), WEAK (avoid this neighborhood), DUD (no Twitter
152
+ supply, don't paraphrase).
153
+
154
+ Args:
155
+ project: project name
156
+ topic: exact topic string (case-insensitive match against the funnel)
157
+
158
+ Returns: { "found": bool, "stats": {...funnel row...} or null }
159
+ Funnel fields include: attempts_n, tweets_found_total, candidates_n,
160
+ posted_n, likes_total, clicks_total, views_total, clicks_per_post,
161
+ verdict ('strong'|'decent'|'weak'|'dud'|'untried').
162
+ """
163
+ try:
164
+ resp = api_get("/api/v1/topic-funnel",
165
+ {"project": project, "window_days": str(WINDOW_DAYS),
166
+ "platform": "twitter"})
167
+ except SystemExit as e:
168
+ return {"error": f"api_get failed: {e}"}
169
+ rows = ((resp or {}).get("data") or {}).get("rows") or []
170
+ needle = (topic or "").strip().lower()
171
+ for r in rows:
172
+ if (r.get("search_topic") or "").strip().lower() == needle:
173
+ return {"found": True, "stats": r}
174
+ return {"found": False, "stats": None}
175
+
176
+
177
+ @mcp.tool()
178
+ def submit_topic(project: str, topic: str, rationale: str = "") -> dict:
179
+ """Submit a new topic for the project. Runs Jaccard dedup against the
180
+ full active universe FIRST — if it's a near-dupe (sim >= 0.6) of any
181
+ existing topic, the submission is REJECTED and the offending neighbor +
182
+ similarity score are returned. React by proposing a different angle.
183
+
184
+ On success, the topic is written to project_search_topics with
185
+ source='invented', status='active'.
186
+
187
+ Args:
188
+ project: project name
189
+ topic: the topic phrase, 2-6 words, lowercase, no Twitter operators
190
+ rationale: ≤ 30 words explaining the gap this fills
191
+
192
+ Returns: { "ok": bool, "topic": str, "neighbor": str|None,
193
+ "similarity": float, "error": str|None }
194
+ """
195
+ topic_norm = (topic or "").strip().lower()
196
+ if not topic_norm:
197
+ return {"ok": False, "error": "topic is empty"}
198
+
199
+ # Fetch the universe and run Jaccard dedup ON THE SERVER so Claude sees
200
+ # the verdict as a tool-call result rather than dying outside the session.
201
+ try:
202
+ resp = api_get("/api/v1/project-search-topics",
203
+ {"project": project, "status": "active"})
204
+ except SystemExit as e:
205
+ return {"ok": False, "error": f"universe fetch failed: {e}"}
206
+ universe = [(r.get("topic") or "").strip().lower()
207
+ for r in ((resp or {}).get("data") or {}).get("topics") or []
208
+ if r.get("topic")]
209
+ universe_set = set(universe)
210
+
211
+ # Exact-match dupe → reject immediately.
212
+ if topic_norm in universe_set:
213
+ return {"ok": False, "topic": topic_norm,
214
+ "neighbor": topic_norm, "similarity": 1.0,
215
+ "error": "exact_dupe"}
216
+
217
+ # Near-dupe via Jaccard against every universe entry.
218
+ best_neighbor: Optional[str] = None
219
+ best_sim = 0.0
220
+ for u in universe:
221
+ s = _jaccard(topic_norm, u)
222
+ if s > best_sim:
223
+ best_sim = s
224
+ best_neighbor = u
225
+ if best_neighbor is not None and best_sim >= SIMILARITY_THRESHOLD:
226
+ return {"ok": False, "topic": topic_norm,
227
+ "neighbor": best_neighbor, "similarity": round(best_sim, 3),
228
+ "error": "near_dupe"}
229
+
230
+ # Honor invent_topics.py --dry-run. The parent process exports
231
+ # INVENT_DRY_RUN=1 before spawning claude -p, which inherits into this
232
+ # MCP server. We still want the dedup verdict and the "ok" path to fire
233
+ # so Claude's session flow is identical to prod — we just skip the POST.
234
+ if os.environ.get("INVENT_DRY_RUN") == "1":
235
+ return {"ok": True, "topic": topic_norm,
236
+ "neighbor": best_neighbor or "",
237
+ "similarity": round(best_sim, 3),
238
+ "dry_run": True}
239
+
240
+ # Non-dupe → commit via API.
241
+ try:
242
+ api_post("/api/v1/project-search-topics", body={
243
+ "project": project,
244
+ "topic": topic_norm,
245
+ "source": "invented",
246
+ "status": "active",
247
+ "notes": (rationale or "")[:512] or None,
248
+ })
249
+ except SystemExit as e:
250
+ return {"ok": False, "error": f"commit failed: {e}"}
251
+ return {"ok": True, "topic": topic_norm,
252
+ "neighbor": best_neighbor or "",
253
+ "similarity": round(best_sim, 3)}
254
+
255
+
256
+ # === QUERY tools ===========================================================
257
+
258
+ @mcp.tool()
259
+ def search_queries(project: str, q: str = "", limit: int = 200) -> dict:
260
+ """Search the FULL distinct-query history for a project (every query ever
261
+ drafted, cycle or invent). Use BEFORE drafting a new query to confirm
262
+ it's not a re-phrasing of one already tried.
263
+
264
+ Args:
265
+ project: project name
266
+ q: substring filter, case-insensitive
267
+ limit: cap on returned rows (default 200, max 1000)
268
+
269
+ Returns: { "count": int, "queries": [{"query": str, "core": str}, ...] }
270
+ `core` is the normalized form (operators stripped) for dedup compares.
271
+ """
272
+ limit = max(1, min(int(limit or 200), 1000))
273
+ # Request the full distinct set from the route (its own 5000 cap is fine)
274
+ # so the substring filter runs against ALL queries, not just the first
275
+ # alphabetical slice. Otherwise queries starting with 'w' get cut off
276
+ # when limit*5 = 50 and the project has hundreds of cores.
277
+ try:
278
+ resp = api_get("/api/v1/twitter-search-attempts/distinct-queries",
279
+ {"project": project, "limit": "5000"})
280
+ except SystemExit as e:
281
+ return {"error": f"api_get failed: {e}"}
282
+ queries = ((resp or {}).get("data") or {}).get("queries") or []
283
+ q_norm = (q or "").strip().lower()
284
+ out = []
285
+ for raw in queries:
286
+ if q_norm and q_norm not in raw.lower():
287
+ continue
288
+ out.append({"query": raw, "core": _normalize_query(raw)})
289
+ if len(out) >= limit:
290
+ break
291
+ return {"count": len(out), "queries": out}
292
+
293
+
294
+ @mcp.tool()
295
+ def get_query_stats(project: str, query: str) -> dict:
296
+ """Read performance stats for one query string.
297
+
298
+ Looks across BOTH the cycle's posted-engagement bank (top-queries: posts,
299
+ likes, clicks, virality) AND the invent supply test (invented-queries:
300
+ fresh tweets surfaced, even if never posted). Empty fields mean "this
301
+ query never appeared in that lane".
302
+
303
+ Args:
304
+ project: project name
305
+ query: exact query string (matched case-insensitively)
306
+
307
+ Returns: { "found": bool,
308
+ "cycle": {posts, likes, clicks, virality, ...} | None,
309
+ "invent": {supply, attempts} | None }
310
+ """
311
+ needle = (query or "").strip().lower()
312
+ if not needle:
313
+ return {"found": False, "error": "query is empty"}
314
+
315
+ # Cycle side: top-queries returns the posted-engagement winners.
316
+ cycle_row = None
317
+ try:
318
+ resp = api_get("/api/v1/twitter-search-attempts/top-queries",
319
+ {"project": project, "limit": "500"})
320
+ for r in ((resp or {}).get("data") or {}).get("rows") or []:
321
+ if (r.get("query") or "").strip().lower() == needle:
322
+ cycle_row = r
323
+ break
324
+ except SystemExit as e:
325
+ cycle_row = {"error": f"top-queries fetch failed: {e}"}
326
+
327
+ # Invent side: invented-queries returns supply-test winners.
328
+ invent_row = None
329
+ try:
330
+ resp = api_get("/api/v1/twitter-search-attempts/invented-queries",
331
+ {"project": project, "min_supply": "0", "limit": "500"})
332
+ for r in ((resp or {}).get("data") or {}).get("queries") or []:
333
+ if (r.get("query") or "").strip().lower() == needle:
334
+ invent_row = {"supply": r.get("supply", 0),
335
+ "attempts": r.get("attempts", 0)}
336
+ break
337
+ except SystemExit as e:
338
+ invent_row = {"error": f"invented-queries fetch failed: {e}"}
339
+
340
+ return {
341
+ "found": cycle_row is not None or invent_row is not None,
342
+ "cycle": cycle_row,
343
+ "invent": invent_row,
344
+ }
345
+
346
+
347
+ # --- entrypoint -------------------------------------------------------------
348
+
349
+ if __name__ == "__main__":
350
+ mcp.run()