@m13v/s4l 1.7.6-rc.8 → 1.7.6

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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +33 -9
  3. package/mcp/dist/index.js +290 -177
  4. package/mcp/dist/panel.html +31 -17
  5. package/mcp/dist/setup.js +27 -7
  6. package/mcp/dist/telemetry.js +15 -6
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +10 -2
  9. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  10. package/mcp/menubar/s4l_card.py +63 -4
  11. package/mcp/menubar/s4l_card_canvas.py +62 -11
  12. package/mcp/menubar/s4l_menubar.py +380 -95
  13. package/mcp/menubar/s4l_state.py +119 -41
  14. package/mcp/package.json +1 -1
  15. package/package.json +1 -1
  16. package/scripts/account_resolver.py +58 -14
  17. package/scripts/amplitude_24h_signups.py +7 -4
  18. package/scripts/amplitude_signups.py +7 -4
  19. package/scripts/autopilot_stall_watch.py +8 -1
  20. package/scripts/browser_lifecycle.py +90 -0
  21. package/scripts/cdp_ready_check.py +81 -50
  22. package/scripts/claude_job.py +70 -10
  23. package/scripts/dm_conversation.py +15 -12
  24. package/scripts/draft_prompt_core.py +132 -64
  25. package/scripts/engage_github.py +11 -5
  26. package/scripts/engage_reddit.py +12 -5
  27. package/scripts/engagement_signal_feedback.py +1 -4
  28. package/scripts/engagement_styles.py +144 -105
  29. package/scripts/feedback_digest.py +54 -11
  30. package/scripts/find_threads.py +9 -7
  31. package/scripts/github_tools.py +23 -10
  32. package/scripts/learned_preferences.py +112 -78
  33. package/scripts/li_discovery.py +4 -1
  34. package/scripts/link_tail.py +1 -1
  35. package/scripts/live_status.py +151 -0
  36. package/scripts/merge_review_queue.py +15 -2
  37. package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
  38. package/scripts/pick_project.py +7 -4
  39. package/scripts/pick_thread_target.py +64 -13
  40. package/scripts/pick_twitter_thread_target.py +7 -4
  41. package/scripts/platform_strike_events.py +139 -7
  42. package/scripts/post_github.py +376 -35
  43. package/scripts/post_reddit.py +202 -192
  44. package/scripts/preflight.sh +23 -3
  45. package/scripts/preview_reddit_card.py +38 -0
  46. package/scripts/project_stats.py +7 -4
  47. package/scripts/reddit_ban_check.py +324 -0
  48. package/scripts/reddit_browser.py +56 -19
  49. package/scripts/reddit_chat_sync.py +10 -11
  50. package/scripts/reddit_tools.py +20 -4
  51. package/scripts/release-mcpb.sh +47 -7
  52. package/scripts/run_moltbook_cycle.py +9 -5
  53. package/scripts/s4l_ctl.sh +2 -2
  54. package/scripts/s4l_posting_mode.py +67 -6
  55. package/scripts/scan_dm_candidates.py +15 -9
  56. package/scripts/scan_github_replies.py +9 -7
  57. package/scripts/scan_moltbook_replies.py +7 -6
  58. package/scripts/scan_reddit_replies.py +11 -7
  59. package/scripts/scan_twitter_mentions_browser.py +7 -6
  60. package/scripts/scan_twitter_thread_followups.py +7 -6
  61. package/scripts/schedule_state.py +8 -4
  62. package/scripts/score_twitter_candidates.py +8 -3
  63. package/scripts/setup_twitter_auth.py +20 -96
  64. package/scripts/snapshot.py +19 -0
  65. package/scripts/stats.py +12 -10
  66. package/scripts/strike_alert.py +165 -12
  67. package/scripts/sub_moderation_history.py +129 -0
  68. package/scripts/subreddit_bans_client.py +157 -0
  69. package/scripts/test_cross_language_parity.py +185 -0
  70. package/scripts/test_no_silent_fallbacks.py +84 -0
  71. package/scripts/test_percard_posting.py +3 -3
  72. package/scripts/test_stall_watch_batch_progression.py +99 -0
  73. package/scripts/top_performers.py +24 -0
  74. package/scripts/twitter_browser.py +22 -52
  75. package/scripts/twitter_gen_links.py +3 -3
  76. package/scripts/twitter_post_plan.py +4 -4
  77. package/scripts/virality.py +151 -0
  78. package/setup/SKILL.md +1 -1
  79. package/skill/engage-dm-replies.sh +11 -0
  80. package/skill/lib/reddit-backend.sh +1 -0
  81. package/skill/run-draft-and-publish.sh +16 -0
  82. package/skill/run-reddit-search-launchd.sh +1 -0
  83. package/skill/run-reddit-search.sh +10 -1
  84. package/skill/run-reddit-threads.sh +12 -0
  85. package/skill/run-twitter-cycle.sh +104 -88
