@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,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Single source of truth for the engagement MODE (2026-06-26, dual-flag 2026-06-29).
|
|
3
|
+
|
|
4
|
+
The S4L pipeline drafts for TWO independently toggleable lanes:
|
|
5
|
+
|
|
6
|
+
- "personal_brand" (default ON): pure organic engagement to grow the user's
|
|
7
|
+
personal brand. The cycle is forced onto the persona project
|
|
8
|
+
(the config entry with `"persona": true`, normally
|
|
9
|
+
`enabled:false` so the promotion pick never touches it) and
|
|
10
|
+
replies are link-free.
|
|
11
|
+
- "promotion" (default OFF): the normal project/product-marketing pipeline.
|
|
12
|
+
The weighted pick (pick_project.py) chooses among enabled
|
|
13
|
+
projects; replies carry the project's link per the A/B gate.
|
|
14
|
+
|
|
15
|
+
Both can be ON at once. When both are ON the cycle splits **50/50**: each cycle
|
|
16
|
+
invocation flips a coin and runs that one cycle as either a persona (link-free)
|
|
17
|
+
cycle or a normal promotion cycle. The locked pipeline never changes — it just
|
|
18
|
+
reads the env vars env_exports() prints.
|
|
19
|
+
|
|
20
|
+
State lives in ONE small file, `$S4L_STATE_DIR/mode.json`:
|
|
21
|
+
{"personal_brand": true, "promotion": false, "mode": "personal_brand"}
|
|
22
|
+
|
|
23
|
+
The `"mode"` field is a DERIVED legacy mirror (personal_brand if that lane is on,
|
|
24
|
+
else promotion) kept only so any old reader that still does `data["mode"]` keeps
|
|
25
|
+
working. saps_mode.py is the only writer; it always writes all three keys.
|
|
26
|
+
|
|
27
|
+
Backward-compat read: a legacy file `{"mode": "promotion"}` (no flags) maps to
|
|
28
|
+
promotion-only; `{"mode": "personal_brand"}` maps to personal-only. A missing
|
|
29
|
+
file defaults to personal_brand ON / promotion OFF (the 2026-06-29 default flip).
|
|
30
|
+
|
|
31
|
+
The toggle takes effect WITHOUT touching any locked pipeline file: the unlocked
|
|
32
|
+
wrapper `skill/run-draft-and-publish.sh` evals `saps_mode.py env` right before it
|
|
33
|
+
invokes the locked `run-twitter-cycle.sh`, exporting the env vars the locked
|
|
34
|
+
pipeline already honors:
|
|
35
|
+
S4L_FORCE_PROJECT -> pick_project.py forces this exact project
|
|
36
|
+
(--project bypasses the enabled gate), so a
|
|
37
|
+
disabled persona is still selectable.
|
|
38
|
+
TWITTER_TAIL_LINK_RATE=0 -> twitter_post_plan.py ships every reply bare.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
saps_mode.py get # print derived legacy mode (compat)
|
|
42
|
+
saps_mode.py flags # print JSON {personal_brand, promotion}
|
|
43
|
+
saps_mode.py set personal_brand # legacy: personal-only (compat)
|
|
44
|
+
saps_mode.py set promotion # legacy: promotion-only (compat)
|
|
45
|
+
saps_mode.py set-flags <pb> <pr> # set both lanes, e.g. `set-flags 1 1`
|
|
46
|
+
saps_mode.py enable personal_brand|promotion
|
|
47
|
+
saps_mode.py disable personal_brand|promotion
|
|
48
|
+
saps_mode.py toggle personal_brand|promotion # flip ONE lane
|
|
49
|
+
saps_mode.py toggle # legacy whole-mode flip (compat)
|
|
50
|
+
saps_mode.py env # print shell `export` lines for this cycle
|
|
51
|
+
saps_mode.py persona-name # print the persona project name (or empty)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
import random
|
|
57
|
+
import shlex
|
|
58
|
+
import sys
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
PROMOTION = "promotion"
|
|
62
|
+
PERSONAL_BRAND = "personal_brand"
|
|
63
|
+
VALID_MODES = (PROMOTION, PERSONAL_BRAND)
|
|
64
|
+
|
|
65
|
+
# 2026-06-29 default flip: personal brand is the out-of-the-box lane; promotion
|
|
66
|
+
# is opt-in (asked for during setup).
|
|
67
|
+
DEFAULT_PERSONAL_BRAND = True
|
|
68
|
+
DEFAULT_PROMOTION = False
|
|
69
|
+
|
|
70
|
+
# Retained so old imports of `DEFAULT_MODE` don't break.
|
|
71
|
+
DEFAULT_MODE = PERSONAL_BRAND
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def state_dir() -> Path:
|
|
75
|
+
# Mirrors mcp/src/index.ts sapsStateDir() and menubar/s4l_state.py state_dir().
|
|
76
|
+
return Path(
|
|
77
|
+
os.environ.get("S4L_STATE_DIR")
|
|
78
|
+
or (Path.home() / ".social-autoposter-mcp")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mode_file() -> Path:
|
|
83
|
+
return state_dir() / "mode.json"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def config_path() -> Path:
|
|
87
|
+
# Match the locked pipeline's resolution: S4L_REPO_DIR/config.json when set,
|
|
88
|
+
# else the canonical ~/social-autoposter/config.json (what pick_project.py /
|
|
89
|
+
# project_topics.py read directly).
|
|
90
|
+
repo = os.environ.get("S4L_REPO_DIR")
|
|
91
|
+
if repo:
|
|
92
|
+
p = Path(repo) / "config.json"
|
|
93
|
+
if p.exists():
|
|
94
|
+
return p
|
|
95
|
+
return Path.home() / "social-autoposter" / "config.json"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _coerce_bool(v) -> bool:
|
|
99
|
+
if isinstance(v, bool):
|
|
100
|
+
return v
|
|
101
|
+
if isinstance(v, (int, float)):
|
|
102
|
+
return v != 0
|
|
103
|
+
if isinstance(v, str):
|
|
104
|
+
return v.strip().lower() in ("1", "true", "yes", "on")
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_flags() -> dict:
|
|
109
|
+
"""Current lane flags as {"personal_brand": bool, "promotion": bool}.
|
|
110
|
+
|
|
111
|
+
Read precedence: explicit flag keys win; else map a legacy {"mode": ...}
|
|
112
|
+
string; else the (new) default of personal-brand ON / promotion OFF.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(mode_file().read_text())
|
|
116
|
+
except Exception:
|
|
117
|
+
data = None
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
return {"personal_brand": DEFAULT_PERSONAL_BRAND, "promotion": DEFAULT_PROMOTION}
|
|
120
|
+
|
|
121
|
+
if "personal_brand" in data or "promotion" in data:
|
|
122
|
+
return {
|
|
123
|
+
"personal_brand": _coerce_bool(data.get("personal_brand", False)),
|
|
124
|
+
"promotion": _coerce_bool(data.get("promotion", False)),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Legacy single-mode file.
|
|
128
|
+
legacy = str(data.get("mode") or "").strip()
|
|
129
|
+
if legacy == PERSONAL_BRAND:
|
|
130
|
+
return {"personal_brand": True, "promotion": False}
|
|
131
|
+
if legacy == PROMOTION:
|
|
132
|
+
return {"personal_brand": False, "promotion": True}
|
|
133
|
+
return {"personal_brand": DEFAULT_PERSONAL_BRAND, "promotion": DEFAULT_PROMOTION}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _legacy_mode(flags: dict) -> str:
|
|
137
|
+
"""Derived single-mode mirror: personal_brand wins when on (it's the default
|
|
138
|
+
lane), else promotion. Only used for the back-compat `mode` field/readers."""
|
|
139
|
+
return PERSONAL_BRAND if flags.get("personal_brand") else PROMOTION
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_mode() -> str:
|
|
143
|
+
"""Derived legacy mode string (compat shim for old callers)."""
|
|
144
|
+
return _legacy_mode(get_flags())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def write_flags(personal_brand: bool, promotion: bool) -> dict:
|
|
148
|
+
"""Persist both lane flags atomically (plus the derived legacy `mode`)."""
|
|
149
|
+
flags = {"personal_brand": bool(personal_brand), "promotion": bool(promotion)}
|
|
150
|
+
payload = dict(flags)
|
|
151
|
+
payload["mode"] = _legacy_mode(flags)
|
|
152
|
+
d = state_dir()
|
|
153
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
tmp = mode_file().with_suffix(".json.tmp")
|
|
155
|
+
tmp.write_text(json.dumps(payload))
|
|
156
|
+
tmp.replace(mode_file())
|
|
157
|
+
return flags
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def set_mode(mode: str) -> str:
|
|
161
|
+
"""Legacy single-mode setter: turns the named lane ON and the other OFF."""
|
|
162
|
+
mode = (mode or "").strip()
|
|
163
|
+
if mode not in VALID_MODES:
|
|
164
|
+
raise ValueError(f"invalid mode {mode!r}; expected one of {VALID_MODES}")
|
|
165
|
+
write_flags(personal_brand=(mode == PERSONAL_BRAND), promotion=(mode == PROMOTION))
|
|
166
|
+
return mode
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def set_lane(lane: str, on: bool) -> dict:
|
|
170
|
+
lane = (lane or "").strip()
|
|
171
|
+
if lane not in VALID_MODES:
|
|
172
|
+
raise ValueError(f"invalid lane {lane!r}; expected one of {VALID_MODES}")
|
|
173
|
+
flags = get_flags()
|
|
174
|
+
flags[lane] = bool(on)
|
|
175
|
+
return write_flags(flags["personal_brand"], flags["promotion"])
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def toggle_lane(lane: str) -> dict:
|
|
179
|
+
lane = (lane or "").strip()
|
|
180
|
+
if lane not in VALID_MODES:
|
|
181
|
+
raise ValueError(f"invalid lane {lane!r}; expected one of {VALID_MODES}")
|
|
182
|
+
flags = get_flags()
|
|
183
|
+
flags[lane] = not flags.get(lane)
|
|
184
|
+
return write_flags(flags["personal_brand"], flags["promotion"])
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _load_projects() -> list:
|
|
188
|
+
try:
|
|
189
|
+
cfg = json.loads(config_path().read_text())
|
|
190
|
+
return cfg.get("projects") or []
|
|
191
|
+
except Exception:
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def persona_name() -> str:
|
|
196
|
+
"""Name of the persona project (the entry with `persona: true`), or ''.
|
|
197
|
+
|
|
198
|
+
First match wins. Returns '' when no persona is configured yet (the cycle
|
|
199
|
+
then falls back to the normal weighted pick — a safe no-op for the toggle).
|
|
200
|
+
"""
|
|
201
|
+
for p in _load_projects():
|
|
202
|
+
if p.get("persona") is True:
|
|
203
|
+
return str(p.get("name") or "")
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _persona_env_lines() -> str:
|
|
208
|
+
name = persona_name()
|
|
209
|
+
if not name:
|
|
210
|
+
print(
|
|
211
|
+
"[saps_mode] personal_brand lane is on but no persona project "
|
|
212
|
+
"(persona:true) is configured; running the normal pick instead.",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
return ""
|
|
216
|
+
return "\n".join(
|
|
217
|
+
[
|
|
218
|
+
f"export S4L_FORCE_PROJECT={shlex.quote(name)}",
|
|
219
|
+
"export TWITTER_TAIL_LINK_RATE=0",
|
|
220
|
+
# Explicit lane signal so the (locked) cycle can branch the draft
|
|
221
|
+
# directive + inject the persona corpus without re-deriving the lane
|
|
222
|
+
# from S4L_FORCE_PROJECT (which is also set by manual single-project
|
|
223
|
+
# MCP draft_cycle runs). Only the personal_brand lane sets this.
|
|
224
|
+
"export S4L_ACTIVE_LANE=personal_brand",
|
|
225
|
+
]
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def env_exports() -> str:
|
|
230
|
+
"""Shell `export` lines for THIS cycle, safe to `eval`.
|
|
231
|
+
|
|
232
|
+
personal_brand only -> force the persona project + link-free replies.
|
|
233
|
+
promotion only -> nothing (normal weighted pick; persona is enabled:false).
|
|
234
|
+
both on -> 50/50 coin flip per cycle: half persona/link-free,
|
|
235
|
+
half normal promotion pick.
|
|
236
|
+
neither (shouldn't happen; default keeps personal on) -> behave like personal
|
|
237
|
+
so the cycle is never a silent no-op.
|
|
238
|
+
"""
|
|
239
|
+
flags = get_flags()
|
|
240
|
+
pb = flags.get("personal_brand")
|
|
241
|
+
pr = flags.get("promotion")
|
|
242
|
+
|
|
243
|
+
if pb and pr:
|
|
244
|
+
# Both lanes active: this single cycle is one or the other, 50/50.
|
|
245
|
+
if random.random() < 0.5:
|
|
246
|
+
print("[saps_mode] both lanes on; this cycle -> personal_brand (50/50)",
|
|
247
|
+
file=sys.stderr)
|
|
248
|
+
return _persona_env_lines()
|
|
249
|
+
print("[saps_mode] both lanes on; this cycle -> promotion (50/50)",
|
|
250
|
+
file=sys.stderr)
|
|
251
|
+
return ""
|
|
252
|
+
if pb:
|
|
253
|
+
return _persona_env_lines()
|
|
254
|
+
if pr:
|
|
255
|
+
return ""
|
|
256
|
+
# Neither on (degenerate) -> don't leave the cycle dead; run personal.
|
|
257
|
+
print("[saps_mode] no lane enabled; defaulting this cycle to personal_brand.",
|
|
258
|
+
file=sys.stderr)
|
|
259
|
+
return _persona_env_lines()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv) -> int:
|
|
263
|
+
if not argv:
|
|
264
|
+
print(get_mode())
|
|
265
|
+
return 0
|
|
266
|
+
cmd = argv[0]
|
|
267
|
+
if cmd == "get":
|
|
268
|
+
print(get_mode())
|
|
269
|
+
return 0
|
|
270
|
+
if cmd == "flags":
|
|
271
|
+
print(json.dumps(get_flags()))
|
|
272
|
+
return 0
|
|
273
|
+
if cmd == "set":
|
|
274
|
+
if len(argv) < 2:
|
|
275
|
+
print("usage: saps_mode.py set <personal_brand|promotion>", file=sys.stderr)
|
|
276
|
+
return 2
|
|
277
|
+
try:
|
|
278
|
+
print(set_mode(argv[1]))
|
|
279
|
+
return 0
|
|
280
|
+
except ValueError as e:
|
|
281
|
+
print(str(e), file=sys.stderr)
|
|
282
|
+
return 2
|
|
283
|
+
if cmd == "set-flags":
|
|
284
|
+
if len(argv) < 3:
|
|
285
|
+
print("usage: saps_mode.py set-flags <personal_brand 0|1> <promotion 0|1>",
|
|
286
|
+
file=sys.stderr)
|
|
287
|
+
return 2
|
|
288
|
+
flags = write_flags(_coerce_bool(argv[1]), _coerce_bool(argv[2]))
|
|
289
|
+
print(json.dumps(flags))
|
|
290
|
+
return 0
|
|
291
|
+
if cmd in ("enable", "disable"):
|
|
292
|
+
if len(argv) < 2:
|
|
293
|
+
print(f"usage: saps_mode.py {cmd} <personal_brand|promotion>", file=sys.stderr)
|
|
294
|
+
return 2
|
|
295
|
+
try:
|
|
296
|
+
flags = set_lane(argv[1], on=(cmd == "enable"))
|
|
297
|
+
print(json.dumps(flags))
|
|
298
|
+
return 0
|
|
299
|
+
except ValueError as e:
|
|
300
|
+
print(str(e), file=sys.stderr)
|
|
301
|
+
return 2
|
|
302
|
+
if cmd == "toggle":
|
|
303
|
+
if len(argv) >= 2:
|
|
304
|
+
try:
|
|
305
|
+
flags = toggle_lane(argv[1])
|
|
306
|
+
print(json.dumps(flags))
|
|
307
|
+
return 0
|
|
308
|
+
except ValueError as e:
|
|
309
|
+
print(str(e), file=sys.stderr)
|
|
310
|
+
return 2
|
|
311
|
+
# Legacy whole-mode flip: personal<->promotion (mutually exclusive).
|
|
312
|
+
new = PROMOTION if get_mode() == PERSONAL_BRAND else PERSONAL_BRAND
|
|
313
|
+
print(set_mode(new))
|
|
314
|
+
return 0
|
|
315
|
+
if cmd == "env":
|
|
316
|
+
out = env_exports()
|
|
317
|
+
if out:
|
|
318
|
+
print(out)
|
|
319
|
+
return 0
|
|
320
|
+
if cmd == "persona-name":
|
|
321
|
+
print(persona_name())
|
|
322
|
+
return 0
|
|
323
|
+
print(f"unknown command: {cmd}", file=sys.stderr)
|
|
324
|
+
return 2
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
raise SystemExit(main(sys.argv[1:]))
|