@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 +16 -11
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card_canvas.py +8 -3
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/scan_x_profile.py +9 -0
- package/scripts/voice_exemplars.py +51 -15
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
|
-
//
|
|
5897
|
-
//
|
|
5898
|
-
// the connected X profile and
|
|
5899
|
-
//
|
|
5900
|
-
// (
|
|
5901
|
-
//
|
|
5902
|
-
//
|
|
5903
|
-
//
|
|
5904
|
-
//
|
|
5905
|
-
//
|
|
5906
|
-
//
|
|
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;
|
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.5-rc.
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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.
|
|
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
|
@@ -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.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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"}))
|