@m13v/s4l 1.7.4-rc.28 → 1.7.4-rc.29
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 +42 -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.29",
|
|
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
|
|
@@ -1127,6 +1135,40 @@ class S4LMenuBar(rumps.App):
|
|
|
1127
1135
|
"""Menu callback shim: rumps passes the clicked MenuItem last."""
|
|
1128
1136
|
self._set_split(share)
|
|
1129
1137
|
|
|
1138
|
+
# Reveal-cadence presets (seconds between fresh card pop-ups; 0 = as soon
|
|
1139
|
+
# as drafts are ready). Manual mode.json edits can set any value; the
|
|
1140
|
+
# checkmark then lands on an exact match or none, same as the lane split.
|
|
1141
|
+
REVEAL_CADENCE_PRESETS = (
|
|
1142
|
+
(0, "Immediately"),
|
|
1143
|
+
(1800, "Every 30 minutes"),
|
|
1144
|
+
(3600, "Every hour"),
|
|
1145
|
+
(14400, "Every 4 hours"),
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
def _cadence_label(self, secs=None):
|
|
1149
|
+
"""Human label for a cadence value, for menu copy."""
|
|
1150
|
+
if secs is None:
|
|
1151
|
+
secs = st.read_reveal_cadence()
|
|
1152
|
+
for preset, label in self.REVEAL_CADENCE_PRESETS:
|
|
1153
|
+
if round(secs) == preset:
|
|
1154
|
+
return label
|
|
1155
|
+
m = round(secs / 60)
|
|
1156
|
+
return f"Every {m} min" if m else "Immediately"
|
|
1157
|
+
|
|
1158
|
+
def _on_cadence_preset(self, secs, _sender=None):
|
|
1159
|
+
"""Menu callback shim: rumps passes the clicked MenuItem last."""
|
|
1160
|
+
written = st.write_reveal_cadence(secs)
|
|
1161
|
+
self._notify(
|
|
1162
|
+
"S4L draft cards",
|
|
1163
|
+
f"New draft cards will show: {self._cadence_label(written).lower()}",
|
|
1164
|
+
)
|
|
1165
|
+
self._sig = None
|
|
1166
|
+
try:
|
|
1167
|
+
self._tick(None)
|
|
1168
|
+
except Exception as e:
|
|
1169
|
+
sys.stderr.write(f"[s4l-menubar] cadence rebuild failed: {e}\n")
|
|
1170
|
+
sys.stderr.flush()
|
|
1171
|
+
|
|
1130
1172
|
def _set_split(self, share):
|
|
1131
1173
|
"""Set the personal-brand share (pure local mode.json write, same
|
|
1132
1174
|
contract as _toggle_lane) and rebuild so the checkmark moves instantly."""
|
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.29",
|
|
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