@m13v/s4l 1.7.0 → 1.7.1-rc.1

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.0",
3
- "installedAt": "2026-07-07T19:07:37.590Z"
2
+ "version": "1.7.1-rc.1",
3
+ "installedAt": "2026-07-07T19:24:41.432Z"
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.0",
5
+ "version": "1.7.1-rc.1",
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": {
@@ -309,6 +309,15 @@ AUTOPILOT_RUNNING_STALL_SECONDS = 900
309
309
  # healthy single drain (the worker itself dies at ~2 min today).
310
310
  DRAFT_STUCK_SECONDS = 300
311
311
 
312
+ # A poll can read a transient "not stuck" moment (e.g. the activity file
313
+ # between one dying worker attempt and the next claim on the same aged job)
314
+ # even though the underlying episode never really cleared. Without this, each
315
+ # such blip resets _stall_notified and the very next poll re-fires the Sentry
316
+ # alert, producing a burst of duplicate events for one continuous episode
317
+ # (observed: 5 events in 40s, 2026-07-07). Require attention to read false for
318
+ # this long, continuously, before treating the episode as over.
319
+ ATTENTION_CLEAR_COOLDOWN_SECONDS = 60
320
+
312
321
  # Unattended-review watchdog. A card stack is open with pending drafts and the
313
322
  # user has not decided or clicked anything on it for REVIEW_UNATTENDED_SECONDS:
314
323
  # treat that as "the user is not seeing this window" regardless of what AppKit
@@ -501,6 +510,11 @@ class S4LMenuBar(rumps.App):
501
510
  # One-shot guard so the "autopilot not running" notification fires once per
502
511
  # stall episode, not every poll. Reset when the stall clears.
503
512
  self._stall_notified = False
513
+ # Wall-clock time (time.time()) attention first read false since the last
514
+ # true reading, or None while attention is true / already cleared. Debounces
515
+ # _stall_notified's reset against ATTENTION_CLEAR_COOLDOWN_SECONDS so a
516
+ # transient blip doesn't end the episode early and cause a re-fire burst.
517
+ self._attention_clear_since = None
504
518
  # Cached stall flag (set each _tick) so the 1s activity poll can suppress a
505
519
  # stale "drafting" spinner that would otherwise mask the ⚠ in the title.
506
520
  self._stalled = False
@@ -2138,6 +2152,8 @@ class S4LMenuBar(rumps.App):
2138
2152
  )
2139
2153
  self._last_blocker_code = blocker_code
2140
2154
  # Notify once per episode (the draft schedule isn't running for this account).
2155
+ if attention:
2156
+ self._attention_clear_since = None
2141
2157
  if attention and not self._stall_notified:
2142
2158
  # Fleet-wide telemetry: the draft autopilot needs attention on THIS
2143
2159
  # install (orphaned by an account switch, disabled, rate-limited, or a
@@ -2151,7 +2167,10 @@ class S4LMenuBar(rumps.App):
2151
2167
  _capture_msg(
2152
2168
  f"S4L draft autopilot needs attention: {_reason}",
2153
2169
  level="warning",
2154
- _extra={"scheduled_tasks": _registry_summary_for_capture()},
2170
+ _extra={
2171
+ "scheduled_tasks": _registry_summary_for_capture(),
2172
+ "stall_label": self._stall_reason_info[1] or None,
2173
+ },
2155
2174
  phase="draft_schedule",
2156
2175
  reason=_reason,
2157
2176
  schedule_state=str(schedule_state),
@@ -2182,8 +2201,12 @@ class S4LMenuBar(rumps.App):
2182
2201
  "accounts clears them). Open the S4L menu → “Set up draft schedule”.",
2183
2202
  )
2184
2203
  self._stall_notified = True
2185
- elif not attention:
2186
- self._stall_notified = False
2204
+ elif not attention and self._stall_notified:
2205
+ if self._attention_clear_since is None:
2206
+ self._attention_clear_since = time.time()
2207
+ elif time.time() - self._attention_clear_since >= ATTENTION_CLEAR_COOLDOWN_SECONDS:
2208
+ self._stall_notified = False
2209
+ self._attention_clear_since = None
2187
2210
 
