@m13v/s4l 1.6.203-rc.1 → 1.6.203-rc.2

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/index.js CHANGED
@@ -1301,7 +1301,7 @@ tool("engagement_mode", {
1301
1301
  .optional()
1302
1302
  .describe("The RAW voice-memo transcript from the onboarding dictation interview, VERBATIM (do NOT " +
1303
1303
  "paraphrase or summarize). Persisted to the persona_corpus.txt sidecar (never config.json), " +
1304
- "capped ~8000 chars. This is the grounding pool the drafter quotes real specifics from " +
1304
+ "capped ~100000 chars. This is the grounding pool the drafter quotes real specifics from " +
1305
1305
  "(actual projects, numbers, opinions, phrasing), so keep it dense and first-hand."),
1306
1306
  voice: z
1307
1307
  .any()
@@ -2962,6 +2962,12 @@ function kickerEnv() {
2962
2962
  S4L_STATE_DIR: sapsStateDir(),
2963
2963
  TWITTER_TAIL_LINK_RATE: "0",
2964
2964
  TWITTER_PAGE_GEN_RATE: "0",
2965
+ // Rolling virality bar percentile for the draft lane (2026-07-03): the
2966
+ // cycle script now applies the bar to DRAFT_ONLY too, so below-bar picks
2967
+ // never become review cards. p0.90 (not the script's 0.97 default) keeps
2968
+ // card volume useful while cutting bottom-of-pool drafts; cold start
2969
+ // (sample_count < 200) still shows brand-new installs every draft.
2970
+ S4L_TWITTER_VIRALITY_PCTILE: "0.90",
2965
2971
  };
2966
2972
  }
2967
2973
  async function ensureQueueKickerInstalled() {
package/mcp/dist/setup.js CHANGED
@@ -399,7 +399,7 @@ export function ensurePersonaProject(grounding) {
399
399
  if (grounding && typeof grounding.content_corpus === "string" && grounding.content_corpus.trim()) {
400
400
  try {
401
401
  const corpusPath = path.join(path.dirname(cfgPath), "persona_corpus.txt");
402
- fs.writeFileSync(corpusPath, grounding.content_corpus.trim().slice(0, 8000) + "\n", "utf-8");
402
+ fs.writeFileSync(corpusPath, grounding.content_corpus.trim().slice(0, 100000) + "\n", "utf-8");
403
403
  }
404
404
  catch {
405
405
  // ignore: corpus is grounding fuel, not required for a working persona.
@@ -205,7 +205,7 @@ function collectStateSnapshot() {
205
205
  const config = readJsonCapped(cfgPath, 512_000);
206
206
  if (config !== undefined)
207
207
  state.config = config;
208
- const corpus = readTextCapped(path.join(path.dirname(cfgPath), "persona_corpus.txt"), 16_000);
208
+ const corpus = readTextCapped(path.join(path.dirname(cfgPath), "persona_corpus.txt"), 100_000);
209
209
  if (corpus !== undefined)
210
210
  state.persona_corpus = corpus;
211
211
  const stateFiles = [
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.203-rc.1",
3
- "installedAt": "2026-07-04T02:38:15.358Z"
2
+ "version": "1.6.203-rc.2",
3
+ "installedAt": "2026-07-04T04:11:58.027Z"
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.6.203-rc.1",
5
+ "version": "1.6.203-rc.2",
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": {
@@ -13,19 +13,23 @@ Decision shape: {"n": int, "approved": bool, "loved": bool, "text": str,
13
13
  "reject_note": str|None, "interactions": [{"type": str, "ts": str}],
14
14
  "dwell_ms": int}
15
15
 
16
- Approving comes in two strengths: the plain Approve button, and the 😄 button
17
- right next to it, which approves AND stamps loved=True: the user's "this one
18
- was a really good pick" signal, which the feedback digest treats as strong
19
- positive evidence (a plain approve is merely counter-evidence against avoid
20
- entries).
21
-
22
- Overall feedback (guidance not tied to any single thread) has two entry
23
- points that share one submit handler, registered by the menu bar via
24
- `set_feedback_handler` (it ships decision='feedback' review events): the
25
- card's Feedback button swaps the card body for an in-window composer (a
26
- separate floating panel was tried and opened where the user never saw it on
27
- a multi-monitor setup), and the menu bar's "Send feedback…" item opens the
28
- standalone `present_feedback(on_submit)` panel (no card window need exist).
16
+ Approving is ONE button with click-to-escalate strength (two side-by-side
17
+ approve buttons were tried first and read as clutter, 2026-07-03 feedback):
18
+ the first click arms a plain approve, and each extra click within the short
19
+ commit window bumps the reaction (👍 😄 ❤️‍🔥). The decision commits when
20
+ the clicks stop. Any level past the first stamps loved=True: the user's
21
+ "this one was a really good pick" signal, which the feedback digest treats
22
+ as strong positive evidence (a plain approve is merely counter-evidence
23
+ against avoid entries); the exact level rides along as an approve_level_N
24
+ interaction.
25
+
26
+ Overall feedback (guidance not tied to any single thread) lives in the MENU
27
+ BAR only (the card had its own Feedback button + in-window composer, but it
28
+ crowded the action row and was orthogonal to the per-draft decision, so it
29
+ moved out, 2026-07-03 feedback): the menu bar's feedback item opens the
30
+ standalone `present_feedback(on_submit)` panel, whose submit handler the
31
+ menu bar registers at boot via `set_feedback_handler` (it ships
32
+ decision='feedback' review events).
29
33
 
30
34
  Rejecting is a two-step flow: the Reject button swaps the card body for a
31
35
  reason picker (three one-tap categories plus Other, with an optional free-text
@@ -118,6 +122,16 @@ REJECT_REASONS = (
118
122
  # Client-side cap on tracked interactions per card (server clips at 50 too).
119
123
  MAX_INTERACTIONS = 50
120
124
 
125
+ # One-button approve escalation: button title per armed level (index = level).
126
+ # Level 1 = plain approve; 2+ = loved=True on the decision, with the exact
127
+ # level shipped as an approve_level_N interaction for the feedback digest.
128
+ APPROVE_TITLES = ("Approve", "Approve 👍", "Approve 😄", "Approve ❤️‍🔥")
129
+ APPROVE_MAX_LEVEL = len(APPROVE_TITLES) - 1
130
+ # How long after the last approve click the decision commits. Long enough to
131
+ # land a second/third click, short enough that a single approve still feels
132
+ # instant-ish.
133
+ APPROVE_COMMIT_DELAY = 0.6
134
+
121
135
  # Review-surface state mirrored to the state dir for out-of-process observers
122
136
  # (the menu bar watchdog, the dashboard, a debugging session). In the 2026-07-02
123
137
  # incident a card sat unseen for 3 hours and NOTHING on disk could distinguish
@@ -358,7 +372,13 @@ class _ReviewController(NSObject):
358
372
  self._interactions = []
359
373
  self._card_shown_at = None
360
374
  self._reason_field = None
361
- self._feedback_tv = None
375
+ # Armed-but-uncommitted approve level for the CURRENT card (0 = none).
376
+ # Doubles as the staleness token for the delayed commit: every
377
+ # scheduled commit carries the level it was armed with, and only the
378
+ # one matching the live level fires (reject/advance/close just zero
379
+ # the level to void all in-flight commits — no cancellation API).
380
+ self._approve_level = 0
381
+ self._approve_btn = None
362
382
  # Attention anchors for the unattended-review watchdog: the stack counts
363
383
  # as "touched" on present, on any tracked interaction, and on any
364
384
  # decision. No touch past the watchdog threshold = the user is not
@@ -532,52 +552,25 @@ class _ReviewController(NSObject):
532
552
  self._interactions = []
533
553
  self._card_shown_at = time.time()
534
554
  self._reason_field = None
555
+ self._approve_level = 0 # voids any in-flight delayed commit
535
556
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
536
557
 
537
- # Buttons at the TOP, one line: Approve, Approve 😄 (approve + loved),
538
- # Feedback (overall feedback, decides nothing), Reject at the right.
539
- # Widths are hand-fit so all four bezeled buttons share the 348pt row
540
- # without truncating their titles.
541
- approve = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 78, 30))
542
- approve.setTitle_("Approve")
558
+ # Buttons at the TOP, one line: a single Approve at the left (click
559
+ # again within the commit window for a stronger reaction), Reject at
560
+ # the right. Overall feedback moved to the menu bar item.
561
+ approve = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 130, 30))
562
+ approve.setTitle_(APPROVE_TITLES[0])
543
563
  approve.setBezelStyle_(NSBezelStyleRounded)
544
564
  approve.setTarget_(self)
545
565
  approve.setAction_("approve:")
546
- content.addSubview_(approve)
547
-
548
- # "Approve 😄" = approve with the "really good one" signal (loved=True
549
- # on the decision). Same posting path as Approve; only the feedback
550
- # rail sees the difference. Labeled as an approve variant, not a bare
551
- # emoji: a lone 😄 read as decoration and users doubted the click
552
- # registered (2026-07-03 feedback).
553
- smile = NSButton.alloc().initWithFrame_(NSMakeRect(M + 82, H - 42, 100, 30))
554
- smile.setTitle_("Approve 😄")
555
- smile.setBezelStyle_(NSBezelStyleRounded)
556
- smile.setTarget_(self)
557
- smile.setAction_("approveLoved:")
558
- try:
559
- smile.setToolTip_("Approve and mark as a really good pick")
560
- except Exception:
561
- pass
562
- content.addSubview_(smile)
563
-
564
- # Feedback = overall feedback (about the pipeline, not this draft).
565
- # Swaps the card body for the composer IN THIS WINDOW; a separate
566
- # floating panel was tried first and opened somewhere the user never
567
- # saw on a multi-monitor setup (2026-07-03: 7 clicks, zero sightings).
568
- # A borderless bubble.left icon was tried next and read as decoration,
569
- # not a button; a labeled bezel button matching its row mates won
570
- # (2026-07-03 feedback).
571
- fb = NSButton.alloc().initWithFrame_(NSMakeRect(M + 186, H - 42, 90, 30))
572
- fb.setTitle_("Feedback")
573
- fb.setBezelStyle_(NSBezelStyleRounded)
574
- fb.setTarget_(self)
575
- fb.setAction_("feedbackOpen:")
576
566
  try:
577
- fb.setToolTip_("Send overall feedback (not about this draft)")
567
+ approve.setToolTip_(
568
+ "Approve; click again for a stronger reaction (😄 → ❤️‍🔥)"
569
+ )
578
570
  except Exception:
579
571
  pass
580
- content.addSubview_(fb)
572
+ content.addSubview_(approve)
573
+ self._approve_btn = approve
581
574
 
582
575
  reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
583
576
  reject.setTitle_("Reject")
@@ -736,8 +729,11 @@ class _ReviewController(NSObject):
736
729
  self._textview = tv
737
730
 
738
731
  self._panel.setContentView_(content)
739
- # Counter lives in the native title bar, not inside the content.
740
- self._panel.setTitle_(f"Review draft {self._idx + 1} of {len(self._drafts)}")
732
+ # Counter lives in the native title bar, not inside the content, with
733
+ # the product name so a stray card is identifiable at a glance.
734
+ self._panel.setTitle_(
735
+ f"s4l · Review draft {self._idx + 1} of {len(self._drafts)}"
736
+ )
741
737
  # setContentView_ rebuilds the view tree, so the caret would otherwise
742
738
  # default to the Approve button. Re-seat it in the reply field for every
743
739
  # card (not just the first) so each one is immediately editable.
@@ -947,7 +943,7 @@ class _ReviewController(NSObject):
947
943
  # reset the caret / clobber an in-progress edit on the current card).
948
944
  try:
949
945
  self._panel.setTitle_(
950
- f"Review draft {self._idx + 1} of {len(self._drafts)}"
946
+ f"s4l · Review draft {self._idx + 1} of {len(self._drafts)}"
951
947
  )
952
948
  except Exception:
953
949
  pass
@@ -970,112 +966,80 @@ class _ReviewController(NSObject):
970
966
 
971
967
  # ObjC selectors (trailing underscore -> "approve:" etc.)
972
968
  def approve_(self, sender):
973
- self._record(True)
974
- self._fire_decision()
975
- self._advance()
976
-
977
- def approveLoved_(self, sender):
978
- _log("approved with love")
979
- self._record(True, loved=True)
980
- self._fire_decision()
981
- self._advance()
982
-
983
- def feedbackOpen_(self, sender):
984
- # Overall feedback is orthogonal to the card decision: swap the card
985
- # body for the composer (same pattern as the reject reason picker),
986
- # preserving any in-progress reply edit for Back. In THIS window on
987
- # purpose: a separate floating panel opened somewhere the user never
988
- # saw on a multi-monitor setup.
989
- self._track("feedback_open")
990
- self._pending_reply_text = self._current_text()
991
- self._render_feedback()
992
-
993
- @objc.python_method
994
- def _render_feedback(self):
995
- content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
996
- content.addSubview_(
997
- _label(NSMakeRect(M, H - 46, W - 2 * M, 20), "Overall feedback", size=13, bold=True)
998
- )
999
- content.addSubview_(
1000
- _label(
1001
- NSMakeRect(M, H - 80, W - 2 * M, 32),
1002
- "Standing guidance for the drafting loop (thread choice, tone, "
1003
- "audience), not about this draft.",
1004
- size=11,
1005
- muted=True,
1006
- )
969
+ # One button, click-to-escalate: every click bumps the armed level
970
+ # (capped) and restarts the commit window. Each scheduled commit
971
+ # carries the level it was armed with; only the one still matching
972
+ # self._approve_level when it fires actually commits, so extra
973
+ # clicks, reject, advance, and close all void in-flight commits by
974
+ # changing/zeroing the level.
975
+ if self._approve_level < APPROVE_MAX_LEVEL:
976
+ self._approve_level += 1
977
+ level = self._approve_level
978
+ try:
979
+ self._approve_btn.setTitle_(APPROVE_TITLES[level])
980
+ except Exception:
981
+ pass
982
+ _log(f"approve armed at level {level}")
983
+ self.performSelector_withObject_afterDelay_(
984
+ "commitApprove:", level, APPROVE_COMMIT_DELAY
1007
985
  )
1008
- scroll, tv = _editable_scroll(NSMakeRect(M, 54, W - 2 * M, H - 86 - 54))
1009
- content.addSubview_(scroll)
1010
- self._feedback_tv = tv
1011
-
1012
- back = NSButton.alloc().initWithFrame_(NSMakeRect(M, 14, 90, 30))
1013
- back.setTitle_("Back")
1014
- back.setBezelStyle_(NSBezelStyleRounded)
1015
- back.setTarget_(self)
1016
- back.setAction_("cardFeedbackBack:")
1017
- content.addSubview_(back)
1018
-
1019
- send = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 90, 14, 90, 30))
1020
- send.setTitle_("Send")
1021
- send.setBezelStyle_(NSBezelStyleRounded)
1022
- send.setTarget_(self)
1023
- send.setAction_("cardFeedbackSend:")
1024
- content.addSubview_(send)
1025
-
1026
- self._textview = None
1027
- self._panel.setContentView_(content)
1028
- self._panel.makeFirstResponder_(tv)
1029
- _log("in-card feedback composer shown")
1030
986
 
1031
987
  @objc.python_method
1032
- def _restore_card(self):
1033
- # Re-render the card and restore any reply edit made before the swap
1034
- # (same mechanism as rejectBack_).
1035
- pending = getattr(self, "_pending_reply_text", None)
1036
- self._feedback_tv = None
1037
- self._render()
1038
- if pending is not None and self._textview is not None:
1039
- try:
1040
- self._textview.setString_(pending)
1041
- except Exception:
1042
- pass
988
+ def _commit_approve(self, level):
989
+ """Record the armed approve. Level 2+ is the loved signal; the exact
990
+ strength rides along as an approve_level_N interaction."""
991
+ if level > 1:
992
+ self._track(f"approve_level_{level}")
993
+ self._record(True, loved=level > 1)
994
+ self._fire_decision()
1043
995
 
1044
- def cardFeedbackSend_(self, sender):
1045
- text = ""
996
+ def commitApprove_(self, armed):
1046
997
  try:
1047
- text = str(self._feedback_tv.string()).strip()
998
+ armed = int(armed)
1048
999
  except Exception:
1049
- pass
1050
- if not text:
1051
- _log("in-card feedback sent empty; dropped")
1052
- else:
1053
- _log(f"in-card feedback submitted ({len(text)} chars)")
1054
- if _feedback_handler is not None:
1055
- try:
1056
- _feedback_handler(text)
1057
- except Exception:
1058
- pass
1059
- self._restore_card()
1060
-
1061
- def cardFeedbackBack_(self, sender):
1062
- _log("in-card feedback cancelled")
1063
- self._restore_card()
1000
+ return
1001
+ if armed == 0 or armed != self._approve_level:
1002
+ return # stale: escalated further, rejected, advanced, or closed
1003
+ self._approve_level = 0
1004
+ self._commit_approve(armed)
1005
+ self._advance()
1064
1006
 
1065
1007
  def reject_(self, sender):
1066
1008
  # Two-step reject: swap the card body for the reason picker. The
1067
- # decision is recorded when a reason chip (or Skip) is clicked. Any
1068
- # in-progress edit of the reply is preserved for Back.
1009
+ # decision is recorded when a reason chip (or the no-reason button)
1010
+ # is clicked. Any in-progress edit of the reply is preserved for
1011
+ # Back. Voids an armed-but-uncommitted approve (last click wins).
1012
+ self._approve_level = 0
1069
1013
  self._pending_reply_text = self._current_text()
1070
1014
  self._render_reason()
1071
1015
 
1072
1016
  @objc.python_method
1073
1017
  def _render_reason(self):
1074
1018
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
1019
+
1020
+ # Buttons at the TOP, above the title, mirroring the card's action
1021
+ # row so the cursor doesn't have to travel between steps: "Reject,
1022
+ # no reason" sits right-aligned exactly where Reject was (one-click
1023
+ # double-tap keeps the old zero-friction reject), Back sits at the
1024
+ # left where Approve was.
1025
+ back = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 90, 30))
1026
+ back.setTitle_("Back")
1027
+ back.setBezelStyle_(NSBezelStyleRounded)
1028
+ back.setTarget_(self)
1029
+ back.setAction_("rejectBack:")
1030
+ content.addSubview_(back)
1031
+
1032
+ skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 150, H - 42, 150, 30))
1033
+ skip.setTitle_("Reject, no reason")
1034
+ skip.setBezelStyle_(NSBezelStyleRounded)
1035
+ skip.setTarget_(self)
1036
+ skip.setAction_("rejectSkip:")
1037
+ content.addSubview_(skip)
1038
+
1075
1039
  content.addSubview_(
1076
- _label(NSMakeRect(M, H - 46, W - 2 * M, 20), "Why reject this draft?", size=13, bold=True)
1040
+ _label(NSMakeRect(M, H - 72, W - 2 * M, 20), "Why reject this draft?", size=13, bold=True)
1077
1041
  )
