@m13v/s4l 1.6.197-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
apply_onboarding_selections.py -- PROTOTYPE handler for the S4L bundled-question
|
|
4
|
+
widget. Turns the widget's sendPrompt confirmation into concrete actions:
|
|
5
|
+
|
|
6
|
+
1. Engagement lanes -> saps_mode.py enable/disable (DRY-RUN by default so a
|
|
7
|
+
prototype never flips the LIVE autopilot; pass --commit-lanes to really run).
|
|
8
|
+
2. History consent -> history_context.set_optin(...) (persisted sidecar).
|
|
9
|
+
3. If consent == yes -> history_context.pull(project) and summarize candidates.
|
|
10
|
+
|
|
11
|
+
The widget sends a line like:
|
|
12
|
+
"... personal_brand lane: ON, product lane: OFF, read past Claude
|
|
13
|
+
conversations: YES. Apply these ..."
|
|
14
|
+
|
|
15
|
+
so this handler also accepts that raw text via --from-prompt, or explicit flags.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 scripts/apply_onboarding_selections.py --project S4L \
|
|
19
|
+
--personal-brand on --product off --read-history yes
|
|
20
|
+
python3 scripts/apply_onboarding_selections.py --project S4L \
|
|
21
|
+
--from-prompt "personal_brand lane: ON, product lane: OFF, read past Claude conversations: YES"
|
|
22
|
+
# add --commit-lanes to actually toggle saps_mode (default is dry-run)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
35
|
+
import history_context as hc # noqa: E402
|
|
36
|
+
|
|
37
|
+
S4L_MODE = Path(__file__).resolve().parent / "saps_mode.py"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_from_prompt(text: str) -> dict:
|
|
41
|
+
"""Extract the three toggles from the widget's confirmation sentence."""
|
|
42
|
+
t = text.lower()
|
|
43
|
+
|
|
44
|
+
def flag(label: str, on_words=("on", "yes")) -> bool | None:
|
|
45
|
+
m = re.search(re.escape(label) + r"[^:]*:\s*(on|off|yes|no)", t)
|
|
46
|
+
return None if not m else m.group(1) in on_words
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
"personal_brand": flag("personal_brand"),
|
|
50
|
+
"product_mode": flag("product lane"),
|
|
51
|
+
"read_history": flag("read past claude conversations"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply_lanes(personal_brand: bool, product: bool, commit: bool) -> list[str]:
|
|
56
|
+
"""Return the saps_mode commands, running them only when commit=True."""
|
|
57
|
+
plan = [
|
|
58
|
+
[sys.executable, str(S4L_MODE),
|
|
59
|
+
"enable" if personal_brand else "disable", "personal_brand"],
|
|
60
|
+
[sys.executable, str(S4L_MODE),
|
|
61
|
+
"enable" if product else "disable", "promotion"],
|
|
62
|
+
]
|
|
63
|
+
rendered = [" ".join(c) for c in plan]
|
|
64
|
+
if commit:
|
|
65
|
+
for c in plan:
|
|
66
|
+
subprocess.run(c, check=False)
|
|
67
|
+
return rendered
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
72
|
+
ap.add_argument("--project", required=True)
|
|
73
|
+
ap.add_argument("--from-prompt", help="raw widget confirmation text")
|
|
74
|
+
ap.add_argument("--personal-brand", choices=["on", "off"])
|
|
75
|
+
ap.add_argument("--product", choices=["on", "off"])
|
|
76
|
+
ap.add_argument("--read-history", choices=["yes", "no"])
|
|
77
|
+
ap.add_argument("--commit-lanes", action="store_true",
|
|
78
|
+
help="actually toggle saps_mode (default: dry-run print only)")
|
|
79
|
+
args = ap.parse_args()
|
|
80
|
+
|
|
81
|
+
sel = {"personal_brand": None, "product_mode": None, "read_history": None}
|
|
82
|
+
if args.from_prompt:
|
|
83
|
+
sel.update({k: v for k, v in parse_from_prompt(args.from_prompt).items()
|
|
84
|
+
if v is not None})
|
|
85
|
+
if args.personal_brand:
|
|
86
|
+
sel["personal_brand"] = args.personal_brand == "on"
|
|
87
|
+
if args.product:
|
|
88
|
+
sel["product_mode"] = args.product == "on"
|
|
89
|
+
if args.read_history:
|
|
90
|
+
sel["read_history"] = args.read_history == "yes"
|
|
91
|
+
|
|
92
|
+
for k in sel:
|
|
93
|
+
if sel[k] is None:
|
|
94
|
+
raise SystemExit(f"missing selection for '{k}'")
|
|
95
|
+
|
|
96
|
+
out = {"project": args.project, "selections": sel, "actions": {}}
|
|
97
|
+
|
|
98
|
+
lane_cmds = apply_lanes(sel["personal_brand"], sel["product_mode"],
|
|
99
|
+
commit=args.commit_lanes)
|
|
100
|
+
out["actions"]["lanes"] = {
|
|
101
|
+
"committed": args.commit_lanes,
|
|
102
|
+
"commands": lane_cmds,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
optin = hc.set_optin(sel["read_history"])
|
|
106
|
+
out["actions"]["history_optin"] = optin
|
|
107
|
+
|
|
108
|
+
if sel["read_history"]:
|
|
109
|
+
pull = hc.pull(args.project, terms=None, limit=40)
|
|
110
|
+
if pull.get("ok"):
|
|
111
|
+
summary = {
|
|
112
|
+
"sessions": pull["session_count"],
|
|
113
|
+
"snippets": pull["snippet_count"],
|
|
114
|
+
"scope": pull["scope"],
|
|
115
|
+
"sample": [
|
|
116
|
+
{"session": sid[:8],
|
|
117
|
+
"recent_previews": [s["preview"][:140] for s in snips[:2]]}
|
|
118
|
+
for sid, snips in list(pull["sessions"].items())[:5]
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
out["actions"]["history_pull"] = summary
|
|
122
|
+
else:
|
|
123
|
+
out["actions"]["history_pull"] = pull
|
|
124
|
+
else:
|
|
125
|
+
out["actions"]["history_pull"] = {"skipped": "consent=no"}
|
|
126
|
+
|
|
127
|
+
print(json.dumps(out, indent=2)[:6000])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audience-page routing helper.
|
|
3
|
+
|
|
4
|
+
Each project in config.json can declare `landing_pages.audience_pages`, a list
|
|
5
|
+
of curated deep landing pages (not auto-generated SEO /t/<slug> pages — those
|
|
6
|
+
live in a separate rail). Each entry looks like:
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"angle": "founder-ghostwriting",
|
|
10
|
+
"url": "https://s4l.ai/ghostwriting",
|
|
11
|
+
"match_keywords": ["ghostwriter", "tweet ghostwriter", ...],
|
|
12
|
+
"when": "human-readable trigger description for LLMs / docs"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
This module is the single source of truth for:
|
|
16
|
+
|
|
17
|
+
- loading audience_pages for a project
|
|
18
|
+
- matching a candidate's nominated topic/keyword to an audience-page angle
|
|
19
|
+
- mapping a URL back to its angle (for post-hoc tagging)
|
|
20
|
+
- formatting an audience_pages block for injection into post-draft prompts
|
|
21
|
+
(used once post_reddit.py and post_github.py are unlocked to consume it)
|
|
22
|
+
|
|
23
|
+
Used by twitter_gen_links.py to short-circuit the A/B page-gen lane when a
|
|
24
|
+
curated audience page exists for the candidate's topic.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
from typing import Optional
|
|
33
|
+
from urllib.parse import urlsplit
|
|
34
|
+
|
|
35
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
36
|
+
CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_config() -> dict:
|
|
40
|
+
with open(CONFIG_PATH, "r") as f:
|
|
41
|
+
return json.load(f)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _find_project(cfg: dict, name: str) -> Optional[dict]:
|
|
45
|
+
if not name:
|
|
46
|
+
return None
|
|
47
|
+
name_lc = name.lower()
|
|
48
|
+
for p in cfg.get("projects", []):
|
|
49
|
+
if (p.get("name") or "").lower() == name_lc:
|
|
50
|
+
return p
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_audience_pages(project_name: str) -> list[dict]:
|
|
55
|
+
"""Return the audience_pages list for a project, or [] if none configured.
|
|
56
|
+
|
|
57
|
+
Each entry is the raw dict from config.json. Caller does not mutate.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
cfg = _load_config()
|
|
61
|
+
except Exception:
|
|
62
|
+
return []
|
|
63
|
+
proj = _find_project(cfg, project_name)
|
|
64
|
+
if not proj:
|
|
65
|
+
return []
|
|
66
|
+
lp = (proj.get("landing_pages") or {})
|
|
67
|
+
pages = lp.get("audience_pages") or []
|
|
68
|
+
out = []
|
|
69
|
+
for entry in pages:
|
|
70
|
+
if not isinstance(entry, dict):
|
|
71
|
+
continue
|
|
72
|
+
if not entry.get("url") or not entry.get("angle"):
|
|
73
|
+
continue
|
|
74
|
+
out.append(entry)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize(s: str) -> str:
|
|
79
|
+
"""Lowercase + collapse whitespace + strip punctuation for substring match."""
|
|
80
|
+
if not s:
|
|
81
|
+
return ""
|
|
82
|
+
s = s.lower()
|
|
83
|
+
s = re.sub(r"[^a-z0-9\s]+", " ", s)
|
|
84
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
85
|
+
return s
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def match_by_keyword(
|
|
89
|
+
project_name: str,
|
|
90
|
+
*,
|
|
91
|
+
keyword: Optional[str] = None,
|
|
92
|
+
topic: Optional[str] = None,
|
|
93
|
+
reply_text: Optional[str] = None,
|
|
94
|
+
thread_title: Optional[str] = None,
|
|
95
|
+
) -> Optional[dict]:
|
|
96
|
+
"""Pick the best-matching audience page for a candidate.
|
|
97
|
+
|
|
98
|
+
Match strategy: for each audience-page entry, check whether ANY of its
|
|
99
|
+
`match_keywords` (case-insensitive, normalized substring) appears in
|
|
100
|
+
ANY of the provided signals (keyword, topic, reply_text, thread_title).
|
|
101
|
+
|
|
102
|
+
Returns the matched entry dict (with `angle`, `url`, ...) or None.
|
|
103
|
+
|
|
104
|
+
First-match-wins ordered by audience_pages list order (so config.json
|
|
105
|
+
list ordering acts as priority). This is intentional: the most specific
|
|
106
|
+
angle should sit first.
|
|
107
|
+
"""
|
|
108
|
+
pages = load_audience_pages(project_name)
|
|
109
|
+
if not pages:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
haystacks: list[str] = []
|
|
113
|
+
for v in (keyword, topic, reply_text, thread_title):
|
|
114
|
+
n = _normalize(v or "")
|
|
115
|
+
if n:
|
|
116
|
+
haystacks.append(n)
|
|
117
|
+
if not haystacks:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
for entry in pages:
|
|
121
|
+
kws = entry.get("match_keywords") or []
|
|
122
|
+
for kw in kws:
|
|
123
|
+
kw_norm = _normalize(kw)
|
|
124
|
+
if not kw_norm:
|
|
125
|
+
continue
|
|
126
|
+
for hay in haystacks:
|
|
127
|
+
if kw_norm in hay:
|
|
128
|
+
return entry
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def classify_url_as_audience_page(url: str, project_name: str) -> Optional[str]:
|
|
133
|
+
"""Map a URL back to an audience-page angle, or None if not a known page.
|
|
134
|
+
|
|
135
|
+
Used for post-hoc tagging in `posts.link_source` when a URL was baked
|
|
136
|
+
into a draft directly (without going through resolve_link()). Match is
|
|
137
|
+
exact-URL OR same-host + same-path (ignoring query/fragment).
|
|
138
|
+
"""
|
|
139
|
+
if not url or not project_name:
|
|
140
|
+
return None
|
|
141
|
+
pages = load_audience_pages(project_name)
|
|
142
|
+
if not pages:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
target = urlsplit(url.strip())
|
|
146
|
+
except Exception:
|
|
147
|
+
return None
|
|
148
|
+
target_host = (target.netloc or "").lower().lstrip("www.")
|
|
149
|
+
target_path = (target.path or "/").rstrip("/") or "/"
|
|
150
|
+
|
|
151
|
+
for entry in pages:
|
|
152
|
+
try:
|
|
153
|
+
ep = urlsplit(entry["url"])
|
|
154
|
+
except Exception:
|
|
155
|
+
continue
|
|
156
|
+
ep_host = (ep.netloc or "").lower().lstrip("www.")
|
|
157
|
+
ep_path = (ep.path or "/").rstrip("/") or "/"
|
|
158
|
+
if ep_host == target_host and ep_path == target_path:
|
|
159
|
+
return entry.get("angle")
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def prompt_block(project_name: str) -> str:
|
|
164
|
+
"""Render an audience_pages block for injection into a post-draft LLM prompt.
|
|
165
|
+
|
|
166
|
+
Returns "" if the project has no audience_pages. Otherwise returns a short
|
|
167
|
+
markdown-friendly block the LLM can use to pick the right deep URL.
|
|
168
|
+
"""
|
|
169
|
+
pages = load_audience_pages(project_name)
|
|
170
|
+
if not pages:
|
|
171
|
+
return ""
|
|
172
|
+
lines = [
|
|
173
|
+
"Curated audience landing pages for this project. Pick the BEST match",
|
|
174
|
+
"for the thread topic and bake the chosen URL into the reply text;",
|
|
175
|
+
"if none obviously match, link to the project homepage as usual.",
|
|
176
|
+
"",
|
|
177
|
+
]
|
|
178
|
+
for entry in pages:
|
|
179
|
+
lines.append(f"- angle: {entry['angle']}")
|
|
180
|
+
lines.append(f" url: {entry['url']}")
|
|
181
|
+
when = entry.get("when") or ""
|
|
182
|
+
if when:
|
|
183
|
+
lines.append(f" when_to_use: {when}")
|
|
184
|
+
kws = entry.get("match_keywords") or []
|
|
185
|
+
if kws:
|
|
186
|
+
lines.append(f" keyword_signals: {', '.join(kws[:12])}")
|
|
187
|
+
lines.append("")
|
|
188
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# CLI for ops / testing
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def _cli() -> int:
|
|
196
|
+
import argparse
|
|
197
|
+
|
|
198
|
+
ap = argparse.ArgumentParser(description="Audience-page lookup helper")
|
|
199
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
200
|
+
|
|
201
|
+
p_list = sub.add_parser("list", help="List audience pages for a project")
|
|
202
|
+
p_list.add_argument("--project", required=True)
|
|
203
|
+
|
|
204
|
+
p_match = sub.add_parser("match", help="Match a keyword/topic to an audience page")
|
|
205
|
+
p_match.add_argument("--project", required=True)
|
|
206
|
+
p_match.add_argument("--keyword", default=None)
|
|
207
|
+
p_match.add_argument("--topic", default=None)
|
|
208
|
+
p_match.add_argument("--reply", default=None)
|
|
209
|
+
p_match.add_argument("--title", default=None)
|
|
210
|
+
|
|
211
|
+
p_classify = sub.add_parser("classify", help="Classify a URL as an audience page")
|
|
212
|
+
p_classify.add_argument("--project", required=True)
|
|
213
|
+
p_classify.add_argument("--url", required=True)
|
|
214
|
+
|
|
215
|
+
p_prompt = sub.add_parser("prompt", help="Render the prompt block for a project")
|
|
216
|
+
p_prompt.add_argument("--project", required=True)
|
|
217
|
+
|
|
218
|
+
args = ap.parse_args()
|
|
219
|
+
if args.cmd == "list":
|
|
220
|
+
print(json.dumps(load_audience_pages(args.project), indent=2))
|
|
221
|
+
return 0
|
|
222
|
+
if args.cmd == "match":
|
|
223
|
+
out = match_by_keyword(
|
|
224
|
+
args.project,
|
|
225
|
+
keyword=args.keyword,
|
|
226
|
+
topic=args.topic,
|
|
227
|
+
reply_text=args.reply,
|
|
228
|
+
thread_title=args.title,
|
|
229
|
+
)
|
|
230
|
+
print(json.dumps(out, indent=2) if out else "null")
|
|
231
|
+
return 0 if out else 1
|
|
232
|
+
if args.cmd == "classify":
|
|
233
|
+
angle = classify_url_as_audience_page(args.url, args.project)
|
|
234
|
+
print(angle or "")
|
|
235
|
+
return 0 if angle else 1
|
|
236
|
+
if args.cmd == "prompt":
|
|
237
|
+
print(prompt_block(args.project))
|
|
238
|
+
return 0
|
|
239
|
+
return 2
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
raise SystemExit(_cli())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""audit_helper.py — CLI wrapper used by the audit pipelines
|
|
3
|
+
(skill/audit.sh, skill/audit-dm-staleness.sh, skill/audit-reddit-resurrect.sh)
|
|
4
|
+
to replace the inline `psql "$DATABASE_URL"` one-liners they used to embed. The
|
|
5
|
+
direct-Postgres lane was removed 2026-06-01; DATABASE_URL is deliberately
|
|
6
|
+
ignored, no DB, no fallback. Every subcommand prints exactly what the
|
|
7
|
+
corresponding psql call printed so the surrounding shell capture ($(...)),
|
|
8
|
+
integer compares, and `IFS='|' read` loops are unchanged.
|
|
9
|
+
|
|
10
|
+
Subcommands:
|
|
11
|
+
twitter-active-count
|
|
12
|
+
-> GET /api/v1/posts/count?platform=twitter&status=active&has_our_url=true
|
|
13
|
+
-> prints int (was: COUNT(*) FROM posts WHERE platform='twitter'
|
|
14
|
+
AND status='active' AND our_url IS NOT NULL)
|
|
15
|
+
orphan-report
|
|
16
|
+
-> GET /api/v1/posts/status-breakdown
|
|
17
|
+
-> prints pipe-delimited "platform|status|count" lines (one per group),
|
|
18
|
+
empty when none (was: SELECT platform, status, COUNT(*) ... GROUP BY
|
|
19
|
+
... for status NOT IN ('active','deleted','removed'))
|
|
20
|
+
broken-url-count
|
|
21
|
+
-> GET /api/v1/posts/count?status=active&broken_url=true
|
|
22
|
+
-> prints int (was: COUNT(*) FROM posts WHERE status='active'
|
|
23
|
+
AND (our_url IS NULL OR ''='' OR our_url NOT LIKE 'http%'))
|
|
24
|
+
status-count --status S
|
|
25
|
+
-> GET /api/v1/posts/count?status=S
|
|
26
|
+
-> prints int (was: COUNT(*) FROM posts WHERE status='S')
|
|
27
|
+
resurrect-candidates
|
|
28
|
+
-> GET /api/v1/posts/count?platform=reddit&status_in=deleted,removed
|
|
29
|
+
&within_seconds=5184000&has_our_url=true
|
|
30
|
+
-> prints int (was: COUNT(*) FROM posts WHERE platform='reddit'
|
|
31
|
+
AND status IN ('deleted','removed') AND posted_at > NOW() - 60 days
|
|
32
|
+
AND our_url IS NOT NULL)
|
|
33
|
+
dm-staleness-sweep
|
|
34
|
+
-> POST /api/v1/dms/staleness-sweep
|
|
35
|
+
-> prints JSON {aged, downgraded} (was: two UPDATE ... RETURNING CTEs)
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
|
|
44
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
45
|
+
from http_api import api_get, api_post # noqa: E402
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _count(query: dict) -> int:
|
|
49
|
+
resp = api_get("/api/v1/posts/count", query=query)
|
|
50
|
+
return int((resp.get("data") or {}).get("count") or 0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_twitter_active_count(_args) -> int:
|
|
54
|
+
print(_count({"platform": "twitter", "status": "active", "has_our_url": "true"}))
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_orphan_report(_args) -> int:
|
|
59
|
+
resp = api_get("/api/v1/posts/status-breakdown")
|
|
60
|
+
rows = (resp.get("data") or {}).get("rows") or []
|
|
61
|
+
for r in rows:
|
|
62
|
+
print(f"{r.get('platform')}|{r.get('status')}|{int(r.get('count') or 0)}")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_broken_url_count(_args) -> int:
|
|
67
|
+
print(_count({"status": "active", "broken_url": "true"}))
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_status_count(args) -> int:
|
|
72
|
+
print(_count({"status": args.status}))
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_resurrect_candidates(_args) -> int:
|
|
77
|
+
print(_count({
|
|
78
|
+
"platform": "reddit",
|
|
79
|
+
"status_in": "deleted,removed",
|
|
80
|
+
"within_seconds": 60 * 24 * 60 * 60, # 60 days
|
|
81
|
+
"has_our_url": "true",
|
|
82
|
+
}))
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_dm_staleness_sweep(_args) -> int:
|
|
87
|
+
resp = api_post("/api/v1/dms/staleness-sweep", body={})
|
|
88
|
+
data = resp.get("data") or {}
|
|
89
|
+
out = {
|
|
90
|
+
"aged": int(data.get("aged") or 0),
|
|
91
|
+
"downgraded": int(data.get("downgraded") or 0),
|
|
92
|
+
}
|
|
93
|
+
json.dump(out, sys.stdout, separators=(",", ":"))
|
|
94
|
+
sys.stdout.write("\n")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main() -> int:
|
|
99
|
+
p = argparse.ArgumentParser()
|
|
100
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
101
|
+
sub.add_parser("twitter-active-count")
|
|
102
|
+
sub.add_parser("orphan-report")
|
|
103
|
+
sub.add_parser("broken-url-count")
|
|
104
|
+
sc = sub.add_parser("status-count")
|
|
105
|
+
sc.add_argument("--status", required=True)
|
|
106
|
+
sub.add_parser("resurrect-candidates")
|
|
107
|
+
sub.add_parser("dm-staleness-sweep")
|
|
108
|
+
args = p.parse_args()
|
|
109
|
+
return {
|
|
110
|
+
"twitter-active-count": cmd_twitter_active_count,
|
|
111
|
+
"orphan-report": cmd_orphan_report,
|
|
112
|
+
"broken-url-count": cmd_broken_url_count,
|
|
113
|
+
"status-count": cmd_status_count,
|
|
114
|
+
"resurrect-candidates": cmd_resurrect_candidates,
|
|
115
|
+
"dm-staleness-sweep": cmd_dm_staleness_sweep,
|
|
116
|
+
}[args.cmd](args)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|