@m13v/s4l 1.7.1-rc.21 → 1.7.1-rc.22
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/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +61 -25
- package/mcp/package.json +1 -1
- package/package.json +2 -1
- package/scripts/merge_review_queue.py +42 -16
- package/scripts/scan_pii.py +9 -1
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.7.1-rc.
|
|
5
|
+
"version": "1.7.1-rc.22",
|
|
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
|
@@ -619,7 +619,7 @@ class _ReviewController(NSObject):
|
|
|
619
619
|
# Two-draft cards (2026-07-08 redesign, no-recommendation pass same
|
|
620
620
|
# day): both drafts render at once as separate editable boxes;
|
|
621
621
|
# `_selected_draft` (0=a, 1=b) is "whichever box the reviewer is
|
|
622
|
-
# currently in", driven by
|
|
622
|
+
# currently in", driven by caret movement (see textViewDidChangeSelection_ below),
|
|
623
623
|
# not a button. None = not yet chosen this card, _render() defaults
|
|
624
624
|
# it to slot 0 (Draft A) — the model never picks a favorite, so
|
|
625
625
|
# there's no recommendation to default to instead. Reset to None on
|
|
@@ -1076,9 +1076,9 @@ class _ReviewController(NSObject):
|
|
|
1076
1076
|
# day): show BOTH drafts at once, stacked, each independently
|
|
1077
1077
|
# editable, rather than a toggle that swaps one field's content. No
|
|
1078
1078
|
# buttons: selection is "whichever box the reviewer is currently in",
|
|
1079
|
-
# shown via that box
|
|
1080
|
-
# hairline, mirroring a standard focus ring) and
|
|
1081
|
-
#
|
|
1079
|
+
# shown via a dedicated outline view wrapping that box (an accent
|
|
1080
|
+
# outline vs a plain hairline, mirroring a standard focus ring) and
|
|
1081
|
+
# updated live by textViewDidChangeSelection_ below. The model never picks a favorite
|
|
1082
1082
|
# (removed 2026-07-08 per user: no ask-the-model-to-recommend), so
|
|
1083
1083
|
# Draft A (slot 0) is simply the fixed default until the reviewer
|
|
1084
1084
|
# clicks into Draft B. Absent/short (reused stale draft, legacy plan)
|
|
@@ -1089,7 +1089,7 @@ class _ReviewController(NSObject):
|
|
|
1089
1089
|
if dual:
|
|
1090
1090
|
self._selected_draft = sel_idx
|
|
1091
1091
|
|
|
1092
|
-
edit_top = H - 172 -
|
|
1092
|
+
edit_top = H - 172 - 3
|
|
1093
1093
|
link = d.get("link_url")
|
|
1094
1094
|
|
|
1095
1095
|
# Tail link baked at draft time is normally already in each draft's
|
|
@@ -1102,10 +1102,15 @@ class _ReviewController(NSObject):
|
|
|
1102
1102
|
|
|
1103
1103
|
self._draft_textviews = {}
|
|
1104
1104
|
self._draft_scrolls = {}
|
|
1105
|
+
self._draft_outlines = {}
|
|
1105
1106
|
if dual:
|
|
1106
1107
|
avail_h = edit_top - M
|
|
1107
1108
|
gap = 5
|
|
1108
|
-
|
|
1109
|
+
# Reserved only for foreign-language cards, which show a muted
|
|
1110
|
+
# "EN:" line above each box; English drafts (the common case)
|
|
1111
|
+
# have nothing to put there, so reserving it unconditionally used
|
|
1112
|
+
# to leave a dead 15px gap above every box for no reason.
|
|
1113
|
+
label_h = 15 if is_foreign else 0
|
|
1109
1114
|
unit = (avail_h - gap) / 2.0
|
|
1110
1115
|
box_h = unit - label_h
|
|
1111
1116
|
for slot in (0, 1):
|
|
@@ -1126,14 +1131,33 @@ class _ReviewController(NSObject):
|
|
|
1126
1131
|
truncates=True,
|
|
1127
1132
|
)
|
|
1128
1133
|
)
|
|
1134
|
+
# The selection ring can't be drawn on the scroll view's own
|
|
1135
|
+
# layer: NSScrollView's opaque clip/document view fully paints
|
|
1136
|
+
# over its parent layer's border, so setBorderWidth/Color on
|
|
1137
|
+
# scroll.layer() is accepted (no error) but never visibly
|
|
1138
|
+
# renders, at any width or color (verified empirically). A
|
|
1139
|
+
# separate outline view, sized to the box and holding the
|
|
1140
|
+
# scroll view inset a few px inside it, keeps the ring
|
|
1141
|
+
# unobstructed since nothing opaque reaches its edge.
|
|
1142
|
+
outline_frame = NSMakeRect(M, box_y, W - 2 * M, box_h)
|
|
1143
|
+
outline = NSView.alloc().initWithFrame_(outline_frame)
|
|
1144
|
+
_round_rect(outline)
|
|
1145
|
+
inset = 3
|
|
1129
1146
|
scroll, tv = _editable_scroll(
|
|
1130
|
-
NSMakeRect(
|
|
1147
|
+
NSMakeRect(
|
|
1148
|
+
inset,
|
|
1149
|
+
inset,
|
|
1150
|
+
outline_frame.size.width - 2 * inset,
|
|
1151
|
+
outline_frame.size.height - 2 * inset,
|
|
1152
|
+
),
|
|
1153
|
+
_compose(draft.get("text")),
|
|
1131
1154
|
)
|
|
1132
1155
|
tv.setDelegate_(self)
|
|
1133
|
-
|
|
1156
|
+
outline.addSubview_(scroll)
|
|
1157
|
+
content.addSubview_(outline)
|
|
1134
1158
|
self._draft_scrolls[slot] = scroll
|
|
1159
|
+
self._draft_outlines[slot] = outline
|
|
1135
1160
|
self._draft_textviews[slot] = tv
|
|
1136
|
-
self._update_draft_borders()
|
|
1137
1161
|
tv = self._draft_textviews[sel_idx]
|
|
1138
1162
|
else:
|
|
1139
1163
|
reply = d.get("reply_text") or ""
|
|
@@ -1161,6 +1185,13 @@ class _ReviewController(NSObject):
|
|
|
1161
1185
|
self._textview = tv
|
|
1162
1186
|
|
|
1163
1187
|
self._panel.setContentView_(_frosted(content))
|
|
1188
|
+
if dual:
|
|
1189
|
+
# Layer border properties set before a view is attached to its
|
|
1190
|
+
# eventual window get silently dropped when AppKit backs the view
|
|
1191
|
+
# for real on attach, so the accent outline must be (re)applied
|
|
1192
|
+
# only after setContentView_ installs the view tree, not during
|
|
1193
|
+
# construction above.
|
|
1194
|
+
self._update_draft_borders()
|
|
1164
1195
|
# Counter lives in the native title bar, not inside the content, with
|
|
1165
1196
|
# the product name so a stray card is identifiable at a glance.
|
|
1166
1197
|
self._panel.setTitle_(
|
|
@@ -1342,14 +1373,18 @@ class _ReviewController(NSObject):
|
|
|
1342
1373
|
pass
|
|
1343
1374
|
return True
|
|
1344
1375
|
|
|
1345
|
-
# NSTextView
|
|
1346
|
-
#
|
|
1347
|
-
# keystroke
|
|
1348
|
-
#
|
|
1349
|
-
#
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
1352
|
-
|
|
1376
|
+
# NSTextView delegate: fires the instant the caret/selection moves inside
|
|
1377
|
+
# a text view, which includes a plain click-to-place-cursor with no
|
|
1378
|
+
# keystroke (unlike NSTextDidBeginEditingNotification/textDidBeginEditing_,
|
|
1379
|
+
# which only fires once an actual edit starts and does NOT fire from
|
|
1380
|
+
# clicking into a box to merely place the cursor, verified empirically —
|
|
1381
|
+
# that gap was the "clicking the other draft doesn't select it" bug).
|
|
1382
|
+
# Two-draft cards use this as the ONLY selection mechanism: whichever
|
|
1383
|
+
# draft box the reviewer is in IS the selected one. Only the two draft
|
|
1384
|
+
# boxes set self as delegate for this notification (the read-only thread
|
|
1385
|
+
# quote never fires it), so no candidate_id lookup is needed, just a slot
|
|
1386
|
+
# match against self._draft_textviews.
|
|
1387
|
+
def textViewDidChangeSelection_(self, notification):
|
|
1353
1388
|
try:
|
|
1354
1389
|
tv = notification.object()
|
|
1355
1390
|
except Exception:
|
|
@@ -1363,15 +1398,16 @@ class _ReviewController(NSObject):
|
|
|
1363
1398
|
|
|
1364
1399
|
@objc.python_method
|
|
1365
1400
|
def _update_draft_borders(self):
|
|
1366
|
-
"""Redraw the two draft boxes'
|
|
1367
|
-
in-progress edit/caret in either box is never disturbed): the
|
|
1368
|
-
selected box gets a thicker system-accent outline (the
|
|
1369
|
-
selection color, respects the user's macOS accent
|
|
1370
|
-
light/dark mode), the other a plain hairline
|
|
1371
|
-
|
|
1372
|
-
|
|
1401
|
+
"""Redraw the two draft boxes' selection ring in place (no re-render,
|
|
1402
|
+
so an in-progress edit/caret in either box is never disturbed): the
|
|
1403
|
+
selected box's outline view gets a thicker system-accent outline (the
|
|
1404
|
+
platform's own selection color, respects the user's macOS accent
|
|
1405
|
+
choice and light/dark mode), the other a plain hairline. Applied to
|
|
1406
|
+
the dedicated outline wrapper, not the scroll view itself; see the
|
|
1407
|
+
comment at its construction in _render for why."""
|
|
1408
|
+
for slot, outline in (self._draft_outlines or {}).items():
|
|
1373
1409
|
try:
|
|
1374
|
-
layer =
|
|
1410
|
+
layer = outline.layer()
|
|
1375
1411
|
if layer is None:
|
|
1376
1412
|
continue
|
|
1377
1413
|
if slot == self._selected_draft:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.1-rc.
|
|
3
|
+
"version": "1.7.1-rc.22",
|
|
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.1-rc.
|
|
3
|
+
"version": "1.7.1-rc.22",
|
|
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",
|
|
@@ -155,6 +155,7 @@
|
|
|
155
155
|
"node": ">=16"
|
|
156
156
|
},
|
|
157
157
|
"dependencies": {
|
|
158
|
+
"@sentry/node": "^10.64.0",
|
|
158
159
|
"firebase-admin": "^13.8.0",
|
|
159
160
|
"pg": "^8.20.0",
|
|
160
161
|
"ws": "^8.0.0"
|
|
@@ -262,22 +262,48 @@ def main() -> int:
|
|
|
262
262
|
plan_obj = {"candidates": merged}
|
|
263
263
|
if plan_created_at:
|
|
264
264
|
plan_obj["created_at"] = plan_created_at
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
#
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
265
|
+
|
|
266
|
+
# This is the actual delivery: if anything below throws, the cycle's drafts
|
|
267
|
+
# were computed but never reached the store the menu bar reads, and the
|
|
268
|
+
# wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
|
|
269
|
+
# stderr with `|| true`, so a crash here previously vanished into a local
|
|
270
|
+
# log nothing central reads — the exact blind spot that cost the 2026-07-08
|
|
271
|
+
# Karol investigation its root cause. Report it like any other handled
|
|
272
|
+
# pipeline failure (see twitter_post_plan.py's post-failure capture).
|
|
273
|
+
try:
|
|
274
|
+
_atomic_write(dst, plan_obj)
|
|
275
|
+
ensure_store_symlink()
|
|
276
|
+
|
|
277
|
+
# Refresh the review-request marker the menu bar polls (count = pending, not posted).
|
|
278
|
+
pending = len([c for c in merged if not c.get("posted")])
|
|
279
|
+
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
280
|
+
_atomic_write(
|
|
281
|
+
review_request_path(),
|
|
282
|
+
{
|
|
283
|
+
"batch_id": REVIEW_QUEUE_ID,
|
|
284
|
+
"project": project,
|
|
285
|
+
"count": pending,
|
|
286
|
+
"plan_path": dst,
|
|
287
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
|
|
292
|
+
try:
|
|
293
|
+
import sentry_init
|
|
294
|
+
|
|
295
|
+
sentry_init.init()
|
|
296
|
+
sentry_init.capture_message(
|
|
297
|
+
f"merge_review_queue delivery failed: {e}",
|
|
298
|
+
level="error",
|
|
299
|
+
tags={"component": "merge_review_queue", "added": str(added)},
|
|
300
|
+
extra={"plan_src": src},
|
|
301
|
+
)
|
|
302
|
+
sentry_init.flush(2.0)
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
return 1
|
|
306
|
+
|
|
281
307
|
print(
|
|
282
308
|
f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
|
|
283
309
|
f"({pending} pending total) from {os.path.basename(src)}",
|
package/scripts/scan_pii.py
CHANGED
|
@@ -56,6 +56,14 @@ SECRET_PATTERNS = [
|
|
|
56
56
|
# Absolute home path leak. Placeholder forms (/Users/<you>, /Users/USERNAME) pass.
|
|
57
57
|
HOME_PATH_RE = re.compile(r"/Users/(?!<|USER|USERNAME|you\b|me\b|name\b)[a-z0-9._-]{2,}", re.I)
|
|
58
58
|
|
|
59
|
+
|
|
60
|
+
def _home_path_exempt(path: str) -> bool:
|
|
61
|
+
"""launchd .plist files legitimately require absolute paths — launchd does not
|
|
62
|
+
expand ~ or $HOME in ProgramArguments/StandardOutPath/etc. The repo already
|
|
63
|
+
tracks 70+ of them, so exempt this file class from the absolute-home-path rule.
|
|
64
|
+
Secrets, PII, and image checks still apply."""
|
|
65
|
+
return path.startswith("launchd/") and path.endswith(".plist")
|
|
66
|
+
|
|
59
67
|
IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic", ".bmp", ".tiff"}
|
|
60
68
|
|
|
61
69
|
|
|
@@ -146,7 +154,7 @@ def scan(paths: list[str], staged: bool) -> tuple[list[str], list[str]]:
|
|
|
146
154
|
if deny_re and deny_re.search(text):
|
|
147
155
|
m = deny_re.search(text)
|
|
148
156
|
hard.append(f"{path}:{lineno}: PII denylist match ('{m.group(0)}')")
|
|
149
|
-
if HOME_PATH_RE.search(text):
|
|
157
|
+
if HOME_PATH_RE.search(text) and not _home_path_exempt(path):
|
|
150
158
|
m = HOME_PATH_RE.search(text)
|
|
151
159
|
(hard if staged else soft).append(
|
|
152
160
|
f"{path}:{lineno}: absolute home path ('{m.group(0)}...')"
|