@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.10",
3
- "installedAt": "2026-07-11T01:28:53.237Z"
2
+ "version": "1.7.4-rc.12",
3
+ "installedAt": "2026-07-11T02:53:19.464Z"
4
4
  }
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.10",
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": {
@@ -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
- if style:
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.10",
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.10",
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
- lines.append("")
1386
- lines.append(
1387
- "**LENGTH: keep it tight.** One or two sentences, well under the "
1388
- "250-character Twitter limit. A short, sharp reply almost always "
1389
- "beats a paragraph. This applies to the comment text only; any "
1390
- "link/CTA the system appends afterward is separate."
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
- for _style_b_attempt in 1 2 3; do
1733
- s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
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=$(python3 -c "
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
- if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
1753
- break
1754
- fi
1755
- done
1756
- log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)}"
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