@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,875 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# run-instagram-render.sh — Spawn `claude -p` to render ONE fresh IG reel
|
|
3
|
+
# end-to-end per mixer/SKILL.md.
|
|
4
|
+
#
|
|
5
|
+
# Cadence (com.m13v.social-instagram-render.plist):
|
|
6
|
+
# 5 fires/day at 08:30, 11:30, 14:30, 17:30, 20:30 local time, 30 min
|
|
7
|
+
# before each post-cycle slot.
|
|
8
|
+
#
|
|
9
|
+
# Per-fire logic:
|
|
10
|
+
# 1. acquire_lock instagram-render (data.ts edits race otherwise)
|
|
11
|
+
# 2. compute target post_type via 4:1 of last 5 posted IG rows
|
|
12
|
+
# 3. count existing drafts of that type. If >=3, SKIP (buffer healthy).
|
|
13
|
+
# 4. pull from Postgres: local_audio_lru (LRU-ordered local mixer/audio pool),
|
|
14
|
+
# used_angles (14d), used_variant_ids (all-time).
|
|
15
|
+
# 5. spawn run_claude.sh with mixer/SKILL.md as the procedure, plus a
|
|
16
|
+
# compact request envelope (type, post_number, exclusions).
|
|
17
|
+
# 6. on exit, verify post-NNN.mp4, post-NNN.caption.txt, media_posts row
|
|
18
|
+
# with status='draft' and post_type matching target.
|
|
19
|
+
#
|
|
20
|
+
# Exit codes:
|
|
21
|
+
# 0 - rendered, OR buffer healthy and skipped, OR another run holds the lock
|
|
22
|
+
# 1 - real failure (claude error, ffmpeg fail, missing deliverables)
|
|
23
|
+
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
27
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
28
|
+
mkdir -p "$LOG_DIR"
|
|
29
|
+
|
|
30
|
+
# Cycle ID for cross-cycle cost accounting. The single claude -p invocation
|
|
31
|
+
# inherits this via env so log_claude_session.py stamps claude_sessions.cycle_id.
|
|
32
|
+
BATCH_ID="${BATCH_ID:-igren-$(date +%Y%m%d-%H%M%S)}"
|
|
33
|
+
export BATCH_ID
|
|
34
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
35
|
+
|
|
36
|
+
LOG_FILE="$LOG_DIR/instagram-render-$(date +%Y-%m-%d_%H%M%S).log"
|
|
37
|
+
PICK_FILE="/tmp/ig_render_pick_$(date +%s)_$$.json"
|
|
38
|
+
|
|
39
|
+
if [ -f "$REPO_DIR/.env" ]; then
|
|
40
|
+
set -a
|
|
41
|
+
# shellcheck disable=SC1091
|
|
42
|
+
source "$REPO_DIR/.env"
|
|
43
|
+
set +a
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
47
|
+
|
|
48
|
+
# Run accounting for dashboard Job History (Render · Instagram). Matches the
|
|
49
|
+
# pattern in run-instagram-daily.sh / run-twitter-threads.sh / run-reddit-threads.sh:
|
|
50
|
+
# each exit site updates POSTED_CT / SKIPPED_CT / FAILED_CT; the EXIT trap
|
|
51
|
+
# always emits one log_run.py line so the run shows up under render_instagram.
|
|
52
|
+
# "posted" here means "rendered a fresh draft"; "skipped" means buffer was
|
|
53
|
+
# healthy (>=3 drafts) or the lock was already held; "failed" is any real
|
|
54
|
+
# error path.
|
|
55
|
+
RUN_START_EPOCH=$(date +%s)
|
|
56
|
+
POSTED_CT=0
|
|
57
|
+
SKIPPED_CT=0
|
|
58
|
+
FAILED_CT=0
|
|
59
|
+
|
|
60
|
+
cleanup() {
|
|
61
|
+
local rc=$?
|
|
62
|
+
rm -f "$PICK_FILE" "${UNPROVEN_JSON_FILE:-}"
|
|
63
|
+
if [ "$POSTED_CT" -eq 0 ] && [ "$SKIPPED_CT" -eq 0 ] && [ "$FAILED_CT" -eq 0 ]; then
|
|
64
|
+
if [ "$rc" -eq 0 ]; then SKIPPED_CT=1; else FAILED_CT=1; fi
|
|
65
|
+
fi
|
|
66
|
+
local elapsed=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
67
|
+
local cost
|
|
68
|
+
cost=$(/usr/bin/python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-instagram-render" 2>/dev/null || echo "0.0000")
|
|
69
|
+
/usr/bin/python3 "$REPO_DIR/scripts/log_run.py" \
|
|
70
|
+
--script "render_instagram" \
|
|
71
|
+
--posted "$POSTED_CT" --skipped "$SKIPPED_CT" --failed "$FAILED_CT" \
|
|
72
|
+
--cost "$cost" --elapsed "$elapsed" >/dev/null 2>&1 || true
|
|
73
|
+
}
|
|
74
|
+
trap cleanup EXIT INT TERM HUP
|
|
75
|
+
|
|
76
|
+
log "=== instagram-render fire: $(date) ==="
|
|
77
|
+
|
|
78
|
+
# Lock against parallel renders. data.ts edits + npx remotion compositions
|
|
79
|
+
# are not safe to interleave.
|
|
80
|
+
# shellcheck source=lock.sh
|
|
81
|
+
source "$REPO_DIR/skill/lock.sh"
|
|
82
|
+
acquire_lock instagram-render 60
|
|
83
|
+
|
|
84
|
+
# Step 0: pick TARGET account (inverse-recent-share over enabled IG accounts in
|
|
85
|
+
# config.json:instagram.accounts[]). Honors FORCE_ACCOUNT env override. The
|
|
86
|
+
# chosen account scopes Step 1 queries (type buffer, used angles, used
|
|
87
|
+
# variants, audio LRU) so each account has its own rotation.
|
|
88
|
+
log "step 0: picking target account"
|
|
89
|
+
if [ -n "${FORCE_ACCOUNT:-}" ]; then
|
|
90
|
+
TARGET_ACCOUNT=$("$REPO_DIR/scripts/pick_ig_account.py" --account "$FORCE_ACCOUNT" 2>>"$LOG_FILE")
|
|
91
|
+
else
|
|
92
|
+
TARGET_ACCOUNT=$("$REPO_DIR/scripts/pick_ig_account.py" 2>>"$LOG_FILE")
|
|
93
|
+
fi
|
|
94
|
+
if [ -z "$TARGET_ACCOUNT" ]; then
|
|
95
|
+
log "ERROR: pick_ig_account.py returned empty; no enabled accounts?"
|
|
96
|
+
FAILED_CT=1
|
|
97
|
+
exit 1
|
|
98
|
+
fi
|
|
99
|
+
export TARGET_ACCOUNT
|
|
100
|
+
log "target_account=$TARGET_ACCOUNT"
|
|
101
|
+
|
|
102
|
+
# Step 1: compute target type + exclusion lists (scoped to TARGET_ACCOUNT)
|
|
103
|
+
log "step 1: querying Postgres for target_type, draft_count, exclusions (account=$TARGET_ACCOUNT)"
|
|
104
|
+
/opt/homebrew/bin/python3.11 - > "$PICK_FILE" 2>>"$LOG_FILE" <<'PY'
|
|
105
|
+
import glob, json, os, random, sys
|
|
106
|
+
from datetime import datetime
|
|
107
|
+
# HTTP-only (2026-06-01): all media_posts reads route through
|
|
108
|
+
# /api/v1/media-posts/picker-context via http_api. No DATABASE_URL, no
|
|
109
|
+
# psycopg2, no fallback. The api_get call happens once below (after
|
|
110
|
+
# target_account + recent_window_days are known) and every aggregate the
|
|
111
|
+
# picker needs is read out of the returned context dict.
|
|
112
|
+
sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
|
|
113
|
+
from http_api import api_get
|
|
114
|
+
|
|
115
|
+
# Inverse-recent-share weighting at TWO levels, mirroring the Twitter pipeline
|
|
116
|
+
# (scripts/pick_project.py). Effective weight = config_weight / (1 + posts in
|
|
117
|
+
# the last RECENT_WINDOW_DAYS). Over-posting damps without ever exceeding the
|
|
118
|
+
# raw config weight; under-posting catches up automatically.
|
|
119
|
+
#
|
|
120
|
+
# Two env-var overrides bypass the stochastic rolls (one-shot / debugging):
|
|
121
|
+
# FORCE_TYPE=organic|product skip Level-1 roll
|
|
122
|
+
# FORCE_PROJECT=<name> skip Level-2 roll (implies product)
|
|
123
|
+
|
|
124
|
+
cfg = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
|
|
125
|
+
ig_cfg = cfg.get('instagram', {}) or {}
|
|
126
|
+
recent_window_days = int(ig_cfg.get('recent_window_days', 7))
|
|
127
|
+
post_type_weights_cfg = (
|
|
128
|
+
ig_cfg.get('post_type_weights')
|
|
129
|
+
or ig_cfg.get('post_type_ratio')
|
|
130
|
+
or {'organic': 4, 'product': 1}
|
|
131
|
+
)
|
|
132
|
+
force_type = os.environ.get('FORCE_TYPE') or ''
|
|
133
|
+
force_project = os.environ.get('FORCE_PROJECT') or ''
|
|
134
|
+
if force_project and not force_type:
|
|
135
|
+
force_type = 'product' # FORCE_PROJECT implies product
|
|
136
|
+
|
|
137
|
+
# Target account: scopes every recency / buffer / exclusion query so each
|
|
138
|
+
# account has its own type rotation, variant pool, angle list, audio LRU.
|
|
139
|
+
target_account = os.environ.get('TARGET_ACCOUNT', '').strip()
|
|
140
|
+
if not target_account:
|
|
141
|
+
raise SystemExit("TARGET_ACCOUNT env var missing (Step 0 should have set it)")
|
|
142
|
+
|
|
143
|
+
# Per-account overrides: each account in instagram.accounts[] may override the
|
|
144
|
+
# global post_type_weights and supply a `tlh` block (source_dir,
|
|
145
|
+
# variant_prefix, story_brief, unproven_dir, caption_opener) that scopes the
|
|
146
|
+
# TLH render to its own clip pool + voice. Accounts without these fields
|
|
147
|
+
# fall back to globals (matt_diak / matthewheartful behavior is unchanged).
|
|
148
|
+
account_record = next(
|
|
149
|
+
(a for a in (ig_cfg.get('accounts') or [])
|
|
150
|
+
if a.get('username', '').lower() == target_account.lower()),
|
|
151
|
+
{}
|
|
152
|
+
)
|
|
153
|
+
if account_record.get('post_type_weights'):
|
|
154
|
+
post_type_weights_cfg = account_record['post_type_weights']
|
|
155
|
+
account_tlh_config = account_record.get('tlh') or {}
|
|
156
|
+
|
|
157
|
+
# Single HTTP read: every media_posts aggregate the picker needs, scoped to
|
|
158
|
+
# this account + recency window. Local weighting / glob / JSON shaping below
|
|
159
|
+
# is unchanged; only the data source moved from psycopg2 to the API.
|
|
160
|
+
_pc = api_get(
|
|
161
|
+
'/api/v1/media-posts/picker-context',
|
|
162
|
+
query={'target_account': target_account, 'window_days': recent_window_days},
|
|
163
|
+
)
|
|
164
|
+
ctx = (_pc.get('data') or {})
|
|
165
|
+
|
|
166
|
+
# Last-N posted descriptor (kept for telemetry / log readability).
|
|
167
|
+
last10 = list(ctx.get('last10_posted') or [])
|
|
168
|
+
|
|
169
|
+
# ---- LEVEL 1: organic vs product, inverse-recent-share (or FORCE_TYPE) ----
|
|
170
|
+
recent_type_counts = {k: v for k, v in (ctx.get('recent_type_counts') or {}).items() if k}
|
|
171
|
+
for t in ('organic', 'product'):
|
|
172
|
+
recent_type_counts.setdefault(t, 0)
|
|
173
|
+
type_weights = {
|
|
174
|
+
t: float(post_type_weights_cfg.get(t, 0)) / (1 + recent_type_counts[t])
|
|
175
|
+
for t in ('organic', 'product')
|
|
176
|
+
if float(post_type_weights_cfg.get(t, 0)) > 0
|
|
177
|
+
}
|
|
178
|
+
if force_type in ('organic', 'product'):
|
|
179
|
+
target = force_type
|
|
180
|
+
elif not type_weights:
|
|
181
|
+
target = 'organic' # defensive default if config is empty
|
|
182
|
+
else:
|
|
183
|
+
names = list(type_weights.keys())
|
|
184
|
+
ws = [type_weights[n] for n in names]
|
|
185
|
+
target = random.choices(names, weights=ws, k=1)[0]
|
|
186
|
+
|
|
187
|
+
# ---- LEVEL 2: which project, inverse-recent-share (product only) ----
|
|
188
|
+
# Organic content is intentionally product-free -> project_name=NULL.
|
|
189
|
+
selected_project = None
|
|
190
|
+
mixer_enabled_projects = []
|
|
191
|
+
project_post_counts = {}
|
|
192
|
+
project_weights = {}
|
|
193
|
+
if target == 'product':
|
|
194
|
+
enabled = [
|
|
195
|
+
p for p in cfg.get('projects', [])
|
|
196
|
+
if isinstance(p.get('mixer'), dict)
|
|
197
|
+
and p['mixer'].get('enabled') is True
|
|
198
|
+
and p.get('weight', 0) > 0
|
|
199
|
+
]
|
|
200
|
+
mixer_enabled_projects = sorted([p['name'] for p in enabled])
|
|
201
|
+
if not enabled:
|
|
202
|
+
# defensive fallback to mk0r if no project is flagged
|
|
203
|
+
enabled = [{'name': 'mk0r', 'weight': 1}]
|
|
204
|
+
mixer_enabled_projects = ['mk0r']
|
|
205
|
+
|
|
206
|
+
recent_proj_counts = dict(ctx.get('recent_product_counts_by_project') or {})
|
|
207
|
+
for p in enabled:
|
|
208
|
+
project_post_counts[p['name']] = recent_proj_counts.get(p['name'], 0)
|
|
209
|
+
project_weights = {
|
|
210
|
+
p['name']: float(p['weight']) / (1 + project_post_counts[p['name']])
|
|
211
|
+
for p in enabled
|
|
212
|
+
}
|
|
213
|
+
if force_project:
|
|
214
|
+
if force_project not in [p['name'] for p in enabled]:
|
|
215
|
+
raise SystemExit(
|
|
216
|
+
f"FORCE_PROJECT={force_project!r} not in mixer.enabled projects: "
|
|
217
|
+
f"{[p['name'] for p in enabled]}"
|
|
218
|
+
)
|
|
219
|
+
selected_project = force_project
|
|
220
|
+
else:
|
|
221
|
+
names = list(project_weights.keys())
|
|
222
|
+
ws = [project_weights[n] for n in names]
|
|
223
|
+
selected_project = random.choices(names, weights=ws, k=1)[0]
|
|
224
|
+
|
|
225
|
+
# Per-(account, type, project) draft buffer. Each account has its own
|
|
226
|
+
# rotation; a build-up on matt_diak should not block a heartfulmatthew render.
|
|
227
|
+
# draft_counts comes back grouped by (post_type, project_name); sum the rows
|
|
228
|
+
# the same way the two SQL variants did (product = type+project, organic =
|
|
229
|
+
# type only, project-agnostic).
|
|
230
|
+
_draft_rows = ctx.get('draft_counts') or []
|
|
231
|
+
if target == 'product':
|
|
232
|
+
draft_count = sum(
|
|
233
|
+
int(r.get('count') or 0) for r in _draft_rows
|
|
234
|
+
if r.get('post_type') == target and r.get('project_name') == selected_project
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
draft_count = sum(
|
|
238
|
+
int(r.get('count') or 0) for r in _draft_rows
|
|
239
|
+
if r.get('post_type') == target
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
next_num = int(ctx.get('next_post_number') or 1)
|
|
243
|
+
|
|
244
|
+
# Audio policy: local-only, least-recently-used rotation. The render must
|
|
245
|
+
# reuse the existing mixer/audio/ pool and NEVER source fresh audio from the
|
|
246
|
+
# network. We list the on-disk tracks and order them by last use in
|
|
247
|
+
# media_posts (never-used first, then oldest-used first). audio_source values
|
|
248
|
+
# vary in shape (local:/abs/path, local:~/path, ig://reel/<code>), so a track
|
|
249
|
+
# counts as "used" by a row if the row's audio_source contains the track's
|
|
250
|
+
# basename OR its trailing token (reel code / label after the last '_').
|
|
251
|
+
audio_dir = os.path.expanduser('~/social-autoposter/mixer/audio')
|
|
252
|
+
local_files = sorted(glob.glob(os.path.join(audio_dir, '*.m4a')))
|
|
253
|
+
|
|
254
|
+
# audio_usage rows arrive as [audio_source, used_at_iso]; parse the ISO
|
|
255
|
+
# timestamp back to a datetime so the LRU comparison + .isoformat() sort below
|
|
256
|
+
# work exactly as they did with psycopg2-returned datetimes.
|
|
257
|
+
def _parse_dt(s):
|
|
258
|
+
if not s:
|
|
259
|
+
return None
|
|
260
|
+
try:
|
|
261
|
+
return datetime.fromisoformat(str(s).replace('Z', '+00:00'))
|
|
262
|
+
except Exception:
|
|
263
|
+
return None
|
|
264
|
+
audio_usage = [(r[0] or '', _parse_dt(r[1])) for r in (ctx.get('audio_usage') or [])]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _audio_token(path):
|
|
268
|
+
stem = os.path.splitext(os.path.basename(path))[0]
|
|
269
|
+
return stem.rsplit('_', 1)[-1] if '_' in stem else stem
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
audio_lru = []
|
|
273
|
+
for f in local_files:
|
|
274
|
+
base = os.path.basename(f)
|
|
275
|
+
tok = _audio_token(f)
|
|
276
|
+
last_used = None
|
|
277
|
+
for src, used_at in audio_usage:
|
|
278
|
+
if used_at is None:
|
|
279
|
+
continue
|
|
280
|
+
if base in src or (tok and tok in src):
|
|
281
|
+
if last_used is None or used_at > last_used:
|
|
282
|
+
last_used = used_at
|
|
283
|
+
audio_lru.append((f, last_used))
|
|
284
|
+
|
|
285
|
+
# never-used tracks first, then oldest-used first
|
|
286
|
+
audio_lru.sort(key=lambda x: (1, x[1].isoformat()) if x[1] else (0, ''))
|
|
287
|
+
local_audio_lru = [f for f, _ in audio_lru]
|
|
288
|
+
|
|
289
|
+
used_angles = sorted({a for a in (ctx.get('used_theme_angles_14d') or []) if a})
|
|
290
|
+
|
|
291
|
+
used_variants = sorted({v for v in (ctx.get('used_variant_ids') or []) if v is not None})
|
|
292
|
+
|
|
293
|
+
print(json.dumps({
|
|
294
|
+
'target_account': target_account,
|
|
295
|
+
'target_type': target,
|
|
296
|
+
'last10_posted': last10,
|
|
297
|
+
'recent_window_days': recent_window_days,
|
|
298
|
+
'recent_type_counts': recent_type_counts,
|
|
299
|
+
'type_weights_effective': type_weights,
|
|
300
|
+
'post_type_weights_config': post_type_weights_cfg,
|
|
301
|
+
'draft_count_target': draft_count,
|
|
302
|
+
'next_post_number': next_num,
|
|
303
|
+
'local_audio_lru': local_audio_lru,
|
|
304
|
+
'used_theme_angles_14d': used_angles,
|
|
305
|
+
'used_variant_ids': used_variants,
|
|
306
|
+
# Level-2 product routing (NULL when target=='organic').
|
|
307
|
+
'selected_project': selected_project,
|
|
308
|
+
'mixer_enabled_projects': mixer_enabled_projects,
|
|
309
|
+
'recent_product_posts_by_project': project_post_counts,
|
|
310
|
+
'project_weights_effective': project_weights,
|
|
311
|
+
# Per-account TLH overrides (organic format). Empty dict {} means
|
|
312
|
+
# this account uses SKILL.md defaults (Matt's '5. time lapse hooks/'
|
|
313
|
+
# source + AI-defeat caption arc). When non-empty, Claude MUST use
|
|
314
|
+
# these overrides for source_dir, variant_prefix, and story_brief.
|
|
315
|
+
'account_tlh_config': account_tlh_config,
|
|
316
|
+
}, indent=2))
|
|
317
|
+
PY
|
|
318
|
+
|
|
319
|
+
if [ ! -s "$PICK_FILE" ]; then
|
|
320
|
+
log "ERROR: pick query produced no output"
|
|
321
|
+
FAILED_CT=1
|
|
322
|
+
exit 1
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
TARGET=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$PICK_FILE'))['target_type'])")
|
|
326
|
+
DRAFT_COUNT=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$PICK_FILE'))['draft_count_target'])")
|
|
327
|
+
NEXT_NUM=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$PICK_FILE'))['next_post_number'])")
|
|
328
|
+
SELECTED_PROJECT=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$PICK_FILE')).get('selected_project') or '')")
|
|
329
|
+
NNN=$(printf "%03d" "$NEXT_NUM")
|
|
330
|
+
|
|
331
|
+
log "target=$TARGET selected_project=${SELECTED_PROJECT:-<null/organic>} draft_count_target=$DRAFT_COUNT next_post_number=$NEXT_NUM"
|
|
332
|
+
|
|
333
|
+
# Step 1.6: engagement style assignment. Mirrors the twitter cycle pattern:
|
|
334
|
+
# - Organic runs: roll via picker (95% top performer / 5% invent).
|
|
335
|
+
# - Product runs: deterministic mapping from selected_project (the picker
|
|
336
|
+
# never sees walkin/studyly because they're in PLATFORM_POLICY.instagram.never).
|
|
337
|
+
# The assignment is injected into the Claude prompt envelope below and Claude
|
|
338
|
+
# stamps metadata.engagement_style on the media_posts row. sync_ig_to_posts.py
|
|
339
|
+
# mirrors that field to posts.engagement_style for the dashboard A/B.
|
|
340
|
+
source "$REPO_DIR/skill/styles.sh"
|
|
341
|
+
STYLE_ASSIGN_FILE=$(mktemp -t saps_ig_style_XXXXXX.json)
|
|
342
|
+
if [ "$TARGET" = "organic" ]; then
|
|
343
|
+
saps_pick_style instagram posting "$STYLE_ASSIGN_FILE" >/dev/null 2>>"$LOG_FILE" || true
|
|
344
|
+
PICKED_STYLE=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$STYLE_ASSIGN_FILE')).get('style') or '')")
|
|
345
|
+
PICK_MODE=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$STYLE_ASSIGN_FILE')).get('mode',''))")
|
|
346
|
+
else
|
|
347
|
+
case "$SELECTED_PROJECT" in
|
|
348
|
+
mk0r) PICKED_STYLE="ig_walkin_storefront_playbook" ;;
|
|
349
|
+
studyly) PICKED_STYLE="ig_studyly_failing_student_arc" ;;
|
|
350
|
+
*) PICKED_STYLE="" ;;
|
|
351
|
+
esac
|
|
352
|
+
PICK_MODE="use"
|
|
353
|
+
/opt/homebrew/bin/python3.11 - "$STYLE_ASSIGN_FILE" "$PICKED_STYLE" "$PICK_MODE" <<'PY'
|
|
354
|
+
import json, sys
|
|
355
|
+
out, style, mode = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
356
|
+
with open(out, 'w') as f:
|
|
357
|
+
json.dump({
|
|
358
|
+
"mode": mode, "style": style or None,
|
|
359
|
+
"description": None, "example": None, "note": None,
|
|
360
|
+
"reference_styles": [], "distribution_snapshot": [],
|
|
361
|
+
"source": "project_gated",
|
|
362
|
+
}, f)
|
|
363
|
+
PY
|
|
364
|
+
fi
|
|
365
|
+
STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" instagram posting)
|
|
366
|
+
log "engagement_style: picked='${PICKED_STYLE}' mode='${PICK_MODE}' target=${TARGET} project=${SELECTED_PROJECT:-organic}"
|
|
367
|
+
|
|
368
|
+
# Buffer guard: if 3+ drafts of target type already exist, skip.
|
|
369
|
+
# Override with FORCE_RENDER=1 for manual / first-fire runs.
|
|
370
|
+
if [ "${FORCE_RENDER:-0}" != "1" ] && [ "$DRAFT_COUNT" -ge 3 ]; then
|
|
371
|
+
log "skipped: $DRAFT_COUNT drafts of $TARGET already in queue (>= 3 buffer); no render needed"
|
|
372
|
+
SKIPPED_CT=1
|
|
373
|
+
exit 0
|
|
374
|
+
fi
|
|
375
|
+
|
|
376
|
+
# Step 1.5: organic only — 50% rotation injects one untried clip from
|
|
377
|
+
# 'mixer/unproven new content/' as one of the TLH slots. "Tried once" is
|
|
378
|
+
# tracked by media_posts.metadata->>'unproven_clip_basename'; once that
|
|
379
|
+
# basename appears on any row, the clip is retired from the rotation pool.
|
|
380
|
+
# Override with FORCE_UNPROVEN=1 (always pick if any untried) or
|
|
381
|
+
# FORCE_UNPROVEN=0 (never pick). Default: probabilistic 50%.
|
|
382
|
+
UNPROVEN_JSON_FILE="/tmp/ig_unproven_pick_$(date +%s)_$$.json"
|
|
383
|
+
echo '{"use": false, "reason": "default"}' > "$UNPROVEN_JSON_FILE"
|
|
384
|
+
if [ "$TARGET" = "organic" ]; then
|
|
385
|
+
/opt/homebrew/bin/python3.11 - "$UNPROVEN_JSON_FILE" > /dev/null 2>>"$LOG_FILE" <<'PY'
|
|
386
|
+
import json, os, random, sys, glob
|
|
387
|
+
# HTTP-only (2026-06-01): used-clip basenames come from
|
|
388
|
+
# /api/v1/media-posts/unproven-clips. No DATABASE_URL, no psycopg2.
|
|
389
|
+
sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
|
|
390
|
+
from http_api import api_get
|
|
391
|
+
out_path = sys.argv[1]
|
|
392
|
+
# Resolve per-account unproven_dir override from config.json. If the
|
|
393
|
+
# account opts out (tlh.unproven_dir == null) the step short-circuits.
|
|
394
|
+
cfg = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
|
|
395
|
+
target_account_init = os.environ.get('TARGET_ACCOUNT', '').strip()
|
|
396
|
+
account_record_init = next(
|
|
397
|
+
(a for a in ((cfg.get('instagram') or {}).get('accounts') or [])
|
|
398
|
+
if a.get('username', '').lower() == target_account_init.lower()),
|
|
399
|
+
{}
|
|
400
|
+
)
|
|
401
|
+
tlh_cfg_init = account_record_init.get('tlh') or {}
|
|
402
|
+
if 'unproven_dir' in tlh_cfg_init:
|
|
403
|
+
unproven_dir_raw = tlh_cfg_init.get('unproven_dir')
|
|
404
|
+
unproven_dir = os.path.expanduser(unproven_dir_raw) if unproven_dir_raw else None
|
|
405
|
+
else:
|
|
406
|
+
# Default: matt_diak / matthewheartful unchanged.
|
|
407
|
+
unproven_dir = os.path.expanduser('~/social-autoposter/mixer/unproven new content')
|
|
408
|
+
result = {"use": False}
|
|
409
|
+
if unproven_dir is None:
|
|
410
|
+
result = {"use": False, "reason": "account opted out of unproven rotation (tlh.unproven_dir=null)"}
|
|
411
|
+
with open(out_path, 'w') as f:
|
|
412
|
+
json.dump(result, f, indent=2)
|
|
413
|
+
raise SystemExit(0)
|
|
414
|
+
try:
|
|
415
|
+
target_account = os.environ.get('TARGET_ACCOUNT', '').strip()
|
|
416
|
+
if not target_account:
|
|
417
|
+
raise SystemExit("TARGET_ACCOUNT env var missing in unproven-clip step")
|
|
418
|
+
_uc = api_get(
|
|
419
|
+
'/api/v1/media-posts/unproven-clips',
|
|
420
|
+
query={'target_account': target_account},
|
|
421
|
+
)
|
|
422
|
+
used = {b for b in ((_uc.get('data') or {}).get('basenames') or []) if b}
|
|
423
|
+
if not os.path.isdir(unproven_dir):
|
|
424
|
+
result = {"use": False, "reason": "unproven dir missing", "dir": unproven_dir}
|
|
425
|
+
else:
|
|
426
|
+
candidates = []
|
|
427
|
+
for ext in ('*.MP4', '*.mp4', '*.MOV', '*.mov', '*.m4v', '*.M4V'):
|
|
428
|
+
candidates.extend(glob.glob(os.path.join(unproven_dir, ext)))
|
|
429
|
+
untried = [p for p in candidates if os.path.basename(p) not in used]
|
|
430
|
+
force = os.environ.get('FORCE_UNPROVEN')
|
|
431
|
+
if force == '0':
|
|
432
|
+
roll_use = False
|
|
433
|
+
elif force == '1':
|
|
434
|
+
roll_use = True
|
|
435
|
+
else:
|
|
436
|
+
roll_use = (random.random() < 0.5)
|
|
437
|
+
if roll_use and untried:
|
|
438
|
+
pick = random.choice(untried)
|
|
439
|
+
result = {
|
|
440
|
+
"use": True,
|
|
441
|
+
"basename": os.path.basename(pick),
|
|
442
|
+
"path": pick,
|
|
443
|
+
"untried_count": len(untried),
|
|
444
|
+
"total_count": len(candidates),
|
|
445
|
+
"used_count": len(used),
|
|
446
|
+
"force": force,
|
|
447
|
+
}
|
|
448
|
+
else:
|
|
449
|
+
result = {
|
|
450
|
+
"use": False,
|
|
451
|
+
"reason": (
|
|
452
|
+
"no untried clips" if not untried
|
|
453
|
+
else f"coin flip skipped (force={force})"
|
|
454
|
+
),
|
|
455
|
+
"untried_count": len(untried),
|
|
456
|
+
"total_count": len(candidates),
|
|
457
|
+
"used_count": len(used),
|
|
458
|
+
"force": force,
|
|
459
|
+
}
|
|
460
|
+
except Exception as e:
|
|
461
|
+
result = {"use": False, "reason": f"error: {e}"}
|
|
462
|
+
with open(out_path, 'w') as f:
|
|
463
|
+
json.dump(result, f, indent=2)
|
|
464
|
+
PY
|
|
465
|
+
UNPROVEN_USE=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$UNPROVEN_JSON_FILE')).get('use', False))")
|
|
466
|
+
if [ "$UNPROVEN_USE" = "True" ]; then
|
|
467
|
+
UNPROVEN_BASENAME=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$UNPROVEN_JSON_FILE'))['basename'])")
|
|
468
|
+
log "unproven clip injected: $UNPROVEN_BASENAME"
|
|
469
|
+
else
|
|
470
|
+
UNPROVEN_REASON=$(/opt/homebrew/bin/python3.11 -c "import json; print(json.load(open('$UNPROVEN_JSON_FILE')).get('reason', 'n/a'))")
|
|
471
|
+
log "unproven clip not injected: $UNPROVEN_REASON"
|
|
472
|
+
fi
|
|
473
|
+
fi
|
|
474
|
+
|
|
475
|
+
# Step 2: build prompt and spawn claude
|
|
476
|
+
PROMPT_FILE="/tmp/ig_render_prompt_$(date +%s)_$$.txt"
|
|
477
|
+
|
|
478
|
+
cat > "$PROMPT_FILE" <<PROMPT_EOF
|
|
479
|
+
You are the Instagram render-cycle agent for the social-autoposter project.
|
|
480
|
+
Your single job for this fire: render ONE fresh Instagram reel for posting,
|
|
481
|
+
following ~/social-autoposter/mixer/SKILL.md to the letter.
|
|
482
|
+
|
|
483
|
+
TARGET ACCOUNT FOR THIS RUN: ${TARGET_ACCOUNT}
|
|
484
|
+
This reel will be posted to the @${TARGET_ACCOUNT} Instagram account. Every
|
|
485
|
+
recency/exclusion list in the request envelope below is already scoped to
|
|
486
|
+
this account, so you can treat them as authoritative. You MUST set
|
|
487
|
+
target_account='${TARGET_ACCOUNT}' on the media_posts row you write.
|
|
488
|
+
|
|
489
|
+
READ THE SKILL FIRST: ~/social-autoposter/mixer/SKILL.md is your complete
|
|
490
|
+
creative + technical procedure. It explains the two formats (Mixer for niche
|
|
491
|
+
product reels, TLH for AI-lesson hook reels), the caption arc, the ffmpeg
|
|
492
|
+
encode + dub commands, the data.ts variant schema, and the media_posts row
|
|
493
|
+
shape. Follow it exactly. When SKILL and this prompt disagree, SKILL wins.
|
|
494
|
+
|
|
495
|
+
REQUEST ENVELOPE FOR THIS RUN:
|
|
496
|
+
$(cat "$PICK_FILE")
|
|
497
|
+
|
|
498
|
+
UNPROVEN CLIP INJECTION (organic runs only):
|
|
499
|
+
$(cat "$UNPROVEN_JSON_FILE")
|
|
500
|
+
If "use": true above, you MUST include the clip at "path" as ONE of the 3-6
|
|
501
|
+
TLH slots in this render, encoded via the same pure-speedup ffmpeg recipe
|
|
502
|
+
SKILL Section 3 step 2 uses for any raw clip from '5. time lapse hooks/'.
|
|
503
|
+
After insert, set metadata.unproven_clip_basename = "<basename>" on the
|
|
504
|
+
media_posts row. The render-cycle uses this to retire the clip from future
|
|
505
|
+
rotation. See SKILL Section 3 "Unproven clip injection" for details.
|
|
506
|
+
If "use": false, ignore this block; render normally from the existing pool.
|
|
507
|
+
|
|
508
|
+
TYPE MAPPING (do NOT swap these):
|
|
509
|
+
- post_type='organic' -> TLH format. AI-themed lesson, NO product mention by
|
|
510
|
+
name (no Fazm, no Mediar, no AppMaker, no mk0r, no studyly). 7-8s total.
|
|
511
|
+
project_name MUST be NULL in the media_posts row (organic content is
|
|
512
|
+
intentionally product-free; null is the correct attribution).
|
|
513
|
+
- post_type='product' -> Mixer format. The picker has already chosen which
|
|
514
|
+
PROJECT this product reel promotes; see selected_project in the request
|
|
515
|
+
envelope above (currently '${SELECTED_PROJECT}'). The variant you render
|
|
516
|
+
MUST belong to that project (variant.project field in data.ts). Pick from
|
|
517
|
+
the project's pool, oldest-rendered first. project_name MUST equal that
|
|
518
|
+
project string in the media_posts row.
|
|
519
|
+
|
|
520
|
+
DELIVERABLES (must all exist when you exit):
|
|
521
|
+
1. ~/social-autoposter/mixer/remotion/out/post-${NNN}.mp4
|
|
522
|
+
1080x1920, audio dubbed, ready to post. Filename uses post_number=${NEXT_NUM}.
|
|
523
|
+
2. ~/social-autoposter/mixer/remotion/out/post-${NNN}.caption.txt
|
|
524
|
+
The Instagram caption story. UTF-8, plain text, no markdown.
|
|
525
|
+
**CAPTION HARD LIMIT: ≤ 2150 chars total** (Instagram's cap is 2200; we leave
|
|
526
|
+
50 chars of safety buffer for emoji / unicode). If your draft overshoots,
|
|
527
|
+
tighten ruthlessly BEFORE writing the file. Verify with \`wc -c <file>\`.
|
|
528
|
+
The harness enforces this programmatically: if the file is > 2150 on exit, it
|
|
529
|
+
will spawn a focused tighten-only Claude call (up to 3 attempts); if all 3
|
|
530
|
+
fail, the row is flipped to status='caption_too_long' and the render fails.
|
|
531
|
+
3. media_posts row with post_number=${NEXT_NUM}, status='draft',
|
|
532
|
+
post_type='${TARGET}', target_account='${TARGET_ACCOUNT}', and all
|
|
533
|
+
SKILL Section 5 columns populated (variant_id, video_path, caption_text,
|
|
534
|
+
source_clips, overlays, audio_source, metadata.theme_angle, etc.).
|
|
535
|
+
project_name='${SELECTED_PROJECT}' for product, NULL for organic.
|
|
536
|
+
target_account is a NOT NULL column — you MUST set it to '${TARGET_ACCOUNT}'
|
|
537
|
+
on this row. The post-cycle uses target_account to load the right token
|
|
538
|
+
and route the reel to the right Instagram account.
|
|
539
|
+
The caption_text column MUST match the caption.txt file exactly.
|
|
540
|
+
|
|
541
|
+
VISUAL STYLE (current as of May 7 2026, do NOT regress):
|
|
542
|
+
The Overlays.tsx and TimeLapseHookComposition.tsx components were rewritten
|
|
543
|
+
on May 7 to:
|
|
544
|
+
- white background, black text on title cards and overlays
|
|
545
|
+
- instant on: NO spring pop-in, NO fade-up, NO scale-in, NO fade-out
|
|
546
|
+
- elements stay solid for the full overlay duration
|
|
547
|
+
DO NOT modify Overlays.tsx, TimeLapseHookComposition.tsx, or MixerComposition.tsx.
|
|
548
|
+
If the existing components don't render the way you want for this variant, FAIL
|
|
549
|
+
the run and ask the user, do not "fix" the components. The components are the
|
|
550
|
+
deliverable contract for ALL future renders.
|
|
551
|
+
|
|
552
|
+
EXCLUSIONS (read from request envelope above; honor strictly):
|
|
553
|
+
- local_audio_lru: the LOCAL audio pool (mixer/audio/*.m4a), ordered
|
|
554
|
+
least-recently-used first. Pick the FIRST entry, the most stale local
|
|
555
|
+
track. Reusing a track is fine; audio repeating across reels is normal on
|
|
556
|
+
Instagram. NEVER download new audio, NEVER run yt-dlp, NEVER open a browser
|
|
557
|
+
to Instagram for audio. The pool only grows when the user manually drops a
|
|
558
|
+
track in. If the list is empty, FAIL the run; do not source from the network.
|
|
559
|
+
- used_theme_angles_14d: pick a different angle. SKILL Section 3 lists 5
|
|
560
|
+
acceptable AI angles; pick one not in the exclusion list.
|
|
561
|
+
- used_variant_ids: for product runs, exclude these globally; even within
|
|
562
|
+
the selected project, prefer a variant not in this list. For TLH (organic),
|
|
563
|
+
pick a variant_id not in this list.
|
|
564
|
+
|
|
565
|
+
ENGAGEMENT STYLE ASSIGNMENT FOR THIS RUN: ${PICKED_STYLE} (mode=${PICK_MODE})
|
|
566
|
+
|
|
567
|
+
${STYLES_BLOCK}
|
|
568
|
+
|
|
569
|
+
You MUST stamp metadata.engagement_style='${PICKED_STYLE}' on the media_posts
|
|
570
|
+
row you write (in addition to caption_style, theme_angle, theme_label, etc.).
|
|
571
|
+
The dashboard A/B-tests on this label and the next picker round re-reads it as
|
|
572
|
+
performance signal, so it MUST match the assigned style verbatim. If mode=invent
|
|
573
|
+
(no style assigned above), invent a new ig_-prefixed snake_case style name that
|
|
574
|
+
describes the structural archetype of your caption (not the topic), and stamp
|
|
575
|
+
THAT on metadata.engagement_style. Do NOT use any non-ig_ prefixed style for
|
|
576
|
+
Instagram captions.
|
|
577
|
+
|
|
578
|
+
PRODUCT-PATH (post_type='product', project='${SELECTED_PROJECT}'):
|
|
579
|
+
The Mixer registry lives in mixer/remotion/src/mixer/data.ts. Every variant
|
|
580
|
+
declares a 'project' field. For this run you MUST pick a variant whose
|
|
581
|
+
variant.project === '${SELECTED_PROJECT}'. Variants are pre-registered in
|
|
582
|
+
Remotion via Root.tsx; you re-render an existing variant, write a fresh
|
|
583
|
+
caption targeted at the selected project, and log the row.
|
|
584
|
+
|
|
585
|
+
For mk0r: 4 niche variants (spa/autoshop/hotel/mk0r-retail). The reel shows the
|
|
586
|
+
mk0r workflow ("find a local business with no website -> go to mk0r.com ->
|
|
587
|
+
prompt it -> publish"). Each render MUST generate fresh TITLE-OVERLAY text that
|
|
588
|
+
fits the caption: before running npx remotion render, edit
|
|
589
|
+
mixer/remotion/src/mixer/data.ts and update MK0R_OVERLAY_TEXT["<picked-variant-id>"]
|
|
590
|
+
with three model-generated values:
|
|
591
|
+
- headline: 2-3 short lines, with __ACCENT__ marking the accented line
|
|
592
|
+
(e.g. "build an auto shop\n__ACCENT__\nin minutes")
|
|
593
|
+
- accentText: 1-4 words shown in accent color (e.g. "a real website")
|
|
594
|
+
- tagline: 3-7 word subtitle (e.g. "no agency, no template")
|
|
595
|
+
All three MUST describe the CAPABILITY (mk0r builds a real site fast, in one
|
|
596
|
+
prompt) and vary from the current defaults and across runs. Only edit the
|
|
597
|
+
picked variant's entry; leave the other 3 untouched.
|
|
598
|
+
|
|
599
|
+
CAPTION RULES (mk0r) -- HARD, do NOT violate:
|
|
600
|
+
The caption is a PRODUCT DEMO: a real local business that has no website, mk0r
|
|
601
|
+
builds it a real site from one prompt, and what that means for the owner. You
|
|
602
|
+
may reference mk0r.com plainly. You MUST NOT write any income/earnings framing:
|
|
603
|
+
no "\$X a month", no "they paid me", no "recurring revenue", no "signed N
|
|
604
|
+
clients", no "make money / side income / quit your job / flip websites", no
|
|
605
|
+
fabricated dollar amounts or client counts. That get-rich-quick framing tripped
|
|
606
|
+
a Meta fraud-and-deceptive-practices restriction on 2026-06-02 and is
|
|
607
|
+
permanently banned from mk0r captions. Keep it about what mk0r BUILDS, never
|
|
608
|
+
about money the viewer earns.
|
|
609
|
+
|
|
610
|
+
For studyly: 8 generated variants (studyly-i{1,2}-r{1,2,3,4}). Each render
|
|
611
|
+
MUST generate fresh overlay text that fits the caption story arc. Before
|
|
612
|
+
running npx remotion render, edit mixer/remotion/src/mixer/data.ts and update
|
|
613
|
+
STUDYLY_OVERLAY_TEXT["<picked-variant-id>"] with five model-generated values:
|
|
614
|
+
- headline: 2-3 short lines, with __ACCENT__ marking the accented word/phrase
|
|
615
|
+
on its own line (e.g. "stop wasting\n__ACCENT__\nbefore exams")
|
|
616
|
+
- accentText: 1-4 words shown in accent color (e.g. "the night before")
|
|
617
|
+
- tagline: 3-7 word subtitle under the headline (e.g. "the smarter study method")
|
|
618
|
+
- stepOverlay: action text shown during the guide clip, ~40 chars max
|
|
619
|
+
(e.g. "drop your notes into studyly.io")
|
|
620
|
+
- finaleOverlay: payoff text shown during the result clip, ~40 chars max
|
|
621
|
+
(e.g. "and actually remember it this time")
|
|
622
|
+
All five values MUST vary from the current defaults and from each other across
|
|
623
|
+
runs. Only edit the picked variant's entry; leave the other 7 untouched.
|
|
624
|
+
The caption arc is: a real study-method frustration (rereading / flashcards not
|
|
625
|
+
sticking, blanking when the wording changes) -> opens studyly.io -> the method
|
|
626
|
+
shift (it tests you on your OWN notes, rewording so you cant pattern-match) ->
|
|
627
|
+
the lesson. Reference studyly.io as the product. Do NOT fabricate specific
|
|
628
|
+
before/after exam scores or "failed -> passed / topped the class" miracle jumps
|
|
629
|
+
as a typical result; keep any outcome qualitative and personal (exaggerated-
|
|
630
|
+
results claims are a deceptive-practices signal, the same Meta rail that
|
|
631
|
+
restricted mk0r 2026-06-02).
|
|
632
|
+
Studyly variants are intentionally simpler/shorter (15-25s vs mk0r's 26-28s).
|
|
633
|
+
|
|
634
|
+
Pick the variant within the selected project that is least-recently-rendered
|
|
635
|
+
(check media_posts created_at WHERE project_name=selected_project AND variant_id IS NOT NULL).
|
|
636
|
+
|
|
637
|
+
ORGANIC-PATH (post_type='organic'):
|
|
638
|
+
Compose a new TLH variant. You may remix existing pre-encoded
|
|
639
|
+
remotion/public/mixer/tlh-*.mp4 slots (cheaper, faster) OR encode fresh raw
|
|
640
|
+
clips from the account's source folder if available (only if remixing
|
|
641
|
+
produces a stale recombination). The audio_source MUST be a LOCAL file from
|
|
642
|
+
local_audio_lru -- pick the least-recently-used (first) entry. NEVER source
|
|
643
|
+
audio from the network. The caption MUST follow SKILL Section 3 caption arc
|
|
644
|
+
(8 beats). Theme angle must be in SKILL Section 3 list and NOT in
|
|
645
|
+
used_theme_angles_14d.
|
|
646
|
+
|
|
647
|
+
PER-ACCOUNT TLH CONFIG (account_tlh_config in the envelope above):
|
|
648
|
+
- If account_tlh_config is non-empty, it REPLACES the SKILL Section 3 defaults
|
|
649
|
+
for THIS account's organic renders. Specifically:
|
|
650
|
+
* source_dir -> raw clip folder (use this, NOT '5. time lapse hooks/')
|
|
651
|
+
* variant_prefix -> variant_id prefix (e.g. 'omi-lesson-'); pick the next
|
|
652
|
+
free integer (omi-lesson-1, omi-lesson-2, ...).
|
|
653
|
+
Existing variants for this account are in
|
|
654
|
+
used_variant_ids; the new variant_id MUST start with
|
|
655
|
+
variant_prefix AND not collide with used_variant_ids.
|
|
656
|
+
* unproven_dir -> null means this account opts out of the unproven
|
|
657
|
+
rotation entirely (the harness already short-circuits
|
|
658
|
+
the injection step; treat 'use':false as authoritative).
|
|
659
|
+
* caption_opener -> override the 'here is a story.' default if set.
|
|
660
|
+
* story_brief -> REPLACES SKILL Section 3's AI-defeat brief. The
|
|
661
|
+
8-beat structure still applies but the persona,
|
|
662
|
+
setup, forgetting moment, etc. come from the brief.
|
|
663
|
+
Voice + content come from THE BRIEF, not from
|
|
664
|
+
SKILL examples. SKILL examples are reference for the
|
|
665
|
+
default (matt_diak / matthewheartful) account.
|
|
666
|
+
- If account_tlh_config is empty {}, use SKILL Section 3 defaults as before
|
|
667
|
+
(matt_diak / matthewheartful behavior is unchanged).
|
|
668
|
+
- Variant encoding still uses the pure-speedup recipe in SKILL Section 3
|
|
669
|
+
step 2. Variant registration in data.ts is unchanged.
|
|
670
|
+
|
|
671
|
+
DO NOT post to Instagram. The post-cycle (skill/run-instagram-daily.sh)
|
|
672
|
+
posts separately on its own schedule. Your job ends at status='draft'.
|
|
673
|
+
|
|
674
|
+
Your final stdout line MUST be exactly one summary line in this format:
|
|
675
|
+
RENDERED post-${NNN} type=${TARGET} variant=<variant_id> angle="<theme_angle>"
|
|
676
|
+
|
|
677
|
+
If you fail or skip, your final stdout line MUST be:
|
|
678
|
+
FAILED post-${NNN} reason=<short reason>
|
|
679
|
+
|
|
680
|
+
Begin. Read the SKILL, then execute.
|
|
681
|
+
PROMPT_EOF
|
|
682
|
+
|
|
683
|
+
log "step 2: spawning claude -p (will run for several minutes)"
|
|
684
|
+
|
|
685
|
+
# CLAUDE_MODEL is honored if exported (override of global default in
|
|
686
|
+
# ~/.claude/settings.json); otherwise CLI uses settings.json default.
|
|
687
|
+
if ! "$REPO_DIR/scripts/run_claude.sh" "run-instagram-render" \
|
|
688
|
+
${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} \
|
|
689
|
+
--permission-mode bypassPermissions \
|
|
690
|
+
--output-format stream-json --verbose \
|
|
691
|
+
-p "$(cat "$PROMPT_FILE")" >>"$LOG_FILE" 2>&1; then
|
|
692
|
+
rc=$?
|
|
693
|
+
log "claude exited rc=$rc"
|
|
694
|
+
rm -f "$PROMPT_FILE"
|
|
695
|
+
if [ "$rc" -eq 79 ]; then
|
|
696
|
+
log "claude blocked by quota stamp; will retry next cycle"
|
|
697
|
+
SKIPPED_CT=1
|
|
698
|
+
exit 0
|
|
699
|
+
fi
|
|
700
|
+
log "render failed"
|
|
701
|
+
FAILED_CT=1
|
|
702
|
+
exit 1
|
|
703
|
+
fi
|
|
704
|
+
rm -f "$PROMPT_FILE"
|
|
705
|
+
|
|
706
|
+
# Step 3: verify deliverables
|
|
707
|
+
OUT_MP4="$REPO_DIR/mixer/remotion/out/post-${NNN}.mp4"
|
|
708
|
+
OUT_CAP="$REPO_DIR/mixer/remotion/out/post-${NNN}.caption.txt"
|
|
709
|
+
|
|
710
|
+
log "step 3: verifying deliverables"
|
|
711
|
+
log " expected: $OUT_MP4"
|
|
712
|
+
log " expected: $OUT_CAP"
|
|
713
|
+
|
|
714
|
+
if [ ! -f "$OUT_MP4" ]; then
|
|
715
|
+
log "ERROR: $OUT_MP4 missing"
|
|
716
|
+
FAILED_CT=1
|
|
717
|
+
exit 1
|
|
718
|
+
fi
|
|
719
|
+
if [ ! -f "$OUT_CAP" ]; then
|
|
720
|
+
log "ERROR: $OUT_CAP missing"
|
|
721
|
+
FAILED_CT=1
|
|
722
|
+
exit 1
|
|
723
|
+
fi
|
|
724
|
+
|
|
725
|
+
ROW_OK=$(/opt/homebrew/bin/python3.11 - "$NEXT_NUM" "$TARGET" 2>>"$LOG_FILE" <<'PY'
|
|
726
|
+
import os, sys
|
|
727
|
+
# HTTP-only (2026-06-01): row check via /api/v1/media-posts/by-number/<n>.
|
|
728
|
+
# No DATABASE_URL, no psycopg2. ok_on_404 lets us print MISSING ourselves.
|
|
729
|
+
sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
|
|
730
|
+
from http_api import api_get
|
|
731
|
+
resp = api_get(f"/api/v1/media-posts/by-number/{int(sys.argv[1])}", ok_on_404=True)
|
|
732
|
+
row = None if resp.get('_not_found') else (resp.get('data') or {}).get('media_post')
|
|
733
|
+
if not row:
|
|
734
|
+
print("MISSING")
|
|
735
|
+
elif row.get('post_type') != sys.argv[2]:
|
|
736
|
+
print(f"BAD_TYPE got={row.get('post_type')} want={sys.argv[2]}")
|
|
737
|
+
elif row.get('status') != 'draft':
|
|
738
|
+
print(f"BAD_STATUS got={row.get('status')} want=draft")
|
|
739
|
+
else:
|
|
740
|
+
print(f"OK variant={row.get('variant_id')}")
|
|
741
|
+
PY
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
case "$ROW_OK" in
|
|
745
|
+
OK*) log "DB row OK: $ROW_OK" ;;
|
|
746
|
+
*) log "ERROR: DB row check failed: $ROW_OK"; FAILED_CT=1; exit 1 ;;
|
|
747
|
+
esac
|
|
748
|
+
|
|
749
|
+
VARIANT=$(echo "$ROW_OK" | sed 's/^OK variant=//')
|
|
750
|
+
|
|
751
|
+
# Step 3.5: caption length gate (HARD LIMIT 2150 chars).
|
|
752
|
+
# IG's actual limit is 2200; we keep a 50-char safety buffer for emoji/unicode.
|
|
753
|
+
# If over: spawn focused tighten-only claude calls (max 3 attempts).
|
|
754
|
+
# If still over after 3 attempts: flip row to status='caption_too_long' and
|
|
755
|
+
# exit non-zero. The picker filters by status='draft' so the bad row is
|
|
756
|
+
# automatically skipped; the next render fire creates a fresh draft.
|
|
757
|
+
# Never auto-truncate -- silent author-voice loss is worse than a failed render.
|
|
758
|
+
CAP_LIMIT=2150
|
|
759
|
+
CAP_LEN=$(wc -c < "$OUT_CAP" | tr -d ' ')
|
|
760
|
+
log "step 3.5: caption length check len=${CAP_LEN} limit=${CAP_LIMIT}"
|
|
761
|
+
|
|
762
|
+
if [ "$CAP_LEN" -gt "$CAP_LIMIT" ]; then
|
|
763
|
+
log "caption over limit (${CAP_LEN} > ${CAP_LIMIT}); spawning tighten loop (max 3 attempts)"
|
|
764
|
+
attempt=0
|
|
765
|
+
while [ "$attempt" -lt 3 ] && [ "$CAP_LEN" -gt "$CAP_LIMIT" ]; do
|
|
766
|
+
attempt=$((attempt + 1))
|
|
767
|
+
log " tighten attempt ${attempt}/3 (current len=${CAP_LEN})"
|
|
768
|
+
|
|
769
|
+
TIGHTEN_PROMPT=$(mktemp /tmp/ig_tighten_prompt_XXXXXX.txt)
|
|
770
|
+
NEW_CAP_OUT=$(mktemp /tmp/ig_tighten_out_XXXXXX.txt)
|
|
771
|
+
|
|
772
|
+
cat > "$TIGHTEN_PROMPT" <<TIGHTEN_EOF
|
|
773
|
+
You are the caption-tightening agent for an Instagram reel.
|
|
774
|
+
|
|
775
|
+
Your single job: rewrite the caption below so total length is <= ${CAP_LIMIT}
|
|
776
|
+
characters, while preserving the voice and ALL 8 beats of the caption arc
|
|
777
|
+
(opener "here is a story.", age+setup, wrong-about-AI moment, breaking event,
|
|
778
|
+
felt sense, workflow change, contrarian lesson in one sharp line, closing
|
|
779
|
+
instruction). For Mixer/product captions, preserve the product-demo structure
|
|
780
|
+
and the plain product reference (mk0r.com / studyly.io) if present. NEVER add
|
|
781
|
+
income/earnings framing ("\$X a month", "they paid me", "recurring revenue",
|
|
782
|
+
"signed N clients") to an mk0r caption while tightening -- that framing is
|
|
783
|
+
banned (Meta fraud restriction 2026-06-02).
|
|
784
|
+
|
|
785
|
+
RULES (hard):
|
|
786
|
+
- Total length MUST be <= ${CAP_LIMIT} chars. Count and verify before responding.
|
|
787
|
+
- Keep ALL beats. Do NOT drop a beat to fit.
|
|
788
|
+
- Preserve the voice: lowercase, plain, no markdown. Keep existing emoji.
|
|
789
|
+
- Cut adjectives, collapse compound sentences, drop redundant examples,
|
|
790
|
+
prefer "i was tired" over "i was tired in a way that didn't show up in a paycheck".
|
|
791
|
+
- Output ONLY the rewritten caption. No prose around it. No 'here is the
|
|
792
|
+
rewritten caption' preamble. No backticks. No commentary. Just the caption text.
|
|
793
|
+
|
|
794
|
+
CURRENT CAPTION (length=${CAP_LEN}, must shrink to <= ${CAP_LIMIT}):
|
|
795
|
+
---BEGIN---
|
|
796
|
+
$(cat "$OUT_CAP")
|
|
797
|
+
---END---
|
|
798
|
+
|
|
799
|
+
Output the tightened caption now. Just the text body. Nothing else.
|
|
800
|
+
TIGHTEN_EOF
|
|
801
|
+
|
|
802
|
+
if "$REPO_DIR/scripts/run_claude.sh" "run-instagram-render-tighten" \
|
|
803
|
+
${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} \
|
|
804
|
+
--permission-mode bypassPermissions \
|
|
805
|
+
-p "$(cat "$TIGHTEN_PROMPT")" > "$NEW_CAP_OUT" 2>>"$LOG_FILE"; then
|
|
806
|
+
# run_claude.sh prepends a JSON session-log line; strip any leading
|
|
807
|
+
# JSON-looking line before treating the rest as the new caption.
|
|
808
|
+
# The actual caption is everything except trailing JSON metadata.
|
|
809
|
+
/opt/homebrew/bin/python3.11 - "$NEW_CAP_OUT" "$OUT_CAP" <<'PY'
|
|
810
|
+
import sys, json, re
|
|
811
|
+
raw = open(sys.argv[1]).read()
|
|
812
|
+
# claude -p output: caption text, then possibly a trailing JSON line from
|
|
813
|
+
# log_claude_session.py. Strip any line that parses as a single JSON object
|
|
814
|
+
# containing 'session_id' or 'logged' keys (the session marker).
|
|
815
|
+
lines = raw.splitlines(keepends=True)
|
|
816
|
+
out = []
|
|
817
|
+
for ln in lines:
|
|
818
|
+
stripped = ln.strip()
|
|
819
|
+
if stripped.startswith('{') and stripped.endswith('}'):
|
|
820
|
+
try:
|
|
821
|
+
j = json.loads(stripped)
|
|
822
|
+
if isinstance(j, dict) and ('session_id' in j or 'logged' in j):
|
|
823
|
+
continue # drop the session marker line
|
|
824
|
+
except Exception:
|
|
825
|
+
pass
|
|
826
|
+
out.append(ln)
|
|
827
|
+
text = ''.join(out).strip() + '\n'
|
|
828
|
+
open(sys.argv[2], 'w').write(text)
|
|
829
|
+
PY
|
|
830
|
+
CAP_LEN=$(wc -c < "$OUT_CAP" | tr -d ' ')
|
|
831
|
+
log " attempt ${attempt} result: new len=${CAP_LEN}"
|
|
832
|
+
else
|
|
833
|
+
log " attempt ${attempt} failed: claude exited non-zero"
|
|
834
|
+
fi
|
|
835
|
+
rm -f "$TIGHTEN_PROMPT" "$NEW_CAP_OUT"
|
|
836
|
+
done
|
|
837
|
+
|
|
838
|
+
if [ "$CAP_LEN" -gt "$CAP_LIMIT" ]; then
|
|
839
|
+
log "ERROR: caption still over limit after 3 tighten attempts (final len=${CAP_LEN})"
|
|
840
|
+
log "flipping media_posts row to status='caption_too_long' so picker skips it"
|
|
841
|
+
FAILED_CT=1
|
|
842
|
+
/opt/homebrew/bin/python3.11 - "$NEXT_NUM" "$CAP_LEN" 2>>"$LOG_FILE" <<'PY'
|
|
843
|
+
import os, sys
|
|
844
|
+
# HTTP-only (2026-06-01): status flip via PATCH /api/v1/media-posts/by-number.
|
|
845
|
+
sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
|
|
846
|
+
from http_api import api_patch
|
|
847
|
+
api_patch(
|
|
848
|
+
f"/api/v1/media-posts/by-number/{int(sys.argv[1])}",
|
|
849
|
+
{"action": "caption_too_long", "caption_len": int(sys.argv[2])},
|
|
850
|
+
)
|
|
851
|
+
print(f"flipped post-{int(sys.argv[1]):03d} to caption_too_long (len={sys.argv[2]})")
|
|
852
|
+
PY
|
|
853
|
+
exit 1
|
|
854
|
+
fi
|
|
855
|
+
|
|
856
|
+
# Sync tightened caption into DB row.
|
|
857
|
+
log "tighten loop succeeded; syncing caption_text column"
|
|
858
|
+
/opt/homebrew/bin/python3.11 - "$NEXT_NUM" "$OUT_CAP" 2>>"$LOG_FILE" <<'PY'
|
|
859
|
+
import os, sys
|
|
860
|
+
# HTTP-only (2026-06-01): caption sync via PATCH /api/v1/media-posts/by-number.
|
|
861
|
+
sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
|
|
862
|
+
from http_api import api_patch
|
|
863
|
+
cap = open(sys.argv[2]).read()
|
|
864
|
+
api_patch(
|
|
865
|
+
f"/api/v1/media-posts/by-number/{int(sys.argv[1])}",
|
|
866
|
+
{"action": "sync_caption", "caption_text": cap},
|
|
867
|
+
)
|
|
868
|
+
print(f"synced post-{int(sys.argv[1]):03d} caption_text (len={len(cap)})")
|
|
869
|
+
PY
|
|
870
|
+
log "caption tightened OK (final len=${CAP_LEN})"
|
|
871
|
+
fi
|
|
872
|
+
|
|
873
|
+
log "=== rendered post-${NNN} (${TARGET}, variant=${VARIANT}) successfully ==="
|
|
874
|
+
POSTED_CT=1
|
|
875
|
+
exit 0
|