@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.
@@ -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.29",
3
+ "installedAt": "2026-07-13T22:06:08.099Z"
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.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": {
@@ -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
@@ -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."""
@@ -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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.4-rc.28",
3
+ "version": "1.7.4-rc.29",
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",