@m13v/s4l 1.7.3 → 1.7.4-rc.10
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 +71 -2
- package/mcp/dist/twitterAuth.js +3 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +111 -11
- package/mcp/menubar/s4l_menubar.py +7 -0
- package/mcp/menubar/s4l_state.py +4 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/build_persona.py +8 -3
- package/scripts/engagement_styles.py +12 -1
- package/scripts/enrich_reply_parents.py +266 -0
- package/scripts/feedback_digest.py +70 -0
- package/scripts/invent_styles.py +245 -0
- package/scripts/recent_self_posts.py +152 -0
- package/scripts/scan_x_profile.py +210 -17
- package/scripts/tlh_pick_248.py +92 -0
- package/scripts/top_performers.py +89 -9
- package/scripts/voice_exemplars.py +461 -0
- package/skill/engage-twitter.sh +9 -0
- package/skill/run-twitter-cycle.sh +51 -4
package/mcp/dist/index.js
CHANGED
|
@@ -1394,6 +1394,31 @@ async function seedSearchQueriesForProject(project, rawQueries) {
|
|
|
1394
1394
|
return { note: ` (Search-query seeding skipped — ${e.message}.)`, queries };
|
|
1395
1395
|
}
|
|
1396
1396
|
}
|
|
1397
|
+
// After a project save, persist the profile scan's engagement-ranked top
|
|
1398
|
+
// replies into that project's voice.examples (and the persona_corpus.txt
|
|
1399
|
+
// exemplar section when the project is the persona). Every drafting prompt on
|
|
1400
|
+
// every platform already mirrors voice.examples, so this ONE write feeds them
|
|
1401
|
+
// all. scripts/voice_exemplars.py reads the last_profile_scan.json sidecar
|
|
1402
|
+
// scan_x_profile.py wrote and only quotes the user's own public replies
|
|
1403
|
+
// verbatim (mechanical ranking, no synthesis). Best-effort by design: no scan
|
|
1404
|
+
// yet, no usable replies, or hand-written voice.examples (exit 3, respected)
|
|
1405
|
+
// all return null and never block the save.
|
|
1406
|
+
async function applyScannedVoiceExamples(project) {
|
|
1407
|
+
try {
|
|
1408
|
+
const res = await runPython("scripts/voice_exemplars.py", ["apply", "--project", project], {
|
|
1409
|
+
timeoutMs: 30_000,
|
|
1410
|
+
});
|
|
1411
|
+
const last = res.stdout.trim().split("\n").slice(-1)[0] || "";
|
|
1412
|
+
const parsed = JSON.parse(last);
|
|
1413
|
+
if (parsed.ok && parsed.voice_examples_written) {
|
|
1414
|
+
return `Stored ${parsed.voice_examples_written} of their top-performing real replies (ranked by engagement, with the threads they answered) as voice.examples — every drafter now mirrors them.`;
|
|
1415
|
+
}
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1397
1422
|
// ---- engagement_mode: choose personal-brand vs product (setup-time) --------
|
|
1398
1423
|
// Part of onboarding: AFTER X connect + profile_scan, BEFORE product config, the
|
|
1399
1424
|
// agent asks the user which mode they want and calls this. It persists the mode
|
|
@@ -1603,6 +1628,10 @@ tool("engagement_mode", {
|
|
|
1603
1628
|
return textContent(`Mode saved as ${mode}, but provisioning the persona project failed: ${e?.message || e}. ` +
|
|
1604
1629
|
`Retry engagement_mode action:'set'.`);
|
|
1605
1630
|
}
|
|
1631
|
+
// Persist the profile scan's top-performing real replies as the persona's
|
|
1632
|
+
// voice.examples + the persona_corpus.txt exemplar section (best-effort;
|
|
1633
|
+
// see applyScannedVoiceExamples).
|
|
1634
|
+
const personaExemplarNote = await applyScannedVoiceExamples(personaName);
|
|
1606
1635
|
// Seed the persona's topics into the DB universe the cycle reads (best-effort;
|
|
1607
1636
|
// the cycle's own fail-loud path still reports if topics are missing).
|
|
1608
1637
|
let personaTopicsSeeded = false;
|
|
@@ -1687,6 +1716,7 @@ tool("engagement_mode", {
|
|
|
1687
1716
|
persona_topic_count: personaTopicCount,
|
|
1688
1717
|
persona_query_count: personaQueryCount,
|
|
1689
1718
|
persona_query_note: personaQueryNote || null,
|
|
1719
|
+
persona_voice_examples: personaExemplarNote,
|
|
1690
1720
|
kicker_installed: kickerInstall ? kickerInstall.ok : null,
|
|
1691
1721
|
kicker_detail: kickerInstall ? kickerInstall.detail : null,
|
|
1692
1722
|
onboarding: onboardingSnapshot(),
|
|
@@ -1966,12 +1996,17 @@ tool("project_config", {
|
|
|
1966
1996
|
counts: scan.counts,
|
|
1967
1997
|
posts: scan.posts,
|
|
1968
1998
|
comments: scan.comments,
|
|
1999
|
+
top_posts: scan.top_posts,
|
|
2000
|
+
top_replies: scan.top_replies,
|
|
1969
2001
|
grounding_instructions: scan.grounding_instructions,
|
|
1970
2002
|
website_research_instructions: WEBSITE_RESEARCH_INSTRUCTIONS,
|
|
1971
2003
|
onboarding: onboardingSnapshot(),
|
|
1972
2004
|
next_step: "FOUR steps, in order. FIRST (VOICE, from this scan): read the bio, posts, and comments " +
|
|
1973
2005
|
"as GROUND TRUTH and, per grounding_instructions, extract their profession/identity, " +
|
|
1974
|
-
"voice & vibe (tone, phrasing, casing, tics),
|
|
2006
|
+
"voice & vibe (tone, phrasing, casing, tics), verbatim golden-rule example replies (the " +
|
|
2007
|
+
"scan pre-ranks these by real engagement in top_replies/top_posts, with stats, parent " +
|
|
2008
|
+
"tweets, and thread continuations; persist them into the project's voice.examples so " +
|
|
2009
|
+
"every drafter mirrors them), " +
|
|
1975
2010
|
"a phrase bank + things they avoid, and their icp. The scan is BACKWARD-LOOKING (only what " +
|
|
1976
2011
|
"they already posted) so it is the source for VOICE, not the primary source for topics. " +
|
|
1977
2012
|
"SECOND (the DICTATION interview — this is where TOPICS + grounding corpus come from, do NOT " +
|
|
@@ -2084,6 +2119,13 @@ tool("project_config", {
|
|
|
2084
2119
|
});
|
|
2085
2120
|
}
|
|
2086
2121
|
const result = applySetup(args);
|
|
2122
|
+
// Persist the profile scan's engagement-ranked exemplars into this
|
|
2123
|
+
// project's voice.examples (+ the persona corpus section when the target
|
|
2124
|
+
// is the persona). Reads the last_profile_scan.json sidecar the scanner
|
|
2125
|
+
// wrote; verbatim quotes of the user's own public replies, so no
|
|
2126
|
+
// synthesis happens here. Best-effort: no scan yet or hand-written
|
|
2127
|
+
// examples present (exit 3) are both fine, and never block the save.
|
|
2128
|
+
const exemplarNote = await applyScannedVoiceExamples(result.project);
|
|
2087
2129
|
if (result.persona) {
|
|
2088
2130
|
// no-op on the onboarding ledger; readiness is reported below as usual.
|
|
2089
2131
|
}
|
|
@@ -2181,6 +2223,7 @@ tool("project_config", {
|
|
|
2181
2223
|
kicker_detail: kickerInstall ? kickerInstall.detail : null,
|
|
2182
2224
|
fields_set: result.fields_set,
|
|
2183
2225
|
fields_removed: result.fields_removed,
|
|
2226
|
+
voice_examples: exemplarNote,
|
|
2184
2227
|
config_path: configPath(),
|
|
2185
2228
|
onboarding: onboardingSnapshot(),
|
|
2186
2229
|
note: (result.persona
|
|
@@ -2197,7 +2240,8 @@ tool("project_config", {
|
|
|
2197
2240
|
: `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
|
|
2198
2241
|
`First derive those fields from existing context, profile_scan, and website research, then ` +
|
|
2199
2242
|
`call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
|
|
2200
|
-
advancedNote
|
|
2243
|
+
advancedNote +
|
|
2244
|
+
(exemplarNote ? ` ${exemplarNote}` : ""),
|
|
2201
2245
|
});
|
|
2202
2246
|
}
|
|
2203
2247
|
catch (e) {
|
|
@@ -5266,6 +5310,31 @@ async function main() {
|
|
|
5266
5310
|
void sendStateSnapshot("startup");
|
|
5267
5311
|
const ss = setInterval(() => void sendStateSnapshot("interval"), 15 * 60_000);
|
|
5268
5312
|
ss.unref();
|
|
5313
|
+
// One-shot voice-exemplar catch-up for installs onboarded BEFORE the
|
|
5314
|
+
// exemplar feature: if the persona has no voice.examples_scanned_at, rescan
|
|
5315
|
+
// the connected X profile and store the top-performing replies as
|
|
5316
|
+
// voice.examples + the persona_corpus.txt exemplar section. Additive only
|
|
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
|
|
5320
|
+
// WAITS politely on the twitter-browser lock, polling while holding
|
|
5321
|
+
// nothing, until cycles/DM runs free the browser, up to 12h before
|
|
5322
|
+
// deferring to the next boot). Delayed so boot-time work (runtime
|
|
5323
|
+
// provision, kicker install) settles first.
|
|
5324
|
+
const backfill = setTimeout(() => {
|
|
5325
|
+
if (isPaused())
|
|
5326
|
+
return;
|
|
5327
|
+
// timeout covers the 12h lock wait plus generous room for the scan itself
|
|
5328
|
+
void runPython("scripts/voice_exemplars.py", ["backfill"], { timeoutMs: 13 * 3600_000 })
|
|
5329
|
+
.then((r) => {
|
|
5330
|
+
const last = r.stdout.trim().split("\n").slice(-1)[0] || "";
|
|
5331
|
+
console.error(`[social-autoposter-mcp] voice-exemplars backfill: ${last}`);
|
|
5332
|
+
})
|
|
5333
|
+
.catch((e) => {
|
|
5334
|
+
console.error("[social-autoposter-mcp] voice-exemplars backfill failed:", e?.message || e);
|
|
5335
|
+
});
|
|
5336
|
+
}, 3 * 60_000);
|
|
5337
|
+
backfill.unref();
|
|
5269
5338
|
}
|
|
5270
5339
|
main().catch(async (err) => {
|
|
5271
5340
|
console.error("[social-autoposter-mcp] fatal:", err);
|
package/mcp/dist/twitterAuth.js
CHANGED
|
@@ -60,8 +60,9 @@ export async function xScanProfile(opts) {
|
|
|
60
60
|
args.push("--handle", opts.handle);
|
|
61
61
|
args.push("--posts", String(opts?.posts ?? 20));
|
|
62
62
|
args.push("--comments", String(opts?.comments ?? 50));
|
|
63
|
-
// The scan scrolls two timelines
|
|
64
|
-
|
|
63
|
+
// The scan scrolls two timelines plus visits the top posts' permalinks for
|
|
64
|
+
// thread expansion; give it room but keep it bounded.
|
|
65
|
+
const res = await runPython("scripts/scan_x_profile.py", args, { timeoutMs: 240_000 });
|
|
65
66
|
try {
|
|
66
67
|
return JSON.parse(res.stdout.trim().split("\n").slice(-1).join("\n"));
|
|
67
68
|
}
|
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
|
+
"version": "1.7.4-rc.10",
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -732,6 +732,21 @@ class _ReviewController(NSObject):
|
|
|
732
732
|
self._selected_draft = None
|
|
733
733
|
self._draft_textviews = {}
|
|
734
734
|
self._draft_scrolls = {}
|
|
735
|
+
# Per-draft hover dwell (two-draft cards, 2026-07-10): accumulated
|
|
736
|
+
# milliseconds the pointer spent over each draft box, so the feedback
|
|
737
|
+
# digest can tell an informed keep of Draft A (they read B and stayed)
|
|
738
|
+
# from a fast approve that says nothing about B. Raw ms ship on the
|
|
739
|
+
# decision; the read-vs-skim threshold lives digest-side so it can be
|
|
740
|
+
# tuned without a client release. _draft_hover_open holds the enter
|
|
741
|
+
# timestamp of any hover still in progress (flushed on decision).
|
|
742
|
+
self._draft_hover_ms = {0: 0, 1: 0}
|
|
743
|
+
self._draft_hover_open = {}
|
|
744
|
+
# Slots the caret has actually been in this card (2026-07-10 follow-up):
|
|
745
|
+
# lets the decision distinguish "clicked into B, then came BACK to A"
|
|
746
|
+
# (an explicit head-to-head choice of A, per user) from "never touched
|
|
747
|
+
# B at all". Only the UNCHOSEN slot's membership matters at decision
|
|
748
|
+
# time; the selected slot is trivially visited.
|
|
749
|
+
self._draft_visited = set()
|
|
735
750
|
# Attention anchors for the unattended-review watchdog: the stack counts
|
|
736
751
|
# as "touched" on present, on any tracked interaction, and on any
|
|
737
752
|
# decision. No touch past the watchdog threshold = the user is not
|
|
@@ -1059,6 +1074,9 @@ class _ReviewController(NSObject):
|
|
|
1059
1074
|
self._interactions = []
|
|
1060
1075
|
self._card_shown_at = time.time()
|
|
1061
1076
|
self._selected_draft = None
|
|
1077
|
+
self._draft_hover_ms = {0: 0, 1: 0}
|
|
1078
|
+
self._draft_hover_open = {}
|
|
1079
|
+
self._draft_visited = set()
|
|
1062
1080
|
self._reason_field = None
|
|
1063
1081
|
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
|
|
1064
1082
|
|
|
@@ -1389,6 +1407,19 @@ class _ReviewController(NSObject):
|
|
|
1389
1407
|
tv.setDelegate_(self)
|
|
1390
1408
|
outline.addSubview_(scroll)
|
|
1391
1409
|
content.addSubview_(outline)
|
|
1410
|
+
# Hover dwell per draft box (same NSTrackingArea pattern as the
|
|
1411
|
+
# eye buttons): enter/exit timestamps accumulate into
|
|
1412
|
+
# _draft_hover_ms[slot] so the decision can say whether the
|
|
1413
|
+
# reviewer actually READ the draft they didn't pick. slot rides
|
|
1414
|
+
# on userInfo, mirroring the eyes' `kind` routing.
|
|
1415
|
+
outline.addTrackingArea_(
|
|
1416
|
+
NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
|
|
1417
|
+
outline.bounds(),
|
|
1418
|
+
NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
|
|
1419
|
+
self,
|
|
1420
|
+
{"kind": "draft", "slot": slot},
|
|
1421
|
+
)
|
|
1422
|
+
)
|
|
1392
1423
|
self._draft_scrolls[slot] = scroll
|
|
1393
1424
|
self._draft_outlines[slot] = outline
|
|
1394
1425
|
self._draft_textviews[slot] = tv
|
|
@@ -1542,21 +1573,32 @@ class _ReviewController(NSObject):
|
|
|
1542
1573
|
self._show_details_popover()
|
|
1543
1574
|
|
|
1544
1575
|
@objc.python_method
|
|
1545
|
-
def
|
|
1546
|
-
"""
|
|
1547
|
-
|
|
1548
|
-
|
|
1576
|
+
def _hover_info(self, event):
|
|
1577
|
+
"""(kind, slot) a tracking-area event belongs to, from the userInfo
|
|
1578
|
+
stamped at creation: ('stats'|'details', None) for the eye icons,
|
|
1579
|
+
('draft', 0|1) for the two draft boxes. Defaults to ('stats', None),
|
|
1580
|
+
the original single-eye behavior, if the area carries no info."""
|
|
1549
1581
|
try:
|
|
1550
1582
|
info = event.trackingArea().userInfo()
|
|
1551
|
-
if info
|
|
1552
|
-
|
|
1583
|
+
if info:
|
|
1584
|
+
kind = info.get("kind")
|
|
1585
|
+
if kind == "draft":
|
|
1586
|
+
return "draft", int(info.get("slot"))
|
|
1587
|
+
if kind == "details":
|
|
1588
|
+
return "details", None
|
|
1553
1589
|
except Exception:
|
|
1554
1590
|
pass
|
|
1555
|
-
return "stats"
|
|
1591
|
+
return "stats", None
|
|
1556
1592
|
|
|
1557
|
-
# NSTrackingArea owner callbacks (hover over either eye icon
|
|
1593
|
+
# NSTrackingArea owner callbacks (hover over either eye icon or, on
|
|
1594
|
+
# two-draft cards, either draft box). Draft hovers only bank dwell time
|
|
1595
|
+
# (no popover, no logging: the boxes are big and enter/exit fires on
|
|
1596
|
+
# every pass of the pointer).
|
|
1558
1597
|
def mouseEntered_(self, event):
|
|
1559
|
-
kind = self.
|
|
1598
|
+
kind, slot = self._hover_info(event)
|
|
1599
|
+
if kind == "draft":
|
|
1600
|
+
self._draft_hover_open[slot] = time.time()
|
|
1601
|
+
return
|
|
1560
1602
|
_log(f"{kind} eye hover enter")
|
|
1561
1603
|
if kind == "details":
|
|
1562
1604
|
self._show_details_popover()
|
|
@@ -1564,9 +1606,32 @@ class _ReviewController(NSObject):
|
|
|
1564
1606
|
self._show_stats_popover()
|
|
1565
1607
|
|
|
1566
1608
|
def mouseExited_(self, event):
|
|
1609
|
+
kind, slot = self._hover_info(event)
|
|
1610
|
+
if kind == "draft":
|
|
1611
|
+
started = self._draft_hover_open.pop(slot, None)
|
|
1612
|
+
if started is not None:
|
|
1613
|
+
self._draft_hover_ms[slot] = self._draft_hover_ms.get(slot, 0) + int(
|
|
1614
|
+
(time.time() - started) * 1000
|
|
1615
|
+
)
|
|
1616
|
+
return
|
|
1567
1617
|
_log("eye hover exit")
|
|
1568
1618
|
self._close_stats_popover()
|
|
1569
1619
|
|
|
1620
|
+
@objc.python_method
|
|
1621
|
+
def _flush_draft_hovers(self):
|
|
1622
|
+
"""Bank any hover still in progress (pointer inside a draft box at
|
|
1623
|
+
decision time, e.g. a keyboard approve) so _record reads final
|
|
1624
|
+
totals."""
|
|
1625
|
+
now = time.time()
|
|
1626
|
+
for slot, started in list(self._draft_hover_open.items()):
|
|
1627
|
+
self._draft_hover_ms[slot] = self._draft_hover_ms.get(slot, 0) + int(
|
|
1628
|
+
(now - started) * 1000
|
|
1629
|
+
)
|
|
1630
|
+
# Keep the hover open (re-anchored at now) rather than deleting
|
|
1631
|
+
# it: the pointer really is still inside the box, so a later
|
|
1632
|
+
# mouseExited_ must not double-count the pre-flush span.
|
|
1633
|
+
self._draft_hover_open[slot] = now
|
|
1634
|
+
|
|
1570
1635
|
@objc.python_method
|
|
1571
1636
|
def _add_link(self, content, frame, text, url, *, size=12, bold=False, right=False, kind="link_click"):
|
|
1572
1637
|
"""Borderless button styled as a link (system link color, underlined).
|
|
@@ -1630,11 +1695,18 @@ class _ReviewController(NSObject):
|
|
|
1630
1695
|
except Exception:
|
|
1631
1696
|
return
|
|
1632
1697
|
for slot, cand_tv in (self._draft_textviews or {}).items():
|
|
1633
|
-
if cand_tv is tv
|
|
1698
|
+
if cand_tv is not tv:
|
|
1699
|
+
continue
|
|
1700
|
+
# Visited even when it's already the selected slot: membership of
|
|
1701
|
+
# the eventually-UNCHOSEN slot is what _record reads, and that
|
|
1702
|
+
# slot only ever gets the caret via a deliberate user click (the
|
|
1703
|
+
# auto-focus seat in _render targets the selected slot only).
|
|
1704
|
+
self._draft_visited.add(slot)
|
|
1705
|
+
if slot != self._selected_draft:
|
|
1634
1706
|
self._selected_draft = slot
|
|
1635
1707
|
self._textview = cand_tv
|
|
1636
1708
|
self._update_draft_borders()
|
|
1637
|
-
|
|
1709
|
+
break
|
|
1638
1710
|
|
|
1639
1711
|
@objc.python_method
|
|
1640
1712
|
def _update_draft_borders(self):
|
|
@@ -1704,9 +1776,32 @@ class _ReviewController(NSObject):
|
|
|
1704
1776
|
chosen_draft = drafts[sel_idx]
|
|
1705
1777
|
orig = (chosen_draft.get("text") or "").strip()
|
|
1706
1778
|
draft_variant = chosen_draft.get("variant") or ("a" if sel_idx == 0 else "b")
|
|
1779
|
+
# Full pairwise context for the feedback digest (2026-07-10): the
|
|
1780
|
+
# UNCHOSEN draft's text+style ride along so "picked B over A" (or
|
|
1781
|
+
# "kept A after reading B", per the hover dwell) is a usable
|
|
1782
|
+
# preference PAIR, not just a winner with no loser. Shipped as one
|
|
1783
|
+
# nested dict end to end (decision -> review event -> jsonb column)
|
|
1784
|
+
# so adding a field never needs another schema hop.
|
|
1785
|
+
self._flush_draft_hovers()
|
|
1786
|
+
other = drafts[1 - sel_idx]
|
|
1787
|
+
draft_choice = {
|
|
1788
|
+
"variant": draft_variant,
|
|
1789
|
+
"index": sel_idx,
|
|
1790
|
+
"auto_selected": bool(sel_idx == 0),
|
|
1791
|
+
"style": chosen_draft.get("style") or None,
|
|
1792
|
+
"unchosen_text": (other.get("text") or "").strip() or None,
|
|
1793
|
+
"unchosen_style": other.get("style") or None,
|
|
1794
|
+
"hover_a_ms": int(self._draft_hover_ms.get(0, 0)),
|
|
1795
|
+
"hover_b_ms": int(self._draft_hover_ms.get(1, 0)),
|
|
1796
|
+
# True = the caret was in the unchosen box at some point, i.e.
|
|
1797
|
+
# they tried the other draft and came back: an explicit choice
|
|
1798
|
+
# even when the winner is the preselected default.
|
|
1799
|
+
"visited_other": bool((1 - sel_idx) in self._draft_visited),
|
|
1800
|
+
}
|
|
1707
1801
|
else:
|
|
1708
1802
|
orig = (d.get("reply_text") or "").strip()
|
|
1709
1803
|
draft_variant = None
|
|
1804
|
+
draft_choice = None
|
|
1710
1805
|
link = d.get("link_url") or ""
|
|
1711
1806
|
drop_link = False
|
|
1712
1807
|
if approved:
|
|
@@ -1762,6 +1857,11 @@ class _ReviewController(NSObject):
|
|
|
1762
1857
|
"draft_variant": draft_variant,
|
|
1763
1858
|
"draft_index": sel_idx,
|
|
1764
1859
|
"draft_auto_selected": bool(dual and sel_idx == 0),
|
|
1860
|
+
# Nested pairwise record (chosen vs unchosen text/style plus
|
|
1861
|
+
# per-box hover dwell); None on single-draft candidates. The
|
|
1862
|
+
# flat three fields above stay for their existing consumers
|
|
1863
|
+
# (edit-learning variant stamp in s4l_menubar).
|
|
1864
|
+
"draft_choice": draft_choice,
|
|
1765
1865
|
}
|
|
1766
1866
|
)
|
|
1767
1867
|
self._last_decision_at = time.time()
|
|
@@ -2941,6 +2941,13 @@ 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"),
|
|
2944
2951
|
}
|
|
2945
2952
|
)
|
|
2946
2953
|
except Exception:
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -786,6 +786,10 @@ 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"),
|
|
789
793
|
"decided_at": time_iso(),
|
|
790
794
|
}
|
|
791
795
|
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.
|
|
3
|
+
"version": "1.7.4-rc.10",
|
|
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
package/scripts/build_persona.py
CHANGED
|
@@ -90,9 +90,14 @@ GROUNDING_INSTRUCTIONS = (
|
|
|
90
90
|
" description 2-3 sentences: who this person is as a builder/voice.\n"
|
|
91
91
|
" content_angle one paragraph of concrete, first-hand experience the "
|
|
92
92
|
"persona can speak from (real projects, real numbers, real pain).\n"
|
|
93
|
-
" voice {tone, never[]}: how they actually write (read
|
|
94
|
-
"posts/replies in the x source). Keep the organic rules: first
|
|
95
|
-
"specific, no links, no feature lists, no sales, no em dashes
|
|
93
|
+
" voice {tone, never[], examples[]}: how they actually write (read "
|
|
94
|
+
"their own posts/replies in the x source). Keep the organic rules: first "
|
|
95
|
+
"person, specific, no links, no feature lists, no sales, no em dashes. For "
|
|
96
|
+
"examples[], the x source pre-ranks their best real replies/posts by "
|
|
97
|
+
"engagement (top_replies / top_posts, each with stats, the parent tweet, and "
|
|
98
|
+
"thread continuations): keep up to 5 verbatim, or run "
|
|
99
|
+
"`voice_exemplars.py apply --scan <x-source.json>` after apply to write "
|
|
100
|
+
"voice.examples + the persona_corpus.txt exemplar section deterministically.\n"
|
|
96
101
|
" search_topics ~15 topics they have genuine experience with.\n"
|
|
97
102
|
" content_corpus (OPTIONAL but STRONGLY encouraged) the RAW gathered "
|
|
98
103
|
"corpus as one plain-text block: the persona's actual posts, replies, repo "
|
|
@@ -1034,7 +1034,18 @@ def compute_target_distribution(platform, context="posting"):
|
|
|
1034
1034
|
# mode="invent" and the prompt hands the model the top N as reference
|
|
1035
1035
|
# material to derive a new style from.
|
|
1036
1036
|
|
|
1037
|
-
|
|
1037
|
+
# 2026-07-10: inline invention retired (rate 0). It ran inside the drafting
|
|
1038
|
+
# prep session with the top-performers leaderboard + winner exemplars in
|
|
1039
|
+
# context, so every "new" style was a renamed clone of the winning
|
|
1040
|
+
# agree-then-relocate move; combined with name-only dedup the registry hit
|
|
1041
|
+
# ~938 styles that were mostly one structure. Invention now lives in the
|
|
1042
|
+
# standalone scripts/invent_styles.py job (operator-only launchd
|
|
1043
|
+
# com.m13v.s4l-invent-styles: the registry is global across installs, so a
|
|
1044
|
+
# central daily run replaces per-post rolls). The invent-mode plumbing in
|
|
1045
|
+
# pickers/posters is kept intact: register_style() is what the standalone
|
|
1046
|
+
# job calls, and a nonzero rate here re-enables the inline path if ever
|
|
1047
|
+
# wanted.
|
|
1048
|
+
INVENT_RATE = 0.0 # retired inline roll (was 0.05, ~1 in 20 posts)
|
|
1038
1049
|
CURATED_TOP_N = 5 # size of the invent-mode reference list (top 5 by score)
|
|
1039
1050
|
|
|
1040
1051
|
# Fallback target comment length (chars) for any style that lacks an explicit
|