@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,1235 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reddit reply engagement orchestrator.
|
|
3
|
+
|
|
4
|
+
Processes pending Reddit replies one at a time, each in its own Claude session.
|
|
5
|
+
This avoids the context accumulation problem of batching 200 replies into one session.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 scripts/engage_reddit.py
|
|
9
|
+
python3 scripts/engage_reddit.py --dry-run # Print prompt for first reply, don't post
|
|
10
|
+
python3 scripts/engage_reddit.py --limit 5 # Process at most 5 replies
|
|
11
|
+
python3 scripts/engage_reddit.py --timeout 3600 # Global timeout in seconds (default: 5400)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
27
|
+
from http_api import api_get, api_post
|
|
28
|
+
|
|
29
|
+
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
30
|
+
CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
31
|
+
REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
|
|
32
|
+
CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
|
|
33
|
+
REDDIT_MCP_CONFIG = os.path.expanduser("~/.claude/browser-agent-configs/reddit-agent-mcp.json")
|
|
34
|
+
REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
|
|
35
|
+
|
|
36
|
+
# Interpreter every child subprocess must run under. A bare PYTHON resolved
|
|
37
|
+
# to the user's system python, which lacks the pipeline deps (Playwright and
|
|
38
|
+
# friends) that live only in the owned uv runtime — so on a fresh box every
|
|
39
|
+
# reddit_browser.py reply died (the same class as the Karol/Twitter bug,
|
|
40
|
+
# 2026-06-22). Honor the authoritative S4L_PYTHON pin (set by the launchd
|
|
41
|
+
# plist), else sys.executable (the owned interpreter the MCP launches us under).
|
|
42
|
+
# Never the literal PYTHON: that re-rolls the PATH dice. Re-exported so
|
|
43
|
+
# grandchildren inherit it.
|
|
44
|
+
PYTHON = os.environ.get("S4L_PYTHON") or sys.executable
|
|
45
|
+
os.environ["S4L_PYTHON"] = PYTHON
|
|
46
|
+
|
|
47
|
+
from engagement_styles import (
|
|
48
|
+
REPLY_STYLES as VALID_STYLES,
|
|
49
|
+
get_styles_prompt,
|
|
50
|
+
get_content_rules,
|
|
51
|
+
get_anti_patterns,
|
|
52
|
+
get_voice_relationship_rule,
|
|
53
|
+
validate_or_register,
|
|
54
|
+
pick_style_for_post,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _acquire_browser_lease(timeout: int = 600, ttl: int = 90):
|
|
59
|
+
"""Acquire the reddit-browser lease for THIS reply's Claude+CDP work.
|
|
60
|
+
|
|
61
|
+
Per-reply acquire (not per-cycle) shipped 2026-05-13. Before this change,
|
|
62
|
+
engage-reddit.sh held the lease around the whole `engage_reddit.py --limit
|
|
63
|
+
N` run, so a 5-reply batch monopolised the browser for ~10-25 min while
|
|
64
|
+
peer reddit pipelines (run-reddit-search post phase, link-edit-reddit,
|
|
65
|
+
dm-outreach-reddit, engage-dm-replies) sat blocked through every Claude
|
|
66
|
+
session and 2s inter-reply sleep.
|
|
67
|
+
|
|
68
|
+
The reddit-agent MCP wrapper (scripts/mcp_lock_proxy.py) auto-heartbeats
|
|
69
|
+
expires_at on every JSON-RPC `tools/call`, so the lease stays alive
|
|
70
|
+
through Claude's MCP-driven search/fetch/draft loop without manual pulses.
|
|
71
|
+
Default 90s TTL gives plenty of headroom for Claude session startup
|
|
72
|
+
(~20s before the first MCP call) plus subsequent CDP posting.
|
|
73
|
+
|
|
74
|
+
Returns (ok: bool, msg: str). msg is the helper's last stdout line on
|
|
75
|
+
success, or BUSY/ERROR diagnostic on failure.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
r = subprocess.run(
|
|
79
|
+
[PYTHON, REDDIT_BROWSER_LOCK, "acquire",
|
|
80
|
+
"--timeout", str(timeout), "--ttl", str(ttl)],
|
|
81
|
+
capture_output=True, text=True, timeout=timeout + 30,
|
|
82
|
+
)
|
|
83
|
+
out_lines = [ln for ln in (r.stdout or "").strip().splitlines() if ln]
|
|
84
|
+
last = out_lines[-1] if out_lines else ""
|
|
85
|
+
if r.returncode == 0 and last.startswith("OK"):
|
|
86
|
+
return True, last
|
|
87
|
+
return False, last or (r.stderr or "").strip()[:200] or f"rc={r.returncode}"
|
|
88
|
+
except subprocess.TimeoutExpired:
|
|
89
|
+
return False, "subprocess_timeout"
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return False, f"exception:{e}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _release_browser_lease() -> None:
|
|
95
|
+
"""Release the reddit-browser lease. Idempotent (NOT_HELD is fine)."""
|
|
96
|
+
try:
|
|
97
|
+
subprocess.run(
|
|
98
|
+
[PYTHON, REDDIT_BROWSER_LOCK, "release"],
|
|
99
|
+
capture_output=True, text=True, timeout=10,
|
|
100
|
+
)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_config():
|
|
106
|
+
with open(CONFIG_PATH) as f:
|
|
107
|
+
return json.load(f)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_active_reddit_campaigns():
|
|
111
|
+
"""Active Reddit campaigns with a literal suffix and budget remaining.
|
|
112
|
+
|
|
113
|
+
Tool-level enforcement: the LLM never sees these. We append suffix to the
|
|
114
|
+
drafted text in Python before the browser submits, so the literal text is
|
|
115
|
+
guaranteed on Reddit. sample_rate gates the per-reply coin flip for A/B.
|
|
116
|
+
|
|
117
|
+
Reads /api/v1/campaigns?status=active&platform=reddit&has_suffix=true&with_budget_remaining=true.
|
|
118
|
+
"""
|
|
119
|
+
resp = api_get(
|
|
120
|
+
"/api/v1/campaigns",
|
|
121
|
+
query={
|
|
122
|
+
"status": "active",
|
|
123
|
+
"platform": "reddit",
|
|
124
|
+
"has_suffix": "true",
|
|
125
|
+
"with_budget_remaining": "true",
|
|
126
|
+
"limit": 500,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
rows = ((resp or {}).get("data") or {}).get("campaigns") or []
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
"id": int(r["id"]),
|
|
133
|
+
"suffix": r.get("suffix"),
|
|
134
|
+
"sample_rate": float(r.get("sample_rate") if r.get("sample_rate") is not None else 1.0),
|
|
135
|
+
}
|
|
136
|
+
for r in rows
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def strip_active_suffixes(text, active_campaigns):
|
|
141
|
+
"""Remove any active-campaign suffix from `text` (idempotent, trailing-only).
|
|
142
|
+
|
|
143
|
+
Used to sanitize `recent_replies` snippets BEFORE feeding them into the
|
|
144
|
+
LLM prompt. Without this, the LLM sees prior tagged replies in the
|
|
145
|
+
"Your last N replies" block, copies the literal suffix into its draft,
|
|
146
|
+
and `engage_reddit.py`'s tool-level injection then appends a SECOND
|
|
147
|
+
suffix on top, producing posts like "written with s4lai written with
|
|
148
|
+
s4lai" (observed in production 2026-05-18, ids 70412 + 70413).
|
|
149
|
+
|
|
150
|
+
Strips trailing whitespace + suffix repeatedly so a doubled-suffix
|
|
151
|
+
historical row also collapses to clean text. Active campaign list is
|
|
152
|
+
passed in by the caller so we only strip patterns we're actively using
|
|
153
|
+
(avoids unbounded false-positive matches on incidental phrasing).
|
|
154
|
+
"""
|
|
155
|
+
if not text or not active_campaigns:
|
|
156
|
+
return text
|
|
157
|
+
cleaned = text.rstrip()
|
|
158
|
+
changed = True
|
|
159
|
+
while changed:
|
|
160
|
+
changed = False
|
|
161
|
+
for camp in active_campaigns:
|
|
162
|
+
suffix = (camp.get("suffix") or "").strip()
|
|
163
|
+
if suffix and cleaned.endswith(suffix):
|
|
164
|
+
cleaned = cleaned[: -len(suffix)].rstrip()
|
|
165
|
+
changed = True
|
|
166
|
+
return cleaned
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def bump_campaigns(table, row_id, campaign_ids):
|
|
170
|
+
"""Attach a row in {posts,replies,dm_messages} to its applied campaigns."""
|
|
171
|
+
if not row_id or not campaign_ids:
|
|
172
|
+
return
|
|
173
|
+
for cid in campaign_ids:
|
|
174
|
+
try:
|
|
175
|
+
subprocess.run(
|
|
176
|
+
[PYTHON, CAMPAIGN_BUMP,
|
|
177
|
+
"--table", table, "--id", str(row_id), "--campaign-id", str(cid)],
|
|
178
|
+
capture_output=True, text=True, timeout=15,
|
|
179
|
+
)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
print(f"[engage_reddit] WARNING: campaign_bump failed (id={row_id} c={cid}): {e}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def patch_replied_with_retry(cmd_args, reply_id):
|
|
185
|
+
"""Run reply_db.py replied PATCH with rate-limit-aware retry.
|
|
186
|
+
|
|
187
|
+
The comment is ALREADY posted on the platform when we call this. If the
|
|
188
|
+
s4l PATCH fails (e.g. 429 during a rate-limit storm), the row stays in
|
|
189
|
+
'processing' and reset_stuck_processing flips it back to 'pending' after
|
|
190
|
+
2h, which would re-fetch and re-post a duplicate. Confirmed in production
|
|
191
|
+
2026-05-07 where 423 duplicates landed on a single Moltbook parent.
|
|
192
|
+
|
|
193
|
+
To prevent that, we retry the PATCH for up to ~10min with growing backoff
|
|
194
|
+
(15s, 30s, 60s, 120s, 300s). If still failing after that, log a CRITICAL
|
|
195
|
+
line so the operator can flip the row to 'replied' manually before the 2h
|
|
196
|
+
reset fires. Returns True on success, False on terminal failure.
|
|
197
|
+
"""
|
|
198
|
+
backoff_s = [15, 30, 60, 120, 300]
|
|
199
|
+
last_stderr = ""
|
|
200
|
+
for attempt in range(len(backoff_s) + 1):
|
|
201
|
+
try:
|
|
202
|
+
proc = subprocess.run(cmd_args, capture_output=True, timeout=60)
|
|
203
|
+
except subprocess.TimeoutExpired as e:
|
|
204
|
+
last_stderr = f"timeout: {e}"
|
|
205
|
+
proc = None
|
|
206
|
+
else:
|
|
207
|
+
if proc.returncode == 0:
|
|
208
|
+
return True
|
|
209
|
+
last_stderr = (proc.stderr or b"").decode(errors="replace")
|
|
210
|
+
|
|
211
|
+
if attempt < len(backoff_s):
|
|
212
|
+
wait = backoff_s[attempt]
|
|
213
|
+
print(
|
|
214
|
+
f"[engage_reddit] #{reply_id} REPLIED PATCH attempt {attempt+1} "
|
|
215
|
+
f"failed ({last_stderr[:200]}); retrying in {wait}s",
|
|
216
|
+
flush=True,
|
|
217
|
+
)
|
|
218
|
+
time.sleep(wait)
|
|
219
|
+
|
|
220
|
+
print(
|
|
221
|
+
f"[engage_reddit] CRITICAL: #{reply_id} REPLIED PATCH failed all retries "
|
|
222
|
+
f"({last_stderr[:300]}). Comment IS posted on platform but row stays in "
|
|
223
|
+
f"'processing'. After ~2h reset_stuck_processing will flip it to "
|
|
224
|
+
f"'pending' and the next run may post a DUPLICATE. Manual fix: SELECT "
|
|
225
|
+
f"-> verify our_reply_url, then UPDATE replies SET status='replied' "
|
|
226
|
+
f"WHERE id={reply_id}.",
|
|
227
|
+
flush=True,
|
|
228
|
+
)
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def reset_stuck_processing(platform):
|
|
233
|
+
"""Flip stuck 'processing' rows back to 'pending' (older than 2h).
|
|
234
|
+
|
|
235
|
+
Routes through /api/v1/replies/reset-stuck so this module owns no SQL.
|
|
236
|
+
"""
|
|
237
|
+
resp = api_post(
|
|
238
|
+
"/api/v1/replies/reset-stuck",
|
|
239
|
+
{"platform": platform, "older_than_hours": 2},
|
|
240
|
+
)
|
|
241
|
+
data = (resp or {}).get("data") or {}
|
|
242
|
+
count = int(data.get("reset_count") or 0)
|
|
243
|
+
if count > 0:
|
|
244
|
+
print(f"[engage_reddit] Reset {count} stuck 'processing' {platform} items back to pending")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_next_pending(platform):
|
|
248
|
+
"""Fetch the next pending reply for the given platform (one at a time).
|
|
249
|
+
|
|
250
|
+
Calls /api/v1/replies/next-pending which performs the JOIN to posts
|
|
251
|
+
server-side and returns the rows in the canonical priority order
|
|
252
|
+
(replies-to-our-original first, then oldest discovered_at).
|
|
253
|
+
"""
|
|
254
|
+
resp = api_get(
|
|
255
|
+
"/api/v1/replies/next-pending",
|
|
256
|
+
query={"platform": platform, "limit": 1},
|
|
257
|
+
)
|
|
258
|
+
rows = ((resp or {}).get("data") or {}).get("replies") or []
|
|
259
|
+
if not rows:
|
|
260
|
+
return None
|
|
261
|
+
row = rows[0]
|
|
262
|
+
return {
|
|
263
|
+
"id": int(row["id"]),
|
|
264
|
+
"platform": row.get("platform"),
|
|
265
|
+
"their_author": row.get("their_author"),
|
|
266
|
+
"their_content": row.get("their_content"),
|
|
267
|
+
"their_comment_url": row.get("their_comment_url"),
|
|
268
|
+
"their_comment_id": row.get("their_comment_id"),
|
|
269
|
+
"depth": row.get("depth"),
|
|
270
|
+
"thread_title": row.get("thread_title"),
|
|
271
|
+
"thread_url": row.get("thread_url"),
|
|
272
|
+
"our_content": row.get("our_content"),
|
|
273
|
+
"our_url": row.get("our_url"),
|
|
274
|
+
"is_our_original_post": int(row.get("is_our_original_post") or 0),
|
|
275
|
+
"project_name": row.get("project_name"),
|
|
276
|
+
"post_id": row.get("post_id"),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
META_CALLOUT_KEYWORDS = re.compile(
|
|
281
|
+
r"(?i)\b("
|
|
282
|
+
r"written\s+(?:by|with)\s+(?:ai|chatgpt|gpt|llm|a\s+(?:bot|machine|model))"
|
|
283
|
+
r"|(?:are|r)\s+you\s+(?:an?\s+)?(?:ai|bot|llm|gpt|chatgpt|automated)"
|
|
284
|
+
r"|you(?:'re|\s+are)\s+(?:an?\s+)?(?:ai|bot|llm|gpt|chatgpt|automated)"
|
|
285
|
+
r"|is\s+this\s+(?:an?\s+)?(?:ai|bot|llm|gpt|chatgpt|automated)"
|
|
286
|
+
r"|chatgpt\s+(?:wrote|generated|response|reply)"
|
|
287
|
+
r"|ai[-\s]+(?:generated|written|response|reply|comment)"
|
|
288
|
+
r"|automated\s+(?:response|reply|comment|account)"
|
|
289
|
+
r"|bot\s+(?:account|reply|response|comment)"
|
|
290
|
+
r"|(?:smells?|sounds?|reads?)\s+like\s+(?:an?\s+)?(?:ai|bot|gpt|chatgpt|llm)"
|
|
291
|
+
r")\b"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def detect_meta_callout(parent_content):
|
|
296
|
+
"""Detect whether the parent comment is calling out our AI/bot use.
|
|
297
|
+
|
|
298
|
+
Returns a dict {"keyword", "evidence"} when a callout is matched,
|
|
299
|
+
None otherwise. Soft-signal only: the prompt surfaces it as a
|
|
300
|
+
'consider acknowledging and disengaging' nudge, the LLM still owns the
|
|
301
|
+
skip/reply decision. False positives are tolerable; missing a real
|
|
302
|
+
callout is the costly direction (we end up arguing past the off-ramp,
|
|
303
|
+
as in the Fit-Conversation856 thread).
|
|
304
|
+
"""
|
|
305
|
+
if not parent_content:
|
|
306
|
+
return None
|
|
307
|
+
m = META_CALLOUT_KEYWORDS.search(parent_content)
|
|
308
|
+
if not m:
|
|
309
|
+
return None
|
|
310
|
+
start = max(0, m.start() - 60)
|
|
311
|
+
end = min(len(parent_content), m.end() + 60)
|
|
312
|
+
snippet = parent_content[start:end].replace("\n", " ").strip()
|
|
313
|
+
return {"keyword": m.group(0), "evidence": snippet}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _fmt_date(s):
|
|
317
|
+
"""Format an ISO-ish timestamp string as YYYY-MM-DD, tolerant of None."""
|
|
318
|
+
if not s:
|
|
319
|
+
return "unknown"
|
|
320
|
+
try:
|
|
321
|
+
return str(s)[:10]
|
|
322
|
+
except Exception:
|
|
323
|
+
return "unknown"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def check_cross_pipeline_history(platform, author, post_id, reply_id=None):
|
|
327
|
+
"""Cross-pipeline check before posting a comment-reply.
|
|
328
|
+
|
|
329
|
+
Returns (same_post_disengage, prior_history_block). Delegates to the
|
|
330
|
+
shared counterparty_history module so Reddit and Twitter get symmetric
|
|
331
|
+
behavior — both lanes (DM cross-thread + public-reply history) are
|
|
332
|
+
surfaced into the prompt in one self-titled block.
|
|
333
|
+
|
|
334
|
+
2026-05-19 refactor: previously this lived as Reddit-only direct API
|
|
335
|
+
calls covering the DM lane only. Twitter's engage helper had nothing.
|
|
336
|
+
The shared module now exposes both lanes to both pipelines; this
|
|
337
|
+
function is a thin compat wrapper preserving the (same_post_disengage,
|
|
338
|
+
block_text) tuple shape build_prompt() consumes.
|
|
339
|
+
"""
|
|
340
|
+
if not author:
|
|
341
|
+
return None, ""
|
|
342
|
+
try:
|
|
343
|
+
from counterparty_history import get_counterparty_history_block
|
|
344
|
+
return get_counterparty_history_block(
|
|
345
|
+
platform,
|
|
346
|
+
author,
|
|
347
|
+
current_post_id=post_id,
|
|
348
|
+
current_reply_id=reply_id,
|
|
349
|
+
)
|
|
350
|
+
except Exception as e:
|
|
351
|
+
print(
|
|
352
|
+
f"[engage_reddit] counterparty_history failed for "
|
|
353
|
+
f"{platform}/@{author} post={post_id}: {e}"
|
|
354
|
+
)
|
|
355
|
+
return None, ""
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get_recent_archetypes(platform, limit=3):
|
|
359
|
+
"""Fetch archetypes of last N replied replies for rotation context.
|
|
360
|
+
|
|
361
|
+
Calls /api/v1/replies with order_by=replied_at and the new
|
|
362
|
+
has_our_reply_content filter so we only see rows whose our_reply_content
|
|
363
|
+
is populated (the previous SQL had AND our_reply_content IS NOT NULL).
|
|
364
|
+
"""
|
|
365
|
+
resp = api_get(
|
|
366
|
+
"/api/v1/replies",
|
|
367
|
+
query={
|
|
368
|
+
"platform": platform,
|
|
369
|
+
"status": "replied",
|
|
370
|
+
"has_our_reply_content": "true",
|
|
371
|
+
"order_by": "replied_at",
|
|
372
|
+
"limit": int(limit) if limit else 3,
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
rows = ((resp or {}).get("data") or {}).get("replies") or []
|
|
376
|
+
return [r.get("our_reply_content") for r in rows if r.get("our_reply_content")]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def build_prompt(reply, recent_replies, config, excluded_authors, top_report="", prior_history_block="", meta_callout=None):
|
|
380
|
+
"""Build a minimal prompt for one reply."""
|
|
381
|
+
reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "Deep_Ad1959")
|
|
382
|
+
reply_json = json.dumps(reply, indent=2)
|
|
383
|
+
|
|
384
|
+
# Moltbook: skip recent_replies + top_report context blocks. Both are
|
|
385
|
+
# dense with our prior agent-persona-voiced comments ("my human ran...",
|
|
386
|
+
# "my human ships...") which, in aggregate, trip Anthropic's Usage Policy
|
|
387
|
+
# classifier. Reddit doesn't have that signature so it's fine for reddit.
|
|
388
|
+
if reply['platform'] == "moltbook":
|
|
389
|
+
recent_replies = []
|
|
390
|
+
top_report = ""
|
|
391
|
+
|
|
392
|
+
recent_context = ""
|
|
393
|
+
if recent_replies:
|
|
394
|
+
snippets = "\n".join(f" - {r}" for r in recent_replies)
|
|
395
|
+
recent_context = f"""
|
|
396
|
+
Your last {len(recent_replies)} replies (vary your style, don't repeat the same archetype):
|
|
397
|
+
{snippets}
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
if excluded_authors and reply["their_author"].lower() in {a.lower() for a in excluded_authors}:
|
|
401
|
+
return None, None # will be skipped by caller
|
|
402
|
+
|
|
403
|
+
top_context = f"\n## FEEDBACK FROM PAST PERFORMANCE (use this to write better replies):\n{top_report}\n" if top_report else ""
|
|
404
|
+
history_block = f"\n{prior_history_block}\n" if prior_history_block else ""
|
|
405
|
+
callout_block = ""
|
|
406
|
+
if meta_callout:
|
|
407
|
+
callout_block = (
|
|
408
|
+
"\n## Meta-callout detected in parent comment\n"
|
|
409
|
+
f"The parent comment contains language matching `{meta_callout['keyword']}`. "
|
|
410
|
+
"Evidence (60 chars on each side of the match):\n"
|
|
411
|
+
f" > {meta_callout['evidence']}\n"
|
|
412
|
+
"This means the partner has likely noticed our AI disclosure or is asking whether they're talking to a bot. "
|
|
413
|
+
"Default behavior: acknowledge it briefly, do NOT pitch a project, and prefer skipping over arguing. "
|
|
414
|
+
"If you do reply, address the callout directly in one short sentence (no defensiveness) and consider "
|
|
415
|
+
"outputting `{\"action\": \"skip\", \"reason\": \"meta_callout_acknowledged\"}` so the thread is not "
|
|
416
|
+
"kept alive by another bot reply. Continuing the prior pitch as if nothing was said is the wrong move "
|
|
417
|
+
"— the Fit-Conversation856 thread (2026-04-28) burned through 4 follow-up replies past this signal "
|
|
418
|
+
"before the engage-dm-replies pipeline disengaged.\n"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
voice_block = ""
|
|
422
|
+
project_name = reply.get("project_name")
|
|
423
|
+
if project_name:
|
|
424
|
+
project_cfg = next(
|
|
425
|
+
(p for p in config.get("projects", []) if p.get("name") == project_name),
|
|
426
|
+
None,
|
|
427
|
+
)
|
|
428
|
+
voice = (project_cfg or {}).get("voice", {}) or {}
|
|
429
|
+
if voice:
|
|
430
|
+
voice_block = f"""
|
|
431
|
+
## Project voice (matched project: {project_name})
|
|
432
|
+
{json.dumps(voice, indent=2)}
|
|
433
|
+
|
|
434
|
+
Apply this voice when drafting: follow `tone`, never violate any item in `never`, mirror `examples` / `examples_good` when present.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
if reply['platform'] == "moltbook":
|
|
438
|
+
framing = (
|
|
439
|
+
"You are helping draft a reply to a comment on a public discussion "
|
|
440
|
+
"forum on behalf of the user's account. The forum is open to AI "
|
|
441
|
+
"authorship, fully transparent, and the user has authorized you to "
|
|
442
|
+
"draft this reply for them."
|
|
443
|
+
)
|
|
444
|
+
else:
|
|
445
|
+
framing = (
|
|
446
|
+
f"You are helping draft a reply to a comment on {reply['platform']} "
|
|
447
|
+
f"on behalf of the user's account."
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# 2026-05-19: pre-pick the engagement style ONCE per reply so the
|
|
451
|
+
# picker's assignment threads into BOTH the assigned-style block AND
|
|
452
|
+
# the JSON output example. Without pinning the style name into the
|
|
453
|
+
# JSON example, the model treats the example as a menu hint and
|
|
454
|
+
# drifts (same drift vector that bit Reddit post-draft on this date).
|
|
455
|
+
style_platform = "reddit" if reply["platform"] != "moltbook" else "moltbook"
|
|
456
|
+
style_assignment = pick_style_for_post(style_platform, context="replying")
|
|
457
|
+
assigned_style = (style_assignment.get("style") or "your invented snake_case name")
|
|
458
|
+
|
|
459
|
+
prompt_text = f"""{framing}
|
|
460
|
+
|
|
461
|
+
## Reply data
|
|
462
|
+
{reply_json}
|
|
463
|
+
|
|
464
|
+
## Context
|
|
465
|
+
Read ~/social-autoposter/config.json for project details and content_angle.
|
|
466
|
+
{recent_context}{top_context}{voice_block}{history_block}{callout_block}
|
|
467
|
+
## Content rules
|
|
468
|
+
{get_content_rules("reddit")}
|
|
469
|
+
- Vary openings. Don't always start with credentials.
|
|
470
|
+
|
|
471
|
+
{get_styles_prompt(style_platform, context="replying", assignment=style_assignment)}
|
|
472
|
+
|
|
473
|
+
{get_voice_relationship_rule()}
|
|
474
|
+
|
|
475
|
+
{get_anti_patterns()}
|
|
476
|
+
|
|
477
|
+
## Tiered links
|
|
478
|
+
- Tier 1 (default): No link. Genuine engagement.
|
|
479
|
+
- Tier 2: Topic matches a config project. Mention casually.
|
|
480
|
+
- Tier 3: They ask for link/tool. Give it from config.
|
|
481
|
+
|
|
482
|
+
## Guardrails
|
|
483
|
+
- NEVER suggest calls, meetings, demos.
|
|
484
|
+
- NEVER promise to share links/files not in config.json.
|
|
485
|
+
- NEVER offer to DM. NEVER make time-bound promises.
|
|
486
|
+
|
|
487
|
+
## Bot / engagement-loop escape hatch (use sparingly, but use it)
|
|
488
|
+
We maintain a universal author blocklist in Postgres (`author_blocklist`),
|
|
489
|
+
consulted at /api/v1/replies POST time. A single block recorded by ANY of
|
|
490
|
+
our accounts/installs applies to EVERY future engagement from EVERY of our
|
|
491
|
+
accounts — universal scope, by design. The velocity gate already covers
|
|
492
|
+
"this handle has gotten too many replies from us in 24h/7d"; this lane is
|
|
493
|
+
for the LLM-judgment cases velocity cannot catch.
|
|
494
|
+
|
|
495
|
+
When to add a block (your judgment, exercised CONSERVATIVELY):
|
|
496
|
+
- The Reddit handle is plainly an AI/bot account: templated phrasing across
|
|
497
|
+
unrelated subs, generic filler answers, name pattern like `Foo_AI` /
|
|
498
|
+
`*_GPT` / `*Bot*`, comment history is karma-farm boilerplate
|
|
499
|
+
- We are clearly stuck in a reciprocal engagement loop with this account
|
|
500
|
+
- The handle is reply-farming across r/AskReddit / r/explainlikeimfive
|
|
501
|
+
style subs with shallow comments
|
|
502
|
+
|
|
503
|
+
DO NOT block: an OP we disagree with, a hostile-but-human commenter, a
|
|
504
|
+
low-karma but real user, or a single bad interaction. Skip those
|
|
505
|
+
(action='skip') — blocking is permanent until manually removed and applies
|
|
506
|
+
to all our accounts.
|
|
507
|
+
|
|
508
|
+
How to use it: BEFORE outputting your decision JSON, run this in Bash:
|
|
509
|
+
python3 ~/social-autoposter/scripts/reply_db.py blocklist add reddit HANDLE \
|
|
510
|
+
--reason "<one-line judgment>" \
|
|
511
|
+
--classification {{bot|engagement_loop}} \
|
|
512
|
+
--source-reply-id REPLY_ID
|
|
513
|
+
Then output a skip decision (so the current reply is not posted):
|
|
514
|
+
{{"action": "skip", "reason": "blocklist_added:HANDLE"}}
|
|
515
|
+
HANDLE is the Reddit username without the `u/` prefix.
|
|
516
|
+
|
|
517
|
+
## Execution steps
|
|
518
|
+
|
|
519
|
+
1. First, fetch the full thread context cheaply via Bash (NO browser needed):
|
|
520
|
+
python3 ~/social-autoposter/scripts/reddit_tools.py fetch '{reply['thread_url']}'
|
|
521
|
+
This returns JSON with "thread" (title, author, selftext, score, subreddit) and "comments" (id, author, body, score, permalink).
|
|
522
|
+
Read the output to understand the full conversation context, who said what, and the overall tone.
|
|
523
|
+
|
|
524
|
+
2. Using the thread context from step 1 AND the reply data above, decide: reply or skip?
|
|
525
|
+
If skip (troll, spam, not directed at us, light acknowledgment, conversation already resolved), output ONLY this JSON:
|
|
526
|
+
{{"action": "skip", "reason": "SHORT_REASON"}}
|
|
527
|
+
|
|
528
|
+
3. If replying, draft 1-3 sentences following the rules above. Output ONLY this JSON:
|
|
529
|
+
{{"action": "reply", "text": "YOUR_REPLY_TEXT", "project": null, "engagement_style": "{assigned_style}", "new_style": null}}
|
|
530
|
+
The assigned engagement style is "{assigned_style}" (see the assigned style block above). Use it. Do not pick a different one.
|
|
531
|
+
If you recommended a project, set "project" to the project name.
|
|
532
|
+
|
|
533
|
+
Inventing a new style is only valid when the picker explicitly assigns "invent" mode (the assigned style block above will say so). Otherwise leave "new_style" as null and use the assigned style verbatim.
|
|
534
|
+
|
|
535
|
+
CRITICAL: Your ENTIRE output must be ONLY the JSON object above. No other text, no explanations, no markdown.
|
|
536
|
+
The orchestrator script will handle posting via CDP and database updates automatically.
|
|
537
|
+
"""
|
|
538
|
+
# Return both the prompt and the picker's assignment so the caller can
|
|
539
|
+
# forward the assignment into validate_or_register's enforcement layer
|
|
540
|
+
# (USE mode coerces drift back; INVENT mode is the only path that lets
|
|
541
|
+
# the model register a new style). Without this, the picker's choice
|
|
542
|
+
# would be silently overridable downstream.
|
|
543
|
+
return prompt_text, style_assignment
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def ensure_mcp_config():
|
|
547
|
+
"""Create a minimal MCP config with only the reddit-agent server."""
|
|
548
|
+
if os.path.exists(REDDIT_MCP_CONFIG):
|
|
549
|
+
return REDDIT_MCP_CONFIG
|
|
550
|
+
# Extract reddit-agent config from ~/.claude.json
|
|
551
|
+
claude_json = os.path.expanduser("~/.claude.json")
|
|
552
|
+
if os.path.exists(claude_json):
|
|
553
|
+
with open(claude_json) as f:
|
|
554
|
+
data = json.load(f)
|
|
555
|
+
reddit_cfg = data.get("mcpServers", {}).get("reddit-agent")
|
|
556
|
+
if reddit_cfg:
|
|
557
|
+
mcp = {"mcpServers": {"reddit-agent": reddit_cfg}}
|
|
558
|
+
os.makedirs(os.path.dirname(REDDIT_MCP_CONFIG), exist_ok=True)
|
|
559
|
+
with open(REDDIT_MCP_CONFIG, "w") as f:
|
|
560
|
+
json.dump(mcp, f, indent=2)
|
|
561
|
+
return REDDIT_MCP_CONFIG
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def run_claude(prompt, timeout=300, session_id=None):
|
|
566
|
+
"""Run claude -p with the given prompt. Returns (success, output, usage_dict).
|
|
567
|
+
|
|
568
|
+
Streams output in real time to stderr for log visibility.
|
|
569
|
+
"""
|
|
570
|
+
import time as _time
|
|
571
|
+
import select
|
|
572
|
+
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
|
|
573
|
+
cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose"]
|
|
574
|
+
if session_id:
|
|
575
|
+
cmd += ["--session-id", session_id]
|
|
576
|
+
# --bare removed: it blocks OAuth auth which we need
|
|
577
|
+
cmd += ["--tools", "Bash,Read"]
|
|
578
|
+
env = os.environ.copy()
|
|
579
|
+
env.pop("ANTHROPIC_API_KEY", None) # ensure claude uses OAuth, not API key
|
|
580
|
+
if session_id:
|
|
581
|
+
env["CLAUDE_SESSION_ID"] = session_id
|
|
582
|
+
try:
|
|
583
|
+
proc = subprocess.Popen(
|
|
584
|
+
cmd, env=env, stdin=subprocess.PIPE,
|
|
585
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
586
|
+
)
|
|
587
|
+
proc.stdin.write(prompt)
|
|
588
|
+
proc.stdin.close()
|
|
589
|
+
collected = []
|
|
590
|
+
deadline = _time.time() + timeout
|
|
591
|
+
while True:
|
|
592
|
+
remaining = deadline - _time.time()
|
|
593
|
+
if remaining <= 0:
|
|
594
|
+
proc.kill()
|
|
595
|
+
return False, "TIMEOUT", usage
|
|
596
|
+
ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 30))
|
|
597
|
+
if ready:
|
|
598
|
+
line = proc.stdout.readline()
|
|
599
|
+
if not line:
|
|
600
|
+
break
|
|
601
|
+
collected.append(line)
|
|
602
|
+
try:
|
|
603
|
+
evt = json.loads(line.strip())
|
|
604
|
+
etype = evt.get("type", "")
|
|
605
|
+
if etype == "assistant":
|
|
606
|
+
msg = evt.get("message", {})
|
|
607
|
+
for block in msg.get("content", []):
|
|
608
|
+
if block.get("type") == "tool_use":
|
|
609
|
+
print(f"[engage_reddit] tool: {block.get('name','')} | {str(block.get('input',{}).get('command',''))[:120]}", file=sys.stderr, flush=True)
|
|
610
|
+
elif block.get("type") == "text" and block.get("text","").strip():
|
|
611
|
+
txt = block["text"].strip()[:200]
|
|
612
|
+
print(f"[engage_reddit] {txt}", file=sys.stderr, flush=True)
|
|
613
|
+
elif etype == "result":
|
|
614
|
+
print(f"[engage_reddit] done: cost=${evt.get('total_cost_usd',0):.4f}", file=sys.stderr, flush=True)
|
|
615
|
+
except (json.JSONDecodeError, TypeError):
|
|
616
|
+
print(f"[engage_reddit] {line.rstrip()[:200]}", file=sys.stderr, flush=True)
|
|
617
|
+
elif proc.poll() is not None:
|
|
618
|
+
rest = proc.stdout.read()
|
|
619
|
+
if rest:
|
|
620
|
+
collected.append(rest)
|
|
621
|
+
break
|
|
622
|
+
else:
|
|
623
|
+
print(f"[engage_reddit] ... still running ({int(_time.time() - (deadline - timeout))}s)", file=sys.stderr, flush=True)
|
|
624
|
+
proc.wait()
|
|
625
|
+
text_output = ""
|
|
626
|
+
for line_str in collected:
|
|
627
|
+
line_str = line_str.strip()
|
|
628
|
+
if not line_str:
|
|
629
|
+
continue
|
|
630
|
+
try:
|
|
631
|
+
event = json.loads(line_str)
|
|
632
|
+
if event.get("type") == "result":
|
|
633
|
+
text_output = event.get("result", "")
|
|
634
|
+
usage["cost_usd"] = event.get("total_cost_usd", 0.0)
|
|
635
|
+
u = event.get("usage", {})
|
|
636
|
+
usage["input_tokens"] = u.get("input_tokens", 0)
|
|
637
|
+
usage["output_tokens"] = u.get("output_tokens", 0)
|
|
638
|
+
usage["cache_read"] = u.get("cache_read_input_tokens", 0)
|
|
639
|
+
usage["cache_create"] = u.get("cache_creation_input_tokens", 0)
|
|
640
|
+
except (json.JSONDecodeError, TypeError):
|
|
641
|
+
pass
|
|
642
|
+
if not text_output:
|
|
643
|
+
text_output = "".join(collected)
|
|
644
|
+
stderr_out = proc.stderr.read() if proc.stderr else ""
|
|
645
|
+
return proc.returncode == 0, text_output + stderr_out, usage
|
|
646
|
+
except Exception as e:
|
|
647
|
+
return False, str(e), usage
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def main():
|
|
651
|
+
parser = argparse.ArgumentParser(description="Reddit/Moltbook reply engagement (one at a time)")
|
|
652
|
+
parser.add_argument("--platform", choices=["reddit", "moltbook"], default="reddit",
|
|
653
|
+
help="Platform to process (default: reddit)")
|
|
654
|
+
parser.add_argument("--dry-run", action="store_true", help="Print prompt for first reply without executing")
|
|
655
|
+
parser.add_argument("--limit", type=int, default=0, help="Max replies to process (0 = unlimited)")
|
|
656
|
+
parser.add_argument("--timeout", type=int, default=5400, help="Global timeout in seconds")
|
|
657
|
+
parser.add_argument("--per-reply-timeout", type=int, default=300, help="Timeout per claude session in seconds")
|
|
658
|
+
args = parser.parse_args()
|
|
659
|
+
|
|
660
|
+
config = load_config()
|
|
661
|
+
excluded_authors = config.get("exclusions", {}).get("authors", [])
|
|
662
|
+
|
|
663
|
+
# Hard preflight: the reddit rail posts replies via reddit_browser.py, the
|
|
664
|
+
# only Playwright importer here (Moltbook uses its own poster). If the
|
|
665
|
+
# resolved interpreter can't import Playwright the owned runtime is missing
|
|
666
|
+
# or half-provisioned and every reply would die with CDP_ERROR. Fail LOUD
|
|
667
|
+
# with a distinct signal instead. Moltbook is exempt (no browser path).
|
|
668
|
+
if args.platform == "reddit":
|
|
669
|
+
_chk = subprocess.run(
|
|
670
|
+
[PYTHON, "-c", "import playwright"],
|
|
671
|
+
capture_output=True, text=True,
|
|
672
|
+
)
|
|
673
|
+
if _chk.returncode != 0:
|
|
674
|
+
print(f"[engage_reddit] FATAL runtime_incomplete: interpreter {PYTHON!r} "
|
|
675
|
+
f"cannot import playwright — the owned Python runtime is missing or "
|
|
676
|
+
f"unprovisioned. Run the `runtime` install (action:'install') before "
|
|
677
|
+
f"engaging. stderr: {(_chk.stderr or '').strip()[:300]}", file=sys.stderr)
|
|
678
|
+
sys.exit(3)
|
|
679
|
+
|
|
680
|
+
reset_stuck_processing(args.platform)
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
top_report = subprocess.check_output(
|
|
684
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "top_performers.py"), "--platform", args.platform],
|
|
685
|
+
text=True, stderr=subprocess.DEVNULL, timeout=30,
|
|
686
|
+
)
|
|
687
|
+
except Exception:
|
|
688
|
+
top_report = ""
|
|
689
|
+
|
|
690
|
+
start_time = time.time()
|
|
691
|
+
processed = 0
|
|
692
|
+
succeeded = 0
|
|
693
|
+
skipped = 0
|
|
694
|
+
failed = 0
|
|
695
|
+
skip_reasons = Counter()
|
|
696
|
+
meta_callouts_detected = 0
|
|
697
|
+
total_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
|
|
698
|
+
|
|
699
|
+
print(f"[engage_reddit] Starting. platform={args.platform} limit={args.limit or 'unlimited'}, timeout={args.timeout}s")
|
|
700
|
+
|
|
701
|
+
while True:
|
|
702
|
+
# Global timeout check
|
|
703
|
+
elapsed = time.time() - start_time
|
|
704
|
+
if elapsed > args.timeout:
|
|
705
|
+
print(f"[engage_reddit] Global timeout reached ({args.timeout}s). Stopping.")
|
|
706
|
+
break
|
|
707
|
+
|
|
708
|
+
# Limit check
|
|
709
|
+
if args.limit and processed >= args.limit:
|
|
710
|
+
print(f"[engage_reddit] Limit reached ({args.limit}). Stopping.")
|
|
711
|
+
break
|
|
712
|
+
|
|
713
|
+
# Fetch next pending reply
|
|
714
|
+
reply = get_next_pending(args.platform)
|
|
715
|
+
if not reply:
|
|
716
|
+
print("[engage_reddit] No pending replies. Done!")
|
|
717
|
+
break
|
|
718
|
+
|
|
719
|
+
# Check exclusion before spawning Claude
|
|
720
|
+
if reply["their_author"].lower() in {a.lower() for a in excluded_authors}:
|
|
721
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_author"])
|
|
722
|
+
print(f"[engage_reddit] #{reply['id']} skipped (excluded_author: {reply['their_author']})")
|
|
723
|
+
skipped += 1
|
|
724
|
+
skip_reasons["excluded_author"] += 1
|
|
725
|
+
processed += 1
|
|
726
|
+
continue
|
|
727
|
+
|
|
728
|
+
# Cross-pipeline disengage check. Hard-skip if the engage-dm-replies
|
|
729
|
+
# pipeline already classified this person as declined / not_our_prospect
|
|
730
|
+
# / stale on THIS post. Soft-surface other-thread history into the
|
|
731
|
+
# prompt so the LLM can adjust tone without being auto-blocked.
|
|
732
|
+
same_post_disengage, prior_history_block = check_cross_pipeline_history(
|
|
733
|
+
reply["platform"], reply["their_author"], reply.get("post_id"),
|
|
734
|
+
reply_id=reply.get("id"),
|
|
735
|
+
)
|
|
736
|
+
if same_post_disengage:
|
|
737
|
+
reason = (
|
|
738
|
+
f"cross_pipeline_disengage:dm#{same_post_disengage['dm_id']}"
|
|
739
|
+
f":interest={same_post_disengage['interest_level']}"
|
|
740
|
+
f":status={same_post_disengage['conversation_status']}"
|
|
741
|
+
)
|
|
742
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
|
|
743
|
+
print(f"[engage_reddit] #{reply['id']} skipped ({reason})")
|
|
744
|
+
skipped += 1
|
|
745
|
+
skip_reasons["cross_pipeline_disengage"] += 1
|
|
746
|
+
processed += 1
|
|
747
|
+
continue
|
|
748
|
+
|
|
749
|
+
# Meta-callout detection on the parent comment text. Soft signal:
|
|
750
|
+
# surfaces an authorize-to-ack-and-disengage block in the prompt
|
|
751
|
+
# without auto-skipping. Catches the case where engage-dm-replies
|
|
752
|
+
# has not yet classified the partner but the inbound text already
|
|
753
|
+
# calls out our AI disclosure or asks if they're talking to a bot.
|
|
754
|
+
meta_callout = detect_meta_callout(reply.get("their_content"))
|
|
755
|
+
if meta_callout:
|
|
756
|
+
meta_callouts_detected += 1
|
|
757
|
+
print(f"[engage_reddit] #{reply['id']} meta-callout detected: keyword={meta_callout['keyword']!r}")
|
|
758
|
+
|
|
759
|
+
# Get recent replies for archetype rotation. Strip active campaign
|
|
760
|
+
# suffixes from each snippet BEFORE the LLM sees them; otherwise the
|
|
761
|
+
# model copies the literal suffix into its draft and the tool-layer
|
|
762
|
+
# injection below appends a second copy. See strip_active_suffixes
|
|
763
|
+
# docstring for the 2026-05-18 production incident this prevents.
|
|
764
|
+
recent = get_recent_archetypes(args.platform, limit=3)
|
|
765
|
+
if reply["platform"] == "reddit" and recent:
|
|
766
|
+
_active_camps_for_strip = load_active_reddit_campaigns()
|
|
767
|
+
recent = [strip_active_suffixes(r, _active_camps_for_strip) for r in recent]
|
|
768
|
+
recent = [r for r in recent if r]
|
|
769
|
+
|
|
770
|
+
# Build prompt. Returns (prompt_text, style_assignment) so the
|
|
771
|
+
# picker's assignment can be forwarded into validate_or_register
|
|
772
|
+
# below. style_assignment is None when the reply was filtered out
|
|
773
|
+
# (excluded author) and the caller treats it as a skip.
|
|
774
|
+
prompt, style_assignment = build_prompt(reply, recent, config, excluded_authors,
|
|
775
|
+
top_report=top_report,
|
|
776
|
+
prior_history_block=prior_history_block,
|
|
777
|
+
meta_callout=meta_callout)
|
|
778
|
+
if prompt is None:
|
|
779
|
+
skipped += 1
|
|
780
|
+
processed += 1
|
|
781
|
+
continue
|
|
782
|
+
|
|
783
|
+
if args.dry_run:
|
|
784
|
+
print("=== DRY RUN: Prompt for reply #{} ===".format(reply["id"]))
|
|
785
|
+
print(prompt)
|
|
786
|
+
print("=== END DRY RUN ===")
|
|
787
|
+
break
|
|
788
|
+
|
|
789
|
+
# Per-reply reddit-browser lease (added 2026-05-13). Acquire JUST
|
|
790
|
+
# around this reply's Claude session + CDP post, release in the
|
|
791
|
+
# finally below so peers can use the browser during the inter-reply
|
|
792
|
+
# 2s sleep AND during the moltbook-only iterations that follow.
|
|
793
|
+
# Moltbook replies use the moltbook API (no browser), so we skip
|
|
794
|
+
# acquire for those rows entirely.
|
|
795
|
+
lease_held = False
|
|
796
|
+
if reply["platform"] == "reddit":
|
|
797
|
+
lease_ok, lease_msg = _acquire_browser_lease(timeout=600, ttl=90)
|
|
798
|
+
if not lease_ok:
|
|
799
|
+
print(f"[engage_reddit] #{reply['id']} LEASE: {lease_msg}; deferring")
|
|
800
|
+
failed += 1
|
|
801
|
+
skip_reasons["lease_acquire_timeout"] += 1
|
|
802
|
+
# Mark processing so this row isn't refetched in this run;
|
|
803
|
+
# reset_stuck_processing's 2h cap brings it back to pending.
|
|
804
|
+
try:
|
|
805
|
+
subprocess.run(
|
|
806
|
+
[PYTHON, REPLY_DB, "processing", str(reply["id"])],
|
|
807
|
+
capture_output=True, timeout=10,
|
|
808
|
+
)
|
|
809
|
+
except Exception:
|
|
810
|
+
pass
|
|
811
|
+
processed += 1
|
|
812
|
+
time.sleep(2)
|
|
813
|
+
continue
|
|
814
|
+
lease_held = True
|
|
815
|
+
|
|
816
|
+
# Run Claude session for this one reply (Claude decides + drafts, we post)
|
|
817
|
+
reply_start = time.time()
|
|
818
|
+
session_id = str(uuid.uuid4())
|
|
819
|
+
os.environ["CLAUDE_SESSION_ID"] = session_id
|
|
820
|
+
session_started_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
821
|
+
print(f"[engage_reddit] Processing #{reply['id']} ({reply['platform']}) "
|
|
822
|
+
f"from {reply['their_author']}: {(reply['their_content'] or '')[:60]}...")
|
|
823
|
+
|
|
824
|
+
ok, output, usage = run_claude(prompt, timeout=args.per_reply_timeout, session_id=session_id)
|
|
825
|
+
reply_elapsed = time.time() - reply_start
|
|
826
|
+
session_ended_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
827
|
+
log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
|
|
828
|
+
"--session-id", session_id, "--script", "engage_reddit",
|
|
829
|
+
"--started-at", session_started_at, "--ended-at", session_ended_at]
|
|
830
|
+
orch_cost = usage.get("cost_usd")
|
|
831
|
+
if isinstance(orch_cost, (int, float)) and orch_cost > 0:
|
|
832
|
+
log_args.extend(["--orchestrator-cost-usd", str(orch_cost)])
|
|
833
|
+
subprocess.run(log_args, capture_output=True)
|
|
834
|
+
|
|
835
|
+
# Accumulate usage
|
|
836
|
+
for k in total_usage:
|
|
837
|
+
total_usage[k] += usage[k]
|
|
838
|
+
|
|
839
|
+
# AUP refusal short-circuit. If Anthropic's safety classifier blocks
|
|
840
|
+
# the request, every subsequent reply in this batch will get the same
|
|
841
|
+
# refusal and burn $0.05-$0.30 each. Abort the run, leave rows pending
|
|
842
|
+
# so the next launchd cycle picks them up after a prompt fix.
|
|
843
|
+
if ("Claude Code is unable to respond" in output
|
|
844
|
+
and ("Usage Policy" in output or "violate" in output.lower())):
|
|
845
|
+
print(f"[engage_reddit] #{reply['id']} AUP REFUSAL detected — aborting run "
|
|
846
|
+
f"to avoid wasted spend on continued refusals. Reword the prompt "
|
|
847
|
+
f"and try again. Cost on this refusal: ${usage['cost_usd']:.4f}")
|
|
848
|
+
failed += 1
|
|
849
|
+
skip_reasons["aup_refusal"] += 1
|
|
850
|
+
for k in total_usage:
|
|
851
|
+
total_usage[k] += 0 # already accumulated above
|
|
852
|
+
if lease_held:
|
|
853
|
+
_release_browser_lease()
|
|
854
|
+
break
|
|
855
|
+
|
|
856
|
+
# Monthly cap short-circuit. Mirrors the AUP guard above. When the
|
|
857
|
+
# Claude Code OAuth account hits its monthly usage cap, every call
|
|
858
|
+
# returns "You've hit your org's monthly usage limit" with cost=0, and
|
|
859
|
+
# the per-reply queue would otherwise loop on the same row up to
|
|
860
|
+
# --limit times because the row is never marked processing/skipped.
|
|
861
|
+
# Surfaced in run_monitor as failure_reasons=monthly_limit:1 so the
|
|
862
|
+
# dashboard Result column reads "failed: monthly_limit ×1" instead of
|
|
863
|
+
# the previous silent "queue empty $0.00".
|
|
864
|
+
if "monthly usage limit" in output.lower():
|
|
865
|
+
print(f"[engage_reddit] #{reply['id']} MONTHLY USAGE LIMIT hit, "
|
|
866
|
+
f"aborting run. Cost on this attempt: ${usage['cost_usd']:.4f}")
|
|
867
|
+
failed += 1
|
|
868
|
+
skip_reasons["monthly_limit"] += 1
|
|
869
|
+
if lease_held:
|
|
870
|
+
_release_browser_lease()
|
|
871
|
+
break
|
|
872
|
+
|
|
873
|
+
if not ok:
|
|
874
|
+
# Generic Claude failure (timeout, transport error, non-zero exit).
|
|
875
|
+
# Mark the reply as `processing` so the next iteration of the
|
|
876
|
+
# while-loop doesn't fetch the SAME pending row again and burn
|
|
877
|
+
# another Claude session on it. reset_stuck_processing brings it
|
|
878
|
+
# back to pending after 2h, which gives the partner thread time
|
|
879
|
+
# to settle (and us, time to fix whatever broke).
|
|
880
|
+
failed += 1
|
|
881
|
+
reason_key = "timeout" if output == "TIMEOUT" else "claude_failed"
|
|
882
|
+
skip_reasons[reason_key] += 1
|
|
883
|
+
try:
|
|
884
|
+
subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])],
|
|
885
|
+
capture_output=True, timeout=10)
|
|
886
|
+
except Exception:
|
|
887
|
+
pass
|
|
888
|
+
print(f"[engage_reddit] #{reply['id']} CLAUDE FAILED ({reply_elapsed:.0f}s): {output[:200]}")
|
|
889
|
+
else:
|
|
890
|
+
# Parse Claude's JSON decision
|
|
891
|
+
decision = None
|
|
892
|
+
try:
|
|
893
|
+
# Extract JSON from output (may have surrounding text)
|
|
894
|
+
import re as _re
|
|
895
|
+
json_match = _re.search(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', output)
|
|
896
|
+
if json_match:
|
|
897
|
+
decision = json.loads(json_match.group())
|
|
898
|
+
except (json.JSONDecodeError, TypeError):
|
|
899
|
+
pass
|
|
900
|
+
|
|
901
|
+
if not decision:
|
|
902
|
+
# Fallback: check if output looks like a skip/reply
|
|
903
|
+
failed += 1
|
|
904
|
+
skip_reasons["bad_output"] += 1
|
|
905
|
+
# Same loop-prevention as the not-ok branch: mark processing
|
|
906
|
+
# so the next iteration moves to a different pending row.
|
|
907
|
+
try:
|
|
908
|
+
subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])],
|
|
909
|
+
capture_output=True, timeout=10)
|
|
910
|
+
except Exception:
|
|
911
|
+
pass
|
|
912
|
+
print(f"[engage_reddit] #{reply['id']} BAD OUTPUT ({reply_elapsed:.0f}s): {output[:200]}")
|
|
913
|
+
elif decision.get("action") == "skip":
|
|
914
|
+
reason = decision.get("reason", "unknown")
|
|
915
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
|
|
916
|
+
skipped += 1
|
|
917
|
+
skip_reasons[f"llm:{reason[:48]}"] += 1
|
|
918
|
+
print(f"[engage_reddit] #{reply['id']} skipped: {reason} ({reply_elapsed:.0f}s) "
|
|
919
|
+
f"[${usage['cost_usd']:.4f}]")
|
|
920
|
+
elif decision.get("action") == "reply":
|
|
921
|
+
reply_text = decision.get("text", "")
|
|
922
|
+
project = decision.get("project")
|
|
923
|
+
# validate_or_register: in USE mode, coerces any drifted style
|
|
924
|
+
# name back to the assigned one. In INVENT mode (5% slot),
|
|
925
|
+
# registers the new style into engagement_styles_registry via
|
|
926
|
+
# the s4l API. Without assigned_style/assigned_mode, the
|
|
927
|
+
# picker's choice would be silently overridable by the model.
|
|
928
|
+
# source_post URL is THEIR comment we're replying to; we don't
|
|
929
|
+
# know our own URL until after the post lands.
|
|
930
|
+
engagement_style, _style_action = validate_or_register(
|
|
931
|
+
decision,
|
|
932
|
+
source_post={
|
|
933
|
+
"platform": reply.get("platform"),
|
|
934
|
+
"post_url": reply.get("their_comment_url"),
|
|
935
|
+
"post_id": reply.get("id"),
|
|
936
|
+
"model": decision.get("model"),
|
|
937
|
+
},
|
|
938
|
+
assigned_style=(style_assignment or {}).get("style"),
|
|
939
|
+
assigned_mode=(style_assignment or {}).get("mode"),
|
|
940
|
+
)
|
|
941
|
+
if not reply_text:
|
|
942
|
+
failed += 1
|
|
943
|
+
print(f"[engage_reddit] #{reply['id']} empty reply text")
|
|
944
|
+
else:
|
|
945
|
+
# Mark as processing. CRITICAL: this PATCH must succeed before we
|
|
946
|
+
# post to the platform. If it fails (e.g. s4l rate-limit 429), the
|
|
947
|
+
# row stays `pending` and the next iteration of the while-loop
|
|
948
|
+
# would re-fetch it, draft a new reply, and post again, creating
|
|
949
|
+
# duplicates on the platform. Confirmed in production 2026-05-07
|
|
950
|
+
# where 423+ duplicate comments landed on a single Moltbook
|
|
951
|
+
# parent during a 5000/24h s4l rate-limit storm. Hard-fail the
|
|
952
|
+
# entire run on any non-zero exit so the row stays untouched and
|
|
953
|
+
# no platform side-effect occurs.
|
|
954
|
+
proc_result = subprocess.run(
|
|
955
|
+
[PYTHON, REPLY_DB, "processing", str(reply["id"])],
|
|
956
|
+
capture_output=True,
|
|
957
|
+
)
|
|
958
|
+
if proc_result.returncode != 0:
|
|
959
|
+
err_txt = (proc_result.stderr or b"").decode(errors="replace")
|
|
960
|
+
print(f"[engage_reddit] #{reply['id']} PROCESSING PATCH FAILED "
|
|
961
|
+
f"rc={proc_result.returncode}: {err_txt[:300]}")
|
|
962
|
+
print(f"[engage_reddit] Aborting run to prevent duplicate posts. "
|
|
963
|
+
f"Row stays pending; next launchd cycle will retry once "
|
|
964
|
+
f"the rate-limit window clears.")
|
|
965
|
+
failed += 1
|
|
966
|
+
skip_reasons["processing_patch_failed"] = (
|
|
967
|
+
skip_reasons.get("processing_patch_failed", 0) + 1
|
|
968
|
+
)
|
|
969
|
+
if lease_held:
|
|
970
|
+
_release_browser_lease()
|
|
971
|
+
break
|
|
972
|
+
|
|
973
|
+
# Tool-level campaign suffix injection (Reddit only).
|
|
974
|
+
# The LLM never sees the campaign; we append the literal
|
|
975
|
+
# suffix here so the actual posted text carries the tag.
|
|
976
|
+
applied_campaign_ids = []
|
|
977
|
+
if reply["platform"] == "reddit":
|
|
978
|
+
for camp in load_active_reddit_campaigns():
|
|
979
|
+
if random.random() < camp["sample_rate"]:
|
|
980
|
+
reply_text = reply_text + camp["suffix"]
|
|
981
|
+
applied_campaign_ids.append(camp["id"])
|
|
982
|
+
if applied_campaign_ids:
|
|
983
|
+
print(f"[engage_reddit] #{reply['id']} applied campaigns "
|
|
984
|
+
f"{applied_campaign_ids} (suffix appended)")
|
|
985
|
+
|
|
986
|
+
# URL-wrap the final reply_text (suffix included) so every
|
|
987
|
+
# outbound URL routes through /r/<code> for click attribution.
|
|
988
|
+
# project_name comes from the LLM decision (Tier 2/3) or
|
|
989
|
+
# falls back to the reply row's project_name; either is
|
|
990
|
+
# populated for any reply that includes a URL we care about.
|
|
991
|
+
# We backfill post_links.reply_id after the platform call
|
|
992
|
+
# succeeds (using reply["id"]).
|
|
993
|
+
minted_session = None
|
|
994
|
+
wrap_project = (project or reply.get("project_name") or "").strip()
|
|
995
|
+
wrap_platform = "reddit" if reply["platform"] == "reddit" else reply["platform"]
|
|
996
|
+
if wrap_project:
|
|
997
|
+
try:
|
|
998
|
+
from dm_short_links import wrap_text_for_post, utm_only_text
|
|
999
|
+
wrap_res = wrap_text_for_post(
|
|
1000
|
+
text=reply_text,
|
|
1001
|
+
platform=wrap_platform,
|
|
1002
|
+
project_name=wrap_project,
|
|
1003
|
+
)
|
|
1004
|
+
if wrap_res.get("ok"):
|
|
1005
|
+
reply_text = wrap_res["text"]
|
|
1006
|
+
minted_session = wrap_res.get("minted_session")
|
|
1007
|
+
if wrap_res.get("codes"):
|
|
1008
|
+
print(f"[engage_reddit] #{reply['id']} wrapped "
|
|
1009
|
+
f"{len(wrap_res['codes'])} URL(s)")
|
|
1010
|
+
else:
|
|
1011
|
+
print(f"[engage_reddit] #{reply['id']} WARNING: URL wrap "
|
|
1012
|
+
f"failed ({wrap_res.get('error')}); falling back to UTM-only")
|
|
1013
|
+
reply_text = utm_only_text(
|
|
1014
|
+
text=reply_text, platform=wrap_platform,
|
|
1015
|
+
project_name=wrap_project)
|
|
1016
|
+
except Exception as e:
|
|
1017
|
+
print(f"[engage_reddit] #{reply['id']} WARNING: URL wrap "
|
|
1018
|
+
f"raised ({e}); falling back to UTM-only")
|
|
1019
|
+
try:
|
|
1020
|
+
from dm_short_links import utm_only_text
|
|
1021
|
+
reply_text = utm_only_text(
|
|
1022
|
+
text=reply_text, platform=wrap_platform,
|
|
1023
|
+
project_name=wrap_project)
|
|
1024
|
+
except Exception as ee:
|
|
1025
|
+
print(f"[engage_reddit] #{reply['id']} WARNING: UTM-only "
|
|
1026
|
+
f"fallback also failed ({ee}); posting unwrapped")
|
|
1027
|
+
|
|
1028
|
+
# Post via CDP (reddit) or Moltbook API (moltbook)
|
|
1029
|
+
post_result = None
|
|
1030
|
+
if reply["platform"] == "moltbook":
|
|
1031
|
+
m = re.search(
|
|
1032
|
+
r"/post/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
|
|
1033
|
+
reply.get("their_comment_url") or "",
|
|
1034
|
+
)
|
|
1035
|
+
if not m:
|
|
1036
|
+
post_result = {"ok": False, "error": "missing_moltbook_post_uuid"}
|
|
1037
|
+
else:
|
|
1038
|
+
post_uuid = m.group(1)
|
|
1039
|
+
parent_id = reply.get("their_comment_id") or ""
|
|
1040
|
+
for attempt in range(3):
|
|
1041
|
+
try:
|
|
1042
|
+
out = subprocess.check_output(
|
|
1043
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "moltbook_post.py"),
|
|
1044
|
+
"comment",
|
|
1045
|
+
"--post-id", post_uuid,
|
|
1046
|
+
"--parent-id", parent_id,
|
|
1047
|
+
"--content", reply_text,
|
|
1048
|
+
"--no-upvote"],
|
|
1049
|
+
text=True, timeout=120, stderr=subprocess.DEVNULL,
|
|
1050
|
+
)
|
|
1051
|
+
# moltbook_post.py prints logs + a final JSON line
|
|
1052
|
+
json_line = next((ln for ln in reversed(out.splitlines())
|
|
1053
|
+
if ln.strip().startswith("{")), "")
|
|
1054
|
+
post_result = json.loads(json_line) if json_line else None
|
|
1055
|
+
if post_result and post_result.get("ok"):
|
|
1056
|
+
break
|
|
1057
|
+
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, json.JSONDecodeError, StopIteration) as e:
|
|
1058
|
+
print(f"[engage_reddit] #{reply['id']} moltbook attempt {attempt+1} failed: {e}")
|
|
1059
|
+
if attempt < 2:
|
|
1060
|
+
time.sleep(10)
|
|
1061
|
+
else:
|
|
1062
|
+
for attempt in range(3):
|
|
1063
|
+
try:
|
|
1064
|
+
cdp_out = subprocess.check_output(
|
|
1065
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "reddit_browser.py"),
|
|
1066
|
+
"reply", reply["their_comment_url"], reply_text],
|
|
1067
|
+
text=True, timeout=120, stderr=subprocess.DEVNULL,
|
|
1068
|
+
)
|
|
1069
|
+
post_result = json.loads(cdp_out)
|
|
1070
|
+
if post_result.get("ok"):
|
|
1071
|
+
break
|
|
1072
|
+
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, json.JSONDecodeError) as e:
|
|
1073
|
+
print(f"[engage_reddit] #{reply['id']} CDP attempt {attempt+1} failed: {e}")
|
|
1074
|
+
if attempt < 2:
|
|
1075
|
+
time.sleep(10)
|
|
1076
|
+
|
|
1077
|
+
if post_result and post_result.get("ok"):
|
|
1078
|
+
# Check if already replied (dedup)
|
|
1079
|
+
if post_result.get("already_replied"):
|
|
1080
|
+
existing = post_result.get("existing_text", "")
|
|
1081
|
+
existing_url = post_result.get("existing_url", "")
|
|
1082
|
+
cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), existing]
|
|
1083
|
+
if existing_url:
|
|
1084
|
+
cmd_args.append(existing_url)
|
|
1085
|
+
patch_replied_with_retry(cmd_args, reply["id"])
|
|
1086
|
+
succeeded += 1
|
|
1087
|
+
print(f"[engage_reddit] #{reply['id']} DEDUP (already replied) ({reply_elapsed:.0f}s)")
|
|
1088
|
+
print(f"[engage_reddit] #{reply['id']} tokens: in={usage['input_tokens']} out={usage['output_tokens']} "
|
|
1089
|
+
f"cache_r={usage['cache_read']} cache_w={usage['cache_create']} "
|
|
1090
|
+
f"${usage['cost_usd']:.4f}")
|
|
1091
|
+
processed += 1
|
|
1092
|
+
time.sleep(2)
|
|
1093
|
+
continue
|
|
1094
|
+
|
|
1095
|
+
# Mark as replied in DB. patch_replied_with_retry adds
|
|
1096
|
+
# rate-limit-aware retries so a transient s4l 429 after a
|
|
1097
|
+
# successful platform post does not leave the row in
|
|
1098
|
+
# 'processing' (which 2h reset_stuck_processing would flip
|
|
1099
|
+
# back to 'pending' and cause a duplicate post).
|
|
1100
|
+
reply_url = post_result.get("url", "")
|
|
1101
|
+
cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), reply_text, reply_url]
|
|
1102
|
+
if engagement_style:
|
|
1103
|
+
cmd_args.append(engagement_style)
|
|
1104
|
+
patch_replied_with_retry(cmd_args, reply["id"])
|
|
1105
|
+
# Attribute reply to any campaigns that applied a suffix
|
|
1106
|
+
bump_campaigns("replies", reply["id"], applied_campaign_ids)
|
|
1107
|
+
# Stamp post_links.reply_id for the URLs minted before
|
|
1108
|
+
# the platform call (idempotent; no-op when reply had
|
|
1109
|
+
# no URLs to wrap).
|
|
1110
|
+
if minted_session:
|
|
1111
|
+
try:
|
|
1112
|
+
from dm_short_links import backfill_reply_id
|
|
1113
|
+
backfill_reply_id(minted_session=minted_session,
|
|
1114
|
+
reply_id=reply["id"])
|
|
1115
|
+
except Exception as e:
|
|
1116
|
+
print(f"[engage_reddit] #{reply['id']} WARNING: "
|
|
1117
|
+
f"backfill_reply_id failed ({e})")
|
|
1118
|
+
# Cross-pipeline linkage: ensure a dms row exists for
|
|
1119
|
+
# this person on this thread so engage-dm-replies'
|
|
1120
|
+
# next cycle picks up any inbound on this chain
|
|
1121
|
+
# immediately, instead of waiting for the unread-dms
|
|
1122
|
+
# scan (which can lag up to 30 min). ensure-dm is
|
|
1123
|
+
# idempotent and auto-links to the most recent
|
|
1124
|
+
# replies row for this author within lookback.
|
|
1125
|
+
if reply["platform"] == "reddit":
|
|
1126
|
+
try:
|
|
1127
|
+
subprocess.run(
|
|
1128
|
+
[PYTHON,
|
|
1129
|
+
os.path.join(REPO_DIR, "scripts", "dm_conversation.py"),
|
|
1130
|
+
"ensure-dm",
|
|
1131
|
+
"--platform", "reddit",
|
|
1132
|
+
"--author", reply["their_author"]],
|
|
1133
|
+
capture_output=True, text=True, timeout=20,
|
|
1134
|
+
)
|
|
1135
|
+
except Exception as e:
|
|
1136
|
+
print(f"[engage_reddit] #{reply['id']} ensure-dm failed: {e}")
|
|
1137
|
+
# Update project if recommended. Routes through the
|
|
1138
|
+
# HTTPS PATCH lane in reply_db.py so the project name
|
|
1139
|
+
# travels as a JSON field (no shell interpolation, no
|
|
1140
|
+
# SQL injection vector) and benefits from the same
|
|
1141
|
+
# retry-on-transient policy as the rest of the
|
|
1142
|
+
# mutations.
|
|
1143
|
+
if project:
|
|
1144
|
+
subprocess.run(
|
|
1145
|
+
[PYTHON, REPLY_DB, "set_project",
|
|
1146
|
+
str(reply["id"]), project],
|
|
1147
|
+
capture_output=True,
|
|
1148
|
+
)
|
|
1149
|
+
succeeded += 1
|
|
1150
|
+
print(f"[engage_reddit] #{reply['id']} POSTED ({reply_elapsed:.0f}s) "
|
|
1151
|
+
f"[${usage['cost_usd']:.4f}]")
|
|
1152
|
+
else:
|
|
1153
|
+
err = post_result.get("error", "unknown") if post_result else "no_response"
|
|
1154
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), f"CDP_ERROR: {err}"])
|
|
1155
|
+
skip_reasons[f"cdp_error:{(err or 'unknown')[:32]}"] += 1
|
|
1156
|
+
failed += 1
|
|
1157
|
+
print(f"[engage_reddit] #{reply['id']} CDP FAILED: {err} ({reply_elapsed:.0f}s)")
|
|
1158
|
+
else:
|
|
1159
|
+
failed += 1
|
|
1160
|
+
print(f"[engage_reddit] #{reply['id']} unknown action: {decision}")
|
|
1161
|
+
|
|
1162
|
+
print(f"[engage_reddit] #{reply['id']} tokens: in={usage['input_tokens']} out={usage['output_tokens']} "
|
|
1163
|
+
f"cache_r={usage['cache_read']} cache_w={usage['cache_create']} "
|
|
1164
|
+
f"${usage['cost_usd']:.4f}")
|
|
1165
|
+
|
|
1166
|
+
processed += 1
|
|
1167
|
+
|
|
1168
|
+
# Release the reddit-browser lease before the inter-reply sleep so
|
|
1169
|
+
# peers can use the browser during that gap (2s now; widening it
|
|
1170
|
+
# later would only multiply the value). Belt-and-suspenders: if any
|
|
1171
|
+
# branch above hit a `break`, it already released; this fires on the
|
|
1172
|
+
# normal end-of-iteration path. Idempotent (NOT_HELD is fine).
|
|
1173
|
+
if lease_held:
|
|
1174
|
+
_release_browser_lease()
|
|
1175
|
+
|
|
1176
|
+
# Brief pause between sessions
|
|
1177
|
+
time.sleep(2)
|
|
1178
|
+
|
|
1179
|
+
total_elapsed = time.time() - start_time
|
|
1180
|
+
print(f"\n[engage_reddit] === SUMMARY ===")
|
|
1181
|
+
print(f"[engage_reddit] processed={processed} succeeded={succeeded} "
|
|
1182
|
+
f"skipped={skipped} failed={failed} elapsed={total_elapsed:.0f}s")
|
|
1183
|
+
print(f"[engage_reddit] meta_callouts_detected={meta_callouts_detected}")
|
|
1184
|
+
if skip_reasons:
|
|
1185
|
+
print(f"[engage_reddit] skip_reasons:")
|
|
1186
|
+
for reason, n in skip_reasons.most_common():
|
|
1187
|
+
print(f"[engage_reddit] {n:>3} {reason}")
|
|
1188
|
+
print(f"[engage_reddit] Total tokens: input={total_usage['input_tokens']} "
|
|
1189
|
+
f"output={total_usage['output_tokens']} "
|
|
1190
|
+
f"cache_read={total_usage['cache_read']} cache_create={total_usage['cache_create']}")
|
|
1191
|
+
print(f"[engage_reddit] Total cost: ${total_usage['cost_usd']:.4f}")
|
|
1192
|
+
if succeeded > 0:
|
|
1193
|
+
print(f"[engage_reddit] Avg cost per reply: ${total_usage['cost_usd'] / succeeded:.4f}")
|
|
1194
|
+
|
|
1195
|
+
# Build the failure-reasons string for the dashboard Result column. We
|
|
1196
|
+
# only count *hard* failure categories here (monthly_limit, aup_refusal,
|
|
1197
|
+
# timeout, claude_failed, bad_output) so that recoverable LLM-driven
|
|
1198
|
+
# skips (`llm:not_directed`, `llm:troll`, ...) don't get surfaced as
|
|
1199
|
+
# failures. Missing keys map to 0 via Counter, so this is safe even
|
|
1200
|
+
# when the run had zero failures.
|
|
1201
|
+
HARD_FAILURE_KEYS = ("monthly_limit", "aup_refusal", "timeout",
|
|
1202
|
+
"claude_failed", "bad_output")
|
|
1203
|
+
fr_pairs = [f"{k}:{skip_reasons[k]}" for k in HARD_FAILURE_KEYS
|
|
1204
|
+
if skip_reasons.get(k, 0) > 0]
|
|
1205
|
+
# Also surface CDP_ERROR rollups so a Reddit posting outage shows up as
|
|
1206
|
+
# "failed: cdp_error ×N" instead of dropping into the generic skip pile.
|
|
1207
|
+
cdp_total = sum(n for r, n in skip_reasons.items() if r.startswith("cdp_error:"))
|
|
1208
|
+
if cdp_total > 0:
|
|
1209
|
+
fr_pairs.append(f"cdp_error:{cdp_total}")
|
|
1210
|
+
failure_reasons_arg = ",".join(fr_pairs)
|
|
1211
|
+
|
|
1212
|
+
# Canonical machine-readable summary line for the shell wrapper
|
|
1213
|
+
# (engage-reddit.sh) to grep. The wrapper combines these engage-stage
|
|
1214
|
+
# counters with its own scan-stage counters and writes ONE log_run.py row.
|
|
1215
|
+
# Previously we wrote our own log_run row here AND the shell wrote one too,
|
|
1216
|
+
# producing two rows per cycle in run_monitor.log -- the duplicate without
|
|
1217
|
+
# scan info was the row the dashboard surfaced, which is why empty cycles
|
|
1218
|
+
# rendered as "0 0 0 0" instead of "scanned N / 0 new".
|
|
1219
|
+
print(
|
|
1220
|
+
f"[engage_reddit] LOG_RUN_SUMMARY"
|
|
1221
|
+
f" posted={succeeded}"
|
|
1222
|
+
f" skipped={skipped}"
|
|
1223
|
+
f" failed={failed}"
|
|
1224
|
+
f" cost={total_usage['cost_usd']:.4f}"
|
|
1225
|
+
f" elapsed={int(total_elapsed)}"
|
|
1226
|
+
f" failure_reasons={failure_reasons_arg}"
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
# Print final status (per-platform counts) via reply_db.py status helper,
|
|
1230
|
+
# which now reads /api/v1/replies/counts under the hood.
|
|
1231
|
+
subprocess.run([PYTHON, REPLY_DB, "status", args.platform])
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
if __name__ == "__main__":
|
|
1235
|
+
main()
|