@m13v/s4l 1.7.4-rc.4 → 1.7.4
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 +134 -101
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +24 -5
- package/mcp/dist/runtime.js +110 -0
- package/mcp/dist/version.js +163 -54
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +55 -19
- package/mcp/menubar/s4l_menubar.py +179 -56
- package/mcp/menubar/s4l_state.py +41 -1
- package/mcp/package.json +1 -1
- package/package.json +4 -2
- package/scripts/active_experiments.py +30 -6
- package/scripts/autopilot_stall_watch.py +185 -63
- package/scripts/cdp_ready_check.py +94 -0
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +221 -26
- package/scripts/enrich_reply_parents.py +68 -22
- package/scripts/feedback_digest.py +24 -1
- package/scripts/generate_daily_human_style.py +31 -7
- package/scripts/invent_styles.py +103 -26
- package/scripts/linkedin_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +43 -0
- package/scripts/memory_snapshot.py +57 -0
- package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/reddit_browser.py +57 -3
- package/scripts/s4l_box_update.sh +20 -1
- package/scripts/s4l_mode.py +3 -4
- package/scripts/scan_x_profile.py +25 -7
- package/scripts/schedule_state.py +23 -0
- package/scripts/scheduled_task_selfheal.py +213 -16
- package/scripts/setup_twitter_auth.py +6 -1
- package/scripts/snapshot.py +179 -61
- package/scripts/stall_guard.py +70 -0
- package/scripts/test_scheduled_task_selfheal.py +121 -0
- package/scripts/top_performers.py +34 -0
- package/scripts/twitter_browser.py +146 -8
- package/scripts/twitter_post_plan.py +10 -1
- package/scripts/twitter_scan.py +26 -0
- package/scripts/watchdog_hung_runs.py +117 -3
- package/skill/amplitude-24h-signups.sh +1 -1
- package/skill/audit-dm-staleness.sh +1 -1
- package/skill/audit-linkedin.sh +1 -1
- package/skill/audit-reddit-resurrect.sh +1 -1
- package/skill/audit.sh +5 -5
- package/skill/check-web-chats.sh +1 -1
- package/skill/dm-outreach-linkedin.sh +4 -4
- package/skill/dm-outreach-reddit.sh +1 -1
- package/skill/dm-outreach-twitter.sh +1 -1
- package/skill/engage-dm-replies.sh +16 -3
- package/skill/engage-linkedin.sh +2 -2
- package/skill/engage-moltbook.sh +1 -1
- package/skill/engage-twitter.sh +4 -4
- package/skill/github-engage.sh +1 -1
- package/skill/lib/browser-launch.sh +54 -0
- package/skill/lib/linkedin-backend.sh +22 -15
- package/skill/lib/reddit-backend.sh +17 -11
- package/skill/lib/twitter-backend.sh +137 -16
- package/skill/link-edit-github.sh +1 -1
- package/skill/link-edit-moltbook.sh +1 -1
- package/skill/link-edit-reddit.sh +1 -1
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/linkedin-presence.sh +1 -1
- package/skill/lock.sh +30 -6
- package/skill/precompute-stats.sh +1 -1
- package/skill/refresh-instagram-tokens.sh +1 -1
- package/skill/refresh-twitter-following.sh +1 -1
- package/skill/run-generate-daily-style.sh +1 -1
- package/skill/run-instagram-daily.sh +1 -1
- package/skill/run-instagram-render.sh +1 -1
- package/skill/run-linkedin-launchd.sh +1 -1
- package/skill/run-linkedin-unipile.sh +1 -1
- package/skill/run-linkedin.sh +1 -1
- package/skill/run-reddit-search.sh +2 -2
- package/skill/run-reddit-threads.sh +1 -1
- package/skill/run-twitter-cycle.sh +190 -55
- package/skill/scan-instagram-replies.sh +1 -1
- package/skill/sentry-digest.sh +1 -1
- package/skill/stats-instagram.sh +1 -1
- package/skill/stats-linkedin.sh +2 -2
- package/skill/stats.sh +1 -1
- package/skill/sweep-link-clicks.sh +1 -1
- package/scripts/_lock_preempt_test.py +0 -60
- package/scripts/saps_activity.py +0 -18
- package/scripts/saps_mode.py +0 -18
package/skill/lock.sh
CHANGED
|
@@ -121,18 +121,42 @@ if [ -z "${_SA_LOCK_DIRS+x}" ]; then
|
|
|
121
121
|
# is defect "owner=OTHER" (wipes a live peer's lock -> double-hold).
|
|
122
122
|
if _sa_we_own_lock "$d"; then
|
|
123
123
|
_sa_lock_event trap_rm "$_lname" "$(_sa_lock_owner_tag "$d")"
|
|
124
|
-
echo "[lock] trap-released $_lname pid=$$ at $(date +%H:%M:%
|
|
124
|
+
echo "[lock] trap-released $_lname pid=$$ at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
|
|
125
125
|
rm -rf "$d"
|
|
126
126
|
else
|
|
127
127
|
_sa_lock_event trap_rm_skipped "$_lname" "$(_sa_lock_owner_tag "$d")"
|
|
128
|
-
echo "[lock] trap-release SKIPPED $_lname pid=$$ (not owner) at $(date +%H:%M:%
|
|
128
|
+
echo "[lock] trap-release SKIPPED $_lname pid=$$ (not owner) at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
|
|
129
129
|
fi
|
|
130
130
|
done
|
|
131
131
|
for t in ${_SA_LOCK_TICKETS[@]+"${_SA_LOCK_TICKETS[@]}"}; do
|
|
132
132
|
rm -f "$t"
|
|
133
133
|
done
|
|
134
134
|
}
|
|
135
|
-
trap _sa_release_locks EXIT
|
|
135
|
+
trap _sa_release_locks EXIT
|
|
136
|
+
# 2026-07-12: INT/TERM/HUP release locks AND EXIT, instead of sharing the
|
|
137
|
+
# plain EXIT handler. Scenario this fixes (observed 2026-07-12 12:02:09,
|
|
138
|
+
# engage-dm-replies.sh pid 72465): a stray signal fired the old combined
|
|
139
|
+
# trap, which released BOTH held locks and then RESUMED the script (bash
|
|
140
|
+
# continues after a trapped signal when the handler doesn't exit). The run
|
|
141
|
+
# kept driving the shared twitter Chrome without its twitter-browser lock,
|
|
142
|
+
# the twitter cycle legitimately acquired the freed lock and navigated the
|
|
143
|
+
# same tab mid-send, and all 3 send-dm attempts died with
|
|
144
|
+
# thread_url_redirected ("shared-tab drift", human_dm_replies id 100045).
|
|
145
|
+
# A signaled run must never continue lock-protected work: log WHICH signal
|
|
146
|
+
# hit (the old single-line event gave no clue), clean up once, exit 128+N.
|
|
147
|
+
# Scripts that install their own combined traps after sourcing this file
|
|
148
|
+
# (e.g. run-twitter-cycle.sh's _sa_combined_exit) still override these.
|
|
149
|
+
_sa_exit_on_signal() {
|
|
150
|
+
local _sig="$1" _code="$2"
|
|
151
|
+
_sa_lock_event signal_exit "-" "signal=$_sig"
|
|
152
|
+
echo "[lock] caught SIG${_sig} pid=$$; releasing locks and exiting $_code at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
|
|
153
|
+
_sa_release_locks
|
|
154
|
+
trap - EXIT
|
|
155
|
+
exit "$_code"
|
|
156
|
+
}
|
|
157
|
+
trap '_sa_exit_on_signal INT 130' INT
|
|
158
|
+
trap '_sa_exit_on_signal TERM 143' TERM
|
|
159
|
+
trap '_sa_exit_on_signal HUP 129' HUP
|
|
136
160
|
fi
|
|
137
161
|
|
|
138
162
|
acquire_lock() {
|
|
@@ -223,7 +247,7 @@ acquire_lock() {
|
|
|
223
247
|
[ "$_existing" != "$ticket_file" ] && _new_t+=("$_existing")
|
|
224
248
|
done
|
|
225
249
|
_SA_LOCK_TICKETS=(${_new_t[@]+"${_new_t[@]}"})
|
|
226
|
-
echo "[lock] acquired $name pid=$$ at $(date +%H:%M:%
|
|
250
|
+
echo "[lock] acquired $name pid=$$ at $(date -u +%Y-%m-%dT%H:%M:%SZ) waited=${waited}s" >&2
|
|
227
251
|
_sa_lock_event acquired "$name" "waited=${waited}s"
|
|
228
252
|
break
|
|
229
253
|
fi
|
|
@@ -670,10 +694,10 @@ release_lock() {
|
|
|
670
694
|
if _sa_we_own_lock "$lock_dir"; then
|
|
671
695
|
_sa_lock_event release "$name" "$(_sa_lock_owner_tag "$lock_dir")"
|
|
672
696
|
rm -rf "$lock_dir"
|
|
673
|
-
echo "[lock] released $name pid=$$ at $(date +%H:%M:%
|
|
697
|
+
echo "[lock] released $name pid=$$ at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
|
|
674
698
|
else
|
|
675
699
|
_sa_lock_event release_skipped "$name" "$(_sa_lock_owner_tag "$lock_dir")"
|
|
676
|
-
echo "[lock] release SKIPPED $name pid=$$ (not owner) at $(date +%H:%M:%
|
|
700
|
+
echo "[lock] release SKIPPED $name pid=$$ (not owner) at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
|
|
677
701
|
fi
|
|
678
702
|
# Rebuild the lock stack without this entry so the EXIT trap doesn't try to
|
|
679
703
|
# rm it again (harmless, but keeps the stack honest if release_lock is paired
|
|
@@ -31,5 +31,5 @@ python3 "$REPO_DIR/scripts/precompute_dashboard_stats.py"
|
|
|
31
31
|
EXIT_CODE=$?
|
|
32
32
|
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
33
33
|
|
|
34
|
-
echo "[$(date +%H:%M:%
|
|
34
|
+
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] === done in ${RUN_ELAPSED}s (exit=${EXIT_CODE}) ==="
|
|
35
35
|
exit "$EXIT_CODE"
|
|
@@ -19,7 +19,7 @@ LOG_DIR="$REPO_DIR/skill/logs"
|
|
|
19
19
|
mkdir -p "$LOG_DIR"
|
|
20
20
|
LOG_FILE="$LOG_DIR/refresh-instagram-tokens-$(date +%Y-%m-%d_%H%M%S).log"
|
|
21
21
|
|
|
22
|
-
log() { echo "[$(date +%H:%M:%
|
|
22
|
+
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
|
|
23
23
|
log "=== refresh-instagram-tokens fire: $(date) ==="
|
|
24
24
|
|
|
25
25
|
RUN_START=$(date +%s)
|
|
@@ -16,7 +16,7 @@ set -uo pipefail
|
|
|
16
16
|
LOG_DIR="$HOME/social-autoposter/skill/logs"
|
|
17
17
|
mkdir -p "$LOG_DIR"
|
|
18
18
|
LOG_FILE="$LOG_DIR/refresh-twitter-following-$(date +%Y-%m-%d_%H%M%S).log"
|
|
19
|
-
log() { echo "[$(date +%H:%M:%
|
|
19
|
+
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
|
|
20
20
|
|
|
21
21
|
REPO_DIR="$HOME/social-autoposter"
|
|
22
22
|
|
|
@@ -36,7 +36,7 @@ if [ -f "$REPO_DIR/.env" ]; then
|
|
|
36
36
|
set +a
|
|
37
37
|
fi
|
|
38
38
|
|
|
39
|
-
log() { echo "[$(date +%H:%M:%
|
|
39
|
+
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
|
|
40
40
|
|
|
41
41
|
# Run accounting for dashboard Job History (Post Threads · Instagram).
|
|
42
42
|
# Each exit site updates POSTED_CT / SKIPPED_CT / FAILED_CT; the EXIT trap
|
|
@@ -43,7 +43,7 @@ if [ -f "$REPO_DIR/.env" ]; then
|
|
|
43
43
|
set +a
|
|
44
44
|
fi
|
|
45
45
|
|
|
46
|
-
log() { echo "[$(date +%H:%M:%
|
|
46
|
+
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
|
|
47
47
|
|
|
48
48
|
# Run accounting for dashboard Job History (Render · Instagram). Matches the
|
|
49
49
|
# pattern in run-instagram-daily.sh / run-twitter-threads.sh / run-reddit-threads.sh:
|
|
@@ -40,7 +40,7 @@ mkdir -p "$LOG_DIR"
|
|
|
40
40
|
# State: ~/.claude/social-autoposter/linkedin.killswitch
|
|
41
41
|
# Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
|
|
42
42
|
if [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
|
|
43
|
-
echo "[$(date +%H:%M:%
|
|
43
|
+
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LINKEDIN_KILLSWITCH active. Skipping run-linkedin." \
|
|
44
44
|
>> "$LOG_DIR/launchd-linkedin-stderr.log"
|
|
45
45
|
exit 0
|
|
46
46
|
fi
|
package/skill/run-linkedin.sh
CHANGED
|
@@ -85,7 +85,7 @@ LINKEDIN_BACKEND="${LINKEDIN_BACKEND:-browser}"
|
|
|
85
85
|
# Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
|
|
86
86
|
# Only gates the browser backend — UniPile has no headed session to compromise.
|
|
87
87
|
if [ "$LINKEDIN_BACKEND" = "browser" ] && [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
|
|
88
|
-
echo "[$(date +%H:%M:%
|
|
88
|
+
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
|
|
89
89
|
echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
|
|
90
90
|
exit 0
|
|
91
91
|
fi
|
|
@@ -43,7 +43,7 @@ LOG_DIR="$REPO_DIR/skill/logs"
|
|
|
43
43
|
mkdir -p "$LOG_DIR"
|
|
44
44
|
LOG_FILE="$LOG_DIR/run-reddit-search-$(date +%Y-%m-%d_%H%M%S).log"
|
|
45
45
|
|
|
46
|
-
log() { echo "[$(date +%H:%M:%
|
|
46
|
+
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
|
|
47
47
|
|
|
48
48
|
log "=== Reddit Search Post Run: $(date) ==="
|
|
49
49
|
|
|
@@ -88,7 +88,7 @@ _parse_post_results() {
|
|
|
88
88
|
# Bug observed 2026-05-08: a leaked "[14:57:04] Post phase: ..." line
|
|
89
89
|
# produced "TOTAL_POSTED + [14:57:04]: syntax error" and `set -e` aborted
|
|
90
90
|
# the script BEFORE the discover lane ran.
|
|
91
|
-
echo "[$(date +%H:%M:%
|
|
91
|
+
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Post phase: exit code $rc; counting as failed." >> "$LOG_FILE"
|
|
92
92
|
echo "0 1"
|
|
93
93
|
fi
|
|
94
94
|
}
|
|
@@ -33,7 +33,7 @@ RUN_START_EPOCH=$(date +%s)
|
|
|
33
33
|
# calls added in 5e41d96 (2026-05-10) fall through to macOS /usr/bin/log,
|
|
34
34
|
# which barfs `Unknown subcommand` and ERR-traps the run at exit=64 before
|
|
35
35
|
# the browser lease is ever acquired (silent kill since 2026-05-10).
|
|
36
|
-
log() { echo "[$(date +%H:%M:%
|
|
36
|
+
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
|
|
37
37
|
|
|
38
38
|
# Diagnostic: log the failing line and command before set -e kills the script.
|
|
39
39
|
# Without this, silent deaths (e.g., Claude exits non-zero inside the $() below)
|
|
@@ -56,6 +56,7 @@ ulimit -n 4096 2>/dev/null || true
|
|
|
56
56
|
# below (sourced libs + child scripts inherit it), so this one line fixes the
|
|
57
57
|
# whole cycle's repo resolution on a bare .mcpb install.
|
|
58
58
|
REPO_DIR="${S4L_REPO_DIR:-$HOME/social-autoposter}"
|
|
59
|
+
export REPO_DIR
|
|
59
60
|
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
60
61
|
LOG_DIR="$REPO_DIR/skill/logs"
|
|
61
62
|
mkdir -p "$LOG_DIR"
|
|
@@ -173,7 +174,7 @@ if [ -f "$REPO_DIR/.env" ]; then
|
|
|
173
174
|
set +a
|
|
174
175
|
fi
|
|
175
176
|
|
|
176
|
-
log() { echo "[$(date +%H:%M:%
|
|
177
|
+
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
|
|
177
178
|
|
|
178
179
|
log "=== Twitter Cycle (batch=$BATCH_ID): $(date) ==="
|
|
179
180
|
log "Logic=D (no-ripen + 1h freshness + 2k_view_cap; experiment concluded 2026-05-31); discover_freshness=${FRESHNESS_HOURS_DISCOVER}h"
|
|
@@ -502,7 +503,7 @@ python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase p
|
|
|
502
503
|
_PJ_ERR="$(mktemp)"
|
|
503
504
|
PROJECTS_JSON=$(python3 - 2>"$_PJ_ERR" <<'PY'
|
|
504
505
|
import json, os, subprocess, sys
|
|
505
|
-
REPO = os.path.expanduser('~/social-autoposter')
|
|
506
|
+
REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
506
507
|
sys.path.insert(0, os.path.join(REPO, 'scripts'))
|
|
507
508
|
import project_excludes as pe
|
|
508
509
|
|
|
@@ -882,6 +883,33 @@ print(cd//60, cons, cd, max(0, now-fs))' "$_GATE_FILE" "$_NOW" 2>/dev/null || ec
|
|
|
882
883
|
release_lock "twitter-browser" 2>/dev/null || true
|
|
883
884
|
exit 1
|
|
884
885
|
fi
|
|
886
|
+
# Probe-error guard (2026-07-12, S4L-4H): output lacking a "gated" verdict
|
|
887
|
+
# means the probe CRASHED (typically the CDP attach failed or timed out on a
|
|
888
|
+
# wedged Chrome) rather than returning a clean bill. The old code logged
|
|
889
|
+
# 'Pre-flight access OK: {"success": false, ...}' and marched a wedged browser
|
|
890
|
+
# into the scan. Heal once (ensure_twitter_browser_for_backend now does a real
|
|
891
|
+
# CDP readiness check and restarts a wedged harness Chrome), re-probe, and end
|
|
892
|
+
# the cycle if there is still no verdict.
|
|
893
|
+
if [ -z "$_ACCESS_OUT" ] || ! printf '%s' "$_ACCESS_OUT" | grep -q '"gated"'; then
|
|
894
|
+
log " Pre-flight access probe FAILED (no verdict; browser not driveable): $(printf '%s' "$_ACCESS_OUT" | tr '\n' ' ' | tr -s ' ' | sed 's/^ *//')"
|
|
895
|
+
echo "twitter_cdp_wedge: access_probe_no_verdict" >&2
|
|
896
|
+
ensure_twitter_browser_for_backend || true
|
|
897
|
+
_ACCESS_OUT=$(TWITTER_CDP_URL="${TWITTER_CDP_URL:-http://127.0.0.1:9555}" \
|
|
898
|
+
python3 "$REPO_DIR/scripts/twitter_access_check.py" --session-probe --wait-ms 12000 2>/dev/null)
|
|
899
|
+
if [ -z "$_ACCESS_OUT" ] || ! printf '%s' "$_ACCESS_OUT" | grep -q '"gated"'; then
|
|
900
|
+
log " Pre-flight access probe still has no verdict after harness heal; ending cycle."
|
|
901
|
+
echo "twitter_batches: ended $BATCH_ID"
|
|
902
|
+
release_lock "twitter-browser" 2>/dev/null || true
|
|
903
|
+
exit 1
|
|
904
|
+
fi
|
|
905
|
+
if printf '%s' "$_ACCESS_OUT" | grep -q '"gated": *true'; then
|
|
906
|
+
log " Recovered probe reports an access gate; ending cycle (backoff engages on the next firing)."
|
|
907
|
+
echo "twitter_batches: ended $BATCH_ID"
|
|
908
|
+
release_lock "twitter-browser" 2>/dev/null || true
|
|
909
|
+
exit 1
|
|
910
|
+
fi
|
|
911
|
+
log " Access probe recovered after harness heal."
|
|
912
|
+
fi
|
|
885
913
|
# Probe came back clean. If a backoff marker exists we were gated: record the
|
|
886
914
|
# recovery (how long the gate lasted, since first_seen) BEFORE deleting it, so
|
|
887
915
|
# the lift event + duration survive in the log even though the marker is gone.
|
|
@@ -977,7 +1005,8 @@ if [ "$SCAN_ATTEMPT" -gt 1 ]; then
|
|
|
977
1005
|
rm -f "$UNIVERSE_EXHAUSTED_MARKER"
|
|
978
1006
|
PROJECTS_JSON=$(python3 - "$PROJECTS_JSON" "$TRIED_TOPICS_JSON" "$UNIVERSE_EXHAUSTED_MARKER" <<'PY' 2>>"$LOG_FILE"
|
|
979
1007
|
import json, os, sys
|
|
980
|
-
|
|
1008
|
+
REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
1009
|
+
sys.path.insert(0, os.path.join(REPO, 'scripts'))
|
|
981
1010
|
from pick_search_topic import pick_topic_for_project, PickerError, UniverseExhaustedError
|
|
982
1011
|
|
|
983
1012
|
projects = json.loads(sys.argv[1] or '[]')
|
|
@@ -1349,7 +1378,8 @@ fi
|
|
|
1349
1378
|
# search_attempts log so a Phase 1 abort still leaves the marks behind.
|
|
1350
1379
|
python3 - "$PROJECTS_JSON" <<'PY' 2>&1 | tee -a "$LOG_FILE" || true
|
|
1351
1380
|
import json, os, sys
|
|
1352
|
-
|
|
1381
|
+
REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
1382
|
+
sys.path.insert(0, os.path.join(REPO, 'scripts'))
|
|
1353
1383
|
import project_excludes as pe
|
|
1354
1384
|
projects = json.loads(sys.argv[1] or '[]')
|
|
1355
1385
|
total = 0
|
|
@@ -1643,9 +1673,10 @@ done <<< "$CANDIDATES"
|
|
|
1643
1673
|
|
|
1644
1674
|
ALL_PROJECTS_JSON=$(python3 -c "
|
|
1645
1675
|
import json, os, sys
|
|
1646
|
-
|
|
1676
|
+
repo_dir = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
1677
|
+
sys.path.insert(0, os.path.join(repo_dir, 'scripts'))
|
|
1647
1678
|
import learned_preferences as lp
|
|
1648
|
-
config = json.load(open(os.path.
|
|
1679
|
+
config = json.load(open(os.path.join(repo_dir, 'config.json')))
|
|
1649
1680
|
projects = config.get('projects', [])
|
|
1650
1681
|
# learned_preferences is a SINGLE install-wide block since 2026-07-08 (see
|
|
1651
1682
|
# scripts/learned_preferences.py), not one per project. Computed once here
|
|
@@ -1725,12 +1756,33 @@ log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)
|
|
|
1725
1756
|
# are actually different (a same-name pair defeats the whole point); INVENT
|
|
1726
1757
|
# mode on either side is accepted immediately since an invented name is
|
|
1727
1758
|
# definitionally distinct from a pinned one. This is orthogonal to the
|
|
1728
|
-
#
|
|
1759
|
+
# treatment_v3/control_v3 draft-prompt A/B below (that varies WORDING of the
|
|
1729
1760
|
# directive for the whole cycle; this varies STYLE per draft slot), so neither
|
|
1730
1761
|
# experiment disturbs the other.
|
|
1731
1762
|
STYLE_ASSIGN_FILE_B=$(mktemp -t s4l_twitter_assign_b_XXXXXX.json)
|
|
1732
|
-
|
|
1733
|
-
|
|
1763
|
+
# --- Draft-B exploration source (2026-07-11) ---------------------------------
|
|
1764
|
+
# Style B is now the EXPLORE slot: it trials the newest human_derived styles
|
|
1765
|
+
# and post-2026-07-10 inventions (least-used first) instead of drawing a
|
|
1766
|
+
# second scored pick from the same proven pool as Style A. This is the
|
|
1767
|
+
# distribution channel for the standalone invent_styles.py job: the card
|
|
1768
|
+
# pick + the posted draft's engagement write a new style's first real score,
|
|
1769
|
+
# and winners graduate into the Draft-A pool via the normal sampler. NOTHING
|
|
1770
|
+
# is invented here (pick_exploration_style never returns mode=invent). On an
|
|
1771
|
+
# empty pool or API failure we fall back to the legacy second scored pick so
|
|
1772
|
+
# dual-draft cards never break. The source tag rides the S4L_EXP_ convention:
|
|
1773
|
+
# active_experiments.collect() auto-stamps it onto every plan candidate and
|
|
1774
|
+
# the review card's details-eye renders it with zero card-side code.
|
|
1775
|
+
DRAFT_B_SOURCE=$(python3 -c "
|
|
1776
|
+
import json, sys
|
|
1777
|
+
sys.path.insert(0, '$REPO_DIR/scripts')
|
|
1778
|
+
from engagement_styles import pick_exploration_style
|
|
1779
|
+
a = pick_exploration_style('twitter', context='posting', exclude={'$PICKED_STYLE'})
|
|
1780
|
+
if a and a.get('style'):
|
|
1781
|
+
with open('$STYLE_ASSIGN_FILE_B', 'w') as f:
|
|
1782
|
+
json.dump(a, f)
|
|
1783
|
+
print(a.get('source') or '')
|
|
1784
|
+
" 2>/dev/null || echo "")
|
|
1785
|
+
if [ -n "$DRAFT_B_SOURCE" ]; then
|
|
1734
1786
|
PICKED_STYLE_B=$(python3 -c "
|
|
1735
1787
|
import json
|
|
1736
1788
|
try:
|
|
@@ -1740,7 +1792,22 @@ try:
|
|
|
1740
1792
|
except Exception:
|
|
1741
1793
|
print('')
|
|
1742
1794
|
" 2>/dev/null)
|
|
1743
|
-
PICKED_MODE_B
|
|
1795
|
+
PICKED_MODE_B="use"
|
|
1796
|
+
fi
|
|
1797
|
+
if [ -z "${PICKED_STYLE_B:-}" ]; then
|
|
1798
|
+
DRAFT_B_SOURCE="scored_fallback"
|
|
1799
|
+
for _style_b_attempt in 1 2 3; do
|
|
1800
|
+
s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
|
|
1801
|
+
PICKED_STYLE_B=$(python3 -c "
|
|
1802
|
+
import json
|
|
1803
|
+
try:
|
|
1804
|
+
with open('$STYLE_ASSIGN_FILE_B') as f:
|
|
1805
|
+
d = json.load(f)
|
|
1806
|
+
print(d.get('style') or '')
|
|
1807
|
+
except Exception:
|
|
1808
|
+
print('')
|
|
1809
|
+
" 2>/dev/null)
|
|
1810
|
+
PICKED_MODE_B=$(python3 -c "
|
|
1744
1811
|
import json
|
|
1745
1812
|
try:
|
|
1746
1813
|
with open('$STYLE_ASSIGN_FILE_B') as f:
|
|
@@ -1749,44 +1816,52 @@ try:
|
|
|
1749
1816
|
except Exception:
|
|
1750
1817
|
print('use')
|
|
1751
1818
|
" 2>/dev/null)
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
done
|
|
1756
|
-
|
|
1819
|
+
if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
|
|
1820
|
+
break
|
|
1821
|
+
fi
|
|
1822
|
+
done
|
|
1823
|
+
fi
|
|
1824
|
+
export S4L_EXP_DRAFT_B_SOURCE="$DRAFT_B_SOURCE"
|
|
1825
|
+
log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)} source=$DRAFT_B_SOURCE"
|
|
1757
1826
|
|
|
1758
|
-
# --- Draft-prompt A/B:
|
|
1827
|
+
# --- Draft-prompt A/B: style-as-form (v3, 2026-07-10) ------------------------
|
|
1759
1828
|
# Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
|
|
1760
1829
|
# assignment is at cycle granularity, not per post; the whole batch shares it).
|
|
1761
|
-
#
|
|
1762
|
-
#
|
|
1763
|
-
#
|
|
1764
|
-
#
|
|
1765
|
-
#
|
|
1766
|
-
#
|
|
1767
|
-
#
|
|
1768
|
-
#
|
|
1769
|
-
#
|
|
1770
|
-
#
|
|
1771
|
-
#
|
|
1830
|
+
# control_v3 = the plain draft directive (v2's control text verbatim).
|
|
1831
|
+
# treatment_v3 = style-as-FORM package: the assigned engagement style is the
|
|
1832
|
+
# binding FORM of the draft (defining move + per-style length +
|
|
1833
|
+
# end-of-block self-check, rendered arm-aware by
|
|
1834
|
+
# engagement_styles.get_assigned_style_prompt, which reads
|
|
1835
|
+
# S4L_DRAFT_PROMPT_VARIANT at render time IN this cycle
|
|
1836
|
+
# process), PLUS the two-layer learned_preferences contract
|
|
1837
|
+
# (style owns form, preferences own voice inside it; the old
|
|
1838
|
+
# 'overrides on conflict' phrasing made the model treat the
|
|
1839
|
+
# style as optional), PLUS the v2 skeleton ban carried over.
|
|
1840
|
+
# History: v1 (2026-06-29) decoupled the product pivot; the model kept the
|
|
1841
|
+
# skeleton (30% ~= 28% on 857 replies). v2 (2026-07-06) banned the
|
|
1842
|
+
# concede-then-reverse STRUCTURE. v3 (2026-07-10) replaces v2's treatment
|
|
1843
|
+
# wholesale: drafts converged in shape regardless of assigned style, so v3
|
|
1844
|
+
# makes the style block load-bearing instead of only banning one skeleton.
|
|
1772
1845
|
# The arm is stamped onto every post this cycle via S4L_DRAFT_PROMPT_VARIANT
|
|
1773
1846
|
# (read by twitter_post_plan.py -> log_post.py -> posts.draft_prompt_variant),
|
|
1774
1847
|
# mirroring the tail_link_variant plumbing. Split tunable via
|
|
1775
1848
|
# TWITTER_DRAFT_PROMPT_AB_RATE = fraction of cycles assigned to 'treatment'.
|
|
1776
1849
|
# CODE DEFAULT 0.5 = 50/50 EVERYWHERE (2026-07-06): every install runs a real
|
|
1777
|
-
# holdback so treatment
|
|
1778
|
-
#
|
|
1850
|
+
# holdback so treatment can always be measured against the plain control
|
|
1851
|
+
# prompt. The old default of 1 (100% treatment) was changed because it
|
|
1779
1852
|
# silently dropped the control arm whenever the .env pin did not propagate to the
|
|
1780
1853
|
# running env (the installed-package driver reads its OWN .env, not the source
|
|
1781
1854
|
# tree's), leaving no control data. Robustly defaulting to 0.5 in code, not via an
|
|
1782
1855
|
# .env override, prevents that. The dashboard reads the SAME var with the SAME
|
|
1783
1856
|
# default (bin/server.js), so display and routing never diverge.
|
|
1784
1857
|
DRAFT_PROMPT_AB_RATE="${TWITTER_DRAFT_PROMPT_AB_RATE:-0.5}"
|
|
1785
|
-
# Arm VALUE versioned to '...
|
|
1786
|
-
#
|
|
1787
|
-
#
|
|
1788
|
-
#
|
|
1789
|
-
#
|
|
1858
|
+
# Arm VALUE versioned to '..._v3' on 2026-07-10 to RESET the experiment. The old
|
|
1859
|
+
# v1/v2 rows stay in the DB under their old labels but the dashboard now counts
|
|
1860
|
+
# only the '_v3' arms, so the style-as-form experiment starts fresh from zero.
|
|
1861
|
+
# Bump this suffix again on any future reset (keep bin/server.js
|
|
1862
|
+
# DRAFT_PROMPT_VARIANT_DEFS, scripts/active_experiments.py DESCRIPTIONS, and the
|
|
1863
|
+
# treatment_v3 gate in scripts/engagement_styles.py get_assigned_style_prompt in
|
|
1864
|
+
# sync).
|
|
1790
1865
|
S4L_DRAFT_PROMPT_VARIANT=$(python3 -c "
|
|
1791
1866
|
import random
|
|
1792
1867
|
try:
|
|
@@ -1794,34 +1869,42 @@ try:
|
|
|
1794
1869
|
except Exception:
|
|
1795
1870
|
rate = 0.5
|
|
1796
1871
|
rate = min(1.0, max(0.0, rate))
|
|
1797
|
-
print('
|
|
1798
|
-
" 2>/dev/null || echo
|
|
1872
|
+
print('treatment_v3' if random.random() < rate else 'control_v3')
|
|
1873
|
+
" 2>/dev/null || echo treatment_v3)
|
|
1799
1874
|
export S4L_DRAFT_PROMPT_VARIANT
|
|
1800
1875
|
log "Draft-prompt A/B arm: $S4L_DRAFT_PROMPT_VARIANT (rate=$DRAFT_PROMPT_AB_RATE)"
|
|
1801
|
-
if [ "$S4L_DRAFT_PROMPT_VARIANT" = "
|
|
1802
|
-
DRAFT_DIRECTIVE="Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. If your draft contains that concede-then-reverse pivot, rewrite it from a different entry point. This
|
|
1876
|
+
if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
|
|
1877
|
+
DRAFT_DIRECTIVE="Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. THE ASSIGNED ENGAGEMENT STYLE IS THE FORM OF THIS DRAFT, not a flavor hint: the style block above defines the draft's structure, defining move, and length. Commit to that form BEFORE writing, and run the style block's self-check before returning the draft. Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. If your draft contains that concede-then-reverse pivot, rewrite it from a different entry point. This ban OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's defining move, express it without the skeleton. Lead with substance from ONE entry point and vary the entry point across replies: a concrete first-hand specific or number; a direct answer to the exact question asked; one sharp opinion with no hedge; a genuine question that moves the thread forward; or a relevant pointer. No warm-up framing sentence before the substance. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory, and it works TOGETHER with the engagement style on different layers: the style owns the FORM (structure, defining move, length) and learned_preferences.draft_style_notes own the voice, wording, and content choices INSIDE that form. When a preference seems to conflict with the style, keep the style's structure and satisfy the preference within it; never drop the style's defining move to satisfy a wording note. Treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
1803
1878
|
else
|
|
1804
1879
|
DRAFT_DIRECTIVE="Otherwise: draft a reply using the best engagement style. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
1805
1880
|
fi
|
|
1806
|
-
# Personal-brand lane (S4L_ACTIVE_LANE=personal_brand, set by
|
|
1881
|
+
# Personal-brand lane (S4L_ACTIVE_LANE=personal_brand, set by s4l_mode.py):
|
|
1807
1882
|
# replace the product-framed directive entirely. This lane is pure organic
|
|
1808
1883
|
# growth: no product, no link, no CTA. The reply must add real value grounded in
|
|
1809
1884
|
# the persona's first-hand material (the PERSONA CORPUS block + the persona voice
|
|
1810
1885
|
# block), not concede-and-agree filler. Replaces the product-framed promotion
|
|
1811
|
-
# directives above, but is itself arm-aware (see below):
|
|
1812
|
-
# skeleton ban
|
|
1886
|
+
# directives above, but is itself arm-aware (see below): treatment_v3 adds the
|
|
1887
|
+
# skeleton ban + the two-layer style/preferences contract, control_v3 keeps the
|
|
1888
|
+
# plain persona directive, so the A/B runs in this lane too.
|
|
1813
1889
|
if [ "${S4L_ACTIVE_LANE:-}" = "personal_brand" ]; then
|
|
1814
|
-
# Arm-aware
|
|
1815
|
-
# concede-then-reverse ban clause
|
|
1816
|
-
#
|
|
1817
|
-
#
|
|
1818
|
-
#
|
|
1819
|
-
|
|
1820
|
-
|
|
1890
|
+
# Arm-aware persona lane (v3, 2026-07-10): treatment_v3 adds the
|
|
1891
|
+
# concede-then-reverse ban clause AND swaps the learned_preferences
|
|
1892
|
+
# relation from 'overrides the style on conflict' to the two-layer
|
|
1893
|
+
# contract (style owns form, preferences own voice inside it);
|
|
1894
|
+
# control_v3 keeps the plain persona directive. The style block itself
|
|
1895
|
+
# (STYLES_BLOCK, rendered in this process) is also arm-aware, so the
|
|
1896
|
+
# persona lane gets the style-as-FORM block on treatment automatically.
|
|
1897
|
+
# The arm is stamped onto the card + surfaced (active_experiments.py no
|
|
1898
|
+
# longer drops draft_prompt for personal_brand), so persona cards show
|
|
1899
|
+
# treatment_v3 / control_v3.
|
|
1900
|
+
if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
|
|
1901
|
+
PERSONA_SKELETON_BAN=" Also do NOT use the concede-then-reverse skeleton in ANY form: banned openings include 'X is the easy part/half/win, the hard part is Y', 'X was never the [thing], it's Y', 'X isn't the [problem], it's Y', 'the real/actual/harder part is Y', 'what actually breaks/ships/matters is Y', 'the part nobody says/shows is Y', and 'X is solved, Y is what breaks'; if a draft has that pivot, rewrite it from one of the entry points above. This ban OVERRIDES the assigned style's example when that example uses the skeleton: keep the style's defining move, express it without the skeleton."
|
|
1902
|
+
PERSONA_PREFS_RELATION="(learned_preferences work TOGETHER with the engagement style on different layers: the style owns the FORM, structure, defining move, and length; draft_style_notes own the voice and wording INSIDE that form; on apparent conflict keep the style's structure and satisfy the preference within it)"
|
|
1821
1903
|
else
|
|
1822
1904
|
PERSONA_SKELETON_BAN=""
|
|
1905
|
+
PERSONA_PREFS_RELATION="(it overrides the engagement style's structural template on conflict)"
|
|
1823
1906
|
fi
|
|
1824
|
-
DRAFT_DIRECTIVE="Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the PERSONA CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's \`voice\` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').${PERSONA_SKELETON_BAN} This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing
|
|
1907
|
+
DRAFT_DIRECTIVE="Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the PERSONA CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's \`voice\` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').${PERSONA_SKELETON_BAN} This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The persona's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing ${PERSONA_PREFS_RELATION}, and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
1825
1908
|
fi
|
|
1826
1909
|
|
|
1827
1910
|
# 2026-07-10 anti-sameness: --no-project-sections strips the multi-project
|
|
@@ -2047,7 +2130,7 @@ All project configs: $ALL_PROJECTS_JSON
|
|
|
2047
2130
|
|
|
2048
2131
|
## PROJECT TOP PERFORMERS (query on demand, do NOT skip routing first)
|
|
2049
2132
|
The feedback reports below carry a per-style exemplar only; project winners are no longer bulk-injected. AFTER you have decided which project a candidate's draft is for, you MAY pull that project's own recent winners (last 30 days, ranked by real click rate) when you are unsure how this product converts in replies:
|
|
2050
|
-
python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief
|
|
2133
|
+
python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief --invoked-by '$BATCH_ID'
|
|
2051
2134
|
(PROJECT_NAME exactly as it appears in the candidate's 'Project match' / config.json.) Treat the results as evidence of which CLAIMS and ANGLES landed for that product, never as structural templates: do not copy their sentence shape, opener, or pivot wording. One call per project at most; skip the call entirely for projects you already queried this session.
|
|
2052
2135
|
|
|
2053
2136
|
$RECENT_SELF_BLOCK
|
|
@@ -2156,6 +2239,27 @@ CRITICAL:
|
|
|
2156
2239
|
# installs) treats --allowedTools as a one-value flag, so a space-separated second
|
|
2157
2240
|
# tool would leak in as the prompt. On the box these flags ride through
|
|
2158
2241
|
# claude_job.py; Desktop's own web search + the reworded prompt enable it there.
|
|
2242
|
+
# --- Prep-prompt snapshot (2026-07-11) ---------------------------------------
|
|
2243
|
+
# Persist the exact rendered PREP_PROMPT per batch so prompt-block presence is
|
|
2244
|
+
# verifiable after any release (grep the file), instead of reverse-engineering
|
|
2245
|
+
# it from package scripts. The queue's prompt-*.md files are transient work
|
|
2246
|
+
# files deleted on completion, and the generation trace deliberately carries
|
|
2247
|
+
# only the exemplar context, so this is the ONLY durable full-prompt record.
|
|
2248
|
+
# Local-only, newest 50 kept (file cleanup, not candidate-row retention; the
|
|
2249
|
+
# no-retention rule covers DB *_candidates rows). Never blocks the run.
|
|
2250
|
+
PREP_PROMPT_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/prep-prompts"
|
|
2251
|
+
if mkdir -p "$PREP_PROMPT_DIR" 2>/dev/null; then
|
|
2252
|
+
_PP_FILE="$PREP_PROMPT_DIR/prep-prompt-$BATCH_ID.md"
|
|
2253
|
+
if printf '%s' "$PREP_PROMPT" > "$_PP_FILE" 2>/dev/null; then
|
|
2254
|
+
ls -t "$PREP_PROMPT_DIR"/prep-prompt-*.md 2>/dev/null | tail -n +51 | while IFS= read -r _pp_old; do
|
|
2255
|
+
rm -f "$_pp_old"
|
|
2256
|
+
done
|
|
2257
|
+
log "[prep_prompt_snapshot] batch=$BATCH_ID bytes=$(wc -c < "$_PP_FILE" | tr -d ' ') path=$_PP_FILE"
|
|
2258
|
+
else
|
|
2259
|
+
log "WARN: prep-prompt snapshot write failed for batch=$BATCH_ID (non-fatal)"
|
|
2260
|
+
fi
|
|
2261
|
+
fi
|
|
2262
|
+
|
|
2159
2263
|
PREP_OUTPUT=$(printf '%s' "$PREP_PROMPT" | "$REPO_DIR/scripts/run_claude.sh" "run-twitter-cycle-prep" --strict-mcp-config --mcp-config "$TW_MCP_CONFIG" --allowedTools WebSearch,WebFetch -p --output-format json --json-schema "$PREP_SCHEMA" 2>&1)
|
|
2160
2264
|
|
|
2161
2265
|
echo "$PREP_OUTPUT" >> "$LOG_FILE"
|
|
@@ -2176,7 +2280,9 @@ POST_TOP_N="${S4L_TWITTER_POST_TOP_N:-1}"
|
|
|
2176
2280
|
# --- ROLLING VIRALITY BAR (2026-07-02) --------------------------------------
|
|
2177
2281
|
# Fetch THIS install's trailing-24h virality percentile so the parse step posts
|
|
2178
2282
|
# the top-1 ONLY if it clears the bar. This holds the post rate near the target
|
|
2179
|
-
# (~
|
|
2283
|
+
# (~10 / day as of 2026-07-13 reach-recovery: p0.99 replayed against the prior
|
|
2284
|
+
# week's picks yields ~12/day vs ~91/day at the old p0.90) with NO hard cap:
|
|
2285
|
+
# the bar is the Nth percentile of the install's
|
|
2180
2286
|
# OWN recent candidate pool (via /api/v1/twitter-candidates/virality-threshold),
|
|
2181
2287
|
# so it self-calibrates to cadence and niche instead of being a fixed number.
|
|
2182
2288
|
# The bar applies to BOTH lanes (2026-07-03, per user instruction): the
|
|
@@ -2188,15 +2294,17 @@ POST_TOP_N="${S4L_TWITTER_POST_TOP_N:-1}"
|
|
|
2188
2294
|
# - Cold start: sample_count < min, so a fresh pool posts ungated until it fills.
|
|
2189
2295
|
# (This is also what keeps brand-new installs seeing every draft card.)
|
|
2190
2296
|
# - Fetch failure: fail-open, never silence posting on a transient API blip.
|
|
2191
|
-
# Virality percentile is HARDCODED to 0.
|
|
2297
|
+
# Virality percentile is HARDCODED to 0.99 here (raised from 0.90 on 2026-07-13,
|
|
2298
|
+
# reach-recovery: quality over volume): single source of truth, no env
|
|
2192
2299
|
# var, no fallback, one path (every install behaves identically regardless of how
|
|
2193
2300
|
# its plist was generated). Sample floor S4L_TWITTER_VIRALITY_MIN_SAMPLE default 200.
|
|
2194
|
-
VIRALITY_THRESHOLD=$(S4L_VPCTILE="0.
|
|
2301
|
+
VIRALITY_THRESHOLD=$(S4L_VPCTILE="0.99" \
|
|
2195
2302
|
S4L_VMIN="${S4L_TWITTER_VIRALITY_MIN_SAMPLE:-200}" \
|
|
2196
2303
|
S4L_SCRIPTS_DIR="$REPO_DIR/scripts" \
|
|
2197
2304
|
python3 -c "
|
|
2198
2305
|
import os, sys
|
|
2199
|
-
|
|
2306
|
+
_repo = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
2307
|
+
sys.path.insert(0, os.environ.get('S4L_SCRIPTS_DIR') or os.path.join(_repo, 'scripts'))
|
|
2200
2308
|
from http_api import api_get
|
|
2201
2309
|
try:
|
|
2202
2310
|
r = api_get('/api/v1/twitter-candidates/virality-threshold',
|
|
@@ -2211,7 +2319,7 @@ except BaseException as e:
|
|
|
2211
2319
|
sys.stderr.write(f'virality-bar fetch failed (bar OFF this cycle): {e}\n')
|
|
2212
2320
|
" 2>>"$LOG_FILE" || echo "")
|
|
2213
2321
|
if [ -n "$VIRALITY_THRESHOLD" ]; then
|
|
2214
|
-
log "Virality bar ACTIVE: p0.
|
|
2322
|
+
log "Virality bar ACTIVE: p0.99 = $VIRALITY_THRESHOLD (this install, trailing 24h); top-1 kept only if it clears the bar."
|
|
2215
2323
|
else
|
|
2216
2324
|
log "Virality bar OFF this cycle (cold-start/thin pool or fetch failed); top-1 kept ungated."
|
|
2217
2325
|
fi
|
|
@@ -2364,6 +2472,33 @@ if [ "$PREP_PARSE_EXIT" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
|
|
|
2364
2472
|
fi
|
|
2365
2473
|
log "Phase 2b-prep complete. plan_count=$PLAN_COUNT"
|
|
2366
2474
|
|
|
2475
|
+
# On-demand project-winners usage marker (2026-07-10). top_performers.py
|
|
2476
|
+
# appends a JSON line to the state-dir ledger on every --project call (the
|
|
2477
|
+
# draft prompt tells the model to pass --invoked-by "$BATCH_ID"). Count this
|
|
2478
|
+
# batch's lines and log a greppable marker so "did the drafting session
|
|
2479
|
+
# actually use the per-project query" is answerable from the cycle log alone.
|
|
2480
|
+
# Stderr-marker convention: format is load-bearing elsewhere; keep it stable.
|
|
2481
|
+
TP_ONDEMAND=$(python3 -c "
|
|
2482
|
+
import json, os, sys
|
|
2483
|
+
path = os.path.join(os.environ.get('S4L_STATE_DIR', os.path.expanduser('~/.social-autoposter-mcp')), 'top-performers-invocations.jsonl')
|
|
2484
|
+
n, projects = 0, []
|
|
2485
|
+
try:
|
|
2486
|
+
for line in open(path):
|
|
2487
|
+
try:
|
|
2488
|
+
r = json.loads(line)
|
|
2489
|
+
except Exception:
|
|
2490
|
+
continue
|
|
2491
|
+
if r.get('invoked_by') == '$BATCH_ID':
|
|
2492
|
+
n += 1
|
|
2493
|
+
p = r.get('project')
|
|
2494
|
+
if p and p not in projects:
|
|
2495
|
+
projects.append(p)
|
|
2496
|
+
except OSError:
|
|
2497
|
+
pass
|
|
2498
|
+
print(f'{n} projects={projects}')
|
|
2499
|
+
" 2>/dev/null || echo "0 projects=[]")
|
|
2500
|
+
log "[project_top_performers] batch=$BATCH_ID on_demand_invocations=$TP_ONDEMAND"
|
|
2501
|
+
|
|
2367
2502
|
# twitter-browser lock was already released right after thread-media capture
|
|
2368
2503
|
# (before the Claude drafting call above), since nothing from there through
|
|
2369
2504
|
# Phase 2b-gen touches the browser. Phase 2b-post re-acquires unconditionally
|
|
@@ -16,7 +16,7 @@ LOG_DIR="$REPO_DIR/skill/logs"
|
|
|
16
16
|
mkdir -p "$LOG_DIR"
|
|
17
17
|
LOG_FILE="$LOG_DIR/scan-instagram-replies-$(date +%Y-%m-%d_%H%M%S).log"
|
|
18
18
|
|
|
19
|
-
log() { echo "[$(date +%H:%M:%
|
|
19
|
+
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
|
|
20
20
|
log "=== scan-instagram-replies fire: $(date) ==="
|
|
21
21
|
|
|
22
22
|
RUN_START=$(date +%s)
|