@m13v/s4l 1.6.197-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Social Autoposter - Original Twitter thread poster
|
|
3
|
+
#
|
|
4
|
+
# Picks one (project, topic_angle) target via pick_twitter_thread_target.py,
|
|
5
|
+
# which enforces:
|
|
6
|
+
# 1. Hard global cap of 3 original threads per UTC calendar day.
|
|
7
|
+
# 2. Per-(project, topic_angle) floor window (default 2 days).
|
|
8
|
+
# 3. Per-project inverse-share weighting (don't pile on one project).
|
|
9
|
+
#
|
|
10
|
+
# Then spawns a Claude session with the twitter-harness browser to research, draft, and post
|
|
11
|
+
# ONE original thread (1-6 tweets, chained as a Twitter thread).
|
|
12
|
+
#
|
|
13
|
+
# Called by launchd. See com.m13v.social-twitter-threads.plist.
|
|
14
|
+
# Mirror of skill/run-reddit-threads.sh; deviations are commented.
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
19
|
+
|
|
20
|
+
# Cycle ID for cross-cycle cost accounting (see run-twitter-cycle.sh for the
|
|
21
|
+
# same pattern). Stamps claude_sessions.cycle_id so get_run_cost.py --cycle-id
|
|
22
|
+
# reports per-cycle spend instead of bleeding across concurrent runs.
|
|
23
|
+
BATCH_ID="${BATCH_ID:-twthr-$(date +%Y%m%d-%H%M%S)}"
|
|
24
|
+
export BATCH_ID
|
|
25
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
26
|
+
|
|
27
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
28
|
+
CONFIG_FILE="$REPO_DIR/config.json"
|
|
29
|
+
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
30
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
31
|
+
mkdir -p "$LOG_DIR"
|
|
32
|
+
LOG_FILE="$LOG_DIR/run-twitter-threads-$(date +%Y-%m-%d_%H%M%S).log"
|
|
33
|
+
|
|
34
|
+
echo "=== Twitter Threads Run: $(date) ===" | tee "$LOG_FILE"
|
|
35
|
+
RUN_START_EPOCH=$(date +%s)
|
|
36
|
+
|
|
37
|
+
# Diagnostic trap (parallel to reddit version): log line + cmd before set -e exits.
|
|
38
|
+
trap 'rc=$?; echo "SCRIPT DIED line=$LINENO cmd=\"$BASH_COMMAND\" exit=$rc" | tee -a "$LOG_FILE" >&2' ERR
|
|
39
|
+
|
|
40
|
+
# Pipeline lock at top. Browser lock acquired later, just before the Claude/MCP step.
|
|
41
|
+
source "$REPO_DIR/skill/lock.sh"
|
|
42
|
+
# Harness-only browser bootstrap (twitter-agent path fully removed 2026-05-19).
|
|
43
|
+
# Sets MCP_CONFIG_FILE + BROWSER_INSTRUCTIONS for the Claude SDK call below.
|
|
44
|
+
source "$REPO_DIR/skill/lib/twitter-backend.sh"
|
|
45
|
+
acquire_lock "twitter-threads" 600
|
|
46
|
+
|
|
47
|
+
# Engagement styles
|
|
48
|
+
source "$REPO_DIR/skill/styles.sh"
|
|
49
|
+
STYLES_BLOCK=$(generate_styles_block twitter posting)
|
|
50
|
+
|
|
51
|
+
# Pick target. The picker enforces the daily cap; exit 3 = cap reached, exit 2 = no eligible angle.
|
|
52
|
+
set +e
|
|
53
|
+
TARGET_JSON=$(/usr/bin/python3 "$REPO_DIR/scripts/pick_twitter_thread_target.py" --json 2>&1)
|
|
54
|
+
PICK_RC=$?
|
|
55
|
+
set -e
|
|
56
|
+
if [ "$PICK_RC" -eq 3 ]; then
|
|
57
|
+
echo "DAILY_CAP_REACHED: skipping this fire (3 threads per UTC day)." | tee -a "$LOG_FILE"
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
if [ "$PICK_RC" -eq 2 ]; then
|
|
61
|
+
echo "NO_ELIGIBLE_TARGET: every (project,angle) is inside its floor window. Stopping." | tee -a "$LOG_FILE"
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
if [ "$PICK_RC" -ne 0 ]; then
|
|
65
|
+
echo "PICKER_FAILED rc=$PICK_RC output=$TARGET_JSON" | tee -a "$LOG_FILE"
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
PROJECT=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['project']['name'])")
|
|
70
|
+
TOPIC_ANGLE=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['topic_angle'])")
|
|
71
|
+
DAILY_COUNT=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['daily_count_today'])")
|
|
72
|
+
DAILY_CAP=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['daily_cap'])")
|
|
73
|
+
|
|
74
|
+
echo "Target: project=$PROJECT" | tee -a "$LOG_FILE"
|
|
75
|
+
echo "Angle: $TOPIC_ANGLE" | tee -a "$LOG_FILE"
|
|
76
|
+
echo "Daily: $DAILY_COUNT/$DAILY_CAP posts today (UTC)" | tee -a "$LOG_FILE"
|
|
77
|
+
|
|
78
|
+
# Posting account
|
|
79
|
+
POST_ACCOUNT=$(/usr/bin/python3 -c "
|
|
80
|
+
import json
|
|
81
|
+
c = json.load(open('$CONFIG_FILE'))
|
|
82
|
+
print((c.get('accounts',{}).get('twitter',{}).get('handle','@m13v_')).lstrip('@'))
|
|
83
|
+
")
|
|
84
|
+
|
|
85
|
+
# Per-project context block (same JSON-driven shape as reddit version).
|
|
86
|
+
# Reads twitter_threads first, falls back to threads if a key is absent there.
|
|
87
|
+
export PROJECT_ENV="$PROJECT"
|
|
88
|
+
export CONFIG_PATH="$CONFIG_FILE"
|
|
89
|
+
CONTEXT_BLOCK=$(/usr/bin/python3 <<'PYEOF'
|
|
90
|
+
import json, datetime, os
|
|
91
|
+
CONFIG = os.environ['CONFIG_PATH']
|
|
92
|
+
name = os.environ['PROJECT_ENV']
|
|
93
|
+
c = json.load(open(CONFIG))
|
|
94
|
+
proj = next((p for p in c['projects'] if p['name'] == name), None)
|
|
95
|
+
if not proj:
|
|
96
|
+
print("(project not found)")
|
|
97
|
+
raise SystemExit(0)
|
|
98
|
+
|
|
99
|
+
tt = proj.get('twitter_threads') or {}
|
|
100
|
+
t = proj.get('threads') or {} # fallback for content_sources/dynamic_context
|
|
101
|
+
lp = proj.get('landing_pages') or {}
|
|
102
|
+
|
|
103
|
+
def first(*keys):
|
|
104
|
+
"""Return the first non-empty value across (tt, t) for any of the given keys."""
|
|
105
|
+
for src in (tt, t):
|
|
106
|
+
for k in keys:
|
|
107
|
+
v = src.get(k)
|
|
108
|
+
if v:
|
|
109
|
+
return v
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
out = []
|
|
113
|
+
out.append(f"Project: {proj['name']}")
|
|
114
|
+
out.append(f"Description: {proj.get('description','').strip()}")
|
|
115
|
+
if proj.get('website'): out.append(f"Website: {proj['website']}")
|
|
116
|
+
if lp.get('base_url'): out.append(f"Base URL: {lp['base_url']}")
|
|
117
|
+
if proj.get('content_angle'):
|
|
118
|
+
out.append(f"\nContent angle: {proj['content_angle']}")
|
|
119
|
+
|
|
120
|
+
voice = proj.get('voice')
|
|
121
|
+
if voice:
|
|
122
|
+
out.append(f"\nVoice tone: {voice.get('tone','')}")
|
|
123
|
+
if voice.get('never'):
|
|
124
|
+
out.append("Voice never: " + "; ".join(voice['never']))
|
|
125
|
+
|
|
126
|
+
# Dynamic day counter
|
|
127
|
+
dc = first('dynamic_context') or {}
|
|
128
|
+
day = dc.get('day_counter')
|
|
129
|
+
if day:
|
|
130
|
+
base = day['base_count']
|
|
131
|
+
ref = datetime.date.fromisoformat(day['ref_date'])
|
|
132
|
+
days = (datetime.date.today() - ref).days
|
|
133
|
+
count = base + days
|
|
134
|
+
label = day.get('label','day count')
|
|
135
|
+
out.append(f"\nLive {label}: {count}+")
|
|
136
|
+
for f in dc.get('static_facts') or []:
|
|
137
|
+
out.append(f"- {f}")
|
|
138
|
+
|
|
139
|
+
# Source paths
|
|
140
|
+
out.append("\n## Product source (READ for context before drafting)")
|
|
141
|
+
repo = lp.get('repo','')
|
|
142
|
+
if repo:
|
|
143
|
+
rp = os.path.expanduser(repo)
|
|
144
|
+
status = "" if os.path.isdir(rp) else " [MISSING ON DISK]"
|
|
145
|
+
out.append(f"- Website repo: {rp}{status}")
|
|
146
|
+
for s in lp.get('product_source') or []:
|
|
147
|
+
p = os.path.expanduser(s.get('path',''))
|
|
148
|
+
status = "" if os.path.isdir(p) else " [MISSING]"
|
|
149
|
+
desc = s.get('description','').strip()
|
|
150
|
+
out.append(f"- {p}{status}\n {desc}")
|
|
151
|
+
|
|
152
|
+
# content_sources
|
|
153
|
+
cs = first('content_sources') or {}
|
|
154
|
+
if cs.get('guide_dir'):
|
|
155
|
+
gd = os.path.expanduser(cs['guide_dir'])
|
|
156
|
+
out.append(f"\nGuide dir (read page.tsx files here for specific detail): {gd}")
|
|
157
|
+
if cs.get('link_base'):
|
|
158
|
+
out.append(f"Link base for any URL you include: {cs['link_base']}")
|
|
159
|
+
if cs.get('readme_url'):
|
|
160
|
+
out.append(f"README url: {cs['readme_url']}")
|
|
161
|
+
if cs.get('read_instructions'):
|
|
162
|
+
out.append(cs['read_instructions'])
|
|
163
|
+
|
|
164
|
+
print("\n".join(out))
|
|
165
|
+
PYEOF
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
echo "--- Context block ---" | tee -a "$LOG_FILE"
|
|
169
|
+
echo "$CONTEXT_BLOCK" | tee -a "$LOG_FILE"
|
|
170
|
+
echo "---------------------" | tee -a "$LOG_FILE"
|
|
171
|
+
|
|
172
|
+
# Prompt context loaders below all route through scripts/twitter_threads_helper.py
|
|
173
|
+
# (HTTP /api/v1/posts) instead of three direct psql one-liners as of 2026-05-18.
|
|
174
|
+
# Filters preserved byte-equivalent: thread_url = our_url, project + window +
|
|
175
|
+
# status WHERE clauses. Post 2026-05-23, the '(mention - no original post)'
|
|
176
|
+
# placeholder rows no longer live in `posts` (they moved to the dedicated
|
|
177
|
+
# `mentions` table), so the legacy NOT ILIKE '(mention%' guard was dropped.
|
|
178
|
+
|
|
179
|
+
# Recent originals by us in last 14 days for THIS project (avoid repeats).
|
|
180
|
+
RECENT_POSTS=$(python3 "$REPO_DIR/scripts/twitter_threads_helper.py" \
|
|
181
|
+
recent-posts --project "$PROJECT" --days 14 --limit 10 \
|
|
182
|
+
2>/dev/null || echo "(api error)")
|
|
183
|
+
|
|
184
|
+
# Recent engagement styles for this project on Twitter.
|
|
185
|
+
RECENT_STYLES=$(python3 "$REPO_DIR/scripts/twitter_threads_helper.py" \
|
|
186
|
+
recent-styles --project "$PROJECT" --limit 5 \
|
|
187
|
+
2>/dev/null || echo "(api error)")
|
|
188
|
+
|
|
189
|
+
# Top performers (tone calibration) — composite (upvotes + 3*comments + views/100).
|
|
190
|
+
TOP_POSTS=$(python3 "$REPO_DIR/scripts/twitter_threads_helper.py" \
|
|
191
|
+
top-posts --project "$PROJECT" --limit 8 \
|
|
192
|
+
2>/dev/null || echo "(api error)")
|
|
193
|
+
|
|
194
|
+
# Structured output schema. The model returns a "tweets" array (1-6 items)
|
|
195
|
+
# representing a single chained Twitter thread, plus the same compliance fields
|
|
196
|
+
# as the reddit version.
|
|
197
|
+
RESULT_SCHEMA='{"type":"object","properties":{"research_files_read":{"type":"array","items":{"type":"string"}},"topic_angle":{"type":"string"},"engagement_style":{"type":"string"},"tweets":{"type":"array","minItems":1,"maxItems":6,"items":{"type":"string","maxLength":280},"description":"1-6 chained tweets. First is the hook, each <=280 chars."},"permalink":{"type":["string","null"],"description":"URL of the FIRST tweet in the thread, or null if aborted"},"abort_reason":{"type":["string","null"]},"source_summary":{"type":"string","description":"Rich summary: (a) topic angle and why, (b) source files read, (c) specific details used"}},"required":["research_files_read","topic_angle","engagement_style","tweets","permalink","abort_reason","source_summary"]}'
|
|
198
|
+
|
|
199
|
+
# Pre-generate session id so the prompt's inline INSERT can stamp it.
|
|
200
|
+
export CLAUDE_SESSION_ID=$(uuidgen | tr 'A-Z' 'a-z')
|
|
201
|
+
|
|
202
|
+
# --- Phase 0: link resolution (mirrors run-twitter-cycle.sh Phase 2b-gen) ---
|
|
203
|
+
# Resolve LINK_URL + LINK_SOURCE BEFORE acquiring the browser lock, so the
|
|
204
|
+
# 10-40 min generate_page.py mint (when A/B lands in the gen lane) does not
|
|
205
|
+
# block other twitter pipelines on the browser. Reuses scripts/twitter_gen_links.py
|
|
206
|
+
# unchanged; we feed it a single-candidate plan synthesised from the
|
|
207
|
+
# (project, topic_angle) the picker already chose.
|
|
208
|
+
THREADS_PLAN_FILE="/tmp/twitter_threads_link_$(date +%s)_$$.json"
|
|
209
|
+
PROJECT_ENV="$PROJECT" TOPIC_ANGLE_ENV="$TOPIC_ANGLE" CONFIG_PATH="$CONFIG_FILE" \
|
|
210
|
+
PLAN_FILE_ENV="$THREADS_PLAN_FILE" \
|
|
211
|
+
/usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE"
|
|
212
|
+
import json, os, re
|
|
213
|
+
|
|
214
|
+
CONFIG = os.environ['CONFIG_PATH']
|
|
215
|
+
project = os.environ['PROJECT_ENV']
|
|
216
|
+
topic_angle = os.environ['TOPIC_ANGLE_ENV']
|
|
217
|
+
plan_file = os.environ['PLAN_FILE_ENV']
|
|
218
|
+
|
|
219
|
+
c = json.load(open(CONFIG))
|
|
220
|
+
proj = next((p for p in c['projects'] if p['name'] == project), None)
|
|
221
|
+
lp = (proj or {}).get('landing_pages') or {}
|
|
222
|
+
has_lp = bool(lp.get('repo') and lp.get('base_url'))
|
|
223
|
+
|
|
224
|
+
def slugify(s):
|
|
225
|
+
s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-')
|
|
226
|
+
return s[:50].rstrip('-')
|
|
227
|
+
|
|
228
|
+
slug = slugify(topic_angle)
|
|
229
|
+
keyword = topic_angle.strip()
|
|
230
|
+
|
|
231
|
+
plan = {"candidates": [{
|
|
232
|
+
"candidate_id": 0,
|
|
233
|
+
"matched_project": project,
|
|
234
|
+
"has_landing_pages": has_lp,
|
|
235
|
+
"link_keyword": keyword,
|
|
236
|
+
"link_slug": slug,
|
|
237
|
+
}]}
|
|
238
|
+
with open(plan_file, 'w') as f:
|
|
239
|
+
json.dump(plan, f)
|
|
240
|
+
print(f"[link-prep] project={project!r} has_lp={has_lp} slug={slug!r}")
|
|
241
|
+
PYEOF
|
|
242
|
+
|
|
243
|
+
echo "[link-gen] running twitter_gen_links.py for ${PROJECT} (no browser lock held)..." | tee -a "$LOG_FILE"
|
|
244
|
+
/usr/bin/python3 "$REPO_DIR/scripts/twitter_gen_links.py" --plan "$THREADS_PLAN_FILE" 2>&1 | tee -a "$LOG_FILE"
|
|
245
|
+
GEN_EXIT=${PIPESTATUS[0]:-1}
|
|
246
|
+
if [ "$GEN_EXIT" -ne 0 ]; then
|
|
247
|
+
echo "[link-gen] WARN: twitter_gen_links.py exited $GEN_EXIT, continuing with whatever link it set" | tee -a "$LOG_FILE"
|
|
248
|
+
fi
|
|
249
|
+
|
|
250
|
+
LINK_URL=$(/usr/bin/python3 -c "
|
|
251
|
+
import json
|
|
252
|
+
try:
|
|
253
|
+
p = json.load(open('$THREADS_PLAN_FILE'))
|
|
254
|
+
print((p.get('candidates') or [{}])[0].get('link_url') or '')
|
|
255
|
+
except Exception:
|
|
256
|
+
print('')
|
|
257
|
+
")
|
|
258
|
+
LINK_SOURCE=$(/usr/bin/python3 -c "
|
|
259
|
+
import json
|
|
260
|
+
try:
|
|
261
|
+
p = json.load(open('$THREADS_PLAN_FILE'))
|
|
262
|
+
print((p.get('candidates') or [{}])[0].get('link_source') or '')
|
|
263
|
+
except Exception:
|
|
264
|
+
print('')
|
|
265
|
+
")
|
|
266
|
+
rm -f "$THREADS_PLAN_FILE"
|
|
267
|
+
export LINK_URL LINK_SOURCE
|
|
268
|
+
echo "[link-gen] resolved LINK_URL='${LINK_URL}' LINK_SOURCE='${LINK_SOURCE}'" | tee -a "$LOG_FILE"
|
|
269
|
+
|
|
270
|
+
# Build the prompt rule for tweet 1's link. Mandatory when we resolved a URL,
|
|
271
|
+
# omitted otherwise (e.g. project has no website AND no landing_pages config).
|
|
272
|
+
if [ -n "$LINK_URL" ]; then
|
|
273
|
+
LINK_LEN=${#LINK_URL}
|
|
274
|
+
TWEET1_BUDGET=$(( 280 - LINK_LEN - 1 ))
|
|
275
|
+
LINK_RULE="MANDATORY LINK: end your FIRST tweet with EXACTLY this URL preceded by a single space: ${LINK_URL}
|
|
276
|
+
- Reserve room: tweet 1 text BEFORE the URL must be <= ${TWEET1_BUDGET} chars (the URL itself is ${LINK_LEN} chars).
|
|
277
|
+
- Do NOT paraphrase, shorten, or wrap the URL. Do NOT include the link in tweets 2+ (X downranks link-heavy threads).
|
|
278
|
+
- The shell verifies post-flight that tweets[0] ends with this exact URL; if not, the post is logged with link_source='link_missing'."
|
|
279
|
+
else
|
|
280
|
+
LINK_RULE="No link: this project has no website or landing_pages configured this cycle. Do NOT include any URL in any tweet."
|
|
281
|
+
fi
|
|
282
|
+
export LINK_RULE
|
|
283
|
+
|
|
284
|
+
# Acquire browser lock right before MCP step.
|
|
285
|
+
acquire_lock "twitter-browser" 3600
|
|
286
|
+
ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|
|
287
|
+
|
|
288
|
+
# Campaign wiring: pre-submit suffix injection (Twitter has no edit API, so we
|
|
289
|
+
# can't mirror reddit-threads' post-submit edit pattern; instead we instruct the
|
|
290
|
+
# model to end the LAST tweet with the literal suffix and verify post-flight).
|
|
291
|
+
# Dice are rolled in Python before Claude runs. campaigns.posts_made is bumped
|
|
292
|
+
# only on a verified-suffix outcome (parallels reddit-threads' verified-edit
|
|
293
|
+
# semantics). Multi-suffix concatenation matches post_reddit.py behavior.
|
|
294
|
+
# HTTP-only lane (2026-06-01): active twitter campaigns come from the s4l.ai
|
|
295
|
+
# API (/api/v1/campaigns?status=active&platform=twitter&has_suffix=true&
|
|
296
|
+
# with_budget_remaining=true), mirroring post_reddit.load_active_reddit_campaigns.
|
|
297
|
+
# No DATABASE_URL, no db.get_conn(), no fallback.
|
|
298
|
+
CAMPAIGN_ENV=$(/usr/bin/python3 <<'PYEOF'
|
|
299
|
+
import json, os, random, sys
|
|
300
|
+
sys.path.insert(0, os.path.join(os.environ.get("HOME",""), "social-autoposter", "scripts"))
|
|
301
|
+
from http_api import api_get
|
|
302
|
+
|
|
303
|
+
resp = api_get("/api/v1/campaigns", query={
|
|
304
|
+
"status": "active",
|
|
305
|
+
"platform": "twitter",
|
|
306
|
+
"has_suffix": "true",
|
|
307
|
+
"with_budget_remaining": "true",
|
|
308
|
+
"limit": 500,
|
|
309
|
+
})
|
|
310
|
+
rows = ((resp or {}).get("data") or {}).get("campaigns") or []
|
|
311
|
+
|
|
312
|
+
applied_ids = []
|
|
313
|
+
suffix_parts = []
|
|
314
|
+
for r in rows:
|
|
315
|
+
cid = int(r["id"])
|
|
316
|
+
suffix = r.get("suffix")
|
|
317
|
+
rate = float(r.get("sample_rate") if r.get("sample_rate") is not None else 1.0)
|
|
318
|
+
if suffix and random.random() < rate:
|
|
319
|
+
applied_ids.append(cid)
|
|
320
|
+
suffix_parts.append(suffix)
|
|
321
|
+
|
|
322
|
+
print(json.dumps({
|
|
323
|
+
"ids_csv": ",".join(str(i) for i in applied_ids),
|
|
324
|
+
"suffix": "".join(suffix_parts),
|
|
325
|
+
}))
|
|
326
|
+
PYEOF
|
|
327
|
+
)
|
|
328
|
+
CAMPAIGN_IDS=$(/usr/bin/python3 -c "import json,sys; print(json.loads(sys.stdin.read())['ids_csv'])" <<< "$CAMPAIGN_ENV")
|
|
329
|
+
CAMPAIGN_SUFFIX=$(/usr/bin/python3 -c "import json,sys; print(json.loads(sys.stdin.read())['suffix'])" <<< "$CAMPAIGN_ENV")
|
|
330
|
+
|
|
331
|
+
if [ -n "$CAMPAIGN_IDS" ]; then
|
|
332
|
+
# Build the prompt block. The suffix is wrapped in backticks for the model's
|
|
333
|
+
# benefit but the model must NOT include the backticks in the actual tweet.
|
|
334
|
+
CAMPAIGN_BLOCK="## ACTIVE CAMPAIGN ATTRIBUTION (mandatory, non-negotiable)
|
|
335
|
+
|
|
336
|
+
The LAST tweet of your thread MUST end with EXACTLY this literal suffix (preserve any leading whitespace, do not include the surrounding backticks):
|
|
337
|
+
\`${CAMPAIGN_SUFFIX}\`
|
|
338
|
+
|
|
339
|
+
Do not paraphrase, translate, capitalize, punctuate, or wrap it in quotes.
|
|
340
|
+
Reserve enough characters in the LAST tweet so the suffix fits within the 280-char cap.
|
|
341
|
+
The same text must appear (a) at the end of the last entry in your tweets array AND (b) at the end of the actual posted tweet (these will be verified)."
|
|
342
|
+
echo "[campaign-twitter-thread] applying campaign_ids=${CAMPAIGN_IDS} suffix='${CAMPAIGN_SUFFIX}'" | tee -a "$LOG_FILE"
|
|
343
|
+
else
|
|
344
|
+
CAMPAIGN_BLOCK=""
|
|
345
|
+
echo "[campaign-twitter-thread] no active campaigns fired (or none active)" | tee -a "$LOG_FILE"
|
|
346
|
+
fi
|
|
347
|
+
|
|
348
|
+
# Capture Claude output to a temp file so non-zero exit doesn't swallow stderr.
|
|
349
|
+
CLAUDE_TMP=$(mktemp)
|
|
350
|
+
set +e
|
|
351
|
+
"$REPO_DIR/scripts/run_claude.sh" "run-twitter-threads" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" -p --output-format json --json-schema "$RESULT_SCHEMA" "${BROWSER_INSTRUCTIONS}
|
|
352
|
+
|
|
353
|
+
You are posting an ORIGINAL Twitter thread for the ${PROJECT} project as @${POST_ACCOUNT}.
|
|
354
|
+
|
|
355
|
+
## Config & Rules
|
|
356
|
+
Read $SKILL_FILE for content rules and anti-AI-detection checklist.
|
|
357
|
+
You may also open $CONFIG_FILE for the full project block if you need anything not summarized below.
|
|
358
|
+
|
|
359
|
+
## Target
|
|
360
|
+
Project: ${PROJECT}
|
|
361
|
+
Topic angle (use this, do NOT pick a different one): ${TOPIC_ANGLE}
|
|
362
|
+
|
|
363
|
+
## Project context (live-assembled)
|
|
364
|
+
${CONTEXT_BLOCK}
|
|
365
|
+
|
|
366
|
+
${STYLES_BLOCK}
|
|
367
|
+
|
|
368
|
+
## Recent originals by us for ${PROJECT} (last 14d, DO NOT recycle phrasing or closer)
|
|
369
|
+
Each entry shows: first 120 chars |ENDING| last 80 chars. Vary your closer.
|
|
370
|
+
${RECENT_POSTS}
|
|
371
|
+
|
|
372
|
+
## Recent engagement styles for ${PROJECT} on Twitter (avoid repeating back-to-back)
|
|
373
|
+
${RECENT_STYLES}
|
|
374
|
+
|
|
375
|
+
## Top performing ${PROJECT} originals (match tone)
|
|
376
|
+
${TOP_POSTS}
|
|
377
|
+
|
|
378
|
+
${CAMPAIGN_BLOCK}
|
|
379
|
+
|
|
380
|
+
## Workflow
|
|
381
|
+
|
|
382
|
+
1. RESEARCH (required): Read the product source paths listed in the context block. Pull 1-2 concrete, specific details from the source code or docs to anchor the thread. Generic threads get ignored.
|
|
383
|
+
|
|
384
|
+
2. SCAN THE TIMELINE: Navigate to https://x.com/home using the navigate tool from the BROWSER BACKEND block above to get a quick read on what is being said in our space today.
|
|
385
|
+
- Read 5-10 recent tweets from accounts in adjacent topics (other indie devs, AI tooling, macOS automation, whatever fits the project).
|
|
386
|
+
- Note the current vocabulary, hot takes, and any thread that is getting unusually high engagement.
|
|
387
|
+
- Close the tab.
|
|
388
|
+
|
|
389
|
+
3. DRAFT the thread.
|
|
390
|
+
- 1 to 6 tweets. Each <= 280 characters (hard cap). The first tweet must work as a standalone hook (people may only see that one).
|
|
391
|
+
- Use the assigned topic_angle (above). Pick an engagement_style from the styles list that fits and is NOT one of the last 3 used for this project.
|
|
392
|
+
- No em dashes anywhere. Commas, periods, plain '-' only.
|
|
393
|
+
- No hashtag spam. At most ONE hashtag total in the entire thread, only if it is genuinely the topical tag people search.
|
|
394
|
+
- No emojis at the start of a tweet. At most one per tweet, and only if it adds meaning.
|
|
395
|
+
- Lowercase first character on most tweets feels natural on X. Do not uniformly lowercase every sentence (that is an AI tell). Mix it.
|
|
396
|
+
- At least one imperfection (sentence fragment, aside, run-on) somewhere in the thread.
|
|
397
|
+
- Ground at least one claim in a specific detail from the source you read in step 1.
|
|
398
|
+
- VARY YOUR CLOSER. Banned closers: 'curious if anyone', 'anyone else', 'thoughts?', 'has anyone'. Sometimes end with a statement, sometimes mid-thought, sometimes a specific question.
|
|
399
|
+
- ${LINK_RULE}
|
|
400
|
+
|
|
401
|
+
4. POST via the browser tools from the BROWSER BACKEND block above:
|
|
402
|
+
- Navigate to https://x.com/compose/post.
|
|
403
|
+
- Fill the first tweet into the textarea selected by [data-testid='tweetTextarea_0']. If the contenteditable does not accept .value=, use the type tool from the BROWSER BACKEND block to type the text directly into the focused element.
|
|
404
|
+
- For each subsequent tweet (if any): click the button with data-testid='addButton' (the small '+' that appends a new tweet to the chain), then fill its textarea. The new textarea will be data-testid='tweetTextarea_1', then 'tweetTextarea_2', etc.
|
|
405
|
+
- When all tweets are filled, click the button with data-testid='tweetButton' (label varies between 'Post' and 'Post all'). Wait 4 seconds.
|
|
406
|
+
- Capture the URL of the FIRST posted tweet:
|
|
407
|
+
- Navigate to https://x.com/${POST_ACCOUNT} and read the most recent pinned-or-top tweet's permalink (browser_evaluate: document.querySelector('article a[href*=\"/status/\"]').href). Confirm its text matches the first tweet you posted.
|
|
408
|
+
- Close the tab.
|
|
409
|
+
|
|
410
|
+
5. DO NOT touch the database. The shell wrapper handles the INSERT after you return.
|
|
411
|
+
IMPORTANT: tweets, permalink, engagement_style, source_summary in your JSON output are what get logged. Make source_summary rich.
|
|
412
|
+
|
|
413
|
+
6. Return the structured JSON output. Every field is required. permalink = URL of the first tweet if posted, null if aborted. tweets array must contain the EXACT text of each tweet posted (no markdown, no additions).
|
|
414
|
+
|
|
415
|
+
CRITICAL: NEVER use em dashes. Use commas, plain hyphens, or separate sentences.
|
|
416
|
+
CRITICAL: Each tweet <=280 chars. The schema enforces this; do not exceed.
|
|
417
|
+
CRITICAL: Use ONLY the browser tools listed in the BROWSER BACKEND block above (the Twitter-dedicated MCP for this run). NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, or mcp__macos-use__* tools.
|
|
418
|
+
CRITICAL: Close browser tabs after each navigation (browser_tabs action 'close').
|
|
419
|
+
CRITICAL: If a browser call times out, wait 30s and retry up to 3 times." > "$CLAUDE_TMP" 2>&1
|
|
420
|
+
CLAUDE_RC=$?
|
|
421
|
+
set -e
|
|
422
|
+
CLAUDE_OUTPUT=$(cat "$CLAUDE_TMP")
|
|
423
|
+
rm -f "$CLAUDE_TMP"
|
|
424
|
+
|
|
425
|
+
echo "$CLAUDE_OUTPUT" | tee -a "$LOG_FILE"
|
|
426
|
+
if [ "$CLAUDE_RC" -ne 0 ]; then
|
|
427
|
+
echo "RUN_CLAUDE_NONZERO_EXIT rc=$CLAUDE_RC (output above is full stderr+stdout)" | tee -a "$LOG_FILE"
|
|
428
|
+
fi
|
|
429
|
+
|
|
430
|
+
# Extract structured_output. claude -p --output-format json wraps results.
|
|
431
|
+
PARSED=$(/usr/bin/python3 -c "
|
|
432
|
+
import json,sys
|
|
433
|
+
try:
|
|
434
|
+
raw = sys.stdin.read()
|
|
435
|
+
d, _ = json.JSONDecoder().raw_decode(raw)
|
|
436
|
+
so = d.get('structured_output') or d
|
|
437
|
+
print(json.dumps(so))
|
|
438
|
+
except Exception as e:
|
|
439
|
+
print(json.dumps({'_parse_error': str(e)}))
|
|
440
|
+
" <<< "$CLAUDE_OUTPUT" 2>/dev/null)
|
|
441
|
+
|
|
442
|
+
PERMALINK=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('permalink') or 'null')" <<< "$PARSED" 2>/dev/null)
|
|
443
|
+
ABORT_REASON=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('abort_reason') or '')" <<< "$PARSED" 2>/dev/null)
|
|
444
|
+
|
|
445
|
+
# Step compliance summary
|
|
446
|
+
/usr/bin/python3 -c "
|
|
447
|
+
import json,sys
|
|
448
|
+
r = json.loads(sys.stdin.read())
|
|
449
|
+
if '_parse_error' in r:
|
|
450
|
+
print(f'Step compliance: PARSE ERROR ({r[\"_parse_error\"]})')
|
|
451
|
+
else:
|
|
452
|
+
files = r.get('research_files_read', [])
|
|
453
|
+
tweets = r.get('tweets', [])
|
|
454
|
+
style = r.get('engagement_style', '?')
|
|
455
|
+
over = [i for i,t in enumerate(tweets) if len(t) > 280]
|
|
456
|
+
print(f'Step compliance: research={len(files)} files, tweets={len(tweets)}, style={style}, over_280={over or \"none\"}')
|
|
457
|
+
" <<< "$PARSED" 2>/dev/null | tee -a "$LOG_FILE"
|
|
458
|
+
|
|
459
|
+
if [ "$PERMALINK" != "null" ] && [ "$PERMALINK" != "" ] && [ "$PERMALINK" != "PARSE_ERROR" ]; then
|
|
460
|
+
echo "POSTED: $PERMALINK" | tee -a "$LOG_FILE"
|
|
461
|
+
|
|
462
|
+
# Authoritative DB INSERT. Same pattern as reddit threads runner.
|
|
463
|
+
PARSED="$PARSED" \
|
|
464
|
+
CLAUDE_SESSION_ID="$CLAUDE_SESSION_ID" \
|
|
465
|
+
PROJECT_ENV="$PROJECT" \
|
|
466
|
+
POST_ACCOUNT="$POST_ACCOUNT" \
|
|
467
|
+
REPO_DIR="$REPO_DIR" \
|
|
468
|
+
CAMPAIGN_IDS="$CAMPAIGN_IDS" \
|
|
469
|
+
CAMPAIGN_SUFFIX="$CAMPAIGN_SUFFIX" \
|
|
470
|
+
LINK_URL="$LINK_URL" \
|
|
471
|
+
LINK_SOURCE="$LINK_SOURCE" \
|
|
472
|
+
/usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE" || true
|
|
473
|
+
import json, os, subprocess, sys
|
|
474
|
+
sys.path.insert(0, os.path.join(os.environ["REPO_DIR"], "scripts"))
|
|
475
|
+
from http_api import api_post
|
|
476
|
+
|
|
477
|
+
parsed = json.loads(os.environ.get("PARSED") or "{}")
|
|
478
|
+
permalink = parsed.get("permalink") or ""
|
|
479
|
+
tweets = parsed.get("tweets") or []
|
|
480
|
+
summary = parsed.get("source_summary", "")
|
|
481
|
+
style = parsed.get("engagement_style", "") or None
|
|
482
|
+
session = os.environ.get("CLAUDE_SESSION_ID") or None
|
|
483
|
+
project = os.environ.get("PROJECT_ENV", "")
|
|
484
|
+
account = os.environ.get("POST_ACCOUNT", "")
|
|
485
|
+
link_url = (os.environ.get("LINK_URL") or "").strip()
|
|
486
|
+
link_source = (os.environ.get("LINK_SOURCE") or "").strip() or None
|
|
487
|
+
|
|
488
|
+
if not permalink or not tweets:
|
|
489
|
+
print("[db-insert] SKIP - empty permalink or tweets in structured_output")
|
|
490
|
+
sys.exit(0)
|
|
491
|
+
|
|
492
|
+
# Verify post-flight that tweet 1 ends with the resolved link. The cycle
|
|
493
|
+
# pipeline gets this for free (twitter_browser.py concatenates the URL); for
|
|
494
|
+
# threads the LLM does the appending, so we audit and downgrade link_source
|
|
495
|
+
# when the model dropped the link.
|
|
496
|
+
if link_url:
|
|
497
|
+
first_tweet = (tweets[0] or "").rstrip()
|
|
498
|
+
if first_tweet.endswith(link_url):
|
|
499
|
+
print(f"[link-verify] OK first tweet ends with {link_url!r}")
|
|
500
|
+
else:
|
|
501
|
+
print(f"[link-verify] MISS expected suffix {link_url!r} not at end of tweet1; "
|
|
502
|
+
f"tail={first_tweet[-80:]!r}")
|
|
503
|
+
link_source = "link_missing"
|
|
504
|
+
|
|
505
|
+
# Stitch tweets into our_content with double-newline separators so downstream
|
|
506
|
+
# stats/refresh queries treat the whole thread as one row.
|
|
507
|
+
body = "\n\n".join(t.strip() for t in tweets if t and t.strip())
|
|
508
|
+
# Twitter doesn't have a separate title; use the first tweet's first 100 chars
|
|
509
|
+
# so dashboard listings have something readable.
|
|
510
|
+
title = (tweets[0] or "")[:100]
|
|
511
|
+
|
|
512
|
+
# HTTP-only lane (2026-06-01): the authoritative post log goes through the
|
|
513
|
+
# s4l.ai API (POST /api/v1/posts). No DATABASE_URL, no db.get_conn(). The
|
|
514
|
+
# endpoint dedups on (platform, thread_url) server-side and returns 409 with
|
|
515
|
+
# existing_post_id, replacing the old SELECT idempotency guard (thread_url ==
|
|
516
|
+
# our_url == permalink here, so dedup is equivalent to the old our_url check).
|
|
517
|
+
resp = api_post("/api/v1/posts", {
|
|
518
|
+
"platform": "twitter",
|
|
519
|
+
"thread_url": permalink,
|
|
520
|
+
"thread_author": account,
|
|
521
|
+
"thread_title": title,
|
|
522
|
+
"thread_content": body,
|
|
523
|
+
"our_url": permalink,
|
|
524
|
+
"our_content": body,
|
|
525
|
+
"our_account": account,
|
|
526
|
+
"source_summary": summary,
|
|
527
|
+
"project": project,
|
|
528
|
+
"engagement_style": style,
|
|
529
|
+
"status": "active",
|
|
530
|
+
"claude_session_id": session,
|
|
531
|
+
"link_source": link_source,
|
|
532
|
+
}, ok_on_conflict=True)
|
|
533
|
+
|
|
534
|
+
if not resp.get("ok", True) and (resp.get("error") or {}).get("code") == "duplicate_thread":
|
|
535
|
+
existing_id = ((resp.get("error") or {}).get("details") or {}).get("existing_post_id")
|
|
536
|
+
print(f"[db-insert] SKIP — post {permalink} already in DB as id={existing_id}")
|
|
537
|
+
sys.exit(0)
|
|
538
|
+
|
|
539
|
+
post_id = (resp.get("data") or {}).get("post", {}).get("id")
|
|
540
|
+
print(f"[db-insert] OK — inserted posts.id={post_id} for {permalink}")
|
|
541
|
+
|
|
542
|
+
# Campaign verification gate. Twitter has no edit API so we cannot fix the
|
|
543
|
+
# tweet after posting; instead we verify the model honored the suffix
|
|
544
|
+
# instruction and bump the counter only if it did. Mirrors the verified-edit
|
|
545
|
+
# semantics in skill/run-reddit-threads.sh.
|
|
546
|
+
campaign_ids_csv = (os.environ.get("CAMPAIGN_IDS") or "").strip()
|
|
547
|
+
campaign_suffix = os.environ.get("CAMPAIGN_SUFFIX") or ""
|
|
548
|
+
if campaign_ids_csv and campaign_suffix:
|
|
549
|
+
last_tweet = (tweets[-1] or "").rstrip()
|
|
550
|
+
expected = campaign_suffix.rstrip()
|
|
551
|
+
if last_tweet.endswith(expected):
|
|
552
|
+
bump = os.path.join(os.environ["REPO_DIR"], "scripts", "campaign_bump.py")
|
|
553
|
+
for cid in [c for c in campaign_ids_csv.split(",") if c.strip()]:
|
|
554
|
+
try:
|
|
555
|
+
subprocess.run(
|
|
556
|
+
["python3", bump,
|
|
557
|
+
"--table", "posts", "--id", str(post_id),
|
|
558
|
+
"--campaign-id", cid.strip()],
|
|
559
|
+
capture_output=True, text=True, timeout=15,
|
|
560
|
+
)
|
|
561
|
+
except Exception as e:
|
|
562
|
+
print(f"[campaign-twitter-thread] WARNING: campaign_bump failed (id={post_id} c={cid}): {e}")
|
|
563
|
+
print(f"[campaign-twitter-thread] OK — last tweet ends with suffix, campaigns {campaign_ids_csv} bumped on post {post_id}")
|
|
564
|
+
else:
|
|
565
|
+
# Verification failed: model did not add the suffix as instructed.
|
|
566
|
+
# Leave the post untagged. campaign_id stays NULL, so the row joins
|
|
567
|
+
# the control bucket for A/B purposes (parallels reddit-threads'
|
|
568
|
+
# degraded path on edit-thread failure).
|
|
569
|
+
print(f"[campaign-twitter-thread] WARNING: last tweet does not end with expected suffix; post {post_id} stays untagged. last_tail={last_tweet[-80:]!r} expected={expected!r}")
|
|
570
|
+
PYEOF
|
|
571
|
+
|
|
572
|
+
elif [ -n "$ABORT_REASON" ] && [ "$ABORT_REASON" != "PARSE_ERROR" ]; then
|
|
573
|
+
echo "ABORTED: $ABORT_REASON" | tee -a "$LOG_FILE"
|
|
574
|
+
else
|
|
575
|
+
echo "UNKNOWN OUTCOME (check JSON output above)" | tee -a "$LOG_FILE"
|
|
576
|
+
fi
|
|
577
|
+
|
|
578
|
+
# Surface this run in the dashboard's Job History under "Post Threads · Twitter".
|
|
579
|
+
# Script name `thread_twitter` is what bin/server.js classifyScript() matches.
|
|
580
|
+
ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
581
|
+
if [ "$PERMALINK" != "null" ] && [ "$PERMALINK" != "" ] && [ "$PERMALINK" != "PARSE_ERROR" ]; then
|
|
582
|
+
POSTED_CT=1; SKIPPED_CT=0; FAILED_CT=0
|
|
583
|
+
elif [ -n "$ABORT_REASON" ] && [ "$ABORT_REASON" != "PARSE_ERROR" ]; then
|
|
584
|
+
POSTED_CT=0; SKIPPED_CT=1; FAILED_CT=0
|
|
585
|
+
else
|
|
586
|
+
POSTED_CT=0; SKIPPED_CT=0; FAILED_CT=1
|
|
587
|
+
fi
|
|
588
|
+
_COST=$(/usr/bin/python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-twitter-threads" 2>/dev/null || echo "0.0000")
|
|
589
|
+
/usr/bin/python3 "$REPO_DIR/scripts/log_run.py" --script "thread_twitter" --posted "$POSTED_CT" --skipped "$SKIPPED_CT" --failed "$FAILED_CT" --cost "$_COST" --elapsed "$ELAPSED" || true
|
|
590
|
+
|
|
591
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
592
|
+
find "$LOG_DIR" -name "run-twitter-threads-*.log" -mtime +14 -delete 2>/dev/null || true
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scan-instagram-replies.sh — Discover new inbound comments on our Instagram
|
|
3
|
+
# posts via the Graph API and insert them into the `replies` table.
|
|
4
|
+
#
|
|
5
|
+
# Mirrors the pattern used by stats-instagram.sh: API-only (no browser),
|
|
6
|
+
# instagram-poster lock (so scan, stats, and post can't race for the same
|
|
7
|
+
# token-bucket), then a SUMMARY-line parsed by log_run.py for the dashboard
|
|
8
|
+
# Jobs panel.
|
|
9
|
+
#
|
|
10
|
+
# Logs: skill/logs/scan-instagram-replies-YYYY-MM-DD_HHMMSS.log
|
|
11
|
+
|
|
12
|
+
set -uo pipefail
|
|
13
|
+
|
|
14
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
15
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
16
|
+
mkdir -p "$LOG_DIR"
|
|
17
|
+
LOG_FILE="$LOG_DIR/scan-instagram-replies-$(date +%Y-%m-%d_%H%M%S).log"
|
|
18
|
+
|
|
19
|
+
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
20
|
+
log "=== scan-instagram-replies fire: $(date) ==="
|
|
21
|
+
|
|
22
|
+
RUN_START=$(date +%s)
|
|
23
|
+
|
|
24
|
+
# instagram-poster lock — stats, scan, daily-post, and render all share this
|
|
25
|
+
# lane so we don't race on the same /me/media token bucket.
|
|
26
|
+
# shellcheck source=lock.sh
|
|
27
|
+
source "$REPO_DIR/skill/lock.sh"
|
|
28
|
+
acquire_lock instagram-poster 30
|
|
29
|
+
|
|
30
|
+
OUTPUT_FILE="/tmp/scan-instagram-replies-$$.out"
|
|
31
|
+
if ! /opt/homebrew/bin/python3.11 "$REPO_DIR/scripts/scan_instagram_comments.py" 2>>"$LOG_FILE" | tee -a "$LOG_FILE" >"$OUTPUT_FILE"; then
|
|
32
|
+
log "scan_instagram_comments.py exited non-zero — logging run as failed"
|
|
33
|
+
DISCOVERED=0; SKIPPED=0; CHECKED=0; ALREADY=0; ACCOUNTS=0
|
|
34
|
+
else
|
|
35
|
+
SUMMARY=$(grep '^SUMMARY:' "$OUTPUT_FILE" | tail -1)
|
|
36
|
+
DISCOVERED=$(echo "$SUMMARY" | sed -n 's/.*DISCOVERED=\([0-9]*\).*/\1/p'); DISCOVERED=${DISCOVERED:-0}
|
|
37
|
+
SKIPPED=$(echo "$SUMMARY" | sed -n 's/.*SKIPPED=\([0-9]*\).*/\1/p'); SKIPPED=${SKIPPED:-0}
|
|
38
|
+
CHECKED=$(echo "$SUMMARY" | sed -n 's/.*CHECKED=\([0-9]*\).*/\1/p'); CHECKED=${CHECKED:-0}
|
|
39
|
+
ALREADY=$(echo "$SUMMARY" | sed -n 's/.*ALREADY=\([0-9]*\).*/\1/p'); ALREADY=${ALREADY:-0}
|
|
40
|
+
ACCOUNTS=$(echo "$SUMMARY" | sed -n 's/.*ACCOUNTS=\([0-9]*\).*/\1/p'); ACCOUNTS=${ACCOUNTS:-0}
|
|
41
|
+
fi
|
|
42
|
+
rm -f "$OUTPUT_FILE"
|
|
43
|
+
|
|
44
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
45
|
+
|
|
46
|
+
log "logging run: discovered=$DISCOVERED skipped=$SKIPPED checked=$CHECKED already=$ALREADY accounts=$ACCOUNTS elapsed=${RUN_ELAPSED}s"
|
|
47
|
+
|
|
48
|
+
# discovered -> posted (new pending rows are the productive output of a scan,
|
|
49
|
+
# same convention scan_reddit_replies / scan_github_replies use).
|
|
50
|
+
# skipped -> skipped. checked -> scanned (media items inspected).
|
|
51
|
+
/opt/homebrew/bin/python3.11 "$REPO_DIR/scripts/log_run.py" \
|
|
52
|
+
--script "scan_instagram_comments" \
|
|
53
|
+
--posted "$DISCOVERED" \
|
|
54
|
+
--skipped "$SKIPPED" \
|
|
55
|
+
--failed 0 \
|
|
56
|
+
--scanned "$CHECKED" \
|
|
57
|
+
--cost 0 \
|
|
58
|
+
--elapsed "$RUN_ELAPSED" >>"$LOG_FILE" 2>&1 || log "log_run.py failed"
|
|
59
|
+
|
|
60
|
+
log "=== scan-instagram-replies done ==="
|
|
61
|
+
exit 0
|