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

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/repo.js CHANGED
@@ -71,6 +71,18 @@ export function run(cmd, args, opts = {}) {
71
71
  catch {
72
72
  /* a spawn observer must never break the run */
73
73
  }
74
+ if (opts.stdin != null) {
75
+ // Swallow async pipe errors (e.g. EPIPE when the child exits before
76
+ // reading) — an unhandled stream error would crash the whole process.
77
+ child.stdin.on("error", () => { });
78
+ try {
79
+ child.stdin.write(opts.stdin);
80
+ child.stdin.end();
81
+ }
82
+ catch {
83
+ /* a closed stdin must never break the run */
84
+ }
85
+ }
74
86
  let stdout = "";
75
87
  let stderr = "";
76
88
  // Per-stream partial-line buffers so onLine fires on whole lines only,
@@ -195,16 +195,63 @@ async function latestFromGithubRedirect() {
195
195
  return null;
196
196
  }
197
197
  }
198
+ // Optional GitHub token (2026-07-30): authenticated probes get 5000/h instead
199
+ // of the anonymous 60/h-per-IP quota that silenced the staging update banner
200
+ // on 2026-07-13 and again on 2026-07-30. Sources, in order: GITHUB_TOKEN /
201
+ // GH_TOKEN env, then `gh auth token` when the gh CLI exists (dev/operator
202
+ // machines; .mcpb boxes have neither and resolve to null instantly). The token
203
+ // is only ever sent to api.github.com, always via stdin (curl -H @-) so it
204
+ // never appears in argv/`ps`, and the `gh auth token` shell-out is noTee so it
205
+ // never reaches the telemetry relay. Keep in lockstep with
206
+ // scripts/snapshot.py::_github_token.
207
+ let ghTokCache = { at: 0, tok: null };
208
+ const GH_TOK_TTL_MS = 900_000;
209
+ async function githubToken() {
210
+ const now = Date.now();
211
+ if (ghTokCache.at && now - ghTokCache.at < GH_TOK_TTL_MS)
212
+ return ghTokCache.tok;
213
+ let tok = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
214
+ if (!tok) {
215
+ for (const gh of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "gh"]) {
216
+ const res = await run(gh, ["auth", "token"], { timeoutMs: 5000, noTee: true });
217
+ if (res.code === -1)
218
+ continue; // not spawnable at this path; try the next
219
+ const cand = (res.stdout || "").trim();
220
+ if (res.code === 0 && cand)
221
+ tok = cand;
222
+ break;
223
+ }
224
+ }
225
+ ghTokCache = { at: now, tok };
226
+ return tok;
227
+ }
198
228
  // Conditional-request state lives in the SHARED cache file (latest-release.json)
199
229
  // so the ETag survives process boundaries: short-lived MCP respawns used to pay
200
230
  // a full 200 per process; now every probe sends If-None-Match and gets a free
