@m13v/s4l 1.7.4-rc.4 → 1.7.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/README.md +134 -101
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +24 -5
- package/mcp/dist/runtime.js +110 -0
- package/mcp/dist/version.js +163 -54
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +55 -19
- package/mcp/menubar/s4l_menubar.py +179 -56
- package/mcp/menubar/s4l_state.py +41 -1
- package/mcp/package.json +1 -1
- package/package.json +4 -2
- package/scripts/active_experiments.py +30 -6
- package/scripts/autopilot_stall_watch.py +185 -63
- package/scripts/cdp_ready_check.py +94 -0
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +221 -26
- package/scripts/enrich_reply_parents.py +68 -22
- package/scripts/feedback_digest.py +24 -1
- package/scripts/generate_daily_human_style.py +31 -7
- package/scripts/invent_styles.py +103 -26
- package/scripts/linkedin_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +43 -0
- package/scripts/memory_snapshot.py +57 -0
- package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/reddit_browser.py +57 -3
- package/scripts/s4l_box_update.sh +20 -1
- package/scripts/s4l_mode.py +3 -4
- package/scripts/scan_x_profile.py +25 -7
- package/scripts/schedule_state.py +23 -0
- package/scripts/scheduled_task_selfheal.py +213 -16
- package/scripts/setup_twitter_auth.py +6 -1
- package/scripts/snapshot.py +179 -61
- package/scripts/stall_guard.py +70 -0
- package/scripts/test_scheduled_task_selfheal.py +121 -0
- package/scripts/top_performers.py +34 -0
- package/scripts/twitter_browser.py +146 -8
- package/scripts/twitter_post_plan.py +10 -1
- package/scripts/twitter_scan.py +26 -0
- package/scripts/watchdog_hung_runs.py +117 -3
- package/skill/amplitude-24h-signups.sh +1 -1
- package/skill/audit-dm-staleness.sh +1 -1
- package/skill/audit-linkedin.sh +1 -1
- package/skill/audit-reddit-resurrect.sh +1 -1
- package/skill/audit.sh +5 -5
- package/skill/check-web-chats.sh +1 -1
- package/skill/dm-outreach-linkedin.sh +4 -4
- package/skill/dm-outreach-reddit.sh +1 -1
- package/skill/dm-outreach-twitter.sh +1 -1
- package/skill/engage-dm-replies.sh +16 -3
- package/skill/engage-linkedin.sh +2 -2
- package/skill/engage-moltbook.sh +1 -1
- package/skill/engage-twitter.sh +4 -4
- package/skill/github-engage.sh +1 -1
- package/skill/lib/browser-launch.sh +54 -0
- package/skill/lib/linkedin-backend.sh +22 -15
- package/skill/lib/reddit-backend.sh +17 -11
- package/skill/lib/twitter-backend.sh +137 -16
- package/skill/link-edit-github.sh +1 -1
- package/skill/link-edit-moltbook.sh +1 -1
- package/skill/link-edit-reddit.sh +1 -1
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/linkedin-presence.sh +1 -1
- package/skill/lock.sh +30 -6
- package/skill/precompute-stats.sh +1 -1
- package/skill/refresh-instagram-tokens.sh +1 -1
- package/skill/refresh-twitter-following.sh +1 -1
- package/skill/run-generate-daily-style.sh +1 -1
- package/skill/run-instagram-daily.sh +1 -1
- package/skill/run-instagram-render.sh +1 -1
- package/skill/run-linkedin-launchd.sh +1 -1
- package/skill/run-linkedin-unipile.sh +1 -1
- package/skill/run-linkedin.sh +1 -1
- package/skill/run-reddit-search.sh +2 -2
- package/skill/run-reddit-threads.sh +1 -1
- package/skill/run-twitter-cycle.sh +190 -55
- package/skill/scan-instagram-replies.sh +1 -1
- package/skill/sentry-digest.sh +1 -1
- package/skill/stats-instagram.sh +1 -1
- package/skill/stats-linkedin.sh +2 -2
- package/skill/stats.sh +1 -1
- package/skill/sweep-link-clicks.sh +1 -1
- package/scripts/_lock_preempt_test.py +0 -60
- package/scripts/saps_activity.py +0 -18
- package/scripts/saps_mode.py +0 -18
package/scripts/invent_styles.py
CHANGED
|
@@ -15,12 +15,26 @@ This job runs OUTSIDE any drafting context, on the operator Mac only:
|
|
|
15
15
|
style), so invention must NOT fan out per install the way topic
|
|
16
16
|
invention does. One central daily run is the correct scope; that is
|
|
17
17
|
the deliberate difference from invent_topics.py's per-install kicker.
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
- VERBALIZED SAMPLING (2026-07-10, arXiv 2510.01171): a single "invent
|
|
19
|
+
one style" ask reliably returns the modal answer (typicality bias from
|
|
20
|
+
preference training), which is why 653 of ~950 registered styles
|
|
21
|
+
cluster at 53-98 target_chars and one rhetorical family. Instead the
|
|
22
|
+
prompt asks for N_CANDIDATES candidate styles WITH a verbalized
|
|
23
|
+
typicality probability each, and selection takes the LOWEST-probability
|
|
24
|
+
candidate that survives dedup: we sample the tail of the distribution,
|
|
25
|
+
not the mode. This is the principled fix; the family blocklist below is
|
|
26
|
+
only a backstop.
|
|
27
|
+
- The existing registry is framed as OCCUPIED NICHES to stay out of
|
|
28
|
+
(quality-diversity framing, arXiv 2310.13032), NOT as reference
|
|
29
|
+
inspiration; showing top performers as exemplars is what anchored the
|
|
30
|
+
retired inline path to the champion family.
|
|
31
|
+
- target_chars DIVERSITY: the prompt shows live length-band occupancy
|
|
32
|
+
and requires candidates to spread across bands, biased to the
|
|
33
|
+
least-occupied ones, so invention stops minting 80-char styles.
|
|
21
34
|
- Post-hoc semantic dedup (token-Jaccard on description+example, plus a
|
|
22
|
-
reframe-family heuristic) rejects near-clones;
|
|
23
|
-
with a grown avoid-list, at most DUPE_RETRIES
|
|
35
|
+
reframe-family heuristic) rejects near-clones; when every candidate is
|
|
36
|
+
rejected we re-prompt with a grown avoid-list, at most DUPE_RETRIES
|
|
37
|
+
times.
|
|
24
38
|
- Accepted styles are registered via engagement_styles.register_style
|
|
25
39
|
(kind='model_invented'), same as the retired inline path, so pickers
|
|
26
40
|
see them on their next tick with zero other wiring.
|
|
@@ -52,6 +66,12 @@ SCRIPT_TAG = "invent-styles"
|
|
|
52
66
|
CALL_TIMEOUT_SEC = 420
|
|
53
67
|
DUPE_RETRIES = 3
|
|
54
68
|
SIMILARITY_THRESHOLD = 0.5 # Jaccard on description+example tokens
|
|
69
|
+
N_CANDIDATES = 5 # verbalized-sampling fan-out per call
|
|
70
|
+
|
|
71
|
+
# Length bands for target_chars diversity. Occupancy is computed live from
|
|
72
|
+
# the registry and shown in the prompt; candidates must spread across bands
|
|
73
|
+
# and bias toward the least-occupied ones.
|
|
74
|
+
LENGTH_BANDS = [(30, 60), (60, 100), (100, 150), (150, 200), (200, 250)]
|
|
55
75
|
|
|
56
76
|
# Heuristic markers of the saturated agree-then-relocate/reframe family.
|
|
57
77
|
# A proposal whose description/example leans on these is a clone of the
|
|
@@ -98,6 +118,23 @@ def _find_near_dupe(proposal, universe):
|
|
|
98
118
|
return None
|
|
99
119
|
|
|
100
120
|
|
|
121
|
+
def band_occupancy(universe):
|
|
122
|
+
"""[(lo, hi, count), ...] for LENGTH_BANDS over the registry's
|
|
123
|
+
target_chars. The live histogram the prompt shows so candidates can
|
|
124
|
+
claim underrepresented bands."""
|
|
125
|
+
counts = [0] * len(LENGTH_BANDS)
|
|
126
|
+
for e in universe.values():
|
|
127
|
+
try:
|
|
128
|
+
tc = int(e.get("target_chars") or 0)
|
|
129
|
+
except (TypeError, ValueError):
|
|
130
|
+
continue
|
|
131
|
+
for i, (lo, hi) in enumerate(LENGTH_BANDS):
|
|
132
|
+
if lo <= tc < hi:
|
|
133
|
+
counts[i] += 1
|
|
134
|
+
break
|
|
135
|
+
return [(lo, hi, counts[i]) for i, (lo, hi) in enumerate(LENGTH_BANDS)]
|
|
136
|
+
|
|
137
|
+
|
|
101
138
|
def build_prompt(universe, avoid):
|
|
102
139
|
names = sorted(universe.keys())
|
|
103
140
|
# Detail only a bounded sample (prompt-size guard): the seeds plus the
|
|
@@ -117,25 +154,42 @@ def build_prompt(universe, avoid):
|
|
|
117
154
|
"\nAlready proposed and REJECTED this run (do not resubmit or "
|
|
118
155
|
"paraphrase): " + ", ".join(avoid) + "\n"
|
|
119
156
|
)
|
|
157
|
+
bands = band_occupancy(universe)
|
|
158
|
+
band_lines = "\n".join(
|
|
159
|
+
f"- {lo}-{hi} chars: {count} existing styles"
|
|
160
|
+
f"{' <- UNDERREPRESENTED, prefer this band' if count == min(c for _, _, c in bands) else ''}"
|
|
161
|
+
for lo, hi, count in bands
|
|
162
|
+
)
|
|
120
163
|
return f"""You maintain the engagement-style registry for a social reply system. A style is a named rhetorical TEMPLATE (description + one example reply) that drafters follow when writing short replies on X/Twitter and Reddit.
|
|
121
164
|
|
|
122
|
-
|
|
165
|
+
The registry below is OCCUPIED TERRITORY, not inspiration. Your job is to find empty niches: structural families and length bands that no existing style covers. Do not riff on what is already there.
|
|
166
|
+
|
|
167
|
+
The registry is saturated with ONE structural family: agree-then-relocate (concede the surface point, move the spotlight to the hidden/harder/unmeasured part, often "X is easy, Y is the real work" or "X was never the point, Y is"). Do NOT propose any member of that family, however disguised.
|
|
123
168
|
|
|
124
169
|
EXISTING STYLE NAMES ({len(names)} total):
|
|
125
170
|
{", ".join(names)}
|
|
126
171
|
|
|
127
|
-
REPRESENTATIVE DETAILS (sample):
|
|
172
|
+
REPRESENTATIVE DETAILS (occupied niches, sample):
|
|
128
173
|
{chr(10).join(detailed)}
|
|
174
|
+
|
|
175
|
+
LENGTH-BAND OCCUPANCY (live registry histogram of target_chars):
|
|
176
|
+
{band_lines}
|
|
129
177
|
{avoid_block}
|
|
130
|
-
TASK:
|
|
178
|
+
TASK (verbalized sampling): generate {N_CANDIDATES} CANDIDATE styles. For each, verbalize a `probability`: how likely this exact style would be as a language model's single first answer to "invent a new reply style" (0.0-1.0, honest, they need not sum to 1). Deliberately include tail candidates: at least 3 of the {N_CANDIDATES} must have probability under 0.10, meaning genuinely atypical moves a model would almost never produce first. We will programmatically select from the LOW-probability tail, so the obvious candidates are effectively discards; put your creativity into the tail.
|
|
179
|
+
|
|
180
|
+
Diversity requirements across the {N_CANDIDATES} candidates:
|
|
181
|
+
- Each from a DIFFERENT structural family. Families worth mining (or others you identify): direct answer with zero framing; first-person confession of a specific failure; pure curious question with no thesis; dry understatement one-liner; enthusiastic cosign with one concrete addition; flat disagreement stated plainly without conceding anything first; a tiny numbered checklist; a vivid analogy that does NOT end in a lesson; deadpan humor riffing on the thread's wording.
|
|
182
|
+
- Each in a DIFFERENT length band from the histogram above, biased toward the least-occupied bands. `target_chars` must be the actual length of your example, and the example must genuinely inhabit its band (a 200-char style is narrative, not a padded one-liner).
|
|
131
183
|
|
|
132
|
-
|
|
133
|
-
-
|
|
184
|
+
Per-candidate rules:
|
|
185
|
+
- `description` must state the style's DEFINING MOVE (the one thing every draft in this style must contain) and its OPENING (how the first words enter: e.g. lowercase noun, a number, the question itself). A future model reading only the description must know exactly what shape to write.
|
|
186
|
+
- `note` states when to use / when not to, and how a product mention would enter this style if ever (one clause; the link/CTA layer is downstream, so no URLs or link mechanics).
|
|
187
|
+
- The example must read like a real human reply (lowercase ok), NO links, NO product names.
|
|
134
188
|
- The style must be usable across many products and threads, not thread-specific.
|
|
135
|
-
-
|
|
189
|
+
- Every candidate is dedup-checked by token similarity against every existing style's description+example; if you cannot field {N_CANDIDATES} genuinely different moves, return the saturation envelope instead of forcing paraphrases.
|
|
136
190
|
|
|
137
191
|
Answer with ONLY one JSON object, no prose, in one of these two shapes:
|
|
138
|
-
{{"name": "snake_case_name", "description": "...", "example": "...", "why_existing_didnt_fit": "...", "target_chars": <int
|
|
192
|
+
{{"candidates": [{{"name": "snake_case_name", "description": "...", "example": "...", "note": "...", "why_existing_didnt_fit": "...", "target_chars": <int>, "probability": <float>}}, ...]}}
|
|
139
193
|
{{"saturated": true, "reason": "..."}}"""
|
|
140
194
|
|
|
141
195
|
|
|
@@ -202,21 +256,43 @@ def main():
|
|
|
202
256
|
print(f"[invent_styles] slot={slot} model reports saturation: "
|
|
203
257
|
f"{obj.get('reason', '')[:200]}", file=sys.stderr)
|
|
204
258
|
break
|
|
205
|
-
|
|
206
|
-
if not
|
|
207
|
-
print(f"[invent_styles] slot={slot}
|
|
208
|
-
file=sys.stderr)
|
|
209
|
-
avoid.append(name or "(unnamed)")
|
|
210
|
-
continue
|
|
211
|
-
dupe = _find_near_dupe({**obj, "name": name}, universe)
|
|
212
|
-
if dupe:
|
|
213
|
-
reason, existing = dupe
|
|
214
|
-
print(f"[invent_styles] slot={slot} rejected {name!r}: {reason} "
|
|
215
|
-
f"vs {existing}", file=sys.stderr)
|
|
216
|
-
avoid.append(name)
|
|
259
|
+
candidates = obj.get("candidates")
|
|
260
|
+
if not isinstance(candidates, list) or not candidates:
|
|
261
|
+
print(f"[invent_styles] slot={slot} attempt={attempt} no "
|
|
262
|
+
f"candidates array in output", file=sys.stderr)
|
|
217
263
|
continue
|
|
218
|
-
|
|
219
|
-
|
|
264
|
+
# Verbalized-sampling selection: walk the tail first (ascending
|
|
265
|
+
# verbalized probability = least typical candidate first) and
|
|
266
|
+
# take the first that survives dedup. The modal candidates at
|
|
267
|
+
# the top of the distribution only get a chance if every tail
|
|
268
|
+
# candidate is a clone.
|
|
269
|
+
def _prob(c):
|
|
270
|
+
try:
|
|
271
|
+
return float(c.get("probability"))
|
|
272
|
+
except (TypeError, ValueError):
|
|
273
|
+
return 1.0 # unparseable prob sorts as maximally typical
|
|
274
|
+
for cand in sorted(candidates, key=_prob):
|
|
275
|
+
name = str(cand.get("name") or "").strip()
|
|
276
|
+
if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
|
|
277
|
+
print(f"[invent_styles] slot={slot} bad name {name!r}; "
|
|
278
|
+
f"skipping candidate", file=sys.stderr)
|
|
279
|
+
avoid.append(name or "(unnamed)")
|
|
280
|
+
continue
|
|
281
|
+
dupe = _find_near_dupe({**cand, "name": name}, universe)
|
|
282
|
+
if dupe:
|
|
283
|
+
reason, existing = dupe
|
|
284
|
+
print(f"[invent_styles] slot={slot} rejected {name!r} "
|
|
285
|
+
f"(p={_prob(cand):.2f}): {reason} vs {existing}",
|
|
286
|
+
file=sys.stderr)
|
|
287
|
+
avoid.append(name)
|
|
288
|
+
continue
|
|
289
|
+
accepted = {**cand, "name": name}
|
|
290
|
+
print(f"[invent_styles] slot={slot} selected tail candidate "
|
|
291
|
+
f"{name!r} (p={_prob(cand):.2f}) from "
|
|
292
|
+
f"{len(candidates)} candidates", file=sys.stderr)
|
|
293
|
+
break
|
|
294
|
+
if accepted:
|
|
295
|
+
break
|
|
220
296
|
if not accepted:
|
|
221
297
|
continue
|
|
222
298
|
if args.dry_run:
|
|
@@ -227,6 +303,7 @@ def main():
|
|
|
227
303
|
{
|
|
228
304
|
"description": accepted.get("description", ""),
|
|
229
305
|
"example": accepted.get("example", ""),
|
|
306
|
+
"note": accepted.get("note", ""),
|
|
230
307
|
"why_existing_didnt_fit": accepted.get("why_existing_didnt_fit", ""),
|
|
231
308
|
"target_chars": accepted.get("target_chars"),
|
|
232
309
|
},
|
|
@@ -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
|
|
|
@@ -880,6 +884,29 @@ def _hold_check_due_seconds():
|
|
|
880
884
|
return (datetime.now(timezone.utc) - due).total_seconds()
|
|
881
885
|
|
|
882
886
|
|
|
887
|
+
def _cadence_break_active():
|
|
888
|
+
"""True while scripts/linkedin_cadence.py has us in a scheduled break.
|
|
889
|
+
|
|
890
|
+
Reads the cadence state file directly (no import of linkedin_cadence, to
|
|
891
|
+
avoid a circular import — linkedin_cadence imports this module). Recovery
|
|
892
|
+
must never attempt a real login or hold-check re-probe during a break,
|
|
893
|
+
REGARDLESS of which signal put the killswitch file in place: an
|
|
894
|
+
already-active real block (e.g. login_redirect) at the moment a break
|
|
895
|
+
starts must NOT get its 24h auto-recovery clock honored mid-break. The
|
|
896
|
+
2026-07-12 incident (a real hard_block login attempt fired during an
|
|
897
|
+
active cadence break) is exactly what this guards against."""
|
|
898
|
+
path = os.path.expanduser(
|
|
899
|
+
os.environ.get(
|
|
900
|
+
"LINKEDIN_CADENCE_FILE", os.path.join(STATE_DIR, "linkedin_cadence.json")
|
|
901
|
+
)
|
|
902
|
+
)
|
|
903
|
+
try:
|
|
904
|
+
with open(path, "r") as f:
|
|
905
|
+
return json.load(f).get("phase") == "break"
|
|
906
|
+
except Exception:
|
|
907
|
+
return False
|
|
908
|
+
|
|
909
|
+
|
|
883
910
|
def _cmd_recover_check(args):
|
|
884
911
|
"""Gate for the hourly recovery job. Exits 0 when there is work to do and
|
|
885
912
|
prints the MODE on stdout so the shell knows which path to drive:
|
|
@@ -894,6 +921,22 @@ def _cmd_recover_check(args):
|
|
|
894
921
|
if not is_active():
|
|
895
922
|
print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
|
|
896
923
|
sys.exit(1)
|
|
924
|
+
if _cadence_break_active():
|
|
925
|
+
print(
|
|
926
|
+
"recover-check: cadence break active (scripts/linkedin_cadence.py); "
|
|
927
|
+
"suppressing ALL real recovery action (login/hold) regardless of "
|
|
928
|
+
"signal until the break ends, per 'stop all activity for 2 days'",
|
|
929
|
+
file=sys.stderr,
|
|
930
|
+
)
|
|
931
|
+
sys.exit(1)
|
|
932
|
+
payload = read() or {}
|
|
933
|
+
if payload.get("signal") == "scheduled_break":
|
|
934
|
+
print(
|
|
935
|
+
"recover-check: scheduled_break active (cadence pause, not a real "
|
|
936
|
+
"incident); deferring to scripts/linkedin_cadence.py, not probing",
|
|
937
|
+
file=sys.stderr,
|
|
938
|
+
)
|
|
939
|
+
sys.exit(1)
|
|
897
940
|
if is_terminal():
|
|
898
941
|
print(
|
|
899
942
|
"recover-check: TERMINAL (auto-recovery gave up); "
|
|
@@ -700,6 +700,8 @@ def build_summary() -> dict[str, Any]:
|
|
|
700
700
|
"menubar": menubar_status(),
|
|
701
701
|
"twitter_cycle": twitter_cycle_status(),
|
|
702
702
|
"draft_publish": draft_publish_wrapper_status(),
|
|
703
|
+
"chrome_relaunches": chrome_relaunch_status(),
|
|
704
|
+
"cdp_health": cdp_health_status(),
|
|
703
705
|
"process_count": len(rows),
|
|
704
706
|
"mem": {
|
|
705
707
|
"total_mb": total,
|
|
@@ -845,6 +847,25 @@ def reaper_status() -> dict[str, Any] | None:
|
|
|
845
847
|
return None
|
|
846
848
|
|
|
847
849
|
|
|
850
|
+
def cdp_health_status() -> dict[str, Any] | None:
|
|
851
|
+
"""Last CDP readiness verdict, carried on the heartbeat.
|
|
852
|
+
|
|
853
|
+
Written by skill/lib/twitter-backend.sh on every
|
|
854
|
+
ensure_twitter_browser_for_backend call: whether the harness Chrome
|
|
855
|
+
completed a REAL connect_over_cdp handshake (not just /json/version),
|
|
856
|
+
plus the action taken (ok / wedge_restart / relaunched / relaunch_failed /
|
|
857
|
+
external_wedged). Makes a wedged-Chrome episode (S4L-4H, 2026-07-11) a
|
|
858
|
+
one-query answer in installation_resource_samples instead of a Sentry +
|
|
859
|
+
local-log archaeology session. Best-effort."""
|
|
860
|
+
try:
|
|
861
|
+
p = REPO_DIR / "skill" / "logs" / "cdp-health.json"
|
|
862
|
+
data = json.loads(p.read_text())
|
|
863
|
+
data["age_sec"] = round(time.time() - p.stat().st_mtime, 1)
|
|
864
|
+
return data
|
|
865
|
+
except Exception:
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
|
|
848
869
|
def twitter_cycle_status() -> dict[str, Any] | None:
|
|
849
870
|
"""Tail of the newest twitter-cycle log, carried on the heartbeat.
|
|
850
871
|
|
|
@@ -898,6 +919,42 @@ def draft_publish_wrapper_status() -> dict[str, Any] | None:
|
|
|
898
919
|
return None
|
|
899
920
|
|
|
900
921
|
|
|
922
|
+
def chrome_relaunch_status() -> dict[str, Any] | None:
|
|
923
|
+
"""Harness-Chrome relaunch rate from skill/logs/chrome-relaunch-events.log.
|
|
924
|
+
|
|
925
|
+
The skill/lib/*-backend.sh launchers append one dated line per Chrome
|
|
926
|
+
launch. A healthy box launches Chrome rarely (boot, user quit, crash); a
|
|
927
|
+
high 24h count is the signature of the 2026-07-12 kill-respawn loop
|
|
928
|
+
(launchd reaping the kicker job's process group took Chrome with it every
|
|
929
|
+
cycle, and each relaunch stole the user's focus). Carried on the heartbeat
|
|
930
|
+
so the rate is queryable per-install WITHOUT the menubar foreground
|
|
931
|
+
observer, which only reports while the menubar process is alive."""
|
|
932
|
+
try:
|
|
933
|
+
p = REPO_DIR / "skill" / "logs" / "chrome-relaunch-events.log"
|
|
934
|
+
if not p.exists():
|
|
935
|
+
return None
|
|
936
|
+
now = time.time()
|
|
937
|
+
counts = {"1h": 0, "24h": 0}
|
|
938
|
+
last_line = None
|
|
939
|
+
for ln in _tail_lines(p, 400, approx_line_bytes=64):
|
|
940
|
+
ln = ln.strip()
|
|
941
|
+
if not ln:
|
|
942
|
+
continue
|
|
943
|
+
try:
|
|
944
|
+
ts = dt.datetime.strptime(ln.split()[0], "%Y-%m-%dT%H:%M:%SZ")
|
|
945
|
+
age = now - ts.replace(tzinfo=dt.timezone.utc).timestamp()
|
|
946
|
+
except (ValueError, IndexError):
|
|
947
|
+
continue
|
|
948
|
+
if age <= 3600:
|
|
949
|
+
counts["1h"] += 1
|
|
950
|
+
if age <= 86400:
|
|
951
|
+
counts["24h"] += 1
|
|
952
|
+
last_line = ln
|
|
953
|
+
return {"count_1h": counts["1h"], "count_24h": counts["24h"], "last": last_line}
|
|
954
|
+
except Exception:
|
|
955
|
+
return None
|
|
956
|
+
|
|
957
|
+
|
|
901
958
|
def _tail_lines(path: Path, n: int, approx_line_bytes: int = 4096) -> list[str]:
|
|
902
959
|
"""Return the last `n` lines of a possibly-large file without reading it all.
|
|
903
960
|
Reads a bounded tail window (n * approx_line_bytes) from the end. Best-effort."""
|