@m13v/s4l 1.7.4-rc.28 → 1.7.4-rc.30
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/menubar/s4l_card.py +20 -16
- package/mcp/menubar/s4l_menubar.py +121 -0
- package/mcp/menubar/s4l_state.py +40 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
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.4-rc.
|
|
5
|
+
"version": "1.7.4-rc.30",
|
|
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/menubar/s4l_card.py
CHANGED
|
@@ -46,10 +46,11 @@ menu bar registers at boot via `set_feedback_handler` (it ships
|
|
|
46
46
|
decision='feedback' review events).
|
|
47
47
|
|
|
48
48
|
Rejecting is a two-step flow: the Reject button swaps the card body for a
|
|
49
|
-
reason picker (three one-tap categories
|
|
50
|
-
note). The category feeds the feedback-digest loop that distills
|
|
51
|
-
rejections into each project's learned_preferences config block;
|
|
52
|
-
|
|
49
|
+
reason picker (three one-tap categories, all optional, plus an optional
|
|
50
|
+
free-text note). The category feeds the feedback-digest loop that distills
|
|
51
|
+
human rejections into each project's learned_preferences config block; the
|
|
52
|
+
picker's red Reject button commits with no category (the note, if typed, is
|
|
53
|
+
still sent). Link clicks (author profile,
|
|
53
54
|
thread ↗) and per-card dwell time ride along on the decision so the digest can
|
|
54
55
|
infer intent (e.g. profile-checked-then-rejected = author-quality signal) even
|
|
55
56
|
when the reason is skipped.
|
|
@@ -185,13 +186,15 @@ M = 16
|
|
|
185
186
|
NS_BEZEL_BORDER = 2 # NSBezelBorder
|
|
186
187
|
|
|
187
188
|
# Reject-reason categories, in display order. Tags are 1-based button tags on
|
|
188
|
-
# the reason picker; values must
|
|
189
|
-
# constraint server-side (wrong_author | off_topic | bad_draft | other).
|
|
189
|
+
# the reason picker; values must be a subset of the review_events.reject_category
|
|
190
|
+
# CHECK constraint server-side (wrong_author | off_topic | bad_draft | other).
|
|
191
|
+
# "other" is still valid server-side but no longer offered as a button: picking
|
|
192
|
+
# a category is optional (the red Reject button commits without one) and the
|
|
193
|
+
# free-text note covers everything a generic Other chip used to.
|
|
190
194
|
REJECT_REASONS = (
|
|
191
195
|
("wrong_author", "Wrong author / audience"),
|
|
192
196
|
("off_topic", "Off-topic thread"),
|
|
193
|
-
("bad_draft", "
|
|
194
|
-
("other", "Other"),
|
|
197
|
+
("bad_draft", "Feels like AI writing"),
|
|
195
198
|
)
|
|
196
199
|
|
|
197
200
|
# Client-side cap on tracked interactions per card (server clips at 50 too).
|
|
@@ -1990,10 +1993,11 @@ class _ReviewController(NSObject):
|
|
|
1990
1993
|
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
|
|
1991
1994
|
|
|
1992
1995
|
# Buttons at the TOP, above the title, mirroring the card's action
|
|
1993
|
-
# row so the cursor doesn't have to travel between steps: "Reject
|
|
1994
|
-
#
|
|
1995
|
-
#
|
|
1996
|
-
# left where Approve
|
|
1996
|
+
# row so the cursor doesn't have to travel between steps: "Reject"
|
|
1997
|
+
# sits exactly where the card's Reject was (one-click double-tap
|
|
1998
|
+
# keeps the old zero-friction reject) and commits with no category,
|
|
1999
|
+
# since picking one is optional; Back sits at the left where Approve
|
|
2000
|
+
# was.
|
|
1997
2001
|
back = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 90, 30))
|
|
1998
2002
|
back.setTitle_("Back")
|
|
1999
2003
|
back.setBezelStyle_(NSBezelStyleRounded)
|
|
@@ -2001,8 +2005,8 @@ class _ReviewController(NSObject):
|
|
|
2001
2005
|
back.setAction_("rejectBack:")
|
|
2002
2006
|
content.addSubview_(back)
|
|
2003
2007
|
|
|
2004
|
-
skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M -
|
|
2005
|
-
skip.setTitle_("Reject
|
|
2008
|
+
skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
|
|
2009
|
+
skip.setTitle_("Reject")
|
|
2006
2010
|
skip.setBezelStyle_(NSBezelStyleRounded)
|
|
2007
2011
|
try:
|
|
2008
2012
|
skip.setHasDestructiveAction_(True) # red title on macOS 12+
|
|
@@ -2013,7 +2017,7 @@ class _ReviewController(NSObject):
|
|
|
2013
2017
|
content.addSubview_(skip)
|
|
2014
2018
|
|
|
2015
2019
|
content.addSubview_(
|
|
2016
|
-
_label(NSMakeRect(M, H - 72, W - 2 * M, 20), "Why reject this draft?", size=13, bold=True)
|
|
2020
|
+
_label(NSMakeRect(M, H - 72, W - 2 * M, 20), "Why reject this draft? (optional)", size=13, bold=True)
|
|
2017
2021
|
)
|
|
2018
2022
|
y = H - 106
|
|
2019
2023
|
for i, (_, title) in enumerate(REJECT_REASONS):
|
|
@@ -2061,7 +2065,7 @@ class _ReviewController(NSObject):
|
|
|
2061
2065
|
note.setEditable_(True)
|
|
2062
2066
|
note.setFont_(NSFont.systemFontOfSize_(12))
|
|
2063
2067
|
try:
|
|
2064
|
-
note.setPlaceholderString_("Optional note (sent with
|
|
2068
|
+
note.setPlaceholderString_("Optional note (sent with or without a reason)")
|
|
2065
2069
|
note.cell().setWraps_(True)
|
|
2066
2070
|
except Exception:
|
|
2067
2071
|
pass
|
|
@@ -463,6 +463,14 @@ REVIEW_UNATTENDED_SENTRY_SECONDS = 3600.0
|
|
|
463
463
|
REVIEW_SNOOZE_SECONDS = float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))
|
|
464
464
|
SNOOZE_FILE = os.path.join(st.state_dir(), "review-snooze.json")
|
|
465
465
|
|
|
466
|
+
# Reveal cadence (2026-07-13): how often FRESH draft cards may pop, independent
|
|
467
|
+
# of how often the pipeline drafts (mode.json `reveal_cadence_secs`, default 1h,
|
|
468
|
+
# 0 = as soon as ready). Only fresh presentations are paced: an already-open
|
|
469
|
+
# card still grows live via extend_active, snooze still parks everything, and
|
|
470
|
+
# the menu's "Review N pending drafts" always bypasses. The last-presented
|
|
471
|
+
# epoch is persisted so a menubar restart mid-interval doesn't re-pop early.
|
|
472
|
+
REVEAL_STAMP_FILE = os.path.join(st.state_dir(), "review-last-presented.json")
|
|
473
|
+
|
|
466
474
|
|
|
467
475
|
def _label_elapsed_secs(label):
|
|
468
476
|
"""Parse the trailing duration the producer encodes in a drafting activity
|
|
@@ -547,6 +555,9 @@ class S4LMenuBar(rumps.App):
|
|
|
547
555
|
# (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
|
|
548
556
|
# doesn't re-pop the card the user just put away.
|
|
549
557
|
self._review_snooze_until = self._read_snooze_until()
|
|
558
|
+
# Reveal cadence: when the last fresh card presentation happened (epoch,
|
|
559
|
+
# 0 = never). Loaded from disk so a restart mid-interval doesn't re-pop.
|
|
560
|
+
self._last_presented_at = self._read_last_presented()
|
|
550
561
|
# Unattended-review watchdog state (_maybe_heal_review).
|
|
551
562
|
self._review_heal_at = 0.0
|
|
552
563
|
self._review_unattended_notified = False
|
|
@@ -1127,6 +1138,40 @@ class S4LMenuBar(rumps.App):
|
|
|
1127
1138
|
"""Menu callback shim: rumps passes the clicked MenuItem last."""
|
|
1128
1139
|
self._set_split(share)
|
|
1129
1140
|
|
|
1141
|
+
# Reveal-cadence presets (seconds between fresh card pop-ups; 0 = as soon
|
|
1142
|
+
# as drafts are ready). Manual mode.json edits can set any value; the
|
|
1143
|
+
# checkmark then lands on an exact match or none, same as the lane split.
|
|
1144
|
+
REVEAL_CADENCE_PRESETS = (
|
|
1145
|
+
(0, "Immediately"),
|
|
1146
|
+
(1800, "Every 30 minutes"),
|
|
1147
|
+
(3600, "Every hour"),
|
|
1148
|
+
(14400, "Every 4 hours"),
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
def _cadence_label(self, secs=None):
|
|
1152
|
+
"""Human label for a cadence value, for menu copy."""
|
|
1153
|
+
if secs is None:
|
|
1154
|
+
secs = st.read_reveal_cadence()
|
|
1155
|
+
for preset, label in self.REVEAL_CADENCE_PRESETS:
|
|
1156
|
+
if round(secs) == preset:
|
|
1157
|
+
return label
|
|
1158
|
+
m = round(secs / 60)
|
|
1159
|
+
return f"Every {m} min" if m else "Immediately"
|
|
1160
|
+
|
|
1161
|
+
def _on_cadence_preset(self, secs, _sender=None):
|
|
1162
|
+
"""Menu callback shim: rumps passes the clicked MenuItem last."""
|
|
1163
|
+
written = st.write_reveal_cadence(secs)
|
|
1164
|
+
self._notify(
|
|
1165
|
+
"S4L draft cards",
|
|
1166
|
+
f"New draft cards will show: {self._cadence_label(written).lower()}",
|
|
1167
|
+
)
|
|
1168
|
+
self._sig = None
|
|
1169
|
+
try:
|
|
1170
|
+
self._tick(None)
|
|
1171
|
+
except Exception as e:
|
|
1172
|
+
sys.stderr.write(f"[s4l-menubar] cadence rebuild failed: {e}\n")
|
|
1173
|
+
sys.stderr.flush()
|
|
1174
|
+
|
|
1130
1175
|
def _set_split(self, share):
|
|
1131
1176
|
"""Set the personal-brand share (pure local mode.json write, same
|
|
1132
1177
|
contract as _toggle_lane) and rebuild so the checkmark moves instantly."""
|
|
@@ -2625,6 +2670,11 @@ class S4LMenuBar(rumps.App):
|
|
|
2625
2670
|
int(self._review_snooze_until)
|
|
2626
2671
|
if time.time() < self._review_snooze_until
|
|
2627
2672
|
else 0,
|
|
2673
|
+
# Reveal cadence: rebuilds when the preset changes (checkmark +
|
|
2674
|
+
# submenu title) and when a hold starts or lapses (the "Next cards
|
|
2675
|
+
# around HH:MM" line in _state_c).
|
|
2676
|
+
round(st.read_reveal_cadence()),
|
|
2677
|
+
int(self._reveal_hold_until(pending_count)),
|
|
2628
2678
|
)
|
|
2629
2679
|
if sig != self._sig:
|
|
2630
2680
|
self._sig = sig
|
|
@@ -2723,6 +2773,37 @@ class S4LMenuBar(rumps.App):
|
|
|
2723
2773
|
except Exception:
|
|
2724
2774
|
pass
|
|
2725
2775
|
|
|
2776
|
+
def _read_last_presented(self):
|
|
2777
|
+
try:
|
|
2778
|
+
with open(REVEAL_STAMP_FILE) as f:
|
|
2779
|
+
return float((json.load(f) or {}).get("at") or 0.0)
|
|
2780
|
+
except Exception:
|
|
2781
|
+
return 0.0
|
|
2782
|
+
|
|
2783
|
+
def _stamp_presented(self):
|
|
2784
|
+
"""Record a fresh card presentation, mirrored to disk (restart-safe).
|
|
2785
|
+
Starts the next reveal-cadence interval."""
|
|
2786
|
+
self._last_presented_at = time.time()
|
|
2787
|
+
try:
|
|
2788
|
+
with open(REVEAL_STAMP_FILE, "w") as f:
|
|
2789
|
+
json.dump({"at": self._last_presented_at}, f)
|
|
2790
|
+
except Exception:
|
|
2791
|
+
pass
|
|
2792
|
+
|
|
2793
|
+
def _reveal_hold_until(self, pending_count):
|
|
2794
|
+
"""Epoch until which the reveal cadence is actually holding fresh cards
|
|
2795
|
+
back (0.0 = no hold). A hold only exists while there is something to
|
|
2796
|
+
hold: pending drafts, no review in flight, and not snoozed (the snooze
|
|
2797
|
+
label and gate take precedence)."""
|
|
2798
|
+
if pending_count <= 0 or self._review_active:
|
|
2799
|
+
return 0.0
|
|
2800
|
+
now = time.time()
|
|
2801
|
+
if now < self._review_snooze_until:
|
|
2802
|
+
return 0.0
|
|
2803
|
+
cadence = st.read_reveal_cadence()
|
|
2804
|
+
until = self._last_presented_at + cadence
|
|
2805
|
+
return until if (cadence > 0 and now < until) else 0.0
|
|
2806
|
+
|
|
2726
2807
|
def _review_now(self, _=None):
|
|
2727
2808
|
"""Menu: bring the pending draft cards up right now. Clears any snooze;
|
|
2728
2809
|
this is the one presentation path that may take keyboard focus (the
|
|
@@ -2804,6 +2885,17 @@ class S4LMenuBar(rumps.App):
|
|
|
2804
2885
|
sys.stderr.flush()
|
|
2805
2886
|
self._last_review_sig = sig
|
|
2806
2887
|
return
|
|
2888
|
+
# Reveal cadence: hold FRESH pop-ups until the configured interval since
|
|
2889
|
+
# the last presentation has passed (0 = show immediately). Drafting is
|
|
2890
|
+
# untouched; the backlog keeps accumulating and is presented whole once
|
|
2891
|
+
# due. Deliberately after the extend-open-card path above (an on-screen
|
|
2892
|
+
# card keeps growing live) and skipped for focus=True (the menu's
|
|
2893
|
+
# "Review N pending drafts" is an explicit ask). _last_review_sig is NOT
|
|
2894
|
+
# stamped on a hold, so the same set still presents fresh when due.
|
|
2895
|
+
if not focus:
|
|
2896
|
+
cadence = st.read_reveal_cadence()
|
|
2897
|
+
if cadence > 0 and time.time() - self._last_presented_at < cadence:
|
|
2898
|
+
return
|
|
2807
2899
|
with self._review_lock:
|
|
2808
2900
|
self._reset_posting_progress_locked()
|
|
2809
2901
|
self._review_active = True
|
|
@@ -2827,6 +2919,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2827
2919
|
# Record as shown only AFTER the cards are actually up, so a transient
|
|
2828
2920
|
# card-UI failure never permanently suppresses this pending set.
|
|
2829
2921
|
self._last_review_sig = sig
|
|
2922
|
+
self._stamp_presented()
|
|
2830
2923
|
# No macOS notification for fresh drafts, per explicit user
|
|
2831
2924
|
# request (2026-07-03): the card itself is the surface. A missed
|
|
2832
2925
|
# card is the unattended watchdog's job (it heals the window and
|
|
@@ -3415,6 +3508,22 @@ class S4LMenuBar(rumps.App):
|
|
|
3415
3508
|
split_menu.add(it)
|
|
3416
3509
|
items.append(split_menu)
|
|
3417
3510
|
|
|
3511
|
+
# Reveal cadence: how often fresh draft cards may pop (the drafting
|
|
3512
|
+
# pipeline is unchanged; this only paces the pop-up). Always offered:
|
|
3513
|
+
# it is the "don't interrupt me every few minutes" control.
|
|
3514
|
+
cadence = st.read_reveal_cadence()
|
|
3515
|
+
cadence_menu = rumps.MenuItem(
|
|
3516
|
+
f"Show draft cards: {self._cadence_label(cadence).lower()}"
|
|
3517
|
+
)
|
|
3518
|
+
for preset, label in self.REVEAL_CADENCE_PRESETS:
|
|
3519
|
+
it = rumps.MenuItem(
|
|
3520
|
+
label,
|
|
3521
|
+
callback=functools.partial(self._on_cadence_preset, preset),
|
|
3522
|
+
)
|
|
3523
|
+
it.state = 1 if round(cadence) == preset else 0
|
|
3524
|
+
cadence_menu.add(it)
|
|
3525
|
+
items.append(cadence_menu)
|
|
3526
|
+
|
|
3418
3527
|
items.append(rumps.separator)
|
|
3419
3528
|
items.append(rumps.MenuItem("Open dashboard", callback=self._open_dashboard))
|
|
3420
3529
|
# The one entry point for overall feedback (the review card no longer
|
|
@@ -3553,6 +3662,18 @@ class S4LMenuBar(rumps.App):
|
|
|
3553
3662
|
)
|
|
3554
3663
|
)
|
|
3555
3664
|
)
|
|
3665
|
+
else:
|
|
3666
|
+
# Reveal-cadence hold: the backlog exists but the pop-up is being
|
|
3667
|
+
# paced. Same courtesy line as the snooze; "Review N pending
|
|
3668
|
+
# drafts" above shows them right now regardless.
|
|
3669
|
+
hold_until = self._reveal_hold_until(pending_count)
|
|
3670
|
+
if hold_until:
|
|
3671
|
+
items.append(
|
|
3672
|
+
self._label(
|
|
3673
|
+
"Next cards around "
|
|
3674
|
+
+ time.strftime("%H:%M", time.localtime(hold_until))
|
|
3675
|
+
)
|
|
3676
|
+
)
|
|
3556
3677
|
# No bulk-discard item here anymore: "Discard all…" lives in the
|
|
3557
3678
|
# review card's title bar (2026-07-10). Reaching it with no card open
|
|
3558
3679
|
# is "Review N pending drafts" -> Discard all, two clicks.
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -1251,6 +1251,46 @@ def write_split(share):
|
|
|
1251
1251
|
return share
|
|
1252
1252
|
|
|
1253
1253
|
|
|
1254
|
+
# Draft-card reveal cadence: minimum seconds between FRESH card pop-ups
|
|
1255
|
+
# (0 = present as soon as drafts are ready). Drafting itself is untouched;
|
|
1256
|
+
# drafts keep accumulating in the review store and the menubar just paces the
|
|
1257
|
+
# pop-up. Lives in mode.json (every writer preserves unknown keys). Presets
|
|
1258
|
+
# are the menubar's REVEAL_CADENCE_PRESETS; any non-negative value is valid.
|
|
1259
|
+
REVEAL_CADENCE_DEFAULT = 3600.0
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def read_reveal_cadence():
|
|
1263
|
+
"""Seconds between draft-card reveals (0 = immediately, default 1 hour)."""
|
|
1264
|
+
d = read_json(MODE_FILE)
|
|
1265
|
+
try:
|
|
1266
|
+
secs = float(d.get("reveal_cadence_secs"))
|
|
1267
|
+
except (TypeError, ValueError, AttributeError):
|
|
1268
|
+
return REVEAL_CADENCE_DEFAULT
|
|
1269
|
+
return max(0.0, secs)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def write_reveal_cadence(secs):
|
|
1273
|
+
"""Persist the reveal cadence, preserving every other mode.json key.
|
|
1274
|
+
Returns the written value. Never raises (a menu click must not crash)."""
|
|
1275
|
+
try:
|
|
1276
|
+
secs = max(0.0, float(secs))
|
|
1277
|
+
except (TypeError, ValueError):
|
|
1278
|
+
return read_reveal_cadence()
|
|
1279
|
+
try:
|
|
1280
|
+
payload = read_json(MODE_FILE)
|
|
1281
|
+
if not isinstance(payload, dict):
|
|
1282
|
+
payload = {}
|
|
1283
|
+
payload["reveal_cadence_secs"] = secs
|
|
1284
|
+
p = Path(state_dir()) / MODE_FILE
|
|
1285
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1286
|
+
tmp = p.with_suffix(".json.tmp")
|
|
1287
|
+
tmp.write_text(json.dumps(payload))
|
|
1288
|
+
os.replace(str(tmp), str(p))
|
|
1289
|
+
except Exception:
|
|
1290
|
+
pass
|
|
1291
|
+
return secs
|
|
1292
|
+
|
|
1293
|
+
|
|
1254
1294
|
def write_mode(mode):
|
|
1255
1295
|
"""Legacy single-mode setter: named lane ON, the other OFF (compat)."""
|
|
1256
1296
|
if mode not in _VALID_MODES:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.30",
|
|
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