@m13v/s4l 1.7.7-rc.1 → 1.7.7-rc.2

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.7-rc.1",
3
- "installedAt": "2026-07-30T22:08:01.831Z"
2
+ "version": "1.7.7-rc.2",
3
+ "installedAt": "2026-07-30T22:16:14.950Z"
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.7-rc.1",
5
+ "version": "1.7.7-rc.2",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.7-rc.1",
3
+ "version": "1.7.7-rc.2",
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.7-rc.1",
3
+ "version": "1.7.7-rc.2",
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",
@@ -78,6 +78,7 @@ existing "db_unavailable -> script already fails closed" rule in run-linkedin.sh
78
78
 
79
79
  import argparse
80
80
  import json
81
+ import math
81
82
  import os
82
83
  import random
83
84
  import statistics
@@ -103,6 +104,26 @@ def _envi(name, default):
103
104
  return default
104
105
 
105
106
 
107
+ # --- interval GENERATION (not just a ceiling) ------------------------------
108
+ # A pure floor+ceiling cap produces a clipped, bimodal shape: run flat out at
109
+ # the floor until the hourly ceiling bites, then stall. Measured on the first
110
+ # version of this file: 59% of all gaps sat exactly at the 120s floor and NOTHING
111
+ # landed in the 3-10 minute band. That is a sharper machine signature than the
112
+ # unpaced behaviour it replaced, so the floor alone is not pacing.
113
+ #
114
+ # Instead we model arrivals as a POISSON PROCESS: each gap is drawn from an
115
+ # exponential distribution whose mean is set so the daily budget spreads across
116
+ # the active window. Exponential arrivals are memoryless, naturally varied
117
+ # (CV ~= 1.0 by construction), and produce no pile-up at any particular value.
118
+ # MIN_GAP_S survives only as a clamp for the short tail.
119
+ #
120
+ # The draw is DETERMINISTIC in the timestamp of the previous action. This is
121
+ # essential: the gate gets polled repeatedly while waiting, and a fresh random
122
+ # draw per call would let the caller "reroll until lucky", collapsing the whole
123
+ # distribution back onto the floor.
124
+ ACTIVE_START_H = _envi("LI_PACE_ACTIVE_START_H", 8) # local hour, inclusive
125
+ ACTIVE_END_H = _envi("LI_PACE_ACTIVE_END_H", 22) # local hour, exclusive
126
+ MAX_GAP_S = _envi("LI_PACE_MAX_GAP_S", 4 * 3600)
106
127
  MIN_GAP_S = _envi("LI_PACE_MIN_GAP_S", 120)
107
128
  CV_FLOOR = _envf("LI_PACE_CV_FLOOR", 0.25)
108
129
  CV_WINDOW = _envi("LI_PACE_CV_WINDOW", 10)
@@ -172,6 +193,50 @@ def _recent_timestamps(hours=72):
172
193
  conn.close()
173
194
 
174
195
 
196
+ def _active_seconds_per_day():
197
+ span = (ACTIVE_END_H - ACTIVE_START_H) % 24
198
+ return (span or 24) * 3600
199
+
200
+
201
+ def _in_active_window(dt_local):
202
+ h = dt_local.hour
203
+ if ACTIVE_START_H == ACTIVE_END_H:
204
+ return True
205
+ if ACTIVE_START_H < ACTIVE_END_H:
206
+ return ACTIVE_START_H <= h < ACTIVE_END_H
207
+ return h >= ACTIVE_START_H or h < ACTIVE_END_H # window wraps midnight
208
+
209
+
210
+ def _seconds_to_window_open(dt_local):
211
+ """Seconds until the active window next opens. 0 if already open."""
212
+ if _in_active_window(dt_local):
213
+ return 0
214
+ nxt = dt_local.replace(hour=ACTIVE_START_H, minute=0, second=0, microsecond=0)
215
+ if nxt <= dt_local:
216
+ nxt += timedelta(days=1)
217
+ return (nxt - dt_local).total_seconds()
218
+
219
+
220
+ def _target_gap(last_ts):
221
+ """Exponentially-distributed target gap, deterministic in `last_ts`.
222
+
223
+ mean = active_seconds_per_day / MAX_PER_24H, so a full day's budget spreads
224
+ naturally across the active window instead of bunching at a floor.
225
+
226
+ Determinism matters: see the CONFIG note. Same last_ts always yields the
227
+ same target, so polling cannot reroll its way down to MIN_GAP_S.
228
+ """
229
+ import hashlib
230
+
231
+ mean = _active_seconds_per_day() / max(MAX_PER_24H, 1)
232
+ digest = hashlib.sha256(last_ts.isoformat().encode("utf-8")).digest()
233
+ # 53 bits -> uniform in [0,1); nudged off the endpoints for the log below.
234
+ u = int.from_bytes(digest[:7], "big") / float(1 << 56)
235
+ u = min(max(u, 1e-9), 1 - 1e-9)
236
+ gap = -mean * math.log(1.0 - u) # inverse-CDF of Exponential(1/mean)
237
+ return max(MIN_GAP_S, min(gap, MAX_GAP_S))
238
+
239
+
175
240
  def _gaps(ts):
