@m13v/s4l 1.7.4-rc.14 → 1.7.4-rc.16
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_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +12 -0
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/run-twitter-cycle.sh +21 -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.4-rc.
|
|
5
|
+
"version": "1.7.4-rc.16",
|
|
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.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.16",
|
|
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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinkedIn posting cadence: 4 active days, then a mandatory 2-day break.
|
|
3
|
+
|
|
4
|
+
Per user instruction (2026-07-11): LinkedIn activity should not run more than
|
|
5
|
+
four days at a time before taking a two-day break. "Active day" is counted
|
|
6
|
+
only when real posting/engagement activity actually happened that day (an
|
|
7
|
+
outage day does not count toward the four), and the break pauses ALL LinkedIn
|
|
8
|
+
traffic, including the passive presence-check job, not just posting.
|
|
9
|
+
|
|
10
|
+
Every LinkedIn entrypoint (skill/run-linkedin.sh, engage-linkedin.sh,
|
|
11
|
+
engage-dm-replies.sh, dm-outreach-linkedin.sh, audit-linkedin.sh,
|
|
12
|
+
linkedin-presence.sh) already gates on the existence of ONE file:
|
|
13
|
+
~/.claude/social-autoposter/linkedin.killswitch
|
|
14
|
+
That file is scripts/linkedin_killswitch.py's antibot killswitch. This module
|
|
15
|
+
reuses the SAME file for a scheduled break (signal="scheduled_break") so every
|
|
16
|
+
entrypoint pauses automatically, with zero edits to the locked entrypoint
|
|
17
|
+
scripts. It never overwrites a REAL antibot signal, and linkedin_killswitch.py
|
|
18
|
+
is patched (recover-check) to never try to auto-recover a scheduled_break.
|
|
19
|
+
|
|
20
|
+
State lives at ~/.claude/social-autoposter/linkedin_cadence.json:
|
|
21
|
+
{
|
|
22
|
+
"phase": "active" | "break",
|
|
23
|
+
"phase_started": "2026-07-11T20:00:00Z",
|
|
24
|
+
"active_days": ["2026-07-13", "2026-07-14", ...] # UTC dates with
|
|
25
|
+
# confirmed posts,
|
|
26
|
+
# only meaningful
|
|
27
|
+
# while phase=active
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
CLI:
|
|
31
|
+
python3 scripts/linkedin_cadence.py enforce # one tick; called every 15m
|
|
32
|
+
python3 scripts/linkedin_cadence.py status # print state (json)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
from datetime import datetime, timedelta, timezone
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
41
|
+
|
|
42
|
+
import http_api # noqa: E402
|
|
43
|
+
import linkedin_killswitch as ks # noqa: E402
|
|
44
|
+
|
|
45
|
+
STATE_DIR = os.path.expanduser(
|
|
46
|
+
os.environ.get("LINKEDIN_KILLSWITCH_DIR", "~/.claude/social-autoposter")
|
|
47
|
+
)
|
|
48
|
+
STATE_FILE = os.path.expanduser(
|
|
49
|
+
os.environ.get("LINKEDIN_CADENCE_FILE", os.path.join(STATE_DIR, "linkedin_cadence.json"))
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
ACTIVE_DAYS_TARGET = int(os.environ.get("LINKEDIN_CADENCE_ACTIVE_DAYS", "4"))
|
|
53
|
+
BREAK_DAYS = int(os.environ.get("LINKEDIN_CADENCE_BREAK_DAYS", "2"))
|
|
54
|
+
|
|
55
|
+
SCHEDULED_BREAK_SIGNAL = "scheduled_break"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _now():
|
|
59
|
+
return datetime.now(timezone.utc)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _now_iso():
|
|
63
|
+
return _now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _today_str():
|
|
67
|
+
return _now().date().isoformat()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_ts(ts):
|
|
71
|
+
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ensure_dir():
|
|
75
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_state():
|
|
79
|
+
try:
|
|
80
|
+
with open(STATE_FILE, "r") as f:
|
|
81
|
+
return json.load(f)
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_state(state):
|
|
87
|
+
_ensure_dir()
|
|
88
|
+
tmp = STATE_FILE + ".tmp"
|
|
89
|
+
with open(tmp, "w") as f:
|
|
90
|
+
json.dump(state, f, indent=2)
|
|
91
|
+
f.write("\n")
|
|
92
|
+
os.replace(tmp, STATE_FILE)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _default_state_starting_break():
|
|
96
|
+
# First-ever run: user asked to start immediately with a two-day break.
|
|
97
|
+
return {"phase": "break", "phase_started": _now_iso(), "active_days": []}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _today_post_count():
|
|
101
|
+
"""LinkedIn posts made today (UTC), via the same API the dashboard uses.
|
|
102
|
+
|
|
103
|
+
Best-effort: returns None on any failure so callers can skip counting
|
|
104
|
+
this tick rather than wrongly recording 0 activity."""
|
|
105
|
+
try:
|
|
106
|
+
resp = http_api.api_get(
|
|
107
|
+
"/api/v1/dashboard/posts-per-day", {"days": 1, "platform": "linkedin"}
|
|
108
|
+
)
|
|
109
|
+
rows = (resp or {}).get("data", {}).get("rows", [])
|
|
110
|
+
today = _today_str()
|
|
111
|
+
for row in rows:
|
|
112
|
+
if row.get("day") == today:
|
|
113
|
+
return int(row.get("posts_made") or 0)
|
|
114
|
+
return 0
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
print(f"[linkedin_cadence] WARN: posts-per-day query failed: {exc}", file=sys.stderr)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _pause_marker_set():
|
|
121
|
+
"""Set the shared killswitch file for a scheduled break, unless a REAL
|
|
122
|
+
antibot signal is already in charge (never stomp a genuine block)."""
|
|
123
|
+
payload = ks.read()
|
|
124
|
+
if payload is None:
|
|
125
|
+
ks.engage(
|
|
126
|
+
signal=SCHEDULED_BREAK_SIGNAL,
|
|
127
|
+
detail="cadence: scheduled 2-day pause after 4 active days",
|
|
128
|
+
send_email=False,
|
|
129
|
+
)
|
|
130
|
+
print("[linkedin_cadence] pause marker set (scheduled_break)", file=sys.stderr)
|
|
131
|
+
elif payload.get("signal") == SCHEDULED_BREAK_SIGNAL:
|
|
132
|
+
pass # already set by us; idempotent, no trail spam
|
|
133
|
+
else:
|
|
134
|
+
print(
|
|
135
|
+
f"[linkedin_cadence] real killswitch already active (signal="
|
|
136
|
+
f"{payload.get('signal')!r}); deferring to it, not overwriting",
|
|
137
|
+
file=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pause_marker_clear_if_ours():
|
|
142
|
+
payload = ks.read()
|
|
143
|
+
if payload is not None and payload.get("signal") == SCHEDULED_BREAK_SIGNAL:
|
|
144
|
+
ks.clear()
|
|
145
|
+
print("[linkedin_cadence] pause marker cleared (break ended)", file=sys.stderr)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def enforce():
|
|
149
|
+
state = load_state() or _default_state_starting_break()
|
|
150
|
+
if load_state() is None:
|
|
151
|
+
save_state(state)
|
|
152
|
+
print(
|
|
153
|
+
f"[linkedin_cadence] no prior state; starting BREAK phase now "
|
|
154
|
+
f"({BREAK_DAYS}d)",
|
|
155
|
+
file=sys.stderr,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
now = _now()
|
|
159
|
+
phase = state["phase"]
|
|
160
|
+
|
|
161
|
+
if phase == "break":
|
|
162
|
+
started = _parse_ts(state["phase_started"])
|
|
163
|
+
elapsed = now - started
|
|
164
|
+
if elapsed >= timedelta(days=BREAK_DAYS):
|
|
165
|
+
state = {"phase": "active", "phase_started": _now_iso(), "active_days": []}
|
|
166
|
+
save_state(state)
|
|
167
|
+
_pause_marker_clear_if_ours()
|
|
168
|
+
print(
|
|
169
|
+
f"[linkedin_cadence] break ended after {elapsed}; switching to ACTIVE",
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
)
|
|
172
|
+
phase = "active"
|
|
173
|
+
else:
|
|
174
|
+
remaining = timedelta(days=BREAK_DAYS) - elapsed
|
|
175
|
+
_pause_marker_set()
|
|
176
|
+
print(
|
|
177
|
+
f"[linkedin_cadence] BREAK phase: {remaining} remaining",
|
|
178
|
+
file=sys.stderr,
|
|
179
|
+
)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# phase == "active"
|
|
183
|
+
real_block = ks.read()
|
|
184
|
+
if real_block is not None and real_block.get("signal") != SCHEDULED_BREAK_SIGNAL:
|
|
185
|
+
print(
|
|
186
|
+
f"[linkedin_cadence] account down for a real reason (signal="
|
|
187
|
+
f"{real_block.get('signal')!r}); not counting today, not pausing",
|
|
188
|
+
file=sys.stderr,
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
count = _today_post_count()
|
|
193
|
+
today = _today_str()
|
|
194
|
+
if count is not None and count > 0 and today not in state["active_days"]:
|
|
195
|
+
state["active_days"].append(today)
|
|
196
|
+
save_state(state)
|
|
197
|
+
print(
|
|
198
|
+
f"[linkedin_cadence] activity confirmed today ({count} posts); "
|
|
199
|
+
f"active_days={len(state['active_days'])}/{ACTIVE_DAYS_TARGET} "
|
|
200
|
+
f"{state['active_days']}",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if len(state["active_days"]) >= ACTIVE_DAYS_TARGET:
|
|
205
|
+
state = {"phase": "break", "phase_started": _now_iso(), "active_days": []}
|
|
206
|
+
save_state(state)
|
|
207
|
+
_pause_marker_set()
|
|
208
|
+
print(
|
|
209
|
+
f"[linkedin_cadence] {ACTIVE_DAYS_TARGET} active days reached; "
|
|
210
|
+
f"switching to BREAK for {BREAK_DAYS}d",
|
|
211
|
+
file=sys.stderr,
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
print(
|
|
215
|
+
f"[linkedin_cadence] ACTIVE phase: "
|
|
216
|
+
f"{len(state['active_days'])}/{ACTIVE_DAYS_TARGET} active days so far",
|
|
217
|
+
file=sys.stderr,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main():
|
|
222
|
+
if len(sys.argv) < 2 or sys.argv[1] not in ("enforce", "status"):
|
|
223
|
+
print("usage: linkedin_cadence.py [enforce|status]", file=sys.stderr)
|
|
224
|
+
sys.exit(2)
|
|
225
|
+
if sys.argv[1] == "status":
|
|
226
|
+
state = load_state()
|
|
227
|
+
print(json.dumps(state if state is not None else {"phase": None}, indent=2))
|
|
228
|
+
sys.exit(0)
|
|
229
|
+
enforce()
|
|
230
|
+
sys.exit(0)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
main()
|
|
@@ -145,6 +145,10 @@ VALID_SIGNALS = {
|
|
|
145
145
|
"session_invalid_marker",
|
|
146
146
|
"captcha_detected",
|
|
147
147
|
"manual",
|
|
148
|
+
# Deliberate pause from scripts/linkedin_cadence.py (4-active-days /
|
|
149
|
+
# 2-day-break schedule). Not an incident: recover-check must never try to
|
|
150
|
+
# auto-recover it, and engage() should never email on it.
|
|
151
|
+
"scheduled_break",
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
|
|
@@ -894,6 +898,14 @@ def _cmd_recover_check(args):
|
|
|
894
898
|
if not is_active():
|
|
895
899
|
print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
|
|
896
900
|
sys.exit(1)
|
|
901
|
+
payload = read() or {}
|
|
902
|
+
if payload.get("signal") == "scheduled_break":
|
|
903
|
+
print(
|
|
904
|
+
"recover-check: scheduled_break active (cadence pause, not a real "
|
|
905
|
+
"incident); deferring to scripts/linkedin_cadence.py, not probing",
|
|
906
|
+
file=sys.stderr,
|
|
907
|
+
)
|
|
908
|
+
sys.exit(1)
|
|
897
909
|
if is_terminal():
|
|
898
910
|
print(
|
|
899
911
|
"recover-check: TERMINAL (auto-recovery gave up); "
|
|
@@ -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
|
|
@@ -2208,6 +2208,27 @@ CRITICAL:
|
|
|
2208
2208
|
# installs) treats --allowedTools as a one-value flag, so a space-separated second
|
|
2209
2209
|
# tool would leak in as the prompt. On the box these flags ride through
|
|
2210
2210
|
# claude_job.py; Desktop's own web search + the reworded prompt enable it there.
|
|
2211
|
+
# --- Prep-prompt snapshot (2026-07-11) ---------------------------------------
|
|
2212
|
+
# Persist the exact rendered PREP_PROMPT per batch so prompt-block presence is
|
|
2213
|
+
# verifiable after any release (grep the file), instead of reverse-engineering
|
|
2214
|
+
# it from package scripts. The queue's prompt-*.md files are transient work
|
|
2215
|
+
# files deleted on completion, and the generation trace deliberately carries
|
|
2216
|
+
# only the exemplar context, so this is the ONLY durable full-prompt record.
|
|
2217
|
+
# Local-only, newest 50 kept (file cleanup, not candidate-row retention; the
|
|
2218
|
+
# no-retention rule covers DB *_candidates rows). Never blocks the run.
|
|
2219
|
+
PREP_PROMPT_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/prep-prompts"
|
|
2220
|
+
if mkdir -p "$PREP_PROMPT_DIR" 2>/dev/null; then
|
|
2221
|
+
_PP_FILE="$PREP_PROMPT_DIR/prep-prompt-$BATCH_ID.md"
|
|
2222
|
+
if printf '%s' "$PREP_PROMPT" > "$_PP_FILE" 2>/dev/null; then
|
|
2223
|
+
ls -t "$PREP_PROMPT_DIR"/prep-prompt-*.md 2>/dev/null | tail -n +51 | while IFS= read -r _pp_old; do
|
|
2224
|
+
rm -f "$_pp_old"
|
|
2225
|
+
done
|
|
2226
|
+
log "[prep_prompt_snapshot] batch=$BATCH_ID bytes=$(wc -c < "$_PP_FILE" | tr -d ' ') path=$_PP_FILE"
|
|
2227
|
+
else
|
|
2228
|
+
log "WARN: prep-prompt snapshot write failed for batch=$BATCH_ID (non-fatal)"
|
|
2229
|
+
fi
|
|
2230
|
+
fi
|
|
2231
|
+
|
|
2211
2232
|
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)
|
|
2212
2233
|
|
|
2213
2234
|
echo "$PREP_OUTPUT" >> "$LOG_FILE"
|