@m13v/s4l 1.7.6-rc.8 → 1.7.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/README.md +1 -1
- package/bin/cli.js +33 -9
- package/mcp/dist/index.js +290 -177
- package/mcp/dist/panel.html +31 -17
- package/mcp/dist/setup.js +27 -7
- package/mcp/dist/telemetry.js +15 -6
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +10 -2
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +63 -4
- package/mcp/menubar/s4l_card_canvas.py +62 -11
- package/mcp/menubar/s4l_menubar.py +380 -95
- package/mcp/menubar/s4l_state.py +119 -41
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +58 -14
- package/scripts/amplitude_24h_signups.py +7 -4
- package/scripts/amplitude_signups.py +7 -4
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/claude_job.py +70 -10
- package/scripts/dm_conversation.py +15 -12
- package/scripts/draft_prompt_core.py +132 -64
- package/scripts/engage_github.py +11 -5
- package/scripts/engage_reddit.py +12 -5
- package/scripts/engagement_signal_feedback.py +1 -4
- package/scripts/engagement_styles.py +144 -105
- package/scripts/feedback_digest.py +54 -11
- package/scripts/find_threads.py +9 -7
- package/scripts/github_tools.py +23 -10
- package/scripts/learned_preferences.py +112 -78
- package/scripts/li_discovery.py +4 -1
- package/scripts/link_tail.py +1 -1
- package/scripts/live_status.py +151 -0
- package/scripts/merge_review_queue.py +15 -2
- package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
- package/scripts/pick_project.py +7 -4
- package/scripts/pick_thread_target.py +64 -13
- package/scripts/pick_twitter_thread_target.py +7 -4
- package/scripts/platform_strike_events.py +139 -7
- package/scripts/post_github.py +376 -35
- package/scripts/post_reddit.py +202 -192
- package/scripts/preflight.sh +23 -3
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/project_stats.py +7 -4
- package/scripts/reddit_ban_check.py +324 -0
- package/scripts/reddit_browser.py +56 -19
- package/scripts/reddit_chat_sync.py +10 -11
- package/scripts/reddit_tools.py +20 -4
- package/scripts/release-mcpb.sh +47 -7
- package/scripts/run_moltbook_cycle.py +9 -5
- package/scripts/s4l_ctl.sh +2 -2
- package/scripts/s4l_posting_mode.py +67 -6
- package/scripts/scan_dm_candidates.py +15 -9
- package/scripts/scan_github_replies.py +9 -7
- package/scripts/scan_moltbook_replies.py +7 -6
- package/scripts/scan_reddit_replies.py +11 -7
- package/scripts/scan_twitter_mentions_browser.py +7 -6
- package/scripts/scan_twitter_thread_followups.py +7 -6
- package/scripts/schedule_state.py +8 -4
- package/scripts/score_twitter_candidates.py +8 -3
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/snapshot.py +19 -0
- package/scripts/stats.py +12 -10
- package/scripts/strike_alert.py +165 -12
- package/scripts/sub_moderation_history.py +129 -0
- package/scripts/subreddit_bans_client.py +157 -0
- package/scripts/test_cross_language_parity.py +185 -0
- package/scripts/test_no_silent_fallbacks.py +84 -0
- package/scripts/test_percard_posting.py +3 -3
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/scripts/twitter_gen_links.py +3 -3
- package/scripts/twitter_post_plan.py +4 -4
- package/scripts/virality.py +151 -0
- package/setup/SKILL.md +1 -1
- package/skill/engage-dm-replies.sh +11 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-draft-and-publish.sh +16 -0
- package/skill/run-reddit-search-launchd.sh +1 -0
- package/skill/run-reddit-search.sh +10 -1
- package/skill/run-reddit-threads.sh +12 -0
- package/skill/run-twitter-cycle.sh +104 -88
|
@@ -76,6 +76,12 @@ def _config_path():
|
|
|
76
76
|
ARM_TREATMENT = "treatment_v4"
|
|
77
77
|
ARM_CONTROL = "control_v4"
|
|
78
78
|
|
|
79
|
+
# The "treatment_v4 assigns no real style" decision (and its sentinel value)
|
|
80
|
+
# lives ONLY in engagement_styles.py now (STYLE_SENTINEL_TREATMENT,
|
|
81
|
+
# pick_style_for_post, pick_exploration_style, 2026-07-17 consolidation):
|
|
82
|
+
# every platform's picker routes through those two functions, so this
|
|
83
|
+
# module never needs its own copy of the sentinel or the arm check for it.
|
|
84
|
+
|
|
79
85
|
|
|
80
86
|
def pick_draft_prompt_arm(env=None, export=True):
|
|
81
87
|
"""Return (variant, source). source in {env, pin, coin, fallback}.
|
|
@@ -118,17 +124,84 @@ def pick_draft_prompt_arm(env=None, export=True):
|
|
|
118
124
|
# Text transcribed verbatim from run-twitter-cycle.sh (2026-07-16).
|
|
119
125
|
# --------------------------------------------------------------------------
|
|
120
126
|
|
|
121
|
-
|
|
127
|
+
# --------------------------------------------------------------------------
|
|
128
|
+
# Shared treatment_v4 core (2026-07-17). Written ONCE, used by BOTH lanes.
|
|
129
|
+
#
|
|
130
|
+
# Why this exists: promotion and persona used to carry independent copies of
|
|
131
|
+
# nearly-identical priority-order/skeleton-ban/vary-entry-point text. They
|
|
132
|
+
# drifted -- persona's copy was missing the "vary the entry point across
|
|
133
|
+
# replies" sentence promotion had, which a real batch comparison traced to
|
|
134
|
+
# the "nobody ___s it" tell appearing 2x as often in treatment as control:
|
|
135
|
+
# a batch shares ONE assigned style, and without an explicit
|
|
136
|
+
# vary-across-the-batch instruction the model reused that style's defining
|
|
137
|
+
# vocabulary verbatim across every unrelated candidate. Do NOT fork this
|
|
138
|
+
# text again for a lane-specific tweak; parameterize _treatment_core()
|
|
139
|
+
# instead, or add a lane-specific sentence OUTSIDE it.
|
|
140
|
+
# --------------------------------------------------------------------------
|
|
122
141
|
|
|
123
|
-
|
|
142
|
+
def _treatment_core(voice_examples_ref):
|
|
143
|
+
"""Shared PRIORITY ORDER + skeleton ban + entry-point-variation text.
|
|
144
|
+
|
|
145
|
+
voice_examples_ref: the lane-specific noun phrase for priority item (2)
|
|
146
|
+
(promotion scopes to "the matched project's voice.examples"; persona
|
|
147
|
+
scopes to "voice.examples above" since it's already narrowed to the one
|
|
148
|
+
persona project). No mention of engagement style anywhere in here on
|
|
149
|
+
purpose: treatment_v4 assigns none (see engagement_styles.py's
|
|
150
|
+
STYLE_SENTINEL_TREATMENT / pick_style_for_post).
|
|
151
|
+
"""
|
|
152
|
+
return (
|
|
153
|
+
"PRIORITY ORDER for how you write this, highest first: (1) "
|
|
154
|
+
"learned_preferences.draft_style_notes are the "
|
|
155
|
+
"strongest signal available, real human corrections to this "
|
|
156
|
+
"account's own past drafts (some carry a before/after example), and "
|
|
157
|
+
"are MANDATORY, not advisory. (2) "
|
|
158
|
+
f"The ACCOUNT VOICE CORPUS block and {voice_examples_ref} are "
|
|
159
|
+
"VERBATIM GROUND TRUTH for how this account actually writes: "
|
|
160
|
+
"capitalization, punctuation, contractions, sentence length, "
|
|
161
|
+
"terseness. Match those mechanics exactly. (3) voice.tone is a "
|
|
162
|
+
"supporting description only, never ground truth: if it ever "
|
|
163
|
+
"conflicts with what the corpus/examples actually show, follow the "
|
|
164
|
+
"examples, not the tone description. Do NOT use the "
|
|
165
|
+
"concede-then-reverse skeleton in ANY form. Banned openings "
|
|
166
|
+
"include: 'X is the easy part/half/win, the hard part is Y'; 'X "
|
|
167
|
+
"was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; "
|
|
168
|
+
"'the real/actual/harder part is Y'; 'what actually "
|
|
169
|
+
"breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X "
|
|
170
|
+
"is solved, Y is what breaks'. Lead with substance from ONE entry "
|
|
171
|
+
"point and vary the entry point AND the rhetorical move across "
|
|
172
|
+
"every reply you draft this batch, even when the same topic or "
|
|
173
|
+
"style would naturally suggest reusing the same construction: a "
|
|
174
|
+
"concrete first-hand specific or number; a direct answer to the "
|
|
175
|
+
"exact question asked; one sharp opinion with no hedge; a genuine "
|
|
176
|
+
"question that moves the thread forward; or a relevant pointer. No "
|
|
177
|
+
"warm-up framing sentence before the substance."
|
|
178
|
+
)
|
|
124
179
|
|
|
125
|
-
_DIVERGENCE_NOTE_TREATMENT = " Beyond following different style templates: draft_a_text and draft_b_text must be genuinely distinct takes on this thread -- think of them as variant 1a and variant 1b: different entry point, different specific detail seized on, different rhetorical move -- not two phrasings of the same underlying observation dressed in different style language. If 1a and 1b would say essentially the same thing, pick a different angle entirely for 1b rather than just rewording 1a."
|
|
126
180
|
|
|
127
|
-
|
|
181
|
+
_TREATMENT_LENGTH_NOTE = (
|
|
182
|
+
"Length: match how long the account's own real examples above actually "
|
|
183
|
+
"run; when the corpus doesn't make it obvious, default to one or two "
|
|
184
|
+
"sentences, well under Twitter's 250-character practical limit. NEVER "
|
|
185
|
+
"em dashes."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
_DIRECTIVE_TREATMENT = (
|
|
189
|
+
"Otherwise: draft a direct, natural reply that stands on its own as a "
|
|
190
|
+
"useful contribution to the thread. Mention the matched project only "
|
|
191
|
+
"when it is genuinely the most relevant thing to say, and state it "
|
|
192
|
+
"plainly in one clause; most replies will not need it. "
|
|
193
|
+
+ _treatment_core("the matched project's voice.examples") +
|
|
194
|
+
" " + _TREATMENT_LENGTH_NOTE +
|
|
195
|
+
" Never violate voice.never. Treat learned_preferences.audience_avoid "
|
|
196
|
+
"/ thread_avoid matches as strong reasons to skip the candidate. Never "
|
|
197
|
+
"violate content_guardrails.do_not."
|
|
198
|
+
)
|
|
128
199
|
|
|
129
|
-
|
|
200
|
+
_DIRECTIVE_CONTROL = "Otherwise: draft a reply using the best engagement style. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's `voice` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The global learned_preferences block under PROJECT ROUTING is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
130
201
|
|
|
131
|
-
|
|
202
|
+
_DIVERGENCE_NOTE_TREATMENT = " draft_a_text and draft_b_text must be genuinely distinct takes on this thread regardless of style -- think of them as variant 1a and variant 1b: different entry point, different specific detail seized on, different rhetorical move -- not two phrasings of the same underlying observation. If 1a and 1b would say essentially the same thing, pick a different angle entirely for 1b rather than just rewording 1a."
|
|
203
|
+
|
|
204
|
+
_PERSONA_DIRECTIVE_TEMPLATE = "Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the ACCOUNT VOICE CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's `voice` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').@TREATMENT_CORE@ This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing.@LENGTH_NOTE@ Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The global learned_preferences block under PROJECT ROUTING is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing @PREFS_RELATION@, and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
132
205
|
|
|
133
206
|
|
|
134
207
|
def draft_directive(arm=None, lane=None):
|
|
@@ -137,17 +210,17 @@ def draft_directive(arm=None, lane=None):
|
|
|
137
210
|
lane = lane if lane is not None else os.environ.get("S4L_ACTIVE_LANE", "")
|
|
138
211
|
if lane == "personal_brand":
|
|
139
212
|
if arm == ARM_TREATMENT:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
rel = "(per the PRIORITY ORDER above: learned_preferences and the account's real voice
|
|
213
|
+
core = " " + _treatment_core("voice.examples above")
|
|
214
|
+
length_note = " " + _TREATMENT_LENGTH_NOTE
|
|
215
|
+
rel = "(per the PRIORITY ORDER above: learned_preferences and the account's real voice are what drive this reply)"
|
|
143
216
|
else:
|
|
144
|
-
|
|
145
|
-
|
|
217
|
+
core = ""
|
|
218
|
+
length_note = " Length is governed by the per-style LENGTH LIMIT in the style block above. NEVER em dashes."
|
|
146
219
|
rel = "(it overrides the engagement style's structural template on conflict)"
|
|
147
220
|
return (
|
|
148
221
|
_PERSONA_DIRECTIVE_TEMPLATE
|
|
149
|
-
.replace("@
|
|
150
|
-
.replace("@
|
|
222
|
+
.replace("@TREATMENT_CORE@", core)
|
|
223
|
+
.replace("@LENGTH_NOTE@", length_note)
|
|
151
224
|
.replace("@PREFS_RELATION@", rel)
|
|
152
225
|
)
|
|
153
226
|
if arm == ARM_TREATMENT:
|
|
@@ -260,6 +333,9 @@ def global_learned_prefs_json():
|
|
|
260
333
|
config = _load_config()
|
|
261
334
|
block = lp.get_global_block(config)
|
|
262
335
|
block.pop("history", None)
|
|
336
|
+
# edit_examples feed only the digest now (2026-07-17); the drafting and
|
|
337
|
+
# judging prompts read their distilled form via draft_style_notes.
|
|
338
|
+
block.pop("edit_examples", None)
|
|
263
339
|
return json.dumps(block, indent=2)
|
|
264
340
|
except Exception:
|
|
265
341
|
return "{}"
|
|
@@ -279,24 +355,6 @@ def recent_self_block(platform, limit=20):
|
|
|
279
355
|
return ""
|
|
280
356
|
|
|
281
357
|
|
|
282
|
-
def product_names():
|
|
283
|
-
"""Every configured project name (lowercased) plus the agency tag, for
|
|
284
|
-
the reddit never-name-the-product rule. Falls back to a generic list on
|
|
285
|
-
config failure so the rule never silently vanishes."""
|
|
286
|
-
names = []
|
|
287
|
-
try:
|
|
288
|
-
config = _load_config()
|
|
289
|
-
for p in config.get("projects", []):
|
|
290
|
-
n = (p.get("name") or "").strip().lower()
|
|
291
|
-
if n and n not in names:
|
|
292
|
-
names.append(n)
|
|
293
|
-
except Exception:
|
|
294
|
-
pass
|
|
295
|
-
if "s4l" not in names:
|
|
296
|
-
names.append("s4l")
|
|
297
|
-
return names
|
|
298
|
-
|
|
299
|
-
|
|
300
358
|
# --------------------------------------------------------------------------
|
|
301
359
|
# TWITTER renderer. Byte-exact transcription of the run-twitter-cycle.sh
|
|
302
360
|
# PREP_PROMPT heredoc (2026-07-16). @TOKENS@ are unique and replaced below;
|
|
@@ -354,7 +412,7 @@ For each chosen candidate:
|
|
|
354
412
|
- If the candidate block shows an EXISTING DRAFT line AND draft age < 30 minutes, REUSE the draft text verbatim as draft_a_text/draft_a_style (set is_reused_draft=true, draft_b_text=null, draft_b_style=null). Do NOT call log_draft.py; do NOT redraft; do NOT write a second variant, prior cycle already paid the LLM cost for the one draft you have.
|
|
355
413
|
- Otherwise (fresh candidate, is_reused_draft=false): write TWO independent drafts. Do NOT judge, rank, or pick a favorite between them, both are shown to the reviewer, who decides.
|
|
356
414
|
- draft_a_text: follow the DRAFT A style block above (its own description/example/note/length limit).
|
|
357
|
-
- draft_b_text: follow the DRAFT B style block above, written INDEPENDENTLY from scratch as if draft_a_text did not exist. Do NOT lightly reword draft_a_text into draft_b_text, they must diverge in length and rhetorical move
|
|
415
|
+
- draft_b_text: follow the DRAFT B style block above, written INDEPENDENTLY from scratch as if draft_a_text did not exist. Do NOT lightly reword draft_a_text into draft_b_text, they must diverge in length and rhetorical move, not just differ in phrasing. If you notice draft_b_text ending up as a paraphrase of draft_a_text, stop and rewrite it from Style B's own example instead.@DRAFT_B_DIVERGENCE_NOTE@
|
|
358
416
|
- @DRAFT_DIRECTIVE@ (applies to both drafts on fresh candidates; each still obeys its OWN style's length limit, not a shared one).
|
|
359
417
|
3a. PERSIST DRAFT A (skip entirely for reused drafts):
|
|
360
418
|
python3 @REPO_DIR@/scripts/log_draft.py --candidate-id CANDIDATE_ID --text 'DRAFT_A_TEXT' --style DRAFT_A_STYLE --assigned-style '@PICKED_STYLE@' --assigned-mode '@PICKED_MODE@'
|
|
@@ -439,7 +497,18 @@ def render_twitter_prompt(ing):
|
|
|
439
497
|
"""
|
|
440
498
|
arm = ing.get("arm") or os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or ""
|
|
441
499
|
lane = ing.get("lane") if ing.get("lane") is not None else os.environ.get("S4L_ACTIVE_LANE", "")
|
|
500
|
+
# picked_style/picked_style_b already carry the sentinel for treatment_v4
|
|
501
|
+
# (engagement_styles.pick_style_for_post/pick_exploration_style refuse to
|
|
502
|
+
# assign a real style under that arm, 2026-07-17 consolidation), no
|
|
503
|
+
# override needed here -- this used to re-derive the sentinel from `arm`
|
|
504
|
+
# independently of the picker, which is exactly the kind of duplicated
|
|
505
|
+
# decision that let a second, unfixed copy (Reddit's post_reddit.py,
|
|
506
|
+
# which calls the picker directly and never went through this function)
|
|
507
|
+
# silently keep assigning real styles under treatment_v4.
|
|
442
508
|
picked_style = ing.get("picked_style") or ""
|
|
509
|
+
picked_style_b = ing.get("picked_style_b") or ""
|
|
510
|
+
picked_mode = ing.get("picked_mode") or "use"
|
|
511
|
+
picked_mode_b = ing.get("picked_mode_b") or "use"
|
|
443
512
|
return (
|
|
444
513
|
_TW_TEMPLATE
|
|
445
514
|
.replace("@PREFIX@", ing.get("prefix") or "")
|
|
@@ -459,10 +528,10 @@ def render_twitter_prompt(ing):
|
|
|
459
528
|
.replace("@DRAFT_B_DIVERGENCE_NOTE@", draft_b_divergence_note(arm))
|
|
460
529
|
.replace("@DRAFT_DIRECTIVE@", draft_directive(arm, lane))
|
|
461
530
|
.replace("@PICKED_STYLE_OR_INVENT@", picked_style or "(invent)")
|
|
462
|
-
.replace("@PICKED_STYLE_B@",
|
|
463
|
-
.replace("@PICKED_MODE_B@",
|
|
531
|
+
.replace("@PICKED_STYLE_B@", picked_style_b)
|
|
532
|
+
.replace("@PICKED_MODE_B@", picked_mode_b)
|
|
464
533
|
.replace("@PICKED_STYLE@", picked_style)
|
|
465
|
-
.replace("@PICKED_MODE@",
|
|
534
|
+
.replace("@PICKED_MODE@", picked_mode)
|
|
466
535
|
)
|
|
467
536
|
|
|
468
537
|
|
|
@@ -526,21 +595,6 @@ OMIT THESE (clear no-bridge cases only):
|
|
|
526
595
|
## THREAD CONTENT (pre-fetched)
|
|
527
596
|
Each candidate above carries a THREAD CONTENT block (OP + top comments), already fetched for you. Never fetch, navigate, or open any reddit.com URL and never try to reload a thread; apply the SELECTION GATE using the inlined content only. WebSearch and WebFetch ARE available for EXTERNAL fact-checking: use them ONLY when a thread hinges on a current fact, a name, a release, or a claim you are not sure about, so your comment is specific and correct instead of vague. Most comments need no search at all. If a candidate is somehow missing its THREAD CONTENT block, OMIT it.
|
|
528
597
|
|
|
529
|
-
## CRITICAL CONTENT RULES (apply only to threads that pass the gate)
|
|
530
|
-
These platform rules are ABSOLUTE and override anything more permissive in the DRAFT DIRECTIVE below (including any allowance to mention the project).
|
|
531
|
-
- Go BIMODAL on length: 1 punchy sentence (<100 chars) OR 4-5 sentences of real substance. Avoid 2-3 sentence middle-ground.
|
|
532
|
-
- GROUNDING RULE — pick ONE lane per comment:
|
|
533
|
-
LANE 1 - DISCLOSED STORY: open with a hedge ("hypothetically", "imagine someone running this", "scenario:") then you may invent specifics freely.
|
|
534
|
-
LANE 2 - NO FABRICATION: every specific (numbers, durations, places, tools) must appear verbatim in the content_angle above. Otherwise drop the specific and pattern-frame ("the part that breaks down is...", "the typical failure mode is...").
|
|
535
|
-
- VOICE RELATIONSHIP: see the dedicated section below; it governs whether you speak AS the maker or as an outside observer.
|
|
536
|
-
- NEVER mention product names (@PRODUCT_NAMES@).
|
|
537
|
-
- NEVER include URLs or links in your comment text.
|
|
538
|
-
- Prefer replying to OP (top-level reply). ONE comment per thread.
|
|
539
|
-
- Statements beat questions. Be authoritative, not inquisitive.
|
|
540
|
-
|
|
541
|
-
## Content rules
|
|
542
|
-
@CONTENT_RULES@
|
|
543
|
-
|
|
544
598
|
## DRAFT A: assigned style
|
|
545
599
|
@STYLES_BLOCK_A@
|
|
546
600
|
|
|
@@ -550,15 +604,13 @@ These platform rules are ABSOLUTE and override anything more permissive in the D
|
|
|
550
604
|
## DRAFT DIRECTIVE
|
|
551
605
|
@DRAFT_DIRECTIVE@ (applies to both drafts; each still obeys its OWN style's length limit, not a shared one.)
|
|
552
606
|
|
|
553
|
-
@VOICE_RELATIONSHIP@
|
|
554
|
-
|
|
555
607
|
## PERSIST EACH DRAFT (keeps this session alive; do it per thread, not at the end)
|
|
556
608
|
Right after drafting BOTH texts for a thread, persist Draft A (never Draft B) with ONE quick Bash call, then move to the next thread:
|
|
557
609
|
python3 @REPO_DIR@/scripts/log_draft.py --platform reddit --thread-url 'THREAD_URL' --text 'DRAFT_A_TEXT' --style DRAFT_A_STYLE
|
|
558
610
|
Work ONE thread at a time: gate it, draft both texts, persist, next. Failure here is non-fatal, log a warning and continue. Only after EVERY thread is handled do you assemble and return the single result JSON.
|
|
559
611
|
|
|
560
612
|
## OUTPUT FORMAT
|
|
561
|
-
Return ONE JSON object with two arrays (a JSON schema is enforced on this session). Both draft_a_text and draft_b_text are REQUIRED for every posts[] entry — write both, under their respective assigned styles above, applying the
|
|
613
|
+
Return ONE JSON object with two arrays (a JSON schema is enforced on this session). Both draft_a_text and draft_b_text are REQUIRED for every posts[] entry — write both, under their respective assigned styles above, applying the DRAFT DIRECTIVE to each independently:
|
|
562
614
|
|
|
563
615
|
{"posts": [...], "rejects": [...]}
|
|
564
616
|
|
|
@@ -593,6 +645,19 @@ Examples of WRONG proposals (do not emit):
|
|
|
593
645
|
Do NOT narrate beyond the persist calls. Gate, draft-or-reject, persist, return the single JSON object."""
|
|
594
646
|
|
|
595
647
|
|
|
648
|
+
def _strip_grounding_section(styles_block):
|
|
649
|
+
"""Drop the '## GROUNDING RULE' tail that get_assigned_style_prompt
|
|
650
|
+
appends to every style block. Reddit-draft-only (2026-07-18 content-rule
|
|
651
|
+
deprecation): the reddit draft prompt no longer carries the GROUNDING
|
|
652
|
+
RULE or VOICE RELATIONSHIP sections, so the style-block copy would both
|
|
653
|
+
resurrect a deprecated rule and dangle a reference to a section that no
|
|
654
|
+
longer exists. X and the engage rails keep their copies (their prompts
|
|
655
|
+
still render the sections it points at). Safe as truncation because the
|
|
656
|
+
grounding rule is the LAST thing get_assigned_style_prompt appends."""
|
|
657
|
+
i = (styles_block or "").find("## GROUNDING RULE")
|
|
658
|
+
return styles_block[:i].rstrip() if i != -1 else (styles_block or "")
|
|
659
|
+
|
|
660
|
+
|
|
596
661
|
def render_reddit_prompt(ing):
|
|
597
662
|
"""Render the full Reddit draft prompt. Keys in `ing`:
|
|
598
663
|
project_name, content_angle, candidates_block, n_candidates, batch_id,
|
|
@@ -601,14 +666,16 @@ def render_reddit_prompt(ing):
|
|
|
601
666
|
recent_self_block, arm (optional), lane (optional, promotion assumed).
|
|
602
667
|
"""
|
|
603
668
|
arm = ing.get("arm") or os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or ""
|
|
604
|
-
#
|
|
605
|
-
#
|
|
606
|
-
#
|
|
607
|
-
#
|
|
669
|
+
# Lane comes ONLY from the ingredients (post_reddit.py stamps it onto the
|
|
670
|
+
# discover plan at the cycle's assignment point and threads it through
|
|
671
|
+
# build_draft_prompt). Deliberately NOT read from S4L_ACTIVE_LANE here:
|
|
672
|
+
# salvage plans drafted in a later cycle must keep their own (promotion)
|
|
673
|
+
# directive even when THIS cycle's coin flip landed personal_brand.
|
|
674
|
+
# lane='personal_brand' renders the persona directive + the persona-
|
|
675
|
+
# whitelisted projects_json; the RD template carries every block the
|
|
676
|
+
# persona directive references (@CORPUS_BLOCK@ = ACCOUNT VOICE CORPUS).
|
|
608
677
|
lane = ing.get("lane") or ""
|
|
609
678
|
|
|
610
|
-
from engagement_styles import get_content_rules, get_voice_relationship_rule
|
|
611
|
-
|
|
612
679
|
top_ctx = ""
|
|
613
680
|
if not skip_top_report(arm):
|
|
614
681
|
top_report = ing.get("top_report") or ""
|
|
@@ -630,15 +697,16 @@ def render_reddit_prompt(ing):
|
|
|
630
697
|
.replace("@BATCH_ID@", ing.get("batch_id") or "")
|
|
631
698
|
.replace("@RECENT_SELF_BLOCK@", ing.get("recent_self_block") or "")
|
|
632
699
|
.replace("@TOP_CTX@", top_ctx)
|
|
633
|
-
.replace("@
|
|
634
|
-
.replace("@
|
|
635
|
-
.replace("@STYLES_BLOCK_A@", ing.get("styles_block_a") or "")
|
|
636
|
-
.replace("@STYLES_BLOCK_B@", ing.get("styles_block_b") or "")
|
|
700
|
+
.replace("@STYLES_BLOCK_A@", _strip_grounding_section(ing.get("styles_block_a")))
|
|
701
|
+
.replace("@STYLES_BLOCK_B@", _strip_grounding_section(ing.get("styles_block_b")))
|
|
637
702
|
.replace("@DRAFT_DIRECTIVE@", draft_directive(arm, lane))
|
|
638
|
-
.replace("@VOICE_RELATIONSHIP@", get_voice_relationship_rule())
|
|
639
703
|
.replace("@STYLE_A_NAME@", ing.get("style_a_name") or "style_name")
|
|
640
704
|
.replace("@STYLE_B_NAME@", ing.get("style_b_name") or "style_name")
|
|
641
705
|
.replace("@PROJECT_NAME@", project_name)
|
|
706
|
+
# The treatment length note is shared text written for X; on Reddit
|
|
707
|
+
# keep the same short-length instruction without the platform label.
|
|
708
|
+
.replace("well under Twitter's 250-character practical limit",
|
|
709
|
+
"well under 250 characters")
|
|
642
710
|
)
|
|
643
711
|
|
|
644
712
|
|
package/scripts/engage_github.py
CHANGED
|
@@ -40,7 +40,13 @@ except Exception: # never let a missing module break the engage lane
|
|
|
40
40
|
return ""
|
|
41
41
|
|
|
42
42
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
43
|
-
|
|
43
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
44
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
45
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
46
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
47
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
48
|
+
from config import config_path as _canonical_config_path, load_config
|
|
49
|
+
CONFIG_PATH = _canonical_config_path()
|
|
44
50
|
REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
|
|
45
51
|
SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
|
|
46
52
|
|
|
@@ -60,9 +66,6 @@ os.environ["S4L_PYTHON"] = PYTHON
|
|
|
60
66
|
THREAD_CHAR_CAP = 12000
|
|
61
67
|
|
|
62
68
|
|
|
63
|
-
def load_config():
|
|
64
|
-
with open(CONFIG_PATH) as f:
|
|
65
|
-
return json.load(f)
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
def get_next_pending():
|
|
@@ -451,7 +454,10 @@ def main():
|
|
|
451
454
|
# (HTTP-only now; per-account scoping is resolved inside the helper.)
|
|
452
455
|
from github_tools import _dynamic_owner_blocklist
|
|
453
456
|
excluded_repos = excluded_repos | _dynamic_owner_blocklist()
|
|
454
|
-
|
|
457
|
+
# Resolved identity, never a hardcoded default (the old "m13v" fallback
|
|
458
|
+
# silently self-filtered/attributed as the repo owner on other installs).
|
|
459
|
+
from account_resolver import resolve as _resolve_account
|
|
460
|
+
our_username = _resolve_account("github") or ""
|
|
455
461
|
|
|
456
462
|
start_time = time.time()
|
|
457
463
|
processed = 0
|
package/scripts/engage_reddit.py
CHANGED
|
@@ -27,7 +27,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
27
27
|
from http_api import api_get, api_post
|
|
28
28
|
|
|
29
29
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
30
|
-
|
|
30
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
31
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
32
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
33
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
34
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
35
|
+
from config import config_path as _canonical_config_path, load_config
|
|
36
|
+
CONFIG_PATH = _canonical_config_path()
|
|
31
37
|
REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
|
|
32
38
|
CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
|
|
33
39
|
REDDIT_MCP_CONFIG = os.path.expanduser("~/.claude/browser-agent-configs/reddit-agent-mcp.json")
|
|
@@ -110,9 +116,6 @@ def _release_browser_lease() -> None:
|
|
|
110
116
|
pass
|
|
111
117
|
|
|
112
118
|
|
|
113
|
-
def load_config():
|
|
114
|
-
with open(CONFIG_PATH) as f:
|
|
115
|
-
return json.load(f)
|
|
116
119
|
|
|
117
120
|
|
|
118
121
|
def load_active_reddit_campaigns():
|
|
@@ -386,7 +389,11 @@ def get_recent_archetypes(platform, limit=3):
|
|
|
386
389
|
|
|
387
390
|
def build_prompt(reply, recent_replies, config, excluded_authors, top_report="", prior_history_block="", meta_callout=None):
|
|
388
391
|
"""Build a minimal prompt for one reply."""
|
|
389
|
-
|
|
392
|
+
# Resolve through the one account resolver (env -> config); never a hardcoded
|
|
393
|
+
# username. Empty means "unknown account" (the prompt just omits it) rather
|
|
394
|
+
# than silently impersonating the repo owner on a misconfigured install.
|
|
395
|
+
from account_resolver import resolve as _resolve_account
|
|
396
|
+
reddit_username = _resolve_account("reddit") or ""
|
|
390
397
|
reply_json = json.dumps(reply, indent=2)
|
|
391
398
|
|
|
392
399
|
# Moltbook: skip recent_replies + top_report context blocks. Both are
|
|
@@ -55,7 +55,6 @@ MIN_STYLE_SAMPLE = 10
|
|
|
55
55
|
WARM_MIN_LAST_30D = 4
|
|
56
56
|
WARM_MIN_CHILD_REPLIES = 1
|
|
57
57
|
MAX_WARM_PROMOTIONS_PER_RUN = 3
|
|
58
|
-
ENTRY_CHAR_LIMIT = 200 # mirrors learned_preferences.MAX_ENTRY_CHARS
|
|
59
58
|
|
|
60
59
|
|
|
61
60
|
def fetch_window_rows(platform: str, days: int, limit_pages: int = 20) -> list[dict]:
|
|
@@ -165,7 +164,7 @@ def build_warm_entry(w: dict) -> str:
|
|
|
165
164
|
f"({w['replied']}/{w['last_30d']} replied in 30d, {w['child_replies']} child "
|
|
166
165
|
f"replies, 0 risk skips); warm to more engaged/product-relevant replies."
|
|
167
166
|
)
|
|
168
|
-
return entry
|
|
167
|
+
return entry
|
|
169
168
|
|
|
170
169
|
|
|
171
170
|
def build_style_note(top: dict) -> str:
|
|
@@ -174,8 +173,6 @@ def build_style_note(top: dict) -> str:
|
|
|
174
173
|
f"({top['child_reply_rate']:.2f}/reply, n={top['n']}); prefer it for technical-"
|
|
175
174
|
"correction replies (concrete metric, name the exact failure mode)."
|
|
176
175
|
)
|
|
177
|
-
if len(note) > ENTRY_CHAR_LIMIT:
|
|
178
|
-
note = note[: ENTRY_CHAR_LIMIT - 1].rsplit(" ", 1)[0] + "…"
|
|
179
176
|
return note
|
|
180
177
|
|
|
181
178
|
|