@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,1462 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone topic-invention job — runs OUTSIDE the post-comments cycle.
3
+
4
+ Architectural split (2026-05-28): in-cycle EXPLORE_INVENT was removed
5
+ from pick_search_topic.py. Topic invention is now a separate, deliberate
6
+ background job:
7
+
8
+ - Picks ONE project per run using the same `pick_projects()` weighting
9
+ the cycle uses (inverse-recent-share, dampens active projects).
10
+ - Reads that project's per-topic funnel from
11
+ GET /api/v1/topic-funnel?project=<name> — server-side aggregation,
12
+ no local-file state (replaces ~/social-autoposter/state/topic_ledger.json
13
+ from earlier draft of this job, 2026-05-28).
14
+ - Asks Claude to propose 3-5 NEW search_topic candidates given the
15
+ project's description, the existing universe, the strong/decent
16
+ performers, the duds, and the untried tail.
17
+ - For each proposal, computes the closest existing neighbor in the
18
+ project's universe via token-Jaccard similarity (cheap, no
19
+ embeddings needed at our scale).
20
+ - Drops proposals that are exact-match dupes or near-dupes
21
+ (Jaccard >= SIMILARITY_THRESHOLD against any existing topic).
22
+ - POSTs survivors to /api/v1/project-search-topics with
23
+ source='invented', status='active'.
24
+ - POSTs an audit row to /api/v1/invented-topics-audit so invention
25
+ quality is reviewable offline (no local file).
26
+
27
+ Cadence: hourly via launchd com.m13v.social-invent-topics.
28
+ Project budget: one per run (n=1). Knob is PROJECTS_PER_RUN below.
29
+
30
+ CLI:
31
+ python3 scripts/invent_topics.py # pick a project, invent, commit
32
+ python3 scripts/invent_topics.py --project studyly # force a specific project
33
+ python3 scripts/invent_topics.py --dry-run # log plan, do not commit or audit
34
+ python3 scripts/invent_topics.py --proposals 5 # ask Claude for N proposals
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import re
42
+ import socket
43
+ import subprocess
44
+ import sys
45
+ import tempfile
46
+ import time
47
+ from datetime import datetime, timezone
48
+
49
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
50
+
51
+ from http_api import api_get, api_post # noqa: E402
52
+ from pick_project import load_config, pick_projects # noqa: E402
53
+
54
+
55
+ PROJECTS_PER_RUN = 1
56
+ DEFAULT_PROPOSALS = 1 # topics invented per loop iteration (one-topic-at-a-time)
57
+ SIMILARITY_THRESHOLD = 0.6 # Jaccard threshold above which we reject as near-dupe
58
+ WINDOW_DAYS = 30 # ledger window the picker reads from
59
+
60
+ # Retry-loop knobs. Each loop iteration invents ONE topic, drafts queries for
61
+ # it, supply-tests them, logs every attempt, and commits the topic. The loop
62
+ # stops as soon as a single topic clears the supply floor (sum of fresh tweets
63
+ # across its queries >= SUPPLY_FLOOR), or MAX_ATTEMPTS iterations are exhausted.
64
+ DEFAULT_TARGET = 1 # qualifying topics wanted per run (one is enough; supply is the real target)
65
+ DEFAULT_MAX_ATTEMPTS = 5 # hard cap on loop iterations per run (cost guard)
66
+
67
+ # Supply-test knobs (2026-05-28: invent loop now scans drafted queries before
68
+ # committing, mirroring the cycle's Phase 1 freshness gate).
69
+ QUERIES_PER_TOPIC = 5 # distinct queries drafted + scanned per invented topic
70
+ SUPPLY_FLOOR = 3 # min SUM of fresh tweets across a topic's queries to "qualify"
71
+ FRESHNESS_HOURS = 6 # freshness window each query is scanned at (matches discover)
72
+ CDP_PORT = 9555 # managed Chrome the twitter-harness drives
73
+ LOCK_TIMEOUT_SEC = 600 # how long the supply-test helper waits for twitter-browser lock
74
+
75
+ _REPO_DIR = os.path.expanduser("~/social-autoposter")
76
+ _SUPPLY_TEST_SH = os.path.join(_REPO_DIR, "skill", "invent-supply-test.sh")
77
+ _LOG_ATTEMPTS_PY = os.path.join(_REPO_DIR, "scripts", "log_twitter_search_attempts.py")
78
+
79
+
80
+ # --- Query normalization (copied verbatim from qualified_query_bank.normalize
81
+ # so this module needs NO direct DB import — all reads go through the API).
82
+ # Strips per-cycle operators so phrasings that differ only by freshness or
83
+ # min_faves collapse to one core for dedup. -------------------------------
84
+
85
+ def normalize_query(q: str) -> str:
86
+ """Strip per-cycle operators so two queries that differ only by
87
+ since:/min_faves:/filter: collapse to the same core for dedup."""
88
+ q = (q or "").lower()
89
+ for pat in (
90
+ r"\bsince:\S+", r"\buntil:\S+",
91
+ r"\bsince_time:\S+", r"\buntil_time:\S+",
92
+ r"\bmin_faves:\d+", r"\bmin_retweets:\d+", r"\bmin_replies:\d+",
93
+ r"\b-?filter:\S+", r"\blang:\S+",
94
+ ):
95
+ q = re.sub(pat, "", q)
96
+ q = re.sub(r'[()"]', "", q)
97
+ q = re.sub(r"\s+", " ", q).strip()
98
+ return q
99
+
100
+
101
+ # --- Tokenization for cheap similarity --------------------------------------
102
+
103
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
104
+
105
+
106
+ def _tokens(text: str) -> set[str]:
107
+ """Lowercased word-token set used for Jaccard similarity.
108
+
109
+ Strips punctuation and case. Stopword removal is intentionally
110
+ minimal — for short topic strings even small filler words carry
111
+ enough signal to differentiate genuine paraphrases.
112
+ """
113
+ return set(_TOKEN_RE.findall((text or "").lower()))
114
+
115
+
116
+ def _jaccard(a: str, b: str) -> float:
117
+ """Token Jaccard similarity in [0, 1]. Empty inputs return 0.
118
+
119
+ Cheap, deterministic, no embedding cost. Good enough at our scale
120
+ (<200 topics per project) to catch obvious paraphrases like
121
+ 'voice coding agent' vs 'voice AI coding agent' without false
122
+ positives across genuinely different concepts.
123
+ """
124
+ ta, tb = _tokens(a), _tokens(b)
125
+ if not ta or not tb:
126
+ return 0.0
127
+ inter = ta & tb
128
+ union = ta | tb
129
+ return len(inter) / len(union)
130
+
131
+
132
+ # --- Ledger / universe loading via API --------------------------------------
133
+
134
+ def load_project_topics(project_name: str,
135
+ window_days: int = WINDOW_DAYS) -> list[dict]:
136
+ """Fetch the per-topic funnel from /api/v1/topic-funnel for one project.
137
+
138
+ Server-side aggregation (no direct DB access from this client).
139
+ Returns rows already enriched with `verdict` and `clicks_per_post`.
140
+ Hard-fails on network errors — invention without ledger signal
141
+ would be uninformed and risks producing dupes-by-accident.
142
+ """
143
+ try:
144
+ resp = api_get(
145
+ "/api/v1/topic-funnel",
146
+ query={
147
+ "project": project_name,
148
+ "window_days": str(window_days),
149
+ "platform": "twitter",
150
+ },
151
+ )
152
+ except Exception as exc:
153
+ raise SystemExit(
154
+ f"topic-funnel API failed for project={project_name!r}: {exc}"
155
+ ) from exc
156
+ data = (resp or {}).get("data") or {}
157
+ rows = data.get("rows") or []
158
+ # Server returns rows sorted by clicks_total DESC. Re-sort by verdict
159
+ # for the prompt's table so strong/decent appear first regardless of
160
+ # raw click counts (a strong topic with low absolute clicks is still
161
+ # a quality signal we want to highlight).
162
+ verdict_rank = {"strong": 0, "decent": 1, "weak": 2, "untried": 3, "dud": 4}
163
+ rows.sort(key=lambda r: (
164
+ verdict_rank.get(r.get("verdict", "untried"), 5),
165
+ -(r.get("clicks_per_post") or 0),
166
+ -(r.get("posted_n") or 0),
167
+ ))
168
+ return rows
169
+
170
+
171
+ def project_universe_strings(project_name: str) -> set[str]:
172
+ """Full active universe for the project from project_search_topics.
173
+
174
+ Read from /api/v1/project-search-topics for the freshest read at
175
+ invent time. Lowercased for case-insensitive matching against
176
+ proposals.
177
+ """
178
+ try:
179
+ resp = api_get(
180
+ "/api/v1/project-search-topics",
181
+ query={"project": project_name, "status": "active"},
182
+ )
183
+ except Exception as exc:
184
+ raise SystemExit(
185
+ f"could not fetch active universe for project={project_name!r}: {exc}"
186
+ ) from exc
187
+ rows = ((resp or {}).get("data") or {}).get("topics") or []
188
+ return {(r.get("topic") or "").strip().lower() for r in rows if r.get("topic")}
189
+
190
+
191
+ # --- Prompt building --------------------------------------------------------
192
+
193
+ def _format_topic_table(rows: list[dict], max_per_bucket: int = 12) -> str:
194
+ """Compact markdown table of topics grouped by verdict.
195
+
196
+ Caps each bucket at max_per_bucket so the prompt stays bounded
197
+ even when the project has hundreds of untried seeds. The visible
198
+ slice is intentionally sorted by clicks_per_post DESC within each
199
+ bucket so the model sees what's actually converting.
200
+ """
201
+ buckets: dict[str, list[dict]] = {}
202
+ for r in rows:
203
+ buckets.setdefault(r.get("verdict", "untried"), []).append(r)
204
+
205
+ parts: list[str] = []
206
+ for verdict in ("strong", "decent", "weak", "dud", "untried"):
207
+ bucket = buckets.get(verdict, [])
208
+ if not bucket:
209
+ continue
210
+ parts.append(f"\n### {verdict.upper()} ({len(bucket)} total, showing top {min(len(bucket), max_per_bucket)})")
211
+ for r in bucket[:max_per_bucket]:
212
+ cpp = r.get("clicks_per_post")
213
+ cpp_str = f"{cpp:.2f}" if cpp is not None else "—"
214
+ parts.append(
215
+ f"- **{r['search_topic']}** "
216
+ f"(attempts {r['attempts_n']}, "
217
+ f"candidates {r['candidates_n']}, "
218
+ f"posted {r['posted_n']}, "
219
+ f"clicks/post {cpp_str}, "
220
+ f"supply {r['tweets_found_total']})"
221
+ )
222
+ if len(bucket) > max_per_bucket:
223
+ parts.append(f" …and {len(bucket) - max_per_bucket} more in this bucket.")
224
+ return "\n".join(parts)
225
+
226
+
227
+ def build_prompt(
228
+ project: dict,
229
+ topics: list[dict],
230
+ n_proposals: int,
231
+ avoid_topics: set[str] | None = None,
232
+ ) -> str:
233
+ """Assemble the Claude prompt for one tool-using topic invention session.
234
+
235
+ This version of the prompt gives Claude the invent-tools MCP suite
236
+ (search_topics / get_topic_stats / submit_topic) so dedup runs IN-SESSION
237
+ instead of post-hoc. The ledger snapshot is still included as a quick
238
+ overview, but Claude is expected to use the tools to verify against the
239
+ full universe before submitting. submit_topic returns a tool-call error
240
+ on near-dupes, so Claude can react and propose a different angle without
241
+ a new session.
242
+
243
+ `avoid_topics` carries topics already proposed earlier in THIS run.
244
+ Empty/None when the outer loop is on its first session.
245
+ """
246
+ name = project.get("name", "")
247
+ description = project.get("description", "")
248
+ voice = project.get("voice_relationship", "")
249
+
250
+ table = _format_topic_table(topics)
251
+
252
+ avoid_block = ""
253
+ if avoid_topics:
254
+ avoid_lines = "\n".join(f"- {t}" for t in sorted(avoid_topics))
255
+ avoid_block = (
256
+ "\n## Already proposed earlier this run — DO NOT repeat or paraphrase\n\n"
257
+ "An earlier session this run already suggested the topics below "
258
+ "(committed OR rejected as dupes). Stay clear of these:\n\n"
259
+ f"{avoid_lines}\n"
260
+ )
261
+
262
+ return f"""You are inventing ONE new Twitter search_topic seed for project **{name}**.
263
+
264
+ A topic is a short concept phrase (2-6 words typically) used to draft Twitter search queries downstream. Good topics surface fresh, on-topic threads where our reply has product fit. Bad topics are too generic (noise), too narrow (zero supply), or paraphrases of existing topics.
265
+
266
+ ## Project context
267
+ - Name: {name}
268
+ - Description: {description}
269
+ - Voice: {voice}
270
+
271
+ ## Performance ledger snapshot (top 12 per bucket; full universe has {len(topics)} topics)
272
+
273
+ {table}
274
+
275
+ ## Tools available — USE THEM before submitting
276
+
277
+ You have THREE tools exposed by the `invent-tools` MCP server. The ledger above is a snapshot; for ground truth and full coverage, call the tools.
278
+
279
+ 1. **search_topics(project, q="", limit=200)** — search the FULL active topic universe. Use a short substring (e.g. `q="agent"`) to scan everything adjacent to your candidate before you commit. Empty `q` returns the whole list.
280
+ 2. **get_topic_stats(project, topic)** — pull the topic-funnel row for any specific topic (attempts, supply, candidates, posted, likes, clicks, verdict). Use this when search_topics shows an adjacent topic and you need to know if it's STRONG (propose ADJACENT angle) or WEAK/DUD (AVOID neighborhood).
281
+ 3. **submit_topic(project, topic, rationale)** — submit your final topic. The tool runs Jaccard dedup against the full universe ON THE SERVER. If your topic is a near-dupe it returns `ok: false` with the offending neighbor and similarity score. PROPOSE A DIFFERENT ANGLE and try again. When it returns `ok: true`, the topic is committed.
282
+
283
+ ## Ledger bucket guide
284
+ - **STRONG** (clicks_per_post >= 1.0): audience converts. Invent ADJACENT angles, not paraphrases.
285
+ - **DECENT** (>= 0.3): solid signal. Same as strong, lower peak.
286
+ - **WEAK** (posted but few clicks): audience doesn't convert. AVOID this neighborhood.
287
+ - **DUD** (>=3 attempts, zero candidates): no Twitter supply. DON'T paraphrase — they fail at the source level.
288
+ - **UNTRIED** (no attempts in 30d): unknown. Read carefully before proposing nearby.
289
+ {avoid_block}
290
+ ## Workflow (follow strictly)
291
+ 1. Pick a candidate gap angle you think is genuinely new.
292
+ 2. Call `search_topics(project="{name}", q="<one-word probe from your candidate>")` to scan the neighborhood.
293
+ 3. For the 1-2 closest existing topics, call `get_topic_stats(project="{name}", topic="<the topic>")` to read their verdict.
294
+ 4. Based on what you find: keep your candidate if the gap is real, or pivot to a different angle.
295
+ 5. Call `submit_topic(project="{name}", topic="<2-6 words, lowercase>", rationale="<≤30 words, why this gap>")`.
296
+ 6. If `ok: false` (dupe error): use the `neighbor` field to pivot, then call submit_topic again with a genuinely different topic. Up to 5 submission attempts per session.
297
+ 7. When a submission returns `ok: true`, STOP and answer with the final JSON envelope below.
298
+
299
+ ## Required final answer
300
+
301
+ After a successful submit_topic call, end your response with EXACTLY this JSON (no prose around it):
302
+
303
+ ```json
304
+ {{"submitted_topic": "the topic you successfully submitted, verbatim", "rationale": "your rationale, verbatim"}}
305
+ ```
306
+
307
+ If you cannot find a non-dupe topic after several submit attempts and the project is genuinely saturated, end with:
308
+
309
+ ```json
310
+ {{"submitted_topic": null, "reason": "short explanation, e.g. 'tried X, Y, Z all dupes; project saturated this hour'"}}
311
+ ```
312
+
313
+ Strict topic constraints (enforced by submit_topic too):
314
+ - Lowercase, 2-6 words
315
+ - No quotes inside topic strings
316
+ - NO Twitter operators (no `min_faves:`, `-filter:replies`, `since:` — those are added at query-draft time downstream)"""
317
+
318
+
319
+ # --- Claude invocation ------------------------------------------------------
320
+
321
+ # Path to the MCP server that exposes search/stats/submit tools for the
322
+ # in-session topic + query lookups. Kept beside this file so the launchd job
323
+ # always finds it.
324
+ _MCP_SERVER_PY = os.path.join(_REPO_DIR, "scripts", "invent_mcp_server.py")
325
+
326
+ # Tool names the topic round is allowed to call. Claude Code's --allowed-tools
327
+ # accepts the `mcp__<server-name>__<tool>` form for MCP-provided tools.
328
+ # `invent-tools` is the name we passed to FastMCP() in the server.
329
+ _TOPIC_TOOLS = [
330
+ "mcp__invent-tools__search_topics",
331
+ "mcp__invent-tools__get_topic_stats",
332
+ "mcp__invent-tools__submit_topic",
333
+ ]
334
+ # Query round may also probe history if it wants to (read-only).
335
+ _QUERY_TOOLS = [
336
+ "mcp__invent-tools__search_queries",
337
+ "mcp__invent-tools__get_query_stats",
338
+ ]
339
+
340
+
341
+ def _write_mcp_config_file() -> str:
342
+ """Write a temporary MCP config JSON pointing at our invent-tools server
343
+ and return its path. claude -p --mcp-config <file> will spawn the server
344
+ as a subprocess over stdio."""
345
+ cfg = {
346
+ "mcpServers": {
347
+ "invent-tools": {
348
+ "command": "/opt/homebrew/bin/python3.11",
349
+ "args": [_MCP_SERVER_PY],
350
+ }
351
+ }
352
+ }
353
+ fd, path = tempfile.mkstemp(prefix="invent-mcp-", suffix=".json")
354
+ with os.fdopen(fd, "w") as f:
355
+ json.dump(cfg, f)
356
+ return path
357
+
358
+
359
+ def call_claude(prompt: str, timeout_sec: int = 300,
360
+ allowed_tools: list[str] | None = None) -> str:
361
+ """Run a single `claude -p` invocation and return its text output.
362
+
363
+ When `allowed_tools` is non-empty the call goes through the invent-tools
364
+ MCP server (search/stats/submit) and the model can call those tools
365
+ in-session before producing its final response. The Jaccard dedup runs
366
+ on the SERVER inside submit_topic, so a near-dupe surfaces as a
367
+ tool-call error Claude can react to instead of a silent post-hoc kill.
368
+
369
+ Inherits the global model from ~/.claude/settings.json per the
370
+ project's "single source of truth" convention (do NOT hardcode
371
+ --model here). The CLAUDE_MODEL env var, if set, is forwarded.
372
+ """
373
+ cmd = ["claude", "-p", "--output-format", "json"]
374
+ model = os.environ.get("CLAUDE_MODEL")
375
+ if model:
376
+ cmd += ["--model", model]
377
+ # When the parent process is in dry-run mode it sets INVENT_DRY_RUN=1
378
+ # in its own env at startup; claude -p inherits it, which propagates to
379
+ # the MCP server, which short-circuits submit_topic without POSTing.
380
+ mcp_cfg_path = None
381
+ if allowed_tools:
382
+ mcp_cfg_path = _write_mcp_config_file()
383
+ # --strict-mcp-config: ignore any user-level MCP config so test runs
384
+ # never pick up the operator's personal MCP servers by accident.
385
+ # --allowed-tools: explicit allow-list so Claude can't reach for any
386
+ # other tool (Read/Bash/etc) it might infer from its default kit.
387
+ cmd += ["--mcp-config", mcp_cfg_path,
388
+ "--strict-mcp-config",
389
+ "--allowed-tools", ",".join(allowed_tools)]
390
+ try:
391
+ proc = subprocess.run(
392
+ cmd,
393
+ input=prompt,
394
+ text=True,
395
+ capture_output=True,
396
+ timeout=timeout_sec,
397
+ )
398
+ finally:
399
+ if mcp_cfg_path:
400
+ try:
401
+ os.remove(mcp_cfg_path)
402
+ except OSError:
403
+ pass
404
+ if proc.returncode != 0:
405
+ raise SystemExit(
406
+ f"claude -p exited {proc.returncode}: {proc.stderr[:500]}"
407
+ )
408
+ # claude -p --output-format json wraps the model's text in
409
+ # {"result": "...", ...}. Extract the result string.
410
+ try:
411
+ envelope = json.loads(proc.stdout)
412
+ except json.JSONDecodeError as exc:
413
+ raise SystemExit(
414
+ f"could not parse claude envelope: {exc}\nstdout head: {proc.stdout[:500]}"
415
+ ) from exc
416
+ return envelope.get("result") or ""
417
+
418
+
419
+ # --- Output parsing ---------------------------------------------------------
420
+
421
+ def extract_proposals(claude_text: str) -> list[dict]:
422
+ """Pull the proposals[] array out of Claude's output.
423
+
424
+ Defensive: handles plain JSON, JSON in fenced code blocks, and the
425
+ occasional preamble. Returns [] on any parse failure; the caller
426
+ audit-logs the raw text so we can debug offline.
427
+ """
428
+ text = (claude_text or "").strip()
429
+ if not text:
430
+ return []
431
+ # Try fenced JSON first
432
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
433
+ candidate = m.group(1) if m else None
434
+ if candidate is None:
435
+ # Try the first balanced top-level {...} block
436
+ start = text.find("{")
437
+ if start >= 0:
438
+ candidate = text[start:]
439
+ if not candidate:
440
+ return []
441
+ try:
442
+ env = json.loads(candidate)
443
+ except json.JSONDecodeError:
444
+ # Trailing prose can break json.loads; try to find the matching brace
445
+ try:
446
+ depth = 0
447
+ for i, ch in enumerate(candidate):
448
+ if ch == "{":
449
+ depth += 1
450
+ elif ch == "}":
451
+ depth -= 1
452
+ if depth == 0:
453
+ env = json.loads(candidate[: i + 1])
454
+ break
455
+ else:
456
+ return []
457
+ except Exception:
458
+ return []
459
+ props = env.get("proposals") or []
460
+ cleaned: list[dict] = []
461
+ for p in props:
462
+ if not isinstance(p, dict):
463
+ continue
464
+ topic = (p.get("topic") or "").strip().lower()
465
+ rationale = (p.get("rationale") or "").strip()
466
+ if not topic:
467
+ continue
468
+ cleaned.append({"topic": topic, "rationale": rationale})
469
+ return cleaned
470
+
471
+
472
+ def extract_submitted_topic(claude_text: str) -> dict | None:
473
+ """Parse Claude's final envelope from the tool-using topic session.
474
+
475
+ Looks for the LAST JSON object in the response containing a
476
+ `submitted_topic` field. Returns:
477
+ - {"topic": "...", "rationale": "..."} on a successful submission
478
+ - {"topic": None, "reason": "..."} on a saturated session
479
+ - None if the envelope is missing or malformed (caller treats as bailout)
480
+ """
481
+ text = (claude_text or "").strip()
482
+ if not text:
483
+ return None
484
+ # Walk all fenced JSON blocks first, then any bare {...} blocks; take the
485
+ # LAST that contains "submitted_topic" so trailing prose from the tool
486
+ # iteration doesn't shadow the final answer.
487
+ candidates: list[str] = []
488
+ for m in re.finditer(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL):
489
+ candidates.append(m.group(1))
490
+ # Fallback: scan balanced braces for any unfenced JSON
491
+ depth, start = 0, -1
492
+ for i, ch in enumerate(text):
493
+ if ch == "{":
494
+ if depth == 0:
495
+ start = i
496
+ depth += 1
497
+ elif ch == "}":
498
+ if depth > 0:
499
+ depth -= 1
500
+ if depth == 0 and start >= 0:
501
+ candidates.append(text[start:i + 1])
502
+ start = -1
503
+ last_ok: dict | None = None
504
+ for blob in candidates:
505
+ try:
506
+ env = json.loads(blob)
507
+ except json.JSONDecodeError:
508
+ continue
509
+ if not isinstance(env, dict) or "submitted_topic" not in env:
510
+ continue
511
+ topic_val = env.get("submitted_topic")
512
+ if topic_val is None:
513
+ last_ok = {"topic": None,
514
+ "reason": (env.get("reason") or "").strip()}
515
+ else:
516
+ topic = (str(topic_val) or "").strip().lower()
517
+ if not topic:
518
+ continue
519
+ last_ok = {"topic": topic,
520
+ "rationale": (env.get("rationale") or "").strip()}
521
+ return last_ok
522
+
523
+
524
+ # --- Validation -------------------------------------------------------------
525
+
526
+ def find_closest_neighbor(
527
+ proposal: str,
528
+ universe: set[str],
529
+ ) -> tuple[str | None, float]:
530
+ """Return the closest universe topic by Jaccard similarity.
531
+
532
+ Used both to reject near-dupes (Jaccard >= SIMILARITY_THRESHOLD)
533
+ and to attach context to each proposal in the audit log.
534
+ """
535
+ best_topic: str | None = None
536
+ best_sim = 0.0
537
+ for u in universe:
538
+ sim = _jaccard(proposal, u)
539
+ if sim > best_sim:
540
+ best_sim = sim
541
+ best_topic = u
542
+ return best_topic, best_sim
543
+
544
+
545
+ def validate_proposals(
546
+ proposals: list[dict],
547
+ universe: set[str],
548
+ ) -> tuple[list[dict], list[dict]]:
549
+ """Split proposals into (committed_ok, rejected) by dedupe rule.
550
+
551
+ A proposal is rejected when:
552
+ - Its lowercased topic exactly matches an existing universe entry
553
+ - Its highest Jaccard similarity to any universe entry meets or
554
+ exceeds SIMILARITY_THRESHOLD (near-dupe)
555
+
556
+ The reason for rejection plus the offending neighbor is attached
557
+ to each rejected entry for audit-logging.
558
+ """
559
+ committed: list[dict] = []
560
+ rejected: list[dict] = []
561
+ # Build a working set of "already committed this run" so two
562
+ # near-duplicate proposals in the same batch don't both land.
563
+ working_universe = set(universe)
564
+
565
+ for prop in proposals:
566
+ topic = prop["topic"]
567
+ if topic in working_universe:
568
+ rejected.append({
569
+ **prop,
570
+ "reject_reason": "exact_dupe",
571
+ "neighbor": topic,
572
+ "similarity": 1.0,
573
+ })
574
+ continue
575
+ neighbor, sim = find_closest_neighbor(topic, working_universe)
576
+ if neighbor is not None and sim >= SIMILARITY_THRESHOLD:
577
+ rejected.append({
578
+ **prop,
579
+ "reject_reason": "near_dupe",
580
+ "neighbor": neighbor,
581
+ "similarity": round(sim, 3),
582
+ })
583
+ continue
584
+ committed.append({
585
+ **prop,
586
+ "neighbor": neighbor,
587
+ "similarity": round(sim, 3) if neighbor else 0.0,
588
+ })
589
+ working_universe.add(topic)
590
+
591
+ return committed, rejected
592
+
593
+
594
+ # --- Commit + audit (both via API) ------------------------------------------
595
+
596
+ def commit_topic(project_name: str, topic: str, dry_run: bool = False) -> bool:
597
+ """POST a new topic to project_search_topics with source='invented'.
598
+
599
+ Returns True on success. Idempotent in the API layer: re-POSTing
600
+ an existing (project, topic) pair is a no-op upstream. We still
601
+ rely on the local validation step to dedupe so the audit log is
602
+ accurate about which proposals were genuinely new.
603
+ """
604
+ if dry_run:
605
+ print(f"[dry-run] would commit project={project_name!r} topic={topic!r}",
606
+ file=sys.stderr)
607
+ return True
608
+ try:
609
+ api_post(
610
+ "/api/v1/project-search-topics",
611
+ body={
612
+ "project": project_name,
613
+ "topic": topic,
614
+ "source": "invented",
615
+ "status": "active",
616
+ },
617
+ )
618
+ return True
619
+ except SystemExit as exc:
620
+ print(f"[invent_topics] commit FAILED project={project_name!r} "
621
+ f"topic={topic!r} error={exc}", file=sys.stderr)
622
+ return False
623
+
624
+
625
+ def write_audit(payload: dict, dry_run: bool = False) -> None:
626
+ """POST one audit row to /api/v1/invented-topics-audit.
627
+
628
+ No local file: persistence is server-side via the API. Failures
629
+ are logged but never raise — losing one audit row is preferable
630
+ to crashing the invocation that just successfully committed topics.
631
+ """
632
+ if dry_run:
633
+ print(f"[dry-run] would write audit row (project={payload.get('project')!r})",
634
+ file=sys.stderr)
635
+ return
636
+ try:
637
+ api_post("/api/v1/invented-topics-audit", body=payload)
638
+ except SystemExit as exc:
639
+ print(f"[invent_topics] audit POST failed: {exc}", file=sys.stderr)
640
+ except Exception as exc:
641
+ print(f"[invent_topics] audit POST exception: {exc}", file=sys.stderr)
642
+
643
+
644
+ # --- Harness liveness -------------------------------------------------------
645
+
646
+ def harness_alive(port: int = CDP_PORT, timeout: float = 2.0) -> bool:
647
+ """Cheap TCP probe of the managed Chrome's CDP port.
648
+
649
+ The supply test is meaningless if the browser the harness drives isn't up:
650
+ every scan would return 0 and we'd commit real topics as false 'duds'. So
651
+ the loop checks this BEFORE spending Claude tokens on query drafting, and
652
+ treats a mid-run drop as 'untested' (abort, retry next hour) rather than
653
+ 'zero supply'.
654
+ """
655
+ try:
656
+ with socket.create_connection(("127.0.0.1", port), timeout=timeout):
657
+ return True
658
+ except OSError:
659
+ return False
660
+
661
+
662
+ # --- Query drafting (Claude) ------------------------------------------------
663
+
664
+ # Per-project ledger cache so build_query_prompt doesn't fetch top-queries +
665
+ # dud-queries + invented-queries from scratch on every topic in a single run.
666
+ _QUERY_LEDGER_CACHE: dict[str, str] = {}
667
+
668
+
669
+ def _build_query_ledger(project_name: str) -> str:
670
+ """Fetch the per-query performance ledger for the project and format it
671
+ as a markdown table bucketed STRONG / DECENT / WEAK / INVENTED / DUD.
672
+
673
+ Sources:
674
+ - /api/v1/twitter-search-attempts/top-queries → posted-engagement winners
675
+ - /api/v1/twitter-search-attempts/invented-queries → supply-only winners
676
+ - /api/v1/twitter-search-attempts/dud-queries → zero-supply queries
677
+ (avoid paraphrasing — they fail at the source, not the framing)
678
+
679
+ Cached per project for the lifetime of the process so multiple topics in
680
+ one run share one fetch. Returns "" on fetch failure (best-effort
681
+ enrichment; the prompt still works without it).
682
+ """
683
+ if project_name in _QUERY_LEDGER_CACHE:
684
+ return _QUERY_LEDGER_CACHE[project_name]
685
+
686
+ try:
687
+ top_resp = api_get("/api/v1/twitter-search-attempts/top-queries",
688
+ {"project": project_name, "limit": "50"})
689
+ invent_resp = api_get("/api/v1/twitter-search-attempts/invented-queries",
690
+ {"project": project_name, "min_supply": "1",
691
+ "limit": "30"})
692
+ dud_resp = api_get("/api/v1/twitter-search-attempts/dud-queries",
693
+ {"project": project_name, "limit": "20"})
694
+ except SystemExit as exc:
695
+ print(f"[invent_topics] query-ledger fetch failed: {exc}",
696
+ file=sys.stderr)
697
+ _QUERY_LEDGER_CACHE[project_name] = ""
698
+ return ""
699
+
700
+ top_rows = ((top_resp or {}).get("data") or {}).get("rows") or []
701
+ invent_rows = ((invent_resp or {}).get("data") or {}).get("queries") or []
702
+ dud_rows = ((dud_resp or {}).get("data") or {}).get("rows") or []
703
+
704
+ # Bucket top rows by clicks_per_post — same verdict the topic ledger uses.
705
+ strong, decent, weak = [], [], []
706
+ for r in top_rows:
707
+ posts = r.get("posts") or r.get("posted_n") or 0
708
+ clicks = r.get("clicks_total") or 0
709
+ if posts <= 0:
710
+ continue
711
+ cpp = clicks / posts
712
+ if cpp >= 1.0:
713
+ strong.append(r)
714
+ elif cpp >= 0.3:
715
+ decent.append(r)
716
+ else:
717
+ weak.append(r)
718
+
719
+ def _fmt_top(r: dict) -> str:
720
+ q = (r.get("query") or "")[:110]
721
+ return (f"- `{q}` posts {r.get('posts', r.get('posted_n', 0))}, "
722
+ f"likes {r.get('likes_total', 0)}, "
723
+ f"clicks {r.get('clicks_total', 0)}")
724
+
725
+ parts: list[str] = []
726
+ if strong:
727
+ parts.append(f"\n### STRONG queries ({len(strong)} total, "
728
+ f"showing top {min(len(strong), 10)}; clicks_per_post >= 1.0)")
729
+ for r in strong[:10]:
730
+ parts.append(_fmt_top(r))
731
+ if decent:
732
+ parts.append(f"\n### DECENT queries ({len(decent)} total, "
733
+ f"showing top {min(len(decent), 10)}; clicks_per_post >= 0.3)")
734
+ for r in decent[:10]:
735
+ parts.append(_fmt_top(r))
736
+ if weak:
737
+ parts.append(f"\n### WEAK queries ({len(weak)} total, "
738
+ f"showing top {min(len(weak), 6)}; posted but low engagement)")
739
+ for r in weak[:6]:
740
+ parts.append(_fmt_top(r))
741
+ if invent_rows:
742
+ parts.append(f"\n### INVENTED queries ({len(invent_rows)} total; "
743
+ f"surfaced supply but no posts yet)")
744
+ for r in invent_rows[:10]:
745
+ q = (r.get("query") or "")[:110]
746
+ parts.append(f"- `{q}` supply {r.get('supply', 0)}, "
747
+ f"attempts {r.get('attempts', 0)}")
748
+ if dud_rows:
749
+ parts.append(f"\n### DUD queries ({len(dud_rows)} total, "
750
+ f"showing top {min(len(dud_rows), 10)}) — DO NOT paraphrase")
751
+ for r in dud_rows[:10]:
752
+ q = (r.get("query") or "")[:110]
753
+ parts.append(f"- `{q}` attempts {r.get('attempts', 0)}")
754
+
755
+ out = "\n".join(parts) if parts else ""
756
+ _QUERY_LEDGER_CACHE[project_name] = out
757
+ return out
758
+
759
+
760
+ def build_query_prompt(
761
+ project: dict,
762
+ topic: str,
763
+ n_queries: int,
764
+ avoid_queries: set[str] | None = None,
765
+ ) -> str:
766
+ """Prompt Claude to draft N distinct X/Twitter advanced-search queries for
767
+ one invented topic. Includes a per-query performance ledger (STRONG /
768
+ DECENT / WEAK / INVENTED / DUD with posts/clicks/likes/supply stats) so
769
+ the model can pattern-match against what's working for this project
770
+ instead of drafting in the dark. `avoid_queries` carries cores already
771
+ drafted/tried this run so a refill steers away from them."""
772
+ name = project.get("name", "")
773
+ description = project.get("description", "")
774
+ excludes = project.get("excludes_for_search") or project.get("excludes") or []
775
+ excludes_block = ""
776
+ if isinstance(excludes, list) and excludes:
777
+ excludes_block = (
778
+ "\n## Mandatory exclude terms for this project\n\n"
779
+ "Append these as `-term` to EVERY query (they filter known noise):\n"
780
+ f"{' '.join('-' + str(e) for e in excludes)}\n"
781
+ )
782
+
783
+ avoid_block = ""
784
+ if avoid_queries:
785
+ avoid_lines = "\n".join(f"- {q}" for q in sorted(avoid_queries))
786
+ avoid_block = (
787
+ "\n## Already tried — do NOT repeat or trivially re-phrase these\n\n"
788
+ f"{avoid_lines}\n"
789
+ )
790
+
791
+ ledger = _build_query_ledger(name)
792
+ ledger_block = ""
793
+ if ledger:
794
+ ledger_block = (
795
+ "\n## Per-query performance ledger for this project\n\n"
796
+ "The queries below have been tried; use their performance to "
797
+ "shape your N drafts. Mimic the operator structure of STRONG/"
798
+ "DECENT queries when the angle fits the topic; pattern-match "
799
+ "INVENTED queries that already surfaced supply; AVOID the "
800
+ "phrasings in WEAK and DUD.\n"
801
+ f"{ledger}\n"
802
+ )
803
+
804
+ return f"""You are drafting X (Twitter) advanced-search queries to find FRESH threads where project **{name}** could reply with product fit.
805
+
806
+ ## Project
807
+ - Name: {name}
808
+ - Description: {description}
809
+
810
+ ## Topic to cover
811
+ **{topic}**
812
+
813
+ Draft **exactly {n_queries}** DISTINCT search queries that probe this topic from different angles, so together they maximize the chance of surfacing fresh, on-topic tweets.
814
+ {excludes_block}{avoid_block}{ledger_block}
815
+ ## Query rules
816
+ - Each query targets the topic above but varies the angle/phrasing/breadth so the {n_queries} don't overlap.
817
+ - You MAY use X operators: `min_faves:N`, `OR` (inside parentheses), quoted phrases, `-excludeterm`, `lang:en`.
818
+ - Do NOT include `since:`, `until:`, `since_time:`, or `until_time:` — the freshness window ({FRESHNESS_HOURS}h) is applied automatically downstream.
819
+ - Mix breadth: at least one broad query (few/no operators) and at least one tighter query (e.g. `min_faves:5` or a quoted phrase) so we measure supply at multiple precision levels.
820
+ - Keep each query realistic — phrasing real users would actually tweet, not keyword salad.
821
+
822
+ ## Output format
823
+ Return STRICT JSON only, no prose:
824
+
825
+ ```json
826
+ {{"queries": ["query one", "query two", "... exactly {n_queries} total ..."]}}
827
+ ```"""
828
+
829
+
830
+ def extract_queries(claude_text: str, n_expected: int) -> list[str]:
831
+ """Pull the queries[] array out of Claude's output (fenced or bare JSON)."""
832
+ text = (claude_text or "").strip()
833
+ if not text:
834
+ return []
835
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
836
+ candidate = m.group(1) if m else None
837
+ if candidate is None:
838
+ start = text.find("{")
839
+ candidate = text[start:] if start >= 0 else None
840
+ if not candidate:
841
+ return []
842
+ env = None
843
+ try:
844
+ env = json.loads(candidate)
845
+ except json.JSONDecodeError:
846
+ # Trim trailing prose to the matching brace.
847
+ depth = 0
848
+ for i, ch in enumerate(candidate):
849
+ if ch == "{":
850
+ depth += 1
851
+ elif ch == "}":
852
+ depth -= 1
853
+ if depth == 0:
854
+ try:
855
+ env = json.loads(candidate[: i + 1])
856
+ except json.JSONDecodeError:
857
+ env = None
858
+ break
859
+ if not isinstance(env, dict):
860
+ return []
861
+ out: list[str] = []
862
+ seen: set[str] = set()
863
+ for q in env.get("queries") or []:
864
+ if not isinstance(q, str):
865
+ continue
866
+ q = q.strip()
867
+ core = normalize_query(q)
868
+ if not q or not core or core in seen:
869
+ continue
870
+ seen.add(core)
871
+ out.append(q)
872
+ return out[:n_expected] if n_expected else out
873
+
874
+
875
+ # --- Query dedup against history (via API) ----------------------------------
876
+
877
+ def load_existing_query_cores(project_name: str) -> set[str]:
878
+ """Normalized cores of every query ever attempted for this project.
879
+
880
+ Reads /api/v1/twitter-search-attempts/distinct-queries (no direct DB).
881
+ Returns an empty set on API failure so a transient read error degrades to
882
+ 'no dedup' rather than crashing the invocation (we'd rather re-test a
883
+ duplicate query than skip inventing entirely)."""
884
+ try:
885
+ resp = api_get(
886
+ "/api/v1/twitter-search-attempts/distinct-queries",
887
+ query={"project": project_name},
888
+ )
889
+ except SystemExit as exc:
890
+ print(f"[invent_topics] distinct-queries read failed for "
891
+ f"{project_name!r}: {exc} (proceeding without query dedup)",
892
+ file=sys.stderr)
893
+ return set()
894
+ queries = ((resp or {}).get("data") or {}).get("queries") or []
895
+ return {normalize_query(q) for q in queries if q}
896
+
897
+
898
+ def dedup_queries(
899
+ drafted: list[str],
900
+ existing_cores: set[str],
901
+ ) -> tuple[list[str], list[str]]:
902
+ """Split drafted queries into (new, already_tried) by normalized core."""
903
+ new: list[str] = []
904
+ dupes: list[str] = []
905
+ seen = set(existing_cores)
906
+ for q in drafted:
907
+ core = normalize_query(q)
908
+ if core in seen:
909
+ dupes.append(q)
910
+ else:
911
+ new.append(q)
912
+ seen.add(core)
913
+ return new, dupes
914
+
915
+
916
+ # --- Supply test (browser-harness via lock helper) --------------------------
917
+
918
+ def supply_test(
919
+ project_name: str,
920
+ topic: str,
921
+ queries: list[str],
922
+ freshness_hours: int = FRESHNESS_HOURS,
923
+ lock_timeout: int = LOCK_TIMEOUT_SEC,
924
+ ) -> tuple[bool, list[dict]]:
925
+ """Scan each query at `freshness_hours` via the lock+harness helper.
926
+
927
+ Returns (tested, results) where results is
928
+ [{"query": q, "tweets_found": n}, ...] in the SAME order as `queries`.
929
+
930
+ tested=False means the helper produced NO scan records (lock timeout, or
931
+ the browser went down) — the caller must NOT treat that as zero supply.
932
+ """
933
+ if not queries:
934
+ return True, []
935
+ qpayload = [
936
+ {"project": project_name, "query": q, "search_topic": topic}
937
+ for q in queries
938
+ ]
939
+ with tempfile.NamedTemporaryFile(
940
+ "w", suffix=".json", prefix="invent-queries-", delete=False
941
+ ) as qf:
942
+ json.dump(qpayload, qf)
943
+ queries_path = qf.name
944
+ scan_out = tempfile.NamedTemporaryFile(
945
+ suffix=".jsonl", prefix="invent-scan-", delete=False
946
+ ).name
947
+
948
+ try:
949
+ subprocess.run(
950
+ ["bash", _SUPPLY_TEST_SH, queries_path, scan_out,
951
+ str(freshness_hours), str(lock_timeout)],
952
+ check=False,
953
+ timeout=lock_timeout + 600, # helper's own lock wait + scan headroom
954
+ )
955
+ except subprocess.TimeoutExpired:
956
+ print(f"[invent_topics] supply-test helper timed out for topic "
957
+ f"{topic!r}", file=sys.stderr)
958
+ _safe_unlink(queries_path)
959
+ _safe_unlink(scan_out)
960
+ return False, []
961
+
962
+ # Parse per-query scan records. scan() writes one record per call even on
963
+ # zero tweets, so an empty file means the scan loop never ran (untested).
964
+ found_by_core: dict[str, int] = {}
965
+ records = 0
966
+ try:
967
+ with open(scan_out) as f:
968
+ for ln in f:
969
+ ln = ln.strip()
970
+ if not ln:
971
+ continue
972
+ try:
973
+ rec = json.loads(ln)
974
+ except json.JSONDecodeError:
975
+ continue
976
+ records += 1
977
+ core = normalize_query(rec.get("query", ""))
978
+ found_by_core[core] = len(rec.get("tweets") or [])
979
+ except OSError:
980
+ records = 0
981
+
982
+ _safe_unlink(queries_path)
983
+ _safe_unlink(scan_out)
984
+
985
+ if records == 0:
986
+ return False, []
987
+
988
+ results = [
989
+ {"query": q, "tweets_found": int(found_by_core.get(normalize_query(q), 0))}
990
+ for q in queries
991
+ ]
992
+ return True, results
993
+
994
+
995
+ def _safe_unlink(path: str) -> None:
996
+ try:
997
+ os.unlink(path)
998
+ except OSError:
999
+ pass
1000
+
1001
+
1002
+ # --- Attempt logging (via log_twitter_search_attempts.py -> route) ----------
1003
+
1004
+ def log_attempts(
1005
+ project_name: str,
1006
+ topic: str,
1007
+ results: list[dict],
1008
+ batch_id: str,
1009
+ dry_run: bool = False,
1010
+ ) -> None:
1011
+ """Log every scanned query (dud + hit) to twitter_search_attempts via the
1012
+ existing logger script, which POSTs to /api/v1/twitter-search-attempts.
1013
+
1014
+ All attempts are logged on purpose — duds are the anti-list signal and the
1015
+ topic's supply record, per the user's 'log all attempts' rule."""
1016
+ if not results:
1017
+ return
1018
+ rows = [
1019
+ {
1020
+ "query": r["query"],
1021
+ "project": project_name,
1022
+ "tweets_found": r["tweets_found"],
1023
+ "search_topic": topic,
1024
+ }
1025
+ for r in results
1026
+ ]
1027
+ if dry_run:
1028
+ print(f"[dry-run] would log {len(rows)} attempts for topic {topic!r} "
1029
+ f"(batch={batch_id})", file=sys.stderr)
1030
+ return
1031
+ try:
1032
+ subprocess.run(
1033
+ ["python3", _LOG_ATTEMPTS_PY, "--batch-id", batch_id, "--kind", "invent"],
1034
+ input=json.dumps(rows),
1035
+ text=True,
1036
+ check=False,
1037
+ timeout=120,
1038
+ )
1039
+ except subprocess.TimeoutExpired:
1040
+ print(f"[invent_topics] log_attempts timed out for topic {topic!r}",
1041
+ file=sys.stderr)
1042
+
1043
+
1044
+ # --- Per-topic pipeline: draft -> dedup -> scan -> log ----------------------
1045
+
1046
+ def process_topic(
1047
+ project: dict,
1048
+ topic: str,
1049
+ existing_query_cores: set[str],
1050
+ batch_id: str,
1051
+ dry_run: bool = False,
1052
+ ) -> dict:
1053
+ """Run the full draft->dedup->supply-test->log pipeline for one topic.
1054
+
1055
+ Returns a result dict with: queries_drafted, queries_tested, attempts
1056
+ (list of {query, tweets_found}), supply_total, tested (bool), qualifies
1057
+ (bool). `tested=False` signals the browser was unavailable — the caller
1058
+ should abort the run rather than treat the topic as a dud.
1059
+ """
1060
+ project_name = project.get("name", "")
1061
+
1062
+ # 1. Draft queries.
1063
+ qprompt = build_query_prompt(project, topic, QUERIES_PER_TOPIC)
1064
+ raw = call_claude(qprompt)
1065
+ drafted = extract_queries(raw, QUERIES_PER_TOPIC)
1066
+ print(f" [{topic}] drafted {len(drafted)} queries", file=sys.stderr)
1067
+
1068
+ # 2. Dedup against history; refill ONCE if dedup drops us below target.
1069
+ new_q, dupes = dedup_queries(drafted, existing_query_cores)
1070
+ if dupes:
1071
+ print(f" [{topic}] dropped {len(dupes)} already-tried queries",
1072
+ file=sys.stderr)
1073
+ if len(new_q) < QUERIES_PER_TOPIC and drafted:
1074
+ tried_cores = existing_query_cores | {normalize_query(q) for q in drafted}
1075
+ refill_prompt = build_query_prompt(
1076
+ project, topic, QUERIES_PER_TOPIC,
1077
+ avoid_queries={normalize_query(q) for q in drafted},
1078
+ )
1079
+ refill_raw = call_claude(refill_prompt)
1080
+ refill = extract_queries(refill_raw, QUERIES_PER_TOPIC)
1081
+ more_new, _ = dedup_queries(refill, tried_cores)
1082
+ for q in more_new:
1083
+ if len(new_q) >= QUERIES_PER_TOPIC:
1084
+ break
1085
+ new_q.append(q)
1086
+ print(f" [{topic}] refill added {min(len(more_new), QUERIES_PER_TOPIC)} "
1087
+ f"queries (now {len(new_q)})", file=sys.stderr)
1088
+
1089
+ queries = new_q[:QUERIES_PER_TOPIC]
1090
+
1091
+ # 3. Supply-test.
1092
+ if dry_run:
1093
+ # No browser work in dry-run; report the plan only.
1094
+ print(f" [dry-run] [{topic}] would scan {len(queries)} queries at "
1095
+ f"{FRESHNESS_HOURS}h", file=sys.stderr)
1096
+ return {
1097
+ "topic": topic, "queries_drafted": len(drafted),
1098
+ "queries_tested": len(queries), "attempts": [],
1099
+ "supply_total": 0, "tested": True, "qualifies": False,
1100
+ "queries": queries,
1101
+ }
1102
+
1103
+ tested, results = supply_test(project_name, topic, queries)
1104
+ if not tested:
1105
+ return {
1106
+ "topic": topic, "queries_drafted": len(drafted),
1107
+ "queries_tested": len(queries), "attempts": [],
1108
+ "supply_total": 0, "tested": False, "qualifies": False,
1109
+ "queries": queries,
1110
+ }
1111
+
1112
+ supply_total = sum(r["tweets_found"] for r in results)
1113
+ qualifies = supply_total >= SUPPLY_FLOOR
1114
+ for r in results:
1115
+ print(f" scan q={r['query'][:48]!r} -> {r['tweets_found']} fresh",
1116
+ file=sys.stderr)
1117
+ print(f" [{topic}] supply_total={supply_total} "
1118
+ f"(floor={SUPPLY_FLOOR}) qualifies={qualifies}", file=sys.stderr)
1119
+
1120
+ # 4. Log all attempts (dud + hit).
1121
+ log_attempts(project_name, topic, results, batch_id, dry_run=dry_run)
1122
+
1123
+ return {
1124
+ "topic": topic, "queries_drafted": len(drafted),
1125
+ "queries_tested": len(queries), "attempts": results,
1126
+ "supply_total": supply_total, "tested": True, "qualifies": qualifies,
1127
+ "queries": queries,
1128
+ }
1129
+
1130
+
1131
+ # --- Main -------------------------------------------------------------------
1132
+
1133
+ def main():
1134
+ ap = argparse.ArgumentParser(description=__doc__)
1135
+ ap.add_argument("--project", default=None,
1136
+ help="Force a specific project (skips pick_projects)")
1137
+ ap.add_argument("--proposals", type=int, default=DEFAULT_PROPOSALS,
1138
+ help="How many candidate topics to ask Claude for per attempt")
1139
+ ap.add_argument("--target", type=int, default=DEFAULT_TARGET,
1140
+ help="Loop until this many NEW non-dupe topics are committed")
1141
+ ap.add_argument("--max-attempts", type=int, default=DEFAULT_MAX_ATTEMPTS,
1142
+ help="Hard cap on Claude calls per run (cost guard)")
1143
+ ap.add_argument("--dry-run", action="store_true",
1144
+ help="Print plan; do not commit to project_search_topics")
1145
+ ap.add_argument("--window-days", type=int, default=WINDOW_DAYS,
1146
+ help="Ledger window passed to /api/v1/topic-funnel")
1147
+ args = ap.parse_args()
1148
+
1149
+ # Propagate dry-run into the spawned claude -p / MCP server subprocess
1150
+ # tree so submit_topic short-circuits without POSTing during smoke tests.
1151
+ if args.dry_run:
1152
+ os.environ["INVENT_DRY_RUN"] = "1"
1153
+
1154
+ started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
1155
+ t0 = time.time()
1156
+
1157
+ # --- Pick a project (same code path as the cycle) ---
1158
+ config = load_config()
1159
+ if args.project:
1160
+ forced = None
1161
+ for p in config.get("projects", []):
1162
+ if p.get("name", "").lower() == args.project.lower():
1163
+ forced = p
1164
+ break
1165
+ if not forced:
1166
+ raise SystemExit(f"unknown project {args.project!r}")
1167
+ project = forced
1168
+ pick_method = "forced"
1169
+ else:
1170
+ picks = pick_projects(config, platform="twitter", n=PROJECTS_PER_RUN)
1171
+ if not picks:
1172
+ raise SystemExit("pick_projects returned no eligible project")
1173
+ project = picks[0]
1174
+ pick_method = "weighted"
1175
+
1176
+ project_name = project.get("name", "")
1177
+ print(f"[invent_topics] project={project_name!r} (pick_method={pick_method})",
1178
+ file=sys.stderr)
1179
+
1180
+ # --- Load the ledger via API ---
1181
+ topics_for_project = load_project_topics(project_name, args.window_days)
1182
+ print(f"[invent_topics] ledger rows for {project_name}: {len(topics_for_project)}",
1183
+ file=sys.stderr)
1184
+
1185
+ # --- Read fresh universe via API ---
1186
+ universe = project_universe_strings(project_name)
1187
+ universe_size_before = len(universe)
1188
+ print(f"[invent_topics] active universe size: {universe_size_before}",
1189
+ file=sys.stderr)
1190
+
1191
+ # --- Probe the managed Chrome BEFORE spending Claude tokens. A down
1192
+ # browser would make every supply scan return 0 and we'd commit real
1193
+ # topics as false 'duds'. Dry-run skips the probe (no scans run). ----
1194
+ if not args.dry_run and not harness_alive():
1195
+ print(f"[invent_topics] managed Chrome CDP port {CDP_PORT} is not "
1196
+ f"answering; skipping this run (no tokens spent).",
1197
+ file=sys.stderr)
1198
+ return
1199
+
1200
+ # --- Query-dedup corpus: every distinct query ever tried for this project,
1201
+ # normalized to cores. Loaded once; process_topic dedups against it and
1202
+ # we fold each tested topic's queries back in so a later attempt this
1203
+ # run won't re-draft the same cores. -------------------------------
1204
+ existing_query_cores = load_existing_query_cores(project_name)
1205
+ print(f"[invent_topics] existing query cores for {project_name}: "
1206
+ f"{len(existing_query_cores)}", file=sys.stderr)
1207
+
1208
+ # --- batch_id ties every attempt logged this run together in
1209
+ # twitter_search_attempts, mirroring the cycle's batch convention. ---
1210
+ batch_id = f"invent-{project_name}-{int(time.time())}"
1211
+
1212
+ # --- Retry loop: invent ONE topic, draft + dedup + supply-test its
1213
+ # queries, log ALL attempts (dud + hit), ALWAYS commit the topic (even
1214
+ # at 0 supply — the topic is a real concept; its supply record lives in
1215
+ # twitter_search_attempts), and count it toward TARGET only if its
1216
+ # queries cleared SUPPLY_FLOOR. Stop when TARGET qualifying topics land
1217
+ # or MAX_ATTEMPTS iterations run out. A mid-run browser drop ('tested'
1218
+ # False) aborts the run rather than committing false duds. -----------
1219
+ # working_universe grows as we commit so later attempts dedupe against
1220
+ # both the original universe AND topics minted earlier this run.
1221
+ working_universe = set(universe)
1222
+ # avoid_topics carries every proposal seen so far back into the next prompt
1223
+ # as an explicit do-not-repeat list, so each retry explores new ground.
1224
+ avoid_topics: set[str] = set()
1225
+
1226
+ processed: list[dict] = [] # one entry per topic we supply-tested
1227
+ all_rejected: list[dict] = [] # filled by submit_topic dupe errors reported by Claude
1228
+ total_proposals_parsed = 0 # kept for audit compatibility; counts successful submits
1229
+ last_raw = ""
1230
+ attempts_used = 0 # SCANS performed (the only thing that ticks up to max_attempts)
1231
+ claude_calls = 0 # ALL Claude sessions (tool calls hidden inside each session)
1232
+ dupe_retries_total = 0 # kept for audit compatibility; always 0 in MCP-session mode
1233
+ aborted_untested = False
1234
+ saturated_bailout = False
1235
+
1236
+ def n_qualifying() -> int:
1237
+ return sum(1 for p in processed if p.get("qualifies"))
1238
+
1239
+ while attempts_used < args.max_attempts:
1240
+ if n_qualifying() >= args.target:
1241
+ break
1242
+
1243
+ # --- ONE tool-using Claude session per scan slot. Inside this session
1244
+ # Claude can call search_topics / get_topic_stats to explore, and
1245
+ # submit_topic to commit. submit_topic runs Jaccard dedup on the
1246
+ # server, so near-dupes surface as in-session tool errors Claude
1247
+ # reacts to — no post-hoc kill, no dupe-retry-doesn't-count
1248
+ # bookkeeping needed in Python. The session ends when Claude
1249
+ # emits the final JSON envelope with `submitted_topic`. -----------
1250
+ prompt = build_prompt(project, topics_for_project, args.proposals,
1251
+ avoid_topics=avoid_topics)
1252
+ last_raw = call_claude(prompt, allowed_tools=_TOPIC_TOOLS)
1253
+ claude_calls += 1
1254
+
1255
+ envelope = extract_submitted_topic(last_raw)
1256
+ if envelope is None:
1257
+ print(f"[invent_topics] session returned no parseable envelope; "
1258
+ f"raw head: {(last_raw or '')[:200]!r}", file=sys.stderr)
1259
+ saturated_bailout = True
1260
+ break
1261
+
1262
+ if envelope.get("topic") is None:
1263
+ # Claude self-reported saturation (tried N submits, all dupes).
1264
+ reason = envelope.get("reason") or "(no reason given)"
1265
+ print(f"[invent_topics] saturated: claude session reports "
1266
+ f"no non-dupe available — {reason}",
1267
+ file=sys.stderr)
1268
+ saturated_bailout = True
1269
+ break
1270
+
1271
+ topic = envelope["topic"]
1272
+ rationale = envelope.get("rationale", "")
1273
+ total_proposals_parsed += 1
1274
+ avoid_topics.add(topic)
1275
+ working_universe.add(topic)
1276
+ print(f"[invent_topics] scan {attempts_used+1}/{args.max_attempts}: "
1277
+ f"submitted {topic!r} (qualifying so far={n_qualifying()}/{args.target})",
1278
+ file=sys.stderr)
1279
+ print(f" rationale: {rationale[:120]}", file=sys.stderr)
1280
+
1281
+ # The topic is ALREADY in project_search_topics via the submit_topic
1282
+ # tool — do not re-commit. Now draft queries + supply-test.
1283
+ result = process_topic(project, topic, existing_query_cores,
1284
+ batch_id, dry_run=args.dry_run)
1285
+
1286
+ # A False 'tested' means the browser dropped mid-run; abort the run
1287
+ # and let the next hourly retry it. The topic stays committed (it's a
1288
+ # real concept either way), but we don't fabricate a supply verdict.
1289
+ if not result.get("tested", False):
1290
+ print(f"[invent_topics] supply test UNTESTED for {topic!r} "
1291
+ f"(browser unavailable); aborting run.", file=sys.stderr)
1292
+ aborted_untested = True
1293
+ break
1294
+
1295
+ # This was a real supply test — count it against max_attempts.
1296
+ attempts_used += 1
1297
+
1298
+ # Fold this topic's tested query cores into the dedup corpus.
1299
+ for q in result.get("queries", []):
1300
+ existing_query_cores.add(normalize_query(q))
1301
+
1302
+ processed.append({
1303
+ "topic": topic,
1304
+ "rationale": rationale,
1305
+ **result,
1306
+ "committed": True, # submit_topic already wrote it
1307
+ "attempt": attempts_used,
1308
+ })
1309
+
1310
+ print(f" supply={result['supply_total']} qualifies={result['qualifies']}",
1311
+ file=sys.stderr)
1312
+
1313
+ if n_qualifying() >= args.target:
1314
+ break
1315
+
1316
+ target_met = n_qualifying() >= args.target
1317
+
1318
+ # --- Audit row (via API; no local file) ---
1319
+ elapsed = round(time.time() - t0, 2)
1320
+ audit_payload = {
1321
+ "ts": started_at,
1322
+ "elapsed_sec": elapsed,
1323
+ "project": project_name,
1324
+ "pick_method": pick_method,
1325
+ "batch_id": batch_id,
1326
+ "ledger_rows_for_project": len(topics_for_project),
1327
+ "universe_size_before": universe_size_before,
1328
+ "proposals_requested": args.proposals,
1329
+ "target": args.target,
1330
+ "max_attempts": args.max_attempts,
1331
+ "attempts_used": attempts_used,
1332
+ "claude_calls": claude_calls,
1333
+ "dupe_retries_total": dupe_retries_total,
1334
+ "saturated_bailout": saturated_bailout,
1335
+ "target_met": target_met,
1336
+ "aborted_untested": aborted_untested,
1337
+ "proposals_parsed": total_proposals_parsed,
1338
+ "supply_floor": SUPPLY_FLOOR,
1339
+ "queries_per_topic": QUERIES_PER_TOPIC,
1340
+ "freshness_hours": FRESHNESS_HOURS,
1341
+ "processed": [
1342
+ {
1343
+ "topic": p["topic"],
1344
+ "committed": p.get("committed"),
1345
+ "qualifies": p.get("qualifies"),
1346
+ "supply_total": p.get("supply_total"),
1347
+ "queries_drafted": p.get("queries_drafted"),
1348
+ "queries_tested": p.get("queries_tested"),
1349
+ "attempt": p.get("attempt"),
1350
+ "neighbor": p.get("neighbor"),
1351
+ "similarity": p.get("similarity"),
1352
+ "attempts": p.get("attempts"),
1353
+ }
1354
+ for p in processed
1355
+ ],
1356
+ "rejected": all_rejected,
1357
+ "dry_run": args.dry_run,
1358
+ "raw_response_head": (last_raw or "")[:500],
1359
+ }
1360
+ write_audit(audit_payload, dry_run=args.dry_run)
1361
+
1362
+ n_qual = n_qualifying()
1363
+ n_committed_topics = sum(1 for p in processed if p.get("committed"))
1364
+ print(f"[invent_topics] done. project={project_name!r} "
1365
+ f"scans={attempts_used}/{args.max_attempts} "
1366
+ f"claude_calls={claude_calls} dupe_retries={dupe_retries_total} "
1367
+ f"target={args.target} target_met={target_met} "
1368
+ f"saturated_bailout={saturated_bailout} "
1369
+ f"aborted_untested={aborted_untested} "
1370
+ f"proposals={total_proposals_parsed} "
1371
+ f"topics_committed={n_committed_topics} qualifying={n_qual} "
1372
+ f"rejected={len(all_rejected)} elapsed={elapsed}s",
1373
+ file=sys.stderr)
1374
+
1375
+ # --- Surface this run in the dashboard's Status > Job History tab via
1376
+ # log_run.py + run_monitor.log. Skip on dry-run so smoke tests don't
1377
+ # leak fake rows into the dashboard. ----------------------------------
1378
+ if not args.dry_run:
1379
+ _emit_run_monitor_row(
1380
+ project_name=project_name,
1381
+ processed=processed,
1382
+ attempts_used=attempts_used,
1383
+ aborted_untested=aborted_untested,
1384
+ saturated_bailout=saturated_bailout,
1385
+ elapsed_sec=elapsed,
1386
+ )
1387
+
1388
+
1389
+ def _emit_run_monitor_row(
1390
+ project_name: str,
1391
+ processed: list[dict],
1392
+ attempts_used: int,
1393
+ aborted_untested: bool,
1394
+ saturated_bailout: bool,
1395
+ elapsed_sec: float,
1396
+ ) -> None:
1397
+ """Call scripts/log_run.py so the invent run lands in run_monitor.log and
1398
+ the dashboard's Status > Job History tab surfaces it under the
1399
+ 'Invent Topics' filter pill. Best-effort: any failure is logged and
1400
+ swallowed — we never want a dashboard-write hiccup to mask the actual
1401
+ run output."""
1402
+ # Per-topic query counts (parallel array to topic_names; joined with '+').
1403
+ qpt = [int(p.get("queries_tested", 0)) for p in processed]
1404
+ queries_total = sum(qpt)
1405
+ queries_w_supply = sum(
1406
+ 1
1407
+ for p in processed
1408
+ for attempt in (p.get("attempts") or [])
1409
+ if (attempt.get("tweets_found") or 0) > 0
1410
+ )
1411
+ topics_invented = len(processed)
1412
+ n_qual = sum(1 for p in processed if p.get("qualifies"))
1413
+ # Skipped = topics tested but didn't qualify. Failed = 1 iff the run
1414
+ # aborted partway (browser drop); harmless 0 otherwise.
1415
+ skipped = max(topics_invented - n_qual, 0)
1416
+ failed = 1 if aborted_untested else 0
1417
+
1418
+ # topic_names: parallel-to-qpt array of the actual topics committed this
1419
+ # run. Encode each name so it can't break the run_monitor.log segment
1420
+ # parser: replace spaces with '+', strip the four chars that have
1421
+ # structural meaning in the log line (',', ';', '=', '|'). Decoded
1422
+ # client-side in server.js. Empty list = no topics committed = no per-
1423
+ # topic pills get rendered (e.g. saturated runs).
1424
+ def _encode_topic_name(t: str) -> str:
1425
+ encoded = (t or "").strip().replace(" ", "+")
1426
+ for ch in (",", ";", "=", "|"):
1427
+ encoded = encoded.replace(ch, "")
1428
+ return encoded
1429
+
1430
+ topic_names = [_encode_topic_name(p.get("topic", "")) for p in processed]
1431
+ topic_names = [t for t in topic_names if t]
1432
+ topic_names_segment = (
1433
+ f",topic_names={';'.join(topic_names)}" if topic_names else ""
1434
+ )
1435
+
1436
+ invent_kv = ",".join([
1437
+ f"project={project_name}",
1438
+ f"topics={topics_invented}",
1439
+ f"queries={queries_total}",
1440
+ f"queries_w_supply={queries_w_supply}",
1441
+ f"qpt={'+'.join(str(x) for x in qpt) if qpt else '0'}",
1442
+ ]) + topic_names_segment
1443
+ log_run_py = os.path.join(_REPO_DIR, "scripts", "log_run.py")
1444
+ cmd = [
1445
+ "/opt/homebrew/bin/python3.11", log_run_py,
1446
+ "--script", "invent_topics",
1447
+ "--posted", str(topics_invented),
1448
+ "--skipped", str(skipped),
1449
+ "--failed", str(failed),
1450
+ "--cost", "0", # claude -p inherits cost tracking; not surfaced per-call
1451
+ "--elapsed", str(int(elapsed_sec)),
1452
+ "--invent", invent_kv,
1453
+ ]
1454
+ try:
1455
+ subprocess.run(cmd, check=False, timeout=30,
1456
+ capture_output=True, text=True)
1457
+ except (subprocess.TimeoutExpired, OSError) as exc:
1458
+ print(f"[invent_topics] log_run.py emit failed: {exc}", file=sys.stderr)
1459
+
1460
+
1461
+ if __name__ == "__main__":
1462
+ main()