@m13v/s4l 1.7.2-rc.1 → 1.7.2-rc.14
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 +11 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +89 -11
- package/mcp/menubar/s4l_menubar.py +129 -28
- package/mcp/menubar/s4l_state.py +72 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_process_li_notifs.py +91 -0
- package/scripts/claude_job.py +3 -5
- package/scripts/feedback_digest.py +1 -1
- package/scripts/merge_review_queue.py +51 -21
- package/scripts/run_claude.sh +14 -8
- package/scripts/salvage_orphaned_prep_results.py +69 -6
- package/skill/run-twitter-cycle.sh +16 -122
package/mcp/dist/index.js
CHANGED
|
@@ -2244,7 +2244,17 @@ tool("post_drafts", {
|
|
|
2244
2244
|
return;
|
|
2245
2245
|
}
|
|
2246
2246
|
c.terminal = true;
|
|
2247
|
-
|
|
2247
|
+
// Preserve a more specific reason the menu bar already stamped locally
|
|
2248
|
+
// (store_stamp_decision writes "human_rejected" for a card reject,
|
|
2249
|
+
// discard_all_pending writes "human_discarded_all" for the bulk-discard
|
|
2250
|
+
// button) BEFORE this loopback call ever fires, so by the time it lands
|
|
2251
|
+
// here the local reason is already the more informative one. Only
|
|
2252
|
+
// default to "rejected" when nothing more specific got there first
|
|
2253
|
+
// (e.g. a reject driven straight from chat, with no menu-bar card
|
|
2254
|
+
// involved at all).
|
|
2255
|
+
if (!c.terminal_reason) {
|
|
2256
|
+
c.terminal_reason = "rejected";
|
|
2257
|
+
}
|
|
2248
2258
|
c.approved = false;
|
|
2249
2259
|
rejected.push(n);
|
|
2250
2260
|
});
|
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.2-rc.
|
|
5
|
+
"version": "1.7.2-rc.14",
|
|
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
|
@@ -149,14 +149,14 @@ REJECT_REASONS = (
|
|
|
149
149
|
# Client-side cap on tracked interactions per card (server clips at 50 too).
|
|
150
150
|
MAX_INTERACTIONS = 50
|
|
151
151
|
|
|
152
|
-
# Inline approve row:
|
|
153
|
-
# level). Level 1 = plain approve; 2
|
|
152
|
+
# Inline approve row: glyph + tooltip per approval level (button tag =
|
|
153
|
+
# level). Level 1 = plain approve; 2 = loved=True on the decision, with the
|
|
154
154
|
# exact level shipped as an approve_level_N interaction for the feedback
|
|
155
|
-
# digest.
|
|
155
|
+
# digest. Monochrome text-presentation glyphs (U+FE0E) so the buttons stay
|
|
156
|
+
# black-and-white in both appearances instead of rendering as color emoji.
|
|
156
157
|
APPROVE_EMOJIS = (
|
|
157
|
-
("
|
|
158
|
-
("
|
|
159
|
-
("❤️🔥", "Approve, best of the best"),
|
|
158
|
+
("✓︎", "Approve"),
|
|
159
|
+
("♥︎", "Approve, best of the best"),
|
|
160
160
|
)
|
|
161
161
|
|
|
162
162
|
# Review-surface state mirrored to the state dir for out-of-process observers
|
|
@@ -884,12 +884,13 @@ class _ReviewController(NSObject):
|
|
|
884
884
|
# Bezeled, not borderless: bare emoji read as decoration and
|
|
885
885
|
# users doubted the click registered (2026-07-03/04 feedback,
|
|
886
886
|
# twice now — the outline is what says "button").
|
|
887
|
-
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42,
|
|
887
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42, 38, 30))
|
|
888
888
|
btn.setTitle_(emoji)
|
|
889
889
|
btn.setBezelStyle_(NSBezelStyleRounded)
|
|
890
|
-
#
|
|
891
|
-
#
|
|
892
|
-
|
|
890
|
+
# 16pt fits the rounded bezel's ~22px content lane for these
|
|
891
|
+
# text-presentation glyphs (the old color emoji clipped at 16pt,
|
|
892
|
+
# which is why this used to be 13pt).
|
|
893
|
+
btn.setFont_(NSFont.systemFontOfSize_(16))
|
|
893
894
|
btn.setTag_(i + 1) # tag = approval level
|
|
894
895
|
btn.setTarget_(self)
|
|
895
896
|
btn.setAction_("approve:")
|
|
@@ -898,7 +899,7 @@ class _ReviewController(NSObject):
|
|
|
898
899
|
except Exception:
|
|
899
900
|
pass
|
|
900
901
|
content.addSubview_(btn)
|
|
901
|
-
x +=
|
|
902
|
+
x += 42
|
|
902
903
|
|
|
903
904
|
reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
|
|
904
905
|
reject.setTitle_("Reject")
|
|
@@ -1600,6 +1601,37 @@ class _ReviewController(NSObject):
|
|
|
1600
1601
|
self._log_surface(f"extended +{len(added)}")
|
|
1601
1602
|
return len(added)
|
|
1602
1603
|
|
|
1604
|
+
@objc.python_method
|
|
1605
|
+
def prune_drafts(self, ns):
|
|
1606
|
+
"""Drop not-yet-reached drafts (plan index `n` in `ns`) from the stack,
|
|
1607
|
+
e.g. a card the backend already retired (expired freshness gate, etc.)
|
|
1608
|
+
while it was still waiting to be shown. Only ever removes entries AFTER
|
|
1609
|
+
the current index, so the card on screen right now and everything
|
|
1610
|
+
already decided are untouched -- nothing visible disappears out from
|
|
1611
|
+
under the user. Refreshes the title-bar counter live. Returns the
|
|
1612
|
+
count actually removed."""
|
|
1613
|
+
if self._panel is None or not ns:
|
|
1614
|
+
return 0
|
|
1615
|
+
ns = set(ns)
|
|
1616
|
+
kept = []
|
|
1617
|
+
removed = 0
|
|
1618
|
+
for i, d in enumerate(self._drafts):
|
|
1619
|
+
if i > self._idx and d.get("n") in ns:
|
|
1620
|
+
removed += 1
|
|
1621
|
+
continue
|
|
1622
|
+
kept.append(d)
|
|
1623
|
+
if not removed:
|
|
1624
|
+
return 0
|
|
1625
|
+
self._drafts = kept
|
|
1626
|
+
try:
|
|
1627
|
+
self._panel.setTitle_(
|
|
1628
|
+
f"s4l · Review draft {self._idx + 1} of {len(self._drafts)}"
|
|
1629
|
+
)
|
|
1630
|
+
except Exception:
|
|
1631
|
+
pass
|
|
1632
|
+
self._log_surface(f"pruned {removed} expired")
|
|
1633
|
+
return removed
|
|
1634
|
+
|
|
1603
1635
|
@objc.python_method
|
|
1604
1636
|
def _fire_decision(self):
|
|
1605
1637
|
# Fire the per-card callback the instant a decision is made, so an
|
|
@@ -1832,6 +1864,22 @@ def extend_active(drafts):
|
|
|
1832
1864
|
return 0
|
|
1833
1865
|
|
|
1834
1866
|
|
|
1867
|
+
def prune_active(ns):
|
|
1868
|
+
"""Remove not-yet-reached drafts (by plan index `n`) from the open review
|
|
1869
|
+
card, if one is up -- e.g. a card that expired on the backend mid-review
|
|
1870
|
+
(see merge_review_queue.py's backend sync). Never touches the card
|
|
1871
|
+
currently on screen or any already-decided one, so nothing visible is
|
|
1872
|
+
yanked out from under the user. Returns the count actually removed (0 if
|
|
1873
|
+
no card is open or none of `ns` are still ahead in the stack). Main thread
|
|
1874
|
+
only (called from the menu bar's rumps timer)."""
|
|
1875
|
+
if _active is None or not ns:
|
|
1876
|
+
return 0
|
|
1877
|
+
try:
|
|
1878
|
+
return _active.prune_drafts(ns)
|
|
1879
|
+
except Exception:
|
|
1880
|
+
return 0
|
|
1881
|
+
|
|
1882
|
+
|
|
1835
1883
|
def active_status():
|
|
1836
1884
|
"""Live review-surface snapshot for the menu bar's unattended-review
|
|
1837
1885
|
watchdog, or None when no card is open. Main thread only."""
|
|
@@ -1844,6 +1892,36 @@ def active_status():
|
|
|
1844
1892
|
return None
|
|
1845
1893
|
|
|
1846
1894
|
|
|
1895
|
+
def dismiss_active():
|
|
1896
|
+
"""Force-close the open review panel WITHOUT firing on_decision/on_complete,
|
|
1897
|
+
for a bulk discard whose fate for every remaining card was already decided
|
|
1898
|
+
elsewhere (the menu bar's store-level "Discard all pending drafts"). A
|
|
1899
|
+
normal windowShouldClose_ close still fires on_complete (leftover cards are
|
|
1900
|
+
just undecided); this path skips both callbacks entirely so the bulk
|
|
1901
|
+
discard's own bookkeeping is the only thing that runs. Returns True if a
|
|
1902
|
+
panel was actually open."""
|
|
1903
|
+
global _active
|
|
1904
|
+
c = _active
|
|
1905
|
+
if c is None or c._panel is None:
|
|
1906
|
+
return False
|
|
1907
|
+
try:
|
|
1908
|
+
c._close_stats_popover()
|
|
1909
|
+
except Exception:
|
|
1910
|
+
pass
|
|
1911
|
+
try:
|
|
1912
|
+
c._panel.setDelegate_(None)
|
|
1913
|
+
c._panel.close()
|
|
1914
|
+
except Exception:
|
|
1915
|
+
pass
|
|
1916
|
+
c._panel = None
|
|
1917
|
+
c._on_complete = None
|
|
1918
|
+
c._on_decision = None
|
|
1919
|
+
_active = None
|
|
1920
|
+
_log(f"closed: dismissed (bulk discard, {len(c._decisions)} decided of {len(c._drafts)})")
|
|
1921
|
+
_write_review_state(last_event="dismissed")
|
|
1922
|
+
return True
|
|
1923
|
+
|
|
1924
|
+
|
|
1847
1925
|
def heal_active():
|
|
1848
1926
|
"""Self-heal an unattended card: move it to the top-right of the screen the
|
|
1849
1927
|
pointer is on and raise it, WITHOUT stealing keyboard focus (the user is
|
|
@@ -785,19 +785,12 @@ class S4LMenuBar(rumps.App):
|
|
|
785
785
|
relaunch. Primary action for this case now instead of re-arm: no
|
|
786
786
|
clipboard paste, no chat turn required. Re-arm remains the fallback
|
|
787
787
|
for the rarer case where no session directory exists yet for the
|
|
788
|
-
active account (see _build_menu) — fix 5 never fabricates one.
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
"schedule for this account. Its window will close and reopen "
|
|
795
|
-
"in a moment — drafting starts within a few minutes after."
|
|
796
|
-
),
|
|
797
|
-
ok="Restart & Finish Setup", cancel="Cancel",
|
|
798
|
-
)
|
|
799
|
-
if choice != 1:
|
|
800
|
-
return
|
|
788
|
+
active account (see _build_menu) — fix 5 never fabricates one.
|
|
789
|
+
|
|
790
|
+
No confirm dialog: the restart is already disclosed in the menu item
|
|
791
|
+
label itself ("...(restarts Claude)", _build_menu) — a modal repeating
|
|
792
|
+
that would just be a second click for information the user already
|
|
793
|
+
has (2026-07-09). The notify toast below still tells them it started."""
|
|
801
794
|
_capture_msg(
|
|
802
795
|
"S4L finish-schedule-setup clicked",
|
|
803
796
|
phase="draft_schedule",
|
|
@@ -1147,7 +1140,7 @@ class S4LMenuBar(rumps.App):
|
|
|
1147
1140
|
"Uninstall: keep your X login + browser layer (quick uninstall).\n"
|
|
1148
1141
|
"Deep wipe: also remove the shared browser profiles + toolchain."
|
|
1149
1142
|
),
|
|
1150
|
-
ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe",
|
|
1143
|
+
ok="Uninstall & Restart Claude", cancel="Cancel", other="Deep wipe & Restart Claude",
|
|
1151
1144
|
)
|
|
1152
1145
|
if choice == 0: # cancel
|
|
1153
1146
|
return
|
|
@@ -2509,6 +2502,11 @@ class S4LMenuBar(rumps.App):
|
|
|
2509
2502
|
if ob
|
|
2510
2503
|
else 0
|
|
2511
2504
|
)
|
|
2505
|
+
# Pending draft-card count, for the bulk-discard menu item (visibility +
|
|
2506
|
+
# label). Cheap local JSON reads, same source _maybe_start_review uses.
|
|
2507
|
+
_, pending_drafts = self._pending_review()
|
|
2508
|
+
pending_count = len(pending_drafts)
|
|
2509
|
+
|
|
2512
2510
|
# _update_available / _latest_version are in the signature so a freshly
|
|
2513
2511
|
# detected update rebuilds the menu (adding "Update now & restart Claude Desktop") even mid-run.
|
|
2514
2512
|
sig = (
|
|
@@ -2530,10 +2528,14 @@ class S4LMenuBar(rumps.App):
|
|
|
2530
2528
|
schedule_state,
|
|
2531
2529
|
self._stall_reason_info,
|
|
2532
2530
|
os.path.exists(PAUSE_FLAG),
|
|
2531
|
+
pending_count,
|
|
2533
2532
|
)
|
|
2534
2533
|
if sig != self._sig:
|
|
2535
2534
|
self._sig = sig
|
|
2536
|
-
self._build_menu(
|
|
2535
|
+
self._build_menu(
|
|
2536
|
+
runtime_ready, setup_complete, ob, blocker, snap, attention, schedule_state,
|
|
2537
|
+
pending_count=pending_count,
|
|
2538
|
+
)
|
|
2537
2539
|
|
|
2538
2540
|
# Draft-review pop-ups: if a draft cycle left a review request, present the
|
|
2539
2541
|
# cards. Don't start a review mid-run (the spinner means a tool is active).
|
|
@@ -2585,6 +2587,21 @@ class S4LMenuBar(rumps.App):
|
|
|
2585
2587
|
self._posting_batch_total = 0
|
|
2586
2588
|
self._posting_batch_done = 0
|
|
2587
2589
|
|
|
2590
|
+
def _pending_review(self):
|
|
2591
|
+
"""(batch_id, undecided draft cards) from the durable review store —
|
|
2592
|
+
the same source _maybe_start_review presents from. Cheap (small local
|
|
2593
|
+
JSON reads); drives the menu's bulk-discard item, both its visibility
|
|
2594
|
+
and what it acts on."""
|
|
2595
|
+
try:
|
|
2596
|
+
req = st.read_review_request()
|
|
2597
|
+
batch = (req or {}).get("batch_id")
|
|
2598
|
+
if not batch:
|
|
2599
|
+
return None, []
|
|
2600
|
+
plan = st.read_plan(req.get("plan_path") or "")
|
|
2601
|
+
return batch, st.review_drafts(plan)
|
|
2602
|
+
except Exception:
|
|
2603
|
+
return None, []
|
|
2604
|
+
|
|
2588
2605
|
def _maybe_start_review(self):
|
|
2589
2606
|
req = st.read_review_request()
|
|
2590
2607
|
if not req:
|
|
@@ -2615,6 +2632,13 @@ class S4LMenuBar(rumps.App):
|
|
|
2615
2632
|
# live. This is the fix for the "card froze at 1 of 4 while 137 piled
|
|
2616
2633
|
# up" bug — drafts that arrived after the card opened used to be
|
|
2617
2634
|
# stranded because this method returned early on _review_active.
|
|
2635
|
+
# Also prune the other direction: any `n` this same stack used to
|
|
2636
|
+
# carry but that dropped out of the fresh `drafts` list (merge_review_
|
|
2637
|
+
# queue.py's backend sync just marked it terminal, most commonly the
|
|
2638
|
+
# freshness gate expiring it) is removed from the not-yet-reached part
|
|
2639
|
+
# of the stack, so an old card can't sit there waiting to be approved
|
|
2640
|
+
# into a silent no-op (see the 2026-07-09 "approved 3, nothing
|
|
2641
|
+
# posted" investigation).
|
|
2618
2642
|
# - Posting is DRAINING with no panel up (_review_active but not
|
|
2619
2643
|
# _panel_open): leave the signature untouched so the full pending set
|
|
2620
2644
|
# is presented fresh once the drain completes (don't pop a card mid-post).
|
|
@@ -2623,6 +2647,11 @@ class S4LMenuBar(rumps.App):
|
|
|
2623
2647
|
try:
|
|
2624
2648
|
import s4l_card
|
|
2625
2649
|
|
|
2650
|
+
prev_ns = {n for n, _ in (self._last_review_sig or ())}
|
|
2651
|
+
cur_ns = {d.get("n") for d in drafts}
|
|
2652
|
+
vanished = prev_ns - cur_ns
|
|
2653
|
+
if vanished:
|
|
2654
|
+
s4l_card.prune_active(vanished)
|
|
2626
2655
|
s4l_card.extend_active(drafts)
|
|
2627
2656
|
except Exception as e:
|
|
2628
2657
|
sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
|
|
@@ -2881,6 +2910,66 @@ class S4LMenuBar(rumps.App):
|
|
|
2881
2910
|
if not any(d.get("approved") for d in decisions):
|
|
2882
2911
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
2883
2912
|
|
|
2913
|
+
def _discard_all_pending(self, _=None):
|
|
2914
|
+
"""Bulk-discard every pending draft card: rejected with NO reason,
|
|
2915
|
+
instantly, and — deliberately — without shipping a review event for any
|
|
2916
|
+
of them. A normal per-card reject always ships one (feeding the
|
|
2917
|
+
feedback-digest -> learned_preferences rail, see _ship_review_event);
|
|
2918
|
+
this bulk "clear the backlog" action carries no per-card judgment
|
|
2919
|
+
signal, so it must never reach that rail (user-specified 2026-07-08)."""
|
|
2920
|
+
batch, drafts = self._pending_review()
|
|
2921
|
+
if not batch or not drafts:
|
|
2922
|
+
return
|
|
2923
|
+
n = len(drafts)
|
|
2924
|
+
choice = _show_alert(
|
|
2925
|
+
title="Discard all pending drafts?",
|
|
2926
|
+
message=(
|
|
2927
|
+
f"Discards all {n} pending draft(s) right now, with no reason "
|
|
2928
|
+
"given. None of them will post or be shown for review again. "
|
|
2929
|
+
"This does not feed the AI feedback loop (learned_preferences) "
|
|
2930
|
+
"the way an individual reject does."
|
|
2931
|
+
),
|
|
2932
|
+
ok="Discard All", cancel="Cancel",
|
|
2933
|
+
)
|
|
2934
|
+
if choice != 1:
|
|
2935
|
+
return
|
|
2936
|
+
try:
|
|
2937
|
+
st.discard_all_pending(drafts)
|
|
2938
|
+
except Exception as e:
|
|
2939
|
+
sys.stderr.write(f"[s4l-menubar] discard all pending failed: {e}\n")
|
|
2940
|
+
sys.stderr.flush()
|
|
2941
|
+
_capture(e, phase="discard_all_pending")
|
|
2942
|
+
self._alert("Discard failed", str(e)[:200])
|
|
2943
|
+
return
|
|
2944
|
+
ns = [d.get("n") for d in drafts if d.get("n") is not None]
|
|
2945
|
+
cids = [d.get("candidate_id") for d in drafts if d.get("candidate_id") is not None]
|
|
2946
|
+
|
|
2947
|
+
def _persist_discard():
|
|
2948
|
+
try:
|
|
2949
|
+
st.post_drafts(batch, reject=ns, timeout=60)
|
|
2950
|
+
except Exception:
|
|
2951
|
+
pass
|
|
2952
|
+
try:
|
|
2953
|
+
st.flip_discarded_candidates_skipped(cids)
|
|
2954
|
+
except Exception:
|
|
2955
|
+
pass
|
|
2956
|
+
|
|
2957
|
+
threading.Thread(target=_persist_discard, daemon=True).start()
|
|
2958
|
+
try:
|
|
2959
|
+
import s4l_card
|
|
2960
|
+
|
|
2961
|
+
s4l_card.dismiss_active()
|
|
2962
|
+
except Exception:
|
|
2963
|
+
pass
|
|
2964
|
+
with self._review_lock:
|
|
2965
|
+
self._panel_open = False
|
|
2966
|
+
if self._posts_outstanding <= 0:
|
|
2967
|
+
self._review_active = False
|
|
2968
|
+
self._reset_posting_progress_locked()
|
|
2969
|
+
self._last_review_sig = None
|
|
2970
|
+
st.clear_review_request()
|
|
2971
|
+
self._notify("S4L", f"Discarded {n} pending draft(s).")
|
|
2972
|
+
|
|
2884
2973
|
def _ensure_post_worker(self):
|
|
2885
2974
|
# One persistent daemon worker drains the approved-card queue. It never
|
|
2886
2975
|
# exits (avoids an enqueue-vs-exit race) — an idle parked thread is cheap.
|
|
@@ -2986,7 +3075,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2986
3075
|
self.title = "S4L"
|
|
2987
3076
|
|
|
2988
3077
|
# ---- menu construction ------------------------------------------------
|
|
2989
|
-
def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok"):
|
|
3078
|
+
def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok", pending_count=0):
|
|
2990
3079
|
self.menu.clear()
|
|
2991
3080
|
items = []
|
|
2992
3081
|
|
|
@@ -3008,7 +3097,6 @@ class S4LMenuBar(rumps.App):
|
|
|
3008
3097
|
items.append(rumps.MenuItem("Resume S4L", callback=self._pause_toggle))
|
|
3009
3098
|
else:
|
|
3010
3099
|
items.append(rumps.MenuItem("Pause S4L", callback=self._pause_toggle))
|
|
3011
|
-
items.append(self._label(" stop drafting/posting, keep Claude + tray running"))
|
|
3012
3100
|
items.append(rumps.separator)
|
|
3013
3101
|
|
|
3014
3102
|
# Attention = the draft schedule isn't running for THIS account (missing or
|
|
@@ -3083,7 +3171,16 @@ class S4LMenuBar(rumps.App):
|
|
|
3083
3171
|
except Exception:
|
|
3084
3172
|
pass
|
|
3085
3173
|
if can_selfheal:
|
|
3086
|
-
|
|
3174
|
+
# Say "restarts Claude" in the label itself, not just the
|
|
3175
|
+
# confirm modal — a menu item should be honest about a
|
|
3176
|
+
# disruptive action (closing/reopening the whole app)
|
|
3177
|
+
# before the user has committed to clicking it, matching
|
|
3178
|
+
# what the old "Restart Claude Desktop to fix" button made
|
|
3179
|
+
# obvious upfront (2026-07-08).
|
|
3180
|
+
items.append(rumps.MenuItem(
|
|
3181
|
+
"Finish setting up drafts (restarts Claude)",
|
|
3182
|
+
callback=self._finish_schedule_setup,
|
|
3183
|
+
))
|
|
3087
3184
|
else:
|
|
3088
3185
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3089
3186
|
items.append(rumps.separator)
|
|
@@ -3093,7 +3190,7 @@ class S4LMenuBar(rumps.App):
|
|
|
3093
3190
|
elif not setup_complete:
|
|
3094
3191
|
items += self._state_b(ob, blocker)
|
|
3095
3192
|
else:
|
|
3096
|
-
items += self._state_c(snap)
|
|
3193
|
+
items += self._state_c(snap, pending_count)
|
|
3097
3194
|
|
|
3098
3195
|
# Engagement lanes — ALWAYS visible (every state), not just post-setup, so
|
|
3099
3196
|
# the user can see + flip either lane any time. Two INDEPENDENT checkmarks
|
|
@@ -3103,19 +3200,15 @@ class S4LMenuBar(rumps.App):
|
|
|
3103
3200
|
personal_on = bool(flags.get("personal_brand"))
|
|
3104
3201
|
promo_on = bool(flags.get("promotion"))
|
|
3105
3202
|
items.append(rumps.separator)
|
|
3106
|
-
items.append(self._label("Engagement lanes"))
|
|
3107
3203
|
pb_item = rumps.MenuItem("Personal brand", callback=self._toggle_personal)
|
|
3108
3204
|
pb_item.state = 1 if personal_on else 0
|
|
3109
3205
|
items.append(pb_item)
|
|
3110
|
-
items.append(self._label(" organic, link-free engagement"))
|
|
3111
3206
|
pr_item = rumps.MenuItem("Product promotion", callback=self._toggle_promotion)
|
|
3112
3207
|
pr_item.state = 1 if promo_on else 0
|
|
3113
3208
|
items.append(pr_item)
|
|
3114
|
-
items.append(self._label(" promoting your products (link replies)"))
|
|
3115
3209
|
if personal_on and promo_on:
|
|
3116
3210
|
# Both lanes on: the split becomes meaningful, so offer the presets.
|
|
3117
3211
|
share = st.read_split()
|
|
3118
|
-
items.append(self._label(f" both on · cycles split {self._split_pct(share)}"))
|
|
3119
3212
|
split_menu = rumps.MenuItem(f"Lane split: {self._split_pct(share)}")
|
|
3120
3213
|
for preset in self.SPLIT_PRESETS:
|
|
3121
3214
|
it = rumps.MenuItem(
|
|
@@ -3132,7 +3225,6 @@ class S4LMenuBar(rumps.App):
|
|
|
3132
3225
|
# carries a Feedback button); named for what it does to the pipeline,
|
|
3133
3226
|
# not the mechanism.
|
|
3134
3227
|
items.append(rumps.MenuItem("Give overall feedback to AI…", callback=self._menu_feedback))
|
|
3135
|
-
items.append(self._label(" overall guidance, steers future drafts"))
|
|
3136
3228
|
# While the update-verify marker is pending, the pipeline copy still
|
|
3137
3229
|
# resolves the OLD version (it only advances once the restarted server
|
|
3138
3230
|
# re-provisions repo/package, ~2 min), so the snapshot honestly reports
|
|
@@ -3159,8 +3251,10 @@ class S4LMenuBar(rumps.App):
|
|
|
3159
3251
|
items.append(rumps.separator)
|
|
3160
3252
|
items.append(rumps.MenuItem("Tidy autopilot history…", callback=self._prompt_relocate_tasks))
|
|
3161
3253
|
items.append(rumps.separator)
|
|
3162
|
-
|
|
3163
|
-
|
|
3254
|
+
quit_menu = rumps.MenuItem("Quit or Uninstall")
|
|
3255
|
+
quit_menu.add(rumps.MenuItem("Uninstall S4L…", callback=self._reset_machine))
|
|
3256
|
+
quit_menu.add(rumps.MenuItem("Quit", callback=self._quit_app))
|
|
3257
|
+
items.append(quit_menu)
|
|
3164
3258
|
|
|
3165
3259
|
# Collapse consecutive/edge separators so an empty section (e.g. State C
|
|
3166
3260
|
# now renders no status labels) can't leave a doubled or dangling divider.
|
|
@@ -3244,8 +3338,15 @@ class S4LMenuBar(rumps.App):
|
|
|
3244
3338
|
# The engagement-mode toggles live in _build_menu (shown in EVERY state), and
|
|
3245
3339
|
# there is deliberately no "Run draft cycle" / "Post approved drafts" item
|
|
3246
3340
|
# (the autopilot drafts on its own; approving a review card already posts it).
|
|
3247
|
-
def _state_c(self, snap):
|
|
3248
|
-
|
|
3341
|
+
def _state_c(self, snap, pending_count=0):
|
|
3342
|
+
if pending_count <= 0:
|
|
3343
|
+
return []
|
|
3344
|
+
return [
|
|
3345
|
+
rumps.MenuItem(
|
|
3346
|
+
f"Discard {pending_count} pending draft{'s' if pending_count != 1 else ''}…",
|
|
3347
|
+
callback=self._discard_all_pending,
|
|
3348
|
+
)
|
|
3349
|
+
]
|
|
3249
3350
|
|
|
3250
3351
|
|
|
3251
3352
|
if __name__ == "__main__":
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -770,6 +770,78 @@ def store_stamp_decision(batch, decision):
|
|
|
770
770
|
return bool(_store_update(mutate))
|
|
771
771
|
|
|
772
772
|
|
|
773
|
+
def discard_all_pending(drafts):
|
|
774
|
+
"""Bulk-reject an entire pending set (the menu bar's "Discard all pending
|
|
775
|
+
drafts"), durably and atomically in ONE lock acquisition. Each candidate is
|
|
776
|
+
stamped terminal with NO reason (reject_category=None), same as a card's
|
|
777
|
+
"Reject, no reason" button. Deliberately distinct from store_stamp_decision:
|
|
778
|
+
this never ships a review event, by design, so a bulk "clear the backlog"
|
|
779
|
+
click never reaches the review-events/feedback-digest rail and can't
|
|
780
|
+
pollute learned_preferences with a non-judgment. Returns how many were
|
|
781
|
+
stamped."""
|
|
782
|
+
|
|
783
|
+
def mutate(data):
|
|
784
|
+
done = 0
|
|
785
|
+
for d in drafts:
|
|
786
|
+
c = _match_candidate(data, d.get("n"), d.get("candidate_id"))
|
|
787
|
+
if c is None or c.get("posted") is True or c.get("terminal") is True:
|
|
788
|
+
continue
|
|
789
|
+
c["terminal"] = True
|
|
790
|
+
c["terminal_reason"] = "human_discarded_all"
|
|
791
|
+
c["decision"] = {
|
|
792
|
+
"approved": False,
|
|
793
|
+
"text": c.get("reply_text") or "",
|
|
794
|
+
"edited": False,
|
|
795
|
+
"drop_link": False,
|
|
796
|
+
"loved": False,
|
|
797
|
+
"reject_category": None,
|
|
798
|
+
"decided_at": time_iso(),
|
|
799
|
+
}
|
|
800
|
+
done += 1
|
|
801
|
+
return done
|
|
802
|
+
|
|
803
|
+
return _store_update(mutate) or 0
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def flip_discarded_candidates_skipped(candidate_ids):
|
|
807
|
+
"""Flip each bulk-discarded candidate's twitter_candidates row to
|
|
808
|
+
status='skipped', skip_reason='human_discarded_all' — the same DB effect a
|
|
809
|
+
normal reject gets for free as a side effect of its review-events insert
|
|
810
|
+
(see review-events/route.ts). The bulk-discard path deliberately never
|
|
811
|
+
ships a review event (that's the whole point — it must not reach the
|
|
812
|
+
feedback digest), so without this direct call the row would stay
|
|
813
|
+
'pending' until the independent age-based freshness gate happens to expire
|
|
814
|
+
it, wide open to re-discovery/re-drafting of the exact draft a human just
|
|
815
|
+
discarded. One PATCH per candidate via the SAME direct-HTTP path
|
|
816
|
+
flush_review_events already uses (http_api), so this never touches
|
|
817
|
+
review_events. Best-effort: a candidate that already expired/posted by the
|
|
818
|
+
time this runs (404, no longer 'pending') is a no-op, not a failure."""
|
|
819
|
+
if not candidate_ids:
|
|
820
|
+
return
|
|
821
|
+
try:
|
|
822
|
+
from http_api import api_patch
|
|
823
|
+
except Exception as err:
|
|
824
|
+
sys.stderr.write(
|
|
825
|
+
f"[s4l-state] flip_discarded_candidates_skipped: http_api unavailable "
|
|
826
|
+
f"({type(err).__name__}: {err}); {len(candidate_ids)} candidate(s) left 'pending'\n"
|
|
827
|
+
)
|
|
828
|
+
sys.stderr.flush()
|
|
829
|
+
return
|
|
830
|
+
for cid in candidate_ids:
|
|
831
|
+
try:
|
|
832
|
+
api_patch(
|
|
833
|
+
"/api/v1/twitter-candidates/by-id",
|
|
834
|
+
{"id": cid, "action": "mark_skipped", "reason": "human_discarded_all"},
|
|
835
|
+
ok_on_404=True,
|
|
836
|
+
)
|
|
837
|
+
except Exception as err:
|
|
838
|
+
sys.stderr.write(
|
|
839
|
+
f"[s4l-state] flip_discarded_candidates_skipped: PATCH failed for "
|
|
840
|
+
f"candidate_id={cid} ({type(err).__name__}: {err})\n"
|
|
841
|
+
)
|
|
842
|
+
sys.stderr.flush()
|
|
843
|
+
|
|
844
|
+
|
|
773
845
|
def store_mark_post_failed(batch, n, candidate_id=None, error=None):
|
|
774
846
|
"""A decided post that FAILED surfaces via notification/dashboard, not by
|
|
775
847
|
re-presenting the card and not by endless resume retries."""
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.2-rc.
|
|
3
|
+
"version": "1.7.2-rc.14",
|
|
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,91 @@
|
|
|
1
|
+
import json, re, urllib.parse
|
|
2
|
+
|
|
3
|
+
NOTIFS = "/tmp/li_notifs.json"
|
|
4
|
+
CID = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/brzcccx3h.txt"
|
|
5
|
+
PAIRS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bu0y0bphg.txt"
|
|
6
|
+
POSTS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bnb5ginhc.txt"
|
|
7
|
+
|
|
8
|
+
def load_lines(p):
|
|
9
|
+
out=[]
|
|
10
|
+
for ln in open(p, encoding="utf-8", errors="replace"):
|
|
11
|
+
ln=ln.rstrip("\n")
|
|
12
|
+
# skip persisted-output wrapper lines
|
|
13
|
+
if not ln or ln.startswith("<") or ln.startswith("Output too large") or ln.startswith("Preview") or ln.startswith("..."):
|
|
14
|
+
continue
|
|
15
|
+
out.append(ln)
|
|
16
|
+
return out
|
|
17
|
+
|
|
18
|
+
comment_ids = set(load_lines(CID))
|
|
19
|
+
pairs = set(load_lines(PAIRS))
|
|
20
|
+
posts = load_lines(POSTS)
|
|
21
|
+
|
|
22
|
+
# activity number -> post id
|
|
23
|
+
post_by_act = {}
|
|
24
|
+
for ln in posts:
|
|
25
|
+
if "|" not in ln: continue
|
|
26
|
+
pid, url = ln.split("|",1)
|
|
27
|
+
for m in re.findall(r"urn:li:(?:activity|share|ugcPost):(\d+)", url):
|
|
28
|
+
post_by_act[m]=pid
|
|
29
|
+
|
|
30
|
+
# config projects search_topics
|
|
31
|
+
cfg = json.load(open("/Users/matthewdi/social-autoposter/config.json"))
|
|
32
|
+
projtopics = []
|
|
33
|
+
for pr in cfg.get("projects",[]):
|
|
34
|
+
projtopics.append((pr["name"], [t.lower() for t in pr.get("search_topics",[])]))
|
|
35
|
+
|
|
36
|
+
EXCLUDED = {"louis030195","louis3195"}
|
|
37
|
+
OWN = {"matthew diakonov","m13v"}
|
|
38
|
+
|
|
39
|
+
def match_project(snippet):
|
|
40
|
+
s = snippet.lower()
|
|
41
|
+
best=None; bestscore=0
|
|
42
|
+
for name, topics in projtopics:
|
|
43
|
+
score=0
|
|
44
|
+
for t in topics:
|
|
45
|
+
if t and t in s:
|
|
46
|
+
score+=1
|
|
47
|
+
if score>bestscore:
|
|
48
|
+
bestscore=score; best=name
|
|
49
|
+
return best or "S4L"
|
|
50
|
+
|
|
51
|
+
data = json.load(open(NOTIFS))
|
|
52
|
+
decisions=[]
|
|
53
|
+
for d in data:
|
|
54
|
+
href = d["href"]
|
|
55
|
+
dec = urllib.parse.unquote(href)
|
|
56
|
+
m = re.search(r"urn:li:activity:(\d+)", dec)
|
|
57
|
+
activity_id = m.group(1) if m else None
|
|
58
|
+
comment_urn = d["comment_urn"]
|
|
59
|
+
author = d["author"]
|
|
60
|
+
a_low = author.lower().strip()
|
|
61
|
+
rec = dict(author=author, activity_id=activity_id, comment_urn=comment_urn,
|
|
62
|
+
snippet=d["snippet"], href=href, type=d["type"])
|
|
63
|
+
if not comment_urn or not activity_id:
|
|
64
|
+
rec["decision"]="no_comment_urn"; decisions.append(rec); continue
|
|
65
|
+
if a_low in OWN:
|
|
66
|
+
rec["decision"]="own_account"; decisions.append(rec); continue
|
|
67
|
+
if a_low in EXCLUDED or any(x in a_low for x in EXCLUDED):
|
|
68
|
+
rec["decision"]="excluded_author"; decisions.append(rec); continue
|
|
69
|
+
if comment_urn in comment_ids:
|
|
70
|
+
rec["decision"]="already_tracked"; decisions.append(rec); continue
|
|
71
|
+
our_url = "https://www.linkedin.com/feed/update/urn:li:activity:%s/" % activity_id
|
|
72
|
+
apk = author + "|||" + our_url
|
|
73
|
+
if apk in pairs:
|
|
74
|
+
rec["decision"]="author_already_engaged"; decisions.append(rec); continue
|
|
75
|
+
# match post
|
|
76
|
+
pid = post_by_act.get(activity_id)
|
|
77
|
+
rec["post_id"]=pid
|
|
78
|
+
rec["project"]=match_project(d["snippet"])
|
|
79
|
+
rec["decision"]="INSERT"
|
|
80
|
+
decisions.append(rec)
|
|
81
|
+
|
|
82
|
+
json.dump(decisions, open("/tmp/li_decisions.json","w"))
|
|
83
|
+
# summary
|
|
84
|
+
from collections import Counter
|
|
85
|
+
c=Counter(r["decision"] for r in decisions)
|
|
86
|
+
print("TOTAL", len(decisions))
|
|
87
|
+
for k,v in c.items(): print(k,v)
|
|
88
|
+
print("---INSERTS---")
|
|
89
|
+
for i,r in enumerate(decisions):
|
|
90
|
+
if r["decision"]=="INSERT":
|
|
91
|
+
print(i, r["author"], "| act", r["activity_id"], "| post", r.get("post_id"), "| proj", r["project"])
|
package/scripts/claude_job.py
CHANGED
|
@@ -60,7 +60,6 @@ except Exception: # pragma: no cover - cosmetic only
|
|
|
60
60
|
|
|
61
61
|
# script_tag -> queue type. ONLY pure text->JSON claude calls belong here.
|
|
62
62
|
TAG_TO_TYPE = {
|
|
63
|
-
"run-twitter-cycle-queries": "twitter-query",
|
|
64
63
|
"run-twitter-cycle-prep": "twitter-prep",
|
|
65
64
|
"feedback-digest": "feedback-digest",
|
|
66
65
|
# Topic-invention lane (queue-native since 2026-07-06; invent_topics.py
|
|
@@ -75,11 +74,10 @@ TAG_TO_TYPE = {
|
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
# queue type -> (activity state, label) the menu bar shows while the job is in
|
|
78
|
-
# flight. Phase-
|
|
79
|
-
#
|
|
80
|
-
#
|
|
77
|
+
# flight. Phase-2b prep is the reply drafting. Both the launchd provider (which
|
|
78
|
+
# blocks for minutes) and the scheduled-task worker (which does the LLM turn)
|
|
79
|
+
# narrate from this one map.
|
|
81
80
|
TYPE_TO_ACTIVITY = {
|
|
82
|
-
"twitter-query": ("scanning", "search"),
|
|
83
81
|
"twitter-prep": ("drafting", "draft"),
|
|
84
82
|
"feedback-digest": ("learning", "feedback"),
|
|
85
83
|
"invent-topic": ("learning", "new topic"),
|
|
@@ -203,7 +203,7 @@ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
|
|
|
203
203
|
NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved):
|
|
204
204
|
{ev_lines}{overall_block}
|
|
205
205
|
|
|
206
|
-
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
|
|
206
|
+
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
207
|
|
|
208
208
|
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
209
|
|
|
@@ -129,16 +129,31 @@ STATS_KEYS = (
|
|
|
129
129
|
)
|
|
130
130
|
|
|
131
131
|
|
|
132
|
-
def
|
|
133
|
-
"""
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
132
|
+
def _sync_with_backend(cands: list) -> tuple[int, int]:
|
|
133
|
+
"""One bulk /api/v1/twitter-candidates lookup for every still-open candidate
|
|
134
|
+
(not posted, not terminal), used for two things:
|
|
135
|
+
|
|
136
|
+
- stamp the discovery-time `stats` sidecar the card renders (candidates
|
|
137
|
+
that already have one are left alone), same as the old
|
|
138
|
+
_enrich_with_stats this replaces.
|
|
139
|
+
- notice when the backend has ALREADY retired a candidate this plan
|
|
140
|
+
still thinks is 'pending' (most commonly the Phase 0 freshness gate
|
|
141
|
+
flipping status='expired' after FRESHNESS_HOURS — see
|
|
142
|
+
skill/run-twitter-cycle.sh) and mark it terminal here too, same as a
|
|
143
|
+
human "discard all pending" would. Without this, a card can sit in
|
|
144
|
+
the review queue as an approvable draft long after the backend has
|
|
145
|
+
moved on; approving it later silently no-ops (post_drafts returns
|
|
146
|
+
posted:0, no browser ever launches, no post-*.log — see the
|
|
147
|
+
2026-07-09 "approved 3 cards, nothing posted" investigation).
|
|
148
|
+
|
|
149
|
+
Runs on EVERY merge (every cycle), not just once per candidate, so status
|
|
150
|
+
drift after the initial stamp is still caught while a card is still
|
|
151
|
+
pending. Best-effort: any API failure leaves every candidate untouched
|
|
152
|
+
(fail open, same as before). Returns (stamped_count, pruned_count)."""
|
|
153
|
+
pending = [c for c in cands if not c.get("posted") and not c.get("terminal") and _thread_url(c)]
|
|
154
|
+
if not pending:
|
|
155
|
+
return 0, 0
|
|
156
|
+
urls = sorted({_thread_url(c) for c in pending})[:500]
|
|
142
157
|
try:
|
|
143
158
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
144
159
|
from http_api import api_get
|
|
@@ -149,17 +164,24 @@ def _enrich_with_stats(cands: list) -> int:
|
|
|
149
164
|
)
|
|
150
165
|
rows = (resp.get("data") or {}).get("candidates") or []
|
|
151
166
|
except BaseException as e: # http_api raises SystemExit on terminal failure
|
|
152
|
-
print(f"[merge_review_queue]
|
|
153
|
-
return 0
|
|
167
|
+
print(f"[merge_review_queue] backend sync skipped: {e}", file=sys.stderr)
|
|
168
|
+
return 0, 0
|
|
154
169
|
by_url = {str(r.get("tweet_url")): r for r in rows if r.get("tweet_url")}
|
|
155
170
|
stamped = 0
|
|
156
|
-
|
|
171
|
+
pruned = 0
|
|
172
|
+
for c in pending:
|
|
157
173
|
row = by_url.get(_thread_url(c))
|
|
158
174
|
if not row:
|
|
159
175
|
continue
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
176
|
+
if not c.get("stats"):
|
|
177
|
+
c["stats"] = {k: row.get(k) for k in STATS_KEYS}
|
|
178
|
+
stamped += 1
|
|
179
|
+
status = row.get("status")
|
|
180
|
+
if status and status != "pending":
|
|
181
|
+
c["terminal"] = True
|
|
182
|
+
c["discard_reason"] = f"backend_status_{status}"
|
|
183
|
+
pruned += 1
|
|
184
|
+
return stamped, pruned
|
|
163
185
|
|
|
164
186
|
|
|
165
187
|
def main() -> int:
|
|
@@ -255,9 +277,15 @@ def main() -> int:
|
|
|
255
277
|
merged.append(c)
|
|
256
278
|
added += 1
|
|
257
279
|
|
|
258
|
-
stamped =
|
|
280
|
+
stamped, pruned = _sync_with_backend(merged)
|
|
259
281
|
if stamped:
|
|
260
282
|
print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
|
|
283
|
+
if pruned:
|
|
284
|
+
print(
|
|
285
|
+
f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
|
|
286
|
+
"backend (expired/etc.) before they were reviewed",
|
|
287
|
+
file=sys.stderr,
|
|
288
|
+
)
|
|
261
289
|
|
|
262
290
|
plan_obj = {"candidates": merged}
|
|
263
291
|
if plan_created_at:
|
|
@@ -274,15 +302,17 @@ def main() -> int:
|
|
|
274
302
|
_atomic_write(dst, plan_obj)
|
|
275
303
|
ensure_store_symlink()
|
|
276
304
|
|
|
277
|
-
# Refresh the review-request marker the menu bar polls (count = pending,
|
|
278
|
-
|
|
305
|
+
# Refresh the review-request marker the menu bar polls (count = pending,
|
|
306
|
+
# not posted, not terminal -- a just-pruned expired card must not still
|
|
307
|
+
# inflate the badge).
|
|
308
|
+
pending_count = len([c for c in merged if not c.get("posted") and not c.get("terminal")])
|
|
279
309
|
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
280
310
|
_atomic_write(
|
|
281
311
|
review_request_path(),
|
|
282
312
|
{
|
|
283
313
|
"batch_id": REVIEW_QUEUE_ID,
|
|
284
314
|
"project": project,
|
|
285
|
-
"count":
|
|
315
|
+
"count": pending_count,
|
|
286
316
|
"plan_path": dst,
|
|
287
317
|
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
288
318
|
},
|
|
@@ -306,7 +336,7 @@ def main() -> int:
|
|
|
306
336
|
|
|
307
337
|
print(
|
|
308
338
|
f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
|
|
309
|
-
f"({
|
|
339
|
+
f"({pending_count} pending total) from {os.path.basename(src)}",
|
|
310
340
|
file=sys.stderr,
|
|
311
341
|
)
|
|
312
342
|
# Clean up the consumed batch plan so /tmp doesn't fill with orphans.
|
package/scripts/run_claude.sh
CHANGED
|
@@ -321,16 +321,22 @@ EOF
|
|
|
321
321
|
# Dead-man's-switch (2026-07-09): every non-queue-routed tag (reddit,
|
|
322
322
|
# linkedin, github, moltbook, instagram, dm-outreach-*, ...) blocks
|
|
323
323
|
# here exactly like claude_job.py's queue provider does, with the
|
|
324
|
-
# same silent-death risk (SIGKILL/OOM/hard crash while waiting).
|
|
325
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
#
|
|
324
|
+
# same silent-death risk (SIGKILL/OOM/hard crash while waiting).
|
|
325
|
+
# Watches THIS SCRIPT's own pid ($$), not $CLAUDE_PG: if only the
|
|
326
|
+
# claude child dies, `wait` unblocks normally and this script keeps
|
|
327
|
+
# running (RC-checked, logged, retried) — no observability gap.
|
|
328
|
+
# The gap is when the WHOLE TREE (this script included) is killed
|
|
329
|
+
# together, which is what actually happened in the salvage-orphan
|
|
330
|
+
# cases this was built for. Arm per-attempt (job id includes
|
|
331
|
+
# $CLAUDE_PG so a retry never collides with a still-unwinding prior
|
|
332
|
+
# attempt's watcher); disarm right after `wait` returns AND from
|
|
333
|
+
# _sa_cleanup's trap (that path SIGKILLs $CLAUDE_PG's group as
|
|
334
|
+
# ordinary TERM/INT/HUP handling, which must disarm too or a normal
|
|
335
|
+
# watchdog-triggered shutdown would misreport as an unexpected
|
|
336
|
+
# death). See scripts/producer_deathwatch.py.
|
|
331
337
|
_SA_DW_JOB="${SESSION_ID}-${CLAUDE_PG}"
|
|
332
338
|
python3 "$REPO_DIR/scripts/producer_deathwatch.py" arm \
|
|
333
|
-
--watch-pid "
|
|
339
|
+
--watch-pid "$$" --job-id "$_SA_DW_JOB" --qtype "$SCRIPT_TAG" \
|
|
334
340
|
--batch "${BATCH_ID:-${SA_CYCLE_ID:--}}" --call-path direct \
|
|
335
341
|
>/dev/null 2>&1 || true
|
|
336
342
|
wait "$CLAUDE_PG"
|
|
@@ -18,10 +18,14 @@ Safe by construction:
|
|
|
18
18
|
- Best-effort: any single failure is logged and skipped; never raises.
|
|
19
19
|
|
|
20
20
|
Degradation vs a normal cycle: salvaged candidates skip the cycle's post-provider
|
|
21
|
-
top-N selection (so MORE cards, which is fine)
|
|
22
|
-
arm stamp that run-twitter-cycle.sh's plan writer adds after the provider returns
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
top-N selection (so MORE cards, which is fine), lack the tail-link / experiments
|
|
22
|
+
arm stamp that run-twitter-cycle.sh's plan writer adds after the provider returns,
|
|
23
|
+
and (two-draft schema only) lack assigned_style/assigned_mode, which live in the
|
|
24
|
+
cycle's shell variables, not the model output. The reply text itself IS complete
|
|
25
|
+
end-to-end (_mirror_two_draft_fields backfills reply_text/drafts from
|
|
26
|
+
draft_a_text/draft_b_text for the post-2026-07-07/08 two-draft schema, since the
|
|
27
|
+
model output alone has no reply_text field to check for completeness). A salvaged
|
|
28
|
+
card is strictly better than a lost draft.
|
|
25
29
|
|
|
26
30
|
Usage:
|
|
27
31
|
python3 scripts/salvage_orphaned_prep_results.py # automated (safe age gate)
|
|
@@ -59,15 +63,73 @@ except Exception: # standalone fallbacks
|
|
|
59
63
|
pass
|
|
60
64
|
|
|
61
65
|
|
|
66
|
+
def _mirror_two_draft_fields(candidates):
|
|
67
|
+
"""Backfill reply_text/drafts for two-draft-schema candidates (2026-07-07/08
|
|
68
|
+
redesign) that reach salvage. The normal cycle path (run-twitter-cycle.sh)
|
|
69
|
+
mirrors draft_a_text onto reply_text/engagement_style/drafts right after the
|
|
70
|
+
model returns, but salvage bypasses that shell-side step entirely, so an
|
|
71
|
+
orphaned post-redesign result reached review cards with NEITHER field set.
|
|
72
|
+
The menubar card (s4l_card.py) reads d.get("drafts") first, then falls back
|
|
73
|
+
to d.get("reply_text") or "", so those cards rendered the thread with a
|
|
74
|
+
completely empty editable reply box despite draft_a_text/draft_b_text
|
|
75
|
+
holding real, already-drafted content (root-caused 2026-07-09 via
|
|
76
|
+
candidate 374925 and 4 siblings, all missing 'experiments' too, confirming
|
|
77
|
+
they came through this salvage path rather than a normal cycle write).
|
|
78
|
+
|
|
79
|
+
assigned_style/assigned_mode are deliberately left OUT (not set to None,
|
|
80
|
+
just absent): the picker's per-cycle style assignment lives only in
|
|
81
|
+
run-twitter-cycle.sh's shell variables, not in the model's JSON output, so
|
|
82
|
+
it can't be recovered here. twitter_post_plan.py already has a documented
|
|
83
|
+
fallback for that ("assigned_mode key absent" -> use the plan-level
|
|
84
|
+
assignment, itself None for a salvaged plan), so leaving the keys out is
|
|
85
|
+
the safe, already-supported degradation, same class as the existing
|
|
86
|
+
no-experiments-stamp degradation.
|
|
87
|
+
"""
|
|
88
|
+
for c in candidates:
|
|
89
|
+
if not isinstance(c, dict) or "draft_a_text" not in c or "reply_text" in c:
|
|
90
|
+
continue
|
|
91
|
+
c["reply_text"] = c.get("draft_a_text") or ""
|
|
92
|
+
c["engagement_style"] = c.get("draft_a_style") or ""
|
|
93
|
+
c["new_style"] = c.get("draft_a_new_style")
|
|
94
|
+
if c.get("draft_a_text_en"):
|
|
95
|
+
c["reply_text_en"] = c["draft_a_text_en"]
|
|
96
|
+
draft_b_text = c.get("draft_b_text")
|
|
97
|
+
if not c.get("is_reused_draft") and draft_b_text:
|
|
98
|
+
c["drafts"] = [
|
|
99
|
+
{
|
|
100
|
+
"variant": "a", "text": c.get("draft_a_text") or "",
|
|
101
|
+
"style": c.get("draft_a_style") or "",
|
|
102
|
+
"text_en": c.get("draft_a_text_en"),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"variant": "b", "text": draft_b_text,
|
|
106
|
+
"style": c.get("draft_b_style") or "",
|
|
107
|
+
"text_en": c.get("draft_b_text_en"),
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
62
112
|
def _is_prep_result(obj):
|
|
63
|
-
"""True iff obj looks like a twitter-prep result (candidates
|
|
113
|
+
"""True iff obj looks like a twitter-prep result (drafted candidates).
|
|
114
|
+
|
|
115
|
+
"reply_text" was the single-draft field before the 2026-07-07/08 two-draft
|
|
116
|
+
redesign (draft_a_text/draft_b_text per candidate, no single recommended
|
|
117
|
+
reply). Checking only "reply_text" made every post-redesign orphaned
|
|
118
|
+
result silently misclassified as non-prep and marked .skipped instead of
|
|
119
|
+
recovered — the exact "worker drafted but no card" bug this script exists
|
|
120
|
+
to prevent. Accept either field so both old and current schema results
|
|
121
|
+
are recognized.
|
|
122
|
+
"""
|
|
64
123
|
if not isinstance(obj, dict):
|
|
65
124
|
return False
|
|
66
125
|
cands = obj.get("candidates")
|
|
67
126
|
if not isinstance(cands, list) or not cands:
|
|
68
127
|
return False
|
|
69
128
|
c0 = cands[0]
|
|
70
|
-
|
|
129
|
+
if not isinstance(c0, dict):
|
|
130
|
+
return False
|
|
131
|
+
has_text = "reply_text" in c0 or "draft_a_text" in c0
|
|
132
|
+
return has_text and ("candidate_url" in c0 or "candidate_id" in c0)
|
|
71
133
|
|
|
72
134
|
|
|
73
135
|
def main():
|
|
@@ -130,6 +192,7 @@ def main():
|
|
|
130
192
|
pass
|
|
131
193
|
continue
|
|
132
194
|
|
|
195
|
+
_mirror_two_draft_fields(obj["candidates"])
|
|
133
196
|
n = len(obj["candidates"])
|
|
134
197
|
age_min = (now - st.st_mtime) / 60.0
|
|
135
198
|
_plog(f"[salvage] ORPHAN prep result job {job_id}: producer never consumed it "
|
|
@@ -734,14 +734,12 @@ ENGAGED_COUNT=$(echo "$ENGAGED_TWEET_IDS" | python3 -c 'import json,sys; print(l
|
|
|
734
734
|
log "Recently-engaged tweet IDs loaded: $ENGAGED_COUNT (last 48h; scanner will skip them)"
|
|
735
735
|
|
|
736
736
|
|
|
737
|
-
# --- Phase 1:
|
|
738
|
-
#
|
|
739
|
-
#
|
|
740
|
-
#
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
log "Acquiring twitter-browser lock for Phase 1 Claude scan..."
|
|
737
|
+
# --- Phase 1: deterministic query bank, scrapes tweets ----------------------
|
|
738
|
+
# No Claude call (removed 2026-07-08; see the query-bank comment below). The
|
|
739
|
+
# Python pipeline runs each banked query via headless Chrome and writes the
|
|
740
|
+
# tweets directly to SCAN_TWEETS_FILE for the shell.
|
|
741
|
+
|
|
742
|
+
log "Acquiring twitter-browser lock for Phase 1 scan..."
|
|
745
743
|
acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
|
|
746
744
|
log "twitter-browser lock held (pid=$$) Phase 1"
|
|
747
745
|
# Drop stale Chrome singleton symlinks before launch. Background ungraceful-
|
|
@@ -911,16 +909,15 @@ log " Pre-flight access OK: $(printf '%s' "$_ACCESS_OUT" | tr '\n' ' ' | tr -s
|
|
|
911
909
|
# cap is hit before target, proceed with whatever we have (even 1 candidate
|
|
912
910
|
# is better than 0). When BATCH_COUNT is still 0 after the loop, the
|
|
913
911
|
# post-loop empty_batch branch fires.
|
|
914
|
-
#
|
|
915
|
-
#
|
|
912
|
+
# Phase 1 is the deterministic qualified-query bank (no Claude): the bank
|
|
913
|
+
# replays every historically qualified query for the picked project in a
|
|
916
914
|
# single pass, so there is nothing to "retry-draft" and one attempt is enough.
|
|
917
|
-
# The
|
|
918
|
-
#
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
fi
|
|
915
|
+
# (The old LLM-draft path used a 5-attempt retry loop, because LLM-drafted
|
|
916
|
+
# queries frequently returned empty and needed re-drafting; removed 2026-07-08
|
|
917
|
+
# — unused since the bank became the default 2026-05-28, and the queue-routed
|
|
918
|
+
# claude call it made held the twitter-browser lock for no reason, the same
|
|
919
|
+
# class of bug fixed in Phase 2b-prep the same day.)
|
|
920
|
+
MAX_SCAN_ATTEMPTS=1
|
|
924
921
|
RETRY_TARGET=5
|
|
925
922
|
SCAN_ATTEMPT=0
|
|
926
923
|
BATCH_COUNT=0
|
|
@@ -1073,110 +1070,8 @@ export SCAN_TWEETS_FILE
|
|
|
1073
1070
|
# Output downstream is identical: $RAW_FILE + $QUERIES_FILE feed the scorer
|
|
1074
1071
|
# and twitter_search_attempts logger the same way as before.
|
|
1075
1072
|
#
|
|
1076
|
-
|
|
1077
|
-
#
|
|
1078
|
-
log "Lean Phase 1: drafting queries (no browser tools)..."
|
|
1079
|
-
|
|
1080
|
-
QUERIES_OUTPUT=$("$REPO_DIR/scripts/run_claude.sh" "run-twitter-cycle-queries" --strict-mcp-config --mcp-config "$TW_MCP_CONFIG" -p --output-format json --json-schema "$SCAN_SCHEMA_LEAN" "${TW_ENGINE_PREFIX}You are a Twitter query drafter. Your ONLY job is to draft fresh X advanced-search queries that surface tweets relevant to our projects. You do NOT post, you do NOT call any tools, you do NOT scrape. A separate Python pipeline runs your queries over the same CDP-driven Chrome and applies a strict freshness gate; you only return the query strings.
|
|
1081
|
-
|
|
1082
|
-
## Step 1: Draft one search query per project
|
|
1083
|
-
|
|
1084
|
-
You have $(echo "$PROJECTS_JSON" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))') projects. Draft exactly ONE Twitter search query for each, tailored to that project's ASSIGNED search_topic.
|
|
1085
|
-
|
|
1086
|
-
Each project entry carries TWO fields that drive your behavior: \`topic_picked_mode\` (either \`use\` or \`explore_invent\`) and \`search_topic\` (a string in \`use\` mode, NULL in \`explore_invent\` mode).
|
|
1087
|
-
|
|
1088
|
-
USE mode (~90% of cycles, indicated by \`topic_picked_mode: \"use\"\` and a non-null \`search_topic\`):
|
|
1089
|
-
The Python picker has already chosen this project's search_topic by weighted-random sampling over the FULL universe in config.json. Your job is to translate that ASSIGNED topic into the best Twitter advanced-search query that will surface fresh, on-topic tweets. Do NOT substitute a different topic; do NOT paraphrase the topic. End-to-end attribution joins on the exact string.
|
|
1090
|
-
|
|
1091
|
-
EXPLORE_INVENT mode (~10% of cycles, indicated by \`topic_picked_mode: \"explore_invent\"\` and \`search_topic: null\`):
|
|
1092
|
-
The picker is asking you to INVENT a brand-new search_topic. Look at the project's own \`reference_topics\` array and propose ONE new topic concept that does NOT appear there and is NOT a paraphrase of anything in it. Use your invented topic as the query's \`search_topic\` AND drive the keyword phrasing from it (one consistent string per project).
|
|
1093
|
-
|
|
1094
|
-
Projects:
|
|
1095
|
-
$PROJECTS_JSON
|
|
1096
|
-
|
|
1097
|
-
Top past queries FOR THE PROJECT YOU'RE DRAFTING FOR (7-day window, per-project, sorted by clicks DESC first, then composite-scored: clicks×100 + likes + views×0.001). CLICKS ARE THE PRIORITY SIGNAL. Each row carries THREE labels that tell you what to do with it as a reference:
|
|
1098
|
-
|
|
1099
|
-
- \`supply_bucket\`: low (<1 tweet/attempt), medium (1-5), high (>5). Raw supply X returned for this phrasing.
|
|
1100
|
-
- \`conversion_bucket\`: low (<0.2 post_rate), medium (0.2-0.6), high (>=0.6). How often a found tweet survived the draft gate.
|
|
1101
|
-
- \`guidance\`: one of MIMIC, KEEP_STYLE, NARROW, BROADEN — the action to take when drawing from this query.
|
|
1102
|
-
- \`posts_per_attempt\`: posts produced per Phase 1 search invocation; <0.1 means most attempts produce zero survivors.
|
|
1103
|
-
|
|
1104
|
-
How to act on \`guidance\`:
|
|
1105
|
-
- MIMIC — gold tier. Reuse the operator skeleton verbatim, swap only the topic keyword for the picker-assigned topic.
|
|
1106
|
-
- KEEP_STYLE — solid. Use the operator pattern as inspiration; small phrasing tweaks OK.
|
|
1107
|
-
- NARROW — high supply, low conversion (noisy pond). If you draw from it, ADD specificity: more OR alternates, stricter min_faves, extra -term excludes.
|
|
1108
|
-
- BROADEN — low supply (query dying or topic running dry). The OPERATORS are dead weight. Shorten to 1-2 keywords, drop OR groups, step min_faves down a tier. Do NOT inherit operators from a BROADEN-tagged row.
|
|
1109
|
-
|
|
1110
|
-
The canonical source for \`min_faves:N\` selection is the PER-PROJECT SUPPLY SIGNAL block below.
|
|
1111
|
-
$TOP_QUERIES_PER_PROJECT_JSON
|
|
1112
|
-
|
|
1113
|
-
TOP-PERFORMING SEARCH TOPICS (conceptual seeds, 14d window) — context for query phrasing only; you draft a query for the picker-assigned topic, you do NOT swap topics here:
|
|
1114
|
-
$TOP_TOPICS_JSON
|
|
1115
|
-
|
|
1116
|
-
DUD QUERIES — DO NOT REUSE these phrasings or close variants. They returned ZERO tweets in the last 48h:
|
|
1117
|
-
$DUD_QUERIES_JSON
|
|
1118
|
-
|
|
1119
|
-
DUD CONCEPT SEEDS — these search_topic seeds pulled in tweets that Phase 2b's draft gate kept skipping over the last 7d. Per entry: \`omit_rate\` = skipped_n / (posted_n + skipped_n), \`sample_skip_reasons\` are the top reject reasons. If \`omit_rate >= 0.6\` AND \`skipped_n >= 5\`, REWORD the query narrower or drop the seed and pick a different config.json seed for that project:
|
|
1120
|
-
$DUD_TOPICS_JSON
|
|
1121
|
-
|
|
1122
|
-
PER-PROJECT SUPPLY SIGNAL — for each project, the historical median tweets_found at each \`min_faves:N\` tier you've drafted in the last 14d. Pick the LOWEST tier where \`median_tweets_found >= 3\`; if every tier is below 3, drop one tier lower than the lowest you've tried. Trust this table over priors:
|
|
1123
|
-
$SUPPLY_SIGNAL_JSON
|
|
1124
|
-
|
|
1125
|
-
ALREADY-ENGAGED TWEET IDS (last 48h) — the Python scraper skips these regardless, but knowing them helps you avoid drafting a query that would predominantly surface dead candidates:
|
|
1126
|
-
$ENGAGED_TWEET_IDS
|
|
1127
|
-
|
|
1128
|
-
THIS-CYCLE QUERIES ALREADY TRIED with per-query outcomes (attempt $SCAN_ATTEMPT/$MAX_SCAN_ATTEMPTS, target=$RETRY_TARGET candidates after filters). Do NOT repeat any of these phrasings or close variants. Read each entry's \`verdict\` field and respond directionally (do NOT default to generic "broaden"):
|
|
1129
|
-
- \`dead_supply\` (raw_tweets=0): the phrasing returned ZERO tweets from X. The query was too narrow for X's index. HARD RULE: attempt N+1 MUST execute at least ONE of these THREE concrete broadening moves, NOT a topic rotation. Pick exactly one and apply it visibly: (a) lower \`min_faves\` by ONE FULL TIER (e.g. 20→5, 5→1, 1→0); (b) reduce the OR alternates inside any parenthesized group to AT MOST 2 terms (e.g. \`(A OR B OR C OR D)\` → \`(A OR B)\`); (c) drop ALL \`-term\` excludes EXCEPT those listed in this project's \`excludes_for_search\` (which remain mandatory). The PER-PROJECT SUPPLY SIGNAL block is OVERRIDDEN by \`dead_supply\` THIS CYCLE — do not appeal to historical min_faves when the current attempt returned 0. Swapping the topic noun while keeping the same operator skeleton is NOT broadening and is FORBIDDEN as a response to \`dead_supply\`.
|
|
1130
|
-
- \`all_aged_out\` (raw>0, kept_after_age=0): topic is supply-limited at the current freshness window; every tweet was older than the cap. Pick a structurally adjacent topic; do NOT rephrase the same one (it will just hit the cap again).
|
|
1131
|
-
- \`all_engaged_or_skipped\` (kept_after_age>0, kept_after_skip=0): query phrasing is fine, but the surviving tweets were already engaged on prior cycles. Pick a DIFFERENT topic, not a rephrase.
|
|
1132
|
-
- \`found_some\` (kept_after_skip>0 but below target): query is on-target. Raise min_faves one tier OR add a semantic constraint to lift quality. Do NOT broaden.
|
|
1133
|
-
$TRIED_QUERIES_JSON
|
|
1134
|
-
|
|
1135
|
-
Query guidelines:
|
|
1136
|
-
- MANDATORY: do NOT add any date or time-window operator to your query (no \`since:\`, \`until:\`, \`since_time:\`, \`until_time:\`). The Python scraper enforces the freshness window at the URL level after you return; any time operator you include is stripped and overwritten. Including raw bash arithmetic, format strings, or placeholder text in place of a real epoch will be sent to X as a literal keyword and produce zero results.
|
|
1137
|
-
- MANDATORY EVEN IF YOUR QUERY KEYWORDS DO NOT NAME THE EXCLUDED TOPIC: if a project's \`excludes_for_search\` array is non-empty, append \`-term\` for EVERY listed term to that project's query, verbatim, no exceptions.
|
|
1138
|
-
- MANDATORY: pick \`min_faves:N\` per the PER-PROJECT SUPPLY SIGNAL above. If a project has no entry there (new / first cycle), start at min_faves:20.
|
|
1139
|
-
- Favor discussions/opinions (people sharing experience, asking questions), not news/promos/giveaways.
|
|
1140
|
-
- Pick a query likely to surface tweets RELEVANT to that project's actual domain.
|
|
1141
|
-
- Mix it up each run; don't always use the same query for the same project.
|
|
1142
|
-
- Use the project's ASSIGNED \`search_topic\` plus its \`description\` as grounding for query phrasing.
|
|
1143
|
-
- The \`search_topic\` you emit in the output JSON MUST be the project's assigned \`search_topic\` field pasted VERBATIM (NOT the query string, NOT a paraphrase). The scoring pipeline stamps \`twitter_candidates.search_topic\` from this for end-to-end attribution.
|
|
1144
|
-
|
|
1145
|
-
## Output
|
|
1146
|
-
|
|
1147
|
-
Return ONLY the structured_output JSON with this shape:
|
|
1148
|
-
{\"queries\": [{\"project\": \"PROJECT_NAME\", \"query\": \"X advanced search string with operators\", \"search_topic\": \"assigned or invented topic, verbatim\"}, ...]}
|
|
1149
|
-
|
|
1150
|
-
One entry per project. Do NOT include tweets, do NOT include tweets_found, do NOT call any tool, do NOT scrape. The shell pipeline runs each query via headless Chrome with a strict freshness gate after you return." 2>&1)
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
# Dump the captured envelope to the cycle log for offline inspection.
|
|
1154
|
-
echo "$QUERIES_OUTPUT" >> "$LOG_FILE"
|
|
1155
|
-
|
|
1156
|
-
# Extract the drafted queries to a temp file.
|
|
1157
|
-
QUERIES_TMP="/tmp/twcycle-${BATCH_ID}-attempt-${SCAN_ATTEMPT}-queries.json"
|
|
1158
|
-
python3 -c "
|
|
1159
|
-
import json, sys
|
|
1160
|
-
text = sys.stdin.read().strip()
|
|
1161
|
-
try:
|
|
1162
|
-
env, _ = json.JSONDecoder().raw_decode(text)
|
|
1163
|
-
except Exception as e:
|
|
1164
|
-
print(f'lean phase 1: envelope parse error: {e}', file=sys.stderr)
|
|
1165
|
-
json.dump([], open('$QUERIES_TMP', 'w'))
|
|
1166
|
-
sys.exit(0)
|
|
1167
|
-
so = env.get('structured_output')
|
|
1168
|
-
if so is None:
|
|
1169
|
-
so = env.get('result')
|
|
1170
|
-
if isinstance(so, str):
|
|
1171
|
-
try: so = json.loads(so)
|
|
1172
|
-
except Exception: pass
|
|
1173
|
-
qs = so.get('queries', []) if isinstance(so, dict) else []
|
|
1174
|
-
json.dump(qs, open('$QUERIES_TMP', 'w'))
|
|
1175
|
-
print(f'lean phase 1: drafted {len(qs)} queries', flush=True)
|
|
1176
|
-
" <<< "$QUERIES_OUTPUT" 2>&1 | tee -a "$LOG_FILE"
|
|
1177
|
-
|
|
1178
|
-
else
|
|
1179
|
-
# === DETERMINISTIC QUALIFIED-QUERY-BANK PATH (default, 2026-05-28) ==========
|
|
1073
|
+
# === DETERMINISTIC QUALIFIED-QUERY-BANK PATH (default since 2026-05-28;
|
|
1074
|
+
# only path since 2026-07-08 removal of the LLM query-draft alternative) =====
|
|
1180
1075
|
# No Claude call. Replay every historically qualified query for the picked
|
|
1181
1076
|
# project(s): every distinct query that ever produced a posted reply with
|
|
1182
1077
|
# >=1 like OR >=1 non-bot link click, regardless of the per-cycle
|
|
@@ -1192,7 +1087,6 @@ else
|
|
|
1192
1087
|
log "Phase 1 (bank): building qualified query bank from PROJECTS_JSON (deterministic, no Claude)..."
|
|
1193
1088
|
echo "$PROJECTS_JSON" | python3 "$REPO_DIR/scripts/qualified_query_bank.py" --from-projects-json > "$QUERIES_TMP" 2>>"$LOG_FILE"
|
|
1194
1089
|
fi
|
|
1195
|
-
fi
|
|
1196
1090
|
|
|
1197
1091
|
QUERIES_COUNT=$(python3 -c "
|
|
1198
1092
|
import json
|