package/mcp/dist/setup.js CHANGED
@@ -192,11 +192,19 @@ export function projectExists(name) {
192
192
  return false;
193
193
  }
194
194
  }
195
- // Strip stray JSON-array syntax that leaks into a single topic when a
196
- // stringified array was split on commas (leading "[", trailing "]", and the
197
- // surrounding quotes on each element). Without this, topics like '["AI video
198
- // generation"' and '"ex-Google"]' get seeded verbatim and poison every search
199
- // query with literal [, ] and " characters (Karol, 2026-06-30).
195
+ // Strip stray JSON-array syntax that leaks into a single element ONLY when a
196
+ // stringified array had to be split on commas (leading "[", trailing "]", and
197
+ // the surrounding quotes on each element). Without this, topics like '["AI
198
+ // video generation"' and '"ex-Google"]' get seeded verbatim and poison every
199
+ // search query with literal [, ] and " characters (Karol, 2026-06-30).
200
+ //
201
+ // IMPORTANT: apply this ONLY on the naive-split fallback path. On the clean
202
+ // paths (a real array, or a valid stringified array that JSON.parse handled)
203
+ // each element is already clean, so stripping surrounding quotes there would
204
+ // destroy legitimate phrase-match quotes in a search query — e.g.
205
+ // '"agentic coding" min_faves:10' would lose its LEADING " and seed as
206
+ // 'agentic coding" min_faves:10', weakening the phrase match (reported
207
+ // 2026-07-17). Clean paths therefore only get trimmed.
200
208
  function stripTopicSyntax(x) {
201
209
  let s = String(x).trim();
202
210
  s = s.replace(/^\[+/, "").replace(/\]+$/, "").trim();
@@ -213,6 +221,9 @@ export function normalizeStringList(t) {
213
221
  if (t == null)
214
222
  return undefined;
215
223
  let raw;
224
+ // Elements only carry stray JSON-array syntax when we reach the naive
225
+ // comma/newline split below; on the clean paths they're already delimited.
226
+ let fromNaiveSplit = false;
216
227
  if (Array.isArray(t)) {
217
228
  raw = t;
218
229
  }
@@ -227,9 +238,18 @@ export function normalizeStringList(t) {
227
238
  parsed = null;
228
239
  }
229
240
  }
230
- raw = Array.isArray(parsed) ? parsed : s.split(/[,\n]/);
241
+ if (Array.isArray(parsed)) {
242
+ raw = parsed;
243
+ }
244
+ else {
245
+ raw = s.split(/[,\n]/);
246
+ fromNaiveSplit = true;
247
+ }
231
248
  }
232
- return raw.map((x) => stripTopicSyntax(String(x))).filter(Boolean);
249
+ const clean = fromNaiveSplit
250
+ ? stripTopicSyntax
251
+ : (x) => String(x).trim();
252
+ return raw.map((x) => clean(String(x))).filter(Boolean);
233
253
  }
