@m13v/s4l 1.7.7-rc.1 → 1.7.7-rc.3
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/reddit_ban_check.py +16 -1
- package/skill/engage-linkedin.sh +1 -45
- package/scripts/linkedin_pacing.py +0 -318
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.3",
|
|
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.3",
|
|
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
|
@@ -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
|
@@ -278,34 +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_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)"
|
|
299
|
-
PENDING_COUNT=0
|
|
300
|
-
LI_PACED_OUT=1
|
|
301
|
-
fi
|
|
302
|
-
|
|
303
281
|
if [ "$PENDING_COUNT" -eq 0 ]; then
|
|
304
|
-
|
|
305
|
-
log "Phase B: held back by the pacing gate, not by an empty queue."
|
|
306
|
-
else
|
|
307
|
-
log "Phase B: No pending LinkedIn replies. Done!"
|
|
308
|
-
fi
|
|
282
|
+
log "Phase B: No pending LinkedIn replies. Done!"
|
|
309
283
|
else
|
|
310
284
|
log "Phase B: $PENDING_COUNT pending LinkedIn replies to process"
|
|
311
285
|
|
|
@@ -493,24 +467,6 @@ MANDATORY reply flow for every item:
|
|
|
493
467
|
mode ($PICKED_MODE=invent) craft a NEW snake_case style name not in the
|
|
494
468
|
curated block above and pass it as the [engagement_style] arg in Step 5.
|
|
495
469
|
Professional but casual. NEVER em dashes. Match parent post language.
|
|
496
|
-
Step 3b: PACING GATE - MANDATORY, run this IMMEDIATELY BEFORE every single
|
|
497
|
-
post in Step 4. Never post two replies without a fresh check in
|
|
498
|
-
between; never batch several posts behind one check.
|
|
499
|
-
python3 $REPO_DIR/scripts/linkedin_pacing.py check --json
|
|
500
|
-
Read the JSON "action" field and obey it exactly:
|
|
501
|
-
"allow" (exit 0) -> proceed to Step 4 for THIS reply only.
|
|
502
|
-
"wait" (exit 75) -> sleep the returned "wait_seconds", then
|
|
503
|
-
re-run the check. If it still says wait after
|
|
504
|
-
3 attempts, LEAVE the row pending (do NOT mark
|
|
505
|
-
skipped) and move to the next reply; the next
|
|
506
|
-
cycle will pick it up.
|
|
507
|
-
"stop" (exit 78) -> a 24h/72h ceiling is blown. STOP POSTING
|
|
508
|
-
ENTIRELY for this run. Leave all remaining
|
|
509
|
-
rows pending and finish the run cleanly.
|
|
510
|
-
Do NOT try to out-think, average, or "catch up on" this gate; it is
|
|
511
|
-
the control that keeps our comment cadence from looking like a
|
|
512
|
-
metronome. On 2026-07-20 an unpaced run put 23 comments out in 22
|
|
513
|
-
minutes at 60s +/- 29s and the session was killed that evening.
|
|
514
470
|
Step 4: post reply (OAuth API first, browser fallback)
|
|
515
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).
|
|
516
472
|
If Step 5 fails, the item stays 'processing' and will be reset to 'pending' on the next run.
|
|
@@ -1,318 +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 os
|
|
82
|
-
import random
|
|
83
|
-
import statistics
|
|
84
|
-
import sys
|
|
85
|
-
from datetime import datetime, timedelta, timezone
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# --------------------------------------------------------------------------
|
|
89
|
-
# CONFIG - every value env-overridable so we can tune without editing a frozen
|
|
90
|
-
# file, and so tests can drive it against throwaway numbers.
|
|
91
|
-
# --------------------------------------------------------------------------
|
|
92
|
-
def _envf(name, default):
|
|
93
|
-
try:
|
|
94
|
-
return float(os.environ[name])
|
|
95
|
-
except (KeyError, ValueError):
|
|
96
|
-
return default
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _envi(name, default):
|
|
100
|
-
try:
|
|
101
|
-
return int(os.environ[name])
|
|
102
|
-
except (KeyError, ValueError):
|
|
103
|
-
return default
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
MIN_GAP_S = _envi("LI_PACE_MIN_GAP_S", 120)
|
|
107
|
-
CV_FLOOR = _envf("LI_PACE_CV_FLOOR", 0.25)
|
|
108
|
-
CV_WINDOW = _envi("LI_PACE_CV_WINDOW", 10)
|
|
109
|
-
MAX_PER_1H = _envi("LI_PACE_MAX_1H", 4)
|
|
110
|
-
MAX_PER_24H = _envi("LI_PACE_MAX_24H", 25)
|
|
111
|
-
MAX_PER_72H = _envi("LI_PACE_MAX_72H", 60)
|
|
112
|
-
MIN_SPREAD_S = _envi("LI_PACE_MIN_SPREAD_S", 4 * 3600)
|
|
113
|
-
# When CV is too low we do not just wait the floor, we inject a long randomized
|
|
114
|
-
# pause to actively break the metronome and pull CV back up.
|
|
115
|
-
CV_PAUSE_MIN_S = _envi("LI_PACE_CV_PAUSE_MIN_S", 900)
|
|
116
|
-
CV_PAUSE_MAX_S = _envi("LI_PACE_CV_PAUSE_MAX_S", 3600)
|
|
117
|
-
|
|
118
|
-
PLATFORM = "linkedin"
|
|
119
|
-
|
|
120
|
-
RC_ALLOW = 0
|
|
121
|
-
RC_WAIT = 75
|
|
122
|
-
RC_STOP = 78
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _database_url():
|
|
126
|
-
url = os.environ.get("DATABASE_URL")
|
|
127
|
-
if url:
|
|
128
|
-
return url.strip().strip('"').strip("'")
|
|
129
|
-
env_path = os.path.expanduser("~/social-autoposter/.env")
|
|
130
|
-
try:
|
|
131
|
-
with open(env_path, encoding="utf-8") as fh:
|
|
132
|
-
for line in fh:
|
|
133
|
-
line = line.strip()
|
|
134
|
-
if line.startswith("DATABASE_URL="):
|
|
135
|
-
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
136
|
-
except OSError:
|
|
137
|
-
pass
|
|
138
|
-
return None
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _recent_timestamps(hours=72):
|
|
142
|
-
"""Every LinkedIn write action in the last `hours`, UTC, ascending.
|
|
143
|
-
|
|
144
|
-
UNION of BOTH write streams. See the module docstring: counting only one
|
|
145
|
-
table silently disables most of this gate.
|
|
146
|
-
|
|
147
|
-
Raises on any failure so callers can fail closed.
|
|
148
|
-
"""
|
|
149
|
-
import psycopg2 # imported lazily so `--help` works without the driver
|
|
150
|
-
|
|
151
|
-
url = _database_url()
|
|
152
|
-
if not url:
|
|
153
|
-
raise RuntimeError("DATABASE_URL not resolvable")
|
|
154
|
-
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
155
|
-
conn = psycopg2.connect(url, connect_timeout=10)
|
|
156
|
-
try:
|
|
157
|
-
with conn.cursor() as cur:
|
|
158
|
-
cur.execute(
|
|
159
|
-
"select ts from ("
|
|
160
|
-
" select posted_at as ts from posts"
|
|
161
|
-
" where platform = %s and status = 'active'"
|
|
162
|
-
" and posted_at is not null and posted_at >= %s"
|
|
163
|
-
" union all"
|
|
164
|
-
" select replied_at as ts from replies"
|
|
165
|
-
" where platform = %s"
|
|
166
|
-
" and replied_at is not null and replied_at >= %s"
|
|
167
|
-
") a order by ts",
|
|
168
|
-
(PLATFORM, since, PLATFORM, since),
|
|
169
|
-
)
|
|
170
|
-
return [r[0] for r in cur.fetchall()]
|
|
171
|
-
finally:
|
|
172
|
-
conn.close()
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def _gaps(ts):
|
|
176
|
-
return [
|
|
177
|
-
(b - a).total_seconds()
|
|
178
|
-
for a, b in zip(ts, ts[1:])
|
|
179
|
-
if (b - a).total_seconds() >= 0
|
|
180
|
-
]
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _cv(gaps):
|
|
184
|
-
"""Coefficient of variation. None when undefined (need >= 2 gaps)."""
|
|
185
|
-
if len(gaps) < 2:
|
|
186
|
-
return None
|
|
187
|
-
mean = statistics.fmean(gaps)
|
|
188
|
-
if mean <= 0:
|
|
189
|
-
return 0.0
|
|
190
|
-
return statistics.stdev(gaps) / mean
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def evaluate(now=None, timestamps=None):
|
|
194
|
-
"""Return a decision dict. Pure given its inputs, so it is testable."""
|
|
195
|
-
now = now or datetime.now(timezone.utc)
|
|
196
|
-
ts = timestamps if timestamps is not None else _recent_timestamps()
|
|
197
|
-
|
|
198
|
-
in_1h = [t for t in ts if t >= now - timedelta(hours=1)]
|
|
199
|
-
in_24h = [t for t in ts if t >= now - timedelta(hours=24)]
|
|
200
|
-
in_72h = ts
|
|
201
|
-
|
|
202
|
-
counters = {
|
|
203
|
-
"count_1h": len(in_1h),
|
|
204
|
-
"count_24h": len(in_24h),
|
|
205
|
-
"count_72h": len(in_72h),
|
|
206
|
-
"max_1h": MAX_PER_1H,
|
|
207
|
-
"max_24h": MAX_PER_24H,
|
|
208
|
-
"max_72h": MAX_PER_72H,
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
def decision(action, rc, reason, wait_seconds=0, **extra):
|
|
212
|
-
d = {
|
|
213
|
-
"action": action,
|
|
214
|
-
"rc": rc,
|
|
215
|
-
"reason": reason,
|
|
216
|
-
"wait_seconds": int(wait_seconds),
|
|
217
|
-
"checked_at": now.isoformat(),
|
|
218
|
-
}
|
|
219
|
-
d.update(counters)
|
|
220
|
-
d.update(extra)
|
|
221
|
-
return d
|
|
222
|
-
|
|
223
|
-
# ---- hard ceilings first: these mean "stop", not "wait a bit" ----------
|
|
224
|
-
if len(in_24h) >= MAX_PER_24H:
|
|
225
|
-
return decision("stop", RC_STOP,
|
|
226
|
-
f"24h cap reached ({len(in_24h)}/{MAX_PER_24H})")
|
|
227
|
-
if len(in_72h) >= MAX_PER_72H:
|
|
228
|
-
return decision("stop", RC_STOP,
|
|
229
|
-
f"72h cap reached ({len(in_72h)}/{MAX_PER_72H})")
|
|
230
|
-
|
|
231
|
-
# ---- rolling hour: a wait, since it clears on its own ------------------
|
|
232
|
-
if len(in_1h) >= MAX_PER_1H:
|
|
233
|
-
oldest = min(in_1h)
|
|
234
|
-
wait = (oldest + timedelta(hours=1) - now).total_seconds()
|
|
235
|
-
return decision("wait", RC_WAIT,
|
|
236
|
-
f"1h cap reached ({len(in_1h)}/{MAX_PER_1H})",
|
|
237
|
-
max(wait, 60))
|
|
238
|
-
|
|
239
|
-
# ---- minimum gap ------------------------------------------------------
|
|
240
|
-
if ts:
|
|
241
|
-
since_last = (now - max(ts)).total_seconds()
|
|
242
|
-
if since_last < MIN_GAP_S:
|
|
243
|
-
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))
|
|
247
|
-
|
|
248
|
-
# ---- cadence regularity -----------------------------------------------
|
|
249
|
-
# CRITICAL: both this rule and the spread rule below must be evaluated
|
|
250
|
-
# against the state that WOULD exist if we posted right now, never against
|
|
251
|
-
# history alone. History does not change while we wait, so a rule phrased
|
|
252
|
-
# over past gaps can never be satisfied by waiting: an earlier version of
|
|
253
|
-
# this file compared the last two gaps (120s, 120s -> CV 0.00), told the
|
|
254
|
-
# caller to sleep, and then returned the identical verdict forever. That
|
|
255
|
-
# livelocked the pipeline at 3 actions total and cost 1.3 actions/day
|
|
256
|
-
# instead of the intended ~25. Including the pending gap makes waiting
|
|
257
|
-
# monotonically increase CV, so the rule always resolves itself.
|
|
258
|
-
candidate_gap = (now - max(ts)).total_seconds() if ts else None
|
|
259
|
-
gaps = _gaps(ts)
|
|
260
|
-
if candidate_gap is not None:
|
|
261
|
-
gaps = gaps + [candidate_gap]
|
|
262
|
-
gaps = gaps[-CV_WINDOW:]
|
|
263
|
-
cv = _cv(gaps)
|
|
264
|
-
if cv is not None and cv < CV_FLOOR:
|
|
265
|
-
# Metronomic. Break it with a long randomized pause rather than the floor.
|
|
266
|
-
pause = random.uniform(CV_PAUSE_MIN_S, CV_PAUSE_MAX_S)
|
|
267
|
-
return decision("wait", RC_WAIT,
|
|
268
|
-
f"cadence too regular (CV={cv:.2f} < {CV_FLOOR:.2f} "
|
|
269
|
-
f"over last {len(gaps)} gaps incl. pending)",
|
|
270
|
-
pause, cv=round(cv, 3))
|
|
271
|
-
|
|
272
|
-
# ---- daily spread: do not bunch the day's actions into one window ------
|
|
273
|
-
# Same "as if we posted now" framing: `now` is the effective end of the
|
|
274
|
-
# window, so the spread grows as we wait and the rule cannot livelock.
|
|
275
|
-
if len(in_24h) >= max(3, MAX_PER_24H // 2):
|
|
276
|
-
spread = (now - min(in_24h)).total_seconds()
|
|
277
|
-
if spread < MIN_SPREAD_S:
|
|
278
|
-
wait = MIN_SPREAD_S - spread
|
|
279
|
-
return decision("wait", RC_WAIT,
|
|
280
|
-
f"daily spread too tight ({int(spread/60)}min over "
|
|
281
|
-
f"{len(in_24h)} actions; want >= {MIN_SPREAD_S//3600}h)",
|
|
282
|
-
wait, spread_minutes=int(spread / 60))
|
|
283
|
-
|
|
284
|
-
return decision("allow", RC_ALLOW, "within all pacing limits",
|
|
285
|
-
cv=(round(cv, 3) if cv is not None else None))
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def main():
|
|
289
|
-
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
290
|
-
ap.add_argument("command", choices=["check", "status"])
|
|
291
|
-
ap.add_argument("--json", action="store_true",
|
|
292
|
-
help="emit the full decision as JSON")
|
|
293
|
-
args = ap.parse_args()
|
|
294
|
-
|
|
295
|
-
try:
|
|
296
|
-
d = evaluate()
|
|
297
|
-
except Exception as exc: # noqa: BLE001 - fail closed on ANY read failure
|
|
298
|
-
d = {
|
|
299
|
-
"action": "stop",
|
|
300
|
-
"rc": RC_STOP,
|
|
301
|
-
"reason": f"pacing state unreadable, failing closed: {exc}",
|
|
302
|
-
"wait_seconds": 0,
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if args.command == "status":
|
|
306
|
-
print(json.dumps(d, indent=2))
|
|
307
|
-
return 0
|
|
308
|
-
|
|
309
|
-
if args.json:
|
|
310
|
-
print(json.dumps(d))
|
|
311
|
-
else:
|
|
312
|
-
print(f"{d['action'].upper()}: {d['reason']}"
|
|
313
|
-
+ (f" (wait {d['wait_seconds']}s)" if d["wait_seconds"] else ""))
|
|
314
|
-
return d["rc"]
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if __name__ == "__main__":
|
|
318
|
-
sys.exit(main())
|