@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.2-rc.10",
3
- "installedAt": "2026-07-09T05:38:51.644Z"
2
+ "version": "1.7.2-rc.14",
3
+ "installedAt": "2026-07-10T04:31:17.238Z"
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.7.2-rc.10",
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": {
@@ -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: emoji + tooltip per approval level (button tag =
153
- # level). Level 1 = plain approve; 2+ = loved=True on the decision, with the
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
- ("👍", "Approve"),
158
- ("😄", "Approve, really good pick"),
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, 44, 30))
887
+ btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42, 38, 30))
888
888
  btn.setTitle_(emoji)
889
889
  btn.setBezelStyle_(NSBezelStyleRounded)
890
- # 13pt = the size Reject's title renders at; anything bigger
891
- # overflows the rounded bezel's ~22px content lane (16pt did).
892
- btn.setFont_(NSFont.systemFontOfSize_(13))
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 += 48
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.10",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.2-rc.10",
3
+ "version": "1.7.2-rc.14",
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",
@@ -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 a stronger emoji in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 3 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
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