@m13v/s4l 1.6.197-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,592 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ link_tail.py — Generate a context-aware bridge sentence that folds a landing
4
+ page URL into a Twitter (or other social) reply.
5
+
6
+ Replaces the mechanical concat `f"{reply_text} {link_url}"` in
7
+ twitter_post_plan.py with a one-shot Claude call (default smart model, NOT
8
+ Haiku) that:
9
+
10
+ 1. Re-reads the original thread + the reply we already drafted
11
+ 2. Identifies the strongest claim/mechanism in our reply
12
+ 3. Looks at the landing page URL's slug for a hint about what's there
13
+ 4. Writes 1 short bridge sentence that names a concrete benefit and
14
+ ends with the URL — no period after, no "click here".
15
+
16
+ Why not Haiku: bridge writing requires reading two pieces of context (thread
17
+ + our reply) and producing language that doesn't read as bolted-on. The
18
+ cheap model fails this; tested via the existing studyly Twitter dataset
19
+ (see CLAUDE memory `feedback_link_tail_default_model`).
20
+
21
+ Usage (CLI / from twitter_post_plan.py):
22
+ python3 link_tail.py \\
23
+ --reply-text "Step 2 CK was the one that burned me out worst..." \\
24
+ --link-url "https://studyly.io/t/active-recall-question-generator" \\
25
+ --thread-text "huge milestone, just passed step 2..." \\
26
+ --project "studyly" \\
27
+ --platform "twitter"
28
+
29
+ Stdout (single JSON object):
30
+ {"ok": true, "text": "<reply_text with bridge tail + URL>",
31
+ "tail": "<just the bridge sentence with URL>",
32
+ "model_call_ok": true, "fallback_used": false}
33
+
34
+ On any failure (claude errored, returned empty, returned a sentence that
35
+ fails sanity checks) the script falls back to the mechanical concat:
36
+ {"ok": true, "text": "<reply_text> <link_url>",
37
+ "tail": "<link_url>", "model_call_ok": false,
38
+ "fallback_used": true, "error": "<short reason>"}
39
+
40
+ Exit codes:
41
+ 0 — wrote a JSON object to stdout (whether smart or fallback)
42
+ 2 — argparse / IO failure before we could write any JSON
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import json
49
+ import os
50
+ import re
51
+ import shlex
52
+ import subprocess
53
+ import sys
54
+ import time
55
+ from pathlib import Path
56
+
57
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
58
+ RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
59
+ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
60
+
61
+ # --- X/Twitter length budget -------------------------------------------------
62
+ # X charges a FLAT 23 characters for any http/https URL (t.co wrapping),
63
+ # regardless of the link's real length. So the budget is fixed: text + 23 <= 280
64
+ # => at most 257 characters of text before the link. We only enforce this for
65
+ # twitter; reddit/linkedin have far larger ceilings and need no tail trim.
66
+ TWEET_LIMIT = 280
67
+ URL_WEIGHT = 23
68
+ TWITTER_TEXT_BUDGET = TWEET_LIMIT - URL_WEIGHT # 257 chars for everything but the URL
69
+ _URL_RE = re.compile(r"https?://\S+")
70
+
71
+
72
+ def x_weighted_len(text: str) -> int:
73
+ """Character count the way X computes it: every URL counts as 23."""
74
+ if not text:
75
+ return 0
76
+ return len(_URL_RE.sub("x" * URL_WEIGHT, text))
77
+
78
+
79
+ def _trim_to_chars(s: str, max_chars: int) -> str:
80
+ """Trim `s` to at most `max_chars`, backing off to a word boundary so we
81
+ never chop mid-word, then stripping trailing punctuation/space."""
82
+ s = s.strip()
83
+ if max_chars <= 0:
84
+ return ""
85
+ if len(s) <= max_chars:
86
+ return s
87
+ cut = s[:max_chars].rstrip()
88
+ sp = cut.rfind(" ")
89
+ # only back off to the word boundary when it doesn't gut more than half
90
+ if sp > max_chars * 0.5:
91
+ cut = cut[:sp]
92
+ return cut.rstrip(" ,;:-")
93
+
94
+
95
+ def enforce_budget(text: str, link_url: str,
96
+ limit: int = TWEET_LIMIT) -> tuple[str, bool]:
97
+ """Guarantee x_weighted_len(text) <= limit by trimming the BODY, never the
98
+ link. The link is the most important part of the reply and always stays at
99
+ the end. Returns (text, was_trimmed)."""
100
+ if x_weighted_len(text) <= limit:
101
+ return text, False
102
+ if link_url and link_url in text:
103
+ head, _, tail = text.rpartition(link_url)
104
+ head = head.rstrip()
105
+ tail = tail.strip() # normally empty; the URL ends the reply
106
+ # Budget left for the body after reserving the URL (23) + a joining
107
+ # space + any (rare) trailing chars after the URL.
108
+ max_head = limit - URL_WEIGHT - 1 - len(tail)
109
+ trimmed_head = _trim_to_chars(head, max_head)
110
+ joined = (trimmed_head + " " + link_url).strip()
111
+ if tail:
112
+ joined = (joined + " " + tail).strip()
113
+ return joined, True
114
+ # No link present (shouldn't happen on the twitter path): hard word-trim.
115
+ return _trim_to_chars(text, limit), True
116
+
117
+
118
+ def resolve_voice_relationship(project: str) -> str:
119
+ """Look up the matched project's `voice_relationship` field in config.json.
120
+
121
+ Returns "first_party" or "third_party". Defaults to "first_party" if the
122
+ project is missing or the field is absent, matching the historical
123
+ pre-2026-05-27 behavior so we never silently mute first-party voice.
124
+ The link_tail subprocess runs with --disallowed-tools that bans Read/Glob,
125
+ so the value must be resolved here in Python rather than inside the prompt.
126
+ """
127
+ try:
128
+ with open(CONFIG_PATH, "r") as f:
129
+ cfg = json.load(f)
130
+ except Exception:
131
+ return "first_party"
132
+ name_lc = (project or "").lower()
133
+ for p in cfg.get("projects", []):
134
+ if (p.get("name") or "").lower() == name_lc:
135
+ val = (p.get("voice_relationship") or "").strip().lower()
136
+ if val in ("first_party", "third_party"):
137
+ return val
138
+ return "first_party"
139
+ return "first_party"
140
+
141
+ # Paths to the Claude Code CLI in order of preference. run_claude.sh resolves
142
+ # `claude` from PATH; we fall back to a direct nvm path if PATH lookup fails
143
+ # (twitter_post_plan.py is invoked from a launchd shell that may have a thin
144
+ # PATH).
145
+ CLAUDE_CLI_CANDIDATES = [
146
+ "/Users/matthewdi/.nvm/versions/node/v20.19.4/bin/claude",
147
+ "/opt/homebrew/bin/claude",
148
+ "/usr/local/bin/claude",
149
+ ]
150
+
151
+
152
+ def resolve_claude_cli() -> str:
153
+ for p in CLAUDE_CLI_CANDIDATES:
154
+ if os.path.exists(p) and os.access(p, os.X_OK):
155
+ return p
156
+ # Last resort: ask `which`. If this also fails the caller falls back to
157
+ # mechanical concat — link_tail.py is always tolerant of model failure.
158
+ try:
159
+ out = subprocess.check_output(
160
+ ["/usr/bin/env", "which", "claude"],
161
+ text=True, timeout=5,
162
+ ).strip()
163
+ if out:
164
+ return out
165
+ except Exception:
166
+ pass
167
+ return ""
168
+
169
+
170
+ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
171
+ project: str, platform: str,
172
+ voice_relationship: str = "first_party") -> str:
173
+ """Compose the one-shot prompt for the bridge sentence.
174
+
175
+ Kept tight on purpose: the model gets only the four pieces of context it
176
+ needs, plus a precise output contract. No tools, no MCP, no file access.
177
+
178
+ `voice_relationship` ("first_party" | "third_party") is resolved by the
179
+ caller from config.json and selects the example sentences + voice rule
180
+ embedded in the prompt. third_party projects (Agora, Runner, Podlog,
181
+ studyly, NightOwl, PieLine as of 2026-05-27) MUST be referred to in
182
+ third-person; first_party projects own the "we ship / we built" voice.
183
+ """
184
+ if voice_relationship == "third_party":
185
+ voice_rule = (
186
+ f"VOICE RELATIONSHIP — third_party (HIGHEST PRIORITY):\n"
187
+ f"You are an OUTSIDE OBSERVER of {project}, NOT its team. Refer "
188
+ f"to the product in third-person, by name or by 'their': "
189
+ f"\"{project} ships X\", \"their Y\", \"the {project} team rolled "
190
+ f"out Z\". Acceptable narrator framings: \"I tried {project}\", "
191
+ f"\"I noticed {project} does X\", \"{project} ships Y\". BANNED: "
192
+ f"\"we ship\", \"our product\", \"we built\", \"I built this\", "
193
+ f"\"what we offer\". A bridge that uses first-person plural about "
194
+ f"{project} impersonates the client and is a hard failure."
195
+ )
196
+ example_block = (
197
+ f" - \"fwiw the rubric scoring on rephrased stems is what {project} ships, {link_url}\"\n"
198
+ f" - \"{project} runs the same recall-on-revisit pattern, scores against a 4-axis rubric, {link_url}\""
199
+ )
200
+ else:
201
+ voice_rule = (
202
+ f"VOICE RELATIONSHIP — first_party:\n"
203
+ f"You speak AS the builder / maker of {project}. First-person "
204
+ f"plural about the product (\"we ship X\", \"our Y\", \"we built "
205
+ f"Z\") is allowed. Recommendation-style framing is also fine."
206
+ )
207
+ example_block = (
208
+ f" - \"fwiw the rubric scoring on rephrased stems is what we built {project} for, {link_url}\"\n"
209
+ f" - \"we ship the same recall-on-revisit pattern in {project}, scores against a 4-axis rubric, {link_url}\""
210
+ )
211
+
212
+ # X-specific hard length budget. Pass the agent the current body length AND
213
+ # the budget so it self-fits by compressing the body, keeping the link at
214
+ # the end. Other platforms (reddit/linkedin) have far larger ceilings, so we
215
+ # add no tight cap there.
216
+ length_rule = ""
217
+ if platform == "twitter":
218
+ body_len = len(reply_text or "")
219
+ length_rule = (
220
+ f"HARD LENGTH LIMIT (X counts EVERY link as exactly 23 characters, "
221
+ f"no matter how long it looks):\n"
222
+ f"- The entire final reply must be \u2264 {TWEET_LIMIT} characters with "
223
+ f"the URL counted as {URL_WEIGHT}. That means everything EXCEPT the "
224
+ f"URL must total \u2264 {TWITTER_TEXT_BUDGET} characters.\n"
225
+ f"- The drafted body is currently {body_len} characters. If body + "
226
+ f"bridge would exceed {TWITTER_TEXT_BUDGET} chars of text, COMPRESS "
227
+ f"the body to make room: tighten wording, drop the weakest clause, "
228
+ f"but keep the single strongest claim and keep the URL at the very "
229
+ f"end. Never drop or move the link.\n"
230
+ )
231
+
232
+ return f"""You are writing the FINAL bridge sentence that folds a product link into a social media reply we already drafted. This is a one-shot task. Output ONLY the bridge sentence (no preamble, no explanation, no quotes).
233
+
234
+ PLATFORM: {platform}
235
+ PROJECT: {project}
236
+ LANDING PAGE URL: {link_url}
237
+
238
+ {voice_rule}
239
+
240
+ {length_rule}
241
+
242
+ ORIGINAL THREAD WE ARE REPLYING TO:
243
+ {thread_text}
244
+
245
+ REPLY WE ALREADY DRAFTED (its last sentence is what your bridge will REPLACE / EXTEND):
246
+ {reply_text}
247
+
248
+ YOUR TASK:
249
+ Rewrite the reply so the LAST sentence is a 1-sentence (≤ 22 words) bridge that:
250
+ 1. References the SINGLE strongest specific claim, mechanism, or detail from the existing reply (e.g. "rephrasing on revisit", "a 4-axis rubric", "200ms p95", "automatic distractor scoring") — pick ONE concrete thing, not a category.
251
+ 2. Names a CONCRETE PRODUCT MECHANISM that delivers it (verb + noun, inferred from the URL slug + project context). Do NOT say "a tool for this", "something that helps", "made this for it" — those are banned.
252
+ 3. Ends with the URL exactly as given. No period after. No "click here", "check it out", "give it a try".
253
+ 4. Reads in the voice of the reply (lowercase if reply is lowercase, casual if reply is casual).
254
+ 5. Obeys the VOICE RELATIONSHIP rule above. This rule overrides any default phrasing instinct.
255
+
256
+ REPLACEMENT RULE:
257
+ - If the reply has a clear empathy/advice body, KEEP that body verbatim and append the bridge as a new sentence (separated by a single space).
258
+ - If the reply already trails off with weak filler, REPLACE just the trailing weak portion with the bridge.
259
+
260
+ OUTPUT FORMAT (strict):
261
+ Output the FULL FINAL REPLY TEXT (body + bridge sentence ending in URL) on a single line. Nothing else. No JSON, no markdown, no quotes.
262
+
263
+ Example bridge sentences (do NOT copy verbatim — these are FORM examples, voice-matched to this project):
264
+ {example_block}
265
+
266
+ Write the final reply now."""
267
+
268
+
269
+ def call_claude(prompt: str, *, timeout_sec: int = 120,
270
+ use_run_claude_sh: bool = True) -> tuple[bool, str, str]:
271
+ """Run claude -p in headless mode. Returns (ok, stdout_text, error_msg).
272
+
273
+ Uses run_claude.sh for cost tracking under script_tag 'twitter-link-tail'
274
+ so the cost rolls into the dashboard claude_sessions table. Falls back
275
+ to direct claude invocation if run_claude.sh is missing.
276
+ """
277
+ use_wrapper = use_run_claude_sh and os.path.exists(RUN_CLAUDE_SH)
278
+ cli = resolve_claude_cli()
279
+ if not cli and not use_wrapper:
280
+ return (False, "", "no_claude_cli")
281
+
282
+ if use_wrapper:
283
+ cmd = [
284
+ "bash", RUN_CLAUDE_SH, "twitter-link-tail",
285
+ "-p", prompt,
286
+ "--max-turns", "1",
287
+ "--disallowed-tools",
288
+ "ScheduleWakeup,CronCreate,CronDelete,CronList,EnterPlanMode,EnterWorktree,Bash,Edit,Write,Read,Grep,Glob,WebFetch,WebSearch,Agent,TodoWrite,NotebookEdit,LSP,Monitor,PushNotification,RemoteTrigger,TaskOutput,TaskStop,ListMcpResourcesTool,ReadMcpResourceTool",
289
+ ]
290
+ else:
291
+ cmd = [
292
+ cli, "-p", prompt,
293
+ "--max-turns", "1",
294
+ "--disallowed-tools",
295
+ "ScheduleWakeup,CronCreate,CronDelete,CronList,EnterPlanMode,EnterWorktree,Bash,Edit,Write,Read,Grep,Glob,WebFetch,WebSearch,Agent,TodoWrite,NotebookEdit,LSP,Monitor,PushNotification,RemoteTrigger,TaskOutput,TaskStop,ListMcpResourcesTool,ReadMcpResourceTool",
296
+ ]
297
+
298
+ # Pre-strip MCP config (we don't need any tools for plain text gen). Some
299
+ # claude installs auto-load MCP from ~/.claude/mcp.json — pass an empty
300
+ # JSON config to force-disable. /dev/null doesn't parse as JSON, so we
301
+ # use a real file written once into /tmp.
302
+ empty_mcp = "/tmp/.link_tail_empty_mcp.json"
303
+ if not os.path.exists(empty_mcp):
304
+ try:
305
+ Path(empty_mcp).write_text('{"mcpServers": {}}', encoding="utf-8")
306
+ except Exception:
307
+ empty_mcp = ""
308
+ if empty_mcp:
309
+ cmd += ["--strict-mcp-config", "--mcp-config", empty_mcp]
310
+
311
+ try:
312
+ r = subprocess.run(
313
+ cmd, capture_output=True, text=True, timeout=timeout_sec,
314
+ cwd=REPO_DIR,
315
+ )
316
+ out = (r.stdout or "").strip()
317
+ err = (r.stderr or "").strip()
318
+ if r.returncode != 0:
319
+ return (False, out, f"rc={r.returncode}: {err[:300]}")
320
+ if not out:
321
+ return (False, "", f"empty_stdout: {err[:200]}")
322
+ return (True, out, "")
323
+ except subprocess.TimeoutExpired:
324
+ return (False, "", f"timeout_{timeout_sec}s")
325
+ except FileNotFoundError as e:
326
+ return (False, "", f"file_not_found: {e}")
327
+
328
+
329
+ # Sanity guards. The model occasionally returns extra commentary; strip it.
330
+ PREAMBLE_RES = [
331
+ re.compile(r"^(here(?:'s| is)|here you go|sure|okay|ok|got it|the (?:final )?reply(?: is)?:?)\s*[,:.\-]?\s*", re.IGNORECASE),
332
+ re.compile(r"^[\"'`]+"),
333
+ re.compile(r"[\"'`]+$"),
334
+ ]
335
+ BANNED_PHRASES = [
336
+ "click here", "check it out", "give it a try",
337
+ # Generic-verb-no-object failures.
338
+ "a tool for exactly this", "made this for it",
339
+ ]
340
+
341
+
342
+ def clean_output(text: str) -> str:
343
+ """Strip preamble and surrounding quotes; collapse whitespace."""
344
+ t = text.strip()
345
+ # If the model returned multiple lines, take the LAST non-empty line — the
346
+ # actual reply is at the bottom (preamble like "Here's the reply:" is on
347
+ # earlier lines).
348
+ lines = [ln.strip() for ln in t.splitlines() if ln.strip()]
349
+ if not lines:
350
+ return ""
351
+ candidate = lines[-1]
352
+ # Strip wrapping quotes / markdown.
353
+ for rx in PREAMBLE_RES:
354
+ candidate = rx.sub("", candidate).strip()
355
+ # Collapse internal whitespace.
356
+ candidate = re.sub(r"\s+", " ", candidate).strip()
357
+ return candidate
358
+
359
+
360
+ THIRD_PARTY_VOICE_VIOLATIONS = (
361
+ re.compile(r"\bwe ship\b", re.IGNORECASE),
362
+ re.compile(r"\bwe built\b", re.IGNORECASE),
363
+ re.compile(r"\bwe made\b", re.IGNORECASE),
364
+ re.compile(r"\bwe offer\b", re.IGNORECASE),
365
+ re.compile(r"\bour product\b", re.IGNORECASE),
366
+ re.compile(r"\bI built (?:this|it)\b", re.IGNORECASE),
367
+ re.compile(r"\bwhat we (?:ship|build|offer|make)\b", re.IGNORECASE),
368
+ )
369
+
370
+
371
+ def passes_quality_gate(final_text: str, link_url: str,
372
+ voice_relationship: str = "first_party",
373
+ limit: int | None = None
374
+ ) -> tuple[bool, str]:
375
+ """Return (passes, reason_if_not).
376
+
377
+ Hard rules:
378
+ - must contain link_url
379
+ - must end with link_url (allow trailing whitespace, nothing else)
380
+ - must NOT contain banned phrases
381
+ - must not be shorter than reply text would have been (silly model fail)
382
+ - on third_party projects, must NOT use first-person-plural product
383
+ ownership phrases ("we ship", "we built", "our product", ...). The
384
+ link_tail prompt now selects voice-matched examples but the model can
385
+ still drift; on violation we fall back to the mechanical concat so
386
+ the post still ships without impersonating the client (root cause of
387
+ the 2026-05-27 Agora OODAO incident).
388
+ """
389
+ if not final_text:
390
+ return (False, "empty")
391
+ if link_url not in final_text:
392
+ return (False, "no_url")
393
+ # Trailing-URL check (nothing meaningful after URL, optional ./! is fine
394
+ # to strip; but our prompt forbids trailing period — so just check no
395
+ # alphanumeric content follows).
396
+ tail = final_text.split(link_url, 1)[1].strip()
397
+ if tail and re.search(r"[A-Za-z0-9]", tail):
398
+ return (False, f"content_after_url: {tail[:40]!r}")
399
+ lower = final_text.lower()
400
+ for phrase in BANNED_PHRASES:
401
+ if phrase in lower:
402
+ return (False, f"banned_phrase: {phrase!r}")
403
+ if voice_relationship == "third_party":
404
+ for rx in THIRD_PARTY_VOICE_VIOLATIONS:
405
+ m = rx.search(final_text)
406
+ if m:
407
+ return (False, f"third_party_voice_violation: {m.group(0)!r}")
408
+ # Length sanity: model returning a 5-word stub is a fail.
409
+ if len(final_text.split()) < 8:
410
+ return (False, "too_short")
411
+ # Upper-length backstop (twitter): X-weighted length must fit the cap. The
412
+ # caller trims the body to fit BEFORE the gate, so this should only trip on
413
+ # a degenerate trim; on trip we fall back to the (also budget-enforced)
414
+ # mechanical concat.
415
+ if limit is not None and x_weighted_len(final_text) > limit:
416
+ return (False, f"too_long:{x_weighted_len(final_text)}>{limit}")
417
+ return (True, "")
418
+
419
+
420
+ def mechanical_fallback(reply_text: str, link_url: str) -> str:
421
+ """The pre-existing concat behavior. Identical to the line we replace
422
+ in twitter_post_plan.py."""
423
+ return f"{reply_text} {link_url}".strip() if link_url else reply_text
424
+
425
+
426
+ def main() -> int:
427
+ ap = argparse.ArgumentParser()
428
+ ap.add_argument("--reply-text", required=True,
429
+ help="The reply we already drafted (no link).")
430
+ ap.add_argument("--link-url", required=True,
431
+ help="The landing page URL to fold in.")
432
+ ap.add_argument("--thread-text", default="",
433
+ help="The original thread / tweet we are replying to.")
434
+ ap.add_argument("--project", required=True,
435
+ help="Project name (e.g. 'studyly', 'fazm').")
436
+ ap.add_argument("--platform", default="twitter",
437
+ help="Platform (twitter, reddit, linkedin).")
438
+ ap.add_argument("--voice-relationship", default=None,
439
+ choices=["first_party", "third_party"],
440
+ help="Override the voice_relationship lookup. Defaults to "
441
+ "the value in config.json for --project, or "
442
+ "first_party if missing.")
443
+ ap.add_argument("--timeout", type=int, default=120,
444
+ help="Hard timeout for the claude call (seconds).")
445
+ ap.add_argument("--no-wrapper", action="store_true",
446
+ help="Skip run_claude.sh; call claude directly. For testing.")
447
+ args = ap.parse_args()
448
+
449
+ reply_text = (args.reply_text or "").strip()
450
+ link_url = (args.link_url or "").strip()
451
+ if not reply_text or not link_url:
452
+ # Garbage in → mechanical concat (which respects empty link_url).
453
+ out = {
454
+ "ok": True,
455
+ "text": mechanical_fallback(reply_text, link_url),
456
+ "tail": link_url,
457
+ "model_call_ok": False,
458
+ "fallback_used": True,
459
+ "error": "missing_input",
460
+ }
461
+ print(json.dumps(out), flush=True)
462
+ return 0
463
+
464
+ # Plugin (MCP post_drafts) flow sets S4L_SKIP_LINK_TAIL=1. The bridge only
465
+ # rewords prose around the URL — the minted short link is produced by a
466
+ # separate deterministic wrap step in twitter_post_plan.py — so the Claude
467
+ # call buys nothing there, and on .mcpb customer boxes (no `claude` binary)
468
+ # it burns ~35s of run_claude.sh retry backoff per post before falling back
469
+ # to this exact mechanical concat. Short-circuit straight to the concat.
470
+ # The local cron/plist autopilot leaves this env unset and still generates
471
+ # the bridge sentence.
472
+ if os.environ.get("S4L_SKIP_LINK_TAIL") == "1":
473
+ limit = TWEET_LIMIT if args.platform == "twitter" else None
474
+ fb_text, fb_trim = enforce_budget(
475
+ mechanical_fallback(reply_text, link_url), link_url,
476
+ limit if limit is not None else TWEET_LIMIT * 100)
477
+ out = {
478
+ "ok": True,
479
+ "text": fb_text,
480
+ "tail": link_url,
481
+ "model_call_ok": False,
482
+ "fallback_used": True,
483
+ "budget_trimmed": fb_trim,
484
+ "error": "skipped_plugin_flow",
485
+ "elapsed_sec": 0.0,
486
+ }
487
+ print(json.dumps(out), flush=True)
488
+ return 0
489
+
490
+ voice_relationship = args.voice_relationship or resolve_voice_relationship(args.project)
491
+ # Length cap is X-specific; reddit/linkedin pass None (no tail trim).
492
+ limit = TWEET_LIMIT if args.platform == "twitter" else None
493
+ prompt = build_prompt(
494
+ reply_text=reply_text, link_url=link_url,
495
+ thread_text=(args.thread_text or "").strip()[:2000],
496
+ project=args.project, platform=args.platform,
497
+ voice_relationship=voice_relationship,
498
+ )
499
+
500
+ started = time.time()
501
+ ok, raw, err = call_claude(prompt, timeout_sec=args.timeout,
502
+ use_run_claude_sh=not args.no_wrapper)
503
+ elapsed = round(time.time() - started, 2)
504
+
505
+ if not ok:
506
+ fb_text, fb_trim = enforce_budget(
507
+ mechanical_fallback(reply_text, link_url), link_url,
508
+ limit if limit is not None else TWEET_LIMIT * 100)
509
+ out = {
510
+ "ok": True,
511
+ "text": fb_text,
512
+ "tail": link_url,
513
+ "model_call_ok": False,
514
+ "fallback_used": True,
515
+ "budget_trimmed": fb_trim,
516
+ "error": err or "model_call_failed",
517
+ "elapsed_sec": elapsed,
518
+ }
519
+ print(json.dumps(out), flush=True)
520
+ return 0
521
+
522
+ cleaned = clean_output(raw)
523
+ # Trim the body to fit BEFORE the gate so a good model bridge is preserved
524
+ # (we trim the body, never the link) instead of being thrown away for being
525
+ # a few chars over. No-op on non-twitter (limit is None).
526
+ budget_trimmed = False
527
+ if limit is not None:
528
+ cleaned, budget_trimmed = enforce_budget(cleaned, link_url, limit)
529
+ passes, reason = passes_quality_gate(cleaned, link_url,
530
+ voice_relationship=voice_relationship,
531
+ limit=limit)
532
+ if not passes:
533
+ fb_text, fb_trim = enforce_budget(
534
+ mechanical_fallback(reply_text, link_url), link_url,
535
+ limit if limit is not None else TWEET_LIMIT * 100)
536
+ out = {
537
+ "ok": True,
538
+ "text": fb_text,
539
+ "tail": link_url,
540
+ "model_call_ok": True,
541
+ "fallback_used": True,
542
+ "budget_trimmed": fb_trim,
543
+ "error": f"quality_gate_failed:{reason}",
544
+ "raw_model_output": raw[:500],
545
+ "elapsed_sec": elapsed,
546
+ }
547
+ print(json.dumps(out), flush=True)
548
+ return 0
549
+
550
+ # Successful path: extract the bridge tail (everything after the original
551
+ # reply body's prefix, OR the last sentence containing the URL).
552
+ tail = ""
553
+ # Heuristic: the bridge is the last sentence in cleaned. Split on ". " or
554
+ # "! " or "? "; take the last chunk.
555
+ chunks = re.split(r"(?<=[.!?])\s+", cleaned)
556
+ for c in reversed(chunks):
557
+ if link_url in c:
558
+ tail = c.strip()
559
+ break
560
+ if not tail:
561
+ tail = cleaned
562
+
563
+ out = {
564
+ "ok": True,
565
+ "text": cleaned,
566
+ "tail": tail,
567
+ "model_call_ok": True,
568
+ "fallback_used": False,
569
+ "budget_trimmed": budget_trimmed,
570
+ "elapsed_sec": elapsed,
571
+ }
572
+ print(json.dumps(out), flush=True)
573
+ return 0
574
+
575
+
576
+ if __name__ == "__main__":
577
+ try:
578
+ sys.exit(main())
579
+ except KeyboardInterrupt:
580
+ print(json.dumps({"ok": False, "error": "interrupted"}), flush=True)
581
+ sys.exit(2)
582
+ except Exception as e:
583
+ # Last-resort safety net so callers never get a non-JSON crash.
584
+ print(json.dumps({
585
+ "ok": True,
586
+ "text": "",
587
+ "tail": "",
588
+ "model_call_ok": False,
589
+ "fallback_used": True,
590
+ "error": f"unhandled:{type(e).__name__}:{e}",
591
+ }), flush=True)
592
+ sys.exit(0)