@m13v/s4l 1.7.2-rc.10 → 1.7.2-rc.14
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 +12 -11
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_process_li_notifs.py +91 -0
- package/scripts/feedback_digest.py +1 -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.2-rc.
|
|
5
|
+
"version": "1.7.2-rc.14",
|
|
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
|
@@ -149,14 +149,14 @@ REJECT_REASONS = (
|
|
|
149
149
|
# Client-side cap on tracked interactions per card (server clips at 50 too).
|
|
150
150
|
MAX_INTERACTIONS = 50
|
|
151
151
|
|
|
152
|
-
# Inline approve row:
|
|
153
|
-
# level). Level 1 = plain approve; 2
|
|
152
|
+
# Inline approve row: glyph + tooltip per approval level (button tag =
|
|
153
|
+
# level). Level 1 = plain approve; 2 = loved=True on the decision, with the
|
|
154
154
|
# exact level shipped as an approve_level_N interaction for the feedback
|
|
155
|
-
# digest.
|
|
155
|
+
# digest. Monochrome text-presentation glyphs (U+FE0E) so the buttons stay
|
|
156
|
+
# black-and-white in both appearances instead of rendering as color emoji.
|
|
156
157
|
APPROVE_EMOJIS = (
|
|
157
|
-
("
|
|
158
|
-
("
|
|
159
|
-
("❤️🔥", "Approve, best of the best"),
|
|
158
|
+
("✓︎", "Approve"),
|
|
159
|
+
("♥︎", "Approve, best of the best"),
|
|
160
160
|
)
|
|
161
161
|
|
|
162
162
|
# Review-surface state mirrored to the state dir for out-of-process observers
|
|
@@ -884,12 +884,13 @@ class _ReviewController(NSObject):
|
|
|
884
884
|
# Bezeled, not borderless: bare emoji read as decoration and
|
|
885
885
|
# users doubted the click registered (2026-07-03/04 feedback,
|
|
886
886
|
# twice now — the outline is what says "button").
|
|
887
|
-
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42,
|
|
887
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42, 38, 30))
|
|
888
888
|
btn.setTitle_(emoji)
|
|
889
889
|
btn.setBezelStyle_(NSBezelStyleRounded)
|
|
890
|
-
#
|
|
891
|
-
#
|
|
892
|
-
|
|
890
|
+
# 16pt fits the rounded bezel's ~22px content lane for these
|
|
891
|
+
# text-presentation glyphs (the old color emoji clipped at 16pt,
|
|
892
|
+
# which is why this used to be 13pt).
|
|
893
|
+
btn.setFont_(NSFont.systemFontOfSize_(16))
|
|
893
894
|
btn.setTag_(i + 1) # tag = approval level
|
|
894
895
|
btn.setTarget_(self)
|
|
895
896
|
btn.setAction_("approve:")
|
|
@@ -898,7 +899,7 @@ class _ReviewController(NSObject):
|
|
|
898
899
|
except Exception:
|
|
899
900
|
pass
|
|
900
901
|
content.addSubview_(btn)
|
|
901
|
-
x +=
|
|
902
|
+
x += 42
|
|
902
903
|
|
|
903
904
|
reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
|
|
904
905
|
reject.setTitle_("Reject")
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.2-rc.
|
|
3
|
+
"version": "1.7.2-rc.14",
|
|
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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import json, re, urllib.parse
|
|
2
|
+
|
|
3
|
+
NOTIFS = "/tmp/li_notifs.json"
|
|
4
|
+
CID = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/brzcccx3h.txt"
|
|
5
|
+
PAIRS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bu0y0bphg.txt"
|
|
6
|
+
POSTS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bnb5ginhc.txt"
|
|
7
|
+
|
|
8
|
+
def load_lines(p):
|
|
9
|
+
out=[]
|
|
10
|
+
for ln in open(p, encoding="utf-8", errors="replace"):
|
|
11
|
+
ln=ln.rstrip("\n")
|
|
12
|
+
# skip persisted-output wrapper lines
|
|
13
|
+
if not ln or ln.startswith("<") or ln.startswith("Output too large") or ln.startswith("Preview") or ln.startswith("..."):
|
|
14
|
+
continue
|
|
15
|
+
out.append(ln)
|
|
16
|
+
return out
|
|
17
|
+
|
|
18
|
+
comment_ids = set(load_lines(CID))
|
|
19
|
+
pairs = set(load_lines(PAIRS))
|
|
20
|
+
posts = load_lines(POSTS)
|
|
21
|
+
|
|
22
|
+
# activity number -> post id
|
|
23
|
+
post_by_act = {}
|
|
24
|
+
for ln in posts:
|
|
25
|
+
if "|" not in ln: continue
|
|
26
|
+
pid, url = ln.split("|",1)
|
|
27
|
+
for m in re.findall(r"urn:li:(?:activity|share|ugcPost):(\d+)", url):
|
|
28
|
+
post_by_act[m]=pid
|
|
29
|
+
|
|
30
|
+
# config projects search_topics
|
|
31
|
+
cfg = json.load(open("/Users/matthewdi/social-autoposter/config.json"))
|
|
32
|
+
projtopics = []
|
|
33
|
+
for pr in cfg.get("projects",[]):
|
|
34
|
+
projtopics.append((pr["name"], [t.lower() for t in pr.get("search_topics",[])]))
|
|
35
|
+
|
|
36
|
+
EXCLUDED = {"louis030195","louis3195"}
|
|
37
|
+
OWN = {"matthew diakonov","m13v"}
|
|
38
|
+
|
|
39
|
+
def match_project(snippet):
|
|
40
|
+
s = snippet.lower()
|
|
41
|
+
best=None; bestscore=0
|
|
42
|
+
for name, topics in projtopics:
|
|
43
|
+
score=0
|
|
44
|
+
for t in topics:
|
|
45
|
+
if t and t in s:
|
|
46
|
+
score+=1
|
|
47
|
+
if score>bestscore:
|
|
48
|
+
bestscore=score; best=name
|
|
49
|
+
return best or "S4L"
|
|
50
|
+
|
|
51
|
+
data = json.load(open(NOTIFS))
|
|
52
|
+
decisions=[]
|
|
53
|
+
for d in data:
|
|
54
|
+
href = d["href"]
|
|
55
|
+
dec = urllib.parse.unquote(href)
|
|
56
|
+
m = re.search(r"urn:li:activity:(\d+)", dec)
|
|
57
|
+
activity_id = m.group(1) if m else None
|
|
58
|
+
comment_urn = d["comment_urn"]
|
|
59
|
+
author = d["author"]
|
|
60
|
+
a_low = author.lower().strip()
|
|
61
|
+
rec = dict(author=author, activity_id=activity_id, comment_urn=comment_urn,
|
|
62
|
+
snippet=d["snippet"], href=href, type=d["type"])
|
|
63
|
+
if not comment_urn or not activity_id:
|
|
64
|
+
rec["decision"]="no_comment_urn"; decisions.append(rec); continue
|
|
65
|
+
if a_low in OWN:
|
|
66
|
+
rec["decision"]="own_account"; decisions.append(rec); continue
|
|
67
|
+
if a_low in EXCLUDED or any(x in a_low for x in EXCLUDED):
|
|
68
|
+
rec["decision"]="excluded_author"; decisions.append(rec); continue
|
|
69
|
+
if comment_urn in comment_ids:
|
|
70
|
+
rec["decision"]="already_tracked"; decisions.append(rec); continue
|
|
71
|
+
our_url = "https://www.linkedin.com/feed/update/urn:li:activity:%s/" % activity_id
|
|
72
|
+
apk = author + "|||" + our_url
|
|
73
|
+
if apk in pairs:
|
|
74
|
+
rec["decision"]="author_already_engaged"; decisions.append(rec); continue
|
|
75
|
+
# match post
|
|
76
|
+
pid = post_by_act.get(activity_id)
|
|
77
|
+
rec["post_id"]=pid
|
|
78
|
+
rec["project"]=match_project(d["snippet"])
|
|
79
|
+
rec["decision"]="INSERT"
|
|
80
|
+
decisions.append(rec)
|
|
81
|
+
|
|
82
|
+
json.dump(decisions, open("/tmp/li_decisions.json","w"))
|
|
83
|
+
# summary
|
|
84
|
+
from collections import Counter
|
|
85
|
+
c=Counter(r["decision"] for r in decisions)
|
|
86
|
+
print("TOTAL", len(decisions))
|
|
87
|
+
for k,v in c.items(): print(k,v)
|
|
88
|
+
print("---INSERTS---")
|
|
89
|
+
for i,r in enumerate(decisions):
|
|
90
|
+
if r["decision"]=="INSERT":
|
|
91
|
+
print(i, r["author"], "| act", r["activity_id"], "| post", r.get("post_id"), "| proj", r["project"])
|
|
@@ -203,7 +203,7 @@ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
|
|
|
203
203
|
NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved):
|
|
204
204
|
{ev_lines}{overall_block}
|
|
205
205
|
|
|
206
|
-
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. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "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 picked
|
|
206
|
+
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. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "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 picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
|
|
207
207
|
|
|
208
208
|
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.
|
|
209
209
|
|