@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.
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/linkedin_pacing.py +88 -6
- package/scripts/reddit_ban_check.py +16 -1
- package/skill/engage-linkedin.sh +16 -4
- package/skill/run-linkedin.sh +24 -0
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.7-rc.
|
|
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.
|
|
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
|
@@ -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
|
-
# ----
|
|
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
|
-
|
|
242
|
-
|
|
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"
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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:
|
package/skill/engage-linkedin.sh
CHANGED
|
@@ -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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
package/skill/run-linkedin.sh
CHANGED
|
@@ -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
|