1078
- y = H - 82
1042
+ y = H - 106
1079
1043
  for i, (_, title) in enumerate(REJECT_REASONS):
1080
1044
  btn = NSButton.alloc().initWithFrame_(NSMakeRect(M, y, W - 2 * M, 28))
1081
1045
  btn.setTitle_(title)
@@ -1085,7 +1049,7 @@ class _ReviewController(NSObject):
1085
1049
  btn.setAction_("rejectReason:")
1086
1050
  content.addSubview_(btn)
1087
1051
  y -= 32
1088
- note = NSTextField.alloc().initWithFrame_(NSMakeRect(M, 56, W - 2 * M, 48))
1052
+ note = NSTextField.alloc().initWithFrame_(NSMakeRect(M, 14, W - 2 * M, y + 28 - 14 - 8))
1089
1053
  note.setEditable_(True)
1090
1054
  note.setBezeled_(True)
1091
1055
  note.setFont_(NSFont.systemFontOfSize_(12))
@@ -1097,20 +1061,6 @@ class _ReviewController(NSObject):
1097
1061
  content.addSubview_(note)
1098
1062
  self._reason_field = note
1099
1063
 
1100
- back = NSButton.alloc().initWithFrame_(NSMakeRect(M, 14, 90, 30))
1101
- back.setTitle_("Back")
1102
- back.setBezelStyle_(NSBezelStyleRounded)
1103
- back.setTarget_(self)
1104
- back.setAction_("rejectBack:")
1105
- content.addSubview_(back)
1106
-
1107
- skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 150, 14, 150, 30))
1108
- skip.setTitle_("Reject (no reason)")
1109
- skip.setBezelStyle_(NSBezelStyleRounded)
1110
- skip.setTarget_(self)
1111
- skip.setAction_("rejectSkip:")
1112
- content.addSubview_(skip)
1113
-
1114
1064
  self._textview = None
