@m13v/s4l 1.7.2-rc.4 → 1.7.2-rc.6
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/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +30 -0
- package/mcp/menubar/s4l_menubar.py +91 -5
- package/mcp/menubar/s4l_state.py +33 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +3 -5
- package/skill/run-twitter-cycle.sh +16 -122
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.6",
|
|
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
|
@@ -1844,6 +1844,36 @@ def active_status():
|
|
|
1844
1844
|
return None
|
|
1845
1845
|
|
|
1846
1846
|
|
|
1847
|
+
def dismiss_active():
|
|
1848
|
+
"""Force-close the open review panel WITHOUT firing on_decision/on_complete,
|
|
1849
|
+
for a bulk discard whose fate for every remaining card was already decided
|
|
1850
|
+
elsewhere (the menu bar's store-level "Discard all pending drafts"). A
|
|
1851
|
+
normal windowShouldClose_ close still fires on_complete (leftover cards are
|
|
1852
|
+
just undecided); this path skips both callbacks entirely so the bulk
|
|
1853
|
+
discard's own bookkeeping is the only thing that runs. Returns True if a
|
|
1854
|
+
panel was actually open."""
|
|
1855
|
+
global _active
|
|
1856
|
+
c = _active
|
|
1857
|
+
if c is None or c._panel is None:
|
|
1858
|
+
return False
|
|
1859
|
+
try:
|
|
1860
|
+
c._close_stats_popover()
|
|
1861
|
+
except Exception:
|
|
1862
|
+
pass
|
|
1863
|
+
try:
|
|
1864
|
+
c._panel.setDelegate_(None)
|
|
1865
|
+
c._panel.close()
|
|
1866
|
+
except Exception:
|
|
1867
|
+
pass
|
|
1868
|
+
c._panel = None
|
|
1869
|
+
c._on_complete = None
|
|
1870
|
+
c._on_decision = None
|
|
1871
|
+
_active = None
|
|
1872
|
+
_log(f"closed: dismissed (bulk discard, {len(c._decisions)} decided of {len(c._drafts)})")
|
|
1873
|
+
_write_review_state(last_event="dismissed")
|
|
1874
|
+
return True
|
|
1875
|
+
|
|
1876
|
+
|
|
1847
1877
|
def heal_active():
|
|
1848
1878
|
"""Self-heal an unattended card: move it to the top-right of the screen the
|
|
1849
1879
|
pointer is on and raise it, WITHOUT stealing keyboard focus (the user is
|
|
@@ -2502,6 +2502,11 @@ class S4LMenuBar(rumps.App):
|
|
|
2502
2502
|
if ob
|
|
2503
2503
|
else 0
|
|
2504
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
|
+
|
|
2505
2510
|
# _update_available / _latest_version are in the signature so a freshly
|
|
2506
2511
|
# detected update rebuilds the menu (adding "Update now & restart Claude Desktop") even mid-run.
|
|
2507
2512
|
sig = (
|
|
@@ -2523,10 +2528,14 @@ class S4LMenuBar(rumps.App):
|
|
|
2523
2528
|
schedule_state,
|
|
2524
2529
|
self._stall_reason_info,
|
|
2525
2530
|
os.path.exists(PAUSE_FLAG),
|
|
2531
|
+
pending_count,
|
|
2526
2532
|
)
|
|
2527
2533
|
if sig != self._sig:
|
|
2528
2534
|
self._sig = sig
|
|
2529
|
-
self._build_menu(
|
|
2535
|
+
self._build_menu(
|
|
2536
|
+
runtime_ready, setup_complete, ob, blocker, snap, attention, schedule_state,
|
|
2537
|
+
pending_count=pending_count,
|
|
2538
|
+
)
|
|
2530
2539
|
|
|
2531
2540
|
# Draft-review pop-ups: if a draft cycle left a review request, present the
|
|
2532
2541
|
# cards. Don't start a review mid-run (the spinner means a tool is active).
|
|
@@ -2578,6 +2587,21 @@ class S4LMenuBar(rumps.App):
|
|
|
2578
2587
|
self._posting_batch_total = 0
|
|
2579
2588
|
self._posting_batch_done = 0
|
|
2580
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
|
+
|
|
2581
2605
|
def _maybe_start_review(self):
|
|
2582
2606
|
req = st.read_review_request()
|
|
2583
2607
|
if not req:
|
|
@@ -2874,6 +2898,61 @@ class S4LMenuBar(rumps.App):
|
|
|
2874
2898
|
if not any(d.get("approved") for d in decisions):
|
|
2875
2899
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
2876
2900
|
|
|
2901
|
+
def _discard_all_pending(self, _=None):
|
|
2902
|
+
"""Bulk-discard every pending draft card: rejected with NO reason,
|
|
2903
|
+
instantly, and — deliberately — without shipping a review event for any
|
|
2904
|
+
of them. A normal per-card reject always ships one (feeding the
|
|
2905
|
+
feedback-digest -> learned_preferences rail, see _ship_review_event);
|
|
2906
|
+
this bulk "clear the backlog" action carries no per-card judgment
|
|
2907
|
+
signal, so it must never reach that rail (user-specified 2026-07-08)."""
|
|
2908
|
+
batch, drafts = self._pending_review()
|
|
2909
|
+
if not batch or not drafts:
|
|
2910
|
+
return
|
|
2911
|
+
n = len(drafts)
|
|
2912
|
+
choice = _show_alert(
|
|
2913
|
+
title="Discard all pending drafts?",
|
|
2914
|
+
message=(
|
|
2915
|
+
f"Discards all {n} pending draft(s) right now, with no reason "
|
|
2916
|
+
"given. None of them will post or be shown for review again. "
|
|
2917
|
+
"This does not feed the AI feedback loop (learned_preferences) "
|
|
2918
|
+
"the way an individual reject does."
|
|
2919
|
+
),
|
|
2920
|
+
ok="Discard All", cancel="Cancel",
|
|
2921
|
+
)
|
|
2922
|
+
if choice != 1:
|
|
2923
|
+
return
|
|
2924
|
+
try:
|
|
2925
|
+
st.discard_all_pending(drafts)
|
|
2926
|
+
except Exception as e:
|
|
2927
|
+
sys.stderr.write(f"[s4l-menubar] discard all pending failed: {e}\n")
|
|
2928
|
+
sys.stderr.flush()
|
|
2929
|
+
_capture(e, phase="discard_all_pending")
|
|
2930
|
+
self._alert("Discard failed", str(e)[:200])
|
|
2931
|
+
return
|
|
2932
|
+
ns = [d.get("n") for d in drafts if d.get("n") is not None]
|
|
2933
|
+
|
|
2934
|
+
def _persist_discard():
|
|
2935
|
+
try:
|
|
2936
|
+
st.post_drafts(batch, reject=ns, timeout=60)
|
|
2937
|
+
except Exception:
|
|
2938
|
+
pass
|
|
2939
|
+
|
|
2940
|
+
threading.Thread(target=_persist_discard, daemon=True).start()
|
|
2941
|
+
try:
|
|
2942
|
+
import s4l_card
|
|
2943
|
+
|
|
2944
|
+
s4l_card.dismiss_active()
|
|
2945
|
+
except Exception:
|
|
2946
|
+
pass
|
|
2947
|
+
with self._review_lock:
|
|
2948
|
+
self._panel_open = False
|
|
2949
|
+
if self._posts_outstanding <= 0:
|
|
2950
|
+
self._review_active = False
|
|
2951
|
+
self._reset_posting_progress_locked()
|
|
2952
|
+
self._last_review_sig = None
|
|
2953
|
+
st.clear_review_request()
|
|
2954
|
+
self._notify("S4L", f"Discarded {n} pending draft(s).")
|
|
2955
|
+
|
|
2877
2956
|
def _ensure_post_worker(self):
|
|
2878
2957
|
# One persistent daemon worker drains the approved-card queue. It never
|
|
2879
2958
|
# exits (avoids an enqueue-vs-exit race) — an idle parked thread is cheap.
|
|
@@ -2979,7 +3058,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2979
3058
|
self.title = "S4L"
|
|
2980
3059
|
|
|
2981
3060
|
# ---- menu construction ------------------------------------------------
|
|
2982
|
-
def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok"):
|
|
3061
|
+
def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok", pending_count=0):
|
|
2983
3062
|
self.menu.clear()
|
|
2984
3063
|
items = []
|
|
2985
3064
|
|
|
@@ -3094,7 +3173,7 @@ class S4LMenuBar(rumps.App):
|
|
|
3094
3173
|
elif not setup_complete:
|
|
3095
3174
|
items += self._state_b(ob, blocker)
|
|
3096
3175
|
else:
|
|
3097
|
-
items += self._state_c(snap)
|
|
3176
|
+
items += self._state_c(snap, pending_count)
|
|
3098
3177
|
|
|
3099
3178
|
# Engagement lanes — ALWAYS visible (every state), not just post-setup, so
|
|
3100
3179
|
# the user can see + flip either lane any time. Two INDEPENDENT checkmarks
|
|
@@ -3242,8 +3321,15 @@ class S4LMenuBar(rumps.App):
|
|
|
3242
3321
|
# The engagement-mode toggles live in _build_menu (shown in EVERY state), and
|
|
3243
3322
|
# there is deliberately no "Run draft cycle" / "Post approved drafts" item
|
|
3244
3323
|
# (the autopilot drafts on its own; approving a review card already posts it).
|
|
3245
|
-
def _state_c(self, snap):
|
|
3246
|
-
|
|
3324
|
+
def _state_c(self, snap, pending_count=0):
|
|
3325
|
+
if pending_count <= 0:
|
|
3326
|
+
return []
|
|
3327
|
+
return [
|
|
3328
|
+
rumps.MenuItem(
|
|
3329
|
+
f"Discard {pending_count} pending draft{'s' if pending_count != 1 else ''}…",
|
|
3330
|
+
callback=self._discard_all_pending,
|
|
3331
|
+
)
|
|
3332
|
+
]
|
|
3247
3333
|
|
|
3248
3334
|
|
|
3249
3335
|
if __name__ == "__main__":
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -770,6 +770,39 @@ 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
|
+
|
|
773
806
|
def store_mark_post_failed(batch, n, candidate_id=None, error=None):
|
|
774
807
|
"""A decided post that FAILED surfaces via notification/dashboard, not by
|
|
775
808
|
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.6",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
package/scripts/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"),
|
|
@@ -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
|