201
- // 304 between releases.
231
+ // 304 between releases. Probes authenticate when a GitHub token is available;
232
+ // a 401 (revoked/expired token) retries anonymously so a bad token is never
233
+ // worse than no token.
202
234
  async function curlConditional(url, etag) {
235
+ const tok = await githubToken();
236
+ const first = await curlOnce(url, etag, tok);
237
+ if (first.status === 401 && tok) {
238
+ ghTokCache = { at: Date.now(), tok: null }; // drop the dead token
239
+ return curlOnce(url, etag, null);
240
+ }
241
+ return first;
242
+ }
243
+ async function curlOnce(url, etag, token) {
203
244
  const args = ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json"];
245
+ if (token)
246
+ args.push("-H", "@-"); // Authorization arrives via stdin, never argv
204
247
  if (etag)
205
248
  args.push("-H", `If-None-Match: ${etag}`);
206
249
  args.push("-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url);
207
- const res = await run("curl", args, { timeoutMs: 12000, noTee: true });
250
+ const res = await run("curl", args, {
251
+ timeoutMs: 12000,
252
+ noTee: true,
253
+ stdin: token ? `Authorization: Bearer ${token}` : undefined,
254
+ });
208
255
  let status = 0;
209
256
  let newEtag = null;
210
257
  const body = [];
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.7-rc.2",
3
- "installedAt": "2026-07-30T22:16:14.950Z"
2
+ "version": "1.7.7-rc.4",
3
+ "installedAt": "2026-07-30T23:35:31.407Z"
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.2",
5
+ "version": "1.7.7-rc.4",
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.2",
3
+ "version": "1.7.7-rc.4",
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.2",
3
+ "version": "1.7.7-rc.4",
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",
@@ -73,11 +73,36 @@ channel = ch if ch in ("stable", "staging") else "stable"
73
73
  REPO = "m13v/s4l"
74
74
  TAG_DL = "https://github.com/%s/releases/download/%s/social-autoposter.mcpb"
75
75
 
76
- def curl(url):
76
+ # Optional GitHub token (2026-07-30): authenticated requests get 5000/h vs the
77
+ # anonymous 60/h-per-IP quota (which, exhausted, makes this resolver fail
78
+ # closed). Sources: GITHUB_TOKEN / GH_TOKEN env, then `gh auth token` when the
79
+ # gh CLI exists (boxes have neither; resolves to None instantly). Sent via
80
+ # stdin (-H @-), never argv. Keep in lockstep with snapshot.py::_github_token.
81
+ def gh_token():
82
+ tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
83
+ if not tok:
84
+ for gh in ("/opt/homebrew/bin/gh", "/usr/local/bin/gh", "gh"):
85
+ try:
86
+ r = subprocess.run([gh, "auth", "token"],
87
+ capture_output=True, text=True, timeout=5)
88
+ cand = (r.stdout or "").strip()
89
+ if r.returncode == 0 and cand:
90
+ tok = cand
91
+ break
92
+ except FileNotFoundError:
93
+ continue
94
+ except Exception:
95
+ break
96
+ return tok
97
+
98
+ def curl(url, token=None):
77
99
  try:
78
- r = subprocess.run(["/usr/bin/curl", "-fsSL", "-m", "15",
79
- "-H", "Accept: application/vnd.github+json", url],
80
- capture_output=True, text=True, timeout=20)
100
+ args = ["/usr/bin/curl", "-fsSL", "-m", "15",
101
+ "-H", "Accept: application/vnd.github+json"]
102
+ if token:
103
+ args += ["-H", "@-"]
104
+ r = subprocess.run(args + [url], capture_output=True, text=True, timeout=20,
105
+ input=("Authorization: Bearer %s" % token) if token else None)
81
106
  return r.stdout if r.returncode == 0 else ""
82
107
  except Exception:
83
108
  return ""
@@ -93,8 +118,13 @@ def ver_key(v):
93
118
  m = re.findall(r"\d+", pre)
94
119
  return (nums[0], nums[1], nums[2], 0, int(m[-1]) if m else 0)
95
120
 
121
+ _tok = gh_token()
122
+ _url = "https://api.github.com/repos/%s/releases?per_page=30" % REPO
123
+ _raw = curl(_url, _tok)
124
+ if not _raw and _tok:
125
+ _raw = curl(_url) # bad token must never be worse than anonymous
96
126
  try:
97
- rels = json.loads(curl("https://api.github.com/repos/%s/releases?per_page=30" % REPO) or "[]")
127
+ rels = json.loads(_raw or "[]")
98
128
  except Exception:
99
129
  rels = []
100
130
  best = None
@@ -482,18 +482,69 @@ def _latest_from_github_redirect():
482
482
  return None
483
483
 
484
484
 
485
+ # ---- optional GitHub token (2026-07-30) -----------------------------------
486
+ # Authenticated probes get a 5000/h quota instead of the anonymous 60/h-per-IP
487
+ # quota that silenced the staging update banner on 2026-07-13 and again on
488
+ # 2026-07-30 (rate-limited staging probe degraded to the prerelease-blind
489
+ # releases/latest redirect, so a staging box resolved stable and never saw the
490
+ # rc). Sources, in order: GITHUB_TOKEN / GH_TOKEN env, then `gh auth token`
491
+ # when the gh CLI exists (dev/operator machines; .mcpb boxes have neither and
492
+ # resolve to None instantly). The token is only ever sent to api.github.com,
493
+ # always via stdin (-H @-) so it never appears in `ps` or logs. Keep in
494
+ # lockstep with mcp/src/version.ts::githubToken.
495
+ _gh_tok_cache = {"at": 0.0, "tok": None}
496
+ _GH_TOK_TTL = 900.0
497
+
498
+
499
+ def _github_token():
500
+ now = time.time()
501
+ if _gh_tok_cache["at"] and now - _gh_tok_cache["at"] < _GH_TOK_TTL:
502
+ return _gh_tok_cache["tok"]
503
+ tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
504
+ if not tok:
505
+ for gh in ("/opt/homebrew/bin/gh", "/usr/local/bin/gh", "gh"):
506
+ try:
507
+ res = subprocess.run([gh, "auth", "token"],
508
+ capture_output=True, text=True, timeout=5)
509
+ cand = (res.stdout or "").strip()
510
+ if res.returncode == 0 and cand:
511
+ tok = cand
512
+ break
513
+ except FileNotFoundError:
514
+ continue
515
+ except Exception:
516
+ break
517
+ _gh_tok_cache.update(at=now, tok=tok)
518
+ return tok
519
+
520
+
485
521
  # Conditional-request state lives in the SHARED cache file (latest-release.json)
486
522
  # so the ETag survives process boundaries: short-lived shell-outs used to pay a
487
523
  # full 200 per process; now every probe sends If-None-Match and gets a free 304
488
524
  # between releases.
489
525
  def _curl_conditional(url, etag):
490
- """GET url with optional If-None-Match. Returns (status, new_etag, body)."""
526
+ """GET url with optional If-None-Match, authenticated when a GitHub token
527
+ is available. Returns (status, new_etag, body). On 401 with a token
528
+ (revoked/expired) retries anonymously so a bad token is never worse than
529
+ no token."""
530
+ tok = _github_token()
531
+ status, new_etag, body = _curl_once(url, etag, tok)
532
+ if status == 401 and tok:
533
+ _gh_tok_cache.update(tok=None) # drop the dead token for this process
534
+ status, new_etag, body = _curl_once(url, etag, None)
535
+ return status, new_etag, body
536
+
537
+
538
+ def _curl_once(url, etag, token):
491
539
  args = ["/usr/bin/curl", "-sS", "-m", "10",
492
540
  "-H", "Accept: application/vnd.github+json"]
541
+ if token:
542
+ args += ["-H", "@-"] # Authorization arrives via stdin, never argv
493
543
  if etag:
494
544
  args += ["-H", "If-None-Match: %s" % etag]
495
545
  args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url]
496
- res = subprocess.run(args, capture_output=True, text=True, timeout=12)
546
+ res = subprocess.run(args, capture_output=True, text=True, timeout=12,
547
+ input=("Authorization: Bearer %s" % token) if token else None)
497
548
  status, new_etag, body = 0, None, []
498
549
  for line in (res.stdout or "").splitlines():
499
550
  if line.startswith("__CURL_STATUS__:"):
@@ -278,46 +278,8 @@ RESET_COUNT=$(li_reset_processing 2)
278
278
 
279
279
  PENDING_COUNT=$(li_reply_count pending)
280
280
 
281
- # ---- PACING PREFLIGHT (the enforceable half of the pacing gate) -----------
282
- # The per-reply Step 3b gate inside the Phase B prompt is the fine-grained
283
- # control, but it depends on the model obeying an instruction. This preflight
284
- # is the hard stop: when a 24h/72h ceiling is already blown there is nothing
285
- # Phase B could legally post, so we skip the phase before paying for browser
286
- # bootstrap, the pending pull and a Claude run.
287
- #
288
- # rc=78 ("stop") zeroes PENDING_COUNT rather than exiting, deliberately: an
289
- # early exit here would skip the run summary and the dashboard metric line at
290
- # the bottom of this script, and a pipeline that goes quiet WITHOUT reporting
291
- # is precisely how the 2026-07-20 outage stayed invisible for nine days.
292
- # rc=75 ("wait") still enters the phase; the per-reply gate sleeps the short
293
- # remainder itself.
294
- _LI_PACE_RC=0
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)"
311
- PENDING_COUNT=0
312
- LI_PACED_OUT=1
313
- fi
314
-
315
281
  if [ "$PENDING_COUNT" -eq 0 ]; then
