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