@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.28",
3
- "installedAt": "2026-07-13T21:52:37.483Z"
2
+ "version": "1.7.4-rc.30",
3
+ "installedAt": "2026-07-13T22:13:17.873Z"
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.4-rc.28",
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": {
@@ -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 plus Other, with an optional free-text
50
- note). The category feeds the feedback-digest loop that distills human
51
- rejections into each project's learned_preferences config block; "Skip" keeps
52
- the old zero-friction reject (no reason recorded). Link clicks (author profile,
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 match the review_events.reject_category CHECK
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", "Draft doesn't sound right"),
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
- # no reason" sits right-aligned exactly where Reject was (one-click
1995
- # double-tap keeps the old zero-friction reject), Back sits at the
1996
- # left where Approve was.
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 - 150, H - 42, 150, 30))
2005
- skip.setTitle_("Reject, no reason")
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 whichever reason you pick)")
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.
@@ -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.28",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.4-rc.28",
3
+ "version": "1.7.4-rc.30",
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",