@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,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""twitter_cookie_mirror.py - local 0600 mirror of the managed X session cookies.
|
|
3
|
+
|
|
4
|
+
Why this exists (Gap B, 2026-06-02)
|
|
5
|
+
-----------------------------------
|
|
6
|
+
On a persistent (non-VM) machine the server-side session store
|
|
7
|
+
(social_accounts.session_cookies) is SKIPPED during connect_x because there is no
|
|
8
|
+
social_accounts row to attach the cookies to. That left Chrome's own encrypted
|
|
9
|
+
Cookies SQLite as the ONLY thing keeping the X session across a Chrome relaunch.
|
|
10
|
+
|
|
11
|
+
That store is not durable on a headless / SSH box: macOS encrypts Chrome cookies
|
|
12
|
+
with the per-app `Chrome Safe Storage` key, which lives in the login keychain.
|
|
13
|
+
When the keychain re-locks (idle ~5 min) between the import and the next Chrome
|
|
14
|
+
launch, the freshly-launched Chrome cannot read the Safe Storage key, cannot
|
|
15
|
+
decrypt the existing blobs, and reinitializes the Cookies DB to an empty schema.
|
|
16
|
+
The imported session silently evaporates between `connect_x` and the first cycle.
|
|
17
|
+
|
|
18
|
+
This module is the keychain-independent durability layer. On a successful import
|
|
19
|
+
connect_x writes the validated x.com/twitter.com cookies (CDP-shaped, straight
|
|
20
|
+
from Network.getAllCookies) here as plaintext JSON, and the cycle preflight
|
|
21
|
+
(restore_twitter_session.py, invoked from skill/lib/twitter-backend.sh) re-injects
|
|
22
|
+
them via CDP whenever the live session comes up logged out. A keychain re-lock or
|
|
23
|
+
a wiped Cookies DB is therefore no longer fatal — the next cycle restores.
|
|
24
|
+
|
|
25
|
+
Security
|
|
26
|
+
--------
|
|
27
|
+
The file grants access to the X account: it is exactly as sensitive as the Chrome
|
|
28
|
+
profile itself, and is written 0600 (owner read/write only). Treat it like a
|
|
29
|
+
token. It is intentionally NOT encrypted — the whole point is to survive a locked
|
|
30
|
+
keychain, so adding a keychain-derived key would reintroduce the dependency this
|
|
31
|
+
file exists to remove. On a multi-user host, restrict the home directory.
|
|
32
|
+
|
|
33
|
+
CLI (debug / doctor):
|
|
34
|
+
python3 twitter_cookie_mirror.py count # prints the mirrored cookie count
|
|
35
|
+
python3 twitter_cookie_mirror.py path # prints the mirror file path
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
# Sibling of the harness profile dir, NOT inside it: a VM profile reseed wipes
|
|
46
|
+
# the profile but a persistent machine keeps this file across Chrome relaunches.
|
|
47
|
+
# (On a VM the server-side store is the durable path; the mirror just stays empty
|
|
48
|
+
# there and restore_twitter_session falls through to the API.)
|
|
49
|
+
MIRROR_PATH = (
|
|
50
|
+
Path.home() / ".claude" / "browser-profiles" / "browser-harness.x-cookies.json"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_cookies(cookies, handle: str | None = None) -> int:
|
|
55
|
+
"""Write the given CDP-shaped cookies to the 0600 mirror. Returns count saved.
|
|
56
|
+
|
|
57
|
+
Atomic (temp file + os.replace) so a crash mid-write never leaves a partial
|
|
58
|
+
JSON that the reader would choke on. No-op (returns 0) on an empty list."""
|
|
59
|
+
clean = [c for c in (cookies or []) if isinstance(c, dict) and c.get("name")]
|
|
60
|
+
if not clean:
|
|
61
|
+
return 0
|
|
62
|
+
# Never downgrade a previously-resolved @handle to null. Live handle
|
|
63
|
+
# resolution (_resolve_live_handle) is best-effort and races React hydration,
|
|
64
|
+
# so a re-import can legitimately arrive with handle=None even though we
|
|
65
|
+
# already knew the account. Clobbering it would drop the handle the dashboard
|
|
66
|
+
# + account_resolver rely on. Carry the prior handle forward in that case.
|
|
67
|
+
if not handle:
|
|
68
|
+
prev = (_read().get("handle") or "") if MIRROR_PATH.exists() else ""
|
|
69
|
+
if prev:
|
|
70
|
+
handle = prev
|
|
71
|
+
MIRROR_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
payload = {"handle": handle, "saved_at": int(time.time()), "cookies": clean}
|
|
73
|
+
tmp = MIRROR_PATH.with_name(MIRROR_PATH.name + ".tmp")
|
|
74
|
+
# Create with 0600 from the start so the secret is never briefly world-readable.
|
|
75
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
76
|
+
with os.fdopen(fd, "w") as f:
|
|
77
|
+
json.dump(payload, f)
|
|
78
|
+
os.replace(tmp, MIRROR_PATH)
|
|
79
|
+
try:
|
|
80
|
+
os.chmod(MIRROR_PATH, 0o600)
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
return len(clean)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read() -> dict:
|
|
87
|
+
try:
|
|
88
|
+
with open(MIRROR_PATH) as f:
|
|
89
|
+
data = json.load(f)
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
return {}
|
|
92
|
+
return data if isinstance(data, dict) else {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_cookies() -> list:
|
|
96
|
+
"""Return the mirrored CDP-shaped cookies, or [] if no/invalid mirror."""
|
|
97
|
+
cks = _read().get("cookies")
|
|
98
|
+
return cks if isinstance(cks, list) else []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_meta() -> dict:
|
|
102
|
+
"""Return {handle, saved_at, count} for the mirror, or {} if absent."""
|
|
103
|
+
data = _read()
|
|
104
|
+
if not data:
|
|
105
|
+
return {}
|
|
106
|
+
return {
|
|
107
|
+
"handle": data.get("handle"),
|
|
108
|
+
"saved_at": data.get("saved_at"),
|
|
109
|
+
"count": len(data.get("cookies") or []),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cli(argv: list[str] | None = None) -> int:
|
|
114
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
115
|
+
cmd = argv[0] if argv else "count"
|
|
116
|
+
if cmd == "path":
|
|
117
|
+
print(MIRROR_PATH)
|
|
118
|
+
return 0
|
|
119
|
+
if cmd == "count":
|
|
120
|
+
print(len(load_cookies()))
|
|
121
|
+
return 0
|
|
122
|
+
if cmd == "meta":
|
|
123
|
+
print(json.dumps(load_meta()))
|
|
124
|
+
return 0
|
|
125
|
+
print(f"usage: {Path(sys.argv[0]).name} [count|path|meta]", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(_cli())
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""twitter_cycle_helper.py — small CLI wrapper used by skill/run-twitter-cycle.sh
|
|
3
|
+
to replace the six `psql -t -A -c "..."` one-liners the cycle orchestrator
|
|
4
|
+
used to embed inline. Every subcommand prints exactly one value to stdout
|
|
5
|
+
(string / int / pipe-separated row dump) so bash can capture it with $(...)
|
|
6
|
+
without changing shape.
|
|
7
|
+
|
|
8
|
+
Subcommands:
|
|
9
|
+
status-counts --batch-id ID
|
|
10
|
+
-> GET /api/v1/twitter-candidates/counts-by-batch
|
|
11
|
+
-> prints two integers space-separated: "<posted> <skipped_or_expired>"
|
|
12
|
+
(mirrors the two timeout-10 psql one-liners in the run-summary block)
|
|
13
|
+
|
|
14
|
+
phase0-salvage --batch-id ID --freshness-hours N --legacy-cutoff BATCH_ID
|
|
15
|
+
-> POST /api/v1/twitter-candidates/phase0-salvage
|
|
16
|
+
-> prints "<expired_count>|<salvaged_count>"
|
|
17
|
+
(matches the legacy PHASE0_RESULT pipe-shape)
|
|
18
|
+
|
|
19
|
+
engaged-tweet-ids [--window-hours 48]
|
|
20
|
+
-> GET /api/v1/twitter/engaged-tweet-ids
|
|
21
|
+
-> prints a single JSON array on stdout
|
|
22
|
+
|
|
23
|
+
batch-count --batch-id ID
|
|
24
|
+
-> GET /api/v1/twitter-candidates/counts-by-batch
|
|
25
|
+
-> prints the total integer (all statuses)
|
|
26
|
+
|
|
27
|
+
candidates --batch-id ID
|
|
28
|
+
-> GET /api/v1/twitter-candidates?batch_id=ID&status=pending
|
|
29
|
+
-> prints pipe-separated rows in the exact column order
|
|
30
|
+
run-twitter-cycle.sh's old psql query produced:
|
|
31
|
+
id|tweet_url|author_handle|tweet_text|virality_score|
|
|
32
|
+
delta_score|matched_project|search_topic|likes_t1|retweets_t1|
|
|
33
|
+
replies_t1|views_t1|author_followers|age_hours|
|
|
34
|
+
draft_reply_text|draft_engagement_style|drafted_minutes_ago
|
|
35
|
+
|
|
36
|
+
expire-batch --batch-id ID
|
|
37
|
+
-> POST /api/v1/twitter-candidates/expire-batch
|
|
38
|
+
-> prints the resulting expired_count integer
|
|
39
|
+
|
|
40
|
+
batch-summary --batch-id ID
|
|
41
|
+
-> GET /api/v1/twitter-candidates/counts-by-batch
|
|
42
|
+
-> prints "status1|count1\\nstatus2|count2" (mirrors the legacy
|
|
43
|
+
SUMMARY = `psql -F '|' SELECT status, COUNT(*) ... GROUP BY status`
|
|
44
|
+
pipe-format the cycle log line consumes)
|
|
45
|
+
|
|
46
|
+
Migrated 2026-05-18: removes 6 direct psql calls from
|
|
47
|
+
skill/run-twitter-cycle.sh. The cycle no longer requires DATABASE_URL for
|
|
48
|
+
its core SQL surface (only for the few legacy non-twitter paths that still
|
|
49
|
+
embed psql in other shells).
|
|
50
|
+
"""
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import argparse
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
59
|
+
from http_api import api_get, api_post # noqa: E402
|
|
60
|
+
from twitter_account import resolve_handle as _resolve_twitter_handle # noqa: E402
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _counts(batch_id: str) -> dict:
|
|
64
|
+
resp = api_get(
|
|
65
|
+
"/api/v1/twitter-candidates/counts-by-batch",
|
|
66
|
+
query={"batch_id": batch_id},
|
|
67
|
+
)
|
|
68
|
+
return resp.get("data") or {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_status_counts(batch_id: str) -> int:
|
|
72
|
+
c = _counts(batch_id)
|
|
73
|
+
posted = int(c.get("posted") or 0)
|
|
74
|
+
skipped_or_expired = int(c.get("skipped_or_expired") or 0)
|
|
75
|
+
sys.stdout.write(f"{posted} {skipped_or_expired}\n")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_batch_count(batch_id: str) -> int:
|
|
80
|
+
c = _counts(batch_id)
|
|
81
|
+
sys.stdout.write(f"{int(c.get('total') or 0)}\n")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_batch_summary(batch_id: str) -> int:
|
|
86
|
+
c = _counts(batch_id)
|
|
87
|
+
by_status = c.get("by_status") or {}
|
|
88
|
+
# Pipe-separated, one row per status, like the legacy psql -F '|' output.
|
|
89
|
+
parts = [f"{status}|{count}" for status, count in by_status.items()]
|
|
90
|
+
sys.stdout.write("\n".join(parts) + ("\n" if parts else ""))
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_phase0_salvage(batch_id: str, freshness_hours: int, legacy_cutoff: str) -> int:
|
|
95
|
+
resp = api_post(
|
|
96
|
+
"/api/v1/twitter-candidates/phase0-salvage",
|
|
97
|
+
{
|
|
98
|
+
"batch_id": batch_id,
|
|
99
|
+
"freshness_hours": freshness_hours,
|
|
100
|
+
"legacy_salvage_cutoff_batch_id": legacy_cutoff,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
d = resp.get("data") or {}
|
|
104
|
+
expired = int(d.get("expired_count") or 0)
|
|
105
|
+
salvaged = int(d.get("salvaged_count") or 0)
|
|
106
|
+
try:
|
|
107
|
+
_salvaged = int(d.get("salvaged_count", 0) or 0)
|
|
108
|
+
_expired = int(d.get("expired_count", 0) or 0)
|
|
109
|
+
_sources = d.get("salvaged_from_batches") or []
|
|
110
|
+
if _salvaged > 0:
|
|
111
|
+
_src_str = ",".join(str(s) for s in _sources) if _sources else "unknown"
|
|
112
|
+
print(
|
|
113
|
+
f"[twitter_salvage] batch={batch_id} salvaged_count={_salvaged} "
|
|
114
|
+
f"expired_count={_expired} salvaged_from_batches={_src_str}",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
flush=True,
|
|
117
|
+
)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
sys.stdout.write(f"{expired}|{salvaged}\n")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_engaged_tweet_ids(window_hours: int) -> int:
|
|
125
|
+
# Scope the dedupe pool to THIS machine's Twitter handle. Without
|
|
126
|
+
# this, two machines posting as different handles (e.g. @m13v_ on the
|
|
127
|
+
# local cron, @matt_diak on the VM) share one 486-ID pool and starve
|
|
128
|
+
# each other's candidate supply. Falls back to unscoped (legacy) when
|
|
129
|
+
# no handle is configured, preserving single-account behavior.
|
|
130
|
+
query: dict[str, object] = {"window_hours": window_hours}
|
|
131
|
+
handle = _resolve_twitter_handle()
|
|
132
|
+
if handle:
|
|
133
|
+
query["our_account"] = handle
|
|
134
|
+
resp = api_get("/api/v1/twitter/engaged-tweet-ids", query=query)
|
|
135
|
+
ids = (resp.get("data") or {}).get("tweet_ids") or []
|
|
136
|
+
# The legacy shell expects a JSON array string; mirror that exactly so
|
|
137
|
+
# `python3 -c 'import json,sys; print(len(json.load(sys.stdin)))'`
|
|
138
|
+
# downstream parses unchanged.
|
|
139
|
+
sys.stdout.write(json.dumps(ids))
|
|
140
|
+
sys.stdout.write("\n")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_expire_batch(batch_id: str) -> int:
|
|
145
|
+
resp = api_post(
|
|
146
|
+
"/api/v1/twitter-candidates/expire-batch",
|
|
147
|
+
{"batch_id": batch_id},
|
|
148
|
+
)
|
|
149
|
+
d = resp.get("data") or {}
|
|
150
|
+
sys.stdout.write(f"{int(d.get('expired_count') or 0)}\n")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_stamp_cycle_variant(batch_id: str, variant: str) -> int:
|
|
155
|
+
resp = api_post(
|
|
156
|
+
"/api/v1/twitter-candidates/stamp-cycle-variant",
|
|
157
|
+
{"batch_id": batch_id, "cycle_variant": variant},
|
|
158
|
+
)
|
|
159
|
+
d = resp.get("data") or {}
|
|
160
|
+
sys.stdout.write(f"{int(d.get('stamped_count') or 0)}\n")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _sanitize(s) -> str:
|
|
165
|
+
"""Mirror the SQL `REPLACE(REPLACE(..., E'\n', ' '), E'\r', ' ')` so a
|
|
166
|
+
multi-line tweet/draft body doesn't break the pipe-delimited row format."""
|
|
167
|
+
if s is None:
|
|
168
|
+
return ""
|
|
169
|
+
return str(s).replace("\n", " ").replace("\r", " ")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_candidates(batch_id: str) -> int:
|
|
173
|
+
"""List pending candidates for a batch in the EXACT pipe-separated
|
|
174
|
+
column order run-twitter-cycle.sh's old psql query produced.
|
|
175
|
+
|
|
176
|
+
Sort key (2026-05-27): virality_score DESC.
|
|
177
|
+
virality_score is the composite predictor stamped by score_twitter_candidates.py
|
|
178
|
+
at discovery (velocity * reach_mult * age_decay * rt_bonus * (1+reply_bonus)
|
|
179
|
+
* (1+discussion_bonus)). Cohort analysis on 30d posted data showed the
|
|
180
|
+
[10k+) virality bucket gets ~36x the median reply views of the [0-10)
|
|
181
|
+
bucket, while the previous sort (delta_score + flat-5 intent regex
|
|
182
|
+
boost) ignored author reach, age decay, and discussion quality. The
|
|
183
|
+
intent regex was a crutch when the sort key was raw delta; the model
|
|
184
|
+
reads tweet text directly in the prep prompt and can detect intent
|
|
185
|
+
itself, so the lexical layer is now redundant.
|
|
186
|
+
The 25-row cap is unchanged (draft budget, not a quality gate).
|
|
187
|
+
"""
|
|
188
|
+
from datetime import datetime, timezone
|
|
189
|
+
|
|
190
|
+
# Scope by our_account so a peer machine's pending rows on the same
|
|
191
|
+
# tweet_url don't surface in this machine's batch. The composite
|
|
192
|
+
# (tweet_url, our_account) unique guarantees each machine has its own
|
|
193
|
+
# candidate row; this filter just makes the GET match the INSERT shape.
|
|
194
|
+
query: dict[str, object] = {
|
|
195
|
+
"batch_id": batch_id,
|
|
196
|
+
"status": "pending",
|
|
197
|
+
"limit": 500,
|
|
198
|
+
}
|
|
199
|
+
handle = _resolve_twitter_handle()
|
|
200
|
+
if handle:
|
|
201
|
+
query["our_account"] = handle
|
|
202
|
+
resp = api_get("/api/v1/twitter-candidates", query=query)
|
|
203
|
+
rows = (resp.get("data") or {}).get("candidates") or []
|
|
204
|
+
|
|
205
|
+
def composite(r):
|
|
206
|
+
return float(r.get("virality_score") or 0)
|
|
207
|
+
|
|
208
|
+
now = datetime.now(timezone.utc)
|
|
209
|
+
|
|
210
|
+
def age_hours(r):
|
|
211
|
+
ts = r.get("tweet_posted_at")
|
|
212
|
+
if not ts:
|
|
213
|
+
return 0.0
|
|
214
|
+
try:
|
|
215
|
+
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
216
|
+
return (now - dt).total_seconds() / 3600.0
|
|
217
|
+
except Exception:
|
|
218
|
+
return 0.0
|
|
219
|
+
|
|
220
|
+
def drafted_minutes_ago(r):
|
|
221
|
+
ts = r.get("drafted_at")
|
|
222
|
+
if not ts:
|
|
223
|
+
return -1.0
|
|
224
|
+
try:
|
|
225
|
+
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
226
|
+
return (now - dt).total_seconds() / 60.0
|
|
227
|
+
except Exception:
|
|
228
|
+
return -1.0
|
|
229
|
+
|
|
230
|
+
rows.sort(key=composite, reverse=True)
|
|
231
|
+
rows = rows[:25]
|
|
232
|
+
|
|
233
|
+
for r in rows:
|
|
234
|
+
cols = [
|
|
235
|
+
str(r.get("id") or ""),
|
|
236
|
+
str(r.get("tweet_url") or ""),
|
|
237
|
+
str(r.get("author_handle") or ""),
|
|
238
|
+
_sanitize(r.get("tweet_text")),
|
|
239
|
+
f"{float(r.get('virality_score') or 0):g}",
|
|
240
|
+
f"{float(r.get('delta_score') or 0):g}",
|
|
241
|
+
str(r.get("matched_project") or ""),
|
|
242
|
+
str(r.get("search_topic") or ""),
|
|
243
|
+
str(r.get("likes_t1") or ""),
|
|
244
|
+
str(r.get("retweets_t1") or ""),
|
|
245
|
+
str(r.get("replies_t1") or ""),
|
|
246
|
+
str(r.get("views_t1") or ""),
|
|
247
|
+
str(r.get("author_followers") or ""),
|
|
248
|
+
f"{age_hours(r):g}",
|
|
249
|
+
_sanitize(r.get("draft_reply_text")),
|
|
250
|
+
str(r.get("draft_engagement_style") or ""),
|
|
251
|
+
f"{drafted_minutes_ago(r):g}",
|
|
252
|
+
]
|
|
253
|
+
sys.stdout.write("|".join(cols) + "\n")
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main() -> int:
|
|
258
|
+
ap = argparse.ArgumentParser(description="Helper for run-twitter-cycle.sh")
|
|
259
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
260
|
+
|
|
261
|
+
p_sc = sub.add_parser("status-counts")
|
|
262
|
+
p_sc.add_argument("--batch-id", required=True)
|
|
263
|
+
|
|
264
|
+
p_bc = sub.add_parser("batch-count")
|
|
265
|
+
p_bc.add_argument("--batch-id", required=True)
|
|
266
|
+
|
|
267
|
+
p_bs = sub.add_parser("batch-summary")
|
|
268
|
+
p_bs.add_argument("--batch-id", required=True)
|
|
269
|
+
|
|
270
|
+
p_p0 = sub.add_parser("phase0-salvage")
|
|
271
|
+
p_p0.add_argument("--batch-id", required=True)
|
|
272
|
+
p_p0.add_argument("--freshness-hours", type=int, required=True)
|
|
273
|
+
p_p0.add_argument("--legacy-cutoff", required=True)
|
|
274
|
+
|
|
275
|
+
p_et = sub.add_parser("engaged-tweet-ids")
|
|
276
|
+
p_et.add_argument("--window-hours", type=int, default=48)
|
|
277
|
+
|
|
278
|
+
p_eb = sub.add_parser("expire-batch")
|
|
279
|
+
p_eb.add_argument("--batch-id", required=True)
|
|
280
|
+
|
|
281
|
+
p_ca = sub.add_parser("candidates")
|
|
282
|
+
p_ca.add_argument("--batch-id", required=True)
|
|
283
|
+
|
|
284
|
+
p_cv = sub.add_parser("stamp-cycle-variant")
|
|
285
|
+
p_cv.add_argument("--batch-id", required=True)
|
|
286
|
+
p_cv.add_argument("--variant", required=True)
|
|
287
|
+
|
|
288
|
+
args = ap.parse_args()
|
|
289
|
+
|
|
290
|
+
if args.cmd == "status-counts":
|
|
291
|
+
return cmd_status_counts(args.batch_id)
|
|
292
|
+
if args.cmd == "batch-count":
|
|
293
|
+
return cmd_batch_count(args.batch_id)
|
|
294
|
+
if args.cmd == "batch-summary":
|
|
295
|
+
return cmd_batch_summary(args.batch_id)
|
|
296
|
+
if args.cmd == "phase0-salvage":
|
|
297
|
+
return cmd_phase0_salvage(args.batch_id, args.freshness_hours, args.legacy_cutoff)
|
|
298
|
+
if args.cmd == "engaged-tweet-ids":
|
|
299
|
+
return cmd_engaged_tweet_ids(args.window_hours)
|
|
300
|
+
if args.cmd == "expire-batch":
|
|
301
|
+
return cmd_expire_batch(args.batch_id)
|
|
302
|
+
if args.cmd == "candidates":
|
|
303
|
+
return cmd_candidates(args.batch_id)
|
|
304
|
+
if args.cmd == "stamp-cycle-variant":
|
|
305
|
+
return cmd_stamp_cycle_variant(args.batch_id, args.variant)
|
|
306
|
+
return 1
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
sys.exit(main())
|