@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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- 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()
|