@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 +7 -1
- package/mcp/dist/setup.js +1 -1
- package/mcp/dist/telemetry.js +1 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +121 -165
- package/mcp/menubar/s4l_menubar.py +12 -6
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/build_persona.py +2 -2
- package/scripts/feedback_digest.py +7 -5
- package/scripts/pick_search_topic.py +16 -11
- package/skill/run-twitter-cycle.sh +15 -12
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 ~
|
|
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,
|
|
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.
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -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"),
|
|
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 = [
|
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.6.203-rc.
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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:
|
|
538
|
-
#
|
|
539
|
-
#
|
|
540
|
-
|
|
541
|
-
approve
|
|
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
|
-
|
|
567
|
+
approve.setToolTip_(
|
|
568
|
+
"Approve; click again for a stronger reaction (😄 → ❤️🔥)"
|
|
569
|
+
)
|
|
578
570
|
except Exception:
|
|
579
571
|
pass
|
|
580
|
-
content.addSubview_(
|
|
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
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
self.
|
|
980
|
-
|
|
981
|
-
self.
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
self.
|
|
1038
|
-
|
|
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
|
|
1045
|
-
text = ""
|
|
996
|
+
def commitApprove_(self, armed):
|
|
1046
997
|
try:
|
|
1047
|
-
|
|
998
|
+
armed = int(armed)
|
|
1048
999
|
except Exception:
|
|
1049
|
-
|
|
1050
|
-
if
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
|
1068
|
-
# in-progress edit of the reply is preserved for
|
|
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 -
|
|
1040
|
+
_label(NSMakeRect(M, H - 72, W - 2 * M, 20), "Why reject this draft?", size=13, bold=True)
|
|
1077
1041
|
)
|
|
1078
|
-
y = H -
|
|
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,
|
|
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).
|
|
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
|
|
1267
|
-
#
|
|
1268
|
-
#
|
|
1269
|
-
#
|
|
1270
|
-
#
|
|
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
|
-
#
|
|
2003
|
-
# module-level default handler; register ours before any
|
|
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
|
|
2126
|
-
|
|
2127
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/build_persona.py
CHANGED
|
@@ -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 ~
|
|
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()[:
|
|
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
|
|
22
|
-
|
|
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
|
|
26
|
-
|
|
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
|
|
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
|
|
574
|
-
# 10% of the time, if there's any
|
|
575
|
-
#
|
|
576
|
-
#
|
|
577
|
-
#
|
|
578
|
-
#
|
|
579
|
-
#
|
|
580
|
-
#
|
|
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("
|
|
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]
|
|
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
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
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
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
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
|