316
- if [ "${LI_PACED_OUT:-0}" -eq 1 ]; then
317
- log "Phase B: held back by the pacing gate, not by an empty queue."
318
- else
319
- log "Phase B: No pending LinkedIn replies. Done!"
320
- fi
282
+ log "Phase B: No pending LinkedIn replies. Done!"
321
283
  else
322
284
  log "Phase B: $PENDING_COUNT pending LinkedIn replies to process"
323
285
 
@@ -505,24 +467,6 @@ MANDATORY reply flow for every item:
505
467
  mode ($PICKED_MODE=invent) craft a NEW snake_case style name not in the
506
468
  curated block above and pass it as the [engagement_style] arg in Step 5.
507
469
  Professional but casual. NEVER em dashes. Match parent post language.
508
- Step 3b: PACING GATE - MANDATORY, run this IMMEDIATELY BEFORE every single
509
- post in Step 4. Never post two replies without a fresh check in
510
- between; never batch several posts behind one check.
511
- python3 $REPO_DIR/scripts/linkedin_pacing.py check --json
512
- Read the JSON "action" field and obey it exactly:
513
- "allow" (exit 0) -> proceed to Step 4 for THIS reply only.
514
- "wait" (exit 75) -> sleep the returned "wait_seconds", then
515
- re-run the check. If it still says wait after
516
- 3 attempts, LEAVE the row pending (do NOT mark
517
- skipped) and move to the next reply; the next
518
- cycle will pick it up.
519
- "stop" (exit 78) -> a 24h/72h ceiling is blown. STOP POSTING
520
- ENTIRELY for this run. Leave all remaining
521
- rows pending and finish the run cleanly.
522
- Do NOT try to out-think, average, or "catch up on" this gate; it is
523
- the control that keeps our comment cadence from looking like a
524
- metronome. On 2026-07-20 an unpaced run put 23 comments out in 22
525
- minutes at 60s +/- 29s and the session was killed that evening.
526
470
  Step 4: post reply (OAuth API first, browser fallback)
