@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,847 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Social Autoposter - Original Reddit thread poster (generalized)
|
|
3
|
+
#
|
|
4
|
+
# Picks one (project, subreddit) target via pick_thread_target.py,
|
|
5
|
+
# which enforces per-sub floor and banned-subreddit filtering, then spawns a
|
|
6
|
+
# Claude session with reddit-agent to research, draft, and post ONE original
|
|
7
|
+
# thread.
|
|
8
|
+
#
|
|
9
|
+
# Called by launchd every 6 hours. See com.m13v.social-reddit-threads.plist.
|
|
10
|
+
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
14
|
+
|
|
15
|
+
# Cycle ID for cross-cycle cost accounting (see run-reddit-search.sh / engage.sh
|
|
16
|
+
# for the same pattern). Stamps claude_sessions.cycle_id so get_run_cost.py
|
|
17
|
+
# --cycle-id reports per-cycle spend instead of bleeding across concurrent runs.
|
|
18
|
+
BATCH_ID="${BATCH_ID:-rdthr-$(date +%Y%m%d-%H%M%S)}"
|
|
19
|
+
export BATCH_ID
|
|
20
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
21
|
+
|
|
22
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
23
|
+
CONFIG_FILE="$REPO_DIR/config.json"
|
|
24
|
+
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
25
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
26
|
+
mkdir -p "$LOG_DIR"
|
|
27
|
+
LOG_FILE="$LOG_DIR/run-reddit-threads-$(date +%Y-%m-%d_%H%M%S).log"
|
|
28
|
+
|
|
29
|
+
echo "=== Reddit Threads Run: $(date) ===" | tee "$LOG_FILE"
|
|
30
|
+
RUN_START_EPOCH=$(date +%s)
|
|
31
|
+
|
|
32
|
+
# Match run-reddit-search.sh:38 / engage.sh:46. Without this, the 4 `log "..."`
|
|
33
|
+
# calls added in 5e41d96 (2026-05-10) fall through to macOS /usr/bin/log,
|
|
34
|
+
# which barfs `Unknown subcommand` and ERR-traps the run at exit=64 before
|
|
35
|
+
# the browser lease is ever acquired (silent kill since 2026-05-10).
|
|
36
|
+
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
37
|
+
|
|
38
|
+
# Diagnostic: log the failing line and command before set -e kills the script.
|
|
39
|
+
# Without this, silent deaths (e.g., Claude exits non-zero inside the $() below)
|
|
40
|
+
# leave only the context block in the log with no clue what killed the run.
|
|
41
|
+
trap 'rc=$?; echo "SCRIPT DIED line=$LINENO cmd=\"$BASH_COMMAND\" exit=$rc" | tee -a "$LOG_FILE" >&2' ERR
|
|
42
|
+
|
|
43
|
+
# Pipeline lock at top. The reddit-browser lock is acquired later, just
|
|
44
|
+
# before the Claude/MCP step that drives the browser, so peers can use the
|
|
45
|
+
# profile during our pre-Claude research + prompt build.
|
|
46
|
+
source "$REPO_DIR/skill/lock.sh"
|
|
47
|
+
# reddit-harness backend (2026-05-29). Sets MCP_CONFIG_FILE, BROWSER_INSTRUCTIONS,
|
|
48
|
+
# exports REDDIT_CDP_URL=:9557, provides ensure_reddit_browser_for_backend.
|
|
49
|
+
source "$REPO_DIR/skill/lib/reddit-backend.sh"
|
|
50
|
+
acquire_lock "reddit-threads" 600
|
|
51
|
+
|
|
52
|
+
# Load engagement styles.
|
|
53
|
+
# 2026-05-25: switched from generate_styles_block (which throws away the
|
|
54
|
+
# picker assignment) to the explicit saps_pick_style + saps_render_style_block
|
|
55
|
+
# pair. PICKED_STYLE/PICK_MODE flow into the DB-insert heredoc below as env
|
|
56
|
+
# vars so validate_or_register can coerce USE-mode drift back to the assigned
|
|
57
|
+
# style, matching post_reddit.py / twitter_post_plan.py / post_github.py
|
|
58
|
+
# semantics. Without this, the prompt's "pick from the list" instruction is
|
|
59
|
+
# unenforced and any drifted/invented name silently lands in posts.engagement_style.
|
|
60
|
+
source "$REPO_DIR/skill/styles.sh"
|
|
61
|
+
STYLE_ASSIGN_FILE=$(mktemp -t saps_reddit_threads_style_XXXXXX.json)
|
|
62
|
+
saps_pick_style reddit posting "$STYLE_ASSIGN_FILE" >/dev/null 2>>"$LOG_FILE" || true
|
|
63
|
+
PICKED_STYLE=$(/usr/bin/python3 -c "import json; print(json.load(open('$STYLE_ASSIGN_FILE')).get('style') or '')" 2>/dev/null || echo "")
|
|
64
|
+
PICK_MODE=$(/usr/bin/python3 -c "import json; print(json.load(open('$STYLE_ASSIGN_FILE')).get('mode','')) " 2>/dev/null || echo "")
|
|
65
|
+
STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" reddit posting)
|
|
66
|
+
echo "engagement_style: picked='${PICKED_STYLE}' mode='${PICK_MODE}'" | tee -a "$LOG_FILE"
|
|
67
|
+
|
|
68
|
+
# RETRY-FROM-PENDING (added 2026-05-01 after r/AutoHotkey MCP-crash incident):
|
|
69
|
+
# Before paying for fresh research+drafting, check if a previously-aborted draft
|
|
70
|
+
# is sitting in pending_threads waiting for a retry. If so, pick it up and skip
|
|
71
|
+
# the research/drafting phase entirely. This reuses sunk Claude cost on prior runs.
|
|
72
|
+
RETRY_PAYLOAD=$(/usr/bin/python3 <<'PYEOF' 2>/dev/null
|
|
73
|
+
import sys, os, json
|
|
74
|
+
sys.path.insert(0, os.path.expanduser("~/social-autoposter/scripts"))
|
|
75
|
+
import pending_threads as pt
|
|
76
|
+
rows = pt.list_pending(project=None)
|
|
77
|
+
# Cap at 3 attempts before abandoning, so a perpetually-broken draft doesn't
|
|
78
|
+
# blackhole every run.
|
|
79
|
+
rows = [r for r in rows if (r.get("attempts") or 0) < 3]
|
|
80
|
+
if not rows:
|
|
81
|
+
print("")
|
|
82
|
+
else:
|
|
83
|
+
# Oldest first.
|
|
84
|
+
r = rows[0]
|
|
85
|
+
print(json.dumps(r))
|
|
86
|
+
PYEOF
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if [ -n "$RETRY_PAYLOAD" ]; then
|
|
90
|
+
RETRY_MODE=1
|
|
91
|
+
PENDING_ID=$(echo "$RETRY_PAYLOAD" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
|
92
|
+
PROJECT=$(echo "$RETRY_PAYLOAD" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['project_name'])")
|
|
93
|
+
SUBREDDIT=$(echo "$RETRY_PAYLOAD" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['subreddit'])")
|
|
94
|
+
# Pull title, body, flair from the saved draft via the helper
|
|
95
|
+
PENDING_FULL=$(/usr/bin/python3 "$REPO_DIR/scripts/pending_threads.py" get --id "$PENDING_ID")
|
|
96
|
+
PENDING_TITLE=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('title',''))")
|
|
97
|
+
PENDING_BODY=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('body',''))")
|
|
98
|
+
PENDING_FLAIR=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('flair_target') or '')")
|
|
99
|
+
PENDING_STYLE=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('engagement_style') or '')")
|
|
100
|
+
PENDING_TOPIC=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('topic_angle') or '')")
|
|
101
|
+
PENDING_SOURCE=$(echo "$PENDING_FULL" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin).get('source_summary') or '')")
|
|
102
|
+
IS_OWN="False" # default; not critical for retry
|
|
103
|
+
echo "RETRY_MODE: pending_id=$PENDING_ID project=$PROJECT subreddit=$SUBREDDIT" | tee -a "$LOG_FILE"
|
|
104
|
+
else
|
|
105
|
+
RETRY_MODE=0
|
|
106
|
+
# Pick target
|
|
107
|
+
TARGET_JSON=$(/usr/bin/python3 "$REPO_DIR/scripts/pick_thread_target.py" --json 2>&1) || {
|
|
108
|
+
echo "NO_ELIGIBLE_TARGET: every eligible subreddit is inside its floor window. Stopping." | tee -a "$LOG_FILE"
|
|
109
|
+
exit 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
PROJECT=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['project']['name'])")
|
|
113
|
+
SUBREDDIT=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['subreddit'])")
|
|
114
|
+
IS_OWN=$(echo "$TARGET_JSON" | /usr/bin/python3 -c "import sys,json; print(json.load(sys.stdin)['is_own_community'])")
|
|
115
|
+
|
|
116
|
+
echo "Target: project=$PROJECT subreddit=$SUBREDDIT own_community=$IS_OWN" | tee -a "$LOG_FILE"
|
|
117
|
+
fi
|
|
118
|
+
SUB_SLUG=$(echo "$SUBREDDIT" | sed 's|^r/||I')
|
|
119
|
+
|
|
120
|
+
# Posting account (hardcoded for now; the only configured reddit account)
|
|
121
|
+
POST_ACCOUNT=$(/usr/bin/python3 -c "
|
|
122
|
+
import json
|
|
123
|
+
c = json.load(open('$CONFIG_FILE'))
|
|
124
|
+
print(c.get('accounts',{}).get('reddit',{}).get('username','Deep_Ad1959'))
|
|
125
|
+
")
|
|
126
|
+
|
|
127
|
+
# Build full per-project context block (JSON-driven so prompt stays compact)
|
|
128
|
+
export PROJECT_ENV="$PROJECT"
|
|
129
|
+
export CONFIG_PATH="$CONFIG_FILE"
|
|
130
|
+
CONTEXT_BLOCK=$(/usr/bin/python3 <<'PYEOF'
|
|
131
|
+
import json, datetime, os
|
|
132
|
+
CONFIG = os.environ['CONFIG_PATH']
|
|
133
|
+
name = os.environ['PROJECT_ENV']
|
|
134
|
+
c = json.load(open(CONFIG))
|
|
135
|
+
proj = next((p for p in c['projects'] if p['name'] == name), None)
|
|
136
|
+
if not proj:
|
|
137
|
+
print("(project not found)")
|
|
138
|
+
raise SystemExit(0)
|
|
139
|
+
|
|
140
|
+
t = proj.get('threads') or {}
|
|
141
|
+
lp = proj.get('landing_pages') or {}
|
|
142
|
+
|
|
143
|
+
out = []
|
|
144
|
+
out.append(f"Project: {proj['name']}")
|
|
145
|
+
out.append(f"Description: {proj.get('description','').strip()}")
|
|
146
|
+
if proj.get('website'): out.append(f"Website: {proj['website']}")
|
|
147
|
+
if lp.get('base_url'): out.append(f"Base URL: {lp['base_url']}")
|
|
148
|
+
if proj.get('content_angle'):
|
|
149
|
+
out.append(f"\nContent angle: {proj['content_angle']}")
|
|
150
|
+
|
|
151
|
+
voice = proj.get('voice')
|
|
152
|
+
if voice:
|
|
153
|
+
out.append(f"\nVoice tone: {voice.get('tone','')}")
|
|
154
|
+
if voice.get('never'):
|
|
155
|
+
out.append("Voice never: " + "; ".join(voice['never']))
|
|
156
|
+
|
|
157
|
+
# Dynamic day counter
|
|
158
|
+
dc = t.get('dynamic_context') or {}
|
|
159
|
+
day = dc.get('day_counter')
|
|
160
|
+
if day:
|
|
161
|
+
base = day['base_count']
|
|
162
|
+
ref = datetime.date.fromisoformat(day['ref_date'])
|
|
163
|
+
days = (datetime.date.today() - ref).days
|
|
164
|
+
count = base + days
|
|
165
|
+
label = day.get('label','day count')
|
|
166
|
+
out.append(f"\nLive {label}: {count}+")
|
|
167
|
+
for f in dc.get('static_facts') or []:
|
|
168
|
+
out.append(f"- {f}")
|
|
169
|
+
|
|
170
|
+
# Topic angles
|
|
171
|
+
angles = t.get('topic_angles') or []
|
|
172
|
+
if angles:
|
|
173
|
+
out.append("\nTopic angles to choose from:")
|
|
174
|
+
for a in angles:
|
|
175
|
+
out.append(f"- {a}")
|
|
176
|
+
|
|
177
|
+
# Source paths (SEO pipeline pattern)
|
|
178
|
+
out.append("\n## Product source (READ for context before drafting)")
|
|
179
|
+
repo = lp.get('repo','')
|
|
180
|
+
if repo:
|
|
181
|
+
rp = os.path.expanduser(repo)
|
|
182
|
+
status = "" if os.path.isdir(rp) else " [MISSING ON DISK]"
|
|
183
|
+
out.append(f"- Website repo: {rp}{status}")
|
|
184
|
+
for s in lp.get('product_source') or []:
|
|
185
|
+
p = os.path.expanduser(s.get('path',''))
|
|
186
|
+
status = "" if os.path.isdir(p) else " [MISSING]"
|
|
187
|
+
desc = s.get('description','').strip()
|
|
188
|
+
out.append(f"- {p}{status}\n {desc}")
|
|
189
|
+
|
|
190
|
+
# Threads content_sources
|
|
191
|
+
cs = t.get('content_sources') or {}
|
|
192
|
+
if cs.get('guide_dir'):
|
|
193
|
+
gd = os.path.expanduser(cs['guide_dir'])
|
|
194
|
+
out.append(f"\nGuide dir (read page.tsx files here for specific detail): {gd}")
|
|
195
|
+
if cs.get('link_base'):
|
|
196
|
+
out.append(f"Link base for any URL you include: {cs['link_base']}")
|
|
197
|
+
if cs.get('read_instructions'):
|
|
198
|
+
out.append(cs['read_instructions'])
|
|
199
|
+
|
|
200
|
+
print("\n".join(out))
|
|
201
|
+
PYEOF
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
echo "--- Context block ---" | tee -a "$LOG_FILE"
|
|
205
|
+
echo "$CONTEXT_BLOCK" | tee -a "$LOG_FILE"
|
|
206
|
+
echo "---------------------" | tee -a "$LOG_FILE"
|
|
207
|
+
|
|
208
|
+
# HTTP-only lane (2026-06-01): the four prompt-context reads go through the
|
|
209
|
+
# s4l.ai API via scripts/reddit_threads_helper.py. No DATABASE_URL, no psql,
|
|
210
|
+
# no fallback. Each subcommand prints exactly what the psql -t -A call printed
|
|
211
|
+
# (one row per line, `|`-delimited) so the prompt context is byte-identical.
|
|
212
|
+
RT_HELPER="$REPO_DIR/scripts/reddit_threads_helper.py"
|
|
213
|
+
|
|
214
|
+
# Recent posts in THIS sub (avoid repeats - include endings for closer variation)
|
|
215
|
+
RECENT_POSTS_SUB=$(python3 "$RT_HELPER" recent-posts-sub --sub "$SUB_SLUG" --limit 10 2>/dev/null || echo "(api error)")
|
|
216
|
+
|
|
217
|
+
# Recent posts project-wide (cross-sub dedup - include endings)
|
|
218
|
+
RECENT_POSTS_PROJECT=$(python3 "$RT_HELPER" recent-posts-project --project "$PROJECT" --days 14 --limit 15 2>/dev/null || echo "(api error)")
|
|
219
|
+
|
|
220
|
+
# Recent engagement styles for this project on THIS platform (avoid repeating).
|
|
221
|
+
# Scoped to platform='reddit' because cross-platform history conflated tiers —
|
|
222
|
+
# a Moltbook post yesterday was blocking a Reddit style today for no reason.
|
|
223
|
+
RECENT_STYLES=$(python3 "$RT_HELPER" recent-styles --project "$PROJECT" --limit 5 2>/dev/null || echo "(api error)")
|
|
224
|
+
|
|
225
|
+
# Top performers (tone calibration)
|
|
226
|
+
TOP_POSTS=$(python3 "$RT_HELPER" top-posts --project "$PROJECT" --min-score 5 --limit 10 2>/dev/null || echo "(api error)")
|
|
227
|
+
|
|
228
|
+
if [ "$IS_OWN" = "True" ]; then
|
|
229
|
+
CADENCE_NOTE="This is our OWNED subreddit. Daily cadence (1-day floor). Be yourself, no product pitches."
|
|
230
|
+
else
|
|
231
|
+
CADENCE_NOTE="This is an EXTERNAL subreddit (3-day floor). The thread must pass the sub's self-promo bar. No product links unless genuinely relevant (max 1)."
|
|
232
|
+
fi
|
|
233
|
+
|
|
234
|
+
# JSON schema: forces the model to return structured output with all required fields.
|
|
235
|
+
# This is how we enforce step compliance programmatically.
|
|
236
|
+
RESULT_SCHEMA='{"type":"object","properties":{"research_files_read":{"type":"array","items":{"type":"string"},"description":"Absolute paths of source files actually read during research step"},"subreddit_browsed":{"type":"boolean","description":"Whether you navigated to the subreddit hot page and read threads"},"hot_threads_seen":{"type":"array","items":{"type":"string"},"description":"Titles of 3-5 hot threads you read on the subreddit"},"topic_angle":{"type":"string","description":"The topic angle chosen from the list"},"engagement_style":{"type":"string","description":"The engagement style chosen"},"title":{"type":"string","description":"The exact post title submitted"},"body":{"type":"string","description":"The exact post body submitted"},"permalink":{"type":["string","null"],"description":"The Reddit permalink after successful submission, or null if aborted"},"rules_checked":{"type":"boolean","description":"Whether you checked subreddit rules"},"flair_applied":{"type":["string","null"],"description":"Flair text applied, or null if none"},"abort_reason":{"type":["string","null"],"description":"Reason for aborting, or null if posted successfully"},"permanent_block":{"type":"boolean","description":"Set TRUE only if this subreddit will reject EVERY future post from this account: account-banned, link-only sub, mod rule banning our entire category (e.g. all software/website posts), approved-submitters-only, or any standing rule that makes future thread posts impossible. Set FALSE for one-off issues (this specific topic violates a rule, repetition, transient errors). When TRUE, the sub is added to thread_blocked permanently and never picked again. Default FALSE."},"source_summary":{"type":"string","description":"Rich source summary: (a) topic angle and why, (b) source files read, (c) specific details used"}},"required":["research_files_read","subreddit_browsed","hot_threads_seen","topic_angle","engagement_style","title","body","permalink","rules_checked","flair_applied","abort_reason","permanent_block","source_summary"]}'
|
|
237
|
+
|
|
238
|
+
# Pre-generate session id so the prompt's inline INSERT can stamp it.
|
|
239
|
+
export CLAUDE_SESSION_ID=$(uuidgen | tr 'A-Z' 'a-z')
|
|
240
|
+
|
|
241
|
+
# Acquire the browser lock now, immediately before the Claude/MCP step.
|
|
242
|
+
#
|
|
243
|
+
# Lock strategy (changed 2026-05-10): switched from 3600s bash lock held for
|
|
244
|
+
# the entire Claude session to a 90s Python lease lock. The reddit-agent MCP
|
|
245
|
+
# proxy (scripts/mcp_lock_proxy.py) heartbeats expires_at on every browser
|
|
246
|
+
# tool call, so the lease stays held during real activity but auto-decays
|
|
247
|
+
# within 90s of idleness (Claude reasoning gaps, research file reads, etc.).
|
|
248
|
+
# Peer pipelines (run-reddit-search post phase, engage-reddit, dm-replies-reddit,
|
|
249
|
+
# link-edit-reddit) can use the profile during our long Claude thinking phases.
|
|
250
|
+
#
|
|
251
|
+
# Unified lock (2026-05-10): only the Python lease. The bash pre-flight was
|
|
252
|
+
# removed because lock.sh did not honor expires_at and would block on
|
|
253
|
+
# TTL-stale-but-PID-alive holders. Python acquire performs the orphan-Chrome
|
|
254
|
+
# sweep internally (ported from lock.sh:175-198); ensure_browser_healthy then
|
|
255
|
+
# runs under the exclusive Python lease that follows.
|
|
256
|
+
log "Acquiring reddit-browser lease (TTL 90s, MCP-proxy heartbeated)..."
|
|
257
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 600 --ttl 90 2>&1 | tee -a "$LOG_FILE" || \
|
|
258
|
+
log "WARNING: reddit_browser_lock.py acquire failed; proceeding without lease (peer pipelines may collide)."
|
|
259
|
+
if ! ensure_reddit_browser_for_backend 2>&1 | tee -a "$LOG_FILE"; then
|
|
260
|
+
log "WARNING: reddit-harness bootstrap failed; falling back to ensure_browser_healthy reddit"
|
|
261
|
+
ensure_browser_healthy "reddit"
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
# NOTE 2026-05-07: removed broken pre-flight Chrome health check (commit
|
|
265
|
+
# 971844d, 2026-05-04). Intent was to short-circuit before Claude drafted for
|
|
266
|
+
# $4-5 in a session where reddit-agent MCP tools wouldn't load. But the check
|
|
267
|
+
# probed for an already-running Chrome with a CDP port; Chrome only launches
|
|
268
|
+
# INSIDE the Claude session via launch_persistent_context, so the probe always
|
|
269
|
+
# saw "no port" and aborted every fire. 22 launchd fires, 0 posts from
|
|
270
|
+
# 2026-05-04 12:13 through 2026-05-07. Aligned with run-reddit-search.sh and
|
|
271
|
+
# engage-reddit.sh, which call ensure_browser_healthy and then trust the
|
|
272
|
+
# Claude/MCP step to launch Chrome — they kept posting comments fine the whole
|
|
273
|
+
# time. Re-introducing a real "MCP loaded?" probe is Phase 2 (separate cheap
|
|
274
|
+
# claude -p tool-list call before drafting); for now we accept the same $5
|
|
275
|
+
# tail-risk per fire that every other reddit pipeline already accepts.
|
|
276
|
+
|
|
277
|
+
# Capture Claude output to a temp file so a non-zero exit doesn't swallow stderr
|
|
278
|
+
# before we get a chance to log it. Without this, run_claude.sh failures look
|
|
279
|
+
# like "SCRIPT DIED line=283 exit=1" with zero context.
|
|
280
|
+
CLAUDE_TMP=$(mktemp)
|
|
281
|
+
set +e
|
|
282
|
+
if [ "$RETRY_MODE" = "1" ]; then
|
|
283
|
+
"$REPO_DIR/scripts/run_claude.sh" "run-reddit-threads" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" -p --output-format json --json-schema "$RESULT_SCHEMA" "You are RETRYING a previously-aborted thread for the ${PROJECT} project as u/${POST_ACCOUNT}.
|
|
284
|
+
|
|
285
|
+
$BROWSER_INSTRUCTIONS
|
|
286
|
+
|
|
287
|
+
## CRITICAL: This is a RETRY. The title and body are PRE-WRITTEN and FINAL.
|
|
288
|
+
DO NOT redraft. DO NOT research. DO NOT browse the subreddit. DO NOT pick a topic.
|
|
289
|
+
You are ONLY driving the browser through the submit flow with the exact strings below.
|
|
290
|
+
|
|
291
|
+
## Saved draft (USE EXACTLY AS-IS)
|
|
292
|
+
Subreddit: ${SUBREDDIT}
|
|
293
|
+
Title: ${PENDING_TITLE}
|
|
294
|
+
|
|
295
|
+
Body:
|
|
296
|
+
${PENDING_BODY}
|
|
297
|
+
|
|
298
|
+
Topic angle (for log purposes only): ${PENDING_TOPIC}
|
|
299
|
+
Engagement style (for log purposes only): ${PENDING_STYLE}
|
|
300
|
+
Source summary (for log purposes only): ${PENDING_SOURCE}
|
|
301
|
+
Flair target (if known from prior attempt): ${PENDING_FLAIR}
|
|
302
|
+
|
|
303
|
+
## Workflow
|
|
304
|
+
|
|
305
|
+
1. Navigate to https://old.reddit.com/${SUBREDDIT}/submit?selftext=true (bh_run: goto_url + wait_for_load).
|
|
306
|
+
|
|
307
|
+
2. Fill the title and body using the exact saved strings above. Use js() to set the values
|
|
308
|
+
directly (most reliable on old.reddit's md-container wrapper):
|
|
309
|
+
document.querySelector('textarea[name=\"title\"]').value = TITLE;
|
|
310
|
+
document.querySelector('textarea[name=\"title\"]').dispatchEvent(new Event('input',{bubbles:true}));
|
|
311
|
+
document.querySelector('textarea[name=\"text\"]').value = BODY;
|
|
312
|
+
document.querySelector('textarea[name=\"text\"]').dispatchEvent(new Event('input',{bubbles:true}));
|
|
313
|
+
|
|
314
|
+
3. FLAIR HELPER (if flair required, old.reddit verified 2026-05-01 on r/AutoHotkey).
|
|
315
|
+
OLD.REDDIT SELECTORS ARE STALE in the wild: it is NOT '.flairselector-button',
|
|
316
|
+
NOT '.flairoption', and the confirm button is NOT 'Save'. Do not rely on those.
|
|
317
|
+
Use the visible text/structure described below.
|
|
318
|
+
a. Look for a group labeled around 'choose a flair'. Inside it is a button whose
|
|
319
|
+
visible text is exactly 'select' (lowercase). Click that button.
|
|
320
|
+
b. A modal opens. Header is 'select flair'. Body is a <ul> of <li> rows; each <li>
|
|
321
|
+
is a clickable flair option. Click the <li> matching the saved flair_target above
|
|
322
|
+
(or pick a sensible match if blank, e.g. 'Meta / Discussion', 'Question', 'Help').
|
|
323
|
+
c. Confirm by clicking 'apply' (lowercase, NOT 'Save').
|
|
324
|
+
d. Verify the chosen flair name appears next to the title (replacing '(none)').
|
|
325
|
+
If the 'select' button doesn't open a modal, OR no <li> matches, OR 'apply' is
|
|
326
|
+
missing: ABORT with abort_reason='flair_ui_unexpected' or 'no_suitable_flair'.
|
|
327
|
+
Do NOT loop or retry the click more than twice.
|
|
328
|
+
|
|
329
|
+
4. Click the submit button (visible text 'submit', lowercase on old.reddit). Wait 3
|
|
330
|
+
seconds. Capture the permalink (document.location.href after submission). Close the tab.
|
|
331
|
+
|
|
332
|
+
5. Return structured JSON. Use the saved title, body, topic_angle, engagement_style, and
|
|
333
|
+
source_summary as-is for the log fields. Fill permalink with the actual URL if posted,
|
|
334
|
+
or null if aborted. Set rules_checked=true (they were checked in the original attempt).
|
|
335
|
+
Set subreddit_browsed=false and hot_threads_seen=[] (we're not re-doing browse work).
|
|
336
|
+
Set research_files_read=[] (we're reusing prior research). Set permanent_block=false
|
|
337
|
+
unless the submit step itself returned a forbidden/403 error.
|
|
338
|
+
|
|
339
|
+
CRITICAL: NEVER use em dashes.
|
|
340
|
+
CRITICAL: Use ONLY the browser tool described in the BROWSER BACKEND block above (mcp__reddit-harness__bh_run).
|
|
341
|
+
CRITICAL: If mcp__reddit-harness__bh_run is NOT available in this session (you cannot find it as deferred or callable), return JSON immediately with permalink=null and abort_reason='mcp_browser_unavailable'. DO NOT spawn Python, shell, headless or headed Playwright as a fallback. DO NOT use mcp__reddit-agent__*, mcp__playwright-extension__*, or any other browser MCP. The pipeline depends on the reddit-harness backend specifically.
|
|
342
|
+
CRITICAL: Reuse the SAME tab via goto_url for sequential navigation (the harness keeps one real tab); do NOT open a fresh tab per step.
|
|
343
|
+
CRITICAL: If a browser call times out, wait 30s and retry up to 3 times.
|
|
344
|
+
CRITICAL: This is a RETRY of a \$4-24 sunk-cost draft. Do NOT redraft, do NOT research." > "$CLAUDE_TMP" 2>&1
|
|
345
|
+
CLAUDE_RC=$?
|
|
346
|
+
else
|
|
347
|
+
"$REPO_DIR/scripts/run_claude.sh" "run-reddit-threads" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" -p --output-format json --json-schema "$RESULT_SCHEMA" "You are posting an ORIGINAL thread to ${SUBREDDIT} for the ${PROJECT} project as u/${POST_ACCOUNT}.
|
|
348
|
+
|
|
349
|
+
$BROWSER_INSTRUCTIONS
|
|
350
|
+
|
|
351
|
+
## Config & Rules
|
|
352
|
+
Read $SKILL_FILE for content rules and anti-AI-detection checklist.
|
|
353
|
+
You may also open $CONFIG_FILE for the full project block if you need anything not summarized below.
|
|
354
|
+
|
|
355
|
+
## Target
|
|
356
|
+
Project: ${PROJECT}
|
|
357
|
+
Subreddit: ${SUBREDDIT}
|
|
358
|
+
Own community: ${IS_OWN}
|
|
359
|
+
${CADENCE_NOTE}
|
|
360
|
+
|
|
361
|
+
## Project context (live-assembled)
|
|
362
|
+
${CONTEXT_BLOCK}
|
|
363
|
+
|
|
364
|
+
${STYLES_BLOCK}
|
|
365
|
+
|
|
366
|
+
## Recent posts by us in ${SUBREDDIT} (DO NOT repeat topics OR closers)
|
|
367
|
+
Each entry shows: title |ENDING| last 200 chars of post body. Study the endings to vary your closer.
|
|
368
|
+
${RECENT_POSTS_SUB}
|
|
369
|
+
|
|
370
|
+
## Recent posts by us for ${PROJECT} across all subs (last 14d, don't recycle angle OR closing style)
|
|
371
|
+
${RECENT_POSTS_PROJECT}
|
|
372
|
+
|
|
373
|
+
## Recent engagement styles for ${PROJECT} (avoid repeating the same style back-to-back)
|
|
374
|
+
${RECENT_STYLES}
|
|
375
|
+
|
|
376
|
+
## Top performing ${PROJECT} posts (match tone/style)
|
|
377
|
+
${TOP_POSTS}
|
|
378
|
+
|
|
379
|
+
## Workflow
|
|
380
|
+
|
|
381
|
+
1. RESEARCH (required): Read the product source paths listed in the context block. Specifically:
|
|
382
|
+
- README.md at the repo root
|
|
383
|
+
- Any files under src/ or docs/ that relate to your chosen topic angle
|
|
384
|
+
- For Vipassana: read relevant page.tsx under the guide dir
|
|
385
|
+
Pull 1-2 concrete, specific details from the source code or docs to anchor the post. Generic posts get ignored.
|
|
386
|
+
|
|
387
|
+
2. BROWSE THE SUBREDDIT: Navigate to https://old.reddit.com/${SUBREDDIT}/hot (bh_run: goto_url + wait_for_load).
|
|
388
|
+
- Read 3-5 recent thread titles and their top comments to absorb community tone, vocabulary, and what topics are getting engagement right now.
|
|
389
|
+
- Note any recurring themes or hot-button issues the community cares about today.
|
|
390
|
+
This shapes your post to sound like it belongs in the current conversation, not like a scheduled drop.
|
|
391
|
+
|
|
392
|
+
3. Pick a topic from the threads.topic_angles list (in the context block above) that:
|
|
393
|
+
- Has NOT been posted recently in this subreddit (see above)
|
|
394
|
+
- Is not a recycled angle from other subs (see project-wide list)
|
|
395
|
+
- Fits this subreddit's community and rules
|
|
396
|
+
- Invites genuine discussion (end with a question or open thread)
|
|
397
|
+
- Pick an engagement_style from the styles list above that:
|
|
398
|
+
(a) fits the topic and subreddit culture
|
|
399
|
+
(b) is NOT one of the last 3 styles used for this project (see recent styles above)
|
|
400
|
+
|
|
401
|
+
4. Draft the post. RULES:
|
|
402
|
+
- No em dashes anywhere. Commas, periods, or plain '-' only.
|
|
403
|
+
- No markdown formatting (no ##, no **bold**, no bullet lists).
|
|
404
|
+
- 2-4 short paragraphs, casual tone. Narrator voice follows the VOICE RELATIONSHIP block in the styles section.
|
|
405
|
+
- Include at least one imperfection (sentence fragment, aside, lowercase start).
|
|
406
|
+
- Title: lowercase, no clickbait patterns, no emojis.
|
|
407
|
+
- Ground in a specific detail from the product source you read in step 1.
|
|
408
|
+
- Follow the voice guidance from the project context. Read it out loud; if it sounds like a blog post, rewrite.
|
|
409
|
+
- VARY YOUR CLOSERS: check how recent posts ended (shown after |ENDING| above). Use a DIFFERENT ending pattern. Banned closers: 'curious if anyone', 'anyone else', 'thoughts?', 'has anyone'. Sometimes end with a statement, sometimes mid-thought, sometimes a specific (not generic) question.
|
|
410
|
+
- VARY CAPITALIZATION: do NOT lowercase every sentence start. Mix it naturally: some sentences capitalized, some not. Uniform all-lowercase is a known AI tell.
|
|
411
|
+
|
|
412
|
+
5. SUBREDDIT RULES CHECK (bh_run: goto_url to https://old.reddit.com/${SUBREDDIT}/about/rules + wait_for_load)
|
|
413
|
+
- If strict no-self-promo and our post would read promotional, ABORT. Set abort_reason and permalink=null.
|
|
414
|
+
- Note whether flair is required.
|
|
415
|
+
|
|
416
|
+
PERMANENT_BLOCK DECISION (always set this field):
|
|
417
|
+
- permanent_block = TRUE if the sub has a STANDING rule that rejects every post we could ever make from this account: bans all software/website/AI posts (mod-pinned), link-only sub, approved-submitters-only, account is banned from this sub, no-self-promo with zero exceptions for our category. ALSO set TRUE on submit-time forbidden / 403.
|
|
418
|
+
- permanent_block = FALSE if the issue is specific to THIS post (recent topic was already covered, this title is too promotional, you chose to abort to be safe but the sub itself does accept posts of this type, transient browser/network error, repetition concern).
|
|
419
|
+
- When in doubt, FALSE. False positives are cheap (we just retry the sub later); false negatives waste a Claude run cost (\$1.50-3.50 USD) every time we re-pick the same dead-end sub.
|
|
420
|
+
|
|
421
|
+
6. POST (bh_run):
|
|
422
|
+
- Navigate to https://old.reddit.com/${SUBREDDIT}/submit?selftext=true (goto_url + wait_for_load)
|
|
423
|
+
- Fill title and body. Set the values directly via js() (most reliable on the md-container wrapper):
|
|
424
|
+
document.querySelector('textarea[name=\"title\"]').value = TITLE;
|
|
425
|
+
document.querySelector('textarea[name=\"title\"]').dispatchEvent(new Event('input',{bubbles:true}));
|
|
426
|
+
document.querySelector('textarea[name=\"text\"]').value = BODY;
|
|
427
|
+
document.querySelector('textarea[name=\"text\"]').dispatchEvent(new Event('input',{bubbles:true}));
|
|
428
|
+
- FLAIR HELPER (if flair required, old.reddit verified 2026-05-01 on r/AutoHotkey).
|
|
429
|
+
OLD.REDDIT SELECTORS ARE STALE in the wild: it is NOT '.flairselector-button',
|
|
430
|
+
NOT '.flairoption', and the confirm button is NOT 'Save'. Do not rely on those.
|
|
431
|
+
Use the visible text/structure described below.
|
|
432
|
+
a. After the post body is typed, look for a group labeled around 'choose a flair'.
|
|
433
|
+
Inside it is a button whose visible text is exactly 'select' (lowercase).
|
|
434
|
+
Compute its center coords from getBoundingClientRect via js(), then click_at_xy(x, y).
|
|
435
|
+
b. A modal opens. Header is 'select flair'. Body is a <ul> of <li> rows; each <li>
|
|
436
|
+
is a clickable flair option (cursor: pointer). There is no .flairoption class.
|
|
437
|
+
Find the <li> whose text matches the right flair (e.g. 'Meta / Discussion',
|
|
438
|
+
'Question', 'Help', 'Showcase'). Click that <li>.
|
|
439
|
+
c. The confirm button is labeled 'apply' (lowercase). Click 'apply'. Do NOT look
|
|
440
|
+
for 'Save', 'OK', 'Confirm', or 'Submit' inside the modal — they don't exist.
|
|
441
|
+
d. Verify success by re-reading the snapshot: the '(none)' placeholder next to
|
|
442
|
+
the title should be replaced by the chosen flair name (typically rendered green).
|
|
443
|
+
If the 'select' button doesn't open a modal, OR no <li> matches a sensible flair,
|
|
444
|
+
OR the 'apply' button is missing: ABORT with abort_reason='flair_ui_unexpected'
|
|
445
|
+
or 'no_suitable_flair'. Do NOT loop or retry the click more than twice — repeated
|
|
446
|
+
clicks have crashed the chrome MCP child in past runs (2026-05-01 r/AutoHotkey).
|
|
447
|
+
- Click the submit button (visible text 'submit', lowercase on old.reddit).
|
|
448
|
+
Wait 3 seconds. Capture the permalink (document.location.href after submission).
|
|
449
|
+
- Close the tab.
|
|
450
|
+
|
|
451
|
+
7. DO NOT touch the database. The shell wrapper handles the INSERT after you return.
|
|
452
|
+
IMPORTANT: source_summary, title, body, permalink, engagement_style in your
|
|
453
|
+
JSON output ARE what get logged. Make source_summary rich and grounded in
|
|
454
|
+
the specific files/details you read in step 1.
|
|
455
|
+
ABORT-SAFE: if you abort AFTER drafting (e.g. flair UI broke, MCP child died,
|
|
456
|
+
submit button vanished), still return the title + body you drafted in your
|
|
457
|
+
JSON. The shell wrapper will persist them to pending_threads so the next
|
|
458
|
+
pipeline run can retry the post WITHOUT re-paying for research/drafting.
|
|
459
|
+
|
|
460
|
+
8. Return your structured JSON output. Every field in the schema is required. Fill permalink with the actual URL if posted, or null if aborted.
|
|
461
|
+
|
|
462
|
+
CRITICAL: NEVER use em dashes.
|
|
463
|
+
CRITICAL: Use ONLY the browser tool described in the BROWSER BACKEND block above (mcp__reddit-harness__bh_run).
|
|
464
|
+
CRITICAL: If mcp__reddit-harness__bh_run is NOT available in this session (you cannot find it as deferred or callable), return JSON immediately with permalink=null and abort_reason='mcp_browser_unavailable'. DO NOT spawn Python, shell, headless or headed Playwright as a fallback. DO NOT use mcp__reddit-agent__*, mcp__playwright-extension__*, or any other browser MCP. The pipeline depends on the reddit-harness backend specifically. The shell wrapper persists your draft to pending_threads on abort, so a clean ABORT-SAFE return preserves the work.
|
|
465
|
+
CRITICAL: Reuse the SAME tab via goto_url for sequential navigation (the harness keeps one real tab); do NOT open a fresh tab per step.
|
|
466
|
+
CRITICAL: If a browser call times out, wait 30s and retry up to 3 times." > "$CLAUDE_TMP" 2>&1
|
|
467
|
+
CLAUDE_RC=$?
|
|
468
|
+
fi # end RETRY_MODE branch
|
|
469
|
+
set -e
|
|
470
|
+
CLAUDE_OUTPUT=$(cat "$CLAUDE_TMP")
|
|
471
|
+
rm -f "$CLAUDE_TMP"
|
|
472
|
+
|
|
473
|
+
# Belt-and-suspenders: free the reddit-browser lease if it's still held.
|
|
474
|
+
# Idempotent — release prints OK / NOT_HELD / HELD_BY_OTHER. Mirrors
|
|
475
|
+
# link-edit-reddit.sh:185 and engage-dm-replies.sh:1439. If Claude crashed
|
|
476
|
+
# mid-post the lease auto-decays in 90s, but explicit release frees peers
|
|
477
|
+
# immediately so they can use the profile during the rest of this run's
|
|
478
|
+
# DB writes / logging.
|
|
479
|
+
timeout 3 python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
480
|
+
|
|
481
|
+
# Parse structured output and log results
|
|
482
|
+
echo "$CLAUDE_OUTPUT" | tee -a "$LOG_FILE"
|
|
483
|
+
if [ "$CLAUDE_RC" -ne 0 ]; then
|
|
484
|
+
echo "RUN_CLAUDE_NONZERO_EXIT rc=$CLAUDE_RC (output above is full stderr+stdout)" | tee -a "$LOG_FILE"
|
|
485
|
+
fi
|
|
486
|
+
|
|
487
|
+
# Extract structured_output from the JSON envelope.
|
|
488
|
+
# claude -p --output-format json wraps results as: {"structured_output": {...}, "result": "...", ...}
|
|
489
|
+
PARSED=$(/usr/bin/python3 -c "
|
|
490
|
+
import json,sys
|
|
491
|
+
try:
|
|
492
|
+
raw = sys.stdin.read()
|
|
493
|
+
# run_claude.sh appends a log line to stderr but 2>&1 captures it here too,
|
|
494
|
+
# giving us two concatenated JSON objects. raw_decode stops after the first.
|
|
495
|
+
d, _ = json.JSONDecoder().raw_decode(raw)
|
|
496
|
+
so = d.get('structured_output') or d
|
|
497
|
+
print(json.dumps(so))
|
|
498
|
+
except Exception as e:
|
|
499
|
+
print(json.dumps({'_parse_error': str(e)}))
|
|
500
|
+
" <<< "$CLAUDE_OUTPUT" 2>/dev/null)
|
|
501
|
+
|
|
502
|
+
PERMALINK=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('permalink') or 'null')" <<< "$PARSED" 2>/dev/null)
|
|
503
|
+
TITLE=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print(r.get('title',''))" <<< "$PARSED" 2>/dev/null)
|
|
504
|
+
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)
|
|
505
|
+
# Explicit permanent-block signal from the model. Trusted when present;
|
|
506
|
+
# regex fallback in mark_thread_blocked still runs if Claude omits it.
|
|
507
|
+
PERMANENT_BLOCK=$(/usr/bin/python3 -c "import json,sys; r=json.loads(sys.stdin.read()); print('1' if r.get('permanent_block') is True else '0')" <<< "$PARSED" 2>/dev/null)
|
|
508
|
+
|
|
509
|
+
# Log step compliance summary
|
|
510
|
+
/usr/bin/python3 -c "
|
|
511
|
+
import json,sys
|
|
512
|
+
r = json.loads(sys.stdin.read())
|
|
513
|
+
if '_parse_error' in r:
|
|
514
|
+
print(f'Step compliance: PARSE ERROR ({r[\"_parse_error\"]})')
|
|
515
|
+
else:
|
|
516
|
+
files = r.get('research_files_read', [])
|
|
517
|
+
browsed = r.get('subreddit_browsed', False)
|
|
518
|
+
hot = r.get('hot_threads_seen', [])
|
|
519
|
+
rules = r.get('rules_checked', False)
|
|
520
|
+
style = r.get('engagement_style', '?')
|
|
521
|
+
print(f'Step compliance: research={len(files)} files, browsed={browsed}, hot_threads={len(hot)}, rules_checked={rules}, style={style}')
|
|
522
|
+
" <<< "$PARSED" 2>/dev/null | tee -a "$LOG_FILE"
|
|
523
|
+
|
|
524
|
+
if [ "$PERMALINK" != "null" ] && [ "$PERMALINK" != "PARSE_ERROR" ]; then
|
|
525
|
+
echo "POSTED: $PERMALINK | $TITLE" | tee -a "$LOG_FILE"
|
|
526
|
+
|
|
527
|
+
# Authoritative DB INSERT.
|
|
528
|
+
# Historical bug: step 7 of the prompt asked Claude to run psql via Bash to
|
|
529
|
+
# log the post. Claude sometimes did, sometimes didn't (e.g. mk0r run id
|
|
530
|
+
# 21486 on 2026-04-29 was orphaned and had to be backfilled by hand). The
|
|
531
|
+
# shell already has every required value parsed out of structured_output, so
|
|
532
|
+
# do the INSERT here and stop trusting the model with a database step.
|
|
533
|
+
PARSED="$PARSED" \
|
|
534
|
+
CLAUDE_SESSION_ID="$CLAUDE_SESSION_ID" \
|
|
535
|
+
PROJECT_ENV="$PROJECT" \
|
|
536
|
+
POST_ACCOUNT="$POST_ACCOUNT" \
|
|
537
|
+
REPO_DIR="$REPO_DIR" \
|
|
538
|
+
PENDING_ID_ENV="${PENDING_ID:-}" \
|
|
539
|
+
PICKED_STYLE_ENV="${PICKED_STYLE:-}" \
|
|
540
|
+
PICK_MODE_ENV="${PICK_MODE:-}" \
|
|
541
|
+
/usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE" || true
|
|
542
|
+
import json, os, sys
|
|
543
|
+
sys.path.insert(0, os.path.join(os.environ["REPO_DIR"], "scripts"))
|
|
544
|
+
from http_api import api_post
|
|
545
|
+
import pending_threads as pt
|
|
546
|
+
|
|
547
|
+
parsed = json.loads(os.environ.get("PARSED") or "{}")
|
|
548
|
+
permalink = parsed.get("permalink") or ""
|
|
549
|
+
title = parsed.get("title", "")
|
|
550
|
+
body = parsed.get("body", "")
|
|
551
|
+
summary = parsed.get("source_summary", "")
|
|
552
|
+
raw_style = (parsed.get("engagement_style") or "").strip()
|
|
553
|
+
session = os.environ.get("CLAUDE_SESSION_ID") or None
|
|
554
|
+
project = os.environ.get("PROJECT_ENV", "")
|
|
555
|
+
account = os.environ.get("POST_ACCOUNT", "")
|
|
556
|
+
pending_id_str = os.environ.get("PENDING_ID_ENV", "")
|
|
557
|
+
|
|
558
|
+
# 2026-05-25: validate_or_register pass. The picker assignment (PICKED_STYLE +
|
|
559
|
+
# PICK_MODE) is sourced from the shell-level saps_pick_style call near the top
|
|
560
|
+
# of this script. USE mode coerces drift back to the assigned style; INVENT
|
|
561
|
+
# mode registers the new_style block (if the model shipped one) into
|
|
562
|
+
# engagement_styles_registry via /api/v1/engagement-styles/registry. Without
|
|
563
|
+
# this gate, run-reddit-threads.sh was the lone Reddit pipeline writing raw
|
|
564
|
+
# model output straight to posts.engagement_style, which let names like
|
|
565
|
+
# 'sensory_contrast' leak in without ever hitting the registry (caught
|
|
566
|
+
# 2026-05-25 during the engagement-style audit).
|
|
567
|
+
style = raw_style or None
|
|
568
|
+
try:
|
|
569
|
+
from engagement_styles import validate_or_register
|
|
570
|
+
picked_style = (os.environ.get("PICKED_STYLE_ENV") or "").strip() or None
|
|
571
|
+
pick_mode = (os.environ.get("PICK_MODE_ENV") or "").strip() or None
|
|
572
|
+
if raw_style:
|
|
573
|
+
new_style_block = parsed.get("new_style") if isinstance(parsed.get("new_style"), dict) else None
|
|
574
|
+
decision = {
|
|
575
|
+
"engagement_style": raw_style,
|
|
576
|
+
**({"new_style": new_style_block} if new_style_block else {}),
|
|
577
|
+
}
|
|
578
|
+
coerced_style, action = validate_or_register(
|
|
579
|
+
decision,
|
|
580
|
+
source_post={
|
|
581
|
+
"platform": "reddit",
|
|
582
|
+
"post_url": permalink or None,
|
|
583
|
+
"post_id": None,
|
|
584
|
+
"model": None,
|
|
585
|
+
},
|
|
586
|
+
assigned_style=picked_style,
|
|
587
|
+
assigned_mode=pick_mode,
|
|
588
|
+
)
|
|
589
|
+
if action == "coerced" and coerced_style and coerced_style != raw_style:
|
|
590
|
+
print(f"[engagement_style] coerced {raw_style!r} -> {coerced_style!r} "
|
|
591
|
+
f"(assigned={picked_style!r} mode={pick_mode!r})")
|
|
592
|
+
elif action == "registered":
|
|
593
|
+
print(f"[engagement_style] registered new style {coerced_style!r} "
|
|
594
|
+
f"into engagement_styles_registry")
|
|
595
|
+
elif action == "rejected":
|
|
596
|
+
print(f"[engagement_style] rejected {raw_style!r} (assigned={picked_style!r}); "
|
|
597
|
+
f"falling back to assigned")
|
|
598
|
+
coerced_style = picked_style
|
|
599
|
+
style = (coerced_style or picked_style or raw_style) or None
|
|
600
|
+
except Exception as e:
|
|
601
|
+
# Never block a posted thread on registry plumbing. Log and fall back
|
|
602
|
+
# to the raw model output.
|
|
603
|
+
print(f"[engagement_style] validate_or_register raised {e!r}; "
|
|
604
|
+
f"falling back to raw={raw_style!r}")
|
|
605
|
+
style = raw_style or None
|
|
606
|
+
|
|
607
|
+
if not permalink or not title:
|
|
608
|
+
print("[db-insert] SKIP — empty permalink or title in structured_output")
|
|
609
|
+
sys.exit(0)
|
|
610
|
+
|
|
611
|
+
# HTTP-only lane (2026-06-01): the authoritative post log goes through the
|
|
612
|
+
# s4l.ai API (POST /api/v1/posts). No DATABASE_URL, no psql, no db.get_conn().
|
|
613
|
+
# The endpoint dedups on (platform, thread_url) server-side and returns 409 with
|
|
614
|
+
# existing_post_id, which replaces the old SELECT idempotency guard. For an
|
|
615
|
+
# own-thread post, thread_url == our_url == permalink, so the (platform,
|
|
616
|
+
# thread_url) dedup is equivalent to the old (platform, our_url) check.
|
|
617
|
+
resp = api_post("/api/v1/posts", {
|
|
618
|
+
"platform": "reddit",
|
|
619
|
+
"thread_url": permalink,
|
|
620
|
+
"thread_author": account,
|
|
621
|
+
"thread_title": title,
|
|
622
|
+
"our_url": permalink,
|
|
623
|
+
"our_content": body,
|
|
624
|
+
"our_account": account,
|
|
625
|
+
"source_summary": summary,
|
|
626
|
+
"project": project,
|
|
627
|
+
"engagement_style": style,
|
|
628
|
+
"status": "active",
|
|
629
|
+
"claude_session_id": session,
|
|
630
|
+
}, ok_on_conflict=True)
|
|
631
|
+
|
|
632
|
+
if not resp.get("ok", True) and (resp.get("error") or {}).get("code") == "duplicate_thread":
|
|
633
|
+
existing_id = ((resp.get("error") or {}).get("details") or {}).get("existing_post_id")
|
|
634
|
+
print(f"[db-insert] SKIP — post {permalink} already in DB as id={existing_id}")
|
|
635
|
+
sys.exit(0)
|
|
636
|
+
|
|
637
|
+
post_id = (resp.get("data") or {}).get("post", {}).get("id")
|
|
638
|
+
print(f"[db-insert] OK — inserted posts.id={post_id} for {permalink}")
|
|
639
|
+
|
|
640
|
+
# If this run came from a pending_threads retry, mark the pending row posted
|
|
641
|
+
# so it stops being picked up by future retry cycles.
|
|
642
|
+
if pending_id_str:
|
|
643
|
+
try:
|
|
644
|
+
pt.mark_posted(pending_id=int(pending_id_str), post_id=post_id, permalink=permalink)
|
|
645
|
+
print(f"[pending] OK — pending_threads.id={pending_id_str} marked posted")
|
|
646
|
+
except Exception as e:
|
|
647
|
+
print(f"[pending] WARNING — mark_posted failed for id={pending_id_str}: {e}")
|
|
648
|
+
|
|
649
|
+
# Campaign wiring: post-submit edit pattern.
|
|
650
|
+
# Threads can't apply the suffix at submit time (Claude drives the browser
|
|
651
|
+
# directly via MCP), so we load active campaigns AFTER insert, roll the dice,
|
|
652
|
+
# and use reddit_browser.py edit-thread to append the suffix on the live post.
|
|
653
|
+
# Bumps the campaign counter only on a verified live edit (parallels
|
|
654
|
+
# post_reddit / engage_reddit / send_dm semantics).
|
|
655
|
+
import random, subprocess
|
|
656
|
+
from post_reddit import load_active_reddit_campaigns
|
|
657
|
+
|
|
658
|
+
active_campaigns = load_active_reddit_campaigns()
|
|
659
|
+
applied_campaign_ids = []
|
|
660
|
+
new_body = body
|
|
661
|
+
for camp in active_campaigns:
|
|
662
|
+
if random.random() < camp["sample_rate"]:
|
|
663
|
+
new_body = new_body + camp["suffix"]
|
|
664
|
+
applied_campaign_ids.append(camp["id"])
|
|
665
|
+
|
|
666
|
+
if applied_campaign_ids:
|
|
667
|
+
print(f"[campaign-thread] applying {applied_campaign_ids} (suffix to be appended via edit)")
|
|
668
|
+
rb = os.path.join(os.environ["REPO_DIR"], "scripts", "reddit_browser.py")
|
|
669
|
+
edit_proc = subprocess.run(
|
|
670
|
+
["python3", rb, "edit-thread", permalink, new_body],
|
|
671
|
+
capture_output=True, text=True, timeout=120,
|
|
672
|
+
)
|
|
673
|
+
edit_ok = False
|
|
674
|
+
edit_payload = {}
|
|
675
|
+
try:
|
|
676
|
+
# reddit_browser.py edit-thread prints multi-line JSON via
|
|
677
|
+
# json.dumps(result, indent=2). Use raw_decode on the full stdout so
|
|
678
|
+
# we get the whole document, not just the final '}' line.
|
|
679
|
+
stdout_str = (edit_proc.stdout or "").strip()
|
|
680
|
+
edit_payload, _ = json.JSONDecoder().raw_decode(stdout_str)
|
|
681
|
+
edit_ok = edit_payload.get("ok") is True
|
|
682
|
+
except Exception as e:
|
|
683
|
+
edit_payload = {"_parse_error": f"{type(e).__name__}: {e}",
|
|
684
|
+
"_stdout_tail": (edit_proc.stdout or "")[-200:]}
|
|
685
|
+
if edit_ok:
|
|
686
|
+
from http_api import api_patch
|
|
687
|
+
api_patch(f"/api/v1/posts/{post_id}", {"our_content": new_body})
|
|
688
|
+
bump = os.path.join(os.environ["REPO_DIR"], "scripts", "campaign_bump.py")
|
|
689
|
+
for cid in applied_campaign_ids:
|
|
690
|
+
try:
|
|
691
|
+
subprocess.run(
|
|
692
|
+
["python3", bump,
|
|
693
|
+
"--table", "posts", "--id", str(post_id),
|
|
694
|
+
"--campaign-id", str(cid)],
|
|
695
|
+
capture_output=True, text=True, timeout=15,
|
|
696
|
+
)
|
|
697
|
+
except Exception as e:
|
|
698
|
+
print(f"[campaign-thread] WARNING: campaign_bump failed (id={post_id} c={cid}): {e}")
|
|
699
|
+
print(f"[campaign-thread] OK — edit verified={edit_payload.get('verified')}, our_content updated, counters bumped")
|
|
700
|
+
else:
|
|
701
|
+
# Edit failed — leave the post untagged. The post is already live, so
|
|
702
|
+
# this is a degraded but not data-corrupting outcome. campaign_id stays
|
|
703
|
+
# NULL, so the row joins the control bucket for A/B purposes.
|
|
704
|
+
err = edit_payload.get("error") or edit_payload.get("_parse_error") or "unknown"
|
|
705
|
+
print(f"[campaign-thread] WARNING: edit-thread failed ({err}); post stays untagged. stderr={(edit_proc.stderr or '')[-200:]}")
|
|
706
|
+
else:
|
|
707
|
+
print("[campaign-thread] no active campaigns fired (or none active)")
|
|
708
|
+
PYEOF
|
|
709
|
+
|
|
710
|
+
elif [ -n "$ABORT_REASON" ] && [ "$ABORT_REASON" != "PARSE_ERROR" ]; then
|
|
711
|
+
echo "ABORTED: $ABORT_REASON" | tee -a "$LOG_FILE"
|
|
712
|
+
echo "PERMANENT_BLOCK signal from model: $PERMANENT_BLOCK" | tee -a "$LOG_FILE"
|
|
713
|
+
|
|
714
|
+
# Persist the abandoned draft to pending_threads so we don't lose the
|
|
715
|
+
# research/drafting work (the original 2026-05-01 r/AutoHotkey crash burned
|
|
716
|
+
# ~$24 because a fully-drafted post evaporated when the chrome MCP child
|
|
717
|
+
# died at the flair-click step). Skip persistence on permanent_block — that
|
|
718
|
+
# sub will never accept this post anyway.
|
|
719
|
+
if [ "$PERMANENT_BLOCK" != "1" ]; then
|
|
720
|
+
PARSED="$PARSED" \
|
|
721
|
+
CLAUDE_SESSION_ID="$CLAUDE_SESSION_ID" \
|
|
722
|
+
PROJECT_ENV="$PROJECT" \
|
|
723
|
+
SUBREDDIT_ENV="$SUBREDDIT" \
|
|
724
|
+
POST_ACCOUNT="$POST_ACCOUNT" \
|
|
725
|
+
ABORT_REASON_ENV="$ABORT_REASON" \
|
|
726
|
+
PENDING_ID_ENV="${PENDING_ID:-}" \
|
|
727
|
+
RETRY_MODE_ENV="${RETRY_MODE:-0}" \
|
|
728
|
+
REPO_DIR="$REPO_DIR" \
|
|
729
|
+
/usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE" || true
|
|
730
|
+
import json, os, sys
|
|
731
|
+
sys.path.insert(0, os.path.join(os.environ["REPO_DIR"], "scripts"))
|
|
732
|
+
import pending_threads as pt
|
|
733
|
+
|
|
734
|
+
parsed = json.loads(os.environ.get("PARSED") or "{}")
|
|
735
|
+
title = (parsed.get("title") or "").strip()
|
|
736
|
+
body = (parsed.get("body") or "").strip()
|
|
737
|
+
abort_reason = os.environ.get("ABORT_REASON_ENV", "")
|
|
738
|
+
retry_mode = os.environ.get("RETRY_MODE_ENV", "0") == "1"
|
|
739
|
+
existing_pid = os.environ.get("PENDING_ID_ENV") or ""
|
|
740
|
+
|
|
741
|
+
if retry_mode and existing_pid:
|
|
742
|
+
# We were retrying an existing pending row. Bump its attempts; if it just
|
|
743
|
+
# crossed the abandon threshold (3), mark it abandoned so the retry loop
|
|
744
|
+
# stops picking it up.
|
|
745
|
+
pid = int(existing_pid)
|
|
746
|
+
pt.mark_aborted(
|
|
747
|
+
pending_id=pid,
|
|
748
|
+
abort_reason=abort_reason,
|
|
749
|
+
abort_stage="retry_attempt",
|
|
750
|
+
)
|
|
751
|
+
rec = pt.get(pid) or {}
|
|
752
|
+
if (rec.get("attempts") or 0) >= 3:
|
|
753
|
+
pt.abandon(pending_id=pid, reason=f"max_retries_exceeded ({abort_reason})")
|
|
754
|
+
print(f"[pending] ABANDON — id={pid} attempts={rec.get('attempts')} >= 3, no further retries")
|
|
755
|
+
else:
|
|
756
|
+
print(f"[pending] BUMP — id={pid} attempts={rec.get('attempts')} (will retry next cycle)")
|
|
757
|
+
elif not title or not body:
|
|
758
|
+
print("[pending] SKIP — no title/body in structured_output, nothing to persist")
|
|
759
|
+
else:
|
|
760
|
+
# Fresh-draft abort: save it for retry on next cycle.
|
|
761
|
+
pid = pt.create(
|
|
762
|
+
project=os.environ["PROJECT_ENV"],
|
|
763
|
+
subreddit=os.environ["SUBREDDIT_ENV"],
|
|
764
|
+
account=os.environ["POST_ACCOUNT"],
|
|
765
|
+
title=title,
|
|
766
|
+
body=body,
|
|
767
|
+
flair_target=parsed.get("flair_applied"),
|
|
768
|
+
engagement_style=parsed.get("engagement_style"),
|
|
769
|
+
topic_angle=parsed.get("topic_angle"),
|
|
770
|
+
source_summary=parsed.get("source_summary"),
|
|
771
|
+
claude_session_id=os.environ.get("CLAUDE_SESSION_ID") or None,
|
|
772
|
+
)
|
|
773
|
+
pt.mark_aborted(
|
|
774
|
+
pending_id=pid,
|
|
775
|
+
abort_reason=abort_reason,
|
|
776
|
+
abort_stage="post_attempt_1",
|
|
777
|
+
)
|
|
778
|
+
print(f"[pending] OK — saved draft id={pid} for retry on next thread cycle")
|
|
779
|
+
PYEOF
|
|
780
|
+
else
|
|
781
|
+
echo "[pending] SKIP — permanent_block=true, draft not retryable on this sub" | tee -a "$LOG_FILE"
|
|
782
|
+
# If we were retrying a pending row and the sub got permanently blocked,
|
|
783
|
+
# abandon the pending row so it stops blocking the queue.
|
|
784
|
+
if [ "$RETRY_MODE" = "1" ] && [ -n "${PENDING_ID:-}" ]; then
|
|
785
|
+
PENDING_ID_ENV="$PENDING_ID" \
|
|
786
|
+
ABORT_REASON_ENV="$ABORT_REASON" \
|
|
787
|
+
REPO_DIR="$REPO_DIR" \
|
|
788
|
+
/usr/bin/python3 -c "
|
|
789
|
+
import os, sys
|
|
790
|
+
sys.path.insert(0, os.path.join(os.environ['REPO_DIR'], 'scripts'))
|
|
791
|
+
import pending_threads as pt
|
|
792
|
+
pt.abandon(pending_id=int(os.environ['PENDING_ID_ENV']), reason='permanent_block: '+os.environ.get('ABORT_REASON_ENV',''))
|
|
793
|
+
print(f'[pending] ABANDON — id={os.environ[\"PENDING_ID_ENV\"]} due to permanent_block on retry')
|
|
794
|
+
" 2>&1 | tee -a "$LOG_FILE" || true
|
|
795
|
+
fi
|
|
796
|
+
fi
|
|
797
|
+
# Auto-block path:
|
|
798
|
+
# 1. PRIMARY: trust the model's permanent_block boolean from structured_output
|
|
799
|
+
# (added 2026-04-29). If true, add to thread_blocked unconditionally.
|
|
800
|
+
# 2. FALLBACK: regex match against abort_reason via _abort_is_permanent_block.
|
|
801
|
+
# Catches cases where the model forgot the field or is on an old prompt.
|
|
802
|
+
SUB_SLUG_ENV="$SUB_SLUG" \
|
|
803
|
+
ABORT_REASON_ENV="$ABORT_REASON" \
|
|
804
|
+
PERMANENT_BLOCK_ENV="$PERMANENT_BLOCK" \
|
|
805
|
+
PROJECT_NAME_ENV="${PROJECT_ENV:-}" \
|
|
806
|
+
REPO_DIR="$REPO_DIR" \
|
|
807
|
+
/usr/bin/python3 <<'PYEOF' 2>&1 | tee -a "$LOG_FILE" || true
|
|
808
|
+
import os, sys
|
|
809
|
+
sys.path.insert(0, os.path.join(os.environ["REPO_DIR"], "scripts"))
|
|
810
|
+
from post_reddit import mark_thread_blocked, _abort_is_permanent_block
|
|
811
|
+
|
|
812
|
+
sub = os.environ.get("SUB_SLUG_ENV", "")
|
|
813
|
+
reason = os.environ.get("ABORT_REASON_ENV", "")
|
|
814
|
+
project = os.environ.get("PROJECT_NAME_ENV") or None
|
|
815
|
+
explicit = os.environ.get("PERMANENT_BLOCK_ENV", "0") == "1"
|
|
816
|
+
|
|
817
|
+
if explicit:
|
|
818
|
+
# Model declared the block explicitly. Bypass the regex gate via force=True
|
|
819
|
+
# so the audit record still captures the model's verbatim abort_reason
|
|
820
|
+
# even when the text doesn't match _THREAD_BLOCK_PATTERNS.
|
|
821
|
+
mark_thread_blocked(sub, reason or "permanent_block=true", project=project, force=True)
|
|
822
|
+
print(f"[auto-block] r/{sub} added via explicit permanent_block=true from model (project={project!r})")
|
|
823
|
+
elif _abort_is_permanent_block(reason):
|
|
824
|
+
mark_thread_blocked(sub, reason, project=project)
|
|
825
|
+
print(f"[auto-block] r/{sub} added via regex fallback on abort_reason (project={project!r})")
|
|
826
|
+
else:
|
|
827
|
+
print(f"[auto-block] r/{sub} NOT auto-blocked (permanent_block=false, abort reason looks transient)")
|
|
828
|
+
PYEOF
|
|
829
|
+
else
|
|
830
|
+
echo "UNKNOWN OUTCOME (check JSON output above)" | tee -a "$LOG_FILE"
|
|
831
|
+
fi
|
|
832
|
+
|
|
833
|
+
# Surface this run in the dashboard's Job History under "Post Threads · Reddit".
|
|
834
|
+
# Script name `thread_reddit` is what bin/server.js classifyScript() matches.
|
|
835
|
+
ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
836
|
+
if [ "$PERMALINK" != "null" ] && [ "$PERMALINK" != "" ] && [ "$PERMALINK" != "PARSE_ERROR" ]; then
|
|
837
|
+
POSTED_CT=1; SKIPPED_CT=0; FAILED_CT=0
|
|
838
|
+
elif [ -n "$ABORT_REASON" ] && [ "$ABORT_REASON" != "PARSE_ERROR" ]; then
|
|
839
|
+
POSTED_CT=0; SKIPPED_CT=1; FAILED_CT=0
|
|
840
|
+
else
|
|
841
|
+
POSTED_CT=0; SKIPPED_CT=0; FAILED_CT=1
|
|
842
|
+
fi
|
|
843
|
+
_COST=$(/usr/bin/python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-reddit-threads" 2>/dev/null || echo "0.0000")
|
|
844
|
+
/usr/bin/python3 "$REPO_DIR/scripts/log_run.py" --script "thread_reddit" --posted "$POSTED_CT" --skipped "$SKIPPED_CT" --failed "$FAILED_CT" --cost "$_COST" --elapsed "$ELAPSED" || true
|
|
845
|
+
|
|
846
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
847
|
+
find "$LOG_DIR" -name "run-reddit-threads-*.log" -mtime +14 -delete 2>/dev/null || true
|