@m13v/s4l 1.7.2 → 1.7.4-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.
@@ -13,9 +13,11 @@ The panel is NONACTIVATING and auto-presented cards never take keyboard focus
13
13
  field becomes editable on first click, and only a user-initiated open
14
14
  (present_review(focus=True), the menu bar's "Review N pending drafts")
15
15
  activates the app and seats the caret immediately. A title-bar "Snooze 1h"
16
- button sits opposite the close cross; it and the cross both just close the
17
- panel, and the menu bar interprets any close with undecided drafts as a
18
- snooze (drafts stay pending, re-present after REVIEW_SNOOZE_SECONDS).
16
+ button sits top-left where the traffic lights would be (they're hidden: the
17
+ panel can't minimize or zoom, and the cross duplicated Snooze); it just
18
+ closes the panel, and the menu bar interprets any close with undecided
19
+ drafts (including Cmd-W) as a snooze (drafts stay pending, re-present after
20
+ REVIEW_SNOOZE_SECONDS).
19
21
 
20
22
  Decision shape: {"n": int, "approved": bool, "loved": bool, "text": str,
21
23
  "edited": bool, "drop_link": bool, "reject_category": str|None,
@@ -134,14 +136,30 @@ try:
134
136
  from AppKit import NSWindowStyleMaskNonactivatingPanel
135
137
  except Exception:
136
138
  NSWindowStyleMaskNonactivatingPanel = 0
137
- # Title-bar "Snooze 1h" button (sits opposite the close cross). Missing on
138
- # very old AppKit; the card then degrades to cross-only (closing still
139
- # snoozes, the label is just absent).
139
+ # Title-bar "Snooze 1h" button (top-left, where the traffic lights would be;
140
+ # those are hidden because minimize/zoom are disabled no-ops on this panel and
141
+ # the close cross just duplicated Snooze). Missing on very old AppKit; the
142
+ # card then degrades to the stock cross (closing still snoozes, the labeled
143
+ # button is just absent) because the traffic lights are only hidden when the
144
+ # accessory actually mounted.
140
145
  try:
141
- from AppKit import NSTitlebarAccessoryViewController, NSLayoutAttributeRight
146
+ from AppKit import (
147
+ NSTitlebarAccessoryViewController,
148
+ NSLayoutAttributeLeft,
149
+ NSLayoutAttributeRight,
150
+ )
142
151
  except Exception:
143
152
  NSTitlebarAccessoryViewController = None
153
+ NSLayoutAttributeLeft = None
144
154
  NSLayoutAttributeRight = None
155
+ try:
156
+ from AppKit import (
157
+ NSWindowCloseButton,
158
+ NSWindowMiniaturizeButton,
159
+ NSWindowZoomButton,
160
+ )
161
+ except Exception:
162
+ NSWindowCloseButton = NSWindowMiniaturizeButton = NSWindowZoomButton = None
145
163
  try:
146
164
  from AppKit import NSBezelStyleInline
147
165
  except Exception:
@@ -889,41 +907,95 @@ class _ReviewController(NSObject):
889
907
 
890
908
  @objc.python_method
891
909
  def _add_snooze_accessory(self, panel):
892
- """"Snooze 1h" in the title bar, opposite the close cross: the explicit
893
- "not now" affordance. It just closes the panel; the menu bar treats any
894
- close with undecided drafts as a snooze, so the cross and this button
895
- are the same action, this one merely says so out loud."""
896
- if NSTitlebarAccessoryViewController is None or NSLayoutAttributeRight is None:
910
+ """Title-bar controls, replacing the traffic lights: "Snooze 1h" at
911
+ the top-left (minimize/zoom were disabled no-op dots on this panel,
912
+ and the close cross meant snooze anyway, so one labeled button says
913
+ what the only dismissal actually does) and "Discard all…" at the
914
+ top-right (moved here from the menu bar dropdown 2026-07-10; the
915
+ ellipsis is honest, the handler opens a confirmation alert first).
916
+ Snooze just closes the panel; the menu bar treats any close with
917
+ undecided drafts as a snooze. The traffic lights are hidden ONLY once
918
+ the snooze accessory mounts, so a failure on old AppKit leaves the
919
+ stock cross as the fallback dismissal. Cmd-W keeps working either way
920
+ (the Closable mask stays on)."""
921
+ if NSTitlebarAccessoryViewController is None or NSLayoutAttributeLeft is None:
897
922
  return
898
923
  try:
899
- btn = NSButton.alloc().initWithFrame_(NSMakeRect(0, 0, 80, 17))
900
- btn.setTitle_(_snooze_title())
901
- if NSBezelStyleInline is not None:
902
- btn.setBezelStyle_(NSBezelStyleInline)
903
- else:
904
- btn.setBezelStyle_(NSBezelStyleRounded)
905
- btn.setFont_(NSFont.systemFontOfSize_(10.0))
906
- btn.setToolTip_(
907
- "Put these drafts away for now. They stay pending and the "
908
- "card comes back later (or sooner from the S4L menu)."
924
+ self._add_titlebar_button(
925
+ panel,
926
+ title=_snooze_title(),
927
+ action="snoozeClicked:",
928
+ tooltip=(
929
+ "Put these drafts away for now. They stay pending and the "
930
+ "card comes back later (or sooner from the S4L menu)."
931
+ ),
932
+ layout=NSLayoutAttributeLeft,
909
933
  )
910
- btn.setTarget_(self)
911
- btn.setAction_("snoozeClicked:")
912
- btn.sizeToFit()
913
- bf = btn.frame()
914
- holder = NSView.alloc().initWithFrame_(
915
- NSMakeRect(0, 0, bf.size.width + 8, max(19, bf.size.height + 2))
916
- )
917
- btn.setFrameOrigin_(
918
- (4, max(0, (holder.frame().size.height - bf.size.height) / 2.0))
919
- )
920
- holder.addSubview_(btn)
921
- acc = NSTitlebarAccessoryViewController.alloc().init()
922
- acc.setView_(holder)
923
- acc.setLayoutAttribute_(NSLayoutAttributeRight)
924
- panel.addTitlebarAccessoryViewController_(acc)
934
+ self._hide_traffic_lights(panel)
925
935
  except Exception as e:
926
936
  _log(f"snooze accessory unavailable: {e}")
937
+ if _discard_all_handler is not None and NSLayoutAttributeRight is not None:
938
+ try:
939
+ self._add_titlebar_button(
940
+ panel,
941
+ title="Discard all…",
942
+ action="discardAllClicked:",
943
+ tooltip=(
944
+ "Throw away every pending draft (asks first). Nothing "
945
+ "posts, and unlike a per-card reject this sends no "
946
+ "feedback signal to the AI."
947
+ ),
948
+ layout=NSLayoutAttributeRight,
949
+ )
950
+ except Exception as e:
951
+ _log(f"discard-all accessory unavailable: {e}")
952
+
953
+ @objc.python_method
954
+ def _add_titlebar_button(self, panel, title, action, tooltip, layout):
955
+ btn = NSButton.alloc().initWithFrame_(NSMakeRect(0, 0, 80, 17))
956
+ btn.setTitle_(title)
957
+ if NSBezelStyleInline is not None:
958
+ btn.setBezelStyle_(NSBezelStyleInline)
959
+ else:
960
+ btn.setBezelStyle_(NSBezelStyleRounded)
961
+ btn.setFont_(NSFont.systemFontOfSize_(10.0))
962
+ btn.setToolTip_(tooltip)
963
+ btn.setTarget_(self)
964
+ btn.setAction_(action)
965
+ btn.sizeToFit()
966
+ bf = btn.frame()
967
+ holder = NSView.alloc().initWithFrame_(
968
+ NSMakeRect(0, 0, bf.size.width + 8, max(19, bf.size.height + 2))
969
+ )
970
+ btn.setFrameOrigin_(
971
+ (4, max(0, (holder.frame().size.height - bf.size.height) / 2.0))
972
+ )
973
+ holder.addSubview_(btn)
974
+ acc = NSTitlebarAccessoryViewController.alloc().init()
975
+ acc.setView_(holder)
976
+ acc.setLayoutAttribute_(layout)
977
+ panel.addTitlebarAccessoryViewController_(acc)
978
+
979
+ @objc.python_method
980
+ def _hide_traffic_lights(self, panel):
981
+ """Hide close/minimize/zoom once the Snooze accessory is up: minimize
982
+ and zoom are disabled dots (no Miniaturizable/Resizable in the mask)
983
+ and the cross duplicated Snooze. Hiding, not removing: performClose_
984
+ (Cmd-W and snoozeClicked_) still routes through the hidden close
985
+ button's machinery, and windowShouldClose_ still fires."""
986
+ if NSWindowCloseButton is None:
987
+ return
988
+ for which in (
989
+ NSWindowCloseButton,
990
+ NSWindowMiniaturizeButton,
991
+ NSWindowZoomButton,
992
+ ):
993
+ try:
994
+ b = panel.standardWindowButton_(which)
995
+ if b is not None:
996
+ b.setHidden_(True)
997
+ except Exception:
998
+ pass
927
999
 
928
1000
  def snoozeClicked_(self, sender):
929
1001
  self._track("snooze")
@@ -933,6 +1005,20 @@ class _ReviewController(NSObject):
933
1005
  except Exception:
934
1006
  self._finish()
935
1007
 
1008
+ def discardAllClicked_(self, sender):
1009
+ """Hand off to the menu bar's bulk-discard handler (registered via
1010
+ set_discard_all_handler); it confirms with the user, flips the store,
1011
+ and dismisses this panel via dismiss_active(), so nothing more happens
1012
+ here. On cancel the card simply stays up."""
1013
+ self._track("discard_all")
1014
+ cb = _discard_all_handler
1015
+ if cb is None:
1016
+ return
1017
+ try:
1018
+ cb()
1019
+ except Exception as e:
1020
+ _log(f"discard-all handler failed: {e}")
1021
+
936
1022
  @objc.python_method
937
1023
  def _eye_button(self, frame, kind):
938
1024
  """Borderless SF-Symbol eye whose hover/click opens a popover. A plain
@@ -2122,6 +2208,21 @@ def set_feedback_handler(cb):
2122
2208
  _feedback_handler = cb
2123
2209
 
2124
2210
 
2211
+ # Bulk-discard hook for the title bar's "Discard all…" button, same
2212
+ # registration pattern as _feedback_handler above. The menu bar registers
2213
+ # _discard_all_pending here (it owns the confirmation alert, the store flip,
2214
+ # and dismissing the panel). Cards built while this is None simply don't get
2215
+ # the button.
2216
+ _discard_all_handler = None
2217
+
2218
+
2219
+ def set_discard_all_handler(cb):
2220
+ """Register the title-bar "Discard all…" action. The menu bar calls this
2221
+ before presenting cards with its bulk-discard handler."""
2222
+ global _discard_all_handler
2223
+ _discard_all_handler = cb
2224
+
2225
+
2125
2226
  def _feedback_frame():
2126
2227
  """Just below the open review card when there is one (so it never covers
2127
2228
  the card being reviewed), else the top-right corner of the pointer's
@@ -165,6 +165,38 @@ def _loop():
165
165
  pass
166
166
 
167
167
 
168
+ def emit(line, context="menubar"):
169
+ """Buffer ONE structured line for the relay under a caller-chosen context
170
+ (e.g. "browser-foreground"), and mirror it to the real stderr so the local
171
+ menubar.err.log keeps a copy. Bypasses the _Tee so the line is not ALSO
172
+ buffered under the default "menubar" context. Best-effort; never raises."""
173
+ try:
174
+ t = str(line).strip()
175
+ if not t or _BASE64_BLOB_RE.match(t):
176
+ return
177
+ real = sys.stderr._real if isinstance(sys.stderr, _Tee) else sys.stderr
178
+ try:
179
+ real.write(t + "\n")
180
+ real.flush()
181
+ except Exception:
182
+ pass
183
+ if not ENABLED:
184
+ return
185
+ with _lock:
186
+ _buf.append(
187
+ {
188
+ "ts": _now_iso(),
189
+ "stream": "stdout",
190
+ "line": t[:MAX_LINE_LEN],
191
+ "context": str(context or "menubar")[:200],
192
+ }
193
+ )
194
+ if len(_buf) > MAX_BUFFER:
195
+ del _buf[: len(_buf) - MAX_BUFFER]
196
+ except Exception:
197
+ pass
198
+
199
+
168
200
  def install():
169
201
  """Wrap sys.stderr with the relay tee and start the background flusher.
170
202
  Call once at menubar boot, after the S4L_REPO_DIR sys.path insertion."""
@@ -117,6 +117,19 @@ try:
117
117
  except Exception:
118
118
  pass
119
119
 
120
+ # Ground-truth browser-foreground telemetry: whenever a MANAGED harness Chrome
121
+ # becomes the frontmost app (fresh-launch activation, screencast bringToFront,
122
+ # macOS clamping the off-screen window back on-screen, anything), ship one
123
+ # structured line with context "browser-foreground" through the relay above.
124
+ # Cause-agnostic OS-level observation; the causing sites (screencast.ts) log
125
+ # their own attribution lines under the same context. Best-effort.
126
+ try:
127
+ import s4l_browser_foreground # noqa: E402
128
+
129
+ s4l_browser_foreground.install()
130
+ except Exception:
131
+ pass
132
+
120
133
 
121
134
  def _capture(err, **tags):
122
135
  """Report a handled menu-bar error to Sentry (component=menubar) without ever
@@ -2800,8 +2813,11 @@ class S4LMenuBar(rumps.App):
2800
2813
 
2801
2814
  # present_feedback (the menu bar's feedback item) falls back to
2802
2815
  # the module-level default handler; register ours before any
2803
- # card shows.
2816
+ # card shows. Same pattern for the title bar's "Discard all…"
2817
+ # button (moved out of the dropdown 2026-07-10), which reuses the
2818
+ # bulk-discard handler wholesale, confirmation alert included.
2804
2819
  s4l_card.set_feedback_handler(self._on_feedback_text)
2820
+ s4l_card.set_discard_all_handler(self._discard_all_pending)
2805
2821
  s4l_card.present_review(
2806
2822
  drafts,
2807
2823
  on_decision=lambda d: self._on_card_decision(batch, d),
@@ -3531,12 +3547,9 @@ class S4LMenuBar(rumps.App):
3531
3547
  )
3532
3548
  )
3533
3549
  )
3534
- items.append(
3535
- rumps.MenuItem(
3536
- f"Discard {pending_count} pending draft{plural}…",
3537
- callback=self._discard_all_pending,
3538
- )
3539
- )
3550
+ # No bulk-discard item here anymore: "Discard all…" lives in the
3551
+ # review card's title bar (2026-07-10). Reaching it with no card open
3552
+ # is "Review N pending drafts" -> Discard all, two clicks.
3540
3553
  return items
3541
3554
 
3542
3555
 
@@ -736,6 +736,37 @@ def _match_candidate(data, n, candidate_id):
736
736
  return None
737
737
 
738
738
 
739
+ def candidate_state(c):
740
+ """Canonical lifecycle state of ONE review-queue candidate. Use this instead
741
+ of ad hoc flag checks — "not posted" is NOT "awaiting review".
742
+
743
+ The review queue (review-queue.json) is an APPEND-FOREVER LEDGER: handled
744
+ candidates are never removed, they are flag-stamped in place, so most rows
745
+ in an old queue are retired. States, in precedence order:
746
+
747
+ posted — approved and successfully posted (our_url set).
748
+ terminal — retired without a post; terminal_reason says why
749
+ (rejected, human_rejected, human_discarded_all,
750
+ duplicate_thread_pre_post, ...; None on older rows).
751
+ post_failed — approved but the post attempt errored (post_error says
752
+ why). Settled from the cards' point of view; the resume
753
+ path retries only after a fresh approval clears it.
754
+ approved — human approved, post not yet attempted/confirmed.
755
+ awaiting_review — none of the above. The ONLY state cards present, and
756
+ the only honest "pending" count (review-request.json's
757
+ .count mirrors it at merge time).
758
+ """
759
+ if c.get("posted") is True:
760
+ return "posted"
761
+ if c.get("terminal") is True:
762
+ return "terminal"
763
+ if c.get("post_failed"):
764
+ return "post_failed"
765
+ if c.get("approved") is True:
766
+ return "approved"
767
+ return "awaiting_review"
768
+
769
+
739
770
  def store_stamp_decision(batch, decision):
740
771
  """Write a card decision INTO the store the instant the user clicks. This is
741
772
  the durable record (the old approved-queue.json ledger is no longer
@@ -784,7 +815,7 @@ def discard_all_pending(drafts):
784
815
  done = 0
785
816
  for d in drafts:
786
817
  c = _match_candidate(data, d.get("n"), d.get("candidate_id"))
787
- if c is None or c.get("posted") is True or c.get("terminal") is True:
818
+ if c is None or candidate_state(c) in ("posted", "terminal"):
788
819
  continue
789
820
  c["terminal"] = True
790
821
  c["terminal_reason"] = "human_discarded_all"
@@ -865,7 +896,7 @@ def store_pending_posts(batch="review-queue"):
865
896
  plan = read_plan(store_path())
866
897
  out = []
867
898
  for i, c in enumerate((plan or {}).get("candidates") or []):
868
- if not c.get("approved") or c.get("posted") or c.get("terminal") or c.get("post_failed"):
899
+ if candidate_state(c) != "approved":
869
900
  continue
870
901
  d = c.get("decision") or {}
871
902
  out.append(
@@ -932,7 +963,7 @@ def review_queue_posted_count():
932
963
  cands = (plan or {}).get("candidates")
933
964
  if not cands:
934
965
  return None
935
- return sum(1 for c in cands if c.get("posted") is True)
966
+ return sum(1 for c in cands if candidate_state(c) == "posted")
936
967
 
937
968
 
938
969
  def _plan_generation(batch):
@@ -1008,7 +1039,9 @@ def review_drafts(plan, batch="review-queue"):
1008
1039
  settled_ns = {it.get("n") for it in items if it.get("candidate_id") is None}
1009
1040
  out = []
1010
1041
  for i, c in enumerate(((plan or {}).get("candidates") or [])):
1011
- if c.get("posted") is True or c.get("terminal") is True or c.get("approved") is True:
1042
+ # Only awaiting_review rows become cards. This also skips post_failed
1043
+ # rows (decided-but-failed is settled per the docstring above).
1044
+ if candidate_state(c) != "awaiting_review":
1012
1045
  continue
1013
1046
  cid = c.get("candidate_id")
1014
1047
  if cid is not None and cid in settled_ids:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.2",
3
+ "version": "1.7.4-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.2",
3
+ "version": "1.7.4-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",
@@ -90,9 +90,14 @@ GROUNDING_INSTRUCTIONS = (
90
90
  " description 2-3 sentences: who this person is as a builder/voice.\n"
91
91
  " content_angle one paragraph of concrete, first-hand experience the "
92
92
  "persona can speak from (real projects, real numbers, real pain).\n"
93
- " voice {tone, never[]}: how they actually write (read their own "
94
- "posts/replies in the x source). Keep the organic rules: first person, "
95
- "specific, no links, no feature lists, no sales, no em dashes.\n"
93
+ " voice {tone, never[], examples[]}: how they actually write (read "
94
+ "their own posts/replies in the x source). Keep the organic rules: first "
95
+ "person, specific, no links, no feature lists, no sales, no em dashes. For "
96
+ "examples[], the x source pre-ranks their best real replies/posts by "
97
+ "engagement (top_replies / top_posts, each with stats, the parent tweet, and "
98
+ "thread continuations): keep up to 5 verbatim, or run "
99
+ "`voice_exemplars.py apply --scan <x-source.json>` after apply to write "
100
+ "voice.examples + the persona_corpus.txt exemplar section deterministically.\n"
96
101
  " search_topics ~15 topics they have genuine experience with.\n"
97
102
  " content_corpus (OPTIONAL but STRONGLY encouraged) the RAW gathered "
98
103
  "corpus as one plain-text block: the persona's actual posts, replies, repo "
@@ -1034,7 +1034,18 @@ def compute_target_distribution(platform, context="posting"):
1034
1034
  # mode="invent" and the prompt hands the model the top N as reference
1035
1035
  # material to derive a new style from.
1036
1036
 
1037
- INVENT_RATE = 0.05 # ~1 in 20 posts forces a new-style invention
1037
+ # 2026-07-10: inline invention retired (rate 0). It ran inside the drafting
1038
+ # prep session with the top-performers leaderboard + winner exemplars in
1039
+ # context, so every "new" style was a renamed clone of the winning
1040
+ # agree-then-relocate move; combined with name-only dedup the registry hit
1041
+ # ~938 styles that were mostly one structure. Invention now lives in the
1042
+ # standalone scripts/invent_styles.py job (operator-only launchd
1043
+ # com.m13v.s4l-invent-styles: the registry is global across installs, so a
1044
+ # central daily run replaces per-post rolls). The invent-mode plumbing in
1045
+ # pickers/posters is kept intact: register_style() is what the standalone
1046
+ # job calls, and a nonzero rate here re-enables the inline path if ever
1047
+ # wanted.
1048
+ INVENT_RATE = 0.0 # retired inline roll (was 0.05, ~1 in 20 posts)
1038
1049
  CURATED_TOP_N = 5 # size of the invent-mode reference list (top 5 by score)
1039
1050
 
1040
1051
  # Fallback target comment length (chars) for any style that lacks an explicit