2188
2211
  # Single-source update signal: copy the snapshot's result (snapshot.py
2189
2212
  # _latest_published: GitHub releases/latest first, npm fallback; semver >,
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.7.1-rc.1",
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.0",
3
+ "version": "1.7.1-rc.1",
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,303 @@
1
+ #!/usr/bin/env python3
2
+ """Sentry issue digest for the s4l Sentry project (org mediar-n5).
3
+
4
+ Replaces the raw per-issue Sentry alert email. That alert rule
5
+ (mediar-n5/s4l rule id 17212931) fired on every first-seen issue and every
6
+ high-priority mark regardless of severity, so it also emailed on level=warning
7
+ menubar operational pings ("draft_stuck", "missing", "rate_limited", "review
8
+ card unattended"). Those still exist in Sentry, still get investigated
9
+ on-demand via the "Debugging a customer install" playbook (see CLAUDE.md),
10
+ they just no longer push a raw email. This script is scoped to level:error and
11
+ level:fatal only ("critical Sentry issues" per user instruction 2026-07-07):
12
+ real Python exceptions and pipeline failures, not the synthetic warning pings.
13
+
14
+ Impact ranking uses distinct install_id count, not Sentry's built-in
15
+ userCount, because s4l events are tagged per-install (install_id), not
16
+ per-Sentry-user (userCount is 0 across the board for this project).
17
+
18
+ Idempotency / noise control: a JSON ledger at scripts/state/sentry_digest_ledger.json
19
+ tracks last-seen event/install counts per issue. A digest email is only sent
20
+ when something is NEW (not in the ledger) or GROWING (event count up 20%+ and
21
+ by at least 5 events since the ledger snapshot). First run baselines every
22
+ open critical issue without flagging all of them as new.
23
+
24
+ Usage:
25
+ python3 scripts/sentry_digest.py # normal run (used by launchd)
26
+ python3 scripts/sentry_digest.py --dry-run # print what would happen, no email, no ledger write
27
+
28
+ Patterned after strike_alert.py: same Gmail token, same dash-scrubbing,
29
+ default recipient i@m13v.com.
30
+ """
31
+
32
+ import argparse
33
+ import base64
34
+ import json
35
+ import os
36
+ import subprocess
37
+ import sys
38
+ import urllib.error
39
+ import urllib.parse
40
+ import urllib.request
41
+ from datetime import datetime, timezone
42
+ from email.mime.text import MIMEText
43
+
44
+ SENTRY_ORG = "mediar-n5"
45
+ SENTRY_PROJECT = "s4l"
46
+ SENTRY_PROJECT_ID = "4511598804336640"
47
+ SENTRY_API = "https://sentry.io/api/0"
48
+
49
+ # "Critical Sentry issues" = level:error or level:fatal. Excludes level:warning
50
+ # (the synthetic menubar/autopilot signal pings), which are a different kind
51
+ # of event and already have their own surfacing (menubar UI, dashboard,
52
+ # on-demand Sentry queries during customer debugging).
53
+ ISSUE_QUERY = "is:unresolved level:[error,fatal]"
54
+
55
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
56
+ STATE_DIR = os.path.join(REPO_DIR, "scripts", "state")
57
+ LEDGER_PATH = os.path.join(STATE_DIR, "sentry_digest_ledger.json")
58
+
59
+ GMAIL_TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
60
+ GMAIL_SCOPES = ["https://mail.google.com/"]
61
+ NOTIFICATION_EMAIL = os.environ.get("NOTIFICATION_EMAIL", "i@m13v.com")
62
+
63
+ # Growth threshold: an issue re-alerts if event count grew by at least this
64
+ # many events AND by at least this relative fraction since the ledger snapshot.
65
+ GROWTH_MIN_DELTA = 5
66
+ GROWTH_MIN_RATIO = 1.2
67
+
68
+
69
+ def _scrub_dashes(s):
70
+ if not s:
71
+ return s
72
+ return s.replace("—", ",").replace("–", ",")
73
+
74
+
75
+ def _sentry_token():
76
+ env_token = os.environ.get("SENTRY_AUTH_TOKEN")
77
+ if env_token:
78
+ return env_token
79
+ try:
80
+ out = subprocess.run(
81
+ ["security", "find-generic-password", "-s", "sentry-auth-token", "-w"],
82
+ capture_output=True, text=True, timeout=10,
83
+ )
84
+ if out.returncode == 0:
85
+ return out.stdout.strip()
86
+ except Exception:
87
+ pass
88
+ return None
89
+
90
+
91
+ def _sentry_get(path, token):
92
+ req = urllib.request.Request(f"{SENTRY_API}{path}", headers={"Authorization": f"Bearer {token}"})
93
+ with urllib.request.urlopen(req, timeout=30) as resp:
94
+ return json.loads(resp.read().decode("utf-8"))
95
+
96
+
97
+ def fetch_critical_issues(token):
98
+ path = (
99
+ f"/projects/{SENTRY_ORG}/{SENTRY_PROJECT}/issues/"
100
+ f"?query={urllib.parse.quote(ISSUE_QUERY)}&statsPeriod=24h&sort=freq&limit=100"
101
+ )
102
+ return _sentry_get(path, token)
103
+
104
+
105
+ def fetch_install_impact(issue_id, token):
106
+ """Distinct install_id count + top installs for one issue. Returns (count, top)."""
107
+ try:
108
+ data = _sentry_get(f"/issues/{issue_id}/tags/install_id/", token)
109
+ return data.get("uniqueValues", 0), data.get("topValues", [])
110
+ except urllib.error.HTTPError as e:
111
+ if e.code == 404:
112
+ return 0, [] # tag not present on any event for this issue
113
+ raise
114
+ except Exception:
115
+ return 0, []
116
+
117
+
118
+ def load_ledger():
119
+ if not os.path.exists(LEDGER_PATH):
120
+ return {"version": 1, "lastUpdated": None, "issues": {}}
121
+ try:
122
+ with open(LEDGER_PATH) as f:
123
+ return json.load(f)
124
+ except Exception:
125
+ return {"version": 1, "lastUpdated": None, "issues": {}}
126
+
127
+
128
+ def save_ledger(ledger):
129
+ os.makedirs(STATE_DIR, exist_ok=True)
130
+ tmp = LEDGER_PATH + ".tmp"
131
+ with open(tmp, "w") as f:
132
+ json.dump(ledger, f, indent=2)
133
+ os.replace(tmp, LEDGER_PATH)
134
+
135
+
136
+ def issue_link(short_id):
137
+ return f"https://mediar-n5.sentry.io/issues/?project={SENTRY_PROJECT_ID}&query={short_id}"
138
+
139
+
140
+ def gmail_service():
141
+ from google.auth.transport.requests import Request
142
+ from google.oauth2.credentials import Credentials
143
+ from googleapiclient.discovery import build
144
+
145
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
146
+ if creds.expired and creds.refresh_token:
147
+ creds.refresh(Request())
148
+ with open(GMAIL_TOKEN_PATH, "w") as f:
149
+ f.write(creds.to_json())
150
+ return build("gmail", "v1", credentials=creds)
151
+
152
+
153
+ def send_email(service, to_addr, subject, html_body):
154
+ msg = MIMEText(html_body, "html")
155
+ msg["to"] = to_addr
156
+ msg["subject"] = _scrub_dashes(subject)
157
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
158
+ result = service.users().messages().send(userId="me", body={"raw": raw}).execute()
159
+ return result["id"]
160
+
161
+
162
+ def build_rows_html(rows, growth=False):
163
+ cells = []
164
+ for r in rows:
165
+ link = issue_link(r["shortId"])
166
+ if growth:
167
+ cells.append(
168
+ f"<tr><td><a href='{link}'>{r['shortId']}</a>: {r['title']}</td>"
169
+ f"<td>{r['prevCount']} &rarr; {r['count']}</td>"
170
+ f"<td>{r['prevInstalls']} &rarr; {r['installs']}</td></tr>"
171
+ )
172
+ else:
173
+ cells.append(
174
+ f"<tr><td><a href='{link}'>{r['shortId']}</a>: {r['title']}</td>"
175
+ f"<td>{r['count']}</td><td>{r['installs']}</td></tr>"
176
+ )
177
+ return "".join(cells)
178
+
179
+
180
+ def build_html(new_rows, growing_rows, first_run, total_open):
181
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
182
+ sections = [f"<p><b>Critical Sentry issues (level:error/fatal), s4l project.</b> "
183
+ f"Open total: {total_open}. Generated {today}.</p>"]
184
+
185
+ if first_run:
186
+ sections.append("<p>First run: baselining every open critical issue. No investigation, "
187
+ "just a snapshot. Future runs only flag NEW or GROWING issues.</p>")
188
+
189
+ if new_rows and not first_run:
190
+ sections.append("<h3>New issues</h3><table border='1' cellpadding='6' "
191
+ "style='border-collapse:collapse'><tr><th>Issue</th><th>Events</th>"
192
+ "<th>Installs</th></tr>" + build_rows_html(new_rows) + "</table>")
193
+
194
+ if growing_rows:
195
+ sections.append("<h3>Growing issues</h3><table border='1' cellpadding='6' "
196
+ "style='border-collapse:collapse'><tr><th>Issue</th>"
197
+ "<th>Events (was &rarr; now)</th><th>Installs (was &rarr; now)</th></tr>"
198
+ + build_rows_html(growing_rows, growth=True) + "</table>")
199
+
200
+ if first_run:
201
+ top = sorted(new_rows, key=lambda r: -r["installs"])[:10]
202
+ sections.append("<h3>Top 10 by installs affected (baseline snapshot)</h3>"
203
+ "<table border='1' cellpadding='6' style='border-collapse:collapse'>"
204
+ "<tr><th>Issue</th><th>Events</th><th>Installs</th></tr>"
205
+ + build_rows_html(top) + "</table>")
206
+
207
+ sections.append("<p style='color:#888;font-size:12px'>Ranked by distinct install_id count, "
208
+ "not Sentry's userCount (unset for this project). level:warning menubar "
209
+ "signals are excluded; they're a different kind of event and still queryable "
210
+ "in Sentry directly during customer debugging.</p>")
211
+ return "<div style='font-family:sans-serif;max-width:800px'>" + "".join(sections) + "</div>"
212
+
213
+
214
+ def main():
215
+ parser = argparse.ArgumentParser()
216
+ parser.add_argument("--dry-run", action="store_true", help="Print plan, do not send email or write ledger.")
217
+ args = parser.parse_args()
218
+
219
+ token = _sentry_token()
220
+ if not token:
221
+ print("ERROR: no Sentry auth token (checked SENTRY_AUTH_TOKEN env and keychain sentry-auth-token)", file=sys.stderr)
222
+ sys.exit(1)
223
+
224
+ issues = fetch_critical_issues(token)
225
+ if not isinstance(issues, list):
226
+ print(f"ERROR: unexpected Sentry response: {issues}", file=sys.stderr)
227
+ sys.exit(1)
228
+
229
+ ledger = load_ledger()
230
+ known = ledger.get("issues", {})
231
+ first_run = len(known) == 0
232
+ now_iso = datetime.now(timezone.utc).isoformat()
233
+
234
+ new_rows, growing_rows = [], []
235
+ updated_issues = dict(known)
236
+
237
+ for issue in issues:
238
+ short_id = issue["shortId"]
239
+ count = int(issue.get("count", 0))
240
+ installs, _top = fetch_install_impact(issue["id"], token)
241
+ prev = known.get(short_id)
242
+
243
+ if prev is None:
244
+ new_rows.append({"shortId": short_id, "title": issue["title"][:90], "count": count, "installs": installs})
245
+ else:
246
+ prev_count = int(prev.get("lastEventCount", 0))
247
+ if not first_run and count - prev_count >= GROWTH_MIN_DELTA and prev_count > 0 and count >= prev_count * GROWTH_MIN_RATIO:
248
+ growing_rows.append({
249
+ "shortId": short_id, "title": issue["title"][:90],
250
+ "count": count, "prevCount": prev_count,
251
+ "installs": installs, "prevInstalls": prev.get("lastInstallCount", 0),
252
+ })
253
+
254
+ updated_issues[short_id] = {
255
+ "title": issue["title"][:200],
256
+ "lastEventCount": count,
257
+ "lastInstallCount": installs,
258
+ "firstSeenRun": (prev or {}).get("firstSeenRun", now_iso),
259
+ "lastSeenRun": now_iso,
260
+ }
261
+
262
+ print(f"firstRun={first_run} newCount={len(new_rows)} growingCount={len(growing_rows)} totalOpen={len(issues)}")
263
+
264
+ should_email = first_run or new_rows or growing_rows
265
+ if not should_email:
266
+ print("Nothing new or growing. No email.")
267
+ if not args.dry_run:
268
+ ledger["issues"] = updated_issues
269
+ ledger["lastUpdated"] = now_iso
270
+ save_ledger(ledger)
271
+ return
272
+
273
+ if first_run:
274
+ subject = f"[Sentry] s4l critical-issue digest live: {len(issues)} baselined"
275
+ elif new_rows and growing_rows:
276
+ subject = f"[Sentry] s4l: {len(new_rows)} new, {len(growing_rows)} growing critical issue(s)"
277
+ elif new_rows:
278
+ top_new = max(new_rows, key=lambda r: r["installs"])
279
+ subject = f"[Sentry] s4l: new critical issue, {top_new['shortId']} ({top_new['installs']} installs)"
280
+ else:
281
+ top_grow = max(growing_rows, key=lambda r: r["installs"])
282
+ subject = f"[Sentry] s4l: {top_grow['shortId']} growing ({top_grow['prevCount']} -> {top_grow['count']} events)"
283
+
284
+ html = build_html(new_rows, growing_rows, first_run, len(issues))
285
+
286
+ print(f"Subject: {subject}")
287
+ if args.dry_run:
288
+ print("--dry-run: not sending email, not writing ledger.")
289
+ print(html)
290
+ return
291
+
292
+ service = gmail_service()
293
+ msg_id = send_email(service, NOTIFICATION_EMAIL, subject, html)
294
+ print(f"Email sent: {msg_id}")
295
+
296
+ ledger["issues"] = updated_issues
297
+ ledger["lastUpdated"] = now_iso
298
+ save_ledger(ledger)
299
+ print("Ledger written.")
300
+
301
+
302
+ if __name__ == "__main__":
303
+ main()
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # sentry-digest.sh — critical (level:error/fatal) Sentry issue digest for the
3
+ # s4l Sentry project. Replaces the raw per-issue Sentry alert email; only
4
+ # emails when something is NEW or GROWING. Idempotent via
5
+ # scripts/state/sentry_digest_ledger.json. Wired by
6
+ # launchd/com.m13v.s4l-sentry-digest.plist (every 4 hours).
7
+
8
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ LOG_DIR="$REPO_DIR/skill/logs"
10
+ mkdir -p "$LOG_DIR"
11
+ LOG_FILE="$LOG_DIR/sentry-digest-$(date +%Y%m%d).log"
12
+
13
+ cd "$REPO_DIR" || exit 1
14
+
15
+ {
16
+ echo "=== $(date -u +%Y-%m-%dT%H:%M:%SZ) sentry-digest run ==="
17
+ /usr/bin/python3 scripts/sentry_digest.py
18
+ echo
19
+ } >> "$LOG_FILE" 2>&1