@m13v/s4l 1.7.4-rc.2 → 1.7.4-rc.21
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/mcp/dist/index.js +3 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +126 -12
- package/mcp/menubar/s4l_menubar.py +10 -3
- package/mcp/menubar/s4l_state.py +4 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/active_experiments.py +30 -6
- package/scripts/autopilot_stall_watch.py +2 -2
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +221 -26
- package/scripts/enrich_reply_parents.py +312 -0
- package/scripts/feedback_digest.py +76 -0
- 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 +12 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/scan_x_profile.py +69 -8
- package/scripts/snapshot.py +1 -1
- package/scripts/top_performers.py +34 -0
- package/scripts/voice_exemplars.py +11 -17
- package/skill/engage-twitter.sh +9 -0
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/run-twitter-cycle.sh +154 -49
|
@@ -345,8 +345,12 @@ def cmd_backfill(args) -> int:
|
|
|
345
345
|
at onboarding, and it is where the author's own voice lives; product
|
|
346
346
|
projects pick exemplars up on their next project_config save instead. The
|
|
347
347
|
corpus write is additive (the marked section is the only thing replaced;
|
|
348
|
-
dictation and hand-added material stay).
|
|
349
|
-
|
|
348
|
+
dictation and hand-added material stay). No cooldown by design (user rule
|
|
349
|
+
2026-07-10): every boot retries until the scan succeeds; success stamps
|
|
350
|
+
examples_scanned_at, which makes all later boots a cheap no-op. Boots are
|
|
351
|
+
user-triggered (Desktop launch), so the worst case is one scan attempt per
|
|
352
|
+
launch, and concurrent waiters from overlapping boots dedupe via the
|
|
353
|
+
post-lock eligibility re-check."""
|
|
350
354
|
cfg_path = Path(args.config).expanduser() if args.config else s4l_mode.config_path()
|
|
351
355
|
if not cfg_path.exists():
|
|
352
356
|
print(json.dumps({"ok": True, "did": "nothing", "reason": "no_config"}))
|
|
@@ -360,20 +364,11 @@ def cmd_backfill(args) -> int:
|
|
|
360
364
|
print(json.dumps({"ok": True, "did": "nothing", "reason": "already_done_or_hand_written"}))
|
|
361
365
|
return 0
|
|
362
366
|
|
|
363
|
-
marker = cfg_path.parent / ".voice_exemplars_backfill.json"
|
|
364
|
-
try:
|
|
365
|
-
last = json.loads(marker.read_text()).get("last_attempt", 0)
|
|
366
|
-
except Exception:
|
|
367
|
-
last = 0
|
|
368
|
-
if time.time() - last < args.min_hours_between * 3600:
|
|
369
|
-
print(json.dumps({"ok": True, "did": "nothing", "reason": "attempted_recently"}))
|
|
370
|
-
return 0
|
|
371
|
-
|
|
372
367
|
if not args.no_scan:
|
|
373
368
|
lock = _wait_browser_lock(args.max_wait_minutes * 60)
|
|
374
369
|
if lock is None:
|
|
375
|
-
# Waited the whole window and never got the browser
|
|
376
|
-
#
|
|
370
|
+
# Waited the whole window and never got the browser; the next
|
|
371
|
+
# boot simply tries again.
|
|
377
372
|
print(json.dumps({"ok": True, "did": "nothing", "reason": "browser_busy_timeout"}))
|
|
378
373
|
return 0
|
|
379
374
|
# The wait can be hours; another boot's backfill may have finished
|
|
@@ -387,11 +382,12 @@ def cmd_backfill(args) -> int:
|
|
|
387
382
|
_release_browser_lock()
|
|
388
383
|
print(json.dumps({"ok": True, "did": "nothing", "reason": "done_while_waiting"}))
|
|
389
384
|
return 0
|
|
390
|
-
marker.write_text(json.dumps({"last_attempt": int(time.time())}) + "\n")
|
|
391
385
|
try:
|
|
392
386
|
py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
|
|
387
|
+
# generous: the default depth (60 posts / 150 replies) can scroll
|
|
388
|
+
# for several minutes on prolific accounts
|
|
393
389
|
r = subprocess.run([py, str(HERE / "scan_x_profile.py")],
|
|
394
|
-
capture_output=True, text=True, timeout=
|
|
390
|
+
capture_output=True, text=True, timeout=1200)
|
|
395
391
|
except Exception as e:
|
|
396
392
|
print(json.dumps({"ok": False, "did": "nothing", "reason": f"scan_failed: {e}"}))
|
|
397
393
|
return 0
|
|
@@ -450,8 +446,6 @@ def main(argv) -> int:
|
|
|
450
446
|
b.add_argument("--config", default=None, help="config.json path override (testing)")
|
|
451
447
|
b.add_argument("--top", type=int, default=5)
|
|
452
448
|
b.add_argument("--min-chars", type=int, default=40)
|
|
453
|
-
b.add_argument("--min-hours-between", type=float, default=24.0,
|
|
454
|
-
help="rate limit between scan attempts")
|
|
455
449
|
b.add_argument("--max-wait-minutes", type=float, default=12 * 60,
|
|
456
450
|
help="how long to wait for the twitter-browser lock before "
|
|
457
451
|
"giving up until the next boot")
|
package/skill/engage-twitter.sh
CHANGED
|
@@ -68,6 +68,15 @@ python3 "$REPO_DIR/scripts/scan_twitter_mentions_browser.py" --json-file "$NOTIF
|
|
|
68
68
|
|| log "WARNING: Phase A scan_twitter_mentions_browser.py exited with code $?"
|
|
69
69
|
rm -f "$NOTIFS_JSON"
|
|
70
70
|
|
|
71
|
+
# Phase A2: fill parent-thread linkage on mention-discovered rows. The
|
|
72
|
+
# notifications feed hides the parent tweet id, so scan rows land with
|
|
73
|
+
# mention_id only; this resolves post_id / parent_reply_id / project via
|
|
74
|
+
# fxtwitter HTTP (no browser, no model). Newest rows first, bounded per run.
|
|
75
|
+
log "Phase A2: Enriching parent linkage on mention replies..."
|
|
76
|
+
python3 "$REPO_DIR/scripts/enrich_reply_parents.py" --limit 25 2>&1 \
|
|
77
|
+
| tee -a "$LOG_FILE" \
|
|
78
|
+
|| log "WARNING: Phase A2 enrich_reply_parents.py exited with code $?"
|
|
79
|
+
|
|
71
80
|
# ═══════════════════════════════════════════════════════
|
|
72
81
|
# PHASE B: Respond to pending Twitter replies
|
|
73
82
|
# ═══════════════════════════════════════════════════════
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# linkedin-cadence.sh — enforce the 4-active-day / 2-day-break LinkedIn
|
|
3
|
+
# posting schedule (user instruction, 2026-07-11). Fired every 15 minutes by
|
|
4
|
+
# launchd (com.m13v.social-linkedin-cadence). All logic lives in
|
|
5
|
+
# scripts/linkedin_cadence.py; this wrapper just logs the tick.
|
|
6
|
+
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
export PATH="/opt/homebrew/bin:$PATH"
|
|
9
|
+
|
|
10
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
11
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
12
|
+
mkdir -p "$LOG_DIR"
|
|
13
|
+
LOG="$LOG_DIR/linkedin-cadence.log"
|
|
14
|
+
|
|
15
|
+
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG" >&2; }
|
|
16
|
+
|
|
17
|
+
PY="/opt/homebrew/bin/python3"
|
|
18
|
+
[ -x "$PY" ] || PY="/usr/bin/python3"
|
|
19
|
+
|
|
20
|
+
"$PY" "$REPO_DIR/scripts/linkedin_cadence.py" enforce >>"$LOG" 2>&1
|
|
@@ -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"
|
|
@@ -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
|
|
|
@@ -977,7 +978,8 @@ if [ "$SCAN_ATTEMPT" -gt 1 ]; then
|
|
|
977
978
|
rm -f "$UNIVERSE_EXHAUSTED_MARKER"
|
|
978
979
|
PROJECTS_JSON=$(python3 - "$PROJECTS_JSON" "$TRIED_TOPICS_JSON" "$UNIVERSE_EXHAUSTED_MARKER" <<'PY' 2>>"$LOG_FILE"
|
|
979
980
|
import json, os, sys
|
|
980
|
-
|
|
981
|
+
REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
982
|
+
sys.path.insert(0, os.path.join(REPO, 'scripts'))
|
|
981
983
|
from pick_search_topic import pick_topic_for_project, PickerError, UniverseExhaustedError
|
|
982
984
|
|
|
983
985
|
projects = json.loads(sys.argv[1] or '[]')
|
|
@@ -1349,7 +1351,8 @@ fi
|
|
|
1349
1351
|
# search_attempts log so a Phase 1 abort still leaves the marks behind.
|
|
1350
1352
|
python3 - "$PROJECTS_JSON" <<'PY' 2>&1 | tee -a "$LOG_FILE" || true
|
|
1351
1353
|
import json, os, sys
|
|
1352
|
-
|
|
1354
|
+
REPO = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
1355
|
+
sys.path.insert(0, os.path.join(REPO, 'scripts'))
|
|
1353
1356
|
import project_excludes as pe
|
|
1354
1357
|
projects = json.loads(sys.argv[1] or '[]')
|
|
1355
1358
|
total = 0
|
|
@@ -1643,9 +1646,10 @@ done <<< "$CANDIDATES"
|
|
|
1643
1646
|
|
|
1644
1647
|
ALL_PROJECTS_JSON=$(python3 -c "
|
|
1645
1648
|
import json, os, sys
|
|
1646
|
-
|
|
1649
|
+
repo_dir = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
1650
|
+
sys.path.insert(0, os.path.join(repo_dir, 'scripts'))
|
|
1647
1651
|
import learned_preferences as lp
|
|
1648
|
-
config = json.load(open(os.path.
|
|
1652
|
+
config = json.load(open(os.path.join(repo_dir, 'config.json')))
|
|
1649
1653
|
projects = config.get('projects', [])
|
|
1650
1654
|
# learned_preferences is a SINGLE install-wide block since 2026-07-08 (see
|
|
1651
1655
|
# scripts/learned_preferences.py), not one per project. Computed once here
|
|
@@ -1725,12 +1729,33 @@ log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)
|
|
|
1725
1729
|
# are actually different (a same-name pair defeats the whole point); INVENT
|
|
1726
1730
|
# mode on either side is accepted immediately since an invented name is
|
|
1727
1731
|
# definitionally distinct from a pinned one. This is orthogonal to the
|
|
1728
|
-
#
|
|
1732
|
+
# treatment_v3/control_v3 draft-prompt A/B below (that varies WORDING of the
|
|
1729
1733
|
# directive for the whole cycle; this varies STYLE per draft slot), so neither
|
|
1730
1734
|
# experiment disturbs the other.
|
|
1731
1735
|
STYLE_ASSIGN_FILE_B=$(mktemp -t s4l_twitter_assign_b_XXXXXX.json)
|
|
1732
|
-
|
|
1733
|
-
|
|
1736
|
+
# --- Draft-B exploration source (2026-07-11) ---------------------------------
|
|
1737
|
+
# Style B is now the EXPLORE slot: it trials the newest human_derived styles
|
|
1738
|
+
# and post-2026-07-10 inventions (least-used first) instead of drawing a
|
|
1739
|
+
# second scored pick from the same proven pool as Style A. This is the
|
|
1740
|
+
# distribution channel for the standalone invent_styles.py job: the card
|
|
1741
|
+
# pick + the posted draft's engagement write a new style's first real score,
|
|
1742
|
+
# and winners graduate into the Draft-A pool via the normal sampler. NOTHING
|
|
1743
|
+
# is invented here (pick_exploration_style never returns mode=invent). On an
|
|
1744
|
+
# empty pool or API failure we fall back to the legacy second scored pick so
|
|
1745
|
+
# dual-draft cards never break. The source tag rides the S4L_EXP_ convention:
|
|
1746
|
+
# active_experiments.collect() auto-stamps it onto every plan candidate and
|
|
1747
|
+
# the review card's details-eye renders it with zero card-side code.
|
|
1748
|
+
DRAFT_B_SOURCE=$(python3 -c "
|
|
1749
|
+
import json, sys
|
|
1750
|
+
sys.path.insert(0, '$REPO_DIR/scripts')
|
|
1751
|
+
from engagement_styles import pick_exploration_style
|
|
1752
|
+
a = pick_exploration_style('twitter', context='posting', exclude={'$PICKED_STYLE'})
|
|
1753
|
+
if a and a.get('style'):
|
|
1754
|
+
with open('$STYLE_ASSIGN_FILE_B', 'w') as f:
|
|
1755
|
+
json.dump(a, f)
|
|
1756
|
+
print(a.get('source') or '')
|
|
1757
|
+
" 2>/dev/null || echo "")
|
|
1758
|
+
if [ -n "$DRAFT_B_SOURCE" ]; then
|
|
1734
1759
|
PICKED_STYLE_B=$(python3 -c "
|
|
1735
1760
|
import json
|
|
1736
1761
|
try:
|
|
@@ -1740,7 +1765,22 @@ try:
|
|
|
1740
1765
|
except Exception:
|
|
1741
1766
|
print('')
|
|
1742
1767
|
" 2>/dev/null)
|
|
1743
|
-
PICKED_MODE_B
|
|
1768
|
+
PICKED_MODE_B="use"
|
|
1769
|
+
fi
|
|
1770
|
+
if [ -z "${PICKED_STYLE_B:-}" ]; then
|
|
1771
|
+
DRAFT_B_SOURCE="scored_fallback"
|
|
1772
|
+
for _style_b_attempt in 1 2 3; do
|
|
1773
|
+
s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
|
|
1774
|
+
PICKED_STYLE_B=$(python3 -c "
|
|
1775
|
+
import json
|
|
1776
|
+
try:
|
|
1777
|
+
with open('$STYLE_ASSIGN_FILE_B') as f:
|
|
1778
|
+
d = json.load(f)
|
|
1779
|
+
print(d.get('style') or '')
|
|
1780
|
+
except Exception:
|
|
1781
|
+
print('')
|
|
1782
|
+
" 2>/dev/null)
|
|
1783
|
+
PICKED_MODE_B=$(python3 -c "
|
|
1744
1784
|
import json
|
|
1745
1785
|
try:
|
|
1746
1786
|
with open('$STYLE_ASSIGN_FILE_B') as f:
|
|
@@ -1749,44 +1789,52 @@ try:
|
|
|
1749
1789
|
except Exception:
|
|
1750
1790
|
print('use')
|
|
1751
1791
|
" 2>/dev/null)
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
done
|
|
1756
|
-
|
|
1792
|
+
if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
|
|
1793
|
+
break
|
|
1794
|
+
fi
|
|
1795
|
+
done
|
|
1796
|
+
fi
|
|
1797
|
+
export S4L_EXP_DRAFT_B_SOURCE="$DRAFT_B_SOURCE"
|
|
1798
|
+
log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)} source=$DRAFT_B_SOURCE"
|
|
1757
1799
|
|
|
1758
|
-
# --- Draft-prompt A/B:
|
|
1800
|
+
# --- Draft-prompt A/B: style-as-form (v3, 2026-07-10) ------------------------
|
|
1759
1801
|
# Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
|
|
1760
1802
|
# assignment is at cycle granularity, not per post; the whole batch shares it).
|
|
1761
|
-
#
|
|
1762
|
-
#
|
|
1763
|
-
#
|
|
1764
|
-
#
|
|
1765
|
-
#
|
|
1766
|
-
#
|
|
1767
|
-
#
|
|
1768
|
-
#
|
|
1769
|
-
#
|
|
1770
|
-
#
|
|
1771
|
-
#
|
|
1803
|
+
# control_v3 = the plain draft directive (v2's control text verbatim).
|
|
1804
|
+
# treatment_v3 = style-as-FORM package: the assigned engagement style is the
|
|
1805
|
+
# binding FORM of the draft (defining move + per-style length +
|
|
1806
|
+
# end-of-block self-check, rendered arm-aware by
|
|
1807
|
+
# engagement_styles.get_assigned_style_prompt, which reads
|
|
1808
|
+
# S4L_DRAFT_PROMPT_VARIANT at render time IN this cycle
|
|
1809
|
+
# process), PLUS the two-layer learned_preferences contract
|
|
1810
|
+
# (style owns form, preferences own voice inside it; the old
|
|
1811
|
+
# 'overrides on conflict' phrasing made the model treat the
|
|
1812
|
+
# style as optional), PLUS the v2 skeleton ban carried over.
|
|
1813
|
+
# History: v1 (2026-06-29) decoupled the product pivot; the model kept the
|
|
1814
|
+
# skeleton (30% ~= 28% on 857 replies). v2 (2026-07-06) banned the
|
|
1815
|
+
# concede-then-reverse STRUCTURE. v3 (2026-07-10) replaces v2's treatment
|
|
1816
|
+
# wholesale: drafts converged in shape regardless of assigned style, so v3
|
|
1817
|
+
# makes the style block load-bearing instead of only banning one skeleton.
|
|
1772
1818
|
# The arm is stamped onto every post this cycle via S4L_DRAFT_PROMPT_VARIANT
|
|
1773
1819
|
# (read by twitter_post_plan.py -> log_post.py -> posts.draft_prompt_variant),
|
|
1774
1820
|
# mirroring the tail_link_variant plumbing. Split tunable via
|
|
1775
1821
|
# TWITTER_DRAFT_PROMPT_AB_RATE = fraction of cycles assigned to 'treatment'.
|
|
1776
1822
|
# CODE DEFAULT 0.5 = 50/50 EVERYWHERE (2026-07-06): every install runs a real
|
|
1777
|
-
# holdback so treatment
|
|
1778
|
-
#
|
|
1823
|
+
# holdback so treatment can always be measured against the plain control
|
|
1824
|
+
# prompt. The old default of 1 (100% treatment) was changed because it
|
|
1779
1825
|
# silently dropped the control arm whenever the .env pin did not propagate to the
|
|
1780
1826
|
# running env (the installed-package driver reads its OWN .env, not the source
|
|
1781
1827
|
# tree's), leaving no control data. Robustly defaulting to 0.5 in code, not via an
|
|
1782
1828
|
# .env override, prevents that. The dashboard reads the SAME var with the SAME
|
|
1783
1829
|
# default (bin/server.js), so display and routing never diverge.
|
|
1784
1830
|
DRAFT_PROMPT_AB_RATE="${TWITTER_DRAFT_PROMPT_AB_RATE:-0.5}"
|
|
1785
|
-
# Arm VALUE versioned to '...
|
|
1786
|
-
#
|
|
1787
|
-
#
|
|
1788
|
-
#
|
|
1789
|
-
#
|
|
1831
|
+
# Arm VALUE versioned to '..._v3' on 2026-07-10 to RESET the experiment. The old
|
|
1832
|
+
# v1/v2 rows stay in the DB under their old labels but the dashboard now counts
|
|
1833
|
+
# only the '_v3' arms, so the style-as-form experiment starts fresh from zero.
|
|
1834
|
+
# Bump this suffix again on any future reset (keep bin/server.js
|
|
1835
|
+
# DRAFT_PROMPT_VARIANT_DEFS, scripts/active_experiments.py DESCRIPTIONS, and the
|
|
1836
|
+
# treatment_v3 gate in scripts/engagement_styles.py get_assigned_style_prompt in
|
|
1837
|
+
# sync).
|
|
1790
1838
|
S4L_DRAFT_PROMPT_VARIANT=$(python3 -c "
|
|
1791
1839
|
import random
|
|
1792
1840
|
try:
|
|
@@ -1794,12 +1842,12 @@ try:
|
|
|
1794
1842
|
except Exception:
|
|
1795
1843
|
rate = 0.5
|
|
1796
1844
|
rate = min(1.0, max(0.0, rate))
|
|
1797
|
-
print('
|
|
1798
|
-
" 2>/dev/null || echo
|
|
1845
|
+
print('treatment_v3' if random.random() < rate else 'control_v3')
|
|
1846
|
+
" 2>/dev/null || echo treatment_v3)
|
|
1799
1847
|
export S4L_DRAFT_PROMPT_VARIANT
|
|
1800
1848
|
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
|
|
1849
|
+
if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
|
|
1850
|
+
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
1851
|
else
|
|
1804
1852
|
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
1853
|
fi
|
|
@@ -1808,20 +1856,28 @@ fi
|
|
|
1808
1856
|
# growth: no product, no link, no CTA. The reply must add real value grounded in
|
|
1809
1857
|
# the persona's first-hand material (the PERSONA CORPUS block + the persona voice
|
|
1810
1858
|
# block), not concede-and-agree filler. Replaces the product-framed promotion
|
|
1811
|
-
# directives above, but is itself arm-aware (see below):
|
|
1812
|
-
# skeleton ban
|
|
1859
|
+
# directives above, but is itself arm-aware (see below): treatment_v3 adds the
|
|
1860
|
+
# skeleton ban + the two-layer style/preferences contract, control_v3 keeps the
|
|
1861
|
+
# plain persona directive, so the A/B runs in this lane too.
|
|
1813
1862
|
if [ "${S4L_ACTIVE_LANE:-}" = "personal_brand" ]; then
|
|
1814
|
-
# Arm-aware
|
|
1815
|
-
# concede-then-reverse ban clause
|
|
1816
|
-
#
|
|
1817
|
-
#
|
|
1818
|
-
#
|
|
1819
|
-
|
|
1820
|
-
|
|
1863
|
+
# Arm-aware persona lane (v3, 2026-07-10): treatment_v3 adds the
|
|
1864
|
+
# concede-then-reverse ban clause AND swaps the learned_preferences
|
|
1865
|
+
# relation from 'overrides the style on conflict' to the two-layer
|
|
1866
|
+
# contract (style owns form, preferences own voice inside it);
|
|
1867
|
+
# control_v3 keeps the plain persona directive. The style block itself
|
|
1868
|
+
# (STYLES_BLOCK, rendered in this process) is also arm-aware, so the
|
|
1869
|
+
# persona lane gets the style-as-FORM block on treatment automatically.
|
|
1870
|
+
# The arm is stamped onto the card + surfaced (active_experiments.py no
|
|
1871
|
+
# longer drops draft_prompt for personal_brand), so persona cards show
|
|
1872
|
+
# treatment_v3 / control_v3.
|
|
1873
|
+
if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
|
|
1874
|
+
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."
|
|
1875
|
+
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
1876
|
else
|
|
1822
1877
|
PERSONA_SKELETON_BAN=""
|
|
1878
|
+
PERSONA_PREFS_RELATION="(it overrides the engagement style's structural template on conflict)"
|
|
1823
1879
|
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
|
|
1880
|
+
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
1881
|
fi
|
|
1826
1882
|
|
|
1827
1883
|
# 2026-07-10 anti-sameness: --no-project-sections strips the multi-project
|
|
@@ -2047,7 +2103,7 @@ All project configs: $ALL_PROJECTS_JSON
|
|
|
2047
2103
|
|
|
2048
2104
|
## PROJECT TOP PERFORMERS (query on demand, do NOT skip routing first)
|
|
2049
2105
|
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
|
|
2106
|
+
python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief --invoked-by '$BATCH_ID'
|
|
2051
2107
|
(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
2108
|
|
|
2053
2109
|
$RECENT_SELF_BLOCK
|
|
@@ -2156,6 +2212,27 @@ CRITICAL:
|
|
|
2156
2212
|
# installs) treats --allowedTools as a one-value flag, so a space-separated second
|
|
2157
2213
|
# tool would leak in as the prompt. On the box these flags ride through
|
|
2158
2214
|
# claude_job.py; Desktop's own web search + the reworded prompt enable it there.
|
|
2215
|
+
# --- Prep-prompt snapshot (2026-07-11) ---------------------------------------
|
|
2216
|
+
# Persist the exact rendered PREP_PROMPT per batch so prompt-block presence is
|
|
2217
|
+
# verifiable after any release (grep the file), instead of reverse-engineering
|
|
2218
|
+
# it from package scripts. The queue's prompt-*.md files are transient work
|
|
2219
|
+
# files deleted on completion, and the generation trace deliberately carries
|
|
2220
|
+
# only the exemplar context, so this is the ONLY durable full-prompt record.
|
|
2221
|
+
# Local-only, newest 50 kept (file cleanup, not candidate-row retention; the
|
|
2222
|
+
# no-retention rule covers DB *_candidates rows). Never blocks the run.
|
|
2223
|
+
PREP_PROMPT_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/prep-prompts"
|
|
2224
|
+
if mkdir -p "$PREP_PROMPT_DIR" 2>/dev/null; then
|
|
2225
|
+
_PP_FILE="$PREP_PROMPT_DIR/prep-prompt-$BATCH_ID.md"
|
|
2226
|
+
if printf '%s' "$PREP_PROMPT" > "$_PP_FILE" 2>/dev/null; then
|
|
2227
|
+
ls -t "$PREP_PROMPT_DIR"/prep-prompt-*.md 2>/dev/null | tail -n +51 | while IFS= read -r _pp_old; do
|
|
2228
|
+
rm -f "$_pp_old"
|
|
2229
|
+
done
|
|
2230
|
+
log "[prep_prompt_snapshot] batch=$BATCH_ID bytes=$(wc -c < "$_PP_FILE" | tr -d ' ') path=$_PP_FILE"
|
|
2231
|
+
else
|
|
2232
|
+
log "WARN: prep-prompt snapshot write failed for batch=$BATCH_ID (non-fatal)"
|
|
2233
|
+
fi
|
|
2234
|
+
fi
|
|
2235
|
+
|
|
2159
2236
|
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
2237
|
|
|
2161
2238
|
echo "$PREP_OUTPUT" >> "$LOG_FILE"
|
|
@@ -2196,7 +2273,8 @@ VIRALITY_THRESHOLD=$(S4L_VPCTILE="0.90" \
|
|
|
2196
2273
|
S4L_SCRIPTS_DIR="$REPO_DIR/scripts" \
|
|
2197
2274
|
python3 -c "
|
|
2198
2275
|
import os, sys
|
|
2199
|
-
|
|
2276
|
+
_repo = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
2277
|
+
sys.path.insert(0, os.environ.get('S4L_SCRIPTS_DIR') or os.path.join(_repo, 'scripts'))
|
|
2200
2278
|
from http_api import api_get
|
|
2201
2279
|
try:
|
|
2202
2280
|
r = api_get('/api/v1/twitter-candidates/virality-threshold',
|
|
@@ -2364,6 +2442,33 @@ if [ "$PREP_PARSE_EXIT" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
|
|
|
2364
2442
|
fi
|
|
2365
2443
|
log "Phase 2b-prep complete. plan_count=$PLAN_COUNT"
|
|
2366
2444
|
|
|
2445
|
+
# On-demand project-winners usage marker (2026-07-10). top_performers.py
|
|
2446
|
+
# appends a JSON line to the state-dir ledger on every --project call (the
|
|
2447
|
+
# draft prompt tells the model to pass --invoked-by "$BATCH_ID"). Count this
|
|
2448
|
+
# batch's lines and log a greppable marker so "did the drafting session
|
|
2449
|
+
# actually use the per-project query" is answerable from the cycle log alone.
|
|
2450
|
+
# Stderr-marker convention: format is load-bearing elsewhere; keep it stable.
|
|
2451
|
+
TP_ONDEMAND=$(python3 -c "
|
|
2452
|
+
import json, os, sys
|
|
2453
|
+
path = os.path.join(os.environ.get('S4L_STATE_DIR', os.path.expanduser('~/.social-autoposter-mcp')), 'top-performers-invocations.jsonl')
|
|
2454
|
+
n, projects = 0, []
|
|
2455
|
+
try:
|
|
2456
|
+
for line in open(path):
|
|
2457
|
+
try:
|
|
2458
|
+
r = json.loads(line)
|
|
2459
|
+
except Exception:
|
|
2460
|
+
continue
|
|
2461
|
+
if r.get('invoked_by') == '$BATCH_ID':
|
|
2462
|
+
n += 1
|
|
2463
|
+
p = r.get('project')
|
|
2464
|
+
if p and p not in projects:
|
|
2465
|
+
projects.append(p)
|
|
2466
|
+
except OSError:
|
|
2467
|
+
pass
|
|
2468
|
+
print(f'{n} projects={projects}')
|
|
2469
|
+
" 2>/dev/null || echo "0 projects=[]")
|
|
2470
|
+
log "[project_top_performers] batch=$BATCH_ID on_demand_invocations=$TP_ONDEMAND"
|
|
2471
|
+
|
|
2367
2472
|
# twitter-browser lock was already released right after thread-media capture
|
|
2368
2473
|
# (before the Claude drafting call above), since nothing from there through
|
|
2369
2474
|
# Phase 2b-gen touches the browser. Phase 2b-post re-acquires unconditionally
|