176
241
  return [
177
242
  (b - a).total_seconds()
@@ -236,14 +301,31 @@ def evaluate(now=None, timestamps=None):
236
301
  f"1h cap reached ({len(in_1h)}/{MAX_PER_1H})",
237
302
  max(wait, 60))
238
303
 
239
- # ---- minimum gap ------------------------------------------------------
304
+ # ---- active-hours window ----------------------------------------------
305
+ # Humans do not comment uniformly around the clock. Observed spans were
306
+ # 20-24h/day, which is itself a tell independent of volume.
307
+ now_local = now.astimezone()
308
+ to_open = _seconds_to_window_open(now_local)
309
+ if to_open > 0:
310
+ return decision("wait", RC_WAIT,
311
+ f"outside active window "
312
+ f"{ACTIVE_START_H:02d}:00-{ACTIVE_END_H:02d}:00 local",
313
+ to_open)
314
+
315
+ # ---- sampled inter-action interval (the actual pacing) ----------------
316
+ # Not a flat floor: a per-gap draw from an exponential, so the resulting
317
+ # distribution is continuous instead of piling up at a single value.
240
318
  if ts:
241
- since_last = (now - max(ts)).total_seconds()
242
- if since_last < MIN_GAP_S:
319
+ last = max(ts)
320
+ since_last = (now - last).total_seconds()
321
+ target = _target_gap(last)
322
+ if since_last < target:
243
323
  return decision("wait", RC_WAIT,
244
- f"min gap not met ({int(since_last)}s < {MIN_GAP_S}s)",
245
- MIN_GAP_S - since_last,
246
- seconds_since_last=int(since_last))
324
+ f"sampled interval not elapsed "
325
+ f"({int(since_last)}s < {int(target)}s target)",
326
+ target - since_last,
327
+ seconds_since_last=int(since_last),
328
+ target_gap_s=int(target))
247
329
 
248
330
  # ---- cadence regularity -----------------------------------------------
249
331
  # CRITICAL: both this rule and the spread rule below must be evaluated
@@ -99,7 +99,22 @@ def _reddit_page(pw):
99
99
  try:
100
100
  browser = pw.chromium.connect_over_cdp(ws)
101
101
  ctx = browser.contexts[0] if browser.contexts else browser.new_context()
102
- page = ctx.new_page()
102
+ # Reuse an existing reddit tab instead of new_page(). new_page() opens
103
+ # a fresh tab on the reddit homepage and STEALS OS FOCUS every call —
104
+ # this ran ~per-strike and was the reddit focus-pop the other reddit
105
+ # helpers (reddit_browser / reddit_browser_fetch) already fixed but
106
+ # this 2026-07-17 ban-check never did. Navigating a background tab does
107
+ # not pop. Prefer a tab already on reddit.com; else pages[0]; else
108
+ # create one. Left OPEN for reuse (cleanup_harness_tabs trims to one).
109
+ page = None
110
+ for pg in ctx.pages:
111
+ if "reddit.com" in (pg.url or "") and "login" not in (pg.url or ""):
112
+ page = pg
113
+ break
114
+ if page is None and ctx.pages:
115
+ page = ctx.pages[0]
116
+ if page is None:
117
+ page = ctx.new_page()
103
118
  page.set_default_timeout(20000)
104
119
  return browser, page
105
120
  except Exception:
@@ -292,10 +292,22 @@ PENDING_COUNT=$(li_reply_count pending)
292
292
  # rc=75 ("wait") still enters the phase; the per-reply gate sleeps the short
293
293
  # remainder itself.
