@m13v/s4l 1.7.4-rc.16 → 1.7.4-rc.2
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 +1 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +12 -126
- package/mcp/menubar/s4l_menubar.py +0 -7
- package/mcp/menubar/s4l_state.py +0 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/active_experiments.py +6 -30
- package/scripts/engage_twitter_helper.py +17 -83
- package/scripts/engagement_styles.py +26 -221
- package/scripts/feedback_digest.py +0 -76
- package/scripts/generate_daily_human_style.py +7 -31
- package/scripts/invent_styles.py +26 -103
- package/scripts/linkedin_killswitch.py +0 -12
- package/scripts/recent_self_posts.py +0 -6
- package/scripts/scan_x_profile.py +8 -69
- package/scripts/top_performers.py +0 -34
- package/scripts/voice_exemplars.py +17 -11
- package/skill/engage-twitter.sh +0 -9
- package/skill/run-twitter-cycle.sh +43 -143
- package/scripts/enrich_reply_parents.py +0 -312
- package/scripts/linkedin_cadence.py +0 -234
- package/skill/linkedin-cadence.sh +0 -20
package/mcp/dist/index.js
CHANGED
|
@@ -5315,8 +5315,7 @@ 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
|
-
// examples_scanned_at which makes later boots a no-op, and until then it
|
|
5318
|
+
// examples) and self-limiting (marker file rate-limits scan attempts; it
|
|
5320
5319
|
// WAITS politely on the twitter-browser lock, polling while holding
|
|
5321
5320
|
// nothing, until cycles/DM runs free the browser, up to 12h before
|
|
5322
5321
|
// deferring to the next boot). Delayed so boot-time work (runtime
|
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.2",
|
|
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
|
@@ -431,21 +431,7 @@ def _details_lines(d):
|
|
|
431
431
|
d = d or {}
|
|
432
432
|
lines = []
|
|
433
433
|
style = (d.get("engagement_style") or "").strip()
|
|
434
|
-
|
|
435
|
-
# can tell WHICH style produced the draft they are picking (Draft B is
|
|
436
|
-
# the exploration slot; its source arm renders separately below via the
|
|
437
|
-
# generic experiments lines). Single-draft cards keep the old one-liner.
|
|
438
|
-
_dual = d.get("drafts")
|
|
439
|
-
if isinstance(_dual, list) and len(_dual) == 2:
|
|
440
|
-
_slot_labels = {"a": "Draft A", "b": "Draft B"}
|
|
441
|
-
for _draft in _dual:
|
|
442
|
-
_s = (_draft.get("style") or "").strip()
|
|
443
|
-
if _s:
|
|
444
|
-
_label = _slot_labels.get(
|
|
445
|
-
(_draft.get("variant") or "").strip().lower(), "Draft"
|
|
446
|
-
)
|
|
447
|
-
lines.append(f"{_label} style: {_s}")
|
|
448
|
-
elif style:
|
|
434
|
+
if style:
|
|
449
435
|
lines.append(f"Style: {style}")
|
|
450
436
|
desc = (d.get("style_description") or "").strip()
|
|
451
437
|
if desc:
|
|
@@ -746,21 +732,6 @@ class _ReviewController(NSObject):
|
|
|
746
732
|
self._selected_draft = None
|
|
747
733
|
self._draft_textviews = {}
|
|
748
734
|
self._draft_scrolls = {}
|
|
749
|
-
# Per-draft hover dwell (two-draft cards, 2026-07-10): accumulated
|
|
750
|
-
# milliseconds the pointer spent over each draft box, so the feedback
|
|
751
|
-
# digest can tell an informed keep of Draft A (they read B and stayed)
|
|
752
|
-
# from a fast approve that says nothing about B. Raw ms ship on the
|
|
753
|
-
# decision; the read-vs-skim threshold lives digest-side so it can be
|
|
754
|
-
# tuned without a client release. _draft_hover_open holds the enter
|
|
755
|
-
# timestamp of any hover still in progress (flushed on decision).
|
|
756
|
-
self._draft_hover_ms = {0: 0, 1: 0}
|
|
757
|
-
self._draft_hover_open = {}
|
|
758
|
-
# Slots the caret has actually been in this card (2026-07-10 follow-up):
|
|
759
|
-
# lets the decision distinguish "clicked into B, then came BACK to A"
|
|
760
|
-
# (an explicit head-to-head choice of A, per user) from "never touched
|
|
761
|
-
# B at all". Only the UNCHOSEN slot's membership matters at decision
|
|
762
|
-
# time; the selected slot is trivially visited.
|
|
763
|
-
self._draft_visited = set()
|
|
764
735
|
# Attention anchors for the unattended-review watchdog: the stack counts
|
|
765
736
|
# as "touched" on present, on any tracked interaction, and on any
|
|
766
737
|
# decision. No touch past the watchdog threshold = the user is not
|
|
@@ -1088,9 +1059,6 @@ class _ReviewController(NSObject):
|
|
|
1088
1059
|
self._interactions = []
|
|
1089
1060
|
self._card_shown_at = time.time()
|
|
1090
1061
|
self._selected_draft = None
|
|
1091
|
-
self._draft_hover_ms = {0: 0, 1: 0}
|
|
1092
|
-
self._draft_hover_open = {}
|
|
1093
|
-
self._draft_visited = set()
|
|
1094
1062
|
self._reason_field = None
|
|
1095
1063
|
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
|
|
1096
1064
|
|
|
@@ -1421,19 +1389,6 @@ class _ReviewController(NSObject):
|
|
|
1421
1389
|
tv.setDelegate_(self)
|
|
1422
1390
|
outline.addSubview_(scroll)
|
|
1423
1391
|
content.addSubview_(outline)
|
|
1424
|
-
# Hover dwell per draft box (same NSTrackingArea pattern as the
|
|
1425
|
-
# eye buttons): enter/exit timestamps accumulate into
|
|
1426
|
-
# _draft_hover_ms[slot] so the decision can say whether the
|
|
1427
|
-
# reviewer actually READ the draft they didn't pick. slot rides
|
|
1428
|
-
# on userInfo, mirroring the eyes' `kind` routing.
|
|
1429
|
-
outline.addTrackingArea_(
|
|
1430
|
-
NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
|
|
1431
|
-
outline.bounds(),
|
|
1432
|
-
NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
|
|
1433
|
-
self,
|
|
1434
|
-
{"kind": "draft", "slot": slot},
|
|
1435
|
-
)
|
|
1436
|
-
)
|
|
1437
1392
|
self._draft_scrolls[slot] = scroll
|
|
1438
1393
|
self._draft_outlines[slot] = outline
|
|
1439
1394
|
self._draft_textviews[slot] = tv
|
|
@@ -1587,32 +1542,21 @@ class _ReviewController(NSObject):
|
|
|
1587
1542
|
self._show_details_popover()
|
|
1588
1543
|
|
|
1589
1544
|
@objc.python_method
|
|
1590
|
-
def
|
|
1591
|
-
"""
|
|
1592
|
-
stamped
|
|
1593
|
-
|
|
1594
|
-
the original single-eye behavior, if the area carries no info."""
|
|
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."""
|
|
1595
1549
|
try:
|
|
1596
1550
|
info = event.trackingArea().userInfo()
|
|
1597
|
-
if info:
|
|
1598
|
-
|
|
1599
|
-
if kind == "draft":
|
|
1600
|
-
return "draft", int(info.get("slot"))
|
|
1601
|
-
if kind == "details":
|
|
1602
|
-
return "details", None
|
|
1551
|
+
if info and info.get("kind") == "details":
|
|
1552
|
+
return "details"
|
|
1603
1553
|
except Exception:
|
|
1604
1554
|
pass
|
|
1605
|
-
return "stats"
|
|
1555
|
+
return "stats"
|
|
1606
1556
|
|
|
1607
|
-
# NSTrackingArea owner callbacks (hover over either eye icon
|
|
1608
|
-
# two-draft cards, either draft box). Draft hovers only bank dwell time
|
|
1609
|
-
# (no popover, no logging: the boxes are big and enter/exit fires on
|
|
1610
|
-
# every pass of the pointer).
|
|
1557
|
+
# NSTrackingArea owner callbacks (hover over either eye icon).
|
|
1611
1558
|
def mouseEntered_(self, event):
|
|
1612
|
-
kind
|
|
1613
|
-
if kind == "draft":
|
|
1614
|
-
self._draft_hover_open[slot] = time.time()
|
|
1615
|
-
return
|
|
1559
|
+
kind = self._hover_kind(event)
|
|
1616
1560
|
_log(f"{kind} eye hover enter")
|
|
1617
1561
|
if kind == "details":
|
|
1618
1562
|
self._show_details_popover()
|
|
@@ -1620,32 +1564,9 @@ class _ReviewController(NSObject):
|
|
|
1620
1564
|
self._show_stats_popover()
|
|
1621
1565
|
|
|
1622
1566
|
def mouseExited_(self, event):
|
|
1623
|
-
kind, slot = self._hover_info(event)
|
|
1624
|
-
if kind == "draft":
|
|
1625
|
-
started = self._draft_hover_open.pop(slot, None)
|
|
1626
|
-
if started is not None:
|
|
1627
|
-
self._draft_hover_ms[slot] = self._draft_hover_ms.get(slot, 0) + int(
|
|
1628
|
-
(time.time() - started) * 1000
|
|
1629
|
-
)
|
|
1630
|
-
return
|
|
1631
1567
|
_log("eye hover exit")
|
|
1632
1568
|
self._close_stats_popover()
|
|
1633
1569
|
|
|
1634
|
-
@objc.python_method
|
|
1635
|
-
def _flush_draft_hovers(self):
|
|
1636
|
-
"""Bank any hover still in progress (pointer inside a draft box at
|
|
1637
|
-
decision time, e.g. a keyboard approve) so _record reads final
|
|
1638
|
-
totals."""
|
|
1639
|
-
now = time.time()
|
|
1640
|
-
for slot, started in list(self._draft_hover_open.items()):
|
|
1641
|
-
self._draft_hover_ms[slot] = self._draft_hover_ms.get(slot, 0) + int(
|
|
1642
|
-
(now - started) * 1000
|
|
1643
|
-
)
|
|
1644
|
-
# Keep the hover open (re-anchored at now) rather than deleting
|
|
1645
|
-
# it: the pointer really is still inside the box, so a later
|
|
1646
|
-
# mouseExited_ must not double-count the pre-flush span.
|
|
1647
|
-
self._draft_hover_open[slot] = now
|
|
1648
|
-
|
|
1649
1570
|
@objc.python_method
|
|
1650
1571
|
def _add_link(self, content, frame, text, url, *, size=12, bold=False, right=False, kind="link_click"):
|
|
1651
1572
|
"""Borderless button styled as a link (system link color, underlined).
|
|
@@ -1709,18 +1630,11 @@ class _ReviewController(NSObject):
|
|
|
1709
1630
|
except Exception:
|
|
1710
1631
|
return
|
|
1711
1632
|
for slot, cand_tv in (self._draft_textviews or {}).items():
|
|
1712
|
-
if cand_tv is
|
|
1713
|
-
continue
|
|
1714
|
-
# Visited even when it's already the selected slot: membership of
|
|
1715
|
-
# the eventually-UNCHOSEN slot is what _record reads, and that
|
|
1716
|
-
# slot only ever gets the caret via a deliberate user click (the
|
|
1717
|
-
# auto-focus seat in _render targets the selected slot only).
|
|
1718
|
-
self._draft_visited.add(slot)
|
|
1719
|
-
if slot != self._selected_draft:
|
|
1633
|
+
if cand_tv is tv and slot != self._selected_draft:
|
|
1720
1634
|
self._selected_draft = slot
|
|
1721
1635
|
self._textview = cand_tv
|
|
1722
1636
|
self._update_draft_borders()
|
|
1723
|
-
|
|
1637
|
+
break
|
|
1724
1638
|
|
|
1725
1639
|
@objc.python_method
|
|
1726
1640
|
def _update_draft_borders(self):
|
|
@@ -1790,32 +1704,9 @@ class _ReviewController(NSObject):
|
|
|
1790
1704
|
chosen_draft = drafts[sel_idx]
|
|
1791
1705
|
orig = (chosen_draft.get("text") or "").strip()
|
|
1792
1706
|
draft_variant = chosen_draft.get("variant") or ("a" if sel_idx == 0 else "b")
|
|
1793
|
-
# Full pairwise context for the feedback digest (2026-07-10): the
|
|
1794
|
-
# UNCHOSEN draft's text+style ride along so "picked B over A" (or
|
|
1795
|
-
# "kept A after reading B", per the hover dwell) is a usable
|
|
1796
|
-
# preference PAIR, not just a winner with no loser. Shipped as one
|
|
1797
|
-
# nested dict end to end (decision -> review event -> jsonb column)
|
|
1798
|
-
# so adding a field never needs another schema hop.
|
|
1799
|
-
self._flush_draft_hovers()
|
|
1800
|
-
other = drafts[1 - sel_idx]
|
|
1801
|
-
draft_choice = {
|
|
1802
|
-
"variant": draft_variant,
|
|
1803
|
-
"index": sel_idx,
|
|
1804
|
-
"auto_selected": bool(sel_idx == 0),
|
|
1805
|
-
"style": chosen_draft.get("style") or None,
|
|
1806
|
-
"unchosen_text": (other.get("text") or "").strip() or None,
|
|
1807
|
-
"unchosen_style": other.get("style") or None,
|
|
1808
|
-
"hover_a_ms": int(self._draft_hover_ms.get(0, 0)),
|
|
1809
|
-
"hover_b_ms": int(self._draft_hover_ms.get(1, 0)),
|
|
1810
|
-
# True = the caret was in the unchosen box at some point, i.e.
|
|
1811
|
-
# they tried the other draft and came back: an explicit choice
|
|
1812
|
-
# even when the winner is the preselected default.
|
|
1813
|
-
"visited_other": bool((1 - sel_idx) in self._draft_visited),
|
|
1814
|
-
}
|
|
1815
1707
|
else:
|
|
1816
1708
|
orig = (d.get("reply_text") or "").strip()
|
|
1817
1709
|
draft_variant = None
|
|
1818
|
-
draft_choice = None
|
|
1819
1710
|
link = d.get("link_url") or ""
|
|
1820
1711
|
drop_link = False
|
|
1821
1712
|
if approved:
|
|
@@ -1871,11 +1762,6 @@ class _ReviewController(NSObject):
|
|
|
1871
1762
|
"draft_variant": draft_variant,
|
|
1872
1763
|
"draft_index": sel_idx,
|
|
1873
1764
|
"draft_auto_selected": bool(dual and sel_idx == 0),
|
|
1874
|
-
# Nested pairwise record (chosen vs unchosen text/style plus
|
|
1875
|
-
# per-box hover dwell); None on single-draft candidates. The
|
|
1876
|
-
# flat three fields above stay for their existing consumers
|
|
1877
|
-
# (edit-learning variant stamp in s4l_menubar).
|
|
1878
|
-
"draft_choice": draft_choice,
|
|
1879
1765
|
}
|
|
1880
1766
|
)
|
|
1881
1767
|
self._last_decision_at = time.time()
|
|
@@ -2941,13 +2941,6 @@ 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"),
|
|
2951
2944
|
}
|
|
2952
2945
|
)
|
|
2953
2946
|
except Exception:
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -786,10 +786,6 @@ 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"),
|
|
793
789
|
"decided_at": time_iso(),
|
|
794
790
|
}
|
|
795
791
|
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.2",
|
|
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
|
@@ -40,39 +40,16 @@ 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
|
-
},
|
|
57
43
|
"draft_prompt": {
|
|
58
|
-
"treatment_v3": (
|
|
59
|
-
"style-as-form: the assigned style is the binding FORM (defining "
|
|
60
|
-
"move + per-style length + self-check), learned preferences apply "
|
|
61
|
-
"inside it; keeps the v2 skeleton ban"
|
|
62
|
-
),
|
|
63
|
-
"control_v3": (
|
|
64
|
-
"plain draft directive, uniform length clamp, no structure ban"
|
|
65
|
-
),
|
|
66
|
-
# v2 arms (skeleton-ban) retired 2026-07-10; v1 arms
|
|
67
|
-
# (decoupled-product-pivot) retired 2026-07-06; kept so any
|
|
68
|
-
# straggler card from an old plan still explains itself.
|
|
69
44
|
"treatment_v2": (
|
|
70
|
-
"
|
|
45
|
+
"skeleton ban: forbids the concede-then-reverse "
|
|
71
46
|
'"easy X / hard Y" structure and forces varied entry points'
|
|
72
47
|
),
|
|
73
48
|
"control_v2": (
|
|
74
|
-
"
|
|
49
|
+
"current draft directive (style + product pivot), no structure ban"
|
|
75
50
|
),
|
|
51
|
+
# v1 arms (decoupled-product-pivot) retired 2026-07-06; kept so any
|
|
52
|
+
# straggler card from an old plan still explains itself.
|
|
76
53
|
"treatment": "v1, retired: product pivot decoupled from the reply",
|
|
77
54
|
"control": "v1, retired: original draft directive",
|
|
78
55
|
},
|
|
@@ -122,9 +99,8 @@ def collect(env=None):
|
|
|
122
99
|
if var.startswith(ENV_PREFIX) and (v or "").strip():
|
|
123
100
|
out[var[len(ENV_PREFIX):].lower()] = v.strip()
|
|
124
101
|
# 2026-07-06: the personal_brand persona directive is now ARM-AWARE in
|
|
125
|
-
# run-twitter-cycle.sh (
|
|
126
|
-
#
|
|
127
|
-
# draft_prompt arm DOES touch persona
|
|
102
|
+
# run-twitter-cycle.sh (treatment_v2 adds the concede-then-reverse skeleton ban,
|
|
103
|
+
# control_v2 does not), so the assigned draft_prompt arm DOES touch persona
|
|
128
104
|
# drafts. Keep it stamped so the arm surfaces on persona cards and the per-arm
|
|
129
105
|
# readout covers both lanes. (Previously dropped here because the persona
|
|
130
106
|
# directive overrode both arms wholesale; that is no longer the case.)
|
|
@@ -144,59 +144,6 @@ def _render_media_block(media) -> str:
|
|
|
144
144
|
)
|
|
145
145
|
|
|
146
146
|
|
|
147
|
-
def _build_chain_block(row) -> str:
|
|
148
|
-
"""Conversation chain reconstructed from replies.parent_reply_id linkage.
|
|
149
|
-
|
|
150
|
-
Walks ancestors bottom-up via GET /api/v1/replies/:id and renders the
|
|
151
|
-
chain root-first, each hop showing the inbound comment and (when we
|
|
152
|
-
responded) our reply. Empty string when the row has no parent linkage:
|
|
153
|
-
the root post itself already rides PENDING_DATA via the posts JOIN
|
|
154
|
-
(our_content / thread_title), so a chain block would add nothing.
|
|
155
|
-
|
|
156
|
-
Like counterparty_history_block, the block is self-titled and lands
|
|
157
|
-
inline in PENDING_DATA — no shell-side prompt change needed.
|
|
158
|
-
"""
|
|
159
|
-
parent_id = row.get("parent_reply_id")
|
|
160
|
-
if not parent_id:
|
|
161
|
-
return ""
|
|
162
|
-
hops = []
|
|
163
|
-
seen = set()
|
|
164
|
-
cur = parent_id
|
|
165
|
-
for _ in range(10):
|
|
166
|
-
if not cur or cur in seen:
|
|
167
|
-
break
|
|
168
|
-
seen.add(cur)
|
|
169
|
-
try:
|
|
170
|
-
resp = api_get(f"/api/v1/replies/{cur}")
|
|
171
|
-
except Exception:
|
|
172
|
-
break
|
|
173
|
-
r = (resp.get("data") or {}).get("reply") or {}
|
|
174
|
-
if not r:
|
|
175
|
-
break
|
|
176
|
-
hops.append(r)
|
|
177
|
-
cur = r.get("parent_reply_id")
|
|
178
|
-
if not hops:
|
|
179
|
-
return ""
|
|
180
|
-
|
|
181
|
-
def _one_line(text):
|
|
182
|
-
return " ".join((text or "").split())
|
|
183
|
-
|
|
184
|
-
lines = [
|
|
185
|
-
"## Conversation chain (reconstructed from our DB; root first — "
|
|
186
|
-
"the row you are drafting for replies to the LAST message)"
|
|
187
|
-
]
|
|
188
|
-
for r in reversed(hops):
|
|
189
|
-
lines.append(f"@{r.get('their_author') or '?'}: {_one_line(r.get('their_content'))}")
|
|
190
|
-
ours = _one_line(r.get("our_reply_content"))
|
|
191
|
-
if ours:
|
|
192
|
-
lines.append(f" our reply: {ours}")
|
|
193
|
-
lines.append(
|
|
194
|
-
f"@{row.get('their_author') or '?'}: {_one_line(row.get('their_content'))}"
|
|
195
|
-
" <- you are replying to this"
|
|
196
|
-
)
|
|
197
|
-
return "\n".join(lines)
|
|
198
|
-
|
|
199
|
-
|
|
200
147
|
def cmd_pending_data(batch_size: int) -> int:
|
|
201
148
|
try:
|
|
202
149
|
from account_resolver import resolve as _resolve_account # noqa: WPS433
|
|
@@ -226,50 +173,38 @@ def cmd_pending_data(batch_size: int) -> int:
|
|
|
226
173
|
# top slot then and get enriched.
|
|
227
174
|
ENRICH_TOP_N = 60
|
|
228
175
|
history_blocks = [""] * len(rows)
|
|
229
|
-
chain_blocks = [""] * len(rows)
|
|
230
176
|
try:
|
|
231
177
|
from concurrent.futures import ThreadPoolExecutor
|
|
232
178
|
from counterparty_history import get_counterparty_history_block
|
|
233
179
|
|
|
234
180
|
def _enrich(r):
|
|
235
181
|
author = r.get("their_author")
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
try:
|
|
239
|
-
_disengage, history = get_counterparty_history_block(
|
|
240
|
-
platform="x",
|
|
241
|
-
author=author,
|
|
242
|
-
current_post_id=r.get("post_id"),
|
|
243
|
-
current_reply_id=r.get("id"),
|
|
244
|
-
)
|
|
245
|
-
history = history or ""
|
|
246
|
-
except Exception as e:
|
|
247
|
-
print(
|
|
248
|
-
f"[engage_twitter_helper] counterparty_history failed "
|
|
249
|
-
f"for @{author}: {e}",
|
|
250
|
-
file=sys.stderr,
|
|
251
|
-
)
|
|
182
|
+
if not author:
|
|
183
|
+
return ""
|
|
252
184
|
try:
|
|
253
|
-
|
|
185
|
+
_disengage, block = get_counterparty_history_block(
|
|
186
|
+
platform="x",
|
|
187
|
+
author=author,
|
|
188
|
+
current_post_id=r.get("post_id"),
|
|
189
|
+
current_reply_id=r.get("id"),
|
|
190
|
+
)
|
|
191
|
+
return block or ""
|
|
254
192
|
except Exception as e:
|
|
255
193
|
print(
|
|
256
|
-
f"[engage_twitter_helper]
|
|
257
|
-
f"for
|
|
194
|
+
f"[engage_twitter_helper] counterparty_history failed "
|
|
195
|
+
f"for @{author}: {e}",
|
|
258
196
|
file=sys.stderr,
|
|
259
197
|
)
|
|
260
|
-
|
|
261
|
-
return (history, chain)
|
|
198
|
+
return ""
|
|
262
199
|
|
|
263
200
|
top_rows = rows[:ENRICH_TOP_N]
|
|
264
201
|
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
265
|
-
for idx,
|
|
266
|
-
history_blocks[idx] =
|
|
267
|
-
chain_blocks[idx] = chain
|
|
202
|
+
for idx, block in enumerate(ex.map(_enrich, top_rows)):
|
|
203
|
+
history_blocks[idx] = block
|
|
268
204
|
non_empty = sum(1 for b in history_blocks if b)
|
|
269
|
-
chains_non_empty = sum(1 for b in chain_blocks if b)
|
|
270
205
|
print(
|
|
271
|
-
f"[engage_twitter_helper] enriched
|
|
272
|
-
f"(
|
|
206
|
+
f"[engage_twitter_helper] counterparty_history enriched "
|
|
207
|
+
f"{len(top_rows)}/{len(rows)} rows ({non_empty} with non-empty block)",
|
|
273
208
|
file=sys.stderr,
|
|
274
209
|
)
|
|
275
210
|
except Exception as e:
|
|
@@ -280,7 +215,7 @@ def cmd_pending_data(batch_size: int) -> int:
|
|
|
280
215
|
)
|
|
281
216
|
|
|
282
217
|
out = []
|
|
283
|
-
for r, history_block
|
|
218
|
+
for r, history_block in zip(rows, history_blocks):
|
|
284
219
|
out.append({
|
|
285
220
|
"id": r.get("id"),
|
|
286
221
|
"platform": r.get("platform"),
|
|
@@ -296,7 +231,6 @@ def cmd_pending_data(batch_size: int) -> int:
|
|
|
296
231
|
"is_our_original_post": int(r.get("is_our_original_post") or 0),
|
|
297
232
|
"project_name": r.get("project_name"),
|
|
298
233
|
"counterparty_history_block": history_block,
|
|
299
|
-
"conversation_chain_block": chain_block,
|
|
300
234
|
"their_media_block": _render_media_block(r.get("their_media")),
|
|
301
235
|
})
|
|
302
236
|
# json_agg(...) returns null when the array is empty; engage-twitter.sh's
|