@m13v/s4l 1.6.197-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GitHub issues reply engagement orchestrator.
|
|
3
|
+
|
|
4
|
+
Processes pending GitHub issue replies one at a time, each in its own Claude session.
|
|
5
|
+
Before deciding, fetches the full issue thread via gh CLI so Claude can see the
|
|
6
|
+
entire conversation (title, body, every comment, our own prior replies) and make
|
|
7
|
+
a thread-aware reply-or-skip decision with a JSON escape hatch.
|
|
8
|
+
|
|
9
|
+
This replaces the batched inline prompt in skill/github-engage.sh, which fed
|
|
10
|
+
truncated snippets to Claude with a "Process EVERY reply" directive. That design
|
|
11
|
+
produced spammy self-promotion comments that got flagged on fastrepl/char#4881.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
python3 scripts/engage_github.py
|
|
15
|
+
python3 scripts/engage_github.py --dry-run # Print prompt for first reply, don't post
|
|
16
|
+
python3 scripts/engage_github.py --limit 5 # Process at most 5 replies
|
|
17
|
+
python3 scripts/engage_github.py --timeout 3600 # Global timeout in seconds
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
from http_api import api_get
|
|
32
|
+
from engagement_styles import get_styles_prompt, get_anti_patterns, get_voice_relationship_rule
|
|
33
|
+
|
|
34
|
+
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
35
|
+
CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
36
|
+
REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
|
|
37
|
+
SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
|
|
38
|
+
|
|
39
|
+
# Interpreter every child subprocess must run under. A bare PYTHON resolved
|
|
40
|
+
# to the user's system python, which lacks the pipeline deps that live only in
|
|
41
|
+
# the owned uv runtime — the same fresh-box failure class that broke the Twitter
|
|
42
|
+
# poster (Karol, 2026-06-22). The GitHub rail posts via the REST API (no browser,
|
|
43
|
+
# so no Playwright dep), but its util/DB children still need the owned venv, so
|
|
44
|
+
# pin the interpreter here too. Honor S4L_PYTHON (set by the launchd plist),
|
|
45
|
+
# else sys.executable; never the literal PYTHON.
|
|
46
|
+
PYTHON = os.environ.get("S4L_PYTHON") or sys.executable
|
|
47
|
+
os.environ["S4L_PYTHON"] = PYTHON
|
|
48
|
+
|
|
49
|
+
# Cap the thread JSON we pass to Claude. Long issues with 100+ comments would
|
|
50
|
+
# otherwise blow the prompt budget. 12k chars is ~3k tokens, enough for most
|
|
51
|
+
# threads while leaving headroom for the rules and output.
|
|
52
|
+
THREAD_CHAR_CAP = 12000
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config():
|
|
56
|
+
with open(CONFIG_PATH) as f:
|
|
57
|
+
return json.load(f)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_next_pending():
|
|
61
|
+
"""Fetch the next pending GitHub reply (one at a time, oldest first).
|
|
62
|
+
|
|
63
|
+
Routes through /api/v1/replies/next-pending, which LEFT-JOINs posts +
|
|
64
|
+
mentions server-side and filters orphans. GitHub replies are always
|
|
65
|
+
post-rooted, so the post-side fields (thread_title/thread_url/our_content/
|
|
66
|
+
our_url) come back populated exactly as the old INNER JOIN produced.
|
|
67
|
+
"""
|
|
68
|
+
resp = api_get("/api/v1/replies/next-pending",
|
|
69
|
+
query={"platform": "github", "limit": 1})
|
|
70
|
+
rows = ((resp or {}).get("data") or {}).get("replies") or []
|
|
71
|
+
if not rows:
|
|
72
|
+
return None
|
|
73
|
+
r = rows[0]
|
|
74
|
+
return {
|
|
75
|
+
"id": r.get("id"), "platform": r.get("platform"),
|
|
76
|
+
"their_author": r.get("their_author"),
|
|
77
|
+
"their_content": r.get("their_content"),
|
|
78
|
+
"their_comment_url": r.get("their_comment_url"),
|
|
79
|
+
"their_comment_id": r.get("their_comment_id"),
|
|
80
|
+
"depth": r.get("depth"),
|
|
81
|
+
"thread_title": r.get("thread_title"),
|
|
82
|
+
"thread_url": r.get("thread_url"),
|
|
83
|
+
"our_content": r.get("our_content"),
|
|
84
|
+
"our_url": r.get("our_url"),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_recent_archetypes(limit=3):
|
|
89
|
+
"""Fetch our last N GitHub replies so Claude can vary style across threads.
|
|
90
|
+
|
|
91
|
+
Routes through /api/v1/replies (status=replied, has_our_reply_content,
|
|
92
|
+
ordered by replied_at DESC)."""
|
|
93
|
+
resp = api_get("/api/v1/replies", query={
|
|
94
|
+
"platform": "github",
|
|
95
|
+
"status": "replied",
|
|
96
|
+
"has_our_reply_content": "true",
|
|
97
|
+
"order_by": "replied_at",
|
|
98
|
+
"limit": int(limit),
|
|
99
|
+
})
|
|
100
|
+
rows = ((resp or {}).get("data") or {}).get("replies") or []
|
|
101
|
+
return [r.get("our_reply_content") for r in rows if r.get("our_reply_content")]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_issue_url(url):
|
|
105
|
+
"""Extract (owner, repo, number) from a github.com issue or PR URL."""
|
|
106
|
+
if not url:
|
|
107
|
+
return None, None, None
|
|
108
|
+
m = re.search(r"github\.com/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)", url)
|
|
109
|
+
if not m:
|
|
110
|
+
return None, None, None
|
|
111
|
+
return m.group(1), m.group(2), int(m.group(3))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fetch_thread(owner, repo, number):
|
|
115
|
+
"""Fetch full issue thread via gh CLI. Returns dict with title, body, comments."""
|
|
116
|
+
try:
|
|
117
|
+
out = subprocess.check_output(
|
|
118
|
+
["gh", "issue", "view", str(number), "-R", f"{owner}/{repo}",
|
|
119
|
+
"--json", "title,body,author,state,comments,url"],
|
|
120
|
+
text=True, timeout=30, stderr=subprocess.STDOUT,
|
|
121
|
+
)
|
|
122
|
+
return json.loads(out)
|
|
123
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
124
|
+
err = e.output if hasattr(e, "output") and e.output else str(e)
|
|
125
|
+
return {"_error": str(err)[:300]}
|
|
126
|
+
except json.JSONDecodeError as e:
|
|
127
|
+
return {"_error": f"json_decode: {e}"}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def summarize_thread_for_prompt(thread, our_username):
|
|
131
|
+
"""Compact the gh issue view JSON into a human-readable string for the prompt.
|
|
132
|
+
|
|
133
|
+
The raw JSON is noisy (association, reactionGroups, etc). We want Claude to
|
|
134
|
+
see a clean chronological transcript: issue body first, then each comment
|
|
135
|
+
with author and body. We tag our own comments explicitly so Claude knows
|
|
136
|
+
what we've already said.
|
|
137
|
+
"""
|
|
138
|
+
if "_error" in thread:
|
|
139
|
+
return f"[thread fetch failed: {thread['_error']}]"
|
|
140
|
+
|
|
141
|
+
lines = []
|
|
142
|
+
lines.append(f"Title: {thread.get('title', '(no title)')}")
|
|
143
|
+
lines.append(f"State: {thread.get('state', '?')}")
|
|
144
|
+
author = (thread.get("author") or {}).get("login", "?")
|
|
145
|
+
lines.append(f"Opened by: @{author}")
|
|
146
|
+
lines.append("")
|
|
147
|
+
lines.append("=== Issue body ===")
|
|
148
|
+
lines.append(thread.get("body", "") or "(empty)")
|
|
149
|
+
lines.append("")
|
|
150
|
+
lines.append("=== Comments (chronological) ===")
|
|
151
|
+
|
|
152
|
+
comments = thread.get("comments", []) or []
|
|
153
|
+
for i, c in enumerate(comments, 1):
|
|
154
|
+
c_author = (c.get("author") or {}).get("login", "?")
|
|
155
|
+
is_us = c_author == our_username
|
|
156
|
+
tag = " [THIS IS US]" if is_us else ""
|
|
157
|
+
body = c.get("body", "") or ""
|
|
158
|
+
lines.append(f"\n--- Comment {i} by @{c_author}{tag} ---")
|
|
159
|
+
lines.append(body)
|
|
160
|
+
|
|
161
|
+
text = "\n".join(lines)
|
|
162
|
+
if len(text) > THREAD_CHAR_CAP:
|
|
163
|
+
text = text[:THREAD_CHAR_CAP] + f"\n\n[... truncated, {len(text) - THREAD_CHAR_CAP} chars cut ...]"
|
|
164
|
+
return text
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_prompt(reply, thread_summary, recent_replies, our_username, owner, repo, number):
|
|
168
|
+
reply_json = json.dumps(reply, indent=2, default=str)
|
|
169
|
+
|
|
170
|
+
recent_context = ""
|
|
171
|
+
if recent_replies:
|
|
172
|
+
snippets = "\n".join(f" - {r}" for r in recent_replies)
|
|
173
|
+
recent_context = f"""
|
|
174
|
+
## Your last {len(recent_replies)} GitHub replies (vary your style, don't repeat yourself)
|
|
175
|
+
{snippets}
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
return f"""You are the Social Autoposter GitHub issues engagement bot.
|
|
179
|
+
|
|
180
|
+
Your GitHub username is: {our_username}
|
|
181
|
+
Target issue: {owner}/{repo}#{number}
|
|
182
|
+
|
|
183
|
+
## The triggering comment we need to decide about
|
|
184
|
+
{reply_json}
|
|
185
|
+
|
|
186
|
+
## Full issue thread
|
|
187
|
+
The entire conversation is below. Our own prior comments are tagged [THIS IS US].
|
|
188
|
+
Read it carefully before deciding anything.
|
|
189
|
+
|
|
190
|
+
{thread_summary}
|
|
191
|
+
{recent_context}
|
|
192
|
+
{get_styles_prompt("github", context="replying")}
|
|
193
|
+
|
|
194
|
+
## Content rules
|
|
195
|
+
- Write like a technical peer in the thread, not a marketer.
|
|
196
|
+
- NO em dashes. Use commas, periods, or regular dashes.
|
|
197
|
+
- Match the length and register of the thread. Short threads get short replies.
|
|
198
|
+
- Do not promote. Voice (whether you speak AS the maker or as an outside observer) is governed by the VOICE RELATIONSHIP section below; do not override it here.
|
|
199
|
+
- Never link to your own repo or product in a thread that is a bug report for someone else's project. Ever.
|
|
200
|
+
|
|
201
|
+
## Bot / engagement-loop escape hatch (use sparingly, but use it)
|
|
202
|
+
We maintain a universal author blocklist in Postgres (`author_blocklist`),
|
|
203
|
+
consulted at /api/v1/replies POST time. A single block recorded by ANY of
|
|
204
|
+
our accounts/installs applies to EVERY future engagement from EVERY of our
|
|
205
|
+
accounts — universal scope, by design. The velocity gate already covers
|
|
206
|
+
"this handle has gotten too many replies from us in 24h/7d"; this lane is
|
|
207
|
+
for the LLM-judgment cases velocity cannot catch.
|
|
208
|
+
|
|
209
|
+
When to add a block (your judgment, exercised CONSERVATIVELY):
|
|
210
|
+
- The GitHub handle is plainly an AI/bot account: templated phrasing across
|
|
211
|
+
unrelated issues, generic filler answers, account name pattern like
|
|
212
|
+
`*-bot` / `Foo-AI`, comments are repository drive-by promo
|
|
213
|
+
- We are clearly stuck in a reciprocal engagement loop with this account
|
|
214
|
+
- The handle is comment-spamming across many repos (drive-by self-promo on
|
|
215
|
+
every issue, not actually engaging with the bug)
|
|
216
|
+
|
|
217
|
+
DO NOT block: a maintainer we disagree with, a hostile-but-human critic,
|
|
218
|
+
a low-quality but human comment, or a single bad interaction. Skip those
|
|
219
|
+
(action='skip') — blocking is permanent until manually removed and applies
|
|
220
|
+
to all our accounts.
|
|
221
|
+
|
|
222
|
+
How to use it: do NOT emit a reply for this row. Instead, output the skip
|
|
223
|
+
JSON with reason='blocklist_added:HANDLE', and the orchestrator's
|
|
224
|
+
post-decision step will run reply_db.py blocklist add for you when the
|
|
225
|
+
reason starts with `blocklist_added:`. The handle to pass is the GitHub
|
|
226
|
+
login (e.g. for github.com/octocat, pass octocat). If you also want a
|
|
227
|
+
classification, output reason='blocklist_added:HANDLE:bot' or
|
|
228
|
+
'blocklist_added:HANDLE:engagement_loop'.
|
|
229
|
+
|
|
230
|
+
{get_voice_relationship_rule()}
|
|
231
|
+
|
|
232
|
+
{get_anti_patterns()}
|
|
233
|
+
|
|
234
|
+
## Tiered link strategy (DEFAULT TO TIER 1)
|
|
235
|
+
- Tier 1 (default): No link, no project mention. Just substance that helps the reader.
|
|
236
|
+
- Tier 2: Only if the thread is explicitly about a topic one of our projects solves AND nobody has offered a comparable tool yet AND the maintainer hasn't already resolved it. Mention casually.
|
|
237
|
+
- Tier 3: Only if someone explicitly asks "what do you use" / "any tools for this" / "link?". Then give it directly.
|
|
238
|
+
|
|
239
|
+
## Decision step: reply or skip?
|
|
240
|
+
|
|
241
|
+
Read the FULL thread above. There is NO cap on how many times we can reply to a thread. Active back-and-forth is encouraged when the conversation keeps developing and we have something useful to contribute. Do not skip just because we have prior comments in the thread. Skip only when one of the specific conditions below is clearly true.
|
|
242
|
+
|
|
243
|
+
DEFAULT TO REPLY when you have substance. Lean toward engagement, not silence.
|
|
244
|
+
|
|
245
|
+
SKIP (output action=skip) only when one of these is clearly true:
|
|
246
|
+
- light_acknowledgment: the triggering comment is just thanks, emoji, +1, or other content-free acknowledgment
|
|
247
|
+
- not_directed_at_us: the comment is in a conversation between two other people in the thread and does not ask us anything. Prefer this reason whenever the comment is addressed to someone else by @mention or context, regardless of how many prior comments we've made.
|
|
248
|
+
- no_value_to_add: the specific question or point has already been answered in the thread by someone else, or our reply would just repeat something we or others already said. This is about content, not count.
|
|
249
|
+
- conversation_concluded: the issue has been resolved, a fix has shipped, the maintainer closed it with an answer, and there is nothing substantive left to discuss. This is about thread state, not count.
|
|
250
|
+
- hostile_or_flagged: our prior comments in this thread were flagged as spam, someone called us a bot, or we are being accused of shilling. Back off.
|
|
251
|
+
- off_topic_for_us: the discussion is outside our expertise or unrelated to anything in config.json
|
|
252
|
+
- self_promo_risk: any honest reply would inevitably sound like self-promotion and there is no way to be genuinely helpful without it
|
|
253
|
+
|
|
254
|
+
REPLY (output action=reply with text) when any of these is true:
|
|
255
|
+
- The comment asks a direct question we can answer with useful insight
|
|
256
|
+
- We have specific technical substance to contribute that is not already in the thread
|
|
257
|
+
- The conversation is still alive and a peer reading it would find our next reply useful
|
|
258
|
+
- It is fine to be the 5th, 10th, or 20th reply from our account. Count does not matter. Substance does.
|
|
259
|
+
|
|
260
|
+
## Output format
|
|
261
|
+
Output ONLY ONE JSON object. No markdown, no prose, no explanations, no code fences.
|
|
262
|
+
|
|
263
|
+
For skip:
|
|
264
|
+
{{"action": "skip", "reason": "REASON_FROM_LIST_ABOVE"}}
|
|
265
|
+
|
|
266
|
+
For reply:
|
|
267
|
+
{{"action": "reply", "text": "YOUR_REPLY_TEXT", "project": null, "engagement_style": "STYLE_NAME"}}
|
|
268
|
+
|
|
269
|
+
Set "engagement_style" to the style you chose from the list above. Every reply MUST have an engagement_style. If none of the listed styles fit, you may invent a new one: set engagement_style to your new name AND include a `new_style` block (description, example, note, why_existing_didnt_fit) inside the same JSON object, per the "Inventing a new style" instructions above.
|
|
270
|
+
If you recommended a project from config.json in the reply text, set "project" to that project name.
|
|
271
|
+
The orchestrator posts the reply via gh CLI and updates the database. You only decide and draft.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def run_claude(prompt, timeout=300, session_id=None):
|
|
276
|
+
"""Run claude -p with the given prompt. Returns (success, output, usage_dict).
|
|
277
|
+
|
|
278
|
+
Streams output in real time to stderr for log visibility. Mirrors
|
|
279
|
+
engage_reddit.py exactly.
|
|
280
|
+
"""
|
|
281
|
+
import time as _time
|
|
282
|
+
import select
|
|
283
|
+
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
|
|
284
|
+
cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose"]
|
|
285
|
+
if session_id:
|
|
286
|
+
cmd += ["--session-id", session_id]
|
|
287
|
+
cmd += ["--tools", "Read"]
|
|
288
|
+
env = os.environ.copy()
|
|
289
|
+
env.pop("ANTHROPIC_API_KEY", None) # use OAuth, not API key
|
|
290
|
+
if session_id:
|
|
291
|
+
env["CLAUDE_SESSION_ID"] = session_id
|
|
292
|
+
try:
|
|
293
|
+
proc = subprocess.Popen(
|
|
294
|
+
cmd, env=env, stdin=subprocess.PIPE,
|
|
295
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
296
|
+
)
|
|
297
|
+
proc.stdin.write(prompt)
|
|
298
|
+
proc.stdin.close()
|
|
299
|
+
collected = []
|
|
300
|
+
deadline = _time.time() + timeout
|
|
301
|
+
while True:
|
|
302
|
+
remaining = deadline - _time.time()
|
|
303
|
+
if remaining <= 0:
|
|
304
|
+
proc.kill()
|
|
305
|
+
return False, "TIMEOUT", usage
|
|
306
|
+
ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 30))
|
|
307
|
+
if ready:
|
|
308
|
+
line = proc.stdout.readline()
|
|
309
|
+
if not line:
|
|
310
|
+
break
|
|
311
|
+
collected.append(line)
|
|
312
|
+
try:
|
|
313
|
+
evt = json.loads(line.strip())
|
|
314
|
+
etype = evt.get("type", "")
|
|
315
|
+
if etype == "assistant":
|
|
316
|
+
msg = evt.get("message", {})
|
|
317
|
+
for block in msg.get("content", []):
|
|
318
|
+
if block.get("type") == "tool_use":
|
|
319
|
+
tool_name = block.get("name", "")
|
|
320
|
+
tool_in = str(block.get("input", {}))[:120]
|
|
321
|
+
print(f"[engage_github] tool: {tool_name} | {tool_in}",
|
|
322
|
+
file=sys.stderr, flush=True)
|
|
323
|
+
elif block.get("type") == "text" and block.get("text", "").strip():
|
|
324
|
+
txt = block["text"].strip()[:200]
|
|
325
|
+
print(f"[engage_github] {txt}", file=sys.stderr, flush=True)
|
|
326
|
+
elif etype == "result":
|
|
327
|
+
print(f"[engage_github] done: cost=${evt.get('total_cost_usd', 0):.4f}",
|
|
328
|
+
file=sys.stderr, flush=True)
|
|
329
|
+
except (json.JSONDecodeError, TypeError):
|
|
330
|
+
print(f"[engage_github] {line.rstrip()[:200]}", file=sys.stderr, flush=True)
|
|
331
|
+
elif proc.poll() is not None:
|
|
332
|
+
rest = proc.stdout.read()
|
|
333
|
+
if rest:
|
|
334
|
+
collected.append(rest)
|
|
335
|
+
break
|
|
336
|
+
else:
|
|
337
|
+
elapsed_s = int(_time.time() - (deadline - timeout))
|
|
338
|
+
print(f"[engage_github] ... still running ({elapsed_s}s)",
|
|
339
|
+
file=sys.stderr, flush=True)
|
|
340
|
+
proc.wait()
|
|
341
|
+
text_output = ""
|
|
342
|
+
for line_str in collected:
|
|
343
|
+
line_str = line_str.strip()
|
|
344
|
+
if not line_str:
|
|
345
|
+
continue
|
|
346
|
+
try:
|
|
347
|
+
event = json.loads(line_str)
|
|
348
|
+
if event.get("type") == "result":
|
|
349
|
+
text_output = event.get("result", "")
|
|
350
|
+
usage["cost_usd"] = event.get("total_cost_usd", 0.0)
|
|
351
|
+
u = event.get("usage", {})
|
|
352
|
+
usage["input_tokens"] = u.get("input_tokens", 0)
|
|
353
|
+
usage["output_tokens"] = u.get("output_tokens", 0)
|
|
354
|
+
usage["cache_read"] = u.get("cache_read_input_tokens", 0)
|
|
355
|
+
usage["cache_create"] = u.get("cache_creation_input_tokens", 0)
|
|
356
|
+
except (json.JSONDecodeError, TypeError):
|
|
357
|
+
pass
|
|
358
|
+
if not text_output:
|
|
359
|
+
text_output = "".join(collected)
|
|
360
|
+
stderr_out = proc.stderr.read() if proc.stderr else ""
|
|
361
|
+
return proc.returncode == 0, text_output + stderr_out, usage
|
|
362
|
+
except Exception as e:
|
|
363
|
+
return False, str(e), usage
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def parse_decision(output):
|
|
367
|
+
"""Extract the action JSON object from Claude's output. Returns dict or None."""
|
|
368
|
+
# Try strict object first: balanced braces containing "action":"..."
|
|
369
|
+
# Claude may wrap in ``` or add prose; scan for any {...} containing "action"
|
|
370
|
+
candidates = re.findall(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', output, re.DOTALL)
|
|
371
|
+
for c in candidates:
|
|
372
|
+
try:
|
|
373
|
+
return json.loads(c)
|
|
374
|
+
except (json.JSONDecodeError, TypeError):
|
|
375
|
+
continue
|
|
376
|
+
# Fallback: find the last JSON-looking object
|
|
377
|
+
try:
|
|
378
|
+
start = output.rfind("{")
|
|
379
|
+
end = output.rfind("}")
|
|
380
|
+
if start != -1 and end > start:
|
|
381
|
+
return json.loads(output[start:end + 1])
|
|
382
|
+
except (json.JSONDecodeError, TypeError):
|
|
383
|
+
pass
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def post_comment(owner, repo, number, body):
|
|
388
|
+
"""Post a comment via gh CLI. Returns (ok, url_or_error_string)."""
|
|
389
|
+
try:
|
|
390
|
+
out = subprocess.check_output(
|
|
391
|
+
["gh", "issue", "comment", str(number), "-R", f"{owner}/{repo}", "--body", body],
|
|
392
|
+
text=True, timeout=60, stderr=subprocess.STDOUT,
|
|
393
|
+
)
|
|
394
|
+
url = None
|
|
395
|
+
for line in out.strip().splitlines():
|
|
396
|
+
if line.startswith("https://github.com"):
|
|
397
|
+
url = line.strip()
|
|
398
|
+
break
|
|
399
|
+
return True, url
|
|
400
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
401
|
+
err = e.output if hasattr(e, "output") and e.output else str(e)
|
|
402
|
+
return False, str(err)[:300]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def main():
|
|
406
|
+
parser = argparse.ArgumentParser(description="GitHub issues engagement (one at a time, thread-aware)")
|
|
407
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
408
|
+
help="Print prompt for first pending reply without executing Claude")
|
|
409
|
+
parser.add_argument("--limit", type=int, default=0,
|
|
410
|
+
help="Max replies to process (0 = unlimited)")
|
|
411
|
+
parser.add_argument("--timeout", type=int, default=3600,
|
|
412
|
+
help="Global timeout in seconds")
|
|
413
|
+
parser.add_argument("--per-reply-timeout", type=int, default=300,
|
|
414
|
+
help="Timeout per claude session in seconds")
|
|
415
|
+
args = parser.parse_args()
|
|
416
|
+
|
|
417
|
+
config = load_config()
|
|
418
|
+
excluded_authors = {a.lower() for a in config.get("exclusions", {}).get("authors", [])}
|
|
419
|
+
excluded_repos = {r.lower() for r in config.get("exclusions", {}).get("github_repos", [])}
|
|
420
|
+
# Auto-blocklist: owners with >=2 moderated posts in last 90 days. Same
|
|
421
|
+
# source of truth as github_tools.py cmd_search uses for new candidates.
|
|
422
|
+
# (HTTP-only now; per-account scoping is resolved inside the helper.)
|
|
423
|
+
from github_tools import _dynamic_owner_blocklist
|
|
424
|
+
excluded_repos = excluded_repos | _dynamic_owner_blocklist()
|
|
425
|
+
our_username = config.get("accounts", {}).get("github", {}).get("username", "m13v")
|
|
426
|
+
|
|
427
|
+
start_time = time.time()
|
|
428
|
+
processed = 0
|
|
429
|
+
succeeded = 0
|
|
430
|
+
skipped = 0
|
|
431
|
+
failed = 0
|
|
432
|
+
total_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
|
|
433
|
+
|
|
434
|
+
consecutive_failures = 0
|
|
435
|
+
last_failed_id = None
|
|
436
|
+
|
|
437
|
+
print(f"[engage_github] Starting. limit={args.limit or 'unlimited'}, timeout={args.timeout}s, user={our_username}")
|
|
438
|
+
|
|
439
|
+
while True:
|
|
440
|
+
if time.time() - start_time > args.timeout:
|
|
441
|
+
print(f"[engage_github] Global timeout reached ({args.timeout}s). Stopping.")
|
|
442
|
+
break
|
|
443
|
+
if args.limit and processed >= args.limit:
|
|
444
|
+
print(f"[engage_github] Limit reached ({args.limit}). Stopping.")
|
|
445
|
+
break
|
|
446
|
+
if consecutive_failures >= 3:
|
|
447
|
+
print(f"[engage_github] 3 consecutive Claude failures (likely rate limit). Stopping.")
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
reply = get_next_pending()
|
|
451
|
+
if not reply:
|
|
452
|
+
print("[engage_github] No pending replies. Done!")
|
|
453
|
+
break
|
|
454
|
+
|
|
455
|
+
# Exclusion: author
|
|
456
|
+
if (reply["their_author"] or "").lower() in excluded_authors:
|
|
457
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_author"])
|
|
458
|
+
print(f"[engage_github] #{reply['id']} skipped (excluded_author: {reply['their_author']})")
|
|
459
|
+
skipped += 1
|
|
460
|
+
processed += 1
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
# Parse owner/repo/number from thread_url
|
|
464
|
+
owner, repo, number = parse_issue_url(reply["thread_url"] or "")
|
|
465
|
+
if not owner:
|
|
466
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "bad_thread_url"])
|
|
467
|
+
print(f"[engage_github] #{reply['id']} skipped (bad_thread_url: {reply['thread_url']})")
|
|
468
|
+
skipped += 1
|
|
469
|
+
processed += 1
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
# Exclusion: repo
|
|
473
|
+
repo_key = f"{owner}/{repo}".lower()
|
|
474
|
+
if repo_key in excluded_repos or owner.lower() in excluded_repos:
|
|
475
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_repo"])
|
|
476
|
+
print(f"[engage_github] #{reply['id']} skipped (excluded_repo: {repo_key})")
|
|
477
|
+
skipped += 1
|
|
478
|
+
processed += 1
|
|
479
|
+
continue
|
|
480
|
+
|
|
481
|
+
# Fetch the full thread
|
|
482
|
+
print(f"[engage_github] Fetching thread for {owner}/{repo}#{number}")
|
|
483
|
+
thread = fetch_thread(owner, repo, number)
|
|
484
|
+
if "_error" in thread:
|
|
485
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]),
|
|
486
|
+
f"fetch_error: {thread['_error']}"])
|
|
487
|
+
print(f"[engage_github] #{reply['id']} skipped (fetch_error: {thread['_error'][:100]})")
|
|
488
|
+
skipped += 1
|
|
489
|
+
processed += 1
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
thread_summary = summarize_thread_for_prompt(thread, our_username)
|
|
493
|
+
recent = get_recent_archetypes(limit=3)
|
|
494
|
+
prompt = build_prompt(reply, thread_summary, recent, our_username, owner, repo, number)
|
|
495
|
+
|
|
496
|
+
if args.dry_run:
|
|
497
|
+
print(f"=== DRY RUN: Prompt for reply #{reply['id']} ===")
|
|
498
|
+
print(prompt)
|
|
499
|
+
print("=== END DRY RUN ===")
|
|
500
|
+
break
|
|
501
|
+
|
|
502
|
+
reply_start = time.time()
|
|
503
|
+
session_id = str(uuid.uuid4())
|
|
504
|
+
os.environ["CLAUDE_SESSION_ID"] = session_id
|
|
505
|
+
session_started_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
506
|
+
print(f"[engage_github] Processing #{reply['id']} from @{reply['their_author']} "
|
|
507
|
+
f"on {owner}/{repo}#{number}")
|
|
508
|
+
|
|
509
|
+
ok, output, usage = run_claude(prompt, timeout=args.per_reply_timeout, session_id=session_id)
|
|
510
|
+
reply_elapsed = time.time() - reply_start
|
|
511
|
+
session_ended_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
512
|
+
log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
|
|
513
|
+
"--session-id", session_id, "--script", "engage_github",
|
|
514
|
+
"--started-at", session_started_at, "--ended-at", session_ended_at]
|
|
515
|
+
orch_cost = usage.get("cost_usd")
|
|
516
|
+
if isinstance(orch_cost, (int, float)) and orch_cost > 0:
|
|
517
|
+
log_args.extend(["--orchestrator-cost-usd", str(orch_cost)])
|
|
518
|
+
subprocess.run(log_args, capture_output=True)
|
|
519
|
+
|
|
520
|
+
for k in total_usage:
|
|
521
|
+
total_usage[k] += usage[k]
|
|
522
|
+
|
|
523
|
+
if not ok:
|
|
524
|
+
failed += 1
|
|
525
|
+
consecutive_failures += 1
|
|
526
|
+
# Mark as skipped so the loop advances to the next pending reply
|
|
527
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "claude_error"],
|
|
528
|
+
capture_output=True)
|
|
529
|
+
print(f"[engage_github] #{reply['id']} CLAUDE FAILED ({reply_elapsed:.0f}s): {output[:200]}")
|
|
530
|
+
else:
|
|
531
|
+
consecutive_failures = 0
|
|
532
|
+
decision = parse_decision(output)
|
|
533
|
+
if not decision:
|
|
534
|
+
failed += 1
|
|
535
|
+
print(f"[engage_github] #{reply['id']} BAD OUTPUT ({reply_elapsed:.0f}s): {output[:300]}")
|
|
536
|
+
elif decision.get("action") == "skip":
|
|
537
|
+
reason = decision.get("reason", "unknown") or "unknown"
|
|
538
|
+
# Bot/engagement-loop escape hatch. The github engage prompt
|
|
539
|
+
# is run with --tools Read only (no Bash), so the model
|
|
540
|
+
# cannot shell out to reply_db.py blocklist add itself.
|
|
541
|
+
# Instead it signals via the skip reason pattern
|
|
542
|
+
# `blocklist_added:HANDLE[:classification]` and the
|
|
543
|
+
# orchestrator records the block here. HANDLE defaults to
|
|
544
|
+
# the reply's their_author when the model omits it (which
|
|
545
|
+
# is the common case for github since the author IS the
|
|
546
|
+
# GitHub login).
|
|
547
|
+
if reason.startswith("blocklist_added"):
|
|
548
|
+
parts = reason.split(":")
|
|
549
|
+
handle = parts[1].strip() if len(parts) > 1 and parts[1].strip() else (reply["their_author"] or "").strip()
|
|
550
|
+
classification = parts[2].strip() if len(parts) > 2 and parts[2].strip() in ("bot", "engagement_loop") else "bot"
|
|
551
|
+
if handle:
|
|
552
|
+
bl_cmd = [
|
|
553
|
+
PYTHON, REPLY_DB, "blocklist", "add",
|
|
554
|
+
"github_issues", handle,
|
|
555
|
+
"--reason", f"engage_llm judgment: {reason}",
|
|
556
|
+
"--classification", classification,
|
|
557
|
+
"--severity", "hard",
|
|
558
|
+
"--source-reply-id", str(reply["id"]),
|
|
559
|
+
]
|
|
560
|
+
bl_res = subprocess.run(bl_cmd, capture_output=True, text=True)
|
|
561
|
+
if bl_res.returncode == 0:
|
|
562
|
+
print(f"[engage_github] blocklist add github_issues/{handle} cls={classification}")
|
|
563
|
+
else:
|
|
564
|
+
print(f"[engage_github] blocklist add FAILED for {handle}: {bl_res.stderr[:200]}")
|
|
565
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
|
|
566
|
+
skipped += 1
|
|
567
|
+
print(f"[engage_github] #{reply['id']} SKIPPED: {reason} ({reply_elapsed:.0f}s) "
|
|
568
|
+
f"[${usage['cost_usd']:.4f}]")
|
|
569
|
+
elif decision.get("action") == "reply":
|
|
570
|
+
reply_text = (decision.get("text") or "").strip()
|
|
571
|
+
project = decision.get("project")
|
|
572
|
+
if not reply_text:
|
|
573
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "empty_reply_text"])
|
|
574
|
+
failed += 1
|
|
575
|
+
print(f"[engage_github] #{reply['id']} empty reply text, marked skipped")
|
|
576
|
+
else:
|
|
577
|
+
subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])])
|
|
578
|
+
ok_post, url_or_err = post_comment(owner, repo, number, reply_text)
|
|
579
|
+
if ok_post:
|
|
580
|
+
cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), reply_text]
|
|
581
|
+
if url_or_err:
|
|
582
|
+
cmd_args.append(url_or_err)
|
|
583
|
+
style = decision.get("engagement_style", "")
|
|
584
|
+
if style:
|
|
585
|
+
if not url_or_err:
|
|
586
|
+
cmd_args.append("") # placeholder for url
|
|
587
|
+
cmd_args.append(style)
|
|
588
|
+
subprocess.run(cmd_args)
|
|
589
|
+
if project:
|
|
590
|
+
subprocess.run(
|
|
591
|
+
[PYTHON, REPLY_DB, "set_project", str(reply["id"]), project],
|
|
592
|
+
capture_output=True,
|
|
593
|
+
)
|
|
594
|
+
succeeded += 1
|
|
595
|
+
print(f"[engage_github] #{reply['id']} POSTED ({reply_elapsed:.0f}s) "
|
|
596
|
+
f"[${usage['cost_usd']:.4f}] -> {url_or_err or '(no url)'}")
|
|
597
|
+
else:
|
|
598
|
+
subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]),
|
|
599
|
+
f"post_error: {url_or_err}"])
|
|
600
|
+
failed += 1
|
|
601
|
+
print(f"[engage_github] #{reply['id']} POST FAILED: {url_or_err}")
|
|
602
|
+
else:
|
|
603
|
+
failed += 1
|
|
604
|
+
print(f"[engage_github] #{reply['id']} unknown action: {decision}")
|
|
605
|
+
|
|
606
|
+
print(f"[engage_github] #{reply['id']} tokens: in={usage['input_tokens']} "
|
|
607
|
+
f"out={usage['output_tokens']} cache_r={usage['cache_read']} "
|
|
608
|
+
f"cache_w={usage['cache_create']} ${usage['cost_usd']:.4f}")
|
|
609
|
+
|
|
610
|
+
processed += 1
|
|
611
|
+
time.sleep(2)
|
|
612
|
+
|
|
613
|
+
total_elapsed = time.time() - start_time
|
|
614
|
+
print(f"\n[engage_github] === SUMMARY ===")
|
|
615
|
+
print(f"[engage_github] processed={processed} succeeded={succeeded} "
|
|
616
|
+
f"skipped={skipped} failed={failed} elapsed={total_elapsed:.0f}s")
|
|
617
|
+
print(f"[engage_github] Total tokens: input={total_usage['input_tokens']} "
|
|
618
|
+
f"output={total_usage['output_tokens']} "
|
|
619
|
+
f"cache_read={total_usage['cache_read']} cache_create={total_usage['cache_create']}")
|
|
620
|
+
print(f"[engage_github] Total cost: ${total_usage['cost_usd']:.4f}")
|
|
621
|
+
if succeeded > 0:
|
|
622
|
+
print(f"[engage_github] Avg cost per reply: ${total_usage['cost_usd'] / succeeded:.4f}")
|
|
623
|
+
|
|
624
|
+
# Canonical machine-readable summary line. github-engage.sh greps this and
|
|
625
|
+
# writes ONE log_run.py row that also carries Phase A scan counters. See
|
|
626
|
+
# the comment in engage_reddit.py for the duplicate-row history.
|
|
627
|
+
print(
|
|
628
|
+
f"[engage_github] LOG_RUN_SUMMARY"
|
|
629
|
+
f" posted={succeeded}"
|
|
630
|
+
f" skipped={skipped}"
|
|
631
|
+
f" failed={failed}"
|
|
632
|
+
f" cost={total_usage['cost_usd']:.4f}"
|
|
633
|
+
f" elapsed={int(total_elapsed)}"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
subprocess.run([PYTHON, REPLY_DB, "status"])
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
if __name__ == "__main__":
|
|
640
|
+
main()
|