1115
1065
  self._panel.setContentView_(content)
1116
1066
  self._panel.makeFirstResponder_(note)
@@ -1150,7 +1100,13 @@ class _ReviewController(NSObject):
1150
1100
 
1151
1101
  def windowShouldClose_(self, sender):
1152
1102
  # Closing the window stops review; remaining cards are left undecided
1153
- # (not posted). Finish with whatever was decided so far.
1103
+ # (not posted). An approve armed within the last commit window is
1104
+ # committed first — the user DID click Approve; losing it to a fast
1105
+ # close would silently drop a decision. Finish with whatever was
1106
+ # decided so far.
1107
+ if self._approve_level:
1108
+ level, self._approve_level = self._approve_level, 0
1109
+ self._commit_approve(level)
1154
1110
  self._finish()
1155
1111
  return True
1156
1112
 
@@ -1263,11 +1219,11 @@ def heal_active():
1263
1219
  # ---- overall-feedback composer ----------------------------------------------
1264
1220
  # One small floating panel with a free-text field, for guidance that is about
1265
1221
  # the PIPELINE rather than any single draft ("less shilling", "more dev
1266
- # threads", ...). Reachable from the card's Feedback button and the menu bar's
1267
- # "Send feedback…" item; both call present_feedback(), which falls back to the
1268
- # handler the menu bar registered at boot via set_feedback_handler() (that
1269
- # handler ships a decision='feedback' review event down the same outbox rail
1270
- # as card decisions, so the digest processes it the same way).
1222
+ # threads", ...). Reachable ONLY from the menu bar's feedback item, which
1223
+ # calls present_feedback(); that falls back to the handler the menu bar
1224
+ # registered at boot via set_feedback_handler() (the handler ships a
1225
+ # decision='feedback' review event down the same outbox rail as card
1226
+ # decisions, so the digest processes it the same way).
1271
1227
 
