@m13v/s4l 1.7.5-rc.34 → 1.7.5-rc.35

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
@@ -5893,17 +5893,22 @@ async function main() {
5893
5893
  void sendStateSnapshot("startup");
5894
5894
  const ss = setInterval(() => void sendStateSnapshot("interval"), 15 * 60_000);
5895
5895
  ss.unref();
5896
- // One-shot voice-exemplar catch-up for installs onboarded BEFORE the
5897
- // exemplar feature: if the persona has no voice.examples_scanned_at, rescan
5898
- // the connected X profile and store the top-performing replies as
5899
- // voice.examples + the persona_corpus.txt exemplar section. Additive only
5900
- // (regenerates just its own marked corpus section; respects hand-written
5901
- // examples) and self-limiting (no cooldown by design: success stamps
5902
- // examples_scanned_at which makes later boots a no-op, and until then it
5903
- // WAITS politely on the twitter-browser lock, polling while holding
5904
- // nothing, until cycles/DM runs free the browser, up to 12h before
5905
- // deferring to the next boot). Delayed so boot-time work (runtime
5906
- // provision, kicker install) settles first.
5896
+ // Voice-exemplar catch-up + periodic refresh, checked on every boot
5897
+ // (2026-07-16, extended from the original one-shot-for-legacy-installs
5898
+ // version): rescans the connected X profile and re-stores voice.examples +
5899
+ // the persona_corpus.txt exemplar section whenever
5900
+ // scripts/voice_exemplars.py's _needs_rescan() says so -- never scanned,
5901
+ // scanned by an older SCANNER_VERSION (so a scraper fix like the 2026-07-15
5902
+ // stall-detection bug reaches already-scanned installs on their next boot
5903
+ // instead of silently never re-running), or the last scan is more than
5904
+ // RESCAN_MAX_AGE_DAYS (14) old. Additive only (regenerates just its own
5905
+ // marked corpus section; respects hand-written examples). Each boot is
5906
+ // cheap when nothing needs it (stamped examples_scanned_at +
5907
+ // examples_scanner_version make it a no-op until either goes stale), and
5908
+ // when something DOES need it, it WAITS politely on the twitter-browser
5909
+ // lock, polling while holding nothing, until cycles/DM runs free the
5910
+ // browser, up to 12h before deferring to the next boot. Delayed so
5911
+ // boot-time work (runtime provision, kicker install) settles first.
5907
5912
  const backfill = setTimeout(() => {
5908
5913
  if (isPaused())
5909
5914
  return;
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.5-rc.34",
3
- "installedAt": "2026-07-16T01:35:05.419Z"
2
+ "version": "1.7.5-rc.35",
3
+ "installedAt": "2026-07-16T02:02:31.387Z"
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.5-rc.34",
5
+ "version": "1.7.5-rc.35",
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": {
@@ -100,7 +100,7 @@ from AppKit import (
100
100
  NSLinkAttributeName,
101
101
  NSWindowOcclusionStateVisible,
102
102
  NSBackingStoreBuffered,
103
- NSFloatingWindowLevel,
103
+ NSNormalWindowLevel,
104
104
  NSApplicationActivationPolicyAccessory,
105
105
  NSWindowStyleMaskTitled,
106
106
  NSWindowStyleMaskClosable,
@@ -314,8 +314,13 @@ class _CanvasController(NSObject):
314
314
  # that overlapped the header row under the title bar (2026-07-16 user
315
315
  # report). Keep the real content size around for _render().
316
316
  self._content_size = NSMakeSize(frame.size.width, frame.size.height)
317
- panel.setLevel_(NSFloatingWindowLevel)
318
- panel.setFloatingPanel_(True)
317
+ # Normal window level, NOT floating (2026-07-16 user report): this is
318
+ # a large, deliberate review session the reviewer works IN, unlike
319
+ # the corner card's small always-on-top notification -- it should
320
+ # behave like any other window (other apps can come to front over
321
+ # it) rather than staying glued above everything.
322
+ panel.setLevel_(NSNormalWindowLevel)
323
+ panel.setFloatingPanel_(False)
319
324
  panel.setBecomesKeyOnlyIfNeeded_(True)
320
325
  panel.setHidesOnDeactivate_(False)
321
326
  panel.setReleasedWhenClosed_(False)
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.5-rc.34",
3
+ "version": "1.7.5-rc.35",
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.5-rc.34",
3
+ "version": "1.7.5-rc.35",
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",
@@ -60,6 +60,14 @@ CDP = os.environ.get(
60
60
  os.environ.get("TWITTER_CDP_URL", "http://127.0.0.1:9555"),
61
61
  ).rstrip("/")
62
62
 
63
+ # Bump whenever the scraping/ranking logic changes materially enough that an
64
+ # install's existing scan should be considered stale and worth redoing, even
65
+ # though it "succeeded" under the old code (e.g. the 2026-07-15 stall-detection
66
+ # fix: old scans from accounts where S4L posts heavily silently undercounted
67
+ # organic replies). scripts/voice_exemplars.py stamps this alongside
68
+ # examples_scanned_at and treats a lower stamped version as a rescan trigger.
69
+ SCANNER_VERSION = 2
70
+
63
71
 
64
72
  # --------------------------------------------------------------------------- #
65
73
  # CDP attach (mirrors setup_twitter_auth.py::_attach so behavior is identical).
@@ -560,6 +568,7 @@ def main() -> int:
560
568
  "counts": {"posts": len(posts), "comments": len(comments),
561
569
  "s4l_posted_excluded": len(s4l_ids)},
562
570
  "grounding_instructions": GROUNDING_INSTRUCTIONS,
571
+ "scanner_version": SCANNER_VERSION,
563
572
  }
564
573
 
565
574
  # Persist the corpus beside config.json so the later project-save step
@@ -59,10 +59,41 @@ HERE = Path(__file__).resolve().parent
59
59
  sys.path.insert(0, str(HERE))
60
60
 
61
61
  import s4l_mode # noqa: E402
62
+ from scan_x_profile import SCANNER_VERSION # noqa: E402
62
63
 
63
64
  EXEMPLARS_START = "=== S4L SCANNED EXEMPLARS START (auto-generated by voice_exemplars.py; regenerated on rescan, do not hand-edit) ==="
64
65
  EXEMPLARS_END = "=== S4L SCANNED EXEMPLARS END ==="
65
66
 
67
+ # Periodic staleness refresh (2026-07-16): even a scan done with the current
68
+ # scanner can go stale as the account posts/replies more over time. Re-scan
69
+ # any persona project whose last scan is older than this, on top of the
70
+ # version-triggered rescan below.
71
+ RESCAN_MAX_AGE_DAYS = 14
72
+
73
+
74
+ def _needs_rescan(proj: dict) -> bool:
75
+ """True if this persona project should get a fresh profile scan:
76
+ never scanned, hand-written examples (exempt from auto-touch, but also
77
+ never "need" a rescan since we'd never apply one), the last scan predates
78
+ the current SCANNER_VERSION (a scraping/ranking fix shipped since), or the
79
+ last scan is older than RESCAN_MAX_AGE_DAYS. An unparseable timestamp is
80
+ treated as stale rather than silently never-rescanned."""
81
+ if _is_hand_written(proj):
82
+ return False
83
+ voice = proj.get("voice")
84
+ if not isinstance(voice, dict) or not voice.get("examples_scanned_at"):
85
+ return True
86
+ if int(voice.get("examples_scanner_version") or 0) < SCANNER_VERSION:
87
+ return True
88
+ try:
89
+ scanned_at = datetime.fromisoformat(voice["examples_scanned_at"])
90
+ if scanned_at.tzinfo is None:
91
+ scanned_at = scanned_at.replace(tzinfo=timezone.utc)
92
+ age_days = (datetime.now(timezone.utc) - scanned_at).days
93
+ return age_days >= RESCAN_MAX_AGE_DAYS
94
+ except Exception:
95
+ return True
96
+
66
97
 
67
98
  def _score(item: dict) -> int:
68
99
  """Mirror scan_x_profile.rank_top's weighting for scans that predate it."""
@@ -215,6 +246,7 @@ def _set_examples(proj: dict, scan: dict, top: int, min_chars: int,
215
246
  proj["voice"] = voice
216
247
  voice["examples"] = examples
217
248
  voice["examples_scanned_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
249
+ voice["examples_scanner_version"] = scan.get("scanner_version") or SCANNER_VERSION
218
250
  return True, "ok", examples
219
251
 
220
252
 
@@ -339,15 +371,23 @@ def _release_browser_lock() -> None:
339
371
 
340
372
 
341
373
  def cmd_backfill(args) -> int:
342
- """Called best-effort on MCP boot. If the persona project predates the
343
- exemplar feature (no voice.examples_scanned_at), run a fresh profile scan
344
- and apply. Scoped to the PERSONA project only: every install provisions one
345
- at onboarding, and it is where the author's own voice lives; product
346
- projects pick exemplars up on their next project_config save instead. The
347
- corpus write is additive (the marked section is the only thing replaced;
348
- dictation and hand-added material stay). No cooldown by design (user rule
349
- 2026-07-10): every boot retries until the scan succeeds; success stamps
350
- examples_scanned_at, which makes all later boots a cheap no-op. Boots are
374
+ """Called best-effort on MCP boot. Runs a fresh profile scan and (re)applies
375
+ exemplars for any persona project that _needs_rescan() flags: never
376
+ scanned (predates the exemplar feature), scanned by an older
377
+ SCANNER_VERSION (a scraping/ranking fix shipped since -- e.g. the
378
+ 2026-07-15 stall-detection fix silently undercounted replies on accounts
379
+ where S4L posts heavily, so those pre-fix scans need redoing even though
380
+ they "succeeded"), or scanned more than RESCAN_MAX_AGE_DAYS ago (2026-07-16:
381
+ periodic staleness refresh, since the account keeps posting/replying after
382
+ the last scan). Scoped to the PERSONA project only: every install
383
+ provisions one at onboarding, and it is where the author's own voice
384
+ lives; product projects pick exemplars up on their next project_config
385
+ save instead. The corpus write is additive (the marked section is the
386
+ only thing replaced; dictation and hand-added material stay). No cooldown
387
+ beyond the staleness window by design (user rule 2026-07-10 extended
388
+ 2026-07-16): every boot retries until the scan succeeds; success stamps
389
+ examples_scanned_at + examples_scanner_version, which makes all later
390
+ boots a cheap no-op UNTIL either goes stale again. Boots are
351
391
  user-triggered (Desktop launch), so the worst case is one scan attempt per
352
392
  launch, and concurrent waiters from overlapping boots dedupe via the
353
393
  post-lock eligibility re-check."""
@@ -357,9 +397,7 @@ def cmd_backfill(args) -> int:
357
397
  return 0
358
398
  cfg = json.loads(cfg_path.read_text())
359
399
  personas = [p for p in cfg.get("projects", []) if p.get("persona") is True]
360
- eligible = [p for p in personas
361
- if not (isinstance(p.get("voice"), dict) and p["voice"].get("examples_scanned_at"))
362
- and not _is_hand_written(p)]
400
+ eligible = [p for p in personas if _needs_rescan(p)]
363
401
  if not eligible:
364
402
  print(json.dumps({"ok": True, "did": "nothing", "reason": "already_done_or_hand_written"}))
365
403
  return 0
@@ -375,9 +413,7 @@ def cmd_backfill(args) -> int:
375
413
  # meanwhile. Re-read config and re-check before burning a scan.
376
414
  cfg = json.loads(cfg_path.read_text())
377
415
  personas = [p for p in cfg.get("projects", []) if p.get("persona") is True]
378
- eligible = [p for p in personas
379
- if not (isinstance(p.get("voice"), dict) and p["voice"].get("examples_scanned_at"))
380
- and not _is_hand_written(p)]
416
+ eligible = [p for p in personas if _needs_rescan(p)]
381
417
  if not eligible:
382
418
  _release_browser_lock()
383
419
  print(json.dumps({"ok": True, "did": "nothing", "reason": "done_while_waiting"}))