527
471
  Step 5: python3 reply_db.py replied ID "text" [url] [engagement_style] [is_recommendation] <- mark AFTER success. engagement_style is the style name you applied (in USE mode the assigned '${PICKED_STYLE}'). is_recommendation="1" only when you mentioned a project (Tier 2/3).
528
472
  If Step 5 fails, the item stays 'processing' and will be reset to 'pending' on the next run.
@@ -106,30 +106,6 @@ 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
-
133
109
  # 2026-05-01: lock policy was changed from "hold for the entire run" to
134
110
  # "hold only while a Claude phase is actively driving the browser". The old
135
111
  # policy meant a single 25-45min cycle held linkedin-browser exclusively for
@@ -1,400 +0,0 @@
1
- #!/usr/bin/env python3
2
- """LinkedIn action pacing gate.
3
-
4
- Single source of truth for "is it safe to post another LinkedIn comment right
5
- now, or should we wait / stop for today". Every LinkedIn write path calls
6
- `check` immediately BEFORE each comment and honours the answer.
7
-
8
- Why this exists
9
- ---------------
10
- Until 2026-07-29 the LinkedIn write path had NO pacing control at all: no
11
- minimum gap, no jitter, no hourly cap, no per-day cap. The human-looking cadence
12
- on healthy days was an accident of queue availability and lock contention, not a
13
- control. Observed inter-action gaps routinely bottomed out at 0-3 SECONDS.
14
-
15
- TWO ACTION STREAMS - counting only one is the trap
16
- --------------------------------------------------
17
- LinkedIn writes land in two different tables and BOTH must be counted:
18
-
19
- replies (replied_at) engage-linkedin.sh - replying to comments on our posts
20
- posts (posted_at) run-linkedin.sh - commenting on others' posts,
21
- via log_post.py, status='active'
22
-
23
- The first cut of this gate counted only `replies` and therefore saw ~25% of
24
- reality: it read 2026-07-20 as 23 actions when the true figure was 90, and read
25
- 2026-07-19 as ZERO when it was 73. Any future edit that narrows this query to a
26
- single table silently disables most of the gate. Only rows with status='active'
27
- count as real writes; log_post.py also records rejected candidates.
28
-
29
- What the data does and does NOT support
30
- ---------------------------------------
31
- Combined-stream daily figures, 2026-07-06..07-20:
32
-
33
- n/day 65 .. 96 actions
34
- per hour 2.9 .. 4.4
35
- CV 0.32 .. 1.26 on EVERY day, healthy or not
36
-
37
- An earlier draft of this file claimed CV (sd/mean of inter-action gaps)
38
- separated logout days from healthy ones at ~1.0. That was an artifact of the
39
- replies-only subsample. On the full stream it does NOT separate: 2026-07-14 ran
40
- 96 actions at CV 0.72 with no logout, and 2026-07-20 ran 90 at CV 0.78 and was
41
- killed. We therefore do NOT rely on CV as a predictor; it is retained only at a
42
- very low floor to catch a true metronome, which remains bad regardless of
43
- whether it predicts a logout.
44
-
45
- Stated plainly: we have NO statistic that reliably separates logout days from
46
- healthy days. What we do know is (a) this account is flagged, in LinkedIn's own
47
- words ("temporary restriction for automated activity" twice, "automation tool
48
- detected" once), (b) every active stretch so far has ended in a session kill,
49
- 6 for 6, and (c) 65-96 automated actions a day with sub-second minimum gaps is
50
- indefensible in absolute terms whatever the trigger turns out to be. The
51
- ceilings below are therefore a deliberate ~70% volume cut chosen on judgment,
52
- NOT a proven safe operating point. Revise them as evidence accumulates.
53
-
54
- Ceilings (env-overridable, see CONFIG below):
55
- min gap 120s hard floor; observed minimum was 0-3s
56
- per rolling 1h 4 observed average was 2.9-4.4/h with bursts
57
- per rolling 24h 25 observed 65-96/day
58
- per rolling 72h 60 observed ~230/3d
59
- CV floor 0.25 true-metronome catch only; NOT a logout predictor
60
- min daily spread 4h actions must not bunch into one short window
61
-
62
- CLI
63
- ---
64
- linkedin_pacing.py check # exit 0 = post now, 75 = wait, 78 = stop
65
- linkedin_pacing.py check --json # machine-readable decision
66
- linkedin_pacing.py status # current counters, always exit 0
67
-
68
- Exit codes are distinct so a shell caller can tell "wait" from "stop for now":
69
- 0 -> allowed, post now
70
- 75 -> not yet; sleep `wait_seconds` then re-check (EX_TEMPFAIL-ish)
71
- 78 -> a ceiling is blown; do not post at all this run (matches the repo's
72
- existing rc=78 "skip this fire" convention)
73
-
74
- FAIL-CLOSED: if the database cannot be read we deny (rc=78). The account is
75
- already flagged; posting blind is worse than skipping a cycle. This mirrors the
76
- existing "db_unavailable -> script already fails closed" rule in run-linkedin.sh.
77
- """
78
-
79
- import argparse
80
- import json
81
- import math
82
- import os
83
- import random
84
- import statistics
85
- import sys
86
- from datetime import datetime, timedelta, timezone
87
-
88
-
89
- # --------------------------------------------------------------------------
90
- # CONFIG - every value env-overridable so we can tune without editing a frozen
91
- # file, and so tests can drive it against throwaway numbers.
92
- # --------------------------------------------------------------------------
93
- def _envf(name, default):
94
- try:
95
- return float(os.environ[name])
96
- except (KeyError, ValueError):
97
- return default
98
-
99
-
100
- def _envi(name, default):
101
- try:
102
- return int(os.environ[name])
103
- except (KeyError, ValueError):
104
- return default
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)
127
- MIN_GAP_S = _envi("LI_PACE_MIN_GAP_S", 120)
128
- CV_FLOOR = _envf("LI_PACE_CV_FLOOR", 0.25)
129
- CV_WINDOW = _envi("LI_PACE_CV_WINDOW", 10)
130
- MAX_PER_1H = _envi("LI_PACE_MAX_1H", 4)
131
- MAX_PER_24H = _envi("LI_PACE_MAX_24H", 25)
132
- MAX_PER_72H = _envi("LI_PACE_MAX_72H", 60)
133
- MIN_SPREAD_S = _envi("LI_PACE_MIN_SPREAD_S", 4 * 3600)
134
- # When CV is too low we do not just wait the floor, we inject a long randomized
135
- # pause to actively break the metronome and pull CV back up.
136
- CV_PAUSE_MIN_S = _envi("LI_PACE_CV_PAUSE_MIN_S", 900)
137
- CV_PAUSE_MAX_S = _envi("LI_PACE_CV_PAUSE_MAX_S", 3600)
138
-
139
- PLATFORM = "linkedin"
140
-
141
- RC_ALLOW = 0
142
- RC_WAIT = 75
143
- RC_STOP = 78
144
-
145
-
146
- def _database_url():
147
- url = os.environ.get("DATABASE_URL")
148
- if url:
149
- return url.strip().strip('"').strip("'")
150
- env_path = os.path.expanduser("~/social-autoposter/.env")
151
- try:
152
- with open(env_path, encoding="utf-8") as fh:
153
- for line in fh:
154
- line = line.strip()
155
- if line.startswith("DATABASE_URL="):
156
- return line.split("=", 1)[1].strip().strip('"').strip("'")
157
- except OSError:
158
- pass
159
- return None
160
-
161
-
162
- def _recent_timestamps(hours=72):
163
- """Every LinkedIn write action in the last `hours`, UTC, ascending.
164
-
165
- UNION of BOTH write streams. See the module docstring: counting only one
166
- table silently disables most of this gate.
167
-
168
- Raises on any failure so callers can fail closed.
169
- """
170
- import psycopg2 # imported lazily so `--help` works without the driver
171
-
172
- url = _database_url()
173
- if not url:
174
- raise RuntimeError("DATABASE_URL not resolvable")
175
- since = datetime.now(timezone.utc) - timedelta(hours=hours)
176
- conn = psycopg2.connect(url, connect_timeout=10)
177
- try:
178
- with conn.cursor() as cur:
179
- cur.execute(
180
- "select ts from ("
181
- " select posted_at as ts from posts"
182
- " where platform = %s and status = 'active'"
183
- " and posted_at is not null and posted_at >= %s"
184
- " union all"
185
- " select replied_at as ts from replies"
186
- " where platform = %s"
187
- " and replied_at is not null and replied_at >= %s"
188
- ") a order by ts",
189
- (PLATFORM, since, PLATFORM, since),
190
- )
191
- return [r[0] for r in cur.fetchall()]
192
- finally:
193
- conn.close()
194
-
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
-
240
- def _gaps(ts):
241
- return [
242
- (b - a).total_seconds()
243
- for a, b in zip(ts, ts[1:])
244
- if (b - a).total_seconds() >= 0
245
- ]
246
-
247
-
248
- def _cv(gaps):
249
- """Coefficient of variation. None when undefined (need >= 2 gaps)."""
250
- if len(gaps) < 2:
251
- return None
252
- mean = statistics.fmean(gaps)
253
- if mean <= 0:
254
- return 0.0
255
- return statistics.stdev(gaps) / mean
256
-
257
-
258
- def evaluate(now=None, timestamps=None):
259
- """Return a decision dict. Pure given its inputs, so it is testable."""
260
- now = now or datetime.now(timezone.utc)
261
- ts = timestamps if timestamps is not None else _recent_timestamps()
262
-
263
- in_1h = [t for t in ts if t >= now - timedelta(hours=1)]
264
- in_24h = [t for t in ts if t >= now - timedelta(hours=24)]
265
- in_72h = ts
266
-
267
- counters = {
268
- "count_1h": len(in_1h),
269
- "count_24h": len(in_24h),
270
- "count_72h": len(in_72h),
271
- "max_1h": MAX_PER_1H,
272
- "max_24h": MAX_PER_24H,
273
- "max_72h": MAX_PER_72H,
274
- }
275
-
276
- def decision(action, rc, reason, wait_seconds=0, **extra):
277
- d = {
278
- "action": action,
279
- "rc": rc,
280
- "reason": reason,
281
- "wait_seconds": int(wait_seconds),
282
- "checked_at": now.isoformat(),
283
- }
284
- d.update(counters)
285
- d.update(extra)
286
- return d
287
-
288
- # ---- hard ceilings first: these mean "stop", not "wait a bit" ----------
289
- if len(in_24h) >= MAX_PER_24H:
290
- return decision("stop", RC_STOP,
291
- f"24h cap reached ({len(in_24h)}/{MAX_PER_24H})")
292
- if len(in_72h) >= MAX_PER_72H:
293
- return decision("stop", RC_STOP,
294
- f"72h cap reached ({len(in_72h)}/{MAX_PER_72H})")
295
-
296
- # ---- rolling hour: a wait, since it clears on its own ------------------
297
- if len(in_1h) >= MAX_PER_1H:
298
- oldest = min(in_1h)
299
- wait = (oldest + timedelta(hours=1) - now).total_seconds()
300
- return decision("wait", RC_WAIT,
301
- f"1h cap reached ({len(in_1h)}/{MAX_PER_1H})",
302
- max(wait, 60))
303
-
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.
318
- if ts:
319
- last = max(ts)
320
- since_last = (now - last).total_seconds()
321
- target = _target_gap(last)
322
- if since_last < target:
323
- return decision("wait", RC_WAIT,
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))
329
-
330
- # ---- cadence regularity -----------------------------------------------
331
- # CRITICAL: both this rule and the spread rule below must be evaluated
332
- # against the state that WOULD exist if we posted right now, never against
333
- # history alone. History does not change while we wait, so a rule phrased
334
- # over past gaps can never be satisfied by waiting: an earlier version of
335
- # this file compared the last two gaps (120s, 120s -> CV 0.00), told the
336
- # caller to sleep, and then returned the identical verdict forever. That
337
- # livelocked the pipeline at 3 actions total and cost 1.3 actions/day
338
- # instead of the intended ~25. Including the pending gap makes waiting
339
- # monotonically increase CV, so the rule always resolves itself.
340
- candidate_gap = (now - max(ts)).total_seconds() if ts else None
341
- gaps = _gaps(ts)
342
- if candidate_gap is not None:
343
- gaps = gaps + [candidate_gap]
344
- gaps = gaps[-CV_WINDOW:]
345
- cv = _cv(gaps)
346
- if cv is not None and cv < CV_FLOOR:
347
- # Metronomic. Break it with a long randomized pause rather than the floor.
348
- pause = random.uniform(CV_PAUSE_MIN_S, CV_PAUSE_MAX_S)
349
- return decision("wait", RC_WAIT,
350
- f"cadence too regular (CV={cv:.2f} < {CV_FLOOR:.2f} "
351
- f"over last {len(gaps)} gaps incl. pending)",
352
- pause, cv=round(cv, 3))
353
-
354
- # ---- daily spread: do not bunch the day's actions into one window ------
355
- # Same "as if we posted now" framing: `now` is the effective end of the
356
- # window, so the spread grows as we wait and the rule cannot livelock.
357
- if len(in_24h) >= max(3, MAX_PER_24H // 2):
358
- spread = (now - min(in_24h)).total_seconds()
359
- if spread < MIN_SPREAD_S:
360
- wait = MIN_SPREAD_S - spread
361
- return decision("wait", RC_WAIT,
362
- f"daily spread too tight ({int(spread/60)}min over "
363
- f"{len(in_24h)} actions; want >= {MIN_SPREAD_S//3600}h)",
364
- wait, spread_minutes=int(spread / 60))
365
-
366
- return decision("allow", RC_ALLOW, "within all pacing limits",
367
- cv=(round(cv, 3) if cv is not None else None))
368
-
369
-
370
- def main():
371
- ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
372
- ap.add_argument("command", choices=["check", "status"])
373
- ap.add_argument("--json", action="store_true",
374
- help="emit the full decision as JSON")
375
- args = ap.parse_args()
376
-
377
- try:
378
- d = evaluate()
379
- except Exception as exc: # noqa: BLE001 - fail closed on ANY read failure
380
- d = {
381
- "action": "stop",
382
- "rc": RC_STOP,
383
- "reason": f"pacing state unreadable, failing closed: {exc}",
384
- "wait_seconds": 0,
385
- }
386
-
387
- if args.command == "status":
388
- print(json.dumps(d, indent=2))
389
- return 0
390
-
391
- if args.json:
392
- print(json.dumps(d))
393
- else:
394
- print(f"{d['action'].upper()}: {d['reason']}"
395
- + (f" (wait {d['wait_seconds']}s)" if d["wait_seconds"] else ""))
396
- return d["rc"]
397
-
398
-
399
- if __name__ == "__main__":
400
- sys.exit(main())