@m13v/s4l 1.7.4-rc.10 → 1.7.4-rc.12
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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +15 -1
- package/mcp/package.json +1 -1
- package/package.json +2 -2
- package/scripts/active_experiments.py +14 -0
- package/scripts/engagement_styles.py +130 -8
- package/skill/run-twitter-cycle.sh +46 -8
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.4-rc.
|
|
5
|
+
"version": "1.7.4-rc.12",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -431,7 +431,21 @@ def _details_lines(d):
|
|
|
431
431
|
d = d or {}
|
|
432
432
|
lines = []
|
|
433
433
|
style = (d.get("engagement_style") or "").strip()
|
|
434
|
-
|
|
434
|
+
# Two-draft cards (2026-07-11): name each slot's style so the reviewer
|
|
435
|
+
# can tell WHICH style produced the draft they are picking (Draft B is
|
|
436
|
+
# the exploration slot; its source arm renders separately below via the
|
|
437
|
+
# generic experiments lines). Single-draft cards keep the old one-liner.
|
|
438
|
+
_dual = d.get("drafts")
|
|
439
|
+
if isinstance(_dual, list) and len(_dual) == 2:
|
|
440
|
+
_slot_labels = {"a": "Draft A", "b": "Draft B"}
|
|
441
|
+
for _draft in _dual:
|
|
442
|
+
_s = (_draft.get("style") or "").strip()
|
|
443
|
+
if _s:
|
|
444
|
+
_label = _slot_labels.get(
|
|
445
|
+
(_draft.get("variant") or "").strip().lower(), "Draft"
|
|
446
|
+
)
|
|
447
|
+
lines.append(f"{_label} style: {_s}")
|
|
448
|
+
elif style:
|
|
435
449
|
lines.append(f"Style: {style}")
|
|
436
450
|
desc = (d.get("style_description") or "").strip()
|
|
437
451
|
if desc:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.12",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.12",
|
|
4
4
|
"description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"social-autoposter": "bin/cli.js",
|
|
@@ -160,4 +160,4 @@
|
|
|
160
160
|
"pg": "^8.20.0",
|
|
161
161
|
"ws": "^8.0.0"
|
|
162
162
|
}
|
|
163
|
-
}
|
|
163
|
+
}
|
|
@@ -40,6 +40,20 @@ ENV_PREFIX = "S4L_EXP_"
|
|
|
40
40
|
# Keep draft_prompt entries in sync with bin/server.js
|
|
41
41
|
# DRAFT_PROMPT_VARIANT_DEFS and the arm strings in run-twitter-cycle.sh.
|
|
42
42
|
DESCRIPTIONS = {
|
|
43
|
+
"draft_b_source": {
|
|
44
|
+
"human_derived": (
|
|
45
|
+
"Draft B explore slot: style distilled from real top-performing "
|
|
46
|
+
"human replies (daily synthesizer), least-used first"
|
|
47
|
+
),
|
|
48
|
+
"model_invented": (
|
|
49
|
+
"Draft B explore slot: freshly invented style from the "
|
|
50
|
+
"standalone invention job (post 2026-07-10), least-used first"
|
|
51
|
+
),
|
|
52
|
+
"scored_fallback": (
|
|
53
|
+
"Draft B explore pool was empty; fell back to a second scored "
|
|
54
|
+
"pick from the proven-style pool"
|
|
55
|
+
),
|
|
56
|
+
},
|
|
43
57
|
"draft_prompt": {
|
|
44
58
|
"treatment_v2": (
|
|
45
59
|
"skeleton ban: forbids the concede-then-reverse "
|
|
@@ -36,7 +36,7 @@ import json
|
|
|
36
36
|
import os
|
|
37
37
|
import random
|
|
38
38
|
import sys as _sys_mod
|
|
39
|
-
from datetime import datetime, timezone
|
|
39
|
+
from datetime import datetime, timedelta, timezone
|
|
40
40
|
|
|
41
41
|
# ── Style taxonomy ──────────────────────────────────────────────────
|
|
42
42
|
|
|
@@ -1341,6 +1341,106 @@ def pick_style_for_post(platform, context="posting",
|
|
|
1341
1341
|
}
|
|
1342
1342
|
|
|
1343
1343
|
|
|
1344
|
+
# Days back a model_invented style still counts as "recent" for the Draft-B
|
|
1345
|
+
# exploration pool. The registry holds ~900 pre-2026-07-10 inventions that
|
|
1346
|
+
# are mostly clones of the agree-then-relocate skeleton (see the
|
|
1347
|
+
# invent_styles.py docstring); the exploration slot exists to trial the
|
|
1348
|
+
# standalone job's structurally-diverse output, not to resurrect those.
|
|
1349
|
+
# human_derived rows are NOT date-gated: they arrive one per platform per
|
|
1350
|
+
# day and least-used ordering naturally favors the fresh ones.
|
|
1351
|
+
EXPLORATION_INVENTED_MAX_AGE_DAYS = 14
|
|
1352
|
+
# Hard floor: never trial inventions from before the inline INVENT_RATE was
|
|
1353
|
+
# zeroed (2026-07-10). Anything older is the clone flood, regardless of how
|
|
1354
|
+
# recent the rolling window makes it look.
|
|
1355
|
+
EXPLORATION_INVENTED_EPOCH = datetime(2026, 7, 10, 21, 0,
|
|
1356
|
+
tzinfo=timezone.utc)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def pick_exploration_style(platform, context="posting", exclude=None,
|
|
1360
|
+
rng=None):
|
|
1361
|
+
"""Draft-B exploration picker (2026-07-11). NEVER invents.
|
|
1362
|
+
|
|
1363
|
+
Returns an assignment dict in the exact pick_style_for_post() shape
|
|
1364
|
+
(so s4l_render_style_block and every downstream consumer work
|
|
1365
|
+
unchanged) with an extra "source" key in {"human_derived",
|
|
1366
|
+
"model_invented"}, or None when the pool is empty / anything fails
|
|
1367
|
+
(caller falls back to the scored picker).
|
|
1368
|
+
|
|
1369
|
+
Pool: registry rows with kind='human_derived' (any age) plus
|
|
1370
|
+
kind='model_invented' rows registered in the last
|
|
1371
|
+
EXPLORATION_INVENTED_MAX_AGE_DAYS days. Selection is LEAST-USED first
|
|
1372
|
+
(30-day post count on this platform via compute_target_distribution),
|
|
1373
|
+
uniform among the up-to-5 least-used, so every new style gets trial
|
|
1374
|
+
exposure instead of waiting behind the score-weighted sampler's
|
|
1375
|
+
winner-take-most weights. This is the distribution channel for the
|
|
1376
|
+
standalone invent_styles.py job; the review card's pick plus the
|
|
1377
|
+
posted draft's engagement write the style's first real score, and
|
|
1378
|
+
winners graduate into the Draft-A pool through the normal sampler.
|
|
1379
|
+
"""
|
|
1380
|
+
rnd = rng or random
|
|
1381
|
+
try:
|
|
1382
|
+
never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
|
|
1383
|
+
skip = set(exclude or ()) | never
|
|
1384
|
+
registry = _fetch_registry_styles()
|
|
1385
|
+
cutoff = max(
|
|
1386
|
+
datetime.now(timezone.utc)
|
|
1387
|
+
- timedelta(days=EXPLORATION_INVENTED_MAX_AGE_DAYS),
|
|
1388
|
+
EXPLORATION_INVENTED_EPOCH,
|
|
1389
|
+
)
|
|
1390
|
+
pool = {}
|
|
1391
|
+
for name, entry in registry.items():
|
|
1392
|
+
if name in skip:
|
|
1393
|
+
continue
|
|
1394
|
+
if (entry.get("status") or "active") != "active":
|
|
1395
|
+
continue
|
|
1396
|
+
kind = entry.get("kind")
|
|
1397
|
+
if kind == "human_derived":
|
|
1398
|
+
pool[name] = entry
|
|
1399
|
+
elif kind == "model_invented":
|
|
1400
|
+
invented_at = _parse_iso_utc(entry.get("invented_at"))
|
|
1401
|
+
if invented_at is not None and invented_at >= cutoff:
|
|
1402
|
+
pool[name] = entry
|
|
1403
|
+
if not pool:
|
|
1404
|
+
return None
|
|
1405
|
+
usage = {r["style"]: int(r.get("n") or 0)
|
|
1406
|
+
for r in compute_target_distribution(platform,
|
|
1407
|
+
context=context)}
|
|
1408
|
+
names = list(pool.keys())
|
|
1409
|
+
rnd.shuffle(names) # random tie order before the stable sort
|
|
1410
|
+
names.sort(key=lambda s: usage.get(s, 0))
|
|
1411
|
+
chosen = rnd.choice(names[:5])
|
|
1412
|
+
entry = pool[chosen]
|
|
1413
|
+
return {
|
|
1414
|
+
"mode": "use",
|
|
1415
|
+
"style": chosen,
|
|
1416
|
+
"description": entry.get("description"),
|
|
1417
|
+
"example": entry.get("example"),
|
|
1418
|
+
"note": entry.get("note"),
|
|
1419
|
+
"target_chars": entry.get("target_chars") or DEFAULT_TARGET_CHARS,
|
|
1420
|
+
"source": entry.get("kind"),
|
|
1421
|
+
"usage_n_30d": usage.get(chosen, 0),
|
|
1422
|
+
"reference_styles": [],
|
|
1423
|
+
"distribution_snapshot": [],
|
|
1424
|
+
"picked_at": datetime.now(timezone.utc).isoformat(
|
|
1425
|
+
timespec="seconds"),
|
|
1426
|
+
}
|
|
1427
|
+
except Exception:
|
|
1428
|
+
return None
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _parse_iso_utc(value):
|
|
1432
|
+
"""Parse an ISO timestamp into aware-UTC; None on any failure."""
|
|
1433
|
+
if not value:
|
|
1434
|
+
return None
|
|
1435
|
+
try:
|
|
1436
|
+
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
1437
|
+
if dt.tzinfo is None:
|
|
1438
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
1439
|
+
return dt.astimezone(timezone.utc)
|
|
1440
|
+
except (ValueError, TypeError):
|
|
1441
|
+
return None
|
|
1442
|
+
|
|
1443
|
+
|
|
1344
1444
|
def get_assigned_style_prompt(platform, assignment, context="posting"):
|
|
1345
1445
|
"""Compact prompt block built from a pick_style_for_post() assignment.
|
|
1346
1446
|
|
|
@@ -1382,13 +1482,35 @@ def get_assigned_style_prompt(platform, assignment, context="posting"):
|
|
|
1382
1482
|
# LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
|
|
1383
1483
|
# uses the legacy generic length guidance. The treatment's per-style
|
|
1384
1484
|
# target prompt remains preserved only in the shipped experiment card.
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
)
|
|
1485
|
+
#
|
|
1486
|
+
# EXCEPTION (2026-07-11, Draft-B explore slot): exploration
|
|
1487
|
+
# assignments (pick_exploration_style, marked by `source`) honor the
|
|
1488
|
+
# style's own target_chars so the A/B pair diverges on the length
|
|
1489
|
+
# axis too. The uniform clamp flattened every draft to the same
|
|
1490
|
+
# 2-sentence shape (user: "the older drafts looked all very similar
|
|
1491
|
+
# in terms of the length"). The 06-04 conclusion still governs the
|
|
1492
|
+
# scored path, which never carries `source`.
|
|
1493
|
+
_explore_tc = assignment.get("target_chars")
|
|
1494
|
+
if (assignment.get("source") in ("human_derived", "model_invented")
|
|
1495
|
+
and _explore_tc):
|
|
1496
|
+
lines.append("")
|
|
1497
|
+
lines.append(
|
|
1498
|
+
f"**LENGTH: aim for about {int(_explore_tc)} characters** "
|
|
1499
|
+
"(this style's own winning length; within about 30% either "
|
|
1500
|
+
"way is fine, never above 250). Let the target set the form: "
|
|
1501
|
+
"a very short target means one clipped line, a long one can "
|
|
1502
|
+
"breathe. Do NOT default to the usual two-sentence shape. "
|
|
1503
|
+
"This applies to the comment text only; any link/CTA the "
|
|
1504
|
+
"system appends afterward is separate."
|
|
1505
|
+
)
|
|
1506
|
+
else:
|
|
1507
|
+
lines.append("")
|
|
1508
|
+
lines.append(
|
|
1509
|
+
"**LENGTH: keep it tight.** One or two sentences, well under the "
|
|
1510
|
+
"250-character Twitter limit. A short, sharp reply almost always "
|
|
1511
|
+
"beats a paragraph. This applies to the comment text only; any "
|
|
1512
|
+
"link/CTA the system appends afterward is separate."
|
|
1513
|
+
)
|
|
1392
1514
|
lines.append("")
|
|
1393
1515
|
lines.append(
|
|
1394
1516
|
'In your output JSON, set "engagement_style" to exactly '
|
|
@@ -1729,8 +1729,29 @@ log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)
|
|
|
1729
1729
|
# directive for the whole cycle; this varies STYLE per draft slot), so neither
|
|
1730
1730
|
# experiment disturbs the other.
|
|
1731
1731
|
STYLE_ASSIGN_FILE_B=$(mktemp -t s4l_twitter_assign_b_XXXXXX.json)
|
|
1732
|
-
|
|
1733
|
-
|
|
1732
|
+
# --- Draft-B exploration source (2026-07-11) ---------------------------------
|
|
1733
|
+
# Style B is now the EXPLORE slot: it trials the newest human_derived styles
|
|
1734
|
+
# and post-2026-07-10 inventions (least-used first) instead of drawing a
|
|
1735
|
+
# second scored pick from the same proven pool as Style A. This is the
|
|
1736
|
+
# distribution channel for the standalone invent_styles.py job: the card
|
|
1737
|
+
# pick + the posted draft's engagement write a new style's first real score,
|
|
1738
|
+
# and winners graduate into the Draft-A pool via the normal sampler. NOTHING
|
|
1739
|
+
# is invented here (pick_exploration_style never returns mode=invent). On an
|
|
1740
|
+
# empty pool or API failure we fall back to the legacy second scored pick so
|
|
1741
|
+
# dual-draft cards never break. The source tag rides the S4L_EXP_ convention:
|
|
1742
|
+
# active_experiments.collect() auto-stamps it onto every plan candidate and
|
|
1743
|
+
# the review card's details-eye renders it with zero card-side code.
|
|
1744
|
+
DRAFT_B_SOURCE=$(python3 -c "
|
|
1745
|
+
import json, sys
|
|
1746
|
+
sys.path.insert(0, '$REPO_DIR/scripts')
|
|
1747
|
+
from engagement_styles import pick_exploration_style
|
|
1748
|
+
a = pick_exploration_style('twitter', context='posting', exclude={'$PICKED_STYLE'})
|
|
1749
|
+
if a and a.get('style'):
|
|
1750
|
+
with open('$STYLE_ASSIGN_FILE_B', 'w') as f:
|
|
1751
|
+
json.dump(a, f)
|
|
1752
|
+
print(a.get('source') or '')
|
|
1753
|
+
" 2>/dev/null || echo "")
|
|
1754
|
+
if [ -n "$DRAFT_B_SOURCE" ]; then
|
|
1734
1755
|
PICKED_STYLE_B=$(python3 -c "
|
|
1735
1756
|
import json
|
|
1736
1757
|
try:
|
|
@@ -1740,7 +1761,22 @@ try:
|
|
|
1740
1761
|
except Exception:
|
|
1741
1762
|
print('')
|
|
1742
1763
|
" 2>/dev/null)
|
|
1743
|
-
PICKED_MODE_B
|
|
1764
|
+
PICKED_MODE_B="use"
|
|
1765
|
+
fi
|
|
1766
|
+
if [ -z "${PICKED_STYLE_B:-}" ]; then
|
|
1767
|
+
DRAFT_B_SOURCE="scored_fallback"
|
|
1768
|
+
for _style_b_attempt in 1 2 3; do
|
|
1769
|
+
s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
|
|
1770
|
+
PICKED_STYLE_B=$(python3 -c "
|
|
1771
|
+
import json
|
|
1772
|
+
try:
|
|
1773
|
+
with open('$STYLE_ASSIGN_FILE_B') as f:
|
|
1774
|
+
d = json.load(f)
|
|
1775
|
+
print(d.get('style') or '')
|
|
1776
|
+
except Exception:
|
|
1777
|
+
print('')
|
|
1778
|
+
" 2>/dev/null)
|
|
1779
|
+
PICKED_MODE_B=$(python3 -c "
|
|
1744
1780
|
import json
|
|
1745
1781
|
try:
|
|
1746
1782
|
with open('$STYLE_ASSIGN_FILE_B') as f:
|
|
@@ -1749,11 +1785,13 @@ try:
|
|
|
1749
1785
|
except Exception:
|
|
1750
1786
|
print('use')
|
|
1751
1787
|
" 2>/dev/null)
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
done
|
|
1756
|
-
|
|
1788
|
+
if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
|
|
1789
|
+
break
|
|
1790
|
+
fi
|
|
1791
|
+
done
|
|
1792
|
+
fi
|
|
1793
|
+
export S4L_EXP_DRAFT_B_SOURCE="$DRAFT_B_SOURCE"
|
|
1794
|
+
log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)} source=$DRAFT_B_SOURCE"
|
|
1757
1795
|
|
|
1758
1796
|
# --- Draft-prompt A/B: decouple product pivot (2026-06-29) -------------------
|
|
1759
1797
|
# Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
|