294
294
  _LI_PACE_RC=0
295
- _LI_PACE_OUT="$("$PY_BIN" "$REPO_DIR/scripts/linkedin_pacing.py" check 2>&1)" || _LI_PACE_RC=$?
296
- log "PACING: $_LI_PACE_OUT"
297
- if [ "$_LI_PACE_RC" -eq 78 ] && [ "$PENDING_COUNT" -ne 0 ]; then
298
- log "PACING: ceiling reached; skipping Phase B this fire ($PENDING_COUNT rows stay pending)"
295
+ _LI_PACE_JSON="$("$PY_BIN" "$REPO_DIR/scripts/linkedin_pacing.py" check --json 2>&1)" || _LI_PACE_RC=$?
296
+ _LI_PACE_WAIT=$(printf '%s' "$_LI_PACE_JSON" | "$PY_BIN" -c \
297
+ 'import json,sys
298
+ try: print(int(json.load(sys.stdin).get("wait_seconds", 0)))
299
+ except Exception: print(0)' 2>/dev/null || echo 0)
300
+ log "PACING: $_LI_PACE_JSON"
301
+
302
+ # Skip Phase B when a ceiling is blown (rc=78) OR when the next slot is far off
303
+ # (rc=75 with a long wait). The long-wait case is mostly the overnight
304
+ # active-hours window: without this we would spin up a full Claude session that
305
+ # is forbidden from posting anything for the next several hours. Short waits
306
+ # (< 15 min) still enter the phase, since drafting takes time anyway and the
307
+ # per-reply Step 3b gate can sleep the small remainder.
308
+ if [ "$PENDING_COUNT" -ne 0 ] && { [ "$_LI_PACE_RC" -eq 78 ] || \
309
+ { [ "$_LI_PACE_RC" -eq 75 ] && [ "${_LI_PACE_WAIT:-0}" -gt 900 ]; }; }; then
310
+ log "PACING: not postable now (rc=$_LI_PACE_RC, next slot in ${_LI_PACE_WAIT}s); skipping Phase B ($PENDING_COUNT rows stay pending)"
299
311
  PENDING_COUNT=0
300
312
  LI_PACED_OUT=1
301
313
  fi
@@ -106,6 +106,30 @@ export SA_CYCLE_ID="$BATCH_ID"
106
106
 
107
107
  echo "=== LinkedIn Post Run: $(date) (batch=$BATCH_ID) ===" | tee "$LOG_FILE"
108
108
 
109
+ # ---- PACING GATE (2026-07-30) --------------------------------------------
110
+ # This pipeline is the LARGER of the two LinkedIn write paths: it logged 67-74
111
+ # actions/day via log_post.py in the week before the 2026-07-20 session kill,
112
+ # against ~23/day from engage-linkedin.sh. Gating only that other script would
113
+ # have left ~75% of our LinkedIn footprint unpaced.
114
+ #
115
+ # Placed BEFORE Phase A on purpose. Phase B posts exactly ONE comment per fire,
116
+ # so a single per-run check is sufficient, and bailing here also skips Phase A
117
+ # discovery (~$10-15 of Claude spend) rather than paying for candidates we are
118
+ # not allowed to act on.
119
+ #
120
+ # Any non-allow verdict skips the fire outright: this job is on a 15-minute
121
+ # launchd cadence, so the next fire re-checks shortly. We deliberately do not
122
+ # sleep inside the run and hold the browser lock while waiting.
123
+ if [ "$LINKEDIN_BACKEND" = "browser" ]; then
124
+ _LI_PACE_RC=0
125
+ _LI_PACE_OUT="$(python3 "$REPO_DIR/scripts/linkedin_pacing.py" check 2>&1)" || _LI_PACE_RC=$?
126
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PACING: $_LI_PACE_OUT" | tee -a "$LOG_FILE"
127
+ if [ "$_LI_PACE_RC" -ne 0 ]; then
128
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PACING: skipping this fire (rc=$_LI_PACE_RC); next fire re-checks in ~15min" | tee -a "$LOG_FILE"
129
+ exit 0
130
+ fi
131
+ fi
132
+
109
133
  # 2026-05-01: lock policy was changed from "hold for the entire run" to
110
134
  # "hold only while a Claude phase is actively driving the browser". The old
111
135
  # policy meant a single 25-45min cycle held linkedin-browser exclusively for