@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.14",
3
- "installedAt": "2026-07-11T04:13:18.650Z"
2
+ "version": "1.7.4-rc.16",
3
+ "installedAt": "2026-07-11T18:28:11.012Z"
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.14",
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.14",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.4-rc.14",
3
+ "version": "1.7.4-rc.16",
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",
@@ -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"