@m13v/s4l 1.7.4-rc.1 → 1.7.4-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/dist/index.js CHANGED
@@ -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 (marker file rate-limits scan attempts;
5319
- // BAIL-ON-BUSY on the twitter-browser lock, so it never contends with a
5320
- // running cycle — it just retries on a later boot). Delayed so boot-time
5321
- // work (runtime provision, kicker install) settles first.
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
- void runPython("scripts/voice_exemplars.py", ["backfill"], { timeoutMs: 420_000 })
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}`);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.1",
3
- "installedAt": "2026-07-10T22:41:18.186Z"
2
+ "version": "1.7.4-rc.11",
3
+ "installedAt": "2026-07-11T02:01:02.682Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.4-rc.1",
5
+ "version": "1.7.4-rc.11",
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": {
@@ -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 _hover_kind(self, event):
1546
- """Which eye a tracking-area event belongs to ('stats' | 'details'),
1547
- from the userInfo stamped in _eye_button. Defaults to stats (the
1548
- original single-eye behavior) if the area carries no info."""
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 and info.get("kind") == "details":
1552
- return "details"
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._hover_kind(event)
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 and slot != self._selected_draft:
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
- break
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:
@@ -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.1",
3
+ "version": "1.7.4-rc.11",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.4-rc.1",
3
+ "version": "1.7.4-rc.11",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -160,4 +160,4 @@
160
160
  "pg": "^8.20.0",
161
161
  "ws": "^8.0.0"
162
162
  }
163
- }
163
+ }
@@ -40,6 +40,20 @@ ENV_PREFIX = "S4L_EXP_"
40
40
  # Keep draft_prompt entries in sync with bin/server.js
41
41
  # DRAFT_PROMPT_VARIANT_DEFS and the arm strings in run-twitter-cycle.sh.
42
42
  DESCRIPTIONS = {
43
+ "draft_b_source": {
44
+ "human_derived": (
45
+ "Draft B explore slot: style distilled from real top-performing "
46
+ "human replies (daily synthesizer), least-used first"
47
+ ),
48
+ "model_invented": (
49
+ "Draft B explore slot: freshly invented style from the "
50
+ "standalone invention job (post 2026-07-10), least-used first"
51
+ ),
52
+ "scored_fallback": (
53
+ "Draft B explore pool was empty; fell back to a second scored "
54
+ "pick from the proven-style pool"
55
+ ),
56
+ },
43
57
  "draft_prompt": {
44
58
  "treatment_v2": (
45
59
  "skeleton ban: forbids the concede-then-reverse "
@@ -36,7 +36,7 @@ import json
36
36
  import os
37
37
  import random
38
38
  import sys as _sys_mod
39
- from datetime import datetime, timezone
39
+ from datetime import datetime, timedelta, timezone
40
40
 
41
41
  # ── Style taxonomy ──────────────────────────────────────────────────
42
42
 
@@ -1341,6 +1341,106 @@ def pick_style_for_post(platform, context="posting",
1341
1341
  }
1342
1342
 
1343
1343
 
1344
+ # Days back a model_invented style still counts as "recent" for the Draft-B
1345
+ # exploration pool. The registry holds ~900 pre-2026-07-10 inventions that
1346
+ # are mostly clones of the agree-then-relocate skeleton (see the
1347
+ # invent_styles.py docstring); the exploration slot exists to trial the
1348
+ # standalone job's structurally-diverse output, not to resurrect those.
1349
+ # human_derived rows are NOT date-gated: they arrive one per platform per
1350
+ # day and least-used ordering naturally favors the fresh ones.
1351
+ EXPLORATION_INVENTED_MAX_AGE_DAYS = 14
1352
+ # Hard floor: never trial inventions from before the inline INVENT_RATE was
1353
+ # zeroed (2026-07-10). Anything older is the clone flood, regardless of how
1354
+ # recent the rolling window makes it look.
1355
+ EXPLORATION_INVENTED_EPOCH = datetime(2026, 7, 10, 21, 0,
1356
+ tzinfo=timezone.utc)
1357
+
1358
+
1359
+ def pick_exploration_style(platform, context="posting", exclude=None,
1360
+ rng=None):
1361
+ """Draft-B exploration picker (2026-07-11). NEVER invents.
1362
+
1363
+ Returns an assignment dict in the exact pick_style_for_post() shape
1364
+ (so s4l_render_style_block and every downstream consumer work
1365
+ unchanged) with an extra "source" key in {"human_derived",
1366
+ "model_invented"}, or None when the pool is empty / anything fails
1367
+ (caller falls back to the scored picker).
1368
+
1369
+ Pool: registry rows with kind='human_derived' (any age) plus
1370
+ kind='model_invented' rows registered in the last
1371
+ EXPLORATION_INVENTED_MAX_AGE_DAYS days. Selection is LEAST-USED first
1372
+ (30-day post count on this platform via compute_target_distribution),
1373
+ uniform among the up-to-5 least-used, so every new style gets trial
1374
+ exposure instead of waiting behind the score-weighted sampler's
1375
+ winner-take-most weights. This is the distribution channel for the
1376
+ standalone invent_styles.py job; the review card's pick plus the
1377
+ posted draft's engagement write the style's first real score, and
1378
+ winners graduate into the Draft-A pool through the normal sampler.
1379
+ """
1380
+ rnd = rng or random
1381
+ try:
1382
+ never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
1383
+ skip = set(exclude or ()) | never
1384
+ registry = _fetch_registry_styles()
1385
+ cutoff = max(
1386
+ datetime.now(timezone.utc)
1387
+ - timedelta(days=EXPLORATION_INVENTED_MAX_AGE_DAYS),
1388
+ EXPLORATION_INVENTED_EPOCH,
1389
+ )
1390
+ pool = {}
1391
+ for name, entry in registry.items():
1392
+ if name in skip:
1393
+ continue
1394
+ if (entry.get("status") or "active") != "active":
1395
+ continue
1396
+ kind = entry.get("kind")
1397
+ if kind == "human_derived":
1398
+ pool[name] = entry
1399
+ elif kind == "model_invented":
1400
+ invented_at = _parse_iso_utc(entry.get("invented_at"))
1401
+ if invented_at is not None and invented_at >= cutoff:
1402
+ pool[name] = entry
1403
+ if not pool:
1404
+ return None
1405
+ usage = {r["style"]: int(r.get("n") or 0)
1406
+ for r in compute_target_distribution(platform,
1407
+ context=context)}
1408
+ names = list(pool.keys())
1409
+ rnd.shuffle(names) # random tie order before the stable sort
1410
+ names.sort(key=lambda s: usage.get(s, 0))
1411
+ chosen = rnd.choice(names[:5])
1412
+ entry = pool[chosen]
1413
+ return {
1414
+ "mode": "use",
1415
+ "style": chosen,
1416
+ "description": entry.get("description"),
1417
+ "example": entry.get("example"),
1418
+ "note": entry.get("note"),
1419
+ "target_chars": entry.get("target_chars") or DEFAULT_TARGET_CHARS,
1420
+ "source": entry.get("kind"),
1421
+ "usage_n_30d": usage.get(chosen, 0),
1422
+ "reference_styles": [],
1423
+ "distribution_snapshot": [],
1424
+ "picked_at": datetime.now(timezone.utc).isoformat(
1425
+ timespec="seconds"),
1426
+ }
1427
+ except Exception:
1428
+ return None
1429
+
1430
+
1431
+ def _parse_iso_utc(value):
1432
+ """Parse an ISO timestamp into aware-UTC; None on any failure."""
1433
+ if not value:
1434
+ return None
1435
+ try:
1436
+ dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
1437
+ if dt.tzinfo is None:
1438
+ dt = dt.replace(tzinfo=timezone.utc)
1439
+ return dt.astimezone(timezone.utc)
1440
+ except (ValueError, TypeError):
1441
+ return None
1442
+
1443
+
1344
1444
  def get_assigned_style_prompt(platform, assignment, context="posting"):
1345
1445
  """Compact prompt block built from a pick_style_for_post() assignment.
1346
1446
 
@@ -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=20, help="max original posts to collect")
413
- ap.add_argument("--comments", type=int, default=50, help="max replies/comments to collect")
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. Original posts (current page = posts tab).
450
- posts = scrape_timeline(send, handle, args.posts) if on_profile else []
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
- # 3. Replies / comments = the user's own articles on /with_replies that
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, exclude_ids=post_ids,
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
- # 4. Rank both surfaces by real engagement, then expand the top posts'
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
- """BAIL-ON-BUSY acquire of the pipelines' twitter-browser lock (same mkdir
291
- protocol as skill/lock.sh, without its queue: the backfill is opportunistic
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). Rate-limited by a marker file so a
322
- down browser doesn't retrigger a scan on every boot."""
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 = _try_browser_lock()
368
+ lock = _wait_browser_lock(args.max_wait_minutes * 60)
348
369
  if lock is None:
349
- print(json.dumps({"ok": True, "did": "nothing", "reason": "browser_busy"}))
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=300)
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("--min-hours-between", type=float, default=24.0,
414
- help="rate limit between scan attempts")
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)
@@ -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
  # ═══════════════════════════════════════════════════════
@@ -1729,8 +1729,29 @@ log "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)
1729
1729
  # directive for the whole cycle; this varies STYLE per draft slot), so neither
1730
1730
  # experiment disturbs the other.
1731
1731
  STYLE_ASSIGN_FILE_B=$(mktemp -t s4l_twitter_assign_b_XXXXXX.json)
1732
- for _style_b_attempt in 1 2 3; do
1733
- s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
1732
+ # --- Draft-B exploration source (2026-07-11) ---------------------------------
1733
+ # Style B is now the EXPLORE slot: it trials the newest human_derived styles
1734
+ # and post-2026-07-10 inventions (least-used first) instead of drawing a
1735
+ # second scored pick from the same proven pool as Style A. This is the
1736
+ # distribution channel for the standalone invent_styles.py job: the card
1737
+ # pick + the posted draft's engagement write a new style's first real score,
1738
+ # and winners graduate into the Draft-A pool via the normal sampler. NOTHING
1739
+ # is invented here (pick_exploration_style never returns mode=invent). On an
1740
+ # empty pool or API failure we fall back to the legacy second scored pick so
1741
+ # dual-draft cards never break. The source tag rides the S4L_EXP_ convention:
1742
+ # active_experiments.collect() auto-stamps it onto every plan candidate and
1743
+ # the review card's details-eye renders it with zero card-side code.
1744
+ DRAFT_B_SOURCE=$(python3 -c "
1745
+ import json, sys
1746
+ sys.path.insert(0, '$REPO_DIR/scripts')
1747
+ from engagement_styles import pick_exploration_style
1748
+ a = pick_exploration_style('twitter', context='posting', exclude={'$PICKED_STYLE'})
1749
+ if a and a.get('style'):
1750
+ with open('$STYLE_ASSIGN_FILE_B', 'w') as f:
1751
+ json.dump(a, f)
1752
+ print(a.get('source') or '')
1753
+ " 2>/dev/null || echo "")
1754
+ if [ -n "$DRAFT_B_SOURCE" ]; then
1734
1755
  PICKED_STYLE_B=$(python3 -c "
1735
1756
  import json
1736
1757
  try:
@@ -1740,7 +1761,22 @@ try:
1740
1761
  except Exception:
1741
1762
  print('')
1742
1763
  " 2>/dev/null)
1743
- PICKED_MODE_B=$(python3 -c "
1764
+ PICKED_MODE_B="use"
1765
+ fi
1766
+ if [ -z "${PICKED_STYLE_B:-}" ]; then
1767
+ DRAFT_B_SOURCE="scored_fallback"
1768
+ for _style_b_attempt in 1 2 3; do
1769
+ s4l_pick_style twitter posting "$STYLE_ASSIGN_FILE_B" >/dev/null 2>&1 || true
1770
+ PICKED_STYLE_B=$(python3 -c "
1771
+ import json
1772
+ try:
1773
+ with open('$STYLE_ASSIGN_FILE_B') as f:
1774
+ d = json.load(f)
1775
+ print(d.get('style') or '')
1776
+ except Exception:
1777
+ print('')
1778
+ " 2>/dev/null)
1779
+ PICKED_MODE_B=$(python3 -c "
1744
1780
  import json
1745
1781
  try:
1746
1782
  with open('$STYLE_ASSIGN_FILE_B') as f:
@@ -1749,11 +1785,13 @@ try:
1749
1785
  except Exception:
1750
1786
  print('use')
1751
1787
  " 2>/dev/null)
1752
- if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
1753
- break
1754
- fi
1755
- done
1756
- log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)}"
1788
+ if [ "$PICKED_MODE" = "invent" ] || [ "$PICKED_MODE_B" = "invent" ] || [ "$PICKED_STYLE_B" != "$PICKED_STYLE" ]; then
1789
+ break
1790
+ fi
1791
+ done
1792
+ fi
1793
+ export S4L_EXP_DRAFT_B_SOURCE="$DRAFT_B_SOURCE"
1794
+ log "Engagement style B assigned: mode=$PICKED_MODE_B style=${PICKED_STYLE_B:-(invent)} source=$DRAFT_B_SOURCE"
1757
1795
 
1758
1796
  # --- Draft-prompt A/B: decouple product pivot (2026-06-29) -------------------
1759
1797
  # Per-CYCLE arm (the prep session drafts the whole batch from ONE prompt, so
@@ -2047,7 +2085,7 @@ All project configs: $ALL_PROJECTS_JSON
2047
2085
 
2048
2086
  ## PROJECT TOP PERFORMERS (query on demand, do NOT skip routing first)
2049
2087
  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
2088
+ python3 $REPO_DIR/scripts/top_performers.py --platform twitter --project 'PROJECT_NAME' --top 3 --brief --invoked-by '$BATCH_ID'
2051
2089
  (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
2090
 
2053
2091
  $RECENT_SELF_BLOCK
@@ -2364,6 +2402,33 @@ if [ "$PREP_PARSE_EXIT" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
2364
2402
  fi
2365
2403
  log "Phase 2b-prep complete. plan_count=$PLAN_COUNT"
2366
2404
 
2405
+ # On-demand project-winners usage marker (2026-07-10). top_performers.py
2406
+ # appends a JSON line to the state-dir ledger on every --project call (the
2407
+ # draft prompt tells the model to pass --invoked-by "$BATCH_ID"). Count this
2408
+ # batch's lines and log a greppable marker so "did the drafting session
2409
+ # actually use the per-project query" is answerable from the cycle log alone.
2410
+ # Stderr-marker convention: format is load-bearing elsewhere; keep it stable.
2411
+ TP_ONDEMAND=$(python3 -c "
2412
+ import json, os, sys
2413
+ path = os.path.join(os.environ.get('S4L_STATE_DIR', os.path.expanduser('~/.social-autoposter-mcp')), 'top-performers-invocations.jsonl')
2414
+ n, projects = 0, []
2415
+ try:
2416
+ for line in open(path):
2417
+ try:
2418
+ r = json.loads(line)
2419
+ except Exception:
2420
+ continue
2421
+ if r.get('invoked_by') == '$BATCH_ID':
2422
+ n += 1
2423
+ p = r.get('project')
2424
+ if p and p not in projects:
2425
+ projects.append(p)
2426
+ except OSError:
2427
+ pass
2428
+ print(f'{n} projects={projects}')
2429
+ " 2>/dev/null || echo "0 projects=[]")
2430
+ log "[project_top_performers] batch=$BATCH_ID on_demand_invocations=$TP_ONDEMAND"
2431
+
2367
2432
  # twitter-browser lock was already released right after thread-media capture
2368
2433
  # (before the Claude drafting call above), since nothing from there through
2369
2434
  # Phase 2b-gen touches the browser. Phase 2b-post re-acquires unconditionally