234
254
  function normalizeTopics(t) {
235
255
  return normalizeStringList(t);
@@ -139,12 +139,21 @@ export function checkVersionChange() {
139
139
  }
140
140
  const isFirstBoot = from === null;
141
141
  const changed = !isFirstBoot && from !== to;
142
- try {
143
- fs.mkdirSync(snapshotStateDir(), { recursive: true });
144
- fs.writeFileSync(lastVersionPath(), JSON.stringify({ version: to, seen_at: new Date().toISOString() }), "utf-8");
145
- }
146
- catch {
147
- /* best-effort; a failed write just means the next boot re-detects from "from: null" */
142
+ // Write ONLY when the version actually changed (or first boot / unreadable
143
+ // file). The old rewrite-every-boot kept the file's timestamp meaning "when
144
+ // the server last booted", which reads like an install time and misled a
145
+ // 2026-07-18 investigation into concluding an update hadn't applied. With
146
+ // the guard, running_since is exactly what it says: when THIS version first
147
+ // booted here. Same-version boots leave the file untouched, so detection
148
+ // semantics are unchanged (the file already holds the current version).
149
+ if (changed || isFirstBoot) {
150
+ try {
151
+ fs.mkdirSync(snapshotStateDir(), { recursive: true });
152
+ fs.writeFileSync(lastVersionPath(), JSON.stringify({ version: to, running_since: new Date().toISOString() }), "utf-8");
153
+ }
154
+ catch {
155
+ /* best-effort; a failed write just means the next boot re-detects from "from: null" */
156
+ }
148
157
  }
149
158
  if (changed) {
150
159
  console.error(`[social-autoposter-mcp] self-update detected: ${from} -> ${to}`);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.8",
3
- "installedAt": "2026-07-17T17:51:37.267Z"
2
+ "version": "1.7.6",
3
+ "installedAt": "2026-07-18T17:32:42.014Z"
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.6-rc.8",
5
+ "version": "1.7.6",
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": {
@@ -33,7 +33,7 @@
33
33
  "description": "Configure or edit a project"
34
34
  },
35
35
  {
36
- "name": "post_drafts",
36
+ "name": "approve_drafts",
37
37
  "description": "Post chosen drafts"
38
38
  },
39
39
  {
@@ -48,6 +48,14 @@
48
48
  "name": "restart_menubar",
49
49
  "description": "Restart the S4L menu bar app"
50
50
  },
51
+ {
52
+ "name": "run_drafting",
53
+ "description": "Run the drafting pipeline now"
54
+ },
55
+ {
56
+ "name": "drafting_status",
57
+ "description": "Live drafting-pipeline status + next-run countdown"
58
+ },
51
59
  {
52
60
  "name": "posting_volume",
53
61
  "description": "Read or set posting volume (Aggressive / Steady / Chill)"
@@ -46,7 +46,13 @@ import s4l_log_relay
46
46
 
47
47
  # A managed harness Chrome is one launched on a profile under this marker
48
48
  # (browser-harness = twitter 9555, browser-harness-linkedin = 9556, ...).
49
- _PROFILE_MARKER = os.path.join(".claude", "browser-profiles", "browser-harness")
49
+ # Any managed harness profile under browser-profiles/ counts — matching the
50
+ # literal "browser-harness" prefix silently EXCLUDED the reddit harness
51
+ # (profile "reddit-harness"), so reddit-Chrome activations went unlogged and
52
+ # unattributable for days (2026-07-17). The remote-debugging-port requirement
53
+ # in the check below keeps the user's own Chrome / MCP-agent profiles (which
54
+ # use the debugging PIPE, not a port) out of scope.
55
+ _PROFILE_MARKER = os.path.join(".claude", "browser-profiles", "")
50
56
 
51
57
  # Within this window, repeats of the same (cause, pid) are counted, not emitted.
52
58
  # A screencast-reconnect storm raises Chrome every few seconds; one line per
@@ -331,7 +331,7 @@ def _truncate(s, n=320):
331
331
  return s if len(s) <= n else s[: n - 1] + "…"
332
332
 
333
333
 
334
- def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
334
+ def _set_thread_text(thread_tv, text, link_url, *, font_size=12, title=None):
335
335
  """Set the thread-quote text on `thread_tv`: the FULL text, never
336
336
  trimmed (2026-07-16 user direction -- the box scrolls instead when it
337
337
  doesn't fit; see its NSScrollView wrapper in _render()). A leading
@@ -340,8 +340,15 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
340
340
  of how long the thread is or where the box happens to be scrolled to
341
341
  (the old trailing-link placement could end up scrolled out of view, or
342
342
  -- before scrolling existed at all -- get silently truncated off the
343
- end; see the superseded _fit_thread_body this replaced)."""
343
+ end; see the superseded _fit_thread_body this replaced).
344
+
345
+ `title` (2026-07-17 user direction, Reddit): when given, it's drawn BOLD
346
+ on its own line ahead of `text`, so a Reddit card shows the post title in
347
+ bold with the selftext body beneath it in the same quote box. Twitter and
348
+ title-only Reddit threads pass title=None and render `text` alone,
349
+ exactly as before."""
344
350
  text = (text or "").strip()
351
+ title = (title or "").strip()
345
352
  body = NSMutableAttributedString.alloc().initWithString_attributes_("", {})
346
353
  if link_url:
347
354
  body.appendAttributedString_(
@@ -356,6 +363,19 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
356
363
  },
357
364
  )
358
365
  )
366
+ if title:
367
+ # Bold title on its own line; a trailing newline separates it from the
368
+ # body only when there IS a body (a title with no selftext shouldn't
369
+ # leave a dangling blank line).
370
+ body.appendAttributedString_(
371
+ NSAttributedString.alloc().initWithString_attributes_(
372
+ title + ("\n" if text else ""),
373
+ {
374
+ NSFontAttributeName: NSFont.boldSystemFontOfSize_(font_size),
375
+ NSForegroundColorAttributeName: NSColor.labelColor(),
376
+ },
377
+ )
378
+ )
359
379
  body.appendAttributedString_(
360
380
  NSAttributedString.alloc().initWithString_attributes_(
361
381
  text,
@@ -425,8 +445,26 @@ def _engagement_line(stats):
425
445
  return " · ".join(parts)
426
446
 
427
447
 
448
+ def _compact_arm(variant):
449
+ """Shrink a draft_prompt A/B variant to a heading-sized token so the arm
450
+ fits inline next to project/lane/viral: 'treatment_v4' -> 't v4',
451
+ 'control_v4' -> 'c v4'. Anything that isn't a treatment/control_vN shape
452
+ (future arm names, retired bare 'treatment'/'control') falls back to the
453
+ raw variant, so no arm goes unlabelled. The full one-sentence meaning
454
+ still lives in the details popover via active_experiments.describe()."""
455
+ v = (variant or "").strip()
456
+ if not v:
457
+ return ""
458
+ m = re.match(r"(treatment|control)(?:_v?(\w+))?$", v)
459
+ if not m:
460
+ return v
461
+ arm = "t" if m.group(1) == "treatment" else "c"
462
+ ver = m.group(2)
463
+ return f"{arm} v{ver}" if ver else arm
464
+
465
+
428
466
  def _reply_heading_suffix(d):
429
- """Concise 'project/lane · viral N' readout appended straight onto the
467
+ """Concise 'project/lane · viral N · t v4' readout appended straight onto the
430
468
  'Reply (editable):' heading (2026-07-07), so the two facts a reviewer
431
469
  checks first don't require opening the details eye. Pulled from the same
432
470
  fields _details_lines used to render as 'Project:'/'Lane:'/'Virality
@@ -465,6 +503,15 @@ def _reply_heading_suffix(d):
465
503
  bits.append(f"viral {float(v):g}")
466
504
  except (TypeError, ValueError):
467
505
  pass
506
+ # The drafting A/B arm (treatment vs control), compacted to 't v4'/'c v4'
507
+ # so the reviewer sees which prompt variant produced this draft without
508
+ # opening the details eye. Only draft_prompt rides the heading; other
509
+ # experiments stay solely in the popover. draft_prompt ALSO keeps its full
510
+ # one-sentence meaning in the popover (unlike 'lane', which is skipped
511
+ # there) because the compact token alone doesn't say what the arm does.
512
+ arm = _compact_arm((d.get("experiments") or {}).get("draft_prompt"))
513
+ if arm:
514
+ bits.append(arm)
468
515
  return " · ".join(bits)
469
516
 
470
517
 
@@ -1571,7 +1618,19 @@ class _ReviewController(NSObject):
1571
1618
  pass
1572
1619
  if thread_url:
1573
1620
  thread_tv.setDelegate_(self)
1574
- _set_thread_text(thread_tv, thread_en or d.get("thread_text"), thread_url)
1621
+ # Reddit (2026-07-17 user direction): the thread has a TITLE and a
1622
+ # BODY (selftext). Render the title bold with the body beneath it in
1623
+ # this same quote box. thread_text carries the title (see
1624
+ # merge_review_queue._reddit_plan_to_candidates); thread_selftext is
1625
+ # the body, empty for link posts / title-only threads (then this falls
1626
+ # through to the plain title-only render). Twitter has no selftext, so
1627
+ # it always takes the else branch and renders exactly as before.
1628
+ _thread_title = thread_en or d.get("thread_text")
1629
+ _thread_body = (d.get("thread_selftext") or "").strip() if _plat == "reddit" else ""
1630
+ if _thread_body:
1631
+ _set_thread_text(thread_tv, _thread_body, thread_url, title=_thread_title)
1632
+ else:
1633
+ _set_thread_text(thread_tv, _thread_title, thread_url)
1575
1634
  thread_scroll.setDocumentView_(thread_tv)
1576
1635
  content.addSubview_(thread_scroll)
1577
1636
  # Reply heading — bold. A concise "project/lane · viral N" tag rides
@@ -69,6 +69,7 @@ from AppKit import (
69
69
  NSWindowOcclusionStateVisible,
70
70
  NSBackingStoreBuffered,
71
71
  NSNormalWindowLevel,
72
+ NSFloatingWindowLevel,
72
73
  NSApplicationActivationPolicyAccessory,
73
74
  NSWindowStyleMaskTitled,
74
75
  NSWindowStyleMaskClosable,
@@ -247,13 +248,57 @@ class _CanvasController(NSObject):
247
248
  panel.orderFrontRegardless()
248
249
  self._log_surface("presented")
249
250
  if self._focus:
251
+ self.raise_to_front()
252
+
253
+ @objc.python_method
254
+ def raise_to_front(self):
255
+ """Reliably bring the canvas above the current front app ONCE. The panel
256
+ is a NON-activating panel at NSNormalWindowLevel (so it behaves like a
257
+ normal window the reviewer works IN, per 2026-07-16), which means a plain
258
+ makeKeyAndOrderFront on our accessory app can leave it BEHIND the
259
+ frontmost app: window-level ordering, not activation, decides what covers
260
+ what, and a normal-level window can't jump above another normal-level
261
+ window that belongs to the active app. That is exactly the 2026-07-17
262
+ Nhat report -- the canvas 'presented' occluded ("didn't surface"). Fix:
263
+ briefly promote to NSFloatingWindowLevel so orderFrontRegardless is
264
+ guaranteed to place it in front regardless of activation, then demote
265
+ back to normal a beat later so other apps can cover it again (keeping the
266
+ "work IN it like a normal window" posture; always-on-top is the corner
267
+ card's job, not this one)."""
268
+ panel = self._panel
269
+ if panel is None:
270
+ return
271
+ try:
272
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
273
+ NSApp.activateIgnoringOtherApps_(True)
274
+ except Exception:
275
+ pass
276
+ try:
277
+ panel.setLevel_(NSFloatingWindowLevel)
278
+ except Exception:
279
+ pass
280
+ try:
281
+ panel.makeKeyAndOrderFront_(None)
282
+ panel.orderFrontRegardless()
283
+ except Exception:
284
+ pass
285
+ # Drop back to a normal window level once it's on screen. performSelector
286
+ # keeps it above other windows just long enough to land in front; if
287
+ # scheduling ever fails, demote inline so it can never stay stuck on top.
288
+ try:
289
+ self.performSelector_withObject_afterDelay_("demoteWindowLevel:", None, 0.6)
290
+ except Exception:
250
291
  try:
251
- NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
252
- NSApp.activateIgnoringOtherApps_(True)
292
+ panel.setLevel_(NSNormalWindowLevel)
253
293
  except Exception:
254
294
  pass
255
- panel.makeKeyAndOrderFront_(None)
256
- panel.orderFrontRegardless()
295
+
296
+ def demoteWindowLevel_(self, _):
297
+ try:
298
+ if self._panel is not None:
299
+ self._panel.setLevel_(NSNormalWindowLevel)
300
+ except Exception:
301
+ pass
257
302
 
258
303
  @objc.python_method
259
304
  def _render(self):
@@ -457,6 +502,15 @@ class _CanvasController(NSObject):
457
502
  self._reflow_from(idx)
458
503
  self._resize_doc()
459
504
  self._refresh_header()
505
+ # Last card decided -> nothing left to review, so close the canvas
506
+ # instead of leaving an empty "0 pending drafts" window on screen.
507
+ # _finish() fires on_complete with every decision made this session,
508
+ # exactly like the corner card auto-finishing past its final draft
509
+ # (s4l_card._advance). Reached from a tile completing (approve/reject)
510
+ # and from prune_drafts draining the last candidate; either way an
511
+ # empty canvas has no reason to stay up.
512
+ if not self._order and self._panel is not None:
513
+ self._finish()
460
514
 
461
515
  # Approve All / Discard All buttons live in the header row (see
462
516
  # _render()), not a dedicated card -- these two actions just target the
@@ -746,13 +800,10 @@ def focus_active():
746
800
  if c is None or c._panel is None:
747
801
  return False
748
802
  try:
749
- NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
750
- NSApp.activateIgnoringOtherApps_(True)
751
- except Exception:
752
- pass
753
- try:
754
- c._panel.makeKeyAndOrderFront_(None)
755
- c._panel.orderFrontRegardless()
803
+ # Same reliable front-raise as the initial focused present: a brief
804
+ # window-level promotion so an already-open canvas comes above the front
805
+ # app even though it is a non-activating normal-level panel.
806
+ c.raise_to_front()
756
807
  c._panel.makeMainWindow()
757
808
  return True
758
809
  except Exception: