@m13v/s4l 1.7.4-rc.1 → 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 +8 -5
- 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/enrich_reply_parents.py +266 -0
- package/scripts/feedback_digest.py +70 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/scan_x_profile.py +69 -8
- package/scripts/top_performers.py +34 -0
- package/scripts/voice_exemplars.py +57 -20
- package/skill/engage-twitter.sh +9 -0
- package/skill/run-twitter-cycle.sh +28 -1
package/mcp/dist/index.js
CHANGED
|
@@ -5315,14 +5315,17 @@ 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 (
|
|
5319
|
-
//
|
|
5320
|
-
//
|
|
5321
|
-
//
|
|
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.
|
|
5322
5324
|
const backfill = setTimeout(() => {
|
|
5323
5325
|
if (isPaused())
|
|
5324
5326
|
return;
|
|
5325
|
-
|
|
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 })
|
|
5326
5329
|
.then((r) => {
|
|
5327
5330
|
const last = r.stdout.trim().split("\n").slice(-1)[0] || "";
|
|
5328
5331
|
console.error(`[social-autoposter-mcp] voice-exemplars backfill: ${last}`);
|
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.4-rc.
|
|
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.4-rc.
|
|
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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fill parent-thread linkage on X replies discovered via the notifications lane.
|
|
3
|
+
|
|
4
|
+
The X notifications feed does not expose the parent tweet id, so
|
|
5
|
+
scan_twitter_mentions_browser.py inserts `replies` rows with mention_id only:
|
|
6
|
+
no post_id, no parent_reply_id, and often no project_name. This script
|
|
7
|
+
resolves the parent chain AFTER the fact, deterministically, with no browser
|
|
8
|
+
and no model: fxtwitter's public JSON (already used by fetch_twitter_t1.py)
|
|
9
|
+
returns `replying_to_status` for any tweet.
|
|
10
|
+
|
|
11
|
+
Per row with (post_id IS NULL AND parent_reply_id IS NULL):
|
|
12
|
+
1. fxtwitter GET on their_comment_id -> parent tweet id + handle.
|
|
13
|
+
2. Walk up the ancestor chain (bounded hops) until the root.
|
|
14
|
+
3. First ancestor that is one of OUR posts (/api/v1/posts/lookup, wide
|
|
15
|
+
window) -> PATCH replies.post_id (+ project_name when the row has none).
|
|
16
|
+
4. Immediate parent that is another tracked reply
|
|
17
|
+
(/api/v1/replies?their_comment_id=) -> PATCH parent_reply_id + depth.
|
|
18
|
+
5. Root author -> PATCH thread_author_handle.
|
|
19
|
+
|
|
20
|
+
Terminal misses (tweet deleted, protected, not-a-reply with nothing to link)
|
|
21
|
+
are remembered in a local state file so recurring runs don't refetch forever.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python3 scripts/enrich_reply_parents.py --limit 20 # recurring lane
|
|
25
|
+
python3 scripts/enrich_reply_parents.py --backfill # all missing rows
|
|
26
|
+
python3 scripts/enrich_reply_parents.py --ids 546832 542579 # specific rows
|
|
27
|
+
python3 scripts/enrich_reply_parents.py --limit 5 --dry-run
|
|
28
|
+
|
|
29
|
+
All DB I/O goes through the s4l.ai HTTP API (http_api), never direct SQL.
|
|
30
|
+
"""
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
import urllib.error
|
|
37
|
+
import urllib.request
|
|
38
|
+
|
|
39
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
40
|
+
from http_api import api_get, api_patch # noqa: E402
|
|
41
|
+
|
|
42
|
+
STATE_PATH = os.environ.get(
|
|
43
|
+
"S4L_ENRICH_PARENTS_STATE",
|
|
44
|
+
os.path.expanduser("~/.social-autoposter-enrich-parents.json"),
|
|
45
|
+
)
|
|
46
|
+
MAX_HOPS = 6
|
|
47
|
+
LOOKUP_DAYS = 3650
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_state():
|
|
51
|
+
try:
|
|
52
|
+
with open(STATE_PATH) as f:
|
|
53
|
+
return json.load(f)
|
|
54
|
+
except Exception:
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_state(state):
|
|
59
|
+
tmp = STATE_PATH + ".tmp"
|
|
60
|
+
with open(tmp, "w") as f:
|
|
61
|
+
json.dump(state, f)
|
|
62
|
+
os.replace(tmp, STATE_PATH)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch_fxtwitter(handle, tweet_id):
|
|
66
|
+
"""Returns (status, tweet_dict). status: 'ok' | 'gone' | 'transient'."""
|
|
67
|
+
url = f"https://api.fxtwitter.com/{handle or 'i'}/status/{tweet_id}"
|
|
68
|
+
req = urllib.request.Request(url, headers={"User-Agent": "social-autoposter/1.0"})
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
71
|
+
data = json.loads(resp.read())
|
|
72
|
+
except urllib.error.HTTPError as e:
|
|
73
|
+
# 401 = protected account, 404 = deleted. Both terminal.
|
|
74
|
+
if e.code in (401, 404):
|
|
75
|
+
return "gone", None
|
|
76
|
+
return "transient", None
|
|
77
|
+
except Exception:
|
|
78
|
+
return "transient", None
|
|
79
|
+
code = data.get("code")
|
|
80
|
+
if code == 200 and data.get("tweet"):
|
|
81
|
+
return "ok", data["tweet"]
|
|
82
|
+
if code in (401, 404):
|
|
83
|
+
return "gone", None
|
|
84
|
+
return "transient", None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def walk_ancestors(handle, tweet_id, sleep_s):
|
|
88
|
+
"""Ancestor chain bottom-up: [(id, handle), ...] parent first, root last.
|
|
89
|
+
|
|
90
|
+
Returns (chain, terminal) where terminal is 'root' when the walk reached a
|
|
91
|
+
non-reply tweet, or 'cut' when a hop was deleted/protected/transient (the
|
|
92
|
+
chain up to that point is still usable, but root attribution is not).
|
|
93
|
+
"""
|
|
94
|
+
chain = []
|
|
95
|
+
cur_handle, cur_id = handle, tweet_id
|
|
96
|
+
for _ in range(MAX_HOPS):
|
|
97
|
+
status, tweet = fetch_fxtwitter(cur_handle, cur_id)
|
|
98
|
+
time.sleep(sleep_s)
|
|
99
|
+
if status != "ok":
|
|
100
|
+
# 'gone' is terminal (deleted/protected); 'transient' must NOT be
|
|
101
|
+
# remembered, the next run retries it.
|
|
102
|
+
return chain, status
|
|
103
|
+
parent_id = tweet.get("replying_to_status")
|
|
104
|
+
parent_handle = tweet.get("replying_to") or ""
|
|
105
|
+
if not parent_id:
|
|
106
|
+
return chain, "root"
|
|
107
|
+
chain.append((str(parent_id), parent_handle))
|
|
108
|
+
cur_handle, cur_id = parent_handle, parent_id
|
|
109
|
+
return chain, "hop_cap"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def lookup_our_post(tweet_id):
|
|
113
|
+
resp = api_get(
|
|
114
|
+
"/api/v1/posts/lookup",
|
|
115
|
+
query={"platform": "twitter", "post_id": str(tweet_id), "days": str(LOOKUP_DAYS)},
|
|
116
|
+
)
|
|
117
|
+
return (resp.get("data") or {}).get("post") or None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def lookup_tracked_reply(tweet_id):
|
|
121
|
+
resp = api_get(
|
|
122
|
+
"/api/v1/replies",
|
|
123
|
+
query={"platform": "x", "their_comment_id": str(tweet_id), "limit": "1"},
|
|
124
|
+
)
|
|
125
|
+
rows = (resp.get("data") or {}).get("replies") or []
|
|
126
|
+
return rows[0] if rows else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def fetch_work(limit, our_account=None, ids=None, before_id=None):
|
|
130
|
+
if ids:
|
|
131
|
+
out = []
|
|
132
|
+
for rid in ids:
|
|
133
|
+
resp = api_get(f"/api/v1/replies/{rid}")
|
|
134
|
+
row = (resp.get("data") or {}).get("reply")
|
|
135
|
+
if row:
|
|
136
|
+
out.append(row)
|
|
137
|
+
return out
|
|
138
|
+
query = {
|
|
139
|
+
"platform": "x",
|
|
140
|
+
"missing_parent": "1",
|
|
141
|
+
"order_by": "id",
|
|
142
|
+
"limit": str(limit),
|
|
143
|
+
}
|
|
144
|
+
if our_account:
|
|
145
|
+
query["our_account"] = our_account
|
|
146
|
+
if before_id:
|
|
147
|
+
query["before_id"] = str(before_id)
|
|
148
|
+
resp = api_get("/api/v1/replies", query=query)
|
|
149
|
+
return (resp.get("data") or {}).get("replies") or []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def enrich_row(row, state, sleep_s, dry_run):
|
|
153
|
+
rid = row["id"]
|
|
154
|
+
tid = str(row.get("their_comment_id") or "")
|
|
155
|
+
handle = (row.get("their_author") or "").lstrip("@")
|
|
156
|
+
if not tid:
|
|
157
|
+
return "no_tweet_id"
|
|
158
|
+
if state.get(tid):
|
|
159
|
+
return "state_skip"
|
|
160
|
+
|
|
161
|
+
chain, terminal = walk_ancestors(handle, tid, sleep_s)
|
|
162
|
+
if not chain:
|
|
163
|
+
if terminal == "transient":
|
|
164
|
+
return "transient" # retry next run, no state write
|
|
165
|
+
# Deleted/protected focal tweet, or a standalone mention (not a reply).
|
|
166
|
+
state[tid] = "gone" if terminal == "gone" else "not_a_reply"
|
|
167
|
+
return state[tid]
|
|
168
|
+
|
|
169
|
+
patch = {}
|
|
170
|
+
matched_post = None
|
|
171
|
+
for anc_id, _anc_handle in chain:
|
|
172
|
+
post = lookup_our_post(anc_id)
|
|
173
|
+
if post:
|
|
174
|
+
matched_post = post
|
|
175
|
+
break
|
|
176
|
+
if matched_post:
|
|
177
|
+
patch["post_id"] = matched_post["id"]
|
|
178
|
+
if not row.get("project_name") and matched_post.get("project_name"):
|
|
179
|
+
patch["project_name"] = matched_post["project_name"]
|
|
180
|
+
|
|
181
|
+
parent_id, _parent_handle = chain[0]
|
|
182
|
+
tracked = lookup_tracked_reply(parent_id)
|
|
183
|
+
if tracked and tracked["id"] != rid:
|
|
184
|
+
patch["parent_reply_id"] = tracked["id"]
|
|
185
|
+
patch["depth"] = (tracked.get("depth") or 1) + 1
|
|
186
|
+
|
|
187
|
+
if terminal == "root":
|
|
188
|
+
root_handle = (chain[-1][1] or "").lstrip("@")
|
|
189
|
+
if root_handle and not row.get("thread_author_handle"):
|
|
190
|
+
patch["thread_author_handle"] = root_handle
|
|
191
|
+
|
|
192
|
+
if not patch:
|
|
193
|
+
if terminal == "transient":
|
|
194
|
+
return "transient" # incomplete walk; retry next run
|
|
195
|
+
# Full chain walked, nothing of ours in it: a foreign thread. Remember
|
|
196
|
+
# so we don't rewalk it every run.
|
|
197
|
+
state[tid] = "no_link"
|
|
198
|
+
return "no_link"
|
|
199
|
+
|
|
200
|
+
if dry_run:
|
|
201
|
+
print(f" [DRY] reply {rid}: {json.dumps(patch)}")
|
|
202
|
+
return "would_patch"
|
|
203
|
+
|
|
204
|
+
resp = api_patch(f"/api/v1/replies/{rid}", patch)
|
|
205
|
+
if resp.get("error"):
|
|
206
|
+
print(f" ERROR patching reply {rid}: {resp['error']}", file=sys.stderr)
|
|
207
|
+
return "patch_error"
|
|
208
|
+
state[tid] = "linked"
|
|
209
|
+
kinds = "+".join(k for k in ("post_id", "parent_reply_id", "thread_author_handle") if k in patch)
|
|
210
|
+
print(f" linked reply {rid}: {kinds} {json.dumps(patch)}")
|
|
211
|
+
return "linked"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main():
|
|
215
|
+
ap = argparse.ArgumentParser(description="Backfill parent-thread linkage on X replies")
|
|
216
|
+
ap.add_argument("--limit", type=int, default=20, help="rows per run (recurring lane)")
|
|
217
|
+
ap.add_argument("--backfill", action="store_true", help="keep paging until no work is left")
|
|
218
|
+
ap.add_argument("--ids", nargs="*", type=int, help="enrich specific reply ids")
|
|
219
|
+
ap.add_argument("--our-account", default=None, help="scope to one posting handle")
|
|
220
|
+
ap.add_argument("--all-accounts", action="store_true", help="do not scope by handle")
|
|
221
|
+
ap.add_argument("--sleep", type=float, default=0.5, help="seconds between fxtwitter calls")
|
|
222
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
223
|
+
args = ap.parse_args()
|
|
224
|
+
|
|
225
|
+
our_account = args.our_account
|
|
226
|
+
if not our_account and not args.all_accounts and not args.ids:
|
|
227
|
+
try:
|
|
228
|
+
from account_resolver import resolve as _resolve_account
|
|
229
|
+
our_account = _resolve_account("twitter")
|
|
230
|
+
except Exception:
|
|
231
|
+
our_account = None
|
|
232
|
+
if not our_account:
|
|
233
|
+
print("No twitter account resolvable; pass --our-account or --all-accounts", file=sys.stderr)
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
state = load_state()
|
|
237
|
+
totals = {}
|
|
238
|
+
seen_ids = set()
|
|
239
|
+
before_id = None
|
|
240
|
+
while True:
|
|
241
|
+
rows = fetch_work(
|
|
242
|
+
args.limit if not args.backfill else 200, our_account, args.ids, before_id
|
|
243
|
+
)
|
|
244
|
+
rows = [r for r in rows if r["id"] not in seen_ids]
|
|
245
|
+
if not rows:
|
|
246
|
+
break
|
|
247
|
+
for row in rows:
|
|
248
|
+
seen_ids.add(row["id"])
|
|
249
|
+
# Terminal misses (deleted tweet, foreign thread) never leave the
|
|
250
|
+
# missing_parent queue; the id cursor pages past them.
|
|
251
|
+
before_id = row["id"] if before_id is None else min(before_id, row["id"])
|
|
252
|
+
outcome = enrich_row(row, state, args.sleep, args.dry_run)
|
|
253
|
+
totals[outcome] = totals.get(outcome, 0) + 1
|
|
254
|
+
if len(seen_ids) % 25 == 0 and not args.dry_run:
|
|
255
|
+
save_state(state)
|
|
256
|
+
if not args.dry_run:
|
|
257
|
+
save_state(state)
|
|
258
|
+
if args.ids or not args.backfill:
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
print(f"[enrich_reply_parents] processed={len(seen_ids)} " +
|
|
262
|
+
" ".join(f"{k}={v}" for k, v in sorted(totals.items())))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
main()
|
|
@@ -82,6 +82,13 @@ MAX_EVENTS_PER_RUN = 200
|
|
|
82
82
|
# degraded (a bad search topic, a draft-quality regression), not that any
|
|
83
83
|
# single draft was wrong in a way the digest model could articulate.
|
|
84
84
|
BULK_NO_REASON_THRESHOLD = int(os.environ.get("S4L_BULK_NO_REASON_THRESHOLD", "3"))
|
|
85
|
+
# Two-draft cards ship raw per-box pointer-dwell ms in draft_choice; this is
|
|
86
|
+
# the read-vs-skim floor for the draft the reviewer did NOT pick. At or above
|
|
87
|
+
# it, keeping the preselected Draft A counts as an informed keep (they read B
|
|
88
|
+
# and stayed); below it the approval says nothing about B. Threshold lives
|
|
89
|
+
# HERE, not in the menubar client, so it can be tuned without a client
|
|
90
|
+
# release.
|
|
91
|
+
DRAFT_READ_MS = int(os.environ.get("S4L_DRAFT_READ_MS", "1000"))
|
|
85
92
|
|
|
86
93
|
DISALLOWED_TOOLS = (
|
|
87
94
|
"ScheduleWakeup,CronCreate,CronDelete,CronList,EnterPlanMode,EnterWorktree,"
|
|
@@ -106,10 +113,54 @@ def load_config():
|
|
|
106
113
|
return {"projects": []}
|
|
107
114
|
|
|
108
115
|
|
|
116
|
+
def _draft_choice(e: dict) -> dict | None:
|
|
117
|
+
"""Parsed draft_choice payload (two-draft cards only). The API returns
|
|
118
|
+
jsonb as a dict; a locally-buffered event may still carry it as a JSON
|
|
119
|
+
string. None when absent, unparseable, or missing the unchosen draft
|
|
120
|
+
(nothing pairwise to say without the loser)."""
|
|
121
|
+
dc = e.get("draft_choice")
|
|
122
|
+
if isinstance(dc, str):
|
|
123
|
+
try:
|
|
124
|
+
dc = json.loads(dc)
|
|
125
|
+
except Exception:
|
|
126
|
+
return None
|
|
127
|
+
if not isinstance(dc, dict) or not (dc.get("unchosen_text") or "").strip():
|
|
128
|
+
return None
|
|
129
|
+
return dc
|
|
130
|
+
|
|
131
|
+
|
|
109
132
|
def _event_line(e: dict) -> str:
|
|
110
133
|
"""One compact evidence line per event for the prompt."""
|
|
111
134
|
parts = [f"[{e.get('decision')}{'+loved' if e.get('loved') else ''}]"]
|
|
112
135
|
note = (e.get("reject_note") or "").strip()
|
|
136
|
+
# Two-draft pairwise flags (approvals only: on a reject BOTH drafts died,
|
|
137
|
+
# so which box the caret sat in carries no preference). Weighting ladder,
|
|
138
|
+
# explained to the model in build_prompt: an active switch to Draft B is
|
|
139
|
+
# strong (they necessarily read both); keeping the preselected A counts
|
|
140
|
+
# only when hover dwell shows they actually read B; a fast approve with B
|
|
141
|
+
# unread is flagged as exactly that so no preference gets fabricated.
|
|
142
|
+
dc = _draft_choice(e) if e.get("decision") == "approved" else None
|
|
143
|
+
show_unchosen = False
|
|
144
|
+
if dc:
|
|
145
|
+
if not dc.get("auto_selected"):
|
|
146
|
+
parts.append("picked_draft_b_over_default_a")
|
|
147
|
+
show_unchosen = True
|
|
148
|
+
elif dc.get("visited_other"):
|
|
149
|
+
# Clicked into B, then came back and approved A: an explicit
|
|
150
|
+
# head-to-head choice of the default, same strength as a switch
|
|
151
|
+
# (2026-07-10 user rule: only a zero-interaction approve is
|
|
152
|
+
# no-signal).
|
|
153
|
+
parts.append("chose_default_a_after_trying_b")
|
|
154
|
+
show_unchosen = True
|
|
155
|
+
else:
|
|
156
|
+
other_ms = dc.get("hover_b_ms") or 0
|
|
157
|
+
if other_ms >= DRAFT_READ_MS:
|
|
158
|
+
parts.append(
|
|
159
|
+
f"kept_default_a_after_reading_b={round(other_ms / 1000, 1)}s"
|
|
160
|
+
)
|
|
161
|
+
show_unchosen = True
|
|
162
|
+
else:
|
|
163
|
+
parts.append("second_draft_not_read")
|
|
113
164
|
if e.get("reject_category"):
|
|
114
165
|
parts.append(f"category={e['reject_category']}")
|
|
115
166
|
elif e.get("decision") == "rejected" and not note:
|
|
@@ -147,6 +198,13 @@ def _event_line(e: dict) -> str:
|
|
|
147
198
|
line += f"\n user REWROTE it to: {draft[:300]}"
|
|
148
199
|
elif draft:
|
|
149
200
|
line += f"\n our draft was: {draft[:200]}"
|
|
201
|
+
if dc and show_unchosen:
|
|
202
|
+
line += f"\n the draft they did NOT pick was: {(dc.get('unchosen_text') or '')[:300]}"
|
|
203
|
+
if dc.get("style") or dc.get("unchosen_style"):
|
|
204
|
+
line += (
|
|
205
|
+
f"\n styles: picked={dc.get('style') or '?'}"
|
|
206
|
+
f" not_picked={dc.get('unchosen_style') or '?'}"
|
|
207
|
+
)
|
|
150
208
|
url = (e.get("thread_url") or "").strip()
|
|
151
209
|
if url:
|
|
152
210
|
line += f"\n thread: {url}"
|
|
@@ -205,6 +263,8 @@ NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reaso
|
|
|
205
263
|
|
|
206
264
|
Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
|
|
207
265
|
|
|
266
|
+
Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "second_draft_not_read" means they approved the default without touching or reading the alternative: NO pairwise signal, never infer anything against the unread draft. When 2+ pairwise events agree, diff the picked texts against the not-picked ones and distill WHAT recurs (tone, structure, length, opener type, directness) into draft_style_notes; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
|
|
267
|
+
|
|
208
268
|
You can also block SPECIFIC authors via the plan's block_authors list. A block is a permanent hard exclusion of that one handle from all future thread selection, so it is YOUR judgment call, never automatic. Block when the evidence is strong: a wrong_author reject IS a direct human statement about that author (especially with profile_click), and the author context (author_followers, their post, found_via_topic) or the user's note confirms the account itself was the problem rather than the topic. Do NOT block when the reject looks topic-driven (off_topic/bad_draft on a reasonable account) or when you are unsure; the generalizable TYPE entry in audience_avoid is the softer tool for that.
|
|
209
269
|
|
|
210
270
|
Propose changes to the block. RULES, in priority order:
|
|
@@ -333,6 +393,16 @@ def _is_actionable(e: dict) -> bool:
|
|
|
333
393
|
# as actionable as a reject (and it feeds edit_examples).
|
|
334
394
|
if e.get("edited"):
|
|
335
395
|
return True
|
|
396
|
+
# An explicit pairwise draft choice (switched to B, or tried B and came
|
|
397
|
+
# back to A) is style evidence on par with an edit; without this trigger a
|
|
398
|
+
# reviewer who mostly approves would bank pairwise signals behind the
|
|
399
|
+
# plain-approvals gate forever. Hover-only informed keeps stay
|
|
400
|
+
# NON-actionable on purpose: they are corroborating-weight evidence (like
|
|
401
|
+
# no_reason_given) and ride along with the next real trigger instead of
|
|
402
|
+
# burning a Claude turn on their own.
|
|
403
|
+
dc = _draft_choice(e)
|
|
404
|
+
if dc and (not dc.get("auto_selected") or dc.get("visited_other")):
|
|
405
|
+
return True
|
|
336
406
|
return bool((e.get("reject_note") or "").strip())
|
|
337
407
|
|
|
338
408
|
|
|
@@ -95,6 +95,12 @@ def build_block(platform, limit):
|
|
|
95
95
|
"order_by": "posted_at",
|
|
96
96
|
"order_dir": "desc",
|
|
97
97
|
"limit": str(limit),
|
|
98
|
+
# Scope to THIS install's own posts (server filters on the
|
|
99
|
+
# authenticated X-Installation identity). Without this the
|
|
100
|
+
# default read returns the whole fleet's posts and the block
|
|
101
|
+
# would claim another account's replies as "yours"
|
|
102
|
+
# (found 2026-07-10: 8 of 20 rows were another install's).
|
|
103
|
+
"own_install": "true",
|
|
98
104
|
},
|
|
99
105
|
)
|
|
100
106
|
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
@@ -311,6 +311,50 @@ def scrape_timeline(send, me: str, want: int, max_scrolls: int = 30,
|
|
|
311
311
|
return items[:want]
|
|
312
312
|
|
|
313
313
|
|
|
314
|
+
def _own_posted_ids() -> set:
|
|
315
|
+
"""Status ids of everything S4L itself has posted from this install, via
|
|
316
|
+
/api/v1/posts (install-scoped by the X-Installation header). Used to keep
|
|
317
|
+
the bot's own output OUT of the author-voice exemplar pool: on accounts
|
|
318
|
+
where S4L has been active, a recency scan is dominated by S4L drafts, and
|
|
319
|
+
feeding those back as 'the author's voice' is a feedback loop. Best-effort:
|
|
320
|
+
any failure (offline, fresh install, no API) returns an empty set and the
|
|
321
|
+
scan proceeds unfiltered."""
|
|
322
|
+
try:
|
|
323
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
324
|
+
from http_api import api_get # noqa: PLC0415
|
|
325
|
+
import re
|
|
326
|
+
ids: set = set()
|
|
327
|
+
# The route caps limit at 500, so walk the FULL posting history with a
|
|
328
|
+
# forward posted_at cursor (order_dir=asc + since>=). On prolific
|
|
329
|
+
# accounts a single newest-500 page misses older S4L posts that the
|
|
330
|
+
# profile scan still reaches (bit the operator account: 12.8k posted
|
|
331
|
+
# statuses, 2 of 5 picked exemplars were S4L's own). The since filter
|
|
332
|
+
# is inclusive, so overlap rows dedupe via the set; a page whose max
|
|
333
|
+
# posted_at equals the cursor would loop and breaks instead.
|
|
334
|
+
cursor = None
|
|
335
|
+
for _ in range(80): # hard cap 40k statuses
|
|
336
|
+
q = {"platform": "twitter", "has_our_url": "true", "limit": 500,
|
|
337
|
+
"order_by": "posted_at", "order_dir": "asc"}
|
|
338
|
+
if cursor:
|
|
339
|
+
q["since"] = cursor
|
|
340
|
+
resp = api_get("/api/v1/posts", q)
|
|
341
|
+
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
342
|
+
for r in rows:
|
|
343
|
+
m = re.search(r"/status/(\d+)", str(r.get("our_url") or ""))
|
|
344
|
+
if m:
|
|
345
|
+
ids.add(m.group(1))
|
|
346
|
+
if len(rows) < 500:
|
|
347
|
+
break
|
|
348
|
+
page_max = max((r.get("posted_at") or "" for r in rows), default="")
|
|
349
|
+
if not page_max or page_max == cursor:
|
|
350
|
+
break
|
|
351
|
+
cursor = page_max
|
|
352
|
+
return ids
|
|
353
|
+
except Exception as e:
|
|
354
|
+
print(f"[scan_x_profile] own-posts exclusion unavailable: {e}", file=sys.stderr)
|
|
355
|
+
return set()
|
|
356
|
+
|
|
357
|
+
|
|
314
358
|
# --------------------------------------------------------------------------- #
|
|
315
359
|
# Engagement ranking + thread expansion for exemplar extraction.
|
|
316
360
|
# --------------------------------------------------------------------------- #
|
|
@@ -409,8 +453,8 @@ GROUNDING_INSTRUCTIONS = (
|
|
|
409
453
|
def main() -> int:
|
|
410
454
|
ap = argparse.ArgumentParser()
|
|
411
455
|
ap.add_argument("--handle", default=None, help="@handle to scan (default: live logged-in handle)")
|
|
412
|
-
ap.add_argument("--posts", type=int, default=
|
|
413
|
-
ap.add_argument("--comments", type=int, default=
|
|
456
|
+
ap.add_argument("--posts", type=int, default=60, help="max original posts to collect")
|
|
457
|
+
ap.add_argument("--comments", type=int, default=150, help="max replies/comments to collect")
|
|
414
458
|
ap.add_argument("--top", type=int, default=5, help="how many top posts/replies to rank")
|
|
415
459
|
ap.add_argument("--expand-threads", type=int, default=3,
|
|
416
460
|
help="visit this many top posts' permalinks to capture thread "
|
|
@@ -446,21 +490,37 @@ def main() -> int:
|
|
|
446
490
|
expect=f"/{handle}")
|
|
447
491
|
profile = scrape_profile(send) if on_profile else {}
|
|
448
492
|
|
|
449
|
-
# 2.
|
|
450
|
-
|
|
493
|
+
# 2. Everything S4L itself posted from this install gets excluded from
|
|
494
|
+
# BOTH surfaces (posts and replies): the exemplars must be the
|
|
495
|
+
# human's writing, not the bot's own output echoed back.
|
|
496
|
+
s4l_ids = _own_posted_ids()
|
|
497
|
+
if s4l_ids:
|
|
498
|
+
print(f"[scan_x_profile] excluding {len(s4l_ids)} s4l-posted statuses "
|
|
499
|
+
"from the exemplar pool", file=sys.stderr)
|
|
500
|
+
|
|
501
|
+
# 3. Original posts (current page = posts tab). max_scrolls tracks the
|
|
502
|
+
# requested depth: the scan is programmatic, so scrolling deeper
|
|
503
|
+
# costs only time, and end-of-feed stall detection stops it early
|
|
504
|
+
# on small accounts.
|
|
505
|
+
posts = (scrape_timeline(send, handle, args.posts,
|
|
506
|
+
max_scrolls=max(30, args.posts),
|
|
507
|
+
exclude_ids=s4l_ids)
|
|
508
|
+
if on_profile else [])
|
|
451
509
|
post_ids = {p.get("id") for p in posts if p.get("id")}
|
|
452
510
|
|
|
453
|
-
#
|
|
511
|
+
# 4. Replies / comments = the user's own articles on /with_replies that
|
|
454
512
|
# are NOT among the original posts (set subtraction, not DOM text).
|
|
455
513
|
on_replies = _navigate(send, f"https://x.com/{handle}/with_replies",
|
|
456
514
|
settle=4.0, expect=f"/{handle}/with_replies")
|
|
457
515
|
comments = (
|
|
458
|
-
scrape_timeline(send, handle, args.comments,
|
|
516
|
+
scrape_timeline(send, handle, args.comments,
|
|
517
|
+
max_scrolls=max(30, args.comments),
|
|
518
|
+
exclude_ids=post_ids | s4l_ids,
|
|
459
519
|
capture_parents=True)
|
|
460
520
|
if on_replies else []
|
|
461
521
|
)
|
|
462
522
|
|
|
463
|
-
#
|
|
523
|
+
# 5. Rank both surfaces by real engagement, then expand the top posts'
|
|
464
524
|
# permalinks to capture thread continuations (and untruncated text).
|
|
465
525
|
top_posts = rank_top(posts, args.top)
|
|
466
526
|
top_replies = rank_top(comments, args.top)
|
|
@@ -481,7 +541,8 @@ def main() -> int:
|
|
|
481
541
|
"comments": comments,
|
|
482
542
|
"top_posts": top_posts,
|
|
483
543
|
"top_replies": top_replies,
|
|
484
|
-
"counts": {"posts": len(posts), "comments": len(comments)
|
|
544
|
+
"counts": {"posts": len(posts), "comments": len(comments),
|
|
545
|
+
"s4l_posted_excluded": len(s4l_ids)},
|
|
485
546
|
"grounding_instructions": GROUNDING_INSTRUCTIONS,
|
|
486
547
|
}
|
|
487
548
|
|
|
@@ -567,9 +567,43 @@ def main():
|
|
|
567
567
|
"summary, no bottom posts). This is the lean "
|
|
568
568
|
"on-demand shape the drafting session calls "
|
|
569
569
|
"after routing a candidate to a project."))
|
|
570
|
+
parser.add_argument("--invoked-by", default=None,
|
|
571
|
+
help=("Caller tag recorded in the on-demand invocation "
|
|
572
|
+
"ledger (the drafting prompt passes the cycle's "
|
|
573
|
+
"batch_id). Tracking only; no behavior change."))
|
|
570
574
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
571
575
|
args = parser.parse_args()
|
|
572
576
|
|
|
577
|
+
# On-demand invocation ledger (2026-07-10). Any --project call is the
|
|
578
|
+
# on-demand per-project winners lookup the draft prompt offers; record it
|
|
579
|
+
# at the TOOL level (model self-reports are unreliable) so we can measure
|
|
580
|
+
# whether drafting sessions actually use the query. One JSON line per
|
|
581
|
+
# call in $S4L_STATE_DIR/top-performers-invocations.jsonl; the cycle
|
|
582
|
+
# counts lines for its batch_id after prep and logs a
|
|
583
|
+
# [project_top_performers] marker. Best-effort: never block the report.
|
|
584
|
+
if args.project:
|
|
585
|
+
try:
|
|
586
|
+
import datetime
|
|
587
|
+
state_dir = os.environ.get(
|
|
588
|
+
"S4L_STATE_DIR",
|
|
589
|
+
os.path.expanduser("~/.social-autoposter-mcp"))
|
|
590
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
591
|
+
with open(os.path.join(state_dir,
|
|
592
|
+
"top-performers-invocations.jsonl"),
|
|
593
|
+
"a") as fh:
|
|
594
|
+
fh.write(json.dumps({
|
|
595
|
+
"ts": datetime.datetime.now(
|
|
596
|
+
datetime.timezone.utc).isoformat(),
|
|
597
|
+
"project": args.project,
|
|
598
|
+
"platform": args.platform,
|
|
599
|
+
"brief": bool(args.brief),
|
|
600
|
+
"top": args.top,
|
|
601
|
+
"invoked_by": args.invoked_by,
|
|
602
|
+
}) + "\n")
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
print(f"[top_performers] invocation ledger write failed: {exc!r}",
|
|
605
|
+
file=sys.stderr)
|
|
606
|
+
|
|
573
607
|
(summary, style_perf, top, bottom, fallback_top,
|
|
574
608
|
top_by_group, top_by_style) = _fetch_report_via_api(
|
|
575
609
|
platform=args.platform, project=args.project, top=args.top, bottom=args.bottom,
|
|
@@ -287,9 +287,8 @@ _BROWSER_LOCK = Path("/tmp/social-autoposter-twitter-browser.lock")
|
|
|
287
287
|
|
|
288
288
|
|
|
289
289
|
def _try_browser_lock() -> "Path | None":
|
|
290
|
-
"""
|
|
291
|
-
|
|
292
|
-
and simply retries on a later boot rather than waiting behind a cycle)."""
|
|
290
|
+
"""One mkdir attempt on the pipelines' twitter-browser lock (same protocol
|
|
291
|
+
as skill/lock.sh, without its queue)."""
|
|
293
292
|
try:
|
|
294
293
|
_BROWSER_LOCK.mkdir()
|
|
295
294
|
except OSError:
|
|
@@ -302,6 +301,34 @@ def _try_browser_lock() -> "Path | None":
|
|
|
302
301
|
return _BROWSER_LOCK
|
|
303
302
|
|
|
304
303
|
|
|
304
|
+
def _wait_browser_lock(max_wait_s: float, poll_s: float = 20.0) -> "Path | None":
|
|
305
|
+
"""Wait for the twitter-browser lock however long it takes (up to
|
|
306
|
+
max_wait_s). Holds no other lock while waiting, so this cannot deadlock;
|
|
307
|
+
it just queues politely behind cycles / DM runs. Reclaims a stale lock
|
|
308
|
+
(holder pid dead + lease expired), mirroring skill/lock.sh."""
|
|
309
|
+
deadline = time.time() + max_wait_s
|
|
310
|
+
while True:
|
|
311
|
+
lock = _try_browser_lock()
|
|
312
|
+
if lock is not None:
|
|
313
|
+
return lock
|
|
314
|
+
try:
|
|
315
|
+
pid = int((_BROWSER_LOCK / "pid").read_text().strip())
|
|
316
|
+
expires = int((_BROWSER_LOCK / "expires_at").read_text().strip())
|
|
317
|
+
pid_alive = True
|
|
318
|
+
try:
|
|
319
|
+
os.kill(pid, 0)
|
|
320
|
+
except OSError:
|
|
321
|
+
pid_alive = False
|
|
322
|
+
if not pid_alive and time.time() > expires:
|
|
323
|
+
shutil.rmtree(_BROWSER_LOCK, ignore_errors=True)
|
|
324
|
+
continue # retry mkdir immediately
|
|
325
|
+
except Exception:
|
|
326
|
+
pass # lock mid-transition; just poll again
|
|
327
|
+
if time.time() >= deadline:
|
|
328
|
+
return None
|
|
329
|
+
time.sleep(poll_s)
|
|
330
|
+
|
|
331
|
+
|
|
305
332
|
def _release_browser_lock() -> None:
|
|
306
333
|
try:
|
|
307
334
|
pid = (_BROWSER_LOCK / "pid").read_text().strip()
|
|
@@ -318,8 +345,12 @@ def cmd_backfill(args) -> int:
|
|
|
318
345
|
at onboarding, and it is where the author's own voice lives; product
|
|
319
346
|
projects pick exemplars up on their next project_config save instead. The
|
|
320
347
|
corpus write is additive (the marked section is the only thing replaced;
|
|
321
|
-
dictation and hand-added material stay).
|
|
322
|
-
|
|
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
|
|
351
|
+
user-triggered (Desktop launch), so the worst case is one scan attempt per
|
|
352
|
+
launch, and concurrent waiters from overlapping boots dedupe via the
|
|
353
|
+
post-lock eligibility re-check."""
|
|
323
354
|
cfg_path = Path(args.config).expanduser() if args.config else s4l_mode.config_path()
|
|
324
355
|
if not cfg_path.exists():
|
|
325
356
|
print(json.dumps({"ok": True, "did": "nothing", "reason": "no_config"}))
|
|
@@ -333,25 +364,30 @@ def cmd_backfill(args) -> int:
|
|
|
333
364
|
print(json.dumps({"ok": True, "did": "nothing", "reason": "already_done_or_hand_written"}))
|
|
334
365
|
return 0
|
|
335
366
|
|
|
336
|
-
marker = cfg_path.parent / ".voice_exemplars_backfill.json"
|
|
337
|
-
try:
|
|
338
|
-
last = json.loads(marker.read_text()).get("last_attempt", 0)
|
|
339
|
-
except Exception:
|
|
340
|
-
last = 0
|
|
341
|
-
if time.time() - last < args.min_hours_between * 3600:
|
|
342
|
-
print(json.dumps({"ok": True, "did": "nothing", "reason": "attempted_recently"}))
|
|
343
|
-
return 0
|
|
344
|
-
marker.write_text(json.dumps({"last_attempt": int(time.time())}) + "\n")
|
|
345
|
-
|
|
346
367
|
if not args.no_scan:
|
|
347
|
-
lock =
|
|
368
|
+
lock = _wait_browser_lock(args.max_wait_minutes * 60)
|
|
348
369
|
if lock is None:
|
|
349
|
-
|
|
370
|
+
# Waited the whole window and never got the browser; the next
|
|
371
|
+
# boot simply tries again.
|
|
372
|
+
print(json.dumps({"ok": True, "did": "nothing", "reason": "browser_busy_timeout"}))
|
|
373
|
+
return 0
|
|
374
|
+
# The wait can be hours; another boot's backfill may have finished
|
|
375
|
+
# meanwhile. Re-read config and re-check before burning a scan.
|
|
376
|
+
cfg = json.loads(cfg_path.read_text())
|
|
377
|
+
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)]
|
|
381
|
+
if not eligible:
|
|
382
|
+
_release_browser_lock()
|
|
383
|
+
print(json.dumps({"ok": True, "did": "nothing", "reason": "done_while_waiting"}))
|
|
350
384
|
return 0
|
|
351
385
|
try:
|
|
352
386
|
py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
|
|
387
|
+
# generous: the default depth (60 posts / 150 replies) can scroll
|
|
388
|
+
# for several minutes on prolific accounts
|
|
353
389
|
r = subprocess.run([py, str(HERE / "scan_x_profile.py")],
|
|
354
|
-
capture_output=True, text=True, timeout=
|
|
390
|
+
capture_output=True, text=True, timeout=1200)
|
|
355
391
|
except Exception as e:
|
|
356
392
|
print(json.dumps({"ok": False, "did": "nothing", "reason": f"scan_failed: {e}"}))
|
|
357
393
|
return 0
|
|
@@ -410,8 +446,9 @@ def main(argv) -> int:
|
|
|
410
446
|
b.add_argument("--config", default=None, help="config.json path override (testing)")
|
|
411
447
|
b.add_argument("--top", type=int, default=5)
|
|
412
448
|
b.add_argument("--min-chars", type=int, default=40)
|
|
413
|
-
b.add_argument("--
|
|
414
|
-
help="
|
|
449
|
+
b.add_argument("--max-wait-minutes", type=float, default=12 * 60,
|
|
450
|
+
help="how long to wait for the twitter-browser lock before "
|
|
451
|
+
"giving up until the next boot")
|
|
415
452
|
b.add_argument("--no-scan", action="store_true",
|
|
416
453
|
help="skip the live scan and use the existing last_profile_scan.json")
|
|
417
454
|
b.set_defaults(func=cmd_backfill)
|
package/skill/engage-twitter.sh
CHANGED
|
@@ -68,6 +68,15 @@ python3 "$REPO_DIR/scripts/scan_twitter_mentions_browser.py" --json-file "$NOTIF
|
|
|
68
68
|
|| log "WARNING: Phase A scan_twitter_mentions_browser.py exited with code $?"
|
|
69
69
|
rm -f "$NOTIFS_JSON"
|
|
70
70
|
|
|
71
|
+
# Phase A2: fill parent-thread linkage on mention-discovered rows. The
|
|
72
|
+
# notifications feed hides the parent tweet id, so scan rows land with
|
|
73
|
+
# mention_id only; this resolves post_id / parent_reply_id / project via
|
|
74
|
+
# fxtwitter HTTP (no browser, no model). Newest rows first, bounded per run.
|
|
75
|
+
log "Phase A2: Enriching parent linkage on mention replies..."
|
|
76
|
+
python3 "$REPO_DIR/scripts/enrich_reply_parents.py" --limit 25 2>&1 \
|
|
77
|
+
| tee -a "$LOG_FILE" \
|
|
78
|
+
|| log "WARNING: Phase A2 enrich_reply_parents.py exited with code $?"
|
|
79
|
+
|
|
71
80
|
# ═══════════════════════════════════════════════════════
|
|
72
81
|
# PHASE B: Respond to pending Twitter replies
|
|
73
82
|
# ═══════════════════════════════════════════════════════
|
|
@@ -2047,7 +2047,7 @@ All project configs: $ALL_PROJECTS_JSON
|
|
|
2047
2047
|
|
|
2048
2048
|
## PROJECT TOP PERFORMERS (query on demand, do NOT skip routing first)
|
|
2049
2049
|
The feedback reports below carry a per-style exemplar only; project winners are no longer bulk-injected. AFTER you have decided which project a candidate's draft is for, you MAY pull that project's own recent winners (last 30 days, ranked by real click rate) when you are unsure how this product converts in replies:
|
|
2050
|
-
python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief
|
|
2050
|
+
python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief --invoked-by '$BATCH_ID'
|
|
2051
2051
|
(PROJECT_NAME exactly as it appears in the candidate's 'Project match' / config.json.) Treat the results as evidence of which CLAIMS and ANGLES landed for that product, never as structural templates: do not copy their sentence shape, opener, or pivot wording. One call per project at most; skip the call entirely for projects you already queried this session.
|
|
2052
2052
|
|
|
2053
2053
|
$RECENT_SELF_BLOCK
|
|
@@ -2364,6 +2364,33 @@ if [ "$PREP_PARSE_EXIT" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
|
|
|
2364
2364
|
fi
|
|
2365
2365
|
log "Phase 2b-prep complete. plan_count=$PLAN_COUNT"
|
|
2366
2366
|
|
|
2367
|
+
# On-demand project-winners usage marker (2026-07-10). top_performers.py
|
|
2368
|
+
# appends a JSON line to the state-dir ledger on every --project call (the
|
|
2369
|
+
# draft prompt tells the model to pass --invoked-by "$BATCH_ID"). Count this
|
|
2370
|
+
# batch's lines and log a greppable marker so "did the drafting session
|
|
2371
|
+
# actually use the per-project query" is answerable from the cycle log alone.
|
|
2372
|
+
# Stderr-marker convention: format is load-bearing elsewhere; keep it stable.
|
|
2373
|
+
TP_ONDEMAND=$(python3 -c "
|
|
2374
|
+
import json, os, sys
|
|
2375
|
+
path = os.path.join(os.environ.get('S4L_STATE_DIR', os.path.expanduser('~/.social-autoposter-mcp')), 'top-performers-invocations.jsonl')
|
|
2376
|
+
n, projects = 0, []
|
|
2377
|
+
try:
|
|
2378
|
+
for line in open(path):
|
|
2379
|
+
try:
|
|
2380
|
+
r = json.loads(line)
|
|
2381
|
+
except Exception:
|
|
2382
|
+
continue
|
|
2383
|
+
if r.get('invoked_by') == '$BATCH_ID':
|
|
2384
|
+
n += 1
|
|
2385
|
+
p = r.get('project')
|
|
2386
|
+
if p and p not in projects:
|
|
2387
|
+
projects.append(p)
|
|
2388
|
+
except OSError:
|
|
2389
|
+
pass
|
|
2390
|
+
print(f'{n} projects={projects}')
|
|
2391
|
+
" 2>/dev/null || echo "0 projects=[]")
|
|
2392
|
+
log "[project_top_performers] batch=$BATCH_ID on_demand_invocations=$TP_ONDEMAND"
|
|
2393
|
+
|
|
2367
2394
|
# twitter-browser lock was already released right after thread-media capture
|
|
2368
2395
|
# (before the Claude drafting call above), since nothing from there through
|
|
2369
2396
|
# Phase 2b-gen touches the browser. Phase 2b-post re-acquires unconditionally
|