1272
1228
  FB_W = 380
1273
1229
  FB_H = 200
@@ -1999,8 +1999,9 @@ class S4LMenuBar(rumps.App):
1999
1999
  try:
2000
2000
  import s4l_card
2001
2001
 
2002
- # The card's 💬 (overall feedback) button opens the composer via the
2003
- # module-level default handler; register ours before any card shows.
2002
+ # present_feedback (the menu bar's feedback item) falls back to
2003
+ # the module-level default handler; register ours before any
2004
+ # card shows.
2004
2005
  s4l_card.set_feedback_handler(self._on_feedback_text)
2005
2006
  s4l_card.present_review(
2006
2007
  drafts,
@@ -2122,9 +2123,10 @@ class S4LMenuBar(rumps.App):
2122
2123
  pass
2123
2124
 
2124
2125
  def _on_feedback_text(self, text):
2125
- """Ship overall feedback (the card's 💬 button or the menu bar's
2126
- "Send feedback…" item) as a decision='feedback' review event on the
2127
- same outbox rail as card decisions. project is intentionally omitted
2126
+ """Ship overall feedback (the menu bar's "Give feedback…" item; the
2127
+ card had its own button once but it moved out, 2026-07-03 feedback)
2128
+ as a decision='feedback' review event on the same outbox rail as
2129
+ card decisions. project is intentionally omitted
2128
2130
  (NULL server-side): the feedback digest folds project-less feedback
2129
2131
  into EVERY configured project's prompt."""
2130
2132
  body = (text or "").strip()[:2000]
@@ -2386,7 +2388,11 @@ class S4LMenuBar(rumps.App):
2386
2388
 
2387
2389
  items.append(rumps.separator)
2388
2390
  items.append(rumps.MenuItem("Open dashboard", callback=self._open_dashboard))
2389
- items.append(rumps.MenuItem("Send feedback…", callback=self._menu_feedback))
2391
+ # The one entry point for overall feedback (the review card no longer
2392
+ # carries a Feedback button); named for what it does to the pipeline,
2393
+ # not the mechanism.
2394
+ items.append(rumps.MenuItem("Give feedback…", callback=self._menu_feedback))
2395
+ items.append(self._label(" overall guidance, steers future drafts"))
2390
2396
  # While the update-verify marker is pending, the pipeline copy still
2391
2397
  # resolves the OLD version (it only advances once the restarted server
2392
2398
  # re-provisions repo/package, ~2 min), so the snapshot honestly reports
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.6.203-rc.1",
3
+ "version": "1.6.203-rc.2",
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.6.203-rc.1",
3
+ "version": "1.6.203-rc.2",
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",
@@ -98,7 +98,7 @@ GROUNDING_INSTRUCTIONS = (
98
98
  "corpus as one plain-text block: the persona's actual posts, replies, repo "
99
99
  "descriptions, site copy, verbatim. This is NOT synthesized. It becomes the "
100
100
  "grounding pool the drafter quotes real specifics from, so keep it dense and "
101
- "first-hand. Trim only obvious noise; do NOT paraphrase. Cap ~8000 chars.\n"
101
+ "first-hand. Trim only obvious noise; do NOT paraphrase. Cap ~100000 chars.\n"
102
102
  + PUBLIC_ONLY_NOTE
103
103
  + "\nThen write the fields to /tmp/persona.json and run "
104
104
  "`build_persona.py apply --from /tmp/persona.json`, or hand them to the "
@@ -346,7 +346,7 @@ def cmd_apply(args) -> int:
346
346
  if isinstance(corpus, str) and corpus.strip():
347
347
  corpus_path = cfg_path.parent / "persona_corpus.txt"
348
348
  try:
349
- corpus_path.write_text(corpus.strip()[:8000] + "\n")
349
+ corpus_path.write_text(corpus.strip()[:100000] + "\n")
350
350
  corpus_written = str(corpus_path)
351
351
  changed.append("content_corpus")
352
352
  except Exception as e:
@@ -18,12 +18,14 @@ scripts/learned_preferences.py for the full loop). Per run:
18
18
  never digested twice. Events are marked processed even when the plan is
19
19
  "no changes" — a considered no-op is a completed digestion, not a retry.
20
20
 
21
- Overall feedback (decision='feedback', project IS NULL; typed into the card's
22
- 💬 composer or the menu bar's "Send feedback…" item) is fetched once per run,
21
+ Overall feedback (decision='feedback', project IS NULL; typed into the
22
+ composer behind the menu bar's "Give feedback…" item) is fetched once per run,
23
23
  folded into EVERY configured project's prompt as explicit standing guidance,
24
24
  and marked processed only after all attempted project digests succeed.
25
- Loved approvals (the card's 😄 button) arrive as loved=true on approved
26
- events and are surfaced to the model as strong positive evidence.
25
+ Loved approvals (the card's escalated approve: extra clicks on the one
26
+ Approve button) arrive as loved=true on approved events, with the exact
27
+ strength as an approve_level_N interaction, and are surfaced to the model
28
+ as strong positive evidence.
27
29
 
28
30
  Failure handling: a Claude failure or unparseable plan leaves the events
29
31
  unprocessed for the next run. A run-level flock prevents concurrent digests.
@@ -163,7 +165,7 @@ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
163
165
  NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(approved)} approved, {len(loved)} of the approvals loved):
164
166
  {ev_lines}{overall_block}
165
167
 
166
- Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user pressed the emphatic-approve button ("this was a really good one"): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
168
+ Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user clicked Approve extra times for a stronger reaction ("this was a really good one"; approve_level_N in interactions carries the strength): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
167
169
 
168
170
  You can also block SPECIFIC authors via the plan's block_authors list. A block is a permanent hard exclusion of that one handle from all future thread selection, so it is YOUR judgment call, never automatic. Block when the evidence is strong: a wrong_author reject IS a direct human statement about that author (especially with profile_click), and the author context (author_followers, their post, found_via_topic) or the user's note confirms the account itself was the problem rather than the topic. Do NOT block when the reject looks topic-driven (off_topic/bad_draft on a reasonable account) or when you are unsure; the generalizable TYPE entry in audience_avoid is the softer tool for that.
169
171
 
@@ -570,24 +570,29 @@ def pick_topic_for_project(project_name, platform="twitter",
570
570
  for i in range(min(REFERENCE_TOP_N, len(pool)))
571
571
  ]
572
572
 
573
- # 2026-05-28 dedicated explore branch for freshly-invented topics.
574
- # 10% of the time, if there's any source='invented' + attempts_n==0
575
- # topic in the pool, pick one of those uniformly. Gives invent_topics.py
576
- # outputs a guaranteed sampling cadence (~1 sample every other day per
577
- # project at default 5 picks/day) instead of languishing at the
578
- # COLD_TOPIC_WEIGHT floor for weeks. Falls through to the normal
579
- # weighted-random branch when (a) no eligible invented-untried topics
580
- # exist OR (b) the random roll lands outside the explore rate.
573
+ # 2026-05-28 dedicated explore branch for never-tried topics.
574
+ # 10% of the time, if there's any attempts_n==0 topic in the pool
575
+ # (regardless of source: invented by invent_topics.py OR freshly
576
+ # seeded/appended into config.json by a human or agent), pick one of
577
+ # those uniformly. Gives every brand-new topic a guaranteed sampling
578
+ # cadence (~1 sample every other day per project at default 5 picks/day)
579
+ # instead of languishing at the COLD_TOPIC_WEIGHT floor for weeks.
580
+ # 2026-07-04: widened from source=='invented' to any attempts_n==0 so a
581
+ # topic added to config after bootstrap (behaviorally identical to an
582
+ # invention: new, untried, needs its first shot) gets the same first-shot
583
+ # lane and proves itself on real click/supply signal from there. Falls
584
+ # through to the normal weighted-random branch when (a) no eligible
585
+ # untried topics exist OR (b) the random roll lands outside the rate.
581
586
  invented_untried_idxs = [
582
587
  i for i, r in enumerate(pool)
583
- if r.get("source") == "invented"
584
- and int(r.get("attempts_n") or 0) == 0
588
+ if int(r.get("attempts_n") or 0) == 0
585
589
  ]
586
590
  if invented_untried_idxs and rnd.random() < INVENTED_UNTRIED_EXPLORE_RATE:
587
591
  chosen_idx = rnd.choice(invented_untried_idxs)
588
592
  sys.stderr.write(
589
- f"[pick_search_topic] invented_untried_explore "
593
+ f"[pick_search_topic] untried_explore "
590
594
  f"project={project_name!r} pool_size={len(invented_untried_idxs)} "
595
+ f"source={pool[chosen_idx].get('source')!r} "
591
596
  f"chosen={pool[chosen_idx]['search_topic']!r}\n"
592
597
  )
593
598
  chosen = pool[chosen_idx]
@@ -2128,19 +2128,23 @@ POST_TOP_N="${S4L_TWITTER_POST_TOP_N:-1}"
2128
2128
  # (~20-30 / 8h) with NO hard cap: the bar is the Nth percentile of the install's
2129
2129
  # OWN recent candidate pool (via /api/v1/twitter-candidates/virality-threshold),
2130
2130
  # so it self-calibrates to cadence and niche instead of being a fixed number.
2131
+ # The bar applies to BOTH lanes (2026-07-03, per user instruction): the
2132
+ # autopilot lane drops below-bar picks before POSTING, and the DRAFT_ONLY lane
2133
+ # drops them before they become review cards, so human review time is never
2134
+ # spent on bottom-of-pool drafts. Dropped picks stay status='pending' (never
2135
+ # 'rejected'); Phase 0 salvages and re-judges them next cycle.
2131
2136
  # The bar is OFF (empty threshold) when:
2132
- # - DRAFT_ONLY: new users / manual review see every draft (we don't even fetch).
2133
2137
  # - Cold start: sample_count < min, so a fresh pool posts ungated until it fills.
2138
+ # (This is also what keeps brand-new installs seeing every draft card.)
2134
2139
  # - Fetch failure: fail-open, never silence posting on a transient API blip.
2135
2140
  # Tunables: S4L_TWITTER_VIRALITY_PCTILE (default 0.97),
2136
2141
  # S4L_TWITTER_VIRALITY_MIN_SAMPLE (default 200).
2137
- VIRALITY_THRESHOLD=""
2138
- if [ "${DRAFT_ONLY:-0}" != "1" ]; then
2139
- VIRALITY_THRESHOLD=$(S4L_VPCTILE="${S4L_TWITTER_VIRALITY_PCTILE:-0.97}" \
2140
- S4L_VMIN="${S4L_TWITTER_VIRALITY_MIN_SAMPLE:-200}" \
2141
- python3 -c "
2142
+ VIRALITY_THRESHOLD=$(S4L_VPCTILE="${S4L_TWITTER_VIRALITY_PCTILE:-0.97}" \
2143
+ S4L_VMIN="${S4L_TWITTER_VIRALITY_MIN_SAMPLE:-200}" \
2144
+ S4L_SCRIPTS_DIR="$REPO_DIR/scripts" \
2145
+ python3 -c "
2142
2146
  import os, sys
2143
- sys.path.insert(0, os.path.expanduser('~/social-autoposter/scripts'))
2147
+ sys.path.insert(0, os.environ.get('S4L_SCRIPTS_DIR') or os.path.expanduser('~/social-autoposter/scripts'))
2144
2148
  from http_api import api_get
2145
2149
  try:
2146
2150
  r = api_get('/api/v1/twitter-candidates/virality-threshold',
@@ -2154,11 +2158,10 @@ try:
2154
2158
  except BaseException as e:
2155
2159
  sys.stderr.write(f'virality-bar fetch failed (bar OFF this cycle): {e}\n')
2156
2160
  " 2>>"$LOG_FILE" || echo "")
2157
- if [ -n "$VIRALITY_THRESHOLD" ]; then
2158
- log "Virality bar ACTIVE: p${S4L_TWITTER_VIRALITY_PCTILE:-0.97} = $VIRALITY_THRESHOLD (this install, trailing 24h); top-1 posts only if it clears the bar."
2159
- else
2160
- log "Virality bar OFF this cycle (cold-start/thin pool or fetch failed); top-1 posts ungated."
2161
- fi
2161
+ if [ -n "$VIRALITY_THRESHOLD" ]; then
2162
+ log "Virality bar ACTIVE: p${S4L_TWITTER_VIRALITY_PCTILE:-0.97} = $VIRALITY_THRESHOLD (this install, trailing 24h); top-1 kept only if it clears the bar."
2163
+ else
2164
+ log "Virality bar OFF this cycle (cold-start/thin pool or fetch failed); top-1 kept ungated."
2162
2165
  fi
2163
2166
 
2164
2167
  # Parse the prep envelope and write the plan to \$PLAN_FILE; also extract the