@m13v/s4l 1.7.4-rc.16 → 1.7.4-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
@@ -5315,8 +5315,7 @@ async function main() {
5315
5315
  // the connected X profile and store the top-performing replies as
5316
5316
  // voice.examples + the persona_corpus.txt exemplar section. Additive only
5317
5317
  // (regenerates just its own marked corpus section; respects hand-written
5318
- // examples) and self-limiting (no cooldown by design: success stamps
5319
- // examples_scanned_at which makes later boots a no-op, and until then it
5318
+ // examples) and self-limiting (marker file rate-limits scan attempts; it
5320
5319
  // WAITS politely on the twitter-browser lock, polling while holding
5321
5320
  // nothing, until cycles/DM runs free the browser, up to 12h before
5322
5321
  // deferring to the next boot). Delayed so boot-time work (runtime
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.16",
3
- "installedAt": "2026-07-11T18:28:11.012Z"
2
+ "version": "1.7.4-rc.2",
3
+ "installedAt": "2026-07-10T23:25:56.505Z"
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.4-rc.16",
5
+ "version": "1.7.4-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": {
@@ -431,21 +431,7 @@ def _details_lines(d):
431
431
  d = d or {}
432
432
  lines = []
433
433
  style = (d.get("engagement_style") or "").strip()
434
- # Two-draft cards (2026-07-11): name each slot's style so the reviewer
435
- # can tell WHICH style produced the draft they are picking (Draft B is
436
- # the exploration slot; its source arm renders separately below via the
437
- # generic experiments lines). Single-draft cards keep the old one-liner.
438
- _dual = d.get("drafts")
439
- if isinstance(_dual, list) and len(_dual) == 2:
440
- _slot_labels = {"a": "Draft A", "b": "Draft B"}
441
- for _draft in _dual:
442
- _s = (_draft.get("style") or "").strip()
443
- if _s:
444
- _label = _slot_labels.get(
445
- (_draft.get("variant") or "").strip().lower(), "Draft"
446
- )
447
- lines.append(f"{_label} style: {_s}")
448
- elif style:
434
+ if style:
449
435
  lines.append(f"Style: {style}")
450
436
  desc = (d.get("style_description") or "").strip()
451
437
  if desc:
@@ -746,21 +732,6 @@ class _ReviewController(NSObject):
746
732
  self._selected_draft = None
747
733
  self._draft_textviews = {}
748
734
  self._draft_scrolls = {}
749
- # Per-draft hover dwell (two-draft cards, 2026-07-10): accumulated
750
- # milliseconds the pointer spent over each draft box, so the feedback
751
- # digest can tell an informed keep of Draft A (they read B and stayed)
752
- # from a fast approve that says nothing about B. Raw ms ship on the
753
- # decision; the read-vs-skim threshold lives digest-side so it can be
754
- # tuned without a client release. _draft_hover_open holds the enter
755
- # timestamp of any hover still in progress (flushed on decision).
756
- self._draft_hover_ms = {0: 0, 1: 0}
757
- self._draft_hover_open = {}
758
- # Slots the caret has actually been in this card (2026-07-10 follow-up):
759
- # lets the decision distinguish "clicked into B, then came BACK to A"
760
- # (an explicit head-to-head choice of A, per user) from "never touched
761
- # B at all". Only the UNCHOSEN slot's membership matters at decision
762
- # time; the selected slot is trivially visited.
763
- self._draft_visited = set()
764
735
  # Attention anchors for the unattended-review watchdog: the stack counts
765
736
  # as "touched" on present, on any tracked interaction, and on any
766
737
  # decision. No touch past the watchdog threshold = the user is not
@@ -1088,9 +1059,6 @@ class _ReviewController(NSObject):
1088
1059
  self._interactions = []
1089
1060
  self._card_shown_at = time.time()
1090
1061
  self._selected_draft = None
1091
- self._draft_hover_ms = {0: 0, 1: 0}
1092
- self._draft_hover_open = {}
1093
- self._draft_visited = set()
1094
1062
  self._reason_field = None
1095
1063
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
1096
1064
 
@@ -1421,19 +1389,6 @@ class _ReviewController(NSObject):
1421
1389
  tv.setDelegate_(self)
1422
1390
  outline.addSubview_(scroll)
1423
1391
  content.addSubview_(outline)
1424
- # Hover dwell per draft box (same NSTrackingArea pattern as the
1425
- # eye buttons): enter/exit timestamps accumulate into
1426
- # _draft_hover_ms[slot] so the decision can say whether the
1427
- # reviewer actually READ the draft they didn't pick. slot rides
1428
- # on userInfo, mirroring the eyes' `kind` routing.
1429
- outline.addTrackingArea_(
1430
- NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
1431
- outline.bounds(),
1432
- NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
1433
- self,
1434
- {"kind": "draft", "slot": slot},
1435
- )
1436
- )
1437
1392
  self._draft_scrolls[slot] = scroll
1438
1393
  self._draft_outlines[slot] = outline
1439
1394
  self._draft_textviews[slot] = tv
@@ -1587,32 +1542,21 @@ class _ReviewController(NSObject):
1587
1542
  self._show_details_popover()
1588
1543
 
1589
1544
  @objc.python_method
1590
- def _hover_info(self, event):
1591
- """(kind, slot) a tracking-area event belongs to, from the userInfo
1592
- stamped at creation: ('stats'|'details', None) for the eye icons,
1593
- ('draft', 0|1) for the two draft boxes. Defaults to ('stats', None),
1594
- the original single-eye behavior, if the area carries no info."""
1545
+ def _hover_kind(self, event):
1546
+ """Which eye a tracking-area event belongs to ('stats' | 'details'),
1547
+ from the userInfo stamped in _eye_button. Defaults to stats (the
1548
+ original single-eye behavior) if the area carries no info."""
1595
1549
  try:
1596
1550
  info = event.trackingArea().userInfo()
1597
- if info:
1598
- kind = info.get("kind")
1599
- if kind == "draft":
1600
- return "draft", int(info.get("slot"))
1601
- if kind == "details":
1602
- return "details", None
1551
+ if info and info.get("kind") == "details":
1552
+ return "details"
1603
1553
  except Exception:
1604
1554
  pass
1605
- return "stats", None
1555
+ return "stats"
1606
1556
 
1607
- # NSTrackingArea owner callbacks (hover over either eye icon or, on
1608
- # two-draft cards, either draft box). Draft hovers only bank dwell time
1609
- # (no popover, no logging: the boxes are big and enter/exit fires on
1610
- # every pass of the pointer).
1557
+ # NSTrackingArea owner callbacks (hover over either eye icon).
1611
1558
  def mouseEntered_(self, event):
1612
- kind, slot = self._hover_info(event)
1613
- if kind == "draft":
1614
- self._draft_hover_open[slot] = time.time()
1615
- return
1559
+ kind = self._hover_kind(event)
1616
1560
  _log(f"{kind} eye hover enter")
1617
1561
  if kind == "details":
1618
1562
  self._show_details_popover()
@@ -1620,32 +1564,9 @@ class _ReviewController(NSObject):
1620
1564
  self._show_stats_popover()
1621
1565
 
1622
1566
  def mouseExited_(self, event):
1623
- kind, slot = self._hover_info(event)
1624
- if kind == "draft":
1625
- started = self._draft_hover_open.pop(slot, None)
1626
- if started is not None:
1627
- self._draft_hover_ms[slot] = self._draft_hover_ms.get(slot, 0) + int(
1628
- (time.time() - started) * 1000
1629
- )
1630
- return
1631
1567
  _log("eye hover exit")
1632
1568
  self._close_stats_popover()
1633
1569
 
1634
- @objc.python_method
1635
- def _flush_draft_hovers(self):
1636
- """Bank any hover still in progress (pointer inside a draft box at
1637
- decision time, e.g. a keyboard approve) so _record reads final
1638
- totals."""
1639
- now = time.time()
1640
- for slot, started in list(self._draft_hover_open.items()):
1641
- self._draft_hover_ms[slot] = self._draft_hover_ms.get(slot, 0) + int(
1642
- (now - started) * 1000
1643
- )
1644
- # Keep the hover open (re-anchored at now) rather than deleting
1645
- # it: the pointer really is still inside the box, so a later
1646
- # mouseExited_ must not double-count the pre-flush span.
1647
- self._draft_hover_open[slot] = now
1648
-
1649
1570
  @objc.python_method
1650
1571
  def _add_link(self, content, frame, text, url, *, size=12, bold=False, right=False, kind="link_click"):
1651
1572
  """Borderless button styled as a link (system link color, underlined).
@@ -1709,18 +1630,11 @@ class _ReviewController(NSObject):
1709
1630
  except Exception:
1710
1631
  return
1711
1632
  for slot, cand_tv in (self._draft_textviews or {}).items():
1712
- if cand_tv is not tv:
1713
- continue
1714
- # Visited even when it's already the selected slot: membership of
1715
- # the eventually-UNCHOSEN slot is what _record reads, and that
1716
- # slot only ever gets the caret via a deliberate user click (the
1717
- # auto-focus seat in _render targets the selected slot only).
1718
- self._draft_visited.add(slot)
1719
- if slot != self._selected_draft:
1633
+ if cand_tv is tv and slot != self._selected_draft:
1720
1634
  self._selected_draft = slot
1721
1635
  self._textview = cand_tv
1722
1636
  self._update_draft_borders()
1723
- break
1637
+ break
1724
1638
 
1725
1639
  @objc.python_method
1726
1640
  def _update_draft_borders(self):
@@ -1790,32 +1704,9 @@ class _ReviewController(NSObject):
1790
1704
  chosen_draft = drafts[sel_idx]
1791
1705
  orig = (chosen_draft.get("text") or "").strip()
1792
1706
  draft_variant = chosen_draft.get("variant") or ("a" if sel_idx == 0 else "b")
1793
- # Full pairwise context for the feedback digest (2026-07-10): the
1794
- # UNCHOSEN draft's text+style ride along so "picked B over A" (or
1795
- # "kept A after reading B", per the hover dwell) is a usable
1796
- # preference PAIR, not just a winner with no loser. Shipped as one
1797
- # nested dict end to end (decision -> review event -> jsonb column)
1798
- # so adding a field never needs another schema hop.
1799
- self._flush_draft_hovers()
1800
- other = drafts[1 - sel_idx]
1801
- draft_choice = {
1802
- "variant": draft_variant,
1803
- "index": sel_idx,
1804
- "auto_selected": bool(sel_idx == 0),
1805
- "style": chosen_draft.get("style") or None,
1806
- "unchosen_text": (other.get("text") or "").strip() or None,
1807
- "unchosen_style": other.get("style") or None,
1808
- "hover_a_ms": int(self._draft_hover_ms.get(0, 0)),
1809
- "hover_b_ms": int(self._draft_hover_ms.get(1, 0)),
1810
- # True = the caret was in the unchosen box at some point, i.e.
1811
- # they tried the other draft and came back: an explicit choice
1812
- # even when the winner is the preselected default.
1813
- "visited_other": bool((1 - sel_idx) in self._draft_visited),
1814
- }
1815
1707
  else:
1816
1708
  orig = (d.get("reply_text") or "").strip()
1817
1709
  draft_variant = None
1818
- draft_choice = None
1819
1710
  link = d.get("link_url") or ""
1820
1711
  drop_link = False
1821
1712
  if approved:
@@ -1871,11 +1762,6 @@ class _ReviewController(NSObject):
1871
1762
  "draft_variant": draft_variant,
1872
1763
  "draft_index": sel_idx,
1873
1764
  "draft_auto_selected": bool(dual and sel_idx == 0),
1874
- # Nested pairwise record (chosen vs unchosen text/style plus
1875
- # per-box hover dwell); None on single-draft candidates. The
1876
- # flat three fields above stay for their existing consumers
1877
- # (edit-learning variant stamp in s4l_menubar).
1878
- "draft_choice": draft_choice,
1879
1765
  }
1880
1766
  )
1881
1767
  self._last_decision_at = time.time()
@@ -2941,13 +2941,6 @@ class S4LMenuBar(rumps.App):
2941
2941
  # posts); English translations on the card are display-only
2942
2942
  # and never shipped here.
2943
2943
  "language": decision.get("language"),
2944
- # Two-draft pairwise context (None on single-draft cards):
2945
- # {variant, index, auto_selected, style, unchosen_text,
2946
- # unchosen_style, hover_a_ms, hover_b_ms}. Lets the
2947
- # feedback digest learn "picked B over A" / "kept A after
2948
- # actually reading B" as preference pairs. Older servers
2949
- # simply ignore the key.
2950
- "draft_choice": decision.get("draft_choice"),
2951
2944
  }
2952
2945
  )
2953
2946
  except Exception:
@@ -786,10 +786,6 @@ def store_stamp_decision(batch, decision):
786
786
  "drop_link": bool(decision.get("drop_link")),
787
787
  "loved": bool(decision.get("loved")),
788
788
  "reject_category": decision.get("reject_category"),
789
- # Two-draft pairwise record (chosen vs unchosen + hover dwell),
790
- # None on single-draft cards. Durable locally so the choice
791
- # survives even if the review-events flush never lands.
792
- "draft_choice": decision.get("draft_choice"),
793
789
  "decided_at": time_iso(),
794
790
  }
795
791
  if decision.get("approved"):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.4-rc.16",
3
+ "version": "1.7.4-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.7.4-rc.16",
3
+ "version": "1.7.4-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",
@@ -40,39 +40,16 @@ ENV_PREFIX = "S4L_EXP_"
40
40
  # Keep draft_prompt entries in sync with bin/server.js
41
41
  # DRAFT_PROMPT_VARIANT_DEFS and the arm strings in run-twitter-cycle.sh.
42
42
  DESCRIPTIONS = {
43
- "draft_b_source": {
44
- "human_derived": (
45
- "Draft B explore slot: style distilled from real top-performing "
46
- "human replies (daily synthesizer), least-used first"
47
- ),
48
- "model_invented": (
49
- "Draft B explore slot: freshly invented style from the "
50
- "standalone invention job (post 2026-07-10), least-used first"
51
- ),
52
- "scored_fallback": (
53
- "Draft B explore pool was empty; fell back to a second scored "
54
- "pick from the proven-style pool"
55
- ),
56
- },
57
43
  "draft_prompt": {
58
- "treatment_v3": (
59
- "style-as-form: the assigned style is the binding FORM (defining "
60
- "move + per-style length + self-check), learned preferences apply "
61
- "inside it; keeps the v2 skeleton ban"
62
- ),
63
- "control_v3": (
64
- "plain draft directive, uniform length clamp, no structure ban"
65
- ),
66
- # v2 arms (skeleton-ban) retired 2026-07-10; v1 arms
67
- # (decoupled-product-pivot) retired 2026-07-06; kept so any
68
- # straggler card from an old plan still explains itself.
69
44
  "treatment_v2": (
70
- "v2, retired: skeleton ban, forbids the concede-then-reverse "
45
+ "skeleton ban: forbids the concede-then-reverse "
71
46
  '"easy X / hard Y" structure and forces varied entry points'
72
47
  ),
73
48
  "control_v2": (
74
- "v2, retired: draft directive (style + product pivot), no structure ban"
49
+ "current draft directive (style + product pivot), no structure ban"
75
50
  ),
51
+ # v1 arms (decoupled-product-pivot) retired 2026-07-06; kept so any
52
+ # straggler card from an old plan still explains itself.
76
53
  "treatment": "v1, retired: product pivot decoupled from the reply",
77
54
  "control": "v1, retired: original draft directive",
78
55
  },
@@ -122,9 +99,8 @@ def collect(env=None):
122
99
  if var.startswith(ENV_PREFIX) and (v or "").strip():
123
100
  out[var[len(ENV_PREFIX):].lower()] = v.strip()
124
101
  # 2026-07-06: the personal_brand persona directive is now ARM-AWARE in
125
- # run-twitter-cycle.sh (treatment_v3 adds the skeleton ban + two-layer
126
- # style/preferences contract, control_v3 does not), so the assigned
127
- # draft_prompt arm DOES touch persona
102
+ # run-twitter-cycle.sh (treatment_v2 adds the concede-then-reverse skeleton ban,
103
+ # control_v2 does not), so the assigned draft_prompt arm DOES touch persona
128
104
  # drafts. Keep it stamped so the arm surfaces on persona cards and the per-arm
129
105
  # readout covers both lanes. (Previously dropped here because the persona
130
106
  # directive overrode both arms wholesale; that is no longer the case.)
@@ -144,59 +144,6 @@ def _render_media_block(media) -> str:
144
144
  )
145
145
 
146
146
 
147
- def _build_chain_block(row) -> str:
148
- """Conversation chain reconstructed from replies.parent_reply_id linkage.
149
-
150
- Walks ancestors bottom-up via GET /api/v1/replies/:id and renders the
151
- chain root-first, each hop showing the inbound comment and (when we
152
- responded) our reply. Empty string when the row has no parent linkage:
153
- the root post itself already rides PENDING_DATA via the posts JOIN
154
- (our_content / thread_title), so a chain block would add nothing.
155
-
156
- Like counterparty_history_block, the block is self-titled and lands
157
- inline in PENDING_DATA — no shell-side prompt change needed.
158
- """
159
- parent_id = row.get("parent_reply_id")
160
- if not parent_id:
161
- return ""
162
- hops = []
163
- seen = set()
164
- cur = parent_id
165
- for _ in range(10):
166
- if not cur or cur in seen:
167
- break
168
- seen.add(cur)
169
- try:
170
- resp = api_get(f"/api/v1/replies/{cur}")
171
- except Exception:
172
- break
173
- r = (resp.get("data") or {}).get("reply") or {}
174
- if not r:
175
- break
176
- hops.append(r)
177
- cur = r.get("parent_reply_id")
178
- if not hops:
179
- return ""
180
-
181
- def _one_line(text):
182
- return " ".join((text or "").split())
183
-
184
- lines = [
185
- "## Conversation chain (reconstructed from our DB; root first — "
186
- "the row you are drafting for replies to the LAST message)"
187
- ]
188
- for r in reversed(hops):
189
- lines.append(f"@{r.get('their_author') or '?'}: {_one_line(r.get('their_content'))}")
190
- ours = _one_line(r.get("our_reply_content"))
191
- if ours:
192
- lines.append(f" our reply: {ours}")
193
- lines.append(
194
- f"@{row.get('their_author') or '?'}: {_one_line(row.get('their_content'))}"
195
- " <- you are replying to this"
196
- )
197
- return "\n".join(lines)
198
-
199
-
200
147
  def cmd_pending_data(batch_size: int) -> int:
201
148
  try:
202
149
  from account_resolver import resolve as _resolve_account # noqa: WPS433
@@ -226,50 +173,38 @@ def cmd_pending_data(batch_size: int) -> int:
226
173
  # top slot then and get enriched.
227
174
  ENRICH_TOP_N = 60
228
175
  history_blocks = [""] * len(rows)
229
- chain_blocks = [""] * len(rows)
230
176
  try:
231
177
  from concurrent.futures import ThreadPoolExecutor
232
178
  from counterparty_history import get_counterparty_history_block
233
179
 
234
180
  def _enrich(r):
235
181
  author = r.get("their_author")
236
- history = ""
237
- if author:
238
- try:
239
- _disengage, history = get_counterparty_history_block(
240
- platform="x",
241
- author=author,
242
- current_post_id=r.get("post_id"),
243
- current_reply_id=r.get("id"),
244
- )
245
- history = history or ""
246
- except Exception as e:
247
- print(
248
- f"[engage_twitter_helper] counterparty_history failed "
249
- f"for @{author}: {e}",
250
- file=sys.stderr,
251
- )
182
+ if not author:
183
+ return ""
252
184
  try:
253
- chain = _build_chain_block(r)
185
+ _disengage, block = get_counterparty_history_block(
186
+ platform="x",
187
+ author=author,
188
+ current_post_id=r.get("post_id"),
189
+ current_reply_id=r.get("id"),
190
+ )
191
+ return block or ""
254
192
  except Exception as e:
255
193
  print(
256
- f"[engage_twitter_helper] chain block failed "
257
- f"for reply {r.get('id')}: {e}",
194
+ f"[engage_twitter_helper] counterparty_history failed "
195
+ f"for @{author}: {e}",
258
196
  file=sys.stderr,
259
197
  )
260
- chain = ""
261
- return (history, chain)
198
+ return ""
262
199
 
263
200
  top_rows = rows[:ENRICH_TOP_N]
264
201
  with ThreadPoolExecutor(max_workers=8) as ex:
265
- for idx, (history, chain) in enumerate(ex.map(_enrich, top_rows)):
266
- history_blocks[idx] = history
267
- chain_blocks[idx] = chain
202
+ for idx, block in enumerate(ex.map(_enrich, top_rows)):
203
+ history_blocks[idx] = block
268
204
  non_empty = sum(1 for b in history_blocks if b)
269
- chains_non_empty = sum(1 for b in chain_blocks if b)
270
205
  print(
271
- f"[engage_twitter_helper] enriched {len(top_rows)}/{len(rows)} rows "
272
- f"(history={non_empty}, chain={chains_non_empty} non-empty)",
206
+ f"[engage_twitter_helper] counterparty_history enriched "
207
+ f"{len(top_rows)}/{len(rows)} rows ({non_empty} with non-empty block)",
273
208
  file=sys.stderr,
274
209
  )
275
210
  except Exception as e:
@@ -280,7 +215,7 @@ def cmd_pending_data(batch_size: int) -> int:
280
215
  )
281
216
 
282
217
  out = []
283
- for r, history_block, chain_block in zip(rows, history_blocks, chain_blocks):
218
+ for r, history_block in zip(rows, history_blocks):
284
219
  out.append({
285
220
  "id": r.get("id"),
286
221
  "platform": r.get("platform"),
@@ -296,7 +231,6 @@ def cmd_pending_data(batch_size: int) -> int:
296
231
  "is_our_original_post": int(r.get("is_our_original_post") or 0),
297
232
  "project_name": r.get("project_name"),
298
233
  "counterparty_history_block": history_block,
299
- "conversation_chain_block": chain_block,
300
234
  "their_media_block": _render_media_block(r.get("their_media")),
301
235
  })
302
236
  # json_agg(...) returns null when the array is empty; engage-twitter.sh's