@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,1311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GitHub Issues posting orchestrator with momentum-gated candidate selection.
|
|
3
|
+
|
|
4
|
+
Two-phase design (consolidated 2026-04-24, replacing the short-lived
|
|
5
|
+
run_github_cycle.py):
|
|
6
|
+
|
|
7
|
+
Phase 1: search project topics across N seeds, snapshot T0 comment + reaction
|
|
8
|
+
counts. The originating seed is stamped on every candidate so the
|
|
9
|
+
feedback loop (top_search_topics.py) gets fed back into the next run.
|
|
10
|
+
Sleep --sleep seconds (default 600).
|
|
11
|
+
Phase 2a: re-poll every candidate, compute delta_score = 3*Δcomments + 2*Δreactions.
|
|
12
|
+
Phase 2b: adaptive cap (CAP_DEFAULT, bumped to CAP_BUMPED when >= HIGH_DELTA_BUMP
|
|
13
|
+
candidates clear DELTA_THRESHOLD), Claude only drafts comments — no
|
|
14
|
+
Bash tools, no in-flight searches, single JSON response. Python posts
|
|
15
|
+
via gh and persists everything (search_topic, language, engagement_style,
|
|
16
|
+
claude_session_id) to the posts table.
|
|
17
|
+
|
|
18
|
+
Why a single Python orchestrator instead of letting Claude search itself:
|
|
19
|
+
the pre-filter cuts Claude's tool budget to zero, the momentum gate suppresses
|
|
20
|
+
posts on stale threads, and the seed-per-candidate signal closes the
|
|
21
|
+
top_search_topics feedback loop. Claude returns one JSON in one shot.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python3 scripts/post_github.py
|
|
25
|
+
python3 scripts/post_github.py --sleep 60 --dry-run # quick dev
|
|
26
|
+
python3 scripts/post_github.py --project Fazm # force project
|
|
27
|
+
python3 scripts/post_github.py --limit 5 # caps adaptive cap
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import atexit
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import random
|
|
35
|
+
import re
|
|
36
|
+
import signal
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
import uuid
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
|
|
43
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
44
|
+
from http_api import api_get
|
|
45
|
+
import pick_project
|
|
46
|
+
from author_history_block import render as _render_author_history
|
|
47
|
+
from project_topics import topics_for_project
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Run-summary safety net (atexit + SIGTERM/SIGHUP handlers).
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Mirrors the bash-side fix shipped to run-reddit-search.sh / run-twitter-cycle.sh
|
|
53
|
+
# / run-linkedin.sh: under SIGTERM the orchestrator can land between a
|
|
54
|
+
# successful gh-comment post (`posted += 1`) and the inline log_run.py call
|
|
55
|
+
# at the bottom of main(), silently dropping the run from run_monitor.log
|
|
56
|
+
# while the `posts` table already shows the comment.
|
|
57
|
+
#
|
|
58
|
+
# Mechanism:
|
|
59
|
+
# - _RUN_STATE is a module-level dict main() updates as it runs
|
|
60
|
+
# (run_start, cost, posted, skipped, failed).
|
|
61
|
+
# - _emit_run_summary_oneshot() shells out to scripts/log_run.py with
|
|
62
|
+
# whatever state is current. Idempotent via _RUN_STATE['emitted'].
|
|
63
|
+
# - atexit.register catches normal exits + uncaught exceptions.
|
|
64
|
+
# - signal.signal() converts SIGTERM/SIGHUP into a sys.exit(128+signum)
|
|
65
|
+
# call so atexit handlers actually run (Python's default SIGTERM
|
|
66
|
+
# handler is to exit immediately, BYPASSING atexit).
|
|
67
|
+
# - SIGINT / KeyboardInterrupt: Python's default already raises an
|
|
68
|
+
# exception that unwinds through atexit, no extra wiring needed.
|
|
69
|
+
#
|
|
70
|
+
# Each existing inline log_run.py call (Claude failure path, success path)
|
|
71
|
+
# sets _RUN_STATE['emitted'] = True after running so the atexit handler
|
|
72
|
+
# becomes a no-op for those branches and we don't double-write.
|
|
73
|
+
_RUN_STATE = {
|
|
74
|
+
"emitted": False,
|
|
75
|
+
"run_start": None,
|
|
76
|
+
"posted": 0,
|
|
77
|
+
"skipped": 0,
|
|
78
|
+
"failed": 0,
|
|
79
|
+
"cost": 0.0,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _emit_run_summary_oneshot():
|
|
84
|
+
if _RUN_STATE["emitted"] or _RUN_STATE["run_start"] is None:
|
|
85
|
+
return
|
|
86
|
+
_RUN_STATE["emitted"] = True
|
|
87
|
+
elapsed = int(time.time() - _RUN_STATE["run_start"])
|
|
88
|
+
try:
|
|
89
|
+
subprocess.run(
|
|
90
|
+
[
|
|
91
|
+
PYTHON, os.path.join(os.path.dirname(os.path.abspath(__file__)), "log_run.py"),
|
|
92
|
+
"--script", "post_github",
|
|
93
|
+
"--posted", str(_RUN_STATE["posted"]),
|
|
94
|
+
"--skipped", str(_RUN_STATE["skipped"]),
|
|
95
|
+
"--failed", str(_RUN_STATE["failed"]),
|
|
96
|
+
"--cost", f"{_RUN_STATE['cost']:.4f}",
|
|
97
|
+
"--elapsed", str(elapsed),
|
|
98
|
+
],
|
|
99
|
+
timeout=15,
|
|
100
|
+
check=False,
|
|
101
|
+
)
|
|
102
|
+
except Exception:
|
|
103
|
+
# Trap context: never raise from the safety net. Better to lose this
|
|
104
|
+
# one summary line than to crash a shutdown sequence that might be
|
|
105
|
+
# holding a browser lock or DB connection that other peers need.
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _signal_to_exit(signum, _frame):
|
|
110
|
+
# Convert the signal into a normal-looking exit so atexit fires.
|
|
111
|
+
sys.exit(128 + signum)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
atexit.register(_emit_run_summary_oneshot)
|
|
115
|
+
# Only install handlers when running as the main entry point so importing
|
|
116
|
+
# post_github (e.g. for unit tests, or when SCRIPTS adds it to PYTHONPATH)
|
|
117
|
+
# doesn't override the parent process's signal handling.
|
|
118
|
+
if __name__ == "__main__" or os.environ.get("POST_GITHUB_INSTALL_TRAPS") == "1":
|
|
119
|
+
for _sig in (signal.SIGTERM, signal.SIGHUP):
|
|
120
|
+
try:
|
|
121
|
+
signal.signal(_sig, _signal_to_exit)
|
|
122
|
+
except (ValueError, OSError):
|
|
123
|
+
# Non-main-thread import or unsupported signal: skip silently.
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
from engagement_styles import (
|
|
127
|
+
VALID_STYLES, get_styles_prompt, get_content_rules, get_anti_patterns,
|
|
128
|
+
validate_or_register, pick_style_for_post,
|
|
129
|
+
)
|
|
130
|
+
# Audience-page routing: tells Claude which curated landing pages exist for the
|
|
131
|
+
# project so it can bake a deep URL (e.g. https://s4l.ai/ghostwriting) into the
|
|
132
|
+
# draft when the issue topic matches. See scripts/audience_pages.py + the
|
|
133
|
+
# landing_pages.audience_pages block in config.json.
|
|
134
|
+
from audience_pages import (
|
|
135
|
+
prompt_block as _audience_prompt_block,
|
|
136
|
+
classify_url_as_audience_page as _audience_classify_url,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
140
|
+
SCRIPTS = os.path.join(REPO_DIR, "scripts")
|
|
141
|
+
CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
142
|
+
SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
|
|
143
|
+
GITHUB_TOOLS = os.path.join(SCRIPTS, "github_tools.py")
|
|
144
|
+
RUN_CLAUDE = os.path.join(SCRIPTS, "run_claude.sh")
|
|
145
|
+
|
|
146
|
+
# Interpreter every child subprocess must run under. A bare PYTHON resolved
|
|
147
|
+
# to the user's system python, which lacks the pipeline deps that live only in
|
|
148
|
+
# the owned uv runtime — the same fresh-box failure class that broke the Twitter
|
|
149
|
+
# poster (Karol, 2026-06-22). The GitHub rail posts via the REST API (no browser,
|
|
150
|
+
# so no Playwright dep), but its util/DB children still need the owned venv, so
|
|
151
|
+
# pin the interpreter here too. Honor S4L_PYTHON (set by the launchd plist),
|
|
152
|
+
# else sys.executable; never the literal PYTHON.
|
|
153
|
+
PYTHON = os.environ.get("S4L_PYTHON") or sys.executable
|
|
154
|
+
os.environ["S4L_PYTHON"] = PYTHON
|
|
155
|
+
|
|
156
|
+
# Momentum tunables. Edit here, not at call sites.
|
|
157
|
+
DELTA_THRESHOLD = 1.0
|
|
158
|
+
HIGH_DELTA_BUMP = 3
|
|
159
|
+
CAP_DEFAULT = 1
|
|
160
|
+
CAP_BUMPED = 3
|
|
161
|
+
CLAUDE_CANDIDATE_LIMIT = 8 # show top N to Claude
|
|
162
|
+
SEARCH_PER_TOPIC = 5 # gh search --limit per topic
|
|
163
|
+
MAX_TOPICS_PER_PROJECT = 6
|
|
164
|
+
|
|
165
|
+
# Maintainer-just-spoke gate. authorAssociation values that count as "maintainer".
|
|
166
|
+
# If the most recent commenter on a candidate issue is one of these, we drop the
|
|
167
|
+
# candidate to avoid piling on a maintainer who just set direction (root cause of
|
|
168
|
+
# the antiwork/gumroad LOW_QUALITY minimization, posts #21826 + #22200).
|
|
169
|
+
MAINTAINER_ASSOCIATIONS = {"OWNER", "MEMBER", "COLLABORATOR"}
|
|
170
|
+
|
|
171
|
+
# Relevance gate. Claude returns relevance:0..3 per draft; we drop everything
|
|
172
|
+
# below this floor before posting. 2 = "project's tools/audience could plausibly
|
|
173
|
+
# help here." 0/1 = off-domain. Tunable.
|
|
174
|
+
MIN_RELEVANCE = 2
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def log(msg):
|
|
178
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] [post_github] {msg}", flush=True)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def load_config():
|
|
182
|
+
with open(CONFIG_PATH) as f:
|
|
183
|
+
return json.load(f)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------- Project picking & context ---------------------------------------
|
|
187
|
+
|
|
188
|
+
def get_top_performers(project_name, platform="github"):
|
|
189
|
+
try:
|
|
190
|
+
result = subprocess.run(
|
|
191
|
+
[PYTHON, os.path.join(SCRIPTS, "top_performers.py"),
|
|
192
|
+
"--platform", platform, "--project", project_name],
|
|
193
|
+
capture_output=True, text=True, timeout=15,
|
|
194
|
+
)
|
|
195
|
+
if result.returncode == 0:
|
|
196
|
+
return result.stdout.strip()
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_top_search_topics(project_name, platform="github", limit=8, window_days=30):
|
|
203
|
+
"""Best-performing search_topic seeds for this project on this platform.
|
|
204
|
+
Empty string if no data yet. Mirrors post_reddit.get_top_search_topics."""
|
|
205
|
+
try:
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
[PYTHON, os.path.join(SCRIPTS, "top_search_topics.py"),
|
|
208
|
+
"--project", project_name, "--platform", platform,
|
|
209
|
+
"--window-days", str(window_days), "--limit", str(limit)],
|
|
210
|
+
capture_output=True, text=True, timeout=15,
|
|
211
|
+
)
|
|
212
|
+
if result.returncode == 0:
|
|
213
|
+
return result.stdout.strip()
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
return ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_recent_comments(limit=5):
|
|
220
|
+
"""Last N github comments by id DESC. Tuple form `(id, our_content)` so
|
|
221
|
+
the generation_trace audit row can store the IDs alongside the text (no
|
|
222
|
+
duplication: the text is already in the posts table, the IDs let us
|
|
223
|
+
reverse-link). Backward-compat note: this used to return a plain list
|
|
224
|
+
of strings; callers that consume `recent_comments` for prompt-building
|
|
225
|
+
were updated in the same change."""
|
|
226
|
+
resp = api_get("/api/v1/posts", query={
|
|
227
|
+
"platform": "github",
|
|
228
|
+
"order_by": "id",
|
|
229
|
+
"order_dir": "desc",
|
|
230
|
+
"limit": int(limit),
|
|
231
|
+
})
|
|
232
|
+
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
233
|
+
# Return as list of (id, content) tuples. The caller-side conversion
|
|
234
|
+
# to a flat string list for prompt-building is one-line below in main().
|
|
235
|
+
return [(int(r["id"]), r.get("our_content") or "") for r in rows]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Generation trace plumbing lives in scripts/generation_trace.py so the
|
|
239
|
+
# github / reddit / twitter pipelines all write the same shape. See that
|
|
240
|
+
# module for the shape contract and migrations/2026-05-12_generation_trace.sql
|
|
241
|
+
# for the JSONB column definition.
|
|
242
|
+
import generation_trace as _gen_trace
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _angle_str(v):
|
|
246
|
+
if isinstance(v, str):
|
|
247
|
+
return v.strip()
|
|
248
|
+
if isinstance(v, dict):
|
|
249
|
+
return "; ".join(f"{k}: {_angle_str(x)}" for k, x in v.items() if x)
|
|
250
|
+
if isinstance(v, (list, tuple)):
|
|
251
|
+
return ", ".join(_angle_str(x) for x in v if x)
|
|
252
|
+
return str(v) if v else ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_content_angle(project, config):
|
|
256
|
+
"""Rich angle: prefer content_angle override, otherwise compose from
|
|
257
|
+
description / differentiator / icp / setup / messaging / voice.
|
|
258
|
+
|
|
259
|
+
Always appends the project's audience-pages block (when configured) so the
|
|
260
|
+
draft prompt knows which curated landing pages it should link to for
|
|
261
|
+
topic-matched issues.
|
|
262
|
+
"""
|
|
263
|
+
if project.get("content_angle"):
|
|
264
|
+
base = project["content_angle"]
|
|
265
|
+
else:
|
|
266
|
+
parts = []
|
|
267
|
+
for key in ("description", "differentiator", "icp", "setup"):
|
|
268
|
+
s = _angle_str(project.get(key))
|
|
269
|
+
if s:
|
|
270
|
+
parts.append(s)
|
|
271
|
+
messaging = project.get("messaging", {}) or {}
|
|
272
|
+
for key in ("lead_with_pain", "solution", "proof"):
|
|
273
|
+
s = _angle_str(messaging.get(key))
|
|
274
|
+
if s:
|
|
275
|
+
parts.append(s)
|
|
276
|
+
voice = project.get("voice", {}) or {}
|
|
277
|
+
if voice.get("tone"):
|
|
278
|
+
parts.append(f"Voice: {voice['tone']}")
|
|
279
|
+
if voice.get("never"):
|
|
280
|
+
parts.append("Never: " + "; ".join(voice["never"]))
|
|
281
|
+
examples = voice.get("examples") or voice.get("examples_good") or []
|
|
282
|
+
if examples:
|
|
283
|
+
parts.append("Voice examples: " + " | ".join(examples[:3]))
|
|
284
|
+
base = " ".join(parts) if parts else config.get("content_angle", "")
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
ap_block = _audience_prompt_block(project.get("name") or "")
|
|
288
|
+
except Exception:
|
|
289
|
+
ap_block = ""
|
|
290
|
+
if ap_block:
|
|
291
|
+
return (base + "\n\n" + ap_block).strip() if base else ap_block.strip()
|
|
292
|
+
return base
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------- Phase 1 / 2 momentum helpers ------------------------------------
|
|
296
|
+
|
|
297
|
+
def gh_search(query, limit=SEARCH_PER_TOPIC):
|
|
298
|
+
try:
|
|
299
|
+
out = subprocess.check_output(
|
|
300
|
+
[PYTHON, GITHUB_TOOLS, "search", query, "--limit", str(limit)],
|
|
301
|
+
text=True, timeout=45,
|
|
302
|
+
)
|
|
303
|
+
items = json.loads(out)
|
|
304
|
+
except Exception as e:
|
|
305
|
+
log(f" gh_search failed for '{query}': {e}")
|
|
306
|
+
return []
|
|
307
|
+
return [i for i in items if not i.get("already_posted")]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def gh_view_counts(repo, number):
|
|
311
|
+
"""Return dict{comment_count, reaction_count, title, body, author, url,
|
|
312
|
+
maintainer_last_speaker, last_commenter, last_comment_assoc} or None if the
|
|
313
|
+
issue is no longer open / unfetchable.
|
|
314
|
+
|
|
315
|
+
`gh issue view --json comments` returns each comment with `authorAssociation`
|
|
316
|
+
(OWNER/MEMBER/COLLABORATOR/CONTRIBUTOR/NONE/...) and `createdAt`. We use the
|
|
317
|
+
most recent comment to detect "maintainer just spoke" so phase 1 can drop
|
|
318
|
+
those candidates without an extra API call."""
|
|
319
|
+
try:
|
|
320
|
+
out = subprocess.check_output(
|
|
321
|
+
["gh", "issue", "view", str(number), "-R", repo,
|
|
322
|
+
"--json", "title,body,author,url,comments,reactionGroups,state"],
|
|
323
|
+
text=True, timeout=30, stderr=subprocess.STDOUT,
|
|
324
|
+
)
|
|
325
|
+
data = json.loads(out)
|
|
326
|
+
except Exception:
|
|
327
|
+
return None
|
|
328
|
+
if data.get("state") and data["state"].lower() != "open":
|
|
329
|
+
return None
|
|
330
|
+
comments = data.get("comments") or []
|
|
331
|
+
reaction_count = 0
|
|
332
|
+
for g in data.get("reactionGroups") or []:
|
|
333
|
+
reaction_count += int(
|
|
334
|
+
(g.get("users") or {}).get("totalCount", 0) or g.get("totalCount", 0) or 0
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Maintainer-just-spoke gate. Sort comments by createdAt desc, look at the
|
|
338
|
+
# most recent one (regardless of timing). If the issue's last word came from
|
|
339
|
+
# someone with push access, the thread is being driven and we shouldn't pile
|
|
340
|
+
# on. The OP's authorAssociation is checked separately (issue.author isn't
|
|
341
|
+
# included in `comments`, only in the top-level `author` field).
|
|
342
|
+
maintainer_last_speaker = False
|
|
343
|
+
last_commenter = ""
|
|
344
|
+
last_comment_assoc = ""
|
|
345
|
+
if comments:
|
|
346
|
+
try:
|
|
347
|
+
sorted_c = sorted(
|
|
348
|
+
comments,
|
|
349
|
+
key=lambda c: c.get("createdAt", "") or "",
|
|
350
|
+
reverse=True,
|
|
351
|
+
)
|
|
352
|
+
last = sorted_c[0]
|
|
353
|
+
last_commenter = (last.get("author") or {}).get("login", "") or ""
|
|
354
|
+
last_comment_assoc = (last.get("authorAssociation") or "").upper()
|
|
355
|
+
if last_comment_assoc in MAINTAINER_ASSOCIATIONS:
|
|
356
|
+
maintainer_last_speaker = True
|
|
357
|
+
except Exception:
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
"comment_count": len(comments),
|
|
362
|
+
"reaction_count": reaction_count,
|
|
363
|
+
"title": data.get("title", ""),
|
|
364
|
+
"body": (data.get("body") or ""),
|
|
365
|
+
"author": (data.get("author") or {}).get("login", ""),
|
|
366
|
+
"url": data.get("url", ""),
|
|
367
|
+
"maintainer_last_speaker": maintainer_last_speaker,
|
|
368
|
+
"last_commenter": last_commenter,
|
|
369
|
+
"last_comment_assoc": last_comment_assoc,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def delta_score(c0, r0, c1, r1):
|
|
374
|
+
return 3.0 * max(c1 - c0, 0) + 2.0 * max(r1 - r0, 0)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def parse_repo_number(url):
|
|
378
|
+
m = re.match(r"https?://github\.com/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)", url or "")
|
|
379
|
+
if not m:
|
|
380
|
+
return None, None
|
|
381
|
+
return f"{m.group(1)}/{m.group(2)}", int(m.group(3))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def parse_issue_url(url):
|
|
385
|
+
if not url:
|
|
386
|
+
return None, None, None
|
|
387
|
+
m = re.search(r"github\.com/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)", url)
|
|
388
|
+
if not m:
|
|
389
|
+
return None, None, None
|
|
390
|
+
return m.group(1), m.group(2), int(m.group(3))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ---------- Prompt -----------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def build_prompt(project, config, candidates, cap, top_report, recent_comments,
|
|
396
|
+
top_topics_report="", style_assignment=None):
|
|
397
|
+
content_angle = build_content_angle(project, config)
|
|
398
|
+
excluded_repos = config.get("exclusions", {}).get("github_repos", [])
|
|
399
|
+
excluded_authors = config.get("exclusions", {}).get("authors", [])
|
|
400
|
+
# Style enforcement: when style_assignment is provided the JSON example
|
|
401
|
+
# pins the assigned style name into engagement_style so the model cannot
|
|
402
|
+
# silently substitute a different label. INVENT mode (style=None) still
|
|
403
|
+
# leaves engagement_style up to the model but it's expected to fill
|
|
404
|
+
# new_style with the registration block. Without an assignment the
|
|
405
|
+
# legacy menu wording is preserved for backward compatibility.
|
|
406
|
+
_assigned_style_name = (style_assignment or {}).get("style")
|
|
407
|
+
_assigned_mode = (style_assignment or {}).get("mode")
|
|
408
|
+
if _assigned_style_name:
|
|
409
|
+
# USE mode: pin literal name.
|
|
410
|
+
_style_field_example = _assigned_style_name
|
|
411
|
+
elif _assigned_mode == "invent":
|
|
412
|
+
# INVENT mode: the model writes a new snake_case name and fills new_style.
|
|
413
|
+
_style_field_example = "<your invented snake_case name>"
|
|
414
|
+
else:
|
|
415
|
+
# No assignment: legacy menu mode.
|
|
416
|
+
_style_field_example = (
|
|
417
|
+
f"<one of {', '.join(sorted(VALID_STYLES))}, or your invented snake_case name>"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
cand_block = []
|
|
421
|
+
for i, c in enumerate(candidates, 1):
|
|
422
|
+
seed_line = f"seed: {c['search_topic']}\n" if c.get("search_topic") else ""
|
|
423
|
+
last_speaker_line = ""
|
|
424
|
+
if c.get("last_commenter"):
|
|
425
|
+
last_speaker_line = (
|
|
426
|
+
f"last_commenter: {c['last_commenter']} "
|
|
427
|
+
f"({c.get('last_comment_assoc') or 'NONE'})\n"
|
|
428
|
+
)
|
|
429
|
+
history_block = ""
|
|
430
|
+
try:
|
|
431
|
+
_hb = _render_author_history(
|
|
432
|
+
"github", c.get("author") or "", days=30, limit=5
|
|
433
|
+
)
|
|
434
|
+
if _hb:
|
|
435
|
+
history_block = _hb + "\n"
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
cand_block.append(
|
|
439
|
+
f"--- #{i} {c['repo']}#{c['number']} delta={c['delta_score']:.1f} "
|
|
440
|
+
f"(cm {c['comment_count_t0']}->{c['comment_count_t1']}, "
|
|
441
|
+
f"rx {c['reaction_count_t0']}->{c['reaction_count_t1']}) ---\n"
|
|
442
|
+
f"{seed_line}"
|
|
443
|
+
f"{last_speaker_line}"
|
|
444
|
+
f"title: {c['title']}\n"
|
|
445
|
+
f"author: {c['author']}\n"
|
|
446
|
+
f"url: {c['url']}\n"
|
|
447
|
+
f"body: {c['body']}\n"
|
|
448
|
+
f"{history_block}"
|
|
449
|
+
)
|
|
450
|
+
candidates_text = "\n".join(cand_block)
|
|
451
|
+
|
|
452
|
+
recent_ctx = ""
|
|
453
|
+
if recent_comments:
|
|
454
|
+
# recent_comments is now a list of (id, content) tuples (2026-05-12
|
|
455
|
+
# change to support generation_trace audit). Accept both shapes
|
|
456
|
+
# here so any caller still passing plain strings keeps working.
|
|
457
|
+
def _extract(item):
|
|
458
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
459
|
+
return item[1]
|
|
460
|
+
return item
|
|
461
|
+
snippets = "\n".join(
|
|
462
|
+
f" - {_extract(c)}"
|
|
463
|
+
for c in recent_comments
|
|
464
|
+
if _extract(c)
|
|
465
|
+
)
|
|
466
|
+
if snippets:
|
|
467
|
+
recent_ctx = f"""
|
|
468
|
+
Your last {len(recent_comments)} GitHub comments (don't repeat talking points):
|
|
469
|
+
{snippets}
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
top_ctx = ""
|
|
473
|
+
if top_report:
|
|
474
|
+
lines = top_report.split("\n")[:30]
|
|
475
|
+
top_ctx = f"""
|
|
476
|
+
## Feedback from past performance:
|
|
477
|
+
{chr(10).join(lines)}
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
top_topics_ctx = ""
|
|
481
|
+
if top_topics_report:
|
|
482
|
+
top_topics_ctx = f"""
|
|
483
|
+
## Past top-performing search topics (sorted by clicks DESC first, then composite-scored: clicks*100 + comments*3 + upvotes)
|
|
484
|
+
CLICKS ARE THE PRIORITY SIGNAL. Any topic with `clicks > 0` is GOLD TIER, clicks
|
|
485
|
+
are the only metric that proves our reply drove someone to actually visit the
|
|
486
|
+
project's link. Comments and upvotes are vanity. If an issue's seed matches a
|
|
487
|
+
gold-tier topic, prefer that issue; mimic ITS framing (repo type, language,
|
|
488
|
+
issue keyword cluster) FIRST before falling back to other styles. Optimize the
|
|
489
|
+
entire pipeline for clicks; everything else is leading indicators.
|
|
490
|
+
|
|
491
|
+
{top_topics_report}
|
|
492
|
+
|
|
493
|
+
If none of the top topics match this run's candidates, prefer issues with
|
|
494
|
+
strong delta scores. New topics with 0 clicks are fine, we still need to
|
|
495
|
+
explore, but a gold-tier topic that fits should beat any unproven topic.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
project_name = project["name"]
|
|
499
|
+
min_relevance = MIN_RELEVANCE
|
|
500
|
+
project_github = (project.get("github") or "").strip()
|
|
501
|
+
github_repo_block = (
|
|
502
|
+
f"\n\n## Our public repo for self-reply links\n{project_github}\n"
|
|
503
|
+
f"When the self-reply policy below applies, the github blob URL MUST live "
|
|
504
|
+
f"under this repo. Pick a real path you have reason to believe exists; if "
|
|
505
|
+
f"you're unsure, default to the repo root or a top-level README rather "
|
|
506
|
+
f"than inventing a deep path."
|
|
507
|
+
if project_github else ""
|
|
508
|
+
)
|
|
509
|
+
return f"""You are the Social Autoposter drafting GitHub issue comments for project {project_name}.
|
|
510
|
+
|
|
511
|
+
Read {SKILL_FILE} for content rules (no em dashes, anti-AI tells, voice).
|
|
512
|
+
|
|
513
|
+
## Project context
|
|
514
|
+
{content_angle}{github_repo_block}
|
|
515
|
+
|
|
516
|
+
## Pre-filtered candidates (top {len(candidates)} by recent engagement delta)
|
|
517
|
+
|
|
518
|
+
Each candidate already cleared exclusion + already-posted filtering. The seed
|
|
519
|
+
shown is the search_topic that surfaced the issue, echo it back verbatim in
|
|
520
|
+
"search_topic" so we can score which seeds produce engagement.
|
|
521
|
+
|
|
522
|
+
{candidates_text}
|
|
523
|
+
{recent_ctx}{top_ctx}{top_topics_ctx}
|
|
524
|
+
{get_styles_prompt("github", context="posting", assignment=style_assignment)}
|
|
525
|
+
|
|
526
|
+
## Targeting
|
|
527
|
+
- Best topics: Agents, Accessibility, Voice/ASR, Tool Use. Prioritize when present.
|
|
528
|
+
- Exclusions are already filtered, but for reference:
|
|
529
|
+
- Excluded repos: {', '.join(excluded_repos) if excluded_repos else '(none)'}
|
|
530
|
+
- Excluded authors: {', '.join(excluded_authors) if excluded_authors else '(none)'}
|
|
531
|
+
|
|
532
|
+
## Comment style (parent comment)
|
|
533
|
+
- Lead with the pain you hit, then your fix. "the token overhead is brutal" beats "here is how to optimize".
|
|
534
|
+
- Conversational, no markdown headings, no code blocks unless tiny.
|
|
535
|
+
- 400-600 chars. Short enough to read, long enough to show concrete observation, not generic advice.
|
|
536
|
+
- File names FROM THE MAINTAINER'S ISSUE OR REPO are great evidence you read it. File names from OUR OWN codebase do NOT belong in the parent comment, save them for the self-reply (see below) where they ride a real URL. Bare filenames from our repos with no URL ("server.rs, ChatToolExecutor.swift") are the spam shape that gets us moderated; never do that.
|
|
537
|
+
- NO links in the parent comment. The optional self-reply is where one link goes.
|
|
538
|
+
|
|
539
|
+
## Self-reply policy (optional follow-up with ONE github link)
|
|
540
|
+
|
|
541
|
+
Each post may carry an OPTIONAL `self_reply_text` that posts as a separate comment a minute or two after the parent. Its job is to point the maintainer at a specific, public file in one of OUR repos that demonstrates a concrete claim the parent comment made.
|
|
542
|
+
|
|
543
|
+
The self-reply ONLY fires when ALL THREE hold:
|
|
544
|
+
1. Your parent comment makes a specific technical claim ("we ran into X and ended up doing Y") that a single file in our repo would back up.
|
|
545
|
+
2. You can point to a REAL https://github.com/ blob URL with a plausible path. Use the project's public repo (see "Project context" above for the `github` field).
|
|
546
|
+
3. The file is genuinely relevant to the maintainer's question, not a tangentially-related drop.
|
|
547
|
+
|
|
548
|
+
If ANY of the three is missing, set `self_reply_text: null`. Quiet bundles are healthy. A forced "here is some code" reply with a bare filename or an off-topic file is the exact pattern that gets us moderated; we'd rather skip the link than post a weak one.
|
|
549
|
+
|
|
550
|
+
Shape when present (100-220 chars, ONE URL, no markdown):
|
|
551
|
+
"our X that handles Y: https://github.com/<our-org>/<our-repo>/blob/main/<path>"
|
|
552
|
+
or just the natural framing + URL. No tagline, no signoff, no project pitch.
|
|
553
|
+
|
|
554
|
+
## Relevance scoring (REQUIRED, drop anything < {min_relevance})
|
|
555
|
+
|
|
556
|
+
For every candidate you draft, also score `relevance` 0..3 vs. the project above:
|
|
557
|
+
- 3 = direct fit. The issue's problem is exactly what {project_name} solves.
|
|
558
|
+
- 2 = relevant. The project's tools, audience, or problem-space could plausibly help.
|
|
559
|
+
- 1 = tangential. Same abstractions, different problem (e.g. caching advice on a
|
|
560
|
+
copy-variation issue). Don't post these.
|
|
561
|
+
- 0 = unrelated. Don't post these.
|
|
562
|
+
|
|
563
|
+
Scoring < {min_relevance} must go to "skipped" with reason "low_relevance".
|
|
564
|
+
The pipeline drops these automatically; do not try to bypass.
|
|
565
|
+
|
|
566
|
+
## Anti-spam guardrails (skip a candidate if ANY apply)
|
|
567
|
+
|
|
568
|
+
Recent strikes were minimized as LOW_QUALITY because we drafted "expert"
|
|
569
|
+
takes that ignored what the maintainer just said. Skip when:
|
|
570
|
+
- `last_commenter` is OWNER/MEMBER/COLLABORATOR (already pre-filtered, but
|
|
571
|
+
re-confirm: if the maintainer's most recent message sets a clear direction,
|
|
572
|
+
don't pile on with a counter-take).
|
|
573
|
+
- The issue is about content/copy/ux/business decisions and you'd have to
|
|
574
|
+
pivot to architecture/perf/caching to have something to say.
|
|
575
|
+
- You'd have to manufacture experience ("I ran this in production at scale...",
|
|
576
|
+
"I've seen this play out dozens of times...") to fill the 400-char budget.
|
|
577
|
+
- Other recent commenters are obviously pitching their own tool. You'll be
|
|
578
|
+
grouped with them by the maintainer.
|
|
579
|
+
- You'd cite a precedent you can't actually link to (Apple ?ppid, Stripe X,
|
|
580
|
+
Shopify Y, etc.). Hand-wavy precedent name-drops read as fake-expert.
|
|
581
|
+
|
|
582
|
+
## YOUR JOB
|
|
583
|
+
|
|
584
|
+
Pick UP TO {cap} candidates worth commenting on and draft one comment for each.
|
|
585
|
+
|
|
586
|
+
ZERO POSTS IS A VALID, FREQUENTLY CORRECT OUTCOME. Returning `"posts": []` and
|
|
587
|
+
listing the candidates in `"skipped"` is preferred over forcing a comment on a
|
|
588
|
+
mediocre fit. The pipeline runs every cycle; quiet cycles are healthy.
|
|
589
|
+
|
|
590
|
+
## Content rules
|
|
591
|
+
{get_content_rules("github")}
|
|
592
|
+
|
|
593
|
+
{get_anti_patterns()}
|
|
594
|
+
|
|
595
|
+
## OUTPUT FORMAT
|
|
596
|
+
|
|
597
|
+
Return ONLY a single JSON object. No prose, no markdown fencing, no Bash calls:
|
|
598
|
+
|
|
599
|
+
{{
|
|
600
|
+
"posts": [
|
|
601
|
+
{{
|
|
602
|
+
"repo": "<owner/repo>",
|
|
603
|
+
"number": <issue number>,
|
|
604
|
+
"thread_url": "<issue url>",
|
|
605
|
+
"thread_title": "<issue title>",
|
|
606
|
+
"thread_author": "<issue author>",
|
|
607
|
+
"matched_project": "{project_name}",
|
|
608
|
+
"engagement_style": "{_style_field_example}",
|
|
609
|
+
"new_style": null,
|
|
610
|
+
"search_topic": "<the seed from the candidate block, copied verbatim>",
|
|
611
|
+
"language": "<ISO 639-1 code matching the issue language: en, ja, zh, es, ...>",
|
|
612
|
+
"relevance": <int 0..3, see scoring rules above; must be >= {min_relevance} to post>,
|
|
613
|
+
"relevance_rationale": "<one short sentence: why this score>",
|
|
614
|
+
"comment_text": "<the actual comment to post, 400-600 chars, NO links>",
|
|
615
|
+
"self_reply_text": <null OR a 100-220 char follow-up containing exactly ONE https://github.com/... blob URL into one of OUR public repos. See "Self-reply policy" above. Default to null unless all three conditions hold.>
|
|
616
|
+
}}
|
|
617
|
+
],
|
|
618
|
+
"skipped": [
|
|
619
|
+
{{ "url": "<issue url>", "reason": "<short reason; use 'low_relevance' when relevance < {min_relevance}>" }}
|
|
620
|
+
]
|
|
621
|
+
}}
|
|
622
|
+
|
|
623
|
+
If, and ONLY if, none of the listed styles fits, you may invent one. Set
|
|
624
|
+
"engagement_style" to your snake_case name AND replace `"new_style": null` with
|
|
625
|
+
`{{"description": "...", "example": "...", "note": "...", "why_existing_didnt_fit": "..."}}`.
|
|
626
|
+
Inventing should be rare; prefer an existing style if it's even 80% right.
|
|
627
|
+
|
|
628
|
+
CRITICAL: Do NOT call gh, Bash, or any tool. The orchestrator already searched
|
|
629
|
+
and viewed; just return the JSON.
|
|
630
|
+
"""
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ---------- Claude one-shot (no tools needed since pre-filter is in Python) -
|
|
634
|
+
|
|
635
|
+
def run_claude(prompt, timeout=900):
|
|
636
|
+
"""One-shot non-streaming Claude via run_claude.sh wrapper. Returns
|
|
637
|
+
(ok, raw_stdout, usage_dict)."""
|
|
638
|
+
usage = {"cost_usd": 0.0, "input_tokens": 0, "output_tokens": 0,
|
|
639
|
+
"cache_read": 0, "cache_create": 0}
|
|
640
|
+
cmd = [RUN_CLAUDE, "post_github",
|
|
641
|
+
"--strict-mcp-config",
|
|
642
|
+
"--mcp-config", os.path.expanduser("~/.claude/browser-agent-configs/no-agents-mcp.json"),
|
|
643
|
+
"-p", "--output-format", "json", prompt]
|
|
644
|
+
env = os.environ.copy()
|
|
645
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
646
|
+
try:
|
|
647
|
+
proc = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout)
|
|
648
|
+
except subprocess.TimeoutExpired:
|
|
649
|
+
return False, "TIMEOUT", usage
|
|
650
|
+
try:
|
|
651
|
+
outer = json.loads(proc.stdout)
|
|
652
|
+
usage["cost_usd"] = float(outer.get("total_cost_usd", 0.0) or 0.0)
|
|
653
|
+
u = outer.get("usage", {}) or {}
|
|
654
|
+
usage["input_tokens"] = int(u.get("input_tokens", 0) or 0)
|
|
655
|
+
usage["output_tokens"] = int(u.get("output_tokens", 0) or 0)
|
|
656
|
+
usage["cache_read"] = int(u.get("cache_read_input_tokens", 0) or 0)
|
|
657
|
+
usage["cache_create"] = int(u.get("cache_creation_input_tokens", 0) or 0)
|
|
658
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
659
|
+
pass
|
|
660
|
+
return proc.returncode == 0, proc.stdout, usage
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def parse_claude_json(output):
|
|
664
|
+
"""Extract the inner JSON object from --output-format json envelope."""
|
|
665
|
+
try:
|
|
666
|
+
outer = json.loads(output)
|
|
667
|
+
result = outer.get("result", "") if isinstance(outer, dict) else str(outer)
|
|
668
|
+
except Exception:
|
|
669
|
+
result = output
|
|
670
|
+
start = result.find("{")
|
|
671
|
+
if start < 0:
|
|
672
|
+
return None
|
|
673
|
+
depth, in_str, esc, end = 0, False, False, -1
|
|
674
|
+
for i in range(start, len(result)):
|
|
675
|
+
ch = result[i]
|
|
676
|
+
if in_str:
|
|
677
|
+
if esc:
|
|
678
|
+
esc = False
|
|
679
|
+
elif ch == "\\":
|
|
680
|
+
esc = True
|
|
681
|
+
elif ch == '"':
|
|
682
|
+
in_str = False
|
|
683
|
+
continue
|
|
684
|
+
if ch == '"':
|
|
685
|
+
in_str = True
|
|
686
|
+
elif ch == "{":
|
|
687
|
+
depth += 1
|
|
688
|
+
elif ch == "}":
|
|
689
|
+
depth -= 1
|
|
690
|
+
if depth == 0:
|
|
691
|
+
end = i
|
|
692
|
+
break
|
|
693
|
+
if end < 0:
|
|
694
|
+
return None
|
|
695
|
+
try:
|
|
696
|
+
return json.loads(result[start:end + 1])
|
|
697
|
+
except Exception:
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# ---------- Posting + logging ------------------------------------------------
|
|
702
|
+
|
|
703
|
+
def post_comment(owner, repo, number, body):
|
|
704
|
+
try:
|
|
705
|
+
out = subprocess.check_output(
|
|
706
|
+
["gh", "issue", "comment", str(number), "-R", f"{owner}/{repo}", "--body", body],
|
|
707
|
+
text=True, timeout=60, stderr=subprocess.STDOUT,
|
|
708
|
+
)
|
|
709
|
+
url = None
|
|
710
|
+
for line in out.strip().splitlines():
|
|
711
|
+
if line.startswith("https://github.com"):
|
|
712
|
+
url = line.strip()
|
|
713
|
+
break
|
|
714
|
+
return True, url
|
|
715
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
716
|
+
err = e.output if hasattr(e, "output") and e.output else str(e)
|
|
717
|
+
return False, str(err)[:300]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def log_post(thread_url, our_url, text, project_name, thread_author, thread_title,
|
|
721
|
+
github_username, engagement_style=None, search_topic=None, language=None,
|
|
722
|
+
claude_session_id=None, generation_trace_path=None, link_source=None):
|
|
723
|
+
"""Defers to github_tools.py log-post, which handles dedup + INSERT.
|
|
724
|
+
|
|
725
|
+
Returns the new posts.id on success, or None on failure / dedup hit.
|
|
726
|
+
Callers who need attribution wiring (e.g. post_links backfill) check
|
|
727
|
+
the return for truthy before calling backfill_post_id.
|
|
728
|
+
|
|
729
|
+
generation_trace_path (added 2026-05-12): optional path to a JSON
|
|
730
|
+
file with the few-shot context Claude saw. Passed to github_tools.py
|
|
731
|
+
as --generation-trace and stored in posts.generation_trace JSONB.
|
|
732
|
+
File-based instead of inline-JSON to keep argv short (the report
|
|
733
|
+
text can be several KB) and to avoid shell-escape pain.
|
|
734
|
+
|
|
735
|
+
link_source (added 2026-05-17): tags audience-page traffic (e.g.
|
|
736
|
+
'audience_page:founder-ghostwriting') so the dashboard can break out
|
|
737
|
+
curated landing-page hits from generic homepage links.
|
|
738
|
+
"""
|
|
739
|
+
try:
|
|
740
|
+
cmd = [PYTHON, GITHUB_TOOLS, "log-post",
|
|
741
|
+
thread_url, our_url or "", text, project_name,
|
|
742
|
+
thread_author or "unknown", thread_title or "",
|
|
743
|
+
"--account", github_username]
|
|
744
|
+
if engagement_style:
|
|
745
|
+
cmd.extend(["--engagement-style", engagement_style])
|
|
746
|
+
if search_topic:
|
|
747
|
+
cmd.extend(["--search-topic", search_topic])
|
|
748
|
+
if language:
|
|
749
|
+
cmd.extend(["--language", language])
|
|
750
|
+
if claude_session_id:
|
|
751
|
+
cmd.extend(["--claude-session-id", claude_session_id])
|
|
752
|
+
if generation_trace_path:
|
|
753
|
+
cmd.extend(["--generation-trace", generation_trace_path])
|
|
754
|
+
if link_source:
|
|
755
|
+
cmd.extend(["--link-source", link_source])
|
|
756
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
|
757
|
+
if result.stdout.strip():
|
|
758
|
+
try:
|
|
759
|
+
parsed = json.loads(result.stdout.strip())
|
|
760
|
+
if parsed.get("error"):
|
|
761
|
+
log(f"log-post error: {parsed}")
|
|
762
|
+
return None
|
|
763
|
+
# Success envelope from github_tools.py log-post should match
|
|
764
|
+
# log_post.py's shape: {"logged": true, "post_id": N, ...}.
|
|
765
|
+
pid = parsed.get("post_id")
|
|
766
|
+
return pid if isinstance(pid, int) else None
|
|
767
|
+
except json.JSONDecodeError:
|
|
768
|
+
pass
|
|
769
|
+
except Exception as e:
|
|
770
|
+
log(f"WARNING: log-post failed: {e}")
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
# ---------- Main -------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
def main():
|
|
777
|
+
parser = argparse.ArgumentParser(
|
|
778
|
+
description="GitHub Issues posting orchestrator (momentum-gated)")
|
|
779
|
+
parser.add_argument("--sleep", type=int, default=600,
|
|
780
|
+
help="Phase 1 -> Phase 2 momentum window in seconds (default 600)")
|
|
781
|
+
parser.add_argument("--limit", type=int, default=None,
|
|
782
|
+
help="Hard ceiling on posts per run; caps the adaptive cap")
|
|
783
|
+
parser.add_argument("--timeout", type=int, default=900,
|
|
784
|
+
help="Claude drafting timeout in seconds")
|
|
785
|
+
parser.add_argument("--project", default=None, help="Override project selection")
|
|
786
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
787
|
+
help="Print prompt + would-post candidates; do not invoke Claude or post")
|
|
788
|
+
args = parser.parse_args()
|
|
789
|
+
|
|
790
|
+
run_start = time.time()
|
|
791
|
+
# Arm the atexit/SIGTERM safety net: it skips emit until run_start is
|
|
792
|
+
# set, so any pre-main exit (argparse, etc.) is a no-op.
|
|
793
|
+
_RUN_STATE["run_start"] = run_start
|
|
794
|
+
log(f"=== GitHub run: sleep={args.sleep}s ===")
|
|
795
|
+
|
|
796
|
+
config = load_config()
|
|
797
|
+
github_username = config.get("accounts", {}).get("github", {}).get("username", "m13v")
|
|
798
|
+
|
|
799
|
+
# ---- Pick project ------------------------------------------------------
|
|
800
|
+
if args.project:
|
|
801
|
+
project = next(
|
|
802
|
+
(p for p in config.get("projects", [])
|
|
803
|
+
if p.get("name", "").lower() == args.project.lower()),
|
|
804
|
+
None,
|
|
805
|
+
)
|
|
806
|
+
if not project:
|
|
807
|
+
log(f"ERROR: project '{args.project}' not found")
|
|
808
|
+
sys.exit(1)
|
|
809
|
+
project_name = project.get("name")
|
|
810
|
+
log(f"Project (forced): {project_name}")
|
|
811
|
+
else:
|
|
812
|
+
# Shared inverse-recent-share picker (scripts/pick_project.py), the
|
|
813
|
+
# same selection logic twitter and reddit use.
|
|
814
|
+
picks = pick_project.pick_projects(config, platform="github", n=1)
|
|
815
|
+
project = picks[0] if picks else None
|
|
816
|
+
if project is None:
|
|
817
|
+
log("ERROR: no eligible project (none have search_topics)")
|
|
818
|
+
sys.exit(1)
|
|
819
|
+
project_name = project.get("name")
|
|
820
|
+
log(f"Project (inverse-recent-share): {project_name} "
|
|
821
|
+
f"(weight={project.get('weight', 0)})")
|
|
822
|
+
|
|
823
|
+
# ---- Phase 1: search topics, T0 snapshot -------------------------------
|
|
824
|
+
topics_pool = list(topics_for_project(project["name"]))
|
|
825
|
+
if not topics_pool:
|
|
826
|
+
log("Project has no topics to search. Exiting.")
|
|
827
|
+
sys.exit(0)
|
|
828
|
+
# Shuffle before slicing so each run samples a different MAX_TOPICS_PER_PROJECT
|
|
829
|
+
# subset. Without this, projects with >6 seeds always query the first 6, which
|
|
830
|
+
# starves diverse coverage and biases top_search_topics scoring (c0nsl run on
|
|
831
|
+
# 2026-04-24 yielded only 2 candidates because its first 6 seeds were narrow).
|
|
832
|
+
random.shuffle(topics_pool)
|
|
833
|
+
topics = topics_pool[:MAX_TOPICS_PER_PROJECT]
|
|
834
|
+
|
|
835
|
+
log(f"Phase 1: searching {len(topics)} topic queries...")
|
|
836
|
+
raw = []
|
|
837
|
+
seen_urls = set()
|
|
838
|
+
for topic in topics:
|
|
839
|
+
for item in gh_search(topic):
|
|
840
|
+
url = item.get("url")
|
|
841
|
+
if url and url not in seen_urls:
|
|
842
|
+
seen_urls.add(url)
|
|
843
|
+
# Stamp the originating seed so it survives dedup -> INSERT and
|
|
844
|
+
# feeds top_search_topics scoring on the next run.
|
|
845
|
+
item["search_topic"] = topic
|
|
846
|
+
raw.append(item)
|
|
847
|
+
log(f"Phase 1: {len(raw)} unique issues after dedup + already-posted filter")
|
|
848
|
+
if not raw:
|
|
849
|
+
log("No candidates. Exiting.")
|
|
850
|
+
sys.exit(0)
|
|
851
|
+
|
|
852
|
+
candidates = []
|
|
853
|
+
skipped_maintainer = 0
|
|
854
|
+
for item in raw[:CLAUDE_CANDIDATE_LIMIT * 3]:
|
|
855
|
+
repo, number = parse_repo_number(item.get("url"))
|
|
856
|
+
if not repo:
|
|
857
|
+
continue
|
|
858
|
+
counts = gh_view_counts(repo, number)
|
|
859
|
+
if not counts:
|
|
860
|
+
continue
|
|
861
|
+
# Maintainer-just-spoke gate. If the most recent comment is from someone
|
|
862
|
+
# with push access (OWNER/MEMBER/COLLABORATOR), they are driving the
|
|
863
|
+
# thread, so piling on reads as noise and risks LOW_QUALITY hide.
|
|
864
|
+
if counts.get("maintainer_last_speaker"):
|
|
865
|
+
skipped_maintainer += 1
|
|
866
|
+
log(
|
|
867
|
+
f" skip {repo}#{number}: maintainer-just-spoke "
|
|
868
|
+
f"(last={counts.get('last_commenter')}/"
|
|
869
|
+
f"{counts.get('last_comment_assoc')})"
|
|
870
|
+
)
|
|
871
|
+
continue
|
|
872
|
+
candidates.append({
|
|
873
|
+
"repo": repo,
|
|
874
|
+
"number": number,
|
|
875
|
+
"url": counts["url"],
|
|
876
|
+
"title": counts["title"],
|
|
877
|
+
"body": counts["body"],
|
|
878
|
+
"author": counts["author"],
|
|
879
|
+
"comment_count_t0": counts["comment_count"],
|
|
880
|
+
"reaction_count_t0": counts["reaction_count"],
|
|
881
|
+
"search_topic": item.get("search_topic"),
|
|
882
|
+
})
|
|
883
|
+
log(
|
|
884
|
+
f"Phase 1: {len(candidates)} candidates with T0 snapshot "
|
|
885
|
+
f"(skipped {skipped_maintainer} for maintainer-just-spoke)"
|
|
886
|
+
)
|
|
887
|
+
if not candidates:
|
|
888
|
+
log("No live open issues to re-poll. Exiting.")
|
|
889
|
+
sys.exit(0)
|
|
890
|
+
|
|
891
|
+
# ---- Sleep -------------------------------------------------------------
|
|
892
|
+
log(f"Sleeping {args.sleep}s before T1...")
|
|
893
|
+
time.sleep(args.sleep)
|
|
894
|
+
|
|
895
|
+
# ---- Phase 2a: re-poll T1 ---------------------------------------------
|
|
896
|
+
log("Phase 2a: re-polling T1 counts...")
|
|
897
|
+
survivors = []
|
|
898
|
+
skipped_maintainer_phase2 = 0
|
|
899
|
+
for c in candidates:
|
|
900
|
+
counts = gh_view_counts(c["repo"], c["number"])
|
|
901
|
+
if not counts:
|
|
902
|
+
c["comment_count_t1"] = c["comment_count_t0"]
|
|
903
|
+
c["reaction_count_t1"] = c["reaction_count_t0"]
|
|
904
|
+
c["delta_score"] = 0.0
|
|
905
|
+
survivors.append(c)
|
|
906
|
+
continue
|
|
907
|
+
# Re-check maintainer-just-spoke gate. A maintainer may have arrived
|
|
908
|
+
# during the sleep window. If so, drop to avoid piling on.
|
|
909
|
+
if counts.get("maintainer_last_speaker"):
|
|
910
|
+
skipped_maintainer_phase2 += 1
|
|
911
|
+
log(
|
|
912
|
+
f" phase2 skip {c['repo']}#{c['number']}: maintainer arrived "
|
|
913
|
+
f"during sleep (last={counts.get('last_commenter')}/"
|
|
914
|
+
f"{counts.get('last_comment_assoc')})"
|
|
915
|
+
)
|
|
916
|
+
continue
|
|
917
|
+
c["comment_count_t1"] = counts["comment_count"]
|
|
918
|
+
c["reaction_count_t1"] = counts["reaction_count"]
|
|
919
|
+
c["delta_score"] = delta_score(
|
|
920
|
+
c["comment_count_t0"], c["reaction_count_t0"],
|
|
921
|
+
c["comment_count_t1"], c["reaction_count_t1"],
|
|
922
|
+
)
|
|
923
|
+
survivors.append(c)
|
|
924
|
+
if skipped_maintainer_phase2:
|
|
925
|
+
log(
|
|
926
|
+
f"Phase 2a: dropped {skipped_maintainer_phase2} candidates "
|
|
927
|
+
f"after maintainer comment during sleep"
|
|
928
|
+
)
|
|
929
|
+
candidates = survivors
|
|
930
|
+
if not candidates:
|
|
931
|
+
log("Phase 2a: no candidates left after maintainer recheck. Exiting.")
|
|
932
|
+
sys.exit(0)
|
|
933
|
+
|
|
934
|
+
# ---- Phase 2b: adaptive cap -------------------------------------------
|
|
935
|
+
high_delta = [c for c in candidates if c["delta_score"] >= DELTA_THRESHOLD]
|
|
936
|
+
cap = CAP_BUMPED if len(high_delta) >= HIGH_DELTA_BUMP else CAP_DEFAULT
|
|
937
|
+
if args.limit is not None:
|
|
938
|
+
cap = min(cap, max(0, args.limit))
|
|
939
|
+
log(f"Phase 2b: {len(high_delta)} high-momentum candidates -> cap = {cap}")
|
|
940
|
+
|
|
941
|
+
candidates.sort(key=lambda c: c["delta_score"], reverse=True)
|
|
942
|
+
top = candidates[:CLAUDE_CANDIDATE_LIMIT]
|
|
943
|
+
log(f"Phase 2b: showing Claude top {len(top)} by delta, cap = {cap}")
|
|
944
|
+
|
|
945
|
+
if cap <= 0:
|
|
946
|
+
log("cap=0, nothing to post. Exiting.")
|
|
947
|
+
sys.exit(0)
|
|
948
|
+
|
|
949
|
+
top_report = get_top_performers(project_name)
|
|
950
|
+
recent_comments = get_recent_comments()
|
|
951
|
+
top_topics_report = get_top_search_topics(project_name, platform="github")
|
|
952
|
+
|
|
953
|
+
# 2026-05-22: pick the engagement style for this draft batch ONCE so
|
|
954
|
+
# validate_or_register can enforce the picker's choice on every post in
|
|
955
|
+
# the batch (USE mode coerces drift back; INVENT mode lets the model
|
|
956
|
+
# register a new style). GitHub batches share one assignment per cycle;
|
|
957
|
+
# cycles run frequently enough that the picker's distribution averages
|
|
958
|
+
# out across batches. Per-candidate assignment would require N picker
|
|
959
|
+
# calls + injected per-candidate blocks; deferred until the data shows
|
|
960
|
+
# it matters.
|
|
961
|
+
style_assignment = pick_style_for_post("github", context="posting")
|
|
962
|
+
log(f"Style assignment for this batch: mode={style_assignment.get('mode')} "
|
|
963
|
+
f"style={style_assignment.get('style') or '(invent)'}")
|
|
964
|
+
|
|
965
|
+
prompt = build_prompt(project, config, top, cap, top_report,
|
|
966
|
+
recent_comments, top_topics_report=top_topics_report,
|
|
967
|
+
style_assignment=style_assignment)
|
|
968
|
+
|
|
969
|
+
# Build the generation_trace audit blob: what Claude is about to see.
|
|
970
|
+
# Captured BEFORE the Claude call so we never end up with a post row
|
|
971
|
+
# missing its trace (e.g. if Claude errors out, we never call
|
|
972
|
+
# log_post and the file is GC'd by the OS). Same trace path is used
|
|
973
|
+
# for every post produced from this Claude invocation, since they
|
|
974
|
+
# all saw the same few-shot context.
|
|
975
|
+
#
|
|
976
|
+
# Why a temp file: argv has a ~256 KB cap on macOS and the top_report
|
|
977
|
+
# alone can run several KB. The path travels through 3 hops
|
|
978
|
+
# (post_github → log_post() → github_tools.py log-post) and stays
|
|
979
|
+
# cheap to pass; the JSON body only deserializes once at the SQL
|
|
980
|
+
# INSERT step. tempfile.NamedTemporaryFile(delete=False) so the file
|
|
981
|
+
# survives the with-block close and the child process can read it.
|
|
982
|
+
generation_trace_path = None
|
|
983
|
+
try:
|
|
984
|
+
trace = _gen_trace.build_trace(
|
|
985
|
+
platform="github",
|
|
986
|
+
project_name=project_name,
|
|
987
|
+
prompt_chars=len(prompt),
|
|
988
|
+
top_performers_text=top_report or "",
|
|
989
|
+
top_search_topics_text=top_topics_report or "",
|
|
990
|
+
# recent_comments here is the list of (id, content) tuples
|
|
991
|
+
# from get_recent_comments(); extract just the IDs.
|
|
992
|
+
recent_comment_ids=[pid for pid, _ in (recent_comments or [])],
|
|
993
|
+
model=None,
|
|
994
|
+
min_score_floor=5, # PLATFORM_MIN_SCORE['github']
|
|
995
|
+
)
|
|
996
|
+
generation_trace_path = _gen_trace.write_trace_tempfile(
|
|
997
|
+
trace, prefix="github_gen_trace_",
|
|
998
|
+
)
|
|
999
|
+
if generation_trace_path:
|
|
1000
|
+
log(f"Generation trace: {generation_trace_path} "
|
|
1001
|
+
f"({os.path.getsize(generation_trace_path)} bytes)")
|
|
1002
|
+
except Exception as e:
|
|
1003
|
+
# Audit row is nice-to-have, never a blocker. Log and continue.
|
|
1004
|
+
log(f"WARNING: generation_trace build failed ({e}); proceeding without trace")
|
|
1005
|
+
|
|
1006
|
+
if args.dry_run:
|
|
1007
|
+
log("=== DRY RUN ===")
|
|
1008
|
+
log(f"Prompt length: {len(prompt)} chars")
|
|
1009
|
+
if generation_trace_path:
|
|
1010
|
+
log(f"Trace would be saved with each post: {generation_trace_path}")
|
|
1011
|
+
for c in top[:cap]:
|
|
1012
|
+
log(f" would consider {c['repo']}#{c['number']} "
|
|
1013
|
+
f"delta={c['delta_score']:.1f} title={c['title'][:60]}")
|
|
1014
|
+
return
|
|
1015
|
+
|
|
1016
|
+
# ---- Phase 2b: invoke Claude (one-shot, no tools) ----------------------
|
|
1017
|
+
claude_session_id = str(uuid.uuid4())
|
|
1018
|
+
os.environ["CLAUDE_SESSION_ID"] = claude_session_id
|
|
1019
|
+
log("Phase 2b: invoking Claude for drafting...")
|
|
1020
|
+
claude_start = time.time()
|
|
1021
|
+
ok, output, usage = run_claude(prompt, timeout=args.timeout)
|
|
1022
|
+
log(f"Claude finished in {time.time() - claude_start:.0f}s (${usage['cost_usd']:.4f})")
|
|
1023
|
+
# Mirror cost into the safety-net state so a SIGTERM after this point
|
|
1024
|
+
# records the spend even if we never reach the post loop.
|
|
1025
|
+
_RUN_STATE["cost"] = usage["cost_usd"]
|
|
1026
|
+
|
|
1027
|
+
if not ok:
|
|
1028
|
+
log(f"Claude FAILED: {output[:300]}")
|
|
1029
|
+
_RUN_STATE["failed"] = 1
|
|
1030
|
+
subprocess.run([
|
|
1031
|
+
PYTHON, os.path.join(SCRIPTS, "log_run.py"),
|
|
1032
|
+
"--script", "post_github",
|
|
1033
|
+
"--posted", "0", "--skipped", "0", "--failed", "1",
|
|
1034
|
+
"--cost", f"{usage['cost_usd']:.4f}",
|
|
1035
|
+
"--elapsed", f"{int(time.time() - run_start)}",
|
|
1036
|
+
])
|
|
1037
|
+
# Mark emitted so the atexit handler doesn't double-write the
|
|
1038
|
+
# tailored Claude-failure summary above.
|
|
1039
|
+
_RUN_STATE["emitted"] = True
|
|
1040
|
+
sys.exit(1)
|
|
1041
|
+
|
|
1042
|
+
decisions = parse_claude_json(output) or {}
|
|
1043
|
+
posts = decisions.get("posts", []) or []
|
|
1044
|
+
skipped = decisions.get("skipped", []) or []
|
|
1045
|
+
log(f"Claude picked {len(posts)}, skipped {len(skipped)}")
|
|
1046
|
+
|
|
1047
|
+
# Relevance gate. Anything Claude scored below MIN_RELEVANCE goes to the
|
|
1048
|
+
# skipped bucket, NOT posted, regardless of how confident the comment_text
|
|
1049
|
+
# reads. This is the programmatic backstop for the prompt rule.
|
|
1050
|
+
relevance_dropped = []
|
|
1051
|
+
kept_posts = []
|
|
1052
|
+
for d in posts:
|
|
1053
|
+
try:
|
|
1054
|
+
rel = int(d.get("relevance", 0))
|
|
1055
|
+
except (TypeError, ValueError):
|
|
1056
|
+
rel = 0
|
|
1057
|
+
if rel < MIN_RELEVANCE:
|
|
1058
|
+
relevance_dropped.append({
|
|
1059
|
+
"url": d.get("thread_url", ""),
|
|
1060
|
+
"reason": (
|
|
1061
|
+
f"low_relevance (relevance={rel}, "
|
|
1062
|
+
f"rationale={(d.get('relevance_rationale') or '').strip()[:120]})"
|
|
1063
|
+
),
|
|
1064
|
+
})
|
|
1065
|
+
else:
|
|
1066
|
+
kept_posts.append(d)
|
|
1067
|
+
if relevance_dropped:
|
|
1068
|
+
log(
|
|
1069
|
+
f"Relevance gate dropped {len(relevance_dropped)}/{len(posts)} "
|
|
1070
|
+
f"draft(s) below MIN_RELEVANCE={MIN_RELEVANCE}"
|
|
1071
|
+
)
|
|
1072
|
+
for r in relevance_dropped:
|
|
1073
|
+
log(f" drop {r['url']}: {r['reason']}")
|
|
1074
|
+
skipped.extend(relevance_dropped)
|
|
1075
|
+
posts = kept_posts
|
|
1076
|
+
|
|
1077
|
+
if not posts:
|
|
1078
|
+
log("No valid post decisions. Last 500 chars of output:")
|
|
1079
|
+
log(output.strip()[-500:])
|
|
1080
|
+
|
|
1081
|
+
posted = 0
|
|
1082
|
+
failed = 0
|
|
1083
|
+
for i, decision in enumerate(posts):
|
|
1084
|
+
thread_url = decision.get("thread_url", "")
|
|
1085
|
+
text = (decision.get("comment_text") or "").strip()
|
|
1086
|
+
thread_author = decision.get("thread_author", "unknown")
|
|
1087
|
+
thread_title = decision.get("thread_title", "")
|
|
1088
|
+
# validate_or_register enforces the picker's batch-level assignment:
|
|
1089
|
+
# in USE mode any drifted engagement_style label is silently coerced
|
|
1090
|
+
# back to the assigned name; in INVENT mode the new_style block is
|
|
1091
|
+
# registered into engagement_styles_registry via the s4l API. All
|
|
1092
|
+
# posts in this batch share style_assignment by design (see picker
|
|
1093
|
+
# call above).
|
|
1094
|
+
engagement_style, _style_action = validate_or_register(
|
|
1095
|
+
decision,
|
|
1096
|
+
source_post={
|
|
1097
|
+
"platform": "github",
|
|
1098
|
+
"post_url": thread_url,
|
|
1099
|
+
"post_id": None,
|
|
1100
|
+
"model": decision.get("model"),
|
|
1101
|
+
},
|
|
1102
|
+
assigned_style=(style_assignment or {}).get("style"),
|
|
1103
|
+
assigned_mode=(style_assignment or {}).get("mode"),
|
|
1104
|
+
)
|
|
1105
|
+
language = (decision.get("language") or "en").strip().lower()[:5] or "en"
|
|
1106
|
+
|
|
1107
|
+
owner, repo, number = parse_issue_url(thread_url)
|
|
1108
|
+
if not owner or not text:
|
|
1109
|
+
log(f"SKIP: bad URL or empty text: {thread_url}")
|
|
1110
|
+
failed += 1
|
|
1111
|
+
_RUN_STATE["failed"] = failed
|
|
1112
|
+
continue
|
|
1113
|
+
|
|
1114
|
+
# URL-wrap before sending to GitHub. project for wrapping is the
|
|
1115
|
+
# decision-resolved match (e.g., the project whose repo the issue
|
|
1116
|
+
# belongs to) or the orchestrator's own project_name. log_post
|
|
1117
|
+
# uses the same fallback chain so attribution lines up.
|
|
1118
|
+
wrap_project = (decision.get("matched_project") or project_name or "").strip()
|
|
1119
|
+
minted_session = None
|
|
1120
|
+
# Audience-page detection (2026-05-17). Inspect the unwrapped text for
|
|
1121
|
+
# any URL that exactly matches a curated audience-page (e.g.
|
|
1122
|
+
# https://s4l.ai/ghostwriting). When found, posts.link_source is
|
|
1123
|
+
# stamped 'audience_page:<angle>' for the row. Detection runs BEFORE
|
|
1124
|
+
# wrap_text_for_post because wrapping rewrites the URLs to /r/<code>
|
|
1125
|
+
# short links; classify_url_as_audience_page() needs the original
|
|
1126
|
+
# target URL.
|
|
1127
|
+
audience_page_link_source = None
|
|
1128
|
+
if wrap_project:
|
|
1129
|
+
try:
|
|
1130
|
+
for _url_m in re.finditer(r'https?://[^\s)\]>"\']+', text):
|
|
1131
|
+
_raw = _url_m.group(0).rstrip('.,);!?]')
|
|
1132
|
+
_angle = _audience_classify_url(_raw, wrap_project)
|
|
1133
|
+
if _angle:
|
|
1134
|
+
audience_page_link_source = f"audience_page:{_angle}"
|
|
1135
|
+
break
|
|
1136
|
+
except Exception as _e:
|
|
1137
|
+
log(f"WARNING: audience-page classify raised ({_e})")
|
|
1138
|
+
if wrap_project:
|
|
1139
|
+
try:
|
|
1140
|
+
from dm_short_links import wrap_text_for_post, utm_only_text
|
|
1141
|
+
wrap_res = wrap_text_for_post(text=text, platform="github_issues",
|
|
1142
|
+
project_name=wrap_project)
|
|
1143
|
+
if wrap_res.get("ok"):
|
|
1144
|
+
text = wrap_res["text"]
|
|
1145
|
+
minted_session = wrap_res.get("minted_session")
|
|
1146
|
+
if wrap_res.get("codes"):
|
|
1147
|
+
log(f"wrapped {len(wrap_res['codes'])} URL(s): {wrap_res['codes']}")
|
|
1148
|
+
else:
|
|
1149
|
+
log(f"WARNING: URL wrap failed ({wrap_res.get('error')}); falling back to UTM-only")
|
|
1150
|
+
text = utm_only_text(text=text, platform="github_issues", project_name=wrap_project)
|
|
1151
|
+
except Exception as e:
|
|
1152
|
+
log(f"WARNING: URL wrap raised ({e}); falling back to UTM-only")
|
|
1153
|
+
try:
|
|
1154
|
+
from dm_short_links import utm_only_text
|
|
1155
|
+
text = utm_only_text(text=text, platform="github_issues", project_name=wrap_project)
|
|
1156
|
+
except Exception as ee:
|
|
1157
|
+
log(f"WARNING: UTM-only fallback also failed ({ee}); posting unwrapped")
|
|
1158
|
+
|
|
1159
|
+
log(f"Posting {i + 1}/{len(posts)} -> {owner}/{repo}#{number}: {thread_title[:60]}")
|
|
1160
|
+
ok_post, url_or_err = post_comment(owner, repo, number, text)
|
|
1161
|
+
if not ok_post:
|
|
1162
|
+
log(f"POST FAILED: {url_or_err}")
|
|
1163
|
+
failed += 1
|
|
1164
|
+
_RUN_STATE["failed"] = failed
|
|
1165
|
+
time.sleep(3)
|
|
1166
|
+
continue
|
|
1167
|
+
|
|
1168
|
+
new_post_id = log_post(
|
|
1169
|
+
thread_url, url_or_err, text,
|
|
1170
|
+
decision.get("matched_project") or project_name,
|
|
1171
|
+
thread_author, thread_title, github_username,
|
|
1172
|
+
engagement_style=engagement_style,
|
|
1173
|
+
search_topic=(decision.get("search_topic") or "").strip() or None,
|
|
1174
|
+
language=language,
|
|
1175
|
+
claude_session_id=claude_session_id,
|
|
1176
|
+
# Same trace blob for every post in this run — they all saw
|
|
1177
|
+
# the same few-shot context. If the trace file couldn't be
|
|
1178
|
+
# built earlier this is None and log_post drops the flag.
|
|
1179
|
+
generation_trace_path=generation_trace_path,
|
|
1180
|
+
link_source=audience_page_link_source,
|
|
1181
|
+
)
|
|
1182
|
+
# Stamp post_links.post_id for the URLs minted before posting.
|
|
1183
|
+
# Idempotent; no-op when minted_session is None or the dedup path
|
|
1184
|
+
# in github_tools.py log-post returned no post_id (e.g., dup thread).
|
|
1185
|
+
if minted_session and new_post_id:
|
|
1186
|
+
try:
|
|
1187
|
+
from dm_short_links import backfill_post_id
|
|
1188
|
+
backfill_post_id(minted_session=minted_session, post_id=new_post_id)
|
|
1189
|
+
except Exception as e:
|
|
1190
|
+
log(f"WARNING: backfill_post_id failed ({e})")
|
|
1191
|
+
posted += 1
|
|
1192
|
+
# Keep the safety-net counters in sync after each successful post so
|
|
1193
|
+
# a SIGTERM mid-loop still emits the partial-but-correct count.
|
|
1194
|
+
_RUN_STATE["posted"] = posted
|
|
1195
|
+
_RUN_STATE["failed"] = failed
|
|
1196
|
+
_RUN_STATE["skipped"] = len(skipped)
|
|
1197
|
+
log(f"POSTED: {url_or_err or 'ok'}")
|
|
1198
|
+
|
|
1199
|
+
# ---- Optional self-reply with ONE github blob URL ------------------
|
|
1200
|
+
# Restored 2026-05-17 after the April 13 over-correction stripped
|
|
1201
|
+
# github of all CTAs (zero link_edit_content rows in May). The bundle
|
|
1202
|
+
# is the proven pattern for driving clicks; the model gets explicit
|
|
1203
|
+
# license to skip it (self_reply_text=null) when it can't point to a
|
|
1204
|
+
# genuinely relevant file in our repos. See the "Self-reply policy"
|
|
1205
|
+
# section of build_prompt() for the three-condition skip rule.
|
|
1206
|
+
sr_raw = (decision.get("self_reply_text") or "")
|
|
1207
|
+
sr_text = sr_raw.strip() if isinstance(sr_raw, str) else ""
|
|
1208
|
+
if sr_text and re.search(r"https?://github\.com/", sr_text):
|
|
1209
|
+
# 30-90s jitter between parent and child. The March 18 strike pair
|
|
1210
|
+
# (3072/3073) posted 0.2s apart, which is the bot-signature
|
|
1211
|
+
# timing. Humans don't follow up that fast. Random within the
|
|
1212
|
+
# window so we don't accumulate a uniform-timing fingerprint.
|
|
1213
|
+
sr_delay = random.randint(30, 90)
|
|
1214
|
+
log(f"Self-reply queued after {sr_delay}s delay...")
|
|
1215
|
+
time.sleep(sr_delay)
|
|
1216
|
+
|
|
1217
|
+
# URL-wrap the self-reply via dm_short_links so the github blob
|
|
1218
|
+
# link gets /r/<code> or UTM-tagged attribution, same as every
|
|
1219
|
+
# other URL in the pipeline. CLAUDE.md: "Never post bare URLs."
|
|
1220
|
+
sr_minted_session = None
|
|
1221
|
+
if wrap_project:
|
|
1222
|
+
try:
|
|
1223
|
+
from dm_short_links import wrap_text_for_post, utm_only_text
|
|
1224
|
+
sr_wrap = wrap_text_for_post(text=sr_text, platform="github_issues",
|
|
1225
|
+
project_name=wrap_project)
|
|
1226
|
+
if sr_wrap.get("ok"):
|
|
1227
|
+
sr_text = sr_wrap["text"]
|
|
1228
|
+
sr_minted_session = sr_wrap.get("minted_session")
|
|
1229
|
+
if sr_wrap.get("codes"):
|
|
1230
|
+
log(f"self-reply wrapped {len(sr_wrap['codes'])} URL(s): "
|
|
1231
|
+
f"{sr_wrap['codes']}")
|
|
1232
|
+
else:
|
|
1233
|
+
log(f"WARNING: self-reply URL wrap failed "
|
|
1234
|
+
f"({sr_wrap.get('error')}); falling back to UTM-only")
|
|
1235
|
+
sr_text = utm_only_text(text=sr_text, platform="github_issues",
|
|
1236
|
+
project_name=wrap_project)
|
|
1237
|
+
except Exception as e:
|
|
1238
|
+
log(f"WARNING: self-reply URL wrap raised ({e}); "
|
|
1239
|
+
f"falling back to UTM-only")
|
|
1240
|
+
try:
|
|
1241
|
+
from dm_short_links import utm_only_text
|
|
1242
|
+
sr_text = utm_only_text(text=sr_text, platform="github_issues",
|
|
1243
|
+
project_name=wrap_project)
|
|
1244
|
+
except Exception as ee:
|
|
1245
|
+
log(f"WARNING: self-reply UTM-only fallback also failed ({ee}); "
|
|
1246
|
+
f"posting unwrapped")
|
|
1247
|
+
|
|
1248
|
+
ok_sr, sr_url_or_err = post_comment(owner, repo, number, sr_text)
|
|
1249
|
+
if ok_sr:
|
|
1250
|
+
sr_post_id = log_post(
|
|
1251
|
+
thread_url, sr_url_or_err, sr_text,
|
|
1252
|
+
decision.get("matched_project") or project_name,
|
|
1253
|
+
thread_author, thread_title, github_username,
|
|
1254
|
+
engagement_style=engagement_style,
|
|
1255
|
+
search_topic=(decision.get("search_topic") or "").strip() or None,
|
|
1256
|
+
language=language,
|
|
1257
|
+
claude_session_id=claude_session_id,
|
|
1258
|
+
generation_trace_path=generation_trace_path,
|
|
1259
|
+
link_source=audience_page_link_source,
|
|
1260
|
+
)
|
|
1261
|
+
if sr_minted_session and sr_post_id:
|
|
1262
|
+
try:
|
|
1263
|
+
from dm_short_links import backfill_post_id
|
|
1264
|
+
backfill_post_id(minted_session=sr_minted_session,
|
|
1265
|
+
post_id=sr_post_id)
|
|
1266
|
+
except Exception as e:
|
|
1267
|
+
log(f"WARNING: self-reply backfill_post_id failed ({e})")
|
|
1268
|
+
posted += 1
|
|
1269
|
+
_RUN_STATE["posted"] = posted
|
|
1270
|
+
log(f"SELF-REPLY POSTED: {sr_url_or_err or 'ok'}")
|
|
1271
|
+
else:
|
|
1272
|
+
log(f"SELF-REPLY FAILED: {sr_url_or_err}")
|
|
1273
|
+
failed += 1
|
|
1274
|
+
_RUN_STATE["failed"] = failed
|
|
1275
|
+
|
|
1276
|
+
time.sleep(3)
|
|
1277
|
+
|
|
1278
|
+
# Clean up the generation_trace temp file. By this point every post
|
|
1279
|
+
# that landed has its trace persisted to posts.generation_trace JSONB,
|
|
1280
|
+
# so the on-disk JSON is redundant. macOS would eventually purge
|
|
1281
|
+
# /var/folders/, but explicit cleanup keeps temp dirs tidy when this
|
|
1282
|
+
# runs every 20 min via launchd.
|
|
1283
|
+
_gen_trace.cleanup_trace_tempfile(generation_trace_path)
|
|
1284
|
+
|
|
1285
|
+
total_elapsed = time.time() - run_start
|
|
1286
|
+
log(f"=== SUMMARY: elapsed={total_elapsed:.0f}s posted={posted} failed={failed} ===")
|
|
1287
|
+
log(f"Tokens: input={usage['input_tokens']} output={usage['output_tokens']} "
|
|
1288
|
+
f"cache_read={usage['cache_read']} cache_create={usage['cache_create']}")
|
|
1289
|
+
log(f"Cost: ${usage['cost_usd']:.4f}")
|
|
1290
|
+
|
|
1291
|
+
# Final happy-path summary write. Sync the safety-net state in case the
|
|
1292
|
+
# last post-loop iteration didn't (e.g. zero candidates kept), then mark
|
|
1293
|
+
# emitted so the atexit handler short-circuits.
|
|
1294
|
+
_RUN_STATE["posted"] = posted
|
|
1295
|
+
_RUN_STATE["failed"] = failed
|
|
1296
|
+
_RUN_STATE["skipped"] = len(skipped)
|
|
1297
|
+
_RUN_STATE["cost"] = usage["cost_usd"]
|
|
1298
|
+
subprocess.run([
|
|
1299
|
+
PYTHON, os.path.join(SCRIPTS, "log_run.py"),
|
|
1300
|
+
"--script", "post_github",
|
|
1301
|
+
"--posted", str(posted),
|
|
1302
|
+
"--skipped", str(len(skipped)),
|
|
1303
|
+
"--failed", str(failed),
|
|
1304
|
+
"--cost", f"{usage['cost_usd']:.4f}",
|
|
1305
|
+
"--elapsed", f"{int(total_elapsed)}",
|
|
1306
|
+
])
|
|
1307
|
+
_RUN_STATE["emitted"] = True
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
if __name__ == "__main__":
|
|
1311
|
+
main()
|